feat(precedents): citation_formatted דטרמיניסטי בקוד — Gemini מחלץ רכיבים, לא מעצב (#145)
הבעיה (#145): מחלץ-המטא ביקש מ-Gemini Flash *לעצב* את מראה-המקום המלא (citation_formatted). ב-JSON-mode חופשי (ללא responseSchema) המודל החזיר JSON תקין ומלא אך **השמיט בעקביות** דווקא את השדה הזה — אומת על 8070-05-25, 1194-12-25, 1200-12-25 (וגם כשהצדדים זוהו). השדה הקשה ביותר (עיצוב מחרוזת) + היתר-בפרומפט להשאיר ריק → Flash מפיל אותו. הפתרון: citation_formatted הוא **שדה-תצוגה נגזר** (X1 §3 / INV-ID2) — מורכב דטרמיניסטית מרכיבים מובְנים, לא מעוצב ע"י LLM. תפקיד ה-LLM מצטמצם לחילוץ רכיבים אמינים (שורת-הצדדים, קידומת-ההליך לפסקי-בית-משפט). - db.format_precedent_citation(record) — מרכיב לפי כללי-הציטוט-האחיד: ועדת-ערר (מחוזית/ארצית/בל"מ) מ-proceeding_type+district+source_kind; פסקי-בית-משפט מ-court_prefix(LLM)+district-abbrev. מוציא docket נקי מ-case_number מזוהם ("עע\"מ 683/13"→"683/13"). נמנע ('') כשחסר רכיב (צדדים/docket/תאריך/קידומת) — abstention על המצאה (INV-AH). - case_law.parties (V39) — שורת "עורר נ' משיב" כבסיס re-derivable. - מחלץ-המטא: הפרומפט מחלץ parties+citation_prefix (לא citation_formatted); apply_to_record מרכיב דטרמיניסטית מהרשומה-האפקטיבית וממלא רק שדה ריק (עריכות-יו"ר נשמרות). - scripts/backfill_precedent_citations.py — backfill 2-מעברים (דטרמיניסטי→LLM), מדווח שורות-נמנעות, idempotent. אומת: 3 הרשומות הידניות משוחזרות תו-בתו; פסק עליון אמיתי מולא end-to-end (עע"מ 683/13 ... נבו 3.9.2015). test_fu2b_reconcile ✓. Invariants: INV-ID2/X1§3 (ציטוט=תצוגה נגזר, לא מפתח) · INV-AH (abstention, אפס המצאה) · G1 (docket נקי) · G2 (מסלול-יחיד — מחליף את נתיב-ה-LLM, לא מקביל). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,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) |
|
||||
| `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_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) |
|
||||
| `backup-db.sh` | bash | גיבוי PostgreSQL יומי ל-`data/backups/` (gzip) | לתזמן: `0 2 * * *` |
|
||||
| `restore-db.sh` | bash | שחזור DB מגיבוי (companion ל-backup-db.sh) | ידני |
|
||||
|
||||
118
scripts/backfill_precedent_citations.py
Normal file
118
scripts/backfill_precedent_citations.py
Normal 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())
|
||||
Reference in New Issue
Block a user