Files
legal-ai/web-ui/src/components/training/corpus-panel.tsx
Chaim bb0cd7c6a2
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
Six-phase upgrade of /training from a read-only dashboard into a full
Style Studio for managing Daphna's style corpus.

- Upload Sheet on /training: file → proofread preview → commit (no more
  CLI-only `upload-training` skill).
- Rich corpus metadata: GET /api/training/corpus returns summary, outcome,
  key_principles, page_count, parties (regex), legal_citation, lessons_count.
  PATCH endpoint for chair edits. CorpusDetailDrawer with 4 tabs (details
  /content/lessons/patterns) replaces the bare table row.
- LLM metadata enrichment: style_metadata_extractor + MCP tools
  (style_corpus_enrich, style_corpus_pending_enrichment) fill summary
  /outcome/key_principles via claude_session (free, host-side).
- Per-decision lessons: new decision_lessons table + 4 REST endpoints +
  LessonsTab in drawer; hermes-curator now auto-posts findings as
  decision_lessons(source=curator).
- Curator Portrait tab: prompt rendered with link to Gitea, recent
  curator findings, style_analyzer training prompts, propose-change
  form that writes proposals to data/curator-proposals/ for manual
  chair review (no auto-mutation of the agent file).
- Style chat tab: SSE-streamed conversations with the style agent.
  New host-side pm2 service (legal-chat-service, port 8770) wraps
  claude CLI with stream-json + --resume continuation; FastAPI proxies
  via host.docker.internal. Zero API cost — uses chaim's claude.ai
  subscription. chat_conversations + chat_messages persist history.

Architecture: keeps the existing rule that claude_session only runs
on the host (not the container). The new legal-chat-service is the
canonical bridge between the container and the local CLI for the chat
feature; everything else (upload, metadata, lessons) stays within the
container's existing capabilities.

Audit script (scripts/audit_training_corpus.py) included for verifying
which corpus rows still need enrichment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:06:22 +00:00

186 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Trash2, Sparkles } 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";
import { CorpusDetailDrawer } from "./corpus-detail-drawer";
/*
* Corpus tab: table of all decisions currently in the style corpus.
*
* Click any row → opens CorpusDetailDrawer with the enriched metadata
* + edit UI. The trash button is now in its own narrow column and uses
* stopPropagation so deleting a row doesn't also open the drawer.
*
* We use browser confirm() for the destructive action rather than a
* full shadcn AlertDialog because this is a single admin operation
* gated by an API-level safety net (FK cascade is best-effort but
* style_corpus DELETE returns 404 on missing rows, so the worst case
* is a no-op).
*/
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, onOpen,
}: { item: CorpusDecision; onOpen: () => void }) {
const del = useDeleteCorpusEntry();
const onDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
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 (
<TableRow
className="border-rule hover:bg-gold-wash/30 cursor-pointer"
onClick={onOpen}
>
<TableCell className="font-semibold text-navy tabular-nums">
{item.decision_number || "—"}
</TableCell>
<TableCell className="text-ink-muted tabular-nums">
{formatDate(item.decision_date)}
</TableCell>
<TableCell>
{item.subject_categories.length === 0 ? (
<span className="text-ink-light"></span>
) : (
<div className="flex flex-wrap gap-1">
{item.subject_categories.slice(0, 3).map((s) => (
<Badge key={s} variant="outline"
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40">
{s}
</Badge>
))}
{item.subject_categories.length > 3 && (
<span className="text-[0.7rem] text-ink-muted">
+{item.subject_categories.length - 3}
</span>
)}
</div>
)}
</TableCell>
<TableCell className="text-[0.78rem] text-ink-soft">
<div className="flex items-center gap-2">
<span className="truncate">{item.legal_citation || "—"}</span>
{item.lessons_count > 0 && (
<Badge variant="outline"
className="text-[0.7rem] bg-info-bg text-info border-info/40 shrink-0">
<Sparkles className="w-3 h-3 me-0.5" />
{item.lessons_count}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-ink-soft tabular-nums">
{formatChars(item.chars)}
{item.page_count > 0 && (
<span className="text-ink-muted text-[0.72rem] ms-1">
· {item.page_count} ע׳
</span>
)}
</TableCell>
<TableCell className="text-ink-muted tabular-nums text-[0.78rem]">
{formatDate(item.created_at)}
</TableCell>
<TableCell className="text-end">
<Button
variant="ghost"
size="sm"
onClick={onDelete}
disabled={del.isPending}
aria-label={`הסר את ${item.decision_number || "החלטה זו"} מהקורפוס`}
className="text-danger hover:text-danger hover:bg-danger-bg"
>
<Trash2 className="w-4 h-4" aria-hidden="true" />
</Button>
</TableCell>
</TableRow>
);
}
export function CorpusPanel() {
const { data, isPending, error } = useCorpus();
const [selected, setSelected] = useState<CorpusDecision | null>(null);
if (error) {
return (
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
{error.message}
</div>
);
}
return (
<>
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
<Table>
<TableHeader className="bg-rule-soft/60">
<TableRow className="border-rule">
<TableHead className="text-navy text-right">מס׳ החלטה</TableHead>
<TableHead className="text-navy text-right">תאריך</TableHead>
<TableHead className="text-navy text-right">נושאים</TableHead>
<TableHead className="text-navy text-right">מראה מקום</TableHead>
<TableHead className="text-navy text-right">תווים / עמודים</TableHead>
<TableHead className="text-navy text-right">נוסף בתאריך</TableHead>
<TableHead className="text-navy" />
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
[...Array(4)].map((_, i) => (
<TableRow key={i} className="border-rule">
{[...Array(7)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-24" />
</TableCell>
))}
</TableRow>
))
) : data?.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center text-ink-muted py-12">
הקורפוס ריק
</TableCell>
</TableRow>
) : (
data?.map((item) => (
<Row key={item.id} item={item} onOpen={() => setSelected(item)} />
))
)}
</TableBody>
</Table>
</div>
<CorpusDetailDrawer
decision={selected}
onOpenChange={(open) => { if (!open) setSelected(null); }}
/>
</>
);
}