Merge pull request 'feat(precedents): citation_formatted דטרמיניסטי בקוד — Gemini מחלץ רכיבים, לא מעצב (#145)' (#262) from worktree-precedent-deterministic-citation into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m47s
G12 Leak-Guard / leak-guard (push) Successful in 8s
Lint — undefined names / undefined-names (push) Successful in 22s

This commit was merged in pull request #262.
This commit is contained in:
2026-06-15 03:38:51 +00:00
4 changed files with 261 additions and 33 deletions

View File

@@ -1560,6 +1560,18 @@ CREATE INDEX IF NOT EXISTS idx_plans_meta_tsv ON plans USING gin(meta_tsv);
""" """
# ── V39: case_law.parties ──────────────────────────────────────────
# The "עורר נ' משיב" line, extracted from the caption as a structured component.
# It is the re-derivable BASIS for the deterministic citation_formatted
# (format_precedent_citation) — the LLM extracts the party line (a reliable caption
# read) instead of formatting the whole Markdown citation, which it dropped outright
# (#145). citation_formatted stays a DERIVED display field (X1 §3 / INV-ID2); this
# column is its irreducible bold component.
SCHEMA_V39_SQL = """
ALTER TABLE case_law ADD COLUMN IF NOT EXISTS parties TEXT DEFAULT '';
"""
# Stable, arbitrary key for the session-level advisory lock that serialises # Stable, arbitrary key for the session-level advisory lock that serialises
# schema DDL across processes. Every short-lived process (cron drains, services) # schema DDL across processes. Every short-lived process (cron drains, services)
# re-runs the idempotent migrations on startup; without this lock two processes # re-runs the idempotent migrations on startup; without this lock two processes
@@ -1620,6 +1632,7 @@ async def _apply_schema_ddl(conn: asyncpg.Connection) -> None:
await conn.execute(SCHEMA_V36_SQL) await conn.execute(SCHEMA_V36_SQL)
await conn.execute(SCHEMA_V37_SQL) await conn.execute(SCHEMA_V37_SQL)
await conn.execute(SCHEMA_V38_SQL) await conn.execute(SCHEMA_V38_SQL)
await conn.execute(SCHEMA_V39_SQL)
async def init_schema() -> None: async def init_schema() -> None:
@@ -3513,6 +3526,88 @@ def format_plan_citation(plan: dict) -> str:
return sentence return sentence
# Clean court docket inside a possibly citation-shaped case_number
# ("עע\"מ 683/13" → "683/13"). Legacy court-ruling rows stored the full citation
# in the identity field (X1 §4 known violation); pull the docket out so the
# assembled citation never doubles the prefix.
_CITATION_DOCKET_RE = re.compile(r"\d{1,6}(?:[-/]\d{1,4}){1,2}")
# District → administrative-court abbreviation as it appears in citations
# (`עת"מ (י-ם) 1234/56 ...`). Empty/unknown → the abbrev parenthetical is omitted
# rather than guessed.
_DISTRICT_COURT_ABBREV = {
"ירושלים": "י",
"תל אביב": 'ת"א',
"מרכז": "מרכז",
"חיפה": "חי'",
"צפון": "נצ'",
"דרום": 'ב"ש',
}
def _citation_docket(case_number: str) -> str:
s = (case_number or "").strip()
if not s:
return ""
m = _CITATION_DOCKET_RE.search(s)
return m.group(0) if m else s
def format_precedent_citation(
record: dict, *, parties: str | None = None, court_prefix: str = "",
) -> str:
"""Deterministically render a precedent's unified-rules citation (מראה מקום).
DERIVED display field (X1 §3 / INV-ID2) assembled from stored components — NEVER
formatted by an LLM, which proved to drop the field outright (#145). The LLM's job
shrinks to extracting reliable COMPONENTS (the ``parties`` line and, for court
rulings, the caption ``court_prefix``); the formatted string is built here.
• ועדת-ערר family — prefix from ``proceeding_type`` ('ערר'/'בל"מ'), forum from
``district``, national level → 'ערר ארצי'. Reporter: our own decisions
(``source_kind='internal_committee'``) are unpublished → date only; external /
Nevo rows → 'נבו '.
• court rulings (עליון/מנהלי) — prefix from the caption (``court_prefix``, e.g.
'ע"א'/'עת"מ'/'ת"א'); admin-court district abbrev when known; reporter 'נבו '.
Abstains (returns '') when an essential component is missing — parties, docket, date,
or an indeterminate court prefix — never inventing one (INV-AH).
"""
parties = (parties if parties is not None else (record.get("parties") or "")).strip()
docket = _citation_docket(record.get("case_number") or "")
d = _coerce_plan_date(record.get("date"))
if not (parties and docket and d):
return ""
date_str = f"{d.day}.{d.month}.{d.year}"
level = (record.get("precedent_level") or "").strip()
source_type = (record.get("source_type") or "").strip()
is_committee = level.startswith("ועדת_ערר") or source_type == "appeals_committee"
if is_committee:
reporter = "" if record.get("source_kind") == "internal_committee" else "נבו "
if level == "ועדת_ערר_ארצית":
head = f"ערר ארצי {docket}"
else:
prefix = 'בל"מ' if (record.get("proceeding_type") or "").strip() == 'בל"מ' else "ערר"
district = (record.get("district") or "").strip()
if not district:
return ""
head = f"{prefix} (ועדות ערר - מחוז {district}) {docket}"
else:
prefix = (court_prefix or "").strip()
if not prefix:
return "" # court-ruling prefix is not derivable from structured fields
reporter = "נבו "
if level == "מנהלי":
abbrev = _DISTRICT_COURT_ABBREV.get((record.get("district") or "").strip(), "")
head = f"{prefix} ({abbrev}) {docket}" if abbrev else f"{prefix} {docket}"
else:
head = f"{prefix} {docket}"
return f"{head} **{parties}** ({reporter}{date_str})"
def _plan_row_to_dict(row) -> dict | None: def _plan_row_to_dict(row) -> dict | None:
if row is None: if row is None:
return None return None
@@ -4259,7 +4354,7 @@ async def update_case_law(case_law_id: UUID, **fields) -> dict | None:
"case_number", "case_name", "court", "date", "practice_area", "appeal_subtype", "case_number", "case_name", "court", "date", "practice_area", "appeal_subtype",
"subject_tags", "summary", "headnote", "nevo_ratio", "key_quote", "source_url", "subject_tags", "summary", "headnote", "nevo_ratio", "key_quote", "source_url",
"source_type", "precedent_level", "is_binding", "district", "chair_name", "source_type", "precedent_level", "is_binding", "district", "chair_name",
"proceeding_type", "citation_formatted", "proceeding_type", "citation_formatted", "parties",
} }
updates = {k: v for k, v in fields.items() if k in allowed} updates = {k: v for k, v in fields.items() if k in allowed}
if not updates: if not updates:

