All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
מבטל את ה-toggle "תור נקי / דורש תיקון-חילוץ" שבו "תור נקי" ריק לגמרי (כל ההלכות-הנקיות נפתרו), והעבודה האמיתית חבויה מאחורי הכפתור השני שגם מערבב התלבטות-פאנל עם פגמי-חילוץ. אושר ב-Claude Design (כרטיס 19-halacha-queue-unified). במקום זה — תור אחד, fetch אחד, פיצול client-side לפי **סוג-הפעולה**: - "להכרעתך" = הלכות שהפאנל דן בהן (יש panel_round) או נקיות → אשר/דחה, עם טבלת-ההתלבטות; ממוין פיצול-פאנל-תחילה (FU-3). - "דורש תיקון-חילוץ" = מסומנות-דגל שלא עברו התלבטות → תיקון-חילוץ. `useHalachotPending` אוחד לקריאה אחת (exclude_low_quality=false + order_by_priority + cluster + include_equivalents + include_panel_round); נוסף `isExtractionFixItem(h)` (= !panel_round && יש דגל). PendingPanel מפצל ב-useMemo, segmented-control עם מוני שני הדליים. אפס שינוי-backend (הפרמטרים כבר קיימים מ-#220/#222). display-only, שער-אישור יחיד (INV-IA/G10). ולידציה: tsc + eslint נקי. חלק מ-#133. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
696 lines
23 KiB
TypeScript
696 lines
23 KiB
TypeScript
/**
|
|
* 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;
|
|
chair_name: string | null;
|
|
district: string | null;
|
|
citation_formatted: string;
|
|
extraction_status: string;
|
|
halacha_extraction_status: string;
|
|
metadata_extraction_status: string;
|
|
metadata_extraction_requested_at: string | null;
|
|
halacha_extraction_requested_at: string | null;
|
|
created_at: string;
|
|
halachot_count: number;
|
|
approved_count: number;
|
|
pending_count: number;
|
|
rejected_count: number;
|
|
deferred_count: number;
|
|
};
|
|
|
|
export type Halacha = {
|
|
id: string;
|
|
case_law_id: string;
|
|
halacha_index: number;
|
|
rule_statement: string;
|
|
rule_type: string;
|
|
// authority over the committee — DERIVED from the source (INV-DM7), read-only.
|
|
authority?: "binding" | "persuasive" | null;
|
|
reasoning_summary: string;
|
|
supporting_quote: string;
|
|
page_reference: string;
|
|
practice_areas: string[];
|
|
subject_tags: string[];
|
|
cites: string[];
|
|
confidence: number;
|
|
quote_verified: boolean;
|
|
/* #81 strict-rubric quality flags — non_decision | truncated_quote |
|
|
* thin_restatement | quote_unverified. Any flag blocked auto-approval. */
|
|
quality_flags?: string[];
|
|
review_status: "pending_review" | "approved" | "rejected" | "published" | "deferred";
|
|
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;
|
|
/* X11 citation corroboration — how many distinct later courts/committees
|
|
* adopted this halacha (positive treatment), and whether any cited it
|
|
* negatively (distinguished/criticized/overruled). */
|
|
corroboration_count?: number;
|
|
corroboration_negative?: boolean;
|
|
/* #84.2 near-duplicate clustering (present only when fetched with cluster=true):
|
|
* same-precedent halachot within the cluster cosine share a cluster_id, so the
|
|
* UI collapses them into one review card. cluster_size === 1 → singleton. */
|
|
cluster_id?: string;
|
|
cluster_size?: number;
|
|
/* #84.2 parallel authority (present only when fetched with include_equivalents):
|
|
* the SAME principle stated independently in OTHER precedents — recurrence, not
|
|
* citation (distinct from corroboration_count). */
|
|
equivalents?: {
|
|
halacha_id: string;
|
|
case_number: string;
|
|
rule_statement: string;
|
|
cosine: number | null;
|
|
}[];
|
|
/* #133/FU-2 — the latest 3-judge panel deliberation (present only when fetched
|
|
* with include_panel_round). Surfaced in the review card so the chair sees WHY
|
|
* the panel split before she decides; her decision is the gold label the
|
|
* active-learning loop learns from. Capture-only — does not affect review_status. */
|
|
panel_round?: {
|
|
question: "keep" | "entailed";
|
|
verdict: "unanimous_yes" | "unanimous_no" | "split" | "incomplete";
|
|
applied_action: string;
|
|
round_ts: string | null;
|
|
judges: {
|
|
model: "claude" | "deepseek" | "gemini";
|
|
vote: boolean | null;
|
|
reason: string;
|
|
}[];
|
|
};
|
|
};
|
|
|
|
export type RelatedCase = {
|
|
id: string;
|
|
case_number: string;
|
|
case_name: string;
|
|
court: string;
|
|
precedent_level: string;
|
|
date: string | null;
|
|
relation_type: string;
|
|
};
|
|
|
|
export type PrecedentDetail = Precedent & {
|
|
full_text: string;
|
|
halachot: Halacha[];
|
|
related_cases: RelatedCase[];
|
|
};
|
|
|
|
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;
|
|
authority?: "binding" | "persuasive" | null;
|
|
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;
|
|
sourceKind?: string;
|
|
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.sourceKind) p.set("source_kind", filters.sourceKind);
|
|
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,
|
|
/* Poll while any row is mid-processing or queued for the local MCP
|
|
* worker. Once everything settles to completed/failed the polling
|
|
* stops on its own — no fixed background timer. */
|
|
refetchInterval: (query) => {
|
|
const data = query.state.data;
|
|
if (!data) return false;
|
|
const active = data.items.some((p) => isPrecedentActive(p));
|
|
return active ? 5000 : false;
|
|
},
|
|
});
|
|
}
|
|
|
|
/** A precedent is "active" while text/halacha extraction is in flight or
|
|
* legitimately queued for the local MCP worker. Used by the auto-refresh
|
|
* poller and by the row UI to disable destructive actions.
|
|
*
|
|
* Once a status is "completed" or "failed", the row is NEVER active —
|
|
* even if the corresponding `*_requested_at` timestamp still has a value.
|
|
* The worker is supposed to NULL it on success but in practice doesn't
|
|
* always, and treating those rows as active leaves them permanently
|
|
* undeletable. */
|
|
export function isPrecedentActive(p: Precedent): boolean {
|
|
// Text extraction
|
|
if (p.extraction_status === "processing") return true;
|
|
|
|
// Halacha extraction
|
|
if (p.halacha_extraction_status === "processing") return true;
|
|
if (
|
|
p.halacha_extraction_status === "pending" &&
|
|
p.halacha_extraction_requested_at !== null
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
// Metadata extraction now has its own status column. Active while the
|
|
// worker is processing the row, or while it's queued (timestamp set) and
|
|
// hasn't reached a terminal state yet.
|
|
if (p.metadata_extraction_status === "processing") return true;
|
|
if (
|
|
p.metadata_extraction_requested_at !== null &&
|
|
p.metadata_extraction_status !== "completed" &&
|
|
p.metadata_extraction_status !== "failed"
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
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 });
|
|
},
|
|
});
|
|
}
|
|
|
|
// Valid Hebrew districts for appeals-committee decisions. Mirrors
|
|
// VALID_DISTRICTS in mcp-server/src/legal_mcp/tools/internal_decisions.py —
|
|
// keep in sync with the service-side guard.
|
|
export const COMMITTEE_DISTRICTS = [
|
|
"ירושלים",
|
|
"תל אביב",
|
|
"מרכז",
|
|
"חיפה",
|
|
"צפון",
|
|
"דרום",
|
|
"ארצי",
|
|
] as const;
|
|
|
|
export type CommitteeDistrict = (typeof COMMITTEE_DISTRICTS)[number];
|
|
|
|
// A citation that targets internal_decision_upload, not the external library.
|
|
// Matches the prefix list in precedent_library service (ערר/בל"מ/ARAR).
|
|
const COMMITTEE_PREFIXES = ["ערר ", "ערר(", "בל\"מ ", "בל\"מ(", "ARAR "];
|
|
|
|
export function isCommitteeCitation(citation: string): boolean {
|
|
const c = citation.trimStart();
|
|
return COMMITTEE_PREFIXES.some((p) => c.startsWith(p));
|
|
}
|
|
|
|
export type InternalDecisionUploadInput = {
|
|
file: File;
|
|
case_number: string;
|
|
/** Full citation (מראה-מקום), stored as citation_formatted. Distinct from
|
|
* the canonical case_number identifier. */
|
|
citation?: string;
|
|
chair_name: string;
|
|
district: CommitteeDistrict | string;
|
|
case_name?: string;
|
|
court?: string;
|
|
decision_date?: string;
|
|
practice_area?: PracticeArea;
|
|
appeal_subtype?: string;
|
|
subject_tags?: string[];
|
|
is_binding?: boolean;
|
|
summary?: string;
|
|
};
|
|
|
|
export function useUploadInternalDecision() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: async (input: InternalDecisionUploadInput) => {
|
|
const fd = new FormData();
|
|
fd.append("file", input.file);
|
|
fd.append("case_number", input.case_number);
|
|
if (input.citation) fd.append("citation", input.citation);
|
|
fd.append("chair_name", input.chair_name);
|
|
fd.append("district", input.district);
|
|
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.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 ?? false));
|
|
if (input.summary) fd.append("summary", input.summary);
|
|
|
|
const res = await fetch("/api/internal-decisions/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 function useLinkRelatedCase(caseId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (vars: { relatedId: string; relationType?: string }) =>
|
|
apiRequest<{ linked: boolean }>(
|
|
`/api/precedent-library/${encodeURIComponent(caseId)}/relations`,
|
|
{
|
|
method: "POST",
|
|
body: {
|
|
related_id: vars.relatedId,
|
|
relation_type: vars.relationType ?? "same_case_chain",
|
|
},
|
|
},
|
|
),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUnlinkRelatedCase(caseId: string) {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (relatedId: string) =>
|
|
apiRequest<{ unlinked: boolean }>(
|
|
`/api/precedent-library/${encodeURIComponent(caseId)}/relations/${encodeURIComponent(relatedId)}`,
|
|
{ method: "DELETE" },
|
|
),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: libraryKeys.detail(caseId) });
|
|
},
|
|
});
|
|
}
|
|
|
|
export type PrecedentPatch = Partial<{
|
|
case_number: string;
|
|
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;
|
|
district: string;
|
|
chair_name: string;
|
|
citation_formatted: string;
|
|
}>;
|
|
|
|
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 });
|
|
},
|
|
});
|
|
}
|
|
|
|
/* Extraction can't run inside the container (no `claude` CLI). The
|
|
* "request" endpoints below stamp a queue marker in case_law; the chair
|
|
* (or me) drains the queue from Claude Code by invoking the MCP tool
|
|
* `precedent_process_pending`, which runs the actual extractor locally.
|
|
* See the rule in mcp-server/src/legal_mcp/services/claude_session.py. */
|
|
|
|
export function useRequestMetadataExtraction() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: string) =>
|
|
apiRequest<{ queued: boolean }>(
|
|
`/api/precedent-library/${encodeURIComponent(id)}/request-metadata`,
|
|
{ method: "POST" },
|
|
),
|
|
onSuccess: (_, id) => {
|
|
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
|
|
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useRequestHalachotExtraction() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: (id: string) =>
|
|
apiRequest<{ queued: boolean }>(
|
|
`/api/precedent-library/${encodeURIComponent(id)}/request-halachot`,
|
|
{ method: "POST" },
|
|
),
|
|
onSuccess: (_, id) => {
|
|
qc.invalidateQueries({ queryKey: libraryKeys.detail(id) });
|
|
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
|
},
|
|
});
|
|
}
|
|
|
|
/** #84.1/#84.2/#84.3 — the chair review queue.
|
|
*
|
|
* ONE fetch of the whole pending set (#133 unified queue): all pending halachot,
|
|
* priority-ordered (panel-split first → most-uncertain → oldest, #84.3/FU-3),
|
|
* near-duplicate-clustered, with the latest panel deliberation attached. The
|
|
* review panel splits this client-side by ACTION — "להכרעה" (has a panel round)
|
|
* vs "תיקון-חילוץ" (flagged, never adjudicated) — instead of the old empty
|
|
* clean/needsfix toggle. */
|
|
export function useHalachotPending(opts: { limit?: number } = {}) {
|
|
const { limit = 200 } = opts;
|
|
const qs = `review_status=pending_review&exclude_low_quality=false`
|
|
+ `&order_by_priority=true&cluster=true&include_equivalents=true`
|
|
+ `&include_panel_round=true&limit=${limit}`;
|
|
return useQuery({
|
|
queryKey: libraryKeys.halachotPending(),
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<{ items: Halacha[]; count: number }>(`/api/halachot?${qs}`, { signal }),
|
|
staleTime: 5_000,
|
|
refetchOnMount: "always",
|
|
});
|
|
}
|
|
|
|
/** A pending item belongs in the "needs extraction fix" segment when it carries a
|
|
* quality flag AND the panel never deliberated it (no round). Everything else —
|
|
* deliberated items and clean items — is a chair-judgment item. (#133 unified queue) */
|
|
export function isExtractionFixItem(h: Halacha): boolean {
|
|
return !h.panel_round && (h.quality_flags?.length ?? 0) > 0;
|
|
}
|
|
|
|
export function useHalachotByStatus(status: string, limit = 300) {
|
|
return useQuery({
|
|
queryKey: libraryKeys.halachot({ review_status: status, limit: String(limit) }),
|
|
queryFn: ({ signal }) =>
|
|
apiRequest<{ items: Halacha[]; count: number }>(
|
|
`/api/halachot?review_status=${encodeURIComponent(status)}&limit=${limit}`,
|
|
{ signal },
|
|
),
|
|
staleTime: 10_000,
|
|
refetchOnMount: "always",
|
|
});
|
|
}
|
|
|
|
export type HalachaPatch = Partial<{
|
|
review_status: "pending_review" | "approved" | "rejected" | "published" | "deferred";
|
|
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 });
|
|
// APR-5 / ADM-2 (INV-IA2): approving/rejecting a halacha changes the
|
|
// chair-pending aggregate and the /operations halacha_review counter.
|
|
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
|
|
qc.invalidateQueries({ queryKey: ["operations"] });
|
|
},
|
|
});
|
|
}
|
|
|
|
export type BatchReviewStatus =
|
|
| "approved" | "rejected" | "deferred" | "pending_review" | "published";
|
|
|
|
/** #84 — apply one review status to many halachot in a single request. */
|
|
export function useBatchReviewHalachot() {
|
|
const qc = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ ids, status, reviewer }: {
|
|
ids: string[]; status: BatchReviewStatus; reviewer?: string;
|
|
}) =>
|
|
apiRequest<{ updated: number }>(
|
|
`/api/halachot/batch`,
|
|
{ method: "POST", body: { halacha_ids: ids, review_status: status, reviewer } },
|
|
),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: libraryKeys.all });
|
|
// APR-5 / ADM-2 (INV-IA2): a batch review shifts the chair-pending
|
|
// aggregate and the /operations halacha_review counter.
|
|
qc.invalidateQueries({ queryKey: ["chair", "pending"] });
|
|
qc.invalidateQueries({ queryKey: ["operations"] });
|
|
},
|
|
});
|
|
}
|