Repair ContractClock persistence and receipt safety
This commit is contained in:
2
a2a.yaml
2
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
|
||||
|
||||
73
agent.py
73
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}"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user