View File

@@ -1,12 +1,18 @@
"""Auto-extract precedent metadata from a freshly-uploaded ruling. """Auto-extract precedent metadata from a freshly-uploaded ruling.
Runs after chunking. Reads the precedent's full_text and asks Claude to Runs after chunking. Reads the precedent's full_text and asks Gemini to
fill in the metadata fields that an upload form usually leaves empty: fill in the metadata fields that an upload form usually leaves empty:
short case_name, summary, headnote, key_quote, subject_tags, short case_name, summary, headnote, key_quote, subject_tags,
appeal_subtype, decision_date, precedent_level, court — plus appeal_subtype, decision_date, precedent_level, court — plus
chair_name + district for internal_committee rows (which the upload chair_name + district for internal_committee rows (which the upload
path stamps with PLACEHOLDER_PENDING_EXTRACTION when missing). path stamps with PLACEHOLDER_PENDING_EXTRACTION when missing).
The full citation (citation_formatted) is NOT formatted by the LLM — a Flash
model reliably extracts the party line but drops the formatted string outright
(#145). Instead the LLM extracts COMPONENTS (parties, citation_prefix) and
``apply_to_record`` assembles the citation deterministically via
``db.format_precedent_citation`` (X1 §3 / INV-ID2 — a derived display field).
Caller policy: only empty user-supplied fields are filled. Anything the Caller policy: only empty user-supplied fields are filled. Anything the
chair already typed in the upload form is preserved. This is enforced chair already typed in the upload form is preserved. This is enforced
in ``apply_to_record``. in ``apply_to_record``.
@@ -64,7 +70,8 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.", "case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
"chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.", "chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים''ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו''תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.", "district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים''ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו''תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"citation_formatted": "המראה מקום המלא לפי **כללי הציטוט האחיד**, בפורמט Markdown — שמות הצדדים בלבד מוקפים בכפול-כוכבית (`**…**`), הכל השאר רגיל. ראה כללים מפורטים בסעיף 12 למטה." "parties": "שמות הצדדים בשורה אחת בצורה 'עורר נ\\' משיב' — בדיוק כפי שמופיעים בכותרת/רובריקה. בלי הדגשה, בלי מספר-תיק, בלי תוארים מיותרים. למשל 'ישיבת חברת אהבת שלום נ\\' תאיה' או 'ראם חיים נ\\' הוועדה המקומית לתכנון ובניה ירושלים'. אם הצדדים אינם מופיעים בטקסט (למשל החלטה שמתחילה בגוף בלי רובריקה) — מחרוזת ריקה. **אל תמציא שמות.**",
"citation_prefix": "קידומת-ההליך של פסיקת בית-משפט בלבד, כפי שמופיעה בראש הכותרת: ע\\"א / רע\\"א / בג\\"ץ / עע\\"מ / עת\\"מ / ע\\"פ / דנ\\"א / ת\\"א וכד'. **רק לפסקי בית-משפט (עליון/מנהלי)** — להחלטות ועדת-ערר השאר ריק (הקוד גוזר 'ערר'/'בל\\"מ' מעצמו). אם לא ברור — מחרוזת ריקה."
} }
## כללי איכות ## כללי איכות
@@ -80,22 +87,10 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות. 10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות.
11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY"'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר"'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות. 11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY"'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר"'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות.
12. **chair_name / district** — חובה למלא רק עבור החלטות ועדת ערר (source_type='appeals_committee'). chair_name נמצא בכותרת ("בפני: עו\"ד פלוני אלמוני, יו\"ר") או בחתימה. district = מחוז הוועדה, מתוך רשימה סגורה. עבור פסקי בית משפט — שני השדות ריקים. 12. **chair_name / district** — חובה למלא רק עבור החלטות ועדת ערר (source_type='appeals_committee'). chair_name נמצא בכותרת ("בפני: עו\"ד פלוני אלמוני, יו\"ר") או בחתימה. district = מחוז הוועדה, מתוך רשימה סגורה. עבור פסקי בית משפט — שני השדות ריקים.
13. **citation_formatted — כללי הציטוט האחיד הישראלי**. הרכב את המראה מקום במחרוזת אחת בפורמט Markdown, **כשרק שמות הצדדים מודגשים** (מוקפים ב-`**…**`). כל השאר — קיצור הערכאה, סוגריים של הרכב/מחוז, מספר תיק, מאגר/תאריך **רגיל ללא הדגשה**. 13. **parties / citation_prefix — רכיבי המראה-מקום (לא המראה-מקום עצמו)**. אינך מרכיב את הציטוט המעוצב — המערכת מרכיבה אותו דטרמיניסטית מהרכיבים. עליך רק **לחלץ** שני רכיבים נקיים:
- **parties** — שורת הצדדים "[עורר/מבקש] נ' [משיב]" כפי שמופיעה בכותרת/רובריקה. בלי מספר-תיק, בלי קידומת-הליך, בלי הדגשה. הצדדים = מי שמופיע בין מספר-התיק לבין שם-הערכאה/התאריך. אם אין רובריקה עם צדדים (החלטה שפותחת ישר בגוף) — השאר ריק; **אל תמציא שמות**.
תבניות לסוגי פסיקה: - **citation_prefix** — קידומת-ההליך **רק לפסקי בית-משפט** (ע"א / רע"א / בג"ץ / עע"מ / עת"מ / ע"פ / דנ"א / ת"א…), כפי שכתובה בראש הכותרת. להחלטות ועדת-ערר — ריק (המערכת גוזרת 'ערר'/'בל"מ' מ-proceeding_type).
* **בית משפט עליון — לא פורסם:** `ע"א 1234/56 **פלוני נ' אלמוני** (נבו 1.2.3456)` - שניהם רשות; ריק עדיף על ניחוש (INV-AH — abstention על המצאה).
* **בית משפט עליון — פורסם:** `ע"א 1234/56 **פלוני נ' אלמוני**, פ"ד יב(3) 456 (1990)`
* **בית משפט מנהלי:** `עת"מ (י-ם) 1234/56 **פלוני נ' הוועדה** (נבו 1.2.3456)` — "(י-ם)" / ""א)" / וכד' = קיצור המחוז
* **ועדת ערר תכנון ובנייה (מחוזית):** `ערר (ועדות ערר - תכנון ובנייה ת"א-יפו) 81002-01-21 **אברהם אגסי נ' הועדה המקומית לתכנון ובנייה תל אביב** (נבו 25.9.2025)`
* **בל"מ (בקשה להארכת מועד):** `בל"מ (ועדות ערר - ירושלים) 1028/20 **חלוואני ריאד נ' רשות הרישוי - הוועדה המקומית ירושלים** (נבו 7.1.2021)`
* **ועדת ערר ארצית:** `ערר ארצי 8047/23 **פלוני נ' אלמוני** (נבו 1.2.3456)`
כללים:
- **הצדדים מודגשים בלבד** — כל השאר רגיל. אל תדגיש את "ע"א" / "ערר" / מספר התיק / "(נבו ...)" / "פ"ד".
- הצדדים = מי שמופיע **בין מספר התיק לבין הסוגריים הסופיים** (תאריך/מאגר), כלומר "[עורר/מבקש] נ' [משיב]".
- תאריך בסוגריים סופיים בפורמט עברי "(נבו 25.9.2025)" — יום.חודש.שנה ללא אפסים מובילים.
- אם המאגר הוא נבו והפסיקה לא פורסמה ב-פ"ד — השתמש ב-"(נבו DATE)". אם פורסמה ב-פ"ד — הוסף את ההפניה הפורמלית אחרי הצדדים: `..., פ"ד יב(3) 456 (1990)`.
- אם לא ניתן לזהות איזשהו רכיב במדויק — השאר את **כל** השדה ריק. אל תניח / תמציא.
""" """
@@ -210,14 +205,14 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
# silently storing free-text in what callers treat as a filter facet. # silently storing free-text in what callers treat as a filter facet.
if d in {"ירושלים", "תל אביב", "מרכז", "חיפה", "צפון", "דרום", "ארצית"}: if d in {"ירושלים", "תל אביב", "מרכז", "חיפה", "צפון", "דרום", "ארצית"}:
out["district"] = d out["district"] = d
if isinstance(result.get("citation_formatted"), str): # parties / citation_prefix — COMPONENTS of the citation, not the formatted
cf = result["citation_formatted"].strip() # string. citation_formatted itself is assembled deterministically by
# Sanity check: a valid citation should contain at least one bold # db.format_precedent_citation in apply_to_record (#145): a Flash model reliably
# marker pair (the parties) AND a closing paren (the reporter/date). # extracts the party line but dropped the formatted citation outright.
# If the LLM returned a half-formed string, drop it rather than if isinstance(result.get("parties"), str):
# store junk that the UI then has to special-case. out["parties"] = result["parties"].strip()
if cf.count("**") >= 2 and ")" in cf: if isinstance(result.get("citation_prefix"), str):
out["citation_formatted"] = cf out["citation_prefix"] = result["citation_prefix"].strip()
return out return out
@@ -371,12 +366,12 @@ async def apply_to_record(
): ):
fields_to_update["case_number"] = cn_clean fields_to_update["case_number"] = cn_clean
# citation_formatted — full citation per Israeli citation rules. Only # parties — store the extracted "עורר נ' משיב" line (the re-derivable basis for
# fill if empty; user edits in /precedents/[id] are preserved. # the deterministic citation). Only fill when empty; chair edits are preserved.
if not (record.get("citation_formatted") or "").strip(): if not (record.get("parties") or "").strip():
s = (suggested.get("citation_formatted") or "").strip() p = (suggested.get("parties") or "").strip()
if s: if p:
fields_to_update["citation_formatted"] = s fields_to_update["parties"] = p
# chair_name / district — only for internal_committee rows. The DB CHECK # chair_name / district — only for internal_committee rows. The DB CHECK
# forces these to be non-empty, so the upload endpoint stamps the row # forces these to be non-empty, so the upload endpoint stamps the row
@@ -414,6 +409,25 @@ async def apply_to_record(
if eff_st != derived_st: if eff_st != derived_st:
fields_to_update["source_type"] = derived_st fields_to_update["source_type"] = derived_st
# citation_formatted — DERIVED deterministically from the effective record
# (db.format_precedent_citation), NEVER formatted by the LLM (#145, INV-ID2).
# Built last, so it sees this run's component updates (case_number/date/level/
# source_type/district/proceeding_type/parties). Only fill when empty so chair
# edits in /precedents/[id] are preserved; abstains (no write) when a component
# is missing.
if not (record.get("citation_formatted") or "").strip():
eff = {**record, **fields_to_update}
eff_parties = (
fields_to_update.get("parties") or record.get("parties") or ""
).strip()
cit = db.format_precedent_citation(
eff,
parties=eff_parties,
court_prefix=(suggested.get("citation_prefix") or "").strip(),
)
if cit:
fields_to_update["citation_formatted"] = cit
if not fields_to_update: if not fields_to_update:
return {"updated": False, "fields": []} return {"updated": False, "fields": []}

View File

@@ -34,6 +34,7 @@
| `legal-metadata-drain.config.cjs` | pm2/js | **תזמון כל 15 דק' של `drain_metadata_queue.py`** (cron `*/15 * * * *`, `METADATA_DRAIN_CRON` לעקיפה) — מונע סתימה של תור חילוץ-המטא ב-/precedents. דורש `GEMINI_API_KEY` ב-`~/.env`. התקנה: `pm2 start scripts/legal-metadata-drain.config.cjs && pm2 save`. | pm2 cron (host-side) | | `legal-metadata-drain.config.cjs` | pm2/js | **תזמון כל 15 דק' של `drain_metadata_queue.py`** (cron `*/15 * * * *`, `METADATA_DRAIN_CRON` לעקיפה) — מונע סתימה של תור חילוץ-המטא ב-/precedents. דורש `GEMINI_API_KEY` ב-`~/.env`. התקנה: `pm2 start scripts/legal-metadata-drain.config.cjs && pm2 save`. | pm2 cron (host-side) |
| `reconcile_metadata_status.py` | python | **נרמול `metadata_extraction_status` תקוע (G1)** — שורות עם ברירת-המחדל `'pending'` שאינן בצנרת-Gemini נערמות כ-backlog-רפאים שהדריינר (סורק `*_requested_at IS NOT NULL`) לעולם לא מנקה ומנפח את מונה "ממתין" ב-/operations. מיישב כל שורה למצב-אמת במקור: `internal_committee``completed` (מטא דטרמיניסטי, מחוץ ל-Gemini), `external_upload` מלא→`completed`, `external_upload` עם טקסט וחסר שם/תקציר→חותם `requested_at` (הדריינר יטפל), `cited_only` (אין טקסט)→`skipped`. אידמפוטנטי. תיקון-המקור הנלווה ב-`db.create_internal_committee_decision`. הרצה: `mcp-server/.venv/bin/python scripts/reconcile_metadata_status.py`. | חד-פעמי / re-runnable כהגנת-drift | | `reconcile_metadata_status.py` | python | **נרמול `metadata_extraction_status` תקוע (G1)** — שורות עם ברירת-המחדל `'pending'` שאינן בצנרת-Gemini נערמות כ-backlog-רפאים שהדריינר (סורק `*_requested_at IS NOT NULL`) לעולם לא מנקה ומנפח את מונה "ממתין" ב-/operations. מיישב כל שורה למצב-אמת במקור: `internal_committee``completed` (מטא דטרמיניסטי, מחוץ ל-Gemini), `external_upload` מלא→`completed`, `external_upload` עם טקסט וחסר שם/תקציר→חותם `requested_at` (הדריינר יטפל), `cited_only` (אין טקסט)→`skipped`. אידמפוטנטי. תיקון-המקור הנלווה ב-`db.create_internal_committee_decision`. הרצה: `mcp-server/.venv/bin/python scripts/reconcile_metadata_status.py`. | חד-פעמי / re-runnable כהגנת-drift |
| `backfill_plans_registry.py` | python | **ייבוא מרשם-התכניות (V38) מקורפוס-ההחלטות** — סורק `data/cases/*/drafts/decision.md` + `data/training/cmp/*.md`, מאתר פסקאות-תוקף ("פורסמה למתן תוקף"), מחלץ רשומת-תכנית מובנית (`plans_extractor`, claude CLI מקומי) ועושה `upsert_plan(review_status='pending_review')` עם provenance. ה-SSOT לזהות+תוקף של תכנית, פעם-אחת במקום גזירה-מחדש מהשומות בכל תיק (G2). idempotent על plan_number מנורמל (G1/G3). `--dry-run` (ברירת-מחדל, כלום לא נכתב) / `--apply` / `--glob` (תת-קבוצה). אחרי הרצה: אישור-יו"ר ב-`plan_review`/תור-האישור (G10). הרץ: `mcp-server/.venv/bin/python scripts/backfill_plans_registry.py`. | ידני (חד-פעמי + לפי-צורך כשנוספות החלטות) | | `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`. | ידני (חד-פעמי + לפי-צורך כשנוספות החלטות) |
| `backfill_precedent_citations.py` | python | **#145** — backfill ל-`citation_formatted` (מראה-מקום) ברשומות `case_law` ריקות, באמצעות `db.format_precedent_citation` הדטרמיניסטי (X1 §3 / INV-ID2 — שדה-תצוגה נגזר, לא מעוצב ע"י LLM ש-הפיל אותו, #145). שני מעברים לכל שורה: (1) **ללא-LLM** — הרכבה מהשדות השמורים (ממלא שורות-ועדה עם parties+docket+date); (2) **LLM** — אם (1) נמנע ויש full_text, מריץ את מחלץ-המטא (extract_and_apply) שמחלץ רכיבים (parties, citation_prefix) ואז מרכיב — זה ממלא את 171 פסקי-בתי-המשפט מהכותרת. שורות בלי רובריקה (אין צדדים) נשארות ריקות ומדווחות, לא מנוחשות (INV-AH). idempotent — רק שדה ריק (G3). `--apply` / `--limit N` / `--no-llm`. הרץ: `HOME=/home/chaim mcp-server/.venv/bin/python scripts/backfill_precedent_citations.py`. | ידני (חד-פעמי + לפי-צורך) |
| `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) | | `auto-sync-cases.sh` | bash | סנכרון תיקי ערר ל-Gitea — רץ כל דקה | `* * * * *` (cron) |
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` | | `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני | | `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |

View File

@@ -0,0 +1,118 @@
"""Backfill citation_formatted (מראה מקום) on case_law rows that lack it.
Why this exists: a Flash model was asked to *format* the full citation and dropped
the field outright on every run (#145). citation_formatted is now a DERIVED display
field assembled deterministically (db.format_precedent_citation, X1 §3 / INV-ID2) from
structured components. This script applies that derivation to the existing corpus.
Two-pass per row (cheapest first, INV-AH abstention throughout — never invents):
1. NO-LLM: try db.format_precedent_citation on the STORED row. Fills committee rows
that already have parties + docket + date (e.g. once parties were captured). No
API cost.
2. LLM: if pass 1 abstains and the row has full_text, run the metadata extractor
(extract_and_apply) — it extracts the COMPONENTS (parties, citation_prefix) and
assembles the citation. This is what fills the 171 court rulings whose captions
carry the parties+prefix.
Rows where even the LLM can't recover a component (no rubric → no parties, e.g. our own
caption-stripped internal decisions) are left empty and LOGGED — not back-filled with a
guess (חוקה §6 — אין בליעה שקטה; the chair fills those by hand in /precedents/[id]).
Idempotent (G3): only ever fills an EMPTY citation_formatted; re-running skips rows that
already have one.
Run (dry-run, default — reports what each pass WOULD do, writes nothing):
HOME=/home/chaim mcp-server/.venv/bin/python scripts/backfill_precedent_citations.py
Apply:
HOME=/home/chaim mcp-server/.venv/bin/python scripts/backfill_precedent_citations.py --apply
Options:
--limit N process at most N empty-citation rows
--no-llm pass-1 only (deterministic from stored fields; zero API cost)
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "mcp-server", "src"))
from legal_mcp.services import db, precedent_metadata_extractor # noqa: E402
async def _empty_citation_rows(limit: int | None) -> list[dict]:
pool = await db.get_pool()
sql = (
"SELECT id, case_number, source_kind, source_type, precedent_level, "
" (full_text IS NOT NULL AND length(full_text) > 200) AS has_text "
"FROM case_law WHERE COALESCE(citation_formatted, '') = '' "
"ORDER BY created_at"
)
if limit:
sql += f" LIMIT {int(limit)}"
rows = await pool.fetch(sql)
return [dict(r) for r in rows]
async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run)")
ap.add_argument("--limit", type=int, default=None)
ap.add_argument("--no-llm", action="store_true", help="deterministic pass only (no API)")
args = ap.parse_args()
rows = await _empty_citation_rows(args.limit)
print(f"רשומות עם citation_formatted ריק: {len(rows)}\n")
n_pass1 = n_pass2 = n_abstain = 0
for r in rows:
cid = r["id"]
# Pass 1 — deterministic from the stored row (no LLM).
record = await db.get_case_law(cid)
cit = db.format_precedent_citation(record)
if cit:
n_pass1 += 1
print(f" ✓ [det] {r['case_number']}: {cit}")
if args.apply:
await db.update_case_law(cid, citation_formatted=cit)
await db.recompute_searchable(cid)
continue
# Pass 2 — extract components via the LLM, then assemble.
if args.no_llm or not r["has_text"]:
n_abstain += 1
why = "no full_text" if not r["has_text"] else "no-llm"
print(f" · [skip:{why}] {r['case_number']} ({r['precedent_level'] or ''})")
continue
if not args.apply:
print(f" ? [llm?] {r['case_number']} — would run extractor (dry-run)")
continue
res = await precedent_metadata_extractor.extract_and_apply(cid)
record2 = await db.get_case_law(cid)
new_cit = (record2.get("citation_formatted") or "").strip()
if new_cit:
n_pass2 += 1
print(f" ✓ [llm] {r['case_number']}: {new_cit}")
else:
n_abstain += 1
parties = (record2.get("parties") or "").strip()
print(
f" · [abstain] {r['case_number']} ({r['precedent_level'] or ''}) — "
f"{'no parties in text' if not parties else 'missing component'} "
f"[extractor:{res.get('status')}]"
)
print(
f"\nסיכום: דטרמיניסטי={n_pass1} · LLM={n_pass2} · "
f"נמנע (חסר רכיב)={n_abstain}"
+ ("" if args.apply else " (dry-run — לא נכתב)")
)
if __name__ == "__main__":
asyncio.run(main())