feat(plans): מרשם-תכניות קנוני (V38) + נוסח-ציטוט אחיד דטרמיניסטי לבלוק ט
מוסיף ישות קנונית לתכניות בניין-עיר (תב"ע) שחוזרות בין תיקים — SSOT לזהות+תוקף (פרסום למתן תוקף ברשומות + מס' ילקוט-הפרסומים) + משפט-ייעוד — במקום גזירה-מחדש מהשומות בכל תיק. בלוק ט מצטט את התוקף בנוסח אחיד דטרמיניסטי (format_plan_citation), כך שתאריך-פרסום/מס'-ילקוט לעולם לא מהוזים ע"י ה-LLM. - DB: טבלת plans (V38) + CRUD + _normalize_plan_number (G1) + format_plan_citation; upsert idempotent (G3) עם כלל-מיזוג: תוקף מאושר לא נדרס — סתירה נרשמת ב-discrepancies (G10 / אין בליעה שקטה). - services/plans_extractor.py: חילוץ עובדתי (claude CLI מקומי) → pending_review. - block_writer.py: _build_plans_registry_context מזריק משפטי-ציטוט מאושרים בלבד לבלוק ט; תכניות חסרות/לא-מאושרות מסומנות במפורש (לא נבלעות). - tools/plans.py + server.py: extract_plans / plan_get / plan_search / plan_list / plan_upsert / plan_review (שער-יו"ר G10), עם extract/get-symmetry (X9). - scripts/backfill_plans_registry.py: ייבוא מקורפוס-ההחלטות (טיוטות + סופיי-דפנה). - docs: block-schema (בלוק ט), SKILL, spec 02-data-model + 04. Invariants: G1/INV-DM2/X1 (מזהה מנורמל בכתיבה) · G2/INV-DM6 (מקור-אמת יחיד, appraiser_facts ללא שינוי) · G3 (upsert) · INV-DM4/G9 (provenance) · INV-DM5/G10 (review_status) · INV-AH (ציטוט דטרמיניסטי) · G5 (lookup לא קורפוס) · G11/block-schema (נוסח-הציטוט) · X9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -320,10 +320,25 @@ Conclusion → Rule → Explanation → Application → Conclusion.
|
|||||||
**Content model:**
|
**Content model:**
|
||||||
- Types: narrative, citation-block
|
- Types: narrative, citation-block
|
||||||
- Elements: section-heading, numbered-para, blockquote (ציטוט מהוראות תכנית)
|
- Elements: section-heading, numbered-para, blockquote (ציטוט מהוראות תכנית)
|
||||||
- Sources: הוראות תכנית (PDF), נספחי בינוי, החלטות מרכזות
|
- Sources: הוראות תכנית (PDF), נספחי בינוי, החלטות מרכזות, **מרשם-התכניות** (טבלת `plans` — זהות+תוקף קנוניים, מאושרי-יו"ר; ראה להלן)
|
||||||
|
|
||||||
|
**משפט-ציטוט-תכנית (קנוני — נוסח דפנה):**
|
||||||
|
לכל תכנית, חלק **הזהות והתוקף** נכתב בנוסח אחיד ודטרמיניסטי הנגזר ממרשם-התכניות
|
||||||
|
(`db.format_plan_citation`), כך שתאריך-הפרסום ומספר-הילקוט לעולם אינם מנוסחים מחדש
|
||||||
|
(ובכך גם לא מהוזים — INV-AH). התבנית (רשומות בלבד; חלקים בסוגריים = לפי-זמינות):
|
||||||
|
|
||||||
|
> `{שם-התכנית} פורסמה למתן תוקף ברשומות ביום {D.M.YYYY}[, י"פ {מס'}][ — {ייעוד}].`
|
||||||
|
|
||||||
|
דוגמאות-אמת מהקורפוס: *"תכנית מי/820 פורסמה למתן תוקף ביום 9.8.2001 — משנה את הוראות
|
||||||
|
תכנית מי/200…"* · *"תכנית הל/435 פורסמה למתן תוקף ביום 8.11.2007…"*. הניתוח התכנוני
|
||||||
|
(ייעוד, פרשנות) מנוסח בסגנון דפנה; **תאריך-התוקף ומספר-הילקוט — מהמרשם, ככתבם.**
|
||||||
|
תכנית שזוהתה בתיק אך **חסרה במרשם או טרם אושרה** מוזכרת בלי תאריך-תוקף (לא מנחשים).
|
||||||
|
המרשם מוזן ע"י `extract_plans` / `backfill_plans_registry.py`, ונכנס לשימוש רק
|
||||||
|
אחרי אישור-יו"ר (`plan_review`, review_status=approved — G10).
|
||||||
|
|
||||||
**Constraints:**
|
**Constraints:**
|
||||||
- MUST: ציטוט ישיר מהוראות תכנית עם הדגשת (bold) מילים מכריעות
|
- MUST: ציטוט ישיר מהוראות תכנית עם הדגשת (bold) מילים מכריעות
|
||||||
|
- MUST: לזהות+תוקף של תכנית — להשתמש במשפט-הציטוט הקנוני מהמרשם (לעיל); אסור להמציא תאריך-פרסום/מס'-ילקוט
|
||||||
- MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות
|
- MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות
|
||||||
- Dependencies: block-chet (מספור), block-vav (הגדרות תכניות)
|
- Dependencies: block-chet (מספור), block-vav (הגדרות תכניות)
|
||||||
- Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות)
|
- Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
| `chair_feedback` | הערת-יו"ר על טיוטה | `id`; FK→`cases` | `block_id`, `feedback_text`, `category`, `lesson_extracted`, `resolved` (`db.py:452-462`) |
|
| `chair_feedback` | הערת-יו"ר על טיוטה | `id`; FK→`cases` | `block_id`, `feedback_text`, `category`, `lesson_extracted`, `resolved` (`db.py:452-462`) |
|
||||||
| `missing_precedents` | תקדים חסר שהתבקש ולא נמצא | `id` | (`db.py:806`) — backlog ל-quality-at-source |
|
| `missing_precedents` | תקדים חסר שהתבקש ולא נמצא | `id` | (`db.py:806`) — backlog ל-quality-at-source |
|
||||||
| `style_corpus` | קורפוס-סגנון של דפנה (אימון) | `id`; FK→`documents` | `decision_number`, `full_text`, `practice_area`, `appeal_subtype` (`db.py:118-131`) |
|
| `style_corpus` | קורפוס-סגנון של דפנה (אימון) | `id`; FK→`documents` | `decision_number`, `full_text`, `practice_area`, `appeal_subtype` (`db.py:118-131`) |
|
||||||
|
| `plans` | מרשם-תכניות — זהות+תוקף של תב"ע, שימוש חוזר בין תיקים (V38) | `plan_number` מנורמל (`UNIQUE`) | `display_name`, `aliases`, `plan_type`, `gazette_date`, `yalkut_number`, `purpose`, `citation_formatted`, `review_status`, provenance (`source_case_number`/`source_document_id`/`model_used`), `discrepancies` (SCHEMA_V38) |
|
||||||
|
|
||||||
> שכבות-עזר נוספות (`document_image_embeddings`, `precedent_image_embeddings` — multimodal,
|
> שכבות-עזר נוספות (`document_image_embeddings`, `precedent_image_embeddings` — multimodal,
|
||||||
> `db.py:707,726`; `case_law_relations` — שרשרת-תיק, `db.py:754`; `precedent_internal_citations`
|
> `db.py:707,726`; `case_law_relations` — שרשרת-תיק, `db.py:754`; `precedent_internal_citations`
|
||||||
@@ -87,6 +88,7 @@ proceeding_type)`. לכן המזהה הקנוני הוא **(`case_number` מנו
|
|||||||
| `legal_arguments` (+`legal_argument_propositions`) | OPUS (`aggregate_claims_to_arguments`) | **חסר** (בניגוד ל-halachot) | `cited_precedents TEXT[]` (לא-FK) |
|
| `legal_arguments` (+`legal_argument_propositions`) | OPUS (`aggregate_claims_to_arguments`) | **חסר** (בניגוד ל-halachot) | `cited_precedents TEXT[]` (לא-FK) |
|
||||||
| `appraiser_facts` | OPUS (`extract_appraiser_facts`) | — | `document_id` (FK); `appraiser_side` default `''` |
|
| `appraiser_facts` | OPUS (`extract_appraiser_facts`) | — | `document_id` (FK); `appraiser_side` default `''` |
|
||||||
| `halachot` | OPUS (`halacha_extractor`) | **`review_status`** ✓ | `case_law_id` (FK); `quote_verified` |
|
| `halachot` | OPUS (`halacha_extractor`) | **`review_status`** ✓ | `case_law_id` (FK); `quote_verified` |
|
||||||
|
| `plans` | claude-local (`plans_extractor`) / ידני-יו"ר (`plan_upsert`) | **`review_status`** ✓ (כמו halachot, INV-DM5/G10) | `source_document_id` (FK); `source_case_number`; `discrepancies` (סתירת-תוקף מול שורה מאושרת — לא-דורס, §6) |
|
||||||
| `decision_blocks` / `decision_paragraphs` | Opus/script (`write_block`) | `status` | `model_used` + audit-event provenance (FU-7); `citations JSONB` ללא-FK |
|
| `decision_blocks` / `decision_paragraphs` | Opus/script (`write_block`) | `status` | `model_used` + audit-event provenance (FU-7); `citations JSONB` ללא-FK |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -72,6 +72,13 @@
|
|||||||
— שם §4. **טיוטת-ביניים** (Pre-Ruling Draft) בוחרת תת-קבוצת בלוקים (ו, ט, ז, ח) —
|
— שם §4. **טיוטת-ביניים** (Pre-Ruling Draft) בוחרת תת-קבוצת בלוקים (ו, ט, ז, ח) —
|
||||||
block-schema.md §7; שלב-החילוץ השמאי שלה (`extract_appraiser_facts`) מזין את בלוק ט.
|
block-schema.md §7; שלב-החילוץ השמאי שלה (`extract_appraiser_facts`) מזין את בלוק ט.
|
||||||
|
|
||||||
|
> **ציטוט-תכנית קנוני (בלוק ט):** הזהות והתוקף של תכנית (תאריך פרסום למתן תוקף
|
||||||
|
> ברשומות + מס' ילקוט-הפרסומים) נכתבים בנוסח אחיד **דטרמיניסטי** מ**מרשם-התכניות**
|
||||||
|
> (`plans`, [02-data-model](02-data-model.md)) דרך `db.format_plan_citation` — לא
|
||||||
|
> מנוסחים מחדש ע"י ה-LLM, ובכך לא מהוזים ([anti-hallucination-gate](../anti-hallucination-gate.md),
|
||||||
|
> INV-AH). הנוסח עצמו הוא תוכן-משפטי באחריות היו"ר ([block-schema.md](../block-schema.md)
|
||||||
|
> בלוק ט); המרשם נכנס לשימוש רק אחרי אישור-יו"ר (`review_status`, G10).
|
||||||
|
|
||||||
> **התמקדות לפי feedback היו"ר:** הסיוע מתמקד בבלוקים המהותיים (ו–יב); בלוקים א–ד
|
> **התמקדות לפי feedback היו"ר:** הסיוע מתמקד בבלוקים המהותיים (ו–יב); בלוקים א–ד
|
||||||
> ממולאים מ-template ואינם דורשים ניתוח. ראה `MEMORY.md` → "התעלם מכותרות".
|
> ממולאים מ-template ואינם דורשים ניתוח. ראה `MEMORY.md` → "התעלם מכותרות".
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from legal_mcp.tools import ( # noqa: E402
|
|||||||
training_enrichment as train_tools,
|
training_enrichment as train_tools,
|
||||||
digests as digest_tools,
|
digests as digest_tools,
|
||||||
court_fetch as cf_tools,
|
court_fetch as cf_tools,
|
||||||
|
plans as plans_tools,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -706,6 +707,58 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Planning-schemes registry (V38) — מרשם-התכניות ─────────────────
|
||||||
|
# SSOT לזהות+תוקף של תכנית, נעשה שימוש חוזר בין תיקים (G2). פלט-LLM נכנס
|
||||||
|
# pending_review וממתין לאישור-יו"ר (plan_review, G10) לפני שמשמש בבלוק ט.
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def extract_plans(case_number: str) -> str:
|
||||||
|
"""חילוץ תכניות ותוקפן מכל מסמכי התיק אל מרשם-התכניות (pending_review). ה-extract היקר."""
|
||||||
|
return await plans_tools.extract_plans(case_number)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def plan_get(plan_number: str) -> str:
|
||||||
|
"""קריאת תכנית מהמרשם לפי מספר (מנורמל; נופל ל-alias). ה-get הזול."""
|
||||||
|
return await plans_tools.plan_get(plan_number)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def plan_search(query: str, limit: int = 20) -> str:
|
||||||
|
"""חיפוש fuzzy במרשם-התכניות לפי מספר/שם/ייעוד."""
|
||||||
|
return await plans_tools.plan_search(query, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def plan_list(review_status: str = "", limit: int = 500) -> str:
|
||||||
|
"""רשימת תכניות במרשם, אופציונלית מסוננת לפי review_status (תור-אישור)."""
|
||||||
|
return await plans_tools.plan_list(review_status, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def plan_upsert(
|
||||||
|
plan_number: str,
|
||||||
|
display_name: str = "",
|
||||||
|
plan_type: str = "",
|
||||||
|
gazette_date: str = "",
|
||||||
|
yalkut_number: str = "",
|
||||||
|
purpose: str = "",
|
||||||
|
review_status: str = "approved",
|
||||||
|
aliases: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""כתיבה/עריכה ידנית מבוקרת של תכנית במרשם (ברירת-מחדל approved — קלט-יו"ר). מנרמל בכתיבה (G1)."""
|
||||||
|
return await plans_tools.plan_upsert(
|
||||||
|
plan_number, display_name, plan_type, gazette_date,
|
||||||
|
yalkut_number, purpose, review_status, aliases,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
async def plan_review(plan_id: str, status: str) -> str:
|
||||||
|
"""שער-היו"ר (G10): אישור/דחייה/איפוס של תכנית במרשם (approved/rejected/pending_review)."""
|
||||||
|
return await plans_tools.plan_review(plan_id, status)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
async def write_interim_draft(case_number: str, instructions: str = "") -> str:
|
||||||
"""כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט."""
|
"""כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט."""
|
||||||
|
|||||||
@@ -196,6 +196,11 @@ BLOCK_PROMPTS = {
|
|||||||
1. **תכניות חלות** — מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות. ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות.
|
1. **תכניות חלות** — מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות. ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות.
|
||||||
2. **תת-פרק היתרים** — כותרת משנה "היתרים" (או "היתרי בנייה שניתנו במקרקעין"). פירוט ההיתרים הרלוונטיים על פי השומות שהוגשו לתיק.
|
2. **תת-פרק היתרים** — כותרת משנה "היתרים" (או "היתרי בנייה שניתנו במקרקעין"). פירוט ההיתרים הרלוונטיים על פי השומות שהוגשו לתיק.
|
||||||
|
|
||||||
|
## ציטוט תכנית ותוקפה (קריטי — מרשם-התכניות):
|
||||||
|
- לכל תכנית, לחלק **הזהות והתוקף** (מספר-התכנית + מתי פורסמה למתן תוקף ברשומות + מס' ילקוט-הפרסומים) — השתמש **ככתבו** במשפט-הציטוט הקנוני המופיע תחת "מרשם-התכניות" למטה. **אל תמציא** תאריך-פרסום או מספר-ילקוט.
|
||||||
|
- את הייעוד והניתוח התכנוני אתה רשאי לנסח בסגנון דפנה; את תאריך-התוקף ומספר-הילקוט — לעולם לא.
|
||||||
|
- אם תכנית זוהתה בתיק אך **חסרה במרשם או טרם אושרה** — הזכר את התכנית בלי לקבוע תאריך-תוקף, ואל תנחש תאריך.
|
||||||
|
|
||||||
## כללי ציון סתירות בין שמאים (קריטי):
|
## כללי ציון סתירות בין שמאים (קריטי):
|
||||||
- אם שני שמאים או יותר מסרו מידע שונה על אותה תכנית או היתר — חובה לסמן זאת במפורש בנוסח ניטרלי, למשל:
|
- אם שני שמאים או יותר מסרו מידע שונה על אותה תכנית או היתר — חובה לסמן זאת במפורש בנוסח ניטרלי, למשל:
|
||||||
> "יצוין כי שמאי הוועדה ציין כי תכנית פלונית חלה על המקרקעין במלואה, בעוד שמאי העורר סבר כי חלקה של התכנית בלבד חל"
|
> "יצוין כי שמאי הוועדה ציין כי תכנית פלונית חלה על המקרקעין במלואה, בעוד שמאי העורר סבר כי חלקה של התכנית בלבד חל"
|
||||||
@@ -215,6 +220,9 @@ BLOCK_PROMPTS = {
|
|||||||
## תכניות שזוהו (ממטא-דאטה של מסמכים):
|
## תכניות שזוהו (ממטא-דאטה של מסמכים):
|
||||||
{plans_context}
|
{plans_context}
|
||||||
|
|
||||||
|
## מרשם-התכניות — משפטי-ציטוט קנוניים (מקור-אמת לתוקף; השתמש ככתבם):
|
||||||
|
{plans_registry_context}
|
||||||
|
|
||||||
## עובדות שמאיות שחולצו (תכניות + היתרים, פרק לכל שמאי):
|
## עובדות שמאיות שחולצו (תכניות + היתרים, פרק לכל שמאי):
|
||||||
{appraiser_facts_context}
|
{appraiser_facts_context}
|
||||||
|
|
||||||
@@ -333,6 +341,7 @@ async def write_block(
|
|||||||
claims_context = await _build_claims_context(case_id)
|
claims_context = await _build_claims_context(case_id)
|
||||||
direction_context = _build_direction_context(decision)
|
direction_context = _build_direction_context(decision)
|
||||||
plans_context = await _build_plans_context(case_id)
|
plans_context = await _build_plans_context(case_id)
|
||||||
|
plans_registry_context = await _build_plans_registry_context(case_id)
|
||||||
daphna_style_exemplars, case_law_citations, _precedent_case_law_ids = (
|
daphna_style_exemplars, case_law_citations, _precedent_case_law_ids = (
|
||||||
await _build_precedents_context(case_id, block_id)
|
await _build_precedents_context(case_id, block_id)
|
||||||
)
|
)
|
||||||
@@ -371,6 +380,7 @@ async def write_block(
|
|||||||
claims_context=claims_context,
|
claims_context=claims_context,
|
||||||
direction_context=direction_context,
|
direction_context=direction_context,
|
||||||
plans_context=plans_context,
|
plans_context=plans_context,
|
||||||
|
plans_registry_context=plans_registry_context,
|
||||||
daphna_style_exemplars=daphna_style_exemplars,
|
daphna_style_exemplars=daphna_style_exemplars,
|
||||||
case_law_citations=case_law_citations,
|
case_law_citations=case_law_citations,
|
||||||
style_context=style_context,
|
style_context=style_context,
|
||||||
@@ -580,6 +590,60 @@ async def _build_plans_context(case_id: UUID) -> str:
|
|||||||
return "(לא זוהו תכניות)"
|
return "(לא זוהו תכניות)"
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_plans_registry_context(case_id: UUID) -> str:
|
||||||
|
"""Render chair-APPROVED canonical plan citation sentences from the registry (V38).
|
||||||
|
|
||||||
|
The identity+validity clause is deterministic (db.format_plan_citation), so the
|
||||||
|
writer never invents publication dates or ילקוט numbers (INV-AH). Plans seen in
|
||||||
|
the case but absent from the registry — or present yet not approved — are listed
|
||||||
|
explicitly as gaps, never silently dropped (חוקה §6, אין בליעה שקטה).
|
||||||
|
"""
|
||||||
|
idents: set[str] = set()
|
||||||
|
for f in await db.list_appraiser_facts(case_id, fact_type="plan"):
|
||||||
|
if f.get("identifier"):
|
||||||
|
idents.add(f["identifier"])
|
||||||
|
for doc in await db.list_documents(case_id):
|
||||||
|
metadata = doc.get("metadata") or {}
|
||||||
|
if isinstance(metadata, str):
|
||||||
|
metadata = json.loads(metadata)
|
||||||
|
for p in (metadata.get("references", {}) or {}).get("plans", []):
|
||||||
|
name = (p.get("plan_name") or "").strip()
|
||||||
|
if name:
|
||||||
|
idents.add(name)
|
||||||
|
|
||||||
|
if not idents:
|
||||||
|
return "(לא זוהו תכניות בתיק. הרץ extract_plans ואשר אותן במרשם-התכניות.)"
|
||||||
|
|
||||||
|
approved: list[dict] = []
|
||||||
|
unapproved: list[dict] = []
|
||||||
|
missing: list[str] = []
|
||||||
|
seen_numbers: set[str] = set()
|
||||||
|
for ident in sorted(idents):
|
||||||
|
plan = await db.get_plan_by_number(ident)
|
||||||
|
if plan is None:
|
||||||
|
missing.append(ident)
|
||||||
|
continue
|
||||||
|
if plan["plan_number"] in seen_numbers:
|
||||||
|
continue
|
||||||
|
seen_numbers.add(plan["plan_number"])
|
||||||
|
(approved if plan["review_status"] == "approved" else unapproved).append(plan)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
if approved:
|
||||||
|
lines.append("### משפטי-ציטוט קנוניים (מאושרים — השתמש בהם ככתבם לזהות+תוקף):")
|
||||||
|
for p in approved:
|
||||||
|
lines.append(f"- {p.get('citation_formatted') or db.format_plan_citation(p)}")
|
||||||
|
if unapproved:
|
||||||
|
lines.append('\n### תכניות במרשם שטרם אושרו (אל תצטט מהן תוקף — ממתינות לאישור-יו"ר):')
|
||||||
|
for p in unapproved:
|
||||||
|
lines.append(f"- {p['display_name'] or p['plan_number']} (status={p['review_status']})")
|
||||||
|
if missing:
|
||||||
|
lines.append("\n### תכניות שזוהו בתיק אך חסרות במרשם (הרץ extract_plans ואשר):")
|
||||||
|
for ident in missing:
|
||||||
|
lines.append(f"- {ident}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
APPRAISER_SIDE_LABEL_HE = {
|
APPRAISER_SIDE_LABEL_HE = {
|
||||||
"committee": "שמאי הוועדה המקומית",
|
"committee": "שמאי הוועדה המקומית",
|
||||||
"appellant": "שמאי העורר",
|
"appellant": "שמאי העורר",
|
||||||
@@ -1003,6 +1067,7 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
|||||||
claims_context = await _build_claims_context(case_id)
|
claims_context = await _build_claims_context(case_id)
|
||||||
direction_context = _build_direction_context(decision)
|
direction_context = _build_direction_context(decision)
|
||||||
plans_context = await _build_plans_context(case_id)
|
plans_context = await _build_plans_context(case_id)
|
||||||
|
plans_registry_context = await _build_plans_registry_context(case_id)
|
||||||
daphna_style_exemplars, case_law_citations, _ = (
|
daphna_style_exemplars, case_law_citations, _ = (
|
||||||
await _build_precedents_context(case_id, block_id)
|
await _build_precedents_context(case_id, block_id)
|
||||||
)
|
)
|
||||||
@@ -1037,6 +1102,7 @@ async def get_block_context(case_id: UUID, block_id: str, instructions: str = ""
|
|||||||
claims_context=claims_context,
|
claims_context=claims_context,
|
||||||
direction_context=direction_context,
|
direction_context=direction_context,
|
||||||
plans_context=plans_context,
|
plans_context=plans_context,
|
||||||
|
plans_registry_context=plans_registry_context,
|
||||||
daphna_style_exemplars=daphna_style_exemplars,
|
daphna_style_exemplars=daphna_style_exemplars,
|
||||||
case_law_citations=case_law_citations,
|
case_law_citations=case_law_citations,
|
||||||
style_context=style_context,
|
style_context=style_context,
|
||||||
|
|||||||
@@ -1511,6 +1511,54 @@ SCHEMA_V37_SQL = """
|
|||||||
ALTER TABLE drain_controls ADD COLUMN IF NOT EXISTS burst_until TIMESTAMPTZ;
|
ALTER TABLE drain_controls ADD COLUMN IF NOT EXISTS burst_until TIMESTAMPTZ;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
SCHEMA_V38_SQL = """
|
||||||
|
-- plans: canonical registry of planning schemes (תכניות בניין-עיר) reused across
|
||||||
|
-- cases. SSOT for a plan's IDENTITY + VALIDITY (date published למתן תוקף ברשומות +
|
||||||
|
-- ילקוט-הפרסומים number) + one-line purpose. DISTINCT from appraiser_facts, which
|
||||||
|
-- stays the per-appraiser, per-case factual snapshot (G2 — no parallel path): the
|
||||||
|
-- same plan recurs across many cases, so its validity lives here ONCE rather than
|
||||||
|
-- being re-derived from the appraisals every time. LLM-extracted rows enter
|
||||||
|
-- 'pending_review' and are NOT used in writing until the chair approves
|
||||||
|
-- (INV-DM5/G10 — mirrors halachot.review_status). The approved validity feeds
|
||||||
|
-- block-tet's DETERMINISTIC citation sentence (format_plan_citation), so dates are
|
||||||
|
-- never hallucinated by the writer (INV-AH). Identity = the normalized plan_number
|
||||||
|
-- (G1/INV-DM2); display_name/citation_formatted are derived display fields, never
|
||||||
|
-- the key.
|
||||||
|
CREATE TABLE IF NOT EXISTS plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
plan_number TEXT NOT NULL UNIQUE, -- canonical, normalized at write (G1)
|
||||||
|
display_name TEXT NOT NULL DEFAULT '', -- surface form: "תכנית מי/820"
|
||||||
|
aliases TEXT[] NOT NULL DEFAULT '{}', -- other surface forms seen
|
||||||
|
plan_type TEXT NOT NULL DEFAULT '', -- ארצית|מחוזית|מקומית|מפורטת|כוללנית|''
|
||||||
|
gazette_date DATE, -- פורסמה למתן תוקף ברשומות ביום…
|
||||||
|
yalkut_number TEXT NOT NULL DEFAULT '', -- מס' ילקוט הפרסומים (י"פ)
|
||||||
|
purpose TEXT NOT NULL DEFAULT '', -- one-line ייעוד
|
||||||
|
citation_formatted TEXT NOT NULL DEFAULT '', -- rendered canonical sentence (derived)
|
||||||
|
review_status TEXT NOT NULL DEFAULT 'pending_review'
|
||||||
|
CHECK (review_status IN ('pending_review', 'approved', 'rejected')), -- G10 gate
|
||||||
|
-- provenance (INV-DM4/G9): where this record was learned from + by what model
|
||||||
|
source_case_number TEXT NOT NULL DEFAULT '',
|
||||||
|
source_document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
|
||||||
|
model_used TEXT NOT NULL DEFAULT '',
|
||||||
|
-- discrepancies: validity values that DIFFER from an already-approved row,
|
||||||
|
-- captured for chair adjudication instead of silently overwriting (חוקה §6 —
|
||||||
|
-- אין בליעה שקטה). Shape: [{field, old, new, source_case_number}].
|
||||||
|
discrepancies JSONB NOT NULL DEFAULT '[]',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
-- lookup-only tsvector (G5 — this is a structured registry, NOT a 4th retrieval
|
||||||
|
-- corpus; no embeddings). GENERATED → auto-maintained on content change (INV-DM3).
|
||||||
|
meta_tsv tsvector GENERATED ALWAYS AS (
|
||||||
|
to_tsvector('simple',
|
||||||
|
coalesce(plan_number, '') || ' ' ||
|
||||||
|
coalesce(display_name, '') || ' ' ||
|
||||||
|
coalesce(purpose, ''))
|
||||||
|
) STORED
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plans_review ON plans(review_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plans_meta_tsv ON plans USING gin(meta_tsv);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
# 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)
|
||||||
@@ -1529,7 +1577,7 @@ async def _run_schema_migrations(pool: asyncpg.Pool) -> None:
|
|||||||
await _apply_schema_ddl(conn)
|
await _apply_schema_ddl(conn)
|
||||||
finally:
|
finally:
|
||||||
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
|
await conn.execute("SELECT pg_advisory_unlock($1)", _MIGRATION_LOCK_KEY)
|
||||||
logger.info("Database schema initialized (v1-v37)")
|
logger.info("Database schema initialized (v1-v38)")
|
||||||
|
|
||||||
|
|
||||||
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
||||||
@@ -1571,6 +1619,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
|
|||||||
await conn.execute(SCHEMA_V35_SQL)
|
await conn.execute(SCHEMA_V35_SQL)
|
||||||
await conn.execute(SCHEMA_V36_SQL)
|
await conn.execute(SCHEMA_V36_SQL)
|
||||||
await conn.execute(SCHEMA_V37_SQL)
|
await conn.execute(SCHEMA_V37_SQL)
|
||||||
|
await conn.execute(SCHEMA_V38_SQL)
|
||||||
|
|
||||||
|
|
||||||
async def init_schema() -> None:
|
async def init_schema() -> None:
|
||||||
@@ -3375,6 +3424,280 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]:
|
|||||||
return conflicts
|
return conflicts
|
||||||
|
|
||||||
|
|
||||||
|
# ── Plans registry (V38) ──────────────────────────────────────────
|
||||||
|
# Canonical registry of planning schemes (תכניות). SSOT for a plan's identity +
|
||||||
|
# validity, reused across cases (G2). See SCHEMA_V38_SQL for the data contract.
|
||||||
|
|
||||||
|
def _normalize_plan_number(raw: str) -> str:
|
||||||
|
"""Canonical write-time form of a plan identifier (G1/INV-DM2; mirrors X1).
|
||||||
|
|
||||||
|
Deterministic and format-only — does NOT invent or drop data:
|
||||||
|
trim · unify Hebrew gershayim (״/‴ → ") · strip a leading bare scheme word
|
||||||
|
("תכנית"/"תוכנית") · collapse whitespace around '/' and runs of spaces.
|
||||||
|
NOTE: תמ"א / תב"ע are KEPT — the scheme acronym is part of the identifier; only
|
||||||
|
the generic lead word "תכנית"/"תוכנית" is stripped. The full surface form
|
||||||
|
(including "תכנית") lives in display_name, never in the key.
|
||||||
|
"""
|
||||||
|
s = (raw or "").strip()
|
||||||
|
s = s.replace("״", '"').replace("″", '"') # gershayim → ASCII "
|
||||||
|
s = re.sub(r"^(?:תכנית|תוכנית)\s+", "", s)
|
||||||
|
s = re.sub(r"\s*/\s*", "/", s)
|
||||||
|
s = " ".join(s.split())
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_plan_date(v) -> date | None:
|
||||||
|
"""Accept a date/datetime/ISO-string/DD.MM.YYYY and return a date (or None).
|
||||||
|
|
||||||
|
The extractors emit ISO (YYYY-MM-DD); DD.MM.YYYY / DD/MM/YYYY is tolerated for
|
||||||
|
safety. Unparseable → None (the validity clause is simply omitted — never guessed).
|
||||||
|
"""
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
if isinstance(v, datetime):
|
||||||
|
return v.date()
|
||||||
|
if isinstance(v, date):
|
||||||
|
return v
|
||||||
|
s = str(v).strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(s[:10])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
m = re.match(r"^\s*(\d{1,2})[./](\d{1,2})[./](\d{4})\s*$", s)
|
||||||
|
if m:
|
||||||
|
d, mo, y = (int(g) for g in m.groups())
|
||||||
|
try:
|
||||||
|
return date(y, mo, d)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def format_plan_citation(plan: dict) -> str:
|
||||||
|
"""Render the canonical block-tet citation sentence from a plan record.
|
||||||
|
|
||||||
|
DETERMINISTIC — the identity+validity clause is built from stored fields, never
|
||||||
|
by an LLM (INV-AH). Pattern (chair-approved, corpus-derived, רשומות-only):
|
||||||
|
{display} פורסמה למתן תוקף ברשומות ביום {D.M.YYYY}[, י"פ {yalkut}][ — {purpose}].
|
||||||
|
Returns '' when there is neither a display name nor a plan number.
|
||||||
|
"""
|
||||||
|
name = (plan.get("display_name") or "").strip() or (plan.get("plan_number") or "").strip()
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
sentence = name
|
||||||
|
gd = _coerce_plan_date(plan.get("gazette_date"))
|
||||||
|
if gd:
|
||||||
|
clause = f"פורסמה למתן תוקף ברשומות ביום {gd.day}.{gd.month}.{gd.year}"
|
||||||
|
yalkut = (plan.get("yalkut_number") or "").strip()
|
||||||
|
if yalkut:
|
||||||
|
clause += f', י"פ {yalkut}'
|
||||||
|
sentence = f"{name} {clause}"
|
||||||
|
purpose = (plan.get("purpose") or "").strip()
|
||||||
|
if purpose:
|
||||||
|
sentence += f" — {purpose}"
|
||||||
|
if not sentence.endswith("."):
|
||||||
|
sentence += "."
|
||||||
|
return sentence
|
||||||
|
|
||||||
|
|
||||||
|
def _plan_row_to_dict(row) -> dict | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
d["id"] = str(d["id"])
|
||||||
|
if d.get("source_document_id"):
|
||||||
|
d["source_document_id"] = str(d["source_document_id"])
|
||||||
|
if isinstance(d.get("discrepancies"), str):
|
||||||
|
d["discrepancies"] = json.loads(d["discrepancies"])
|
||||||
|
gd = d.get("gazette_date")
|
||||||
|
d["gazette_date"] = gd.isoformat() if gd else None
|
||||||
|
d["aliases"] = list(d.get("aliases") or [])
|
||||||
|
d.pop("meta_tsv", None)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_plan(
|
||||||
|
plan_number: str,
|
||||||
|
display_name: str = "",
|
||||||
|
aliases: list[str] | None = None,
|
||||||
|
plan_type: str = "",
|
||||||
|
gazette_date=None,
|
||||||
|
yalkut_number: str = "",
|
||||||
|
purpose: str = "",
|
||||||
|
review_status: str = "pending_review",
|
||||||
|
source_case_number: str = "",
|
||||||
|
source_document_id: UUID | None = None,
|
||||||
|
model_used: str = "",
|
||||||
|
) -> dict:
|
||||||
|
"""Idempotent upsert of a plan, keyed on the normalized plan_number.
|
||||||
|
|
||||||
|
G3 (idempotent) + G1 (normalize at write). Merge rule (G10 / no-silent-swallow):
|
||||||
|
• new plan → insert as given (LLM extractions arrive 'pending_review').
|
||||||
|
• existing APPROVED → NEVER overwrite validity; add unseen aliases and record any
|
||||||
|
DIFFERING validity value into `discrepancies` for the chair to adjudicate.
|
||||||
|
• existing PENDING/REJECTED → fill empty fields from the new data (merge-up).
|
||||||
|
Returns the resulting plan dict.
|
||||||
|
"""
|
||||||
|
if review_status not in ("pending_review", "approved", "rejected"):
|
||||||
|
raise ValueError(f"review_status לא חוקי: {review_status}")
|
||||||
|
num = _normalize_plan_number(plan_number)
|
||||||
|
if not num:
|
||||||
|
raise ValueError("plan_number ריק לאחר נרמול")
|
||||||
|
gd = _coerce_plan_date(gazette_date)
|
||||||
|
display_name = (display_name or "").strip() or num
|
||||||
|
incoming_aliases = [a.strip() for a in (aliases or []) if a and a.strip()]
|
||||||
|
|
||||||
|
pool = await get_pool()
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
async with conn.transaction():
|
||||||
|
ex = await conn.fetchrow(
|
||||||
|
"SELECT * FROM plans WHERE plan_number = $1 FOR UPDATE", num,
|
||||||
|
)
|
||||||
|
|
||||||
|
if ex is None:
|
||||||
|
merged_aliases = sorted({
|
||||||
|
a for a in incoming_aliases if a not in (num, display_name)
|
||||||
|
})
|
||||||
|
citation = format_plan_citation({
|
||||||
|
"display_name": display_name, "plan_number": num,
|
||||||
|
"gazette_date": gd, "yalkut_number": yalkut_number, "purpose": purpose,
|
||||||
|
})
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""INSERT INTO plans
|
||||||
|
(plan_number, display_name, aliases, plan_type, gazette_date,
|
||||||
|
yalkut_number, purpose, citation_formatted, review_status,
|
||||||
|
source_case_number, source_document_id, model_used)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||||
|
RETURNING *""",
|
||||||
|
num, display_name, merged_aliases, plan_type or "", gd,
|
||||||
|
yalkut_number or "", purpose or "", citation, review_status,
|
||||||
|
source_case_number or "", source_document_id, model_used or "",
|
||||||
|
)
|
||||||
|
return _plan_row_to_dict(row)
|
||||||
|
|
||||||
|
ex = dict(ex)
|
||||||
|
# Always accumulate newly-seen surface forms.
|
||||||
|
seen = set(ex.get("aliases") or [])
|
||||||
|
for a in incoming_aliases + [display_name]:
|
||||||
|
if a and a not in (num, ex.get("display_name") or ""):
|
||||||
|
seen.add(a)
|
||||||
|
merged_aliases = sorted(seen)
|
||||||
|
|
||||||
|
if ex["review_status"] == "approved":
|
||||||
|
# Do not touch chair-approved validity. Record any conflicting value.
|
||||||
|
disc = ex.get("discrepancies") or []
|
||||||
|
if isinstance(disc, str):
|
||||||
|
disc = json.loads(disc)
|
||||||
|
checks = (
|
||||||
|
("gazette_date", gd.isoformat() if gd else "",
|
||||||
|
ex["gazette_date"].isoformat() if ex["gazette_date"] else ""),
|
||||||
|
("yalkut_number", (yalkut_number or "").strip(), ex["yalkut_number"] or ""),
|
||||||
|
("purpose", (purpose or "").strip(), ex["purpose"] or ""),
|
||||||
|
)
|
||||||
|
for field, newval, oldval in checks:
|
||||||
|
if newval and newval != oldval:
|
||||||
|
disc.append({
|
||||||
|
"field": field, "old": oldval, "new": newval,
|
||||||
|
"source_case_number": source_case_number or "",
|
||||||
|
})
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE plans SET aliases = $2, discrepancies = $3, updated_at = now()
|
||||||
|
WHERE id = $1 RETURNING *""",
|
||||||
|
ex["id"], merged_aliases,
|
||||||
|
json.dumps(disc, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
return _plan_row_to_dict(row)
|
||||||
|
|
||||||
|
# pending / rejected → merge-up empty fields, keep existing review_status.
|
||||||
|
new_display = ex["display_name"] or display_name
|
||||||
|
new_type = ex["plan_type"] or plan_type or ""
|
||||||
|
new_gd = ex["gazette_date"] or gd
|
||||||
|
new_yalkut = ex["yalkut_number"] or yalkut_number or ""
|
||||||
|
new_purpose = ex["purpose"] or purpose or ""
|
||||||
|
citation = format_plan_citation({
|
||||||
|
"display_name": new_display, "plan_number": num,
|
||||||
|
"gazette_date": new_gd, "yalkut_number": new_yalkut, "purpose": new_purpose,
|
||||||
|
})
|
||||||
|
row = await conn.fetchrow(
|
||||||
|
"""UPDATE plans SET
|
||||||
|
display_name = $2, aliases = $3, plan_type = $4, gazette_date = $5,
|
||||||
|
yalkut_number = $6, purpose = $7, citation_formatted = $8,
|
||||||
|
source_case_number = COALESCE(NULLIF($9, ''), source_case_number),
|
||||||
|
source_document_id = COALESCE($10, source_document_id),
|
||||||
|
model_used = COALESCE(NULLIF($11, ''), model_used),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = $1 RETURNING *""",
|
||||||
|
ex["id"], new_display, merged_aliases, new_type, new_gd,
|
||||||
|
new_yalkut, new_purpose, citation,
|
||||||
|
source_case_number or "", source_document_id, model_used or "",
|
||||||
|
)
|
||||||
|
return _plan_row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_plan_by_number(plan_number: str) -> dict | None:
|
||||||
|
"""Look up a plan by normalized number; falls back to an alias match."""
|
||||||
|
num = _normalize_plan_number(plan_number)
|
||||||
|
if not num:
|
||||||
|
return None
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow("SELECT * FROM plans WHERE plan_number = $1", num)
|
||||||
|
if row is None:
|
||||||
|
row = await pool.fetchrow("SELECT * FROM plans WHERE $1 = ANY(aliases)", num)
|
||||||
|
return _plan_row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_plan_by_id(plan_id: UUID) -> dict | None:
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow("SELECT * FROM plans WHERE id = $1", plan_id)
|
||||||
|
return _plan_row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_plans(review_status: str = "", limit: int = 500) -> list[dict]:
|
||||||
|
"""List plans, optionally filtered by review_status (backlog view)."""
|
||||||
|
pool = await get_pool()
|
||||||
|
if review_status:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT * FROM plans WHERE review_status = $1 ORDER BY updated_at DESC LIMIT $2",
|
||||||
|
review_status, limit,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT * FROM plans ORDER BY review_status, updated_at DESC LIMIT $1", limit,
|
||||||
|
)
|
||||||
|
return [_plan_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def search_plans(query: str, limit: int = 20) -> list[dict]:
|
||||||
|
"""Fuzzy lookup by number / display name / purpose (ILIKE + tsvector)."""
|
||||||
|
q = (query or "").strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
pool = await get_pool()
|
||||||
|
pattern = f"%{q}%"
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"""SELECT * FROM plans
|
||||||
|
WHERE plan_number ILIKE $1 OR display_name ILIKE $1 OR purpose ILIKE $1
|
||||||
|
OR meta_tsv @@ plainto_tsquery('simple', $2)
|
||||||
|
ORDER BY review_status, updated_at DESC LIMIT $3""",
|
||||||
|
pattern, q, limit,
|
||||||
|
)
|
||||||
|
return [_plan_row_to_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def set_plan_review_status(plan_id: UUID, status: str) -> dict | None:
|
||||||
|
"""Chair gate (G10): approve / reject / reset a plan record."""
|
||||||
|
if status not in ("pending_review", "approved", "rejected"):
|
||||||
|
raise ValueError(f"review_status לא חוקי: {status}")
|
||||||
|
pool = await get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"UPDATE plans SET review_status = $2, updated_at = now() WHERE id = $1 RETURNING *",
|
||||||
|
plan_id, status,
|
||||||
|
)
|
||||||
|
return _plan_row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
# ── V7: External precedent library + halachot ─────────────────────
|
# ── V7: External precedent library + halachot ─────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
193
mcp-server/src/legal_mcp/services/plans_extractor.py
Normal file
193
mcp-server/src/legal_mcp/services/plans_extractor.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""חילוץ מובנה של תכניות בניין-עיר ותוקפן לתוך מרשם-התכניות (טבלת plans).
|
||||||
|
|
||||||
|
תכלית: לבנות SSOT קנוני לתכניות שחוזרות בין תיקים — מספר-תכנית מנורמל, תוקף
|
||||||
|
(פרסום למתן תוקף ברשומות + מס' ילקוט-הפרסומים), ומשפט-ייעוד אחד — כדי שבלוק ט
|
||||||
|
יצטט אותן בנוסח אחיד ודטרמיניסטי (format_plan_citation) במקום לגזור מחדש מהשומות
|
||||||
|
בכל תיק (G2).
|
||||||
|
|
||||||
|
חילוץ עובדתי בלבד. הרשומות נכנסות review_status='pending_review' וממתינות
|
||||||
|
לאישור-יו"ר (INV-DM5/G10) לפני שישמשו בכתיבה. הקריאות ל-LLM מתבצעות דרך
|
||||||
|
claude_session המקומי בלבד (כמו שאר המחלצים) — לא Anthropic SDK ישיר.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import claude_session, db
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Descriptive provenance tag for INV-DM4 (we call the local claude CLI session,
|
||||||
|
# not a pinned model id — the session model is whatever is configured).
|
||||||
|
MODEL_TAG = "claude_local"
|
||||||
|
|
||||||
|
|
||||||
|
EXTRACT_PLANS_PROMPT = """אתה מחלץ מידע עובדתי על תכניות בניין-עיר (תב"ע) עבור מרשם-תכניות של ועדת ערר.
|
||||||
|
|
||||||
|
תפקידך: לחלץ כל תכנית שמצוין לגביה **תוקף** — מתי פורסמה למתן תוקף (ברשומות / בילקוט הפרסומים) — או ייעוד ברור.
|
||||||
|
|
||||||
|
## כללים
|
||||||
|
- עובדתי בלבד. אל תסיק, אל תפרש, ואל תמציא תאריך שאינו כתוב במפורש.
|
||||||
|
- חלץ רק תכניות שמופיע לגביהן מידע-תוקף או ייעוד ברור. דלג על אזכור-אגב ללא פרטים.
|
||||||
|
- gazette_date: תאריך הפרסום למתן תוקף, בפורמט ISO (YYYY-MM-DD). אם לא צוין תאריך — השאר "".
|
||||||
|
- yalkut_number: מספר ילקוט הפרסומים / י"פ אם צוין (למשל "5965"). אחרת "".
|
||||||
|
- display_name: שם-התכנית כפי שמקובל לכתוב בהחלטה, כולל המילה "תכנית" (למשל "תכנית מי/820").
|
||||||
|
- plan_number: מזהה-התכנית בלבד, ללא המילה "תכנית" (למשל "מי/820", "5166/ב", "152-0132902", "תמ\\"א 38").
|
||||||
|
- plan_type: אחד מ- ארצית / מחוזית / מקומית / מפורטת / כוללנית, אם ניתן לקבוע מהטקסט. אחרת "".
|
||||||
|
- purpose: משפט-ייעוד אחד תמציתי (מה התכנית עושה/משנה/קובעת). אחרת "".
|
||||||
|
- raw_quote: ציטוט מילולי של המשפט שממנו חולץ התוקף, עד 200 תווים.
|
||||||
|
|
||||||
|
## פלט
|
||||||
|
החזר JSON array בלבד — ללא markdown, ללא הסברים:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"plan_number": "מי/820",
|
||||||
|
"display_name": "תכנית מי/820",
|
||||||
|
"plan_type": "מקומית",
|
||||||
|
"gazette_date": "2001-08-09",
|
||||||
|
"yalkut_number": "",
|
||||||
|
"purpose": "משנה את הוראות תכנית מי/200 ומרחיבה את השימושים המותרים באזור חקלאי",
|
||||||
|
"raw_quote": "תוכנית מי/820 ... פורסמה למתן תוקף ביום 9.8.2001"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
אם אין תכניות עם מידע-תוקף/ייעוד — החזר [].
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_text(text: str, max_chars: int = 25000) -> list[str]:
|
||||||
|
"""Split a long document at paragraph boundaries (mirrors appraiser extractor)."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_plans_from_text(text: str) -> list[dict]:
|
||||||
|
"""Extract plan candidates from arbitrary text via the local claude session.
|
||||||
|
|
||||||
|
Returns a list of normalized candidate dicts (not yet persisted). Factual only.
|
||||||
|
"""
|
||||||
|
candidates: list[dict] = []
|
||||||
|
chunks = _chunk_text(text)
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
chunk_label = f" (חלק {i+1}/{len(chunks)})" if len(chunks) > 1 else ""
|
||||||
|
prompt = (
|
||||||
|
f"{EXTRACT_PLANS_PROMPT}\n\n"
|
||||||
|
f"--- תחילת מסמך{chunk_label} ---\n{chunk}\n--- סוף מסמך ---"
|
||||||
|
)
|
||||||
|
result = await claude_session.query_json(prompt, tools="") # no tool_use
|
||||||
|
if not isinstance(result, list):
|
||||||
|
logger.warning(
|
||||||
|
"extract_plans_from_text: chunk %d returned non-list (%s)",
|
||||||
|
i, type(result).__name__,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
for item in result:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
num = (item.get("plan_number") or "").strip()
|
||||||
|
if not num:
|
||||||
|
continue
|
||||||
|
candidates.append({
|
||||||
|
"plan_number": num,
|
||||||
|
"display_name": (item.get("display_name") or "").strip(),
|
||||||
|
"plan_type": (item.get("plan_type") or "").strip(),
|
||||||
|
"gazette_date": (item.get("gazette_date") or "").strip(),
|
||||||
|
"yalkut_number": (item.get("yalkut_number") or "").strip(),
|
||||||
|
"purpose": (item.get("purpose") or "").strip(),
|
||||||
|
"raw_quote": (item.get("raw_quote") or "").strip(),
|
||||||
|
})
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_candidates(
|
||||||
|
candidates: list[dict],
|
||||||
|
*,
|
||||||
|
source_case_number: str = "",
|
||||||
|
source_document_id: UUID | None = None,
|
||||||
|
model_used: str = MODEL_TAG,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Upsert extracted candidates into the registry as pending_review (G10)."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for c in candidates:
|
||||||
|
try:
|
||||||
|
plan = await db.upsert_plan(
|
||||||
|
plan_number=c["plan_number"],
|
||||||
|
display_name=c.get("display_name", ""),
|
||||||
|
plan_type=c.get("plan_type", ""),
|
||||||
|
gazette_date=c.get("gazette_date") or None,
|
||||||
|
yalkut_number=c.get("yalkut_number", ""),
|
||||||
|
purpose=c.get("purpose", ""),
|
||||||
|
review_status="pending_review",
|
||||||
|
source_case_number=source_case_number,
|
||||||
|
source_document_id=source_document_id,
|
||||||
|
model_used=model_used,
|
||||||
|
)
|
||||||
|
out.append(plan)
|
||||||
|
except ValueError as e:
|
||||||
|
# Don't swallow — surface the bad candidate so it isn't silently dropped.
|
||||||
|
logger.warning("upsert_candidates: skipped %r — %s", c.get("plan_number"), e)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_plans_for_case(case_id: UUID) -> dict:
|
||||||
|
"""Extract plan candidates from every document with text in the case.
|
||||||
|
|
||||||
|
Upserts them into the registry as pending_review. Thorough by design (we do not
|
||||||
|
pre-filter by doc_type — a plan's validity can be cited anywhere). Returns a
|
||||||
|
summary for serialization back to the caller.
|
||||||
|
"""
|
||||||
|
case = await db.get_case(case_id)
|
||||||
|
source_case_number = (case or {}).get("case_number", "") or ""
|
||||||
|
docs = await db.list_documents(case_id)
|
||||||
|
|
||||||
|
by_doc: list[dict] = []
|
||||||
|
seen_numbers: dict[str, dict] = {}
|
||||||
|
total_candidates = 0
|
||||||
|
for doc in docs:
|
||||||
|
text = await db.get_document_text(UUID(doc["id"]))
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
cands = await extract_plans_from_text(text)
|
||||||
|
except Exception as e: # noqa: BLE001 — record, don't swallow
|
||||||
|
logger.exception("extract_plans_for_case: failed on doc %s", doc["id"])
|
||||||
|
by_doc.append({
|
||||||
|
"document_id": doc["id"], "title": doc.get("title", ""),
|
||||||
|
"status": "error", "error": str(e), "candidates": 0,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
plans = await upsert_candidates(
|
||||||
|
cands,
|
||||||
|
source_case_number=source_case_number,
|
||||||
|
source_document_id=UUID(doc["id"]),
|
||||||
|
)
|
||||||
|
total_candidates += len(cands)
|
||||||
|
for p in plans:
|
||||||
|
seen_numbers[p["plan_number"]] = p
|
||||||
|
by_doc.append({
|
||||||
|
"document_id": doc["id"], "title": doc.get("title", ""),
|
||||||
|
"status": "completed", "candidates": len(cands),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "completed",
|
||||||
|
"case_number": source_case_number,
|
||||||
|
"documents_scanned": len(by_doc),
|
||||||
|
"total_candidates": total_candidates,
|
||||||
|
"distinct_plans": len(seen_numbers),
|
||||||
|
"plans": list(seen_numbers.values()),
|
||||||
|
"by_document": by_doc,
|
||||||
|
}
|
||||||
138
mcp-server/src/legal_mcp/tools/plans.py
Normal file
138
mcp-server/src/legal_mcp/tools/plans.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""MCP tools for the planning-schemes registry (טבלת plans, V38).
|
||||||
|
|
||||||
|
Extract/get-symmetry per X9: `extract_plans` is the expensive LLM extraction;
|
||||||
|
`plan_get`/`plan_search`/`plan_list` are cheap reads. `plan_upsert` is the manual
|
||||||
|
chair-curated write, `plan_review` is the human approval gate (INV-DM5/G10). Every
|
||||||
|
tool returns the SSoT JSON envelope (ok/err) — GAP-48.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from legal_mcp.services import db
|
||||||
|
from legal_mcp.tools.envelope import err, ok
|
||||||
|
|
||||||
|
_VALID_STATUS = ("pending_review", "approved", "rejected")
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_plans(case_number: str) -> str:
|
||||||
|
"""חילוץ תכניות ותוקפן מכל מסמכי התיק אל מרשם-התכניות (נכנסות pending_review).
|
||||||
|
|
||||||
|
ה-extract היקר (קריאת-LLM, לא-דטרמיניסטי) שמזין את המרשם. הרשומות ממתינות
|
||||||
|
לאישור-יו"ר (plan_review) לפני שישמשו בכתיבת בלוק ט.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
case_number: מספר תיק הערר
|
||||||
|
"""
|
||||||
|
from legal_mcp.services import plans_extractor
|
||||||
|
|
||||||
|
case = await db.get_case_by_number(case_number)
|
||||||
|
if not case:
|
||||||
|
return err(f"תיק {case_number} לא נמצא.")
|
||||||
|
try:
|
||||||
|
result = await plans_extractor.extract_plans_for_case(UUID(case["id"]))
|
||||||
|
return ok(result)
|
||||||
|
except Exception as e: # noqa: BLE001 — surface, don't swallow
|
||||||
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_get(plan_number: str) -> str:
|
||||||
|
"""קריאת תכנית מהמרשם לפי מספר (מנורמל; נופל ל-alias). ה-get הזול."""
|
||||||
|
try:
|
||||||
|
plan = await db.get_plan_by_number(plan_number)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return err(str(e))
|
||||||
|
if not plan:
|
||||||
|
return err(f"תכנית '{plan_number}' לא נמצאה במרשם.")
|
||||||
|
return ok(plan)
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_search(query: str, limit: int = 20) -> str:
|
||||||
|
"""חיפוש fuzzy במרשם לפי מספר/שם/ייעוד (ILIKE + tsvector)."""
|
||||||
|
try:
|
||||||
|
results = await db.search_plans(query, limit)
|
||||||
|
return ok({"query": query, "count": len(results), "results": results})
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_list(review_status: str = "", limit: int = 500) -> str:
|
||||||
|
"""רשימת תכניות במרשם, אופציונלית מסוננת לפי review_status (תור-אישור)."""
|
||||||
|
if review_status and review_status not in _VALID_STATUS:
|
||||||
|
return err("review_status חייב להיות אחד מ: pending_review / approved / rejected")
|
||||||
|
try:
|
||||||
|
plans = await db.list_plans(review_status, limit)
|
||||||
|
return ok({
|
||||||
|
"count": len(plans),
|
||||||
|
"review_status": review_status or "all",
|
||||||
|
"plans": plans,
|
||||||
|
})
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_upsert(
|
||||||
|
plan_number: str,
|
||||||
|
display_name: str = "",
|
||||||
|
plan_type: str = "",
|
||||||
|
gazette_date: str = "",
|
||||||
|
yalkut_number: str = "",
|
||||||
|
purpose: str = "",
|
||||||
|
review_status: str = "approved",
|
||||||
|
aliases: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""כתיבה/עריכה ידנית מבוקרת של תכנית במרשם.
|
||||||
|
|
||||||
|
קלט-יו"ר ידני — ברירת-המחדל review_status='approved' (היו"ר היא הסמכות). מנרמל את
|
||||||
|
plan_number בכתיבה (G1) ו-upsert idempotent (G3). אם התכנית כבר מאושרת ותוקף-חדש
|
||||||
|
סותר — הנתון נרשם ב-discrepancies ולא דורס (G10/§6).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan_number: מזהה-התכנית (יומר לצורה קנונית) — למשל "מי/820", "תמ\\"א 38"
|
||||||
|
display_name: שם-תצוגה כולל "תכנית" (למשל "תכנית מי/820")
|
||||||
|
plan_type: ארצית/מחוזית/מקומית/מפורטת/כוללנית (אופציונלי)
|
||||||
|
gazette_date: תאריך פרסום למתן תוקף ברשומות, ISO YYYY-MM-DD (אופציונלי)
|
||||||
|
yalkut_number: מס' ילקוט הפרסומים / י"פ (אופציונלי)
|
||||||
|
purpose: משפט-ייעוד אחד (אופציונלי)
|
||||||
|
review_status: pending_review/approved/rejected (ברירת-מחדל approved)
|
||||||
|
aliases: צורות חלופיות מופרדות בפסיק (אופציונלי)
|
||||||
|
"""
|
||||||
|
if review_status not in _VALID_STATUS:
|
||||||
|
return err("review_status חייב להיות אחד מ: pending_review / approved / rejected")
|
||||||
|
alias_list = [a.strip() for a in aliases.split(",") if a.strip()] if aliases else []
|
||||||
|
try:
|
||||||
|
plan = await db.upsert_plan(
|
||||||
|
plan_number=plan_number,
|
||||||
|
display_name=display_name,
|
||||||
|
aliases=alias_list,
|
||||||
|
plan_type=plan_type,
|
||||||
|
gazette_date=gazette_date or None,
|
||||||
|
yalkut_number=yalkut_number,
|
||||||
|
purpose=purpose,
|
||||||
|
review_status=review_status,
|
||||||
|
model_used="chair_manual",
|
||||||
|
)
|
||||||
|
return ok(plan)
|
||||||
|
except ValueError as e:
|
||||||
|
return err(str(e))
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return err(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
async def plan_review(plan_id: str, status: str) -> str:
|
||||||
|
"""שער-היו"ר (G10): אישור / דחייה / איפוס של רשומת-תכנית במרשם.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan_id: מזהה ה-UUID של הרשומה (מ-plan_list/plan_get)
|
||||||
|
status: approved / rejected / pending_review
|
||||||
|
"""
|
||||||
|
if status not in _VALID_STATUS:
|
||||||
|
return err("status חייב להיות approved / rejected / pending_review")
|
||||||
|
try:
|
||||||
|
plan = await db.set_plan_review_status(UUID(plan_id), status)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return err(str(e))
|
||||||
|
if not plan:
|
||||||
|
return err(f"תכנית {plan_id} לא נמצאה.")
|
||||||
|
return ok(plan)
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
| `drain_metadata_queue.py` | python | **ריקון תור חילוץ-המטא של הפסיקה** — `process_pending_extractions(kind='metadata')` ב-batches עד ריק. רץ על **Gemini Flash** (structured JSON, `gemini_session`) — מהיר ואמין, במקום ה-claude CLI ה-agentic שפגע ב-`error_max_turns`. no-op מהיר כשריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_metadata_queue.py [batch]`. | דרך `legal-metadata-drain.config.cjs` (pm2 cron) |
|
| `drain_metadata_queue.py` | python | **ריקון תור חילוץ-המטא של הפסיקה** — `process_pending_extractions(kind='metadata')` ב-batches עד ריק. רץ על **Gemini Flash** (structured JSON, `gemini_session`) — מהיר ואמין, במקום ה-claude CLI ה-agentic שפגע ב-`error_max_turns`. no-op מהיר כשריק. הרצה ידנית: `mcp-server/.venv/bin/python scripts/drain_metadata_queue.py [batch]`. | דרך `legal-metadata-drain.config.cjs` (pm2 cron) |
|
||||||
| `legal-metadata-drain.config.cjs` | pm2/js | **תזמון כל 15 דק' של `drain_metadata_queue.py`** (cron `*/15 * * * *`, `METADATA_DRAIN_CRON` לעקיפה) — מונע סתימה של תור חילוץ-המטא ב-/precedents. דורש `GEMINI_API_KEY` ב-`~/.env`. התקנה: `pm2 start scripts/legal-metadata-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
| `legal-metadata-drain.config.cjs` | pm2/js | **תזמון כל 15 דק' של `drain_metadata_queue.py`** (cron `*/15 * * * *`, `METADATA_DRAIN_CRON` לעקיפה) — מונע סתימה של תור חילוץ-המטא ב-/precedents. דורש `GEMINI_API_KEY` ב-`~/.env`. התקנה: `pm2 start scripts/legal-metadata-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
|
||||||
| `reconcile_metadata_status.py` | python | **נרמול `metadata_extraction_status` תקוע (G1)** — שורות עם ברירת-המחדל `'pending'` שאינן בצנרת-Gemini נערמות כ-backlog-רפאים שהדריינר (סורק `*_requested_at IS NOT NULL`) לעולם לא מנקה ומנפח את מונה "ממתין" ב-/operations. מיישב כל שורה למצב-אמת במקור: `internal_committee`→`completed` (מטא דטרמיניסטי, מחוץ ל-Gemini), `external_upload` מלא→`completed`, `external_upload` עם טקסט וחסר שם/תקציר→חותם `requested_at` (הדריינר יטפל), `cited_only` (אין טקסט)→`skipped`. אידמפוטנטי. תיקון-המקור הנלווה ב-`db.create_internal_committee_decision`. הרצה: `mcp-server/.venv/bin/python scripts/reconcile_metadata_status.py`. | חד-פעמי / re-runnable כהגנת-drift |
|
| `reconcile_metadata_status.py` | python | **נרמול `metadata_extraction_status` תקוע (G1)** — שורות עם ברירת-המחדל `'pending'` שאינן בצנרת-Gemini נערמות כ-backlog-רפאים שהדריינר (סורק `*_requested_at IS NOT NULL`) לעולם לא מנקה ומנפח את מונה "ממתין" ב-/operations. מיישב כל שורה למצב-אמת במקור: `internal_committee`→`completed` (מטא דטרמיניסטי, מחוץ ל-Gemini), `external_upload` מלא→`completed`, `external_upload` עם טקסט וחסר שם/תקציר→חותם `requested_at` (הדריינר יטפל), `cited_only` (אין טקסט)→`skipped`. אידמפוטנטי. תיקון-המקור הנלווה ב-`db.create_internal_committee_decision`. הרצה: `mcp-server/.venv/bin/python scripts/reconcile_metadata_status.py`. | חד-פעמי / re-runnable כהגנת-drift |
|
||||||
|
| `backfill_plans_registry.py` | python | **ייבוא מרשם-התכניות (V38) מקורפוס-ההחלטות** — סורק `data/cases/*/drafts/decision.md` + `data/training/cmp/*.md`, מאתר פסקאות-תוקף ("פורסמה למתן תוקף"), מחלץ רשומת-תכנית מובנית (`plans_extractor`, claude CLI מקומי) ועושה `upsert_plan(review_status='pending_review')` עם provenance. ה-SSOT לזהות+תוקף של תכנית, פעם-אחת במקום גזירה-מחדש מהשומות בכל תיק (G2). idempotent על plan_number מנורמל (G1/G3). `--dry-run` (ברירת-מחדל, כלום לא נכתב) / `--apply` / `--glob` (תת-קבוצה). אחרי הרצה: אישור-יו"ר ב-`plan_review`/תור-האישור (G10). הרץ: `mcp-server/.venv/bin/python scripts/backfill_plans_registry.py`. | ידני (חד-פעמי + לפי-צורך כשנוספות החלטות) |
|
||||||
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
|
||||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||||
|
|||||||
128
scripts/backfill_plans_registry.py
Normal file
128
scripts/backfill_plans_registry.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Backfill the planning-schemes registry (טבלת plans) from our existing decisions.
|
||||||
|
|
||||||
|
Scans the decision corpus — both our drafts (data/cases/*/drafts/decision.md) and
|
||||||
|
Daphna's published finals (data/training/cmp/*.md) — for paragraphs that state a
|
||||||
|
plan's validity ("פורסמה למתן תוקף …"), extracts the structured plan record via the
|
||||||
|
local-LLM extractor, and upserts each into the registry as review_status='pending_review'.
|
||||||
|
|
||||||
|
The chair then reviews the queue (plan_list / plan_review, or the future UI) and only
|
||||||
|
the approved rows become the SSOT that block-tet cites. This is the "import from all our
|
||||||
|
decisions" step — it seeds identity+validity once instead of re-deriving from appraisals
|
||||||
|
per case (G2).
|
||||||
|
|
||||||
|
Idempotent (G3): re-running upserts on the normalized plan_number, never duplicating.
|
||||||
|
|
||||||
|
Run (dry-run, the default — prints what WOULD be ingested, writes nothing):
|
||||||
|
mcp-server/.venv/bin/python scripts/backfill_plans_registry.py
|
||||||
|
Apply (actually upsert as pending_review):
|
||||||
|
mcp-server/.venv/bin/python scripts/backfill_plans_registry.py --apply
|
||||||
|
Limit to a subset while testing:
|
||||||
|
mcp-server/.venv/bin/python scripts/backfill_plans_registry.py --glob 'data/training/cmp/*.md'
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
|
||||||
|
|
||||||
|
from legal_mcp.services import db, plans_extractor # noqa: E402
|
||||||
|
|
||||||
|
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
_MARKER = "פורסמה למתן תוקף"
|
||||||
|
_DEFAULT_GLOBS = (
|
||||||
|
"data/cases/*/drafts/decision.md",
|
||||||
|
"data/training/cmp/*.md",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_paragraphs(text: str) -> list[str]:
|
||||||
|
"""Return paragraphs that assert a plan's validity (contain the marker)."""
|
||||||
|
paras = re.split(r"\n\s*\n", text)
|
||||||
|
return [p.strip() for p in paras if _MARKER in p]
|
||||||
|
|
||||||
|
|
||||||
|
def _source_case_number(path: str) -> str:
|
||||||
|
"""Derive a provenance case number from the file path, best-effort.
|
||||||
|
|
||||||
|
data/cases/<num>/drafts/decision.md → <num>. Otherwise '' (training finals are
|
||||||
|
keyed by Daphna's filename, not our case-number space)."""
|
||||||
|
m = re.search(r"/data/cases/([^/]+)/", path)
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _process_file(path: str, *, apply: bool) -> dict:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
text = fh.read()
|
||||||
|
paras = _candidate_paragraphs(text)
|
||||||
|
if not paras:
|
||||||
|
return {"path": path, "paragraphs": 0, "candidates": 0, "upserted": 0}
|
||||||
|
|
||||||
|
block = "\n\n".join(paras)
|
||||||
|
candidates = await plans_extractor.extract_plans_from_text(block)
|
||||||
|
|
||||||
|
upserted = 0
|
||||||
|
if apply and candidates:
|
||||||
|
plans = await plans_extractor.upsert_candidates(
|
||||||
|
candidates,
|
||||||
|
source_case_number=_source_case_number(path),
|
||||||
|
model_used="backfill",
|
||||||
|
)
|
||||||
|
upserted = len(plans)
|
||||||
|
|
||||||
|
rel = os.path.relpath(path, _REPO_ROOT)
|
||||||
|
print(f"\n• {rel} — {len(paras)} פסקאות-תוקף, {len(candidates)} מועמדים"
|
||||||
|
+ (f", {upserted} נכתבו" if apply else " (dry-run)"))
|
||||||
|
for c in candidates:
|
||||||
|
gd = c.get("gazette_date") or "—"
|
||||||
|
yp = f' י"פ {c["yalkut_number"]}' if c.get("yalkut_number") else ""
|
||||||
|
print(f" - {c.get('display_name') or c['plan_number']} | תוקף: {gd}{yp}")
|
||||||
|
return {
|
||||||
|
"path": path, "paragraphs": len(paras),
|
||||||
|
"candidates": len(candidates), "upserted": upserted,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--apply", action="store_true",
|
||||||
|
help="actually upsert (default: dry-run, writes nothing)")
|
||||||
|
parser.add_argument("--glob", action="append", dest="globs",
|
||||||
|
help="override the corpus glob(s); repeatable")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
import glob as globmod
|
||||||
|
globs = args.globs or list(_DEFAULT_GLOBS)
|
||||||
|
files: list[str] = []
|
||||||
|
for g in globs:
|
||||||
|
files.extend(sorted(globmod.glob(os.path.join(_REPO_ROOT, g))))
|
||||||
|
files = sorted(set(files))
|
||||||
|
|
||||||
|
mode = "APPLY" if args.apply else "DRY-RUN"
|
||||||
|
print(f"[{mode}] backfill plans registry — {len(files)} קבצים, globs={globs}")
|
||||||
|
|
||||||
|
totals = {"paragraphs": 0, "candidates": 0, "upserted": 0}
|
||||||
|
for path in files:
|
||||||
|
try:
|
||||||
|
r = await _process_file(path, apply=args.apply)
|
||||||
|
except Exception as e: # noqa: BLE001 — record, keep going
|
||||||
|
print(f"\n!! שגיאה ב-{path}: {e}", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
for k in totals:
|
||||||
|
totals[k] += r[k]
|
||||||
|
|
||||||
|
print(f"\n=== סיכום [{mode}]: {len(files)} קבצים | "
|
||||||
|
f"{totals['paragraphs']} פסקאות | {totals['candidates']} מועמדים | "
|
||||||
|
f"{totals['upserted']} נכתבו (pending_review) ===")
|
||||||
|
if not args.apply:
|
||||||
|
print("הרץ עם --apply כדי לכתוב למרשם, ואז אשר ב-plan_review / תור-האישור.")
|
||||||
|
|
||||||
|
await db.close_pool()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -440,6 +440,7 @@ description: This skill should be used when writing legal decisions (החלטו
|
|||||||
- ⚠️ **"רקע ניטרלי" (בלוק ו):** אם משפט מכיל ציטוט ישיר מצד, או מילות שיפוט ("חריג", "חטא") — הוא שייך לטענות (ז) או לדיון (י), לא לרקע. החלטות קודמות = עובדה יבשה בלבד.
|
- ⚠️ **"רקע ניטרלי" (בלוק ו):** אם משפט מכיל ציטוט ישיר מצד, או מילות שיפוט ("חריג", "חטא") — הוא שייך לטענות (ז) או לדיון (י), לא לרקע. החלטות קודמות = עובדה יבשה בלבד.
|
||||||
- ⚠️ **"ללא כפילות" (בלוק י):** הפנה לבלוקים קודמים ("כאמור בסעיף X"), אל תחזור עליהם. חריג: "נשוב על כך כי..." (חזרה מכוונת עם שכבה חדשה).
|
- ⚠️ **"ללא כפילות" (בלוק י):** הפנה לבלוקים קודמים ("כאמור בסעיף X"), אל תחזור עליהם. חריג: "נשוב על כך כי..." (חזרה מכוונת עם שכבה חדשה).
|
||||||
- ⚠️ **טענות מקוריות בלבד (בלוק ז):** מכתבי ערר/תשובה. השלמות טיעון → בלוק ח עם תוכן מפורט.
|
- ⚠️ **טענות מקוריות בלבד (בלוק ז):** מכתבי ערר/תשובה. השלמות טיעון → בלוק ח עם תוכן מפורט.
|
||||||
|
- ⚠️ **ציטוט-תכנית קנוני (בלוק ט):** את הזהות והתוקף של כל תכנית — נוסח אחיד מ**מרשם-התכניות**: `{שם-התכנית} פורסמה למתן תוקף ברשומות ביום {D.M.YYYY}[, י"פ {מס'}] — {ייעוד}`. תאריך-הפרסום ומספר-הילקוט באים מהמרשם ככתבם, **לעולם לא מומצאים**. תכנית שאינה במרשם/לא-אושרה — מזכירים בלי תאריך-תוקף. ראה `block-schema.md` בלוק ט.
|
||||||
|
|
||||||
### 11.3 הבדלים בין סוגי עררים
|
### 11.3 הבדלים בין סוגי עררים
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user