Files
legal-ai/web-ui/src/lib/api/training.ts
Chaim 6e69c1dc38
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 9s
feat(ia): IA גל-2 — איחוד-משטחים: ערוץ-למידה אחד · /operations⊇/diagnostics · MET-2/3 (#131, X17)
גל-2 מבקלוג #127 — איחוד-משטחים לפי משטח-היעד של X17. מקיים INV-IA1/IA3/IA4 +
דלתות-הספ (X6 INV-UI7/8, 07-learning §0.4, 00-constitution G2). שומר G10/INV-LRN1
(לא הוסר שום שער-אנושי — רק שער/דגל כפול).

א) תיבת-אישור אחת (INV-IA1): כרטיסי "אישור הלכות"+"פסיקה חסרה" ב-/operations
   מצביעים ל-/approvals (לתיבת-האישורים ←) — /operations מנטר, /approvals מחליט.

ב) ערוץ-למידה אחד (INV-IA3): הוסר applied_to_skill end-to-end —
   - UI: כפתור "סמן כ'אומץ'" + badge "אומץ" ב-lessons-tab; badge ב-curator-portrait.
   - API: LessonPatch, _lesson_to_json, patch call, curator recent_findings (→review_status).
   - db.py: list/add/update_decision_lesson לא בוחרים/כותבים applied_to_skill;
     הפרמטר הוסר. העמודה+אינדקס נשמרים (back-compat, ללא migration), מסומנים DEPRECATED.
   - types: DecisionLesson/LessonPatch/CuratorFinding.
   review_status='approved' = הסטטוס היחיד "זורם-לכותב" (INV-LRN1, #126).

ג) MET-2/3 lost-update (INV-IA3): _append_methodology_override רץ עכשיו בטרנזקציה
   אחת עם SELECT ... FOR UPDATE — אין read-modify-write מתפצל מול עורך-המתודולוגיה
   או promote מקביל. /methodology = העורך-הקנוני; promote מבטל את ה-cache (גל-1 MET-1).

ד) /operations⊇/diagnostics (INV-IA4): גוף /diagnostics חולץ ל-<SystemHealthSection/>
   ומורנדר ב-/operations תחת "בריאות-מערכת". /diagnostics → redirect ל-/operations.
   /diagnostics הוסר מהניווט. משטח-ניטור יחיד.

ה) דלתות-ספ (≥3 מקורות ב-X17, אושר ע"י חיים /goal):
   - X6: INV-UI7 (aggregate=SSoT, mutation מבטל queryKey) + INV-UI8 (render-or-remove, חלקיות).
   - 07-learning §0.4: שער-אחד + טרנזקציה-אחת + applied_to_skill מוסר.
   - 00-constitution G2: תאום-המתודולוגיה כהפרה-ידועה-ממותנת.
   - X17 דלתות-ספ סומנו  קודדו.

בדיקות: py_compile app.py + db.py ✓ · tsc --noEmit ✓ · eslint ✓ (לבד מ-learning-panel:109
קיים-מראש). next build נכשל ב-worktree רק בגלל symlink (Turbopack) — Docker/CI תקין.
api:types יתרענן בדפלוי (curator/lessons אינם response-modeled; הטיפוסים יד-כתובים עודכנו).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 21:04:57 +00:00

597 lines
17 KiB
TypeScript

/**
* 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<StyleReport>("/api/training/style-report", { signal }),
staleTime: 60_000,
});
}
export function useCorpus() {
return useQuery({
queryKey: trainingKeys.corpus(),
queryFn: ({ signal }) =>
apiRequest<CorpusDecision[]>("/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<CompareResult>(
`/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() });
// LRN-8 (INV-IA2): deleting a corpus row nulls style_corpus_id on any
// chat that referenced it — refresh the conversation list so those
// chats no longer show a stale corpus link.
qc.invalidateQueries({ queryKey: chatKeys.conversations() });
},
});
}
// ── 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<ChatConversation[]>("/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<ChatHealth>("/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<ChatConversation>("/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;
// review_status replaces the removed applied_to_skill flag (LRN-1/INV-IA3).
review_status: "proposed" | "approved" | "rejected";
decision_number: string;
decision_date: string;
created_at: string;
};
export type CuratorStats = {
total_findings: number;
decisions_with_findings: number;
decisions_total: number;
// LRN-5 (INV-IA5): the count of curator lessons with review_status='approved'
// (the real INV-LRN1 writer gate) — replaces the old findings_applied, which
// counted the informative-only applied_to_skill flag.
findings_approved: 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<CuratorPrompt>("/api/training/curator/prompt", { signal }),
staleTime: 5 * 60_000,
});
}
export function useStyleAnalyzerPrompts() {
return useQuery({
queryKey: curatorKeys.analyzerPrompt(),
queryFn: ({ signal }) =>
apiRequest<StyleAnalyzerPrompts>(
"/api/training/curator/style-analyzer-prompt",
{ signal },
),
staleTime: 5 * 60_000,
});
}
export function useCuratorStats() {
return useQuery({
queryKey: curatorKeys.stats(),
queryFn: ({ signal }) =>
apiRequest<CuratorStats>("/api/training/curator/stats", { signal }),
staleTime: 60_000,
});
}
export function useCuratorProposals() {
return useQuery({
queryKey: curatorKeys.proposals(),
queryFn: ({ signal }) =>
apiRequest<CuratorProposalFile[]>("/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<string, unknown>;
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<UploadFileResponse> => {
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<AnalyzeTrainingResponse> => {
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";
// "panel:deepseek+gemini" — the two-judge style panel; (string) keeps it open
// to future panel sources without a type break.
source: "manual" | "curator" | "chair" | "style_analyzer" | (string & {});
// review gate (INV-LRN1/G10): only "approved" lessons flow to the writer.
// Panel-written lessons start as "proposed" and wait for the chair here.
// (applied_to_skill removed — LRN-1/INV-IA3: review_status is the single gate.)
review_status: "proposed" | "approved" | "rejected";
created_by: string;
created_at: string;
updated_at: string;
};
export type LessonReviewStatus = DecisionLesson["review_status"];
export type LessonCreate = {
lesson_text: string;
category?: DecisionLesson["category"];
source?: DecisionLesson["source"];
};
export type LessonPatch = {
lesson_text?: string;
category?: DecisionLesson["category"];
review_status?: DecisionLesson["review_status"];
};
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<DecisionLesson[]>(
`/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<DecisionLesson>(
`/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<CommitTrainingResponse>("/api/training/upload", {
method: "POST",
body,
}),
});
}