fix(precedents): חילוץ-מטא-דאטה ממלא תחום (practice_area) ושם-יו"ר לכל החלטת-ועדה
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s

שני פערים שצפו מ-/precedents בחילוץ-ההלכות:

1. **practice_area לא סומן** — השדה הועבר ל-LLM כקונטקסט-קריאה-בלבד ולא חולץ
   מעולם, כך שהעלאות שהשאירו אותו ריק נשארו ריקות והרדיו ב-/precedents הופיע
   ללא בחירה. עכשיו נגזר ב-apply_to_record: עדיפות לגזירה דטרמיניסטית מקידומת
   מספר-התיק (1xxx→rishuy, 8xxx→היטל, 9xxx→197 — מקור-אמת לדוקטי ועדת-ערר,
   INV-AH rule-based), ובנפילה — סיווג-תוכן של ה-LLM (שדה practice_area חדש
   בפרומפט, אנום-סגור) עבור פסקי-בית-משפט שהקידומת שלהם אינה מקודדת תחום.
   ממלא רק כשריק (G1 — נרמול במקור, לא תיקון-בקריאה).

2. **שם-יו"ר לא חולץ** (למשל 1132-09-24) — המיזוג היה מגודר על
   source_kind=='internal_committee' בלבד, ודילג בשקט על החלטות-ועדה שהועלו
   במסלול הפסיקה החיצוני (external_upload + source_type=appeals_committee, כמו
   החלטת ת"א מנבו) — היו"ר ישב בבלוק-החתימה אך לא חולץ. עכשיו מגודר על "האם זו
   החלטת-ועדה" (source_type/level אפקטיביים), לעולם לא על פסק-בית-משפט. ה-CHECK
   כופה non-empty רק ל-internal_committee, לכן כתיבה ל-external בטוחה.

חיזוק-פרומפט (לבקשת היו"ר): chair_name מציין מפורשות את בלוק-החתימה הדו-טורי
(מזכיר↔יו"ר — לקחת את צד-היו"ר) ומזהיר לא לחלץ יו"ר של פסקי-דין **מצוטטים**
בגוף ההחלטה.

UI (לוגיקה-בלבד, פטור משער-העיצוב): edit-sheet מסנכרן-מחדש מהרשומה הטרייה בכל
פתיחה (re-arm על סגירה) ו-usePrecedent עושה poll בזמן חילוץ — כך מילוי-רקע של
practice_area/chair_name מופיע בלי refresh מלא ("הכפתור לא נשאר מסומן").

בדיקות: test_metadata_extract_chair_practice_area.py (6 תרחישי-מיזוג, offline).

Invariants: G1 (נרמול-במקור), G2 (אותו extractor, לא מסלול מקביל),
INV-AH (גזירה דטרמיניסטית מועדפת, abstention כשאין ודאות).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-17 09:53:42 +00:00
parent 5370ada37c
commit 406e93b9bf
4 changed files with 245 additions and 7 deletions

View File

@@ -27,6 +27,10 @@ from uuid import UUID
from legal_mcp.config import parse_llm_json
from legal_mcp.services import db, gemini_session
from legal_mcp.services.practice_area import (
DOMAIN_PRACTICE_AREAS,
derive_domain_practice_area,
)
logger = logging.getLogger(__name__)
@@ -58,6 +62,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
{
"case_name_short": "שם קצר ל-3-6 מילים (למשל 'אהרון ברק' או 'ב. קרן-נכסים'). אל תכלול מספר תיק. שם המבקש/העורר העיקרי. אם זו החלטה מאוחדת — שם הצד המוביל.",
"appeal_subtype": "תת-סוג ספציפי בתוך תחום המשפט (למשל 'תכנית רחביה', 'מימוש במכר', 'תמ\\"א 38', 'שימוש חורג', 'סופיות ההחלטה'). מילה אחת או צירוף קצר.",
"practice_area": "תחום-העל המשפטי — אחד מ-3 בלבד: 'rishuy_uvniya' (רישוי ובנייה / היתרי בנייה / שימוש חורג / הקלות / תכנון), 'betterment_levy' (היטל השבחה — חיוב בעל מקרקעין בגין עליית-שווי מאישור תכנית), 'compensation_197' (פיצויים לפי סעיף 197 לחוק התכנון והבנייה — פגיעה במקרקעין ע\\\"י תכנית). קבע לפי מהות הסכסוך כפי שהוא עולה מהטקסט. אם לא ברור לאיזה מהשלושה — מחרוזת ריקה (אל תנחש).",
"summary": "תקציר עניני 2-3 משפטים: מה הייתה השאלה, מה הוכרע. בלי שיפוט.",
"headnote": "headnote בסגנון נבו: 1-2 משפטים שמסכמים את העיקרון שנקבע/יושם בפסק. למשל 'תכנית רחביה — היטל השבחה במימוש במכר — אין לחייב כשהזכויות צפות'.",
"key_quote": "ציטוט מילולי בודד, 30-100 מילים, שמייצג את לב הפסק. חייב להופיע מילה במילה בטקסט. אם אין ציטוט מתאים — מחרוזת ריקה.",
@@ -68,7 +73,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY''ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר''בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.",
"case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.",
"chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"chair_name": "שם יו\\\"ר ההרכב של **ההחלטה הזו** — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. כמעט תמיד מופיע — בשני מקומות: (א) בכותרת/רובריקה בראש המסמך, ליד 'בפני:' / 'בהרכב:' / רשימת חברי הוועדה; (ב) בבלוק-החתימה בסוף ההחלטה, אחרי 'ההחלטה ניתנה' — שם מופיעים זה-לצד-זה מזכיר/ת הוועדה והיו\\\"ר (למשל בשתי עמודות: בצד אחד 'פלוני, עו\\\"ד / מזכיר ועדת הערר' ובצד השני 'אלמוני, עו\\\"ד / יו\\\"ר ועדת הערר'). **קח את השם שמעליו/לצדו כתוב 'יו\\\"ר' — לא את המזכיר/ה.** השאר שם פרטי+משפחה בלבד, בלי תוארים ('עו\\\"ד', 'אדריכל', 'עו\\\"ד דפנה תמיר''דפנה תמיר'). **אזהרה קריטית:** אל תיקח שם יו\\\"ר של פסק/החלטה אחרים ש**מצוטטים** בגוף ההחלטה (למשל 'כפי שנקבע ברשותה של יו\\\"ר פלונית בערר אחר...') — אלה תקדימים מצוטטים, לא היו\\\"ר של ההחלטה הנוכחית. אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים''ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו''תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה.",
"parties": "שמות הצדדים בשורה אחת בצורה 'עורר נ\\' משיב' — בדיוק כפי שמופיעים בכותרת/רובריקה. בלי הדגשה, בלי מספר-תיק, בלי תוארים מיותרים. למשל 'ישיבת חברת אהבת שלום נ\\' תאיה' או 'ראם חיים נ\\' הוועדה המקומית לתכנון ובניה ירושלים'. אם הצדדים אינם מופיעים בטקסט (למשל החלטה שמתחילה בגוף בלי רובריקה) — מחרוזת ריקה. **אל תמציא שמות.**",
"citation_prefix": "קידומת-ההליך של פסיקת בית-משפט בלבד, כפי שמופיעה בראש הכותרת: ע\\"א / רע\\"א / בג\\"ץ / עע\\"מ / עת\\"מ / ע\\"פ / דנ\\"א / ת\\"א וכד'. **רק לפסקי בית-משפט (עליון/מנהלי)** — להחלטות ועדת-ערר השאר ריק (הקוד גוזר 'ערר'/'בל\\"מ' מעצמו). אם לא ברור — מחרוזת ריקה."
@@ -169,6 +174,16 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
out["case_name_short"] = result["case_name_short"].strip()
if isinstance(result.get("appeal_subtype"), str):
out["appeal_subtype"] = result["appeal_subtype"].strip()
if isinstance(result.get("practice_area"), str):
# Closed domain enum (axis B). Anything else (incl. the legacy
# multi-tenant 'appeals_committee' value or free text) is dropped so a
# slip can't write an unrenderable value into the radio facet — the
# deterministic case_number-prefix derivation in apply_to_record is the
# authoritative source anyway; this is the content fallback for court
# rulings whose docket prefix doesn't encode the domain.
pa = result["practice_area"].strip()
if pa in DOMAIN_PRACTICE_AREAS:
out["practice_area"] = pa
if isinstance(result.get("summary"), str):
out["summary"] = result["summary"].strip()
if isinstance(result.get("headnote"), str):
@@ -380,6 +395,25 @@ async def apply_to_record(
else:
fields_to_update["case_number"] = cn_clean
# practice_area — the domain facet (axis B) that drives the /precedents radio
# and search filters. The LLM never set it historically (it was passed in as
# read-only context), so committee/court uploads that left it blank stayed
# blank forever. Fill when empty, preferring the DETERMINISTIC case_number
# prefix (1xxx→rishuy, 8xxx→היטל, 9xxx→197 — authoritative for ועדת-ערר
# dockets, INV-AH rule-based) and falling back to the LLM's content
# classification for court rulings whose docket prefix doesn't encode a
# domain. Built off the EFFECTIVE case_number so a same-run normalization is
# seen. Abstains (no write) when neither yields a domain value.
if not (record.get("practice_area") or "").strip():
eff_cn = (
fields_to_update.get("case_number") or record.get("case_number") or ""
)
pa = derive_domain_practice_area(eff_cn) or (
suggested.get("practice_area") or ""
).strip()
if pa in DOMAIN_PRACTICE_AREAS:
fields_to_update["practice_area"] = pa
# parties — store the extracted "עורר נ' משיב" line (the re-derivable basis for
# the deterministic citation). Only fill when empty; chair edits are preserved.
if not (record.get("parties") or "").strip():
@@ -387,11 +421,30 @@ async def apply_to_record(
if p:
fields_to_update["parties"] = p
# chair_name / district — only for internal_committee rows. The DB CHECK
# forces these to be non-empty, so the upload endpoint stamps the row
# with "(טרם חולץ)" as a placeholder. Treat that placeholder as empty
# so the LLM-extracted value can overwrite it.
if record.get("source_kind") == "internal_committee":
# chair_name / district — for ANY ועדת-ערר decision, regardless of how it
# entered the corpus. Previously gated on source_kind=='internal_committee',
# which silently skipped committee decisions uploaded via the EXTERNAL
# precedent path (source_kind='external_upload', source_type='appeals_committee'
# — e.g. another district's decision pulled from נבו): the chair sat in the
# signature block but was never extracted. The CHECK only forces non-empty for
# internal_committee, so writing a chair onto an external_upload row is safe;
# for internal rows the upload endpoint stamps "(טרם חולץ)" which we treat as
# empty. The LLM prompt already abstains (empty) for court rulings, so this is
# additionally gated on the decision actually being a committee one — never a
# court ruling. Derive "is committee" from the effective source_type/level so a
# same-run fill is seen.
eff_st_chair = (
fields_to_update.get("source_type") or record.get("source_type") or ""
).strip()
eff_lvl_chair = (
fields_to_update.get("precedent_level") or record.get("precedent_level") or ""
).strip()
is_committee_decision = (
record.get("source_kind") == "internal_committee"
or eff_st_chair == "appeals_committee"
or eff_lvl_chair.startswith("ועדת_ערר")
)
if is_committee_decision:
cur_chair = (record.get("chair_name") or "").strip()
if cur_chair in ("", PLACEHOLDER_PENDING_EXTRACTION):
s = (suggested.get("chair_name") or "").strip()