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:
@@ -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/קוד. אלו כפופים לכללי
|
||||
|
||||
@@ -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 הבלוקים (סיכום)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)} מסמכים ממתינים")
|
||||
|
||||
76
mcp-server/tests/test_reanalyze_impact_diff.py
Normal file
76
mcp-server/tests/test_reanalyze_impact_diff.py
Normal file
@@ -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"] == ["מענה"]
|
||||
Reference in New Issue
Block a user