Implements the chair-approved redesign of /missing-precedents (Claude Design card 09). The "צוטט ע״י" and "תיק" columns were empty for ~98% of rows because the corpus-decision reliance lives in precedent_internal_citations (keyed to case_law), a different system than missing_precedents (keyed to cases). Backend (G2 — read-time bridge, no stored duplication): - list_missing_precedents: new cited_by_chairs / cited_by_decisions columns, resolved from the citation graph by normalized docket number (same normalization the relinker uses). For an open gap cited by committee decisions, surfaces the chair(s) (e.g. דפנה תמיר) + the deciding case numbers. Frontend (matches approved mockup): - "צוטט ע״י" shows the chair chip (bridge) with priority over the generic discovery-source chips; "תיק" shows the deciding decision number(s) + "+N". - Status filter reduced to פתוח / נסגר (removed הועלה, לא-רלוונטי, הכל). - "פסיקה" column collapses to a single line when case_name is empty — fixes the duplicated-citation two-row rendering. - Lifecycle note simplified to open → closed. Verified: bridge SQL returns chair+decisions on live data; tsc --noEmit clean; py_compile clean. Follow-up (data, separate): LLM backfill of legal_topic (נושא); create missing_precedents rows for the 28 graph-cited rulings not yet tracked. Invariants: G2 (single source of truth — bridge derived at read, no duplicated column) · INV-AH context (closing source gaps). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
/**
|
|
* Missing precedents — citations the parties brought up but that aren't
|
|
* yet in the corpus.
|
|
*
|
|
* Lifecycle: 'open' → researcher logs gap → chair uploads decision via
|
|
* the dialog → POST /upload routes to internal_decision_upload (ערר/בל"מ)
|
|
* or precedent_library_upload (court rulings), then status flips to
|
|
* 'closed' with linked_case_law_id set.
|
|
*
|
|
* Endpoints touched:
|
|
* - POST /api/missing-precedents create (JSON body)
|
|
* - GET /api/missing-precedents?status=open list (filters)
|
|
* - GET /api/missing-precedents/{id} detail
|
|
* - PATCH /api/missing-precedents/{id} metadata edit
|
|
* - DELETE /api/missing-precedents/{id} remove
|
|
* - POST /api/missing-precedents/{id}/upload multipart upload + close
|
|
*/
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { ApiError, apiRequest } from "./client";
|
|
|
|
export type CitedByParty =
|
|
| "appellant"
|
|
| "respondent"
|
|
| "committee"
|
|
| "permit_applicant"
|
|
| "unknown";
|
|
|
|
export type MissingPrecedentStatus =
|
|
| "open"
|
|
| "uploaded"
|
|
| "closed"
|
|
| "irrelevant";
|
|
|
|
export type MissingPrecedent = {
|
|
id: string;
|
|
citation: string;
|
|
case_name: string | null;
|
|
cited_in_case_id: string | null;
|
|
cited_in_case_number: string | null; // joined
|
|
cited_in_document_id: string | null;
|
|
cited_by_party: CitedByParty | null;
|
|
cited_by_party_name: string | null;
|
|
legal_topic: string | null;
|
|
legal_issue: string | null;
|
|
claim_quote: string | null;
|
|
status: MissingPrecedentStatus;
|
|
linked_case_law_id: string | null;
|
|
linked_case_law_number: string | null;
|
|
linked_case_law_name: string | null;
|
|
closed_at: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
notes: string | null;
|
|
// provenance of the gap (#148) — how it surfaced, for the "צוטט ע״י" column.
|
|
discovery_source: string | null; // manual | cited_only | digest | court_fetch
|
|
cited_by_precedents: string[] | null; // corpus precedents citing a cited_only stub
|
|
yomon_number: string | null; // digest gap's source yomon number
|
|
// Bridge to the corpus citation graph: committee decisions (and their chairs)
|
|
// that cite this still-missing ruling — fills "צוטט ע״י <יו״ר>" + "תיק".
|
|
cited_by_chairs: string[] | null;
|
|
cited_by_decisions: string[] | null;
|
|
};
|
|
|
|
export type MissingPrecedentListResponse = {
|
|
items: MissingPrecedent[];
|
|
count: number;
|
|
by_status: Partial<Record<MissingPrecedentStatus, number>>;
|
|
total_open: number;
|
|
};
|
|
|
|
export type MissingPrecedentCreateInput = {
|
|
citation: string;
|
|
case_number?: string;
|
|
cited_in_document_id?: string;
|
|
cited_by_party?: CitedByParty;
|
|
cited_by_party_name?: string;
|
|
legal_topic?: string;
|
|
legal_issue?: string;
|
|
claim_quote?: string;
|
|
case_name?: string;
|
|
notes?: string;
|
|
};
|
|
|
|
export type MissingPrecedentPatch = Partial<{
|
|
legal_topic: string;
|
|
legal_issue: string;
|
|
notes: string;
|
|
cited_by_party: CitedByParty;
|
|
cited_by_party_name: string;
|
|
case_name: string;
|
|
status: MissingPrecedentStatus;
|
|
citation: string;
|
|
claim_quote: string;
|
|
}>;
|
|
|
|
export type MissingPrecedentFilters = {
|
|
status?: MissingPrecedentStatus | "";
|
|
caseNumber?: string;
|
|
caseId?: string;
|
|
legalTopic?: string;
|
|
/** Free-text term — matched across the gap's מראה-מקום, case name, and
|
|
* cited-in appeal number (find a gap by the missing decision's number). */
|
|
q?: string;
|
|
limit?: number;
|
|
};
|
|
|
|
export const missingPrecedentKeys = {
|
|
all: ["missing-precedents"] as const,
|
|
list: (filters: MissingPrecedentFilters) =>
|
|
[...missingPrecedentKeys.all, "list", filters] as const,
|
|
detail: (id: string) => [...missingPrecedentKeys.all, "detail", id] as const,
|
|
};
|
|
|
|
export function useMissingPrecedents(filters: MissingPrecedentFilters = {}) {
|
|
return useQuery({
|
|
queryKey: missingPrecedentKeys.list(filters),
|
|
queryFn: ({ signal }) => {
|
|
const p = new URLSearchParams();
|
|
if (filters.status) p.set("status", filters.status);
|
|
if (filters.caseNumber) p.set("case_number", filters.caseNumber);
|
|
if (filters.caseId) p.set("case_id", filters.caseId);
|
|
if (filters.legalTopic) p.set("legal_topic", filters.legalTopic);
|
|
if (filters.q) p.set("q", filters.q);
|
|
if (filters.limit) p.set("limit", String(filters.limit));
|
|
const qs = p.toString();
|
|
return apiRequest<MissingPrecedentListResponse>(
|
|
`/api/missing-precedents${qs ? `?${qs}` : ""}`,
|
|
{ signal },
|
|
);
|
|
},
|
|
staleTime: 15_000,
|
|
});
|
|
}
|
|
|
|
/** Counter for the sidebar / nav badge — open rows only. */
|
|
export function useMissingPrecedentsOpenCount() {
|
|
return useQuery({
|
|
queryKey: [...missingPrecedentKeys.all, "open-count"] as const,
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<MissingPrecedentListResponse>(
|
|
"/api/missing-precedents?status=open&limit=1",
|
|
{ signal },
|
|
),
|
|
staleTime: 30_000,
|
|
select: (data) => data.total_open,
|
|
});
|
|
}
|
|
|
|
export function useMissingPrecedent(id: string | null) {
|
|
return useQuery({
|
|
queryKey: missingPrecedentKeys.detail(id ?? ""),
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<MissingPrecedent>(
|
|
`/api/missing-precedents/${encodeURIComponent(id!)}`,
|
|
{ signal },
|
|
),
|
|
enabled: Boolean(id),
|
|
staleTime: 15_000,
|
|
});
|
|
}
|
|
|
|
export function useCreateMissingPrecedent() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (input: MissingPrecedentCreateInput) =>
|
|
apiRequest<MissingPrecedent>("/api/missing-precedents", {
|
|
method: "POST",
|
|
body: input,
|
|
}),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
|
// APR-6 / ADM-3 (INV-IA2): a new open gap raises the chair-pending
|
|
// aggregate and the /operations missing_precedents counter.
|
|
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
|
|
qc.invalidateQueries({ queryKey: ["operations"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateMissingPrecedent() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ id, patch }: { id: string; patch: MissingPrecedentPatch }) =>
|
|
apiRequest<MissingPrecedent>(
|
|
`/api/missing-precedents/${encodeURIComponent(id)}`,
|
|
{ method: "PATCH", body: patch },
|
|
),
|
|
onSuccess: (_, { id }) => {
|
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
|
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
|
// APR-6 / ADM-3 (INV-IA2): a status edit (e.g. open→irrelevant) shifts
|
|
// the chair-pending aggregate and the /operations counter.
|
|
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
|
|
qc.invalidateQueries({ queryKey: ["operations"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useDeleteMissingPrecedent() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: string) =>
|
|
apiRequest<{ deleted: boolean }>(
|
|
`/api/missing-precedents/${encodeURIComponent(id)}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
|
// APR-6 / ADM-3 (INV-IA2): removing a gap lowers the chair-pending
|
|
// aggregate and the /operations counter.
|
|
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
|
|
qc.invalidateQueries({ queryKey: ["operations"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export type MissingPrecedentUploadInput = {
|
|
id: string;
|
|
file: File;
|
|
case_number?: string;
|
|
chair_name?: string;
|
|
district?: string;
|
|
case_name?: string;
|
|
court?: string;
|
|
decision_date?: string;
|
|
practice_area?: string;
|
|
appeal_subtype?: string;
|
|
subject_tags?: string[];
|
|
is_binding?: boolean;
|
|
headnote?: string;
|
|
summary?: string;
|
|
precedent_level?: string;
|
|
source_type?: string;
|
|
};
|
|
|
|
export function useUploadMissingPrecedent() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (input: MissingPrecedentUploadInput) => {
|
|
const fd = new FormData();
|
|
fd.append("file", input.file);
|
|
if (input.case_number) fd.append("case_number", input.case_number);
|
|
if (input.chair_name) fd.append("chair_name", input.chair_name);
|
|
if (input.district) fd.append("district", input.district);
|
|
if (input.case_name) fd.append("case_name", input.case_name);
|
|
if (input.court) fd.append("court", input.court);
|
|
if (input.decision_date) fd.append("decision_date", input.decision_date);
|
|
if (input.practice_area) fd.append("practice_area", input.practice_area);
|
|
if (input.appeal_subtype) fd.append("appeal_subtype", input.appeal_subtype);
|
|
if (input.subject_tags && input.subject_tags.length) {
|
|
fd.append("subject_tags", JSON.stringify(input.subject_tags));
|
|
}
|
|
fd.append("is_binding", String(input.is_binding ?? true));
|
|
if (input.headnote) fd.append("headnote", input.headnote);
|
|
if (input.summary) fd.append("summary", input.summary);
|
|
if (input.precedent_level)
|
|
fd.append("precedent_level", input.precedent_level);
|
|
if (input.source_type) fd.append("source_type", input.source_type);
|
|
|
|
const res = await fetch(
|
|
`/api/missing-precedents/${encodeURIComponent(input.id)}/upload`,
|
|
{ method: "POST", body: fd },
|
|
);
|
|
const parsed = await res.json().catch(() => null);
|
|
if (!res.ok) {
|
|
throw new ApiError(
|
|
`Upload failed with ${res.status}`,
|
|
res.status,
|
|
parsed,
|
|
);
|
|
}
|
|
return parsed as {
|
|
missing_precedent: MissingPrecedent;
|
|
case_law_id: string;
|
|
route: "internal_committee" | "external_upload";
|
|
};
|
|
},
|
|
onSuccess: (_, { id }) => {
|
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.detail(id) });
|
|
qc.invalidateQueries({ queryKey: missingPrecedentKeys.all });
|
|
qc.invalidateQueries({ queryKey: ["precedent-library"] });
|
|
// APR-6 / ADM-3 (INV-IA2): uploading closes the gap → chair-pending
|
|
// aggregate and the /operations missing_precedents counter both drop.
|
|
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
|
|
qc.invalidateQueries({ queryKey: ["operations"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Hebrew labels for display. */
|
|
export const CITED_BY_PARTY_LABELS: Record<CitedByParty, string> = {
|
|
appellant: "עורר",
|
|
respondent: "משיב",
|
|
committee: "ועדה",
|
|
permit_applicant: "מבקש היתר",
|
|
unknown: "לא ידוע",
|
|
};
|
|
|
|
export const STATUS_LABELS: Record<MissingPrecedentStatus, string> = {
|
|
open: "פתוח",
|
|
uploaded: "הועלה",
|
|
closed: "נסגר",
|
|
irrelevant: "לא רלוונטי",
|
|
};
|