From 10ca631141404063b603c216369b845450b6cad2 Mon Sep 17 00:00:00 2001 From: Quach Thien Phu Date: Fri, 24 Jul 2026 09:28:56 -0700 Subject: [PATCH 1/4] feat(pydantic-agent): add shipping-label-agent, first Pydantic AI example Test-mode-only shipping-label booking agent for ASI:One. Demonstrates Pydantic AI's requires_approval=True human-in-the-loop gating, deps_type dependency injection, and structured output inside the real ASI:One interactive-card flow. Two-tier Stripe test payments, Shippo test-mode rate shopping, hazmat self-certification, optional insurance, and SAMPLE-label purchase with PDF delivery via Agentverse External Storage. Co-authored-by: Cursor --- CHANGELOG.md | 1 + README.md | 1 + .../shipping-label-agent/.env.example | 22 + .../shipping-label-agent/ARCHITECTURE.md | 155 ++ pydantic-agent/shipping-label-agent/README.md | 89 + pydantic-agent/shipping-label-agent/agent.py | 82 + .../shipping-label-agent/architecture.png | Bin 0 -> 209316 bytes .../shipping-label-agent/chat_proto.py | 1897 +++++++++++++++++ .../shipping-label-agent/payment.py | 298 +++ .../shipping-label-agent/pydantic_agent.py | 227 ++ .../shipping-label-agent/pyproject.toml | 27 + .../shipping-label-agent/requirements.txt | 25 + .../shipping-label-agent/session_state.py | 79 + .../shipping-label-agent/shipping.py | 467 ++++ .../shipping-label-agent/tests/conftest.py | 41 + .../tests/test_address_confirmation.py | 428 ++++ .../tests/test_agent_logic.py | 185 ++ .../tests/test_chat_proto.py | 309 +++ .../tests/test_hazmat_insurance.py | 330 +++ .../tests/test_payment.py | 97 + .../tests/test_payment_protocol.py | 194 ++ .../tests/test_second_payment.py | 266 +++ .../tests/test_sender_stage.py | 221 ++ .../tests/test_session_window.py | 102 + .../tests/test_shipping.py | 425 ++++ 25 files changed, 5968 insertions(+) create mode 100644 pydantic-agent/shipping-label-agent/.env.example create mode 100644 pydantic-agent/shipping-label-agent/ARCHITECTURE.md create mode 100644 pydantic-agent/shipping-label-agent/README.md create mode 100644 pydantic-agent/shipping-label-agent/agent.py create mode 100644 pydantic-agent/shipping-label-agent/architecture.png create mode 100644 pydantic-agent/shipping-label-agent/chat_proto.py create mode 100644 pydantic-agent/shipping-label-agent/payment.py create mode 100644 pydantic-agent/shipping-label-agent/pydantic_agent.py create mode 100644 pydantic-agent/shipping-label-agent/pyproject.toml create mode 100644 pydantic-agent/shipping-label-agent/requirements.txt create mode 100644 pydantic-agent/shipping-label-agent/session_state.py create mode 100644 pydantic-agent/shipping-label-agent/shipping.py create mode 100644 pydantic-agent/shipping-label-agent/tests/conftest.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_address_confirmation.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_agent_logic.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_chat_proto.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_hazmat_insurance.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_payment.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_payment_protocol.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_second_payment.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_sender_stage.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_session_window.py create mode 100644 pydantic-agent/shipping-label-agent/tests/test_shipping.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c317fa..0d9eddab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this repository are documented in this file. ### Added +- `pydantic-agent/shipping-label-agent/`: the repo's first Pydantic AI example, a test-mode-only shipping-label booking agent for ASI:One. Demonstrates Pydantic AI's differentiators inside the real ASI:One card flow: structured output (typed package extraction), dependency injection (the Shippo test client injected via `deps_type`), and native `requires_approval=True` human-in-the-loop gating on the label purchase. Two-tier Stripe test-mode payments (an intake fee gate on the first message, then a second charge for the exact label price before purchase), Shippo test-mode address validation, a curated rate-shop carousel, hazmat self-certification and optional shipment insurance, SAMPLE-label purchase with the PDF delivered via Agentverse External Storage, and conditional USPS/DHL Express pickup vs. drop-off locator. Establishes the `pydantic-agent/` framework folder. - `stripe-payment-agents/twitch-growth-agent/`: Twitch channel growth copilot built on the Fetch.ai uAgents framework. Integrates ASI:One LLM (intent classification, LangGraph 5-node growth pipeline, announcement drafting), Stripe embedded checkout (in-chat one-time unlock), Twitch Helix API (chat settings, announcements, raids, clips), and EventSub WebSocket (reactive copilot that monitors live stream events and proactively suggests actions). - `Browser-based-agents/playwright/job-application-agent/`: Playwright + ASI:One + Stripe job application agent. Orchestrates a Chromium session to auto-fill Greenhouse application forms using a stored user profile, with LLM-drafted free-text answers via ASI:One, Stripe-gated premium features, and resume ingestion. diff --git a/README.md b/README.md index cb49da2c..fa68c1ed 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ innovation-lab-examples/ | [Claude Agent SDK](Claude%20Agent%20SDK/) | Real estate search agent with Claude SDK | Python, Claude SDK, uAgents | 🟡 Intermediate | | [google-genai-parallel-processing](google-genai-parallel-processing/) | Parallel processing with Google GenAI | Python, Google GenAI, uAgents | 🟡 Intermediate | | [flight-tracker-openai-workflow-agent](flight-tracker-openai-workflow-agent/) | Flight tracking with OpenAI workflow agents | Python, OpenAI SDK, uAgents | 🟡 Intermediate | +| [pydantic-agent/shipping-label-agent](pydantic-agent/shipping-label-agent/) | First Pydantic AI example: test-mode shipping-label booking with structured output, dependency injection, requires_approval human-in-the-loop gating, two-tier Stripe payments, and Shippo test-mode labels via interactive cards | Python, uAgents, Pydantic AI, ASI:One, Stripe, Shippo, Cards | 🔴 Advanced | ### 🔗 Agent-to-Agent (A2A) diff --git a/pydantic-agent/shipping-label-agent/.env.example b/pydantic-agent/shipping-label-agent/.env.example new file mode 100644 index 00000000..3907e8ae --- /dev/null +++ b/pydantic-agent/shipping-label-agent/.env.example @@ -0,0 +1,22 @@ +# Agent identity +AGENT_NAME=shipping-label-agent +AGENT_SEED=replace_with_a_unique_random_seed_phrase +AGENT_PORT=8090 +RESET_STORAGE_ON_START=true + +# ASI:One (model backend for Pydantic AI, OpenAI-compatible) +# Get a key at https://asi1.ai/dashboard/api-keys +ASI_ONE_API_KEY=your_asi1_api_key_here +ASI_ONE_MODEL=asi1 + +# Stripe (TEST keys only) +STRIPE_SECRET_KEY=sk_test_your_stripe_secret_key +STRIPE_PUBLISHABLE_KEY=pk_test_your_stripe_publishable_key +STRIPE_AMOUNT_CENTS=500 # $5.00 flat fee +STRIPE_CURRENCY=usd +STRIPE_SUCCESS_URL=https://agentverse.ai +STRIPE_CHECKOUT_EXPIRES_SECONDS=1800 + +# Shippo (TEST token only) +SHIPPO_TOKEN=shippo_test_your_shippo_test_token + diff --git a/pydantic-agent/shipping-label-agent/ARCHITECTURE.md b/pydantic-agent/shipping-label-agent/ARCHITECTURE.md new file mode 100644 index 00000000..d645b01a --- /dev/null +++ b/pydantic-agent/shipping-label-agent/ARCHITECTURE.md @@ -0,0 +1,155 @@ +# Architecture + +## Components + +```mermaid +graph TD + U["User
(ASI:One chat)"] + + subgraph agent["uAgent - shipping-label-agent (mailbox)"] + CP["chat_proto.py
state machine + card builders"] + PAY["payment.py
AgentPaymentProtocol handler"] + PA["pydantic_agent.py
Pydantic AI layer"] + SH["shipping.py
ShippoClient"] + SS["session_state.py
ctx.storage"] + end + + ASI["ASI:One
asi1-mini (OpenAI-compatible)"] + STRIPE["Stripe
test-mode Checkout"] + SHIPPO["Shippo
test-mode API"] + + U <-->|ChatMessage / card selection| CP + CP --> SS + PAY --> SS + CP -->|extract + purchase| PA + PA -->|asi1-mini calls| ASI + PA -->|deps_type: ShippingDeps, purchase only| SH + CP -->|validate address, rate shop, pickup| SH + SH --> SHIPPO + CP -->|RequestPayment x2| PAY + PAY <-->|CommitPayment / RejectPayment, via ASI:One| STRIPE +``` + +Two uAgent protocols are mounted on the same agent: `AgentChatProtocol` (`chat_proto.py`) +drives the card-based conversation, and `AgentPaymentProtocol` (`payment.py`) drives both +Stripe charges. Both read and write the same session record in `ctx.storage` +(`session_state.py`), which is how a payment confirmation on one protocol resumes a +conversation in progress on the other. + +`pydantic_agent.py` is the only module that talks to ASI:One or holds business logic +about *how* a package gets extracted or a label gets bought. `chat_proto.py` calls into +it and renders whatever it returns as cards; it has no model-calling logic of its own. +It does, however, call `ShippoClient` directly for everything except the actual purchase: +address validation, rate shopping, and pickup scheduling all happen straight from +`chat_proto.py`. Only `purchase_label` runs behind the Pydantic AI layer, reached through +`ShippingDeps` dependency injection so `requires_approval` can gate that one call. + +`payment.py`'s two direct calls to Stripe (`create_checkout_session`, `verify_paid`) are +plain REST calls out to Stripe's API. The `CommitPayment`/`RejectPayment` messages coming +back, by contrast, arrive over the same mailbox/ASI:One channel as the chat protocol, not +from Stripe directly; ASI:One is what renders the embedded Stripe checkout and relays the +outcome back to the agent. + +## Where Pydantic AI fits + +Three `pydantic_agent.py` agents do the actual reasoning; everything else is plumbing. + + +| Agent | Feature | What it's for | +| ---------------------- | --------------------------------------------------------- | ----------------------------------------------------------- | +| `extract_agent` | `output_type=PackageDetails` | Turns free-text package descriptions into a typed model. | +| `sender_extract_agent` | `output_type=SenderProfile` | Same idea, for the ship-from profile. | +| `purchase_agent` | `deps_type=ShippingDeps`, `@tool(requires_approval=True)` | Buys the label, but only on an explicitly approved resume. | + + +`purchase_agent` is the centerpiece. Its one tool, `purchase_label`, is declared with +`requires_approval=True`, so calling it doesn't buy anything: it returns a +`DeferredToolRequests` and stops. `start_purchase()` runs that first turn and hands the +caller a serialized message history plus the pending `tool_call_id`. Nothing about the +label purchase is real until `resume_purchase()` is called on a later turn with a +`ToolApproved` or `ToolDenied` result; only `ToolApproved` re-enters the tool body and +calls Shippo. + +```mermaid +sequenceDiagram + participant User + participant Chat as chat_proto.py + participant PA as purchase_agent (Pydantic AI) + participant Pay as payment.py + participant Shippo + + User->>Chat: taps "Confirm and buy label" + Chat->>PA: start_purchase(rate_id) + PA->>PA: calls purchase_label (requires_approval=True) + PA-->>Chat: DeferredToolRequests (deferred, nothing bought) + Chat->>Pay: RequestPayment(label price) + Pay->>User: native Stripe checkout sheet + User->>Pay: pays + Pay->>Pay: verify with Stripe + alt payment verified + Pay->>Chat: handle_label_payment_success() + Chat->>PA: resume_purchase(approved=True) + PA->>Shippo: purchase(rate_id) + Shippo-->>PA: label + tracking number + PA-->>Chat: PurchaseResult + Chat->>User: label + confirmation card + else payment failed/declined + Chat->>PA: resume_purchase(approved=False) + PA-->>Chat: no purchase (tool never ran) + Chat->>User: back to rate picking, nothing charged + end +``` + + + +This is the reason the example charges for the label *before* resuming the tool rather +than after: `resume_purchase(approved=True)` is the only code path that can reach +`ShippoClient.purchase`, and it's only called once a Stripe payment for that exact price +has already cleared. + +## Session state machine + +`chat_proto.py` drives a single `state` field in `ctx.storage` through this sequence. +Every arrow is one user message (or one payment-protocol event landing on `payment.py`, +which calls back into `chat_proto.py`'s success/failure handlers): + +``` +UNINITIALIZED + → AWAITING_PAYMENT (native RequestPayment, gate fee) + → AWAITING_SENDER (form: ship-from profile) + → AWAITING_SENDER_CONFIRM (review: only if Shippo suggests a correction) + → AWAITING_PACKAGE (form: recipient + parcel) + → AWAITING_PACKAGE_CONFIRM (review: only if Shippo suggests a correction) + → SHOWING_RATES (carousel: curated cheapest/fastest/recommended) + → SHOWING_DETAIL (detail: one rate, full breakdown) + → AWAITING_HAZMAT_CHECK (review: self-certify contents; "special handling" → DONE) + → AWAITING_INSURANCE_CHECK (review: only if declared value exceeds free coverage) + → AWAITING_PURCHASE_APPROVAL (review: requires_approval=True fires here) + → AWAITING_LABEL_PAYMENT (native RequestPayment, exact label price incl. insurance) + → AWAITING_PICKUP (form: USPS/DHL Express only; else a drop-off link) + → DONE (terminal confirmation card) +``` + +The two `*_CONFIRM` states are a detour, not a fork: they always return to the state +that led into them (`AWAITING_PACKAGE` or `SHOWING_RATES`) once the user picks "use +suggested" or "keep as typed." `AWAITING_HAZMAT_CHECK` and `AWAITING_INSURANCE_CHECK` +fire per shipment (contents and declared value are per-package): hazmat comes first +because "this needs special handling" ends the shipment before anything else runs, and +the insurance step is skipped entirely when the declared value is already covered for +free. A failed label payment sends the session back to `SHOWING_RATES` rather than +forward: the deferred tool is resumed with `ToolDenied` first, so no label is purchased +for a payment that didn't clear. + +## Directory layout + +``` +pydantic-agent/shipping-label-agent/ + agent.py uAgent entry point; startup test-key assertions; protocol wiring + chat_proto.py Card builders, selection parsing, the state machine above + pydantic_agent.py Pydantic AI agents: structured output, DI, requires_approval + payment.py AgentPaymentProtocol handler; Stripe test-mode checkout + shipping.py ShippoClient: validate, rate shop, purchase, pickup + session_state.py ctx.storage read/write, session states, new-window reset + tests/ Offline tests (TestModel/FunctionModel + httpx.MockTransport) +``` + diff --git a/pydantic-agent/shipping-label-agent/README.md b/pydantic-agent/shipping-label-agent/README.md new file mode 100644 index 00000000..cb305481 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/README.md @@ -0,0 +1,89 @@ +# Shipping Label Booking Agent (Pydantic AI) + +The first [Pydantic AI](https://ai.pydantic.dev/) example in this repo. It shops shipping rates across whatever carriers are connected in Shippo, and buys the label once you approve the price, entirely through ASI:One Interactive Cards. + +## Architecture + +![Architecture diagram](architecture.png) + +See [`ARCHITECTURE.md`](ARCHITECTURE.md) for the component breakdown, the approval-gate sequence diagram, and the full session state machine. + +## Setup + +```bash +cd pydantic-agent/shipping-label-agent +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # fill in your test keys +python agent.py +``` + +You need a test-mode key for each of: + +| Key | Where to get it | +| ---------------------------------------------- | -------------------------------------------------------------------- | +| `ASI_ONE_API_KEY` | [asi1.ai/dashboard/api-keys](https://asi1.ai/dashboard/api-keys) | +| `STRIPE_SECRET_KEY` / `STRIPE_PUBLISHABLE_KEY` | Stripe Dashboard → Developers → API keys (`sk_test_…` / `pk_test_…`) | +| `SHIPPO_TOKEN` | Shippo Dashboard → API (`shippo_test_…`) | + +Test credentials: + +- Stripe success: `4242 4242 4242 4242`. Stripe decline: `4000 0000 0000 0002` + (Stripe's documented `card_declined` card). +- Shippo: any `shippo_test_…` token. Labels are watermarked `SAMPLE - DO NOT MAIL`. + +## Tests + +```bash +source .venv/bin/activate +ruff format --check . && ruff check . && mypy . +pytest -q +``` + +All 88 tests run offline, with no ASI:One, Stripe, or Shippo network calls, using `FunctionModel`/`TestModel` for the Pydantic AI agents and `httpx.MockTransport` for Shippo. Coverage includes the full happy path, payment decline, an invalid or auto-corrected address on either side of the shipment, zero rates returned, a carrier without pickup support, a Shippo transaction that doesn't resolve to `SUCCESS`, the label payment failing (verifying the deferred tool resumes with `ToolDenied` and Shippo's purchase endpoint is never called), the hazmat self-certification gate (stop vs. continue), the insurance skip-rule and re-quote math, pre-flight package sanity checks, and international fail-fast detection. `tests/conftest.py` fails loudly if any fixture key isn't a test key. + +## Flow + +1. **First message, payment gate.** A native `RequestPayment` message (not a card).ASI:One renders its own Stripe checkout sheet. Nothing else is discussed until Stripe confirms the session as paid. +2. **Ship-from profile** (`form`). Captured once per session, kept in a schema separate from the recipient, so the sender is never whatever the user typed as the recipient's name. Phone and email are both required here, not optional. USPS (and some other carriers) reject the label _purchase_ call, not the earlier address-validation or rate-shop calls, if either is blank, so this is caught immediately instead of after a Stripe charge has already cleared. +3. **Recipient + package** (`form`). Address, weight, dimensions, declared value. Non-US destinations are caught immediately (US domestic only), and the numbers are sanity-checked before any API call: zero/negative dimensions and an implausible declared value (over $10,000, XCover's ceiling) are rejected on the form. +4. **Address validation**, for both addresses. Shippo can consider an address deliverable while silently normalizing something you typed wrong (a city that doesn't match its own ZIP, most concretely). When that happens, a `review` card shows what you typed next to Shippo's suggested version and asks which one to use, instead of trusting either one blindly. A genuinely invalid address is rejected outright and sent back to the form. +5. **Rate shop** (`carousel`). A curated shortlist (cheapest, fastest, one recommended pick, deduplicated) with a tappable "see all N options" tile for the rest. If a heavy/oversized package legitimately returns only one or two rates (or none), that's explained in plain language rather than shown as an unexplained short list. Works with any carrier Shippo returns a rate for; USPS/UPS/FedEx/DHL Express are all handled identically (see _Carrier coverage_ below). +6. **Detail** (`detail`) on tap: full breakdown for one option. +7. **Hazmat self-certification** (`review`). Before buying, you confirm the contents. The card links the chosen carrier's own prohibited-items list. Choosing "this needs special handling" stops the purchase and points at the USPS HAZMAT and UPS Limited Quantity guides (many everyday items still ship without special declaration); it never buys a label. Choosing "nothing hazardous" continues. +8. **Insurance** (`review`), only when the declared value exceeds what the chosen service already covers for free ($100 by default, $50 for UPS Ground Saver). It offers XCover coverage at Shippo's documented 1.25% domestic rate; adding it re-quotes just that one service with `extra.insurance` set and carries the higher total forward. If the declared value is already covered, this step is skipped and stated plainly. +9. **Purchase approval** (`review`). This is where `requires_approval=True` actually fires: the purchase tool already deferred on the first Pydantic AI run, before this card was ever sent. Tapping confirm doesn't call Shippo yet; it triggers the second payment below, which is what resumes the deferred tool. +10. **Second payment**, for the exact label price (insurance included). Only a cleared charge resumes the deferred tool with `ToolApproved`, which is what actually calls Shippo. A decline resumes it with `ToolDenied` (no label is bought) and returns to step 5. +11. **Purchase.** Asserts `test == true` and `status == "SUCCESS"` before reporting success. The label ships as a direct link to Shippo's hosted PDF. +12. **Pickup**, conditional. USPS and DHL Express get a scheduling form; everything else gets a drop-off locator link for that carrier. +13. **Confirmation.** Tracking number, carrier, and the one "test label, not a real shipment" line, said once here and never repeated at any earlier step. + +### Carrier coverage + +A fresh Shippo test account returns USPS rates only. UPS, FedEx, and DHL Express appear in the carousel once their **test** carrier accounts are connected in the Shippo dashboard (Settings → Carriers). The agent rate-shops whatever's connected and doesn't hard-code a carrier list. Pickup eligibility and drop-off locator links are keyed by Shippo's own provider strings (`"USPS"`, `"UPS"`, `"FedEx"`, `"DHL Express"`) and cover all four identically; see `shipping.PICKUP_ELIGIBLE_PROVIDERS` / `shipping.DROP_OFF_LOCATORS`. + +### Why a test label doesn't look like a retail one + +These are documented carrier/Shippo test-mode behaviors, not agent bugs: + +- **UPS Ground Saver labels show two addresses and two tracking numbers.** Ground Saver hands off to USPS for final-mile delivery, so the label legitimately carries both carriers' blocks. See UPS's [Ground Saver label spec](https://assets.ups.com/adobe/assets/urn:aaid:aem:af1b19f8-dce2-426a-bd91-7eed3f899a2d/original/as/ups-ground-saver-gtl-us-en.pdf). +- **Test tracking numbers are simulated placeholders.** Per Shippo: "Test mode generates tracking numbers, but does not update the tracking information" ([Testing the Shippo API](https://docs.goshippo.com/docs/Guides_general/testing)). The number in chat and the one on the label PDF can legitimately differ. +- **The `SAMPLE`/`VOID` watermark is expected**, per Shippo's own docs: test-mode labels "are not real labels... [and] will have VOID, or Sample Do Not Use printed on them" ([How to Use Test Mode](https://support.goshippo.com/hc/en-us/articles/360003902611)). +- **Some UPS test labels render as a bare placeholder template** instead of a graphic label. Per Shippo's [carrier capabilities](https://docs.goshippo.com/docs/carriers/carriercapabilities) docs, UPS (unlike USPS) requires connecting your own carrier account even in test mode, and how fully that test account is provisioned appears to affect label fidelity. +- **A missing sender email/phone only fails at label purchase, not before.** Per Shippo's own [troubleshooting doc](https://support.goshippo.com/hc/en-us/articles/115002024943-Troubleshooting-Common-Error-Messages-in-Shippo), USPS rejects the transaction call, not the earlier address-validation or rate-shop calls, if the sender's email or phone is blank. That's why both are required fields on the sender-profile form rather than optional. + +## Known limitations + +By design, this example does not implement: + +- **International shipping and customs forms**: US domestic only. Non-US addresses are detected early and refused with an explanation rather than failing at rate-shopping. +- **Multiple packages in a single session**: one parcel per booking. +- **Fully regulated hazmat**: the self-certification step stops the shipment and points at the carriers' own HAZMAT/Limited-Quantity processes; it does not file dangerous-goods paperwork or filter services down to a ground-only subset. +- **Carrier-direct insurance**: insurance uses Shippo's default XCover provider only, not FedEx/UPS/OnTrac carrier-provided coverage. +- **Editing or cancelling a booked pickup**: Shippo has no API for this; you'd contact the carrier directly with the confirmation code. +- **Any live Stripe or Shippo account**: real charges and real shipments are intentionally impossible here. +- **Uploading the label to Agentverse External Storage**: it's delivered as a direct link to Shippo's own hosted PDF instead, which is simpler and doesn't require an Agentverse API key. + +## License + +Apache 2.0. See the root [`LICENSE`](../../LICENSE) of the Innovation Lab repository. diff --git a/pydantic-agent/shipping-label-agent/agent.py b/pydantic-agent/shipping-label-agent/agent.py new file mode 100644 index 00000000..8d4aa231 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/agent.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import os + +from dotenv import load_dotenv + +load_dotenv() + +from uagents import Agent, Context + +import payment +from chat_proto import chat_proto +from payment import payment_proto +from shipping import SHIPPO_TEST_PREFIX + + +def assert_test_keys() -> None: + """Fail loudly unless Stripe and Shippo keys are test keys. + + This is the hard guarantee that no environment/config/fallback can select a + live key: the process will not start otherwise. + """ + payment.assert_stripe_test_keys() + + shippo_token = (os.getenv("SHIPPO_TOKEN") or "").strip() + if not shippo_token.startswith(SHIPPO_TEST_PREFIX): + raise RuntimeError( + f"SHIPPO_TOKEN must be a test token starting with '{SHIPPO_TEST_PREFIX}'. " + "This example is test-mode-only and will not run with a live token." + ) + + +agent = Agent( + name=os.getenv("AGENT_NAME", "shipping-label-agent"), + seed=os.environ.get("AGENT_SEED", "shipping-label-agent-dev-seed"), + port=int(os.getenv("AGENT_PORT", "8090")), + mailbox=True, + publish_agent_details=True, +) + +# ``ctx.storage`` persists to a JSON file on disk (``_data.json``) and +# survives process restarts by design - that's what lets a genuinely long-lived +# conversation resume after a redeploy. But for this example, every fresh +# ``python agent.py`` run during development/review should behave like a brand +# new demo instance: pay again, no leftover state from a previous test run. +# Default true (an example agent, never a durable service); set to "false" to +# keep state across restarts if you want to test that persistence deliberately. +_RESET_STORAGE_ON_START = (os.getenv("RESET_STORAGE_ON_START", "true").strip().lower()) not in { + "0", + "false", + "no", +} + + +@agent.on_event("startup") +async def startup(ctx: Context) -> None: + assert_test_keys() + if _RESET_STORAGE_ON_START: + ctx.storage.clear() + ctx.logger.info( + "[agent] RESET_STORAGE_ON_START=true - wiped all session state; " + "this run starts as a brand-new demo (pay again from message one)." + ) + ctx.logger.info(f"[agent] {agent.name} | {agent.address}") + ctx.logger.info( + "[agent] TEST MODE only - Stripe test keys + Shippo test token. No real money moves." + ) + port = os.getenv("AGENT_PORT", "8090") + ctx.logger.info( + f"[agent] Inspector: https://agentverse.ai/inspect/" + f"?uri=http://127.0.0.1:{port}&address={agent.address}" + ) + + +agent.include(chat_proto, publish_manifest=True) +agent.include(payment_proto, publish_manifest=True) + + +if __name__ == "__main__": + # Fail fast before the server starts if keys aren't test keys. + assert_test_keys() + agent.run() diff --git a/pydantic-agent/shipping-label-agent/architecture.png b/pydantic-agent/shipping-label-agent/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..cfe6798f4cae2369bfba919740a88e01ab8a9e61 GIT binary patch literal 209316 zcmeFZby$^Kw?0e@Dhj9|NJ$AucZ+m`pmaAG+LjZ?~*{4*NRq`TqTm*M&YVRy=dg5%;*qJyxiiiY)$dvg2rIX!!DSQtD`ExDjY* zn9p!=;1Nv?Uk)_1lgc)dl4|mjlGJL>j+Qod7HDX4p$S^Yv^Cm@GxXFiV&UGUpZl!ulk^&)#*?te8Uuv zml!eCZMYu#NxX>m&M67~;^q|Qp`zv)W}nhg2C>8w%d}{%vpx+hXV4-kTg#4PrKkm; z%E5j|^iUh4^h$Gsr>DkUg6=y5#9x{OXB2#IZ@UYn`1dJMy`Nm=4t~a*xG!<)Y)4-8 zF6n`l(7t*uYdwt@?31^(md>@_0(+YbfVQE-V3RHdXh?a5IIP+f>ZPJAl5F8^8 zIYWD~KR`e|;|z(Iahz`Qn@I8(F-)cjrP>lKXXGU?u5WAW1hEL*Zt}Cr@Zq>Ur=6Hg zA{fB_&FW3?6X$!PL`yq+!KKEcG4netQ8)UoC# zZ^zf)4^5+ci-^Zx$e!%F7?niQj333?8XT^X7fqtLnlo|3Z~WZn-DlPbS2igL(5t8k zo&{j^yK-QgV>;=%OY+xg{KUc(yC0}TjS+>}ITn(;Pl;FeHc(>ek`xU!6&Xp-=w%G- z`v+VRH=M^Ws<^pfkz)EPB#{9PUeidgh4Y+plU2FDfU7|Z7fz4{B9KEObn|+-eMKBEaOx#|6uKI3FK01dNjW>Vy z*xkarr(SinjnjPQU~dUL^_WWJe&*WZ>K#2vWquK~jlfwoQmgTxCPMdntG8}l5h%N0 zUXAH@x$G6$qrFPp25$+>+h?tgEA-}UYS;xwe|Yxk)&=!zQ`+WFldTW#(R zEk0+zYrlC*(T=l%N;s0D<(+AlHm0$OY^hAqIpLya$;%7Z4#F;^u~v*`l0WA5>GJV@ z-mcW1Zkt}4cBOE8MY?Y_ft}Vp%r!_UEhV9Qyy;3%$eTFaG@0l6X$C{+YWx1zGb=xF z?j4-F`*}N3Z|NACnC#B>w%^{{dlcBlCC7H@$hY5qe*U#Y8b5$L80)xBVA$&f5{zBv zp09&eXi5Rt&+aW(p}CLY?_MRq#k@vMNq0xSXNRTgorN)3GWOIRQ?=uo0p(d3b~t)h zc{j1=F!8fU$&WP#u8z@E9d~&Bu@5if4&@TDj|9QpyFCOrrUA(`njx6ue>@{JqQeap zH;pl;!*7$K3&BXa$0Du}b3+dMId;L-b(-M&6RkSA6xE?avNiX$T4m2le!NEU;e5G_ z>_fZwJ3%?i(^5QV(!+?ePxM+?3+momkoeI%G3~j={3TFK)|)e+z(lF@q)YR_d6Mu~ z>d#Ip1$Dhtc*a$9yzq7Em`g*1oAL0#v9u8GTf_^vTDKT80!}OyY?Avh83t-i{M?k^ z+M3CjdiHV{34It%Zf6!tzFiK_H8wRNrp(8>*DpLd&6|;`oUY26 z-+xW@+M=3KVL?Gkq34q~k3&CB6(khSUlY!`Z8U$UrlO`^NK5pF!NUe6-{5V|pATkB zYGj^DbP+ zn-4x2Ve)c}3o8OU!ahPHLWLqbD)N--CB6LX-$~N>(kTt>4YY=(hG%LLYJ%N_lh601 zwwX*FCgr8LHoL|)Q{PARaIu8_J$vtHvHy*O9P|$AR_YexysLL! z8b}WHjBvcYmHL(8wMNh|t*eYjG(iL-X{lo~EA5BY5ypP&N4=UknmLAMXC?^D3YNVl z&b%FFP&(ruXBWrIAgffPMEa=Vk!c_ABU$ClQdb>vO}4@`x7d$nmgdAaA8`zt_FJyE z4ZRh4S=Z`Qxn*$T3HBh(Ytu;SaOvp0f^PyHI*H|pl|_u2c7-g3ZkqYUBRYfHmvq>l zJ3RBdndA7njOj%|d9|5h+0U}{n|c=BZ=$SQDmqFBt=)Sm%iYVmpO?I7919St5)Pr@ z6M7L%7qLdpM5@EPQj=3d+fv$8+P~azrjO}WL2JRwnKz6b*O+oNql83f3(Fq3JYD7c zKA2N38yv|-Hq5h~5MCeNl=uEy`}r@)W1KE6JW-xAvt!K8!;@aGBfs*!vyagWA0%-< z?VjSD?wu5zC!5-W6aB-E;x>;r(@w;tm)uP*Pp!(nA7ghf@hR~&^!F6@`tGr~<|ou| zS!6v|QF_|9(|1!$&qv5dr@^M-%GT}8l&$5xC%crJdYdQqDtE4Y9dLRsSS!Gek%noG zehhQOslKYLx&@=O>3&meQw8;W*4?bIEbo_7FH>HYzZAXbaFJUw>K>8AYe}sj!|$Z4 zSaoEEs>!kgbI;>JG9ompAi|SeSm?Iq+>C(C&}3D*lj8*Dcv8!#$}?p(x)*fs&uE;nB^ss2W++ye zJ%8oq?%eYCIQ+ObZIMj5@y+q`O!a5}U?@uONtsR5P4lwP?rNSn)2+gl7B4)rWWH^_ zKe=af_eJiDbeqhrYrD*Tjza9dl3vjPm$ff>u&t3iCA=N#BULD+%H_kQX(7_J)~28S zIpJH2z*~=u$_^lc{Lfb#b^d&uruN96?OG3ve=HlU2x}x z_7~2NwzFRfM4#XKd|QwvlhLvBQ{aU?&3nb;$KLE=jQ-L7$3`Df{(RPC0|yY7?&?--Zf zC)xZbVu^WJc?<;{zrLoD@O#YK&zN}5$Y`?OdezjY)Wc~omQ%5xeM^^yMNn&2UsrS6 zv%q+!no9OSd*5vId4z?1$8eIP?p6JZI!^X^qwTXdbPJDbiRz0wxb2bkr^fO+_Ny3r zS9F%!tqRQwy{U;>30ZmjGx3L3(Sd%<4X1LhEawfJ9DGH-xSnmy+HJwbZcIrqn8K1c-)-cCSC0udiVDG3xMJ-><>cJjO?EPR>#w zJ8Sh}gU6iLm4|`9g6g>Nhxe1iu1i|^M(#Whc(o||MeBBPf407&^_4vly-pcN`9mzj zbJwI}GI8+I+srCwt|{}uf+Y6=@`ElJA?~{YRYW;|lq{qZe z*U$rY$8$s5uHGXQuhL^{Q{6ijNQVozUoS)tQ!G#fFCH6l^d;CBTbF$K`g`4m%&Q|l1ycf{w@9OT@Cl;OkIBm50jnoCZm1-^7V+t86PI^&l@*q zZ0~#$9;s@unTcGe-{4!E&v&O^#2?Y}H})6U{Ck=y79l&lhcIV;@hUS+}8aj8(@7U3C+iV-J~C8jNo8fAxgh+&@q_NvJA6dM2p$ z!4I^#&o}|RAM*1t2$V6YN6uuKy*>HyWtsj#wT4c}lHHQVhorSS*CofWS*7jt(bsxe z*WIvR5Bj5bGu)B;F27(3^#zuhyrkMmd^B_i%!x(#f0%`~yrr@-8Z%tuqG6$vp<%-n zI{b*CU-*43gU*PC`RC&pXlTJUXjs3VqXPdTf9}H%@|wT?#f%L?I|lzFf*+48jK80a z8BXLr{jW#Mof?Vpv$Y%Ey4SL zvbnD9s;#UfWaenkW@7GWYQg4Ve+zjZG!YLWxU{!$HKF#fw{vh2@(`u@^BF>LjXcaw zL;dGdTx~^Zw3XGUB^{kDsQK79*f?m!j#E=pi#VHG3aLxU{CYY3Pn5>m)%BJTJG;BP zJDWQ_OCbj`+cM=T+EzpZn@exI#46; zYhvo?<|;}%@QD2R{Qvfw!%swcrS{<~xi|#`P+yAjtN;4a8!i^kl8*N9nXY1o=k@Ew zsK5O6LJ@Xk=_p(A*FOLGC~UOYaS`_4hbDHM^~VG)oFlo7lo0aWwS*;IB-=C0gTVtM$}-Gy!NBxYYmSUors!gnqdH!4u&r z0nvB_9OGse-Z>NgXLo}kvx)yd-@>0CF2*3<6cTMO&>{UFtrH9P{8#?}adCewYcm-Q zomQ;kD+|egvV&Lz)W&y5|D(Obr5-cGz>3%$2rAS656%Xjf^0VC|7|vkG5_CY|5wZT zKg<5FZu{wiRFi?5z^kAAR{&wg7;OFv+UiY=Z z{t+EvJp+tH^LnqHb^ERFnf^uH#d?)=l0he9nKT%FtbWi?m0uozQ|w-KbaDus#>PD# zIudjVCH{G&HMPZE`qmtJW@WYeN(X!6uBn)p^3^6Stml?p%q8{fsUHe7WSO;|vZQcZ zttaG_sr*-O`7@M^(}_39rBzu+9ER-xZ@k^TjnS3)%F%ZIQ6Dq0BzB|QLRY)$?q5=+ zjKhCrgURfx+mu-FCh_B&+f<+I*ef<@HqLB`)!1>QveMaqd6BK7YB%bY&S@r7b|uwh za{NuW^UQntq>VzIGD{+Iau(0kDYq>OvLn;%*a;*pykaYpIlSExqMjVV{$jmL9ZBp- zuY*qu7bzc98P>SkFlj%_`sum#Es0(+k>%8U)zzjx?6nYjd`i62r<3dlE7~$dy(iyB zUg%}}0S1xex3`^gK+?-Mj(%i%7lW+`Ic?9Uc$i0*>;g6NRoY+$7T?+vvaizW@`yK& zjvBQmv9rSiaRC8n-G+2!vef7pU`@O!w~1M_sXvt%xqs>NUaY^AF68FC9iecz5|k?+ zdB|u+U+nwCe((HP492IY25}~@gHnoUDzL5J-d`B5RUE9ar}y1ntGL-fC_d(o;l3~w zBOOlM)|tZnZT4f~H6`STH6AGuiarVbhvkI>UO}8)-As&ofRTGM;)gmAJ}-XujjQkeX;iTd>B_`o#$%1mXR+#DYs?B zO2l!LbsQ!npR34<1}yqRe%}VHXyR>T9Gtt3ONn0PQ=g@m$6^ItEDNJfU=f5E7>_rH za>3dd@qc_OVdu8&3{h9bKJsiDsNrnb=eTU8eq)>^0d!Fn$>@i@v3&NrczAd*x97j4 zc<*0D3#>X}H`*|4{=M3Fr{Z--j!Yyx$F5+%}l1 zU6<#_CpX3y7afOP0?{y*&uoJC=GpW;>L*mKFyylvpnel}u55TE^2m2qMc(<$U?&aA zJ4=sE_vWmu4?1@2?QC?#UsBDmTM)I_?>vL6_y} zGGMEY2n$E;HX+9Lu{nhV=C*2=@0~t+6?RN!ZzGmNs=4K{MC8XNqOT;DnzaZid47MF z9?Nbd7A)j&)H@R_S)yae)Oot=T=;z!0kU{SI%P6m8w>VRX{>sDf*wm*xr#~ea{8Oh z375_WuFns4z)$fE4mP&Dwsx{x_tED#!UpG9xybQD^6(tb2ZG_rdw5i(EG~V0ohKQ5 zijv!%AuJ#tPru&l1_lO3(L`p3$2eZ^#Lf4Qlz-SLnlzxq!9$STtQZgw<59Xy^!u*S zFhVb9wV1rHAMAHkxE<46p2v@seFhgFKjGd965VLnd=|&Xy^4RmSr)ii{zW$1f84B? z?{;gFk)O?!;%&m{&wLECUolA74daiWW7gJ@A>*;8rt)2j2`&knp+&K>-}W(<3W)lt zVDIt6OdUvfZfRp9Nim5{{>k-%$$?U{>=rXM0&(gY?4XmRH1Iet<~io;aus%;W7qC* zVW2GUa335H2f1K}p>b#`OZI`I{s#sSAM6NV7tJAHHQ>LRC6tp1gdnPDY@9{P3DMel zdu_&?JpJL3rXVK>rl9^VBj(VA2{tWC$9Wm1=N z7_PC987j9;n(TVOpCV&*{z&sxSAoNdFS9a7`E5i~06L~Qc+`|v-y`Lf_RA&I;IeIu zJr5<1eXg*7K)M{KDi#CALO(Hm^4{+)McHQYc5t~^u7!xhpc)WBpViVkUCQ(MxkVhU zY?Ss-ed+-56oLs+pDn4zE&pVt>61qVu>0eXFmpGFISvg9-H3P-FhQQgz2iZ~ zyPM1J>pWeIri|xtbCUyb9Bj~%C}ne6-h+kx_QAuerQ3+_`v&V!{l32dy%TqXgTpDM zbC4uRL_jdsziQ!(>rp74z6EcS4*Yw&gh@gKP ztoX_aaxoum3{1?fCa=B8I8}xNy7PqSSh?47WSl2k31^0DE7yK}I!%aT9*6f^X9$_5 zWYm-xCDXjasC8dV1eDNmMpfiuOEi`H_W~|{tUBE+00PflzN^d(R+g;I^tBXzY9Bv* zE0ilWSotQ!VLYL}{|APV|6aN4><9VdXBnv>5i7n-;o!k&VC^ZhQq`$+pdgj;*|BPx zMX`*(cQTWR+PFEt;=!9klvDMf2}z*_wYL-gz&_Cqw>oU^)p-m(lW=;ZzSeHw+0`$V zj_eN~J`^i+AVx9Z-@7(ei(sRLkuvAQUEwF_xuW)i>dA?7%yba&o$iowVq^JePzwlX z^EnJLMqc1g1cahGinSffnB7<5@ub;E@C3P!dc3)n!4L@q} zAy^_s?^??t^`Gr8z6@|-J;kj|?eI1ZuSUvge|;U? z(VlQwc|*{7^1h(URPsqOdCrXdjn73o+=92p0?uC6R02peHq&9U|F@r`E(y1p8A4x? z6wlv20%t)zgGJ1$OZPemKMu;Zj;XE(aSqg8D)*bVR=?(=g?id0WJu&`@iwsfTdynTbD0nYN}ZJFWW zk^gV80mjf2w;JC}F8 zKphG*GDlr9+p9--qIgpQ*tm+$<3oTePVI>W8*&MfR(z77mwbieXokogE`=8urzu75 zgEh7ybqQd(-ik8OfikP;$#?04w=XO%hJ@g5LGd=3pXnD1=%KCL_R}QXLM*qHis$kh zl03DC5>rBKnASLiT@gf?>h)f3o)eM$xiXPtv7+AVo33A-7I|)dkc%Q0q(MhV@8=Yw zti)wNDG(12Rh%5y#86CPWfW(SvkL^{Xa$%{Z`9z!hRsN~zS-70x-shS_}q1x?9Ly5 z5Q*gCXmw_Y4w4gUH28RW&X<3pbD8Q~UfAf(k#(LM;N)(Ba#sDRe)YJsk&*Au1b`LI z-`3ah+4ZZ8qb`1F^lzLSsn>HDsk1o{Xm=~K>@xrG_*yKRK@DSf+Kmr|TB=c0R~F{3 zrOl@D*)v1~%|j%njG_FALp{%Yc)n0nl1*$$e`~e5N-i*o`Xlgx>wg`CW)qD4Zx{eF}(K{*#% z;lFe2<;$1PohD>yxI2C8-F@TPIV>~d*s9_YU_1zKS9nPfiu)SoRI&A1G5bt&|9`NHlCtw5QO=j9mrZAn8o zIg>Z?F)W5wwjByx^3>Yca}v=$DI%WFtvT)$<^d*oUv8_F6rJ8{W_F5PVC?3`@=X%G zrq=g)iU?Rci$v3?O?rj+*ob|Msx4eheTWcx5B3--f|O^-ui?A;GT4%TPcH*J(bKz}Bg- zOOZ6tcTD@K_gL*BU|hxcr!+J)<`DRw`|WvsZt(Sv|Nf4d(tp1RFHrVDUUZrp39=0&d<+p1(uX@l4l+BEiuP9i?s!$#rJ@F zb{tIW`dL_ve3tLA=vS%n^f1(9%bvOc3A3h!@(f`(9#?bg9DPi* za7;cQJdU9plN3?1fK#?orr0pK+X|QigL=tAud=oEV?2RpnUR#(8YPCJJ5L0Rj2it6 z!bsQ`mJA_6OhP(5zh10+gXjGBboY@Mr-`?o+s?^Xjtm>6z_k!vCd5Z^H#woe<%yPXoNLyC-s^Vn z@#8y1uXrpqVYv-h<@a<_T-o2ufl8X~YFYKIuYbU&GAK~R>>=08CwtF^`lV zgjrreJZQ4TsV17$Sn7@-52jqH>6`HnB|@{`3-U9PtmLL<9N$_OxbM z{@hS?-$S;i{A5_UFL`Gjjfip_clOn1b));YfXARK^~+}}KlS#IsXGjL;!QW;{`C{G zDNs&Go;pj`2%>k#n$OzwwYF4VTcU{AU4xrhK{$2;N%$A|ngz>(_I4_MU>-Y_%tBG& zJ?IXEgyeecxyzc($y=uTyIblxGWdf(LRTRD-$ZH&Nh1m&*AUKIZyq|)3<*5AL;1A~ z9IkK!K`a2f=^RJuPF>~GykpGPrit#GDV_y7!(+V%qSWh}vgkJj7WjS01$hc}5yu7oGmS z=%!+9fFbmmJ}aRqIh3T?E#4kpg8ut;@vM6NCZt`dym5GE7z94N*_aAvZ@3?bc}%G8 zX^I4oncO6iULv8McUM~PlN*;kw`cP0Z!B;38Bg5{4QPi z5<;Q|pQdUD4vwCNMgj?V-B%9kCLz;(5nEpvz6==J)x$7fh2l(d?n{D>L{=A%NBm#J zfSk+lS&`??z-bt&byt0tCP)h;QarFp?O@zFb@}JwE~yfWhPCvCvl`Q}uqgfU#~&iT zf}K>q7l`6;f!YYGpAdqBvL$rsP2dkj;=y1+3ccCVaSqSSXLYzkdF;P@FyRr+mWj5M zk3cx|n-)eqId69s8y$@-41|TV-*}y$k+{R~q6Bi1tk?LL;Bz&I)OMY3w0$Y!iuAT3R*$r8MOkdx$03doDn7i?3QD78`Utis$ zm>qYSn5#NBFB`2?0A*{D!9KVmm-~Xg&U175BKz{I3q#e?WjR%3b1R>W{N)SO^T@Z~ zL0&e0m!`A7zvJlA93d95;6V~hVpiSj=MO|NV=>TyH0i?bw;xwxM_5;!!4Hv>>tzw3 z@>vQ5xJ-9(xN5kzA|M7bdfNrZy05oa%_LCl?63@^s{khqWmWJ;`EB!6YC1Y89yO_O z9Ig+rcc&a{t@=KfS#>0HnvpZO140LL2$wYQahQui?I;1F+C}UM2}fOeOG31y_WcDEY>c8sdX*|(>vJ<|j>6AnF?D%5 zTESq+GisgVfSW!OlHG!zCm;7VK}+OGB&)Ic%RjJ@N+mi~IPP%&QKCMz7*w;(vXSnn zsh1ADtN9_R;0qtvv6ZO@jtuJEOrN^I%cY}DO4+TtGs+tyKb2E%tjrdcK+w`6A7NL^ z3a}fj=z93>;{zyvTj9}fkjBn+i4kdd?k=}j09y)~Akz9lF;s=y>+=`pR|hDkb%D91 z0e0*Reot!(cYd47`1kLZAb`f5xunvHbfLg4fxN!pIg^)^?OF}IJ}HNBfTS-(mSSp3 zgEGp|GN7n}`PTVsG(}~;r&wihuC~TqiiW6cq@Jf>G1^)9)E=F7`v7u&m|-~|@ljM@ zKma#nzO(|hL;Q$vh)8m7C*p(|1sjMcT#;$6udUVIK;w`CN5Ew&!n#LNBFvJ|X5g7g zJR6UAj%udZwb=>vhc6isJnsY8KKm*aX9A!{d02NEU+;txVBGI(I<5(+Ou2^jUJL}% zm^?(fYaz6vv#kx&iwgcm70w6yzAhi4Shd?U9S=nP_jk|VF`FCodhG@z=Qkj0euaXK zAo^0ABLSl6SqLODl$3|`8TI`a7^!#?+!EGEk)D;6l}o`v>h(#92}17e^E0h6w6V~b z2(GCbLFfx8%80&|1MJf^^xOKz_;b0q$4t*vm4>R&Y&6r7u0Xf^iElzRCGX96q$IgE zORI%c&vWHCO(I*9aWPbvruPA7+{QiC`wqYY!x;vpkoP5JV?;YLURY|-6PqinTh}eC zwI(J3y7YFq&UqBD@wX?5)4_4j1l~F%g95~zj}IIJcZ~s-oc{ZgJXc~FH5UHbV_*mB zT7>^1k-6J<@C2h66s%&Hp^kNka&WqRJrXC}x<_s@TeiN)s8P(h+^qzsrp0SgUV{6n zcDhVWGM7b|Qo7K`hZS&4D=&{x34HkMppqeCGc{?K{^gO|kA8+^CP;x5T6HUW(ZjtG zO(9wi?BNne1o2xm5arICeS+#nZ0I`yG0g(H^wm=oeFB4(j!Dq#5$5ToHFNb{W^!K~ zNdSxJfZkGe9cAA#$2GE+obkoRgG7MU2(-?rn`g0A0G^$Es-Pw9BpHt!l7q?as{ysm zxJP&U4Wu!4U{c(s*MU7D=@KC_sCN62Xx;Q4qJKJHfnj}(&swkS3Q#P7w2hA_u#x?u>Vi#M-)>rmbx)6PQ28Bxb{gNlp3i_l_9_k%30d(nb+ zHWn2juz#D$&*Xc_rYg3T`*RQ2##XavQQ|zKV(mu1dIXiUmq86K52-!-gK*(rv9o)o zFF##$y(a7UPLTjsgut*fGYS{>dkgB+VBPFP*Awp^miB@e>*%z1dzwtn=D-Z@$z%bp{J zE+YH@gX78s0I@jKWJ53;$E z&wnDuH7!(at)%44NfCPva6j(>R6-&9i8Lg0H&esJ=$Ri@i{ znu?!7JSuk|z!4|{gPgNA$xE{wpKD4=%%87p9m_~SA?Jm1u0CFiAI@h=_It+Wy-m`qgWKi=WrNJ_)K)W?Z3-qKVX}+?G^p@j#r*bh&`SRB!ed%{Y@Z?yWySOt?RSp3Qx;G05vh zXO+_gXjw*QY!NEwB)LG(OxdR96l6hju3kF^@DzU8>1j@$)bu=%BJ|$Y%atRn_FR4R zdKD^5AyJ&xhu2Q>d+EOiE|FY!ZGKP-9B4xAdnmvE-p{0I&MUe+OZfm@GOA8a9nBr5 zA>8p9l1LPPk<$;nf1f}HJbUKD1xy)Nla0mEq@EtN)!@yl>6u=3gv$j6RNg1*Pgxg2 zCagVv04O`$GA6{awgn*yX#DoQ2I+bmEN2H{_@+%x=n52${{62p2^-%rCAfImj4 zCx=JX*1a?cZ$0GO-g#KP9-2Q|Al3=!L>o&8{SE=p?AXU3lPkUTRiZDj5+`E76@UrJ z)>@xxSBoaemN4{i5Q@QtP7dLDLN8^64AB{Gkj>rN-E{g4l(&A>t*dNC4R#oEYui^2 zwxUU8TiR233y~6mCp;ME78Vv(azK>p%=>B{2Rqp%>wpkTh2DU>K(4k!t$cnl>wYrB z3s`mU9961?6t@-Ng&QkVT^IT;vt8*6INtRl+t*u>FG{b{Atj4Ky5}GoI82w%cY+T6 z7v;+12^e8UjZ5c+-&WXk8A~SO{KeJp-Tb!8>j8v=K{kBHy52f{e@C&}8$qU3Vwmtf z!&_wymsphzk>4#X-RgA|eKl3qEp`T9=9vXyuM!imj&0ASx}IfUr69wNgEop3g~m>Q zEaWSxdHy`1TJMdgcm(g4e;(xEupZs5Ke~SnVc?4pbFT6OKCYV|7%MZ_<0P9dVDmJ| zmepJL=6J8+{%^W)=%QcDz{4%0fS}`ofcDqh>0z%-wwWdJ!8k{OQDejZt{1M9%yD(? z^aov>)08a3HExRg@*Ff6xXCAJQ*F#=KNPI=>p5OTNtuTcE8r8D+4xVrPK-k@8qyIz z5D>L!Kg%sGn(^pHq2_{!!BkMdr{ljs3KLQzB7}nNr?yupFz?V(pCSGFxUs{tMGHJj zunF+XVs4<+a`IOxpl(cjG87F1+9(Nd=tymt3)caMr6tPM*iVS39(%-P;)em%omji- z*2B9&L8(k&*ZP3IzQ60UaKQB_7Kg0(4SMxN#Ka6&uU<8*RUr;KT=U^ofHGv&2Uw#h zhM61C1o}!g0GnL2RFLAFr#e$G%8cc(2t0liS55{~`Q3*IGc#1pO^x!Z!#nk;2g5dO ze8X}WUYpXf`=1(IM%sQ5>)JteIqdnQ6Cb^V*cur1xi94%Shui`9rOOFt3!T76@Z2T zZBN`r)J&qTHU?c3&K9G(rB^4O1Aac6h9^loHi$7lSeXPr9iW5Bh3d2+^5s$50xTa1 zSa;@tB{6LOuT_ja#Q>m?1XN?n3;@i2gZ#ESf+erOPln#g2u%geniyzBdF^@WQ2+fJ zlzfK<$Fp?d&;rD%v9a0otEd4pa&q^~;jq`QsULqA^pJ2#Iq=wNN;%*|yT)I?dZpA) zH5zaH@u;nAK;)F{^L)04<@znfKoQTCWY4b5D@HdzJpP*KVy=$sQPu=)zdS%TmZ4@X zQ3=ow_E>dB(VqQYBi!$Qo4Lq+CjN_>;<6jym;qAqcz4iO!X?o__J&OfoKxi1#9}t@ z>1>9oN*6CaGd;Av!>i0QAO!eck%oPkZmS-M11&`-BBHNb>A(u5Ohc`5^cFQ(F`^~} zeAryHniDM+3#_)sUh03iqxZ1?xO2w>deiDaH-UP@;Mbj^i$=*r=k+3VP=P^h#sBIy zo1kB1KG_o~S9O|TOC`g) zC>}P(gpvALoSWRb*Nk&5gbV}iwC$hk*{1Xb{m80FEdCJ7n>rzxXjoySQ+rq@}_Ok|+{k%GFW&$rjw*?H?uT#k#!QqZcKEoYPq0v7G3q+V`y}Oz)G(#lix7|5s}q= z$M~I9Q6L4nBao}j58RW=4@FiU@#2M=#pZA`(7M091%kZE(iY0q$6L^M6OlJw9I1zn zuA*%_K;)zx+2}Ao4V3#lboV7}*jyizBpuq{u^~Vf*ZNrqPNX#{r|I1nwx8bVubu`& zXb3=+sxJ&5;qm7r2XbF4Z6ghLeiH<58AlBVZ{JGT$FUqn62vMUwTmp0rRd5TF2pnDW`rcfe7cmLf7 zHBm6AagBK>8T_yBd(=&loH(>^CQE;;1cgRl;8%e?wMcwl<7Vr5u(wffqGMPV z^nl;79Yj5Vlm(q(^^8B>8)r5(HT~tzAnmcuz5i1{NL6N&@U_*<*>^Z%)OnB>OqBXflEkn;YMMMIl<)aYZnt*7IHPxA$I;^GN~z=6!A`=a>|xI!)sQiyZx<+Yki#8#kc93H4j6%pHYy ziGc=S2k=K`LqP~~Vi5(3B&w9+G)j&^36k_kkhH1Px%_(_0iSTEsXCyW$b^uh$ixX8 zex0Q~3szt=0i)K_G~jjLV1*&^nX@MM^pP2;P60{4UsO;i{jov}gtuwQ+1+S7NI)9O z9}Bg3=)V9kcmF%d}`v>~+YVo9UV|Rp|-y_IP?@zInBN{<{hWnv8>yIc9?6vICnChHif=!guCVKtO;2PRNlZ9Y7X@r^rM zYY5cCUEmm(-Bdt=u2@>l!SVaBQH>Utnm`MzVVqgU8}(4-_yQdpw%(FU=e(rR1&V3fL}4@ z2gnp!p=jtF2n-B_a2S6!<=7pR*2!ULg{x&k*k-Ytb^q8AdA3_1CS}rjo{NO?i^jP? zVsn89(NSHgKnFsW)puu|w+jdx-j4OB?$HF~U~3utaLY#>>~Z&LBN0BlYN7IH0r1O<3)b@pS_g0Se>m}^+DnB~YW3B+JG!J39Ux6f zbD`c+NM>vkEzzo>HXIE9wjg$7L2uqFqI?MT{df$=y5*o<;F~Og%a@S@VO6!?TxrWB z5L{EbW#K*>!|t&#+>)E7@WOX-pi~l?FIE|q*1fNe-wIvFrbIdFf0&a4Qs-T;$I>pm%$ z`F$$C&8v`NlFw`0s*VQGs{qxIvbQLMRdWa-Tce`nzb!5gEM9(w!q^A(3D1zlL17ho z{mFHCXij*0f;Od?&npvD1bDc(_n~Y{-3HmEJV1#ezBVT(;6MwYlp|&S3*g6^X}?8r ztA9j%B561_DU;kYs2?czIG}98T0LKd6%<6yqvW&)F!YnMy6s8y}<%3@!>sv^)`T>>EJjt3Hiek=LpKl}^DiRq9zh){a@ zD=3dq-={_w9g)5x`Jza-Tw!-_ugeXTYagE6=+OUh1wkbs9d8?|y!laKeeSc_Q(j|9 zO#axy-^HjtsK6%cH%1hnJ|smEW1=pFK<*iQEFMEHDuqQL_qd75fA=DA)m+2Y>jYgi zrkB?PNp&kdS1p*?c?yH#i@It(2?g+5JK&a2W}p(+nRc? z0-AuZ%*@Q)rPrsCg1fK~aPxyK0=;{CPQ1GOKs12_F4a@yFRjJBVw+e$BcScu<6#i9 z2aN>Z^!0V4c337g$VTO6V; zDN`dv6Px-?GMv)h%s)5Mx3;;i!D%AsEUWGZS7=tLf}lp+^tP%Is&Ak;l6WkrDPwc% zT%%CqAtxSep|nC0n|-Pehz3C&`H`_}ak%ypa1hUCfr4cPz*WBRQ(|y)XD8f}_E*em z`|7DaG}Pd$2aQYf8jv_3!)okIK@U~`&8XxNqaxB2IwV8E0d9IkZ#WTef3$-=OW9mS!jTV5E^7GQ^^Z({U zc8Cw@#qa;ahg2|Nnm-Y8aH^^+Fr?$*D@$J`S3ST&iO!!tZ_sC67#$LFA6la+%RJgr zcVv!MZo(JZ+mlR#v61_-{$rn`7&azT9N@X(~gC;QI<%%er`1maB^ z2(u8MZkmB|beqq_L9B!bG-QmSZ}}cnos~V$EW~C;8ynYVKX$NAHPfLk3?(4LwgSkK z4&_66JL>z{7=YK}k;Ec+_uf5uH8r)ccP)HXtgCHJftY!K)BhZ>M}3>aqpTzW1Ug?C zg7*)L1;<1JjX2Z`auC{wG4gJGedP=&+7pme9!x+ua9(2gw>(^h`1@sr7pR~q@a(zA zY7hU|1$x!;)WX@cYXDldKpSJRH1_g60Ex4_rne4n>K|9sY~WMP(%}{VSThX<$ns-g zS}(N}kjiBkLy?d1l%o7_`}!$#+8B$fe*xU?$a06>L$**OLja7}NFvgHS_r^@j(h4o z2A}AFC}BR`^AJQ8B$df$;!pzVKlV}|l4SEa`Y$K}C_p^y_=jRWtv_{2S%IWvSzk}( z0j=Y_zw3$2^MgpJ_H}$R=D&oIu~^7TY@EHgsP?a}o&~kWN{RnohtkEru!6d?Q2zkb zNAaLAYI|n#Iv0SpX8k22sB^TC&IpKGl6Y+1d#tF7m@Ysm&v{iAzM$e~$QGCUKeqVS zau$(=c(2DMpsZx99K*5xXCjgnEPbJI5DQyW`$o4AG=UI85DB~@P*p>wDtIY(al)`* zA`Ey2WM~hWOKrPf+aX29XLtV7)4Jz-jR$*<2)r$?fTS%CJN9q1x10h5oLKcF z_@0Wxqo=-xr3>Ff0N%jsYl9O3qfK-Qjd=69z{CU=zqJy4)kB6jSf?BdcFAeN$ zx~IZk&qXBc-HkhBV%yWSmC5Ts97&fS(&f?bqNgpEzv`O82AdVY2^o4R))wgpM%%8Mi=csHkAX0 zOZpD^abgxmC^Gm!+b@>~DiStqd+qeS(R-Ft(*T$!Zze5V*_q?^ggG_+o3^&`w6dpC zgxsFAiI^6KFYLfUCPB?Eck9+Om}6wDvCM9~P~N8w<3lNeE|03CAi*JH9!1REBC8S8 zy<)R5I0U?kTq&Y?Z!aVEV;YbY$wI zmQlH3xX*6X&oO@zGPUd!^%qKM0@r}^Fn!9asMzSY*I9n~VA_XQvIEf>j$LqzWK|5m z?Av!#Z>7l_QEZLWTUemv5Rb?JN{Z(W!$M`0_;x)f2^Z8x{C4bJcH4h-`xiL!Ji1Iq z>O7~kDRz>0aj!-X!cdP|F7&jWL7?9n-C5rND-oVp9&+gxv^O&|@(wxKOv+M4LXpJ=XKa1J9 zZP!Ct4_f+E-vqZ}bg6Y6X`T~dmXc1IfaO>rvT{cA>?k~xxYt>3NrnW&GA*TaOWFdR zvPXgv)AvXWpNa4on!bS*BA$X z7Bov+Fm>?O%NMHYUQ5POvT6^L%-+}QVNW&6vRoI2K5KqkDsBP2b~ujdKp4&&>wsV! zTPZexD+vG$bw8%!zqvgZabv6TMAwvhg`G|zs6{klFG}LLl%O+#@Aq@px55ZwO3pgP zAm>&8-Q{4Ianayu3PQ;phj>`r6;OoKB}q|~D27`V8DQ!83_El>=3>QIOne6N)i}s> zHKL>e`d?W=^^)O)@QLi6D3~C;Ns(ka>ICEg(v4leku8gZ59;%WYz_R72zgs321Y7R z=RW%=xZ?-k$_3v5F@RM#J+!yGOP)GET>I(bi3l8DYt5J%w;wXzb5Xv>z)j-;rM`L+ z#?}`}iP;CpqtGA_1KS+9&;I&()77ai1bDaLi^xT%^O`#e zo!kGOLj#+EQlu-ToB2n@8>@s&J-`@;-8MT+rn-qJ$Kp?$wp7sCK&u-1Os#J!hw@W= zd;WO$1gX20FZW_cbS863GAJbzSP**6g^@KU?$NY|$1txwzR;zfO<%)uDaMeq67=bb z!1(s)Lwo8SxtW&1 zngYYZ1=7MD=3eS#nBz+Xy{#^ct}uh9!^s*M=(26_dJ#d%DS33*;zSr2CFSjZ)U$Mi zJpmCAB$FF{M(=L7__;FzbIefq=I^hq3TCPn32v3`1#^<+L3?|fM-g$R(#tWzKATGq z>vk4uRY0>)L;?H=Nmt17Tv@k_-^aWi`0oR;7f3s_*f)B{BE1i!XV$#Ew3b1qsT_s} zHwqF`b#mbgDf(Ts*x^f=%%2wwd?|sin@~t(v8m-9T@R$JI7WuG+I&WJz@e+s%Xa-N zD>6+Ysd=ur%zqzLy$X=K%D+4m@miC6_4>843=zIUHI&527rgnnvb#EX*{M(3T|Sk5 z>mTg)uZT8Q2KK!<*I$MTqeWzq0t1?saof&P@l^T=aPD`h$>P|e3o_Aqp^CjqT&dS&{vT&w z8CPYt_N@#m8&q&hDS~vDh;&Ivw}7;if^^EJ6a`61QISScNkJM^q)|eoVI$pL0xIuy z&p2n`yyrY0&ZlAg>E_<+Uh9f~Tr1@Gs3o_G%_Jh(d%|Umy~78}tj?QM`kkT7FV4v* z0#4h9x_uu^DmLd`85 zuKkAOcH>kZx^`th3^&epw^ zb}*fQxqgsisxM2O4kkSWf-+PM27!%|Iy+gQA*8&4!23FzDg_ZM7Pi-&AtL(58xpwr zWuKEbt{!yN98hP!eW1FKH2?Ov9nz{zzK^_Q*3blEO=bS`5vlzOrfiXaaBnS-EJuvi zxw%Aaz4R3Y4M$k6VrKhdc(vnGUkiw0sUKJ)6(>&8vT5f;fZpQytxiGfYmt2quF{5& zFNkHlzj%8w`#+IBE0mC>y>930vK$CvRBXxj{CdS3tT9XF)8^n7vnzZGVd8nt1!$sU z0Q4@ak2j6;qnhplpZ9BBHMNMDNO8S|%a#Z}304x*Il~>5P$fY-Q5H*1D{_eBpMqG$Ur%pP;cw2j8l;_`f;;j}+NqLS(FK9>O{O8G5na5LV`DtgDFkrs$5?%08)Q3WN|?R^2G-mS(VM0x^f8d0VX z(u16|(N%zN$pGddw0`nqw_8caWQ#m3Bjn`d0HHRQ0)|4!mvLD9xKtmhm`E$X!l5j*b^_q!kPZ zcgx|&x_`vIGINFtl1ETUg0M)(H~;$m@aoIHrFZV^e~(rRJjq~|&8w~;?;J!+W098@ zn$KdMI1*~nM9vdpPIWoN!$R&+L?Nww79P!+rF;85dwD;Nr-z)EMW?aO?d8LJ-eO4u z*16RgKZp0?E8C6U{S-ONSf`0_@$06mP?--xh_GF*T31;(LzlPlt`4$+P32;VBBu&l zegWU@ByTqZod*Hj7ncLkz_OHr8nBEuNFf;C{%i@V3r8cg!MER(ChMH6QcoIi&i0&W3r9N`>;E(1CM?T19)MXJK`qPQ{ z`;sr4EWJ=aGG0(IC}1nVTCS zF^R01{UWP;+@rU{!NxNPs$fy$Cr)gt7aJ+>8nEN4r$eCZyV7eAt+xM=P^_SOYk-YY z)BVNUUZX(_!zz_waS;#{kleBYYq zQQHcKP;?_A!DB9a^^zA1;L!#tu|QJ3Uu}pN3@dN@qp_aMI6mKsSa1pE+gX8gOSRm1 z40=MLG9qq(+TU`N4t=Qs_5pX)zsil7H`E5aSta1;A)8aAjTYS=>iQZW&V=twcc#%g zzxo!qHVq}?VBl6i*n8sDGC~`)%g$rwBjmq#WU5}=gPL==2B1&@b-yW=c21@Qm#Ej7 z`xz<;cL3gbybjn?spnETQyZH)aR{LL11iFc=5e+qvFpERB14m;`h0PDo*BBu=DzLG zGbvOAqkgmF_jtDaDxRqgh~xSY-SE40e5Dz%aSBeyFeHfKH7Z0g=H55o6FJakr{7Ve z`_GHlq6~{SDfaFH<-x^cJ?7g;<;i}PnmY0t&}8dAHn4b3Mr@G1%|VOXvgcKc0=edg zHo@umf+pe@w?O4xa*D4nIpWz#ttTf4P~ugF?xX&NL{fXO))xt+a=v@a+JR%rNz?tj zJvv2Xd&qeU&>edB{gu6t!QBF`SC_4O*qQWJHvxU|nFnoKd&A(DM^okB9uQmz09jqV zw?7J{;+1s{rA2>Z)eTG_LaK~n`L>Wrl@oP>*FL4L@MTN0nOvOBn4FZxDslhEuX_}a z{;ULdl#<`!Bd0csCjwa{qvHaWUhl@;VOMWHdwni)!Eg8fSO{jzYZ?@ZI-#Bf~__HZ)nwF!mIfNKGe5Q7KD808l!{EJW(xfcn_Y zY@i4|lVR^bzL<`_?OTT|0giS`^tRuq)mVZ&3Ujej0N^;ef%W}#Zn;u9z61bb3 z<%eHr{Kv2F`$`GkwwBo51>u8lTl)+S{#mE5>az7k2>{jDmW@7g6f+J2XrNzg|A+H5 zW*ovqTQ4vEx2OjMNce)tz321WMsICL`7*EY8zofbI-~kR0VK0yzggL$gK&#=E&$=}B#Cftt zlJpJC%@exP@l`=7&jT36_=jDhg_&L$gAnZFu>ThXc%pRvbjiPk;gPxkmH+cIgsbu- zw3%vB`2UuA{thk?4~{&DBvxa7a40#kk{-qQ3oI0T9Fu2x5+uShF<88^57DF#>$z+T zxz4aA^KAICsHvsV8WAmxbcNf1Rc5Tn%_ax};uM!}DOD(6&fA9JdxGukpfJ0O6Zn~L z@KX?LkAbuDbPV0bS9OhPFsg>Hk99(trmYTn@>SMqOL1kaUI3zS0!YBKd54Mbti!0E zvWP25KD*h?ia`xoc)eaJK^lis=-=O?pam_#{iY>Ri1GA+Wa|4$N&-uL8Z?yO@58_^ zt!Wf3XOm_sz#NRr#Q3>RFrIA6iqtHf@QdPNS0&KPO^&8eYZU|7-hWzn;byqVnp~LdZGr#3G18gt-%Q(ButXi$ z?MLw@0`G5+jRsWy+#1XUW1LuAX>RQpyHQmiwESkOBCFEasSoe}k3GQvdqUIn>)~(Y zr1A~yO$A_&Z5YS8bSd`$nuL@YA|W8Zt%EMoLRED0`v17w0Ek6`At%*V#e7~72u6h4 zrrBuDgw(Nf=oJ^hhTt^rx@qv=*Wny;KrZo|KYT#S8CQVc)Q49$ZqgWf&J0vQ#_{bs zrtsfV06FwPLS+NWnVlEM$0h$`WsZDY({l)op^=@3|3cFhUm)2Aphl)76o%f9LcrlB zd~_=C`CBm1Ll=q%8(_Wvr;k423*|@MMd@~GMDbepeA3RjF}ij;5tD(Wp4A#J24Lo_-u+k$ zf!N;;(yqvSD{jvJ{@I}@YVv0CbEgi5kR!e@8FuXCWAC}_%mJ9*y?*Jj-z8|i^!Fga z=BgwJZM6w6(=X8dacKJ=+yu$Auro9w+fO?`Jaj%yaR%+*RdX0te4RuNgfL9y828>$ z0FhcPu$OieHU+-03qAkG_XlnKAQS_`9r&^@3wU=*U>4jU)O&VNJtjh#$u`nu;`QG* zvlX(L`B+&G@5vqd4jBH7h?C=L|Ly?AGRGURx8iIUAkY^<$=r$r>#Ftu)WIb!w%T|9+_;j8+ErvABw4d?lQsr(;58^*Zw z#|t_SUHH4Mp7-K)hpoH_it+-a=rw&>r5gNyJ@rF)YF=vfV~3ww+!w&LKBgqEF~&F0 zu?)uO7{zYOU$Od9_v^>HDt|bT@6+BeOzP0TI&@kp;IyPE274SnEk>vX=xVoM(n*=% z#`4HW9`w3ZZL$x6hVLnHnpEHq|C2TVFlt-y(tq81M{s6|;7y)pm^=JBFic417=R79 zI0cNeXCKm)Zwp@e`+ySqx9U27zOpXH#CY)Z9sWJkFn{jamlSErL;Hw&85c1nBJ&92 z8tp0QcQzbJFqwbu}n)TOO&YjNz@F*_;{>=ot~wFzO8&YnHE$cGLdbp)V>bhB!yrw{%9 zh`TrlOi;b?MbVB5rZ&69JR5g$^VsE&cG`?!^tXjXcO(w za(jJu@C0&scm)KrZ;>X_SkrO*7eG+Z8+~*?4H#4y^z=+^(f@rZ)FL6~WjEapD8G8> z(=Yjgg1QeJpmS>gM!m^Q^kBt43$KH5`?jFe3l8vsIAQ}uYuGydp)Y%Ahg>&@4Z^zC zZ(?_FneIy{eG9$ZRseZ%WD;;wI~YvoRmF8N=wI1P6grwg$nWDKs6$)p&=Zox!g&n# za(hm3=q(bR6@Z3!5k`PI?tr#x5V0XGx5|#eFDV9>eVx=hkVll%lj_%D(1)Jt&=z6k zfgM7!v%t-N=mDr19X=c*>qFp{HIQVM0y|j0w-yN?tauLmyxM_`__zX&`TuRnp&#o9 z1&e`EiZL+`V^RqW(Xs{rS;QYiB{v|QkNr>UQ3l$MMX+Yx-GQ;XeZtFCXOZ;)scI?Y zghnZl;|bD8D_1?lJ+zJokG(Ix92~}DA}mU%Lt%_r>DvqIyu#P{H3N%TK=&bas6X3e z!32^Epk-yGS_L9nZbbmSOnvmw&Nz68l5x;k4i7*v@w87>{HHl0nHXfvy@z0bZ)Po#=)0iE?j{aHCLjL4_T{Z^bv&~G%drIt<(7jEa~(;7vHiqUUeou@awc5 zNEjWXLjZ^n;a|X14FG0l0xh9c%>A~jhZE&%F<)q%p{~*MqHf-CKXG6i`?5;i4B;p# z09alhdYNJI>LWo1k6v;rx~cr8^jHL>lrKh}i&x-;7&RF1_1*jJD@K?UB)Z{2hRYIOCit-$Kr- zqK)FQga1?_+38QGX9B#u)eqi)jFy0~w{gdSiTIi`K;j_*-S&wv?WF$DQH%+H4`lMU zIyxhX)WxB4=S2W2In~v8ZoWSvyi_)x1LSH9;YnHCe>PNs0-9OpLlbm}HZb$q4Pb$O z7+4sLOK(pDroj^EnVQSz^#N-N$!R@(_yO;r-2u#9fFV(j34g($jv}zQG$0RXS_Hu4 zY5_b+f&7oK18?ZMG7f&Z7*L#XpvNJoHzGdfpCdSz^%92t*~w_7TsU|_1ZmXl9z-Ib z4Ki!RW1cf6w-oEa=Q5+lL~I4y?G@{j1D}i>ia?aeR-c*M7kQ@o9mtWfJ~|VvKrG#X zOzkc5;}1fSo1mf(Jrs0bar#cSJ{ttN)B^Oamoce|7J!{s8bbcYkX+4tL1uAq69-6? z<6T3BC2voS0G&$^Ib>7xIo`tqxP>7+vQ(+Vt+E=iFU(jU2ov4vb1#*iqp&Sjk8gLC zTx$GC#ET!>1s#lm>ciCo8Gu#b=rBLldwaNzqEvFgFJpxltURS)2@_#wJoV2TJK`Hp z)b#3RT5|Gx&dLRz%Gr!Kd}Sm4n#K#xp_@?cyn>$5QvBF~2SYNT^<*y!;mz&7$&?_fSBp9Z21_t_N&Ns zX-JTh?qFc@H9(%q9OMWr!}AG%{JKGOnHqB^|Ks3WCCW;}B_=d7;i|5N?OXzE%&tva zDVP{bpy+NKpZnZ_bm@mq-F*pl+$ErdC{m#eVr6*svJIX1fAsAj2v zxc6@fjyVkR4lRO#w*-k9TCc&BFS@r-qzxjfu@{Ahig8`K2$By7pKaSNx?Kz*k1p{B zgo%Zx`gVl?ps^obHF&Mb#kj-|Zh;>;JnkJxu6tlJ3M9?fK_0M1_Gdwgi; zQmdhTpO`4Jkk_KlB+oVOwCLLt%lu)<5(2fI-i_53GOvFiC*wP*#wjHpBrM z<}Gq?@+zd8&~#=8_aC!8jsB$ulK9k$0Xum{q9F za4gWF^o>Mds0km2?{ZG{!Ql@32(AL{3Rtfh{JbzQ4m1~-e}*e;r9c11czyzSxCx}( zI{{UkgZnO)=sOhV3-A9Rf0FrHJ z2Zq~^IM;m^G&Hx#?wYkuLIZp^iLeB$x32|c#Dg}_*Rn&iM@pmWZVZvTL z^c+^`8z3_vIErXhD?C5L9OShAs5=K2xG7#D`xsr)yv;8eFmAfNIAUZ}*{y^a_E zTag=a+}Avu3Sw{RCQJo;{B%;&0=7*LB!secP;2!0>@AWVe*ek5xD&dF)H`n$bceQZ zHdL)DQcfLAxC-=YcRn=18rwo=o)43UKBqaIHoWvqfR*Gx5JY}7m%9n_{*v)9;f7q| zk!px%j$*w*K+Eu4EpUSAeZpj8<%uW84}|~gVmQD{WkPe_3OY-3;1#pIPBfi4_&4xy z($S$mz$XsldW7}Mozjt5iFAhej@(Lz){-cz3fJfR=m~4kk$K%mzQ)TNn z*1JDQv46NNA!2620Y0G!YM=V*e{Hy=d1>+tO*3PsW(GGX5~n@Rho1On%c#kt5L5N7 z_C#B-R26~+Q2WcviGl+UwKGkR?oe9Z3ArKV>tW}`e9CSY0qsmfH$Pa?? z!kce;(ELLau*HiVQiPv(M97K$IeSnab^Nr4=@bhH3IXr273_)nf`m_A9$-P5D11YY z(Ro-e0n%QIFr`M`o848)@xBPaf1Ma-#Ah!TsC0`GkgP$;x2Ub25wnSz8TBKbf~$m1EwlgXXWToJ@}c99ZCM1E6Z5>WhF#?KkwWRk^GbEPK6SfcPd^5d8CYj`;GN zB^Plo@H}k{f+(`Yj8Bwb4pj*_IOhniA-FD58=oTQexFx9^&*)qis-(C6{-r-fm_`f z**>u3E~G`Pu-ft$x1RNdCRO}&`xV!@>((%qH}RH6&7Ua08`yO#uC7tyBzuKF!T!l$ z0DNXSP@L`gQNtM-7GMiK`VDLbLW?j4%Crm3vkULeyjKUV$nwI?t6UNS0w`3t z0Pw(ul3};Omb(!b&ir$jC9rtr^yGj!+UI~5((cWUPaKzh9XUD9q{KJqCyFk&i*#*@ zz_2rL)EEhdN?-dZ7qq z&_aP#SmTwC<&%pzDFKmdyQ6u_2Kx<8m|%@yI&u&4eyBy1?aOZdbT+3|CTx-yW&|E&H*gua#n0Q-gzPGWmwDZ1XtNNoJxqb2Jd#9?(Rd4 zQ&AxQq7IN*o4Z)d?Vj-H)4$m;hCgr5;s7E-OOsjLf1~x8H>AoE08i>Zo;BjWb}%DR zhv1$TS&@hXQ5k{k%V$ED4lcZT2=aCAnSqB~rxKDu(UlXs*^;=YMK{zuA-oSi^cMY= z&$lC{=2#GVsZ3iplNzFZ;Jf&DEFbL=qKegn3211cgQj0UF6Oi zXd3zf{5VH=4@%d8W~QVIhm#yN1#Vu=?w!i17uG`r@rVoua-Ar}@Rt++9E~Hm?6x1| zu3q&8U+bsxn%3<&uYNImA--Wte#?&c9N5gkeFREI>I#5Z^q~dpveizXX9?|u<1j^z z+<~eMG6{uNGw`=$aML15_%p=cNkb<|htTnp4ZF`JhZ=G5%p%seKI ztz~5KLjV5Gzz6}%U2keI+~~<=Tb>xu0H=E!5g>&Hh)q2=TY!nbhkHlrh*LNoOQmpf zD)Q`rA@(Z>?4L;zJCJ#yyDUCNx4+YVZy`eJv}4**#XRRctX6vR7HqB?aJ|QvS##aI z46_e86pe;}k2#9dYn;W4m8UQKcv?6uuPiu5fq4ybUq6YCfr6uga zq1^6<8?4TA?JCMS!T{__H}owFT7$KR@|LOdI*7u9jMf6sgVoCQ8ZfKBIz=hOn;39| z(&7D4M;|Q}NETFEaHVX8R7B}>090=ObRfpL1){(_;~-5wtowLQxVlEbS|X3#kbn-y z2+a|N7i@cfC6zVw4BT+y4Zm~g2w=N6D<`59*W3zVl#de8m+8TAd8`CC_dh2>q8uI3 zcuQ0##t;$!Ds|F{@_F0uh=Ob-R#9Y;MosN4oIJN7`V2s_8w%tEa z_>CrL`tCriu-99B<&>_*9hF~jzJKDK`>JUi+zZq^E~RdA8`NL?)2lCGzssEFAmqoF zp=0~}HmyJbv@9{!%lpxRSFxO|3T5=DQ}hCOi@(`xKbhC&Ld4EbdN27WB>*o#%QdZK z?=S*aK`dQp{`roy{=JA zX)CIHlpm9?K8)8%bde;fhQ0`CS}W}8CO}FkxAdkCnN)5MJ|a9 zvBnptiDteD1r$R`_8k&pW6jY}C_sb}AzK1}n9I2)vqpefW9>HMdlh|NUO(I6>VS}u zP~ptgam}hIj4Dl>k=EAxfL!IU2>imP{u`N} zY;^CZYj3#Z+G9%Ll26`GOCFkk2$Xb3SmN}>Ft-Cim54d&zEMF}kv3+fJLJwGt;(CL zury5*&C$x7Pc2}$KzlAD72E2@2VhglN3 z;u&gc^!AL{R#`qgdCRnA9B;xW#9{LYgaT)w=hSO8CFob08;ECq<3kZ=jt242%RQYn z(xmEMvw-`)3qZsoP6b||4{o3nY&ZmT5;c+fLg8MIx${&)cf0QYU!}k*`wN(f$TsU* z0O4LdE?mbp!o3;ixISG3dOO355rJ$yCu4LB&Y~`CGRIw22t3R(VR|y)65uG z=KOlR*=5zpNN>XXTFCy13l8_sCc7WkyU~N=?8un%7#EE&(Cb@Y${fw~p{WS5)-c(U zcX0+CVPmhFKq2T@27&U<1x100W29A30Sxk9+IptNtua9@FFbKZib}p_caCikK@bDZ zqB40dC3t!t$H&Kq`&Hc_%~!@S3pJ(f7uY%W>fPY#odvh)Ahfo`g!)tuDv;P;e1rj> z;HM3kj8@&#mEpFv4y2p)Tam@jWk`7{GLgpf`KoiIC|se}1HOdo-ORu7?x9nOV}2y=kU<6JEGc)HF1rVuR(b2OquDr93$Hlz|PtGpN?c_x=uNXpYb zO5Z!qp`t^1(A7gwqLd8WdCjn(ArK3arZ!?G%}@x69HqusE#4A-|LdJUs&pcL7k0CE z$p#Xg+XRtxH_-F)OLf&ydYUB4E<0-ckxtC z0j&qcRh-lgdTsR?7L-x&<izsUmF3LiZ9?wP_oK@o4Wh7l+b@h zoje!*Mm@^iuXWoc_gp0n9te=xlGg;C7Hwu%qCTJH+jmjA~0*h z5&6A3?S)p;wbM>e=acl;|L%P`ij9*TF~X7Z%)}nrEZ1XkeuoypGZ7tou&*7f6&>cK zq@Q*<)VuA$(>#}^#kzz$!v5Z6KcrhXe|-O5q^!x8O;e`B#U2s#=x+dN6#gK|cf9@X zMZNgVkP<-f9j=*3Bi?4PAchcL&C9GlaledP!Sbj9{|dgb#uNFp6#u?4C4z>v`%zWz z^ZC~199lU_`;cVvDl=fF+2j^IR$%4*A7=+zD+6j9{JiIUE4N)nr=7c$`TOB!AVY1! z_nXurJELX!o^aQ1+}9CzK}_20*~)%c^%qWlS$Y5H7ug`b@%F zG4M|V;H|p}_3>RCRq>2E8APlstds3Whu@hhkj8`ZO$2^v+lZp1mYyh#gtI3{|bD(!rbRVYgjqW`kq@jVrcQK;(i7KP*Z*j`? z_hw;lhv~ry9W3nIvoL3{Y^uUsj@ky%~+#6wy9c%#jIS2Z4D?+Z( zhqyS{iK}U#XA_$*!5_X1z?XZd!|gXWr1FX3vf42wKtE;Q#JKu})asgo`xuJcG{Dlj14UqFH(^UGUsW8lOQf_E z$n@3Nqtv~1`SPxySm?j3ji&w@bROHsk12+_R5)K~z!`QeDO#Prgjt9m)HdIX;@G!V zb2oN`z&r2Fhi##21roqY?9;O~{Qb8GjqdtH4e$It(br*;cu0?z64u_N+5GO;W`5s% z5Ld5uZ;T%gcEd1bne_A3X=AL(57*c2*?BKJO8RfbefN~6Ei*UKyYYd$PMp5+LA?LCtsVRXU`iZgddtwtuAM_7sL%;)=o zp1T8_LZPB{CpLu7;KTke0#SnRPTet?51{Yr2N2OPkOISu8a82Le>BAqeH&+!PBXTS zA!Yi+&-W`-92)*XowdK3R^N)D?u?QVDyrx#zTwAz|B~uwUs;C_PX4x)jtFFRF-}T|O(65$nNr*e{Xk%WsI1kTykQPDEuQ z*4aAyBhM@f<*VqwM5NEj*2chC=en-Woo-A={KmPbEI&dBmtPWHL*Mex@N$&QDacc0 z@>GtxA&U*H>X04{aH;VMWC`-#NJwAijFnbEr}&fl*w0jK*nSjB``tTDDofa3pQF0g z1B&{-K{-r#+^c;kDVIfy+Z0dYzGw4xet6 zOqSt!#0m{%*W+@#j%s=76n{5+Gy$_s2w^pC_!0c}$f0{>aycdW)mwTGZuCy&B4+kQL}`Fp+63A7WTBK1Be5H!%}ckGMd)7&1?rx)76sQHZ`Mc%Sb6cR zKng1+sd$>7&N0gG53FX8$Rl?`UUa;rk5AGvQ~bjg$z?&bzPt;gB10bY{9DHD4JXZu zFY*jAyYeM!%nV$YU);80ufo#Tt`OXqW9zRBcW1RxXU@W}m%KB6OiIBsx|bXiE?)wd zDGg)NA_K@!n)Dj@$~!>X4v{bvMY>Cm7_M>B(nR=O`{mKKnY{sWddj-YM>&Y5@#K2y6s&yh=ZQBN`finFmc8DfpCOEomf(v#l<}alG)L+!G z5)BU`TP94TIbT+1hPr%w$?VbuJ1dOt%B##1YQqGaW$!Vk zR~O;7P+PRQ`#g-OvbrkTO>6r-BYk(%#JJXJL{)t-)if`_=xS}r+Ub;lu{YdGL6Qb1 z$+@X>&S!q?I4|Qbf*4y7Wnl^68V!p)%1wJ8WG{M8)E;eqsR)Xjq1uXFt(j9lyWUoH zYwKGKxMt5OGN)Lx6En~oI~#G+ez;F6Ue$;?m8EudSDm>HZQDfO7XkD1T(DZQjO{#Y zN5+(?6gNooN>i)mn&=ezRMuB$34>zjd7_%FA8kjB?|s#7e6_wT8pBNa=sLdW9%7zC zC1P4Ua%Vk2KTbG4o3{4+Wx`qsjVg=SNmX6bcaNx~kt)2Z@jI$1E-XwwW1XQS<%aZR zc5OF3L7$75r0SIy`BfyjFrIj>Ru?e5b$VW>iKhLS=V#63pT~GrIr`J0v+JbSBvPHH zl}8`{jSPt48Gwt3vo5Kjt@ODe)d=yI&vCf1_{xEYPTL@ERX`+ki5wO)<2K>4_YSmt zS`Q^+NJA~p=uwW5`c;Hb~!@LLWPeQU3)v>B{w632K?*vBLel;M&f0!;@nc^kVau%chtH8Irh=? z*;82*m&S@PgOR{|PLWs7=fTFd>*ZUSJgA%Wev#?u3p3xl!==wUt#bIv%J8C2A!>)| zY#F1Q8bR(FpY`Lb$xbBDC9v5WagVM0PeVoX=*D55YaP7D_`yon=)D`nn0e&>!J!)V z-T??MQQzvBFhlMU%#rM0IwB3ae>{Qd)2{n9zQ*HM;&f&0Wm7 ziXUsO-rbV=i`ygWAP-NHPfO?K9pmh}8(%|4Pp)9mTzkb>)>QBwWmaeeZ{o?nN@9Tg z)UI=^<}U@TS*(#yFIQeU@v|DiOV+PY9yCT|)gju%vFO&6f4xs%5p>p<2WK#}D^xr# zr@{2IK)RyI@rw(j^em-6C3EK3TmngbKbHQ`$_r@laA((a4|DJ}#*(#xAgs#j2K@X8v2Q9DZ9$I_YrrMHF$lY=HW2u#BPt5 zu9*`(sz3->!@vY?b`^3lCf%%Wd!1WPW_aep#ki*Vyu#SsV9Rjo1;;dk%lHd z#K)oUxQD`<$iAAv%_8p3qg#0<2V?{xlx z|LyX=on??@h$^B#9{HUm{^jn@3$1lEb-Xz6jM9vg-Rnc0WJIMdiP5UD6Z z@pQ{N_w7^t>)kj-gsWp*XEZzI)ZVbqc%A^T;TqW=F(zzI#{TS$5~ma~gfksJ{;naN$*Rn8Q3{f))<(>j1cW$UzP4#v;5%ZNB=*@#0$M4O7g<;eKhPCk}S zv93M#t-$-$2K4M!__s%uX;`YVq`g@u6q#vPH-iTsnb^|Xb*A2BC8(Ba3JDru7=(9Z z1>lo}!4H6hZvc=VKYcdn+(w5Xs4?y$RM40Q3C9@^1(wf4oaGZ};qTubj%Tc8I00>3 zhe==yVlNqTYAyT3R6DP@NuRS?dlW?^xrFA*3R{5sAh_BVvL(@wkEO?)tac#6+~mi%H( z3t`6A+D581UB7T(ogCbt>X0g6ES_NN{!5WfLh<&6zeV@KbVQE?zK(oAG$Unm7FZ_r z%%#E2yLvP~C8O{2oD^p%y#1zQsCV0R1rf@9@Jt%{``G`b!mS4ZxWBE23*W-|7uSVh@zYGbdH^?x`OQoV^&ekitL@V<-;?Vdzp*S|GQr9NbE}IypOF4%5 z+|M$$PthST)W_OYZ)#V9a==i#me!y63x>4?Mt+Tviog{cWICv0f9wujaB8CQZ!wU_-zV}i4^%~xUQUc4-= zG0l%3ocbl7Faj(~=TXPlc0c)Z%GBRTB8>I1Vf9lMYGkove5GU_Fb)1^Ay-^`Q(>B=Id+Cq_3M-O(xVi0w9&?na);M}MqV9*6!Y)6Z+VeZ8l zeg*O{k$XiZ=6?YvKtZS>>hyiHG=JCXOGygPQ~b499!yH=h0M(ML3?3^e}d*&f6DA< zAOF}+K3;!<&arZ7HsH#`^jA)ATd!(6f0dd7&>QO?-SP1bin~hDuKQW(Lr~ob+sth- zwM&t>uQGan7;En zTi*|(;0?Z0bVuF|e#)WW&`lzFjmDHWk??U^Lds1E8p{p71wg94k+RdjSjm`I5VwK- zMtaTv!5PDFw6rDrMcT&glw)Up2F5%KG4Hy<*%?7D3c-WXSJAS+tfx*h#ni=i{_@KX z36D4(Dt-Evh!YoL#RAZ}G2JeoJ;DXnE}X@`48w26#JWswP(99cki@?ivmkRV=^kc3 z!rv4<>3X4pZ{wCLt8W}>HoZV6U;joz8j%FRb{@5yK_$Ea&?6J~XNtA+7+QmZAV+at zs5AB4zsT65!};?@*R&DQ%+uVe>14IbYf4!wcQX^*^hDy9(kISz3<79tS&^?=qILuQ zXiqUu_R#nVnKLU7&iH$32TppusO6iB-9RQ40PWm5eVz4dU&^zQmHwQ>mviURY76Ht z{sW4!qG5N)-V1tG4!3cGX*rgIV!-!ms579eo1j!PqF(dIgSE>2-@>WcU6Q` zl5@}ly<$uCEJe&(B%Y$ZBpdrzu*w@{g>exj0Yb(UFaAzd-#cG!;^jSb;B3`Q1L#<4$IWb;U(i`{)9LZB_Ii zY-y>4C%(hh7L3ff_>TF~D-nXZ8pC^~3`CquM*Z9M2m00{I5fV&^yC0YS4-&V8Zmz% zFoww}iIA*~mSid-JQ=Nm8cEy?No%f8-6PC0z*bIrSAfxDDV+<`zx9r*_@35Vn@a{- z;o=w9A7R9@E;9nm<=cYj-2zEZlf0&QO^#c;LWNJJZV-q!EFovU0&WvBj{;6rA`O1U z92$F)Sv=Af$!~x}@^FZ>QUr-aHocS~*G^~m?&7<0bQvScB#$(zd{T3maQI^BieZE(Kp=PwYi%+_|w$>oil z`o-`LNb;f5#6}jI^u$H`!g|u?N}X6QKJq-C6Z%&f@z?R4XrCWP7ED0`Suja!Inhw9 zlQ^L6c4kMbv%3r%WSDvM{R9P$5I?pa^F%I_YcA(JuH^QjmM(W{0Xu@PGAWN(?5ezO z*#cg?QjLF2MI-)1+N)22W`7EheS>8r9)zA%6}go76$H`4qP7NVD!>AH0tK66KT^Xf z68{MG@h!_cP?+_@41p)1wtzjV&+eQ~uqGsX5_*XSGW@1f+Tf5r8z0&{<8viyO z;`qoR=DfJ>7iIFh(ZJ9|uz&duG^oPK)~AnyRAPDR!QU`d+foe@#3kDg6Xj_hA2mkj zCSsNCPqW6K!dAX<rbj)D_hAk)0DFc z5^`DQT)pO)lJ2^)#))`8;$U1xbM!VepJy)%js?;!z!V4<(@5x^E_3Qgn&cU~j~=Up z83%?lRx!=iDbfO1;h>f;Z+-tbF#z1c(kpRbaN43BDr{Tz@^enM3i)W+5U#&+?-9I( zHl10@lO+Nr1%SDqV-QF1BUzAL!HhYrl=sf6^z{TIqz(bz&}mc0?o;O>oZ=UAdXztH>oN zmHhm@F=wIpc|30olgCN`94&Lnvpb^bO2qq$2)FiF$d_09VES^27Bb|+tW5|QS@{dR z-&dAQ)ZF-}lyT7=x-OWI!g&v>$9`xxX1_p%R}ift)SSNaw|>`GE8xCl>+FZ(QqT#u zg6hXCiK91{>f2nd#6^!O&?ptd*s(@4nDOwnT6BgWuWSq-u~oD_-H7NfH94?|M|`j2 zu5pk@7D&=qO47*4*BgsGuWL8)6nFm{B%|>Ce0< zm~sWg{!7){M*1~g|t3qTD zF4;2!C;6de=rH8%|yfXnU;Gp7=u622X-p@RcryW7^&7xuwSpHB-ORK20Deox9VBRHdXNvORj9{ zVcSo0$K4@j$De40S-bjRg&F2;jl(q28e1g@@2i@`nO9`gOAm7AE;Ihr)O8Zd(b3TJ zo_C(wYij5TkA21UIMd12Vt{nBU5hzIrQ)Zb8Sxc6^~oAr$Gwl<3W^g?ZP$}-dq3M- zc244C%@-urX)Gm}_qQ71V_&y=r>w=K^{(bq@3#Jk>RQjTsvQ|vdfs+FaUFYCP@`)7 z?n_OgD%0}z&cx4}q3!YQJ;Coz@01fmxVC?O-x}pB&j)%abFOijU=P35OUOq5>kp03 zA4(P9wr?*sYE#DMsiX8ZxyYy@_l58D@5^#)xILO2ka`nvM*KPHkDs4YS~P}3cPngY zzM*Cs!@e%@pv1RE?%jLGmYbV*8M6{GJEcXym+d2$)~ze3g)bUPe8tZP&uXdsdL-Eu zvX;N`v8K@XF!K<-WI|kXNtgSDZg_0B_KowRP0^)1k10IZ2k*SjRgy&agY(i9)E|zs z!K}ru(c`}fYef3x3m#C|o}-H9dIe9(2skq41>*@P$7mi=`FO{UJTkTC$Y5Ov-oCXk zZ^`CZ4|YNdF1zdnfHlJ#@sl32{ePreEejF4FF?Wb!RGu6Sb z4c4Q3Z*wuLQ!bOpJUEAuP~zk8%JjI!7S1)c2HUWL(Zm>C?dPmG{X_0as+)z$bCJ7A zs?!&CD+cbE=;6>9#wCWgAhfe5I3aBwU+Gcn=?6F6RGljR2q!jWw%!2a8;RkHoEzlQ znBUGaLU)AVBJ#S;jrgCS9d?yG`k-oyAcHJ9w8bZ?LRO zgppWJn|%`#-FtzHsPj(`t6D6c`)c5?sS=z1FQ{t_3Y+(&SX&Bd$+MYnrv_r=ESO2& z*s+Ja0jt!<;bgM6Nltj~IKd{_^YR&7j2A2Fm$|GeK|br}o&dUM*;>NqQ3KPa@9bDf z6txPaLf$BHRe!}$u-p-!{F|V-Zh!;YPTp#h$pN@MxyRS4S2Ov$>CbnH+p{85 zu3E#+U2do@&kV(CbP-DZZsz|=*69opTp;J8LZ55;L0~zcARTpKP0*`fDU}dKS=_f& zO{}O!W8%L6fw~lcjLY^qw1^Q?fAH^nu}QbgvBp{i zzDlO0KIjkPpVx(t8oyH7k^mKx&Ge?srb~hxH?7IT+R72|Xtzm)?LM6~n2#TUOqOx` zGY`kF+3oC7r96F<3h)Xxi1?1?#wLbPxt4vOHj}HJ(Rt)H#@2PX8pO>i^r<6_lcSi_ zswtIs$m?o(c>fnx(VTg?ZueJvDL)!2SjVTWDXJL%j#Fh+5ls4dBd?UGl776BqW0?2 zDpL7%HL@+z&m;=uEO*F^+)ac&I~mC)5mc{Iu(n)54S$-Koq(egyDoT%`fmk+1yi!X z5L(F-{8rOLA0?8wmUCgpO6m23+HLrullT;{^h;C^Sn^AMB2(GsvDKBs;Ek!lKcqu? z5-cHxONZPo3*!%s5h0sWs%8H%GJ|h*$3V*BGHPBtlOp4TSy3A{SAPV($q^@L8Y+&R z8D^UI(dB~~C%s9n()(5?F%JbmU6BC2!BukZoA*6A^NwLK5y)^n!@#F-kwV3-lrs7% zW5W7Ly__B|3$(3VGWx}~-t#;r`@(+5MoL36nz#bh^hpj$(?2-yMd7pTO8t1y=1mhc zGDf;^!S~|Y=siT|1J^0Ek;pGNcFK?mwlk0IH_?R?qnWc(ap^=oHXMG=l|~QEwpN`W zNM~q?3^*xDL3+De7--0GL1ddGl`5Uvb!#3}YERh~;CQ;$$LScRWD;GcL>m14r^qCw zh7qj=+PG;vP{gQ-ekn3Swl|`WnK--3Q;wQ8nq1z<&BaKv%Qa)fKh~%ef17@pHbuU! ztJq3&+A58iknh)xFV%J*e>q3?xU#CUz%^JbP6GZvS80-ok1I45gV3bx8Lx|qvju1f zEBC0n?#AW?IK;*G>}csamD^|jT-(qzpfoPc5{qj6D5m*VS!-(V|Izi`@l^ir{~YUB z*(>YVd(WgCGqaExvMVzqM3lYvE)qgkqHL1ADIy~Z85s#BwEV8S_xOCizsL9cPk)4R z?)x>a>v=uLj`yxl=i~8>)BZW-x;_rGk^2`j%AOxHyb&uWqe@ZDnLOr>|GH@SwV^%A zbl2&DSowJ~wh|p@J_=LN75n8?r8YQM{8FDBdw<88uj%nihRHVvInFQMul?Auu+AH~ zhEMYA8weHeb(V_&Z864V-wOe=VijKi-@8`z#HsFx?_ca@IEAuapMVvNU$59~1)z&o zo$!?NyBim&&x#+cxN_*z;y&S?(tGTlE>)+`^qAQ_D8|^x0%p=b3b;a|b>*^S4Vr+^ zW;!7tCH4>er;VX(+b=F(d>hUqJu>TFcDy2MYBA63f3d#}`rg-P$TlY{vV<}pVb3h| z{x_(oPmgM%kLRC@#-~SAOF8wOeIJj$BHjOXN0%xgLh}8Odd) z!Rpe&*t72C9`5IJ0vC8BBii1e`~=go==^DS*#rVvf4L`{lCjI4i`xKw>VC_->{t5x z)gSKMEo@c#A%3p_qk^KkBbxSOT@%&D2SD z6^$ULUGo=HKZnBKolYF&=}dOQeR(U5SY6S!lwFK*Oy+~?^OoMRJ4+>BrohGefLY&u z-4XY>bn?1SMfE9aBmcCI+ALq2#yx5Gg_SHIt8r#}@I#zD|IakF3j@5L#i%XTy?H$^ z7(BSY?7NH<5S4@YU*%Nt%Dy(3-?{Vi{qeH>`16qPWa`%|6Pz-pm*}52nNt4R6$V$D ztC#l6jBeu>X0H>Z2sU91XEWbpzFImFt8LVW_Rn8dE(#Ufm(WlOB`M5VF>HA0qZ(vV zm@^ToIg+(m!1QlhBTNJMx7gfg>R+;BFFul23n%G&`2i)t%l=%=C~2Ww{?d^eMT_N! z{xg29N&z}$%O}eITL&FNjb?$yZktVtnmbEh>U0a27|U)@)r*7k?=(nX5R7pKDS!Re z*dliD1CpIAwzvzkIIZ3TZC50kUN`n}#npg~gXOdfqS1;Pv@9U#5q$w;8hW+3pYB`L ze>g6Aezp8G+g>lFCbL%WyQUCPPKjRnnB;c`l+jhfBw9>a0#p$KF@4c0CcIlZqiXN4 zmh(pT8|IGO)G&V6 z$!XS>(X7$O)0^2`FPR8ZC^@ShsqwDyxn>i0RaZQ7@@f~qnBTv^~@z1zj5OZ7mN*Uvxb!cA7E(L!IBPvu1X=iBvr{W*D#q2%*&f=Ol zD0|p197@pnANG&5ckqu{MDDVvbZ9~qVkvU`N-jWE_?kNPf2c7ysA9fkp^oT0Z~u^5 z>#fPc8?=cv7?M5%3{~X&7eE>AC8`DU@IyB9Pn=>i?i`on<_tcjh4#~g(`0}@yD+_b zY2NoYLv9BAZU><`-K5R$-*DchJ>QW~7I4Yqd>`j^F5)lIDxHnDAvx_br9WHD0nfq8 z=f#IR;=7`(&wBI3ZB;Bw!dDBuV#JZ{7alagBz8?dXoO?Nh*u*1+om=D$(YW4T0tH6 z2ZCbXHa3AEl;HQ{cU``9>bknJ^@}281OM~F_$887%l);J^Ea;`!lX4Z!rx{Hi(D?9 z!LKx-`}bwthXIK6y7PugM|3zd^~T}viL(LrbCUECTR)obY~s>f-Pmn^MSnwxsGc`*dd$K&4ycT+74{&9Z@ zc}o2%$K?0*>Usm=?Wexx<=+exo#c}A#i@I&L=OSO`2fY1c)d~B>;J(kr|QqJ;kK;I zl1nopr30bw&A^w;u^8@3^ykTo#Vej4V4ff-zh0E4U0FfkMcf^vL|xv0OYIu9>oqEC zwiL(9%Zuu)ZElT|cDJAUzvKa5cduU&Y=SqEKI?@ty*K3--Tjf)pkAY0#a9sW*|hNF z-NPS}{-?EMM|57^NMl%jm^@cyMcd2o&Pl-xcFIKln#6i*TqH6^`Mw6xpW{a_OkZdw z>yj-Sznj~|Z**lv{HKNkiWr0MB4Mv<5sZ_Y#%b9OB?_fqC791B_5~fGmSWTr$ph!sjXF=U;fp8UVafju2r+!EpQ2RsP+9rVJ~|vMmR`zm-bQ) z=-vXNw*}yCEll1naaUKvY_-qlA`epVPL;qjbw6LWQL5WV6Qdyv{X(KvA8U+{<=a!< ztBqF-(7G<56GmjToMN=Qp5UA5PVYY(-ICbp-QsIU7vp5<&n;L37)4)hPa{O5(Tk`w zy%#aKhB3Y1&8TqWn#NimMeYez>xM?Rk~Q&wn08Lw)T2$kK3@>*w0w9ipYo~;;hF=- z>XR#QuHCQIjeTxH2#JWk7AZ+tb2LBNf@`)07!oLxtFGU{SA zz58EV^dIQdBz2NC?r8=(u3v6Qk#jj{Itfmh1#5dLn65$w`Z=Fly-ZiT?FCarR*COl zNsdt@ZQ2y17HcwrwxwQk!448L2lE-q|LDVl`8;~)Os9cHAN0n}Wvc4ac17ol3&eC! zla<9&38HVLSR>MU51Lox?zGI}FVf5-&LvH}-X_X9VY}2F^9KBKj1R93-r4xRSjH~) zRC7OTC+vdK&Q~rKCA0l5;eapg;AB^~_kfn`Z;@R&P6#5wIFq{{RZjFU0DI2`QH4DQ zb)Y+9gZ~=wwz0T+-=`8R3+SIat+J_YE=vDr6bOigD`kHK*hwYy|ERX|hZ&fsiUGa< zel?gD%6Ei{2x|U(z?rdJBW*f=%cbT!;Lm=AI3nAdN4dHO z+thxWx1aO>fN9DWfd7fsnZ|FS(B*DhGi}-NUx0;p1&ruK%3H#{&Inn8o`_~JcJQNF z*IG0+sip%L)p^0!iaeYHbH{Ry)G z=54R_rO~7(f3Fg!Q2=fFIVqM))awxQbNvGaqVkJS4ypWL3;4(pH}|{6 zIe)0KeOaR)aZLB|(iG!w%d1}V^plUoOkb3pJvU>w-8XQf;$?JR&`|1wl&iB0`+v}( zg%|jM<8S|F)4^^Zv{EaN>G`A*-V`6k6A@kH_EHzU_X&v1P#;qz+xK)ZTZ|EpYA zYtLQcx=x?spUy=(7 z=H(VA*6XWapkH~c))@;nDB&$bXGA2zTTQR+mI5SuzEm_x;Bs!VB5sWNvZrb*BBBkS z-{a;=;xhQie2}Bgvf{*L@lq_#E?YdtPSWvARL_n3URkZZ(T^1q)e7Pb-w5c~DonYV ze>Z*mR8nl@aPQmtnYJOC2F3EfEC!NUQA)qQrhUnyi!hBl9p9NmCyy ztaL=+=Q1xumT+KJxVA=8aWTaW#sxC|)MIb^1BCpIpYAC{l`)3fW^_kgXWeScMbii3z)C{ zoOA(<7q@`t=P5T>UU9R;%k*Enx#TmbfVA@zB@VHl{M&Jf9G-k33rJls*)HIrPBBv} zsZ}cu$WQv0E{c%tEfA~HbB1~9zzMj&f6FU^XYALDJoO(8J>xkBM{mFl>&X^e1$3&NRVL}nyq|$`##V#Bb+DJ+!6$r0g&i>; zeJ%_t2ECm>ryuqd#`Q#$4R?;Tkd}3Ujas==&n@BWqy$`d z0}$JuFmHm=Q*GdONW6P;=+?W8vO6(1TIHje9iBn;{)U!MR|&Q9i+84I>7uUb-OdC4 z_@oA?=;y($^y5=%mGyJX{~VmyZ)kq*1WT2nJtpnz5b}q}M0!H0K6jcQpuE=O|F=gX z=FSRwuS^ydh39W7E{lkfjSU~e)I)wTdYrmu^VBi;&Rm1|3F9$A`zD1buY>m=dmoJa z;|0*{$h)~jEElnJZ)h!-kr|o+tRpXFK-ah@c;F>| z1DwurKbb1tf$r+cM`7A{e$j`D7t$Mm8Iu6R8bt(|A3tfbh&8f?xtaq*gXH5#BW(;O zq#LYAKMj^Q_uE1O6|qv0;qxX%h`EEgOzsCCFb|snz?kjJS0!g7@ZhbM&$PD;`PUcH zkuyJ840z=go2n9N`v-r|rDf}BT!o_BtaJqs ztOLkR$1af&P`C9X+K5KVy8*tZAeHR3FHE*7CdO7aSOO@~lcQ z0H6#$MZ}{gK+AU;=3!qX$a@hjw*CtGqxr)(oX;I^onh&S<{Ogbr6kd~_m8LOlUC}1 z&=lPzfDRYc?yDDEbMUGt-OMk)7moQZL3hvSHN?`!#Azh5tk3C} zV?AY(6gEn4S~I>|1E*4t>oVP^|Mh*Rn^AqmC=G(M}p8-J=_I^;jz5wb8AG-{| zYF;jrTq>mc{Vgk!+wH&L7i~_kN#|BMeqp^RX<}T{eax;NAP~{p3f2p`d?`jEmtlO3 zn0=O8(KQP*tm&&T6T6qk%H4C#-O&+k>UM8hp`W6A_wJc;`X6_Oq6=8IuCJXxj%)nb zY)ojK7(}HQ#VZRjf|2SD#8ut}>PUr0X2D*bKc`q(GB!5oTQ;i0Y4W+ z8W%zr&%J8#`&^_w7lL>jE;2R<0?wefRGX%R0t04m08WGYt49?!8)3k9_IP+icm*oc z)g_ns?O*#(?vRtnzA>JD-T%toi6C{_N>5>Q&qWA7JeZOarGiD%CbdJi`DxhiDi5Xf z4r>6MW8o*XQX3Axl;^^i(YPF#zrMGLiqe)_PIjH~;<003n&+sqcsjd2R?Qe@{g*u( zgWf~IKkxMDer7|eAJ5_h<=QKwlAOY~dA;(c2yptrVy+%cv@Y69tNM^2|7JP;A7ey_ zJUU-+4BE^mw-zH9I$Qlu;ZWHYvGB`=Ng_IWIS5+mi{tr$Fvc!FKY2E=vFn1JL~qA@;UBCDu8nb44OtiPnEBj znI&o^tZnhnYEj82L${Yi87F0^*odlLGz$M~wf(c4u2JHvF*;FZ8^^%kn^Ok2zJ~B} zag#(6#fn+;c~Kf4DR+SSq*KhlMR~UYPJ^OR z-NwwtC%_{;;S&YgGecsZx2=oR|X?Ffo)H~=t4O3|f$$YSu zT1Yk-tN7|17=51ic9A!jG#^&9SXisRhpo9}E3lT` zyU69pBw$tG^3&0_s5HZu=V4fySp=er3yt(>{w%RR05;^C+E#(3fwftO2I(+i7&;WK z`59jb-9EsUF9i(uIle}~xH)Cz7s? z9v<7s(N_6&mHekvHi_ZJpBOFZIfnlFj(ktbY4EI_17T*^t8OslHK6YxrqU?%9tcNF z!q;&QVDjz({*O0wglp6=f{0VFdvS(Itl`?bAP8(?*|P+@ltS>WQrBl-gu>~vARdxm ztg1qI9NR=zPuZPNtL)HTM=B(mK#Pj81;ol1=Jl?rNU|vfHyLR(Wd518MwGVW`<(g& z>Pqwmchg`i^us7+9@9V;wiZG#dUBZq{U_zQwYod-c$#65Bv%v>KL&PM%LM#Z?Ho}o zOE9~-hp$>I4_0P)5*T9LPl($Ft7kV=6*yp(F|K6~N0CSBuvvD-Gs}rqSS|svhuuh@ z-^RDfuMK2F`X{EAKsU%-MdXfwvEk6b&mef_RtMYjsy{Gvb1;>wo4q7}Yq@6A1Wfvo zdaZ>x>7P)zvR4NWm5IMQniH+_01@CG4s4Yix7;>*&#MLgy#;p62@;n9P53bhjh8 zqT|hD3!-t6s#Oy++lawW;g?K4qdo3db0Vg9=sWdFEh6h$_Upn*m-E<^O`@l*^o$-iWX1FxFln7Ag4YXv5x28nwPz4* zyDe#L0faUo0+o@!aStiU8bu2@&P#bj9Cn=w5f2OK1%tulWT&Oh-KM@G1wx51FW1fo z;uduzSCVz)3BOcSH4lR2g(vsj$7Af#YU>@FpfPF$+b5o*2mY&fu~#%vzZ+)!=zl7)^!@x!LcRs;#qysL)OSOI%zct-SN zY7|_De9%W+Ib+u~X@8cCn=lA)^FGxDb{tY8Y{M|g1KjV-<;9Y%JgysM87BP_u)ioKIrgryI!*gO-pY)FQAZe5*^b7-d|ZdT6G3xI@5wj*s#pq}kE% zX??Q|U-gpd5Ikd2Xh-RQVUP~*q1l0R*jmI+y-F7A3nAWt(TRM=cd;SU?Idba-&Jo0 z6tYR;tEJR>j&fLIowbrT3H_=&B_ZtU!cmW(Yqp3uu97wQU;saemt>hd~up=&VVAM zG)4A1QO8hKg7jGQ*EweW&BcQUBa;R{Z#{ghF%hh9kIyxzyi4e(Z^B@Eg;MgP+*BD= zV>Dvk$l@L{?zWbvmIFepP5s}vS@PugVwPv_b|z2Kq?Mx|9_L<|)m7qH+c_PZut{1- z?C#|7_xZwv-XUANPh{t4)q87Ha!8maeOt5d`ETeKyZ}9NJNz&M7w`%;_#ph$YBpDC zZo!RS_P(P?^Y1sAKTjDa*0G0*eXc3p-08bLdvh&>@iaI>RL0&}|EfU$=i2^r{a|@G zVG;4<(LqgPM1+ce_AEK=x^!>xhnlSoEcQ7q@}1x!AHQE%8;64*ia-1;)7kg}V~kY_ z&MG30p7X_K+fcK<(rXGkbWM9rrd^mw^<I=t3bujv| zFUOIGlDO4=InP4#^(#Z%+awZVp~%;fgQ~(`G~SPYiU>|OIUR!2*)AUxT-%OOa6Sn! zmKVyg351-TK;5JMjtv(W5^t)78h~$&XW;7#RukEVgY8?|HNeXhSkPbsn?vVh`g6wI zvS`*u+gZ4L(p=`2fXtjz+GWQ0Lfs^_mz(@HMeZ%jF`HbWjox;F$FpCc^yd|1u_0s< zs5es&-+Y6%tZ{iDA7u3-ouX;TQfb$s_(@I573fFY!A3wk)Soe*KkTbf;IUR(;-Q{u zZOr^EmcH{B)j^0^P`FESTVW{o-$mFU7AN8fq7_HH-s(GnE3b^` z&yiIU6YO8yenu+plTKYZZ@eOmN8JIR(h(nCeTRCm10cCuDlhX)F!9v18@?bti&V$` z`fLY}VTr!*4EG|ftd3<_ZrbzGX)l1-<|S#6cn{T`G0k}60u7xo8};x?o^Eh?KQUh} zW$0aD4F%yG38&yV#%DJr=Q=m+nJct|*$2sv7?(+RI>4KjnQ-kY-;1ySjrb`r0;P+l z@(l7Rt4==A#D4KXf$&Aw=%MN$UMsUn&F73$gw8T%f!hX2@?a;b+V~3$#ZUy>=EM3u zivcn@kXNjasj;u8JcCi2<__<%0=QVYsW)+8s2g>K8?A{~+PdJ|`~BMb2NA|xh!618 z@Ax5M8;<)6qE=6ho^wq;-MD%*QbN8(Zn|_RlK;*A)9OKglWv9kLKQIqqZBVK_;Oy2 zvXyiNxSp(U{k@JZFNd=3f?X)5-c2|%IW2$d6Nycg#HzAH-ixBTtq)t5LHS#-S+|;xdWw+B=un!L=@p?>6B!qCQjA-)e}eyqmoZyqDvm! z;g80uL@FodNZp06G*JbM$B}krV}?#My+7B=H~4Q!3oVinQm~Ze3e;UhON-$n6PE@s ze2O?PVQu3rF;bDWk%0W$`A!@jfILO?EZ3goK&|@d)WF*Z_9xqbn$+*de7f2CE-Lg{ z;=0v25~b*vaJn$FO9^4smS^WY&e62U*4&5?q|TS^SoU_EnN?P-IB)Paf4(L*MnJRM zar*XbF(J*u`T*J_b7>rJig87f_nARQJ7as#`<0v@PJTt^ZpdOv2M-AF6cu zFfmUNZ(G})2LG1kT8jOH?#P|zfK5qly7=DbgYrV7aqhA~9SB%zIt-7HHVDFoFdsQUZO z*Wc$iE!Rjsb32tZ5!Mr4Broy_|HE`bLXY>EFD?DPBW5Sv{@SAXiwprKx8wSi zE^u*fTu1Ca{&n#7jIoF{qvOz8@EdpLDLtFbOfg&-@`)) zKls6M8EIvda4d=M_Ef10$BQ^H^`9-tq!iNL;ffs!{@EGc#w6c|PeYp2&`2gBrzGBF z$8%5q{wFC-){4;TS*o7sZ1GfHk$e_hPhQ{0D*l#YfPK~#cKHnMhbu1y>-8R8u((&7 z&Rfc8%YC1M;T#khq#aI^_YLRAyTem%JfBJAc*$~eA*2dMhXbFvO^7<&;+m2MzMVb_};PJwAG}{)}uW-(2bF1MtXfkID1nsYwfxlT66GXjK zfdI2N)p6b{QJR`HnBIA zzOEB@kkS=v&q4X}qU4ofGcN&?y6_aU2%LQsuaEr)j_DV7jxW*d7ukAE@)jAG+Vr$B zon7W&UU)wjKX7|O&{Z;PXS$AjTV4JxV-dAw)AdAwH_bw3UwzF}R8bmCj?IjE!*9l`r{?#w(hBQZaene5P^a;xKvAJgDww*H z5ToxCU76CQ_Ew84q4}aCU&nwU}ye}fgHu0Ne#%!t)5Y{nDZR<2LMNbAtBSD~& z8_&MhM@-PsH0YnyYH?gT$v3QNo;g)toOe#>+~2qkKPzs3cq`F8W$ijY0@BwefYB^b zVr|3y6eoU*zf00Be%wf%p#y*WT?NPVKI8`KnALW6X53UdyLvk5^8^5aXMWb8Bb5@} zhHfh)^z=E6`=8={5b@gdZG%UB{q^-v)AdcA&sIkGM2_^U9jHhwe#9k>6qiNxSbV2q zx48XwH8!s6gJl0z&vi+&Y|5yV(ec^aXZwzdYJAO_SS~8{xgOasnzTL^%B_XS$@~gQ z4t*+gw74PFBjp52lyOu$ytOQ0UdIs8FPwjOMb`rHO)fbV?>vuxMBa){w=>pZv|T!T zyP6ezg>CDYeotfQ#Rpt^orC&MmeOS_^D8%n;>IV)&h`gEj5$5sPy7N@C?}5B62DIN z*BYMK60+KHeJsm1g6!rPC3#bwZ8#9g;y z$gb#`VZGXswB+_GmfrB6JXx01vD#V87os1spfs+K?ew}Eqmztngjv@@y&VaeY++AQ zL^(ahcMp66`Mo;#d``mv&kqo$aCx-gRv$Nt6VZ!=*LOQ`Y%B+gG=D7Q5DTY_ro2)* zQ3*~x91r%>&V~_-+KQvaD6x!R+wCNE(L2ZTSNZMo&B81=G|6j<=QE46NQxk$*~KF( z&G(|DT{oIY3s2P_|EY3KCU1Q33ZO!anu1VJd<)%$Bnjs7*bFt$;x^)Unbi`f2p*Nn-7Y(mM ze1MjjuBH6~_1@ii)5a=LOO<1JwImN&jY6Sz+8kD=^qiScY5d0VyL zJ73l|TB-QYRpWs_?x2$AGS`J1O6e51=r*8S>%ObIxwaZ)0gxHi6OApsgUI|r>awCQ zqkFvj+|f2rr$>!a6QhA&6Exl*jxFXC2P*cYmi&OahV4(ngE#R``h;)4-VS7W6!V&T zR3&X%&%M~L*f)b6mr7$WsZ1O0Et_xXZf~i0r%fj5Tf^xpf1q4SG%(gh;@x^k-^D#{ zfZGX&@NJ~lL36y;1|kQmQFsAvJzC>%YAEVfQYZV$q;i+r6PfY`?%3)kQk<0L-}*uk zL8XZo!H6G~-Iy{Iq5jk3eI;$7J>9ZR)OQTr`Vh4{PCPFDnCe~iO*d-aMZq2_a2UZmJ5fFm`PcU_-w*= zLOd(r_}?gsAusHM!TUONmO1#UY{ALtymaO$G!X!q*Uf5Z({k;$%nf?{mwqn0II$Q^ zEzC+i3wWmWzHdr#9mC1!-?O*YqDP}4e`WIOmP)h&#<9DxAYEBOnD=qFTSW!AZvQ@H zRrHW3P2kl;iXd_gIj}{jeqeGbX8r(sBhzG0-ZF)RNj-gyfDr>QAM!fbomTka&2 z9JBPy+|+X>%C2F;)Yb<@VGDTvgCUM{FvY{46a3iUHQZfJ1NbAE8Mm!Y|EePk0iB*@iwScR z7cXgc0KvhDw^zx!zLAozS)?fK?Oy%S)Twu$`>cKV%2QX*@VkXEZja~WM>~ZR++Htc zM#-9;tY81-+Bef#*{U*Fn(312QUfS6yv$uf`ZITRRxPK3J9Ey$d_ek_cPSe>q$CdUhoyzDPRJ>~brB|&x&MSV`xKI0eo>z$ln(WDVF z?{>!NE?5XgDOYLT8P9gB?jWqAl~@|^9QRB$Hvr1kkK}}qVe`v8I7edKgB-W>Q|+qG zF_tQ~{X_TP0tARjgG} zTJqwZV>_R0dBwM9yXkM&u3q9h-{mZ9$=q{DO|i|dDIT5B6QtL|T0l|VZH9iw4JsI)s zu;K4nNLU)(B*G3+xV%|CvD`liW#>G)xSh6odE0nDD007Z%(6n64}W{Td}223Gx_Z8 zJ}s`FCw?(6^O2B$kQaUa@^i=!3KD5qZ1lAqoK7W+Aag<;CEPma%Ksj7mFJ&% zglh1QvubwnA$Rgrb*TrSEG=cnx7$~5TB~4>H+Ful5xk41xf&>>LZ1Q)N`D3|-Wx`` zjO0DV^BMcPOg7sgg%0CH`Z>5F4XJKIaKb#2q7*wI^T5cNbx;idPmdG9)!vFBop??9 zj#O-*30f8$#MbsBT+IRZO%^<&H9)1Hf99 z7n#~8D6)5yU?!*X?yC}79Oh^~3i78S+dW{&cZL5fcZ&M9I3uL}xlD5EnR(hIrmD^w z55s!8U;4beoHnbOj3XR*x4pRJ9aONlN$f&W(I^lEzF;LZ zx2aM#v5;(U#|f~ax8_Rb8sEOTG05DvA{a%dCZ3scNv2abH|ZDXr^Wayu$8uyugMCj z)R=eT6FSnsOPw=}UsW+IpHiiru7{j!)FZx}w1{?Y5%bxBo9S#?KJP1UDioEdnF`slIF98 zLg%4W2I)7wqt4D#qxIMQpSnHHbvEeMwC0NU$vzoZR!aL!hKFIVB*Xl$q)v%eI_9d2 z?Yf9|dwSZ1*}!qLqnYv5OCQsF1*RlKlObiQr#d*nlZD=N*X?@mP23Lw!uro5c<3Lw zy;jGDr=CFcbL5=v zz4FG<=Nx(>2B+#3Cax%ngSo`k{h5-iD@~R&W|Z}kUG0z zP8Zya=YAIBn=LUAKZ`5m&rji{6U6McV4r48UrML%S08iaRgZ^4so|UGAXD@z@LVFG z;u@UETs1P;x*X9FT1%R-I$^rM@&di^2E27fiSblR)VN*$LV5BfsTR(V`tbLcvA z*09f!A^ssc--CdOu}l4)K&Z^^p$Qa+J}cT14(@G;$|Qep!GBb+%u9os496i=&klDO zi)12+D^2_cl~HId9Y9wx9POdPb9*4vPSYClD?kSD!e~Ws19As;5i&+~hq+G`BZHfHtZ_1YQ1Cqj zIc_5~pVCFWGPK%U&~AyKTYyxX%XtM&_<^J8h0fm1s~-sA0gSQa<{{f*A#(9x+G;QWttV^n|l?FLr6Z@hnX+$ zZhhwAN6PX?8$#IyWm#^qnJb;-y zWw0jMk$!l}pn{n5A6ODG!B%FX<-QEjI7AYyX7HiRmJ0(byUoF1nbm8F5l<8ha4s+1 zI&0z^{7LoFT}pmA6tqdjry+tS)$VBL>H>3|u{&g;Y+xjiD&((47cz*o0c#|$Mm+iD zWYtjXg~4kXmtdevifgHS2E02?;yeR==+Jd{Ioz%|ZXz&zDNMfk!xQd@v4#}8Zr z?jw^WmE-I~`(0-DYDYahXm9TI`Wp&GV7*mw{D>OQKmpTq-+UjbU`i5k-f<)GL(RKe z&t5jekeXqhhe{(ha{FaGBsX|~Nziwx_RkVKcfi%Y1xdhG9Y2K2(wkJc8mn=M6&>P* zY1d8}NaN#E_q?yHzX1%yuv%%y!F0;L-w*(kdSjOA@9l)aKS>dC@7}!y)!#4)_C=iH zKyZ1jwYT&Nhkg$_e_INa|H0U@3dwRt4WH-49F$Mg3TFc4-a*Gd(3!-5pDk{*Gn6hhu_H_!5QrfpEO5c_c=WvPpApL<$QR`#&i4$Z+Ojuv>_0B z2{f-csoE3j9q{<-KW4^?8%p1}b46Du-i(X}r1ZznS(`=br#lhXvVj_SsHdnj;cGMN zo0PpXCPs*?I@oxJQz#V`29EiZnqh>??! z+LsAGMKZKlMKhIP|5l%YY&4aeleH3hrIPSIUkw;ujKE5%1W#@b3uxLFG2J)vQHQ5< zC8Ygt77^ulFQ7lPASUGM2Rh*?g#|;yhd`@p0?Nbtnh{52=xU~XPw4U@vd^FZNLtX@ zfirx-oNPPQ?2N!)^)iC+3F6_JcuVcXA(~q7TldWv6R%T28!@2}FP2RRC;liS^w``} zga$Q27Xn1?COD+duD^pcwzS1}(lM_RVR4Vphrs-x5z(`Ky$&?h4jH>3zi*(D@h)Ya z2O`ck7}@9=ov(Zc))TK0CqDPPj+r$Ww~K)Nq@vWKkkbc^Y7_|sc|O8As{O+{Lh$FC z57wTu*;8pEL4+@JwFNpgNs3dt7lBpCwNJ{da~qdLk`cYG5-I1V62wdrA^!XSLU z+mqyx0q-lRs*MY8cqN`%MOXaHy5wATW&Wzqd3X)F_)C+~nx3bsbbFL*@-ka`Z=QH1Fh|0*c)&t?hFB1c=amA#!@JRmWq&APXnuqhEbnh*;efz ze$k&vtp=lQr#sLqGhU0=siBQHP)Op-EjXu8N8e!xTdsmXvErw;l0TFEg0ggv>1K*HZE;p^gvk9Is_ZFR2FBO_~VZArLe`9uNi}HX}bVE?0cM zaA;>VkX=H0Rc+uVBp0eQU{H312XK!I1l04PK^6*ph2p1BQmuO1>f0g84IW5aJ$DW%5C$ zz=?HSfQRj4+BxMcERAA?+~!8-kjN`2cdOKcFN1Ds4#w$}{rt!Pd_UL%29~x(dBMgt zu%jko{_sYB|N49DGYCub`~Kz{l1Mi$ zcuwfTTP5JJE?*F?<-@;6`bSC;cC(MYeBb92%>$8yh?)8-Nv}zowS)r~XXiO}2CL<^ z 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 LC;OZgJC6Mif-$Vq literal 0 HcmV?d00001 diff --git a/pydantic-agent/shipping-label-agent/chat_proto.py b/pydantic-agent/shipping-label-agent/chat_proto.py new file mode 100644 index 00000000..d504f52e --- /dev/null +++ b/pydantic-agent/shipping-label-agent/chat_proto.py @@ -0,0 +1,1897 @@ +from __future__ import annotations + +import asyncio +import json +import os +import re +from datetime import UTC, datetime +from typing import Any +from uuid import uuid4 + +from uagents import Context, Protocol +from uagents_core.contrib.protocols.chat import ( + ChatAcknowledgement, + ChatMessage, + MetadataContent, + TextContent, + chat_protocol_spec, +) + +import payment +from pydantic_agent import ( + PackageDetails, + SenderProfile, + ShippingDeps, + extract_package, + extract_sender_profile, + resume_purchase, + start_purchase, +) +from session_state import ( + AWAITING_HAZMAT_CHECK, + AWAITING_INSURANCE_CHECK, + AWAITING_LABEL_PAYMENT, + AWAITING_PACKAGE, + AWAITING_PACKAGE_CONFIRM, + AWAITING_PICKUP, + AWAITING_PURCHASE_APPROVAL, + AWAITING_SENDER, + AWAITING_SENDER_CONFIRM, + DONE, + SHOWING_DETAIL, + SHOWING_RATES, + UNINITIALIZED, + check_new_window_and_reset, + get_state, + save_state, +) +from shipping import ( + FIXED_SENDER_NAME, + MAX_INSURABLE_VALUE_USD, + UPS_HAZMAT_GUIDE_URL, + UPS_PROHIBITED_URL, + USPS_HAZMAT_GUIDE_URL, + USPS_PROHIBITED_URL, + Address, + Parcel, + PurchaseResult, + RateOption, + ShippoClient, + ShippoError, + address_diff, + free_included_coverage_usd, + insurance_premium_usd, + is_domestic_country, +) + +chat_proto = Protocol(spec=chat_protocol_spec) + +CARD_PROTOCOL_VERSION = "1" +_PAID_WORDS = {"paid", "done", "i've paid", "ive paid", "payment done", "continue"} +_RATES_SHOWN_DEFAULT = 3 + + +def sender_address_from_profile(profile: dict[str, Any]) -> Address: + """Build the ship-from :class:`Address` from the user's captured profile. + + The sender is now a genuine intake step (see :func:`sender_profile_form_card` + and :func:`_handle_sender_stage`) kept entirely separate from the recipient + form, so it can never end up being whatever the user typed as the + recipient's name. + """ + return Address( + name=str(profile["from_name"]), + street1=str(profile["from_street1"]), + city=str(profile["from_city"]), + state=str(profile["from_state"]), + zip=str(profile["from_zip"]), + country=str(profile.get("from_country", "US")), + phone=str(profile.get("from_phone", "")), + email=str(profile.get("from_email", "")), + ) + + +def sender_profile_summary(profile: dict[str, Any]) -> str: + """One-line confirmation of the ship-from address the user just entered.""" + addr = sender_address_from_profile(profile) + return f"Shipping from: {addr.name}, {addr.street1}, {addr.city}, {addr.state} {addr.zip}." + + +def shippo_client() -> ShippoClient: + return ShippoClient(os.environ["SHIPPO_TOKEN"]) + + +def _wrap(card_kind: str, payload: dict[str, Any], *, is_terminal: bool = False) -> dict[str, str]: + meta: dict[str, str] = { + "card_protocol_version": CARD_PROTOCOL_VERSION, + "requires_card_interaction": "true", + "card_kind": card_kind, + "card_payload": json.dumps(payload), + } + if is_terminal: + meta["is_terminal"] = "true" + return meta + + +async def send_card(ctx: Context, sender: str, narration: str, card: dict[str, str]) -> None: + content: list[Any] = [] + if narration: + content.append(TextContent(type="text", text=narration)) + content.append(MetadataContent(type="metadata", metadata=card)) + await ctx.send( + sender, + ChatMessage(timestamp=datetime.now(UTC), msg_id=uuid4(), content=content), + ) + + +async def send_text(ctx: Context, sender: str, text: str) -> None: + await ctx.send( + sender, + ChatMessage( + timestamp=datetime.now(UTC), + msg_id=uuid4(), + content=[TextContent(type="text", text=text)], + ), + ) + + +# Carrier service names come back from Shippo with trademark glyphs (e.g. "UPS +# 2nd Day Air®"); stripped everywhere a rate is rendered to the user. +_TRADEMARK_RE = re.compile(r"[®™\u00ae\u2122]") + + +def _clean_name(text: str) -> str: + return re.sub(r"\s+", " ", _TRADEMARK_RE.sub("", text or "")).strip() + + +def _carrier_label(rate: RateOption) -> str: + return _clean_name(f"{rate.provider} {rate.servicelevel_name}") + + +def _money(amount: float, currency: str = "USD") -> str: + if (currency or "USD").upper() == "USD": + return f"${amount:.2f}" + return f"{amount:.2f} {currency}" + + +def _eta_phrase(rate: RateOption) -> str: + days = rate.estimated_days + if days is None: + return "delivery estimate not provided" + if days <= 1: + return "about 1 business day" + return f"about {days} business days" + + +_BADGE_PHRASE = {"Recommended": "my recommendation", "Cheapest": "cheapest", "Fastest": "fastest"} + + +def _badges_phrase(labels: list[str]) -> str: + """Join badge labels into natural prose (no brackets), e.g. 'cheapest and fastest'.""" + phrases = [_BADGE_PHRASE.get(label, label.lower()) for label in labels] + if not phrases: + return "" + if len(phrases) == 1: + return phrases[0] + return ", ".join(phrases[:-1]) + " and " + phrases[-1] + + +# Non-US country signals for the "fail fast on international" check. Kept +# conservative (explicit country names / clearly non-US postal formats) so a +# normal US address is never misflagged. +_INTL_COUNTRY_RE = re.compile( + r"\b(canada|canadian|mexico|mexican|united\s+kingdom|england|scotland|wales|" + r"ireland|france|germany|spain|italy|netherlands|belgium|australia|" + r"new\s+zealand|japan|china|india|brazil|switzerland|sweden|norway|denmark|" + r"austria|portugal|poland|singapore|hong\s*kong|south\s+korea|korea|" + r"philippines|vietnam|thailand|indonesia|malaysia|uae|dubai)\b", + re.IGNORECASE, +) +# Canadian postal code (A1A 1A1) and UK-style outward codes — strong non-US signals. +_INTL_POSTAL_RE = re.compile( + r"\b([A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d)\b|\b([A-Za-z]{1,2}\d[A-Za-z\d]?\s?\d[A-Za-z]{2})\b" +) + + +def _looks_international_text(text: str) -> bool: + """Heuristic: does this free-text address look non-US?""" + if not text: + return False + if _INTL_COUNTRY_RE.search(text): + return True + return bool(_INTL_POSTAL_RE.search(text)) + + +def _looks_international_selection(selection: dict[str, Any]) -> bool: + """Structured (form) variant: a non-US country, or a ZIP containing letters + (US ZIPs are all digits; Canadian/UK postal codes are the giveaway).""" + country = str(selection.get("to_country") or selection.get("from_country") or "US") + if not is_domestic_country(country): + return True + zip_code = str(selection.get("to_zip") or selection.get("from_zip") or "") + return bool(zip_code) and bool(re.search(r"[A-Za-z]", zip_code)) + + +_INTL_MESSAGE = ( + "This demo ships US domestic only - it doesn't support international destinations, " + "customs forms, or duties. Please enter a US shipping address to continue." +) + + +def _missing_sender_contact_message(profile: SenderProfile) -> str | None: + """Catch a blank sender email/phone before Shippo does. + + Shippo's own troubleshooting docs: "Couldn't buy label. Sender info + missing email or phone. Sender email and phone number required for + USPS." That check only runs at label-*purchase* time - well after + address validation, rate shopping, and (in this flow) a second Stripe + charge have all already succeeded - so this catches it immediately + after intake instead of after the user has paid twice. + """ + missing = [ + label + for label, value in (("email", profile.from_email), ("phone", profile.from_phone)) + if not (value or "").strip() + ] + if not missing: + return None + fields = " and ".join(missing) + return ( + f"USPS requires a sender {fields} to generate the label (it's not printed on the " + f"label - carriers use it for internal notifications only). Please re-enter your " + f"ship-from details including your {fields}." + ) + + +def _preflight_selection_error(selection: dict[str, Any]) -> str | None: + """Cheap sanity checks on the intake form's numbers before calling Shippo. + + Only runs on the form (structured) path - the prose path goes through + Pydantic AI extraction, whose schema already enforces ``gt=0``. Returns a + user-facing message, or ``None`` when the numbers look fine. + """ + numeric = ("weight_lb", "length_in", "width_in", "height_in") + if not any(k in selection for k in numeric): + return None + try: + dims = {k: float(selection.get(k, 0) or 0) for k in numeric} + declared = float(selection.get("declared_value_usd", 0) or 0) + except (TypeError, ValueError): + return "Weight and dimensions need to be numbers - please re-enter them." + if [k for k, v in dims.items() if v <= 0]: + return "Weight and every dimension must be greater than zero - please re-enter them." + if declared < 0: + return "Declared value can't be negative - please re-enter it." + if declared > MAX_INSURABLE_VALUE_USD: + return ( + f"That declared value looks unusually high. This demo caps it at " + f"{_money(MAX_INSURABLE_VALUE_USD)} (the most XCover will insure) - " + "please re-enter a lower value." + ) + return None + + +# There is intentionally no payment card here. The payment gate uses the +# native uagents_core payment protocol (see :mod:`payment`) so ASI:One renders +# its own "Pay with Stripe / Reject" sheet, exactly like the quiz-agent. + + +def sender_profile_form_card() -> dict[str, str]: + # The documented `form` schema is exactly {title, fields, submit_cta} - unlike + # `carousel`, it has no top-level "subtitle" key. A mismatched payload gets + # silently rejected by ASI:One and falls back to plain text, so any framing + # copy belongs in the narration text passed by callers, not here. + payload = { + "title": "Your shipping profile", + "fields": [ + { + "name": "from_name", + "kind": "text", + "label": "Your name", + "required": True, + "placeholder": FIXED_SENDER_NAME, + }, + { + "name": "from_street1", + "kind": "text", + "label": "Street address", + "required": True, + "placeholder": "215 Clayton St.", + }, + { + "name": "from_city", + "kind": "text", + "label": "City", + "required": True, + "placeholder": "San Francisco", + }, + { + "name": "from_state", + "kind": "text", + "label": "State (2-letter)", + "required": True, + "placeholder": "CA", + }, + { + "name": "from_zip", + "kind": "text", + "label": "ZIP code", + "required": True, + "placeholder": "94117", + }, + # USPS (and some other carriers) reject the label *purchase* call - not + # the earlier rate-shop call - if the sender's phone/email are blank, + # per Shippo's own troubleshooting doc ("Sender email and phone number + # required for USPS"). That only surfaces after the second payment has + # already cleared, so both are required here instead of optional. + { + "name": "from_phone", + "kind": "text", + "label": "Phone", + "required": True, + "placeholder": "+1 555 341 9393", + }, + { + "name": "from_email", + "kind": "text", + "label": "Email", + "required": True, + "placeholder": "shawn@example.com", + }, + ], + "submit_cta": {"label": "Continue", "selection": {"action": "submit_sender"}}, + } + return _wrap("form", payload) + + +def package_form_card() -> dict[str, str]: + payload = { + "title": "Package details", + "fields": [ + {"name": "to_name", "kind": "text", "label": "Recipient name", "required": True}, + {"name": "to_street1", "kind": "text", "label": "Street address", "required": True}, + {"name": "to_city", "kind": "text", "label": "City", "required": True}, + {"name": "to_state", "kind": "text", "label": "State (2-letter)", "required": True}, + {"name": "to_zip", "kind": "text", "label": "ZIP code", "required": True}, + {"name": "weight_lb", "kind": "number", "label": "Weight (lb)", "required": True}, + {"name": "length_in", "kind": "number", "label": "Length (in)", "required": True}, + {"name": "width_in", "kind": "number", "label": "Width (in)", "required": True}, + {"name": "height_in", "kind": "number", "label": "Height (in)", "required": True}, + { + "name": "declared_value_usd", + "kind": "number", + "label": "Declared value (USD)", + "required": False, + "placeholder": "0", + }, + ], + "submit_cta": {"label": "Get rates", "selection": {"action": "submit_package"}}, + } + return _wrap("form", payload) + + +def _format_address_line(addr: Address) -> str: + return f"{addr.street1}, {addr.city}, {addr.state} {addr.zip}".strip(", ") + + +def _correction_hint(submitted: Address, corrected: Address | None) -> str: + """A short ' Did you mean: ...?' suffix when Shippo suggests a different + address than what was typed - used on the *invalid* path, where we still + make the user re-enter the form but want to point them at the likely fix + (e.g. a street Shippo couldn't match at all, per its own docs example). + """ + if not corrected or not address_diff(submitted, corrected): + return "" + return f" Did you mean: {_format_address_line(corrected)}?" + + +def address_correction_card(submitted: Address, corrected: Address) -> dict[str, str]: + """A ``review`` card offering Shippo's suggested correction vs. what the + user typed - covers the "valid, but probably not what you meant" case + (e.g. a city that doesn't match its own ZIP) that address validation + alone doesn't catch, since Shippo's own validator considers it + deliverable either way. + """ + payload = { + "title": "Double-check this address", + "summary_rows": [ + {"label": "You entered", "value": _format_address_line(submitted)}, + {"label": "Suggested (USPS-verified)", "value": _format_address_line(corrected)}, + ], + "approve_cta": { + "label": "Use suggested address", + "primary": True, + "selection": {"action": "use_suggested_address"}, + }, + "reject_cta": { + "label": "Keep as I typed it", + "selection": {"action": "keep_typed_address"}, + }, + } + return _wrap("review", payload) + + +_BADGE_VARIANT = {"Recommended": "success", "Cheapest": "success", "Fastest": "info"} + + +def _rate_badge_labels(rate: RateOption, tags: dict[str, str]) -> list[str]: + """Which of cheapest/fastest/recommended apply to this rate, as plain labels.""" + labels: list[str] = [] + if tags.get("recommended") == rate.rate_id: + labels.append("Recommended") + if tags.get("cheapest") == rate.rate_id: + labels.append("Cheapest") + if tags.get("fastest") == rate.rate_id: + labels.append("Fastest") + return labels + + +def _rate_badges_cta(rate: RateOption, tags: dict[str, str]) -> list[dict[str, str]]: + """Badges in the shape the carousel schema expects: ``{label, variant}``.""" + return [ + {"label": label, "variant": _BADGE_VARIANT.get(label, "info")} + for label in _rate_badge_labels(rate, tags) + ] + + +def _curate_rates( + rates: list[RateOption], tags: dict[str, str], *, limit: int = _RATES_SHOWN_DEFAULT +) -> list[RateOption]: + """The short, curated set to show by default: recommended, cheapest, fastest. + + Deduplicated (a rate can be more than one of these) and capped at ``limit``. + The user can always ask to "show all" to see every option Shippo returned. + """ + by_id = {r.rate_id: r for r in rates} + picked_ids: list[str] = [] + for key in ("recommended", "cheapest", "fastest"): + rid = tags.get(key) + if rid and rid not in picked_ids and rid in by_id: + picked_ids.append(rid) + curated = [by_id[rid] for rid in picked_ids] + return curated[:limit] if limit else curated + + +def rates_carousel( + rates: list[RateOption], tags: dict[str, str], *, total: int | None = None +) -> dict[str, str]: + """Build the rate-shopping carousel. + + ``total`` is the full count of rates Shippo returned. When it's larger than + ``len(rates)`` (i.e. this is the curated subset), a real tappable "See all" + tile is appended so the full list is discoverable without needing to type + anything, instead of only being reachable via a text hint. + """ + items: list[dict[str, Any]] = [] + for r in rates: + item: dict[str, Any] = { + "id": r.rate_id, + "title": _carrier_label(r), + "subtitle": _eta_phrase(r).capitalize(), + "secondary_text": _money(r.amount, r.currency), + "primary_cta": { + "label": f"Buy for {_money(r.amount, r.currency)}", + "selection": {"action": "select_rate", "rate_id": r.rate_id}, + }, + } + badges = _rate_badges_cta(r, tags) + if badges: + item["badges"] = badges + items.append(item) + if total is not None and total > len(rates): + items.append( + { + "id": "show_all_rates", + "title": f"See all {total} options", + "subtitle": f"{total - len(rates)} more not shown here", + "primary_cta": { + "label": "Show all", + "selection": {"action": "show_all_rates"}, + }, + } + ) + return _wrap( + "carousel", + { + "title": "Choose a shipping option", + "subtitle": "Test-mode rates from Shippo", + "items": items, + }, + ) + + +def rates_text_summary(shown: list[RateOption], tags: dict[str, str], *, total: int) -> str: + """Guaranteed-visible plain-text narration alongside the carousel card. + + Because ASI:One silently degrades to plain text on any card-schema mismatch, + this narration is a complete, self-contained rate comparison the user can act + on even if the carousel never renders. + """ + curated = len(shown) < total + if curated: + header = f"I found {total} shipping options. Here are the {len(shown)} that stand out:" + elif total == 1: + header = "I found one shipping option for this package:" + else: + header = f"Here are all {total} shipping options:" + lines = [header, ""] + for i, r in enumerate(shown, start=1): + line = f"{i}. {_carrier_label(r)}: {_money(r.amount, r.currency)}, {_eta_phrase(r)}" + badges = _badges_phrase(_rate_badge_labels(r, tags)) + if badges: + line += f" — {badges}" + lines.append(line) + lines.append("") + if curated: + remaining = total - len(shown) + lines.append( + f"Tap a card to choose one, or reply with a number or carrier name. " + f"Want the other {remaining}? Just say 'show all'." + ) + else: + lines.append("Tap a card to choose one, or reply with a number or carrier name.") + return "\n".join(lines) + + +def rate_detail_card(rate: RateOption) -> dict[str, str]: + summary_rows = [ + {"label": "Carrier", "value": rate.provider}, + { + "label": "Service", + "value": _clean_name(rate.servicelevel_name or rate.servicelevel_token), + }, + {"label": "Price", "value": _money(rate.amount, rate.currency)}, + {"label": "Estimated transit", "value": _eta_phrase(rate)}, + ] + if rate.duration_terms: + summary_rows.append({"label": "Terms", "value": rate.duration_terms}) + payload = { + "title": _carrier_label(rate), + "summary_rows": summary_rows, + "ctas": [ + { + "label": "Continue to purchase", + "primary": True, + "selection": {"action": "buy_rate", "rate_id": rate.rate_id}, + }, + {"label": "Back to options", "selection": {"action": "back_to_rates"}}, + ], + } + return _wrap("detail", payload) + + +def purchase_review_card(rate: RateOption) -> dict[str, str]: + summary_rows = [ + {"label": "Carrier", "value": _carrier_label(rate)}, + {"label": "Total", "value": _money(rate.amount, rate.currency)}, + ] + if rate.included_insurance_price > 0: + summary_rows.insert( + 1, {"label": "Insurance", "value": _money(rate.included_insurance_price, rate.currency)} + ) + payload = { + "title": "Confirm and buy label", + "summary_rows": summary_rows, + "approve_cta": { + "label": f"Confirm and pay {_money(rate.amount, rate.currency)}", + "primary": True, + "selection": {"action": "approve_purchase"}, + }, + "reject_cta": {"label": "Cancel", "selection": {"action": "deny_purchase"}}, + } + return _wrap("review", payload) + + +def _prohibited_url(provider: str) -> str: + """The carrier-specific prohibited-items page for the chosen provider.""" + return UPS_PROHIBITED_URL if (provider or "").strip().upper() == "UPS" else USPS_PROHIBITED_URL + + +def hazmat_review_card(provider: str) -> dict[str, str]: + """Self-certification gate shown before purchase. + + The buttons state the *outcome* ("Nothing hazardous, continue" vs. "This + needs special handling") rather than a raw yes/no, since it's easy to + misread whether "yes" means "yes it's hazardous" or "yes, continue". + """ + payload = { + "title": "Confirm what's inside", + "summary_rows": [ + { + "label": "Prohibited items", + "value": f"Review {provider}'s restricted/prohibited list before continuing.", + }, + { + "label": "Your responsibility", + "value": "Declaring contents accurately is the shipper's responsibility.", + }, + ], + "approve_cta": { + "label": "Nothing hazardous, continue", + "primary": True, + "selection": {"action": "hazmat_clear"}, + }, + "reject_cta": { + "label": "This needs special handling", + "selection": {"action": "hazmat_stop"}, + }, + } + return _wrap("review", payload) + + +def insurance_review_card( + rate: RateOption, *, free_coverage: float, declared_value: float, premium: float +) -> dict[str, str]: + """Optional-insurance offer, shown only when declared value exceeds the + coverage the chosen service already includes for free.""" + payload = { + "title": "Add shipping insurance?", + "summary_rows": [ + { + "label": "Included free", + "value": f"{_money(free_coverage)} with {_carrier_label(rate)}", + }, + {"label": "Your declared value", "value": _money(declared_value)}, + { + "label": "Full coverage", + "value": f"insure {_money(declared_value)} for about {_money(premium)} more", + }, + ], + "approve_cta": { + "label": f"Add insurance for about {_money(premium)}", + "primary": True, + "selection": {"action": "add_insurance"}, + }, + "reject_cta": {"label": "Skip insurance", "selection": {"action": "skip_insurance"}}, + } + return _wrap("review", payload) + + +def pickup_form_card(provider: str) -> dict[str, str]: + payload = { + "title": f"Schedule a {provider} pickup", + "fields": [ + { + "name": "building_location_type", + "kind": "select", + "label": "Where will the parcel be?", + "required": True, + "options": [ + {"value": "Front Door", "label": "Front Door"}, + {"value": "Back Door", "label": "Back Door"}, + {"value": "Office", "label": "Office"}, + {"value": "Reception", "label": "Reception"}, + {"value": "Mail Room", "label": "Mail Room"}, + ], + }, + { + "name": "instructions", + "kind": "text", + "label": "Instructions for the courier (optional)", + "required": False, + "placeholder": "e.g. Ring the bell", + }, + ], + "submit_cta": {"label": "Schedule pickup", "selection": {"action": "schedule_pickup"}}, + } + return _wrap("form", payload) + + +def confirmation_card(purchase: PurchaseResult, rate: RateOption | None = None) -> dict[str, str]: + """The one terminal card of the flow — carries the single TEST/SAMPLE note. + + ``rate`` is the exact :class:`RateOption` the user picked earlier in this + same conversation, used to fill in the carrier when Shippo's transaction + response doesn't expand it (see ``ShippoClient.purchase``'s docstring) so + this card never shows a blank/unknown carrier. + """ + carrier = _carrier_label(rate) if rate else purchase.provider + payload = { + "title": "Label ready", + "summary_rows": [ + {"label": "Carrier", "value": carrier or "n/a"}, + {"label": "Tracking number", "value": purchase.tracking_number or "n/a"}, + {"label": "Mode", "value": "TEST / SAMPLE - not a real shipment"}, + ], + "ctas": [{"label": "Done", "selection": {"action": "done"}}], + } + return _wrap("detail", payload, is_terminal=True) + + +def _extract_text(msg: ChatMessage) -> str: + for block in msg.content: + if isinstance(block, TextContent): + text = re.sub(r"^@\S+\s+", "", (block.text or "")).strip() + text = re.sub(r"\n*!\[[^\]]*\]\(https?://[^)]+\)", "", text).strip() + return text + return "" + + +_ACTION_KEYWORDS = [ + ("show_all_rates", r"show\s*all|see\s*all|all\s*options|more\s*options|full\s*list"), + ( + "use_suggested_address", + r"use\s*(the\s*)?suggest|suggested\s*(one|address)|yes.{0,10}suggest", + ), + ( + "keep_typed_address", + ( + r"keep\s*(it\s*)?as\s*(i\s*)?typed|keep\s*(it\s*)?as\s*is|keep\s*mine|" + r"keep\s*(the\s*)?original|as\s*typed" + ), + ), + ("approve_purchase", r"\b(confirm|approve|buy|purchase)\b"), + ("deny_purchase", r"\b(cancel|deny|reject|no)\b"), + ("reject_payment", r"reject\s*payment"), + ("back_to_rates", r"back"), + ("schedule_pickup", r"pickup|pick\s*up|schedule"), + ("done", r"\b(done|finish|thanks|thank you)\b"), +] + + +def parse_selection(text: str) -> dict[str, Any]: + """Parse a card selection from JSON (direct @mention) or prose (planner).""" + stripped = (text or "").strip() + if stripped.startswith("{") and stripped.endswith("}"): + try: + data = json.loads(stripped) + if isinstance(data, dict): + return {str(k): v for k, v in data.items()} + except json.JSONDecodeError: + pass + + selection: dict[str, Any] = {} + m = re.search(r"rate[_\s-]?id[\s:=\"']*([A-Za-z0-9]+)", stripped, re.IGNORECASE) + if m: + selection["rate_id"] = m.group(1) + selection.setdefault("action", "select_rate") + low = stripped.lower() + for action, pattern in _ACTION_KEYWORDS: + if re.search(pattern, low): + selection.setdefault("action", action) + break + return selection + + +def _sender_from_selection(selection: dict[str, Any]) -> SenderProfile | None: + """Build :class:`SenderProfile` from a submitted form selection (JSON).""" + try: + return SenderProfile( + from_name=str(selection["from_name"]), + from_street1=str(selection["from_street1"]), + from_city=str(selection["from_city"]), + from_state=str(selection["from_state"]), + from_zip=str(selection["from_zip"]), + from_country=str(selection.get("from_country", "US")), + from_phone=str(selection.get("from_phone", "") or ""), + from_email=str(selection.get("from_email", "") or ""), + ) + except (KeyError, TypeError, ValueError): + return None + + +def _package_from_selection(selection: dict[str, Any]) -> PackageDetails | None: + """Build :class:`PackageDetails` from a submitted form selection (JSON).""" + try: + return PackageDetails( + to_name=str(selection["to_name"]), + to_street1=str(selection["to_street1"]), + to_city=str(selection["to_city"]), + to_state=str(selection["to_state"]), + to_zip=str(selection["to_zip"]), + to_country=str(selection.get("to_country", "US")), + weight_lb=float(selection["weight_lb"]), + length_in=float(selection["length_in"]), + width_in=float(selection["width_in"]), + height_in=float(selection["height_in"]), + declared_value_usd=float(selection.get("declared_value_usd", 0) or 0), + ) + except (KeyError, TypeError, ValueError): + return None + + +def _to_address(pkg: PackageDetails) -> Address: + return Address( + name=pkg.to_name, + street1=pkg.to_street1, + city=pkg.to_city, + state=pkg.to_state, + zip=pkg.to_zip, + country=pkg.to_country, + ) + + +def _parcel(pkg: PackageDetails) -> Parcel: + return Parcel( + length_in=pkg.length_in, + width_in=pkg.width_in, + height_in=pkg.height_in, + weight_lb=pkg.weight_lb, + ) + + +def _tag_rates(rates: list[RateOption]) -> dict[str, str]: + if not rates: + return {} + cheapest = min(rates, key=lambda r: r.amount) + with_eta = [r for r in rates if r.estimated_days is not None] + fastest = min(with_eta, key=lambda r: r.estimated_days or 0) if with_eta else cheapest + # Recommend the cheapest option that is also within a day of the fastest ETA. + if fastest.estimated_days is not None: + contenders = [ + r + for r in rates + if r.estimated_days is not None and r.estimated_days <= fastest.estimated_days + 1 + ] + recommended = min(contenders, key=lambda r: r.amount) if contenders else cheapest + else: + recommended = cheapest + return { + "cheapest": cheapest.rate_id, + "fastest": fastest.rate_id, + "recommended": recommended.rate_id, + } + + +def _find_rate(rates: list[dict[str, Any]], rate_id: str) -> RateOption | None: + for r in rates: + if r.get("rate_id") == rate_id: + return RateOption(**r) + return None + + +def _resolve_selected_rate( + rates: list[dict[str, Any]], text: str, selection: dict[str, Any] +) -> RateOption | None: + """Resolve which carousel option the user picked. + + Prefers the exact ``rate_id`` from a tapped CTA (JSON selection), then falls + back to prose the way a chat user would phrase it: a tag word + ("cheapest"/"fastest"/"recommended"), a 1-based number, or a carrier name. + """ + rate_id = selection.get("rate_id") + if rate_id: + found = _find_rate(rates, str(rate_id)) + if found: + return found + + if not rates: + return None + rate_objs = [RateOption(**r) for r in rates] + tags = _tag_rates(rate_objs) + low = (text or "").lower() + + for word, key in ( + ("cheapest", "cheapest"), + ("fastest", "fastest"), + ("recommend", "recommended"), + ): + if word in low and tags.get(key): + match = _find_rate(rates, tags[key]) + if match: + return match + + num = re.search(r"\bnumber\s+(\d+)\b|#\s*(\d+)|^\s*(\d+)\s*$", low) + if num: + digits = next((g for g in num.groups() if g), None) + if digits is not None: + idx = int(digits) - 1 + if 0 <= idx < len(rate_objs): + return rate_objs[idx] + + for r in rate_objs: + if r.provider and r.provider.lower() in low: + return r + return None + + +async def _deliver_label(ctx: Context, sender: str, purchase: PurchaseResult) -> None: + """Send the label PDF link. + + Shippo's test-mode ``label_url`` is already a fully public, directly + downloadable PDF - re-uploading it to Agentverse ExternalStorage and + attaching it as ``ResourceContent`` added an extra hop that produced a + broken, unlabeled duplicate link in the chat with no upside, so we just + link straight to it. The single TEST/SAMPLE disclaimer lives on the + confirmation card/message that follows, not here. + """ + if purchase.label_url: + await send_text(ctx, sender, f"[Download your shipping label (PDF)]({purchase.label_url})") + else: + await send_text( + ctx, + sender, + "The label was purchased, but Shippo didn't return a label URL - check your " + "Shippo dashboard for it.", + ) + + +@chat_proto.on_message(ChatMessage) +async def handle_message(ctx: Context, sender: str, msg: ChatMessage) -> None: + await ctx.send( + sender, + ChatAcknowledgement(timestamp=datetime.now(UTC), acknowledged_msg_id=msg.msg_id), + ) + try: + await _handle_inner(ctx, sender, msg) + except Exception as exc: + ctx.logger.exception("[shipping] handler crashed") + await send_text(ctx, sender, f"Something went wrong on my end ({exc}). Please try again.") + + +async def _handle_inner(ctx: Context, sender: str, msg: ChatMessage) -> None: + text = _extract_text(msg) + # New chat window -> full reset, so a new conversation always starts unpaid + # (see check_new_window_and_reset's docstring for why this is necessary). + check_new_window_and_reset(ctx, sender) + state_data = get_state(ctx, sender) + selection = parse_selection(text) + + # RULE 1: the payment gate fires on the very first message, no exceptions. + # This sends a bare native RequestPayment - no narration, no custom card - + # so ASI:One renders its own payment sheet (mirrors the quiz-agent exactly). + if state_data["state"] == UNINITIALIZED: + await payment.request_payment(ctx, sender, state_data) + return + + if not state_data.get("stripe_paid"): + if text.lower() in _PAID_WORDS: + if await payment.confirm_payment_via_text(ctx, sender): + return + await send_text( + ctx, + sender, + "Stripe still shows this as unpaid. Finish the checkout, then type 'paid' again.", + ) + return + # Any other message while unpaid -> re-issue the native payment request. + await payment.request_payment(ctx, sender, state_data) + return + + stage = state_data["state"] + if stage == AWAITING_SENDER: + await _handle_sender_stage(ctx, sender, state_data, text, selection) + elif stage == AWAITING_SENDER_CONFIRM: + await _handle_sender_confirm_stage(ctx, sender, state_data, selection) + elif stage == AWAITING_PACKAGE: + await _handle_package_stage(ctx, sender, state_data, text, selection) + elif stage == AWAITING_PACKAGE_CONFIRM: + await _handle_package_confirm_stage(ctx, sender, state_data, selection) + elif stage == SHOWING_RATES: + await _handle_rates_stage(ctx, sender, state_data, text, selection) + elif stage == SHOWING_DETAIL: + await _handle_detail_stage(ctx, sender, state_data, selection) + elif stage == AWAITING_HAZMAT_CHECK: + await _handle_hazmat_stage(ctx, sender, state_data, text, selection) + elif stage == AWAITING_INSURANCE_CHECK: + await _handle_insurance_stage(ctx, sender, state_data, text, selection) + elif stage == AWAITING_PURCHASE_APPROVAL: + await _handle_approval_stage(ctx, sender, state_data, selection) + elif stage == AWAITING_LABEL_PAYMENT: + await _handle_label_payment_stage(ctx, sender, state_data, text) + elif stage == AWAITING_PICKUP: + await _handle_pickup_stage(ctx, sender, state_data, selection) + else: # DONE or anything else -> start a new package intake within the paid session. + state_data["state"] = AWAITING_PACKAGE + save_state(ctx, sender, state_data) + await send_card( + ctx, sender, "Let's ship another package. Enter its details:", package_form_card() + ) + + +async def _handle_sender_stage( + ctx: Context, sender: str, state_data: dict[str, Any], text: str, selection: dict[str, Any] +) -> None: + if _looks_international_text(text) or _looks_international_selection(selection): + await send_card(ctx, sender, _INTL_MESSAGE, sender_profile_form_card()) + return + + profile = _sender_from_selection(selection) + if profile is None and text: + # Prose path: use Pydantic AI structured extraction, same pattern as intake. + try: + profile = await extract_sender_profile(text) + except Exception as exc: # noqa: BLE001 + ctx.logger.warning(f"[sender] extraction failed: {exc}") + profile = None + if profile is None: + await send_card( + ctx, + sender, + "I need your ship-from address to continue - please fill in the form.", + sender_profile_form_card(), + ) + return + + contact_error = _missing_sender_contact_message(profile) + if contact_error: + await send_card(ctx, sender, contact_error, sender_profile_form_card()) + return + + from_addr = sender_address_from_profile(profile.model_dump()) + try: + validation = await asyncio.to_thread(shippo_client().validate_address, from_addr) + except ShippoError as exc: + # Fail open: a Shippo outage shouldn't dead-end the whole demo. The + # user still typed a complete address; we just couldn't double-check + # it, so say so and move on rather than blocking on our own error. + ctx.logger.warning(f"[sender] address validation errored: {exc}") + await _accept_sender_profile(ctx, sender, state_data, profile) + return + + if not validation.is_valid: + problems = "; ".join(validation.messages) or "the address could not be verified" + hint = _correction_hint(from_addr, validation.corrected) + await send_card( + ctx, + sender, + f"That ship-from address needs a fix: {problems}.{hint} Please re-enter it.", + sender_profile_form_card(), + ) + return + + if validation.corrected and address_diff(from_addr, validation.corrected): + state_data["pending_sender_typed"] = profile.model_dump() + state_data["pending_sender_corrected"] = validation.corrected.model_dump() + state_data["state"] = AWAITING_SENDER_CONFIRM + save_state(ctx, sender, state_data) + await send_card( + ctx, + sender, + "That address is deliverable, but USPS has a slightly different version on " + "file - which one should the label use?", + address_correction_card(from_addr, validation.corrected), + ) + return + + await _accept_sender_profile(ctx, sender, state_data, profile) + + +async def _accept_sender_profile( + ctx: Context, sender: str, state_data: dict[str, Any], profile: SenderProfile +) -> None: + """Save the (validated, or validation-unavailable) sender profile and move + on to package intake - the one path shared by "address matched exactly" + and "Shippo's validator was unreachable" outcomes.""" + state_data["sender_profile"] = profile.model_dump() + state_data.pop("pending_sender_typed", None) + state_data.pop("pending_sender_corrected", None) + state_data["state"] = AWAITING_PACKAGE + save_state(ctx, sender, state_data) + await send_card( + ctx, + sender, + f"{sender_profile_summary(state_data['sender_profile'])} Now tell me about your package.", + package_form_card(), + ) + + +async def _handle_sender_confirm_stage( + ctx: Context, sender: str, state_data: dict[str, Any], selection: dict[str, Any] +) -> None: + """Resolve the "use suggested / keep as typed" choice from + :func:`address_correction_card` for the ship-from address.""" + typed = state_data.get("pending_sender_typed") + corrected = state_data.get("pending_sender_corrected") + if not typed: + state_data["state"] = AWAITING_SENDER + save_state(ctx, sender, state_data) + await send_card( + ctx, + sender, + "Let's start over - where are you shipping from?", + sender_profile_form_card(), + ) + return + + action = selection.get("action") + if action not in {"use_suggested_address", "keep_typed_address"}: + submitted = sender_address_from_profile(typed) + suggested = Address(**corrected) if corrected else submitted + await send_card( + ctx, + sender, + "Tap a button above, or reply 'use suggested' or 'keep as typed'.", + address_correction_card(submitted, suggested), + ) + return + + profile_dict = dict(typed) + if action == "use_suggested_address" and corrected: + profile_dict["from_street1"] = corrected.get("street1", profile_dict["from_street1"]) + profile_dict["from_city"] = corrected.get("city", profile_dict["from_city"]) + profile_dict["from_state"] = corrected.get("state", profile_dict["from_state"]) + profile_dict["from_zip"] = corrected.get("zip", profile_dict["from_zip"]) + + await _accept_sender_profile(ctx, sender, state_data, SenderProfile(**profile_dict)) + + +async def _handle_package_stage( + ctx: Context, sender: str, state_data: dict[str, Any], text: str, selection: dict[str, Any] +) -> None: + if not state_data.get("sender_profile"): + # Defensive: a session persisted before the sender-profile step existed + # (or that otherwise skipped it) must not crash on a missing sender - + # send it back to capture one instead. + state_data["state"] = AWAITING_SENDER + save_state(ctx, sender, state_data) + await send_card( + ctx, + sender, + "First, where are you shipping from?", + sender_profile_form_card(), + ) + return + + if _looks_international_text(text) or _looks_international_selection(selection): + await send_card(ctx, sender, _INTL_MESSAGE, package_form_card()) + return + + preflight = _preflight_selection_error(selection) + if preflight: + await send_card(ctx, sender, preflight, package_form_card()) + return + + pkg = _package_from_selection(selection) + if pkg is None and text: + # Prose path: use Pydantic AI structured extraction. + try: + pkg = await extract_package(text) + except Exception as exc: # noqa: BLE001 + ctx.logger.warning(f"[intake] extraction failed: {exc}") + pkg = None + if pkg is None: + await send_card( + ctx, + sender, + "I need the full package details to continue - please fill in the form.", + package_form_card(), + ) + return + + to_addr = _to_address(pkg) + try: + validation = await asyncio.to_thread(shippo_client().validate_address, to_addr) + except ShippoError as exc: + # Fail open here too - see the matching comment in _handle_sender_stage. + ctx.logger.warning(f"[intake] address validation errored: {exc}") + await _run_rate_shop(ctx, sender, state_data, pkg) + return + + if not validation.is_valid: + problems = "; ".join(validation.messages) or "the address could not be verified" + hint = _correction_hint(to_addr, validation.corrected) + await send_card( + ctx, + sender, + f"That destination address needs a fix: {problems}.{hint} Please re-enter it.", + package_form_card(), + ) + return + + if validation.corrected and address_diff(to_addr, validation.corrected): + state_data["pending_package_typed"] = pkg.model_dump() + state_data["pending_package_corrected"] = validation.corrected.model_dump() + state_data["state"] = AWAITING_PACKAGE_CONFIRM + save_state(ctx, sender, state_data) + await send_card( + ctx, + sender, + "That address is deliverable, but USPS has a slightly different version on " + "file - which one should the label use?", + address_correction_card(to_addr, validation.corrected), + ) + return + + await _run_rate_shop(ctx, sender, state_data, pkg) + + +async def _run_rate_shop( + ctx: Context, sender: str, state_data: dict[str, Any], pkg: PackageDetails +) -> None: + """Shared tail of package intake: rate-shop and show the curated carousel. + + Reached either straight from a package address that validated cleanly, or + from :func:`_handle_package_confirm_stage` once the user has picked + "suggested" or "as typed" for a corrected destination address. + """ + client = shippo_client() + from_addr = sender_address_from_profile(state_data["sender_profile"]) + to_addr = _to_address(pkg) + rates = await asyncio.to_thread(client.rate_shop, from_addr, to_addr, _parcel(pkg)) + if not rates: + state_data["state"] = AWAITING_PACKAGE + state_data.pop("pending_package_typed", None) + state_data.pop("pending_package_corrected", None) + save_state(ctx, sender, state_data) + await send_text( + ctx, + sender, + "No carrier returned a rate for this package in test mode. That usually means " + "it's over a carrier's size or weight limit (commonly around 150 lb, or too " + "large in length plus girth), or no connected test carrier serves that route. " + "Try reducing the weight or dimensions, or double-check the destination, then " + "send the form again.", + ) + return + + tags = _tag_rates(rates) + state_data["package"] = pkg.model_dump() + state_data["rates"] = [r.model_dump() for r in rates] + state_data["selected_rate_id"] = None + state_data.pop("pending_package_typed", None) + state_data.pop("pending_package_corrected", None) + state_data["state"] = SHOWING_RATES + save_state(ctx, sender, state_data) + + curated = _curate_rates(rates, tags) + # A short, heavy/oversized package legitimately gets only one or two rates; + # say why instead of showing an unexplained tiny list. + caveat = "" + if len(rates) <= 2: + caveat = ( + "Only a few carriers returned a rate for this package - larger or heavier " + "parcels are served by fewer services. Here's what's available:\n\n" + ) + await send_card( + ctx, + sender, + caveat + rates_text_summary(curated, tags, total=len(rates)), + rates_carousel(curated, tags, total=len(rates)), + ) + + +async def _handle_package_confirm_stage( + ctx: Context, sender: str, state_data: dict[str, Any], selection: dict[str, Any] +) -> None: + """Resolve the "use suggested / keep as typed" choice from + :func:`address_correction_card` for the recipient address.""" + typed = state_data.get("pending_package_typed") + corrected = state_data.get("pending_package_corrected") + if not typed: + state_data["state"] = AWAITING_PACKAGE + save_state(ctx, sender, state_data) + await send_card( + ctx, sender, "Let's try that again - the package details:", package_form_card() + ) + return + + action = selection.get("action") + if action not in {"use_suggested_address", "keep_typed_address"}: + submitted = _to_address(PackageDetails(**typed)) + suggested = Address(**corrected) if corrected else submitted + await send_card( + ctx, + sender, + "Tap a button above, or reply 'use suggested' or 'keep as typed'.", + address_correction_card(submitted, suggested), + ) + return + + pkg_dict = dict(typed) + if action == "use_suggested_address" and corrected: + pkg_dict["to_street1"] = corrected.get("street1", pkg_dict["to_street1"]) + pkg_dict["to_city"] = corrected.get("city", pkg_dict["to_city"]) + pkg_dict["to_state"] = corrected.get("state", pkg_dict["to_state"]) + pkg_dict["to_zip"] = corrected.get("zip", pkg_dict["to_zip"]) + + await _run_rate_shop(ctx, sender, state_data, PackageDetails(**pkg_dict)) + + +async def _handle_rates_stage( + ctx: Context, sender: str, state_data: dict[str, Any], text: str, selection: dict[str, Any] +) -> None: + rates_raw = state_data.get("rates", []) + if selection.get("action") == "show_all_rates": + rate_objs = [RateOption(**r) for r in rates_raw] + tags = _tag_rates(rate_objs) + await send_card( + ctx, + sender, + rates_text_summary(rate_objs, tags, total=len(rate_objs)), + rates_carousel(rate_objs, tags), + ) + return + + rate = _resolve_selected_rate(rates_raw, text, selection) + if not rate: + await send_text( + ctx, + sender, + "I couldn't tell which option you picked - tap a card above, or reply with its " + "number, the carrier name, 'cheapest', 'fastest', 'recommended', or 'show all'.", + ) + return + state_data["selected_rate_id"] = rate.rate_id + state_data["state"] = SHOWING_DETAIL + save_state(ctx, sender, state_data) + detail_lines = [ + f"{_carrier_label(rate)}: {_money(rate.amount, rate.currency)}, {_eta_phrase(rate)}.", + ] + if rate.duration_terms: + detail_lines.append(f"Terms: {rate.duration_terms}") + detail_lines.append("") + detail_lines.append("Reply 'buy' to continue to purchase, or 'back' to see the other options.") + await send_card(ctx, sender, "\n".join(detail_lines), rate_detail_card(rate)) + + +async def _handle_detail_stage( + ctx: Context, sender: str, state_data: dict[str, Any], selection: dict[str, Any] +) -> None: + action = selection.get("action") + rates = state_data.get("rates", []) + if action == "back_to_rates": + state_data["state"] = SHOWING_RATES + save_state(ctx, sender, state_data) + rate_objs = [RateOption(**r) for r in rates] + rate_tags = _tag_rates(rate_objs) + curated = _curate_rates(rate_objs, rate_tags) + await send_card( + ctx, + sender, + rates_text_summary(curated, rate_tags, total=len(rate_objs)), + rates_carousel(curated, rate_tags, total=len(rate_objs)), + ) + return + + rate_id = selection.get("rate_id") or state_data.get("selected_rate_id") + rate = _find_rate(rates, str(rate_id)) if rate_id else None + if not rate: + await send_text(ctx, sender, "Pick an option first, then continue to purchase.") + return + + # Before purchase: self-certification of contents (hazmat), then optional + # insurance. Both fire between "Continue to purchase" and the approval + # gate, per shipment (contents/value are per-package, not a session setting). + state_data["selected_rate_id"] = rate.rate_id + state_data["state"] = AWAITING_HAZMAT_CHECK + save_state(ctx, sender, state_data) + await _send_hazmat_gate(ctx, sender, rate) + + +async def _send_hazmat_gate(ctx: Context, sender: str, rate: RateOption) -> None: + prohibited = _prohibited_url(rate.provider) + narration = ( + f"Before I buy this label, please confirm what's inside. Prohibited and " + f"restricted items can't be shipped - here is {rate.provider}'s list: {prohibited}\n\n" + "Declaring the contents accurately is the shipper's responsibility." + ) + await send_card(ctx, sender, narration, hazmat_review_card(rate.provider)) + + +def _hazmat_signal(text: str, selection: dict[str, Any]) -> str | None: + """Interpret the hazmat card response from a JSON action or prose. + + Returns ``"clear"``, ``"stop"``, or ``None`` (ambiguous -> re-prompt). We + read the raw text ourselves rather than the generic parse, since a bare + "no" is genuinely ambiguous here and must not be silently assumed. + """ + action = selection.get("action") + if action == "hazmat_clear": + return "clear" + if action == "hazmat_stop": + return "stop" + low = (text or "").lower() + if re.search(r"special\s*handling|hazardous|hazmat|prohibited|restricted|dangerous", low): + return "stop" + if re.search(r"nothing\s*hazardous|not\s*hazardous|no\s*hazard|all\s*clear|safe|continue", low): + return "clear" + return None + + +async def _handle_hazmat_stage( + ctx: Context, sender: str, state_data: dict[str, Any], text: str, selection: dict[str, Any] +) -> None: + rate = _find_rate(state_data.get("rates", []), str(state_data.get("selected_rate_id"))) + if not rate: + state_data["state"] = SHOWING_RATES + save_state(ctx, sender, state_data) + await send_text(ctx, sender, "Let's pick a shipping option again.") + return + + signal = _hazmat_signal(text, selection) + if signal == "stop": + # Don't purchase. Point at the two carrier-specific hazmat guides and + # note that common items often ship without special declaration, so the + # user isn't left assuming everything is simply refused. + state_data["state"] = DONE + save_state(ctx, sender, state_data) + await send_text( + ctx, + sender, + "Understood - I won't buy this label. Hazardous or restricted items need the " + "carrier's special-handling process, which this demo doesn't cover:\n\n" + f"- USPS HAZMAT guide: {USPS_HAZMAT_GUIDE_URL}\n" + f"- UPS Limited Quantity guide: {UPS_HAZMAT_GUIDE_URL}\n\n" + "Note that many everyday items (for example nail polish or a phone with a " + "small battery) can often ship without any special declaration - check the " + "guides above. When you're ready, send any message to start a new package; " + "your profile and payment stay in place.", + ) + return + if signal == "clear": + await _maybe_offer_insurance(ctx, sender, state_data, rate) + return + + await send_card( + ctx, + sender, + "Tap a button above: 'Nothing hazardous, continue' or 'This needs special handling'.", + hazmat_review_card(rate.provider), + ) + + +async def _maybe_offer_insurance( + ctx: Context, sender: str, state_data: dict[str, Any], rate: RateOption +) -> None: + """Insurance gate: skip entirely when the declared value is already covered + for free by the chosen service, otherwise offer optional XCover insurance.""" + declared = float((state_data.get("package") or {}).get("declared_value_usd", 0) or 0) + free_coverage = free_included_coverage_usd(rate.provider, rate.servicelevel_name) + + if declared <= free_coverage: + if declared > 0: + await send_text( + ctx, + sender, + f"Your declared value of {_money(declared)} is already covered for free by " + f"{_carrier_label(rate)} (up to {_money(free_coverage)}), so no extra " + "insurance is needed.", + ) + await _begin_purchase_approval(ctx, sender, state_data, rate) + return + + if declared > MAX_INSURABLE_VALUE_USD: + # XCover can't insure above this; proceed with just the free coverage + # rather than dead-ending, and say so plainly. + await send_text( + ctx, + sender, + f"Heads up: declared values above {_money(MAX_INSURABLE_VALUE_USD)} can't be " + f"insured through this demo, so only {_carrier_label(rate)}'s included " + f"{_money(free_coverage)} applies.", + ) + await _begin_purchase_approval(ctx, sender, state_data, rate) + return + + premium = insurance_premium_usd(declared) + state_data["state"] = AWAITING_INSURANCE_CHECK + save_state(ctx, sender, state_data) + narration = ( + f"{_carrier_label(rate)} includes {_money(free_coverage)} of coverage for free, but " + f"you declared {_money(declared)}. I can insure the full value for about " + f"{_money(premium)} more, or you can skip it." + ) + await send_card( + ctx, + sender, + narration, + insurance_review_card( + rate, free_coverage=free_coverage, declared_value=declared, premium=premium + ), + ) + + +def _insurance_signal(text: str, selection: dict[str, Any]) -> str | None: + action = selection.get("action") + if action == "add_insurance": + return "add" + if action == "skip_insurance": + return "skip" + low = (text or "").lower() + if re.search(r"add\s*insurance|insure|with\s*insurance|protect|yes", low): + return "add" + if re.search(r"skip|no\s*insurance|without\s*insurance|no\s*thanks", low): + return "skip" + return None + + +async def _handle_insurance_stage( + ctx: Context, sender: str, state_data: dict[str, Any], text: str, selection: dict[str, Any] +) -> None: + rate = _find_rate(state_data.get("rates", []), str(state_data.get("selected_rate_id"))) + if not rate: + state_data["state"] = SHOWING_RATES + save_state(ctx, sender, state_data) + await send_text(ctx, sender, "Let's pick a shipping option again.") + return + + signal = _insurance_signal(text, selection) + if signal == "skip": + await _begin_purchase_approval(ctx, sender, state_data, rate) + return + if signal == "add": + insured = await _requote_with_insurance(ctx, sender, state_data, rate) + await _begin_purchase_approval(ctx, sender, state_data, insured) + return + + declared = float((state_data.get("package") or {}).get("declared_value_usd", 0) or 0) + free_coverage = free_included_coverage_usd(rate.provider, rate.servicelevel_name) + await send_card( + ctx, + sender, + "Tap a button above to add insurance or skip it.", + insurance_review_card( + rate, + free_coverage=free_coverage, + declared_value=declared, + premium=insurance_premium_usd(declared), + ), + ) + + +async def _requote_with_insurance( + ctx: Context, sender: str, state_data: dict[str, Any], base_rate: RateOption +) -> RateOption: + """Re-quote just the chosen service with ``extra.insurance`` set, and return + a rate whose ``amount`` reflects the insured total. + + One extra Shippo call (not a full re-shop). If Shippo returns a real + ``included_insurance_price`` we trust its total; in test mode it usually + doesn't compute one, so we fall back to the documented 1.25% domestic + premium on top of the base price. The result is stored back into the + session's rate list so every downstream step (approval card, label payment, + confirmation) sees the same insured price. + """ + declared = float((state_data.get("package") or {}).get("declared_value_usd", 0) or 0) + premium = insurance_premium_usd(declared) + insured = base_rate.model_copy( + update={ + "amount": round(base_rate.amount + premium, 2), + "included_insurance_price": premium, + } + ) + + pkg_dict = state_data.get("package") or {} + try: + pkg = PackageDetails(**pkg_dict) + client = shippo_client() + from_addr = sender_address_from_profile(state_data["sender_profile"]) + requoted = await asyncio.to_thread( + client.rate_shop, + from_addr, + _to_address(pkg), + _parcel(pkg), + insurance_amount=declared, + ) + match = next( + ( + r + for r in requoted + if r.provider == base_rate.provider + and r.servicelevel_token == base_rate.servicelevel_token + ), + None, + ) + if match: + if match.included_insurance_price > 0: + # Shippo computed the premium; its amount already includes it. + insured = match + else: + # Test mode didn't add it - keep the real (insured) rate_id but + # reflect the documented premium in the amount we show/charge. + insured = match.model_copy( + update={ + "amount": round(base_rate.amount + premium, 2), + "included_insurance_price": premium, + } + ) + except Exception as exc: # noqa: BLE001 - fall back to the local estimate + ctx.logger.warning(f"[insurance] re-quote failed, using local estimate: {exc}") + + # Persist the insured rate so all downstream lookups resolve to it. + rates = [r for r in state_data.get("rates", []) if r.get("rate_id") != insured.rate_id] + rates.append(insured.model_dump()) + state_data["rates"] = rates + state_data["selected_rate_id"] = insured.rate_id + save_state(ctx, sender, state_data) + return insured + + +async def _begin_purchase_approval( + ctx: Context, sender: str, state_data: dict[str, Any], rate: RateOption +) -> None: + """Kick off the requires_approval purchase and send the review card. + + The first run of the Pydantic AI purchase agent defers on the gated + ``purchase_label`` tool, returning a DeferredToolRequests we render as the + review card. Approval later triggers the second Stripe charge, and only a + successful charge resumes the tool (see :func:`_handle_approval_stage`). + """ + deps = ShippingDeps(shippo=shippo_client(), selected_rate=rate) + start = await start_purchase(deps, rate.rate_id) + if not start.deferred: + await send_text( + ctx, + sender, + "I couldn't set up the purchase approval just now. Please try again.", + ) + return + state_data["selected_rate_id"] = rate.rate_id + state_data["purchase_history_json"] = start.history_json + state_data["purchase_tool_call_id"] = start.tool_call_id + state_data["state"] = AWAITING_PURCHASE_APPROVAL + save_state(ctx, sender, state_data) + price = _money(rate.amount, rate.currency) + lines = [ + "One last check before I buy the label:", + f"- Carrier: {_carrier_label(rate)}", + ] + if rate.included_insurance_price > 0: + lines.append(f"- Insurance: {_money(rate.included_insurance_price, rate.currency)}") + lines.append(f"- Total: {price}") + lines.append("") + lines.append(f"Reply 'confirm' and I'll charge {price} for this label and buy it, or 'cancel'.") + await send_card(ctx, sender, "\n".join(lines), purchase_review_card(rate)) + + +async def _handle_approval_stage( + ctx: Context, sender: str, state_data: dict[str, Any], selection: dict[str, Any] +) -> None: + action = selection.get("action") + history_json = state_data.get("purchase_history_json") or "" + tool_call_id = state_data.get("purchase_tool_call_id") or "" + + if action == "deny_purchase": + deps = ShippingDeps(shippo=shippo_client()) + await resume_purchase( + deps, history_json=history_json, tool_call_id=tool_call_id, approved=False + ) + state_data["state"] = SHOWING_DETAIL + save_state(ctx, sender, state_data) + await send_text( + ctx, + sender, + "Cancelled - no label was purchased and nothing was charged for shipping. " + "You can pick a different option or confirm again.", + ) + return + + rate = _find_rate(state_data.get("rates", []), str(state_data.get("selected_rate_id"))) + if action != "approve_purchase": + if rate: + await send_card( + ctx, sender, "Please confirm or cancel the purchase:", purchase_review_card(rate) + ) + return + + if not rate: + await send_text(ctx, sender, "Something went wrong - let's pick a shipping option again.") + state_data["state"] = SHOWING_RATES + save_state(ctx, sender, state_data) + return + + # Approving the review card above only records intent - it moves no money. + # Charge a second, separate Stripe payment for the exact rate price before + # the deferred tool is ever resumed with approval. A successful payment + # resumes it with ``approved=True`` (handle_label_payment_success); a + # failed/declined one resumes with ``approved=False`` and returns the user + # to rate picking (handle_label_payment_failed). + await payment.request_payment( + ctx, + sender, + state_data, + amount_cents=round(rate.amount * 100), + description=f"{rate.provider} {rate.servicelevel_name} label - {rate.currency} {rate.amount:.2f}", + purpose="label", + ) + + +async def _handle_label_payment_stage( + ctx: Context, sender: str, state_data: dict[str, Any], text: str +) -> None: + """Awaiting the second (label-price) payment - manual 'paid' fallback or re-issue.""" + if text.lower() in _PAID_WORDS: + if await payment.confirm_payment_via_text(ctx, sender): + return + await send_text( + ctx, + sender, + "Stripe still shows this label payment as unpaid. Finish the checkout, then " + "type 'paid' again.", + ) + return + + rate = _find_rate(state_data.get("rates", []), str(state_data.get("selected_rate_id"))) + if not rate: + state_data["state"] = SHOWING_RATES + save_state(ctx, sender, state_data) + await send_text(ctx, sender, "Something went wrong - let's pick a shipping option again.") + return + + # Any other message while this second payment is outstanding -> re-send it. + await payment.request_payment( + ctx, + sender, + state_data, + amount_cents=round(rate.amount * 100), + description=f"{rate.provider} {rate.servicelevel_name} label - {rate.currency} {rate.amount:.2f}", + purpose="label", + ) + + +async def handle_label_payment_success( + ctx: Context, sender: str, state_data: dict[str, Any] +) -> None: + """The second payment (the label's real price) succeeded - now actually buy it.""" + rate = _find_rate(state_data.get("rates", []), str(state_data.get("selected_rate_id"))) + deps = ShippingDeps(shippo=shippo_client(), selected_rate=rate) + history_json = state_data.get("purchase_history_json") or "" + tool_call_id = state_data.get("purchase_tool_call_id") or "" + + try: + purchase = await resume_purchase( + deps, history_json=history_json, tool_call_id=tool_call_id, approved=True + ) + except Exception as exc: # noqa: BLE001 - surface Shippo failures to the user + if deps.purchase_result is not None: + # The gated tool call itself succeeded - Shippo already issued the + # label - and only the model's follow-up reply generation failed + # afterward (e.g. a transient ASI:One hiccup). Use the real result + # instead of telling the user their payment vanished. + ctx.logger.warning(f"[purchase] label bought but final reply failed: {exc}") + purchase = deps.purchase_result + else: + ctx.logger.error(f"[purchase] failed after payment: {exc}") + state_data["state"] = SHOWING_DETAIL + save_state(ctx, sender, state_data) + await send_text( + ctx, + sender, + f"Your payment went through, but the label purchase failed ({exc}). Please try " + "again - you were not charged twice.", + ) + return + + if purchase is None: + state_data["state"] = SHOWING_DETAIL + save_state(ctx, sender, state_data) + await send_text( + ctx, sender, "Something went wrong finalizing the purchase. Please try again." + ) + return + + state_data["purchase"] = purchase.model_dump() + save_state(ctx, sender, state_data) + + await _deliver_label(ctx, sender, purchase) + await _offer_pickup_or_finish(ctx, sender, state_data, purchase, rate) + + +async def handle_label_payment_failed( + ctx: Context, sender: str, state_data: dict[str, Any] +) -> None: + """The second payment failed/was declined - cancel the tool, go back to rate picking.""" + deps = ShippingDeps(shippo=shippo_client()) + history_json = state_data.get("purchase_history_json") or "" + tool_call_id = state_data.get("purchase_tool_call_id") or "" + try: + await resume_purchase( + deps, + history_json=history_json, + tool_call_id=tool_call_id, + approved=False, + denial_message="The payment for this label failed, so the purchase was cancelled.", + ) + except Exception as exc: # noqa: BLE001 - this is best-effort cleanup + ctx.logger.warning(f"[purchase] cancel-on-payment-failure had an issue: {exc}") + + state_data["state"] = SHOWING_RATES + save_state(ctx, sender, state_data) + rate_objs = [RateOption(**r) for r in state_data.get("rates", [])] + tags = _tag_rates(rate_objs) + curated = _curate_rates(rate_objs, tags) + await send_card( + ctx, + sender, + "That payment didn't go through, so the label wasn't purchased and you weren't " + "charged. Pick an option to try again:\n\n" + + rates_text_summary(curated, tags, total=len(rate_objs)), + rates_carousel(curated, tags, total=len(rate_objs)), + ) + + +async def _offer_pickup_or_finish( + ctx: Context, + sender: str, + state_data: dict[str, Any], + purchase: PurchaseResult, + rate: RateOption | None = None, +) -> None: + rate = rate or _find_rate(state_data.get("rates", []), str(state_data.get("selected_rate_id"))) + provider = rate.provider if rate else purchase.provider + zip_code = str((state_data.get("package") or {}).get("to_zip", "")) + + if rate and rate.supports_pickup(): + state_data["state"] = AWAITING_PICKUP + save_state(ctx, sender, state_data) + await send_card( + ctx, + sender, + f"{provider} supports scheduled pickups. Want me to arrange one?", + pickup_form_card(provider), + ) + return + + # Drop-off carriers: point to the carrier's own locator (ZIP appended). + locator = rate.drop_off_url(zip_code) if rate else None + state_data["state"] = DONE + save_state(ctx, sender, state_data) + if locator: + await send_text( + ctx, + sender, + f"{provider} doesn't support API pickups - drop your parcel at a nearby " + f"location: {locator}", + ) + await _send_confirmation(ctx, sender, purchase, rate) + + +async def _send_confirmation( + ctx: Context, sender: str, purchase: PurchaseResult, rate: RateOption | None +) -> None: + """The one place the TEST/SAMPLE disclaimer is said, clearly, exactly once.""" + carrier = _carrier_label(rate) if rate else purchase.provider + narration = ( + f"You're all set - your {carrier or 'label'} is booked.\n\n" + f"- Carrier: {carrier or 'n/a'}\n" + f"- Tracking number: {purchase.tracking_number or 'n/a'}\n\n" + "A quick note on test mode: this is a demo SAMPLE label. It's watermarked, can't be " + "mailed, and its tracking number is simulated by Shippo, so it won't update on the " + "carrier's website - that's expected here, not an error." + ) + await send_card(ctx, sender, narration, confirmation_card(purchase, rate)) + + +async def _handle_pickup_stage( + ctx: Context, sender: str, state_data: dict[str, Any], selection: dict[str, Any] +) -> None: + purchase = PurchaseResult(**state_data["purchase"]) + action = selection.get("action") + rate = _find_rate(state_data.get("rates", []), str(state_data.get("selected_rate_id"))) + + if action == "done" or (action and action not in {"schedule_pickup"}): + state_data["state"] = DONE + save_state(ctx, sender, state_data) + await _send_confirmation(ctx, sender, purchase, rate) + return + + client = shippo_client() + try: + pickup = await asyncio.to_thread( + client.schedule_pickup, + carrier_account=rate.carrier_account if rate else "", + transaction_id=purchase.transaction_id, + address=sender_address_from_profile(state_data["sender_profile"]), + building_location_type=str(selection.get("building_location_type", "Front Door")), + instructions=str(selection.get("instructions", "")), + ) + except Exception as exc: # noqa: BLE001 + ctx.logger.warning(f"[pickup] scheduling failed: {exc}") + state_data["state"] = DONE + save_state(ctx, sender, state_data) + await send_text( + ctx, + sender, + f"I couldn't schedule the pickup ({exc}). Your label is still valid - you can " + "drop the parcel off instead.", + ) + await _send_confirmation(ctx, sender, purchase, rate) + return + + state_data["state"] = DONE + save_state(ctx, sender, state_data) + when = pickup.confirmed_start_time or "the requested window" + await send_text( + ctx, + sender, + f"Pickup {pickup.status.lower() or 'requested'} (confirmation " + f"{pickup.confirmation_code or 'n/a'}), starting around {when}. No courier is " + "actually dispatched for this demo.", + ) + await _send_confirmation(ctx, sender, purchase, rate) + + +@chat_proto.on_message(ChatAcknowledgement) +async def handle_ack(ctx: Context, sender: str, msg: ChatAcknowledgement) -> None: + ctx.logger.debug(f"ACK from {sender} for {msg.acknowledged_msg_id}") diff --git a/pydantic-agent/shipping-label-agent/payment.py b/pydantic-agent/shipping-label-agent/payment.py new file mode 100644 index 00000000..08f9fc26 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/payment.py @@ -0,0 +1,298 @@ +from __future__ import annotations + +import asyncio +import os +import time +from typing import Any + +from uagents import Context, Protocol +from uagents_core.contrib.protocols.payment import ( + CommitPayment, + CompletePayment, + Funds, + RejectPayment, + RequestPayment, + payment_protocol_spec, +) + +from session_state import ( + AWAITING_LABEL_PAYMENT, + AWAITING_PAYMENT, + AWAITING_SENDER, + get_state, + save_state, +) + +STRIPE_TEST_SECRET_PREFIX = "sk_test_" +STRIPE_TEST_PUBLISHABLE_PREFIX = "pk_test_" + +# Stripe's documented test cards (https://docs.stripe.com/testing). +TEST_SUCCESS_CARD = "4242424242424242" +TEST_DECLINE_CARD = "4000000000000002" # generic card_declined + +payment_proto = Protocol(spec=payment_protocol_spec, role="seller") + + +def config() -> dict[str, Any]: + """Read Stripe config from the environment (test defaults).""" + return { + "secret_key": (os.getenv("STRIPE_SECRET_KEY") or "").strip(), + "publishable_key": (os.getenv("STRIPE_PUBLISHABLE_KEY") or "").strip(), + "amount_cents": int(os.getenv("STRIPE_AMOUNT_CENTS", "500")), + "currency": (os.getenv("STRIPE_CURRENCY", "usd") or "usd").lower(), + "success_url": ( + os.getenv("STRIPE_SUCCESS_URL", "https://agentverse.ai") or "https://agentverse.ai" + ).rstrip("/"), + } + + +def assert_stripe_test_keys() -> None: + """Fail loudly unless the configured Stripe keys are test keys.""" + c = config() + secret = c["secret_key"] + if not secret.startswith(STRIPE_TEST_SECRET_PREFIX): + raise RuntimeError( + "STRIPE_SECRET_KEY must be a test key starting with " + f"'{STRIPE_TEST_SECRET_PREFIX}'. This example is test-mode-only and will " + "not run with a live key." + ) + publishable = c["publishable_key"] + if publishable and not publishable.startswith(STRIPE_TEST_PUBLISHABLE_PREFIX): + raise RuntimeError( + "STRIPE_PUBLISHABLE_KEY must be a test key starting with " + f"'{STRIPE_TEST_PUBLISHABLE_PREFIX}'." + ) + + +def _stripe() -> Any: + """Return the configured Stripe SDK module (indirection eases testing).""" + import stripe as _s + + _s.api_key = config()["secret_key"] + return _s + + +def _expires_at() -> int: + """Checkout expiry, clamped to Stripe's 30 min - 24 h window.""" + sec = int(os.getenv("STRIPE_CHECKOUT_EXPIRES_SECONDS", "1800")) + return int(time.time()) + max(1800, min(24 * 3600, sec)) + + +def amount_str(amount_cents: int | None = None) -> str: + """A charge amount as a human string, e.g. ``5.00``.""" + cents = config()["amount_cents"] if amount_cents is None else amount_cents + return f"{cents / 100:.2f}" + + +def create_checkout_session( + sender: str, + chat_session_id: str, + amount_cents: int | None = None, + description: str | None = None, +) -> dict[str, Any]: + """Create an **embedded** Stripe Checkout session. + + ``ui_mode="embedded_page"`` is what ASI:One's native payment card renderer + expects - it uses ``client_secret`` + ``publishable_key`` to mount the + Stripe form in-place when the user taps "Pay with Stripe", instead of + bouncing them to a separate hosted checkout page/URL. + + ``amount_cents``/``description`` let a caller charge something other than + the flat intake fee - e.g. the exact price of the shipping label the user + picked, once they've chosen a rate. + """ + c = config() + s = _stripe() + amount = c["amount_cents"] if amount_cents is None else amount_cents + product_name = description or "Shipping Label Agent - service fee" + return_url = ( + f"{c['success_url']}?session_id={{CHECKOUT_SESSION_ID}}" + f"&chat_session_id={chat_session_id}&user={sender}" + ) + session = s.checkout.Session.create( + ui_mode="embedded_page", + redirect_on_completion="if_required", + payment_method_types=["card"], + mode="payment", + return_url=return_url, + expires_at=_expires_at(), + line_items=[ + { + "price_data": { + "currency": c["currency"], + "product_data": {"name": product_name}, + "unit_amount": amount, + }, + "quantity": 1, + } + ], + metadata={ + "user_address": sender, + "session_id": chat_session_id, + "service": "shipping_label", + }, + ) + return { + "client_secret": getattr(session, "client_secret", "") or "", + "id": session.id, + "checkout_session_id": session.id, + "publishable_key": c["publishable_key"], + "currency": c["currency"], + "amount_cents": str(amount), + "ui_mode": "embedded_page", + } + + +def verify_paid(checkout_session_id: str) -> bool: + """Return True if the Stripe checkout session is fully paid.""" + if not checkout_session_id: + return False + try: + session = _stripe().checkout.Session.retrieve(checkout_session_id) + return getattr(session, "payment_status", None) == "paid" + except Exception: # noqa: BLE001 - any lookup failure means "not verified as paid" + return False + + +async def request_payment( + ctx: Context, + sender: str, + state_data: dict[str, Any], + *, + amount_cents: int | None = None, + description: str | None = None, + purpose: str = "gate", +) -> None: + """Create a Stripe checkout, store it, and send a bare ``RequestPayment``. + + Send ONLY ``RequestPayment`` - no text before or after. ASI:One renders the + native "Pay with Stripe / Reject" card from this message alone; any text + sent in the same handler call causes ASI:One to swallow the payment card + and show only the text bubble instead. + + ``purpose`` distinguishes the two charges in this flow: ``"gate"`` is the + flat intake fee charged on the very first message; ``"label"`` is the + second, separate charge for the exact price of the label the user picked, + charged after they approve the purchase and before Shippo is ever called. + ``on_commit``/``on_reject``/``confirm_payment_via_text`` all branch on + this stored purpose to resume the right flow. + """ + checkout = await asyncio.to_thread( + create_checkout_session, sender, str(ctx.session), amount_cents, description + ) + + state_data["state"] = AWAITING_PAYMENT if purpose == "gate" else AWAITING_LABEL_PAYMENT + state_data["stripe_session_id"] = checkout["checkout_session_id"] + state_data["payment_purpose"] = purpose + save_state(ctx, sender, state_data) + + amount = amount_str(amount_cents) + default_description = f"Pay ${amount} for one shipping label" + await ctx.send( + sender, + RequestPayment( + accepted_funds=[Funds(currency="USD", amount=amount, payment_method="stripe")], + recipient=str(ctx.agent.address), + deadline_seconds=int(os.getenv("STRIPE_CHECKOUT_EXPIRES_SECONDS", "1800")), + reference=str(ctx.session), + description=description or default_description, + metadata={"stripe": checkout, "service": "shipping_label", "purpose": purpose}, + ), + ) + ctx.logger.info( + f"[payment] RequestPayment({purpose}) -> {sender} | " + f"checkout={checkout['checkout_session_id']} | ${amount}" + ) + + +async def confirm_payment_via_text(ctx: Context, sender: str) -> bool: + """Re-verify the stored checkout when the user types 'paid'/'done'. + + Returns True if payment was confirmed and the matching purpose's success + path (gate access or label purchase) has run. + """ + state_data = get_state(ctx, sender) + checkout_id = state_data.get("stripe_session_id") + if not checkout_id: + return False + paid = await asyncio.to_thread(verify_paid, checkout_id) + if not paid: + return False + await _handle_paid(ctx, sender, state_data) + return True + + +async def _handle_paid(ctx: Context, sender: str, state_data: dict[str, Any]) -> None: + """Dispatch a confirmed payment to the right success path by purpose.""" + if state_data.get("payment_purpose") == "label": + from chat_proto import handle_label_payment_success + + await handle_label_payment_success(ctx, sender, state_data) + else: + await _grant_access(ctx, sender, state_data) + + +async def _grant_access(ctx: Context, sender: str, state_data: dict[str, Any]) -> None: + """Mark the session paid and move it into the sender-profile intake.""" + from chat_proto import send_card, sender_profile_form_card + + state_data["stripe_paid"] = True + state_data["state"] = AWAITING_SENDER + save_state(ctx, sender, state_data) + await send_card( + ctx, + sender, + "Payment confirmed! First, where are you shipping from?", + sender_profile_form_card(), + ) + + +@payment_proto.on_message(CommitPayment) +async def on_commit(ctx: Context, sender: str, msg: CommitPayment) -> None: + """Verify the Stripe payment, complete it, and continue the right flow.""" + ctx.logger.info(f"[payment] CommitPayment from {sender} | txn={msg.transaction_id}") + state_data = get_state(ctx, sender) + checkout_id = str(state_data.get("stripe_session_id") or "") + purpose = state_data.get("payment_purpose", "gate") + + paid = await asyncio.to_thread(verify_paid, checkout_id) if checkout_id else False + if not paid: + ctx.logger.error(f"[payment] Stripe verification FAILED: {checkout_id}") + await ctx.send( + sender, + RejectPayment(reason="Stripe payment not confirmed yet. Please finish checkout."), + ) + if purpose == "label": + from chat_proto import handle_label_payment_failed + + await handle_label_payment_failed(ctx, sender, state_data) + return + + await ctx.send(sender, CompletePayment(transaction_id=msg.transaction_id)) + ctx.logger.info(f"[payment] Verified | sender={sender} | checkout={checkout_id}") + await _handle_paid(ctx, sender, state_data) + + +@payment_proto.on_message(RejectPayment) +async def on_reject(ctx: Context, sender: str, msg: RejectPayment) -> None: + """The buyer cancelled payment - reset the gate, or cancel the label purchase.""" + ctx.logger.info(f"[payment] Rejected by {sender}: {msg.reason}") + state_data = get_state(ctx, sender) + + if state_data.get("payment_purpose") == "label": + from chat_proto import handle_label_payment_failed + + await handle_label_payment_failed(ctx, sender, state_data) + return + + state_data["state"] = AWAITING_PAYMENT + state_data["stripe_paid"] = False + save_state(ctx, sender, state_data) + + from chat_proto import send_text + + await send_text( + ctx, + sender, + "Payment cancelled. Send any message when you're ready to try again.", + ) diff --git a/pydantic-agent/shipping-label-agent/pydantic_agent.py b/pydantic-agent/shipping-label-agent/pydantic_agent.py new file mode 100644 index 00000000..b1b1f544 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/pydantic_agent.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import asyncio +import os +from dataclasses import dataclass + +from pydantic import BaseModel, Field +from pydantic_ai import ( + Agent, + DeferredToolRequests, + DeferredToolResults, + RunContext, + ToolDenied, +) +from pydantic_ai.messages import ModelMessage, ModelMessagesTypeAdapter +from pydantic_ai.models import Model +from pydantic_ai.models.openai import OpenAIChatModel +from pydantic_ai.providers.openai import OpenAIProvider + +from shipping import PurchaseResult, RateOption, ShippoClient + +ASI_ONE_BASE_URL = "https://api.asi1.ai/v1" +DEFAULT_ASI_MODEL = os.getenv("ASI_ONE_MODEL", "asi1-mini") + + +class PackageDetails(BaseModel): + """Typed shipment intake - the structured-output target of ``extract_agent``.""" + + to_name: str = Field(description="Recipient full name") + to_street1: str = Field(description="Recipient street address, line 1") + to_city: str = Field(description="Recipient city") + to_state: str = Field(description="Recipient 2-letter state code") + to_zip: str = Field(description="Recipient ZIP/postal code") + to_country: str = Field(default="US", description="Recipient ISO-2 country code") + weight_lb: float = Field(gt=0, description="Package weight in pounds") + length_in: float = Field(gt=0, description="Package length in inches") + width_in: float = Field(gt=0, description="Package width in inches") + height_in: float = Field(gt=0, description="Package height in inches") + declared_value_usd: float = Field(ge=0, default=0, description="Declared value in USD") + + +class SenderProfile(BaseModel): + """The user's own ship-from address - captured once per session, right after + payment, and kept separate from :class:`PackageDetails` (the recipient) so + the sender can never accidentally end up being whatever the user typed as + the recipient's name. + """ + + from_name: str = Field(description="Sender's full name") + from_street1: str = Field(description="Sender's street address, line 1") + from_city: str = Field(description="Sender's city") + from_state: str = Field(description="Sender's 2-letter state code") + from_zip: str = Field(description="Sender's ZIP/postal code") + from_country: str = Field(default="US", description="Sender's ISO-2 country code") + from_phone: str = Field(default="", description="Sender's phone number") + from_email: str = Field(default="", description="Sender's email address") + + +@dataclass +class ShippingDeps: + """Injected dependencies for the purchase agent (Pydantic AI DI).""" + + shippo: ShippoClient + # The exact rate the user picked during rate-shopping. Shippo's real + # transaction response only returns provider/service/amount inline when + # expanded (see shipping.ShippoClient.purchase's docstring); this is the + # fallback ``purchase_label`` uses so the confirmation never shows a + # blank carrier. + selected_rate: RateOption | None = None + # Set by the ``purchase_label`` tool once a test label is bought, so the + # caller can read the concrete result regardless of the model's final text. + purchase_result: PurchaseResult | None = None + + +@dataclass +class PurchaseStart: + """Outcome of the first (deferring) purchase run.""" + + deferred: bool + history_json: str + tool_call_id: str = "" + rate_id: str = "" + + +def build_asi1_model() -> OpenAIChatModel: + """Build the ASI:One OpenAI-compatible chat model. + + A placeholder api_key keeps construction (and imports) working in tests, + where a ``TestModel``/``FunctionModel`` is always passed to the run helpers + and no real request is ever sent to ASI:One. + """ + return OpenAIChatModel( + DEFAULT_ASI_MODEL, + provider=OpenAIProvider( + base_url=ASI_ONE_BASE_URL, + api_key=os.environ.get("ASI_ONE_API_KEY") or "not-used-in-tests", + ), + ) + + +_DEFAULT_MODEL = build_asi1_model() + +extract_agent: Agent[None, PackageDetails] = Agent( + _DEFAULT_MODEL, + output_type=PackageDetails, + system_prompt=( + "You extract US domestic shipping details from the user's message into the " + "structured schema. Use 2-letter state codes and infer inches/pounds. If a " + "value is missing, make a minimal reasonable assumption rather than inventing " + "an address." + ), +) + + +async def extract_package(text: str, *, model: Model | None = None) -> PackageDetails: + """Extract typed :class:`PackageDetails` from free text (structured output).""" + result = await extract_agent.run(text, model=model) + return result.output + + +sender_extract_agent: Agent[None, SenderProfile] = Agent( + _DEFAULT_MODEL, + output_type=SenderProfile, + system_prompt=( + "You extract a US domestic ship-from (sender) profile from the user's message " + "into the structured schema, including phone and email if the user gave them - " + "USPS requires both to actually generate a label. Never invent a phone number or " + "email address; leave the field blank if the user truly didn't provide one." + ), +) + + +async def extract_sender_profile(text: str, *, model: Model | None = None) -> SenderProfile: + """Extract typed :class:`SenderProfile` from free text (structured output).""" + result = await sender_extract_agent.run(text, model=model) + return result.output + + +purchase_agent: Agent[ShippingDeps, str | DeferredToolRequests] = Agent( + _DEFAULT_MODEL, + deps_type=ShippingDeps, + output_type=[str, DeferredToolRequests], + system_prompt=( + "You buy a single shipping label by calling the purchase_label tool with the " + "exact rate_id you are given. Never invent a rate_id. After the tool returns, " + "reply with a one-line confirmation." + ), +) + + +@purchase_agent.tool(requires_approval=True) +async def purchase_label(ctx: RunContext[ShippingDeps], rate_id: str) -> str: + """Buy the shipping label for ``rate_id`` (TEST MODE only). + + Gated by ``requires_approval=True`` - Pydantic AI defers this call until the + user approves it, so the body only runs after an explicit ``ToolApproved``. + ``ShippoClient.purchase`` asserts ``test is True`` and ``status == 'SUCCESS'``. + """ + result = await asyncio.to_thread( + ctx.deps.shippo.purchase, rate_id, fallback_rate=ctx.deps.selected_rate + ) + ctx.deps.purchase_result = result + return ( + f"Purchased TEST label {result.transaction_id} " + f"({result.provider} {result.servicelevel_token}), tracking {result.tracking_number}." + ) + + +def _dump_history(messages: list[ModelMessage]) -> str: + return ModelMessagesTypeAdapter.dump_json(messages).decode("utf-8") + + +def _load_history(history_json: str) -> list[ModelMessage]: + return list(ModelMessagesTypeAdapter.validate_json(history_json)) + + +async def start_purchase( + deps: ShippingDeps, rate_id: str, *, model: Model | None = None +) -> PurchaseStart: + """First purchase run: the model calls the gated tool, which defers. + + Returns the serialized message history plus the pending tool-call id so the + caller can resume with an approval or denial on the next chat turn. + """ + result = await purchase_agent.run( + f"Purchase the shipping label for rate_id={rate_id}.", + deps=deps, + model=model, + ) + history_json = _dump_history(result.all_messages()) + output = result.output + if isinstance(output, DeferredToolRequests) and output.approvals: + pending = output.approvals[0] + return PurchaseStart( + deferred=True, + history_json=history_json, + tool_call_id=pending.tool_call_id, + rate_id=rate_id, + ) + # No approval was requested (e.g. the model declined to call the tool). + return PurchaseStart(deferred=False, history_json=history_json, rate_id=rate_id) + + +async def resume_purchase( + deps: ShippingDeps, + *, + history_json: str, + tool_call_id: str, + approved: bool, + denial_message: str = "The user declined to purchase the label.", + model: Model | None = None, +) -> PurchaseResult | None: + """Resume the deferred purchase with an approve/deny decision. + + On approval the gated tool runs and buys the test label; the concrete + :class:`PurchaseResult` is returned. On denial the tool never runs and + ``None`` is returned. + """ + results = DeferredToolResults() + results.approvals[tool_call_id] = True if approved else ToolDenied(denial_message) + await purchase_agent.run( + message_history=_load_history(history_json), + deferred_tool_results=results, + deps=deps, + model=model, + ) + return deps.purchase_result diff --git a/pydantic-agent/shipping-label-agent/pyproject.toml b/pydantic-agent/shipping-label-agent/pyproject.toml new file mode 100644 index 00000000..9bd7aad4 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "shipping-label-agent" +version = "0.1.0" +description = "Pydantic AI shipping-label booking agent for ASI:One (test-mode-only example)" +readme = "README.md" +requires-python = ">=3.11" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +# E402: module-level imports after load_dotenv() are intentional in agent.py. +ignore = ["E402"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] +pythonpath = ["."] + +[tool.mypy] +python_version = "3.11" +ignore_missing_imports = true +check_untyped_defs = true +warn_redundant_casts = true +no_implicit_optional = true diff --git a/pydantic-agent/shipping-label-agent/requirements.txt b/pydantic-agent/shipping-label-agent/requirements.txt new file mode 100644 index 00000000..11ccbc1f --- /dev/null +++ b/pydantic-agent/shipping-label-agent/requirements.txt @@ -0,0 +1,25 @@ +# Fetch.ai agent framework + ASI:One chat protocol (pins match the quiz-agent) +uagents==0.23.6 +uagents-core==0.4.0 + +# Pydantic AI — structured output, dependency injection, requires_approval gating +pydantic-ai>=1.0.0 +pydantic>=2.7.0 + +# ASI:One is reached through Pydantic AI's OpenAI-compatible model +openai>=1.40.0 + +# Stripe test-mode Checkout — the payment gate rail (mirrored from quiz-agent) +stripe>=8.0.0 + +# Shippo test-mode REST calls +httpx>=0.27.0 + +# Utilities +python-dotenv>=1.0.0 + +# Dev / CI (ruff, mypy, pytest) +pytest>=8.0.0 +pytest-asyncio>=0.24.0 +ruff>=0.8.0 +mypy>=1.11.0 diff --git a/pydantic-agent/shipping-label-agent/session_state.py b/pydantic-agent/shipping-label-agent/session_state.py new file mode 100644 index 00000000..fd51da33 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/session_state.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from typing import Any + +from uagents import Context + +UNINITIALIZED = "UNINITIALIZED" +AWAITING_PAYMENT = "AWAITING_PAYMENT" +AWAITING_SENDER = "AWAITING_SENDER" +AWAITING_SENDER_CONFIRM = "AWAITING_SENDER_CONFIRM" +AWAITING_PACKAGE = "AWAITING_PACKAGE" +AWAITING_PACKAGE_CONFIRM = "AWAITING_PACKAGE_CONFIRM" +SHOWING_RATES = "SHOWING_RATES" +SHOWING_DETAIL = "SHOWING_DETAIL" +AWAITING_HAZMAT_CHECK = "AWAITING_HAZMAT_CHECK" +AWAITING_INSURANCE_CHECK = "AWAITING_INSURANCE_CHECK" +AWAITING_PURCHASE_APPROVAL = "AWAITING_PURCHASE_APPROVAL" +AWAITING_LABEL_PAYMENT = "AWAITING_LABEL_PAYMENT" +AWAITING_PICKUP = "AWAITING_PICKUP" +DONE = "DONE" + +_SESSION_KEY = "session:{}" + + +def default_state() -> dict[str, Any]: + return { + "state": UNINITIALIZED, + "stripe_session_id": None, + "stripe_paid": False, + "payment_purpose": "gate", + "sender_profile": None, + "pending_sender_typed": None, + "pending_sender_corrected": None, + "package": None, + "pending_package_typed": None, + "pending_package_corrected": None, + "rates": [], + "selected_rate_id": None, + "purchase_history_json": None, + "purchase_tool_call_id": None, + "purchase": None, + } + + +def get_state(ctx: Context, sender: str) -> dict[str, Any]: + raw = ctx.storage.get(_SESSION_KEY.format(sender)) + if not raw: + return default_state() + try: + data: dict[str, Any] = json.loads(raw) + return data + except (TypeError, json.JSONDecodeError): + return default_state() + + +def save_state(ctx: Context, sender: str, data: dict[str, Any]) -> None: + ctx.storage.set(_SESSION_KEY.format(sender), json.dumps(data)) + + +_WINDOW_KEY = "chat:window:{}" + + +def check_new_window_and_reset(ctx: Context, sender: str) -> None: + """Force a full session reset when a message arrives on a new chat window. + + ``ctx.storage`` is keyed purely by ``sender``, and ASI:One reuses the same + ``sender`` address across a user's separate chat conversations - so without + this, starting a brand-new chat would silently resume an already-paid + session from a previous, unrelated conversation instead of asking to pay + again. ``ctx.session`` changes per chat window, so comparing it against the + last-seen value lets each new conversation start truly fresh. Mirrors + quiz-agent's identical "hackflow pattern" fix. + """ + current_window = str(ctx.session) + stored_window = ctx.storage.get(_WINDOW_KEY.format(sender)) + if stored_window and stored_window != current_window: + save_state(ctx, sender, default_state()) + ctx.storage.set(_WINDOW_KEY.format(sender), current_window) diff --git a/pydantic-agent/shipping-label-agent/shipping.py b/pydantic-agent/shipping-label-agent/shipping.py new file mode 100644 index 00000000..3fd668b6 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/shipping.py @@ -0,0 +1,467 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Any +from urllib.parse import quote_plus + +import httpx +from pydantic import BaseModel, Field + +SHIPPO_TEST_PREFIX = "shippo_test_" +DEFAULT_BASE_URL = "https://api.goshippo.com" +FIXED_SENDER_NAME = "Shawn Ippotle" +PICKUP_ELIGIBLE_PROVIDERS = {"USPS", "DHL Express"} + +DROP_OFF_LOCATORS = { + "USPS": "https://tools.usps.com/locations/?location={zip}", + "DHL Express": "https://locator.dhl.com/?countryCode=US&postalCode={zip}", + "UPS": "https://www.ups.com/dropoff?loc=en_US&postalCode={zip}", + "FedEx": "https://www.fedex.com/en-us/dropoff-pickup-locations.html?searchtext={zip}", +} + + +USPS_PROHIBITED_URL = "https://pe.usps.com/text/pub52/welcome.htm" # USPS Publication 52 +UPS_PROHIBITED_URL = ( + "https://www.ups.com/us/en/support/shipping-support/" + "shipping-special-care-regulated-items/prohibited-items" +) +USPS_HAZMAT_GUIDE_URL = ( + "https://support.goshippo.com/hc/en-us/articles/15969609312539-How-to-Ship-HAZMAT-with-USPS" +) +UPS_HAZMAT_GUIDE_URL = ( + "https://support.goshippo.com/hc/en-us/articles/38392469249563-How-to-Ship-HAZMAT-with-UPS" +) + +DOMESTIC_INSURANCE_RATE = 0.0125 +MAX_INSURABLE_VALUE_USD = 10_000.0 +FREE_COVERAGE_DEFAULT_USD = 100.0 + + +def free_included_coverage_usd(provider: str, servicelevel_name: str) -> float: + """USD of coverage a carrier/service already includes at no extra cost.""" + p = (provider or "").strip().lower() + s = (servicelevel_name or "").strip().lower() + if p == "ups" and "ground saver" in s: + return 50.0 + return FREE_COVERAGE_DEFAULT_USD + + +def insurance_premium_usd(declared_value_usd: float) -> float: + """XCover premium (US domestic) to insure ``declared_value_usd``.""" + return round(max(declared_value_usd, 0.0) * DOMESTIC_INSURANCE_RATE, 2) + + +def is_domestic_country(country: str) -> bool: + return (country or "US").strip().upper() in {"US", "USA", "UNITED STATES"} + + +class ShippoError(RuntimeError): + """Raised when Shippo returns an error or a non-test / non-success response.""" + + +class Address(BaseModel): + """A postal address in the shape Shippo's address/shipment endpoints expect.""" + + name: str + street1: str + city: str + state: str + zip: str + country: str = "US" + street2: str = "" + phone: str = "" + email: str = "" + + def to_shippo(self) -> dict[str, str]: + return self.model_dump(exclude_defaults=False) + + +class Parcel(BaseModel): + """Parcel dimensions/weight (imperial units, matching the intake card).""" + + length_in: float = Field(gt=0) + width_in: float = Field(gt=0) + height_in: float = Field(gt=0) + weight_lb: float = Field(gt=0) + + def to_shippo(self) -> dict[str, str]: + return { + "length": str(self.length_in), + "width": str(self.width_in), + "height": str(self.height_in), + "distance_unit": "in", + "weight": str(self.weight_lb), + "mass_unit": "lb", + } + + +class AddressValidation(BaseModel): + """Normalised result of Shippo's automatic US address validation. + + ``corrected`` is Shippo's own cleaned-up version of the address (e.g. a + standardized city name for the ZIP) when it returned one - present on + both valid *and* invalid results (see the docs' own example: an + unmatched street still comes back with a corrected city/zip). It's + ``None`` when Shippo didn't echo back any address fields at all. + """ + + is_valid: bool + is_complete: bool + messages: list[str] = Field(default_factory=list) + corrected: Address | None = None + + +class RateOption(BaseModel): + """One purchasable rate returned by the test-mode rate shop.""" + + rate_id: str + provider: str + servicelevel_name: str + servicelevel_token: str + amount: float + currency: str + estimated_days: int | None = None + duration_terms: str = "" + carrier_account: str = "" + test: bool = True + # Insurance premium already folded into ``amount`` (>0 only on a rate that + # was re-quoted with ``extra.insurance``). See ``ShippoClient.rate_shop``. + included_insurance_price: float = 0.0 + + def supports_pickup(self) -> bool: + return self.provider in PICKUP_ELIGIBLE_PROVIDERS + + def drop_off_url(self, zip_code: str) -> str | None: + template = DROP_OFF_LOCATORS.get(self.provider) + if not template: + return None + return template.format(zip=quote_plus(zip_code)) + + +class PurchaseResult(BaseModel): + """Normalised result of buying a label via the test-mode transaction endpoint.""" + + transaction_id: str + status: str + test: bool + label_url: str + tracking_number: str + tracking_url_provider: str = "" + amount: float | None = None + currency: str = "USD" + provider: str = "" + servicelevel_token: str = "" + + +class PickupResult(BaseModel): + """Normalised result of scheduling a carrier pickup in test mode.""" + + status: str + confirmation_code: str = "" + confirmed_start_time: str = "" + confirmed_end_time: str = "" + messages: list[str] = Field(default_factory=list) + + +_DIFF_FIELDS = ("street1", "city", "state", "zip") + + +def address_diff(submitted: Address, corrected: Address) -> dict[str, tuple[str, str]]: + """Field-level differences between what the user typed and what Shippo/USPS + normalized it to (e.g. ``{"city": ("Irvine", "Tustin")}``). + + Comparison is case-insensitive and whitespace-trimmed so cosmetic + normalization (Shippo often upper-cases city names) doesn't get flagged + as a "correction" that isn't actually meaningful to the user. + """ + diff: dict[str, tuple[str, str]] = {} + for field in _DIFF_FIELDS: + typed = str(getattr(submitted, field, "") or "").strip() + found = str(getattr(corrected, field, "") or "").strip() + if found and typed.casefold() != found.casefold(): + diff[field] = (typed, found) + return diff + + +def _messages_to_strings(raw: Any) -> list[str]: + """Shippo ``messages`` come back as strings or ``{"text": ...}`` dicts.""" + out: list[str] = [] + if isinstance(raw, list): + for m in raw: + if isinstance(m, dict): + text = m.get("text") or m.get("source") or "" + if text: + out.append(str(text)) + elif m: + out.append(str(m)) + return out + + +class ShippoClient: + """Minimal, test-mode-only wrapper over the Shippo REST API.""" + + def __init__( + self, + token: str, + *, + base_url: str = DEFAULT_BASE_URL, + client: httpx.Client | None = None, + timeout: float = 30.0, + ) -> None: + if not token.startswith(SHIPPO_TEST_PREFIX): + raise ShippoError( + f"Refusing to run: SHIPPO_TOKEN must be a test token starting with " + f"'{SHIPPO_TEST_PREFIX}'. This example never touches live Shippo." + ) + self._token = token + self._base_url = base_url.rstrip("/") + self._client = client or httpx.Client(base_url=self._base_url, timeout=timeout) + + @property + def headers(self) -> dict[str, str]: + return { + "Authorization": f"ShippoToken {self._token}", + "Content-Type": "application/json", + } + + def _post( + self, path: str, payload: dict[str, Any], *, params: dict[str, str] | None = None + ) -> dict[str, Any]: + resp = self._client.post(path, json=payload, headers=self.headers, params=params) + if resp.status_code >= 400: + raise ShippoError( + f"Shippo {path} returned HTTP {resp.status_code}: {self._error_detail(resp)}" + ) + data: dict[str, Any] = resp.json() + return data + + @staticmethod + def _error_detail(resp: httpx.Response) -> str: + """Shippo's real error reason, not a blind slice of the raw body. + + Error responses often echo the whole submitted object back before the + actual explanation, e.g. a 400 from ``/pickups/`` echoes the full + address/location first and puts ``messages`` after it. A fixed-length + slice of ``resp.text`` can (and did) cut off before ever reaching it. + Falls back to a short slice only if the body isn't the JSON shape + Shippo documents. + """ + try: + body = resp.json() + except ValueError: + return resp.text[:400] + if isinstance(body, dict): + messages = _messages_to_strings(body.get("messages")) + if messages: + return "; ".join(messages) + detail = body.get("detail") + if detail: + return str(detail) + return resp.text[:400] + + def validate_address(self, address: Address) -> AddressValidation: + """Create an address object; US addresses are validated automatically. + + Per Shippo's own docs, the response echoes back a (possibly + corrected/standardized) address at the top level alongside + ``validation_results`` - even for an address that fails validation. + We surface that corrected address so the caller can offer it as a + suggestion (e.g. a city that doesn't match its ZIP, like "Irvine" + for a ZIP whose USPS-preferred city is "Tustin") instead of silently + trusting whichever one - typed or corrected - happens to be right. + """ + payload = {**address.to_shippo(), "validate": True} + data = self._post("/addresses/", payload) + results = data.get("validation_results") or {} + messages = _messages_to_strings(results.get("messages")) + # Shippo omits ``is_valid`` when it could not run validation at all; treat a + # complete address with no error messages as acceptable so the example does + # not dead-end on carriers/addresses Shippo cannot verify in test mode. + is_complete = bool(data.get("is_complete", False)) + raw_valid = results.get("is_valid") + is_valid = bool(raw_valid) if raw_valid is not None else (is_complete and not messages) + + corrected: Address | None = None + if data.get("street1") or data.get("city") or data.get("zip"): + try: + corrected = Address( + name=str(data.get("name") or address.name), + street1=str(data.get("street1") or ""), + street2=str(data.get("street2") or ""), + city=str(data.get("city") or ""), + state=str(data.get("state") or ""), + zip=str(data.get("zip") or ""), + country=str(data.get("country") or address.country), + phone=str(data.get("phone") or ""), + email=str(data.get("email") or ""), + ) + except (TypeError, ValueError): + corrected = None + + return AddressValidation( + is_valid=is_valid, is_complete=is_complete, messages=messages, corrected=corrected + ) + + def rate_shop( + self, + address_from: Address, + address_to: Address, + parcel: Parcel, + *, + insurance_amount: float | None = None, + insurance_currency: str = "USD", + insurance_content: str = "Merchandise", + ) -> list[RateOption]: + """Create a shipment and return its test-mode rates (cheapest first). + + When ``insurance_amount`` is given, Shippo's documented shipment-level + ``extra.insurance`` flag is set (``{amount, currency, content}``), and + each returned rate's ``included_insurance_price`` reflects the premium + already folded into its ``amount``. Source: + https://docs.goshippo.com/docs/Shipments/ShippingInsurance + """ + payload: dict[str, Any] = { + "address_from": address_from.to_shippo(), + "address_to": address_to.to_shippo(), + "parcels": [parcel.to_shippo()], + "async": False, + } + if insurance_amount is not None and insurance_amount > 0: + payload["extra"] = { + "insurance": { + "amount": f"{insurance_amount:.2f}", + "currency": insurance_currency, + "content": insurance_content, + } + } + data = self._post("/shipments/", payload) + + if data.get("test") is not True: + raise ShippoError( + "Expected a test-mode shipment (test=true) but Shippo returned " + f"test={data.get('test')!r}. Aborting — this example is test-only." + ) + + rates: list[RateOption] = [] + for r in data.get("rates", []): + try: + rates.append( + RateOption( + rate_id=r["object_id"], + provider=r.get("provider", ""), + servicelevel_name=(r.get("servicelevel") or {}).get("name", "") + or r.get("servicelevel_name", ""), + servicelevel_token=(r.get("servicelevel") or {}).get("token", "") + or r.get("servicelevel_token", ""), + amount=float(r.get("amount", 0) or 0), + currency=r.get("currency", "USD"), + estimated_days=r.get("estimated_days"), + duration_terms=r.get("duration_terms", "") or "", + carrier_account=r.get("carrier_account", "") or "", + test=bool(r.get("test", True)), + included_insurance_price=float(r.get("included_insurance_price", 0) or 0), + ) + ) + except (KeyError, TypeError, ValueError): + continue + rates.sort(key=lambda o: o.amount) + return rates + + def purchase( + self, + rate_id: str, + *, + label_file_type: str = "PDF", + fallback_rate: RateOption | None = None, + ) -> PurchaseResult: + """Buy a label for ``rate_id``; asserts test=true and status=SUCCESS. + + Shippo's transaction response returns ``rate`` as a plain object-id + *string* unless you ask it to expand + (https://docs.goshippo.com/docs/API_Concepts/APIExpand) - so we + request ``?expand=rate`` to get the provider/service/amount back + inline. If Shippo still doesn't expand it (test mode has been known to + skip this), ``fallback_rate`` - the same :class:`RateOption` the user + picked during rate-shopping - fills in those fields instead, so the + confirmation never shows a blank carrier. + """ + payload = {"rate": rate_id, "label_file_type": label_file_type, "async": False} + data = self._post("/transactions/", payload, params={"expand": "rate"}) + + if data.get("test") is not True: + raise ShippoError( + "Transaction did not come back as test mode (test != true) — refusing " + "to treat it as a valid SAMPLE label." + ) + status = str(data.get("status", "")).upper() + if status != "SUCCESS": + messages = _messages_to_strings(data.get("messages")) or [ + f"status={status or 'UNKNOWN'}" + ] + raise ShippoError("Label purchase did not succeed: " + "; ".join(messages)) + + rate = data.get("rate") + amount: float | None = None + currency = fallback_rate.currency if fallback_rate else "USD" + provider = fallback_rate.provider if fallback_rate else "" + servicelevel_token = fallback_rate.servicelevel_token if fallback_rate else "" + if fallback_rate: + amount = fallback_rate.amount + if isinstance(rate, dict): + amount = float(rate.get("amount", 0) or 0) or amount + currency = rate.get("currency", currency) + provider = rate.get("provider", provider) or provider + servicelevel_token = rate.get("servicelevel_token", servicelevel_token) or ( + servicelevel_token + ) + + return PurchaseResult( + transaction_id=str(data.get("object_id", "")), + status=status, + test=True, + label_url=str(data.get("label_url", "")), + tracking_number=str(data.get("tracking_number", "")), + tracking_url_provider=str(data.get("tracking_url_provider", "") or ""), + amount=amount, + currency=currency, + provider=provider, + servicelevel_token=servicelevel_token, + ) + + def schedule_pickup( + self, + *, + carrier_account: str, + transaction_id: str, + address: Address, + start_hours_from_now: int = 2, + window_hours: int = 4, + building_location_type: str = "Front Door", + instructions: str = "", + ) -> PickupResult: + """Schedule a USPS / DHL Express pickup for an already-purchased label.""" + now = datetime.now(UTC) + start = now + timedelta(hours=start_hours_from_now) + end = start + timedelta(hours=window_hours) + location: dict[str, Any] = { + "building_location_type": building_location_type, + "address": address.to_shippo(), + } + if instructions: + location["instructions"] = instructions + payload = { + "carrier_account": carrier_account, + "location": location, + "transactions": [transaction_id], + "requested_start_time": start.isoformat().replace("+00:00", "Z"), + "requested_end_time": end.isoformat().replace("+00:00", "Z"), + "is_test": True, + } + data = self._post("/pickups/", payload) + return PickupResult( + status=str(data.get("status", "")), + confirmation_code=str(data.get("confirmation_code", "") or ""), + confirmed_start_time=str(data.get("confirmed_start_time", "") or ""), + confirmed_end_time=str(data.get("confirmed_end_time", "") or ""), + messages=_messages_to_strings(data.get("messages")), + ) diff --git a/pydantic-agent/shipping-label-agent/tests/conftest.py b/pydantic-agent/shipping-label-agent/tests/conftest.py new file mode 100644 index 00000000..e681227b --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/conftest.py @@ -0,0 +1,41 @@ +"""Shared test setup. + +Sets deterministic **test** credentials for the whole suite before any project +module is imported, and exposes an autouse fixture that fails loudly if any key +does not match its test-key prefix - so a live key can never sneak into a run. +""" + +from __future__ import annotations + +import os + +import pytest + +# Test credentials — set before importing project modules. These are obviously +# fake and only exercise the test-key-prefix guards; no network call uses them. +os.environ.setdefault("STRIPE_SECRET_KEY", "sk_test_dummy_secret") +os.environ.setdefault("STRIPE_PUBLISHABLE_KEY", "pk_test_dummy_publishable") +os.environ.setdefault("SHIPPO_TOKEN", "shippo_test_dummy_token") +os.environ.setdefault("ASI_ONE_API_KEY", "asi-test-key-not-used") + +STRIPE_TEST_SECRET_PREFIX = "sk_test_" +STRIPE_TEST_PUBLISHABLE_PREFIX = "pk_test_" +SHIPPO_TEST_PREFIX = "shippo_test_" + + +@pytest.fixture(autouse=True) +def require_test_keys() -> None: + """Fail the test run loudly unless every configured key is a test key.""" + secret = os.environ.get("STRIPE_SECRET_KEY", "") + assert secret.startswith(STRIPE_TEST_SECRET_PREFIX), ( + f"STRIPE_SECRET_KEY must start with {STRIPE_TEST_SECRET_PREFIX!r} (test key only)" + ) + publishable = os.environ.get("STRIPE_PUBLISHABLE_KEY", "") + if publishable: + assert publishable.startswith(STRIPE_TEST_PUBLISHABLE_PREFIX), ( + f"STRIPE_PUBLISHABLE_KEY must start with {STRIPE_TEST_PUBLISHABLE_PREFIX!r}" + ) + shippo_token = os.environ.get("SHIPPO_TOKEN", "") + assert shippo_token.startswith(SHIPPO_TEST_PREFIX), ( + f"SHIPPO_TOKEN must start with {SHIPPO_TEST_PREFIX!r} (test token only)" + ) diff --git a/pydantic-agent/shipping-label-agent/tests/test_address_confirmation.py b/pydantic-agent/shipping-label-agent/tests/test_address_confirmation.py new file mode 100644 index 00000000..8b05c319 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_address_confirmation.py @@ -0,0 +1,428 @@ +"""The address-correction confirmation step (``AWAITING_SENDER_CONFIRM`` / +``AWAITING_PACKAGE_CONFIRM``). + +Shippo's own address validator can call an address "valid" (deliverable) +while silently normalizing something the user typed wrong - most concretely, +a city that doesn't match its own ZIP (e.g. "Irvine" for a ZIP whose +USPS-preferred city is "Tustin"). That slipped straight through the old +"is_valid or bounce back to the form" check, only to fail later - after +money had already changed hands - when the carrier's own stricter address +validator rejected it at label-purchase time. + +These tests cover the fix end to end: a correction pauses the flow with a +real ``review`` card offering "use suggested" vs "keep as typed", for both +the sender (ship-from) and recipient (package) addresses, plus the +fail-open behavior when Shippo's validation call itself errors. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest + +pytest.importorskip("uagents") +pytest.importorskip("pydantic_ai") +pytest.importorskip("httpx") + +import httpx + +import chat_proto +from session_state import ( + AWAITING_PACKAGE, + AWAITING_PACKAGE_CONFIRM, + AWAITING_SENDER, + AWAITING_SENDER_CONFIRM, + SHOWING_RATES, + default_state, + save_state, +) + +SENDER = "agent1qbuyer" + +VALID_SENDER_SELECTION = { + "action": "submit_sender", + "from_name": "Phu Quach", + "from_street1": "30 Preston Pl", + "from_city": "Irvine", + "from_state": "CA", + "from_zip": "92782", + "from_phone": "+1 555 111 2222", + "from_email": "phu@example.com", +} + +_SAVED_SENDER_PROFILE = { + "from_name": "Phu Quach", + "from_street1": "30 Preston Pl", + "from_city": "Irvine", + "from_state": "CA", + "from_zip": "92782", + "from_country": "US", + "from_phone": "+1 555 111 2222", + "from_email": "phu@example.com", +} + +VALID_PACKAGE_SELECTION = { + "action": "submit_package", + "to_name": "Aditya Lagad", + "to_street1": "99 Monroe Ave NW", + "to_city": "Irvine", + "to_state": "CA", + "to_zip": "92782", + "weight_lb": 2, + "length_in": 12, + "width_in": 9, + "height_in": 4, +} + + +class _FakeStorage: + def __init__(self) -> None: + self._data: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self._data.get(key) + + def set(self, key: str, value: str) -> None: + self._data[key] = value + + +class _FakeLogger: + def info(self, *_: object) -> None: + pass + + def warning(self, *_: object) -> None: + pass + + def error(self, *_: object) -> None: + pass + + +class FakeContext: + def __init__(self) -> None: + self.session = "session-123" + self.agent = SimpleNamespace(address="agent1qseller") + self.storage = _FakeStorage() + self.logger = _FakeLogger() + self.sent: list[Any] = [] + + async def send(self, _sender: str, message: Any) -> None: + self.sent.append(message) + + +def _corrected_address_handler(request: httpx.Request) -> httpx.Response: + """A "valid, but here's a normalized version" response - the Irvine/Tustin + case that started this whole investigation.""" + return httpx.Response( + 201, + json={ + "object_id": "addr_ok", + "is_complete": True, + "validation_results": {"is_valid": True, "messages": []}, + "name": "placeholder", + "street1": "30 Preston Pl", + "city": "Tustin", + "state": "CA", + "zip": "92782", + "country": "US", + }, + ) + + +def _exact_match_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "object_id": "addr_ok", + "is_complete": True, + "validation_results": {"is_valid": True, "messages": []}, + }, + ) + + +def _erroring_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, text="Shippo is down") + + +def _rate_shop_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "object_id": "ship_1", + "status": "SUCCESS", + "test": True, + "rates": [ + { + "object_id": "r1", + "provider": "USPS", + "servicelevel": {"name": "Priority", "token": "usps_priority"}, + "amount": "9.50", + "currency": "USD", + "estimated_days": 3, + "test": True, + } + ], + }, + ) + + +def _patch_shippo_client( + monkeypatch: pytest.MonkeyPatch, handler: Callable[[httpx.Request], httpx.Response] +) -> None: + def _fake_shippo_client() -> chat_proto.ShippoClient: + http = httpx.Client( + transport=httpx.MockTransport(handler), base_url="https://api.goshippo.com" + ) + return chat_proto.ShippoClient("shippo_test_dummy_token", client=http) + + monkeypatch.setattr(chat_proto, "shippo_client", _fake_shippo_client) + + +def _last_card_payload(ctx: FakeContext) -> dict[str, Any]: + payloads = [c.metadata for m in ctx.sent for c in m.content if hasattr(c, "metadata")] + return json.loads(payloads[-1]["card_payload"]) + + +def _last_card_kind(ctx: FakeContext) -> str: + payloads = [c.metadata for m in ctx.sent for c in m.content if hasattr(c, "metadata")] + return str(payloads[-1]["card_kind"]) + + +# sender address correction +async def test_sender_address_with_corrected_city_pauses_for_confirmation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_shippo_client(monkeypatch, _corrected_address_handler) + ctx = FakeContext() + state = default_state() + state["state"] = AWAITING_SENDER + state["stripe_paid"] = True + save_state(ctx, SENDER, state) + + await chat_proto._handle_sender_stage(ctx, SENDER, state, "", VALID_SENDER_SELECTION) + + assert state["state"] == AWAITING_SENDER_CONFIRM + assert state["sender_profile"] is None # not committed yet + assert _last_card_kind(ctx) == "review" + payload = _last_card_payload(ctx) + rows = {row["label"]: row["value"] for row in payload["summary_rows"]} + assert "Irvine" in rows["You entered"] + assert "Tustin" in rows["Suggested (USPS-verified)"] + assert payload["approve_cta"]["selection"]["action"] == "use_suggested_address" + assert payload["reject_cta"]["selection"]["action"] == "keep_typed_address" + + +async def test_sender_confirm_use_suggested_applies_the_correction( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ctx = FakeContext() + state = default_state() + state["state"] = AWAITING_SENDER_CONFIRM + state["stripe_paid"] = True + state["pending_sender_typed"] = _SAVED_SENDER_PROFILE + state["pending_sender_corrected"] = { + "name": "placeholder", + "street1": "30 Preston Pl", + "street2": "", + "city": "Tustin", + "state": "CA", + "zip": "92782", + "country": "US", + "phone": "", + "email": "", + } + save_state(ctx, SENDER, state) + + await chat_proto._handle_sender_confirm_stage( + ctx, SENDER, state, {"action": "use_suggested_address"} + ) + + assert state["state"] == AWAITING_PACKAGE + assert state["sender_profile"]["from_city"] == "Tustin" + assert "pending_sender_typed" not in state or state.get("pending_sender_typed") is None + + +async def test_sender_confirm_keep_typed_preserves_original( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ctx = FakeContext() + state = default_state() + state["state"] = AWAITING_SENDER_CONFIRM + state["stripe_paid"] = True + state["pending_sender_typed"] = _SAVED_SENDER_PROFILE + state["pending_sender_corrected"] = { + "name": "placeholder", + "street1": "30 Preston Pl", + "street2": "", + "city": "Tustin", + "state": "CA", + "zip": "92782", + "country": "US", + "phone": "", + "email": "", + } + save_state(ctx, SENDER, state) + + await chat_proto._handle_sender_confirm_stage( + ctx, SENDER, state, {"action": "keep_typed_address"} + ) + + assert state["state"] == AWAITING_PACKAGE + assert state["sender_profile"]["from_city"] == "Irvine" + + +async def test_sender_confirm_unclear_reply_re_shows_the_card( + monkeypatch: pytest.MonkeyPatch, +) -> None: + ctx = FakeContext() + state = default_state() + state["state"] = AWAITING_SENDER_CONFIRM + state["stripe_paid"] = True + state["pending_sender_typed"] = _SAVED_SENDER_PROFILE + state["pending_sender_corrected"] = { + "name": "placeholder", + "street1": "30 Preston Pl", + "street2": "", + "city": "Tustin", + "state": "CA", + "zip": "92782", + "country": "US", + "phone": "", + "email": "", + } + save_state(ctx, SENDER, state) + + await chat_proto._handle_sender_confirm_stage(ctx, SENDER, state, {}) + + assert state["state"] == AWAITING_SENDER_CONFIRM # unchanged, still pending + assert _last_card_kind(ctx) == "review" + + +async def test_sender_validation_outage_fails_open(monkeypatch: pytest.MonkeyPatch) -> None: + """A Shippo outage during validation must not dead-end the whole demo - + the user already typed a complete address, so proceed with it.""" + _patch_shippo_client(monkeypatch, _erroring_handler) + ctx = FakeContext() + state = default_state() + state["state"] = AWAITING_SENDER + state["stripe_paid"] = True + save_state(ctx, SENDER, state) + + await chat_proto._handle_sender_stage(ctx, SENDER, state, "", VALID_SENDER_SELECTION) + + assert state["state"] == AWAITING_PACKAGE + assert state["sender_profile"]["from_name"] == "Phu Quach" + + +# recipient (package) address correction +def _state_with_sender(stage: str) -> dict[str, Any]: + state = default_state() + state["state"] = stage + state["stripe_paid"] = True + state["sender_profile"] = _SAVED_SENDER_PROFILE + return state + + +async def test_package_address_with_corrected_city_pauses_for_confirmation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_shippo_client(monkeypatch, _corrected_address_handler) + ctx = FakeContext() + state = _state_with_sender(AWAITING_PACKAGE) + save_state(ctx, SENDER, state) + + await chat_proto._handle_package_stage(ctx, SENDER, state, "", VALID_PACKAGE_SELECTION) + + assert state["state"] == AWAITING_PACKAGE_CONFIRM + assert state["package"] is None # not committed / rate-shopped yet + assert _last_card_kind(ctx) == "review" + + +async def test_package_confirm_use_suggested_then_rate_shops( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_shippo_client(monkeypatch, _rate_shop_handler) + ctx = FakeContext() + state = _state_with_sender(AWAITING_PACKAGE_CONFIRM) + state["pending_package_typed"] = { + "to_name": "Aditya Lagad", + "to_street1": "99 Monroe Ave NW", + "to_city": "Irvine", + "to_state": "CA", + "to_zip": "92782", + "to_country": "US", + "weight_lb": 2, + "length_in": 12, + "width_in": 9, + "height_in": 4, + "declared_value_usd": 0, + } + state["pending_package_corrected"] = { + "name": "placeholder", + "street1": "99 Monroe Ave NW", + "street2": "", + "city": "Tustin", + "state": "CA", + "zip": "92782", + "country": "US", + "phone": "", + "email": "", + } + save_state(ctx, SENDER, state) + + await chat_proto._handle_package_confirm_stage( + ctx, SENDER, state, {"action": "use_suggested_address"} + ) + + assert state["state"] == SHOWING_RATES + assert state["package"]["to_city"] == "Tustin" + assert len(state["rates"]) == 1 + + +async def test_package_validation_outage_fails_open_into_rate_shop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + if request.url.path == "/addresses/": + return _erroring_handler(request) + return _rate_shop_handler(request) + + _patch_shippo_client(monkeypatch, handler) + ctx = FakeContext() + state = _state_with_sender(AWAITING_PACKAGE) + save_state(ctx, SENDER, state) + + await chat_proto._handle_package_stage(ctx, SENDER, state, "", VALID_PACKAGE_SELECTION) + + assert state["state"] == SHOWING_RATES + assert "/addresses/" in calls + assert "/shipments/" in calls + + +async def test_no_correction_needed_when_address_matches_exactly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The common case - no mismatch - must not be interrupted by a + confirmation step at all.""" + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + if request.url.path == "/addresses/": + return _exact_match_handler(request) + return _rate_shop_handler(request) + + _patch_shippo_client(monkeypatch, handler) + ctx = FakeContext() + state = _state_with_sender(AWAITING_PACKAGE) + save_state(ctx, SENDER, state) + + await chat_proto._handle_package_stage(ctx, SENDER, state, "", VALID_PACKAGE_SELECTION) + + assert state["state"] == SHOWING_RATES diff --git a/pydantic-agent/shipping-label-agent/tests/test_agent_logic.py b/pydantic-agent/shipping-label-agent/tests/test_agent_logic.py new file mode 100644 index 00000000..a69ed765 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_agent_logic.py @@ -0,0 +1,185 @@ +"""Pydantic AI logic tests, driven by FunctionModel/TestModel (no ASI:One call). + +The centrepiece is the ``requires_approval=True`` gate on ``purchase_label``: + +* ``start_purchase`` must *defer* - the label must NOT be bought yet. +* resuming with an approval buys the SAMPLE label and returns a PurchaseResult. +* resuming with a denial buys nothing. + +Also verifies structured-output extraction (``extract_package``) runs offline. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +# Skip cleanly (rather than erroring collection) if run outside the example venv. +pytest.importorskip("pydantic_ai") +pytest.importorskip("httpx") + +import httpx +from pydantic_ai import ModelResponse, TextPart, ToolCallPart +from pydantic_ai.messages import ModelMessage +from pydantic_ai.models.function import AgentInfo, FunctionModel +from pydantic_ai.models.test import TestModel + +from pydantic_agent import ( + PackageDetails, + ShippingDeps, + extract_package, + resume_purchase, + start_purchase, +) +from shipping import RateOption, ShippoClient + +TEST_TOKEN = "shippo_test_dummy_token" +RATE_ID = "r_cheap" + + +def _fake_shippo(handler: Callable[[httpx.Request], httpx.Response]) -> ShippoClient: + http = httpx.Client(transport=httpx.MockTransport(handler), base_url="https://api.goshippo.com") + return ShippoClient(TEST_TOKEN, client=http) + + +def _successful_transaction_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "object_id": "txn_1", + "status": "SUCCESS", + "test": True, + "label_url": "https://deliver.goshippo.com/txn_1.pdf", + "tracking_number": "9271901755477000000000011", + "rate": { + "amount": "7.50", + "currency": "USD", + "provider": "USPS", + "servicelevel_token": "usps_ground", + }, + "messages": [], + }, + ) + + +def _purchase_script(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + """Call the gated tool first; once it has returned, emit a final text reply.""" + for message in messages: + for part in getattr(message, "parts", []): + if part.__class__.__name__ == "ToolReturnPart": + return ModelResponse(parts=[TextPart("Done — the test label was purchased.")]) + return ModelResponse(parts=[ToolCallPart("purchase_label", {"rate_id": RATE_ID})]) + + +async def test_purchase_gate_defers_before_approval() -> None: + """requires_approval must pause the purchase: no label bought on the first run.""" + deps = ShippingDeps(shippo=_fake_shippo(_successful_transaction_handler)) + model = FunctionModel(_purchase_script) + + start = await start_purchase(deps, RATE_ID, model=model) + + assert start.deferred is True + assert start.tool_call_id # a pending approval exists + assert deps.purchase_result is None # the label was NOT bought yet + + +async def test_purchase_completes_on_approval() -> None: + deps = ShippingDeps(shippo=_fake_shippo(_successful_transaction_handler)) + model = FunctionModel(_purchase_script) + + start = await start_purchase(deps, RATE_ID, model=model) + result = await resume_purchase( + deps, + history_json=start.history_json, + tool_call_id=start.tool_call_id, + approved=True, + model=model, + ) + + assert result is not None + assert result.status == "SUCCESS" + assert result.test is True + assert result.transaction_id == "txn_1" + assert deps.purchase_result is result + + +async def test_purchase_cancelled_on_denial() -> None: + calls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + return _successful_transaction_handler(request) + + deps = ShippingDeps(shippo=_fake_shippo(handler)) + model = FunctionModel(_purchase_script) + + start = await start_purchase(deps, RATE_ID, model=model) + result = await resume_purchase( + deps, + history_json=start.history_json, + tool_call_id=start.tool_call_id, + approved=False, + model=model, + ) + + assert result is None + assert deps.purchase_result is None + assert calls == [] # the Shippo transaction endpoint was never hit + + +async def test_purchase_backfills_provider_from_selected_rate() -> None: + """Real Shippo transaction responses return ``rate`` as a bare id string + unless expanded; ``ShippingDeps.selected_rate`` is the fallback that keeps + the final confirmation from showing a blank/unknown carrier.""" + + def unexpanded_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "object_id": "txn_2", + "status": "SUCCESS", + "test": True, + "label_url": "https://deliver.goshippo.com/txn_2.pdf", + "tracking_number": "9271901755477000000000022", + "rate": "819282d7ec8d4b1db3d03bbf8f0e1a7d", # plain id, not expanded + "messages": [], + }, + ) + + selected_rate = RateOption( + rate_id=RATE_ID, + provider="UPS", + servicelevel_name="Ground Saver", + servicelevel_token="ups_ground_saver", + amount=14.01, + currency="USD", + ) + deps = ShippingDeps( + shippo=_fake_shippo(unexpanded_handler), + selected_rate=selected_rate, + ) + model = FunctionModel(_purchase_script) + + start = await start_purchase(deps, RATE_ID, model=model) + result = await resume_purchase( + deps, + history_json=start.history_json, + tool_call_id=start.tool_call_id, + approved=True, + model=model, + ) + + assert result is not None + assert result.provider == "UPS" + assert result.servicelevel_token == "ups_ground_saver" + + +async def test_structured_extraction_offline() -> None: + pkg: PackageDetails = await extract_package( + "Ship a 2 lb, 5x5x5 in box to Mrs Hippo, 965 Mission St, San Francisco, CA 94105.", + model=TestModel(), + ) + assert isinstance(pkg, PackageDetails) + assert pkg.weight_lb > 0 + assert pkg.length_in > 0 diff --git a/pydantic-agent/shipping-label-agent/tests/test_chat_proto.py b/pydantic-agent/shipping-label-agent/tests/test_chat_proto.py new file mode 100644 index 00000000..13df91e2 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_chat_proto.py @@ -0,0 +1,309 @@ +"""Card-shape, curation, and wording tests for ``chat_proto.py``. + +These lock in the concrete UX fixes from review: + +* The carousel now matches the documented ASI:One schema (top-level + ``title``/``subtitle``, a per-item ``id``, ``badges`` as ``{label, variant}`` + dicts) - the previous shape was missing all three, which is exactly why + ASI:One silently fell back to a text wall instead of rendering a card. +* Rate shopping shows a short curated list (cheapest/fastest/recommended) by + default instead of dumping every option, with a 'show all' escape hatch. +* The ship-from sender *name* is always the fixed demo placeholder, never + read from env or (worse) the recipient's name. +* Empty 'Terms'/'Mode' rows are omitted instead of rendered as '-' placeholders. +* The final confirmation is the only place TEST/SAMPLE wording appears, and it + sources the carrier from the rate the user actually picked (not the + possibly-blank Shippo transaction field). +""" + +from __future__ import annotations + +import json + +import pytest + +# Skip cleanly (rather than erroring collection) if run outside the example venv. +pytest.importorskip("uagents") +pytest.importorskip("pydantic_ai") +pytest.importorskip("httpx") + +import chat_proto +from shipping import FIXED_SENDER_NAME, PurchaseResult, RateOption + + +def _rate(rate_id: str, provider: str, amount: float, days: int = 3, terms: str = "") -> RateOption: + return RateOption( + rate_id=rate_id, + provider=provider, + servicelevel_name=f"{provider} Service", + servicelevel_token=f"{provider.lower()}_svc", + amount=amount, + currency="USD", + estimated_days=days, + duration_terms=terms, + ) + + +RATES = [ + _rate("r_cheap", "USPS", 7.50, days=4), + _rate("r_mid", "UPS", 14.00, days=3), + _rate("r_fast", "FedEx", 40.00, days=1), + _rate("r_pricey", "UPS", 90.00, days=1), +] + + +# sender profile (captured from the user, kept separate from the recipient) +_SENDER_PROFILE = { + "from_name": "Jane Sender", + "from_street1": "1 Market St.", + "from_city": "San Francisco", + "from_state": "CA", + "from_zip": "94105", + "from_country": "US", + "from_phone": "", + "from_email": "", +} + + +def test_sender_address_built_from_captured_profile_not_recipient() -> None: + """The recipient's name must never leak into the sender address - the sender + is built purely from the separately-captured profile dict.""" + addr = chat_proto.sender_address_from_profile(_SENDER_PROFILE) + assert addr.name == "Jane Sender" + assert addr.name != "Whatever The Recipient Typed" + + +def test_sender_profile_summary_mentions_the_captured_address() -> None: + summary = chat_proto.sender_profile_summary(_SENDER_PROFILE) + assert "Jane Sender" in summary + assert "1 Market St." in summary + + +def test_sender_profile_form_card_has_no_default_env_dependency() -> None: + """The form's placeholders are static hints (Shippo's canonical demo + persona), not values pulled from any SHIP_FROM_* env var - there is no such + env var anymore.""" + card = chat_proto.sender_profile_form_card() + payload = json.loads(card["card_payload"]) + field_names = {f["name"] for f in payload["fields"]} + assert field_names == { + "from_name", + "from_street1", + "from_city", + "from_state", + "from_zip", + "from_phone", + "from_email", + } + name_field = next(f for f in payload["fields"] if f["name"] == "from_name") + assert name_field["placeholder"] == FIXED_SENDER_NAME + + +def test_sender_from_selection_builds_a_sender_profile() -> None: + profile = chat_proto._sender_from_selection(_SENDER_PROFILE) + assert profile is not None + assert profile.from_name == "Jane Sender" + + +# carousel schema +def test_carousel_matches_the_documented_asi_one_schema() -> None: + tags = chat_proto._tag_rates(RATES) + card = chat_proto.rates_carousel(RATES, tags) + assert card["card_kind"] == "carousel" + payload = json.loads(card["card_payload"]) + + assert payload["title"] + assert payload["subtitle"] + assert len(payload["items"]) == len(RATES) + for item, rate in zip(payload["items"], RATES): + assert item["id"] == rate.rate_id + assert item["title"] + assert "primary_cta" in item + assert item["primary_cta"]["selection"]["rate_id"] == rate.rate_id + for badge in item.get("badges", []): + assert set(badge) == {"label", "variant"} + + +def test_carousel_adds_a_tappable_see_all_tile_when_curated() -> None: + """Seeing the full list must be a real tappable card element, not only a + text hint - otherwise it's not discoverable without typing something.""" + tags = chat_proto._tag_rates(RATES) + curated = chat_proto._curate_rates(RATES, tags) + card = chat_proto.rates_carousel(curated, tags, total=len(RATES)) + payload = json.loads(card["card_payload"]) + + assert len(payload["items"]) == len(curated) + 1 + tile = payload["items"][-1] + assert tile["primary_cta"]["selection"]["action"] == "show_all_rates" + assert str(len(RATES)) in tile["title"] + + +def test_carousel_has_no_see_all_tile_when_already_showing_everything() -> None: + tags = chat_proto._tag_rates(RATES) + card = chat_proto.rates_carousel(RATES, tags, total=len(RATES)) + payload = json.loads(card["card_payload"]) + assert len(payload["items"]) == len(RATES) + + +def test_curate_rates_picks_cheapest_fastest_recommended_deduped() -> None: + tags = chat_proto._tag_rates(RATES) + curated = chat_proto._curate_rates(RATES, tags) + assert 1 <= len(curated) <= 3 + ids = {r.rate_id for r in curated} + assert tags["cheapest"] in ids + assert tags["fastest"] in ids + assert tags["recommended"] in ids + + +def test_rates_text_summary_mentions_show_all_when_curated() -> None: + tags = chat_proto._tag_rates(RATES) + curated = chat_proto._curate_rates(RATES, tags) + text = chat_proto.rates_text_summary(curated, tags, total=len(RATES)) + assert "show all" in text.lower() + assert str(len(RATES)) in text + + +def test_show_all_rates_keyword_parses_to_action() -> None: + selection = chat_proto.parse_selection("show all options please") + assert selection.get("action") == "show_all_rates" + + +# Terms / dash-free rows +def test_rate_detail_card_omits_empty_terms_row() -> None: + rate = _rate("r1", "USPS", 10.0, terms="") + payload = json.loads(chat_proto.rate_detail_card(rate)["card_payload"]) + labels = [row["label"] for row in payload["summary_rows"]] + assert "Terms" not in labels + assert not any(row["value"] == "-" for row in payload["summary_rows"]) + + +def test_rate_detail_card_includes_terms_when_present() -> None: + rate = _rate("r1", "USPS", 10.0, terms="1-3 business days") + payload = json.loads(chat_proto.rate_detail_card(rate)["card_payload"]) + rows = {row["label"]: row["value"] for row in payload["summary_rows"]} + assert rows["Terms"] == "1-3 business days" + + +def test_purchase_review_card_has_no_mode_row() -> None: + """TEST/SAMPLE wording belongs on the final confirmation only.""" + rate = _rate("r1", "USPS", 10.0) + payload = json.loads(chat_proto.purchase_review_card(rate)["card_payload"]) + labels = [row["label"] for row in payload["summary_rows"]] + assert "Mode" not in labels + assert not any("TEST" in str(row["value"]) for row in payload["summary_rows"]) + + +# final confirmation +def test_confirmation_card_sources_carrier_from_the_selected_rate() -> None: + """Guards the blank-carrier bug: Shippo's transaction ``rate`` field comes + back unexpanded as a bare id, so the confirmation must use the rate the + user actually picked earlier in the conversation, not ``purchase.provider``.""" + purchase = PurchaseResult( + transaction_id="txn_1", + status="SUCCESS", + test=True, + label_url="https://deliver.goshippo.com/x.pdf", + tracking_number="1Z999AA10123456784", + provider="", # simulates Shippo not expanding rate + ) + rate = _rate("r1", "UPS", 14.0) + payload = json.loads(chat_proto.confirmation_card(purchase, rate)["card_payload"]) + rows = {row["label"]: row["value"] for row in payload["summary_rows"]} + assert rows["Carrier"] == "UPS UPS Service" + assert rows["Tracking number"] == "1Z999AA10123456784" + + +def test_confirmation_card_mentions_test_sample_exactly_once() -> None: + purchase = PurchaseResult( + transaction_id="txn_1", + status="SUCCESS", + test=True, + label_url="https://deliver.goshippo.com/x.pdf", + tracking_number="1Z999AA10123456784", + ) + rate = _rate("r1", "UPS", 14.0) + payload = json.loads(chat_proto.confirmation_card(purchase, rate)["card_payload"]) + mentions = sum( + 1 + for row in payload["summary_rows"] + if "TEST" in str(row["value"]) or "SAMPLE" in str(row["value"]) + ) + assert mentions == 1 + + +def test_no_stray_resource_content_helpers_remain() -> None: + """The broken duplicate-link artifact came from ExternalStorage/ResourceContent + delivery; that code path was removed in favor of the direct label_url link.""" + assert not hasattr(chat_proto, "_download_bytes") + + +# natural-language rate text (no [], ~, (), or trademark glyphs) +def test_rate_text_and_carousel_are_free_of_technical_artifacts() -> None: + rate = RateOption( + rate_id="r_tm", + provider="UPS", + servicelevel_name="2nd Day Air\u00ae", + servicelevel_token="ups_2nd_day", + amount=64.92, + currency="USD", + estimated_days=2, + ) + tags = chat_proto._tag_rates([rate]) + text = chat_proto.rates_text_summary([rate], tags, total=1) + carousel = json.loads(chat_proto.rates_carousel([rate], tags)["card_payload"]) + title = carousel["items"][0]["title"] + + for artifact in ("[", "]", "~", "(", ")", "\u00ae", "USD ", "day(s)"): + assert artifact not in text, f"found {artifact!r} in rate text" + assert artifact not in title, f"found {artifact!r} in carousel title" + assert "$64.92" in text # money uses a $ sign, not 'USD 64.92' + + +# pre-flight sanity checks on the intake form +def _pkg_selection(**overrides: object) -> dict[str, object]: + base = { + "to_name": "Mrs Hippo", + "to_street1": "965 Mission St", + "to_city": "San Francisco", + "to_state": "CA", + "to_zip": "94105", + "weight_lb": 2, + "length_in": 5, + "width_in": 5, + "height_in": 5, + "declared_value_usd": 100, + } + base.update(overrides) + return base + + +def test_preflight_flags_nonpositive_dimensions() -> None: + assert chat_proto._preflight_selection_error(_pkg_selection(weight_lb=0)) is not None + assert chat_proto._preflight_selection_error(_pkg_selection(length_in=-1)) is not None + + +def test_preflight_flags_implausible_declared_value() -> None: + assert ( + chat_proto._preflight_selection_error(_pkg_selection(declared_value_usd=50000)) is not None + ) + assert chat_proto._preflight_selection_error(_pkg_selection(declared_value_usd=-5)) is not None + + +def test_preflight_passes_sane_package_and_ignores_prose() -> None: + assert chat_proto._preflight_selection_error(_pkg_selection()) is None + # A prose message (no numeric form fields) is not the form path -> no check. + assert chat_proto._preflight_selection_error({"action": "select_rate"}) is None + + +# international fail-fast +def test_international_text_is_detected() -> None: + assert chat_proto._looks_international_text("Send it to Toronto, Canada M5V 2T6") is True + assert chat_proto._looks_international_text("965 Mission St, San Francisco, CA 94105") is False + + +def test_international_selection_is_detected_by_country_or_postal() -> None: + assert chat_proto._looks_international_selection({"to_country": "CA"}) is True + assert chat_proto._looks_international_selection({"to_zip": "M5V 2T6"}) is True + assert ( + chat_proto._looks_international_selection({"to_zip": "94105", "to_country": "US"}) is False + ) diff --git a/pydantic-agent/shipping-label-agent/tests/test_hazmat_insurance.py b/pydantic-agent/shipping-label-agent/tests/test_hazmat_insurance.py new file mode 100644 index 00000000..3059f5c9 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_hazmat_insurance.py @@ -0,0 +1,330 @@ +"""Hazmat self-certification and optional-insurance gates. + +Both fire per shipment, after a rate is picked and before the purchase-approval +step. This verifies: + +* "Continue to purchase" routes into the hazmat gate (not straight to approval). +* "This needs special handling" stops the purchase and links the two + carrier-specific hazmat guides - it never buys a label. +* "Nothing hazardous" skips insurance when the declared value is already + covered for free, and offers insurance only when it isn't. +* Adding insurance re-quotes the chosen service and carries the higher total + forward; skipping keeps the base rate. +""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest + +pytest.importorskip("uagents") +pytest.importorskip("pydantic_ai") +pytest.importorskip("httpx") + +import httpx + +import chat_proto +import session_state +from shipping import ( + UPS_HAZMAT_GUIDE_URL, + USPS_HAZMAT_GUIDE_URL, + RateOption, + ShippoClient, +) + +SENDER = "agent1qbuyer" +TOKEN = "shippo_test_dummy_token" + +RATE = RateOption( + rate_id="r_base", + provider="USPS", + servicelevel_name="Ground Advantage", + servicelevel_token="usps_ground_advantage", + amount=9.55, + currency="USD", + estimated_days=3, +) + +_PACKAGE = { + "to_name": "Mrs Hippo", + "to_street1": "965 Mission St", + "to_city": "San Francisco", + "to_state": "CA", + "to_zip": "94105", + "to_country": "US", + "weight_lb": 2.0, + "length_in": 5.0, + "width_in": 5.0, + "height_in": 5.0, + "declared_value_usd": 500.0, +} + +_SENDER_PROFILE = { + "from_name": "Jane Sender", + "from_street1": "1 Market St.", + "from_city": "San Francisco", + "from_state": "CA", + "from_zip": "94105", + "from_country": "US", + "from_phone": "", + "from_email": "", +} + + +class _FakeStorage: + def __init__(self) -> None: + self._data: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self._data.get(key) + + def set(self, key: str, value: str) -> None: + self._data[key] = value + + +class _FakeLogger: + def info(self, *_: object) -> None: ... + def error(self, *_: object) -> None: ... + def warning(self, *_: object) -> None: ... + def debug(self, *_: object) -> None: ... + + +class FakeContext: + def __init__(self) -> None: + self.session = "session-1" + self.agent = SimpleNamespace(address="agent1qseller") + self.storage = _FakeStorage() + self.logger = _FakeLogger() + self.sent: list[Any] = [] + + async def send(self, _sender: str, message: Any) -> None: + self.sent.append(message) + + +def _fake_shippo(handler: Callable[[httpx.Request], httpx.Response]) -> ShippoClient: + http = httpx.Client(transport=httpx.MockTransport(handler), base_url="https://api.goshippo.com") + return ShippoClient(TOKEN, client=http) + + +def _last_card(ctx: FakeContext) -> dict[str, str]: + metas = [ + c.metadata for m in ctx.sent for c in getattr(m, "content", []) if hasattr(c, "metadata") + ] + return metas[-1] + + +def _last_card_payload(ctx: FakeContext) -> dict[str, Any]: + return json.loads(_last_card(ctx)["card_payload"]) + + +def _texts(ctx: FakeContext) -> str: + out: list[str] = [] + for m in ctx.sent: + for c in getattr(m, "content", []): + if hasattr(c, "text"): + out.append(c.text) + return "\n".join(out) + + +def _state(stage: str, *, declared: float = 500.0) -> dict[str, Any]: + state = session_state.default_state() + state["stripe_paid"] = True + state["state"] = stage + state["sender_profile"] = _SENDER_PROFILE + pkg = dict(_PACKAGE) + pkg["declared_value_usd"] = declared + state["package"] = pkg + state["rates"] = [RATE.model_dump()] + state["selected_rate_id"] = RATE.rate_id + return state + + +# routing into the hazmat gate +async def test_continue_to_purchase_routes_to_hazmat_gate() -> None: + ctx = FakeContext() + state = _state(session_state.SHOWING_DETAIL) + await chat_proto._handle_detail_stage( + ctx, SENDER, state, {"action": "buy_rate", "rate_id": RATE.rate_id} + ) + assert state["state"] == session_state.AWAITING_HAZMAT_CHECK + payload = _last_card_payload(ctx) + assert _last_card(ctx)["card_kind"] == "review" + assert payload["approve_cta"]["selection"]["action"] == "hazmat_clear" + assert payload["reject_cta"]["selection"]["action"] == "hazmat_stop" + + +# "this needs special handling" -> stop, link both guides, no purchase +async def test_hazmat_special_handling_stops_and_links_guides( + monkeypatch: pytest.MonkeyPatch, +) -> None: + began: list[RateOption] = [] + + async def _spy_begin(_ctx: Any, _sender: str, _state: Any, rate: RateOption) -> None: + began.append(rate) + + monkeypatch.setattr(chat_proto, "_begin_purchase_approval", _spy_begin) + ctx = FakeContext() + state = _state(session_state.AWAITING_HAZMAT_CHECK) + + await chat_proto._handle_hazmat_stage(ctx, SENDER, state, "", {"action": "hazmat_stop"}) + + assert state["state"] == session_state.DONE + assert began == [] # never started a purchase + body = _texts(ctx) + assert USPS_HAZMAT_GUIDE_URL in body + assert UPS_HAZMAT_GUIDE_URL in body + + +# "nothing hazardous" -> insurance gate behavior +async def test_hazmat_clear_skips_insurance_when_declared_value_is_covered( + monkeypatch: pytest.MonkeyPatch, +) -> None: + began: list[RateOption] = [] + + async def _spy_begin(_ctx: Any, _sender: str, _state: Any, rate: RateOption) -> None: + began.append(rate) + + monkeypatch.setattr(chat_proto, "_begin_purchase_approval", _spy_begin) + ctx = FakeContext() + state = _state(session_state.AWAITING_HAZMAT_CHECK, declared=50.0) # <= USPS free $100 + + await chat_proto._handle_hazmat_stage(ctx, SENDER, state, "", {"action": "hazmat_clear"}) + + assert state["state"] != session_state.AWAITING_INSURANCE_CHECK + assert began and began[0].rate_id == RATE.rate_id # went straight to approval + + +async def test_hazmat_clear_offers_insurance_when_underinsured( + monkeypatch: pytest.MonkeyPatch, +) -> None: + began: list[RateOption] = [] + + async def _spy_begin(_ctx: Any, _sender: str, _state: Any, rate: RateOption) -> None: + began.append(rate) + + monkeypatch.setattr(chat_proto, "_begin_purchase_approval", _spy_begin) + ctx = FakeContext() + state = _state(session_state.AWAITING_HAZMAT_CHECK, declared=500.0) # > USPS free $100 + + await chat_proto._handle_hazmat_stage(ctx, SENDER, state, "", {"action": "hazmat_clear"}) + + assert state["state"] == session_state.AWAITING_INSURANCE_CHECK + assert began == [] + payload = _last_card_payload(ctx) + assert payload["approve_cta"]["selection"]["action"] == "add_insurance" + # 1.25% of $500 = $6.25. + assert "6.25" in payload["approve_cta"]["label"] + + +# insurance decision +async def test_skip_insurance_keeps_base_rate(monkeypatch: pytest.MonkeyPatch) -> None: + began: list[RateOption] = [] + + async def _spy_begin(_ctx: Any, _sender: str, _state: Any, rate: RateOption) -> None: + began.append(rate) + + monkeypatch.setattr(chat_proto, "_begin_purchase_approval", _spy_begin) + ctx = FakeContext() + state = _state(session_state.AWAITING_INSURANCE_CHECK) + + await chat_proto._handle_insurance_stage(ctx, SENDER, state, "", {"action": "skip_insurance"}) + + assert began and began[0].amount == RATE.amount + assert began[0].included_insurance_price == 0.0 + + +async def test_add_insurance_requotes_and_raises_total(monkeypatch: pytest.MonkeyPatch) -> None: + """Test mode usually returns no ``included_insurance_price``; the documented + 1.25% domestic premium is added on top of the base price instead.""" + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.content or b"{}") + assert body["extra"]["insurance"]["amount"] == "500.00" + return httpx.Response( + 201, + json={ + "object_id": "ship_ins", + "status": "SUCCESS", + "test": True, + "rates": [ + { + "object_id": "r_insured", + "provider": "USPS", + "servicelevel": { + "name": "Ground Advantage", + "token": "usps_ground_advantage", + }, + "amount": "9.55", # test mode: unchanged, no included price + "currency": "USD", + "estimated_days": 3, + "test": True, + } + ], + }, + ) + + monkeypatch.setattr(chat_proto, "shippo_client", lambda: _fake_shippo(handler)) + began: list[RateOption] = [] + + async def _spy_begin(_ctx: Any, _sender: str, _state: Any, rate: RateOption) -> None: + began.append(rate) + + monkeypatch.setattr(chat_proto, "_begin_purchase_approval", _spy_begin) + ctx = FakeContext() + state = _state(session_state.AWAITING_INSURANCE_CHECK, declared=500.0) + + await chat_proto._handle_insurance_stage(ctx, SENDER, state, "", {"action": "add_insurance"}) + + assert began, "purchase approval should begin after adding insurance" + insured = began[0] + assert insured.included_insurance_price == 6.25 + assert insured.amount == round(9.55 + 6.25, 2) + + +async def test_add_insurance_trusts_shippo_included_price_when_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "object_id": "ship_ins", + "status": "SUCCESS", + "test": True, + "rates": [ + { + "object_id": "r_insured", + "provider": "USPS", + "servicelevel": { + "name": "Ground Advantage", + "token": "usps_ground_advantage", + }, + "amount": "13.55", # Shippo folded the premium in + "currency": "USD", + "estimated_days": 3, + "included_insurance_price": "4.00", + "test": True, + } + ], + }, + ) + + monkeypatch.setattr(chat_proto, "shippo_client", lambda: _fake_shippo(handler)) + began: list[RateOption] = [] + + async def _spy_begin(_ctx: Any, _sender: str, _state: Any, rate: RateOption) -> None: + began.append(rate) + + monkeypatch.setattr(chat_proto, "_begin_purchase_approval", _spy_begin) + ctx = FakeContext() + state = _state(session_state.AWAITING_INSURANCE_CHECK, declared=500.0) + + await chat_proto._handle_insurance_stage(ctx, SENDER, state, "", {"action": "add_insurance"}) + + assert began + assert began[0].amount == 13.55 + assert began[0].included_insurance_price == 4.00 diff --git a/pydantic-agent/shipping-label-agent/tests/test_payment.py b/pydantic-agent/shipping-label-agent/tests/test_payment.py new file mode 100644 index 00000000..ad24336d --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_payment.py @@ -0,0 +1,97 @@ +"""Stripe payment gate tests: happy path + Stripe's documented decline card. + +No network: the Stripe SDK is replaced with a small in-memory fake via +``payment._stripe``. The decline path uses Stripe's documented ``card_declined`` +test number (``4000000000000002``) to model a checkout that never becomes paid. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +import payment + + +class _FakeSession: + def __init__(self, session_id: str, url: str, payment_status: str) -> None: + self.id = session_id + self.url = url + self.payment_status = payment_status + self.client_secret = "cs_secret_test" + + +class FakeStripe: + """Minimal stand-in for the ``stripe`` module used by ``payment``.""" + + def __init__(self) -> None: + self.api_key: str | None = None + self._sessions: dict[str, _FakeSession] = {} + self._next = ("cs_test_paid", "https://checkout.stripe.test/pay", "paid") + self.checkout = SimpleNamespace( + Session=SimpleNamespace(create=self._create, retrieve=self._retrieve) + ) + + def set_next(self, session_id: str, payment_status: str) -> None: + self._next = (session_id, "https://checkout.stripe.test/pay", payment_status) + + def _create(self, **_: object) -> _FakeSession: + session_id, url, status = self._next + session = _FakeSession(session_id, url, status) + self._sessions[session_id] = session + return session + + def _retrieve(self, session_id: str) -> _FakeSession: + return self._sessions.get(session_id, _FakeSession(session_id, "", "unpaid")) + + +@pytest.fixture() +def fake_stripe(monkeypatch: pytest.MonkeyPatch) -> FakeStripe: + fake = FakeStripe() + monkeypatch.setattr(payment, "_stripe", lambda: fake) + return fake + + +def test_assert_stripe_test_keys_accepts_test_keys() -> None: + # Uses the sk_test_/pk_test_ keys from conftest — should not raise. + payment.assert_stripe_test_keys() + + +def test_assert_stripe_test_keys_rejects_live_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("STRIPE_SECRET_KEY", "sk_live_should_never_run") + with pytest.raises(RuntimeError): + payment.assert_stripe_test_keys() + + +def test_checkout_session_is_embedded_and_carries_client_secret( + fake_stripe: FakeStripe, +) -> None: + fake_stripe.set_next("cs_test_ok", "paid") + checkout = payment.create_checkout_session("agent1qxyz", "session-123") + assert checkout["checkout_session_id"] == "cs_test_ok" + assert checkout["ui_mode"] == "embedded_page" + assert checkout["client_secret"] + # All metadata values must be plain strings - RequestPayment.metadata is + # typed dict[str, str | dict[str, str]], so nested values can't be int/etc. + assert all(isinstance(v, str) for v in checkout.values()) + + +def test_happy_path_payment_confirmed(fake_stripe: FakeStripe) -> None: + fake_stripe.set_next("cs_test_ok", "paid") + checkout = payment.create_checkout_session("agent1qxyz", "session-123") + assert payment.verify_paid(checkout["checkout_session_id"]) is True + + +def test_documented_decline_card_is_never_paid(fake_stripe: FakeStripe) -> None: + # Stripe's documented generic decline card. + assert payment.TEST_DECLINE_CARD == "4000000000000002" + # A checkout paid for with the decline card stays unpaid. + fake_stripe.set_next("cs_test_declined", "unpaid") + checkout = payment.create_checkout_session("agent1qxyz", "session-123") + assert payment.verify_paid(checkout["checkout_session_id"]) is False + + +def test_verify_paid_handles_missing_session(fake_stripe: FakeStripe) -> None: + assert payment.verify_paid("") is False + assert payment.verify_paid("cs_never_created") is False diff --git a/pydantic-agent/shipping-label-agent/tests/test_payment_protocol.py b/pydantic-agent/shipping-label-agent/tests/test_payment_protocol.py new file mode 100644 index 00000000..044ae0f5 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_payment_protocol.py @@ -0,0 +1,194 @@ +"""Native payment-protocol tests: the actual mechanism ASI:One renders natively. + +This is the fix for the bug where the agent sent a hand-rolled ``review`` card +with a raw checkout URL dumped into chat text, instead of the native +``uagents_core.contrib.protocols.payment`` messages the quiz-agent uses (which +ASI:One renders as its own "Pay with Stripe / Reject" sheet). + +Verifies: + +* ``request_payment`` sends ONLY a bare ``RequestPayment`` (no narration text) - + any accompanying text causes ASI:One to swallow the native card. +* ``on_commit`` grants access (sends the package form) only once Stripe actually + shows the checkout as paid; otherwise it replies with ``RejectPayment``. +* ``on_reject`` resets the session back to the payment gate. + +No network: the Stripe SDK is replaced with a small in-memory fake, exactly +like ``test_payment.py``. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +# Skip cleanly (rather than erroring collection) if run outside the example venv. +# pydantic_ai is required too: on_commit's grant-access path lazily imports +# chat_proto, which in turn imports pydantic_agent (pydantic_ai-based). +pytest.importorskip("uagents") +pytest.importorskip("pydantic_ai") +pytest.importorskip("httpx") + +from uagents_core.contrib.protocols.payment import ( + CommitPayment, + RejectPayment, + RequestPayment, +) + +import payment +import session_state + + +class _FakeSession: + def __init__(self, session_id: str, payment_status: str) -> None: + self.id = session_id + self.client_secret = "cs_secret_test" + self.payment_status = payment_status + + +class FakeStripe: + def __init__(self) -> None: + self._sessions: dict[str, _FakeSession] = {} + self._next = ("cs_test_ok", "paid") + self.checkout = SimpleNamespace( + Session=SimpleNamespace(create=self._create, retrieve=self._retrieve) + ) + + def set_next(self, session_id: str, payment_status: str) -> None: + self._next = (session_id, payment_status) + + def seed(self, session_id: str, payment_status: str) -> None: + """Directly register a session as retrievable, bypassing ``create``.""" + self._sessions[session_id] = _FakeSession(session_id, payment_status) + + def _create(self, **_: object) -> _FakeSession: + session_id, status = self._next + session = _FakeSession(session_id, status) + self._sessions[session_id] = session + return session + + def _retrieve(self, session_id: str) -> _FakeSession: + return self._sessions.get(session_id, _FakeSession(session_id, "unpaid")) + + +class FakeStorage: + def __init__(self) -> None: + self._data: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self._data.get(key) + + def set(self, key: str, value: str) -> None: + self._data[key] = value + + +class FakeLogger: + def info(self, *_: object) -> None: + pass + + def error(self, *_: object) -> None: + pass + + def warning(self, *_: object) -> None: + pass + + +class FakeContext: + """Just enough of ``uagents.Context`` for the payment protocol handlers.""" + + def __init__(self) -> None: + self.session = "session-123" + self.agent = SimpleNamespace(address="agent1qseller") + self.storage = FakeStorage() + self.logger = FakeLogger() + self.sent: list[Any] = [] + + async def send(self, _sender: str, message: Any) -> None: + self.sent.append(message) + + +@pytest.fixture() +def fake_stripe(monkeypatch: pytest.MonkeyPatch) -> FakeStripe: + fake = FakeStripe() + monkeypatch.setattr(payment, "_stripe", lambda: fake) + return fake + + +async def test_request_payment_sends_only_request_payment(fake_stripe: FakeStripe) -> None: + """No text/card may accompany RequestPayment, or ASI:One swallows the card.""" + ctx = FakeContext() + state_data = session_state.default_state() + + await payment.request_payment(ctx, "agent1qbuyer", state_data) + + assert len(ctx.sent) == 1 + assert isinstance(ctx.sent[0], RequestPayment) + assert state_data["state"] == session_state.AWAITING_PAYMENT + assert state_data["stripe_session_id"] == "cs_test_ok" + + +async def test_on_commit_rejects_when_stripe_shows_unpaid(fake_stripe: FakeStripe) -> None: + ctx = FakeContext() + state_data = session_state.default_state() + state_data["stripe_session_id"] = "cs_never_paid" + session_state.save_state(ctx, "agent1qbuyer", state_data) + + await payment.on_commit( + ctx, + "agent1qbuyer", + CommitPayment( + funds={"amount": "5.00", "currency": "USD", "payment_method": "stripe"}, + recipient="agent1qseller", + transaction_id="pi_test_1", + ), + ) + + assert len(ctx.sent) == 1 + assert isinstance(ctx.sent[0], RejectPayment) + reloaded = session_state.get_state(ctx, "agent1qbuyer") + assert reloaded.get("stripe_paid") is not True + + +async def test_on_commit_grants_access_when_paid(fake_stripe: FakeStripe) -> None: + fake_stripe.seed("cs_test_paid", "paid") + ctx = FakeContext() + state_data = session_state.default_state() + state_data["stripe_session_id"] = "cs_test_paid" + session_state.save_state(ctx, "agent1qbuyer", state_data) + + await payment.on_commit( + ctx, + "agent1qbuyer", + CommitPayment( + funds={"amount": "5.00", "currency": "USD", "payment_method": "stripe"}, + recipient="agent1qseller", + transaction_id="pi_test_2", + ), + ) + + kinds = [type(m).__name__ for m in ctx.sent] + assert "CompletePayment" in kinds + reloaded = session_state.get_state(ctx, "agent1qbuyer") + assert reloaded["stripe_paid"] is True + assert reloaded["state"] == session_state.AWAITING_SENDER + + +async def test_on_reject_resets_to_payment_gate() -> None: + ctx = FakeContext() + state_data = session_state.default_state() + state_data["state"] = session_state.AWAITING_PACKAGE + state_data["stripe_paid"] = True + session_state.save_state(ctx, "agent1qbuyer", state_data) + + await payment.on_reject(ctx, "agent1qbuyer", RejectPayment(reason="cancelled")) + + reloaded = session_state.get_state(ctx, "agent1qbuyer") + assert reloaded["state"] == session_state.AWAITING_PAYMENT + assert reloaded["stripe_paid"] is False + + +async def test_confirm_payment_via_text_false_without_checkout() -> None: + ctx = FakeContext() + assert await payment.confirm_payment_via_text(ctx, "agent1qbuyer") is False diff --git a/pydantic-agent/shipping-label-agent/tests/test_second_payment.py b/pydantic-agent/shipping-label-agent/tests/test_second_payment.py new file mode 100644 index 00000000..00d99c6d --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_second_payment.py @@ -0,0 +1,266 @@ +"""The second, separate Stripe payment for the label's exact price. + +Before this fix, "Confirm and buy label" only resumed Pydantic AI's +``requires_approval`` gate and bought the label immediately - nothing ever +charged the user for the label itself, so only the flat intake fee ever moved +any money. This verifies the fix: + +* Approving the purchase review card does NOT buy the label - it starts a + second, separate Stripe ``RequestPayment`` for the exact rate price, and + the deferred tool is left pending. +* A successful second payment (``handle_label_payment_success``) is what + actually resumes the tool with ``approved=True`` and buys the label. +* A failed/declined second payment (``handle_label_payment_failed``) resumes + the tool with ``approved=False`` - the Shippo transaction endpoint is never + called - and sends the user back to rate-picking, not to a purchased label. +""" + +from __future__ import annotations + +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest + +# Skip cleanly (rather than erroring collection) if run outside the example venv. +pytest.importorskip("uagents") +pytest.importorskip("pydantic_ai") +pytest.importorskip("httpx") + +import httpx +from pydantic_ai import ModelResponse, TextPart, ToolCallPart +from pydantic_ai.messages import ModelMessage +from pydantic_ai.models.function import AgentInfo, FunctionModel +from uagents_core.contrib.protocols.payment import RequestPayment + +import chat_proto +import payment +import session_state +from pydantic_agent import start_purchase +from shipping import RateOption, ShippoClient + +TOKEN = "shippo_test_dummy_token" +RATE = RateOption( + rate_id="r_ups_ground_saver", + provider="UPS", + servicelevel_name="Ground Saver", + servicelevel_token="ups_ground_saver", + amount=14.01, + currency="USD", +) + + +def _fake_shippo(handler: Callable[[httpx.Request], httpx.Response]) -> ShippoClient: + http = httpx.Client(transport=httpx.MockTransport(handler), base_url="https://api.goshippo.com") + return ShippoClient(TOKEN, client=http) + + +def _successful_transaction_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "object_id": "txn_42", + "status": "SUCCESS", + "test": True, + "label_url": "https://deliver.goshippo.com/txn_42.pdf", + "tracking_number": "1Z999AA10123456784", + "rate": "819282d7ec8d4b1db3d03bbf8f0e1a7d", # unexpanded, like real Shippo + "messages": [], + }, + ) + + +def _purchase_script(messages: list[ModelMessage], info: AgentInfo) -> ModelResponse: + for message in messages: + for part in getattr(message, "parts", []): + if part.__class__.__name__ == "ToolReturnPart": + return ModelResponse(parts=[TextPart("Done — the test label was purchased.")]) + return ModelResponse(parts=[ToolCallPart("purchase_label", {"rate_id": RATE.rate_id})]) + + +class _FakeStripeSession: + def __init__(self, session_id: str, payment_status: str) -> None: + self.id = session_id + self.client_secret = "cs_secret_test" + self.payment_status = payment_status + + +class FakeStripe: + def __init__(self) -> None: + self._sessions: dict[str, _FakeStripeSession] = {} + self._next = ("cs_test_ok", "paid") + self.checkout = SimpleNamespace( + Session=SimpleNamespace(create=self._create, retrieve=self._retrieve) + ) + + def set_next(self, session_id: str, payment_status: str) -> None: + self._next = (session_id, payment_status) + + def _create(self, **_: object) -> _FakeStripeSession: + session_id, status = self._next + session = _FakeStripeSession(session_id, status) + self._sessions[session_id] = session + return session + + def _retrieve(self, session_id: str) -> _FakeStripeSession: + return self._sessions.get(session_id, _FakeStripeSession(session_id, "unpaid")) + + +class FakeContext: + def __init__(self) -> None: + self.session = "session-123" + self.agent = SimpleNamespace(address="agent1qseller") + self.storage = _FakeStorage() + self.logger = _FakeLogger() + self.sent: list[Any] = [] + + async def send(self, _sender: str, message: Any) -> None: + self.sent.append(message) + + +class _FakeStorage: + def __init__(self) -> None: + self._data: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self._data.get(key) + + def set(self, key: str, value: str) -> None: + self._data[key] = value + + +class _FakeLogger: + def info(self, *_: object) -> None: + pass + + def error(self, *_: object) -> None: + pass + + def warning(self, *_: object) -> None: + pass + + +@pytest.fixture() +def fake_stripe(monkeypatch: pytest.MonkeyPatch) -> FakeStripe: + fake = FakeStripe() + monkeypatch.setattr(payment, "_stripe", lambda: fake) + return fake + + +async def _deferred_state(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + """A session state as it would look right after the review card is sent - + the deferred tool is pending, waiting on an approval decision.""" + monkeypatch.setattr( + chat_proto, "shippo_client", lambda: _fake_shippo(_successful_transaction_handler) + ) + from pydantic_agent import ShippingDeps + + deps = ShippingDeps(shippo=_fake_shippo(_successful_transaction_handler), selected_rate=RATE) + model = FunctionModel(_purchase_script) + start = await start_purchase(deps, RATE.rate_id, model=model) + + state_data = session_state.default_state() + state_data["stripe_paid"] = True + state_data["state"] = session_state.AWAITING_PURCHASE_APPROVAL + state_data["rates"] = [RATE.model_dump()] + state_data["selected_rate_id"] = RATE.rate_id + state_data["purchase_history_json"] = start.history_json + state_data["purchase_tool_call_id"] = start.tool_call_id + return state_data + + +async def test_approving_purchase_does_not_buy_the_label_it_charges_first( + monkeypatch: pytest.MonkeyPatch, fake_stripe: FakeStripe +) -> None: + """The old bug: approving went straight to a real Shippo purchase with no + payment for the label price. Now it must only request a second charge.""" + calls: list[str] = [] + + def counting_handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + return _successful_transaction_handler(request) + + monkeypatch.setattr(chat_proto, "shippo_client", lambda: _fake_shippo(counting_handler)) + fake_stripe.set_next("cs_test_label_ok", "paid") + + state_data = await _deferred_state(monkeypatch) + ctx = FakeContext() + + await chat_proto._handle_approval_stage( + ctx, "agent1qbuyer", state_data, {"action": "approve_purchase"} + ) + + assert "/transactions/" not in calls # Shippo was NOT charged/called yet + assert len(ctx.sent) == 1 + assert isinstance(ctx.sent[0], RequestPayment) + assert state_data["state"] == session_state.AWAITING_LABEL_PAYMENT + assert state_data["payment_purpose"] == "label" + # The charge must be for the exact rate price, not the flat intake fee. + assert ctx.sent[0].accepted_funds[0].amount == "14.01" + + +async def test_second_payment_success_buys_the_label( + monkeypatch: pytest.MonkeyPatch, fake_stripe: FakeStripe +) -> None: + monkeypatch.setattr( + chat_proto, "shippo_client", lambda: _fake_shippo(_successful_transaction_handler) + ) + state_data = await _deferred_state(monkeypatch) + ctx = FakeContext() + + await chat_proto.handle_label_payment_success(ctx, "agent1qbuyer", state_data) + + assert state_data["purchase"] is not None + assert state_data["purchase"]["status"] == "SUCCESS" + # UPS Ground Saver doesn't support API pickups -> straight to DONE + confirmation. + assert state_data["state"] == session_state.DONE + texts = [m for m in ctx.sent if hasattr(m, "content")] + assert texts # label link + drop-off note + confirmation card were sent + + +async def test_second_payment_failure_cancels_purchase_and_returns_to_rates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + def counting_handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + return _successful_transaction_handler(request) + + monkeypatch.setattr(chat_proto, "shippo_client", lambda: _fake_shippo(counting_handler)) + state_data = await _deferred_state(monkeypatch) + ctx = FakeContext() + + await chat_proto.handle_label_payment_failed(ctx, "agent1qbuyer", state_data) + + assert "/transactions/" not in calls # the label was never purchased + assert state_data.get("purchase") in (None, {}) + assert state_data["state"] == session_state.SHOWING_RATES + assert len(ctx.sent) == 1 + sent_metadata = [c for c in ctx.sent[0].content if hasattr(c, "metadata")] + assert sent_metadata and sent_metadata[0].metadata["card_kind"] == "carousel" + + +async def test_on_reject_during_label_payment_cancels_purchase( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A RejectPayment while awaiting the label charge must not leave a + dangling deferred tool or a purchase - it must fall through to the same + cancel-and-return-to-rates path as a failed payment.""" + from uagents_core.contrib.protocols.payment import RejectPayment + + monkeypatch.setattr( + chat_proto, "shippo_client", lambda: _fake_shippo(_successful_transaction_handler) + ) + state_data = await _deferred_state(monkeypatch) + state_data["payment_purpose"] = "label" + state_data["state"] = session_state.AWAITING_LABEL_PAYMENT + ctx = FakeContext() + session_state.save_state(ctx, "agent1qbuyer", state_data) + + await payment.on_reject(ctx, "agent1qbuyer", RejectPayment(reason="cancelled")) + + reloaded = session_state.get_state(ctx, "agent1qbuyer") + assert reloaded["state"] == session_state.SHOWING_RATES + assert reloaded.get("purchase") in (None, {}) diff --git a/pydantic-agent/shipping-label-agent/tests/test_sender_stage.py b/pydantic-agent/shipping-label-agent/tests/test_sender_stage.py new file mode 100644 index 00000000..5f3ae082 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_sender_stage.py @@ -0,0 +1,221 @@ +"""The sender-profile intake step (``AWAITING_SENDER``). + +Before this, the ship-from address was a single hard-coded demo constant the +user was never asked about - a real gap, since a "shopping" agent that never +learns where *you* are shipping from isn't usable beyond this one demo +persona. This verifies the fix: right after payment, the agent asks for the +user's own ship-from address in a dedicated form, kept completely separate +from the recipient form, validates it through Shippo the same way the +recipient address is validated, and only then moves on to package intake. +""" + +from __future__ import annotations + +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest + +pytest.importorskip("uagents") +pytest.importorskip("pydantic_ai") +pytest.importorskip("httpx") + +import httpx +from pydantic_ai.models.test import TestModel + +import chat_proto +from pydantic_agent import SenderProfile, extract_sender_profile +from session_state import AWAITING_PACKAGE, AWAITING_SENDER, default_state, save_state + +VALID_SENDER_SELECTION = { + "action": "submit_sender", + "from_name": "Jane Sender", + "from_street1": "1 Market St.", + "from_city": "San Francisco", + "from_state": "CA", + "from_zip": "94105", + "from_phone": "+1 555 111 2222", + "from_email": "jane@example.com", +} + + +class _FakeStorage: + def __init__(self) -> None: + self._data: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self._data.get(key) + + def set(self, key: str, value: str) -> None: + self._data[key] = value + + +class _FakeLogger: + def info(self, *_: object) -> None: + pass + + def warning(self, *_: object) -> None: + pass + + def error(self, *_: object) -> None: + pass + + +class FakeContext: + def __init__(self) -> None: + self.session = "session-123" + self.agent = SimpleNamespace(address="agent1qseller") + self.storage = _FakeStorage() + self.logger = _FakeLogger() + self.sent: list[Any] = [] + + async def send(self, _sender: str, message: Any) -> None: + self.sent.append(message) + + +def _valid_address_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "object_id": "addr_ok", + "is_complete": True, + "validation_results": {"is_valid": True, "messages": []}, + }, + ) + + +def _invalid_address_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "object_id": "addr_bad", + "is_complete": False, + "validation_results": { + "is_valid": False, + "messages": [{"text": "Unable to find a valid city, state or 5-digit zip."}], + }, + }, + ) + + +def _patch_shippo_client( + monkeypatch: pytest.MonkeyPatch, handler: Callable[[httpx.Request], httpx.Response] +) -> None: + def _fake_shippo_client() -> chat_proto.ShippoClient: + http = httpx.Client( + transport=httpx.MockTransport(handler), base_url="https://api.goshippo.com" + ) + return chat_proto.ShippoClient("shippo_test_dummy_token", client=http) + + monkeypatch.setattr(chat_proto, "shippo_client", _fake_shippo_client) + + +SENDER = "agent1qbuyer" + + +async def test_valid_sender_form_moves_to_package_intake(monkeypatch: pytest.MonkeyPatch) -> None: + _patch_shippo_client(monkeypatch, _valid_address_handler) + ctx = FakeContext() + state = default_state() + state["state"] = AWAITING_SENDER + state["stripe_paid"] = True + save_state(ctx, SENDER, state) + + await chat_proto._handle_sender_stage(ctx, SENDER, state, "", VALID_SENDER_SELECTION) + + assert state["state"] == AWAITING_PACKAGE + assert state["sender_profile"]["from_name"] == "Jane Sender" + # The next thing sent must be the package (recipient) form, not another + # sender form or a payment prompt. + payloads = [c.metadata for m in ctx.sent for c in m.content if hasattr(c, "metadata")] + assert payloads[-1]["card_kind"] == "form" + import json as _json + + assert _json.loads(payloads[-1]["card_payload"])["title"] == "Package details" + + +async def test_invalid_sender_address_resends_the_sender_form( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _patch_shippo_client(monkeypatch, _invalid_address_handler) + ctx = FakeContext() + state = default_state() + state["state"] = AWAITING_SENDER + state["stripe_paid"] = True + save_state(ctx, SENDER, state) + + await chat_proto._handle_sender_stage(ctx, SENDER, state, "", VALID_SENDER_SELECTION) + + assert state["state"] == AWAITING_SENDER # not advanced + assert state["sender_profile"] is None + payloads = [c.metadata for m in ctx.sent for c in m.content if hasattr(c, "metadata")] + assert payloads[-1]["card_kind"] == "form" + import json as _json + + assert _json.loads(payloads[-1]["card_payload"])["title"] == "Your shipping profile" + + +async def test_sender_missing_email_or_phone_resends_the_form_before_any_shippo_call( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: USPS rejects the label *purchase* call (not the earlier + rate-shop or address-validation calls) if the sender's phone/email are + blank - which used to only surface after a second Stripe charge had + already cleared. Missing contact info must be caught here instead, and + Shippo must never even be called.""" + calls: list[str] = [] + + def counting_handler(request: httpx.Request) -> httpx.Response: + calls.append(request.url.path) + return _valid_address_handler(request) + + _patch_shippo_client(monkeypatch, counting_handler) + ctx = FakeContext() + state = default_state() + state["state"] = AWAITING_SENDER + state["stripe_paid"] = True + save_state(ctx, SENDER, state) + + selection = dict(VALID_SENDER_SELECTION) + del selection["from_email"] + del selection["from_phone"] + + await chat_proto._handle_sender_stage(ctx, SENDER, state, "", selection) + + assert state["state"] == AWAITING_SENDER # not advanced + assert state["sender_profile"] is None + assert calls == [] # Shippo was never called + payloads = [c.metadata for m in ctx.sent for c in m.content if hasattr(c, "metadata")] + assert payloads[-1]["card_kind"] == "form" + texts = [c.text for m in ctx.sent for c in m.content if hasattr(c, "text")] + assert any("email" in t and "phone" in t for t in texts) + + +async def test_sender_form_requires_phone_and_email_fields() -> None: + """The form itself must ask for both - not just accept them if typed.""" + import json + + payload = json.loads(chat_proto.sender_profile_form_card()["card_payload"]) + fields = {f["name"]: f for f in payload["fields"]} + assert fields["from_phone"]["required"] is True + assert fields["from_email"]["required"] is True + + +async def test_sender_form_never_reuses_recipient_style_fields() -> None: + """The sender and recipient forms must be genuinely separate schemas - + the sender form has no ``to_*`` fields at all.""" + import json + + payload = json.loads(chat_proto.sender_profile_form_card()["card_payload"]) + field_names = {f["name"] for f in payload["fields"]} + assert not any(name.startswith("to_") for name in field_names) + + +async def test_extract_sender_profile_offline() -> None: + profile: SenderProfile = await extract_sender_profile( + "I'm Jane Sender at 1 Market St, San Francisco, CA 94105.", + model=TestModel(), + ) + assert isinstance(profile, SenderProfile) + assert profile.from_name diff --git a/pydantic-agent/shipping-label-agent/tests/test_session_window.py b/pydantic-agent/shipping-label-agent/tests/test_session_window.py new file mode 100644 index 00000000..4f55a824 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_session_window.py @@ -0,0 +1,102 @@ +"""Per-chat-window session reset - the same fix quiz-agent calls the "hackflow +pattern": ``ctx.storage`` is keyed only by ``sender``, and ASI:One reuses the +same ``sender`` address across a user's separate chat conversations. Without +this, a brand-new chat window would silently resume an already-paid session +left over from a previous, unrelated conversation instead of asking to pay +again. ``ctx.session`` changes per chat window, so this is what actually +decides "is this a fresh conversation", not whether the agent process itself +was restarted. +""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("uagents") + +from session_state import ( + AWAITING_PACKAGE, + UNINITIALIZED, + check_new_window_and_reset, + get_state, + save_state, +) + + +class _FakeStorage: + def __init__(self) -> None: + self._data: dict[str, str] = {} + + def get(self, key: str) -> str | None: + return self._data.get(key) + + def set(self, key: str, value: str) -> None: + self._data[key] = value + + +class FakeContext: + def __init__(self, session: str) -> None: + self.session = session + self.storage = _FakeStorage() + + +SENDER = "agent1qsomeuser" + + +def test_same_window_does_not_reset_a_paid_session() -> None: + ctx = FakeContext(session="window-1") + state = get_state(ctx, SENDER) + state["state"] = AWAITING_PACKAGE + state["stripe_paid"] = True + save_state(ctx, SENDER, state) + + # First call in this window just records it - no reset yet. + check_new_window_and_reset(ctx, SENDER) + assert get_state(ctx, SENDER)["stripe_paid"] is True + + # A second message in the SAME window must not lose the paid state. + check_new_window_and_reset(ctx, SENDER) + reloaded = get_state(ctx, SENDER) + assert reloaded["stripe_paid"] is True + assert reloaded["state"] == AWAITING_PACKAGE + + +def test_new_window_resets_an_already_paid_session() -> None: + ctx = FakeContext(session="window-1") + state = get_state(ctx, SENDER) + state["state"] = AWAITING_PACKAGE + state["stripe_paid"] = True + save_state(ctx, SENDER, state) + check_new_window_and_reset(ctx, SENDER) # records window-1 as seen + + # A brand-new chat conversation for the same sender gets a new ctx.session. + ctx.session = "window-2" + check_new_window_and_reset(ctx, SENDER) + + reloaded = get_state(ctx, SENDER) + assert reloaded["stripe_paid"] is False + assert reloaded["state"] == UNINITIALIZED + + +def test_restarting_the_process_without_a_new_window_keeps_the_paid_session() -> None: + """Restarting ``python agent.py`` must NOT wipe every user's paid session - + only a genuinely new chat window (a new ``ctx.session``) should. A process + restart with the same on-disk storage and the same window resumes exactly + where the conversation left off. + """ + ctx = FakeContext(session="window-1") + state = get_state(ctx, SENDER) + state["state"] = AWAITING_PACKAGE + state["stripe_paid"] = True + save_state(ctx, SENDER, state) + check_new_window_and_reset(ctx, SENDER) + + # Simulate "process restarted" by constructing a fresh FakeContext instance + # that reuses the same underlying storage dict and the same window id. + restarted_ctx = FakeContext(session="window-1") + restarted_ctx.storage = ctx.storage + + check_new_window_and_reset(restarted_ctx, SENDER) + reloaded = get_state(restarted_ctx, SENDER) + assert reloaded["stripe_paid"] is True + assert reloaded["state"] == AWAITING_PACKAGE diff --git a/pydantic-agent/shipping-label-agent/tests/test_shipping.py b/pydantic-agent/shipping-label-agent/tests/test_shipping.py new file mode 100644 index 00000000..b52bf223 --- /dev/null +++ b/pydantic-agent/shipping-label-agent/tests/test_shipping.py @@ -0,0 +1,425 @@ +"""Shippo test-mode client tests (no network — httpx.MockTransport). + +Covers: the test-token guard, rate shopping (incl. the ``test:true`` assertion +and the zero-rates case), label purchase (incl. the ``test:true`` + ``SUCCESS`` +assertions and a status that does not resolve to success), address validation +feedback, and pickup eligibility for carriers that don't support API pickups. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest + +# Skip cleanly (rather than erroring collection) if run outside the example venv. +pytest.importorskip("httpx") +pytest.importorskip("pydantic") + +import httpx + +from shipping import ( + Address, + Parcel, + PickupResult, + RateOption, + ShippoClient, + ShippoError, + address_diff, + free_included_coverage_usd, + insurance_premium_usd, +) + +TEST_TOKEN = "shippo_test_dummy_token" + +_TO = Address( + name="Mrs Hippo", street1="965 Mission St", city="San Francisco", state="CA", zip="94105" +) +_FROM = Address( + name="Mr Hippo", street1="215 Clayton St", city="San Francisco", state="CA", zip="94117" +) +_PARCEL = Parcel(length_in=5, width_in=5, height_in=5, weight_lb=2) + + +def _client(handler: Callable[[httpx.Request], httpx.Response]) -> ShippoClient: + transport = httpx.MockTransport(handler) + http = httpx.Client(transport=transport, base_url="https://api.goshippo.com") + return ShippoClient(TEST_TOKEN, client=http) + + +def _json(request: httpx.Request) -> dict[str, Any]: + import json + + return json.loads(request.content or b"{}") + + +# token guard +def test_rejects_non_test_token() -> None: + with pytest.raises(ShippoError): + ShippoClient("live_token_should_never_run") + + +# rate shopping +def _shipment_response(rates: list[dict[str, Any]], *, test: bool = True) -> dict[str, Any]: + return {"object_id": "ship_1", "status": "SUCCESS", "test": test, "rates": rates} + + +def _rate(object_id: str, provider: str, amount: str, days: int) -> dict[str, Any]: + return { + "object_id": object_id, + "provider": provider, + "servicelevel": {"name": f"{provider} Ground", "token": f"{provider.lower()}_ground"}, + "amount": amount, + "currency": "USD", + "estimated_days": days, + "carrier_account": "ca_1", + "test": True, + } + + +def test_rate_shop_returns_sorted_rates() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/shipments/" + rates = [_rate("r_expensive", "UPS", "24.30", 1), _rate("r_cheap", "USPS", "7.50", 3)] + return httpx.Response(201, json=_shipment_response(rates)) + + rates = _client(handler).rate_shop(_FROM, _TO, _PARCEL) + assert [r.rate_id for r in rates] == ["r_cheap", "r_expensive"] # cheapest first + assert rates[0].amount == 7.50 + + +def test_rate_shop_rejects_non_test_shipment() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, json=_shipment_response([_rate("r", "USPS", "7.50", 3)], test=False) + ) + + with pytest.raises(ShippoError): + _client(handler).rate_shop(_FROM, _TO, _PARCEL) + + +def test_rate_shop_zero_rates() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, json=_shipment_response([])) + + assert _client(handler).rate_shop(_FROM, _TO, _PARCEL) == [] + + +# insurance wire format (extra.insurance + included_insurance_price) +def test_rate_shop_sends_extra_insurance_and_parses_included_price() -> None: + """The documented shipment-level ``extra.insurance`` flag must be sent as + ``{amount, currency, content}`` and the returned ``included_insurance_price`` + parsed onto the rate. Source: docs.goshippo.com/docs/Shipments/ShippingInsurance + """ + + def handler(request: httpx.Request) -> httpx.Response: + body = _json(request) + assert body["extra"]["insurance"] == { + "amount": "250.00", + "currency": "USD", + "content": "Merchandise", + } + rate = _rate("r_ins", "USPS", "9.55", 2) + rate["included_insurance_price"] = "3.13" + return httpx.Response(201, json=_shipment_response([rate])) + + rates = _client(handler).rate_shop(_FROM, _TO, _PARCEL, insurance_amount=250.0) + assert rates[0].included_insurance_price == 3.13 + + +def test_rate_shop_omits_extra_when_no_insurance() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert "extra" not in _json(request) + return httpx.Response(201, json=_shipment_response([_rate("r", "USPS", "7.50", 3)])) + + rates = _client(handler).rate_shop(_FROM, _TO, _PARCEL) + assert rates[0].included_insurance_price == 0.0 + + +def test_free_coverage_defaults_to_100_with_ups_ground_saver_exception() -> None: + assert free_included_coverage_usd("USPS", "Priority Mail") == 100.0 + assert free_included_coverage_usd("USPS", "Ground Advantage") == 100.0 + assert free_included_coverage_usd("UPS", "UPS Ground") == 100.0 + assert free_included_coverage_usd("UPS", "UPS Ground Saver") == 50.0 + + +def test_insurance_premium_is_1_25_percent_domestic() -> None: + assert insurance_premium_usd(400.0) == 5.0 + assert insurance_premium_usd(0.0) == 0.0 + + +# purchase +def _transaction_response(*, status: str, test: bool) -> dict[str, Any]: + return { + "object_id": "txn_1", + "status": status, + "test": test, + "label_url": "https://deliver.goshippo.com/txn_1.pdf", + "tracking_number": "9271901755477000000000011", + "tracking_url_provider": "https://tools.usps.com/go/TrackConfirmAction?tLabels=927", + "rate": { + "amount": "7.50", + "currency": "USD", + "provider": "USPS", + "servicelevel_token": "usps_ground", + }, + "messages": [], + } + + +def test_purchase_success() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/transactions/" + assert _json(request)["rate"] == "r_cheap" + return httpx.Response(201, json=_transaction_response(status="SUCCESS", test=True)) + + result = _client(handler).purchase("r_cheap") + assert result.status == "SUCCESS" + assert result.test is True + assert result.label_url.endswith(".pdf") + assert result.tracking_number + + +def test_purchase_requests_expand_rate() -> None: + """Shippo's transaction ``rate`` is a plain object-id string by default - + we must ask it to expand so provider/amount come back inline.""" + + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.params.get("expand") == "rate" + return httpx.Response(201, json=_transaction_response(status="SUCCESS", test=True)) + + result = _client(handler).purchase("r_cheap") + assert result.provider == "USPS" + + +def test_purchase_backfills_provider_when_shippo_returns_unexpanded_rate() -> None: + """If Shippo still returns ``rate`` as a bare string id (undocumented but + possible in test mode), the caller's already-known selected rate fills in + provider/service/amount so the confirmation never shows a blank carrier.""" + + def handler(request: httpx.Request) -> httpx.Response: + body = _transaction_response(status="SUCCESS", test=True) + body["rate"] = "819282d7ec8d4b1db3d03bbf8f0e1a7d" # plain id, not expanded + return httpx.Response(201, json=body) + + fallback = RateOption( + rate_id="r_cheap", + provider="USPS", + servicelevel_name="Ground Advantage", + servicelevel_token="usps_ground_advantage", + amount=7.50, + currency="USD", + ) + result = _client(handler).purchase("r_cheap", fallback_rate=fallback) + assert result.provider == "USPS" + assert result.servicelevel_token == "usps_ground_advantage" + assert result.amount == 7.50 + + +def test_purchase_rejects_non_test_transaction() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(201, json=_transaction_response(status="SUCCESS", test=False)) + + with pytest.raises(ShippoError): + _client(handler).purchase("r_cheap") + + +def test_purchase_rejects_unsuccessful_status() -> None: + def handler(request: httpx.Request) -> httpx.Response: + body = _transaction_response(status="ERROR", test=True) + body["messages"] = [{"text": "Invalid rate"}] + return httpx.Response(201, json=body) + + with pytest.raises(ShippoError) as exc: + _client(handler).purchase("r_cheap") + assert "Invalid rate" in str(exc.value) + + +# address validation +def test_validate_address_flags_invalid() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/addresses/" + return httpx.Response( + 201, + json={ + "is_complete": True, + "validation_results": { + "is_valid": False, + "messages": [{"text": "The address as submitted could not be found."}], + }, + }, + ) + + validation = _client(handler).validate_address(_TO) + assert validation.is_valid is False + assert "could not be found" in validation.messages[0] + + +def test_validate_address_accepts_complete() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={"is_complete": True, "validation_results": {"is_valid": True, "messages": []}}, + ) + + assert _client(handler).validate_address(_TO).is_valid is True + + +def test_validate_address_surfaces_corrected_city_when_valid() -> None: + """Shippo can call an address "valid" (deliverable) while silently + normalizing a city that doesn't match its own ZIP - e.g. "Irvine" for a + ZIP whose USPS-preferred city is "Tustin". ``corrected`` must carry that + normalized address so the caller can offer it as a suggestion instead of + only trusting whatever the user typed.""" + mismatched = Address( + name="Phu Quach", street1="30 Preston Pl", city="Irvine", state="CA", zip="92782" + ) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "is_complete": True, + "validation_results": {"is_valid": True, "messages": []}, + "name": "Phu Quach", + "street1": "30 Preston Pl", + "city": "Tustin", + "state": "CA", + "zip": "92782", + "country": "US", + }, + ) + + validation = _client(handler).validate_address(mismatched) + assert validation.is_valid is True + assert validation.corrected is not None + assert validation.corrected.city == "Tustin" + diff = address_diff(mismatched, validation.corrected) + assert diff == {"city": ("Irvine", "Tustin")} + + +def test_validate_address_no_correction_when_it_matches() -> None: + """No spurious "correction" when Shippo echoes back the same address + (aside from case), so the confirm-address UX doesn't fire on every intake.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "is_complete": True, + "validation_results": {"is_valid": True, "messages": []}, + "name": _TO.name, + "street1": _TO.street1, + "city": _TO.city.upper(), + "state": _TO.state, + "zip": _TO.zip, + "country": "US", + }, + ) + + validation = _client(handler).validate_address(_TO) + assert validation.corrected is not None + assert address_diff(_TO, validation.corrected) == {} + + +def test_validate_address_hints_a_correction_even_when_invalid() -> None: + """Per Shippo's own docs example, an unmatched street can still come back + with a corrected city/zip alongside the failure - useful as a hint even + though the address must still be re-entered.""" + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, + json={ + "is_complete": False, + "validation_results": { + "is_valid": False, + "messages": [{"text": "The address as submitted could not be found."}], + }, + "name": "Shawn Ippotle", + "street1": "215 HIPPO ST.", + "city": "SAN FRANCISCO", + "state": "CA", + "zip": "94107", + "country": "US", + }, + ) + + validation = _client(handler).validate_address(_FROM) + assert validation.is_valid is False + assert validation.corrected is not None + assert validation.corrected.zip == "94107" + + +# carrier-agnostic behavior (USPS/UPS/FedEx/DHL Express) +def test_pickup_and_drop_off_cover_all_four_carriers() -> None: + """The example is meant to work with any carrier account the user has + connected in Shippo, not just USPS/UPS - FedEx and DHL Express must be + handled identically to the two carriers used in most manual tests.""" + for provider, pickup_eligible in ( + ("USPS", True), + ("DHL Express", True), + ("UPS", False), + ("FedEx", False), + ): + rate = RateOption( + rate_id="r", + provider=provider, + servicelevel_name="Ground", + servicelevel_token="x", + amount=9.0, + currency="USD", + ) + assert rate.supports_pickup() is pickup_eligible + # Every one of the four gets a real drop-off locator link regardless + # of pickup eligibility (pickup-eligible carriers just don't need it). + assert "94105" in (rate.drop_off_url("94105") or "") + + +# pickup eligibility +def test_pickup_eligibility_by_carrier() -> None: + usps = RateOption( + rate_id="r", + provider="USPS", + servicelevel_name="Priority", + servicelevel_token="usps_priority", + amount=9.0, + currency="USD", + ) + ups = RateOption( + rate_id="r", + provider="UPS", + servicelevel_name="Ground", + servicelevel_token="ups_ground", + amount=9.0, + currency="USD", + ) + assert usps.supports_pickup() is True + assert ups.supports_pickup() is False + # A carrier without API pickup still gets a drop-off locator link. + assert "94105" in (ups.drop_off_url("94105") or "") + + +def test_schedule_pickup_parses_confirmation() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.url.path == "/pickups/" + body = _json(request) + assert body["is_test"] is True + assert body["transactions"] == ["txn_1"] + return httpx.Response( + 201, + json={ + "status": "CONFIRMED", + "confirmation_code": "WTC310058750", + "confirmed_start_time": "2020-05-09T12:00:00Z", + "confirmed_end_time": "2020-05-09T23:59:59Z", + "messages": None, + }, + ) + + result: PickupResult = _client(handler).schedule_pickup( + carrier_account="ca_1", transaction_id="txn_1", address=_FROM + ) + assert result.status == "CONFIRMED" + assert result.confirmation_code == "WTC310058750" From 8a44f945edea9cc626f58737d2000824bfc9af00 Mon Sep 17 00:00:00 2001 From: Quach Thien Phu Date: Fri, 24 Jul 2026 10:03:42 -0700 Subject: [PATCH 2/4] revert: scope this PR to pydantic-agent/shipping-label-agent only Drop the CHANGELOG.md/README.md edits so the diff touches nothing outside the new example's own folder. Co-authored-by: Cursor --- CHANGELOG.md | 1 - README.md | 1 - 2 files changed, 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d9eddab..d4c317fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,6 @@ All notable changes to this repository are documented in this file. ### Added -- `pydantic-agent/shipping-label-agent/`: the repo's first Pydantic AI example, a test-mode-only shipping-label booking agent for ASI:One. Demonstrates Pydantic AI's differentiators inside the real ASI:One card flow: structured output (typed package extraction), dependency injection (the Shippo test client injected via `deps_type`), and native `requires_approval=True` human-in-the-loop gating on the label purchase. Two-tier Stripe test-mode payments (an intake fee gate on the first message, then a second charge for the exact label price before purchase), Shippo test-mode address validation, a curated rate-shop carousel, hazmat self-certification and optional shipment insurance, SAMPLE-label purchase with the PDF delivered via Agentverse External Storage, and conditional USPS/DHL Express pickup vs. drop-off locator. Establishes the `pydantic-agent/` framework folder. - `stripe-payment-agents/twitch-growth-agent/`: Twitch channel growth copilot built on the Fetch.ai uAgents framework. Integrates ASI:One LLM (intent classification, LangGraph 5-node growth pipeline, announcement drafting), Stripe embedded checkout (in-chat one-time unlock), Twitch Helix API (chat settings, announcements, raids, clips), and EventSub WebSocket (reactive copilot that monitors live stream events and proactively suggests actions). - `Browser-based-agents/playwright/job-application-agent/`: Playwright + ASI:One + Stripe job application agent. Orchestrates a Chromium session to auto-fill Greenhouse application forms using a stored user profile, with LLM-drafted free-text answers via ASI:One, Stripe-gated premium features, and resume ingestion. diff --git a/README.md b/README.md index fa68c1ed..cb49da2c 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,6 @@ innovation-lab-examples/ | [Claude Agent SDK](Claude%20Agent%20SDK/) | Real estate search agent with Claude SDK | Python, Claude SDK, uAgents | 🟡 Intermediate | | [google-genai-parallel-processing](google-genai-parallel-processing/) | Parallel processing with Google GenAI | Python, Google GenAI, uAgents | 🟡 Intermediate | | [flight-tracker-openai-workflow-agent](flight-tracker-openai-workflow-agent/) | Flight tracking with OpenAI workflow agents | Python, OpenAI SDK, uAgents | 🟡 Intermediate | -| [pydantic-agent/shipping-label-agent](pydantic-agent/shipping-label-agent/) | First Pydantic AI example: test-mode shipping-label booking with structured output, dependency injection, requires_approval human-in-the-loop gating, two-tier Stripe payments, and Shippo test-mode labels via interactive cards | Python, uAgents, Pydantic AI, ASI:One, Stripe, Shippo, Cards | 🔴 Advanced | ### 🔗 Agent-to-Agent (A2A) From f5332d11f13cad32891d1c88fb7830b8c1011829 Mon Sep 17 00:00:00 2001 From: Quach Thien Phu Date: Fri, 24 Jul 2026 10:10:33 -0700 Subject: [PATCH 3/4] docs: add natural-language changelog entry for shipping-label-agent Co-authored-by: Cursor --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c317fa..9924713f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this repository are documented in this file. ### Added +- `pydantic-agent/shipping-label-agent/`: the repo's first Pydantic AI example. A chat agent that shops shipping rates across USPS, UPS, FedEx, and DHL, walks through address validation and a couple of quick safety checks, then buys the label once the price is approved, entirely through ASI:One interactive cards. Test-mode only: a Stripe payment gate up front, a second charge for the exact label price before purchase, and Shippo test-mode labels throughout. - `stripe-payment-agents/twitch-growth-agent/`: Twitch channel growth copilot built on the Fetch.ai uAgents framework. Integrates ASI:One LLM (intent classification, LangGraph 5-node growth pipeline, announcement drafting), Stripe embedded checkout (in-chat one-time unlock), Twitch Helix API (chat settings, announcements, raids, clips), and EventSub WebSocket (reactive copilot that monitors live stream events and proactively suggests actions). - `Browser-based-agents/playwright/job-application-agent/`: Playwright + ASI:One + Stripe job application agent. Orchestrates a Chromium session to auto-fill Greenhouse application forms using a stored user profile, with LLM-drafted free-text answers via ASI:One, Stripe-gated premium features, and resume ingestion. From 35ac00c5f56dbe2fb073d00096b0db4856ad056a Mon Sep 17 00:00:00 2001 From: Quach Thien Phu Date: Fri, 24 Jul 2026 10:19:01 -0700 Subject: [PATCH 4/4] docs(shipping-label-agent): add demo video link to README Co-authored-by: Cursor --- pydantic-agent/shipping-label-agent/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pydantic-agent/shipping-label-agent/README.md b/pydantic-agent/shipping-label-agent/README.md index cb305481..5ffeb68d 100644 --- a/pydantic-agent/shipping-label-agent/README.md +++ b/pydantic-agent/shipping-label-agent/README.md @@ -2,6 +2,8 @@ The first [Pydantic AI](https://ai.pydantic.dev/) example in this repo. It shops shipping rates across whatever carriers are connected in Shippo, and buys the label once you approve the price, entirely through ASI:One Interactive Cards. +Demo video: https://youtu.be/ZR83LAA2lJI + ## Architecture ![Architecture diagram](architecture.png)