From 4be9cf8543b449c6ad6dcc36a634edae58d53010 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sun, 14 Jun 2026 13:46:26 +0000 Subject: [PATCH] =?UTF-8?q?feat(plans):=20=D7=9E=D7=A8=D7=A9=D7=9D-=D7=AA?= =?UTF-8?q?=D7=9B=D7=A0=D7=99=D7=95=D7=AA=20=D7=A7=D7=A0=D7=95=D7=A0=D7=99?= =?UTF-8?q?=20(V38)=20+=20=D7=A0=D7=95=D7=A1=D7=97-=D7=A6=D7=99=D7=98?= =?UTF-8?q?=D7=95=D7=98=20=D7=90=D7=97=D7=99=D7=93=20=D7=93=D7=98=D7=A8?= =?UTF-8?q?=D7=9E=D7=99=D7=A0=D7=99=D7=A1=D7=98=D7=99=20=D7=9C=D7=91=D7=9C?= =?UTF-8?q?=D7=95=D7=A7=20=D7=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit מוסיף ישות קנונית לתכניות בניין-עיר (תב"ע) שחוזרות בין תיקים — 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) --- docs/block-schema.md | 17 +- docs/spec/02-data-model.md | 2 + docs/spec/04-analysis-writing.md | 7 + mcp-server/src/legal_mcp/server.py | 53 +++ .../src/legal_mcp/services/block_writer.py | 66 ++++ mcp-server/src/legal_mcp/services/db.py | 325 +++++++++++++++++- .../src/legal_mcp/services/plans_extractor.py | 193 +++++++++++ mcp-server/src/legal_mcp/tools/plans.py | 138 ++++++++ scripts/SCRIPTS.md | 1 + scripts/backfill_plans_registry.py | 128 +++++++ skills/decision/SKILL.md | 1 + 11 files changed, 929 insertions(+), 2 deletions(-) create mode 100644 mcp-server/src/legal_mcp/services/plans_extractor.py create mode 100644 mcp-server/src/legal_mcp/tools/plans.py create mode 100644 scripts/backfill_plans_registry.py diff --git a/docs/block-schema.md b/docs/block-schema.md index 3634532..a94be18 100644 --- a/docs/block-schema.md +++ b/docs/block-schema.md @@ -320,10 +320,25 @@ Conclusion → Rule → Explanation → Application → Conclusion. **Content model:** - Types: narrative, citation-block - 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:** - MUST: ציטוט ישיר מהוראות תכנית עם הדגשת (bold) מילים מכריעות +- MUST: לזהות+תוקף של תכנית — להשתמש במשפט-הציטוט הקנוני מהמרשם (לעיל); אסור להמציא תאריך-פרסום/מס'-ילקוט - MUST NOT: ניתוח מעמיק (→ block-yod), הכרעה בין פרשנויות - Dependencies: block-chet (מספור), block-vav (הגדרות תכניות) - Condition: **אופציונלי** — רק כשיש מורכבות תכנונית (תכניות סותרות, תמ"א 38 + שימור, פרשנות) diff --git a/docs/spec/02-data-model.md b/docs/spec/02-data-model.md index aace2ae..66cd0dc 100644 --- a/docs/spec/02-data-model.md +++ b/docs/spec/02-data-model.md @@ -30,6 +30,7 @@ | `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 | | `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, > `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) | | `appraiser_facts` | OPUS (`extract_appraiser_facts`) | — | `document_id` (FK); `appraiser_side` default `''` | | `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 | --- diff --git a/docs/spec/04-analysis-writing.md b/docs/spec/04-analysis-writing.md index 0983933..8266bc2 100644 --- a/docs/spec/04-analysis-writing.md +++ b/docs/spec/04-analysis-writing.md @@ -72,6 +72,13 @@ — שם §4. **טיוטת-ביניים** (Pre-Ruling Draft) בוחרת תת-קבוצת בלוקים (ו, ט, ז, ח) — 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 היו"ר:** הסיוע מתמקד בבלוקים המהותיים (ו–יב); בלוקים א–ד > ממולאים מ-template ואינם דורשים ניתוח. ראה `MEMORY.md` → "התעלם מכותרות". diff --git a/mcp-server/src/legal_mcp/server.py b/mcp-server/src/legal_mcp/server.py index 938dbbb..f414aab 100644 --- a/mcp-server/src/legal_mcp/server.py +++ b/mcp-server/src/legal_mcp/server.py @@ -60,6 +60,7 @@ from legal_mcp.tools import ( # noqa: E402 training_enrichment as train_tools, digests as digest_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) +# ── 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() async def write_interim_draft(case_number: str, instructions: str = "") -> str: """כתיבת ארבעת הבלוקים לטיוטת ביניים (רקע, תכניות+היתרים, טענות, הליכים) — אותו skill וטמפלט.""" diff --git a/mcp-server/src/legal_mcp/services/block_writer.py b/mcp-server/src/legal_mcp/services/block_writer.py index b44c459..1026a7a 100644 --- a/mcp-server/src/legal_mcp/services/block_writer.py +++ b/mcp-server/src/legal_mcp/services/block_writer.py @@ -196,6 +196,11 @@ BLOCK_PROMPTS = { 1. **תכניות חלות** — מבנה הירכי: תכניות ארציות → מחוזיות → מקומיות. ציטוט ישיר מהוראות תכנית עם **הדגשה** של מילים מכריעות. 2. **תת-פרק היתרים** — כותרת משנה "היתרים" (או "היתרי בנייה שניתנו במקרקעין"). פירוט ההיתרים הרלוונטיים על פי השומות שהוגשו לתיק. +## ציטוט תכנית ותוקפה (קריטי — מרשם-התכניות): +- לכל תכנית, לחלק **הזהות והתוקף** (מספר-התכנית + מתי פורסמה למתן תוקף ברשומות + מס' ילקוט-הפרסומים) — השתמש **ככתבו** במשפט-הציטוט הקנוני המופיע תחת "מרשם-התכניות" למטה. **אל תמציא** תאריך-פרסום או מספר-ילקוט. +- את הייעוד והניתוח התכנוני אתה רשאי לנסח בסגנון דפנה; את תאריך-התוקף ומספר-הילקוט — לעולם לא. +- אם תכנית זוהתה בתיק אך **חסרה במרשם או טרם אושרה** — הזכר את התכנית בלי לקבוע תאריך-תוקף, ואל תנחש תאריך. + ## כללי ציון סתירות בין שמאים (קריטי): - אם שני שמאים או יותר מסרו מידע שונה על אותה תכנית או היתר — חובה לסמן זאת במפורש בנוסח ניטרלי, למשל: > "יצוין כי שמאי הוועדה ציין כי תכנית פלונית חלה על המקרקעין במלואה, בעוד שמאי העורר סבר כי חלקה של התכנית בלבד חל" @@ -215,6 +220,9 @@ BLOCK_PROMPTS = { ## תכניות שזוהו (ממטא-דאטה של מסמכים): {plans_context} +## מרשם-התכניות — משפטי-ציטוט קנוניים (מקור-אמת לתוקף; השתמש ככתבם): +{plans_registry_context} + ## עובדות שמאיות שחולצו (תכניות + היתרים, פרק לכל שמאי): {appraiser_facts_context} @@ -333,6 +341,7 @@ async def write_block( claims_context = await _build_claims_context(case_id) direction_context = _build_direction_context(decision) 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 = ( await _build_precedents_context(case_id, block_id) ) @@ -371,6 +380,7 @@ async def write_block( claims_context=claims_context, direction_context=direction_context, plans_context=plans_context, + plans_registry_context=plans_registry_context, daphna_style_exemplars=daphna_style_exemplars, case_law_citations=case_law_citations, style_context=style_context, @@ -580,6 +590,60 @@ async def _build_plans_context(case_id: UUID) -> str: 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 = { "committee": "שמאי הוועדה המקומית", "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) direction_context = _build_direction_context(decision) plans_context = await _build_plans_context(case_id) + plans_registry_context = await _build_plans_registry_context(case_id) daphna_style_exemplars, case_law_citations, _ = ( 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, direction_context=direction_context, plans_context=plans_context, + plans_registry_context=plans_registry_context, daphna_style_exemplars=daphna_style_exemplars, case_law_citations=case_law_citations, style_context=style_context, diff --git a/mcp-server/src/legal_mcp/services/db.py b/mcp-server/src/legal_mcp/services/db.py index 0c14c0b..811207e 100644 --- a/mcp-server/src/legal_mcp/services/db.py +++ b/mcp-server/src/legal_mcp/services/db.py @@ -1511,6 +1511,54 @@ SCHEMA_V37_SQL = """ 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 # 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) finally: 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: @@ -1571,6 +1619,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None: await conn.execute(SCHEMA_V35_SQL) await conn.execute(SCHEMA_V36_SQL) await conn.execute(SCHEMA_V37_SQL) + await conn.execute(SCHEMA_V38_SQL) async def init_schema() -> None: @@ -3375,6 +3424,280 @@ async def detect_appraiser_conflicts(case_id: UUID) -> list[dict]: 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 ───────────────────── diff --git a/mcp-server/src/legal_mcp/services/plans_extractor.py b/mcp-server/src/legal_mcp/services/plans_extractor.py new file mode 100644 index 0000000..6a79c74 --- /dev/null +++ b/mcp-server/src/legal_mcp/services/plans_extractor.py @@ -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, + } diff --git a/mcp-server/src/legal_mcp/tools/plans.py b/mcp-server/src/legal_mcp/tools/plans.py new file mode 100644 index 0000000..882be36 --- /dev/null +++ b/mcp-server/src/legal_mcp/tools/plans.py @@ -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) diff --git a/scripts/SCRIPTS.md b/scripts/SCRIPTS.md index f47ae8b..8187465 100644 --- a/scripts/SCRIPTS.md +++ b/scripts/SCRIPTS.md @@ -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) | | `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 | +| `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) | | `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` | | `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני | diff --git a/scripts/backfill_plans_registry.py b/scripts/backfill_plans_registry.py new file mode 100644 index 0000000..a770074 --- /dev/null +++ b/scripts/backfill_plans_registry.py @@ -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//drafts/decision.md → . 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()) diff --git a/skills/decision/SKILL.md b/skills/decision/SKILL.md index 3cf4449..538ac5a 100644 --- a/skills/decision/SKILL.md +++ b/skills/decision/SKILL.md @@ -440,6 +440,7 @@ description: This skill should be used when writing legal decisions (החלטו - ⚠️ **"רקע ניטרלי" (בלוק ו):** אם משפט מכיל ציטוט ישיר מצד, או מילות שיפוט ("חריג", "חטא") — הוא שייך לטענות (ז) או לדיון (י), לא לרקע. החלטות קודמות = עובדה יבשה בלבד. - ⚠️ **"ללא כפילות" (בלוק י):** הפנה לבלוקים קודמים ("כאמור בסעיף X"), אל תחזור עליהם. חריג: "נשוב על כך כי..." (חזרה מכוונת עם שכבה חדשה). - ⚠️ **טענות מקוריות בלבד (בלוק ז):** מכתבי ערר/תשובה. השלמות טיעון → בלוק ח עם תוכן מפורט. +- ⚠️ **ציטוט-תכנית קנוני (בלוק ט):** את הזהות והתוקף של כל תכנית — נוסח אחיד מ**מרשם-התכניות**: `{שם-התכנית} פורסמה למתן תוקף ברשומות ביום {D.M.YYYY}[, י"פ {מס'}] — {ייעוד}`. תאריך-הפרסום ומספר-הילקוט באים מהמרשם ככתבם, **לעולם לא מומצאים**. תכנית שאינה במרשם/לא-אושרה — מזכירים בלי תאריך-תוקף. ראה `block-schema.md` בלוק ט. ### 11.3 הבדלים בין סוגי עררים -- 2.49.1