feat: Stage A finalizers + #35/#36/#37 — critical-gap closure
Some checks failed
Build & Deploy / build-and-deploy (push) Has been cancelled

Four parallel sub-agents closed the remaining critical gaps from the
26/05 Stage A/B sprint. Each block independently tested; aggregated here.

## #30/#31 finalizers (sub-agent A)
* Auto-derive practice_area in case_create from case_number prefix
  (1xxx→rishuy_uvniya, 8xxx→betterment_levy, 9xxx→compensation_197);
  default for CaseCreateRequest is now "" (the DB constraint catches
  any stray "appeals_committee").
* practice_area.py: derive_subtype now handles axis-B domain values
  (rishuy_uvniya/betterment_levy/compensation_197) without parsing the
  case number; new helper derive_domain_practice_area().
* Halacha re-extraction verified unnecessary — all 6 reclassified
  records already had is_binding=false and approved halachot.
* Regression tests: 6 cases in tests/test_corpus_constraints.py
  covering practice_area enum, internal-committee chair/district,
  external-upload arar prefix, MCP guard.
* UI: district input → Select dropdown (7 districts) in
  precedent-edit-sheet.tsx, preserving legacy free-text values.

## #37 בל"מ subtypes (sub-agent B)
* 3 new appeal_subtypes: extension_request_{building_permit,
  betterment_levy,compensation}. APPEALS_COMMITTEE_SUBTYPES extended,
  SUBTYPES_BY_AREA mappings added.
* New helpers: is_blam_subject(), is_blam_subtype(),
  derive_subtype_with_blam(case_number, subject, practice_area).
  case_create now uses it to auto-detect "בקשה להארכת מועד" subjects.
* 3 methodology templates under docs/methodology/extension-request-*.md.
* paperclip_client.py mapping updated for the 3 new subtypes
  (extension_request_building_permit→CMP, the other two→CMPA).
* Frontend: bilingual "בל"מ" badge + filter dropdown on cases list +
  detail header; appeal-type-bars collapseBlam() merges בל"מ into its
  parent domain for aggregate bars.
* Wizard auto-detects בל"מ from subject during case creation.
* 3 Berlinger cases (1017/1018/1019-03-26) migrated to
  appeal_subtype=extension_request_building_permit via psql.

## #35 missing_precedents feature (sub-agent C)
* Schema V13: missing_precedents table (citation, case_id, party,
  legal_topic, status, linked_case_law_id, claim_quote, ...) +
  FK constraints + 3 indexes. Applied via psql + idempotent migration.
* 6 db.py service functions, 3 MCP tools, 6 FastAPI endpoints
  (POST/GET/PATCH/DELETE/upload — upload routes by citation prefix
  to ingest_internal_decision or ingest_precedent).
* Next.js page /missing-precedents with 5 status tabs + filters +
  sidebar badge counter + detail drawer with metadata edit + smart
  upload form that switches fields per committee/court.
* Bootstrap: 7 rows imported from the JSON file
  (3 citations × cases, all status=closed with linked_case_law_id).
* legal-researcher.md: new §2ב.5 with missing_precedent_create
  usage + dedup semantics + tool grant.

## #36 legal_arguments aggregation (sub-agent D)
* Schema V14: legal_arguments + legal_argument_propositions M:M.
  Applied via psql.
* New service argument_aggregator.py with two functions —
  aggregate_claims_to_arguments() (Claude CLI / claude_session) and
  get_legal_arguments(). Graceful llm_unavailable handling when CLI
  is missing (containers).
* 2 MCP tools + 2 API endpoints (POST .../aggregate-arguments as
  BackgroundTask, GET .../legal-arguments).
* Frontend: shadcn Accordion + new legal-arguments-panel.tsx with
  hierarchical (party → priority badge → arguments) display, "טיעונים"
  tab on the case page, "חשב/חשב מחדש" buttons.
* scripts/backfill_legal_arguments.py + SCRIPTS.md entry — dry-run
  found 8 candidate cases including 1017/1018/1019.

## Open follow-ups (intentionally deferred)
* npm run api:types in web-ui (CLAUDE.md flow) — recommended before
  the next UI commit; not required for backend deployment.
* Run backfill_legal_arguments.py --apply once the container picks up
  the new aggregator service.
* webhook on missing-precedents upload-close to Paperclip (optional).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 08:34:40 +00:00
parent af651d0135
commit f3cc9ca9d4
33 changed files with 4588 additions and 37 deletions

View File

