From dfb2ffe7ce31fd0e40aa7b105413c8804520173f Mon Sep 17 00:00:00 2001 From: Chaim Date: Tue, 30 Jun 2026 12:14:54 +0000 Subject: [PATCH] =?UTF-8?q?feat(analysis):=20=D7=93=D7=92=D7=9C=20"=D7=9C?= =?UTF-8?q?=D7=90-=D7=A0=D7=95=D7=AA=D7=97"=20+=20=D7=A0=D7=99=D7=AA=D7=95?= =?UTF-8?q?=D7=97-=D7=9E=D7=97=D7=93=D7=A9=20=D7=9E=D7=90=D7=97=D7=93=20+?= =?UTF-8?q?=20=D7=91=D7=93=D7=99=D7=A7=D7=AA-=D7=94=D7=A9=D7=A4=D7=A2?= =?UTF-8?q?=D7=94=20(#201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/spec/02-data-model.md | 13 ++ docs/spec/04-analysis-writing.md | 20 +++ mcp-server/src/legal_mcp/server.py | 12 ++ .../legal_mcp/services/claims_extractor.py | 8 + mcp-server/src/legal_mcp/services/db.py | 67 +++++++ .../src/legal_mcp/tools/legal_arguments.py | 164 +++++++++++++++++- mcp-server/src/legal_mcp/tools/workflow.py | 26 ++- .../tests/test_reanalyze_impact_diff.py | 76 ++++++++ 8 files changed, 375 insertions(+), 11 deletions(-) create mode 100644 mcp-server/tests/test_reanalyze_impact_diff.py diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md index 0b265e0..b194be1 100644 --- a/docs/spec/02-data-model.md +++ b/docs/spec/02-data-model.md @@ -95,6 +95,19 @@ WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו `document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201). +**דגל "לא-נותח" — per-מסמך (#201, SCHEMA_V49).** שני שדות מתעדים את *אירוע* חילוץ-הטענות +של המסמך (לא נגזרים מ-`doc_type`): `claims_extracted_at TIMESTAMPTZ` (נחתם ע"י +`claims_extractor` **אחרי** ששמרנו את טענות המסמך) ו-`claims_extraction_status TEXT` +(`pending`/`completed`/`no_claims`/`failed`). `_row_to_doc` חושף `claims_analyzed` +(`claims_extracted_at IS NOT NULL`). מקור-האמת היחיד ל"האם המסמך נכלל בניתוח" הוא +`claims_extracted_at` — לא משוחזר ממקום אחר. אינדקס חלקי `idx_documents_claims_pending(case_id) +WHERE is_primary AND claims_extracted_at IS NULL` (יחד עם `is_primary` של V47) הופך את +"מסמך-עיקרי שנוסף-אחרי-ניתוח ולא-נכלל" (`db.primary_docs_not_analyzed`) לשאילתה זולה. +נחשף ב-`workflow_status` (`primary_docs_not_analyzed` + next-step). הניתוח-מחדש המאחד +(`reanalyze_claims`, [04-analysis-writing §1.3](04-analysis-writing.md)) מחלץ-מחדש רק את +המסמכים-העיקריים החדשים/לא-נותחו, **מאחד** עם הטענות הקיימות, ומריץ צבירה-מחדש (G2 — מסלול +קנוני אחד, לא fork). + ### 2ד. ישויות-נגזרות (אחסון-ניתוחים) מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי diff --git a/docs/spec/04-analysis-writing.md b/docs/spec/04-analysis-writing.md index f1676aa..4caa424 100644 --- a/docs/spec/04-analysis-writing.md +++ b/docs/spec/04-analysis-writing.md @@ -97,6 +97,26 @@ knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arg דפנה (`build_party_claims_summary_docx`). מימוש: `tools/drafting.py` · `services/party_claims_summary.py` · `services/analysis_docx_exporter.py`. +### 1.5 ניתוח-מחדש מאחד אחרי מסמך-עיקרי חדש (#201) + +תיק מנותח לרוב מכתב-הערר לבד, ומסמך-עיקרי (תשובה/התנגדות/פרוטוקול…) מגיע מאוחר יותר. +כל מסמך נושא **דגל "לא-נותח"** (`claims_extracted_at`, [02-data-model §2ג](02-data-model.md)), +ו-`workflow_status` מסמן "מסמך-עיקרי שטרם-נכלל בניתוח". `reanalyze_claims(case_number, +reanalyze_all_primary=False)` סוגר את הפער **בלי force-delete גורף**: + +1. **snapshot לפני** — הטיעונים המאוגדים הנוכחיים (בסיס בדיקת-ההשפעה). +2. **חילוץ-מאחד** — מחלץ רק את המסמכים-העיקריים החדשים/לא-נותחו (`db.primary_docs_not_analyzed`) + דרך **אותו** `claims_extractor.extract_and_store_claims`; `store_claims` מחליף רק את טענות + *אותו* מסמך (לפי `source_document`), כך שטענות ממסמכים שכבר-נותחו **נשמרות** (האיחוד). +3. **צבירה-מחדש** — `aggregate_claims_to_arguments(force=True)` מחשב את הטיעונים מחדש מתוך + מערך-הטענות המאוחד השלם. זהו **מסלול-החישוב הקנוני** ([G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)), + לא מסלול-עיבוד מקביל; הוא מוחק רק `legal_arguments` (נגזר), לעולם לא `claims`. +4. **בדיקת-השפעה ליו"ר** — snapshot אחרי + `_impact_diff` מחזיר per-צד אילו טיעונים + נוספו/הוסרו ואיך השתנה תמהיל-העדיפויות ("מאזן-ההמלצה"), עם דגל `changed`. הפלט **מוצג** + ליו"ר ולא מוחל אוטומטית ([G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant)). + +ראה `tools/legal_arguments.py` (`reanalyze_claims`, `_snapshot`, `_impact_diff`). + --- ## 2. ארכיטקטורת 12 הבלוקים (סיכום) diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index f29f6da..4a9e557 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -572,6 +572,18 @@ async def get_legal_arguments( return await la_tools.get_legal_arguments(case_number, party) +@mcp.tool() +async def reanalyze_claims( + case_number: str, + reanalyze_all_primary: bool = False, +) -> str: + """ניתוח-מחדש מאחד אחרי הוספת מסמך-עיקרי: מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד, + מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה + (diff טיעונים/המלצה לפני↔אחרי) ליו"ר. reanalyze_all_primary=True = כל המסמכים-העיקריים. + """ + return await la_tools.reanalyze_claims(case_number, reanalyze_all_primary) + + # References @mcp.tool() async def extract_references( diff --git a/mcp-server/src/legal_mcp/services/claims_extractor.py b/mcp-server/src/legal_mcp/services/claims_extractor.py index 2959786..c2aa19e 100644 --- a/mcp-server/src/legal_mcp/services/claims_extractor.py +++ b/mcp-server/src/legal_mcp/services/claims_extractor.py @@ -360,6 +360,10 @@ async def extract_and_store_claims( claims = await extract_claims_with_ai(text, doc_type, party_hint) if not claims: + # Stamp the document as analysed even with zero claims, so it is not + # perpetually flagged "not-analysed" (#201 — the not-analysed flag is + # about whether extraction *ran*, not whether it found anything). + await db.mark_document_claims_extracted(document_id, status="no_claims") return {"status": "no_claims", "total": 0, "source": source_name} # Determine claim_type from document type and title @@ -368,6 +372,10 @@ async def extract_and_store_claims( c["claim_type"] = claim_type stored = await db.store_claims(case_id, claims, source_document=source_name) + # Mark this document analysed (WS2 / #201). store_claims already replaced + # this document's prior claims by source_document — so re-running is a clean + # per-document refresh that does NOT touch other documents' claims (the merge). + await db.mark_document_claims_extracted(document_id, status="completed") # Summarize by role role_counts: dict[str, int] = {} diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index ede2743..1e2a0c7 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -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( diff --git a/mcp-server/src/legal_mcp/tools/legal_arguments.py b/mcp-server/src/legal_mcp/tools/legal_arguments.py index 656bedb..f782ad8 100644 --- a/mcp-server/src/legal_mcp/tools/legal_arguments.py +++ b/mcp-server/src/legal_mcp/tools/legal_arguments.py @@ -4,9 +4,18 @@ from __future__ import annotations from uuid import UUID -from legal_mcp.services import argument_aggregator, db +from legal_mcp.services import argument_aggregator, claims_extractor, db from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope +# Party labels reused across this module (display + impact diff). +_PARTY_HE = { + "appellant": "עוררים", + "respondent": "משיבים", + "committee": "ועדה מקומית", + "permit_applicant": "מבקשי היתר", + "unknown": "צד לא מזוהה", +} + async def aggregate_claims_to_arguments( case_number: str, @@ -55,16 +64,9 @@ async def get_legal_arguments( ) # Group by party for nicer display. - party_he = { - "appellant": "עוררים", - "respondent": "משיבים", - "committee": "ועדה מקומית", - "permit_applicant": "מבקשי היתר", - "unknown": "צד לא מזוהה", - } by_party: dict[str, list[dict]] = {} for a in args: - label = party_he.get(a["party"], a["party"]) + label = _PARTY_HE.get(a["party"], a["party"]) by_party.setdefault(label, []).append(a) return ok({ @@ -72,3 +74,147 @@ async def get_legal_arguments( "total": len(args), "by_party": by_party, }) + + +# ── Re-analysis with merge + impact diff (WS2 / #201) ─────────────── + +def _snapshot(args: list[dict]) -> dict: + """Build a comparable snapshot of aggregated arguments for the impact diff. + + Captures, per party: the set of argument titles and a priority histogram — + enough to show the chair what changed without dumping full bodies. The + priority mix is the deterministic "recommendation" signal: it summarises the + balance of threshold/substantive/procedural/relief arguments per side. + """ + by_party: dict[str, dict] = {} + for a in args: + party = a.get("party", "unknown") + bucket = by_party.setdefault(party, {"titles": [], "priorities": {}}) + bucket["titles"].append((a.get("argument_title") or "").strip()) + pr = a.get("priority", "substantive") + bucket["priorities"][pr] = bucket["priorities"].get(pr, 0) + 1 + return {"total": len(args), "by_party": by_party} + + +def _impact_diff(before: dict, after: dict) -> dict: + """Diff two argument snapshots → a chair-facing before↔after summary. + + Reports, per party, which argument titles were added/removed and how the + priority mix (the "recommendation" balance) shifted. ``changed`` is a quick + boolean the UI/chair can gate on. Nothing here is auto-applied — it is purely + surfaced for the human gate (G10). + """ + parties = sorted(set(before["by_party"]) | set(after["by_party"])) + per_party: list[dict] = [] + any_change = False + for party in parties: + b = before["by_party"].get(party, {"titles": [], "priorities": {}}) + a = after["by_party"].get(party, {"titles": [], "priorities": {}}) + b_titles, a_titles = set(b["titles"]), set(a["titles"]) + added = sorted(a_titles - b_titles) + removed = sorted(b_titles - a_titles) + prio_before = dict(sorted(b["priorities"].items())) + prio_after = dict(sorted(a["priorities"].items())) + changed = bool(added or removed or prio_before != prio_after) + any_change = any_change or changed + per_party.append({ + "party": party, + "party_he": _PARTY_HE.get(party, party), + "count_before": len(b["titles"]), + "count_after": len(a["titles"]), + "added_arguments": added, + "removed_arguments": removed, + "priority_before": prio_before, + "priority_after": prio_after, + "changed": changed, + }) + return { + "changed": any_change, + "total_before": before["total"], + "total_after": after["total"], + "by_party": per_party, + } + + +async def reanalyze_claims( + case_number: str, + reanalyze_all_primary: bool = False, +) -> str: + """ניתוח-מחדש מאחד של טענות — מחלץ ממסמכים-עיקריים חדשים/לא-נותחו בלבד, + מאחד עם הטענות הקיימות (לא מוחק הכול), מריץ צבירה-מחדש, ומחזיר בדיקת-השפעה + (diff טיעונים/המלצה לפני↔אחרי) ליו"ר. + + Args: + case_number: מספר תיק הערר. + reanalyze_all_primary: True = לחלץ מחדש מכל המסמכים-העיקריים (ולא רק + מאלה שטרם-נותחו). ברירת-מחדל False = רק מסמכים-עיקריים חדשים/לא-נותחו. + + מנגנון האיחוד (לא force-delete): כל מסמך מחולץ דרך + ``claims_extractor.extract_and_store_claims``, ש-``store_claims`` שלו מחליף רק + את טענות *אותו* מסמך (לפי ``source_document``) — כך טענות ממסמכים שכבר-נותחו + נשמרות. הצבירה-מחדש (``aggregate_claims_to_arguments(force=True)``) מחשבת את + הטיעונים מחדש מתוך **מערך-הטענות המאוחד השלם** — מסלול-החישוב הקנוני, לא מסלול + מקביל (G2). + """ + case = await db.get_case_by_number(case_number) + if not case: + return err(f"תיק {case_number} לא נמצא.") + + case_id = UUID(case["id"]) + + # 1. BEFORE snapshot — current aggregated arguments (the impact baseline). + before_args = await argument_aggregator.get_legal_arguments(case_id) + before = _snapshot(before_args) + + # 2. Select PRIMARY documents to (re)extract. Default: only those not yet + # analysed (the not-analysed flag). reanalyze_all_primary widens to every + # primary doc. Source of truth for "primary" = is_primary (V47). + if reanalyze_all_primary: + all_docs = await db.list_documents(case_id) + targets = [d for d in all_docs if d.get("is_primary")] + else: + targets = await db.primary_docs_not_analyzed(case_id) + + if not targets: + return ok({ + "case_number": case_number, + "status": "no_pending_documents", + "message": "אין מסמכים-עיקריים חדשים/לא-נותחו. לא בוצע ניתוח-מחדש.", + "documents_analyzed": [], + "impact": _impact_diff(before, before), + }) + + # 3. Extract+merge claims from the selected documents only. Each call replaces + # only that document's claims (merge), never the whole case (no force-delete). + analyzed: list[dict] = [] + for doc in targets: + text = await db.get_document_text(UUID(doc["id"])) + if not text: + await db.mark_document_claims_extracted(UUID(doc["id"]), status="no_claims") + analyzed.append({"document": doc["title"], "status": "empty_text", "total": 0}) + continue + res = await claims_extractor.extract_and_store_claims( + case_id=case_id, + document_id=UUID(doc["id"]), + text=text, + doc_type=doc["doc_type"], + ) + analyzed.append({"document": doc["title"], **res}) + + # 4. Re-aggregate from the now-complete merged claim set. force=True is the + # canonical recompute (deletes only legal_arguments, NOT claims) — same + # path aggregate_claims_to_arguments always uses, not a fork. + agg = await argument_aggregator.aggregate_claims_to_arguments(case_id, force=True) + + # 5. AFTER snapshot + impact diff for the chair. + after_args = await argument_aggregator.get_legal_arguments(case_id) + after = _snapshot(after_args) + impact = _impact_diff(before, after) + + return ok({ + "case_number": case_number, + "status": "completed", + "documents_analyzed": analyzed, + "aggregation": agg, + "impact": impact, + }) diff --git a/mcp-server/src/legal_mcp/tools/workflow.py b/mcp-server/src/legal_mcp/tools/workflow.py index a272a8e..b4741f1 100644 --- a/mcp-server/src/legal_mcp/tools/workflow.py +++ b/mcp-server/src/legal_mcp/tools/workflow.py @@ -46,8 +46,19 @@ async def workflow_status(case_number: str) -> str: "extraction": doc["extraction_status"], "chunks": chunk_map.get(doc["id"], 0), "pages": doc.get("page_count"), + # WS2 / #201: primary/secondary + whether claims were analysed. + "is_primary": doc.get("is_primary", False), + "claims_analyzed": doc.get("claims_analyzed", False), }) + # WS2 / #201: PRIMARY documents added after the analysis ran and not yet + # included → the "not-analysed" condition that should prompt a merging + # re-analysis (reanalyze_claims). Derived from is_primary + claims_extracted_at. + pending_primary = await db.primary_docs_not_analyzed(case_id) + primary_not_analyzed = [ + {"title": d["title"], "type": d["doc_type"]} for d in pending_primary + ] + # Check draft status from pathlib import Path from legal_mcp import config @@ -64,15 +75,19 @@ async def workflow_status(case_number: str) -> str: "documents": doc_status, "total_documents": len(docs), "total_chunks": sum(chunk_map.values()), + "primary_docs_not_analyzed": primary_not_analyzed, "has_draft": has_draft, "draft_size_bytes": draft_size, - "next_steps": _suggest_next_steps(case, docs, has_draft), + "next_steps": _suggest_next_steps(case, docs, has_draft, primary_not_analyzed), } return ok(status) -def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]: +def _suggest_next_steps( + case: dict, docs: list, has_draft: bool, + primary_not_analyzed: list | None = None, +) -> list[str]: """Suggest next steps based on case state.""" steps = [] doc_types = {d["doc_type"] for d in docs} @@ -85,6 +100,13 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]: if "response" not in doc_types: steps.append("העלה תשובת ועדה/משיבים") + # WS2 / #201: a primary document was added but not yet analysed. + if primary_not_analyzed: + titles = ", ".join(d["title"] for d in primary_not_analyzed) + steps.append( + f"מסמך-עיקרי לא-נותח ({titles}) — הרץ ניתוח-מחדש מאחד (reanalyze_claims)" + ) + pending = [d for d in docs if d["extraction_status"] == "pending"] if pending: steps.append(f"עיבוד {len(pending)} מסמכים ממתינים") diff --git a/mcp-server/tests/test_reanalyze_impact_diff.py b/mcp-server/tests/test_reanalyze_impact_diff.py new file mode 100644 index 0000000..15e2a4e --- /dev/null +++ b/mcp-server/tests/test_reanalyze_impact_diff.py @@ -0,0 +1,76 @@ +"""Unit tests for the re-analysis impact diff — WS2 / task #201. + +`_snapshot` + `_impact_diff` are pure functions (no DB) that produce the +before↔after summary surfaced to the chair after a merging re-analysis. They +must report per-party argument titles added/removed and the shift in the +priority ("recommendation balance") mix, and set ``changed`` correctly. +""" + +from __future__ import annotations + +from legal_mcp.tools.legal_arguments import _impact_diff, _snapshot + + +def _arg(party, title, priority="substantive"): + return {"party": party, "argument_title": title, "priority": priority} + + +def test_no_change_is_flagged_false(): + args = [_arg("appellant", "זכות עמידה"), _arg("committee", "שיהוי")] + snap = _snapshot(args) + diff = _impact_diff(snap, snap) + assert diff["changed"] is False + assert diff["total_before"] == diff["total_after"] == 2 + # Every party entry reports changed=False. + assert all(p["changed"] is False for p in diff["by_party"]) + + +def test_added_argument_detected(): + before = _snapshot([_arg("appellant", "זכות עמידה")]) + after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("appellant", "תחולת תמא 38")]) + diff = _impact_diff(before, after) + assert diff["changed"] is True + assert diff["total_before"] == 1 + assert diff["total_after"] == 2 + appellant = next(p for p in diff["by_party"] if p["party"] == "appellant") + assert appellant["added_arguments"] == ["תחולת תמא 38"] + assert appellant["removed_arguments"] == [] + assert appellant["party_he"] == "עוררים" + + +def test_removed_argument_detected(): + before = _snapshot([_arg("committee", "שיהוי"), _arg("committee", "סמכות")]) + after = _snapshot([_arg("committee", "שיהוי")]) + diff = _impact_diff(before, after) + committee = next(p for p in diff["by_party"] if p["party"] == "committee") + assert committee["removed_arguments"] == ["סמכות"] + assert committee["count_before"] == 2 + assert committee["count_after"] == 1 + assert diff["changed"] is True + + +def test_priority_mix_shift_is_a_change(): + # Same title, but priority moved threshold -> substantive: the recommendation + # balance shifted, so the chair must see a change even with identical titles. + before = _snapshot([_arg("appellant", "סמכות", priority="threshold")]) + after = _snapshot([_arg("appellant", "סמכות", priority="substantive")]) + diff = _impact_diff(before, after) + appellant = next(p for p in diff["by_party"] if p["party"] == "appellant") + assert appellant["added_arguments"] == [] + assert appellant["removed_arguments"] == [] + assert appellant["priority_before"] == {"threshold": 1} + assert appellant["priority_after"] == {"substantive": 1} + assert appellant["changed"] is True + assert diff["changed"] is True + + +def test_new_party_appears(): + before = _snapshot([_arg("appellant", "זכות עמידה")]) + after = _snapshot([_arg("appellant", "זכות עמידה"), _arg("respondent", "מענה")]) + diff = _impact_diff(before, after) + parties = {p["party"] for p in diff["by_party"]} + assert parties == {"appellant", "respondent"} + respondent = next(p for p in diff["by_party"] if p["party"] == "respondent") + assert respondent["count_before"] == 0 + assert respondent["count_after"] == 1 + assert respondent["added_arguments"] == ["מענה"]