Phase 5: secondary screens (diagnostics, skills, training)

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>
This commit is contained in:
2026-04-11 17:33:33 +00:00
parent ac0a5ee30b
commit fb1f73fa25
11 changed files with 1228 additions and 6 deletions

View File

@@ -0,0 +1,30 @@
/**
* Paperclip skills — listing + sync actions.
*
* Skills live in Paperclip's database (separate from the main legal-ai DB)
* and are exposed via /api/admin/skills. The UI just needs read access for
* Phase 5; install/sync/delete mutations can follow in Phase 6.
*/
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type Skill = {
slug: string;
name: string;
db_markdown_chars: number;
file_inventory: Array<{ path: string; size?: number }> | null;
updated_at: string | null;
disk_exists: boolean;
disk_skill_md_bytes: number | null;
not_in_db?: boolean;
};
export function useSkills() {
return useQuery({
queryKey: ["skills", "list"] as const,
queryFn: ({ signal }) =>
apiRequest<Skill[]>("/api/admin/skills", { signal }),
staleTime: 30_000,
});
}

View File

@@ -0,0 +1,42 @@
/**
* System-level hooks: diagnostics + active task snapshot.
*
* The vanilla UI polled /api/system/diagnostics and /api/system/tasks on
* an interval. We replace the polling with TanStack Query's refetchInterval
* — same effect, but participates in the shared cache and survives route
* transitions without setting up its own setInterval bookkeeping.
*/
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type DiagDoc = {
id: string;
title: string;
status: string;
case_number: string;
created_at: string | null;
};
export type Diagnostics = {
db_ok: boolean;
tables: Record<string, number | null>;
failed_documents: DiagDoc[];
stuck_documents: DiagDoc[];
active_tasks: Array<{
task_id: string;
filename: string;
status: string;
step: string;
}>;
};
export function useDiagnostics() {
return useQuery({
queryKey: ["system", "diagnostics"] as const,
queryFn: ({ signal }) =>
apiRequest<Diagnostics>("/api/system/diagnostics", { signal }),
refetchInterval: 10_000,
staleTime: 5_000,
});
}

View File

@@ -0,0 +1,151 @@
/**
* 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() });
},
});
}