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>
505 lines
15 KiB
TypeScript
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 });
|
|
},
|
|
});
|
|
}
|