diff --git a/agent.py b/agent.py index 219f9ed..738d47b 100644 --- a/agent.py +++ b/agent.py @@ -44,6 +44,7 @@ MAX_INVOICES = 50 MAX_UPLOADS = 5 MAX_UPLOAD_BYTES = 256 * 1024 MAX_TEXT_FIELD_LENGTH = 180 +AGENT_VERSION = "0.1.3" ACCEPTED_MEDIA_TYPES = {"application/json", "text/json", "text/csv", "text/plain"} DB_CONNECT_OPTIONS = "-c statement_timeout=5000 -c lock_timeout=3000 -c idle_in_transaction_session_timeout=10000" @@ -81,7 +82,7 @@ class InvoiceGuardStudioV1(A2AAgent[InvoiceGuardStudioV1Config, PlatformUserAuth "One-page InvoiceGuard app for deterministic duplicate invoice number " "and stated-total mismatch review with user-scoped Postgres persistence." ) - version = "0.1.2" + version = AGENT_VERSION config_model = InvoiceGuardStudioV1Config auth_model = PlatformUserAuth @@ -124,7 +125,7 @@ class InvoiceGuardStudioV1(A2AAgent[InvoiceGuardStudioV1Config, PlatformUserAuth str, Field(min_length=1, max_length=120, pattern=r"^[A-Za-z0-9_.:-]+$"), ], - invoices: Annotated[list[dict[str, Any]], Field(min_length=1, max_length=MAX_INVOICES)], + invoices: Annotated[list[InvoiceInput], Field(min_length=1, max_length=MAX_INVOICES)], ) -> dict[str, Any]: tenant = _tenant_key(ctx) result = _review_payload(case_id, invoices) @@ -300,7 +301,7 @@ def _money_json(value: Decimal) -> float: return float(value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) -def _review_payload(case_id: str, raw_invoices: list[dict[str, Any]]) -> dict[str, Any]: +def _review_payload(case_id: str, raw_invoices: list[InvoiceInput | dict[str, Any]]) -> dict[str, Any]: try: clean_case_id = _clean_case_id(case_id) except ValueError as exc: @@ -311,16 +312,29 @@ def _review_payload(case_id: str, raw_invoices: list[dict[str, Any]]) -> dict[st return _validation_error("too_many_invoices", f"At most {MAX_INVOICES} invoices are allowed per review.", case_id=clean_case_id) invoices: list[dict[str, Any]] = [] - for index, item in enumerate(raw_invoices): - if not isinstance(item, dict): + for index, raw_item in enumerate(raw_invoices): + if isinstance(raw_item, InvoiceInput): + item = raw_item.model_dump() + elif isinstance(raw_item, dict): + try: + item = InvoiceInput.model_validate(raw_item).model_dump() + except ValidationError: + item = raw_item + else: return _validation_error("invalid_invoice", f"Invoice at index {index} must be an object.", case_id=clean_case_id) missing = [name for name in ("invoice_number", "vendor", "subtotal", "tax", "total") if item.get(name) in (None, "")] + invoice_number = str(item.get("invoice_number", "")).strip() + vendor = str(item.get("vendor", "")).strip() + if not invoice_number or not vendor: + missing.extend(name for name, value in (("invoice_number", invoice_number), ("vendor", vendor)) if not value and name not in missing) if missing: return _validation_error( "incomplete_invoice", f"Invoice at index {index} is missing required field(s): {', '.join(missing)}.", case_id=clean_case_id, ) + if len(invoice_number) > 120 or len(vendor) > MAX_TEXT_FIELD_LENGTH: + return _validation_error("invalid_invoice", f"Invoice at index {index} exceeds text field length limits.", case_id=clean_case_id) subtotal = _money(item.get("subtotal")) tax = _money(item.get("tax")) total = _money(item.get("total")) @@ -329,8 +343,8 @@ def _review_payload(case_id: str, raw_invoices: list[dict[str, Any]]) -> dict[st invoices.append( { "index": index, - "invoice_number": str(item["invoice_number"]).strip(), - "vendor": str(item["vendor"]).strip(), + "invoice_number": invoice_number, + "vendor": vendor, "subtotal": _money_json(subtotal), "tax": _money_json(tax), "total": _money_json(total), @@ -473,7 +487,7 @@ def _receipt_for(tenant: str, case: dict[str, Any], source: str, saved_at: str) "receipt_id": f"invoice_guard:{case['case_id']}:{digest[:16]}", "kind": "production_execution_receipt", "agent": "invoice-guard-studio-v1", - "agent_version": "0.1.1", + "agent_version": AGENT_VERSION, "skill": "review_invoices", "tenant_hash": hashlib.sha256(tenant.encode("utf-8")).hexdigest()[:16], "input_hash": digest, diff --git a/tests/test_full_stack_contract.py b/tests/test_full_stack_contract.py index de978a4..0ed8d0c 100644 --- a/tests/test_full_stack_contract.py +++ b/tests/test_full_stack_contract.py @@ -76,6 +76,15 @@ def test_incomplete_invoice_failure_fixture(): assert result["code"] == "incomplete_invoice" assert "vendor" in result["message"] and "total" in result["message"] + blank = _review_payload("studio-invoice-invalid", [{"invoice_number": " ", "vendor": " ", "subtotal": 10, "tax": 1, "total": 11}]) + assert blank["ok"] is False + assert blank["code"] == "incomplete_invoice" + assert "invoice_number" in blank["message"] and "vendor" in blank["message"] + + oversized = _review_payload("studio-invoice-invalid", [{"invoice_number": "I" * 121, "vendor": "Acme", "subtotal": 10, "tax": 1, "total": 11}]) + assert oversized["ok"] is False + assert oversized["code"] == "invalid_invoice" + def test_browser_upload_bridge_bounds_and_parses_json(): payload = json.dumps({"invoices": SUCCESS_INVOICES}).encode()