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>
This commit is contained in:
284
web-ui/src/lib/api/digests.ts
Normal file
284
web-ui/src/lib/api/digests.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user