/** * Style-acquisition learning surface (T6/T13) — reconciliation ledger + style-distance. * Backs the /training "למידה" tab. Endpoints under /api/learning. */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "./client"; import { trainingKeys } from "./training"; import { methodologyKeys } from "./methodology"; export type DraftFinalPair = { id: string; case_id: string | null; case_number: string; title: string; status: "final_received" | "analyzed" | "lessons_folded" | string; change_percent: number | null; created_at: string | null; updated_at: string | null; }; export type StyleDistance = { case_number: string; outcome: string; golden_ratio_adherence: { outcome: string; total_words: number; sections: Record; max_deviation: number | null; }; anti_pattern_hits: { total: number; by_pattern: Record }; draft_to_final_diff: { change_percent?: number } | null; pair_status: string | null; summary: { ratio_max_deviation_pp: number | null; anti_pattern_total: number; change_percent: number | null; }; error?: string; }; export const learningKeys = { all: ["learning"] as const, pairs: (status: string) => [...learningKeys.all, "pairs", status] as const, distance: (caseNumber: string) => [...learningKeys.all, "distance", caseNumber] as const, caseStatus: (caseNumber: string) => [...learningKeys.all, "case-status", caseNumber] as const, }; export function useReconciliationLedger(status = "") { return useQuery({ queryKey: learningKeys.pairs(status), queryFn: ({ signal }) => apiRequest<{ items: DraftFinalPair[]; count: number }>( `/api/learning/pairs${status ? `?status=${status}` : ""}`, { signal }, ), staleTime: 15_000, }); } export function useStyleDistance(caseNumber: string | null) { return useQuery({ queryKey: learningKeys.distance(caseNumber ?? ""), queryFn: ({ signal }) => apiRequest( `/api/learning/style-distance/${encodeURIComponent(caseNumber!)}`, { signal }, ), enabled: Boolean(caseNumber), staleTime: 15_000, }); } // ── T14: curator distillation proposal + chair approval gate ────── export type DistillationChange = { type?: string; domain?: string; block?: string; description?: string; draft_text?: string; final_text?: string; lesson?: string; }; export type PairDetail = { id: string; case_number: string; title: string; status: string; diff_stats: { change_percent?: number } | null; overall_assessment: string; changes: DistillationChange[]; // style_method only (server-filtered) new_expressions: string[]; }; export function usePairDetail(pairId: string | null) { return useQuery({ queryKey: [...learningKeys.all, "pair", pairId ?? ""] as const, queryFn: ({ signal }) => apiRequest(`/api/learning/pairs/${pairId}`, { signal }), enabled: Boolean(pairId), staleTime: 15_000, }); } export function usePromoteLearning(pairId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: (body: { lessons: string[]; phrases: string[] }) => apiRequest<{ id: string; status: string; folded_lessons: number; folded_phrases: number }>( `/api/learning/pairs/${pairId}/promote`, { method: "POST", body }, ), onSuccess: () => { qc.invalidateQueries({ queryKey: learningKeys.all }); // LRN-10 (INV-IA2): promote flips folded lessons' review_status, so the // per-corpus LessonsTab must refetch (lessons key is corpus-scoped — // invalidate the whole "lessons" prefix since promote is pair-scoped). qc.invalidateQueries({ queryKey: [...trainingKeys.all, "lessons"] }); // MET-1 (INV-IA2): promote also writes discussion_rules + transition_ // phrases['universal'] — /methodology (staleTime 30s) would stay stale. qc.invalidateQueries({ queryKey: methodologyKeys.all }); }, }); } // ── Post-final pipeline status (case indicator) ────────────────────── // Derived status of the two post-final pipelines for one case: whether voice // learning + halacha extraction ran, succeeded, why not, and how many halachot // were extracted. Backs the indicator in the drafts panel's final-decision section. export type VoiceLearningStatus = { ran: boolean; outcome: "succeeded" | "failed" | "not_run"; error: string | null; pair_status: string; // final_received | analyzed | lessons_folded | '' lessons_count: number; style_corpus_enrolled: boolean; lessons_proposed: number; analyzed_at: string | null; }; export type HalachaExtractionStatus = { enrolled_in_corpus: boolean; not_enrolled_reason: string | null; status: | "pending" | "processing" | "completed" | "failed" | "partial" | "extraction_failed" | "no_chunks" | "busy" | null; failed?: boolean; halachot_count: number; approved: number; pending: number; rejected: number; }; export type CaseLearningStatus = { final_uploaded: boolean; voice_learning: VoiceLearningStatus; halacha_extraction: HalachaExtractionStatus; }; /** Whether the indicator should keep polling (a pipeline is mid-flight). */ function isLearningInFlight(s: CaseLearningStatus | undefined): boolean { const h = s?.halacha_extraction.status; return h === "processing" || h === "pending" || h === "busy"; } export function useCaseLearningStatus(caseNumber: string, enabled = true) { return useQuery({ queryKey: learningKeys.caseStatus(caseNumber), queryFn: ({ signal }) => apiRequest( `/api/cases/${caseNumber}/learning-status`, { signal }, ), enabled, staleTime: 15_000, // Background pipelines: refetch gently while something is still running. refetchInterval: (query) => isLearningInFlight(query.state.data as CaseLearningStatus | undefined) ? 15_000 : false, }); }