feat(plans): מרשם-תכניות קנוני (V38) + נוסח-ציטוט אחיד דטרמיניסטי לבלוק ט
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

מוסיף ישות קנונית לתכניות בניין-עיר (תב"ע) שחוזרות בין תיקים — 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:
2026-06-14 13:46:26 +00:00
parent 83293ca619
commit 4be9cf8543
11 changed files with 929 additions and 2 deletions

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