Files
legal-ai/web-ui/src/lib/api/training.ts
Chaim 987da57da4
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(ui): synthesis/superseded badges + merge provenance in lessons-tab (#162)
עוצב ואושר דרך שער-העיצוב (Claude Design 12c-training-lessons.html).
- lessons-tab: SOURCE_BADGE['synthesis']="סינתזה" (navy מלא) · REVIEW_BADGE['superseded']="מוזג"
  (אפור-מעומעם, לא נדחה); שורות superseded מעומעמות (opacity).
- provenance: לקח-על מציג "⤷ מוזג מ-N לקחים · דחייה תשחזר אותם".
- backend: list_decision_lessons + _lesson_to_json מחזירים synthesized_from; training.ts
  DecisionLesson — 'synthesis'/'superseded' ל-unions + synthesized_from?: string[].

הבאדג'ים היו מוגנים-fallback קודם (ללא קריסה) — כעת מתויגים נכון. closes #162.

בדיקות: py_compile ✓ · leak-guard ✓ · tsc --noEmit ✓.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:59:31 +00:00

628 lines
18 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;
};
// One decision_lessons channel split by the INV-LRN1 review gate.
export type LessonChannelStats = {
total: number;
approved: number;
proposed: number;
rejected: number;
};
// The three honest learning channels that feed the writer (INV-IA2/IA5).
// Earlier this was a single flat count of source='curator' — which nothing
// wrote, so it always read 0 while the panel/methodology channels carried all
// the real learning. See get_curator_stats (web/app.py).
export type CuratorStats = {
decisions_total: number;
channels: {
// A — Claude draft↔final distillation → methodology overrides; flows now.
distillation: {
discussion_rules: number;
transition_phrases: number;
items_total: number;
pairs_folded: number;
pairs_total: number;
};
// B — DeepSeek+Gemini panel → decision_lessons; flows when chair-approved.
panel: LessonChannelStats & {
decisions: number;
by_practice_area: Record<string, number>;
};
// C — curator agent's qualitative findings → decision_lessons (source='curator').
curator: LessonChannelStats & {
decisions_reviewed: 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.
// "synthesis" — a super-lesson merging overlapping lessons (#158/INV-LRN8);
// "panel:deepseek+gemini" — the two-judge style panel; (string) keeps it open.
source: "manual" | "curator" | "chair" | "style_analyzer" | "synthesis" | (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.
// "superseded" — a source lesson merged into a synthesis (#158); kept as
// provenance, no longer fed to the writer.
// (applied_to_skill removed — LRN-1/INV-IA3: review_status is the single gate.)
review_status: "proposed" | "approved" | "rejected" | "superseded";
created_by: string;
created_at: string;
updated_at: string;
// #158/INV-LRN8: ids of the source lessons a 'synthesis' row merged (provenance).
synthesized_from?: 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,
}),
});
}