feat(analysis): דגל "לא-נותח" + ניתוח-מחדש מאחד + בדיקת-השפעה (#201)
WS2b של עיצוב-מחדש זרימת-העבודה. מאפשר ניתוח כתב-הערר לבד ואז איחוד מסמך-עיקרי שנוסף מאוחר, בלי force-delete גורף, עם diff ליו"ר. - SCHEMA_V49: documents.claims_extracted_at + claims_extraction_status (אירוע-חילוץ per-מסמך, לא נגזר מ-doc_type), + אינדקס חלקי idx_documents_claims_pending. idempotent (ADD COLUMN IF NOT EXISTS). V48 שמור ל-#357. - claims_extractor חותם את המסמך אחרי שמירת/אי-מציאת טענות. - db.primary_docs_not_analyzed (is_primary V47 + claims_extracted_at IS NULL) + mark_document_claims_extracted; _row_to_doc חושף claims_analyzed. - reanalyze_claims (כלי-MCP): snapshot→חילוץ-מאחד רק למסמכים חדשים/לא-נותחו (store_claims מחליף per-source ⇒ טענות אחרות נשמרות)→aggregate force=True (מסלול קנוני, מוחק רק legal_arguments)→_impact_diff before↔after ליו"ר. - workflow_status חושף primary_docs_not_analyzed + next-step. - ספ: 02-data-model §2ג (דגל לא-נותח), 04-analysis-writing §1.3. - 5 בדיקות-יחידה ל-_impact_diff/_snapshot (פונקציות טהורות). Invariants: - G1 (נרמול-במקור): claims_extracted_at נחתם בנקודת-החילוץ, claims_analyzed נגזר ממנו — לא תיקון-בקריאה. - G2 (מסלול קנוני יחיד / merge-not-fork): reanalyze מרחיב את מסלול claims_extractor+argument_aggregator הקיים, לא forks; האיחוד דרך store_claims per-source; aggregate force=True מוחק רק נגזר (legal_arguments). - INV-DM (מודל-נתונים): דגל-אירוע per-מסמך, אינדקס חלקי, מקור-אמת יחיד. - G10 (שער-אנושי): בדיקת-ההשפעה מוצגת ליו"ר, לא מוחלת אוטומטית. - INV-TOOL idempotency: SCHEMA_V49 idempotent; ניתוח-מחדש חוזר על מסמך כבר-נותח הוא refresh נקי (store_methods replace per-source). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1870,6 +1870,23 @@ CREATE INDEX IF NOT EXISTS idx_protocol_analysis_case ON protocol_analysis(case_
|
||||
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
|
||||
"""
|
||||
|
||||
# V49 (WS2 / task #201): per-document "claims analysed" tracking — the "not-analysed"
|
||||
# flag. `claims_extracted_at` is stamped by claims_extractor AFTER a document's claims
|
||||
# are stored; `claims_extraction_status` records the outcome
|
||||
# ('pending' | 'completed' | 'no_claims' | 'failed'). These describe the per-document
|
||||
# extraction *event* (the single source of truth for "has this doc been analysed") —
|
||||
# NOT derived from doc_type. They let us detect a PRIMARY document (is_primary, V47)
|
||||
# added after the analysis ran and not yet included → "primary doc not yet analysed".
|
||||
# The partial index pairs with idx_documents_primary so that query is cheap. Idempotent:
|
||||
# ADD COLUMN IF NOT EXISTS; re-running is a no-op. (V48 = protocol_analysis, #357.)
|
||||
SCHEMA_V49_SQL = """
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extracted_at TIMESTAMPTZ;
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS claims_extraction_status TEXT
|
||||
NOT NULL DEFAULT 'pending';
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_claims_pending
|
||||
ON documents(case_id) WHERE is_primary AND claims_extracted_at IS NULL;
|
||||
"""
|
||||
|
||||
|
||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||
@@ -1941,6 +1958,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||
await conn.execute(SCHEMA_V46_SQL)
|
||||
await conn.execute(SCHEMA_V47_SQL)
|
||||
await conn.execute(SCHEMA_V48_SQL)
|
||||
await conn.execute(SCHEMA_V49_SQL)
|
||||
|
||||
|
||||
async def init_schema() -> None:
|
||||
@@ -2321,6 +2339,14 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
||||
else is_primary_doc_type(d.get("doc_type"))
|
||||
d["is_primary"] = is_primary
|
||||
d["doc_category"] = "primary" if is_primary else "secondary"
|
||||
# Per-document claims-analysis state (WS2 / #201). `claims_analyzed` is the
|
||||
# human-facing "has this doc been included in the claims analysis" flag —
|
||||
# True iff claims extraction has been stamped for it. Single source of truth:
|
||||
# claims_extracted_at. Defaults keep pre-migration rows well-formed.
|
||||
extracted_at = d.get("claims_extracted_at")
|
||||
if "claims_extraction_status" not in d or d.get("claims_extraction_status") is None:
|
||||
d["claims_extraction_status"] = "pending"
|
||||
d["claims_analyzed"] = extracted_at is not None
|
||||
return d
|
||||
|
||||
|
||||
@@ -2370,6 +2396,47 @@ async def get_claims(case_id: UUID, party_role: str | None = None) -> list[dict]
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Per-document claims-analysis tracking (WS2 / #201) ──────────────
|
||||
|
||||
async def mark_document_claims_extracted(
|
||||
doc_id: UUID, status: str = "completed",
|
||||
) -> None:
|
||||
"""Stamp a document as having had its claims extracted (WS2 / #201).
|
||||
|
||||
Sets ``claims_extracted_at = now()`` and records the outcome in
|
||||
``claims_extraction_status`` ('completed' | 'no_claims' | 'failed').
|
||||
This is the single source of truth for the per-document "not-analysed"
|
||||
flag — it is written at the point claims are stored, not derived later.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE documents SET claims_extracted_at = now(), "
|
||||
"claims_extraction_status = $2 WHERE id = $1",
|
||||
doc_id, status,
|
||||
)
|
||||
|
||||
|
||||
async def primary_docs_not_analyzed(case_id: UUID) -> list[dict]:
|
||||
"""Return PRIMARY documents in a case that have NOT yet been analysed.
|
||||
|
||||
"Primary" = ``is_primary`` (the V47 generated column, single source of
|
||||
truth = PRIMARY_DOC_TYPES). "Not analysed" = ``claims_extracted_at IS NULL``.
|
||||
Backed by the partial index ``idx_documents_claims_pending``. Surfaces the
|
||||
"there is a primary document not yet included in the analysis" condition
|
||||
(#201). Returns the same shape as ``list_documents`` rows.
|
||||
"""
|
||||
pool = await get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT * FROM documents "
|
||||
"WHERE case_id = $1 AND is_primary AND claims_extracted_at IS NULL "
|
||||
"ORDER BY created_at",
|
||||
case_id,
|
||||
)
|
||||
return [_row_to_doc(r) for r in rows]
|
||||
|
||||
|
||||
# ── Decisions ──────────────────────────────────────────────────────
|
||||
|
||||
async def create_decision(
|
||||
|
||||
Reference in New Issue
Block a user