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

@@ -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) {
<h3 className="text-sm font-semibold text-navy">
העלאת הפסיקה לקורפוס
</h3>
<div className="text-[0.78rem] text-ink-muted">
<div className="text-[0.78rem] text-ink-muted leading-relaxed">
ניתוב אוטומטי לפי הציטוט:&nbsp;
<strong className="text-navy">
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
</strong>
<br />
שדות נוספים (שם, ערכאה, תאריך, יו״ר, מחוז, תת־סוג) יחולצו אוטומטית
מהקובץ ברקע.
</div>
<form onSubmit={handleUpload} className="space-y-3">
@@ -360,134 +359,137 @@ export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="court">ערכאה</Label>
<Input
id="court"
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>
<details className="group rounded-md border border-rule bg-rule-soft/30">
<summary className="cursor-pointer select-none px-3 py-2 text-[0.78rem] text-ink-muted hover:text-navy">
אופציונלי דריסה ידנית של שדות שיחולצו אוטומטית
</summary>
<div className="space-y-3 border-t border-rule px-3 py-3">
<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>
<Label htmlFor="chair_name">
יו״ר <span className="text-danger">*</span>
</Label>
<Label htmlFor="court">ערכאה</Label>
<Input
id="chair_name"
value={chairName}
onChange={(e) => setChairName(e.target.value)}
placeholder="דפנה תמיר"
id="court"
value={court}
onChange={(e) => setCourt(e.target.value)}
placeholder="בית המשפט העליון"
dir="rtl"
required
/>
</div>
<div>
<Label htmlFor="district">
מחוז <span className="text-danger">*</span>
</Label>
<Select value={district} onValueChange={setDistrict}>
<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="בחר" />
<SelectValue placeholder="ללא" />
</SelectTrigger>
<SelectContent>
{DISTRICTS.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
{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>
<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>
<Label htmlFor="committee_case_number">
מספר ערר (לציטוט הקטן)
</Label>
<Input
id="committee_case_number"
value={committeeCaseNumber}
onChange={(e) => setCommitteeCaseNumber(e.target.value)}
placeholder="ערר 1112/22 ..."
<Label htmlFor="summary">תקציר</Label>
<Textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={2}
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>
<Label htmlFor="summary">תקציר</Label>
<Textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
</details>
<Button
type="submit"

View File

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