diff --git a/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py b/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py index 6adbc2b..42acc38 100644 --- a/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py +++ b/mcp-server/src/legal_mcp/services/precedent_metadata_extractor.py @@ -3,7 +3,9 @@ Runs after chunking. Reads the precedent's full_text and asks Claude to fill in the metadata fields that an upload form usually leaves empty: short case_name, summary, headnote, key_quote, subject_tags, -appeal_subtype, decision_date, precedent_level, court. +appeal_subtype, decision_date, precedent_level, court — plus +chair_name + district for internal_committee rows (which the upload +path stamps with PLACEHOLDER_PENDING_EXTRACTION when missing). Caller policy: only empty user-supplied fields are filled. Anything the chair already typed in the upload form is preserved. This is enforced @@ -22,6 +24,12 @@ from legal_mcp.services import claude_session, db logger = logging.getLogger(__name__) +# Sentinel inserted by the upload endpoint when a committee row is created +# without chair_name/district (the DB CHECK forces non-empty). Treated as +# empty by ``apply_to_record`` so LLM-extracted values overwrite it. +PLACEHOLDER_PENDING_EXTRACTION = "(טרם חולץ)" + + # The prompt is short — we only need the first 12K chars of the ruling # (header + opening of discussion is enough for naming + summary). For # subject tags we sample the discussion section too. @@ -52,7 +60,9 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א "source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.", "proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY' → 'ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר' → 'בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.", "court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.", - "case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות." + "case_number_clean": "מספר הערר/תיק כפי שמופיע בכותרת — רק הספרות והאלכסון, למשל '1062/24' או '8031/21'. ללא המילה 'ערר', ללא שם הצדדים, ללא סוגריים. אם יש כמה עררים מאוחדים — הרשום הראשון. מחרוזת ריקה אם לא ניתן לזהות.", + "chair_name": "שם יו\\\"ר ההרכב — רלוונטי **רק להחלטות ועדת ערר**, לא לפסקי בית משפט. חפש בכותרת/חתימה: 'עו\\\"ד דפנה תמיר, יו\\\"ר ועדת הערר', 'בפני: עו\\\"ד פלוני אלמוני (יו\\\"ר)'. השאר שם פרטי+משפחה בלי תוארים ('עו\\\"ד', 'אדריכל'). אם זה פסק דין של בית משפט — מחרוזת ריקה.", + "district": "מחוז ועדת הערר — רלוונטי **רק להחלטות ועדת ערר**. ערכים מותרים: 'ירושלים', 'תל אביב', 'מרכז', 'חיפה', 'צפון', 'דרום', 'ארצית'. זהה מהכותרת ('ועדת הערר לתכנון ובניה — מחוז ירושלים' → 'ירושלים'; 'ועדות ערר - תכנון ובנייה תל אביב-יפו' → 'תל אביב'). אם זה פסק דין של בית משפט — מחרוזת ריקה." } ## כללי איכות @@ -67,6 +77,7 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א 9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים. 10. **court** — מהכותרת הראשית של הפסק. ניסוח מלא (לא קיצור). מחרוזת ריקה אם לא ניתן לזהות. 11. **proceeding_type** — חובה לזהות עבור החלטות ועדת ערר; ריק עבור פסיקת בית משפט. הסימן הברור: בכותרת הראשונה של המסמך כתוב "ערר (ועדות ערר ...) NNNN/YY" → 'ערר'; "בל\"מ NNNN/YY" או הנושא "בקשה להארכת מועד להגשת ערר" → 'בל\"מ'. שני הסוגים יכולים לחלוק אותו מספר תיק — לכן חשוב להבחין מפורשות. +12. **chair_name / district** — חובה למלא רק עבור החלטות ועדת ערר (source_type='appeals_committee'). chair_name נמצא בכותרת ("בפני: עו\"ד פלוני אלמוני, יו\"ר") או בחתימה. district = מחוז הוועדה, מתוך רשימה סגורה. עבור פסקי בית משפט — שני השדות ריקים. """ @@ -170,6 +181,14 @@ async def extract_metadata(case_law_id: UUID | str) -> dict: out["court"] = result["court"].strip() if isinstance(result.get("case_number_clean"), str): out["case_number_clean"] = result["case_number_clean"].strip() + if isinstance(result.get("chair_name"), str): + out["chair_name"] = result["chair_name"].strip() + if isinstance(result.get("district"), str): + d = result["district"].strip() + # Closed enum for districts — anything else is dropped to avoid + # silently storing free-text in what callers treat as a filter facet. + if d in {"ירושלים", "תל אביב", "מרכז", "חיפה", "צפון", "דרום", "ארצית"}: + out["district"] = d return out @@ -285,6 +304,22 @@ async def apply_to_record( if cn: fields_to_update["case_number"] = cn + # 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": + cur_chair = (record.get("chair_name") or "").strip() + if cur_chair in ("", PLACEHOLDER_PENDING_EXTRACTION): + s = (suggested.get("chair_name") or "").strip() + if s: + fields_to_update["chair_name"] = s + cur_district = (record.get("district") or "").strip() + if cur_district in ("", PLACEHOLDER_PENDING_EXTRACTION): + s = (suggested.get("district") or "").strip() + if s: + fields_to_update["district"] = s + if not fields_to_update: return {"updated": False, "fields": []} diff --git a/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx b/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx index f002bf6..ac16276 100644 --- a/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx +++ b/web-ui/src/components/missing-precedents/missing-precedent-detail-drawer.tsx @@ -121,17 +121,13 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) { toast.error("בחר קובץ"); return; } - if (isCommittee && (!chairName.trim() || !district.trim())) { - toast.error("החלטת ועדת ערר דורשת שם יו״ר ומחוז"); - return; - } try { - const result = await upload.mutateAsync({ + await upload.mutateAsync({ id: mp.id, file, case_number: isCommittee ? committeeCaseNumber || undefined : undefined, - chair_name: isCommittee ? chairName : undefined, - district: isCommittee ? district : undefined, + chair_name: isCommittee ? chairName || undefined : undefined, + district: isCommittee ? district || undefined : undefined, case_name: caseName || undefined, court: court || undefined, decision_date: decisionDate || undefined, @@ -142,7 +138,7 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) { summary: summary || undefined, }); toast.success( - `הפסיקה נכנסה לקורפוס (${result.route === "internal_committee" ? "ועדת ערר" : "פסק דין"}) והרשומה נסגרה.`, + "הקובץ הועלה. חילוץ המטא־דאטה (שם, ערכאה, תאריך, יו״ר, מחוז…) מתבצע ברקע ויסתיים בתוך כדקה.", ); onOpenChange(false); } catch (e: unknown) { @@ -341,11 +337,14 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {