feat: external precedent library with auto halacha extraction
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 1m27s
Adds a third corpus of legal authority distinct from style_corpus (Daphna's prior decisions for voice) and case_precedents (chair-attached quotes per case). The new corpus holds chair-uploaded court rulings and other appeals committee decisions, with binding rules (הלכות) extracted automatically and queued for chair approval. Pipeline (web/app.py + services/precedent_library.py): file → extract → chunk → Voyage embed → halacha_extractor → store + publish progress over the existing Redis SSE channel. Schema V7 (services/db.py): extends case_law with source_kind + extraction status fields under a CHECK constraint pinning practice_area to the three appeals committee domains (rishuy_uvniya, betterment_levy, compensation_197). New precedent_chunks (vector(1024)) and halachot tables (vector(1024) over rule_statement, IVFFlat indexes, gin on practice_areas/subject_tags). Halachot start as pending_review; only approved/published rows are visible to search_precedent_library. Agents: legal-writer, legal-researcher, legal-analyst, legal-ceo, legal-qa get search_precedent_library. legal-writer prompt explains the three-corpus distinction and CREAC use; legal-qa now verifies that every cited halacha resolves to an approved row in the corpus. UI: /precedents page with four tabs — library / semantic search / pending review (J/K nav, A/R/E shortcuts, badge count) / stats. Reuses the existing upload-sheet progress + SSE pattern. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
387
web-ui/src/lib/api/precedent-library.ts
Normal file
387
web-ui/src/lib/api/precedent-library.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* External Precedent Library hooks.
|
||||
*
|
||||
* The library is the authoritative case-law corpus — chair-uploaded
|
||||
* court rulings + other appeals committee decisions, with halachot
|
||||
* extracted automatically and queued for chair approval. Distinct from:
|
||||
* - /api/training (Daphna's style corpus — sample decisions for tone)
|
||||
* - /api/precedents (chair-attached quotes per case section)
|
||||
*
|
||||
* Endpoints touched (all under /api/precedent-library and /api/halachot):
|
||||
* - POST /upload (multipart) → task_id (consumed by useProgress)
|
||||
* - GET / (filters) → list
|
||||
* - GET /{id} → detail with halachot
|
||||
* - PATCH /{id} → metadata edit
|
||||
* - DELETE /{id} → remove
|
||||
* - POST /{id}/extract-halachot → re-run halacha extractor
|
||||
* - GET /search → semantic search (halachot + chunks)
|
||||
* - GET /stats
|
||||
* - GET /api/halachot?status=... → review queue
|
||||
* - PATCH /api/halachot/{id} → approve/reject/edit
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ApiError, apiRequest } from "./client";
|
||||
|
||||
export type PracticeArea =
|
||||
| ""
|
||||
| "rishuy_uvniya"
|
||||
| "betterment_levy"
|
||||
| "compensation_197";
|
||||
|
||||
export type SourceType = "" | "court_ruling" | "appeals_committee";
|
||||
|
||||
export type Precedent = {
|
||||
id: string;
|
||||
case_number: string;
|
||||
case_name: string;
|
||||
court: string;
|
||||
date: string | null;
|
||||
practice_area: PracticeArea | "";
|
||||
appeal_subtype: string;
|
||||
source_type: SourceType | "";
|
||||
precedent_level: string;
|
||||
is_binding: boolean;
|
||||
summary: string;
|
||||
headnote: string;
|
||||
subject_tags: string[];
|
||||
source_kind: string;
|
||||
extraction_status: string;
|
||||
halacha_extraction_status: string;
|
||||
created_at: string;
|
||||
halachot_count: number;
|
||||
approved_count: number;
|
||||
};
|
||||
|
||||
export type Halacha = {
|
||||
id: string;
|
||||
case_law_id: string;
|
||||
halacha_index: number;
|
||||
rule_statement: string;
|
||||
rule_type: string;
|
||||
reasoning_summary: string;
|
||||
supporting_quote: string;
|
||||
page_reference: string;
|
||||
practice_areas: string[];
|
||||
subject_tags: string[];
|
||||
cites: string[];
|
||||
confidence: number;
|
||||
quote_verified: boolean;
|
||||
review_status: "pending_review" | "approved" | "rejected" | "published";
|
||||
reviewer: string;
|
||||
reviewed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/* Joined from case_law for review/list views */
|
||||
case_number?: string;
|
||||
case_name?: string;
|
||||
court?: string;
|
||||
decision_date?: string | null;
|
||||
precedent_level?: string;
|
||||
};
|
||||
|
||||
export type PrecedentDetail = Precedent & {
|
||||
full_text: string;
|
||||
halachot: Halacha[];
|
||||
};
|
||||
|
||||
export type SearchHit =
|
||||
| {
|
||||
type: "halacha";
|
||||
score: number;
|
||||
halacha_id: string;
|
||||
case_law_id: string;
|
||||
rule_statement: string;
|
||||
reasoning_summary: string;
|
||||
supporting_quote: string;
|
||||
page_reference: string;
|
||||
practice_areas: string[];
|
||||
subject_tags: string[];
|
||||
confidence: number;
|
||||
rule_type: string;
|
||||
case_number: string;
|
||||
case_name: string;
|
||||
court: string;
|
||||
decision_date: string | null;
|
||||
precedent_level: string;
|
||||
}
|
||||
| {
|
||||
type: "passage";
|
||||
score: number;
|
||||
chunk_id: string;
|
||||
case_law_id: string;
|
||||
content: string;
|
||||
section_type: string;
|
||||
page_number: number | null;
|
||||
case_number: string;
|
||||
case_name: string;
|
||||
court: string;
|
||||
decision_date: string | null;
|
||||
precedent_level: string;
|
||||
practice_area: string;
|
||||
};
|
||||
|
||||
export type LibraryStats = {
|
||||
precedents_total: number;
|
||||
by_practice_area: { practice_area: string; count: number }[];
|
||||
by_precedent_level: { precedent_level: string; count: number }[];
|
||||
halachot_total: number;
|
||||
halachot_pending: number;
|
||||
halachot_approved: number;
|
||||
};
|
||||
|
||||
export type ListFilters = {
|
||||
practiceArea?: PracticeArea;
|
||||
court?: string;
|
||||
precedentLevel?: string;
|
||||
sourceType?: SourceType;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export const libraryKeys = {
|
||||
all: ["precedent-library"] as const,
|
||||
list: (filters: ListFilters) =>
|
||||
[...libraryKeys.all, "list", filters] as const,
|
||||
detail: (id: string) => [...libraryKeys.all, "detail", id] as const,
|
||||
search: (q: string, filters: Record<string, string | boolean>) =>
|
||||
[...libraryKeys.all, "search", q, filters] as const,
|
||||
stats: () => [...libraryKeys.all, "stats"] as const,
|
||||
halachotPending: () => [...libraryKeys.all, "halachot", "pending"] as const,
|
||||
halachot: (filters: Record<string, string>) =>
|
||||
[...libraryKeys.all, "halachot", filters] as const,
|
||||
};
|
||||
|
||||
export function usePrecedents(filters: ListFilters = {}) {
|
||||
return useQuery({
|
||||
queryKey: libraryKeys.list(filters),
|
||||
queryFn: ({ signal }) => {
|
||||
const p = new URLSearchParams();
|
||||
if (filters.practiceArea) p.set("practice_area", filters.practiceArea);
|
||||
if (filters.court) p.set("court", filters.court);
|
||||
if (filters.precedentLevel) p.set("precedent_level", filters.precedentLevel);
|
||||
if (filters.sourceType) p.set("source_type", filters.sourceType);
|
||||
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: Precedent[]; count: number }>(
|
||||
`/api/precedent-library${qs ? `?${qs}` : ""}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePrecedent(id: string | null) {
|
||||
return useQuery({
|
||||
queryKey: libraryKeys.detail(id ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<PrecedentDetail>(
|
||||
`/api/precedent-library/${encodeURIComponent(id!)}`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(id),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLibraryStats() {
|
||||
return useQuery({
|
||||
queryKey: libraryKeys.stats(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<LibraryStats>("/api/precedent-library/stats", { signal }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type SearchFilters = {
|
||||
practiceArea?: PracticeArea;
|
||||
court?: string;
|
||||
precedentLevel?: string;
|
||||
appealSubtype?: string;
|
||||
subjectTag?: string;
|
||||
includeHalachot?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export function useLibrarySearch(query: string, filters: SearchFilters = {}) {
|
||||
const params: Record<string, string | boolean> = {};
|
||||
if (filters.practiceArea) params.practice_area = filters.practiceArea;
|
||||
if (filters.court) params.court = filters.court;
|
||||
if (filters.precedentLevel) params.precedent_level = filters.precedentLevel;
|
||||
if (filters.appealSubtype) params.appeal_subtype = filters.appealSubtype;
|
||||
if (filters.subjectTag) params.subject_tag = filters.subjectTag;
|
||||
if (filters.includeHalachot !== undefined)
|
||||
params.include_halachot = filters.includeHalachot;
|
||||
|
||||
return useQuery({
|
||||
queryKey: libraryKeys.search(query, params),
|
||||
queryFn: ({ signal }) => {
|
||||
const p = new URLSearchParams({ q: query });
|
||||
for (const [k, v] of Object.entries(params)) p.set(k, String(v));
|
||||
if (filters.limit) p.set("limit", String(filters.limit));
|
||||
return apiRequest<{ items: SearchHit[]; count: number }>(
|
||||
`/api/precedent-library/search?${p.toString()}`,
|
||||
{ signal },
|
||||
);
|
||||
},
|
||||
enabled: query.trim().length >= 2,
|
||||
staleTime: 10_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export type PrecedentUploadInput = {
|
||||
file: File;
|
||||
citation: string;
|
||||
case_name?: string;
|
||||
court?: string;
|
||||
decision_date?: string;
|
||||
source_type?: SourceType;
|
||||
precedent_level?: string;
|
||||
practice_area?: PracticeArea;
|
||||
appeal_subtype?: string;
|
||||
subject_tags?: string[];
|
||||
is_binding?: boolean;
|
||||
headnote?: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export function useUploadPrecedent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (input: PrecedentUploadInput) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", input.file);
|
||||
fd.append("citation", input.citation);
|
||||
if (input.case_name) fd.append("case_name", input.case_name);
|
||||
if (input.court) fd.append("court", input.court);
|
||||
if (input.decision_date) fd.append("decision_date", input.decision_date);
|
||||
if (input.source_type) fd.append("source_type", input.source_type);
|
||||
if (input.precedent_level)
|
||||
fd.append("precedent_level", input.precedent_level);
|
||||
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));
|
||||
fd.append("is_binding", String(input.is_binding ?? true));
|
||||
if (input.headnote) fd.append("headnote", input.headnote);
|
||||
if (input.summary) fd.append("summary", input.summary);
|
||||
|
||||
const res = await fetch("/api/precedent-library/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 { task_id: string };
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePrecedent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ deleted: boolean }>(
|
||||
`/api/precedent-library/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type PrecedentPatch = Partial<{
|
||||
case_name: string;
|
||||
court: string;
|
||||
decision_date: string;
|
||||
practice_area: PracticeArea;
|
||||
appeal_subtype: string;
|
||||
subject_tags: string[];
|
||||
summary: string;
|
||||
headnote: string;
|
||||
source_type: SourceType;
|
||||
precedent_level: string;
|
||||
is_binding: boolean;
|
||||
}>;
|
||||
|
||||
export function useUpdatePrecedent() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: PrecedentPatch }) =>
|
||||
apiRequest<Precedent>(
|
||||
`/api/precedent-library/${encodeURIComponent(id)}`,
|
||||
{ method: "PATCH", body: patch },
|
||||
),
|
||||
onSuccess: (_, { id }) => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useReExtractHalachot() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ task_id: string }>(
|
||||
`/api/precedent-library/${encodeURIComponent(id)}/extract-halachot`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
onSuccess: (_, id) => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useHalachotPending(limit = 200) {
|
||||
return useQuery({
|
||||
queryKey: libraryKeys.halachotPending(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<{ items: Halacha[]; count: number }>(
|
||||
`/api/halachot?review_status=pending_review&limit=${limit}`,
|
||||
{ signal },
|
||||
),
|
||||
staleTime: 5_000,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
}
|
||||
|
||||
export type HalachaPatch = Partial<{
|
||||
review_status: "pending_review" | "approved" | "rejected" | "published";
|
||||
reviewer: string;
|
||||
rule_statement: string;
|
||||
reasoning_summary: string;
|
||||
subject_tags: string[];
|
||||
practice_areas: string[];
|
||||
}>;
|
||||
|
||||
export function useUpdateHalacha() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: HalachaPatch }) =>
|
||||
apiRequest<Halacha>(
|
||||
`/api/halachot/${encodeURIComponent(id)}`,
|
||||
{ method: "PATCH", body: patch },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user