/** * Digests radar hooks (X12). * * A digest ("כל יום" daily one-pager, Ofer Toister) is a SECONDARY, discovery- * layer source that POINTS at a ruling — never cited in a decision (INV-DIG1), * never extracts halachot (INV-DIG2). Distinct from: * - /api/precedent-library (authoritative case-law corpus, citable) * - /api/training (Daphna's style corpus) * * Endpoints (all under /api/digests): * - POST /upload (multipart) → { status, digest_id } (pending → local enrich) * - GET / (filters) → list * - GET /search → semantic radar search * - GET /queue/pending → digests awaiting local LLM enrichment * - GET /{id} → detail * - PATCH /{id} → metadata edit * - DELETE /{id} → remove * - POST /{id}/link {case_law_id} → bridge to the underlying ruling * - POST /{id}/relink → re-run autolink * - DELETE /{id}/link → clear link */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError, apiRequest } from "./client"; import type { PracticeArea } from "./precedent-library"; export type Digest = { id: string; yomon_number: string; digest_date: string | null; publication: string; source_firm: string; concept_tag: string; headline_holding: string; analysis_text: string; summary: string; underlying_citation: string; underlying_court: string; underlying_date: string | null; underlying_judge: string; practice_area: PracticeArea | ""; appeal_subtype: string; subject_tags: string[]; linked_case_law_id: string | null; source_document_path: string; content_hash: string; extraction_status: string; created_at: string; updated_at: string; }; /** A search hit = a Digest plus the joined linked-ruling fields + score. */ export type DigestSearchHit = Digest & { linked_case_number: string | null; linked_case_name: string | null; linked_searchable: boolean | null; score: number; type: "digest"; }; export type DigestListFilters = { practiceArea?: PracticeArea; conceptTag?: string; /** undefined = all; true = linked to a ruling; false = unlinked (open gap) */ linked?: boolean; search?: string; limit?: number; offset?: number; }; export const digestKeys = { all: ["digests"] as const, list: (filters: DigestListFilters) => [...digestKeys.all, "list", filters] as const, detail: (id: string) => [...digestKeys.all, "detail", id] as const, search: (q: string, filters: Record) => [...digestKeys.all, "search", q, filters] as const, pending: () => [...digestKeys.all, "pending"] as const, }; /** A digest is "active" while it awaits or is mid local LLM enrichment. */ export function isDigestActive(d: Digest): boolean { return d.extraction_status === "pending" || d.extraction_status === "processing"; } export function useDigests(filters: DigestListFilters = {}) { return useQuery({ queryKey: digestKeys.list(filters), queryFn: ({ signal }) => { const p = new URLSearchParams(); if (filters.practiceArea) p.set("practice_area", filters.practiceArea); if (filters.conceptTag) p.set("concept_tag", filters.conceptTag); if (filters.linked !== undefined) p.set("linked", String(filters.linked)); 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: Digest[]; count: number }>( `/api/digests${qs ? `?${qs}` : ""}`, { signal }, ); }, staleTime: 30_000, // Poll while any row is awaiting/mid local enrichment; stop once settled. refetchInterval: (query) => { const data = query.state.data; if (!data) return false; return data.items.some((d) => isDigestActive(d)) ? 5000 : false; }, }); } export function useDigest(id: string | null) { return useQuery({ queryKey: digestKeys.detail(id ?? ""), queryFn: ({ signal }) => apiRequest(`/api/digests/${encodeURIComponent(id!)}`, { signal }), enabled: Boolean(id), staleTime: 30_000, }); } export type DigestSearchFilters = { practiceArea?: PracticeArea; subjectTag?: string; conceptTag?: string; limit?: number; }; export function useDigestSearch(query: string, filters: DigestSearchFilters = {}) { const params: Record = {}; if (filters.practiceArea) params.practice_area = filters.practiceArea; if (filters.subjectTag) params.subject_tag = filters.subjectTag; if (filters.conceptTag) params.concept_tag = filters.conceptTag; return useQuery({ queryKey: digestKeys.search(query, params), queryFn: ({ signal }) => { const p = new URLSearchParams({ q: query }); for (const [k, v] of Object.entries(params)) p.set(k, v); if (filters.limit) p.set("limit", String(filters.limit)); return apiRequest<{ items: DigestSearchHit[]; count: number }>( `/api/digests/search?${p.toString()}`, { signal }, ); }, enabled: query.trim().length >= 2, staleTime: 10_000, placeholderData: (prev) => prev, }); } /** Digests awaiting local LLM enrichment (drained by `digest_process_pending`). */ export function useDigestPending() { return useQuery({ queryKey: digestKeys.pending(), queryFn: ({ signal }) => apiRequest<{ items: Digest[]; count: number }>( "/api/digests/queue/pending", { signal }, ), staleTime: 15_000, }); } export type DigestUploadInput = { file: File; yomon_number?: string; digest_date?: string; practice_area?: PracticeArea; appeal_subtype?: string; subject_tags?: string[]; }; export function useUploadDigest() { const qc = useQueryClient(); return useMutation({ mutationFn: async (input: DigestUploadInput) => { const fd = new FormData(); fd.append("file", input.file); if (input.yomon_number) fd.append("yomon_number", input.yomon_number); if (input.digest_date) fd.append("digest_date", input.digest_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)); const res = await fetch("/api/digests/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 { status: string; digest_id: string; extraction_status: string }; }, onSuccess: () => { qc.invalidateQueries({ queryKey: digestKeys.all }); }, }); } export type DigestPatch = Partial<{ yomon_number: string; digest_date: string; concept_tag: string; headline_holding: string; summary: string; underlying_citation: string; underlying_court: string; underlying_date: string; underlying_judge: string; practice_area: PracticeArea; appeal_subtype: string; subject_tags: string[]; }>; export function useUpdateDigest() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, patch }: { id: string; patch: DigestPatch }) => apiRequest(`/api/digests/${encodeURIComponent(id)}`, { method: "PATCH", body: patch, }), onSuccess: () => { qc.invalidateQueries({ queryKey: digestKeys.all }); }, }); } export function useDeleteDigest() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiRequest<{ deleted: boolean }>( `/api/digests/${encodeURIComponent(id)}`, { method: "DELETE" }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: digestKeys.all }); }, }); } export function useLinkDigest() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, caseLawId }: { id: string; caseLawId: string }) => apiRequest<{ linked: boolean; case_number: string }>( `/api/digests/${encodeURIComponent(id)}/link`, { method: "POST", body: { case_law_id: caseLawId } }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: digestKeys.all }); }, }); } export function useRelinkDigest() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiRequest<{ linked: boolean; case_law_id: string | null; changed: boolean }>( `/api/digests/${encodeURIComponent(id)}/relink`, { method: "POST" }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: digestKeys.all }); }, }); } export function useUnlinkDigest() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiRequest<{ unlinked: boolean }>( `/api/digests/${encodeURIComponent(id)}/link`, { method: "DELETE" }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: digestKeys.all }); }, }); }