Files
legal-ai/web-ui/src/lib/api/precedent-library.ts
Chaim 3e14cd6798 feat: link related precedents across court instances (SCHEMA_V11)
Add ability to mark case_law records as related (e.g. same appeal
through ועדת ערר → מנהלי → עליון):
- DB: case_law_relations join table (bidirectional, V11 migration)
- DB CRUD: add/remove/get_case_law_relations
- Service: get_precedent() now returns related_cases[]
- MCP: precedent_link_cases + precedent_unlink_cases tools
- REST: POST/DELETE /api/precedent-library/{id}/relations
- UI: RelatedCasesSection on detail page with search dialog and unlink

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 07:52:29 +00:00

505 lines
15 KiB
TypeScript

/**
* External Precedent Library hooks.
*
* The library is the authoritative case-law corpus — chair-uploaded
* court rulings + other appeals committee decisions, with halachot
* extracted automatically and queued for chair approval. Distinct from:
* - /api/training (Daphna's style corpus — sample decisions for tone)
* - /api/precedents (chair-attached quotes per case section)
*
* Endpoints touched (all under /api/precedent-library and /api/halachot):
* - POST /upload (multipart) → task_id (consumed by useProgress)
* - GET / (filters) → list
* - GET /{id} → detail with halachot
* - PATCH /{id} → metadata edit
* - DELETE /{id} → remove
* - POST /{id}/extract-halachot → re-run halacha extractor
* - GET /search → semantic search (halachot + chunks)
* - GET /stats
* - GET /api/halachot?status=... → review queue
* - PATCH /api/halachot/{id} → approve/reject/edit
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, apiRequest } from "./client";
export type PracticeArea =
| ""
| "rishuy_uvniya"
| "betterment_levy"
| "compensation_197";
export type SourceType = "" | "court_ruling" | "appeals_committee";
export type Precedent = {
id: string;
case_number: string;
case_name: string;
court: string;
date: string | null;
practice_area: PracticeArea | "";
appeal_subtype: string;
source_type: SourceType | "";
precedent_level: string;
is_binding: boolean;
summary: string;
headnote: string;
subject_tags: string[];
source_kind: string;
chair_name: string | null;
district: string | null;
extraction_status: string;
halacha_extraction_status: string;
metadata_extraction_requested_at: string | null;
halacha_extraction_requested_at: string | null;
created_at: string;
halachot_count: number;
approved_count: number;
};
export type Halacha = {
id: string;
case_law_id: string;
halacha_index: number;
rule_statement: string;
rule_type: string;
reasoning_summary: string;
supporting_quote: string;
page_reference: string;
practice_areas: string[];
subject_tags: string[];
cites: string[];
confidence: number;
quote_verified: boolean;
review_status: "pending_review" | "approved" | "rejected" | "published";
reviewer: string;
reviewed_at: string | null;
created_at: string;
updated_at: string;
/* Joined from case_law for review/list views */
case_number?: string;
case_name?: string;
court?: string;
decision_date?: string | null;
precedent_level?: string;
};
export type RelatedCase = {
id: string;
case_number: string;
case_name: string;
court: string;
precedent_level: string;
date: string | null;
relation_type: string;
};
export type PrecedentDetail = Precedent & {
full_text: string;
halachot: Halacha[];
related_cases: RelatedCase[];
};
export type SearchHit =
| {
type: "halacha";
score: number;
halacha_id: string;
case_law_id: string;
rule_statement: string;
reasoning_summary: string;
supporting_quote: string;
page_reference: string;
practice_areas: string[];
subject_tags: string[];
confidence: number;
rule_type: string;
case_number: string;
case_name: string;
court: string;
decision_date: string | null;
precedent_level: string;
}
| {
type: "passage";
score: number;
chunk_id: string;
case_law_id: string;
content: string;
section_type: string;
page_number: number | null;
case_number: string;
case_name: string;
court: string;
decision_date: string | null;
precedent_level: string;
practice_area: string;
};
export type LibraryStats = {
precedents_total: number;
by_practice_area: { practice_area: string; count: number }[];
by_precedent_level: { precedent_level: string; count: number }[];
halachot_total: number;
halachot_pending: number;
halachot_approved: number;
};
export type ListFilters = {
practiceArea?: PracticeArea;
court?: string;
precedentLevel?: string;
sourceType?: SourceType;
sourceKind?: string;
search?: string;
limit?: number;
offset?: number;
};
export const libraryKeys = {
all: ["precedent-library"] as const,
list: (filters: ListFilters) =>
[...libraryKeys.all, "list", filters] as const,
detail: (id: string) => [...libraryKeys.all, "detail", id] as const,
search: (q: string, filters: Record<string, string | boolean>) =>
[...libraryKeys.all, "search", q, filters] as const,
stats: () => [...libraryKeys.all, "stats"] as const,
halachotPending: () => [...libraryKeys.all, "halachot", "pending"] as const,
halachot: (filters: Record<string, string>) =>
[...libraryKeys.all, "halachot", filters] as const,
};
export function usePrecedents(filters: ListFilters = {}) {
return useQuery({
queryKey: libraryKeys.list(filters),
queryFn: ({ signal }) => {
const p = new URLSearchParams();
if (filters.practiceArea) p.set("practice_area", filters.practiceArea);
if (filters.court) p.set("court", filters.court);
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
if (filters.sourceType) p.set("source_type", filters.sourceType);
if (filters.sourceKind) p.set("source_kind", filters.sourceKind);
if (filters.search) p.set("search", filters.search);
if (filters.limit) p.set("limit", String(filters.limit));
if (filters.offset) p.set("offset", String(filters.offset));
const qs = p.toString();
return apiRequest<{ items: Precedent[]; count: number }>(
`/api/precedent-library${qs ? `?${qs}` : ""}`,
{ signal },
);
},
staleTime: 30_000,
/* Poll while any row is mid-processing or queued for the local MCP
* worker. Once everything settles to completed/failed the polling
* stops on its own — no fixed background timer. */
refetchInterval: (query) => {
const data = query.state.data;
if (!data) return false;
const active = data.items.some((p) => isPrecedentActive(p));
return active ? 5000 : false;
},
});
}
/** A precedent is "active" while text/halacha extraction is in flight or
* legitimately queued for the local MCP worker. Used by the auto-refresh
* poller and by the row UI to disable destructive actions.
*
* Once a status is "completed" or "failed", the row is NEVER active —
* even if the corresponding `*_requested_at` timestamp still has a value.
* The worker is supposed to NULL it on success but in practice doesn't
* always, and treating those rows as active leaves them permanently
* undeletable. */
export function isPrecedentActive(p: Precedent): boolean {
// Text extraction
if (p.extraction_status === "processing") return true;
// Halacha extraction
if (p.halacha_extraction_status === "processing") return true;
if (
p.halacha_extraction_status === "pending" &&
p.halacha_extraction_requested_at !== null
) {
return true;
}
// Metadata extraction has no status column — only the timestamp.
// Treat as active only when extraction hasn't yet fully completed
// (otherwise stale timestamps linger after success).
if (
p.metadata_extraction_requested_at !== null &&
p.extraction_status !== "completed"
) {
return true;
}
return false;
}
export function usePrecedent(id: string | null) {
return useQuery({
queryKey: libraryKeys.detail(id ?? ""),
queryFn: ({ signal }) =>
apiRequest<PrecedentDetail>(
`/api/precedent-library/${encodeURIComponent(id!)}`,
{ signal },
),
enabled: Boolean(id),
staleTime: 30_000,
});
}
export function useLibraryStats() {
return useQuery({
queryKey: libraryKeys.stats(),
queryFn: ({ signal }) =>
apiRequest<LibraryStats>("/api/precedent-library/stats", { signal }),
staleTime: 60_000,
});
}
export type SearchFilters = {
practiceArea?: PracticeArea;
court?: string;
precedentLevel?: string;
appealSubtype?: string;
subjectTag?: string;
includeHalachot?: boolean;
limit?: number;
};
export function useLibrarySearch(query: string, filters: SearchFilters = {}) {
const params: Record<string, string | boolean> = {};
if (filters.practiceArea) params.practice_area = filters.practiceArea;
if (filters.court) params.court = filters.court;
if (filters.precedentLevel) params.precedent_level = filters.precedentLevel;
if (filters.appealSubtype) params.appeal_subtype = filters.appealSubtype;
if (filters.subjectTag) params.subject_tag = filters.subjectTag;
if (filters.includeHalachot !== undefined)
params.include_halachot = filters.includeHalachot;
return useQuery({
queryKey: libraryKeys.search(query, params),
queryFn: ({ signal }) => {
const p = new URLSearchParams({ q: query });
for (const [k, v] of Object.entries(params)) p.set(k, String(v));
if (filters.limit) p.set("limit", String(filters.limit));
return apiRequest<{ items: SearchHit[]; count: number }>(
`/api/precedent-library/search?${p.toString()}`,
{ signal },
);
},
enabled: query.trim().length >= 2,
staleTime: 10_000,
placeholderData: (prev) => prev,
});
}
export type PrecedentUploadInput = {
file: File;
citation: string;
case_name?: string;
court?: string;
decision_date?: string;
source_type?: SourceType;
precedent_level?: string;
practice_area?: PracticeArea;
appeal_subtype?: string;
subject_tags?: string[];
is_binding?: boolean;
headnote?: string;
summary?: string;
};
export function useUploadPrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (input: PrecedentUploadInput) => {
const fd = new FormData();
fd.append("file", input.file);
fd.append("citation", input.citation);
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.source_type) fd.append("source_type", input.source_type);
if (input.precedent_level)
fd.append("precedent_level", input.precedent_level);
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);
const res = await fetch("/api/precedent-library/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 { task_id: string };
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
export function useDeletePrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ deleted: boolean }>(
`/api/precedent-library/${encodeURIComponent(id)}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
export function useLinkRelatedCase(caseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
apiRequest<{ linked: boolean }>(
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
{
method: "POST",
body: {
related_id: vars.relatedId,
relation_type: vars.relationType ?? "same_case_chain",
},
},
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
},
});
}
export function useUnlinkRelatedCase(caseId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (relatedId: string) =>
apiRequest<{ unlinked: boolean }>(
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
{ method: "DELETE" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
},
});
}
export type PrecedentPatch = Partial<{
case_name: string;
court: string;
decision_date: string;
practice_area: PracticeArea;
appeal_subtype: string;
subject_tags: string[];
summary: string;
headnote: string;
source_type: SourceType;
precedent_level: string;
is_binding: boolean;
}>;
export function useUpdatePrecedent() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: PrecedentPatch }) =>
apiRequest<Precedent>(
`/api/precedent-library/${encodeURIComponent(id)}`,
{ method: "PATCH", body: patch },
),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
/* Extraction can't run inside the container (no `claude` CLI). The
* "request" endpoints below stamp a queue marker in case_law; the chair
* (or me) drains the queue from Claude Code by invoking the MCP tool
* `precedent_process_pending`, which runs the actual extractor locally.
* See the rule in mcp-server/src/legal_mcp/services/claude_session.py. */
export function useRequestMetadataExtraction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ queued: boolean }>(
`/api/precedent-library/${encodeURIComponent(id)}/request-metadata`,
{ method: "POST" },
),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
export function useRequestHalachotExtraction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) =>
apiRequest<{ queued: boolean }>(
`/api/precedent-library/${encodeURIComponent(id)}/request-halachot`,
{ method: "POST" },
),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}
export function useHalachotPending(limit = 200) {
return useQuery({
queryKey: libraryKeys.halachotPending(),
queryFn: ({ signal }) =>
apiRequest<{ items: Halacha[]; count: number }>(
`/api/halachot?review_status=pending_review&limit=${limit}`,
{ signal },
),
staleTime: 5_000,
refetchOnMount: "always",
});
}
export type HalachaPatch = Partial<{
review_status: "pending_review" | "approved" | "rejected" | "published";
reviewer: string;
rule_statement: string;
reasoning_summary: string;
subject_tags: string[];
practice_areas: string[];
}>;
export function useUpdateHalacha() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: HalachaPatch }) =>
apiRequest<Halacha>(
`/api/halachot/${encodeURIComponent(id)}`,
{ method: "PATCH", body: patch },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: libraryKeys.all });
},
});
}