From c619c22a51e7865713e672e6d087abba4e2cc10a Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 18 Apr 2026 13:28:04 +0000 Subject: [PATCH] =?UTF-8?q?Add=20pre-ruling=20interim=20draft=20(=D7=98?= =?UTF-8?q?=D7=99=D7=95=D7=98=D7=AA=20=D7=91=D7=99=D7=A0=D7=99=D7=99=D7=9D?= =?UTF-8?q?)=20for=20appeals=20committee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the chair generate a partial decision DOCX before the discussion-and- ruling block is decided. Same template, skill and DOCX styling as the final decision (David, RTL, bookmarks) — only the block selection and order differ: רקע (ו) → תכניות+היתרים (ט) → טענות (ז) → הליכים (ח). The opening (ה), ruling (י), summary (יא), and signatures (יב) are omitted. - New appraiser_facts table + CRUD + conflict detection in db.py (V5 schema). Conflict = same plan/permit identifier reported differently by 2+ appraisers. - New appraiser_facts_extractor service: per-appraisal Claude extraction of plans + permits with raw quotes and page numbers. - block-tet prompt extended with a permits sub-section sourced from the extracted facts, plus an explicit instruction to flag inter-appraiser conflicts in neutral wording without resolving them (deferred to block-yod). - block-chet prompt extended with a post-hearing materials context sourced from documents.metadata.is_post_hearing. - docx_exporter.export_decision now accepts mode='interim' which reorders the blocks per the chair's mental model and writes טיוטת-ביניים-v{N}.docx (versioned independently of regular drafts). - 3 new MCP tools: extract_appraiser_facts, write_interim_draft, export_interim_draft. write_interim_draft auto-runs extraction if the appraiser_facts table is empty for the case. --- docs/block-schema.md | 52 +++++ mcp-server/src/legal_mcp/server.py | 18 ++ .../services/appraiser_facts_extractor.py | 205 ++++++++++++++++++ .../src/legal_mcp/services/block_writer.py | 152 ++++++++++++- mcp-server/src/legal_mcp/services/db.py | 140 +++++++++++- .../src/legal_mcp/services/docx_exporter.py | 57 ++++- mcp-server/src/legal_mcp/tools/drafting.py | 124 +++++++++++ 7 files changed, 731 insertions(+), 17 deletions(-) create mode 100644 mcp-server/src/legal_mcp/services/appraiser_facts_extractor.py diff --git a/docs/block-schema.md b/docs/block-schema.md index 028780b..2b4ddd1 100644 --- a/docs/block-schema.md +++ b/docs/block-schema.md @@ -573,3 +573,55 @@ Conclusion → Rule → Explanation → Application → Conclusion. יא (סיכום) → תלוי ב: י (מסקנות). מפנה ל: י בלבד. יב (חתימות) → עצמאי ``` + +--- + +## 7. טיוטת ביניים (Pre-Ruling Draft) + +ועדת הערר לעיתים מבקשת לראות טיוטה חלקית **לפני** שהוועדה מכריעה — כאשר התיק +לא מגובש או יש מחלוקת בין חברי הוועדה. הטיוטה משמשת בסיס לדיון פנימי לקראת +פרק הדיון וההכרעה. + +### מבנה טיוטת הביניים + +המסמך משתמש **באותו טמפלט, אותו skill ואותם prompts** של החלטה רגילה (David +12pt, RTL, bookmarks). השוני היחיד הוא בחירת הבלוקים וסידורם: + +| מקום | בלוק | תפקיד | +|------|------|-------| +| 1 (אופציונלי) | א-ד | העמוד הראשון. נכלל אם יש תוכן, ולא נדרש שיהיה. | +| 2 | **ו (רקע עובדתי)** | פתח דבר — מקרקעין, סביבה, היסטוריה, החלטה, ערר | +| 3 | **ט (תכניות + היתרים)** | פירוט התכניות החלות **+ תת-פרק היתרים מהשומות**, עם סימון סתירות בין שמאים | +| 4 | **ז (טענות הצדדים)** | תמצית טענות העוררים, הוועדה ומבקשי ההיתר | +| 5 | **ח (הליכים)** | דיון בפני הוועדה, נקודות חדשות שעלו, **השלמות טיעון ומשא-ומתן לפשרה** | + +הבלוקים שמדולגים: ה (פתיחה), י (דיון והכרעה), יא (סיכום), יב (חתימות). + +### עובדות שמאיות וזיהוי סתירות + +בטיוטת ביניים, בלוק ט מורחב לכלול תת-פרק היתרים. המקור הוא טבלת +`appraiser_facts` ב-DB, שמתמלאת ע"י `extract_appraiser_facts` — הפועל על +מסמכים מסוג `appraisal` ומחלץ לכל שמאי בנפרד את התכניות וההיתרים שציין. + +זיהוי סתירות נעשה ב-DB: כל זיהוי שצוין ע"י **שני שמאים שונים או יותר** נחשב +סתירה, ומועבר אל ה-prompt של בלוק ט בנוסח structured. ה-prompt מורה לסמן את +הסתירה במפורש, בנוסח ניטרלי (לדוגמה: "יצוין כי השמאי X ציין... בעוד השמאי Y +סבר כי..."), בלי להכריע בה — ההכרעה תתבצע (אם בכלל) בבלוק י של הטיוטה +הסופית. + +### מסמכי פוסט-דיון + +בלוק ח מקבל בקונטקסט גם רשימת מסמכים שתויגו כ-`metadata.is_post_hearing=true` +(השלמות טיעון, הצעות פשרה). תיוג זה נעשה בעת ההעלאה (UI/API). + +### Pipeline + +``` +1. extract_appraiser_facts(case_number) # ממלא appraiser_facts + מזהה סתירות +2. write_interim_draft(case_number) # כותב blocks ו, ט, ז, ח (ב-DB) +3. export_interim_draft(case_number) # מייצר טיוטת-ביניים-v{N}.docx +``` + +`write_interim_draft` מריץ אוטומטית את `extract_appraiser_facts` אם הטבלה +ריקה. הקובץ הסופי נרשם כ-`active_draft_path` בדיוק כמו טיוטה רגילה, ולכן +`apply_user_edit` ו-`revise_draft` עובדים עליו ללא שינוי. diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 8f81aae..bfea3a3 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -321,6 +321,24 @@ async def export_docx(case_number: str, output_path: str = "") -> str: return await drafting.export_docx(case_number, output_path) +@mcp.tool() +async def extract_appraiser_facts(case_number: str) -> str: + """חילוץ תכניות והיתרים מכל השומות בתיק וזיהוי סתירות בין שמאים. הכנה לטיוטת ביניים.""" + return await drafting.extract_appraiser_facts(case_number) + + +@mcp.tool() +async def write_interim_draft(case_number: str, instructions: str = "") -> str: + """כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט.""" + return await drafting.write_interim_draft(case_number, instructions) + + +@mcp.tool() +async def export_interim_draft(case_number: str, output_path: str = "") -> str: + """ייצוא טיוטת ביניים ל-DOCX — סדר חדש (רקע → תכניות+היתרים → טענות → הליכים), ללא דיון/סיכום.""" + return await drafting.export_interim_draft(case_number, output_path) + + @mcp.tool() async def apply_user_edit(case_number: str, edit_filename: str) -> str: """רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר.""" diff --git a/mcp-server/src/legal_mcp/services/appraiser_facts_extractor.py b/mcp-server/src/legal_mcp/services/appraiser_facts_extractor.py new file mode 100644 index 0000000..68a43a9 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/appraiser_facts_extractor.py @@ -0,0 +1,205 @@ +"""חילוץ עובדות מובנות משומות שמאי: תכניות חלות והיתרים שניתנו במקרקעין. + +תכלית: לבנות את תת-פרק ההיתרים בבלוק ט (תכניות חלות) של ההחלטה, ובמיוחד +לאפשר זיהוי אוטומטי של סתירות בין שמאים שונים על אותו זיהוי (תכנית או היתר). + +שמירה ב-DB: טבלת appraiser_facts (case_id, document_id, appraiser_name, +fact_type, identifier, details JSONB, page_number). +""" + +from __future__ import annotations + +import logging +from uuid import UUID + +from legal_mcp.services import claude_session, db + +logger = logging.getLogger(__name__) + + +EXTRACT_FACTS_PROMPT = """אתה מנתח שומות מקרקעין לטובת ועדת ערר לתכנון ובניה. + +תפקידך: לחלץ מתוך השומה שתי קטגוריות של עובדות אובייקטיביות שעליהן השמאי מבסס את חוות דעתו: +1. **תכניות חלות** — כל תכנית/תמ"א/תב"ע/תכנית מתאר/תכנית מפורטת שצוינה כתקפה על המקרקעין. +2. **היתרים** — כל היתר בנייה/היתר שימוש/היתר חורג שצוין כאילו ניתן (או שלא ניתן) במקרקעין. + +## כללים +- חילוץ עובדתי בלבד — לא לפרש, לא להסיק, לא להעתיק טיעונים משפטיים. רק העובדה היבשה שהשמאי מציין. +- שמור על נאמנות מוחלטת לזיהוי כפי שמופיע במקור (למשל "תמ"א 38" ולא "תמא 38" או "תכנית מתאר ארצית 38"). +- אם השמאי מזכיר אותה תכנית/היתר מספר פעמים — החזר רשומה אחת מאוחדת. +- אם יש סתירה פנימית בשומה (השמאי כותב דבר אחד ואז את ההיפך) — שתי רשומות נפרדות. +- ציטוט המקור (raw_quote) חייב להיות העתקה מילולית של המשפט הרלוונטי, עד 200 תווים. + +## פלט +החזר JSON array בלבד — ללא markdown, ללא הסברים: +[ + { + "fact_type": "plan" | "permit", + "identifier": "תמ\\"א 38" | "היתר 2018/0123", + "details": { + "date": "תאריך אישור/הוצאה אם צוין, אחרת ריק", + "scope": "תיאור היקף/שימוש/זכויות בנייה — בקצרה", + "conditions": "תנאים מיוחדים אם צוינו", + "status": "תקף / פקע / מבוטל / לא צוין", + "raw_quote": "ציטוט מילולי מהשומה" + }, + "page_number": null + } +] + +אם אין תכניות או היתרים בשומה — החזר []. +""" + + +def _chunk_text(text: str, max_chars: int = 25000) -> list[str]: + """Split a long document at paragraph boundaries.""" + if len(text) <= max_chars: + return [text] + chunks: list[str] = [] + pos = 0 + while pos < len(text): + end = min(pos + max_chars, len(text)) + if end < len(text): + break_pos = text.rfind("\n\n", pos, end) + if break_pos > pos + max_chars // 2: + end = break_pos + chunks.append(text[pos:end]) + pos = end + return chunks + + +def _normalize_identifier(identifier: str) -> str: + """Light normalization so trivial spacing differences don't mask conflicts.""" + return " ".join(identifier.strip().split()) + + +async def extract_facts_from_document( + case_id: UUID, + document_id: UUID, + appraiser_name: str, + text: str, +) -> list[dict]: + """Extract structured facts from a single appraisal document via Claude Code.""" + chunks = _chunk_text(text) + all_facts: list[dict] = [] + + for i, chunk in enumerate(chunks): + chunk_label = f" (חלק {i+1}/{len(chunks)})" if len(chunks) > 1 else "" + prompt = ( + f"{EXTRACT_FACTS_PROMPT}\n\n" + f"שמאי: {appraiser_name}{chunk_label}\n\n" + f"--- תחילת שומה ---\n{chunk}\n--- סוף שומה ---" + ) + result = claude_session.query_json(prompt, timeout=180) + if not isinstance(result, list): + logger.warning( + "extract_facts_from_document: chunk %d returned non-list (%s) for doc=%s", + i, type(result).__name__, document_id, + ) + continue + for item in result: + if not isinstance(item, dict): + continue + if item.get("fact_type") not in ("plan", "permit"): + continue + ident = item.get("identifier", "").strip() + if not ident: + continue + all_facts.append({ + "appraiser_name": appraiser_name, + "fact_type": item["fact_type"], + "identifier": _normalize_identifier(ident), + "details": item.get("details") or {}, + "page_number": item.get("page_number"), + }) + + if all_facts: + await db.replace_appraiser_facts(case_id, document_id, all_facts) + else: + await db.replace_appraiser_facts(case_id, document_id, []) + return all_facts + + +def _infer_appraiser_name(doc: dict) -> str: + """Best-effort extraction of the appraiser's name from document title/metadata.""" + metadata = doc.get("metadata") or {} + name = metadata.get("appraiser_name") if isinstance(metadata, dict) else None + if name: + return name + title = doc.get("title", "") + return title or f"שמאי (מסמך {doc.get('id', '')[:8]})" + + +async def extract_appraiser_facts(case_id: UUID) -> dict: + """Extract facts from every appraisal document in the case + detect conflicts. + + Returns a summary dict ready for serialization back to the caller. + """ + docs = await db.list_documents(case_id) + appraisals = [d for d in docs if d.get("doc_type") == "appraisal"] + + if not appraisals: + return { + "status": "no_appraisals", + "appraisal_count": 0, + "total_facts": 0, + "conflicts": [], + } + + by_doc = [] + total_facts = 0 + for doc in appraisals: + text = await db.get_document_text(UUID(doc["id"])) + if not text: + by_doc.append({ + "document_id": doc["id"], + "title": doc.get("title", ""), + "status": "no_text", + "facts_extracted": 0, + }) + continue + + appraiser_name = _infer_appraiser_name(doc) + try: + facts = await extract_facts_from_document( + case_id=case_id, + document_id=UUID(doc["id"]), + appraiser_name=appraiser_name, + text=text, + ) + except Exception as e: + logger.exception("Failed to extract facts for document %s", doc["id"]) + by_doc.append({ + "document_id": doc["id"], + "title": doc.get("title", ""), + "status": "error", + "error": str(e), + "facts_extracted": 0, + }) + continue + + total_facts += len(facts) + by_doc.append({ + "document_id": doc["id"], + "title": doc.get("title", ""), + "appraiser_name": appraiser_name, + "status": "completed", + "facts_extracted": len(facts), + "plans": sum(1 for f in facts if f["fact_type"] == "plan"), + "permits": sum(1 for f in facts if f["fact_type"] == "permit"), + }) + + conflicts = await db.detect_appraiser_conflicts(case_id) + + return { + "status": "completed", + "appraisal_count": len(appraisals), + "total_facts": total_facts, + "conflicts": conflicts, + "by_document": by_doc, + } + + +async def detect_conflicts(case_id: UUID) -> list[dict]: + """Convenience wrapper around db.detect_appraiser_conflicts.""" + return await db.detect_appraiser_conflicts(case_id) diff --git a/mcp-server/src/legal_mcp/services/block_writer.py b/mcp-server/src/legal_mcp/services/block_writer.py index e5070c5..e1549ff 100644 --- a/mcp-server/src/legal_mcp/services/block_writer.py +++ b/mcp-server/src/legal_mcp/services/block_writer.py @@ -165,9 +165,10 @@ BLOCK_PROMPTS = { "block-chet": """כתוב את בלוק ההליכים (בלוק ח, "ההליכים בפני ועדת הערר") של החלטת ועדת ערר. ## כללים: -- תיעוד כרונולוגי: דיון → סיור → השלמות טיעון → החלטות ביניים +- תיעוד כרונולוגי: דיון → סיור → השלמות טיעון → משא-ומתן לפשרה (אם היה) → החלטות ביניים - תאריכים מדויקים -- תוכן כל השלמת טיעון בסעיף נפרד +- אם בדיון עלו נקודות חדשות או הובהרו סוגיות משפטיות — ציין זאת במפורש בסעיף נפרד +- תוכן כל השלמת טיעון/הצעת פשרה בסעיף נפרד עם תאריך - סמן תמונות מסיור: [📷 צילום מסיור] - אין ניתוח או הערכה - מספור רציף @@ -175,24 +176,41 @@ BLOCK_PROMPTS = { ## פרטי התיק: {case_context} +## מסמכים שהוגשו לאחר הדיון (אם יש): +{post_hearing_context} + ## חומרי מקור: {source_context}""", - "block-tet": """כתוב את בלוק התכניות החלות (בלוק ט) של החלטת ועדת ערר. + "block-tet": """כתוב את בלוק התכניות החלות (בלוק ט) של החלטת ועדת ערר, **כולל תת-פרק היתרים**. -## כללים: -- ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות -- מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות +## מבנה נדרש: +1. **תכניות חלות** — מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות. ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות. +2. **תת-פרק היתרים** — כותרת משנה "היתרים" (או "היתרי בנייה שניתנו במקרקעין"). פירוט ההיתרים הרלוונטיים על פי השומות שהוגשו לתיק. + +## כללי ציון סתירות בין שמאים (קריטי): +- אם שני שמאים או יותר מסרו מידע שונה על אותה תכנית או היתר — חובה לסמן זאת במפורש בנוסח ניטרלי, למשל: + > "יצוין כי השמאי X ציין כי תכנית פלונית חלה על המקרקעין במלואה, בעוד השמאי Y סבר כי חלקה של התכנית בלבד חל" +- אין להכריע בסתירה בבלוק זה — ההכרעה (אם נדרשת) תבוא בבלוק י. +- אם אין סתירה — אין להזכיר זאת. + +## כללים נוספים: - אין ניתוח מעמיק (→ בלוק י), אין הכרעה בין פרשנויות - מספור רציף -- בלוק אופציונלי — כתוב רק אם יש מורכבות תכנונית +- אם אין שומות בתיק — דווח רק על תכניות שזוהו ממסמכים אחרים, וציין במשפט אחד שלא הוגשו שומות ## פרטי התיק: {case_context} -## תכניות שזוהו: +## תכניות שזוהו (ממטא-דאטה של מסמכים): {plans_context} +## עובדות שמאיות שחולצו (תכניות + היתרים, פרק לכל שמאי): +{appraiser_facts_context} + +## סתירות שזוהו בין שמאים (חובה לסמן בנוסח): +{appraiser_conflicts_context} + ## חומרי מקור: {source_context}""", @@ -301,6 +319,9 @@ async def write_block( 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) + appraiser_facts_context = await _build_appraiser_facts_context(case_id) + appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id) + post_hearing_context = await _build_post_hearing_context(case_id) outcome = (decision or {}).get("outcome", "rejected") structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "") @@ -332,6 +353,9 @@ async def write_block( structure_guidance=structure_guidance, content_checklist=content_checklist, methodology_guidance=methodology_guidance, + appraiser_facts_context=appraiser_facts_context, + appraiser_conflicts_context=appraiser_conflicts_context, + post_hearing_context=post_hearing_context, ) # Restructure: sources first, then instructions @@ -478,6 +502,112 @@ async def _build_plans_context(case_id: UUID) -> str: return "(לא זוהו תכניות)" +async def _build_appraiser_facts_context(case_id: UUID) -> str: + """Group appraiser_facts by appraiser, then list each appraiser's plans+permits.""" + facts = await db.list_appraiser_facts(case_id) + if not facts: + return "(לא חולצו עובדות שמאיות. הרץ extract_appraiser_facts.)" + + by_appraiser: dict[str, dict[str, list[dict]]] = {} + for f in facts: + bucket = by_appraiser.setdefault(f["appraiser_name"], {"plan": [], "permit": []}) + bucket[f["fact_type"]].append(f) + + lines: list[str] = [] + for name in sorted(by_appraiser.keys()): + lines.append(f"\n### {name}") + for label, key in (("תכניות", "plan"), ("היתרים", "permit")): + items = by_appraiser[name][key] + if not items: + continue + lines.append(f"**{label}:**") + for item in items: + details = item.get("details") or {} + ident = item["identifier"] + scope = (details.get("scope") or "").strip() + date_s = (details.get("date") or "").strip() + status = (details.get("status") or "").strip() + quote = (details.get("raw_quote") or "").strip() + bits = [ident] + if date_s: + bits.append(f"תאריך: {date_s}") + if status: + bits.append(f"סטטוס: {status}") + if scope: + bits.append(f"היקף: {scope}") + line = " | ".join(bits) + if quote: + line += f"\n ציטוט: \"{quote[:200]}\"" + lines.append(f"- {line}") + return "\n".join(lines) + + +async def _build_appraiser_conflicts_context(case_id: UUID) -> str: + """Render conflict groups so the prompt can quote them in the body.""" + conflicts = await db.detect_appraiser_conflicts(case_id) + if not conflicts: + return "(אין סתירות בין שמאים)" + + type_label = {"plan": "תכנית", "permit": "היתר"} + lines: list[str] = [] + for c in conflicts: + lines.append( + f"\n### סתירה — {type_label.get(c['fact_type'], c['fact_type'])}: {c['identifier']}" + ) + for entry in c["entries"]: + details = entry.get("details") or {} + scope = (details.get("scope") or "").strip() + status = (details.get("status") or "").strip() + quote = (details.get("raw_quote") or "").strip() + parts = [f"**{entry['appraiser_name']}**"] + if status: + parts.append(f"סטטוס: {status}") + if scope: + parts.append(f"היקף: {scope}") + line = " | ".join(parts) + if quote: + line += f"\n ציטוט: \"{quote[:200]}\"" + lines.append(f"- {line}") + return "\n".join(lines) + + +async def _build_post_hearing_context(case_id: UUID) -> str: + """List documents flagged as submitted after the hearing. + + Convention: documents.metadata.is_post_hearing == True. + """ + docs = await db.list_documents(case_id) + items: list[dict] = [] + for d in docs: + meta = d.get("metadata") or {} + if isinstance(meta, str): + meta = json.loads(meta) + if not meta.get("is_post_hearing"): + continue + items.append({ + "title": d.get("title", ""), + "doc_type": d.get("doc_type", ""), + "submitted_on": meta.get("submitted_on", ""), + "kind": meta.get("post_hearing_kind", ""), # "supplementary_brief" | "settlement_proposal" | ... + }) + + if not items: + return "(לא הוגשו מסמכים לאחר הדיון, או שהם לא סומנו כ-post_hearing)" + + lines: list[str] = [] + for it in items: + meta_bits = [] + if it["submitted_on"]: + meta_bits.append(f"הוגש: {it['submitted_on']}") + if it["kind"]: + meta_bits.append(f"סוג: {it['kind']}") + if it["doc_type"]: + meta_bits.append(f"doc_type={it['doc_type']}") + meta_str = f" ({', '.join(meta_bits)})" if meta_bits else "" + lines.append(f"- {it['title']}{meta_str}") + return "\n".join(lines) + + async def _build_precedents_context(case_id: UUID, block_id: str) -> str: """Search for similar precedent paragraphs from other decisions and case law.""" parts = [] @@ -654,6 +784,9 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = "" 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) + appraiser_facts_context = await _build_appraiser_facts_context(case_id) + appraiser_conflicts_context = await _build_appraiser_conflicts_context(case_id) + post_hearing_context = await _build_post_hearing_context(case_id) outcome = (decision or {}).get("outcome", "rejected") structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "") @@ -681,6 +814,9 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = "" structure_guidance=structure_guidance, content_checklist=content_checklist, methodology_guidance=methodology_guidance, + appraiser_facts_context=appraiser_facts_context, + appraiser_conflicts_context=appraiser_conflicts_context, + post_hearing_context=post_hearing_context, ) if instructions: diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 656ac32..ccbf365 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -469,6 +469,34 @@ CREATE INDEX IF NOT EXISTS idx_case_law_level ON case_law(precedent_level); """ +# ── Phase 5: Interim draft (appraiser facts + post-hearing flag) ─── + +SCHEMA_V5_SQL = """ + +-- appraiser_facts: תכניות והיתרים שצוינו ע"י כל שמאי בנפרד. +-- בשונה מ-claims (שהוא טענה משפטית), כאן מאוחסנת עובדה עניינית מתוך השומה. +-- שימוש ראשי: זיהוי סתירות בין שמאים על איזו תכנית או היתר חל בנכס. +CREATE TABLE IF NOT EXISTS appraiser_facts ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + case_id UUID NOT NULL REFERENCES cases(id) ON DELETE CASCADE, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + appraiser_name TEXT NOT NULL, + fact_type TEXT NOT NULL CHECK (fact_type IN ('plan', 'permit')), + identifier TEXT NOT NULL, + details JSONB NOT NULL DEFAULT '{}', + page_number INTEGER, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_appraiser_facts_case ON appraiser_facts(case_id, fact_type); +CREATE INDEX IF NOT EXISTS idx_appraiser_facts_identifier ON appraiser_facts(case_id, identifier); + +-- documents.metadata.is_post_hearing: flag for materials submitted after the hearing +-- (השלמות טיעון, הצעות פשרה). Used by block-chet to include them in the proceedings narrative. +-- No schema change needed — uses existing JSONB metadata column. +""" + + async def init_schema() -> None: pool = await get_pool() async with pool.acquire() as conn: @@ -477,7 +505,8 @@ async def init_schema() -> None: await conn.execute(SCHEMA_V2_SQL) await conn.execute(SCHEMA_V3_SQL) await conn.execute(SCHEMA_V4_SQL) - logger.info("Database schema initialized (v1 + v2 + v3 + v4)") + await conn.execute(SCHEMA_V5_SQL) + logger.info("Database schema initialized (v1 + v2 + v3 + v4 + v5)") # ── Case CRUD ─────────────────────────────────────────────────────── @@ -1293,3 +1322,112 @@ async def resolve_chair_feedback( WHERE id = $1""", feedback_id, applied_to, ) + + +# ── Appraiser facts (V5 — for interim drafts) ───────────────────── + +async def replace_appraiser_facts( + case_id: UUID, + document_id: UUID, + facts: list[dict], +) -> int: + """Replace all appraiser_facts for a given document. + + Each fact dict: appraiser_name, fact_type ('plan'|'permit'), + identifier, details (dict), page_number (optional). + """ + pool = await get_pool() + async with pool.acquire() as conn: + async with conn.transaction(): + await conn.execute( + "DELETE FROM appraiser_facts WHERE document_id = $1", document_id, + ) + for f in facts: + await conn.execute( + """INSERT INTO appraiser_facts + (case_id, document_id, appraiser_name, fact_type, + identifier, details, page_number) + VALUES ($1, $2, $3, $4, $5, $6, $7)""", + case_id, document_id, + f["appraiser_name"], + f["fact_type"], + f["identifier"], + json.dumps(f.get("details", {}), ensure_ascii=False), + f.get("page_number"), + ) + return len(facts) + + +async def list_appraiser_facts( + case_id: UUID, + fact_type: str | None = None, +) -> list[dict]: + """List appraiser_facts for a case, optionally filtered by fact_type.""" + pool = await get_pool() + async with pool.acquire() as conn: + if fact_type: + rows = await conn.fetch( + """SELECT * FROM appraiser_facts + WHERE case_id = $1 AND fact_type = $2 + ORDER BY identifier, appraiser_name""", + case_id, fact_type, + ) + else: + rows = await conn.fetch( + """SELECT * FROM appraiser_facts + WHERE case_id = $1 + ORDER BY fact_type, identifier, appraiser_name""", + case_id, + ) + results = [] + for r in rows: + d = dict(r) + d["id"] = str(d["id"]) + d["case_id"] = str(d["case_id"]) + d["document_id"] = str(d["document_id"]) + if isinstance(d.get("details"), str): + d["details"] = json.loads(d["details"]) + results.append(d) + return results + + +async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]: + """Detect conflicts: identifiers cited by 2+ different appraisers in this case. + + A conflict exists when the SAME identifier (e.g., "תמ"א 38") was reported + differently by two appraisers — different details, or one cited it and the + other did not. Returns list of conflict groups. + """ + pool = await get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch( + """SELECT identifier, fact_type, + json_agg(jsonb_build_object( + 'appraiser_name', appraiser_name, + 'details', details, + 'page_number', page_number, + 'document_id', document_id + ) ORDER BY appraiser_name) AS entries, + COUNT(DISTINCT appraiser_name) AS n_appraisers + FROM appraiser_facts + WHERE case_id = $1 + GROUP BY identifier, fact_type + HAVING COUNT(DISTINCT appraiser_name) > 1""", + case_id, + ) + conflicts = [] + for r in rows: + entries = r["entries"] + if isinstance(entries, str): + entries = json.loads(entries) + # Parse nested details if still strings + for e in entries: + if isinstance(e.get("details"), str): + e["details"] = json.loads(e["details"]) + conflicts.append({ + "identifier": r["identifier"], + "fact_type": r["fact_type"], + "n_appraisers": r["n_appraisers"], + "entries": entries, + }) + return conflicts diff --git a/mcp-server/src/legal_mcp/services/docx_exporter.py b/mcp-server/src/legal_mcp/services/docx_exporter.py index 1022d86..be50a8f 100644 --- a/mcp-server/src/legal_mcp/services/docx_exporter.py +++ b/mcp-server/src/legal_mcp/services/docx_exporter.py @@ -168,16 +168,46 @@ def _add_image_placeholder(doc, description: str) -> None: # ── Main export ─────────────────────────────────────────────────── -async def export_decision(case_id: UUID, output_path: str | None = None) -> str: +# Order in which blocks are emitted for each export mode. +# 'final' = standard 12-block decision in canonical order (block_index). +# 'interim' = pre-ruling draft requested by the chair before ratio decidendi +# is set: רקע → תכניות+היתרים → טענות → הליכים, omitting opening (ה), +# ruling (י), summary (יא), and signatures (יב). +_INTERIM_BLOCK_ORDER = [ + "block-alef", # institutional header (skipped if empty — first page optional) + "block-bet", # panel (skipped if empty) + "block-gimel", # parties (skipped if empty) + "block-dalet", # "החלטה" title (skipped if empty) + "block-vav", # רקע עובדתי + "block-tet", # תכניות + היתרים (extended) + "block-zayin", # טענות הצדדים + "block-chet", # הליכים (incl. post-hearing) +] + + +def _draft_filename_prefix(mode: str) -> str: + return "טיוטת-ביניים" if mode == "interim" else "טיוטה" + + +async def export_decision( + case_id: UUID, + output_path: str | None = None, + mode: str = "final", +) -> str: """ייצוא החלטה ל-DOCX. Args: case_id: מזהה התיק output_path: נתיב לשמירה (אופציונלי) + mode: 'final' (ברירת מחדל) או 'interim' (טיוטת ביניים — ללא + דיון/סיכום/חתימות, סדר חדש: רקע → תכניות+היתרים → טענות → הליכים) Returns: נתיב הקובץ שנוצר """ + if mode not in ("final", "interim"): + raise ValueError(f"Unknown export mode: {mode}") + case = await db.get_case(case_id) if not case: raise ValueError(f"Case {case_id} not found") @@ -189,7 +219,7 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str: # Get blocks pool = await db.get_pool() async with pool.acquire() as conn: - blocks = await conn.fetch( + rows = await conn.fetch( """SELECT block_id, block_index, title, content, word_count FROM decision_blocks WHERE decision_id = $1 @@ -197,9 +227,20 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str: UUID(decision["id"]), ) - if not blocks: + if not rows: raise ValueError("No blocks in decision") + by_id = {r["block_id"]: r for r in rows} + + if mode == "interim": + ordered_blocks = [by_id[bid] for bid in _INTERIM_BLOCK_ORDER if bid in by_id] + if not ordered_blocks: + raise ValueError( + "אין בלוקים מתאימים לטיוטת ביניים. הרץ write_interim_draft קודם." + ) + else: + ordered_blocks = list(rows) + # Create document doc = Document() @@ -213,7 +254,7 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str: # Write blocks with bookmarks wrapping each block (anchors for revisions) bm_counter = [_BOOKMARK_ID_START] - for block in blocks: + for block in ordered_blocks: block_id = block["block_id"] content = block["content"] or "" if not content.strip(): @@ -232,8 +273,8 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str: if not output_path: export_dir = config.find_case_dir(case["case_number"]) / "exports" export_dir.mkdir(parents=True, exist_ok=True) - # Find next version number - existing = sorted(export_dir.glob("טיוטה-v*.docx")) + prefix = _draft_filename_prefix(mode) + existing = sorted(export_dir.glob(f"{prefix}-v*.docx")) next_ver = 1 for p in existing: try: @@ -241,11 +282,11 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str: next_ver = max(next_ver, ver + 1) except (IndexError, ValueError): pass - output_path = str(export_dir / f"טיוטה-v{next_ver}.docx") + output_path = str(export_dir / f"{prefix}-v{next_ver}.docx") Path(output_path).parent.mkdir(parents=True, exist_ok=True) doc.save(output_path) - logger.info("DOCX exported: %s", output_path) + logger.info("DOCX exported (mode=%s): %s", mode, output_path) return output_path diff --git a/mcp-server/src/legal_mcp/tools/drafting.py b/mcp-server/src/legal_mcp/tools/drafting.py index e70b7a2..81a69f1 100644 --- a/mcp-server/src/legal_mcp/tools/drafting.py +++ b/mcp-server/src/legal_mcp/tools/drafting.py @@ -416,6 +416,130 @@ async def export_docx(case_number: str, output_path: str = "") -> str: }, ensure_ascii=False, indent=2) +# ── Interim draft (pre-ruling) ──────────────────────────────────── + +# Blocks written for the interim draft, in display order. +# This is the same content the chair sees in the final decision (same template, +# same skill, same prompts) — minus opening, ruling, summary, signatures. +_INTERIM_BLOCKS = ["block-vav", "block-tet", "block-zayin", "block-chet"] + + +async def extract_appraiser_facts(case_number: str) -> str: + """חילוץ תכניות והיתרים מכל השומות בתיק וזיהוי סתירות בין שמאים. + + משמש כהכנה לטיוטת ביניים: בלוק ט (תכניות חלות) זקוק לעובדות מובנות + כדי לפרט תת-פרק היתרים ולסמן סתירות בנוסח ניטרלי. + + Args: + case_number: מספר תיק הערר + """ + from legal_mcp.services import appraiser_facts_extractor + + case = await db.get_case_by_number(case_number) + if not case: + return json.dumps({"status": "error", + "message": f"תיק {case_number} לא נמצא."}, + ensure_ascii=False, indent=2) + case_id = UUID(case["id"]) + try: + result = await appraiser_facts_extractor.extract_appraiser_facts(case_id) + return json.dumps(result, default=str, ensure_ascii=False, indent=2) + except Exception as e: + return json.dumps({"status": "error", "message": str(e)}, + ensure_ascii=False, indent=2) + + +async def write_interim_draft(case_number: str, instructions: str = "") -> str: + """כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט), + טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין — + מריץ extract_appraiser_facts קודם כדי שבלוק ט יקבל פרק היתרים תקף. + + הבלוקים נכתבים באותו skill, אותם prompts ואותו טמפלט כמו בטיוטה רגילה — + הסדר משתנה רק בעת הייצוא ל-DOCX (ראה export_interim_draft). + + Args: + case_number: מספר תיק הערר + instructions: הנחיות נוספות (לכל הבלוקים) + """ + from legal_mcp.services import appraiser_facts_extractor, block_writer + + case = await db.get_case_by_number(case_number) + if not case: + return json.dumps({"status": "error", + "message": f"תיק {case_number} לא נמצא."}, + ensure_ascii=False, indent=2) + case_id = UUID(case["id"]) + + # Make sure appraiser facts exist before writing block-tet (which depends on them). + facts = await db.list_appraiser_facts(case_id) + facts_run: dict | None = None + if not facts: + try: + facts_run = await appraiser_facts_extractor.extract_appraiser_facts(case_id) + except Exception as e: + facts_run = {"status": "error", "message": str(e)} + + results = [] + for bid in _INTERIM_BLOCKS: + try: + r = await block_writer.write_and_store_block(case_id, bid, instructions) + results.append({ + "block_id": bid, + "title": r["title"], + "word_count": r["word_count"], + "status": "completed", + }) + except Exception as e: + results.append({ + "block_id": bid, + "status": "error", + "error": str(e), + }) + + return json.dumps({ + "status": "completed", + "blocks": results, + "appraiser_facts_run": facts_run, + "total_words": sum(r.get("word_count", 0) for r in results), + "completed": sum(1 for r in results if r["status"] == "completed"), + }, default=str, ensure_ascii=False, indent=2) + + +async def export_interim_draft(case_number: str, output_path: str = "") -> str: + """ייצוא טיוטת ביניים ל-DOCX — אותו עיצוב של טיוטה רגילה (David, RTL, + bookmarks), אבל בסדר חדש: רקע → תכניות+היתרים → טענות → הליכים, ללא + דיון/סיכום/חתימות. שם הקובץ: טיוטת-ביניים-v{N}.docx. + + 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 json.dumps({"status": "error", + "message": f"תיק {case_number} לא נמצא."}, + ensure_ascii=False, indent=2) + case_id = UUID(case["id"]) + + try: + path = await docx_exporter.export_decision( + case_id, output_path or None, mode="interim", + ) + await db.set_active_draft_path(case_id, path) + return json.dumps({ + "status": "completed", + "mode": "interim", + "path": path, + "active_draft_path": path, + "message": f"טיוטת ביניים נוצרה: {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 apply_user_edit(case_number: str, edit_filename: str) -> str: """רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.