@@ -0,0 +1,512 @@
"use client";
import { useEffect, useState } from "react";
import { Upload, Save, Loader2, CheckCircle2 } from "lucide-react";
import { toast } from "sonner";
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
useMissingPrecedent,
useUpdateMissingPrecedent,
useUploadMissingPrecedent,
CITED_BY_PARTY_LABELS,
STATUS_LABELS,
type CitedByParty,
type MissingPrecedentStatus,
type MissingPrecedentPatch,
} from "@/lib/api/missing-precedents";
import {
PRACTICE_AREAS, PRECEDENT_LEVELS, DISTRICTS,
} from "@/components/precedents/practice-area";
type Props = {
id: string | null;
onOpenChange: (open: boolean) => void;
};
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
function isCommitteeCitation(citation: string): boolean {
const norm = citation.trim();
return /^(ערר[\s(]|בל"מ[\s(]|ARAR )/.test(norm);
}
export function MissingPrecedentDetailDrawer({ id, onOpenChange }: Props) {
const open = id !== null;
const { data: mp, isPending } = useMissingPrecedent(id);
const update = useUpdateMissingPrecedent();
const upload = useUploadMissingPrecedent();
// Edit form for metadata.
const [legalTopic, setLegalTopic] = useState("");
const [legalIssue, setLegalIssue] = useState("");
const [cityParty, setCitedByParty] = useState<CitedByParty>("unknown");
const [citedByPartyName, setCitedByPartyName] = useState("");
const [caseName, setCaseName] = useState("");
const [notes, setNotes] = useState("");
const [status, setStatus] = useState<MissingPrecedentStatus>("open");
// Upload form fields.
const [file, setFile] = useState<File | null>(null);
const [decisionDate, setDecisionDate] = useState("");
const [court, setCourt] = useState("");
const [practiceArea, setPracticeArea] = useState<string>("");
const [appealSubtype, setAppealSubtype] = useState("");
const [precedentLevel, setPrecedentLevel] = useState("");
const [chairName, setChairName] = useState("");
const [district, setDistrict] = useState("");
const [committeeCaseNumber, setCommitteeCaseNumber] = useState("");
const [summary, setSummary] = useState("");
// Sync form from record when it loads or id changes.
const [syncedId, setSyncedId] = useState<string | null>(null);
if (mp && mp.id !== syncedId) {
setSyncedId(mp.id);
setLegalTopic(mp.legal_topic ?? "");
setLegalIssue(mp.legal_issue ?? "");
setCitedByParty(mp.cited_by_party ?? "unknown");
setCitedByPartyName(mp.cited_by_party_name ?? "");
setCaseName(mp.case_name ?? "");
setNotes(mp.notes ?? "");
setStatus(mp.status);
}
// Reset on close. The cascading-render warning is the intended side
// effect here — wiping the form when the drawer closes.
useEffect(() => {
if (open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setFile(null);
setSyncedId(null);
setDecisionDate(""); setCourt(""); setPracticeArea("");
setAppealSubtype(""); setPrecedentLevel(""); setChairName("");
setDistrict(""); setCommitteeCaseNumber(""); setSummary("");
}, [open]);
const handleSaveMetadata = async () => {
if (!mp) return;
const patch: MissingPrecedentPatch = {
legal_topic: legalTopic,
legal_issue: legalIssue,
cited_by_party: cityParty,
cited_by_party_name: citedByPartyName,
case_name: caseName,
notes,
status,
};
try {
await update.mutateAsync({ id: mp.id, patch });
toast.success("הרשומה עודכנה");
} catch (e) {
toast.error("העדכון נכשל");
console.error(e);
}
};
const isCommittee = mp ? isCommitteeCitation(mp.citation) : false;
const handleUpload = async (e: React.FormEvent) => {
e.preventDefault();
if (!mp || !file) {
toast.error("בחר קובץ");
return;
}
if (isCommittee && (!chairName.trim() || !district.trim())) {
toast.error("החלטת ועדת ערר דורשת שם יו״ר ומחוז");
return;
}
try {
const result = await upload.mutateAsync({
id: mp.id,
file,
case_number: isCommittee ? committeeCaseNumber || undefined : undefined,
chair_name: isCommittee ? chairName : undefined,
district: isCommittee ? district : undefined,
case_name: caseName || undefined,
court: court || undefined,
decision_date: decisionDate || undefined,
practice_area: practiceArea || undefined,
appeal_subtype: appealSubtype || undefined,
precedent_level: precedentLevel || undefined,
source_type: isCommittee ? "appeals_committee" : "court_ruling",
summary: summary || undefined,
});
toast.success(
`הפסיקה נכנסה לקורפוס (${result.route === "internal_committee" ? "ועדת ערר" : "פסק דין"}) והרשומה נסגרה.`,
);
onOpenChange(false);
} catch (e: unknown) {
const msg =
e instanceof Error
? e.message
: typeof e === "string"
? e
: "כשל העלאה";
toast.error(msg);
console.error(e);
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="left"
className="w-full sm:max-w-2xl overflow-y-auto"
>
<SheetHeader className="space-y-1">
<SheetTitle className="text-navy">
פסיקה חסרה
{mp ? (
<Badge
variant="outline"
className="ms-2 align-middle"
>
{STATUS_LABELS[mp.status]}
</Badge>
) : null}
</SheetTitle>
<SheetDescription>
פרטים מלאים והעלאת הפסיקה לקורפוס.
</SheetDescription>
</SheetHeader>
{isPending || !mp ? (
<div className="space-y-3 px-6 py-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
</div>
) : (
<div className="space-y-6 px-6 py-4">
{/* ── Citation block (read-only) ── */}
<section className="space-y-2">
<div className="text-[0.78rem] text-ink-muted">מראה מקום</div>
<div className="text-sm text-navy font-medium bg-rule-soft/40 rounded-md px-3 py-2 leading-relaxed">
{mp.citation}
</div>
{mp.claim_quote ? (
<>
<div className="text-[0.78rem] text-ink-muted mt-3">ציטוט מכתב הטענות</div>
<div className="text-xs text-ink bg-gold-wash/30 border-s-2 border-gold rounded-md px-3 py-2 leading-relaxed">
{mp.claim_quote}
</div>
</>
) : null}
</section>
{/* ── Linked record (if closed) ── */}
{mp.linked_case_law_id ? (
<section className="space-y-1 bg-emerald-50 border border-emerald-200 rounded-lg p-3">
<div className="flex items-center gap-2 text-emerald-800 font-medium text-sm">
<CheckCircle2 className="w-4 h-4" />
מקושר ל
</div>
<div className="text-sm text-emerald-900 truncate">
{mp.linked_case_law_name || "—"}
</div>
<div className="text-[0.72rem] text-emerald-700 truncate">
{mp.linked_case_law_number}
</div>
</section>
) : null}
{/* ── Editable metadata ── */}
<section className="space-y-3">
<h3 className="text-sm font-semibold text-navy">מטא־דאטה</h3>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="legal_topic">נושא משפטי</Label>
<Input
id="legal_topic"
value={legalTopic}
onChange={(e) => setLegalTopic(e.target.value)}
placeholder="זכות עמידה"
dir="rtl"
/>
</div>
<div>
<Label htmlFor="case_name">שם פסיקה</Label>
<Input
id="case_name"
value={caseName}
onChange={(e) => setCaseName(e.target.value)}
placeholder="אנטרים"
dir="rtl"
/>
</div>
</div>
<div>
<Label htmlFor="legal_issue">שאלה משפטית</Label>
<Textarea
id="legal_issue"
value={legalIssue}
onChange={(e) => setLegalIssue(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label htmlFor="cited_by_party">צד מצטט</Label>
<Select
value={cityParty}
onValueChange={(v) => setCitedByParty(v as CitedByParty)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(CITED_BY_PARTY_LABELS) as [CitedByParty, string][]).map(
([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="cited_by_party_name">שם צד</Label>
<Input
id="cited_by_party_name"
value={citedByPartyName}
onChange={(e) => setCitedByPartyName(e.target.value)}
placeholder="לינדאב בע״מ"
dir="rtl"
/>
</div>
</div>
<div>
<Label htmlFor="status">סטטוס</Label>
<Select
value={status}
onValueChange={(v) => setStatus(v as MissingPrecedentStatus)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(Object.entries(STATUS_LABELS) as [
MissingPrecedentStatus,
string,
][]).map(([v, label]) => (
<SelectItem key={v} value={v}>{label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="notes">הערות</Label>
<Textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
<Button
onClick={handleSaveMetadata}
disabled={update.isPending}
variant="outline"
className="border-rule"
>
{update.isPending ? (
<Loader2 className="w-4 h-4 me-1 animate-spin" />
) : (
<Save className="w-4 h-4 me-1" />
)}
שמור פרטים
</Button>
</section>
{/* ── Upload section ── */}
{!mp.linked_case_law_id ? (
<section className="space-y-3 border-t border-rule pt-5">
<h3 className="text-sm font-semibold text-navy">
העלאת הפסיקה לקורפוס
</h3>
<div className="text-[0.78rem] text-ink-muted">
ניתוב אוטומטי לפי הציטוט:&nbsp;
<strong className="text-navy">
{isCommittee ? "החלטת ועדת ערר (internal)" : "פסק דין (library)"}
</strong>
</div>
<form onSubmit={handleUpload} className="space-y-3">
<div>
<Label htmlFor="file">קובץ (PDF / DOCX / RTF / TXT / MD)</Label>
<Input
id="file"
type="file"
accept={ACCEPT}
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
required
/>
</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>
<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>
<Input
id="chair_name"
value={chairName}
onChange={(e) => setChairName(e.target.value)}
placeholder="דפנה תמיר"
dir="rtl"
required
/>
</div>
<div>
<Label htmlFor="district">
מחוז <span className="text-danger">*</span>
</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="summary">תקציר</Label>
<Textarea
id="summary"
value={summary}
onChange={(e) => setSummary(e.target.value)}
rows={2}
dir="rtl"
/>
</div>
<Button
type="submit"
disabled={!file || upload.isPending}
className="bg-navy text-parchment hover:bg-navy-soft"
>
{upload.isPending ? (
<Loader2 className="w-4 h-4 me-1 animate-spin" />
) : (
<Upload className="w-4 h-4 me-1" />
)}
העלאה וסגירה
</Button>
</form>
</section>
) : null}
</div>
)}
</SheetContent>
</Sheet>
);
}