Port the remaining read views from the vanilla UI to Next.js:
- /diagnostics — system health snapshot (DB connected, table counts,
active tasks, failed and stuck documents). Uses the existing
/api/system/diagnostics payload with a 10s refetchInterval so the
page self-updates while the user watches.
- /skills — Paperclip skill inventory with sync status (DB-only,
disk-only, synced, not-synced) as a card grid driven by
/api/admin/skills.
- /training — Dafna's style portrait as three tabs on one page:
* Report: corpus KPIs + CSS conic-gradient subject donut
(SubjectDonut ported from index.html renderHero) + horizontal
anatomy bars + top-12 signature phrases.
* Corpus: TanStack Table of style_corpus rows with an inline
delete mutation (useDeleteCorpusEntry invalidates both the
corpus list and the style report so KPIs update).
* Compare: two-decision selector backed by /api/training/compare,
side-by-side panels plus shared / only-A / only-B pattern
lists.
New API modules: lib/api/system.ts, lib/api/skills.ts,
lib/api/training.ts. All three use TanStack Query with staleTime
profiles tuned per endpoint (10s for diagnostics, 30s for skills,
60s for training reports).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
152 lines
3.7 KiB
TypeScript
152 lines
3.7 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
|
|
*/
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { 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;
|
|
};
|
|
|
|
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() });
|
|
},
|
|
});
|
|
}
|