feat: external precedent library with auto halacha extraction
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:
2026-05-03 08:38:18 +00:00
parent a6edb75bbf
commit 7ee90dce31
23 changed files with 3853 additions and 67 deletions

View 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 });
},
});
}