Files
legal-ai/web-ui/src/lib/api/digests.ts
Chaim 5745d36bb4 feat(digests-ui): publication filter + 'מאמר'/source badges for bulletins
משלים את #154 בצד-לקוח:
- פילטר "מקור" בדף /digests (כל המקורות / כל יום / עו"ד על נדל"ן) — backend:
  list_digests + /api/digests מקבלים publication.
- DigestCard: תג "מאמר" ל-digest_kind='article', ו-chip מקור לפרסום שאינו 'כל יום'.

build (webpack) עובר, lint נקי. digests = hand-written types (אין api:types).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:14:23 +00:00

290 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
/** decision (points at a ruling) · announcement (legislative/notice, no ruling) · other · "" */
digest_kind: 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;
/** source publication: 'כל יום' (daily) | 'עו"ד על נדל"ן' (monthly bulletin) */
publication?: 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.publication) p.set("publication", filters.publication);
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 });
},
});
}