From fb1f73fa25013da2b702d851e1282048d30fe180 Mon Sep 17 00:00:00 2001 From: Chaim Date: Sat, 11 Apr 2026 17:33:33 +0000 Subject: [PATCH] Phase 5: secondary screens (diagnostics, skills, training) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .taskmaster/tasks/tasks.json | 13 +- web-ui/src/app/diagnostics/page.tsx | 219 ++++++++++++++++++ web-ui/src/app/skills/page.tsx | 128 ++++++++++ web-ui/src/app/training/page.tsx | 56 +++++ .../src/components/training/compare-panel.tsx | 189 +++++++++++++++ .../src/components/training/corpus-panel.tsx | 139 +++++++++++ .../training/style-report-panel.tsx | 195 ++++++++++++++++ .../src/components/training/subject-donut.tsx | 72 ++++++ web-ui/src/lib/api/skills.ts | 30 +++ web-ui/src/lib/api/system.ts | 42 ++++ web-ui/src/lib/api/training.ts | 151 ++++++++++++ 11 files changed, 1228 insertions(+), 6 deletions(-) create mode 100644 web-ui/src/app/diagnostics/page.tsx create mode 100644 web-ui/src/app/skills/page.tsx create mode 100644 web-ui/src/app/training/page.tsx create mode 100644 web-ui/src/components/training/compare-panel.tsx create mode 100644 web-ui/src/components/training/corpus-panel.tsx create mode 100644 web-ui/src/components/training/style-report-panel.tsx create mode 100644 web-ui/src/components/training/subject-donut.tsx create mode 100644 web-ui/src/lib/api/skills.ts create mode 100644 web-ui/src/lib/api/system.ts create mode 100644 web-ui/src/lib/api/training.ts diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 6f332ab..4ad3909 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -848,12 +848,13 @@ "description": "Port the remaining 5 views. Use TanStack Table for training corpus and diagnostics lists. Port any charts/visualizations from current index.html. Plan: ~/.claude/plans/joyful-marinating-sutton.md.", "details": "See full plan at ~/.claude/plans/joyful-marinating-sutton.md for architecture, critical files, risks, and open questions. This task is phase 5 of 7 in the legal-ai UI rewrite from vanilla HTML to Next.js 15 + shadcn/ui.", "testStrategy": "Feature parity with old legal-ai/web/static/index.html across all 10 views.", - "status": "pending", + "status": "in-progress", "dependencies": [ "86" ], "priority": "medium", - "subtasks": [] + "subtasks": [], + "updatedAt": "2026-04-11T17:28:06.562Z" }, { "id": "88", @@ -887,20 +888,20 @@ "description": "Add practice_area + appeal_subtype to the wizard, types, schema, case header, and cases table. Gap identified after backend commit 26d09d6 (multi-tenant axis) — new Next.js UI has zero integration while vanilla UI is fully wired. Plan: ~/.claude/plans/woolly-cooking-graham.md", "details": "", "testStrategy": "", - "status": "in-progress", + "status": "done", "dependencies": [ "86" ], "priority": "high", "subtasks": [], - "updatedAt": "2026-04-11T17:13:13.931Z" + "updatedAt": "2026-04-11T17:15:57.831Z" } ], "metadata": { "version": "1.0.0", - "lastModified": "2026-04-11T17:13:13.932Z", + "lastModified": "2026-04-11T17:28:06.563Z", "taskCount": 59, - "completedCount": 53, + "completedCount": 54, "tags": [ "master" ] diff --git a/web-ui/src/app/diagnostics/page.tsx b/web-ui/src/app/diagnostics/page.tsx new file mode 100644 index 0000000..56ab867 --- /dev/null +++ b/web-ui/src/app/diagnostics/page.tsx @@ -0,0 +1,219 @@ +"use client"; + +import Link from "next/link"; +import { AlertTriangle, CheckCircle2, Clock, Database } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useDiagnostics, type DiagDoc } from "@/lib/api/system"; + +const TABLE_LABELS: Record = { + cases: "תיקים", + documents: "מסמכים", + document_chunks: "chunks", + style_corpus: "קורפוס סגנון", + style_patterns: "דפוסי סגנון", +}; + +function formatRelativeTime(iso: string | null) { + if (!iso) return "—"; + const then = new Date(iso); + const diffMs = Date.now() - then.getTime(); + const min = Math.floor(diffMs / 60000); + if (min < 1) return "עכשיו"; + if (min < 60) return `לפני ${min} דקות`; + const hr = Math.floor(min / 60); + if (hr < 24) return `לפני ${hr} שעות`; + const days = Math.floor(hr / 24); + if (days < 30) return `לפני ${days} ימים`; + return then.toLocaleDateString("he-IL"); +} + +function DocRow({ doc, tone }: { doc: DiagDoc; tone: "danger" | "warn" }) { + const cls = + tone === "danger" ? "bg-danger-bg/60 border-danger/30" : "bg-warn-bg/60 border-warn/30"; + return ( +
  • +
    +
    + {doc.title || "(ללא כותרת)"} +
    +
    + {doc.case_number && ( + + ערר {doc.case_number} + + )} + {formatRelativeTime(doc.created_at)} +
    +
    + {doc.status} +
  • + ); +} + +export default function DiagnosticsPage() { + const { data, isPending, error } = useDiagnostics(); + + return ( + +
    +
    + +

    אבחון מערכת

    +

    + מצב ה-DB, מסמכים שנכשלו או תקועים, ומשימות רקע פעילות. מתעדכן כל 10 + שניות. +

    +
    + +
    + + {error ? ( + + + {error.message} + + + ) : ( + <> + {/* DB status + table counts */} +
    + + +
    + + מצב DB +
    + {isPending ? ( + + ) : data?.db_ok ? ( +
    + + מחובר +
    + ) : ( +
    + + מנותק +
    + )} +
    +
    + + + +
    + ספירת טבלאות +
    +
    + {Object.keys(TABLE_LABELS).map((key) => ( +
    +
    + {TABLE_LABELS[key]} +
    +
    + {isPending ? "—" : (data?.tables[key] ?? "—")} +
    +
    + ))} +
    +
    +
    +
    + + {/* Active tasks */} + + +

    + + משימות רקע פעילות + + {data?.active_tasks.length ?? 0} + +

    + {isPending ? ( + + ) : data?.active_tasks.length === 0 ? ( +

    אין משימות פעילות

    + ) : ( +
      + {data?.active_tasks.map((t) => ( +
    • + + {t.filename || t.task_id} + + + {t.step || t.status} + +
    • + ))} +
    + )} +
    +
    + + {/* Failed + stuck docs */} +
    + + +

    + + מסמכים שנכשלו + + {data?.failed_documents.length ?? 0} + +

    + {isPending ? ( + + ) : data?.failed_documents.length === 0 ? ( +

    אין כשלונות

    + ) : ( +
      + {data?.failed_documents.map((d) => ( + + ))} +
    + )} +
    +
    + + + +

    + + תקועים (> 10 דק׳) + + {data?.stuck_documents.length ?? 0} + +

    + {isPending ? ( + + ) : data?.stuck_documents.length === 0 ? ( +

    אין מסמכים תקועים

    + ) : ( +
      + {data?.stuck_documents.map((d) => ( + + ))} +
    + )} +
    +
    +
    + + )} +
    +
    + ); +} diff --git a/web-ui/src/app/skills/page.tsx b/web-ui/src/app/skills/page.tsx new file mode 100644 index 0000000..34b0b89 --- /dev/null +++ b/web-ui/src/app/skills/page.tsx @@ -0,0 +1,128 @@ +"use client"; + +import Link from "next/link"; +import { Plug, HardDrive, Database, FileText } from "lucide-react"; +import { AppShell } from "@/components/app-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useSkills, type Skill } from "@/lib/api/skills"; + +function formatSize(bytes: number | null) { + if (bytes == null) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function statusBadge(s: Skill) { + if (s.not_in_db) { + return לא סונכרן; + } + if (s.db_markdown_chars > 0 && s.disk_exists) { + return מסונכרן; + } + if (s.db_markdown_chars > 0) { + return DB בלבד; + } + return לא ידוע; +} + +function SkillCard({ skill }: { skill: Skill }) { + const fileCount = skill.file_inventory?.length ?? 0; + return ( + + +
    +
    + +
    +

    + {skill.name || skill.slug} +

    + + {skill.slug} + +
    +
    + {statusBadge(skill)} +
    +
    +
    + + {fileCount} + קבצים +
    +
    + + + {(skill.db_markdown_chars / 1000).toFixed(1)}K + + תווים +
    +
    + + + {formatSize(skill.disk_skill_md_bytes)} + +
    +
    + {skill.updated_at && ( +

    + עודכן: {new Date(skill.updated_at).toLocaleDateString("he-IL")} +

    + )} +
    +
    + ); +} + +export default function SkillsPage() { + const { data, isPending, error } = useSkills(); + + return ( + +
    +
    + +

    מיומנויות Paperclip

    +

    + רשימת ה-skills המותקנים במערכת Paperclip ומצב הסנכרון שלהם בין ה-DB + לדיסק. +

    +
    + +
    + + {error ? ( + + + {error.message} + + + ) : isPending ? ( +
    + {[...Array(6)].map((_, i) => ( + + ))} +
    + ) : data?.length === 0 ? ( + + +
    + אין skills מותקנים +
    +
    + ) : ( +
    + {data?.map((s) => )} +
    + )} +
    +
    + ); +} diff --git a/web-ui/src/app/training/page.tsx b/web-ui/src/app/training/page.tsx new file mode 100644 index 0000000..b2efa52 --- /dev/null +++ b/web-ui/src/app/training/page.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; +import { AppShell } from "@/components/app-shell"; +import { Card, CardContent } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { StyleReportPanel } from "@/components/training/style-report-panel"; +import { CorpusPanel } from "@/components/training/corpus-panel"; +import { ComparePanel } from "@/components/training/compare-panel"; + +export default function TrainingPage() { + return ( + +
    +
    + +

    הפורטרט הסגנוני של דפנה

    +

    + לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת, + ביטויי חתימה, וכלי השוואה בין שתי החלטות. +

    +
    + +
    + + + + + + פורטרט סגנון + קורפוס + השוואה + + + + + + + + + + + + + + + + +
    +
    + ); +} diff --git a/web-ui/src/components/training/compare-panel.tsx b/web-ui/src/components/training/compare-panel.tsx new file mode 100644 index 0000000..6d78d77 --- /dev/null +++ b/web-ui/src/components/training/compare-panel.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { useState } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from "@/components/ui/select"; +import { + useCorpus, useCompare, type CompareSide, type PatternEntry, +} from "@/lib/api/training"; + +/* + * Compare two decisions from the style corpus side-by-side. Uses the + * training/compare endpoint which already does the heavy lifting (pattern + * extraction, section stats, shared/unique pattern sets). Our job is + * layout: two columns of metadata + section bars, plus a third "shared" + * section listing patterns that appear in both. + */ + +function SideColumn({ side }: { side: CompareSide }) { + return ( + + +
    +

    + {side.decision_number || "—"} +

    +

    + {side.decision_date || "—"} · {(side.chars / 1000).toFixed(1)}K תווים +

    +
    + {side.subjects.length > 0 && ( +
    + {side.subjects.map((s) => ( + + {s} + + ))} +
    + )} + {side.sections.length > 0 && ( +
    +

    + חלוקה לחלקים +

    +
      + {side.sections.map((sec) => ( +
    • + {sec.type} + + {(sec.chars / 1000).toFixed(1)}K + +
    • + ))} +
    +
    + )} +

    + דפוסי סגנון שנמצאו: {side.patterns_count} +

    +
    +
    + ); +} + +function PatternList({ + title, + items, + tone, +}: { + title: string; + items: PatternEntry[]; + tone: "shared" | "a" | "b"; +}) { + const toneClass = + tone === "shared" + ? "bg-success-bg border-success/40" + : tone === "a" + ? "bg-info-bg border-info/40" + : "bg-gold-wash border-gold/40"; + const toneHeading = + tone === "shared" + ? "text-success" + : tone === "a" + ? "text-info" + : "text-gold-deep"; + + return ( + + +

    + {title} + {items.length} +

    + {items.length === 0 ? ( +

    + ) : ( +
      + {items.slice(0, 20).map((p) => ( +
    • + {p.text} +
    • + ))} +
    + )} +
    +
    + ); +} + +export function ComparePanel() { + const { data: corpus, isPending } = useCorpus(); + const [a, setA] = useState(null); + const [b, setB] = useState(null); + const cmp = useCompare(a, b); + + return ( +
    + + +

    בחר שתי החלטות להשוואה

    +
    + {(["a", "b"] as const).map((slot) => ( +
    + + +
    + ))} +
    +
    +
    + + {a && b && a === b && ( +

    בחר שתי החלטות שונות

    + )} + + {cmp.error && ( + + + {cmp.error.message} + + + )} + + {cmp.isPending && a && b && a !== b && ( + + )} + + {cmp.data && ( + <> +
    + + +
    +
    + + + +
    + + )} +
    + ); +} diff --git a/web-ui/src/components/training/corpus-panel.tsx b/web-ui/src/components/training/corpus-panel.tsx new file mode 100644 index 0000000..3dbb3ec --- /dev/null +++ b/web-ui/src/components/training/corpus-panel.tsx @@ -0,0 +1,139 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from "@/components/ui/table"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useCorpus, useDeleteCorpusEntry, type CorpusDecision } from "@/lib/api/training"; + +/* + * Corpus tab: table of all decisions currently in the style corpus, with a + * single destructive action (remove from corpus). Uses browser confirm() for + * the confirmation — a full shadcn AlertDialog would be overkill for an + * admin-only destructive action with a server-side safety net. + */ + +function formatChars(n: number) { + return `${(n / 1000).toFixed(1)}K`; +} + +function formatDate(iso: string) { + if (!iso) return "—"; + try { + return new Date(iso).toLocaleDateString("he-IL"); + } catch { + return iso; + } +} + +function Row({ item }: { item: CorpusDecision }) { + const del = useDeleteCorpusEntry(); + const onDelete = async () => { + if (!window.confirm(`למחוק את החלטה ${item.decision_number} מהקורפוס?`)) return; + try { + await del.mutateAsync(item.id); + toast.success("נמחק מהקורפוס"); + } catch (e) { + toast.error(e instanceof Error ? e.message : "שגיאה במחיקה"); + } + }; + + return ( + + + {item.decision_number || "—"} + + + {formatDate(item.decision_date)} + + + {item.subject_categories.length === 0 ? ( + + ) : ( +
    + {item.subject_categories.map((s) => ( + + {s} + + ))} +
    + )} +
    + + {formatChars(item.chars)} + + + {formatDate(item.created_at)} + + + + +
    + ); +} + +export function CorpusPanel() { + const { data, isPending, error } = useCorpus(); + + if (error) { + return ( +
    + {error.message} +
    + ); + } + + return ( +
    + + + + מס׳ החלטה + תאריך + נושאים + תווים + נוסף בתאריך + + + + + {isPending ? ( + [...Array(4)].map((_, i) => ( + + {[...Array(6)].map((_, j) => ( + + + + ))} + + )) + ) : data?.length === 0 ? ( + + + הקורפוס ריק + + + ) : ( + data?.map((item) => ) + )} + +
    +
    + ); +} diff --git a/web-ui/src/components/training/style-report-panel.tsx b/web-ui/src/components/training/style-report-panel.tsx new file mode 100644 index 0000000..f165ab0 --- /dev/null +++ b/web-ui/src/components/training/style-report-panel.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SubjectDonut } from "@/components/training/subject-donut"; +import { useStyleReport } from "@/lib/api/training"; + +function KPICard({ + label, + value, + caption, +}: { + label: string; + value: string; + caption?: string; +}) { + return ( + + + + {label} + + + {value} + + {caption && ( + {caption} + )} + + + ); +} + +export function StyleReportPanel() { + const { data, isPending, error } = useStyleReport(); + + if (error) { + return ( + + + {error.message} + + + ); + } + + if (isPending || !data) { + return ( +
    + + +
    + ); + } + + const c = data.corpus; + const dateRange = + c.date_range[0] && c.date_range[1] + ? `${c.date_range[0]} – ${c.date_range[1]}` + : undefined; + const total = c.decision_count; + const totalSubjects = c.subject_distribution.reduce((a, b) => a + b.count, 0); + + return ( +
    + {/* Headline */} + + +

    + ★ {c.headline} +

    +
    +
    + + {/* KPIs */} +
    + + + + +
    + + {/* Subjects + anatomy */} +
    + + +

    פיזור נושאים

    + + {dateRange && ( +

    + טווח תאריכים: {dateRange} +

    + )} +
    +
    + + + +

    אנטומיה של החלטה ממוצעת

    + {data.anatomy.headline && ( +

    + {data.anatomy.headline} +

    + )} + {data.anatomy.sections.length === 0 ? ( +

    אין נתונים על מבנה

    + ) : ( +
      + {data.anatomy.sections.map((s) => { + const pct = Math.round(s.pct * 100); + return ( +
    • +
      + + {s.label} + + + {pct}% · {s.avg_chars.toLocaleString()} תווים + +
      +
      +
      +
      +
    • + ); + })} +
    + )} +
    +
    +
    + + {/* Signature phrases */} + + +

    ביטויי חתימה

    + {data.signature_phrases.headline && ( +

    + {data.signature_phrases.headline} +

    + )} + {data.signature_phrases.items.length === 0 ? ( +

    אין ביטויים שחולצו עדיין

    + ) : ( +
      + {data.signature_phrases.items.slice(0, 12).map((p, i) => ( +
    1. + + #{i + 1} + +
      +

      {p.text}

      + {p.context && ( +

      + {p.context} +

      + )} +
      + + ×{p.frequency} + +
    2. + ))} +
    + )} +
    +
    +
    + ); +} diff --git a/web-ui/src/components/training/subject-donut.tsx b/web-ui/src/components/training/subject-donut.tsx new file mode 100644 index 0000000..48f961c --- /dev/null +++ b/web-ui/src/components/training/subject-donut.tsx @@ -0,0 +1,72 @@ +"use client"; + +/* + * Corpus subject-distribution donut. + * + * Pure CSS conic-gradient — same recipe as the cases StatusDonut, but + * uses a palette-of-gold instead of a status-tone palette. Ported from + * legal-ai/web/static/index.html `renderHero`. + */ + +const DONUT_COLORS = [ + "var(--color-navy)", + "var(--color-gold)", + "var(--color-info)", + "var(--color-warn)", + "var(--color-success)", + "var(--color-ink-muted)", + "var(--color-gold-deep)", +]; + +export function SubjectDonut({ + segments, + total, +}: { + segments: Array<{ label: string; count: number }>; + total: number; +}) { + let pct = 0; + const parts = segments.map((s, i) => { + const start = total === 0 ? 0 : (pct / total) * 360; + pct += s.count; + const end = total === 0 ? 360 : (pct / total) * 360; + return { ...s, start, end, color: DONUT_COLORS[i % DONUT_COLORS.length] }; + }); + + const background = + total === 0 + ? "conic-gradient(var(--color-rule-soft) 0deg 360deg)" + : `conic-gradient(${parts + .map((p) => `${p.color} ${p.start}deg ${p.end}deg`) + .join(", ")})`; + + return ( +
    +
    +
    + + {total} + + החלטות +
    +
    + +
      + {parts.map((p) => ( +
    • + + {p.label} + {p.count} +
    • + ))} +
    +
    + ); +} diff --git a/web-ui/src/lib/api/skills.ts b/web-ui/src/lib/api/skills.ts new file mode 100644 index 0000000..e09cfbc --- /dev/null +++ b/web-ui/src/lib/api/skills.ts @@ -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("/api/admin/skills", { signal }), + staleTime: 30_000, + }); +} diff --git a/web-ui/src/lib/api/system.ts b/web-ui/src/lib/api/system.ts new file mode 100644 index 0000000..2ea9d29 --- /dev/null +++ b/web-ui/src/lib/api/system.ts @@ -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; + 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("/api/system/diagnostics", { signal }), + refetchInterval: 10_000, + staleTime: 5_000, + }); +} diff --git a/web-ui/src/lib/api/training.ts b/web-ui/src/lib/api/training.ts new file mode 100644 index 0000000..59191ea --- /dev/null +++ b/web-ui/src/lib/api/training.ts @@ -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("/api/training/style-report", { signal }), + staleTime: 60_000, + }); +} + +export function useCorpus() { + return useQuery({ + queryKey: trainingKeys.corpus(), + queryFn: ({ signal }) => + apiRequest("/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( + `/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() }); + }, + }); +}