Files
legal-ai/web-ui/src/lib/api/digests.ts
Chaim 06281996ca feat(digests): Phase 2 — API endpoints + /digests UI (X12)
משטחי-משתמש לקורפוס היומונים: endpoints ב-FastAPI + דף UI נפרד /digests
(לדפדוף, חיפוש, העלאה, וקישור לפסק המקורי). היומון נשאר מקור-משני המצביע
על הפסק — אינו מצוטט בהחלטה (INV-DIG1) ואינו מחלץ הלכות (INV-DIG2).

Backend (container-safe + local split):
- digest_library: פוצל ל-create_pending_digest (CONTAINER-SAFE: stage+
  extract_text+create row 'pending', בלי LLM) ↔ enrich_digest/
  process_pending_digests (local: LLM+embed+autolink). ingest_digest מאחד.
- db.list_pending_digests; MCP digest_process_pending (tool+server) — חלופה
  ל-batch script לריקון התור.
- web/app.py: 10 endpoints /api/digests/* (upload/list/search/queue-pending/
  get/patch/delete/link/relink/unlink). upload=INSERT-only pending (ה-LLM רץ
  מקומית — claude_session local-only). כולם מחזירים dict בדפוס precedent.

Frontend (Next 16, ללא api:types — hooks עם טיפוסים hand-written כמו
precedent-library.ts):
- lib/api/digests.ts — hooks (useDigests/useDigestSearch/useDigestPending/
  useUploadDigest/useLink/Relink/Unlink/Delete/Update).
- דף /digests נפרד (לא כרטיסייה ב-/precedents — לשמור גבול סמכותי/משני,
  INV-DIG1): טאבים יומונים/חיפוש + DigestCard (badge קישור-לפסק) +
  DigestUploadDialog + pending badge. nav + header-context.

אומת: backend round-trip מלא (create_pending→list_pending→process_pending→
search→restore); web-ui מתקמפל (webpack/tsc נקי, route /digests נוצר).
הערה: build דיפולטי (turbopack) נכשל ב-worktree עקב symlink ל-node_modules —
ב-CI/Docker (node_modules אמיתי) עובד; אומת עם --webpack.

Invariants: מקיים INV-DIG1/2 (upload לא מחלץ הלכות, UI מציג "מצביע לא
מצוטט"), INV-DIG3 (link/relink/queue). G4 (אין בליעה — שגיאות→toast/HTTP),
G2 (מסלול נפרד, לא מקביל). X6 (חוזה UI↔API — endpoints בדפוס precedent;
hooks hand-written כמו שאר ה-domain modules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:11:05 +00:00

285 lines
8.9 KiB
TypeScript

/**
* 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<string, string>) =>
[...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<Digest>(`/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<string, string> = {};
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<Digest>(`/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 });
},
});
}