feat(precedents): minimum-effort upload — file+citation, rest auto-extracted
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m35s

The missing-precedents drawer + general precedent upload both required
the user to type chair_name, district, practice_area, court, date etc.
upfront — even though those fields can be (and already are, post-upload)
extracted from the document text by the LLM. The metadata-extraction
wakeup also only fired for the /precedent-library/upload path, leaving
missing-precedents committee uploads stuck with whatever stub the user
typed.

Changes:
- Extractor learns chair_name + district, overwrites the new
  PLACEHOLDER_PENDING_EXTRACTION sentinel for internal_committee rows
  (the DB CHECK forces non-empty; we stamp the placeholder at insert).
- missing_precedent_upload no longer 400s on missing chair/district;
  it infers district from the citation when possible, falls back to
  the placeholder, and always fires pc_wake_for_precedent_extraction
  so the LLM can fill in the rest.
- Both upload sheets default to file (+ citation) only; every other
  field is tucked into a closed <details> labeled "אופציונלי — דריסה
  ידנית של שדות שיחולצו אוטומטית". Required validators on chair/
  district/practice_area dropped — the LLM fills them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 14:43:25 +00:00
parent b01722b1b4
commit a02a4e3a64
4 changed files with 291 additions and 225 deletions

View File

@@ -3,7 +3,9 @@
Runs after chunking. Reads the precedent's full_text and asks Claude to 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: 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. 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 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
@@ -22,6 +24,12 @@ from legal_mcp.services import claude_session, db
logger = logging.getLogger(__name__) 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 # The prompt is short — we only need the first 12K chars of the ruling
# (header + opening of discussion is enough for naming + summary). For # (header + opening of discussion is enough for naming + summary). For
# subject tags we sample the discussion section too. # subject tags we sample the discussion section too.
@@ -52,7 +60,9 @@ METADATA_EXTRACTION_PROMPT = """אתה מסייע משפטי בכיר. קרא א
"source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.", "source_type": "אחד מ-2: 'court_ruling' (פסק דין של בית משפט — עליון/מנהלי) / 'appeals_committee' (החלטה של ועדת ערר). אם לא ברור — מחרוזת ריקה.",
"proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY''ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר''בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.", "proceeding_type": "אחד מ-2 (רק להחלטות ועדת ערר): 'ערר' (הליך ערר עיקרי על החלטת ועדה מקומית) / 'בל\\\"מ' (בקשה להארכת מועד להגשת ערר). זהה דרך כותרת המסמך: 'ערר (ועדות ערר ...) NNNN/YY''ערר'; 'בל\\\"מ NNNN/YY' או נושא 'בקשה להארכת מועד להגשת ערר''בל\\\"מ'. בפסיקת בית משפט (לא ועדת ערר) — מחרוזת ריקה.",
"court": "שם הערכאה כפי שהוא מופיע בכותרת (למשל 'בית המשפט העליון', 'בית המשפט המחוזי בירושלים בשבתו כבית משפט לעניינים מנהליים', 'ועדת הערר לתכנון ובניה פיצויים והיטלי השבחה — מחוז ירושלים'). מחרוזת ריקה אם לא ניתן לזהות.", "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` — שני השדות צריכים להיות תואמים. 9. **source_type** — שני ערכים בלבד: "court_ruling" כשהמסמך הוא פסק דין/החלטה של בית משפט (עליון/בג"ץ/מנהלי/מחוזי); "appeals_committee" כשהמסמך הוא החלטה של ועדת ערר (ארצית או מחוזית). זה משלים את `precedent_level` — שני השדות צריכים להיות תואמים.
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 = מחוז הוועדה, מתוך רשימה סגורה. עבור פסקי בית משפט — שני השדות ריקים.
""" """
@@ -170,6 +181,14 @@ async def extract_metadata(case_law_id: UUID | str) -> dict:
out["court"] = result["court"].strip() out["court"] = result["court"].strip()
if isinstance(result.get("case_number_clean"), str): if isinstance(result.get("case_number_clean"), str):
out["case_number_clean"] = result["case_number_clean"].strip() 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 return out
@@ -285,6 +304,22 @@ async def apply_to_record(
if cn: if cn:
fields_to_update["case_number"] = 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: if not fields_to_update:
return {"updated": False, "fields": []} return {"updated": False, "fields": []}

View File

@@ -121,17 +121,13 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
toast.error("בחר קובץ"); toast.error("בחר קובץ");
return; return;
} }
if (isCommittee && (!chairName.trim() || !district.trim())) {
toast.error("החלטת ועדת ערר דורשת שם יו״ר ומחוז");
return;
}
try { try {
const result = await upload.mutateAsync({ await upload.mutateAsync({
id: mp.id, id: mp.id,
file, file,
case_number: isCommittee ? committeeCaseNumber || undefined : undefined, case_number: isCommittee ? committeeCaseNumber || undefined : undefined,
chair_name: isCommittee ? chairName : undefined, chair_name: isCommittee ? chairName || undefined : undefined,
district: isCommittee ? district : undefined, district: isCommittee ? district || undefined : undefined,
case_name: caseName || undefined, case_name: caseName || undefined,
court: court || undefined, court: court || undefined,
decision_date: decisionDate || undefined, decision_date: decisionDate || undefined,
@@ -142,7 +138,7 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
summary: summary || undefined, summary: summary || undefined,
}); });
toast.success( toast.success(
`הפסיקה נכנסה לקורפוס (${result.route === "internal_committee" ? "ועדת ערר" : "פסק דין"}) והרשומה נסגרה.`, "הקובץ הועלה. חילוץ המטא־דאטה (שם, ערכאה, תאריך, יו״ר, מחוז…) מתבצע ברקע ויסתיים בתוך כדקה.",
); );
onOpenChange(false); onOpenChange(false);
} catch (e: unknown) { } catch (e: unknown) {
@@ -341,11 +337,14 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
<h3 className="text-sm font-semibold text-navy"> <h3 className="text-sm font-semibold text-navy">
העלאת הפסיקה לקורפוס העלאת הפסיקה לקורפוס
</h3> </h3>
<div className="text-[0.78rem] text-ink-muted"> <div className="text-[0.78rem] text-ink-muted leading-relaxed">
ניתוב אוטומטי לפי הציטוט:&nbsp; ניתוב אוטומטי לפי הציטוט:&nbsp;
<strong className="text-navy"> <strong className="text-navy">
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"} {isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
</strong> </strong>
<br />
שדות נוספים (שם, ערכאה, תאריך, יו״ר, מחוז, תת־סוג) יחולצו אוטומטית
מהקובץ ברקע.
</div> </div>
<form onSubmit={handleUpload} className="space-y-3"> <form onSubmit={handleUpload} className="space-y-3">
@@ -360,134 +359,137 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
/> />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <details className="group rounded-md border border-rule bg-rule-soft/30">
<div> <summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
<Label htmlFor="court">ערכאה</Label> אופציונלי דריסה ידנית של שדות שיחולצו אוטומטית
<Input </summary>
id="court" <div className="space-y-3 border-t border-rule px-3 py-3">
value={court}
onChange={(e) => setCourt(e.target.value)}
placeholder="בית המשפט העליון"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="decision_date">תאריך</Label>
<Input
id="decision_date"
type="date"
value={decisionDate}
onChange={(e) => setDecisionDate(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="practice_area">תחום</Label>
<Select value={practiceArea} onValueChange={setPracticeArea}>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRACTICE_AREAS.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="appeal_subtype">תת־סוג</Label>
<Input
id="appeal_subtype"
value={appealSubtype}
onChange={(e) => setAppealSubtype(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
</div>
{isCommittee ? (
<>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<Label htmlFor="chair_name"> <Label htmlFor="court">ערכאה</Label>
יו״ר <span className="text-danger">*</span>
</Label>
<Input <Input
id="chair_name" id="court"
value={chairName} value={court}
onChange={(e) => setChairName(e.target.value)} onChange={(e) => setCourt(e.target.value)}
placeholder="דפנה תמיר" placeholder="בית המשפט העליון"
dir="rtl" dir="rtl"
required
/> />
</div> </div>
<div> <div>
<Label htmlFor="district"> <Label htmlFor="decision_date">תאריך</Label>
מחוז <span className="text-danger">*</span> <Input
</Label> id="decision_date"
<Select value={district} onValueChange={setDistrict}> type="date"
value={decisionDate}
onChange={(e) => setDecisionDate(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="practice_area">תחום</Label>
<Select value={practiceArea} onValueChange={setPracticeArea}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="בחר" /> <SelectValue placeholder="ללא" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{DISTRICTS.map((d) => ( {PRACTICE_AREAS.map((a) => (
<SelectItem key={d.value} value={d.value}> <SelectItem key={a.value} value={a.value}>
{d.label} {a.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div>
<Label htmlFor="appeal_subtype">תת־סוג</Label>
<Input
id="appeal_subtype"
value={appealSubtype}
onChange={(e) => setAppealSubtype(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
</div> </div>
{isCommittee ? (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="chair_name">יו״ר</Label>
<Input
id="chair_name"
value={chairName}
onChange={(e) => setChairName(e.target.value)}
placeholder="דפנה תמיר"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="district">מחוז</Label>
<Select value={district} onValueChange={setDistrict}>
<SelectTrigger>
<SelectValue placeholder="בחר" />
</SelectTrigger>
<SelectContent>
{DISTRICTS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="committee_case_number">
מספר ערר (לציטוט הקטן)
</Label>
<Input
id="committee_case_number"
value={committeeCaseNumber}
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
placeholder="ערר 1112/22 ..."
dir="rtl"
/>
</div>
</>
) : (
<div>
<Label htmlFor="precedent_level">רמת תקדים</Label>
<Select
value={precedentLevel}
onValueChange={setPrecedentLevel}
>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div> <div>
<Label htmlFor="committee_case_number"> <Label htmlFor="summary">תקציר</Label>
מספר ערר (לציטוט הקטן) <Textarea
</Label> id="summary"
<Input value={summary}
id="committee_case_number" onChange={(e) => setSummary(e.target.value)}
value={committeeCaseNumber} rows={2}
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
placeholder="ערר 1112/22 ..."
dir="rtl" dir="rtl"
/> />
</div> </div>
</>
) : (
<div>
<Label htmlFor="precedent_level">רמת תקדים</Label>
<Select
value={precedentLevel}
onValueChange={setPrecedentLevel}
>
<SelectTrigger>
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>
{l.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
)} </details>
<div>
<Label htmlFor="summary">תקציר</Label>
<Textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
<Button <Button
type="submit" type="submit"

View File

@@ -93,10 +93,6 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
toast.error("מראה המקום (citation) חובה"); toast.error("מראה המקום (citation) חובה");
return; return;
} }
if (!practiceArea) {
toast.error("בחר תחום משפט");
return;
}
try { try {
const tags = subjectTags const tags = subjectTags
.split(",") .split(",")
@@ -132,8 +128,9 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
<SheetHeader> <SheetHeader>
<SheetTitle className="text-navy">העלאת פסיקה לקורפוס הסמכותי</SheetTitle> <SheetTitle className="text-navy">העלאת פסיקה לקורפוס הסמכותי</SheetTitle>
<SheetDescription className="text-ink-muted"> <SheetDescription className="text-ink-muted">
הקובץ יעבור חילוץ טקסט, יצירת embeddings, וחילוץ הלכות אוטומטי. הקובץ יעבור חילוץ טקסט, embeddings, וחילוץ אוטומטי של מטא־דאטה
ההלכות יחכו לאישורך לפני שהן זמינות לסוכני הכתיבה. (שם, ערכאה, תאריך, תחום, תת־סוג, תגיות) והלכות. ההלכות ימתינו
לאישורך לפני שיהיו זמינות לסוכני הכתיבה.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
@@ -159,104 +156,109 @@ export function PrecedentUploadSheet({ open, onOpenChange }: Props) {
/> />
</div> </div>
{/* Two-col grid */} <details className="group rounded-md border border-rule bg-rule-soft/30">
<div className="grid grid-cols-2 gap-3"> <summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
<div className="space-y-1"> אופציונלי דריסה ידנית של שדות שיחולצו אוטומטית מהמסמך
<Label htmlFor="case-name">שם קצר</Label> </summary>
<Input id="case-name" value={caseName} <div className="space-y-3 border-t border-rule px-3 py-3">
onChange={(e) => setCaseName(e.target.value)} <div className="grid grid-cols-2 gap-3">
placeholder="ב. קרן-נכסים" disabled={isProcessing} /> <div className="space-y-1">
</div> <Label htmlFor="case-name">שם קצר</Label>
<div className="space-y-1"> <Input id="case-name" value={caseName}
<Label htmlFor="court">ערכאה</Label> onChange={(e) => setCaseName(e.target.value)}
<Input id="court" value={court} placeholder="ב. קרן-נכסים" disabled={isProcessing} />
onChange={(e) => setCourt(e.target.value)} </div>
placeholder='בית משפט עליון / בג"ץ / מנהלי / ועדת ערר' <div className="space-y-1">
disabled={isProcessing} /> <Label htmlFor="court">ערכאה</Label>
</div> <Input id="court" value={court}
<div className="space-y-1"> onChange={(e) => setCourt(e.target.value)}
<Label htmlFor="date">תאריך החלטה</Label> placeholder='בית משפט עליון / בג"ץ / מנהלי / ועדת ערר'
<Input id="date" type="date" value={decisionDate} disabled={isProcessing} />
onChange={(e) => setDecisionDate(e.target.value)} </div>
disabled={isProcessing} /> <div className="space-y-1">
</div> <Label htmlFor="date">תאריך החלטה</Label>
<div className="space-y-1"> <Input id="date" type="date" value={decisionDate}
<Label htmlFor="appeal-subtype">תת-סוג (חופשי)</Label> onChange={(e) => setDecisionDate(e.target.value)}
<Input id="appeal-subtype" value={appealSubtype} disabled={isProcessing} />
onChange={(e) => setAppealSubtype(e.target.value)} </div>
placeholder="שימוש חורג / סופיות ההחלטה" disabled={isProcessing} /> <div className="space-y-1">
</div> <Label htmlFor="appeal-subtype">תת-סוג (חופשי)</Label>
</div> <Input id="appeal-subtype" value={appealSubtype}
onChange={(e) => setAppealSubtype(e.target.value)}
placeholder="שימוש חורג / סופיות ההחלטה" disabled={isProcessing} />
</div>
</div>
{/* Practice area (required radio) */} <div className="space-y-1">
<div className="space-y-1"> <Label>תחום משפט</Label>
<Label>תחום משפט (חובה)</Label> <div className="flex gap-4 flex-wrap">
<div className="flex gap-4 flex-wrap"> {PRACTICE_AREAS.map((a) => (
{PRACTICE_AREAS.map((a) => ( <label key={a.value} className="flex items-center gap-2 cursor-pointer">
<label key={a.value} className="flex items-center gap-2 cursor-pointer"> <input
<input type="radio" name="practice_area" value={a.value}
type="radio" name="practice_area" value={a.value} checked={practiceArea === a.value}
checked={practiceArea === a.value} onChange={() => setPracticeArea(a.value as PracticeArea)}
onChange={() => setPracticeArea(a.value as PracticeArea)} disabled={isProcessing}
disabled={isProcessing} />
/> <span className="text-sm text-ink">{a.label}</span>
<span className="text-sm text-ink">{a.label}</span> </label>
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="source-type">סוג מקור</Label>
<Select value={sourceType || "_none"}
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
disabled={isProcessing}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{SOURCE_TYPES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))} ))}
</SelectContent> </div>
</Select> </div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="source-type">סוג מקור</Label>
<Select value={sourceType || "_none"}
onValueChange={(v) => setSourceType(v === "_none" ? "" : v as SourceType)}
disabled={isProcessing}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{SOURCE_TYPES.map((s) => (
<SelectItem key={s.value} value={s.value}>{s.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="precedent-level">רמת תקדים</Label>
<Select value={precedentLevel || "_none"}
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
disabled={isProcessing}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
<Input id="tags" value={subjectTags}
onChange={(e) => setSubjectTags(e.target.value)}
placeholder="חניה, קווי בניין, שיקול דעת" disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="headnote">תקציר / headnote</Label>
<Textarea id="headnote" value={headnote} rows={2}
onChange={(e) => setHeadnote(e.target.value)}
placeholder="תקציר חופשי שיוצג ברשימה" disabled={isProcessing} dir="rtl" />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isBinding}
onChange={(e) => setIsBinding(e.target.checked)}
disabled={isProcessing} />
<span className="text-sm">הלכה מחייבת</span>
</label>
</div> </div>
<div className="space-y-1"> </details>
<Label htmlFor="precedent-level">רמת תקדים</Label>
<Select value={precedentLevel || "_none"}
onValueChange={(v) => setPrecedentLevel(v === "_none" ? "" : v)}
disabled={isProcessing}>
<SelectTrigger><SelectValue placeholder="—" /></SelectTrigger>
<SelectContent>
<SelectItem value="_none"></SelectItem>
{PRECEDENT_LEVELS.map((l) => (
<SelectItem key={l.value} value={l.value}>{l.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="tags">תגיות נושא (מופרדות בפסיקים)</Label>
<Input id="tags" value={subjectTags}
onChange={(e) => setSubjectTags(e.target.value)}
placeholder="חניה, קווי בניין, שיקול דעת" disabled={isProcessing} />
</div>
<div className="space-y-1">
<Label htmlFor="headnote">תקציר / headnote (אופציונלי)</Label>
<Textarea id="headnote" value={headnote} rows={2}
onChange={(e) => setHeadnote(e.target.value)}
placeholder="תקציר חופשי שיוצג ברשימה" disabled={isProcessing} dir="rtl" />
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isBinding}
onChange={(e) => setIsBinding(e.target.checked)}
disabled={isProcessing} />
<span className="text-sm">הלכה מחייבת</span>
</label>
{isProcessing && ( {isProcessing && (
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 space-y-2"> <div className="rounded-lg border border-rule bg-rule-soft/40 p-4 space-y-2">

View File

@@ -4416,6 +4416,9 @@ async def _process_training_document(task_id: str, source: Path, req: ClassifyRe
# corpus) and /api/cases/{n}/precedents (chair-attached quotes). # corpus) and /api/cases/{n}/precedents (chair-attached quotes).
from legal_mcp.services import precedent_library as plib_service # noqa: E402 from legal_mcp.services import precedent_library as plib_service # noqa: E402
from legal_mcp.services.precedent_metadata_extractor import ( # noqa: E402
PLACEHOLDER_PENDING_EXTRACTION,
)
_PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"} _PRACTICE_AREAS = {"", "rishuy_uvniya", "betterment_levy", "compensation_197"}
@@ -5237,11 +5240,20 @@ async def missing_precedent_upload(
try: try:
if is_committee: if is_committee:
if not chair_name.strip() or not district.strip(): # The DB CHECK forces chair_name + district to be non-empty for
raise HTTPException( # internal_committee rows. The UX goal is "upload file + citation
400, # only" — so if the user didn't fill those, infer district from
"החלטת ועדת ערר דורשת chair_name + district", # the citation text (often contains the committee name, e.g.
) # "ועדות ערר - תכנון ובנייה תל אביב-יפו") and fall back to a
# placeholder. The metadata extractor wakeup fired below will
# overwrite both placeholders once the LLM reads the file.
resolved_chair = chair_name.strip() or PLACEHOLDER_PENDING_EXTRACTION
resolved_district = (
district.strip()
or int_decisions_service._district_from_court(court)
or int_decisions_service._district_from_court(citation)
or PLACEHOLDER_PENDING_EXTRACTION
)
# case_number for the committee decision (not the cited-in case) # case_number for the committee decision (not the cited-in case)
committee_case_number = case_number.strip() or citation committee_case_number = case_number.strip() or citation
result = await int_decisions_service.ingest_internal_decision( result = await int_decisions_service.ingest_internal_decision(
@@ -5249,8 +5261,8 @@ async def missing_precedent_upload(
case_name=(case_name.strip() or mp.get("case_name") or "").strip(), case_name=(case_name.strip() or mp.get("case_name") or "").strip(),
court=court.strip(), court=court.strip(),
decision_date=decision_date or None, decision_date=decision_date or None,
chair_name=chair_name.strip(), chair_name=resolved_chair,
district=district.strip(), district=resolved_district,
practice_area=practice_area, practice_area=practice_area,
appeal_subtype=appeal_subtype.strip(), appeal_subtype=appeal_subtype.strip(),
subject_tags=tags, subject_tags=tags,
@@ -5298,6 +5310,21 @@ async def missing_precedent_upload(
except Exception as e: except Exception as e:
logger.exception("missing-precedent close failed") logger.exception("missing-precedent close failed")
raise HTTPException(500, f"קישור הרשומה נכשל: {e}") raise HTTPException(500, f"קישור הרשומה נכשל: {e}")
# Fire metadata-extraction wakeup so the placeholder fields above
# (and any other empty user-supplied fields) get filled in from the
# file's text. Best-effort: mirrors the precedent_library_upload
# contract — failures are logged, not surfaced.
try:
await pc_wake_for_precedent_extraction(
case_law_id=case_law_id,
citation=citation,
practice_area=practice_area,
)
except Exception:
logger.exception(
"missing-precedent: precedent-extraction wakeup failed (non-fatal)"
)
finally: finally:
staged.unlink(missing_ok=True) staged.unlink(missing_ok=True)