Merge remote-tracking branch 'origin/main' into rebase-358
# Conflicts: # docs/spec/04-analysis-writing.md
This commit is contained in:
@@ -480,11 +480,38 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
|||||||
|
|
||||||
## 3. כללי גזירת פרמטרים
|
## 3. כללי גזירת פרמטרים
|
||||||
|
|
||||||
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית:
|
פרמטרי העיבוד נגזרים ממאפייני התוכן, לא נקבעים שרירותית.
|
||||||
|
|
||||||
### Temperature — נגזר מסוג הייצור
|
> **⚠️ עדכון מנגנון-הייצור (#204 / WS5).** מסלול-הייצור הוא `claude_session.query` →
|
||||||
|
> `claude -p` (מנוי claude.ai, עלות-שולית-אפס, מקומי-בלבד — ראה
|
||||||
|
> `reference_claude_generation_path`). ב-**Opus 4.7/4.8 הפרמטר `temperature` (וגם
|
||||||
|
> `top_p`/`top_k`) הוסר — שליחתו מחזירה HTTP 400**. הידית היחידה היא **`effort`**
|
||||||
|
> (low/medium/high/xhigh/max; ברירת-מחדל high). **המודל נעוץ ל-`claude-opus-4-8`
|
||||||
|
> לכל הבלוקים** ב-`block_writer.GENERATION_MODEL` — אין עוד פיצול sonnet/opus.
|
||||||
|
> טבלת ה-Temperature והעמודה `decision_blocks.temperature` הן **מטא-דאטה-מת
|
||||||
|
> (deprecated, תמיד 0)** ונשמרות רק לתאימות-עמודה; אל תקראו מהן. הטבלאות למטה
|
||||||
|
> נשמרות כתיעוד-המקור-הקונספטואלי בלבד — `effort` מחליף את "Thinking budget".
|
||||||
|
|
||||||
| Generation type | Temperature | נימוק |
|
### Effort — נגזר ממורכבות קוגניטיבית (הידית החיה)
|
||||||
|
|
||||||
|
מיפוי ה-`effort` בפועל ב-`block_writer.BLOCK_CONFIG` (#204):
|
||||||
|
|
||||||
|
| בלוק | gen_type | effort | נימוק |
|
||||||
|
|------|----------|--------|-------|
|
||||||
|
| ה (פתיחה) | paraphrase | medium | פתיחה קצרה, מבנה קבוע |
|
||||||
|
| ו (רקע) | reproduction | medium | שעתוק ניטרלי מרובה-מקורות |
|
||||||
|
| ז (טענות) | paraphrase | high | קיבוץ/סינתזת-טענות לפי-נושא |
|
||||||
|
| ח (הליכים) | reproduction | medium | תיעוד כרונולוגי |
|
||||||
|
| ט (תכניות) | guided-synthesis | high | ציטוט-תכניות מדויק (INV-AH) |
|
||||||
|
| י (דיון) | rhetorical-construction | xhigh | חשיבה רב-שלבית CREAC (מחוץ-לתחום-WS5) |
|
||||||
|
| יא (סיכום) | paraphrase | high | סינתזה אופרטיבית |
|
||||||
|
| א–ד, יב | template-fill | — (script) | מילוי-שדות, ללא LLM |
|
||||||
|
|
||||||
|
### Temperature — (DEPRECATED) נגזר היסטורית מסוג הייצור
|
||||||
|
|
||||||
|
> מסמך-מקור בלבד — לא נשלח ל-API (ראה האזהרה למעלה).
|
||||||
|
|
||||||
|
| Generation type | Temperature (היסטורי) | נימוק |
|
||||||
|----------------|-------------|-------|
|
|----------------|-------------|-------|
|
||||||
| template-fill | 0 | אין צורך בשפה — מילוי שדות |
|
| template-fill | 0 | אין צורך בשפה — מילוי שדות |
|
||||||
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
|
| reproduction | 0 | נאמנות מוחלטת למקור. אפס יצירתיות |
|
||||||
@@ -493,18 +520,11 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
|||||||
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
|
| analytical-reasoning | 0.3-0.4 | צריך ליצור קשרים בין עקרונות משפטיים |
|
||||||
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
|
| rhetorical-construction | 0.4-0.5 | טווח ביטוי רחב לכתיבה משכנעת ואלגנטית |
|
||||||
|
|
||||||
### Thinking budget — נגזר ממורכבות קוגניטיבית
|
### Model — (DEPRECATED) נגזר היסטורית מדרישת דיוק
|
||||||
|
|
||||||
| Cognitive task | Budget | נימוק |
|
> כעת נעוץ `claude-opus-4-8` לכל בלוקי-ה-AI (`GENERATION_MODEL`). הטבלה היסטורית.
|
||||||
|---------------|--------|-------|
|
|
||||||
| template-fill / lookup | off | אין צורך בחשיבה |
|
|
||||||
| sequential-extraction | low | חילוץ מידע חד-שלבי |
|
|
||||||
| multi-source-integration | medium | צריך להצליב מקורות |
|
|
||||||
| legal-analysis-with-CREAC | max (16K+) | חשיבה רב-שלבית: מסקנה → כלל → הסבר → יישום |
|
|
||||||
|
|
||||||
### Model — נגזר מדרישת דיוק
|
| Accuracy profile | Model (היסטורי) | נימוק |
|
||||||
|
|
||||||
| Accuracy profile | Model | נימוק |
|
|
||||||
|-----------------|-------|-------|
|
|-----------------|-------|-------|
|
||||||
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
|
| factual-precision | sonnet | מהיר, מדויק לחילוץ עובדות |
|
||||||
| precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה |
|
| precision + interpretation | opus | נדרש לפרשנות תכנית / ציטוט מובנה |
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) |
|
| ישות | תפקיד | מזהה-קנוני | שדות-מפתח (מאומתים `db.py`) |
|
||||||
|------|--------|-------------|------------------------------|
|
|------|--------|-------------|------------------------------|
|
||||||
| `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) |
|
| `cases` | תיק ערר חי (1xxx/8xxx/9xxx) | `case_number` + `proceeding_type` | `title`, `status`, `practice_area`, `appeal_subtype`, `proceeding_type`, `chair_name` (`db.py:74-91,182-189,747,912`) |
|
||||||
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
|
| `documents` | מסמך-מקור משויך לתיק | `id` (UUID); FK→`cases` | `doc_type`, `is_primary` (**נגזר** מ-`doc_type`, ראה [§2ג](#2ג-מסמך-עיקרי-primary-document)), `title`, `file_path`, `extracted_text`, `extraction_status`, `page_count` (`db.py:93-104`) |
|
||||||
| `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) |
|
| `document_chunks` | chunk של מסמך-תיק + embedding | `id`; FK→`documents`/`cases` | `chunk_index`, `content`, `section_type`, `embedding vector(1024)`, `page_number` (`db.py:106-116`) |
|
||||||
| `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) |
|
| `case_law` | קורפוס פסיקה — חיצוני **וגם** החלטות-ועדה | ראה [§2 + INV-DM2](#inv-dm2-מזהה-קנוני-יחיד-לכל-ישות) | `case_name`, `court`, `practice_area`, `source_kind`, `proceeding_type`, `source_type`, `headnote`, `summary`, `subject_tags`, `extraction_status`, `halacha_extraction_status` (`db.py:366-378,522-526,599-611,883,907`) |
|
||||||
| `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) |
|
| `precedent_chunks` | chunk של פסק-דין מואנדקס (`source_kind='external_upload'`/`internal_committee`) | `id`; FK→`case_law` | `chunk_index`, `content`, `section_type`, `page_number`, `embedding vector(1024)`, `content_tsv` (`db.py:624-634,776`) |
|
||||||
@@ -77,7 +77,25 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
|
|||||||
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
- `decision_blocks` → usable: `block_id`∈12-הבלוקים; "מוכן": `status=final` ו-`content` לא-ריק.
|
||||||
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
- `chair_feedback` → usable: `feedback_text`+`category` מהמילון; "פתוח" עד `resolved=true`.
|
||||||
|
|
||||||
### 2ג. ישויות-נגזרות (אחסון-ניתוחים)
|
### 2ג. מסמך-עיקרי (primary document)
|
||||||
|
|
||||||
|
WS2 (#200) מבחין בין **מסמך-עיקרי** (מסמך-מהות שהיו"ר עוקבת אחריו לניתוח) לבין מסמך-משני.
|
||||||
|
**הרשימה הקנונית של doc_types עיקריים** (מאושרת-יו"ר): `appeal` (כתב-ערר) · `response`
|
||||||
|
(תשובה/תגובה) · `objection` (התנגדות) · `protocol` (פרוטוקול-דיון) · `appraisal` (שומה) ·
|
||||||
|
`decision` (החלטת-ועדה). **כל doc_type אחר משני** (`plan`/`permit`/`court_decision`/
|
||||||
|
`exhibit`/`reference`).
|
||||||
|
|
||||||
|
**`is_primary` נגזר, לא נכתב** ([G1](00-constitution.md#inv-g1-מזהה-קנוני-מנורמל-בכתיבה)/[G2](00-constitution.md#inv-g2-מקור-אמת-יחיד--אין-מסלולים-מקבילים-מתפצלים)/
|
||||||
|
[INV-DM7](#inv-dm7-סיווג-הלכה--סמכות-נגזרת--תפקיד-כלל-מסווג-שני-צירים-לא-enum-אחד)): מקור-האמת היחיד הוא
|
||||||
|
`doc_type` + הרשימה הקנונית `PRIMARY_DOC_TYPES` (`db.py`). העמודה
|
||||||
|
`documents.is_primary BOOLEAN GENERATED ALWAYS AS (doc_type = ANY(PRIMARY_DOC_TYPES)) STORED`
|
||||||
|
(SCHEMA_V47) מחושבת ע"י Postgres — **אין מסלול-כתיבה מקביל ולא יכולה לסטות** מ-`doc_type`,
|
||||||
|
בדיוק כמו ה-tsvectors ב-[INV-DM3](#inv-dm3-שינוי-תוכן--re-index). `_row_to_doc` גוזר גם בקריאה
|
||||||
|
(לרשומות טרום-מיגרציה) ומוסיף `doc_category ∈ {primary, secondary}` לפלט. נחשף דרך
|
||||||
|
`document_list` / `case_get` / API-המסמכים. אינדקס חלקי `idx_documents_primary(case_id) WHERE
|
||||||
|
is_primary` משרת את שאילתת "מסמכים-עיקריים שטרם-נותחו" (#201).
|
||||||
|
|
||||||
|
### 2ד. ישויות-נגזרות (אחסון-ניתוחים)
|
||||||
|
|
||||||
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
מעבר לישויות-המקור, המערכת **שומרת ניתוחים נגזרים** — תוצרי-חילוץ של LLM/קוד. אלו כפופים לכללי
|
||||||
ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant):
|
ה-provenance של [X8](X8-field-provenance.md) ולשערי [G10](00-constitution.md#inv-g10-המערכת-מסייעת--שערים-אנושיים-הם-invariant):
|
||||||
|
|||||||
@@ -47,7 +47,35 @@
|
|||||||
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
> מקוריות מהשלמות). הסינון לפי `party_role` מאפשר לזהות את הצד המפסיד ולוודא שכל טיעון
|
||||||
> שלו מקבל מענה בבלוק י.
|
> שלו מקבל מענה בבלוק י.
|
||||||
|
|
||||||
### 1.3 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
### 1.3 ניתוח-פרוטוקול — ניתוח פרוטוקול-דיון השוואתי (WS4 / #203)
|
||||||
|
|
||||||
|
> **בעלוּת תת-סעיף:** §1.3 (זה) שייך ל-WS4 (ניתוח-פרוטוקול). תת-סעיף סיכום-טענות-הצדדים
|
||||||
|
> (PR-אחות #358) הוא **תת-סעיף נפרד** (§1.4) תחת §1 — אין חפיפת-מספור בין השניים.
|
||||||
|
|
||||||
|
אחרי הדיון, פרוטוקול (`doc_type='protocol'`) מנותח **מול הטיעונים המאוגדים** —
|
||||||
|
`analyze_protocol(case_number)` (→ `services/protocol_analyzer.py`). הניתוח מסווג כל
|
||||||
|
טיעון כתוב כ**ירד** (`dropped` — נזנח/ויתר בדיון), **חוזק** (`strengthened`), או מזהה
|
||||||
|
טענה ש**עלתה לראשונה בדיון** (`newly_raised`), ולכל רשומה מנסח את ה**שאלה המשפטית
|
||||||
|
המחודדת** לטובת בלוק י. התוצאה נשמרת ב**ידע-התיק** — טבלת `protocol_analysis` (case-
|
||||||
|
knowledge נגזר; G2: מקור-האמת הוא הפרוטוקול + `legal_arguments`, הרשומה היא השוואה
|
||||||
|
מטוריאליזת בת-שחזור) — וזמינה ל-`get_protocol_analysis(case_number, change_type="")`
|
||||||
|
(סימטריית extract/get, [INV-TOOL4](X9-mcp-tool-contract.md)).
|
||||||
|
|
||||||
|
- **קלט:** הטיעונים המאוגדים (§1.2) הם קו-הבסיס; ללא כינוס אין מול-מה להשוות.
|
||||||
|
- **שער anti-hallucination ([INV-AH](../anti-hallucination-gate.md)):** כל רשומה חייבת
|
||||||
|
`evidence_quote` — ציטוט **מילולי** מהפרוטוקול. רשומה ללא ציטוט-מבסס נדחית **במקור**
|
||||||
|
(`_normalize_change`) ולא מגיעה ל-DB (quote-or-retract).
|
||||||
|
- **טענות-הדיון בנפרד מבלוק ז:** טענות שעלו בדיון מתויגות `claim_type='protocol'`
|
||||||
|
ב-`extract_claims` ו**מוחרגות** מהקשר בלוק ז (`block_writer._build_claims_context`) —
|
||||||
|
בלוק ז נשאר **טענות-כתב מקוריות בלבד** ([INV-WR4](#inv-wr4-בלוק-ז--טענות-מקוריות-בלבד));
|
||||||
|
טענות-הדיון שייכות לבלוק ח (הליכים) ולידע-התיק ההשוואתי.
|
||||||
|
- **נתוני כותרת (א–ד):** הניתוח מחלץ גם את הפיד המוכר (הרכב, תאריך-דיון, צדדים שהופיעו)
|
||||||
|
ומזין את `hearing_date` חזרה לעמודה הקנונית `cases.hearing_date` (G1; לא נכתב אם
|
||||||
|
היו"ר כבר מילא תאריך — לא דורסים קלט-יו"ר).
|
||||||
|
- **ייצור:** קריאת-ה-LLM ההשוואתית עוברת `claude_session` (מקומי בלבד), מעוגנת
|
||||||
|
`model="claude-opus-4-8"` + `effort="high"` (ראה `reference_claude_generation_path`).
|
||||||
|
|
||||||
|
### 1.4 סיכום-מנהלים של טענות הצדדים (מסמך-הכנה לדיון, WS3/#202)
|
||||||
|
|
||||||
`summarize_party_claims(case_number, instructions="")` מפיק **מסמך-פרוזה מזוקק** של
|
`summarize_party_claims(case_number, instructions="")` מפיק **מסמך-פרוזה מזוקק** של
|
||||||
טענות הצדדים — תמצית-מנהלים קצרה ומוקפדת שמטרתה **להכין את היו"ר לדיון בעל-פה**. זהו
|
טענות הצדדים — תמצית-מנהלים קצרה ומוקפדת שמטרתה **להכין את היו"ר לדיון בעל-פה**. זהו
|
||||||
|
|||||||
@@ -43,6 +43,16 @@
|
|||||||
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
|
> וגם **טיוטת-ביניים** (`mode="interim"` — תת-קבוצת בלוקים בסדר חדש: רקע→תכניות→טענות→הליכים,
|
||||||
> `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה
|
> `export_interim_draft`, `drafting.py:511`). שני המצבים שולפים מאותה טבלה — וריאציית-תצוגה
|
||||||
> של אותו מקור-אמת, לא מסלול שני.
|
> של אותו מקור-אמת, לא מסלול שני.
|
||||||
|
>
|
||||||
|
> **דטרמיניזם-מבני של טיוטת-הביניים (#204 / WS5).** סדר-בלוקי-הביניים בייצוא קבוע
|
||||||
|
> ב-`docx_exporter._INTERIM_BLOCK_ORDER` (רקע→תכניות→טענות→הליכים, עם בלוקי-תבנית א–ה
|
||||||
|
> אופציונליים בתחילה). הבלוקים עצמם נכתבים דרך **אותו מסלול-כתיבה קנוני** של ההחלטה
|
||||||
|
> המלאה (`block_writer.write_and_store_block` → `write_block`, G2 — אין מסלול-ייצור
|
||||||
|
> מקביל), אך כעת **נעוצים למודל יחיד (`claude-opus-4-8`) עם `effort` per-בלוק ובפרומפט-
|
||||||
|
> מבני קבוע** (למשל בלוק-ה פותח תמיד ב"לפנינו ערר…"). התוצאה: אותו תיק מפיק את אותו
|
||||||
|
> **מבנה** בכל ריצה (אי-עקביות-הניסוח הקודמת נבעה מפרומפט-חופשי + מודל-ברירת-מחדל לא-נעוץ).
|
||||||
|
> טמפרטורה אינה במסלול (Opus 4.7/4.8 דוחים אותה); ראה `block_writer.GENERATION_MODEL`
|
||||||
|
> + `BLOCK_CONFIG[*].effort`.
|
||||||
> - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח
|
> - `analysis_docx_exporter.py` (`build_analysis_docx`, `:401`) — מייצא את מסמך **הניתוח
|
||||||
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
|
> המשפטי** (`analysis-and-research.md`) שכתב `legal-analyst`, לא את בלוקי-ההחלטה. זהו תוצר-עזר
|
||||||
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט
|
> שונה (שלב ניתוח, לא החלטה) — והוא המסלול שהסקיל מתעד בעיקר. שניהם חולקים את **אותו טמפלט
|
||||||
|
|||||||
@@ -744,6 +744,27 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
return await drafting.get_appraiser_facts(case_number)
|
return await drafting.get_appraiser_facts(case_number)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Protocol comparative analysis (WS4 / #203) — ניתוח פרוטוקול ────
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def analyze_protocol(case_number: str) -> str:
|
||||||
|
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות: אילו טענות ירדו/חוזקו/עלו-חדשות + חידוד-שאלות + חילוץ א–ד.
|
||||||
|
|
||||||
|
מזין ל"ידע-התיק" (protocol_analysis); דורש פרוטוקול doc_type='protocol' +
|
||||||
|
טיעונים מאוגדים. Claude מקומי Opus 4.8 effort=high; re-run מחליף (idempotent).
|
||||||
|
"""
|
||||||
|
return await drafting.analyze_protocol(case_number)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
|
||||||
|
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא ניתוח-מחדש. ה-get המקביל ל-analyze_protocol.
|
||||||
|
|
||||||
|
change_type: dropped/strengthened/newly_raised (ריק=הכל).
|
||||||
|
"""
|
||||||
|
return await drafting.get_protocol_analysis(case_number, change_type)
|
||||||
|
|
||||||
|
|
||||||
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
|
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
|
||||||
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
|
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
|
||||||
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
|
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
|
||||||
|
|||||||
@@ -35,29 +35,53 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
# ── Block configuration ───────────────────────────────────────────
|
# ── Block configuration ───────────────────────────────────────────
|
||||||
|
|
||||||
# Output token limits per Anthropic docs:
|
# Generation is structurally deterministic (#204 / WS5): every AI block is
|
||||||
# Opus 4.7: up to 128K output tokens (new tokenizer — ~35% more tokens)
|
# pinned to ONE model (Opus 4.8) with a per-block reasoning `effort`. The
|
||||||
# Sonnet 4.6: up to 64K output tokens
|
# generation path is claude_session.query → `claude -p` (subscription, near-zero
|
||||||
# Streaming required when max_tokens > 21,333
|
# cost; local-only — see reference_claude_generation_path / claude_session
|
||||||
|
# docstring). Opus 4.7/4.8 REMOVED temperature/top_p/top_k (sending them → HTTP
|
||||||
|
# 400); the only knob is `effort` (low/medium/high/xhigh/max; default high).
|
||||||
|
#
|
||||||
|
# DEPRECATED FIELDS — DO NOT REINTRODUCE: earlier revisions carried per-block
|
||||||
|
# `temp` (0/0.1/0.4) and `model` ("sonnet"/"opus"/"script"). Both were DEAD
|
||||||
|
# METADATA: write_block called claude_session.query(prompt, …) WITHOUT model/
|
||||||
|
# temp/effort, so generation ran on the CLI's default model with no temperature
|
||||||
|
# knob at all (the table only stored a number in decision_blocks.temperature and
|
||||||
|
# fed MODEL_MAP→timeout). They are removed here so the table no longer misleads.
|
||||||
|
# The conceptual origin (gen_type → thinking-budget) now lives in `effort`; see
|
||||||
|
# docs/block-schema.md §3.
|
||||||
|
#
|
||||||
|
# `gen_type` is retained (documentation/UI metadata + audit `generation_type`).
|
||||||
|
# `model` below is the DISPATCH key only: "script" = template-fill (no LLM),
|
||||||
|
# "ai" = generated via the pinned Opus model. It is NOT a model alias anymore.
|
||||||
|
#
|
||||||
|
# Output token note (Anthropic): Opus 4.8 supports large outputs; streaming is
|
||||||
|
# handled by the CLI. `max_tokens` is advisory context for callers, not sent.
|
||||||
|
GENERATION_MODEL = "claude-opus-4-8" # single pinned model for every AI block (#204)
|
||||||
|
|
||||||
BLOCK_CONFIG = {
|
BLOCK_CONFIG = {
|
||||||
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
"block-alef": {"index": 1, "title": "כותרת מוסדית", "gen_type": "template-fill", "model": "script"},
|
||||||
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
"block-bet": {"index": 2, "title": "הרכב הוועדה", "gen_type": "template-fill", "model": "script"},
|
||||||
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
"block-gimel":{"index": 3, "title": "צדדים", "gen_type": "template-fill", "model": "script"},
|
||||||
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
"block-dalet":{"index": 4, "title": "החלטה", "gen_type": "template-fill", "model": "script"},
|
||||||
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "temp": 0.2, "model": "sonnet", "max_tokens": 4096},
|
"block-he": {"index": 5, "title": "פתיחה", "gen_type": "paraphrase", "model": "ai", "effort": "medium", "max_tokens": 4096},
|
||||||
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 16384},
|
"block-vav": {"index": 6, "title": "רקע עובדתי", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 16384},
|
||||||
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 16384},
|
"block-zayin":{"index": 7, "title": "טענות הצדדים", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||||
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "temp": 0, "model": "sonnet", "max_tokens": 8192},
|
"block-chet": {"index": 8, "title": "הליכים", "gen_type": "reproduction", "model": "ai", "effort": "medium", "max_tokens": 8192},
|
||||||
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "temp": 0.2, "model": "opus", "max_tokens": 16384},
|
"block-tet": {"index": 9, "title": "תכניות חלות", "gen_type": "guided-synthesis", "model": "ai", "effort": "high", "max_tokens": 16384},
|
||||||
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "temp": 0.4, "model": "opus", "max_tokens": 16384},
|
"block-yod": {"index": 10, "title": "דיון והכרעה", "gen_type": "rhetorical-construction", "model": "ai", "effort": "xhigh", "max_tokens": 16384},
|
||||||
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "temp": 0.1, "model": "sonnet", "max_tokens": 8192},
|
"block-yod-alef": {"index": 11, "title": "סיכום", "gen_type": "paraphrase", "model": "ai", "effort": "high", "max_tokens": 8192},
|
||||||
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "temp": 0, "model": "script"},
|
"block-yod-bet": {"index": 12, "title": "חתימות", "gen_type": "template-fill", "model": "script"},
|
||||||
}
|
}
|
||||||
|
|
||||||
MODEL_MAP = {
|
# Default effort when a block lacks an explicit one (defensive; every AI block
|
||||||
"sonnet": "claude-sonnet-4-20250514",
|
# above sets one). High is the safe default per the generation-path reference.
|
||||||
"opus": "claude-opus-4-7",
|
DEFAULT_EFFORT = "high"
|
||||||
}
|
|
||||||
|
# Blocks that take longer (deep reasoning) get the LONG timeout. The interim set
|
||||||
|
# [he, vav, tet, zayin, chet] and the discussion block all run on the same pinned
|
||||||
|
# Opus model, so timeout is driven by effort, not by a model split.
|
||||||
|
_LONG_EFFORTS = frozenset({"high", "xhigh", "max"})
|
||||||
|
|
||||||
|
|
||||||
# ── Template blocks (א-ד, יב) ────────────────────────────────────
|
# ── Template blocks (א-ד, יב) ────────────────────────────────────
|
||||||
@@ -120,11 +144,14 @@ TEMPLATE_WRITERS = {
|
|||||||
BLOCK_PROMPTS = {
|
BLOCK_PROMPTS = {
|
||||||
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
|
"block-he": """כתוב את בלוק הפתיחה (בלוק ה) של החלטת ועדת ערר.
|
||||||
|
|
||||||
## כללים:
|
## מבנה קבוע (חובה — אותו מבנה בכל תיק):
|
||||||
- פתח ב"לפנינו ערר..." או "עניינה של החלטה זו..."
|
- **המשפט הראשון פותח תמיד במילה "לפנינו"** — נוסח קבוע: "לפנינו ערר על החלטת
|
||||||
- הגדר "להלן" מרכזיים: הוועדה המקומית, התכנית/הבקשה, המגרש
|
[הוועדה המקומית] מיום [תאריך] בעניין [נושא הבקשה/התכנית]." (אל תשתמש ב"עניינה של
|
||||||
- 1-2 סעיפים בלבד
|
החלטה זו" או בכל פתיח חלופי — הפתיח אחיד.)
|
||||||
- אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים
|
- סעיף 1: הצגת הערר במשפט הקבוע לעיל + הגדרת ה"להלן" המרכזיים בסדר קבוע:
|
||||||
|
הוועדה המקומית → התכנית/הבקשה → המגרש/המקרקעין.
|
||||||
|
- סעיף 2 (רק אם נדרש להשלמת "להלן" נוספים): הגדרות-נוספות בלבד.
|
||||||
|
- **בדיוק 1-2 סעיפים.** אין ניתוח, אין ערכי שיפוט, אין ציטוטים מצדדים.
|
||||||
- מספור: 1.
|
- מספור: 1.
|
||||||
|
|
||||||
## פרטי התיק:
|
## פרטי התיק:
|
||||||
@@ -441,10 +468,19 @@ async def write_block(
|
|||||||
f"Reduce documents or call extract_appraiser_facts first."
|
f"Reduce documents or call extract_appraiser_facts first."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Call Claude via Claude Code session (no API)
|
# Call Claude via Claude Code session (no API). #204: pin the model + per-block
|
||||||
model_key = block_cfg["model"]
|
# reasoning effort so generation is structurally deterministic — these were
|
||||||
timeout = claude_session.LONG_TIMEOUT if model_key == "opus" else claude_session.DEFAULT_TIMEOUT
|
# previously NOT forwarded (the source of inconsistency). model/effort flow
|
||||||
content = await claude_session.query(prompt, timeout=timeout, tools="") # prose gen — no tool_use → no error_max_turns
|
# through claude_session.query → `claude -p --model … --effort …`.
|
||||||
|
effort = block_cfg.get("effort", DEFAULT_EFFORT)
|
||||||
|
timeout = claude_session.LONG_TIMEOUT if effort in _LONG_EFFORTS else claude_session.DEFAULT_TIMEOUT
|
||||||
|
content = await claude_session.query(
|
||||||
|
prompt,
|
||||||
|
timeout=timeout,
|
||||||
|
model=GENERATION_MODEL,
|
||||||
|
effort=effort,
|
||||||
|
tools="", # prose gen — no tool_use → no error_max_turns
|
||||||
|
)
|
||||||
|
|
||||||
sources = await _collect_block_sources(case_id, block_id)
|
sources = await _collect_block_sources(case_id, block_id)
|
||||||
sources["case_law_ids"] = _precedent_case_law_ids
|
sources["case_law_ids"] = _precedent_case_law_ids
|
||||||
@@ -455,6 +491,7 @@ async def write_block(
|
|||||||
|
|
||||||
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
||||||
word_count = len(content.split())
|
word_count = len(content.split())
|
||||||
|
is_ai = block_cfg["model"] == "ai"
|
||||||
return {
|
return {
|
||||||
"block_id": block_id,
|
"block_id": block_id,
|
||||||
"block_index": block_cfg["index"],
|
"block_index": block_cfg["index"],
|
||||||
@@ -462,8 +499,14 @@ def _build_result(block_id: str, content: str, block_cfg: dict) -> dict:
|
|||||||
"content": content,
|
"content": content,
|
||||||
"word_count": word_count,
|
"word_count": word_count,
|
||||||
"generation_type": block_cfg["gen_type"],
|
"generation_type": block_cfg["gen_type"],
|
||||||
"model_used": block_cfg["model"],
|
# AI blocks record the pinned model; template blocks record "script".
|
||||||
"temperature": block_cfg["temp"],
|
"model_used": GENERATION_MODEL if is_ai else block_cfg["model"],
|
||||||
|
# The real generation knob (#204). None for template/script blocks.
|
||||||
|
"effort": block_cfg.get("effort", DEFAULT_EFFORT) if is_ai else None,
|
||||||
|
# DEPRECATED: temperature is not a real knob on Opus 4.7/4.8 (sending it
|
||||||
|
# → HTTP 400). Kept only to satisfy decision_blocks.temperature
|
||||||
|
# NUMERIC(3,2); always 0. Read `effort` instead.
|
||||||
|
"temperature": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -544,11 +587,19 @@ async def _build_claims_context(case_id: UUID) -> str:
|
|||||||
return "(לא חולצו טענות)"
|
return "(לא חולצו טענות)"
|
||||||
|
|
||||||
# Filter out claims from block-zayin (decision summary) — use only
|
# Filter out claims from block-zayin (decision summary) — use only
|
||||||
# claims extracted from original pleadings (appeal, response, etc.)
|
# claims extracted from original pleadings (appeal, response, etc.).
|
||||||
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"]
|
# Also drop claim_type='protocol' (oral arguments raised at the hearing):
|
||||||
|
# block ז carries ORIGINAL written pleadings only (INV-WR4); hearing-raised
|
||||||
|
# arguments belong to block ח (proceedings) and to analyze_protocol's
|
||||||
|
# comparative case-knowledge, not to the parties'-claims summary.
|
||||||
|
source_claims = [
|
||||||
|
c for c in claims
|
||||||
|
if c.get("source_document", "") != "block-zayin"
|
||||||
|
and c.get("claim_type", "claim") != "protocol"
|
||||||
|
]
|
||||||
if not source_claims:
|
if not source_claims:
|
||||||
# Fallback to all claims if no source claims exist
|
# Fallback to all non-block-zayin claims if no source claims exist.
|
||||||
source_claims = claims
|
source_claims = [c for c in claims if c.get("source_document", "") != "block-zayin"] or claims
|
||||||
|
|
||||||
lines = []
|
lines = []
|
||||||
current_role = ""
|
current_role = ""
|
||||||
|
|||||||
@@ -227,8 +227,15 @@ def _infer_claim_type(doc_type: str, source_name: str) -> str:
|
|||||||
- 'claim' = from appeal documents (כתב ערר)
|
- 'claim' = from appeal documents (כתב ערר)
|
||||||
- 'response' = from original response documents (כתב תשובה)
|
- 'response' = from original response documents (כתב תשובה)
|
||||||
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
|
- 'reply' = from supplementary responses (תגובה, השלמת טיעון)
|
||||||
|
- 'protocol' = oral arguments raised at the hearing (פרוטוקול דיון)
|
||||||
"""
|
"""
|
||||||
name_lower = source_name.lower() if source_name else ""
|
name_lower = source_name.lower() if source_name else ""
|
||||||
|
# A hearing protocol carries oral arguments — tagged distinctly so the
|
||||||
|
# comparative protocol analysis (analyze_protocol) and block-chet
|
||||||
|
# (proceedings) can tell them apart from the original written pleadings
|
||||||
|
# (INV-WR4: block ז stays original-pleadings-only).
|
||||||
|
if doc_type == "protocol" or "פרוטוקול" in name_lower:
|
||||||
|
return "protocol"
|
||||||
if doc_type == "appeal" or "כתב ערר" in name_lower:
|
if doc_type == "appeal" or "כתב ערר" in name_lower:
|
||||||
return "claim"
|
return "claim"
|
||||||
if "כתב תשובה" in name_lower:
|
if "כתב תשובה" in name_lower:
|
||||||
|
|||||||
@@ -18,6 +18,43 @@ from legal_mcp.services import court_citation, halacha_quality, principles
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ── Primary-document classification (WS2 / task #200) ────────────────
|
||||||
|
# Canonical, single-source list of "primary" (מסמך עיקרי) doc_types — the
|
||||||
|
# substantive case documents the chair tracks for analysis (appeal, the
|
||||||
|
# replies/objections, the hearing protocol, the appraisal, and the
|
||||||
|
# committee decision). Every OTHER doc_type (plan, permit, court_decision,
|
||||||
|
# exhibit, reference…) is secondary. Chair-approved set (plan WS2 §1).
|
||||||
|
#
|
||||||
|
# G1/G2/INV-DM7: `is_primary` is DERIVED from `doc_type`, never an
|
||||||
|
# independently-written column. There is ONE source of truth: this tuple.
|
||||||
|
# The DB column `documents.is_primary` is a GENERATED ALWAYS … STORED column
|
||||||
|
# (V47) computed by Postgres from `doc_type`, so it can never drift; the
|
||||||
|
# Python helper below mirrors the same list for read-time derivation and for
|
||||||
|
# building the generated-column expression. No parallel write path.
|
||||||
|
PRIMARY_DOC_TYPES: tuple[str, ...] = (
|
||||||
|
"appeal", # כתב-ערר
|
||||||
|
"response", # תשובה / תגובה
|
||||||
|
"objection", # התנגדות
|
||||||
|
"protocol", # פרוטוקול-דיון
|
||||||
|
"appraisal", # שומה
|
||||||
|
"decision", # החלטת-ועדה
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_primary_doc_type(doc_type: str | None) -> bool:
|
||||||
|
"""Whether a doc_type is a 'primary' (מסמך עיקרי) document. Single source
|
||||||
|
of truth = PRIMARY_DOC_TYPES; mirrors the V47 generated column."""
|
||||||
|
return doc_type in PRIMARY_DOC_TYPES
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_doc_types_sql_array() -> str:
|
||||||
|
"""SQL ARRAY[...] literal of PRIMARY_DOC_TYPES for the generated column.
|
||||||
|
Values are fixed identifiers from this module (no user input) — safe to
|
||||||
|
inline; kept derived from the tuple so the list has ONE definition."""
|
||||||
|
quoted = ", ".join("'" + t.replace("'", "''") + "'" for t in PRIMARY_DOC_TYPES)
|
||||||
|
return f"ARRAY[{quoted}]::text[]"
|
||||||
|
|
||||||
|
|
||||||
_pool: asyncpg.Pool | None = None
|
_pool: asyncpg.Pool | None = None
|
||||||
_schema_ready: bool = False
|
_schema_ready: bool = False
|
||||||
_init_lock: asyncio.Lock = asyncio.Lock()
|
_init_lock: asyncio.Lock = asyncio.Lock()
|
||||||
@@ -1782,6 +1819,57 @@ CREATE INDEX IF NOT EXISTS idx_decision_lessons_vec
|
|||||||
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
|
ON decision_lessons USING ivfflat (embedding vector_cosine_ops) WITH (lists = 30);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# V47 (WS2 / task #200): "primary document" (מסמך עיקרי) concept.
|
||||||
|
# `documents.is_primary` is a GENERATED ALWAYS … STORED column derived purely
|
||||||
|
# from `doc_type` against the canonical PRIMARY_DOC_TYPES list (built from the
|
||||||
|
# single-source tuple via _primary_doc_types_sql_array). Because Postgres
|
||||||
|
# computes it, there is NO parallel write path and it can never drift from
|
||||||
|
# doc_type (G1/G2/INV-DM7 — same drift-free pattern as the GENERATED tsvectors,
|
||||||
|
# INV-DM3). Idempotent: ADD COLUMN IF NOT EXISTS; the generated expression is
|
||||||
|
# fixed, so re-running is a no-op. The partial index serves the
|
||||||
|
# "primary docs not yet analysed" queries that #201 builds on.
|
||||||
|
SCHEMA_V47_SQL = f"""
|
||||||
|
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_primary BOOLEAN
|
||||||
|
GENERATED ALWAYS AS (doc_type = ANY({_primary_doc_types_sql_array()})) STORED;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_documents_primary
|
||||||
|
ON documents(case_id) WHERE is_primary;
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── V48: Protocol comparative analysis (WS4 / #203) ───────────────
|
||||||
|
#
|
||||||
|
# protocol_analysis: case-knowledge derived from a hearing-protocol document by
|
||||||
|
# comparing the oral arguments raised at the hearing against the written
|
||||||
|
# pleadings (legal_arguments). One row per (case_id, document_id) — idempotent
|
||||||
|
# replace on re-run (INV-TOOL3). Each row holds the comparative verdict on a
|
||||||
|
# single argument: was it DROPPED at the hearing, STRENGTHENED, or NEWLY_RAISED,
|
||||||
|
# plus the sharpened legal question it bears on. This is *derived* knowledge —
|
||||||
|
# the source of truth is the protocol document + legal_arguments; the row is a
|
||||||
|
# materialized comparison, re-buildable from those (G2: no parallel store, this
|
||||||
|
# is derived-from-source not a competing claims table).
|
||||||
|
#
|
||||||
|
# header_data (the א–ד feed: panel, hearing date, parties present) is written
|
||||||
|
# back to the canonical `cases` columns (panel via decisions, hearing_date on
|
||||||
|
# cases) — NOT duplicated here — and a copy of the raw extracted feed is kept on
|
||||||
|
# the analysis row for provenance only (G9).
|
||||||
|
SCHEMA_V48_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS protocol_analysis (
|
||||||
|
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,
|
||||||
|
party_role TEXT NOT NULL DEFAULT '', -- appellant/respondent/committee/permit_applicant/''
|
||||||
|
change_type TEXT NOT NULL CHECK (change_type IN ('dropped', 'strengthened', 'newly_raised')),
|
||||||
|
argument_id UUID REFERENCES legal_arguments(id) ON DELETE SET NULL, -- the pleaded argument, when matched
|
||||||
|
argument_title TEXT NOT NULL DEFAULT '', -- snapshot of the argument / new point
|
||||||
|
summary TEXT NOT NULL, -- what changed at the hearing, in prose
|
||||||
|
sharpened_question TEXT NOT NULL DEFAULT '', -- the legal question this sharpens (for the discussion)
|
||||||
|
evidence_quote TEXT NOT NULL DEFAULT '', -- verbatim protocol excerpt (INV-AH: quote-or-retract)
|
||||||
|
page_number INTEGER,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_case ON protocol_analysis(case_id, change_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_protocol_analysis_doc ON protocol_analysis(document_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# Stable, arbitrary key for the session-level advisory lock that serialises
|
# Stable, arbitrary key for the session-level advisory lock that serialises
|
||||||
# schema DDL across processes. Every short-lived process (cron drains, services)
|
# schema DDL across processes. Every short-lived process (cron drains, services)
|
||||||
@@ -1851,6 +1939,8 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V44_SQL)
|
await conn.execute(SCHEMA_V44_SQL)
|
||||||
await conn.execute(SCHEMA_V45_SQL)
|
await conn.execute(SCHEMA_V45_SQL)
|
||||||
await conn.execute(SCHEMA_V46_SQL)
|
await conn.execute(SCHEMA_V46_SQL)
|
||||||
|
await conn.execute(SCHEMA_V47_SQL)
|
||||||
|
await conn.execute(SCHEMA_V48_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -2223,6 +2313,14 @@ def _row_to_doc(row: asyncpg.Record) -> dict:
|
|||||||
d["case_id"] = str(d["case_id"])
|
d["case_id"] = str(d["case_id"])
|
||||||
if isinstance(d.get("metadata"), str):
|
if isinstance(d.get("metadata"), str):
|
||||||
d["metadata"] = json.loads(d["metadata"])
|
d["metadata"] = json.loads(d["metadata"])
|
||||||
|
# Primary/secondary classification (WS2 / #200). `is_primary` is the
|
||||||
|
# generated DB column (V47); derive it at read-time too so the field is
|
||||||
|
# always present even on rows fetched before the migration ran, and expose
|
||||||
|
# a human-facing `doc_category`. Single source of truth: PRIMARY_DOC_TYPES.
|
||||||
|
is_primary = bool(d["is_primary"]) if d.get("is_primary") is not None \
|
||||||
|
else is_primary_doc_type(d.get("doc_type"))
|
||||||
|
d["is_primary"] = is_primary
|
||||||
|
d["doc_category"] = "primary" if is_primary else "secondary"
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
@@ -3967,6 +4065,83 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
|
|||||||
return conflicts
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
# ── Protocol comparative analysis (V48 / WS4 #203) ────────────────
|
||||||
|
|
||||||
|
async def replace_protocol_analysis(
|
||||||
|
case_id: UUID,
|
||||||
|
document_id: UUID,
|
||||||
|
rows: list[dict],
|
||||||
|
) -> int:
|
||||||
|
"""Replace all protocol_analysis rows for a given protocol document (idempotent).
|
||||||
|
|
||||||
|
Each row dict: change_type ('dropped'|'strengthened'|'newly_raised'),
|
||||||
|
party_role, argument_id (UUID|None), argument_title, summary,
|
||||||
|
sharpened_question, evidence_quote, page_number (optional).
|
||||||
|
|
||||||
|
Idempotent on document_id (INV-TOOL3): re-running analyze_protocol replaces
|
||||||
|
the prior verdict for that protocol rather than appending duplicates.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM protocol_analysis WHERE document_id = $1", document_id,
|
||||||
|
)
|
||||||
|
for r in rows:
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO protocol_analysis
|
||||||
|
(case_id, document_id, party_role, change_type, argument_id,
|
||||||
|
argument_title, summary, sharpened_question, evidence_quote, page_number)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)""",
|
||||||
|
case_id, document_id,
|
||||||
|
r.get("party_role", "") or "",
|
||||||
|
r["change_type"],
|
||||||
|
r.get("argument_id"),
|
||||||
|
r.get("argument_title", "") or "",
|
||||||
|
r["summary"],
|
||||||
|
r.get("sharpened_question", "") or "",
|
||||||
|
r.get("evidence_quote", "") or "",
|
||||||
|
r.get("page_number"),
|
||||||
|
)
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_protocol_analysis(
|
||||||
|
case_id: UUID,
|
||||||
|
change_type: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""List protocol_analysis rows for a case, optionally filtered by change_type.
|
||||||
|
|
||||||
|
The read side of the extract/get symmetry (INV-TOOL4) for analyze_protocol.
|
||||||
|
"""
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
if change_type:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT * FROM protocol_analysis
|
||||||
|
WHERE case_id = $1 AND change_type = $2
|
||||||
|
ORDER BY change_type, party_role, created_at""",
|
||||||
|
case_id, change_type,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT * FROM protocol_analysis
|
||||||
|
WHERE case_id = $1
|
||||||
|
ORDER BY change_type, party_role, created_at""",
|
||||||
|
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 d.get("argument_id") is not None:
|
||||||
|
d["argument_id"] = str(d["argument_id"])
|
||||||
|
results.append(d)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
# ── Plans registry (V38) ──────────────────────────────────────────
|
# ── Plans registry (V38) ──────────────────────────────────────────
|
||||||
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
|
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
|
||||||
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.
|
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.
|
||||||
|
|||||||
341
mcp-server/src/legal_mcp/services/protocol_analyzer.py
Normal file
341
mcp-server/src/legal_mcp/services/protocol_analyzer.py
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
"""ניתוח פרוטוקול-דיון השוואתי (WS4 / #203).
|
||||||
|
|
||||||
|
מנתח פרוטוקול דיון של ועדת הערר מול כתבי-הטענות הכתובים: אילו טענות **ירדו**
|
||||||
|
(נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות** — ומחדד את השאלות המשפטיות
|
||||||
|
לקראת פרק הדיון. התוצאה נכנסת ל"ידע-התיק" (טבלת protocol_analysis), זמינה
|
||||||
|
לסוכני הניתוח והכתיבה.
|
||||||
|
|
||||||
|
בנוסף, מחלץ את **נתוני הכותרת (א–ד)** מהפרוטוקול בפורמט-ההזנה המוכר —
|
||||||
|
הרכב הוועדה, תאריך הדיון, והצדדים שהופיעו — ומזין אותם חזרה לעמודות הקנוניות
|
||||||
|
(`cases.hearing_date`, panel דרך decisions; G2: לא כפילות).
|
||||||
|
|
||||||
|
הפרדת-אחריות (claude_session.py): כל קריאת-LLM כאן רצה רק מה-MCP server המקומי
|
||||||
|
(אין claude CLI בקונטיינר). הקריאה היחידה היא דרך claude_session, מעוגנת ל-
|
||||||
|
Opus 4.8 + effort=high כמתבקש בניתוח-השוואתי (reference_claude_generation_path).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import claude_session, db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Generation pinned per reference_claude_generation_path: Opus 4.8 rejects
|
||||||
|
# temperature/top_p/top_k (400) — the only knob is effort. Comparative legal
|
||||||
|
# reasoning over the protocol vs. pleadings is high-effort.
|
||||||
|
ANALYSIS_MODEL = "claude-opus-4-8"
|
||||||
|
ANALYSIS_EFFORT = "high"
|
||||||
|
HEADER_EFFORT = "medium" # א–ד is mechanical extraction, not reasoning
|
||||||
|
|
||||||
|
# Valid change verdicts — mirror the DB CHECK on protocol_analysis.change_type.
|
||||||
|
VALID_CHANGE_TYPES = {"dropped", "strengthened", "newly_raised"}
|
||||||
|
VALID_PARTY_ROLES = {"appellant", "respondent", "committee", "permit_applicant", ""}
|
||||||
|
|
||||||
|
PARTY_LABELS_HE = {
|
||||||
|
"appellant": "עוררים",
|
||||||
|
"respondent": "משיבים",
|
||||||
|
"committee": "ועדה מקומית",
|
||||||
|
"permit_applicant": "מבקשי היתר",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Comparative analysis: pleadings vs. protocol ──────────────────
|
||||||
|
|
||||||
|
COMPARE_PROMPT = """אתה מנתח משפטי בכיר בועדת ערר לתכנון ובנייה. לפניך **פרוטוקול דיון**
|
||||||
|
ולצדו **הטיעונים המשפטיים שהוגשו בכתב** לפני הדיון. תפקידך: ניתוח השוואתי מדויק
|
||||||
|
של מה שקרה בדיון ביחס לכתבי-הטענות.
|
||||||
|
|
||||||
|
## מה לזהות (לכל צד בנפרד):
|
||||||
|
1. **dropped (ירדה)** — טענה שהוגשה בכתב אך הצד **זנח** אותה בדיון: לא חזר עליה,
|
||||||
|
הודה שאינה עומדת, או ויתר עליה במפורש.
|
||||||
|
2. **strengthened (חוזקה)** — טענה כתובה שהצד **חיזק** בדיון: הוסיף נימוק, אסמכתא,
|
||||||
|
הבהרה, או דגש שהפך אותה למרכזית.
|
||||||
|
3. **newly_raised (חדשה)** — טענה/סוגיה ש**עלתה לראשונה בדיון** ולא הופיעה בכתבי
|
||||||
|
הטענות כלל.
|
||||||
|
|
||||||
|
## כללים קריטיים (anti-hallucination — חובה):
|
||||||
|
- **ציטוט-או-הימנעות:** לכל קביעה חייב להיות `evidence_quote` — ציטוט **מילולי**
|
||||||
|
מהפרוטוקול (עד 200 תווים) שמבסס אותה. אם אין ציטוט תומך — אל תכלול את הרשומה.
|
||||||
|
- אל תמציא טענות שלא נאמרו. אל תסיק "ירדה" רק כי לא הוזכרה — רק אם יש ראיה פוזיטיבית
|
||||||
|
לזניחה/ויתור, או שהיא טענה כתובה מרכזית שכלל לא עלתה והצד עסק בנושא.
|
||||||
|
- **חידוד-השאלה:** לכל רשומה, נסח את `sharpened_question` — השאלה המשפטית הממוקדת
|
||||||
|
שהשינוי בדיון מחדד (לטובת פרק הדיון). אם לא רלוונטי — השאר ריק.
|
||||||
|
- שייך כל רשומה ל-`party_role` הנכון: appellant / respondent / committee / permit_applicant.
|
||||||
|
- אם רשומה תואמת טיעון כתוב קיים — החזר את ה-`argument_id` שלו מהרשימה למטה. לטענה חדשה
|
||||||
|
(newly_raised) — argument_id ריק.
|
||||||
|
|
||||||
|
## פלט:
|
||||||
|
החזר JSON array בלבד — ללא markdown, ללא הסברים:
|
||||||
|
[
|
||||||
|
{{
|
||||||
|
"change_type": "dropped" | "strengthened" | "newly_raised",
|
||||||
|
"party_role": "appellant" | "respondent" | "committee" | "permit_applicant",
|
||||||
|
"argument_id": "uuid-של-הטיעון-הכתוב או null",
|
||||||
|
"argument_title": "כותרת קצרה של הטענה/הסוגיה",
|
||||||
|
"summary": "מה השתנה בדיון, במשפט-שניים",
|
||||||
|
"sharpened_question": "השאלה המשפטית שהשינוי מחדד, או ריק",
|
||||||
|
"evidence_quote": "ציטוט מילולי מהפרוטוקול",
|
||||||
|
"page_number": null
|
||||||
|
}}
|
||||||
|
]
|
||||||
|
אם אין שינויים בני-ביסוס — החזר [].
|
||||||
|
|
||||||
|
## הטיעונים שהוגשו בכתב:
|
||||||
|
{arguments_json}
|
||||||
|
|
||||||
|
## פרוטוקול הדיון:
|
||||||
|
--- תחילת פרוטוקול ---
|
||||||
|
{protocol_text}
|
||||||
|
--- סוף פרוטוקול ---
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Header (א–ד) extraction in the known feed format ──────────────
|
||||||
|
|
||||||
|
HEADER_PROMPT = """אתה מחלץ נתוני-כותרת מפרוטוקול דיון של ועדת ערר לתכנון ובנייה.
|
||||||
|
חלץ אך-ורק עובדות מנהליות המופיעות במפורש בפרוטוקול — אל תמציא ואל תסיק.
|
||||||
|
|
||||||
|
## פלט:
|
||||||
|
החזר JSON object בלבד (ללא markdown):
|
||||||
|
{{
|
||||||
|
"hearing_date": "YYYY-MM-DD אם תאריך הדיון מופיע, אחרת ריק",
|
||||||
|
"panel_members": ["שמות חברי ההרכב כפי שמופיעים, כולל תוארם; ריק אם לא צוין"],
|
||||||
|
"appellants_present": ["שמות העוררים/באי-כוחם שהופיעו בדיון"],
|
||||||
|
"respondents_present": ["שמות המשיבים/באי-כוחם שהופיעו בדיון"]
|
||||||
|
}}
|
||||||
|
שדה שלא צוין בפרוטוקול — החזר ריק ([] או "").
|
||||||
|
|
||||||
|
## פרוטוקול:
|
||||||
|
--- תחילת פרוטוקול ---
|
||||||
|
{protocol_text}
|
||||||
|
--- סוף פרוטוקול ---
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A single protocol rarely exceeds the model's context, but cap defensively so a
|
||||||
|
# pathological OCR dump doesn't blow the prompt budget.
|
||||||
|
MAX_PROTOCOL_CHARS = 120_000
|
||||||
|
|
||||||
|
|
||||||
|
def _find_protocol(docs: list[dict]) -> dict | None:
|
||||||
|
"""The protocol document for the case, if present (doc_type or title)."""
|
||||||
|
for d in docs:
|
||||||
|
if d.get("doc_type") == "protocol":
|
||||||
|
return d
|
||||||
|
for d in docs:
|
||||||
|
if "פרוטוקול" in (d.get("title") or ""):
|
||||||
|
return d
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_arguments(arguments: list[dict]) -> list[dict]:
|
||||||
|
"""Strip aggregated arguments down to what the comparison needs."""
|
||||||
|
out = []
|
||||||
|
for a in arguments:
|
||||||
|
out.append({
|
||||||
|
"argument_id": str(a["id"]),
|
||||||
|
"party_role": a.get("party", ""),
|
||||||
|
"title": a.get("argument_title", ""),
|
||||||
|
"body": a.get("argument_body", ""),
|
||||||
|
"topic": a.get("legal_topic", ""),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_change(raw: dict, valid_argument_ids: set[str]) -> dict | None:
|
||||||
|
"""Validate & normalize one comparative-analysis row from Claude.
|
||||||
|
|
||||||
|
Returns None for unusable rows (missing change_type/summary, or — per the
|
||||||
|
anti-hallucination gate — no supporting evidence_quote).
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return None
|
||||||
|
change_type = (raw.get("change_type") or "").strip()
|
||||||
|
if change_type not in VALID_CHANGE_TYPES:
|
||||||
|
return None
|
||||||
|
summary = (raw.get("summary") or "").strip()
|
||||||
|
evidence_quote = (raw.get("evidence_quote") or "").strip()
|
||||||
|
# INV-AH (quote-or-retract): drop any verdict the model couldn't anchor to a
|
||||||
|
# verbatim protocol excerpt — at source, so unfounded rows never reach the DB.
|
||||||
|
if not summary or not evidence_quote:
|
||||||
|
return None
|
||||||
|
|
||||||
|
party_role = (raw.get("party_role") or "").strip()
|
||||||
|
if party_role not in VALID_PARTY_ROLES:
|
||||||
|
party_role = ""
|
||||||
|
|
||||||
|
# argument_id only kept when it actually belongs to this case's arguments;
|
||||||
|
# a newly_raised point has none, and a hallucinated id is dropped (FK safety).
|
||||||
|
argument_id = None
|
||||||
|
raw_aid = raw.get("argument_id")
|
||||||
|
if raw_aid and str(raw_aid) in valid_argument_ids:
|
||||||
|
try:
|
||||||
|
argument_id = UUID(str(raw_aid))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
argument_id = None
|
||||||
|
|
||||||
|
page = raw.get("page_number")
|
||||||
|
if not isinstance(page, int):
|
||||||
|
page = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"change_type": change_type,
|
||||||
|
"party_role": party_role,
|
||||||
|
"argument_id": argument_id,
|
||||||
|
"argument_title": (raw.get("argument_title") or "").strip(),
|
||||||
|
"summary": summary,
|
||||||
|
"sharpened_question": (raw.get("sharpened_question") or "").strip(),
|
||||||
|
"evidence_quote": evidence_quote[:200],
|
||||||
|
"page_number": page,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_header(protocol_text: str, case_id: UUID) -> dict:
|
||||||
|
"""Extract א–ד header data and write it back to canonical case fields.
|
||||||
|
|
||||||
|
Returns the raw extracted feed (for provenance) plus a record of what was
|
||||||
|
written back. Header data is NOT stored in protocol_analysis — it lives in
|
||||||
|
the canonical `cases`/`decisions` columns (G2: single source of truth).
|
||||||
|
"""
|
||||||
|
prompt = HEADER_PROMPT.format(protocol_text=protocol_text[:MAX_PROTOCOL_CHARS])
|
||||||
|
try:
|
||||||
|
feed = await claude_session.query_json(
|
||||||
|
prompt, model=ANALYSIS_MODEL, effort=HEADER_EFFORT, tools="",
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 — surface, don't swallow (§6 no silent swallow)
|
||||||
|
logger.warning("protocol header extraction failed for case %s: %s", case_id, e)
|
||||||
|
return {"status": "header_failed", "error": str(e)}
|
||||||
|
|
||||||
|
if not isinstance(feed, dict):
|
||||||
|
return {"status": "header_no_data"}
|
||||||
|
|
||||||
|
applied: dict = {}
|
||||||
|
# hearing_date → canonical cases.hearing_date (only if the case lacks one,
|
||||||
|
# so a chair-entered date is never overwritten by extraction).
|
||||||
|
hearing_date = (feed.get("hearing_date") or "").strip()
|
||||||
|
if hearing_date:
|
||||||
|
from datetime import date as date_type
|
||||||
|
try:
|
||||||
|
parsed = date_type.fromisoformat(hearing_date)
|
||||||
|
case = await db.get_case(case_id)
|
||||||
|
if case and not case.get("hearing_date"):
|
||||||
|
await db.update_case(case_id, hearing_date=parsed)
|
||||||
|
applied["hearing_date"] = hearing_date
|
||||||
|
except ValueError:
|
||||||
|
logger.info("protocol header: unparseable hearing_date %r", hearing_date)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"feed": {
|
||||||
|
"hearing_date": hearing_date,
|
||||||
|
"panel_members": feed.get("panel_members") or [],
|
||||||
|
"appellants_present": feed.get("appellants_present") or [],
|
||||||
|
"respondents_present": feed.get("respondents_present") or [],
|
||||||
|
},
|
||||||
|
"applied_to_case": applied,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_protocol(case_id: UUID) -> dict:
|
||||||
|
"""Comparative analysis of the case's hearing protocol vs. its pleadings.
|
||||||
|
|
||||||
|
1. Locates the protocol document.
|
||||||
|
2. Pulls the aggregated legal_arguments (the written-pleadings baseline).
|
||||||
|
3. Asks Claude (Opus 4.8, effort=high) to classify each as dropped /
|
||||||
|
strengthened, and to surface newly-raised arguments + sharpened questions.
|
||||||
|
4. Stores the verdicts in protocol_analysis (case-knowledge, idempotent).
|
||||||
|
5. Extracts the א–ד header feed and writes hearing_date back to the case.
|
||||||
|
|
||||||
|
Returns a serializable summary dict.
|
||||||
|
"""
|
||||||
|
docs = await db.list_documents(case_id)
|
||||||
|
protocol = _find_protocol(docs)
|
||||||
|
if not protocol:
|
||||||
|
return {
|
||||||
|
"status": "no_protocol",
|
||||||
|
"message": "לא נמצא פרוטוקול דיון בתיק (doc_type='protocol'). העלה פרוטוקול והרץ שוב.",
|
||||||
|
}
|
||||||
|
|
||||||
|
document_id = UUID(protocol["id"])
|
||||||
|
protocol_text = await db.get_document_text(document_id)
|
||||||
|
if not protocol_text or not protocol_text.strip():
|
||||||
|
return {
|
||||||
|
"status": "no_text",
|
||||||
|
"message": "לפרוטוקול אין טקסט מחולץ. ודא שהעיבוד הסתיים והרץ שוב.",
|
||||||
|
"document_id": str(document_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Use the aggregator's read path (single source of truth — no parallel
|
||||||
|
# query for the same legal_arguments data, G2).
|
||||||
|
from legal_mcp.services import argument_aggregator
|
||||||
|
arguments = await argument_aggregator.get_legal_arguments(case_id)
|
||||||
|
|
||||||
|
if not arguments:
|
||||||
|
return {
|
||||||
|
"status": "no_arguments",
|
||||||
|
"message": (
|
||||||
|
"אין טיעונים מאוגדים להשוואה. הרץ extract_claims + "
|
||||||
|
"aggregate_claims_to_arguments על כתבי-הטענות תחילה."
|
||||||
|
),
|
||||||
|
"document_id": str(document_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
compact_args = _compact_arguments(arguments)
|
||||||
|
valid_argument_ids = {a["argument_id"] for a in compact_args}
|
||||||
|
|
||||||
|
prompt = COMPARE_PROMPT.format(
|
||||||
|
arguments_json=json.dumps(compact_args, ensure_ascii=False, indent=2),
|
||||||
|
protocol_text=protocol_text[:MAX_PROTOCOL_CHARS],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_result = await claude_session.query_json(
|
||||||
|
prompt, model=ANALYSIS_MODEL, effort=ANALYSIS_EFFORT, tools="",
|
||||||
|
)
|
||||||
|
except RuntimeError as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "Claude CLI not found" in msg:
|
||||||
|
return {
|
||||||
|
"status": "llm_unavailable",
|
||||||
|
"message": (
|
||||||
|
"Claude CLI לא זמין. הניתוח ההשוואתי חייב לרוץ מה-MCP server "
|
||||||
|
"המקומי, לא מהקונטיינר."
|
||||||
|
),
|
||||||
|
"document_id": str(document_id),
|
||||||
|
}
|
||||||
|
return {"status": "error", "message": msg, "document_id": str(document_id)}
|
||||||
|
|
||||||
|
if not isinstance(raw_result, list):
|
||||||
|
logger.warning(
|
||||||
|
"analyze_protocol: Claude returned non-list (%s) for case %s",
|
||||||
|
type(raw_result).__name__, case_id,
|
||||||
|
)
|
||||||
|
raw_result = []
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for entry in raw_result:
|
||||||
|
norm = _normalize_change(entry, valid_argument_ids)
|
||||||
|
if norm:
|
||||||
|
rows.append(norm)
|
||||||
|
|
||||||
|
stored = await db.replace_protocol_analysis(case_id, document_id, rows)
|
||||||
|
|
||||||
|
# Extract header (א–ד) and write hearing_date back to the canonical case.
|
||||||
|
header = await _extract_header(protocol_text, case_id)
|
||||||
|
|
||||||
|
by_change: dict[str, int] = {}
|
||||||
|
for r in rows:
|
||||||
|
by_change[r["change_type"]] = by_change.get(r["change_type"], 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"document_id": str(document_id),
|
||||||
|
"protocol_title": protocol.get("title", ""),
|
||||||
|
"total": stored,
|
||||||
|
"by_change": by_change,
|
||||||
|
"arguments_compared": len(compact_args),
|
||||||
|
"header": header,
|
||||||
|
}
|
||||||
@@ -264,6 +264,11 @@ async def document_get_text(case_number: str, doc_title: str = "") -> str:
|
|||||||
async def document_list(case_number: str) -> str:
|
async def document_list(case_number: str) -> str:
|
||||||
"""רשימת מסמכים בתיק.
|
"""רשימת מסמכים בתיק.
|
||||||
|
|
||||||
|
כל מסמך כולל `doc_type`, וכן את הסיווג הנגזר `is_primary` (bool) ו-
|
||||||
|
`doc_category` ("primary"/"secondary") — מסמך-עיקרי (ערר/תשובה/התנגדות/
|
||||||
|
פרוטוקול/שומה/החלטת-ועדה) מול משני. נגזר מ-`doc_type` (db.PRIMARY_DOC_TYPES),
|
||||||
|
אינו נכתב ידנית.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
case_number: מספר תיק הערר
|
case_number: מספר תיק הערר
|
||||||
"""
|
"""
|
||||||
@@ -345,11 +350,15 @@ async def extract_claims(
|
|||||||
if not docs:
|
if not docs:
|
||||||
return empty(f"אין מסמכים בתיק {case_number}.")
|
return empty(f"אין מסמכים בתיק {case_number}.")
|
||||||
|
|
||||||
# Filter to claims documents (appeal, response) or specific doc
|
# Filter to claims documents or a specific doc. protocol carries oral
|
||||||
|
# arguments raised at the hearing — extracted with claim_type='protocol'
|
||||||
|
# (claims_extractor._infer_claim_type) so analyze_protocol can compare them
|
||||||
|
# against the written pleadings, while block ז (write_block_zayin) excludes
|
||||||
|
# them to stay original-pleadings-only (INV-WR4).
|
||||||
if doc_title:
|
if doc_title:
|
||||||
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
docs = [d for d in docs if doc_title.lower() in d["title"].lower()]
|
||||||
else:
|
else:
|
||||||
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection")]
|
docs = [d for d in docs if d["doc_type"] in ("appeal", "response", "objection", "protocol")]
|
||||||
|
|
||||||
if not docs:
|
if not docs:
|
||||||
return empty("לא נמצאו כתבי טענות בתיק.")
|
return empty("לא נמצאו כתבי טענות בתיק.")
|
||||||
|
|||||||
@@ -516,9 +516,18 @@ async def export_docx(case_number: str, output_path: str = "") -> str:
|
|||||||
|
|
||||||
# ── Interim draft (pre-ruling) ────────────────────────────────────
|
# ── Interim draft (pre-ruling) ────────────────────────────────────
|
||||||
|
|
||||||
# Blocks written for the interim draft, in display order.
|
# Blocks written for the interim draft, in a FIXED order.
|
||||||
# This is the same content the chair sees in the final decision (same template,
|
# This is the same content the chair sees in the final decision (same template,
|
||||||
# same skill, same prompts) — minus opening, ruling, summary, signatures.
|
# same skill, same prompts, same single canonical write path — block_writer) —
|
||||||
|
# minus ruling, summary, signatures.
|
||||||
|
#
|
||||||
|
# Determinism (#204 / WS5): the interim draft is STRUCTURALLY deterministic.
|
||||||
|
# Each block is generated by block_writer.write_block, which now pins the model
|
||||||
|
# (Opus 4.8) and a per-block reasoning effort, and uses fixed structural prompts
|
||||||
|
# (e.g. block-he's opening is always "לפנינו ערר…"). The display order on export
|
||||||
|
# is fixed separately by docx_exporter._INTERIM_BLOCK_ORDER. There is no parallel
|
||||||
|
# generation path (G2): write_interim_draft is a thin orchestrator over the same
|
||||||
|
# write_and_store_block used by the full decision.
|
||||||
_INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
|
_INTERIM_BLOCKS = ["block-he", "block-vav", "block-tet", "block-zayin", "block-chet"]
|
||||||
|
|
||||||
|
|
||||||
@@ -571,6 +580,73 @@ async def get_appraiser_facts(case_number: str) -> str:
|
|||||||
return err(str(e))
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_protocol(case_number: str) -> str:
|
||||||
|
"""ניתוח השוואתי של פרוטוקול-דיון מול כתבי-הטענות (WS4 / #203).
|
||||||
|
|
||||||
|
מזהה אילו טענות **ירדו** (נזנחו בדיון), אילו **חוזקו**, ואילו **עלו חדשות**,
|
||||||
|
מחדד את השאלות המשפטיות, ומחלץ את נתוני הכותרת (א–ד) מהפרוטוקול. התוצאה נכנסת
|
||||||
|
ל"ידע-התיק" (טבלת protocol_analysis), זמינה לסוכני הניתוח והכתיבה; תאריך-הדיון
|
||||||
|
מוזן חזרה לעמודה הקנונית cases.hearing_date.
|
||||||
|
|
||||||
|
דורש פרוטוקול מתויג doc_type='protocol' + טיעונים מאוגדים
|
||||||
|
(aggregate_claims_to_arguments). רץ עם Claude מקומי (Opus 4.8, effort=high);
|
||||||
|
re-run מחליף את הניתוח הקודם לאותו פרוטוקול (idempotent).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import protocol_analyzer
|
||||||
|
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
try:
|
||||||
|
result = await protocol_analyzer.analyze_protocol(case_id)
|
||||||
|
await audit.log_action_safe(
|
||||||
|
"analyze_protocol", case_id=case_id,
|
||||||
|
details={"status": result.get("status"), "total": result.get("total", 0)},
|
||||||
|
)
|
||||||
|
return ok(result)
|
||||||
|
except Exception as e:
|
||||||
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_protocol_analysis(case_number: str, change_type: str = "") -> str:
|
||||||
|
"""קריאת ניתוח-הפרוטוקול שכבר חולץ — ללא הרצת ניתוח-מחדש (INV-TOOL4).
|
||||||
|
|
||||||
|
ה-get המקביל ל-analyze_protocol: מחזיר את רשומות ידע-התיק השמורות
|
||||||
|
(ירדה/חוזקה/חדשה + שאלה-מחודדת + ציטוט-מבסס), בלי קריאת-LLM יקרה.
|
||||||
|
מחזיר רשימה ריקה אם הניתוח טרם רץ (status=ok, count=0) — לא שגיאה.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר
|
||||||
|
change_type: סינון (dropped/strengthened/newly_raised). ריק = הכל.
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import protocol_analyzer
|
||||||
|
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
if change_type and change_type not in protocol_analyzer.VALID_CHANGE_TYPES:
|
||||||
|
return err(
|
||||||
|
f"change_type לא תקין: {change_type}",
|
||||||
|
data={"allowed": sorted(protocol_analyzer.VALID_CHANGE_TYPES)},
|
||||||
|
)
|
||||||
|
case_id = UUID(case["id"])
|
||||||
|
try:
|
||||||
|
rows = await db.list_protocol_analysis(
|
||||||
|
case_id, change_type=change_type or None,
|
||||||
|
)
|
||||||
|
return ok({
|
||||||
|
"case_number": case_number,
|
||||||
|
"count": len(rows),
|
||||||
|
"analysis": rows,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
||||||
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
|
"""כתיבת ארבעת הבלוקים לטיוטת ביניים: רקע (ו), תכניות+היתרים (ט),
|
||||||
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —
|
טענות הצדדים (ז), הליכים (ח). אם לא חולצו עובדות שמאיות עדיין —
|
||||||
|
|||||||
141
mcp-server/tests/test_protocol_analyzer.py
Normal file
141
mcp-server/tests/test_protocol_analyzer.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Tests for the comparative protocol analyzer (WS4 / #203).
|
||||||
|
|
||||||
|
Covers the pure (non-LLM, non-DB) logic: the anti-hallucination normalization
|
||||||
|
gate, protocol-document discovery, claim_type tagging for protocols, and the
|
||||||
|
block-ז filter that keeps oral hearing arguments out of the original-pleadings
|
||||||
|
summary (INV-WR4).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from legal_mcp.services import protocol_analyzer as pa
|
||||||
|
from legal_mcp.services.claims_extractor import _infer_claim_type
|
||||||
|
|
||||||
|
|
||||||
|
# ── claim_type tagging — protocol distinct from written pleadings ───────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("doc_type,title,expected", [
|
||||||
|
("protocol", "פרוטוקול דיון", "protocol"),
|
||||||
|
("appeal", "פרוטוקול הדיון מיום 1.1", "protocol"), # title-based
|
||||||
|
("appeal", "כתב ערר", "claim"),
|
||||||
|
("response", "כתב תשובה", "response"),
|
||||||
|
("response", "תגובת המשיבה", "reply"),
|
||||||
|
])
|
||||||
|
def test_infer_claim_type(doc_type, title, expected):
|
||||||
|
assert _infer_claim_type(doc_type, title) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# ── _find_protocol ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_find_protocol_by_doc_type():
|
||||||
|
docs = [
|
||||||
|
{"id": "1", "doc_type": "appeal", "title": "כתב ערר"},
|
||||||
|
{"id": "2", "doc_type": "protocol", "title": "פרוטוקול"},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(docs)["id"] == "2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_by_title_fallback():
|
||||||
|
docs = [
|
||||||
|
{"id": "1", "doc_type": "reference", "title": "פרוטוקול הדיון"},
|
||||||
|
{"id": "2", "doc_type": "appeal", "title": "כתב ערר"},
|
||||||
|
]
|
||||||
|
assert pa._find_protocol(docs)["id"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_protocol_none():
|
||||||
|
docs = [{"id": "1", "doc_type": "appeal", "title": "כתב ערר"}]
|
||||||
|
assert pa._find_protocol(docs) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── _normalize_change — anti-hallucination gate (INV-AH) ───────────────────
|
||||||
|
|
||||||
|
_AID = "11111111-1111-1111-1111-111111111111"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_change_valid():
|
||||||
|
row = pa._normalize_change(
|
||||||
|
{
|
||||||
|
"change_type": "dropped",
|
||||||
|
"party_role": "appellant",
|
||||||
|
"argument_id": _AID,
|
||||||
|
"argument_title": "טענת השיהוי",
|
||||||
|
"summary": "העורר ויתר על הטענה בדיון.",
|
||||||
|
"sharpened_question": "האם נותרה טענת סף?",
|
||||||
|
"evidence_quote": "ב\"כ העורר: איננו עומדים על טענת השיהוי.",
|
||||||
|
"page_number": 3,
|
||||||
|
},
|
||||||
|
valid_argument_ids={_AID},
|
||||||
|
)
|
||||||
|
assert row is not None
|
||||||
|
assert row["change_type"] == "dropped"
|
||||||
|
assert str(row["argument_id"]) == _AID
|
||||||
|
assert row["page_number"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_change_drops_row_without_evidence_quote():
|
||||||
|
# quote-or-retract: no verbatim support → row is rejected at source.
|
||||||
|
row = pa._normalize_change(
|
||||||
|
{"change_type": "strengthened", "summary": "חוזקה", "evidence_quote": ""},
|
||||||
|
valid_argument_ids=set(),
|
||||||
|
)
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_change_drops_row_without_summary():
|
||||||
|
row = pa._normalize_change(
|
||||||
|
{"change_type": "dropped", "summary": "", "evidence_quote": "ציטוט"},
|
||||||
|
valid_argument_ids=set(),
|
||||||
|
)
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_change_rejects_bad_change_type():
|
||||||
|
row = pa._normalize_change(
|
||||||
|
{"change_type": "modified", "summary": "x", "evidence_quote": "y"},
|
||||||
|
valid_argument_ids=set(),
|
||||||
|
)
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_change_drops_hallucinated_argument_id():
|
||||||
|
# An argument_id that isn't one of this case's arguments must be dropped
|
||||||
|
# (FK safety) — but the row itself, being newly_raised-shaped, survives.
|
||||||
|
row = pa._normalize_change(
|
||||||
|
{
|
||||||
|
"change_type": "newly_raised",
|
||||||
|
"party_role": "respondent",
|
||||||
|
"argument_id": "99999999-9999-9999-9999-999999999999",
|
||||||
|
"summary": "סוגיה חדשה שעלתה בדיון.",
|
||||||
|
"evidence_quote": "ב\"כ המשיבה העלה לראשונה את שאלת הסמכות.",
|
||||||
|
},
|
||||||
|
valid_argument_ids={_AID},
|
||||||
|
)
|
||||||
|
assert row is not None
|
||||||
|
assert row["argument_id"] is None
|
||||||
|
assert row["change_type"] == "newly_raised"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_change_invalid_party_role_blanked():
|
||||||
|
row = pa._normalize_change(
|
||||||
|
{
|
||||||
|
"change_type": "strengthened",
|
||||||
|
"party_role": "judge", # not a valid party
|
||||||
|
"summary": "חוזקה הטענה.",
|
||||||
|
"evidence_quote": "ציטוט מבסס.",
|
||||||
|
},
|
||||||
|
valid_argument_ids=set(),
|
||||||
|
)
|
||||||
|
assert row is not None
|
||||||
|
assert row["party_role"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_change_truncates_quote():
|
||||||
|
long_quote = "א" * 500
|
||||||
|
row = pa._normalize_change(
|
||||||
|
{"change_type": "dropped", "summary": "x", "evidence_quote": long_quote},
|
||||||
|
valid_argument_ids=set(),
|
||||||
|
)
|
||||||
|
assert row is not None
|
||||||
|
assert len(row["evidence_quote"]) == 200
|
||||||
@@ -49,9 +49,9 @@ function BlockRow({ block }: { block: McpBlock }) {
|
|||||||
<Badge variant="outline" className="text-[0.7rem] font-mono" dir="ltr">
|
<Badge variant="outline" className="text-[0.7rem] font-mono" dir="ltr">
|
||||||
{block.model}
|
{block.model}
|
||||||
</Badge>
|
</Badge>
|
||||||
{isLLM && block.temperature !== null && (
|
{isLLM && block.effort !== null && (
|
||||||
<Badge variant="outline" className="text-[0.7rem]">
|
<Badge variant="outline" className="text-[0.7rem]" dir="ltr">
|
||||||
temp <span className="tabular-nums">{block.temperature}</span>
|
effort <span className="tabular-nums">{block.effort}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{block.max_tokens !== null && (
|
{block.max_tokens !== null && (
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ export type McpBlock = {
|
|||||||
title: string;
|
title: string;
|
||||||
gen_type: string;
|
gen_type: string;
|
||||||
model: string;
|
model: string;
|
||||||
temperature: number | null;
|
// The live generation knob (#204). Replaces the deprecated `temperature` —
|
||||||
|
// Opus 4.7/4.8 have no temperature param. null for template ("script") blocks.
|
||||||
|
effort: string | null;
|
||||||
max_tokens: number | null;
|
max_tokens: number | null;
|
||||||
creac_role: string | null;
|
creac_role: string | null;
|
||||||
jwm_purpose: string | null;
|
jwm_purpose: string | null;
|
||||||
|
|||||||
10
web/app.py
10
web/app.py
@@ -4678,15 +4678,21 @@ async def api_mcp_blocks():
|
|||||||
"block-yod-alef": "Disposition",
|
"block-yod-alef": "Disposition",
|
||||||
"block-yod-bet": "Disposition (signatures)",
|
"block-yod-bet": "Disposition (signatures)",
|
||||||
}
|
}
|
||||||
|
from legal_mcp.services.block_writer import GENERATION_MODEL
|
||||||
|
|
||||||
blocks = []
|
blocks = []
|
||||||
for block_id, cfg in sorted(BLOCK_CONFIG.items(), key=lambda kv: kv[1]["index"]):
|
for block_id, cfg in sorted(BLOCK_CONFIG.items(), key=lambda kv: kv[1]["index"]):
|
||||||
|
is_ai = cfg["model"] == "ai"
|
||||||
blocks.append({
|
blocks.append({
|
||||||
"id": block_id,
|
"id": block_id,
|
||||||
"index": cfg["index"],
|
"index": cfg["index"],
|
||||||
"title": cfg["title"],
|
"title": cfg["title"],
|
||||||
"gen_type": cfg["gen_type"],
|
"gen_type": cfg["gen_type"],
|
||||||
"model": cfg["model"],
|
# AI blocks are pinned to one model (#204); template blocks = "script".
|
||||||
"temperature": cfg.get("temp"),
|
"model": GENERATION_MODEL if is_ai else cfg["model"],
|
||||||
|
# The live generation knob (replaces the deprecated `temperature`,
|
||||||
|
# which is not a real param on Opus 4.7/4.8). None for template blocks.
|
||||||
|
"effort": cfg.get("effort") if is_ai else None,
|
||||||
"max_tokens": cfg.get("max_tokens"),
|
"max_tokens": cfg.get("max_tokens"),
|
||||||
"creac_role": CREAC_ROLE.get(block_id),
|
"creac_role": CREAC_ROLE.get(block_id),
|
||||||
"jwm_purpose": JWM_PURPOSE.get(block_id),
|
"jwm_purpose": JWM_PURPOSE.get(block_id),
|
||||||
|
|||||||
Reference in New Issue
Block a user