Files
legal-ai/mcp-server/src/legal_mcp/tools/workflow.py
Chaim dfb2ffe7ce
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
Lint — undefined names / undefined-names (pull_request) Successful in 11s
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>
2026-06-30 12:19:28 +00:00

596 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""MCP tools for workflow: status, outcome, brainstorming, direction."""
from __future__ import annotations
import logging
from uuid import UUID
from legal_mcp.services import db
from legal_mcp.services.lessons import (
OUTCOME_LABELS_HE,
VALID_OUTCOMES,
canonical_outcome,
)
from legal_mcp.tools.envelope import empty, err, ok # GAP-48: SSoT envelope
logger = logging.getLogger(__name__)
async def workflow_status(case_number: str) -> str:
"""סטטוס תהליך עבודה מלא לתיק - מסמכים, עיבוד, טיוטות.
Args:
case_number: מספר תיק הערר
"""
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
docs = await db.list_documents(case_id)
# Count chunks per document
pool = await db.get_pool()
async with pool.acquire() as conn:
chunk_counts = await conn.fetch(
"SELECT document_id, COUNT(*) as count FROM document_chunks WHERE case_id = $1 GROUP BY document_id",
case_id,
)
chunk_map = {str(r["document_id"]): r["count"] for r in chunk_counts}
doc_status = []
for doc in docs:
doc_status.append({
"title": doc["title"],
"type": doc["doc_type"],
"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
case_dir = config.find_case_dir(case_number)
draft_path = case_dir / "drafts" / "decision.md"
has_draft = draft_path.exists()
draft_size = draft_path.stat().st_size if has_draft else 0
status = {
"case_number": case["case_number"],
"title": case["title"],
"status": case["status"],
"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, primary_not_analyzed),
}
return ok(status)
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}
if not docs:
steps.append("העלה מסמכים לתיק (כתב ערר, תשובת ועדה)")
else:
if "appeal" not in doc_types:
steps.append("העלה כתב ערר")
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)} מסמכים ממתינים")
if docs and not has_draft:
steps.append("התחל ניסוח טיוטת החלטה (/draft-decision)")
elif has_draft and case["status"] == "new":
steps.append("סקור ועדכן את הטיוטה")
steps.append("עדכן סטטוס ל-drafted")
if case["status"] == "drafted":
steps.append("סקירה סופית ועדכון סטטוס ל-reviewed")
elif case["status"] == "reviewed":
steps.append("אישור סופי ועדכון סטטוס ל-final")
return steps
async def get_metrics(case_number: str = "") -> str:
"""מדדי הצלחה — KPIs לתיק ספציפי או דשבורד כולל.
Args:
case_number: מספר תיק (אם ריק — דשבורד כולל)
"""
from legal_mcp.services import metrics
if case_number:
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
result = await metrics.get_case_metrics(UUID(case["id"]))
else:
result = await metrics.get_dashboard()
return ok(result)
async def processing_status() -> str:
"""סטטוס כללי - מספר תיקים, מסמכים ממתינים לעיבוד."""
pool = await db.get_pool()
async with pool.acquire() as conn:
case_count = await conn.fetchval("SELECT COUNT(*) FROM cases")
doc_count = await conn.fetchval("SELECT COUNT(*) FROM documents")
pending_count = await conn.fetchval(
"SELECT COUNT(*) FROM documents WHERE extraction_status = 'pending'"
)
chunk_count = await conn.fetchval("SELECT COUNT(*) FROM document_chunks")
corpus_count = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
pattern_count = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
return ok({
"cases": case_count,
"documents": doc_count,
"pending_processing": pending_count,
"chunks": chunk_count,
"style_corpus_entries": corpus_count,
"style_patterns": pattern_count,
})
# ── Outcome & Brainstorming ───────────────────────────────────────
async def set_outcome(
case_number: str,
outcome: str,
reasoning: str = "",
) -> str:
"""הזנת תוצאה לתיק ערר. יוצר רשומת החלטה ומפעיל סיעור מוחות אם אין נימוק.
Args:
case_number: מספר תיק הערר
outcome: תוצאה — rejection (דחייה) / partial_acceptance (קבלה חלקית) /
full_acceptance (קבלה מלאה). ערכי-legacy (rejected/accepted/partial) ממופים אוטומטית.
reasoning: נימוק (אופציונלי). אם ריק — מפעיל סיעור מוחות.
"""
from legal_mcp.services import brainstorm
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
# GAP-51: accept legacy vocabulary (rejected/accepted/partial), store canonical.
outcome = canonical_outcome(outcome)
if outcome not in VALID_OUTCOMES:
return err(f"תוצאה לא תקינה. אפשרויות: {', '.join(VALID_OUTCOMES)}")
case_id = UUID(case["id"])
# Create or update decision
existing = await db.get_decision_by_case(case_id)
if existing:
await db.update_decision(
UUID(existing["id"]),
outcome=outcome,
outcome_summary=reasoning[:200] if reasoning else "",
outcome_reasoning=reasoning,
)
decision = await db.get_decision(UUID(existing["id"]))
else:
decision = await db.create_decision(
case_id=case_id,
outcome=outcome,
outcome_summary=reasoning[:200] if reasoning else "",
outcome_reasoning=reasoning,
)
# Update case status — aligned with the web /set-outcome endpoint:
# reasoning given → direction is set; no reasoning → outcome only (brainstorm next).
new_status = "direction_approved" if reasoning else "outcome_set"
await db.update_case(case_id, status=new_status, expected_outcome=outcome)
outcome_hebrew = OUTCOME_LABELS_HE.get(outcome, outcome)
result = {
"decision_id": decision["id"],
"outcome": outcome,
"outcome_hebrew": outcome_hebrew,
"reasoning": reasoning,
"has_reasoning": bool(reasoning),
}
if not reasoning:
result["message"] = "לא סופק נימוק. הפעל /brainstorm כדי לקבל כיוונים אפשריים."
result["next_step"] = "brainstorm"
else:
result["message"] = f"תוצאה נשמרה: {outcome_hebrew}. ניתן להתחיל כתיבת טיוטה."
result["next_step"] = "draft"
return ok(result)
async def brainstorm_directions(
case_number: str,
) -> str:
"""סיעור מוחות — הצגת טענות מרכזיות והצעת 2-3 כיוונים אפשריים לנימוק.
Args:
case_number: מספר תיק הערר
"""
from legal_mcp.services import brainstorm
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
# Get existing decision for outcome
decision = await db.get_decision_by_case(case_id)
if not decision:
return err("לא הוזנה תוצאה לתיק. הפעל set_outcome קודם.")
outcome = decision.get("outcome", "")
reasoning = decision.get("outcome_reasoning", "")
directions = await brainstorm.generate_directions(case_id, outcome, reasoning)
# Save brainstorm results to decision
await db.update_decision(
UUID(decision["id"]),
direction_doc={"brainstorm": directions, "approved": False},
)
return ok(directions)
async def approve_direction(
case_number: str,
direction_index: int = 0,
additional_notes: str = "",
) -> str:
"""אישור כיוון — יוצר מסמך כיוון מאושר. לא ניתן להתחיל כתיבת דיון בלי כיוון מאושר.
Args:
case_number: מספר תיק הערר
direction_index: מספר הכיוון שנבחר (0 = ראשון)
additional_notes: הערות נוספות
"""
from legal_mcp.services import brainstorm
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
decision = await db.get_decision_by_case(case_id)
if not decision:
return err("לא הוזנה תוצאה לתיק.")
direction_data = decision.get("direction_doc") or {}
brainstorm_result = direction_data.get("brainstorm", {})
if not brainstorm_result.get("directions"):
return err("לא בוצע סיעור מוחות. הפעל brainstorm_directions קודם.")
direction_doc = brainstorm.build_direction_doc(
outcome=decision.get("outcome", ""),
reasoning=decision.get("outcome_reasoning", ""),
directions_result=brainstorm_result,
selected_direction=direction_index,
additional_notes=additional_notes,
)
await db.update_decision(UUID(decision["id"]), direction_doc=direction_doc)
return ok({"direction": direction_doc},
message="כיוון אושר. ניתן להתחיל כתיבת טיוטה.")
async def ingest_final_version(
case_number: str,
file_path: str = "",
final_text: str = "",
) -> str:
"""קליטת גרסה סופית (שדפנה חתמה). משווה לטיוטה ומחלצת לקחים.
Args:
case_number: מספר תיק הערר
file_path: נתיב לקובץ הגרסה הסופית (PDF/DOCX)
final_text: טקסט ישיר (אם אין קובץ)
"""
from legal_mcp.services import learning_loop
case = await db.get_case_by_number(case_number)
if not case:
return err(f"תיק {case_number} לא נמצא.")
case_id = UUID(case["id"])
# Extract text from file if provided
if file_path and not final_text:
from legal_mcp.services import extractor
final_text, _, _ = await extractor.extract_text(file_path)
if not final_text:
return err("לא סופק טקסט — יש לספק file_path או final_text.")
try:
result = await learning_loop.process_final_version(case_id, final_text)
except ValueError as e:
return err(str(e))
# Auto-ingest into internal committee decisions corpus (best-effort).
# chair_name is resolved via the shared SoT (config.committee_chair_for_case)
# — the SAME resolver the FastAPI upload path uses — so the two paths cannot
# drift (INV-G2) and the DB chair constraint is never hit on an empty chair
# (INV-G1: chair normalised at source). Failures are surfaced, not swallowed
# (engineering rule §6 / feedback_silent_swallow): the result carries the
# reason and final_learning_pipeline prints it.
try:
from legal_mcp import config
from legal_mcp.services import internal_decisions as int_svc
await int_svc.ingest_internal_decision(
case_number=case_number,
case_name=case.get("title", ""),
decision_date=case.get("decision_date"),
chair_name=config.committee_chair_for_case(case, case_number),
district="ירושלים",
practice_area=case.get("practice_area", ""),
appeal_subtype=case.get("appeal_subtype", ""),
text=final_text,
)
result["internal_corpus_ingested"] = True
except Exception as e:
logger.warning(
"ingest_final_version: internal corpus ingestion failed (non-fatal): %s", e)
result["internal_corpus_ingested"] = False
result["internal_corpus_error"] = str(e)
return ok(result)
# ── Chair feedback tools ──────────────────────────────────────────
async def record_chair_feedback(
case_number: str,
feedback_text: str,
block_id: str = "block-yod",
category: str = "missing_content",
lesson_extracted: str = "",
) -> str:
"""תיעוד הערת יו"ר (דפנה) על טיוטת החלטה.
Args:
case_number: מספר תיק הערר
feedback_text: ההערה של דפנה (מה חסר, מה לא נכון, מה צריך לשנות)
block_id: הבלוק שההערה מתייחסת אליו (ברירת מחדל: block-yod)
category: קטגוריה — missing_content/wrong_tone/wrong_structure/factual_error/style/other
lesson_extracted: הלקח שהופק מההערה (אם ברור כבר)
"""
case = await db.get_case_by_number(case_number)
case_id = UUID(case["id"]) if case else None
valid_categories = [
"missing_content", "wrong_tone", "wrong_structure",
"factual_error", "style", "other",
]
if category not in valid_categories:
return err(f"קטגוריה לא חוקית. אפשרויות: {', '.join(valid_categories)}")
feedback_id = await db.record_chair_feedback(
case_id=case_id,
block_id=block_id,
feedback_text=feedback_text,
category=category,
lesson_extracted=lesson_extracted,
)
# Auto-flow chair-authored STYLE feedback to the writer (closes the dead
# chair_feedback→lesson chain — 27 feedback rows had produced 0 lessons). The
# chair is the highest authority, so a style correction she writes flows
# immediately — the chair IS the gate. It rides the SAME discussion_rules channel
# promote uses (db.append_global_rule, G2), reaching every block, without the
# style_corpus coupling decision_lessons require. SUBSTANCE feedback
# (missing_content/factual_error/other) is case-specific → recorded only.
# (INV-LRN1 graduated gate; 07-learning §1.2.)
_STYLE_FB = {"style", "wrong_tone", "wrong_structure"}
flowed = 0
if lesson_extracted.strip() and category in _STYLE_FB:
try:
flowed = await db.append_global_rule(
"discussion_rules", "universal", [lesson_extracted.strip()],
)
except Exception as e:
logger.warning("chair-feedback auto-flow failed for %s: %s", case_number, e)
msg = f"הערה נרשמה בהצלחה. קטגוריה: {category}."
if flowed:
msg += " הלקח (סגנון) זרם אוטומטית לכותב."
return ok({
"feedback_id": str(feedback_id),
"flowed_to_writer": bool(flowed),
"next_steps": [
"כדי להפיק לקח מההערה, הפעל: analyze_chair_feedback",
"כדי לסמן כמטופל: resolve_chair_feedback",
],
}, message=msg)
_CURATOR_FINDING_CATEGORIES = {"style", "structure", "lexicon", "tabular", "general"}
# Agent tags ([סגנון]/[מבנה]/[לקסיקון משפטי]/[טבלאי]) → decision_lessons.category
_CURATOR_TAG_TO_CATEGORY = {
"סגנון": "style", "מבנה": "structure",
"לקסיקון משפטי": "lexicon", "לקסיקון": "lexicon", "טבלאי": "tabular",
}
async def record_curator_findings(case_number: str, findings: list[dict]) -> str:
"""לכידת ממצאי-האוצֵר כ-decision_lessons מובְנים (source='curator', proposed) — INV-LRN3.
האוצֵר מזהה דפוסי-סגנון בקריאת הסופי; עד כה הם חיו רק כהערה ארעית בערוץ-התגובות
(אובד, לא נסקר). כאן הם נתפסים מבנית כך שיופיעו בטאב ״אוצֵר״ ויעברו שער-יו"ר (INV-LRN1/G10).
האוצֵר נשאר read-only על התוכן — הרישום הוא הצעה הממתינה לאישור, לא שינוי-קול.
Args:
case_number: מספר התיק הסופי (= decision_number בקורפוס-הסגנון).
findings: רשימת ממצאים, כל אחד {"text": "...", "category"/"tag": "..."}.
category ∈ style/structure/lexicon/tabular/general (או tag עברי).
"""
if not findings:
return err("findings ריק — אין ממצאים לרשום.")
corpus_id = await db.get_style_corpus_id_by_decision(case_number)
if not corpus_id:
return err(
f"לא נמצאה רשומת style_corpus ל-{case_number} — ודא שהסופי נקלט לקורפוס-הסגנון "
"(enroll_style_corpus) לפני רישום ממצאים."
)
# Dedup against lessons already on this corpus (any source) — re-running §A
# must not pile duplicates (INV-LRN3 reliability).
existing = {(_norm(r["lesson_text"])) for r in await db.list_decision_lessons(corpus_id)}
written, skipped_dup, skipped_empty = [], 0, 0
for f in findings:
text = (f.get("text") or "").strip()
if not text:
skipped_empty += 1
continue
if _norm(text) in existing:
skipped_dup += 1
continue
raw_cat = (f.get("category") or f.get("tag") or "general").strip()
category = _CURATOR_TAG_TO_CATEGORY.get(raw_cat, raw_cat)
if category not in _CURATOR_FINDING_CATEGORIES:
category = "general"
row = await db.add_decision_lesson(
corpus_id,
lesson_text=text,
category=category,
source="curator",
created_by="curator",
review_status="proposed",
)
if row:
written.append(str(row["id"]))
existing.add(_norm(text))
return ok({
"corpus_id": str(corpus_id),
"written": len(written),
"skipped_duplicate": skipped_dup,
"skipped_empty": skipped_empty,
"lesson_ids": written,
}, message=(
f"נרשמו {len(written)} ממצאי-אוצֵר (source=curator, ממתינים לשער-יו\"ר ב-/training). "
f"{skipped_dup} כפילויות דולגו."
))
def _norm(s: str) -> str:
"""Normalize lesson text for dedup — collapse whitespace, strip."""
return " ".join((s or "").split())
async def lesson_synthesize_pending(
practice_area: str = "", category: str = "", apply: bool = True,
) -> str:
"""סינתזת-לקחים (#158 / INV-LRN8): ממזגת לקחי-סגנון חופפים ל"לקח-על" אחד עשיר ומוכלל,
כך שהסט שזורם לכותב קטֵן ומשתבח (התקרה limit=15 מפסיקה לחתוך). מאשכלת לפי דמיון (cosine)
בתוך shard של practice_area+category, ומסנתזת מעוגן-מקור (INV-AH) עם שער-drift.
Args:
practice_area: לצמצם ל-shard אחד (ריק = כל התחומים).
category: style/structure/lexicon/tabular (ריק = כל הקטגוריות).
apply: True = כותב (לקח-על approved + מקורות→superseded); False = dry-run.
"""
from legal_mcp.services import lesson_synthesis
shards = await lesson_synthesis.run_pending(practice_area, category, apply=apply)
applied = sum(1 for s in shards for c in s["clusters"] if c.get("applied"))
clusters = sum(len(s["clusters"]) for s in shards)
return ok({
"apply": apply,
"shards": shards,
"clusters_found": clusters,
"synthesized": applied,
}, message=(
f"סינתזת-לקחים: {clusters} אשכולות ב-{len(shards)} shards · "
f"{applied} לקחי-על {'נכתבו (approved)' if apply else 'דמו (dry-run)'}."
))
async def list_chair_feedback(
case_number: str = "",
category: str = "",
unresolved_only: bool = True,
limit: int = 100,
) -> str:
"""הצגת הערות יו"ר שתועדו, עם אפשרות סינון.
Args:
case_number: סינון לפי תיק (אם ריק — כל ההערות)
category: סינון לפי קטגוריה
unresolved_only: האם להציג רק הערות שלא טופלו (ברירת מחדל: כן)
limit: תקרת תוצאות (INV-TOOL5 / GAP-53)
"""
case_id = None
if case_number:
case = await db.get_case_by_number(case_number)
if case:
case_id = UUID(case["id"])
feedbacks = await db.list_chair_feedback(
case_id=case_id,
category=category or None,
unresolved_only=unresolved_only,
limit=limit,
)
if not feedbacks:
return empty("אין הערות שמתאימות לסינון.")
items = []
for fb in feedbacks:
items.append({
"id": str(fb["id"]),
"case_id": str(fb["case_id"]) if fb["case_id"] else None,
"block_id": fb["block_id"],
"category": fb["category"],
"feedback": fb["feedback_text"],
"lesson": fb["lesson_extracted"],
"resolved": fb["resolved"],
"date": fb["created_at"].isoformat() if fb.get("created_at") else None,
})
return ok({
"total": len(items),
"feedbacks": items,
})