Repair ContractClock persistence and receipt safety

This commit is contained in:
2026-07-18 05:27:54 -03:00
parent 78f8677e6f
commit 21284e8ab4
3 changed files with 88 additions and 23 deletions

View File

@@ -1,5 +1,5 @@
name: contract-clock-studio-v1
version: 0.1.5
version: 0.1.6
entrypoint: agent:ContractClockStudioV1
expose:
public: false

View File

@@ -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}"

View File

@@ -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