Add full decision writing pipeline: classify, extract, brainstorm, write, QA, export

New services (11 files):
- classifier.py: auto doc-type classification + party identification (Claude Haiku)
- claims_extractor.py: claim extraction from pleadings (Claude Sonnet + regex)
- references_extractor.py: plan/case-law/legislation detection (regex)
- brainstorm.py: direction generation with 2-3 options (Claude Sonnet)
- block_writer.py: 12-block decision writer (template + Claude Sonnet/Opus)
- docx_exporter.py: DOCX export with David font, RTL, headings
- qa_validator.py: 6 QA checks with export blocking on critical failure
- learning_loop.py: draft vs final comparison + lesson extraction
- metrics.py: KPIs dashboard per case and global
- audit.py: action audit log
- cli.py: standalone CLI with 11 commands

Updated pipeline: extract → classify → chunk → embed → store → extract_references
New MCP tools: 29 total (was 16)
New DB tables: audit_log, decisions CRUD, claims CRUD
Config: Infisical support, external service allowlist

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-03 10:21:47 +00:00
parent df7cc4f5a5
commit d9e5ef0f46
21 changed files with 3957 additions and 14 deletions

View File

@@ -15,7 +15,7 @@ from legal_mcp.services import db, processor
async def document_upload(
case_number: str,
file_path: str,
doc_type: str = "appeal",
doc_type: str = "auto",
title: str = "",
) -> str:
"""העלאה ועיבוד מסמך לתיק ערר. מחלץ טקסט, יוצר chunks ו-embeddings.
@@ -23,7 +23,7 @@ async def document_upload(
Args:
case_number: מספר תיק הערר
file_path: נתיב מלא לקובץ (PDF, DOCX, RTF, TXT)
doc_type: סוג מסמך (appeal=כתב ערר, response=תשובה, decision=החלטה, reference=מסמך עזר, exhibit=נספח)
doc_type: סוג מסמך (auto=סיווג אוטומטי, appeal=כתב ערר, response=תשובה, protocol=פרוטוקול, plan=תכנית, permit=היתר, court_decision=פסק דין, decision=החלטת ועדה, appraisal=שומה, objection=התנגדות, exhibit=נספח, reference=מסמך עזר)
title: שם המסמך (אם ריק, ייקח משם הקובץ)
"""
case = await db.get_case_by_number(case_number)
@@ -44,17 +44,29 @@ async def document_upload(
dest = case_dir / source.name
shutil.copy2(str(source), str(dest))
# For auto classification, start with "reference" — will be updated after processing
initial_doc_type = doc_type if doc_type != "auto" else "reference"
# Create document record
doc = await db.create_document(
case_id=case_id,
doc_type=doc_type,
doc_type=initial_doc_type,
title=title,
file_path=str(dest),
)
# Process document (extract → chunk → embed → store)
# Process document (extract → classify → chunk → embed → store)
result = await processor.process_document(UUID(doc["id"]), case_id)
# If auto-classification, update doc_type from classification result
actual_doc_type = initial_doc_type
if doc_type == "auto" and result.get("classification"):
classified_type = result["classification"].get("classification", {}).get("doc_type", "")
if classified_type:
actual_doc_type = classified_type
await db.update_document(UUID(doc["id"]), doc_type=classified_type)
doc["doc_type"] = classified_type
# Git commit
repo_dir = config.CASES_DIR / case_number
if repo_dir.exists():
@@ -62,10 +74,16 @@ async def document_upload(
doc_type_hebrew = {
"appeal": "כתב ערר",
"response": "תשובה",
"protocol": "פרוטוקול",
"plan": "תכנית",
"permit": "היתר",
"court_decision": "פסק דין",
"decision": "החלטה",
"reference": "מסמך עזר",
"appraisal": "שומה",
"objection": "התנגדות",
"exhibit": "נספח",
}.get(doc_type, doc_type)
"reference": "מסמך עזר",
}.get(actual_doc_type, actual_doc_type)
subprocess.run(
["git", "commit", "-m", f"הוספת {doc_type_hebrew}: {title}"],
cwd=repo_dir,
@@ -216,3 +234,135 @@ async def document_list(case_number: str) -> str:
return f"אין מסמכים בתיק {case_number}."
return json.dumps(docs, default=str, ensure_ascii=False, indent=2)
async def extract_references(
case_number: str,
doc_title: str = "",
) -> str:
"""זיהוי תכניות, פסיקה וחקיקה מתוך מסמכי תיק.
Args:
case_number: מספר תיק הערר
doc_title: שם מסמך ספציפי (אם ריק, מזהה בכל המסמכים)
"""
from legal_mcp.services import references_extractor
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
docs = await db.list_documents(case_id)
if not docs:
return f"אין מסמכים בתיק {case_number}."
if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
results = []
for doc in docs:
text = await db.get_document_text(UUID(doc["id"]))
if not text:
continue
refs = await references_extractor.extract_and_link_references(
UUID(doc["id"]), case_id, text,
)
results.append({
"document": doc["title"],
"plans": refs["plans"],
"case_law": refs["case_law"],
"case_law_linked": refs["case_law_linked"],
"legislation": refs["legislation"],
})
return json.dumps(results, default=str, ensure_ascii=False, indent=2)
async def extract_claims(
case_number: str,
doc_title: str = "",
party_hint: str = "",
) -> str:
"""חילוץ טענות מכתב טענות בתיק ושמירה ב-DB.
Args:
case_number: מספר תיק הערר
doc_title: שם מסמך ספציפי (אם ריק, מחלץ מכל כתבי הטענות)
party_hint: שם הצד המגיש (אם ידוע)
"""
from legal_mcp.services import claims_extractor
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
docs = await db.list_documents(case_id)
if not docs:
return f"אין מסמכים בתיק {case_number}."
# Filter to claims documents (appeal, response) or specific doc
if doc_title:
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
else:
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
if not docs:
return "לא נמצאו כתבי טענות בתיק."
results = []
for doc in docs:
text = await db.get_document_text(UUID(doc["id"]))
if not text:
continue
result = await claims_extractor.extract_and_store_claims(
case_id=case_id,
document_id=UUID(doc["id"]),
text=text,
doc_type=doc["doc_type"],
party_hint=party_hint,
)
results.append(result)
return json.dumps(results, default=str, ensure_ascii=False, indent=2)
async def get_claims(case_number: str, party_role: str = "") -> str:
"""שליפת טענות שחולצו לתיק.
Args:
case_number: מספר תיק הערר
party_role: סינון לפי צד (appellant/respondent/committee/permit_applicant). ריק = הכל.
"""
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
claims = await db.get_claims(
UUID(case["id"]),
party_role=party_role if party_role else None,
)
if not claims:
return f"אין טענות בתיק {case_number}."
# Format for display
role_hebrew = {
"appellant": "עוררים",
"respondent": "משיבים",
"committee": "ועדה מקומית",
"permit_applicant": "מבקשי היתר",
"appraiser": "שמאי",
}
formatted = []
for c in claims:
formatted.append({
"party": role_hebrew.get(c["party_role"], c["party_role"]),
"claim": c["claim_text"],
"source": c.get("source_document", ""),
})
return json.dumps(formatted, default=str, ensure_ascii=False, indent=2)

View File

@@ -332,9 +332,144 @@ async def get_decision_template(case_number: str) -> str:
return template
async def validate_decision(case_number: str) -> str:
"""בדיקת QA אוטומטית על ההחלטה — 6 בדיקות. אם נכשלת בדיקה קריטית — ייצוא חסום.
Args:
case_number: מספר תיק הערר
"""
from legal_mcp.services import qa_validator
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
try:
result = await qa_validator.validate_decision(case_id)
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)
async def export_docx(case_number: str, output_path: str = "") -> str:
"""ייצוא החלטה לקובץ DOCX מעוצב — גופן David, RTL, כותרות, מספור סעיפים.
Args:
case_number: מספר תיק הערר
output_path: נתיב לשמירה (אופציונלי — ברירת מחדל: תיקיית התיק)
"""
from legal_mcp.services import docx_exporter
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
try:
path = await docx_exporter.export_decision(case_id, output_path or None)
return json.dumps({
"status": "completed",
"path": path,
"message": f"DOCX נוצר: {path}",
}, ensure_ascii=False, indent=2)
except ValueError as e:
return json.dumps({
"status": "error",
"message": str(e),
}, ensure_ascii=False, indent=2)
async def analyze_style() -> str:
"""הרצת ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ דפוסי כתיבה ושומר אותם."""
from legal_mcp.services.style_analyzer import analyze_corpus
result = await analyze_corpus()
return json.dumps(result, ensure_ascii=False, indent=2)
async def write_block(
case_number: str,
block_id: str,
instructions: str = "",
) -> str:
"""כתיבת בלוק יחיד בהחלטה. כותב ושומר ב-DB.
Args:
case_number: מספר תיק הערר
block_id: מזהה הבלוק: block-alef, block-bet, block-gimel, block-dalet, block-he, block-vav, block-zayin, block-chet, block-tet, block-yod, block-yod-alef, block-yod-bet
instructions: הנחיות נוספות לניסוח
"""
from legal_mcp.services import block_writer
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
try:
result = await block_writer.write_and_store_block(case_id, block_id, instructions)
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e:
return str(e)
async def write_all_blocks(
case_number: str,
start_from: str = "block-alef",
instructions: str = "",
) -> str:
"""כתיבת כל הבלוקים בהחלטה, בלוק אחרי בלוק. שומר כל בלוק מיד אחרי כתיבה.
Args:
case_number: מספר תיק הערר
start_from: מאיזה בלוק להתחיל (ברירת מחדל: block-alef)
instructions: הנחיות כלליות
"""
from legal_mcp.services import block_writer
from legal_mcp.services.block_writer import BLOCK_CONFIG
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
# Determine start index
start_idx = BLOCK_CONFIG.get(start_from, {}).get("index", 1)
results = []
block_order = sorted(BLOCK_CONFIG.items(), key=lambda x: x[1]["index"])
for bid, cfg in block_order:
if cfg["index"] < start_idx:
continue
try:
result = await block_writer.write_and_store_block(case_id, bid, instructions)
results.append({
"block_id": bid,
"title": result["title"],
"word_count": result["word_count"],
"status": "completed",
})
except ValueError as e:
results.append({
"block_id": bid,
"title": cfg["title"],
"status": "error",
"error": str(e),
})
# Stop on critical error (e.g., missing direction for block-yod)
if "כיוון מאושר" in str(e):
break
total_words = sum(r.get("word_count", 0) for r in results)
return json.dumps({
"blocks": results,
"total_words": total_words,
"completed": sum(1 for r in results if r["status"] == "completed"),
}, default=str, ensure_ascii=False, indent=2)

View File

@@ -1,4 +1,4 @@
"""MCP tools for workflow status tracking."""
"""MCP tools for workflow: status, outcome, brainstorming, direction."""
from __future__ import annotations
@@ -95,6 +95,25 @@ def _suggest_next_steps(case: dict, docs: list, has_draft: bool) -> list[str]:
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 f"תיק {case_number} לא נמצא."
result = await metrics.get_case_metrics(UUID(case["id"]))
else:
result = await metrics.get_dashboard()
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
async def processing_status() -> str:
"""סטטוס כללי - מספר תיקים, מסמכים ממתינים לעיבוד."""
pool = await db.get_pool()
@@ -116,3 +135,186 @@ async def processing_status() -> str:
"style_corpus_entries": corpus_count,
"style_patterns": pattern_count,
}, ensure_ascii=False, indent=2)
# ── Outcome & Brainstorming ───────────────────────────────────────
async def set_outcome(
case_number: str,
outcome: str,
reasoning: str = "",
) -> str:
"""הזנת תוצאה לתיק ערר. יוצר רשומת החלטה ומפעיל סיעור מוחות אם אין נימוק.
Args:
case_number: מספר תיק הערר
outcome: תוצאה — rejected (דחייה), accepted (קבלה), partial (קבלה חלקית)
reasoning: נימוק (אופציונלי). אם ריק — מפעיל סיעור מוחות.
"""
from legal_mcp.services import brainstorm
case = await db.get_case_by_number(case_number)
if not case:
return f"תיק {case_number} לא נמצא."
valid_outcomes = ("rejected", "accepted", "partial")
if outcome not in valid_outcomes:
return 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
await db.update_case(case_id, status="in_progress", expected_outcome=outcome)
outcome_hebrew = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.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 json.dumps(result, default=str, ensure_ascii=False, indent=2)
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 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 "לא הוזנה תוצאה לתיק. הפעל 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 json.dumps(directions, default=str, ensure_ascii=False, indent=2)
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 f"תיק {case_number} לא נמצא."
case_id = UUID(case["id"])
decision = await db.get_decision_by_case(case_id)
if not decision:
return "לא הוזנה תוצאה לתיק."
direction_data = decision.get("direction_doc") or {}
brainstorm_result = direction_data.get("brainstorm", {})
if not brainstorm_result.get("directions"):
return "לא בוצע סיעור מוחות. הפעל 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 json.dumps({
"status": "approved",
"message": "כיוון אושר. ניתן להתחיל כתיבת טיוטה.",
"direction": direction_doc,
}, default=str, ensure_ascii=False, indent=2)
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 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 "לא סופק טקסט — יש לספק file_path או final_text."
try:
result = await learning_loop.process_final_version(case_id, final_text)
return json.dumps(result, default=str, ensure_ascii=False, indent=2)
except ValueError as e:
return json.dumps({"status": "error", "message": str(e)}, ensure_ascii=False, indent=2)