משלים את #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>
290 lines
9.2 KiB
TypeScript
290 lines
9.2 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;
|
||
/** 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 });
|
||
},
|
||
});
|
||
}
|