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

@@ -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 וטמפלט."""

View File

@@ -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,

View File

@@ -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 ─────────────────────

View 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,
}

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)