diff --git a/a2a.yaml b/a2a.yaml index be1d64f..36e91a2 100644 --- a/a2a.yaml +++ b/a2a.yaml @@ -1,5 +1,5 @@ name: contract-clock-studio-v1 -version: 0.1.5 +version: 0.1.6 entrypoint: agent:ContractClockStudioV1 expose: public: false diff --git a/agent.py b/agent.py index 78844ac..ddb8762 100644 --- a/agent.py +++ b/agent.py @@ -43,6 +43,7 @@ MAX_BROWSER_UPLOAD_BYTES = 512_000 ALLOWED_UPLOAD_MEDIA_TYPES = ("text/plain", "application/octet-stream") DB_STATEMENT_TIMEOUT_MS = 5_000 DB_LOCK_TIMEOUT_MS = 3_000 +DB_IDLE_TRANSACTION_TIMEOUT_MS = 5_000 ContractId = Annotated[ str, @@ -107,7 +108,7 @@ class ContractClockStudioV1(A2AAgent[ContractClockStudioV1Config, PlatformUserAu "from contracts, persists user-scoped timelines, and serves a packed " "one-page React product at /app." ) - version = "0.1.5" + version = "0.1.6" config_model = ContractClockStudioV1Config auth_model = PlatformUserAuth @@ -246,7 +247,7 @@ def _tenant_key(ctx: RunContext[PlatformUserAuth]) -> str | None: user_id = getattr(auth, "user_id", None) if user_id is not None: return f"user:{user_id}" - subject = (getattr(auth, "sub", None) or getattr(auth, "email", None) or "").strip() + subject = (getattr(auth, "sub", None) or "").strip() return f"user:{subject}" if subject else None @@ -306,10 +307,15 @@ def _extract_deadlines(text: str) -> list[ExtractedDeadline]: lower = clause.lower() if "renew" not in lower: continue - dates = _explicit_iso_dates(clause) - if not dates: + renewal_offset = lower.find("renew") + dates_after_renewal = [ + parsed + for offset, parsed in _explicit_iso_dates_with_positions(clause) + if offset > renewal_offset + ] + if not dates_after_renewal: continue - renewal_date = dates[0] + renewal_date = dates_after_renewal[0] if ("renewal", renewal_date) not in seen: deadlines.append( ExtractedDeadline( @@ -344,7 +350,11 @@ def _deadline_clauses(text: str) -> list[str]: def _normalize_text(text: str) -> str: - return re.sub(r"\s+", " ", text).strip() + normalized_lines = [ + re.sub(r"[^\S\n]+", " ", line).strip() + for line in text.splitlines() + ] + return "\n".join(line for line in normalized_lines if line).strip() def _contains_explicit_iso_date(text: str) -> bool: @@ -357,10 +367,14 @@ def _mentions_deadline_domain(text: str) -> bool: def _explicit_iso_dates(text: str) -> list[date]: - out: list[date] = [] + return [parsed for _, parsed in _explicit_iso_dates_with_positions(text)] + + +def _explicit_iso_dates_with_positions(text: str) -> list[tuple[int, date]]: + out: list[tuple[int, date]] = [] for match in re.finditer(r"\b(20\d{2})-(0[1-9]|1[0-2])-([0-2]\d|3[01])\b", text): try: - out.append(date.fromisoformat(match.group(0))) + out.append((match.start(), date.fromisoformat(match.group(0)))) except ValueError: continue return out @@ -486,18 +500,16 @@ async def _persist_contract_result( receipt_id = _receipt_id() now = _now_iso() - input_payload = {"contract_id": contract_id, "title": title, "text_sha256": _sha256(text)} + input_payload = {"contract_id": contract_id, "text_sha256": _sha256(text)} result_payload = result.model_dump(mode="json") with psycopg.connect( os.environ["DATABASE_URL"], connect_timeout=3, - options=f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} -c lock_timeout={DB_LOCK_TIMEOUT_MS}", + options=_database_options(), row_factory=dict_row, ) as conn: with conn.transaction(): with conn.cursor() as cur: - cur.execute("SET LOCAL statement_timeout = %s", (DB_STATEMENT_TIMEOUT_MS,)) - cur.execute("SET LOCAL lock_timeout = %s", (DB_LOCK_TIMEOUT_MS,)) cur.execute( """ INSERT INTO contracts (tenant_key, contract_id, title, source_text_hash, updated_at) @@ -544,7 +556,10 @@ async def _persist_contract_result( _hash_json(input_payload), _hash_json(result_payload), "ok", - json.dumps({"input": input_payload, "result": result_payload}, sort_keys=True), + json.dumps( + {"input": input_payload, "result": _receipt_result_summary(result)}, + sort_keys=True, + ), ), ) return result.model_copy(update={"receipt_id": receipt_id, "saved_at": now}) @@ -573,11 +588,10 @@ async def _persist_receipt_only( with psycopg.connect( os.environ["DATABASE_URL"], connect_timeout=3, - options=f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} -c lock_timeout={DB_LOCK_TIMEOUT_MS}", + options=_database_options(), ) as conn: with conn.transaction(): with conn.cursor() as cur: - cur.execute("SET LOCAL statement_timeout = %s", (DB_STATEMENT_TIMEOUT_MS,)) cur.execute( """ INSERT INTO execution_receipts @@ -594,7 +608,10 @@ async def _persist_receipt_only( _hash_json(input_payload), _hash_json(result.model_dump(mode="json")), "error", - json.dumps({"input": input_payload, "result": result.model_dump(mode="json")}, sort_keys=True), + json.dumps( + {"input": input_payload, "result": _receipt_result_summary(result)}, + sort_keys=True, + ), ), ) except Exception: @@ -620,11 +637,10 @@ async def _load_contract_result( with psycopg.connect( os.environ["DATABASE_URL"], connect_timeout=3, - options=f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} -c lock_timeout={DB_LOCK_TIMEOUT_MS}", + options=_database_options(), row_factory=dict_row, ) as conn: with conn.cursor() as cur: - cur.execute("SET statement_timeout = %s", (DB_STATEMENT_TIMEOUT_MS,)) cur.execute( """ SELECT id, title, updated_at @@ -677,6 +693,27 @@ async def _load_contract_result( ) +def _database_options() -> str: + return ( + f"-c statement_timeout={DB_STATEMENT_TIMEOUT_MS} " + f"-c lock_timeout={DB_LOCK_TIMEOUT_MS} " + f"-c idle_in_transaction_session_timeout={DB_IDLE_TRANSACTION_TIMEOUT_MS}" + ) + + +def _receipt_result_summary(result: ContractResult) -> dict[str, Any]: + return { + "ok": result.ok, + "code": result.code, + "contract_id": result.contract_id, + "deadline_count": len(result.deadlines), + "deadlines": [ + {"kind": deadline.kind, "date": deadline.date} + for deadline in result.deadlines + ], + } + + def _receipt_id() -> str: return f"ccr_{uuid.uuid4().hex}" diff --git a/tests/test_full_stack_contract.py b/tests/test_full_stack_contract.py index 442fa30..e12f834 100644 --- a/tests/test_full_stack_contract.py +++ b/tests/test_full_stack_contract.py @@ -1,6 +1,5 @@ import base64 import importlib.util -import os import sys from pathlib import Path @@ -27,7 +26,7 @@ def test_full_stack_product_contract_card_and_manifest(): frontend = (ROOT / "frontend" / "src" / "App.jsx").read_text(encoding="utf-8") a2a_client = (ROOT / "frontend" / "src" / "a2a.js").read_text(encoding="utf-8") - assert "version: 0.1.5" in manifest + assert "version: 0.1.6" in manifest assert "public: false" in manifest assert "frontend:" in manifest and "mount: /app" in manifest assert "resources:" in manifest and "databases:" in manifest @@ -45,7 +44,7 @@ def test_full_stack_product_contract_card_and_manifest(): assert migrations and all(path.read_text(encoding="utf-8").strip() for path in migrations) card = load_local_project(ROOT).agent_cls().card() - assert card.version == "0.1.5" + assert card.version == "0.1.6" skill_names = {skill.name for skill in card.skills} assert {"analyze_contract", "get_contract_deadlines", "analyze_contract_upload_base64", "analyze_contract_file"} <= skill_names analyze_schema = next(skill.input_schema for skill in card.skills if skill.name == "analyze_contract") @@ -85,6 +84,31 @@ def test_explicit_date_only_success_and_failure(): assert bad.ok is False assert bad.code == "ambiguous_date" + multiple_dates = module._analyze_contract_text( + contract_id="studio-contract-multiple-dates", + title="Multiple dates", + text=( + "This Agreement is effective on 2025-01-01 and renews automatically " + "on 2026-12-31 unless either party gives at least 60 days written notice." + ), + ) + assert [d.model_dump(include={"kind", "date"}) for d in multiple_dates.deadlines] == [ + {"kind": "renewal", "date": "2026-12-31"}, + {"kind": "notice", "date": "2026-11-01"}, + ] + + separate_lines = module._analyze_contract_text( + contract_id="studio-contract-separate-lines", + title="Separate clauses", + text=( + "Agreement renews on 2026-12-31\n" + "60 days written notice applies to another obligation" + ), + ) + assert [d.model_dump(include={"kind", "date"}) for d in separate_lines.deadlines] == [ + {"kind": "renewal", "date": "2026-12-31"}, + ] + @pytest.mark.asyncio async def test_success_failure_upload_and_reload_with_fake_postgres(monkeypatch): @@ -106,7 +130,7 @@ async def test_success_failure_upload_and_reload_with_fake_postgres(monkeypatch) params = params or () normalized = " ".join(sql.split()) if normalized.startswith("SET"): - return + raise AssertionError("timeouts must be configured in connection options") if "INSERT INTO contracts" in normalized: tenant, contract_id, title, source_hash = params key = (tenant, contract_id) @@ -158,6 +182,7 @@ async def test_success_failure_upload_and_reload_with_fake_postgres(monkeypatch) class FakePsycopg: def connect(self, *args, **kwargs): assert "statement_timeout=5000" in kwargs.get("options", "") + assert "idle_in_transaction_session_timeout=5000" in kwargs.get("options", "") return FakeConnection() monkeypatch.setenv("DATABASE_URL", "postgresql://example.invalid/db") @@ -176,6 +201,9 @@ async def test_success_failure_upload_and_reload_with_fake_postgres(monkeypatch) assert success.ok is True assert success.receipt_id and success.saved_at assert len(store["receipts"]) == 1 + receipt_payload = __import__("json").loads(store["receipts"][0][-1]) + assert "source_text" not in str(receipt_payload) + assert receipt_payload["result"]["deadline_count"] == 2 reload_result = await agent.invoke("get_contract_deadlines", ctx, contract_id="studio-contract-v1") assert reload_result.ok is True