Add pre-ruling interim draft (טיוטת ביניים) for appeals committee
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s

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.
This commit is contained in:
2026-04-18 13:28:04 +00:00
parent 2b40e02a65
commit c619c22a51
7 changed files with 731 additions and 17 deletions

View File

@@ -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 אם חסר."""

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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:
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.