Add pre-ruling interim draft (טיוטת ביניים) for appeals committee
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m26s
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:
@@ -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` עובדים עליו ללא שינוי.
|
||||||
|
|||||||
@@ -321,6 +321,24 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
|||||||
return await drafting.export_docx(case_number, output_path)
|
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()
|
@mcp.tool()
|
||||||
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||||
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
"""רישום עריכה שהעלה המשתמש (עריכה-v*.docx) כמקור האמת החדש — מזריק bookmarks אם חסר."""
|
||||||
|
|||||||
205
mcp-server/src/legal_mcp/services/appraiser_facts_extractor.py
Normal file
205
mcp-server/src/legal_mcp/services/appraiser_facts_extractor.py
Normal 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)
|
||||||
@@ -165,9 +165,10 @@ BLOCK_PROMPTS = {
|
|||||||
"block-chet": """כתוב את בלוק ההליכים (בלוק ח, "ההליכים בפני ועדת הערר") של החלטת ועדת ערר.
|
"block-chet": """כתוב את בלוק ההליכים (בלוק ח, "ההליכים בפני ועדת הערר") של החלטת ועדת ערר.
|
||||||
|
|
||||||
## כללים:
|
## כללים:
|
||||||
- תיעוד כרונולוגי: דיון → סיור → השלמות טיעון → החלטות ביניים
|
- תיעוד כרונולוגי: דיון → סיור → השלמות טיעון → משא-ומתן לפשרה (אם היה) → החלטות ביניים
|
||||||
- תאריכים מדויקים
|
- תאריכים מדויקים
|
||||||
- תוכן כל השלמת טיעון בסעיף נפרד
|
- אם בדיון עלו נקודות חדשות או הובהרו סוגיות משפטיות — ציין זאת במפורש בסעיף נפרד
|
||||||
|
- תוכן כל השלמת טיעון/הצעת פשרה בסעיף נפרד עם תאריך
|
||||||
- סמן תמונות מסיור: [📷 צילום מסיור]
|
- סמן תמונות מסיור: [📷 צילום מסיור]
|
||||||
- אין ניתוח או הערכה
|
- אין ניתוח או הערכה
|
||||||
- מספור רציף
|
- מספור רציף
|
||||||
@@ -175,24 +176,41 @@ BLOCK_PROMPTS = {
|
|||||||
## פרטי התיק:
|
## פרטי התיק:
|
||||||
{case_context}
|
{case_context}
|
||||||
|
|
||||||
|
## מסמכים שהוגשו לאחר הדיון (אם יש):
|
||||||
|
{post_hearing_context}
|
||||||
|
|
||||||
## חומרי מקור:
|
## חומרי מקור:
|
||||||
{source_context}""",
|
{source_context}""",
|
||||||
|
|
||||||
"block-tet": """כתוב את בלוק התכניות החלות (בלוק ט) של החלטת ועדת ערר.
|
"block-tet": """כתוב את בלוק התכניות החלות (בלוק ט) של החלטת ועדת ערר, **כולל תת-פרק היתרים**.
|
||||||
|
|
||||||
## כללים:
|
## מבנה נדרש:
|
||||||
- ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות
|
1. **תכניות חלות** — מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות. ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות.
|
||||||
- מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות
|
2. **תת-פרק היתרים** — כותרת משנה "היתרים" (או "היתרי בנייה שניתנו במקרקעין"). פירוט ההיתרים הרלוונטיים על פי השומות שהוגשו לתיק.
|
||||||
|
|
||||||
|
## כללי ציון סתירות בין שמאים (קריטי):
|
||||||
|
- אם שני שמאים או יותר מסרו מידע שונה על אותה תכנית או היתר — חובה לסמן זאת במפורש בנוסח ניטרלי, למשל:
|
||||||
|
> "יצוין כי השמאי X ציין כי תכנית פלונית חלה על המקרקעין במלואה, בעוד השמאי Y סבר כי חלקה של התכנית בלבד חל"
|
||||||
|
- אין להכריע בסתירה בבלוק זה — ההכרעה (אם נדרשת) תבוא בבלוק י.
|
||||||
|
- אם אין סתירה — אין להזכיר זאת.
|
||||||
|
|
||||||
|
## כללים נוספים:
|
||||||
- אין ניתוח מעמיק (→ בלוק י), אין הכרעה בין פרשנויות
|
- אין ניתוח מעמיק (→ בלוק י), אין הכרעה בין פרשנויות
|
||||||
- מספור רציף
|
- מספור רציף
|
||||||
- בלוק אופציונלי — כתוב רק אם יש מורכבות תכנונית
|
- אם אין שומות בתיק — דווח רק על תכניות שזוהו ממסמכים אחרים, וציין במשפט אחד שלא הוגשו שומות
|
||||||
|
|
||||||
## פרטי התיק:
|
## פרטי התיק:
|
||||||
{case_context}
|
{case_context}
|
||||||
|
|
||||||
## תכניות שזוהו:
|
## תכניות שזוהו (ממטא-דאטה של מסמכים):
|
||||||
{plans_context}
|
{plans_context}
|
||||||
|
|
||||||
|
## עובדות שמאיות שחולצו (תכניות + היתרים, פרק לכל שמאי):
|
||||||
|
{appraiser_facts_context}
|
||||||
|
|
||||||
|
## סתירות שזוהו בין שמאים (חובה לסמן בנוסח):
|
||||||
|
{appraiser_conflicts_context}
|
||||||
|
|
||||||
## חומרי מקור:
|
## חומרי מקור:
|
||||||
{source_context}""",
|
{source_context}""",
|
||||||
|
|
||||||
@@ -301,6 +319,9 @@ async def write_block(
|
|||||||
precedents_context = await _build_precedents_context(case_id, block_id)
|
precedents_context = await _build_precedents_context(case_id, block_id)
|
||||||
style_context = await _build_style_context()
|
style_context = await _build_style_context()
|
||||||
discussion_context = await _build_previous_blocks_context(case_id, decision)
|
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")
|
outcome = (decision or {}).get("outcome", "rejected")
|
||||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
||||||
@@ -332,6 +353,9 @@ async def write_block(
|
|||||||
structure_guidance=structure_guidance,
|
structure_guidance=structure_guidance,
|
||||||
content_checklist=content_checklist,
|
content_checklist=content_checklist,
|
||||||
methodology_guidance=methodology_guidance,
|
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
|
# Restructure: sources first, then instructions
|
||||||
@@ -478,6 +502,112 @@ async def _build_plans_context(case_id: UUID) -> str:
|
|||||||
return "(לא זוהו תכניות)"
|
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:
|
async def _build_precedents_context(case_id: UUID, block_id: str) -> str:
|
||||||
"""Search for similar precedent paragraphs from other decisions and case law."""
|
"""Search for similar precedent paragraphs from other decisions and case law."""
|
||||||
parts = []
|
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)
|
precedents_context = await _build_precedents_context(case_id, block_id)
|
||||||
style_context = await _build_style_context()
|
style_context = await _build_style_context()
|
||||||
discussion_context = await _build_previous_blocks_context(case_id, decision)
|
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")
|
outcome = (decision or {}).get("outcome", "rejected")
|
||||||
structure_guidance = STRUCTURE_GUIDANCE.get(outcome, "")
|
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,
|
structure_guidance=structure_guidance,
|
||||||
content_checklist=content_checklist,
|
content_checklist=content_checklist,
|
||||||
methodology_guidance=methodology_guidance,
|
methodology_guidance=methodology_guidance,
|
||||||
|
appraiser_facts_context=appraiser_facts_context,
|
||||||
|
appraiser_conflicts_context=appraiser_conflicts_context,
|
||||||
|
post_hearing_context=post_hearing_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
if instructions:
|
if instructions:
|
||||||
|
|||||||
@@ -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:
|
async def init_schema() -> None:
|
||||||
pool = await get_pool()
|
pool = await get_pool()
|
||||||
async with pool.acquire() as conn:
|
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_V2_SQL)
|
||||||
await conn.execute(SCHEMA_V3_SQL)
|
await conn.execute(SCHEMA_V3_SQL)
|
||||||
await conn.execute(SCHEMA_V4_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 ───────────────────────────────────────────────────────
|
# ── Case CRUD ───────────────────────────────────────────────────────
|
||||||
@@ -1293,3 +1322,112 @@ async def resolve_chair_feedback(
|
|||||||
WHERE id = $1""",
|
WHERE id = $1""",
|
||||||
feedback_id, applied_to,
|
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
|
||||||
|
|||||||
@@ -168,16 +168,46 @@ def _add_image_placeholder(doc, description: str) -> None:
|
|||||||
|
|
||||||
# ── Main export ───────────────────────────────────────────────────
|
# ── 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.
|
"""ייצוא החלטה ל-DOCX.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_id: מזהה התיק
|
case_id: מזהה התיק
|
||||||
output_path: נתיב לשמירה (אופציונלי)
|
output_path: נתיב לשמירה (אופציונלי)
|
||||||
|
mode: 'final' (ברירת מחדל) או 'interim' (טיוטת ביניים — ללא
|
||||||
|
דיון/סיכום/חתימות, סדר חדש: רקע → תכניות+היתרים → טענות → הליכים)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
נתיב הקובץ שנוצר
|
נתיב הקובץ שנוצר
|
||||||
"""
|
"""
|
||||||
|
if mode not in ("final", "interim"):
|
||||||
|
raise ValueError(f"Unknown export mode: {mode}")
|
||||||
|
|
||||||
case = await db.get_case(case_id)
|
case = await db.get_case(case_id)
|
||||||
if not case:
|
if not case:
|
||||||
raise ValueError(f"Case {case_id} not found")
|
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
|
# Get blocks
|
||||||
pool = await db.get_pool()
|
pool = await db.get_pool()
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
blocks = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
"""SELECT block_id, block_index, title, content, word_count
|
"""SELECT block_id, block_index, title, content, word_count
|
||||||
FROM decision_blocks
|
FROM decision_blocks
|
||||||
WHERE decision_id = $1
|
WHERE decision_id = $1
|
||||||
@@ -197,9 +227,20 @@ async def export_decision(case_id: UUID, output_path: str | None = None) -> str:
|
|||||||
UUID(decision["id"]),
|
UUID(decision["id"]),
|
||||||
)
|
)
|
||||||
|
|
||||||
if not blocks:
|
if not rows:
|
||||||
raise ValueError("No blocks in decision")
|
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
|
# Create document
|
||||||
doc = 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)
|
# Write blocks with bookmarks wrapping each block (anchors for revisions)
|
||||||
bm_counter = [_BOOKMARK_ID_START]
|
bm_counter = [_BOOKMARK_ID_START]
|
||||||
for block in blocks:
|
for block in ordered_blocks:
|
||||||
block_id = block["block_id"]
|
block_id = block["block_id"]
|
||||||
content = block["content"] or ""
|
content = block["content"] or ""
|
||||||
if not content.strip():
|
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:
|
if not output_path:
|
||||||
export_dir = config.find_case_dir(case["case_number"]) / "exports"
|
export_dir = config.find_case_dir(case["case_number"]) / "exports"
|
||||||
export_dir.mkdir(parents=True, exist_ok=True)
|
export_dir.mkdir(parents=True, exist_ok=True)
|
||||||
# Find next version number
|
prefix = _draft_filename_prefix(mode)
|
||||||
existing = sorted(export_dir.glob("טיוטה-v*.docx"))
|
existing = sorted(export_dir.glob(f"{prefix}-v*.docx"))
|
||||||
next_ver = 1
|
next_ver = 1
|
||||||
for p in existing:
|
for p in existing:
|
||||||
try:
|
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)
|
next_ver = max(next_ver, ver + 1)
|
||||||
except (IndexError, ValueError):
|
except (IndexError, ValueError):
|
||||||
pass
|
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)
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
doc.save(output_path)
|
doc.save(output_path)
|
||||||
logger.info("DOCX exported: %s", output_path)
|
logger.info("DOCX exported (mode=%s): %s", mode, output_path)
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -416,6 +416,130 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
|||||||
}, ensure_ascii=False, indent=2)
|
}, 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:
|
async def apply_user_edit(case_number: str, edit_filename: str) -> str:
|
||||||
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
|
"""רישום עריכה שהעלה המשתמש כמקור האמת החדש של התיק.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user