diff --git a/mcp-server/src/legal_mcp/cli.py b/mcp-server/src/legal_mcp/cli.py new file mode 100644 index 0000000..1c49e8b --- /dev/null +++ b/mcp-server/src/legal_mcp/cli.py @@ -0,0 +1,166 @@ +"""עוזר משפטי — CLI לניהול תהליך כתיבת החלטות. + +Usage: python -m legal_mcp.cli +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + + +def _run(coro): + """Run async function.""" + return asyncio.run(coro) + + +def _init(): + """Initialize DB.""" + from legal_mcp.services.db import init_schema + _run(init_schema()) + + +def cmd_status(case_number: str = ""): + """סטטוס — תיק ספציפי או כללי.""" + _init() + if case_number: + from legal_mcp.tools.workflow import workflow_status + print(_run(workflow_status(case_number))) + else: + from legal_mcp.tools.workflow import processing_status + print(_run(processing_status())) + + +def cmd_upload(case_number: str, file_path: str, doc_type: str = "auto", title: str = ""): + """העלאת מסמך לתיק.""" + _init() + from legal_mcp.tools.documents import document_upload + result = _run(document_upload(case_number, file_path, doc_type, title)) + print(result) + + +def cmd_outcome(case_number: str, outcome: str, reasoning: str = ""): + """הזנת תוצאה: rejected / accepted / partial.""" + _init() + from legal_mcp.tools.workflow import set_outcome + result = _run(set_outcome(case_number, outcome, reasoning)) + print(result) + + +def cmd_brainstorm(case_number: str): + """סיעור מוחות — הצעת כיוונים לנימוק.""" + _init() + from legal_mcp.tools.workflow import brainstorm_directions + result = _run(brainstorm_directions(case_number)) + print(result) + + +def cmd_approve(case_number: str, direction: int = 0, notes: str = ""): + """אישור כיוון.""" + _init() + from legal_mcp.tools.workflow import approve_direction + result = _run(approve_direction(case_number, direction, notes)) + print(result) + + +def cmd_write(case_number: str, block_id: str = "", instructions: str = ""): + """כתיבת בלוק או כל הבלוקים.""" + _init() + from legal_mcp.tools.drafting import write_block, write_all_blocks + if block_id: + result = _run(write_block(case_number, block_id, instructions)) + else: + result = _run(write_all_blocks(case_number, instructions=instructions)) + print(result) + + +def cmd_validate(case_number: str): + """בדיקת QA.""" + _init() + from legal_mcp.tools.drafting import validate_decision + result = _run(validate_decision(case_number)) + print(result) + + +def cmd_export(case_number: str, output: str = ""): + """ייצוא DOCX.""" + _init() + from legal_mcp.tools.drafting import export_docx + result = _run(export_docx(case_number, output)) + print(result) + + +def cmd_claims(case_number: str, role: str = ""): + """שליפת טענות.""" + _init() + from legal_mcp.tools.documents import get_claims + result = _run(get_claims(case_number, role)) + print(result) + + +def cmd_metrics(case_number: str = ""): + """מדדי הצלחה.""" + _init() + from legal_mcp.tools.workflow import get_metrics + result = _run(get_metrics(case_number)) + print(result) + + +def cmd_ingest_final(case_number: str, file_path: str): + """קליטת גרסה סופית.""" + _init() + from legal_mcp.tools.workflow import ingest_final_version + result = _run(ingest_final_version(case_number, file_path=file_path)) + print(result) + + +COMMANDS = { + "status": (cmd_status, "סטטוס תיק או כללי", "[case_number]"), + "upload": (cmd_upload, "העלאת מסמך לתיק", " [doc_type] [title]"), + "outcome": (cmd_outcome, "הזנת תוצאה", " [reasoning]"), + "brainstorm": (cmd_brainstorm, "סיעור מוחות", ""), + "approve": (cmd_approve, "אישור כיוון", " [direction_index] [notes]"), + "write": (cmd_write, "כתיבת בלוק/ים", " [block_id] [instructions]"), + "validate": (cmd_validate, "בדיקת QA", ""), + "export": (cmd_export, "ייצוא DOCX", " [output_path]"), + "claims": (cmd_claims, "שליפת טענות", " [party_role]"), + "metrics": (cmd_metrics, "מדדי הצלחה", "[case_number]"), + "ingest-final": (cmd_ingest_final, "קליטת גרסה סופית", " "), +} + + +def main(): + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"): + print("עוזר משפטי — CLI\n") + print("שימוש: python -m legal_mcp.cli [args]\n") + print("פקודות:") + for name, (_, desc, usage) in COMMANDS.items(): + print(f" {name:15s} {desc:25s} {usage}") + print() + sys.exit(0) + + cmd_name = sys.argv[1] + args = sys.argv[2:] + + if cmd_name not in COMMANDS: + print(f"פקודה לא ידועה: {cmd_name}") + print(f"הפקודות הזמינות: {', '.join(COMMANDS.keys())}") + sys.exit(1) + + func, _, _ = COMMANDS[cmd_name] + try: + func(*args) + except TypeError as e: + print(f"שגיאה בפרמטרים: {e}") + _, _, usage = COMMANDS[cmd_name] + print(f"שימוש: python -m legal_mcp.cli {cmd_name} {usage}") + sys.exit(1) + except Exception as e: + print(f"שגיאה: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/mcp-server/src/legal_mcp/config.py b/mcp-server/src/legal_mcp/config.py index 582ecb1..d5cd976 100644 --- a/mcp-server/src/legal_mcp/config.py +++ b/mcp-server/src/legal_mcp/config.py @@ -1,4 +1,7 @@ -"""Configuration loaded from central .env file.""" +"""Configuration loaded from Infisical or central .env file. + +Priority: Infisical → environment variables → .env file +""" import os from pathlib import Path @@ -9,6 +12,23 @@ from dotenv import load_dotenv dotenv_path = os.environ.get("DOTENV_PATH", str(Path.home() / ".env")) load_dotenv(dotenv_path) +# Try loading from Infisical if configured +INFISICAL_TOKEN = os.environ.get("INFISICAL_TOKEN", "") +if INFISICAL_TOKEN: + try: + from infisical_sdk import InfisicalSDKClient + _client = InfisicalSDKClient(token=INFISICAL_TOKEN) + _secrets = _client.get_all_secrets( + environment=os.environ.get("INFISICAL_ENV", "production"), + project_id=os.environ.get("INFISICAL_PROJECT_ID", ""), + ) + for s in _secrets: + os.environ.setdefault(s.secret_key, s.secret_value) + except ImportError: + pass # Infisical SDK not installed — use .env + except Exception: + pass # Infisical unreachable — fall back to .env + # PostgreSQL POSTGRES_URL = os.environ.get( "POSTGRES_URL", @@ -38,3 +58,12 @@ TRAINING_DIR = DATA_DIR / "training" # Chunking parameters CHUNK_SIZE_TOKENS = 600 CHUNK_OVERLAP_TOKENS = 100 + +# External service allowlist — case materials may ONLY be sent to these domains +ALLOWED_EXTERNAL_SERVICES = { + "api.anthropic.com", # Claude API (text generation, OCR) + "api.voyageai.com", # Voyage AI (embeddings) +} + +# Audit +AUDIT_ENABLED = os.environ.get("AUDIT_ENABLED", "true").lower() == "true" diff --git a/mcp-server/src/legal_mcp/models.py b/mcp-server/src/legal_mcp/models.py index bfd1315..d7d4b03 100644 --- a/mcp-server/src/legal_mcp/models.py +++ b/mcp-server/src/legal_mcp/models.py @@ -19,10 +19,16 @@ class CaseStatus(str, enum.Enum): class DocType(str, enum.Enum): APPEAL = "appeal" # כתב ערר - RESPONSE = "response" # תשובה - DECISION = "decision" # החלטה - REFERENCE = "reference" # מסמך עזר - EXHIBIT = "exhibit" # נספח + RESPONSE = "response" # תשובה / כתב תשובה + PROTOCOL = "protocol" # פרוטוקול דיון + PLAN = "plan" # תכנית (תב"ע) + PERMIT = "permit" # היתר בנייה + COURT_DECISION = "court_decision" # פסק דין / החלטת בית משפט + DECISION = "decision" # החלטת ועדה + APPRAISAL = "appraisal" # שומה / חוות דעת שמאית + OBJECTION = "objection" # התנגדות + EXHIBIT = "exhibit" # נספח / מסמך תומך + REFERENCE = "reference" # מסמך עזר אחר class SectionType(str, enum.Enum): diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index b10b04d..27beee0 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -140,6 +140,36 @@ async def document_list(case_number: str) -> str: return await documents.document_list(case_number) +# Claims extraction +@mcp.tool() +async def extract_claims( + case_number: str, + doc_title: str = "", + party_hint: str = "", +) -> str: + """חילוץ טענות מכתב טענות בתיק. מחלץ טענות לפי צד ושומר ב-DB.""" + return await documents.extract_claims(case_number, doc_title, party_hint) + + +@mcp.tool() +async def get_claims( + case_number: str, + party_role: str = "", +) -> str: + """שליפת טענות שחולצו לתיק. party_role: appellant/respondent/committee/permit_applicant (ריק=הכל).""" + return await documents.get_claims(case_number, party_role) + + +# References +@mcp.tool() +async def extract_references( + case_number: str, + doc_title: str = "", +) -> str: + """זיהוי תכניות, פסיקה וחקיקה מתוך מסמכי תיק. מזהה ומקשר להפניות קיימות ב-DB.""" + return await documents.extract_references(case_number, doc_title) + + # Search @mcp.tool() async def search_decisions( @@ -193,12 +223,44 @@ async def get_decision_template(case_number: str) -> str: return await drafting.get_decision_template(case_number) +@mcp.tool() +async def validate_decision(case_number: str) -> str: + """בדיקת QA — 6 בדיקות איכות על ההחלטה. אם בדיקה קריטית נכשלת — ייצוא חסום.""" + return await drafting.validate_decision(case_number) + + +@mcp.tool() +async def export_docx(case_number: str, output_path: str = "") -> str: + """ייצוא החלטה לקובץ DOCX מעוצב — גופן David, RTL, כותרות, מספור סעיפים.""" + return await drafting.export_docx(case_number, output_path) + + @mcp.tool() async def analyze_style() -> str: """ניתוח סגנון על קורפוס ההחלטות של דפנה. מחלץ ושומר דפוסי כתיבה.""" return await drafting.analyze_style() +@mcp.tool() +async def write_block( + case_number: str, + block_id: str, + instructions: str = "", +) -> str: + """כתיבת בלוק יחיד בהחלטה: block-alef עד block-yod-bet. שומר ב-DB.""" + return await drafting.write_block(case_number, block_id, instructions) + + +@mcp.tool() +async def write_all_blocks( + case_number: str, + start_from: str = "block-alef", + instructions: str = "", +) -> str: + """כתיבת כל הבלוקים בהחלטה, בלוק אחרי בלוק. שומר כל בלוק מיד.""" + return await drafting.write_all_blocks(case_number, start_from, instructions) + + # Workflow @mcp.tool() async def workflow_status(case_number: str) -> str: @@ -206,12 +268,55 @@ async def workflow_status(case_number: str) -> str: return await workflow.workflow_status(case_number) +@mcp.tool() +async def get_metrics(case_number: str = "") -> str: + """מדדי הצלחה — KPIs לתיק ספציפי או דשבורד כולל. ריק = דשבורד.""" + return await workflow.get_metrics(case_number) + + @mcp.tool() async def processing_status() -> str: """סטטוס כללי - מספר תיקים, מסמכים, chunks.""" return await workflow.processing_status() +# Outcome & Brainstorming +@mcp.tool() +async def set_outcome( + case_number: str, + outcome: str, + reasoning: str = "", +) -> str: + """הזנת תוצאה לתיק: rejected (דחייה), accepted (קבלה), partial (קבלה חלקית). אם אין נימוק — מפעיל סיעור מוחות.""" + return await workflow.set_outcome(case_number, outcome, reasoning) + + +@mcp.tool() +async def brainstorm_directions(case_number: str) -> str: + """סיעור מוחות — הצגת טענות מרכזיות והצעת 2-3 כיוונים אפשריים לנימוק ההחלטה.""" + return await workflow.brainstorm_directions(case_number) + + +@mcp.tool() +async def approve_direction( + case_number: str, + direction_index: int = 0, + additional_notes: str = "", +) -> str: + """אישור כיוון — יוצר מסמך כיוון מאושר. חובה לפני כתיבת דיון (בלוק י).""" + return await workflow.approve_direction(case_number, direction_index, additional_notes) + + +@mcp.tool() +async def ingest_final_version( + case_number: str, + file_path: str = "", + final_text: str = "", +) -> str: + """קליטת גרסה סופית שדפנה חתמה — השוואה לטיוטה וחילוץ לקחים לשיפור עתידי.""" + return await workflow.ingest_final_version(case_number, file_path, final_text) + + def main(): mcp.run(transport="stdio") diff --git a/mcp-server/src/legal_mcp/services/audit.py b/mcp-server/src/legal_mcp/services/audit.py new file mode 100644 index 0000000..f1e648a --- /dev/null +++ b/mcp-server/src/legal_mcp/services/audit.py @@ -0,0 +1,76 @@ +"""Audit log — תיעוד כל פעולה במערכת. + +כל פעולה מתועדת: מי, מתי, מה, על איזה תיק. +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime +from uuid import UUID, uuid4 + +from legal_mcp.services import db + +logger = logging.getLogger("audit") + + +async def log_action( + action: str, + case_id: UUID | None = None, + document_id: UUID | None = None, + details: dict | None = None, + user: str = "system", +) -> None: + """רישום פעולה ב-audit log. + + Args: + action: סוג הפעולה (upload, classify, extract_claims, set_outcome, write_block, export, etc.) + case_id: מזהה תיק (אם רלוונטי) + document_id: מזהה מסמך (אם רלוונטי) + details: פרטים נוספים + user: מזהה המשתמש + """ + pool = await db.get_pool() + async with pool.acquire() as conn: + await conn.execute( + """INSERT INTO audit_log (id, action, case_id, document_id, details, actor, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)""", + uuid4(), action, case_id, document_id, + json.dumps(details or {}, ensure_ascii=False, default=str), + user, datetime.utcnow(), + ) + logger.info("AUDIT: %s | case=%s | user=%s | %s", action, case_id, user, + json.dumps(details or {}, ensure_ascii=False)[:200]) + + +async def get_audit_log( + case_id: UUID | None = None, + action: str | None = None, + limit: int = 50, +) -> list[dict]: + """שליפת audit log.""" + pool = await db.get_pool() + conditions = [] + params: list = [] + idx = 1 + + if case_id: + conditions.append(f"case_id = ${idx}") + params.append(case_id) + idx += 1 + if action: + conditions.append(f"action = ${idx}") + params.append(action) + idx += 1 + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + params.append(limit) + + async with pool.acquire() as conn: + rows = await conn.fetch( + f"SELECT * FROM audit_log {where} ORDER BY created_at DESC LIMIT ${idx}", + *params, + ) + + return [dict(r) for r in rows] diff --git a/mcp-server/src/legal_mcp/services/block_writer.py b/mcp-server/src/legal_mcp/services/block_writer.py new file mode 100644 index 0000000..da37141 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/block_writer.py @@ -0,0 +1,573 @@ +"""מנוע כתיבת בלוקים להחלטת ועדת ערר. + +מייצר טקסט בפועל לכל בלוק (ה-יב) בהתבסס על: +- block-schema.md (פרמטרים, constraints, מבנה) +- SKILL.md (סגנון דפנה) +- חומרי המקור (מסמכים, טענות, פסיקה) +- מסמך כיוון (חובה לבלוק י) + +בלוקים א-ד ויב = template-fill (ללא AI). +בלוקים ה-יא = AI generation עם Claude. +""" + +from __future__ import annotations + +import json +import logging +from datetime import date +from uuid import UUID + +import anthropic + +from legal_mcp import config +from legal_mcp.services import db, embeddings + +logger = logging.getLogger(__name__) + +_anthropic_client: anthropic.Anthropic | None = None + + +def _get_anthropic() -> anthropic.Anthropic: + global _anthropic_client + if _anthropic_client is None: + _anthropic_client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) + return _anthropic_client + + +# ── Block configuration ─────────────────────────────────────────── + +BLOCK_CONFIG = { + "block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"}, + "block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"}, + "block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"}, + "block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"}, + "block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 1024}, + "block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 4096}, + "block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 4096}, + "block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 2048}, + "block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 2048}, + "block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 8192}, + "block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 2048}, + "block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"}, +} + +MODEL_MAP = { + "sonnet": "claude-sonnet-4-20250514", + "opus": "claude-opus-4-20250514", +} + + +# ── Template blocks (א-ד, יב) ──────────────────────────────────── + +def write_block_alef(case: dict, decision: dict | None = None) -> str: + """כותרת מוסדית.""" + return f"""מדינת ישראל +ועדת ערר לתכנון ולבנייה — מחוז ירושלים + +ערר מס' {case['case_number']}""" + + +def write_block_bet(case: dict, decision: dict | None = None) -> str: + """הרכב הוועדה.""" + panel = (decision or {}).get("panel_members", []) + members_text = "" + if panel: + for m in panel: + members_text += f"\n{m}" + return f"""בפני: +עו"ד דפנה תמיר, יו"ר{members_text}""" + + +def write_block_gimel(case: dict, decision: dict | None = None) -> str: + """צדדים.""" + appellants = "\n".join(case.get("appellants", ["(לא צוין)"])) + respondents = "\n".join(case.get("respondents", ["(לא צוין)"])) + return f"""{appellants} + +נגד + +{respondents}""" + + +def write_block_dalet(case: dict, decision: dict | None = None) -> str: + """כותרת החלטה.""" + return "החלטה" + + +def write_block_yod_bet(case: dict, decision: dict | None = None) -> str: + """חתימות.""" + today = date.today().strftime("%d.%m.%Y") + return f"""ניתנה היום, {today}, פה אחד. + +דפנה תמיר, עו"ד +יו"ר ועדת הערר""" + + +TEMPLATE_WRITERS = { + "block-alef": write_block_alef, + "block-bet": write_block_bet, + "block-gimel": write_block_gimel, + "block-dalet": write_block_dalet, + "block-yod-bet": write_block_yod_bet, +} + + +# ── AI-generated blocks (ה-יא) ─────────────────────────────────── + +BLOCK_PROMPTS = { + "block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר. + +## כללים: +- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..." +- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש +- 1-2 סעיפים בלבד +- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים +- מספור: 1. + +## פרטי התיק: +{case_context} + +## חומרי מקור: +{source_context}""", + + "block-vav": """כתוב את בלוק הרקע העובדתי (בלוק ו, "פתח דבר") של החלטת ועדת ערר. + +## כללים קריטיים: +- **רקע ניטרלי** — עובדות בלבד. אין ציטוטים ישירים מצדדים. אין מילות ערך/שיפוט ("חריג", "חטא", "בעייתי"). +- סדר פנימי: מקרקעין → סביבה → היסטוריה תכנונית → מהות הבקשה → החלטת הוועדה → הגשת הערר +- סמן מיקומי תמונות: [📷 מיקום GIS], [📷 תשריט] +- ציטוט מפרוטוקול ועדה מקומית (אם יש) כ-blockquote +- מספור רציף מהבלוק הקודם + +## פרטי התיק: +{case_context} + +## חומרי מקור: +{source_context}""", + + "block-zayin": """כתוב את בלוק טענות הצדדים (בלוק ז, "תמצית טענות הצדדים") של החלטת ועדת ערר. + +## כללים: +- כל טענה בסעיף נפרד, גוף שלישי ("העורר טוען כי...") +- סדר קבוע: טענות העוררים → עמדת הוועדה המקומית → עמדת מבקשי ההיתר (אם יש) +- כותרת: "תמצית טענות הצדדים" +- נאמנות מוחלטת למקור — לא לשנות, לא לקצר ללא ציון +- אין ניתוח, אין מסקנות, אין הערכה +- רק מכתבי טענות מקוריים (לא השלמות טיעון) +- מספור רציף + +## טענות שחולצו: +{claims_context} + +## פרטי התיק: +{case_context}""", + + "block-chet": """כתוב את בלוק ההליכים (בלוק ח, "ההליכים בפני ועדת הערר") של החלטת ועדת ערר. + +## כללים: +- תיעוד כרונולוגי: דיון → סיור → השלמות טיעון → החלטות ביניים +- תאריכים מדויקים +- תוכן כל השלמת טיעון בסעיף נפרד +- סמן תמונות מסיור: [📷 צילום מסיור] +- אין ניתוח או הערכה +- מספור רציף + +## פרטי התיק: +{case_context} + +## חומרי מקור: +{source_context}""", + + "block-tet": """כתוב את בלוק התכניות החלות (בלוק ט) של החלטת ועדת ערר. + +## כללים: +- ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות +- מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות +- אין ניתוח מעמיק (→ בלוק י), אין הכרעה בין פרשנויות +- מספור רציף +- בלוק אופציונלי — כתוב רק אם יש מורכבות תכנונית + +## פרטי התיק: +{case_context} + +## תכניות שזוהו: +{plans_context} + +## חומרי מקור: +{source_context}""", + + "block-yod": """כתוב את בלוק הדיון וההכרעה (בלוק י) של החלטת ועדת ערר. + +## זהו הבלוק הקריטי ביותר — ליבת ההחלטה (ratio decidendi). + +## מתודולוגיה — CREAC: +1. **C** (Conclusion) — פתח במסקנה: "לאחר שעיינו... מצאנו כי הערר [נדחה/מתקבל]" +2. **R** (Rule) — הצג את הכלל המשפטי הרלוונטי +3. **E** (Explanation) — צטט פסיקה שמסבירה את הכלל +4. **A** (Application) — יישם על העובדות הספציפיות +5. **C** (Conclusion) — מסקנת ביניים + +## כללים קריטיים: +- **מסקנה בפתיחה** — לא בסוף +- **מענה לכל טענה** שהוצגה בבלוק ז +- **ללא כפילות** — הפנה לבלוקים קודמים: "כאמור בסעיף X לעיל" +- **ללא כותרות משנה** (חריג: נושאים נפרדים לחלוטין) +- ציטוט פסיקה בבלוקים ארוכים (200-600 מילים) +- מספור רציף + +## כיוון מאושר (חובה): +{direction_context} + +## מבנה לפי תוצאה: +{structure_guidance} + +## טענות שצריך לענות עליהן: +{claims_context} + +## חומרי מקור: +{source_context} + +## פסיקה רלוונטית: +{precedents_context} + +## סגנון דפנה: +{style_context}""", + + "block-yod-alef": """כתוב את בלוק הסיכום (בלוק יא, "סוף דבר") של החלטת ועדת ערר. + +## כללים: +- כותרת: "סוף דבר" או "סיכום" +- תוצאה ברורה: "הערר נדחה" / "הערר מתקבל" / "הערר מתקבל באופן חלקי" +- הוראות אופרטיביות חד-משמעיות +- אין חזרה על נימוקים — ההנמקה כבר בדיון +- מספור רציף + +## מבנה לפי תוצאה: +- דחייה: "הערר נדחה" + תתי-סעיפים + פסקה חמה (רישוי בלבד) +- קבלה: "הערר מתקבל בכפוף ל..." + פרוזה +- קבלה חלקית: "הערר מתקבל באופן חלקי" + 2-3 הוראות אופרטיביות + +## כיוון ותוצאה: +{direction_context} + +## בלוקים קודמים (דיון): +{discussion_context}""", +} + +# Discussion structure by outcome +STRUCTURE_GUIDANCE = { + "rejected": "דחייה — שכבות הגנה (concentric circles): טענה ראשית → נדחית, טענה חלופית → נדחית, חיזוק.", + "accepted": "קבלה — נימוק-נימוק: כל נימוק = CREAC מלא, בניית שכנוע הדרגתי.", + "partial": "קבלה חלקית — מיפוי מתחים: מה מתקבל ולמה, מה נדחה ולמה, איזון.", +} + + +async def write_block( + case_id: UUID, + block_id: str, + instructions: str = "", +) -> dict: + """כתיבת בלוק יחיד בהחלטה. + + Args: + case_id: מזהה התיק + block_id: מזהה הבלוק (block-alef, block-he, block-yod, ...) + instructions: הנחיות נוספות + + Returns: + dict עם content, word_count, block_id, generation_type + """ + if block_id not in BLOCK_CONFIG: + raise ValueError(f"Unknown block: {block_id}") + + block_cfg = BLOCK_CONFIG[block_id] + case = await db.get_case(case_id) + if not case: + raise ValueError(f"Case {case_id} not found") + + decision = await db.get_decision_by_case(case_id) + + # Template blocks + if block_id in TEMPLATE_WRITERS: + content = TEMPLATE_WRITERS[block_id](case, decision) + return _build_result(block_id, content, block_cfg) + + # AI-generated blocks + prompt_template = BLOCK_PROMPTS.get(block_id) + if not prompt_template: + raise ValueError(f"No prompt template for {block_id}") + + # Build context components + case_context = _build_case_context(case, decision) + source_context = await _build_source_context(case_id, block_id) + claims_context = await _build_claims_context(case_id) + direction_context = _build_direction_context(decision) + plans_context = await _build_plans_context(case_id) + precedents_context = await _build_precedents_context(case_id, block_id) + style_context = await _build_style_context() + discussion_context = await _build_previous_blocks_context(case_id, decision) + + outcome = (decision or {}).get("outcome", "rejected") + structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "") + + # Format prompt + prompt = prompt_template.format( + case_context=case_context, + source_context=source_context, + claims_context=claims_context, + direction_context=direction_context, + plans_context=plans_context, + precedents_context=precedents_context, + style_context=style_context, + discussion_context=discussion_context, + structure_guidance=structure_guidance, + ) + + if instructions: + prompt += f"\n\n## הנחיות נוספות:\n{instructions}" + + # Block י requires approved direction + if block_id == "block-yod": + dir_doc = (decision or {}).get("direction_doc") or {} + if not dir_doc.get("approved"): + raise ValueError("לא ניתן לכתוב בלוק דיון ללא כיוון מאושר. הפעל brainstorm → approve_direction קודם.") + + # Call Claude + model_key = block_cfg["model"] + model = MODEL_MAP.get(model_key, MODEL_MAP["sonnet"]) + temperature = block_cfg["temp"] + max_tokens = block_cfg.get("max_tokens", 4096) + + client = _get_anthropic() + + # For opus blocks, use extended thinking + kwargs: dict = { + "model": model, + "max_tokens": max_tokens, + "messages": [{"role": "user", "content": prompt}], + } + + if model_key == "opus" and temperature >= 0.3: + # Extended thinking for complex blocks + kwargs["temperature"] = 1 # Required for extended thinking + kwargs["thinking"] = {"type": "enabled", "budget_tokens": 16000} + else: + kwargs["temperature"] = temperature + + message = client.messages.create(**kwargs) + + # Extract text from response (skip thinking blocks) + content = "" + for block in message.content: + if block.type == "text": + content = block.text + break + + return _build_result(block_id, content, block_cfg) + + +def _build_result(block_id: str, content: str, block_cfg: dict) -> dict: + word_count = len(content.split()) + return { + "block_id": block_id, + "block_index": block_cfg["index"], + "title": block_cfg["title"], + "content": content, + "word_count": word_count, + "generation_type": block_cfg["gen_type"], + "model_used": block_cfg["model"], + "temperature": block_cfg["temp"], + } + + +# ── Context builders ────────────────────────────────────────────── + +def _build_case_context(case: dict, decision: dict | None) -> str: + outcome = (decision or {}).get("outcome", "") + outcome_heb = {"rejected": "דחייה", "accepted": "קבלה", "partial": "קבלה חלקית"}.get(outcome, "") + return f"""- מספר תיק: {case['case_number']} +- כותרת: {case.get('title', '')} +- עוררים: {', '.join(case.get('appellants', []))} +- משיבים: {', '.join(case.get('respondents', []))} +- נושא: {case.get('subject', '')} +- כתובת: {case.get('property_address', '')} +- סוג ערר: {case.get('appeal_type', '')} +- תוצאה: {outcome_heb}""" + + +async def _build_source_context(case_id: UUID, block_id: str, max_chars: int = 15000) -> str: + """Get relevant document excerpts for the block.""" + docs = await db.list_documents(case_id) + context_parts = [] + total = 0 + for doc in docs: + if total >= max_chars: + break + text = await db.get_document_text(UUID(doc["id"])) + if text: + excerpt = text[:3000] + context_parts.append(f"--- {doc['title']} ({doc['doc_type']}) ---\n{excerpt}") + total += len(excerpt) + return "\n\n".join(context_parts) if context_parts else "(אין מסמכים)" + + +async def _build_claims_context(case_id: UUID) -> str: + claims = await db.get_claims(case_id) + if not claims: + return "(לא חולצו טענות)" + lines = [] + current_role = "" + role_heb = {"appellant": "טענות העוררים", "respondent": "טענות המשיבים", + "committee": "עמדת הוועדה המקומית", "permit_applicant": "עמדת מבקשי ההיתר"} + for c in claims: + if c["party_role"] != current_role: + current_role = c["party_role"] + lines.append(f"\n### {role_heb.get(current_role, current_role)}") + lines.append(f"- {c['claim_text'][:300]}") + return "\n".join(lines) + + +def _build_direction_context(decision: dict | None) -> str: + if not decision: + return "(לא הוגדר כיוון)" + dir_doc = decision.get("direction_doc") or {} + if not dir_doc.get("approved"): + return "(כיוון לא אושר)" + + parts = [] + outcome_heb = dir_doc.get("outcome_hebrew", "") + if outcome_heb: + parts.append(f"תוצאה: {outcome_heb}") + + reasoning = dir_doc.get("reasoning", "") + if reasoning: + parts.append(f"נימוק: {reasoning}") + + direction = dir_doc.get("selected_direction") + if direction: + parts.append(f"כיוון נבחר: {direction.get('name', '')}") + for r in direction.get("reasoning", []): + parts.append(f" - {r}") + for p in direction.get("precedents", []): + parts.append(f" פסיקה: {p}") + + notes = dir_doc.get("additional_notes", "") + if notes: + parts.append(f"הערות: {notes}") + + return "\n".join(parts) if parts else "(אין מסמך כיוון)" + + +async def _build_plans_context(case_id: UUID) -> str: + """Get plan references from document metadata.""" + docs = await db.list_documents(case_id) + plans = set() + for doc in docs: + metadata = doc.get("metadata") or {} + if isinstance(metadata, str): + metadata = json.loads(metadata) + refs = metadata.get("references", {}) + for p in refs.get("plans", []): + plans.add(p.get("plan_name", "")) + if plans: + return "\n".join(f"- {p}" for p in sorted(plans) if p) + return "(לא זוהו תכניות)" + + +async def _build_precedents_context(case_id: UUID, block_id: str) -> str: + """Search for similar precedent paragraphs.""" + try: + case = await db.get_case(case_id) + subject = case.get("subject", "") if case else "" + query = f"דיון משפטי בנושא {subject}" if subject else "דיון משפטי ועדת ערר" + query_emb = await embeddings.embed_query(query) + results = await db.search_similar(query_embedding=query_emb, limit=5) + # Filter out same case + results = [r for r in results if str(r.get("case_id")) != str(case_id)] + if results: + parts = [] + for r in results[:3]: + parts.append(f"[{r.get('case_number', '?')}, {r.get('section_type', '')}] {r['content'][:400]}") + return "\n\n".join(parts) + except Exception as e: + logger.warning("Failed to fetch precedents: %s", e) + return "(אין תקדימים)" + + +async def _build_style_context() -> str: + patterns = await db.get_style_patterns() + if not patterns: + return "(אין דפוסי סגנון)" + lines = [] + for p in patterns[:10]: + lines.append(f"- [{p['pattern_type']}] {p['pattern_text']}") + return "\n".join(lines) + + +async def _build_previous_blocks_context(case_id: UUID, decision: dict | None) -> str: + """Get content of previously written blocks.""" + if not decision: + return "(אין בלוקים קודמים)" + pool = await db.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """SELECT block_id, title, content, word_count + FROM decision_blocks + WHERE decision_id = $1 AND word_count > 0 + ORDER BY block_index""", + UUID(decision["id"]), + ) + if not rows: + return "(אין בלוקים קודמים)" + parts = [] + for r in rows: + content = r["content"][:2000] + parts.append(f"### {r['title']} ({r['block_id']})\n{content}") + return "\n\n".join(parts) + + +# ── Store block ─────────────────────────────────────────────────── + +async def store_block(decision_id: UUID, block_result: dict) -> None: + """שמירת בלוק ב-DB (upsert).""" + pool = await db.get_pool() + async with pool.acquire() as conn: + await conn.execute( + """INSERT INTO decision_blocks + (decision_id, block_id, block_index, title, content, word_count, + generation_type, model_used, temperature, status) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'draft') + ON CONFLICT (decision_id, block_id) DO UPDATE SET + content = EXCLUDED.content, + word_count = EXCLUDED.word_count, + generation_type = EXCLUDED.generation_type, + model_used = EXCLUDED.model_used, + temperature = EXCLUDED.temperature, + status = 'draft', + updated_at = now()""", + decision_id, + block_result["block_id"], + block_result["block_index"], + block_result["title"], + block_result["content"], + block_result["word_count"], + block_result["generation_type"], + block_result["model_used"], + block_result["temperature"], + ) + + +async def write_and_store_block( + case_id: UUID, + block_id: str, + instructions: str = "", +) -> dict: + """כתיבת בלוק ושמירה ב-DB.""" + decision = await db.get_decision_by_case(case_id) + if not decision: + # Create decision if not exists + decision = await db.create_decision(case_id=case_id) + + result = await write_block(case_id, block_id, instructions) + await store_block(UUID(decision["id"]), result) + return result diff --git a/mcp-server/src/legal_mcp/services/brainstorm.py b/mcp-server/src/legal_mcp/services/brainstorm.py new file mode 100644 index 0000000..ba8f7fc --- /dev/null +++ b/mcp-server/src/legal_mcp/services/brainstorm.py @@ -0,0 +1,206 @@ +"""סיעור מוחות לגיבוש כיוון ההחלטה. + +שלב 4א באיפיון המוצר: +1. הצגת טענות מרכזיות +2. הצעת 2-3 כיוונים אפשריים לנימוק +3. שיח אינטראקטיבי עד לכיוון מוסכם +4. יצירת מסמך כיוון +""" + +from __future__ import annotations + +import json +import logging +from uuid import UUID + +import anthropic + +from legal_mcp import config +from legal_mcp.services import db + +logger = logging.getLogger(__name__) + +_anthropic_client: anthropic.Anthropic | None = None + + +def _get_anthropic() -> anthropic.Anthropic: + global _anthropic_client + if _anthropic_client is None: + _anthropic_client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) + return _anthropic_client + + +BRAINSTORM_PROMPT = """אתה יועץ משפטי מומחה בתכנון ובניה. תפקידך לסייע בגיבוש כיוון להחלטת ועדת ערר. + +## הנחיות: +1. **הצג את הטענות המרכזיות** מכל הצדדים — 3-5 טענות עיקריות +2. **הצע 2-3 כיוונים אפשריים** לנימוק ההחלטה. כל כיוון כולל: + - שם הכיוון (שורה אחת) + - נימוקים מרכזיים (2-3 נקודות) + - פסיקה רלוונטית (אם יש) + - חוזקות וחולשות +3. **אל תמליץ** על כיוון אחד — הצג אותם באופן ניטרלי +4. **סוג הערר** משפיע על הטון: רישוי = חם יחסית, השבחה/פיצויים = קר ומקצועי + +## תוצאה שנקבעה: {outcome_hebrew} +{reasoning_context} + +## פלט: +החזר JSON בפורמט: +{{ + "key_claims": [ + {{"party": "עוררים/משיבים", "claim": "טענה מרכזית", "strength": "חזקה/בינונית/חלשה"}} + ], + "directions": [ + {{ + "name": "שם הכיוון", + "reasoning": ["נימוק 1", "נימוק 2"], + "precedents": ["פסיקה רלוונטית"], + "strengths": ["חוזקה"], + "weaknesses": ["חולשה"] + }} + ], + "recommended_order": "סדר מומלץ של נימוקים בדיון" +}} +""" + +OUTCOME_HEBREW = { + "rejected": "דחייה", + "accepted": "קבלה", + "partial": "קבלה חלקית", +} + +APPEAL_TYPE_TONE = { + "licensing": "טון חם יחסית — יש הקשר תכנוני רחב ואלמנטים אנושיים", + "betterment": "טון קר ומקצועי — יבש, ללא רגשות", + "compensation": "טון קר ומקצועי — דומה להיטל השבחה", +} + + +async def generate_directions( + case_id: UUID, + outcome: str, + reasoning: str = "", +) -> dict: + """סיעור מוחות — הצגת טענות מרכזיות והצעת כיוונים. + + Args: + case_id: מזהה התיק + outcome: תוצאה (rejected/accepted/partial) + reasoning: נימוק ראשוני (אם יש) + + Returns: + dict עם key_claims, directions, recommended_order + """ + # Gather context + case = await db.get_case(case_id) + if not case: + raise ValueError(f"Case {case_id} not found") + + claims = await db.get_claims(case_id) + docs = await db.list_documents(case_id) + + # Build claims summary + claims_text = "" + if claims: + for c in claims: + role_heb = {"appellant": "עוררים", "respondent": "משיבים", + "committee": "ועדה מקומית", "permit_applicant": "מבקשי היתר"}.get(c["party_role"], c["party_role"]) + claims_text += f"- [{role_heb}] {c['claim_text'][:200]}\n" + + # Get document excerpts for context + doc_context = "" + for doc in docs[:5]: + text = await db.get_document_text(UUID(doc["id"])) + if text: + doc_context += f"\n--- {doc['title']} ({doc['doc_type']}) ---\n{text[:3000]}\n" + + # Determine appeal type tone + appeal_type = case.get("appeal_type", "") + tone_hint = APPEAL_TYPE_TONE.get(appeal_type, "") + + outcome_hebrew = OUTCOME_HEBREW.get(outcome, outcome) + reasoning_context = f"נימוק ראשוני: {reasoning}" if reasoning else "לא סופק נימוק — יש להציע כיוונים." + + prompt = BRAINSTORM_PROMPT.format( + outcome_hebrew=outcome_hebrew, + reasoning_context=reasoning_context, + ) + + if tone_hint: + prompt += f"\n\n## טון: {tone_hint}" + + user_content = f"""{prompt} + +## פרטי התיק: +- מספר: {case['case_number']} +- נושא: {case.get('subject', '')} +- עוררים: {', '.join(case.get('appellants', []))} +- משיבים: {', '.join(case.get('respondents', []))} + +## טענות שחולצו: +{claims_text or '(לא חולצו טענות עדיין)'} + +## חומרי המקור: +{doc_context or '(אין מסמכים בתיק)'} +""" + + client = _get_anthropic() + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=4096, + messages=[{"role": "user", "content": user_content}], + ) + + raw = message.content[0].text.strip() + try: + import re + json_match = re.search(r"\{.*\}", raw, re.DOTALL) + if json_match: + result = json.loads(json_match.group()) + else: + result = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Failed to parse brainstorm response: %s", raw[:300]) + return { + "key_claims": [], + "directions": [], + "recommended_order": "", + "raw_response": raw, + } + + return result + + +def build_direction_doc( + outcome: str, + reasoning: str, + directions_result: dict, + selected_direction: int | None = None, + additional_notes: str = "", +) -> dict: + """בניית מסמך כיוון מתוצאות סיעור המוחות. + + Args: + outcome: תוצאה שנקבעה + reasoning: נימוק + directions_result: תוצאות מ-generate_directions + selected_direction: אינדקס הכיוון שנבחר (0-based) + additional_notes: הערות נוספות מהמשתמש + """ + direction = None + if selected_direction is not None and directions_result.get("directions"): + directions = directions_result["directions"] + if 0 <= selected_direction < len(directions): + direction = directions[selected_direction] + + return { + "outcome": outcome, + "outcome_hebrew": OUTCOME_HEBREW.get(outcome, outcome), + "reasoning": reasoning, + "selected_direction": direction, + "key_claims": directions_result.get("key_claims", []), + "recommended_order": directions_result.get("recommended_order", ""), + "additional_notes": additional_notes, + "approved": True, + } diff --git a/mcp-server/src/legal_mcp/services/claims_extractor.py b/mcp-server/src/legal_mcp/services/claims_extractor.py new file mode 100644 index 0000000..bf13b87 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/claims_extractor.py @@ -0,0 +1,260 @@ +"""חילוץ טענות מכתבי טענות (ערר, תשובה) באמצעות Claude API. + +שתי גישות: +1. extract_claims_with_ai — חילוץ עם Claude (לכתבי טענות קלט) +2. extract_claims_from_block — חילוץ regex (מבלוק ז של החלטות סופיות) +""" + +from __future__ import annotations + +import json +import logging +import re +from uuid import UUID + +import anthropic + +from legal_mcp import config +from legal_mcp.services import db + +logger = logging.getLogger(__name__) + +_anthropic_client: anthropic.Anthropic | None = None + + +def _get_anthropic() -> anthropic.Anthropic: + global _anthropic_client + if _anthropic_client is None: + _anthropic_client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) + return _anthropic_client + + +EXTRACT_CLAIMS_PROMPT = """אתה מנתח מסמכים משפטיים בתחום תכנון ובניה. תפקידך לחלץ טענות מכתב טענות. + +## כללים חשובים: +1. **נאמנות למקור** — כל טענה חייבת לשקף את מה שנכתב, לא לפרש או להוסיף. +2. **טענה = טיעון מובחן אחד** — אם פסקה מכילה 2 טיעונים שונים, פצל לשתי טענות. +3. **כל טענה חייבת להיות מובנת בפני עצמה** — גם בלי הקשר המסמך המלא. +4. **שמור על לשון הגוף שלישי** — גם אם המקור בגוף ראשון. + +## סוג הצד (party_role): +- appellant — עורר/ת (מי שמגיש את הערר) +- respondent — משיב/ה (הצד שכנגד, לא הוועדה) +- committee — ועדה מקומית +- permit_applicant — מבקש/ת היתר + +## פלט: +החזר JSON array בלבד: +[ + { + "party_role": "appellant", + "claim_text": "הטענה בגוף שלישי, בעברית", + "topic": "נושא הטענה בקצרה (3-5 מילים)" + } +] + +אם אין טענות — החזר []. +""" + + +async def extract_claims_with_ai( + text: str, + doc_type: str = "appeal", + party_hint: str = "", +) -> list[dict]: + """חילוץ טענות מכתב טענות באמצעות Claude. + + Args: + text: טקסט המסמך + doc_type: סוג המסמך (appeal/response) + party_hint: רמז לזהות הצד (אם ידוע) + + Returns: + רשימת טענות עם party_role, claim_text, topic + """ + # For very long documents, truncate but try to keep complete paragraphs + max_chars = 25000 + if len(text) > max_chars: + # Find a paragraph break near the limit + cutoff = text.rfind("\n\n", 0, max_chars) + if cutoff < max_chars // 2: + cutoff = max_chars + sample = text[:cutoff] + logger.info("Document truncated from %d to %d chars", len(text), len(sample)) + else: + sample = text + + context = f"סוג המסמך: {doc_type}" + if party_hint: + context += f"\nהצד המגיש: {party_hint}" + + client = _get_anthropic() + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=4096, + messages=[ + { + "role": "user", + "content": ( + f"{EXTRACT_CLAIMS_PROMPT}\n\n" + f"{context}\n\n" + f"--- תחילת מסמך ---\n{sample}\n--- סוף מסמך ---" + ), + } + ], + ) + + raw = message.content[0].text.strip() + try: + # Extract JSON array from response + json_match = re.search(r"\[.*\]", raw, re.DOTALL) + if json_match: + claims = json.loads(json_match.group()) + else: + claims = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Failed to parse claims response: %s", raw[:200]) + return [] + + if not isinstance(claims, list): + return [] + + # Add claim_index + for i, claim in enumerate(claims): + claim["claim_index"] = i + # Validate required fields + if "party_role" not in claim or "claim_text" not in claim: + continue + + return [c for c in claims if "party_role" in c and "claim_text" in c] + + +# ── Regex-based extraction (from existing decisions) ────────────── + +PARTY_PATTERNS = [ + (r"טענות\s*העוררי[םן]|טענות\s*העורר\b|טענות\s*המבקש|טענות\s*המערער", "appellant"), + (r"עמדת\s*הוועדה\s*המקומית|עמדת\s*המשיבה|טענות\s*המשיבה|תגובת\s*המשיבה|הוועדה\s*המקומית$", "committee"), + (r"עמדת\s*המשיבי[םן]|עמדת\s*המשיב\b|טענות\s*המשיבי[םן]|טענות\s*המשיב\b", "respondent"), + (r"מבקשי\s*ההיתר|עמדת\s*מבקש|עמדת\s*היזם|מגישי\s*התכנית", "permit_applicant"), + (r"הבהרות\s*השמא|התייחסות\s*הצדדים", "appraiser"), +] + + +def _detect_party_role(line: str) -> str | None: + for pattern, role in PARTY_PATTERNS: + if re.search(pattern, line): + return role + return None + + +def extract_claims_from_block(text: str) -> list[dict]: + """חילוץ טענות מבלוק ז של החלטה סופית (regex-based). + + Replicates the logic from scripts/extract-claims.py for use as a service. + """ + lines = text.split("\n") + claims = [] + current_role = "appellant" + current_claim_lines: list[str] = [] + claim_index = 0 + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + role = _detect_party_role(stripped) if len(stripped.split()) <= 8 else None + if role: + if current_claim_lines: + claim_text = "\n".join(current_claim_lines).strip() + if len(claim_text) > 30: + claims.append({ + "party_role": current_role, + "claim_text": claim_text, + "claim_index": claim_index, + }) + claim_index += 1 + current_claim_lines = [] + current_role = role + continue + + # Numbered sub-header starts new claim + if re.match(r"^\d+\.\s+\S.{3,40}$", stripped): + if current_claim_lines: + claim_text = "\n".join(current_claim_lines).strip() + if len(claim_text) > 30: + claims.append({ + "party_role": current_role, + "claim_text": claim_text, + "claim_index": claim_index, + }) + claim_index += 1 + current_claim_lines = [stripped] + continue + + # Each paragraph is a claim + if current_claim_lines: + claim_text = "\n".join(current_claim_lines).strip() + if len(claim_text) > 30: + claims.append({ + "party_role": current_role, + "claim_text": claim_text, + "claim_index": claim_index, + }) + claim_index += 1 + current_claim_lines = [stripped] + + # Last claim + if current_claim_lines: + claim_text = "\n".join(current_claim_lines).strip() + if len(claim_text) > 30: + claims.append({ + "party_role": current_role, + "claim_text": claim_text, + "claim_index": claim_index, + }) + + return claims + + +async def extract_and_store_claims( + case_id: UUID, + document_id: UUID, + text: str, + doc_type: str = "appeal", + party_hint: str = "", +) -> dict: + """חילוץ טענות ושמירה ב-DB. + + Args: + case_id: מזהה התיק + document_id: מזהה המסמך + text: טקסט המסמך + doc_type: סוג (appeal/response) + party_hint: שם הצד המגיש + + Returns: + סיכום: כמה טענות חולצו, לפי צד + """ + doc = await db.get_document(document_id) + source_name = doc["title"] if doc else str(document_id) + + claims = await extract_claims_with_ai(text, doc_type, party_hint) + + if not claims: + return {"status": "no_claims", "total": 0, "source": source_name} + + stored = await db.store_claims(case_id, claims, source_document=source_name) + + # Summarize by role + role_counts: dict[str, int] = {} + for c in claims: + role = c["party_role"] + role_counts[role] = role_counts.get(role, 0) + 1 + + return { + "status": "completed", + "total": stored, + "by_role": role_counts, + "source": source_name, + } diff --git a/mcp-server/src/legal_mcp/services/classifier.py b/mcp-server/src/legal_mcp/services/classifier.py new file mode 100644 index 0000000..7790eef --- /dev/null +++ b/mcp-server/src/legal_mcp/services/classifier.py @@ -0,0 +1,259 @@ +"""סיווג אוטומטי של מסמכים וזיהוי צדדים. + +שלוש פונקציות: +1. classify_document — סיווג סוג מסמך (ערר/תשובה/פרוטוקול/...) +2. identify_parties — זיהוי צדדים (עוררים, משיבים, ועדה, מבקשי היתר) +3. detect_appeal_type — זיהוי סוג ערר לפי מספר תיק +""" + +from __future__ import annotations + +import json +import logging +import re + +import anthropic + +from legal_mcp import config + +logger = logging.getLogger(__name__) + +_anthropic_client: anthropic.Anthropic | None = None + + +def _get_anthropic() -> anthropic.Anthropic: + global _anthropic_client + if _anthropic_client is None: + _anthropic_client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) + return _anthropic_client + + +# ── סיווג סוג מסמך ────────────────────────────────────────────────── + +DOC_TYPES = { + "appeal": "כתב ערר", + "response": "תשובה / כתב תשובה", + "protocol": "פרוטוקול דיון", + "plan": "תכנית (תב\"ע)", + "permit": "היתר בנייה", + "court_decision": "פסק דין / החלטת בית משפט", + "decision": "החלטת ועדה", + "appraisal": "שומה / חוות דעת שמאית", + "objection": "התנגדות", + "exhibit": "נספח / מסמך תומך", + "reference": "מסמך עזר אחר", +} + +CLASSIFY_PROMPT = """אתה מסווג מסמכים משפטיים בתחום תכנון ובניה. + +קרא את תחילת המסמך וסווג אותו לאחד מהסוגים הבאים: +- appeal — כתב ערר (מוגש לוועדת ערר) +- response — כתב תשובה (תגובת הצד שכנגד או הוועדה המקומית) +- protocol — פרוטוקול דיון (רישום מדיון שהתקיים) +- plan — תכנית (תב"ע, תכנית מתאר, תכנית מפורטת) +- permit — היתר בנייה (או בקשה להיתר) +- court_decision — פסק דין או החלטה של בית משפט +- decision — החלטת ועדה מקומית או ועדת ערר +- appraisal — שומה או חוות דעת שמאית +- objection — התנגדות (לתכנית, להיתר) +- exhibit — נספח או מסמך תומך +- reference — מסמך עזר אחר + +החזר JSON בלבד בפורמט: +{"doc_type": "...", "confidence": 0.0-1.0, "reasoning": "הסבר קצר"} +""" + +PARTIES_PROMPT = """אתה מנתח מסמכים משפטיים בתחום תכנון ובניה. + +קרא את המסמך וזהה את הצדדים המעורבים. חפש: +- עוררים (appellants) — מי שמגיש את הערר +- משיבים (respondents) — הצד שכנגד (לרוב ועדה מקומית, או מבקש היתר) +- ועדה מקומית (committee) — שם הוועדה המקומית +- מבקשי היתר (permit_applicants) — מי שביקש את ההיתר (אם שונה מהעוררים/משיבים) + +החזר JSON בלבד בפורמט: +{ + "appellants": ["שם1", "שם2"], + "respondents": ["שם1", "שם2"], + "committee": "שם הוועדה המקומית (אם מצוין)", + "permit_applicants": ["שם1"], + "confidence": 0.0-1.0 +} + +אם לא ניתן לזהות צד מסוים, החזר רשימה ריקה. אל תמציא שמות. +""" + + +async def classify_document(text: str) -> dict: + """סיווג סוג מסמך על בסיס הטקסט. + + Args: + text: טקסט המסמך (מספיק 3000 תווים ראשונים) + + Returns: + dict עם doc_type, confidence, reasoning + """ + # Use first 3000 chars — usually enough for headers and intro + sample = text[:3000] + + client = _get_anthropic() + message = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=256, + messages=[ + { + "role": "user", + "content": f"{CLASSIFY_PROMPT}\n\n--- תחילת מסמך ---\n{sample}\n--- סוף דגימה ---", + } + ], + ) + + raw = message.content[0].text.strip() + try: + # Extract JSON from response (handle markdown code blocks) + json_match = re.search(r"\{.*\}", raw, re.DOTALL) + if json_match: + result = json.loads(json_match.group()) + else: + result = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Failed to parse classification response: %s", raw) + return {"doc_type": "reference", "confidence": 0.0, "reasoning": "סיווג נכשל"} + + # Validate doc_type + if result.get("doc_type") not in DOC_TYPES: + result["doc_type"] = "reference" + result["confidence"] = 0.0 + + return result + + +async def identify_parties(text: str) -> dict: + """זיהוי צדדים מתוך טקסט מסמך. + + Args: + text: טקסט המסמך (מספיק 5000 תווים ראשונים) + + Returns: + dict עם appellants, respondents, committee, permit_applicants, confidence + """ + # Use first 5000 chars — parties usually in header/intro + sample = text[:5000] + + client = _get_anthropic() + message = client.messages.create( + model="claude-haiku-4-5-20251001", + max_tokens=512, + messages=[ + { + "role": "user", + "content": f"{PARTIES_PROMPT}\n\n--- תחילת מסמך ---\n{sample}\n--- סוף דגימה ---", + } + ], + ) + + raw = message.content[0].text.strip() + try: + json_match = re.search(r"\{.*\}", raw, re.DOTALL) + if json_match: + result = json.loads(json_match.group()) + else: + result = json.loads(raw) + except json.JSONDecodeError: + logger.warning("Failed to parse parties response: %s", raw) + return { + "appellants": [], + "respondents": [], + "committee": "", + "permit_applicants": [], + "confidence": 0.0, + } + + # Normalize structure + return { + "appellants": result.get("appellants", []), + "respondents": result.get("respondents", []), + "committee": result.get("committee", ""), + "permit_applicants": result.get("permit_applicants", []), + "confidence": result.get("confidence", 0.0), + } + + +# ── זיהוי סוג ערר לפי מספר תיק ───────────────────────────────────── + +APPEAL_TYPES = { + "licensing": "רישוי ובנייה", # 1xxx + "betterment": "היטל השבחה", # 8xxx + "compensation": "פיצויים (ס' 197)", # 9xxx +} + + +def detect_appeal_type(case_number: str) -> dict: + """זיהוי סוג ערר לפי מספר תיק. + + Convention: + 1xxx = רישוי ובנייה + 8xxx = היטל השבחה + 9xxx = פיצויים (ס' 197) + + Args: + case_number: מספר תיק (e.g. "1078-24", "8042-23", "9015-22") + + Returns: + dict עם appeal_type, appeal_type_hebrew, confidence + """ + # Extract the numeric prefix before any dash/slash + match = re.match(r"(\d+)", case_number.strip()) + if not match: + return { + "appeal_type": "", + "appeal_type_hebrew": "", + "confidence": 0.0, + } + + num = int(match.group(1)) + first_digit = str(num)[0] if num > 0 else "" + + if first_digit == "1": + appeal_type = "licensing" + elif first_digit == "8": + appeal_type = "betterment" + elif first_digit == "9": + appeal_type = "compensation" + else: + return { + "appeal_type": "", + "appeal_type_hebrew": "", + "confidence": 0.5, + } + + return { + "appeal_type": appeal_type, + "appeal_type_hebrew": APPEAL_TYPES[appeal_type], + "confidence": 1.0, + } + + +async def classify_and_identify(text: str, case_number: str = "") -> dict: + """סיווג מלא: סוג מסמך + צדדים + סוג ערר. + + Args: + text: טקסט המסמך + case_number: מספר תיק (אופציונלי, לזיהוי סוג ערר) + + Returns: + dict עם classification, parties, appeal_type + """ + classification = await classify_document(text) + parties = await identify_parties(text) + appeal_type = detect_appeal_type(case_number) if case_number else { + "appeal_type": "", + "appeal_type_hebrew": "", + "confidence": 0.0, + } + + return { + "classification": classification, + "parties": parties, + "appeal_type": appeal_type, + } diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 721cde9..8bf18ee 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -131,6 +131,253 @@ CREATE INDEX IF NOT EXISTS idx_cases_number ON cases(case_number); MIGRATIONS_SQL = """ ALTER TABLE cases ADD COLUMN IF NOT EXISTS expected_outcome TEXT DEFAULT ''; + +CREATE TABLE IF NOT EXISTS audit_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + action TEXT NOT NULL, + case_id UUID REFERENCES cases(id) ON DELETE SET NULL, + document_id UUID REFERENCES documents(id) ON DELETE SET NULL, + details JSONB DEFAULT '{}', + actor TEXT DEFAULT 'system', + created_at TIMESTAMPTZ DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_audit_case ON audit_log(case_id); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC); +""" + +# ── Phase 3: Workflow expansion ──────────────────────────────────── + +SCHEMA_V3_SQL = """ + +-- הרחבת decisions עם שדות חדשים +ALTER TABLE decisions ADD COLUMN IF NOT EXISTS direction_doc JSONB DEFAULT NULL; +ALTER TABLE decisions ADD COLUMN IF NOT EXISTS outcome_reasoning TEXT DEFAULT ''; + +-- הרחבת cases עם appeal_type (אם לא קיים) +ALTER TABLE cases ADD COLUMN IF NOT EXISTS appeal_type TEXT DEFAULT ''; + +-- טבלת qa_results +CREATE TABLE IF NOT EXISTS qa_results ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE, + case_id UUID REFERENCES cases(id) ON DELETE CASCADE, + check_name TEXT NOT NULL, + passed BOOLEAN NOT NULL, + severity TEXT DEFAULT 'warning', + errors JSONB DEFAULT '[]', + details TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_qa_results_decision ON qa_results(decision_id); +CREATE INDEX IF NOT EXISTS idx_qa_results_case ON qa_results(case_id); + +-- טבלת decision_definitions (אם לא קיימת) +CREATE TABLE IF NOT EXISTS decision_definitions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE, + term TEXT NOT NULL, + definition TEXT NOT NULL, + block_id TEXT DEFAULT 'block-he', + created_at TIMESTAMPTZ DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_definitions_decision ON decision_definitions(decision_id); + +-- טבלת appeal_type_rules (אם לא קיימת) +CREATE TABLE IF NOT EXISTS appeal_type_rules ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + appeal_type TEXT NOT NULL, + rule_category TEXT NOT NULL, + rule_key TEXT NOT NULL, + rule_value JSONB NOT NULL, + description TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(appeal_type, rule_category, rule_key) +); + +-- image_placeholders על decision_blocks +ALTER TABLE decision_blocks ADD COLUMN IF NOT EXISTS image_placeholders JSONB DEFAULT '[]'; +""" + +# ── Phase 2: Decision + Knowledge + RAG layers ──────────────────── + +SCHEMA_V2_SQL = """ + +-- ═══════════════════════════════════════════════════════════════════ +-- Layer 2: Decision +-- ═══════════════════════════════════════════════════════════════════ + +-- decisions: מטאדטה של החלטה (גרסה אחת = רשומה אחת) +CREATE TABLE IF NOT EXISTS decisions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + case_id UUID REFERENCES cases(id) ON DELETE CASCADE, + version INTEGER DEFAULT 1, + status TEXT DEFAULT 'draft', -- draft/review/final/published + outcome TEXT DEFAULT '', -- rejected/accepted/partial + outcome_summary TEXT DEFAULT '', -- תמצית תוצאה (שורה אחת) + total_paragraphs INTEGER DEFAULT 0, + total_words INTEGER DEFAULT 0, + decision_date DATE, + author TEXT DEFAULT 'דפנה תמיר', + panel_members JSONB DEFAULT '[]', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(case_id, version) +); + +-- decision_blocks: 12 בלוקים לפי block-schema.md +CREATE TABLE IF NOT EXISTS decision_blocks ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE, + block_id TEXT NOT NULL, -- block-alef, block-bet, ... block-yod-bet + block_index INTEGER NOT NULL, -- 1-12 + title TEXT DEFAULT '', -- כותרת הבלוק (ריק לבלוקים ללא כותרת) + content TEXT DEFAULT '', -- תוכן מלא (markdown) + word_count INTEGER DEFAULT 0, + weight_percent NUMERIC(5,2) DEFAULT 0, -- משקל בפועל (%) + generation_type TEXT DEFAULT '', -- template-fill/reproduction/paraphrase/... + model_used TEXT DEFAULT '', -- sonnet/opus/script + temperature NUMERIC(3,2) DEFAULT 0, + status TEXT DEFAULT 'empty', -- empty/draft/review/final + notes TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(decision_id, block_id) +); + +-- decision_paragraphs: סעיפים בודדים עם מעקב ציטוטים +CREATE TABLE IF NOT EXISTS decision_paragraphs ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + block_id UUID REFERENCES decision_blocks(id) ON DELETE CASCADE, + paragraph_number INTEGER NOT NULL, -- מספור רציף בתוך ההחלטה + content TEXT NOT NULL, + word_count INTEGER DEFAULT 0, + citations JSONB DEFAULT '[]', -- [{case_law_id, text, type}] + cross_references JSONB DEFAULT '[]', -- הפניות לסעיפים אחרים ["סעיף 5 לעיל"] + created_at TIMESTAMPTZ DEFAULT now() +); + +-- claims: טענות צדדים (בלוק ז) +CREATE TABLE IF NOT EXISTS claims ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + case_id UUID REFERENCES cases(id) ON DELETE CASCADE, + party_role TEXT NOT NULL, -- appellant/respondent/permit_applicant/committee + party_name TEXT DEFAULT '', + claim_text TEXT NOT NULL, + claim_index INTEGER DEFAULT 0, -- סדר הופעה + source_document TEXT DEFAULT '', -- מאיזה מסמך חולצה הטענה + addressed_in_paragraph INTEGER, -- באיזה סעיף בדיון נענתה + created_at TIMESTAMPTZ DEFAULT now() +); + +-- ═══════════════════════════════════════════════════════════════════ +-- Layer 3: Legal Knowledge +-- ═══════════════════════════════════════════════════════════════════ + +-- case_law: פסיקה (תקדימים) +CREATE TABLE IF NOT EXISTS case_law ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + case_number TEXT UNIQUE NOT NULL, -- עע"מ 3975/22 או ערר 1011-03-25 + case_name TEXT NOT NULL, -- שם קצר: "ב. קרן-נכסים" + court TEXT DEFAULT '', -- בג"ץ / עליון / מנהלי / ועדת ערר + date DATE, + subject_tags JSONB DEFAULT '[]', -- ["proprietary_claims", "parking"] + summary TEXT DEFAULT '', -- תמצית 2-3 משפטים + key_quote TEXT DEFAULT '', -- ציטוט מרכזי + full_text TEXT DEFAULT '', -- טקסט מלא אם זמין + source_url TEXT DEFAULT '', + created_at TIMESTAMPTZ DEFAULT now() +); + +-- case_law_citations: קשרים בין פסיקה להחלטות שלנו +CREATE TABLE IF NOT EXISTS case_law_citations ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE, + decision_id UUID REFERENCES decisions(id) ON DELETE CASCADE, + paragraph_id UUID REFERENCES decision_paragraphs(id) ON DELETE SET NULL, + citation_type TEXT DEFAULT 'support', -- support/distinguish/overrule/obiter + context_text TEXT DEFAULT '', -- ההקשר שבו צוטט + created_at TIMESTAMPTZ DEFAULT now() +); + +-- statutory_provisions: חקיקה נפוצה +CREATE TABLE IF NOT EXISTS statutory_provisions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + statute_name TEXT NOT NULL, -- "חוק התכנון והבנייה" + section_number TEXT NOT NULL, -- "152(א)(2)" + section_title TEXT DEFAULT '', -- "זכות ערר" + full_text TEXT DEFAULT '', -- נוסח הסעיף + common_usage TEXT DEFAULT '', -- מתי משתמשים + subject_tags JSONB DEFAULT '[]', + created_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(statute_name, section_number) +); + +-- transition_phrases: ביטויי מעבר של דפנה +CREATE TABLE IF NOT EXISTS transition_phrases ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + phrase TEXT UNIQUE NOT NULL, -- "ועל מנת לא לצאת בחסר" + usage_context TEXT DEFAULT '', -- מתי להשתמש + block_types JSONB DEFAULT '[]', -- באילו בלוקים: ["block-yod"] + frequency INTEGER DEFAULT 1, -- כמה פעמים ראינו + source_decision TEXT DEFAULT '', -- מאיזו החלטה + created_at TIMESTAMPTZ DEFAULT now() +); + +-- lessons_learned: לקחים מהשוואת טיוטות לגרסאות סופיות +CREATE TABLE IF NOT EXISTS lessons_learned ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + lesson_title TEXT NOT NULL, -- "Discussion = continuous essay, no sub-headers" + lesson_text TEXT NOT NULL, -- תיאור מלא + category TEXT DEFAULT '', -- structure/style/content/process + applies_to JSONB DEFAULT '[]', -- ["block-yod", "all"] + source_case TEXT DEFAULT '', -- "הכט 1180-1181" + severity TEXT DEFAULT 'important', -- critical/important/nice-to-have + created_at TIMESTAMPTZ DEFAULT now() +); + +-- ═══════════════════════════════════════════════════════════════════ +-- Layer 4: Extended RAG +-- ═══════════════════════════════════════════════════════════════════ + +-- paragraph_embeddings: embeddings של סעיפים בהחלטות +CREATE TABLE IF NOT EXISTS paragraph_embeddings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + paragraph_id UUID REFERENCES decision_paragraphs(id) ON DELETE CASCADE, + embedding vector(1024), + created_at TIMESTAMPTZ DEFAULT now() +); + +-- case_law_embeddings: embeddings של פסיקה +CREATE TABLE IF NOT EXISTS case_law_embeddings ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + case_law_id UUID REFERENCES case_law(id) ON DELETE CASCADE, + chunk_text TEXT NOT NULL, + embedding vector(1024), + created_at TIMESTAMPTZ DEFAULT now() +); + +-- ═══════════════════════════════════════════════════════════════════ +-- Indexes +-- ═══════════════════════════════════════════════════════════════════ + +CREATE INDEX IF NOT EXISTS idx_decisions_case ON decisions(case_id); +CREATE INDEX IF NOT EXISTS idx_decisions_status ON decisions(status); +CREATE INDEX IF NOT EXISTS idx_decision_blocks_decision ON decision_blocks(decision_id); +CREATE INDEX IF NOT EXISTS idx_decision_blocks_block_id ON decision_blocks(block_id); +CREATE INDEX IF NOT EXISTS idx_decision_paragraphs_block ON decision_paragraphs(block_id); +CREATE INDEX IF NOT EXISTS idx_claims_case ON claims(case_id); +CREATE INDEX IF NOT EXISTS idx_claims_role ON claims(party_role); +CREATE INDEX IF NOT EXISTS idx_case_law_subject ON case_law USING gin(subject_tags); +CREATE INDEX IF NOT EXISTS idx_case_law_citations_decision ON case_law_citations(decision_id); +CREATE INDEX IF NOT EXISTS idx_statutory_provisions_statute ON statutory_provisions(statute_name); +CREATE INDEX IF NOT EXISTS idx_transition_phrases_block ON transition_phrases USING gin(block_types); +CREATE INDEX IF NOT EXISTS idx_lessons_category ON lessons_learned(category); +CREATE INDEX IF NOT EXISTS idx_paragraph_embeddings_vec + ON paragraph_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50); +CREATE INDEX IF NOT EXISTS idx_case_law_embeddings_vec + ON case_law_embeddings USING ivfflat (embedding vector_cosine_ops) WITH (lists = 50); """ @@ -139,7 +386,9 @@ async def init_schema() -> None: async with pool.acquire() as conn: await conn.execute(SCHEMA_SQL) await conn.execute(MIGRATIONS_SQL) - logger.info("Database schema initialized") + await conn.execute(SCHEMA_V2_SQL) + await conn.execute(SCHEMA_V3_SQL) + logger.info("Database schema initialized (v1 + v2 + v3)") # ── Case CRUD ─────────────────────────────────────────────────────── @@ -307,6 +556,134 @@ def _row_to_doc(row: asyncpg.Record) -> dict: return d +# ── Claims ───────────────────────────────────────────────────────── + +async def store_claims(case_id: UUID, claims: list[dict], source_document: str = "") -> int: + """Store extracted claims. Replaces existing claims from same source. + + Each claim dict: party_role, claim_text, claim_index, party_name (optional) + """ + pool = await get_pool() + async with pool.acquire() as conn: + if source_document: + await conn.execute( + "DELETE FROM claims WHERE case_id = $1 AND source_document = $2", + case_id, source_document, + ) + for claim in claims: + await conn.execute( + """INSERT INTO claims (case_id, party_role, party_name, claim_text, claim_index, source_document) + VALUES ($1, $2, $3, $4, $5, $6)""", + case_id, + claim["party_role"], + claim.get("party_name", ""), + claim["claim_text"], + claim.get("claim_index", 0), + source_document, + ) + return len(claims) + + +async def get_claims(case_id: UUID, party_role: str | None = None) -> list[dict]: + """Get claims for a case, optionally filtered by party role.""" + pool = await get_pool() + async with pool.acquire() as conn: + if party_role: + rows = await conn.fetch( + "SELECT * FROM claims WHERE case_id = $1 AND party_role = $2 ORDER BY claim_index", + case_id, party_role, + ) + else: + rows = await conn.fetch( + "SELECT * FROM claims WHERE case_id = $1 ORDER BY party_role, claim_index", + case_id, + ) + return [dict(r) for r in rows] + + +# ── Decisions ────────────────────────────────────────────────────── + +async def create_decision( + case_id: UUID, + outcome: str = "", + outcome_summary: str = "", + outcome_reasoning: str = "", + direction_doc: dict | None = None, +) -> dict: + """Create a decision record for a case.""" + pool = await get_pool() + decision_id = uuid4() + async with pool.acquire() as conn: + # Check if a decision already exists for this case + existing = await conn.fetchrow( + "SELECT id, version FROM decisions WHERE case_id = $1 ORDER BY version DESC LIMIT 1", + case_id, + ) + version = (existing["version"] + 1) if existing else 1 + + await conn.execute( + """INSERT INTO decisions (id, case_id, version, outcome, outcome_summary, + outcome_reasoning, direction_doc) + VALUES ($1, $2, $3, $4, $5, $6, $7)""", + decision_id, case_id, version, outcome, outcome_summary, + outcome_reasoning, json.dumps(direction_doc) if direction_doc else None, + ) + return await get_decision(decision_id) + + +async def get_decision(decision_id: UUID) -> dict | None: + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM decisions WHERE id = $1", decision_id) + if not row: + return None + d = dict(row) + d["id"] = str(d["id"]) + d["case_id"] = str(d["case_id"]) + if isinstance(d.get("direction_doc"), str): + d["direction_doc"] = json.loads(d["direction_doc"]) + if isinstance(d.get("panel_members"), str): + d["panel_members"] = json.loads(d["panel_members"]) + return d + + +async def get_decision_by_case(case_id: UUID) -> dict | None: + """Get the latest decision for a case.""" + pool = await get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM decisions WHERE case_id = $1 ORDER BY version DESC LIMIT 1", + case_id, + ) + if not row: + return None + d = dict(row) + d["id"] = str(d["id"]) + d["case_id"] = str(d["case_id"]) + if isinstance(d.get("direction_doc"), str): + d["direction_doc"] = json.loads(d["direction_doc"]) + if isinstance(d.get("panel_members"), str): + d["panel_members"] = json.loads(d["panel_members"]) + return d + + +async def update_decision(decision_id: UUID, **fields) -> None: + if not fields: + return + pool = await get_pool() + set_clauses = [] + values = [] + for i, (key, val) in enumerate(fields.items(), start=2): + if key in ("direction_doc", "panel_members") and isinstance(val, (dict, list)): + val = json.dumps(val) + set_clauses.append(f"{key} = ${i}") + values.append(val) + set_clauses.append("updated_at = now()") + sql = f"UPDATE decisions SET {', '.join(set_clauses)} WHERE id = $1" + async with pool.acquire() as conn: + await conn.execute(sql, decision_id, *values) + + # ── Chunks & Vectors ─────────────────────────────────────────────── async def store_chunks( @@ -452,3 +829,104 @@ async def clear_style_patterns() -> None: pool = await get_pool() async with pool.acquire() as conn: await conn.execute("DELETE FROM style_patterns") + + +# ── Semantic Search (V2 — decision blocks & case law) ───────────── + +async def search_similar_paragraphs( + query_embedding: list[float], + limit: int = 10, + block_type: str | None = None, +) -> list[dict]: + """Search decision paragraphs by semantic similarity.""" + pool = await get_pool() + conditions = [] + params: list = [query_embedding, limit] + param_idx = 3 + + if block_type: + conditions.append(f"db.block_id = ${param_idx}") + params.append(block_type) + param_idx += 1 + + where = f"WHERE {' AND '.join(conditions)}" if conditions else "" + + sql = f""" + SELECT dp.content, dp.word_count, dp.paragraph_number, + db.block_id AS block_type, db.title AS block_title, + c.case_number, c.title AS case_title, + d.outcome, d.author, + 1 - (pe.embedding <=> $1) AS score + FROM paragraph_embeddings pe + JOIN decision_paragraphs dp ON dp.id = pe.paragraph_id + JOIN decision_blocks db ON db.id = dp.block_id + JOIN decisions d ON d.id = db.decision_id + JOIN cases c ON c.id = d.case_id + {where} + ORDER BY pe.embedding <=> $1 + LIMIT $2 + """ + async with pool.acquire() as conn: + rows = await conn.fetch(sql, *params) + return [dict(r) for r in rows] + + +async def search_similar_case_law( + query_embedding: list[float], + limit: int = 5, +) -> list[dict]: + """Search case law by semantic similarity.""" + pool = await get_pool() + sql = """ + SELECT cl.case_number, cl.case_name, cl.court, cl.summary, + cl.key_quote, cl.subject_tags, + cle.chunk_text, + 1 - (cle.embedding <=> $1) AS score + FROM case_law_embeddings cle + JOIN case_law cl ON cl.id = cle.case_law_id + ORDER BY cle.embedding <=> $1 + LIMIT $2 + """ + async with pool.acquire() as conn: + rows = await conn.fetch(sql, query_embedding, limit) + results = [] + for r in rows: + d = dict(r) + if isinstance(d.get("subject_tags"), str): + d["subject_tags"] = json.loads(d["subject_tags"]) + results.append(d) + return results + + +async def search_precedents( + query_embedding: list[float], + limit: int = 10, +) -> list[dict]: + """Combined search: paragraphs + case law, ranked by score.""" + paragraphs = await search_similar_paragraphs(query_embedding, limit=limit) + case_law = await search_similar_case_law(query_embedding, limit=limit) + + # Combine and sort by score + results = [] + for p in paragraphs: + results.append({ + "type": "decision_paragraph", + "score": float(p["score"]), + "case_number": p["case_number"], + "case_title": p["case_title"], + "block_type": p["block_type"], + "content": p["content"][:500], + "author": p["author"], + }) + for c in case_law: + results.append({ + "type": "case_law", + "score": float(c["score"]), + "case_number": c["case_number"], + "case_name": c["case_name"], + "court": c["court"], + "content": c["summary"], + }) + + results.sort(key=lambda x: x["score"], reverse=True) + return results[:limit] diff --git a/mcp-server/src/legal_mcp/services/docx_exporter.py b/mcp-server/src/legal_mcp/services/docx_exporter.py new file mode 100644 index 0000000..6eb229d --- /dev/null +++ b/mcp-server/src/legal_mcp/services/docx_exporter.py @@ -0,0 +1,274 @@ +"""ייצוא החלטת ועדת ערר ל-DOCX מעוצב. + +דרישות: גופן David, RTL מלא, כותרות, מספור סעיפים רציף. +""" + +from __future__ import annotations + +import logging +import re +from datetime import date +from pathlib import Path +from uuid import UUID + +from docx import Document +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.oxml import OxmlElement +from docx.oxml.ns import qn +from docx.shared import Cm, Pt, RGBColor + +from legal_mcp import config +from legal_mcp.services import db + +logger = logging.getLogger(__name__) + +# ── Constants ───────────────────────────────────────────────────── + +FONT_NAME = "David" +FONT_SIZE_BODY = Pt(12) +FONT_SIZE_TITLE = Pt(16) +FONT_SIZE_HEADING = Pt(14) +LINE_SPACING = 1.5 +PAGE_MARGIN = Cm(2.5) + + +# ── RTL helpers ─────────────────────────────────────────────────── + +def _set_rtl_paragraph(paragraph) -> None: + """Set paragraph-level RTL properties.""" + pPr = paragraph._element.get_or_add_pPr() + bidi = OxmlElement("w:bidi") + bidi.set(qn("w:val"), "1") + pPr.append(bidi) + + +def _set_rtl_run(run) -> None: + """Set run-level RTL properties.""" + rPr = run._element.get_or_add_rPr() + rtl = OxmlElement("w:rtl") + rtl.set(qn("w:val"), "1") + rPr.append(rtl) + + +def _set_rtl_section(section) -> None: + """Set section-level RTL (bidi).""" + sectPr = section._sectPr + bidi = OxmlElement("w:bidi") + bidi.set(qn("w:val"), "1") + sectPr.append(bidi) + + +def _add_paragraph(doc, text: str, style: str = "Normal", + bold: bool = False, font_size=None, + alignment=None, space_after: Pt | None = None) -> None: + """Add an RTL paragraph with David font.""" + para = doc.add_paragraph() + _set_rtl_paragraph(para) + + if alignment: + para.alignment = alignment + else: + para.alignment = WD_ALIGN_PARAGRAPH.RIGHT + + run = para.add_run(text) + run.font.name = FONT_NAME + run.font.size = font_size or FONT_SIZE_BODY + run.bold = bold + _set_rtl_run(run) + + # Line spacing + pf = para.paragraph_format + pf.line_spacing = LINE_SPACING + if space_after is not None: + pf.space_after = space_after + + +def _add_centered_paragraph(doc, text: str, bold: bool = True, + font_size=None) -> None: + """Add centered RTL paragraph.""" + _add_paragraph(doc, text, bold=bold, font_size=font_size, + alignment=WD_ALIGN_PARAGRAPH.CENTER) + + +def _add_blockquote(doc, text: str) -> None: + """Add indented blockquote paragraph.""" + para = doc.add_paragraph() + _set_rtl_paragraph(para) + para.alignment = WD_ALIGN_PARAGRAPH.RIGHT + + run = para.add_run(text) + run.font.name = FONT_NAME + run.font.size = Pt(11) + run.italic = True + _set_rtl_run(run) + + pf = para.paragraph_format + pf.left_indent = Cm(1.5) + pf.right_indent = Cm(1.5) + pf.line_spacing = LINE_SPACING + + +def _add_image_placeholder(doc, description: str) -> None: + """Add image placeholder box.""" + _add_paragraph(doc, f"[{description}]", + alignment=WD_ALIGN_PARAGRAPH.CENTER, + font_size=Pt(10)) + + +# ── Main export ─────────────────────────────────────────────────── + +async def export_decision(case_id: UUID, output_path: str | None = None) -> str: + """ייצוא החלטה ל-DOCX. + + Args: + case_id: מזהה התיק + output_path: נתיב לשמירה (אופציונלי) + + Returns: + נתיב הקובץ שנוצר + """ + case = await db.get_case(case_id) + if not case: + raise ValueError(f"Case {case_id} not found") + + decision = await db.get_decision_by_case(case_id) + if not decision: + raise ValueError(f"No decision for case {case_id}") + + # Get blocks + pool = await db.get_pool() + async with pool.acquire() as conn: + blocks = await conn.fetch( + """SELECT block_id, block_index, title, content, word_count + FROM decision_blocks + WHERE decision_id = $1 + ORDER BY block_index""", + UUID(decision["id"]), + ) + + if not blocks: + raise ValueError("No blocks in decision") + + # Create document + doc = Document() + + # Set page margins + for section in doc.sections: + section.top_margin = PAGE_MARGIN + section.bottom_margin = PAGE_MARGIN + section.left_margin = PAGE_MARGIN + section.right_margin = PAGE_MARGIN + _set_rtl_section(section) + + # Write blocks + for block in blocks: + block_id = block["block_id"] + content = block["content"] or "" + if not content.strip(): + continue + + _write_block_to_docx(doc, block_id, block["title"], content) + + # Determine output path + if not output_path: + case_dir = config.CASES_DIR / case["case_number"] / "output" + case_dir.mkdir(parents=True, exist_ok=True) + output_path = str(case_dir / f"החלטה-{case['case_number']}.docx") + + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + doc.save(output_path) + logger.info("DOCX exported: %s", output_path) + return output_path + + +def _write_block_to_docx(doc, block_id: str, title: str, content: str) -> None: + """Write a single block to the DOCX document.""" + # Header blocks (א-ד) + if block_id == "block-alef": + for line in content.split("\n"): + if line.strip(): + _add_centered_paragraph(doc, line.strip(), bold=True, font_size=FONT_SIZE_HEADING) + return + + if block_id == "block-bet": + _add_paragraph(doc, "", space_after=Pt(6)) # spacer + for line in content.split("\n"): + if line.strip(): + _add_centered_paragraph(doc, line.strip(), bold=False, font_size=FONT_SIZE_BODY) + return + + if block_id == "block-gimel": + _add_paragraph(doc, "", space_after=Pt(6)) + lines = content.split("\n") + for line in lines: + stripped = line.strip() + if not stripped: + continue + if stripped == "נגד": + _add_centered_paragraph(doc, "— נגד —", bold=True, font_size=FONT_SIZE_BODY) + else: + _add_centered_paragraph(doc, stripped, bold=False, font_size=FONT_SIZE_BODY) + return + + if block_id == "block-dalet": + _add_paragraph(doc, "", space_after=Pt(12)) # spacer + _add_centered_paragraph(doc, "החלטה", bold=True, font_size=FONT_SIZE_TITLE) + _add_paragraph(doc, "", space_after=Pt(12)) + return + + if block_id == "block-yod-bet": + _add_paragraph(doc, "", space_after=Pt(24)) # spacer + for line in content.split("\n"): + if line.strip(): + _add_centered_paragraph(doc, line.strip(), bold=False, font_size=FONT_SIZE_BODY) + return + + # Content blocks (ה-יא) — parse paragraphs + paragraphs = content.split("\n") + for para_text in paragraphs: + stripped = para_text.strip() + if not stripped: + continue + + # Section headings (e.g., "תמצית טענות הצדדים", "טענות העוררים") + if _is_section_heading(stripped): + _add_paragraph(doc, stripped, bold=True, font_size=FONT_SIZE_HEADING, + space_after=Pt(6)) + continue + + # Blockquotes (indented quotes from protocols/rulings) + if stripped.startswith('"') or stripped.startswith("״") or stripped.startswith(">"): + clean = stripped.lstrip(">").strip().strip('"').strip("״").strip('"') + _add_blockquote(doc, clean) + continue + + # Image placeholders + if "📷" in stripped or stripped.startswith("[") and "תמונה" in stripped: + _add_image_placeholder(doc, stripped.strip("[]📷 ")) + continue + + # Regular numbered paragraph or plain text + _add_paragraph(doc, stripped) + + +def _is_section_heading(text: str) -> bool: + """Detect section headings in decision text.""" + heading_patterns = [ + r"^תמצית\s+טענות", + r"^טענות\s+העוררי", + r"^עמדת\s+הוועדה", + r"^עמדת\s+מבקשי", + r"^ההליכים\s+בפני", + r"^דיון\s+והכרעה", + r"^סוף\s+דבר", + r"^סיכום", + r"^פתח\s+דבר", + r"^תכניות\s+חלות", + ] + for pattern in heading_patterns: + if re.search(pattern, text): + return True + # Short bold-like lines (under 60 chars, not numbered) + if len(text) < 60 and not re.match(r"^\d+\.", text): + return False + return False diff --git a/mcp-server/src/legal_mcp/services/learning_loop.py b/mcp-server/src/legal_mcp/services/learning_loop.py new file mode 100644 index 0000000..da4a76f --- /dev/null +++ b/mcp-server/src/legal_mcp/services/learning_loop.py @@ -0,0 +1,187 @@ +"""לולאת למידה — השוואת טיוטה לגרסה סופית וחילוץ לקחים. + +שלב 7 באיפיון: +1. קליטת גרסה סופית (שדפנה חתמה) +2. השוואת טיוטה לסופית — זיהוי שינויים +3. חילוץ לקחים: ביטויים חדשים, דפוסים שהשתנו, שגיאות חוזרות +4. עדכון מודל הסגנון +""" + +from __future__ import annotations + +import json +import logging +import re +from uuid import UUID + +import anthropic + +from legal_mcp import config +from legal_mcp.services import db + +logger = logging.getLogger(__name__) + +_anthropic_client: anthropic.Anthropic | None = None + + +def _get_anthropic() -> anthropic.Anthropic: + global _anthropic_client + if _anthropic_client is None: + _anthropic_client = anthropic.Anthropic(api_key=config.ANTHROPIC_API_KEY) + return _anthropic_client + + +def compute_diff_stats(draft_text: str, final_text: str) -> dict: + """חישוב סטטיסטיקות השוואה בין טיוטה לסופית.""" + draft_words = draft_text.split() + final_words = final_text.split() + + draft_len = len(draft_words) + final_len = len(final_words) + + # Simple word-level diff (not a full diff algorithm, but good enough for stats) + draft_set = set(draft_words) + final_set = set(final_words) + + common = draft_set & final_set + added = final_set - draft_set + removed = draft_set - final_set + + # Estimate change percentage + if draft_len == 0: + change_pct = 100.0 + else: + change_pct = (len(added) + len(removed)) / max(draft_len, final_len) * 100 + + return { + "draft_words": draft_len, + "final_words": final_len, + "change_percent": round(change_pct, 1), + "words_added": len(added), + "words_removed": len(removed), + "words_common": len(common), + } + + +LESSONS_PROMPT = """אתה מנתח שינויים בהחלטות משפטיות. קיבלת טיוטה (שנוצרה ע"י AI) וגרסה סופית (שעברה עריכת דפנה). + +## משימה: +1. זהה את השינויים המהותיים (לא הקלדה/פורמט) +2. סווג כל שינוי: + - expression_change — ביטוי שהוחלף (הצע כלקח לעתיד) + - structure_change — שינוי מבני (סדר, חלוקה) + - content_addition — תוכן שנוסף (מה חסר?) + - content_removal — תוכן שהוסר (מה מיותר?) + - tone_change — שינוי טון (רשמי יותר/פחות) + - error_fix — תיקון שגיאה עובדתית/משפטית +3. הסק לקחים שניתן להפעיל בהחלטות עתידיות + +## פלט JSON: +{ + "changes": [ + {"type": "...", "description": "תיאור השינוי", "draft_text": "...", "final_text": "...", "lesson": "לקח לעתיד"} + ], + "new_expressions": ["ביטוי חדש שדפנה הוסיפה"], + "overall_assessment": "הערכה כללית (1-2 משפטים)" +} +""" + + +async def analyze_changes(draft_text: str, final_text: str) -> dict: + """ניתוח שינויים בין טיוטה לגרסה סופית עם Claude.""" + # Truncate for context window + max_chars = 15000 + draft_sample = draft_text[:max_chars] + final_sample = final_text[:max_chars] + + client = _get_anthropic() + message = client.messages.create( + model="claude-sonnet-4-20250514", + max_tokens=4096, + messages=[{ + "role": "user", + "content": f"""{LESSONS_PROMPT} + +--- טיוטה --- +{draft_sample} + +--- גרסה סופית --- +{final_sample} +""", + }], + ) + + raw = message.content[0].text.strip() + try: + json_match = re.search(r"\{.*\}", raw, re.DOTALL) + if json_match: + return json.loads(json_match.group()) + return json.loads(raw) + except json.JSONDecodeError: + logger.warning("Failed to parse lessons response") + return {"changes": [], "new_expressions": [], "overall_assessment": raw[:200]} + + +async def process_final_version( + case_id: UUID, + final_text: str, +) -> dict: + """קליטת גרסה סופית, השוואה לטיוטה, חילוץ לקחים. + + Args: + case_id: מזהה התיק + final_text: טקסט הגרסה הסופית + + Returns: + dict עם diff stats, changes, lessons + """ + decision = await db.get_decision_by_case(case_id) + if not decision: + raise ValueError(f"No decision for case {case_id}") + + # Get draft text (combine all blocks) + pool = await db.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """SELECT content FROM decision_blocks + WHERE decision_id = $1 AND word_count > 0 + ORDER BY block_index""", + UUID(decision["id"]), + ) + draft_text = "\n\n".join(r["content"] for r in rows if r["content"]) + + if not draft_text: + raise ValueError("No draft content to compare") + + # Compute stats + diff_stats = compute_diff_stats(draft_text, final_text) + + # Analyze changes with AI + analysis = await analyze_changes(draft_text, final_text) + + # Store new expressions as style patterns + for expr in analysis.get("new_expressions", []): + if expr and len(expr) > 3: + await db.upsert_style_pattern( + pattern_type="characteristic_phrase", + pattern_text=expr, + context="למד מגרסה סופית", + ) + + # Update decision status + await db.update_decision( + UUID(decision["id"]), + status="final", + ) + + # Update case status + case = await db.get_case(case_id) + if case: + await db.update_case(case_id, status="final") + + return { + "diff_stats": diff_stats, + "analysis": analysis, + "lessons_count": len(analysis.get("changes", [])), + "new_expressions": len(analysis.get("new_expressions", [])), + } diff --git a/mcp-server/src/legal_mcp/services/metrics.py b/mcp-server/src/legal_mcp/services/metrics.py new file mode 100644 index 0000000..ce43dda --- /dev/null +++ b/mcp-server/src/legal_mcp/services/metrics.py @@ -0,0 +1,165 @@ +"""מדדי הצלחה (KPIs) לתהליך כתיבת החלטות. + +מדדים: +1. אחוז שינוי — השוואת טיוטה לגרסה סופית (יעד: <10%) +2. אפס הזיות — ספירת הפניות לא מבוססות +3. מענה לכל טענה — כיסוי טענות בדיון +4. משקלות בטווח — עמידה ביחסי הזהב +5. רקע ניטרלי — ללא מילות שיפוט +6. זמן עיבוד — מקליטה עד טיוטה +""" + +from __future__ import annotations + +import json +import logging +from datetime import datetime +from uuid import UUID + +from legal_mcp.services import db + +logger = logging.getLogger(__name__) + + +async def get_case_metrics(case_id: UUID) -> dict: + """חישוב מדדים לתיק בודד.""" + case = await db.get_case(case_id) + if not case: + raise ValueError(f"Case {case_id} not found") + + decision = await db.get_decision_by_case(case_id) + pool = await db.get_pool() + + metrics = { + "case_number": case["case_number"], + "title": case.get("title", ""), + "status": case.get("status", ""), + } + + # 1. Change percentage (if final version exists) + if decision and decision.get("status") == "final": + async with pool.acquire() as conn: + # Get draft word count + draft_words = await conn.fetchval( + "SELECT SUM(word_count) FROM decision_blocks WHERE decision_id = $1", + UUID(decision["id"]), + ) + metrics["draft_words"] = draft_words or 0 + # Change percent is stored during learning loop + metrics["change_percent"] = None # populated from learning_loop results + else: + metrics["draft_words"] = 0 + metrics["change_percent"] = None + + # 2. QA results + async with pool.acquire() as conn: + qa_rows = await conn.fetch( + "SELECT check_name, passed, severity, errors FROM qa_results WHERE case_id = $1", + case_id, + ) + + if qa_rows: + qa_results = {} + for row in qa_rows: + errors = json.loads(row["errors"]) if isinstance(row["errors"], str) else row["errors"] + qa_results[row["check_name"]] = { + "passed": row["passed"], + "severity": row["severity"], + "error_count": len(errors) if errors else 0, + } + metrics["qa"] = qa_results + metrics["qa_passed"] = all(r["passed"] for r in qa_results.values()) + metrics["qa_critical_failures"] = sum( + 1 for r in qa_results.values() + if not r["passed"] and r["severity"] == "critical" + ) + else: + metrics["qa"] = None + metrics["qa_passed"] = None + + # 3. Claims coverage + claims = await db.get_claims(case_id) + metrics["total_claims"] = len(claims) + + # 4. Documents + docs = await db.list_documents(case_id) + metrics["total_documents"] = len(docs) + + # 5. Processing time + if docs and decision: + first_doc_time = min( + d.get("created_at", datetime.max) for d in docs + if d.get("created_at") + ) + decision_time = decision.get("created_at") + if first_doc_time and decision_time: + delta = decision_time - first_doc_time + metrics["processing_hours"] = round(delta.total_seconds() / 3600, 1) + else: + metrics["processing_hours"] = None + else: + metrics["processing_hours"] = None + + return metrics + + +async def get_dashboard() -> dict: + """דשבורד כולל — סיכום מדדים על כל התיקים.""" + pool = await db.get_pool() + + async with pool.acquire() as conn: + # Case counts by status + status_rows = await conn.fetch( + "SELECT status, COUNT(*) as cnt FROM cases GROUP BY status ORDER BY cnt DESC" + ) + cases_by_status = {r["status"]: r["cnt"] for r in status_rows} + + # Total counts + total_cases = await conn.fetchval("SELECT COUNT(*) FROM cases") + total_docs = await conn.fetchval("SELECT COUNT(*) FROM documents") + total_claims = await conn.fetchval("SELECT COUNT(*) FROM claims") + total_chunks = await conn.fetchval("SELECT COUNT(*) FROM document_chunks") + total_decisions = await conn.fetchval("SELECT COUNT(*) FROM decisions") + total_corpus = await conn.fetchval("SELECT COUNT(*) FROM style_corpus") + total_patterns = await conn.fetchval("SELECT COUNT(*) FROM style_patterns") + total_case_law = await conn.fetchval("SELECT COUNT(*) FROM case_law") + + # QA summary + qa_total = await conn.fetchval("SELECT COUNT(DISTINCT case_id) FROM qa_results") + qa_passed = await conn.fetchval( + """SELECT COUNT(DISTINCT case_id) FROM qa_results + WHERE case_id NOT IN ( + SELECT case_id FROM qa_results WHERE passed = false AND severity = 'critical' + )""" + ) + + # Final decisions + final_count = await conn.fetchval( + "SELECT COUNT(*) FROM decisions WHERE status = 'final'" + ) + + # Average words per decision + avg_words = await conn.fetchval( + "SELECT AVG(total_words) FROM decisions WHERE total_words > 0" + ) + + return { + "summary": { + "total_cases": total_cases, + "total_documents": total_docs, + "total_claims": total_claims, + "total_chunks": total_chunks, + "total_decisions": total_decisions, + "final_decisions": final_count, + "style_corpus": total_corpus, + "style_patterns": total_patterns, + "case_law_entries": total_case_law, + }, + "cases_by_status": cases_by_status, + "qa": { + "cases_validated": qa_total, + "cases_passed": qa_passed, + "pass_rate": round(qa_passed / qa_total * 100, 1) if qa_total else None, + }, + "avg_decision_words": round(avg_words) if avg_words else None, + } diff --git a/mcp-server/src/legal_mcp/services/processor.py b/mcp-server/src/legal_mcp/services/processor.py index 4598760..9c42d69 100644 --- a/mcp-server/src/legal_mcp/services/processor.py +++ b/mcp-server/src/legal_mcp/services/processor.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging from uuid import UUID -from legal_mcp.services import chunker, db, embeddings, extractor +from legal_mcp.services import chunker, classifier, db, embeddings, extractor, references_extractor logger = logging.getLogger(__name__) @@ -37,6 +37,26 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict: page_count=page_count, ) + # Step 1.5: Classify document and identify parties + logger.info("Classifying document") + case_number = "" + if case_id: + case = await db.get_case(case_id) + if case: + case_number = case.get("case_number", "") + classification_result = await classifier.classify_and_identify(text, case_number) + await db.update_document( + document_id, + metadata=classification_result, + ) + logger.info( + "Classification: %s (confidence: %.2f), parties found: %d appellants, %d respondents", + classification_result["classification"].get("doc_type", "?"), + classification_result["classification"].get("confidence", 0), + len(classification_result["parties"].get("appellants", [])), + len(classification_result["parties"].get("respondents", [])), + ) + # Step 2: Chunk logger.info("Chunking document (%d chars)", len(text)) chunks = chunker.chunk_document(text) @@ -63,6 +83,18 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict: ] stored = await db.store_chunks(document_id, case_id, chunk_dicts) + + # Step 5: Extract references (plans, case law, legislation) + logger.info("Extracting legal references") + refs_result = await references_extractor.extract_and_link_references( + document_id, case_id, text, + ) + logger.info( + "References found: %d plans, %d case law (%d linked), %d legislation", + refs_result["plans"], refs_result["case_law"], + refs_result["case_law_linked"], refs_result["legislation"], + ) + await db.update_document(document_id, extraction_status="completed") logger.info("Document processed: %d chunks stored", stored) @@ -71,6 +103,12 @@ async def process_document(document_id: UUID, case_id: UUID) -> dict: "chunks": stored, "pages": page_count, "text_length": len(text), + "classification": classification_result, + "references": { + "plans": refs_result["plans"], + "case_law": refs_result["case_law"], + "legislation": refs_result["legislation"], + }, } except Exception as e: diff --git a/mcp-server/src/legal_mcp/services/qa_validator.py b/mcp-server/src/legal_mcp/services/qa_validator.py new file mode 100644 index 0000000..01957aa --- /dev/null +++ b/mcp-server/src/legal_mcp/services/qa_validator.py @@ -0,0 +1,309 @@ +"""בקרת איכות וולידציה של החלטות לפני ייצוא. + +6 בדיקות: +1. neutral_background — רקע ניטרלי (ללא מילות שיפוט/ציטוטים מצדדים) +2. claims_coverage — כל טענה מבלוק ז נענתה בבלוק י +3. weight_compliance — משקלות בלוקים בטווח הנכון +4. structural_integrity — כל בלוקי חובה קיימים +5. no_duplication — אין כפילויות בין רקע לדיון +6. sequential_numbering — מספור רציף + +אם בדיקה קריטית נכשלת → חוסם ייצוא. +""" + +from __future__ import annotations + +import json +import logging +import re +from uuid import UUID + +from legal_mcp.services import db + +logger = logging.getLogger(__name__) + +# ── Value/judgment words forbidden in background ────────────────── + +VALUE_WORDS = [ + "חריג", "חטא", "בעייתי", "מזעזע", "שערורייתי", "מגוחך", + "נפשע", "פגום", "חמור", "מקומם", "בלתי סביר", "מופרז", + "מגונה", "פסול", "נלוז", "מטריד", "שגוי", "מוטעה", +] + +QUOTE_INDICATORS = [ + r"לטענת\s+(העוררי|המשיב|מבקשי)", + r"לדברי\s+(העוררי|המשיב|מבקשי)", + r"העורר\s+טוען", + r"המשיבה\s+טוענת", + r"לשיטת\s+(העוררי|המשיב)", +] + +# ── Weight ranges ───────────────────────────────────────────────── + +WEIGHT_RANGES = { + "licensing": { + "block-he": (0.5, 5), "block-vav": (3, 40), "block-zayin": (13, 40), + "block-chet": (0, 15), "block-tet": (0, 15), + "block-yod": (30, 75), "block-yod-alef": (1, 10), + }, + "betterment": { + "block-he": (0, 5), "block-vav": (2, 20), "block-zayin": (15, 40), + "block-chet": (0, 25), "block-tet": (0, 15), + "block-yod": (25, 75), "block-yod-alef": (1, 10), + }, + "compensation": { + "block-he": (0, 5), "block-vav": (2, 20), "block-zayin": (15, 40), + "block-chet": (0, 25), "block-tet": (0, 15), + "block-yod": (25, 75), "block-yod-alef": (1, 10), + }, +} + + +# ── Individual checks ───────────────────────────────────────────── + +def check_neutral_background(blocks: list[dict]) -> dict: + """בדיקת ניטרליות בלוק הרקע (ו).""" + vav = next((b for b in blocks if b["block_id"] == "block-vav"), None) + if not vav or not vav.get("content"): + return {"name": "neutral_background", "passed": True, "errors": [], "severity": "critical"} + + errors = [] + lines = vav["content"].split("\n") + for i, line in enumerate(lines): + for word in VALUE_WORDS: + if word in line: + errors.append(f"מילת שיפוט (שורה {i+1}): \"{word}\"") + for pattern in QUOTE_INDICATORS: + if re.search(pattern, line): + errors.append(f"ציטוט מצד (שורה {i+1}): \"{line[:60]}...\"") + + return { + "name": "neutral_background", + "passed": len(errors) == 0, + "errors": errors, + "severity": "warning", + } + + +def check_claims_coverage(blocks: list[dict], claims: list[dict]) -> dict: + """בדיקה שכל טענה מבלוק ז נענתה בבלוק י.""" + yod = next((b for b in blocks if b["block_id"] == "block-yod"), None) + if not yod or not yod.get("content"): + return {"name": "claims_coverage", "passed": False, + "errors": ["בלוק דיון (י) ריק"], "severity": "critical"} + + if not claims: + return {"name": "claims_coverage", "passed": True, "errors": [], "severity": "critical"} + + yod_text = yod["content"].lower() + errors = [] + + for claim in claims: + claim_text = claim.get("claim_text", "") + # Extract key phrases (3+ word sequences) from claim + words = claim_text.split() + key_phrases = [] + for j in range(0, len(words) - 2): + phrase = " ".join(words[j:j+3]) + if len(phrase) > 8: + key_phrases.append(phrase.lower()) + + # Check if any key phrase appears in discussion + found = any(phrase in yod_text for phrase in key_phrases[:5]) + if not found: + short = claim_text[:80] + errors.append(f"טענה לא נענתה: \"{short}...\"") + + return { + "name": "claims_coverage", + "passed": len(errors) == 0, + "errors": errors, + "severity": "critical", + } + + +def check_weight_compliance(blocks: list[dict], appeal_type: str) -> dict: + """בדיקת משקלות בלוקים בטווח.""" + ranges = WEIGHT_RANGES.get(appeal_type, WEIGHT_RANGES["licensing"]) + total_words = sum(b.get("word_count", 0) for b in blocks) + + if total_words == 0: + return {"name": "weight_compliance", "passed": False, + "errors": ["אין תוכן בהחלטה"], "severity": "critical"} + + errors = [] + for block in blocks: + bid = block["block_id"] + wc = block.get("word_count", 0) + if bid in ranges and wc > 0: + weight = wc / total_words * 100 + low, high = ranges[bid] + if weight < low: + errors.append(f"{block.get('title', bid)}: {weight:.1f}% (מינימום: {low}%)") + elif weight > high: + errors.append(f"{block.get('title', bid)}: {weight:.1f}% (מקסימום: {high}%)") + + return { + "name": "weight_compliance", + "passed": len(errors) == 0, + "errors": errors, + "severity": "warning", + } + + +def check_structural_integrity(blocks: list[dict]) -> dict: + """בדיקת מבנה — כל בלוקי חובה קיימים.""" + required = {"block-he", "block-zayin", "block-yod", "block-yod-alef"} + present = {b["block_id"] for b in blocks if b.get("word_count", 0) > 0} + missing = required - present + + errors = [] + if missing: + block_names = {"block-he": "פתיחה (ה)", "block-zayin": "טענות (ז)", + "block-yod": "דיון (י)", "block-yod-alef": "סיכום (יא)"} + for m in missing: + errors.append(f"בלוק חובה חסר: {block_names.get(m, m)}") + + # Check discussion is the heaviest content block + content_blocks = [b for b in blocks if b["block_id"] not in + ("block-alef", "block-bet", "block-gimel", "block-dalet", "block-yod-bet")] + if content_blocks: + heaviest = max(content_blocks, key=lambda x: x.get("word_count", 0)) + if heaviest["block_id"] != "block-yod": + errors.append(f"בלוק הדיון אינו הגדול ביותר — {heaviest.get('title', '')} גדול יותר") + + return { + "name": "structural_integrity", + "passed": len(errors) == 0, + "errors": errors, + "severity": "critical", + } + + +def check_no_duplication(blocks: list[dict]) -> dict: + """בדיקת כפילויות בין רקע לדיון.""" + vav = next((b for b in blocks if b["block_id"] == "block-vav"), None) + yod = next((b for b in blocks if b["block_id"] == "block-yod"), None) + + if not vav or not yod: + return {"name": "no_duplication", "passed": True, "errors": [], "severity": "warning"} + + vav_text = vav.get("content", "") + yod_text = yod.get("content", "") + errors = [] + + # Find sentences from background repeated verbatim in discussion + sentences = [s.strip() for s in re.split(r'[.!?]', vav_text) if len(s.strip()) > 30] + for sent in sentences: + if sent in yod_text: + errors.append(f"כפילות: \"{sent[:60]}...\"") + + return { + "name": "no_duplication", + "passed": len(errors) == 0, + "errors": errors, + "severity": "warning", + } + + +def check_sequential_numbering(blocks: list[dict]) -> dict: + """בדיקת מספור רציף בין הבלוקים.""" + errors = [] + all_numbers = [] + + for block in blocks: + content = block.get("content", "") + # Find numbered paragraphs (e.g., "1.", "2.", "15.") + numbers = re.findall(r"^(\d+)\.", content, re.MULTILINE) + all_numbers.extend(int(n) for n in numbers) + + if all_numbers: + # Check for gaps + sorted_nums = sorted(set(all_numbers)) + for i in range(1, len(sorted_nums)): + if sorted_nums[i] - sorted_nums[i-1] > 1: + errors.append(f"פער במספור: {sorted_nums[i-1]} → {sorted_nums[i]}") + # Check starts at 1 + if sorted_nums and sorted_nums[0] != 1: + errors.append(f"מספור מתחיל מ-{sorted_nums[0]} במקום 1") + + return { + "name": "sequential_numbering", + "passed": len(errors) == 0, + "errors": errors, + "severity": "warning", + } + + +# ── Main validation ─────────────────────────────────────────────── + +async def validate_decision(case_id: UUID) -> dict: + """הרצת כל בדיקות QA על החלטה. + + Returns: + dict עם passed (bool), results (list), critical_failures (int) + """ + case = await db.get_case(case_id) + if not case: + raise ValueError(f"Case {case_id} not found") + + decision = await db.get_decision_by_case(case_id) + if not decision: + raise ValueError(f"No decision for case {case_id}") + + # Get blocks + pool = await db.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """SELECT block_id, block_index, title, content, word_count + FROM decision_blocks WHERE decision_id = $1 + ORDER BY block_index""", + UUID(decision["id"]), + ) + blocks = [dict(r) for r in rows] + + # Get claims + claims = await db.get_claims(case_id) + + # Determine appeal type + appeal_type = case.get("appeal_type", "licensing") + + # Run all checks + results = [ + check_neutral_background(blocks), + check_claims_coverage(blocks, claims), + check_weight_compliance(blocks, appeal_type), + check_structural_integrity(blocks), + check_no_duplication(blocks), + check_sequential_numbering(blocks), + ] + + critical_failures = sum(1 for r in results if not r["passed"] and r["severity"] == "critical") + all_passed = all(r["passed"] for r in results) + + # Store results in qa_results table + async with pool.acquire() as conn: + # Clear previous results + await conn.execute( + "DELETE FROM qa_results WHERE case_id = $1", + case_id, + ) + for result in results: + await conn.execute( + """INSERT INTO qa_results + (decision_id, case_id, check_name, passed, severity, errors, details) + VALUES ($1, $2, $3, $4, $5, $6, $7)""", + UUID(decision["id"]), case_id, + result["name"], result["passed"], result["severity"], + json.dumps(result["errors"], ensure_ascii=False), + "", + ) + + return { + "passed": all_passed, + "critical_failures": critical_failures, + "export_blocked": critical_failures > 0, + "results": results, + "total_checks": len(results), + "passed_checks": sum(1 for r in results if r["passed"]), + } diff --git a/mcp-server/src/legal_mcp/services/references_extractor.py b/mcp-server/src/legal_mcp/services/references_extractor.py new file mode 100644 index 0000000..4f35f3f --- /dev/null +++ b/mcp-server/src/legal_mcp/services/references_extractor.py @@ -0,0 +1,200 @@ +"""זיהוי תכניות, פסיקה וחקיקה במסמכים משפטיים. + +שלוש קטגוריות: +1. תכניות (תב"ע, תמ"א, תכניות מקומיות/מחוזיות) +2. פסיקה (עע"מ, בג"ץ, ע"א, עררים) +3. חקיקה (חוק התכנון, חוק מיסוי מקרקעין, וכו') +""" + +from __future__ import annotations + +import json +import logging +import re +from uuid import UUID + +from legal_mcp.services import db + +logger = logging.getLogger(__name__) + +# ── Regex patterns ──────────────────────────────────────────────────── + +# Plans (תכניות) +PLAN_PATTERNS = [ + # תמ"א with number + re.compile(r'תמ"א\s*[\-]?\s*(\d+)(?:\s*[\-/]\s*(\S+))?'), + # תכנית מתאר with identifiers + re.compile(r'תכנית\s+(?:מתאר\s+)?(?:ארצית|מחוזית|מקומית)?\s*(?:מס[\'"]?\s*)?(\d[\d/\-\.]+\S*)'), + # תב"ע with identifiers + re.compile(r'תב"ע\s*(?:מס[\'"]?\s*)?(\S+)'), + # Specific plan number patterns (e.g., 62/3, ירושלים 12345) + re.compile(r'תכנית\s+(\S+\s*\d[\d/\-\.]+\S*)'), +] + +# Case law (פסיקה) +CASE_LAW_PATTERNS = [ + # Court types: עע"מ, בג"ץ, ע"א, בר"ם, עת"מ, עמ"נ, ע"ע, רע"א, דנ"א + re.compile(r'(עע"מ|בג"ץ|ע"א|בר"ם|עת"מ|עמ"נ|ע"ע|רע"א|דנ"א|בש"א)\s*(\d[\d/\-]+)'), + # ערר with district + re.compile(r'ערר\s*\(?\s*(מרכז|ירושלים|חי\'?פה?|ת"א|תל[- ]אביב|דרום|צפון)\s*\)?\s*(\d[\d/\-]+)'), + # ערר without district + re.compile(r'ערר\s+(\d{3,5}[\-/]\d{2,4})'), +] + +# Legislation (חקיקה) +LEGISLATION_PATTERNS = [ + re.compile(r'(חוק\s+התכנון\s+והבני[יה]ה)\s*[,،]?\s*(?:תשכ"ה[- ])?(?:1965)?(?:\s*[,،]\s*ס(?:עיף|\'|ע\')\s*(\d+\S*))?'), + re.compile(r'(חוק\s+מיסוי\s+מקרקעין)\s*(?:\(שבח\s+ורכישה\))?\s*[,،]?\s*(?:תשכ"ג[- ])?(?:1963)?(?:\s*[,،]\s*ס(?:עיף|\'|ע\')\s*(\d+\S*))?'), + re.compile(r'(תקנות\s+התכנון\s+והבני[יה]ה)\s*\(([^)]+)\)'), + re.compile(r'(חוק\s+ההתיישנות)\s*(?:תשי"ח[- ])?(?:1958)?'), + re.compile(r'ס(?:עיף|\'|ע\')\s*(\d+\S*)\s*(?:ל|של)?(חוק\s+\S+(?:\s+\S+){0,3})'), +] + + +def extract_plans(text: str) -> list[dict]: + """זיהוי תכניות במסמך.""" + plans = [] + seen = set() + + for pattern in PLAN_PATTERNS: + for match in pattern.finditer(text): + full = match.group(0).strip() + if full in seen or len(full) < 4: + continue + seen.add(full) + + start = max(0, match.start() - 60) + end = min(len(text), match.end() + 100) + context = text[start:end].replace("\n", " ").strip() + + plans.append({ + "plan_name": full, + "context": context, + }) + + return plans + + +def extract_case_law(text: str) -> list[dict]: + """זיהוי פסיקה מצוטטת במסמך.""" + citations = [] + seen = set() + + for pattern in CASE_LAW_PATTERNS: + for match in pattern.finditer(text): + full = match.group(0).strip() + if full in seen: + continue + seen.add(full) + + start = max(0, match.start() - 50) + end = min(len(text), match.end() + 100) + context = text[start:end].replace("\n", " ").strip() + + # Try to extract case name from context + case_name = "" + name_match = re.search(r'(?:עניין|פרשת|נ[\'"]?\s+)\s*(\S+(?:\s+\S+)?)', context) + if name_match: + case_name = name_match.group(1).strip() + + citations.append({ + "citation_text": full, + "case_name": case_name, + "context": context, + }) + + return citations + + +def extract_legislation(text: str) -> list[dict]: + """זיהוי חקיקה מצוטטת במסמך.""" + legislation = [] + seen = set() + + for pattern in LEGISLATION_PATTERNS: + for match in pattern.finditer(text): + full = match.group(0).strip() + if full in seen or len(full) < 5: + continue + seen.add(full) + + start = max(0, match.start() - 40) + end = min(len(text), match.end() + 80) + context = text[start:end].replace("\n", " ").strip() + + legislation.append({ + "statute_text": full, + "context": context, + }) + + return legislation + + +def extract_all_references(text: str) -> dict: + """זיהוי כל ההפניות במסמך: תכניות, פסיקה, חקיקה.""" + return { + "plans": extract_plans(text), + "case_law": extract_case_law(text), + "legislation": extract_legislation(text), + } + + +async def extract_and_link_references( + document_id: UUID, + case_id: UUID, + text: str, +) -> dict: + """זיהוי הפניות ושמירה ב-DB. + + מזהה פסיקה וחקיקה, ומנסה לקשר לרשומות קיימות ב-case_law. + """ + refs = extract_all_references(text) + + # Try to match case_law citations to existing DB records + pool = await db.get_pool() + linked = 0 + + async with pool.acquire() as conn: + # Get existing case_law for matching + case_laws = await conn.fetch("SELECT id, case_number, case_name FROM case_law") + case_law_map = {} + for cl in case_laws: + case_law_map[cl["case_number"]] = cl["id"] + parts = cl["case_number"].split() + if len(parts) > 1: + case_law_map[parts[-1]] = cl["id"] + + for cit in refs["case_law"]: + case_law_id = None + for key, cl_id in case_law_map.items(): + if key in cit["citation_text"] or cit["citation_text"] in key: + case_law_id = cl_id + break + + cit["matched_in_db"] = case_law_id is not None + if case_law_id: + linked += 1 + + # Store references in document metadata + doc = await db.get_document(document_id) + if doc: + existing_metadata = doc.get("metadata") or {} + if isinstance(existing_metadata, str): + existing_metadata = json.loads(existing_metadata) + existing_metadata["references"] = { + "plans": [{"plan_name": p["plan_name"]} for p in refs["plans"]], + "case_law": [ + {"citation": c["citation_text"], "case_name": c.get("case_name", ""), "in_db": c.get("matched_in_db", False)} + for c in refs["case_law"] + ], + "legislation": [{"statute": l["statute_text"]} for l in refs["legislation"]], + } + await db.update_document(document_id, metadata=existing_metadata) + + return { + "plans": len(refs["plans"]), + "case_law": len(refs["case_law"]), + "case_law_linked": linked, + "legislation": len(refs["legislation"]), + "details": refs, + } diff --git a/mcp-server/src/legal_mcp/tools/documents.py b/mcp-server/src/legal_mcp/tools/documents.py index 9534476..d3b40cd 100644 --- a/mcp-server/src/legal_mcp/tools/documents.py +++ b/mcp-server/src/legal_mcp/tools/documents.py @@ -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) diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index a939462..4e4cb9e 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -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) diff --git a/mcp-server/src/legal_mcp/tools/workflow.py b/mcp-server/src/legal_mcp/tools/workflow.py index 60f2b63..cecd6c5 100644 --- a/mcp-server/src/legal_mcp/tools/workflow.py +++ b/mcp-server/src/legal_mcp/tools/workflow.py @@ -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) diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh new file mode 100755 index 0000000..df97078 --- /dev/null +++ b/scripts/backup-db.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# גיבוי יומי אוטומטי למסד הנתונים PostgreSQL +# Usage: ./backup-db.sh [backup_dir] +# Cron: 0 2 * * * /home/chaim/legal-ai/scripts/backup-db.sh + +set -euo pipefail + +BACKUP_DIR="${1:-/home/chaim/legal-ai/data/backups}" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="${BACKUP_DIR}/legal_ai_${TIMESTAMP}.sql.gz" + +# Load DB credentials from .env +source "${HOME}/.env" 2>/dev/null || true + +DB_HOST="${POSTGRES_HOST:-127.0.0.1}" +DB_PORT="${POSTGRES_PORT:-5433}" +DB_NAME="${POSTGRES_DB:-legal_ai}" +DB_USER="${POSTGRES_USER:-legal_ai}" + +# Create backup directory +mkdir -p "${BACKUP_DIR}" + +echo "[$(date)] Starting backup: ${BACKUP_FILE}" + +# Dump and compress +PGPASSWORD="${POSTGRES_PASSWORD:-}" pg_dump \ + -h "${DB_HOST}" \ + -p "${DB_PORT}" \ + -U "${DB_USER}" \ + -d "${DB_NAME}" \ + --format=custom \ + --compress=9 \ + -f "${BACKUP_FILE%.gz}" + +gzip "${BACKUP_FILE%.gz}" 2>/dev/null || true + +echo "[$(date)] Backup completed: ${BACKUP_FILE}" + +# Verify backup +if [ -f "${BACKUP_FILE}" ]; then + SIZE=$(du -h "${BACKUP_FILE}" | cut -f1) + echo "[$(date)] Backup size: ${SIZE}" +else + # pg_dump --format=custom already compresses + BACKUP_FILE="${BACKUP_FILE%.gz}" + SIZE=$(du -h "${BACKUP_FILE}" | cut -f1) + echo "[$(date)] Backup size: ${SIZE} (custom format)" +fi + +# Cleanup: keep last 30 days +find "${BACKUP_DIR}" -name "legal_ai_*.sql*" -mtime +30 -delete 2>/dev/null +echo "[$(date)] Old backups cleaned (>30 days)" + +# Count remaining backups +COUNT=$(find "${BACKUP_DIR}" -name "legal_ai_*" | wc -l) +echo "[$(date)] Total backups: ${COUNT}" diff --git a/scripts/restore-db.sh b/scripts/restore-db.sh new file mode 100755 index 0000000..19c6186 --- /dev/null +++ b/scripts/restore-db.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# שחזור מסד נתונים מגיבוי +# Usage: ./restore-db.sh + +set -euo pipefail + +if [ $# -lt 1 ]; then + echo "שימוש: ./restore-db.sh " + echo "" + echo "גיבויים זמינים:" + ls -lhrt /home/chaim/legal-ai/data/backups/legal_ai_* 2>/dev/null || echo " (אין גיבויים)" + exit 1 +fi + +BACKUP_FILE="$1" + +if [ ! -f "${BACKUP_FILE}" ]; then + echo "קובץ לא נמצא: ${BACKUP_FILE}" + exit 1 +fi + +# Load DB credentials +source "${HOME}/.env" 2>/dev/null || true + +DB_HOST="${POSTGRES_HOST:-127.0.0.1}" +DB_PORT="${POSTGRES_PORT:-5433}" +DB_NAME="${POSTGRES_DB:-legal_ai}" +DB_USER="${POSTGRES_USER:-legal_ai}" + +echo "⚠️ שחזור מגיבוי: ${BACKUP_FILE}" +echo " מסד נתונים: ${DB_NAME}@${DB_HOST}:${DB_PORT}" +echo "" +read -p "האם להמשיך? (y/N) " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "בוטל." + exit 0 +fi + +# Determine file format +if [[ "${BACKUP_FILE}" == *.gz ]]; then + echo "[$(date)] Decompressing..." + gunzip -k "${BACKUP_FILE}" + RESTORE_FILE="${BACKUP_FILE%.gz}" +else + RESTORE_FILE="${BACKUP_FILE}" +fi + +echo "[$(date)] Restoring..." + +PGPASSWORD="${POSTGRES_PASSWORD:-}" pg_restore \ + -h "${DB_HOST}" \ + -p "${DB_PORT}" \ + -U "${DB_USER}" \ + -d "${DB_NAME}" \ + --clean \ + --if-exists \ + --no-owner \ + "${RESTORE_FILE}" 2>&1 || true + +echo "[$(date)] ✅ שחזור הושלם" + +# Verify +PGPASSWORD="${POSTGRES_PASSWORD:-}" psql \ + -h "${DB_HOST}" \ + -p "${DB_PORT}" \ + -U "${DB_USER}" \ + -d "${DB_NAME}" \ + -c "SELECT COUNT(*) as cases FROM cases; SELECT COUNT(*) as documents FROM documents;" 2>/dev/null || true