/** * Training / style corpus hooks. * * Endpoints touched (all under /api/training/): * - GET /style-report → the dashboard payload (corpus stats + anatomy * + signature phrases + per-decision contribution) * - GET /corpus → flat list of decisions for the corpus tab / compare tool * - GET /compare?a=UUID&b=UUID → side-by-side comparison * - DELETE /corpus/{id} → remove a decision from the corpus * - POST /api/upload → multipart file → returns sanitized filename * - POST /analyze → proofread + extract metadata for preview * - POST /upload → commit a proofread decision to the corpus (task_id) */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ApiError, apiRequest } from "./client"; export type StyleReport = { corpus: { decision_count: number; total_chars: number; avg_chars: number; date_range: [string | null, string | null]; decisions: Array<{ number: string; date: string; chars: number; subjects: string[]; }>; subject_distribution: Array<{ label: string; count: number }>; headline: string; }; anatomy: { sections: Array<{ type: string; label: string; avg_chars: number; pct: number; coverage: number; }>; total_coverage: number; headline: string; }; signature_phrases: { items: Array<{ type: string; text: string; context: string; frequency: number; examples: string[]; }>; total_decisions: number; top_display: string; headline: string; }; contribution: { growth_curve: Array<{ decision_number: string; date: string; cumulative: number; }>; decision_contributions: unknown[]; total_patterns: number; headline: string; }; }; export type CorpusDecision = { id: string; decision_number: string; decision_date: string; subject_categories: string[]; chars: number; created_at: string; // Enriched metadata (added in the corpus-page upgrade). summary: string; outcome: string; key_principles: string[]; appeal_subtype: string; practice_area: string; page_count: number; document_id: string | null; doc_title: string; parties: { appellant: string; respondent: string }; legal_citation: string; lessons_count: number; }; export type CorpusDecisionPatch = { decision_number?: string; decision_date?: string; subject_categories?: string[]; summary?: string; outcome?: string; key_principles?: string[]; appeal_subtype?: string; practice_area?: string; }; export type CompareResult = { a: CompareSide; b: CompareSide; shared: PatternEntry[]; only_a: PatternEntry[]; only_b: PatternEntry[]; }; export type CompareSide = { id: string; decision_number: string; decision_date: string; chars: number; subjects: string[]; sections: Array<{ type: string; chars: number }>; patterns_count: number; }; export type PatternEntry = { id: string; type: string; text: string; context: string; }; export const trainingKeys = { all: ["training"] as const, report: () => [...trainingKeys.all, "style-report"] as const, corpus: () => [...trainingKeys.all, "corpus"] as const, compare: (a: string, b: string) => [...trainingKeys.all, "compare", a, b] as const, }; export function useStyleReport() { return useQuery({ queryKey: trainingKeys.report(), queryFn: ({ signal }) => apiRequest("/api/training/style-report", { signal }), staleTime: 60_000, }); } export function useCorpus() { return useQuery({ queryKey: trainingKeys.corpus(), queryFn: ({ signal }) => apiRequest("/api/training/corpus", { signal }), staleTime: 60_000, }); } export function useCompare(a: string | null, b: string | null) { return useQuery({ queryKey: trainingKeys.compare(a ?? "", b ?? ""), queryFn: ({ signal }) => apiRequest( `/api/training/compare?a=${encodeURIComponent(a!)}&b=${encodeURIComponent(b!)}`, { signal }, ), enabled: Boolean(a && b && a !== b), staleTime: Infinity, }); } export function useDeleteCorpusEntry() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiRequest<{ deleted: boolean }>( `/api/training/corpus/${encodeURIComponent(id)}`, { method: "DELETE" }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: trainingKeys.corpus() }); qc.invalidateQueries({ queryKey: trainingKeys.report() }); }, }); } // ── Style-agent chat ───────────────────────────────────────────── export type ChatConversation = { id: string; title: string; style_corpus_id: string | null; decision_number: string; claude_session_id: string | null; message_count: number; created_at: string; last_message_at: string; }; export type ChatMessage = { id: string; role: "user" | "assistant"; content: string; created_at: string; }; export type ChatHealth = { reachable: boolean; status?: number; url: string; error?: string; }; export const chatKeys = { conversations: () => [...trainingKeys.all, "chat", "conversations"] as const, conversation: (id: string) => [...trainingKeys.all, "chat", "conversations", id] as const, health: () => [...trainingKeys.all, "chat", "health"] as const, }; export function useChatConversations() { return useQuery({ queryKey: chatKeys.conversations(), queryFn: ({ signal }) => apiRequest("/api/training/chat/conversations", { signal }), staleTime: 15_000, }); } export function useChatConversation(convId: string | null) { return useQuery({ queryKey: chatKeys.conversation(convId ?? ""), queryFn: ({ signal }) => apiRequest<{ conversation: ChatConversation; messages: ChatMessage[] }>( `/api/training/chat/conversations/${encodeURIComponent(convId!)}`, { signal }, ), enabled: Boolean(convId), staleTime: 5_000, }); } export function useChatHealth() { return useQuery({ queryKey: chatKeys.health(), queryFn: ({ signal }) => apiRequest("/api/training/chat/health", { signal }), staleTime: 30_000, retry: false, }); } export function useCreateChat() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: { title?: string; style_corpus_id?: string | null }) => apiRequest("/api/training/chat/conversations", { method: "POST", body, }), onSuccess: () => { qc.invalidateQueries({ queryKey: chatKeys.conversations() }); }, }); } export function useDeleteChat() { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiRequest<{ deleted: boolean }>( `/api/training/chat/conversations/${encodeURIComponent(id)}`, { method: "DELETE" }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: chatKeys.conversations() }); }, }); } // ── Curator portrait ────────────────────────────────────────────── export type CuratorPrompt = { content: string; filename: string; bytes: number; last_modified: number; gitea_url: string; }; export type StyleAnalyzerPrompts = { analysis_prompt: string; single_decision_prompt: string; synthesis_prompt: string; max_input_tokens: number; }; export type CuratorFinding = { id: string; lesson_text: string; category: string; applied_to_skill: boolean; decision_number: string; decision_date: string; created_at: string; }; export type CuratorStats = { total_findings: number; decisions_with_findings: number; decisions_total: number; findings_applied: number; recent_findings: CuratorFinding[]; }; export type CuratorProposalInput = { title: string; proposed_change: string; rationale: string; }; export type CuratorProposalFile = { filename: string; bytes: number; modified_at: number; }; export const curatorKeys = { prompt: () => [...trainingKeys.all, "curator", "prompt"] as const, analyzerPrompt: () => [...trainingKeys.all, "curator", "analyzer-prompt"] as const, stats: () => [...trainingKeys.all, "curator", "stats"] as const, proposals: () => [...trainingKeys.all, "curator", "proposals"] as const, }; export function useCuratorPrompt() { return useQuery({ queryKey: curatorKeys.prompt(), queryFn: ({ signal }) => apiRequest("/api/training/curator/prompt", { signal }), staleTime: 5 * 60_000, }); } export function useStyleAnalyzerPrompts() { return useQuery({ queryKey: curatorKeys.analyzerPrompt(), queryFn: ({ signal }) => apiRequest( "/api/training/curator/style-analyzer-prompt", { signal }, ), staleTime: 5 * 60_000, }); } export function useCuratorStats() { return useQuery({ queryKey: curatorKeys.stats(), queryFn: ({ signal }) => apiRequest("/api/training/curator/stats", { signal }), staleTime: 60_000, }); } export function useCuratorProposals() { return useQuery({ queryKey: curatorKeys.proposals(), queryFn: ({ signal }) => apiRequest("/api/training/curator/proposals", { signal }), staleTime: 30_000, }); } export function useSubmitCuratorProposal() { const qc = useQueryClient(); return useMutation({ mutationFn: (body: CuratorProposalInput) => apiRequest<{ saved: boolean; filename: string }>( "/api/training/curator/proposals", { method: "POST", body }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: curatorKeys.proposals() }); }, }); } // ── Upload flow ────────────────────────────────────────────────── // Three-step pipeline: // 1. useUploadFile → POST /api/upload (multipart) → { filename } // 2. useAnalyzeFile → POST /api/training/analyze (form) → preview + extracted metadata // 3. useCommitUpload → POST /api/training/upload (json) → { task_id } // Track task_id via useProgress() from documents.ts. export type UploadFileResponse = { filename: string; // sanitized, time-prefixed name in UPLOAD_DIR original_name: string; size: number; }; export type AnalyzeTrainingResponse = { filename: string; clean_text: string; preview: string; decision_number: string; decision_date: string; // ISO YYYY-MM-DD or "" subject_categories: string[]; stats: Record; chars: number; }; export type CommitTrainingRequest = { filename: string; decision_number: string; decision_date: string; // YYYY-MM-DD or "" subject_categories: string[]; title?: string; }; export type CommitTrainingResponse = { task_id: string }; export function useUploadFile() { return useMutation({ mutationFn: async (file: File): Promise => { const fd = new FormData(); fd.append("file", file); const res = await fetch("/api/upload", { method: "POST", body: fd }); const contentType = res.headers.get("content-type") ?? ""; const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null); if (!res.ok) { throw new ApiError( typeof parsed === "object" && parsed && "detail" in parsed ? String((parsed as { detail: unknown }).detail) : `Upload failed with ${res.status}`, res.status, parsed, ); } return parsed as UploadFileResponse; }, }); } export function useAnalyzeTraining() { return useMutation({ mutationFn: async (filename: string): Promise => { const fd = new FormData(); fd.append("filename", filename); const res = await fetch("/api/training/analyze", { method: "POST", body: fd, }); const contentType = res.headers.get("content-type") ?? ""; const parsed = contentType.includes("application/json") ? await res.json().catch(() => null) : await res.text().catch(() => null); if (!res.ok) { throw new ApiError( typeof parsed === "object" && parsed && "detail" in parsed ? String((parsed as { detail: unknown }).detail) : `Analyze failed with ${res.status}`, res.status, parsed, ); } return parsed as AnalyzeTrainingResponse; }, }); } // ── Per-decision lessons ───────────────────────────────────────── export type DecisionLesson = { id: string; style_corpus_id: string; lesson_text: string; category: "style" | "structure" | "lexicon" | "tabular" | "general"; source: "manual" | "curator" | "chair" | "style_analyzer"; applied_to_skill: boolean; created_by: string; created_at: string; updated_at: string; }; export type LessonCreate = { lesson_text: string; category?: DecisionLesson["category"]; source?: DecisionLesson["source"]; }; export type LessonPatch = { lesson_text?: string; category?: DecisionLesson["category"]; applied_to_skill?: boolean; }; export const lessonsKeys = { forCorpus: (corpusId: string) => [...trainingKeys.all, "lessons", corpusId] as const, }; export function useCorpusLessons(corpusId: string | null) { return useQuery({ queryKey: lessonsKeys.forCorpus(corpusId ?? ""), queryFn: ({ signal }) => apiRequest( `/api/training/corpus/${encodeURIComponent(corpusId!)}/lessons`, { signal }, ), enabled: Boolean(corpusId), staleTime: 30_000, }); } export function useAddLesson(corpusId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (body: LessonCreate) => apiRequest( `/api/training/corpus/${encodeURIComponent(corpusId)}/lessons`, { method: "POST", body }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: lessonsKeys.forCorpus(corpusId) }); // lessons_count on the corpus row is computed server-side, so // invalidate the list too — otherwise the badge stays stale. qc.invalidateQueries({ queryKey: trainingKeys.corpus() }); }, }); } export function usePatchLesson(corpusId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, patch }: { id: string; patch: LessonPatch }) => apiRequest<{ updated: boolean }>( `/api/training/lessons/${encodeURIComponent(id)}`, { method: "PATCH", body: patch }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: lessonsKeys.forCorpus(corpusId) }); }, }); } export function useDeleteLesson(corpusId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (id: string) => apiRequest<{ deleted: boolean }>( `/api/training/lessons/${encodeURIComponent(id)}`, { method: "DELETE" }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: lessonsKeys.forCorpus(corpusId) }); qc.invalidateQueries({ queryKey: trainingKeys.corpus() }); }, }); } export function usePatchCorpus() { const qc = useQueryClient(); return useMutation({ mutationFn: ({ id, patch }: { id: string; patch: CorpusDecisionPatch }) => apiRequest<{ updated: boolean; id: string }>( `/api/training/corpus/${encodeURIComponent(id)}`, { method: "PATCH", body: patch }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: trainingKeys.corpus() }); qc.invalidateQueries({ queryKey: trainingKeys.report() }); }, }); } export function useCommitTrainingUpload() { // No onSuccess invalidation here — the row only appears after the // background task finishes. The dialog watches useProgress(task_id) // and invalidates trainingKeys when status === "completed". return useMutation({ mutationFn: (body: CommitTrainingRequest) => apiRequest("/api/training/upload", { method: "POST", body, }), }); }