feat(plans): מרשם-תכניות קנוני (V38) + נוסח-ציטוט אחיד דטרמיניסטי לבלוק ט #252

Merged
chaim merged 1 commits from worktree-plans-registry into main 2026-06-14 13:47:50 +00:00
11 changed files with 929 additions and 2 deletions
Showing only changes of commit 4be9cf8543 - Show all commits

View File

@@ -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 + שימור, פרשנות)

View File

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

View File

@@ -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` → "התעלם מכותרות".

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)

View File

@@ -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) | ידני |

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

View File

@@ -440,6 +440,7 @@ description: This skill should be used when writing legal decisions (החלטו
- ⚠️ **"רקע ניטרלי" (בלוק ו):** אם משפט מכיל ציטוט ישיר מצד, או מילות שיפוט ("חריג", "חטא") — הוא שייך לטענות (ז) או לדיון (י), לא לרקע. החלטות קודמות = עובדה יבשה בלבד.
- ⚠️ **"ללא כפילות" (בלוק י):** הפנה לבלוקים קודמים ("כאמור בסעיף X"), אל תחזור עליהם. חריג: "נשוב על כך כי..." (חזרה מכוונת עם שכבה חדשה).
- ⚠️ **טענות מקוריות בלבד (בלוק ז):** מכתבי ערר/תשובה. השלמות טיעון → בלוק ח עם תוכן מפורט.
- ⚠️ **ציטוט-תכנית קנוני (בלוק ט):** את הזהות והתוקף של כל תכנית — נוסח אחיד מ**מרשם-התכניות**: `{שם-התכנית} פורסמה למתן תוקף ברשומות ביום {D.M.YYYY}[, י"פ {מס'}] — {ייעוד}`. תאריך-הפרסום ומספר-הילקוט באים מהמרשם ככתבם, **לעולם לא מומצאים**. תכנית שאינה במרשם/לא-אושרה — מזכירים בלי תאריך-תוקף. ראה `block-schema.md` בלוק ט.
### 11.3 הבדלים בין סוגי עררים