/** * 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) => [...libraryKeys.all, "search", q, filters] as const, stats: () => [...libraryKeys.all, "stats"] as const, halachotPending: () => [...libraryKeys.all, "halachot", "pending"] as const, halachot: (filters: Record) => [...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( `/api/precedent-library/${encodeURIComponent(id!)}`, { signal }, ), enabled: Boolean(id), staleTime: 30_000, }); } export function useLibraryStats() { return useQuery({ queryKey: libraryKeys.stats(), queryFn: ({ signal }) => apiRequest("/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 = {}; 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( `/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( `/api/halachot/${encodeURIComponent(id)}`, { method: "PATCH", body: patch }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: libraryKeys.all }); }, }); }