feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s

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>
This commit is contained in:
2026-05-27 10:06:22 +00:00
parent 0629f19d5f
commit bb0cd7c6a2
23 changed files with 4568 additions and 75 deletions

View File

@@ -0,0 +1,338 @@
"use client";
/*
* Curator-Portrait tab — shows everything about the agent that learns
* Daphna's style:
* 1. Snapshot stats (curator findings to date, % applied)
* 2. Recent curator findings (last 10) — linked by decision number
* 3. The hermes-curator system prompt, rendered + linked to Gitea
* 4. The style_analyzer training prompts (different lifecycle — runs
* over the corpus at training time, not per-decision)
* 5. Propose-change form — writes a markdown file to disk for chair
* review (no auto-commit)
*
* The prompts are deliberately read-only here: they're symlinked into
* Paperclip and load-bearing for every curator wake. Editing them from
* the UI would silently fork the source of truth.
*/
import { useState } from "react";
import {
Sparkles, ExternalLink, Send, Loader2, FileText, Brain,
CheckCircle2, Clock,
} from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Markdown } from "@/components/ui/markdown";
import {
useCuratorPrompt,
useCuratorStats,
useStyleAnalyzerPrompts,
useSubmitCuratorProposal,
} from "@/lib/api/training";
export function CuratorPortraitPanel() {
return (
<div className="space-y-6">
<StatsCard />
<RecentFindings />
<Tabs defaultValue="curator-prompt" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="curator-prompt">פרומפט ה-Curator</TabsTrigger>
<TabsTrigger value="analyzer-prompt">פרומפט אימון הסגנון</TabsTrigger>
<TabsTrigger value="propose">הצעת שינוי</TabsTrigger>
</TabsList>
<TabsContent value="curator-prompt" className="mt-4">
<CuratorPromptCard />
</TabsContent>
<TabsContent value="analyzer-prompt" className="mt-4">
<StyleAnalyzerPromptCard />
</TabsContent>
<TabsContent value="propose" className="mt-4">
<ProposeChangeForm />
</TabsContent>
</Tabs>
</div>
);
}
// ── stats card ─────────────────────────────────────────────────────
function StatsCard() {
const { data, isPending } = useCuratorStats();
if (isPending) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[...Array(4)].map((_, i) => <Skeleton key={i} className="h-20 w-full" />)}
</div>
);
}
if (!data) return null;
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<Kpi label="ממצאי curator" value={data.total_findings} icon={<Sparkles className="w-4 h-4" />} />
<Kpi label="החלטות שנסקרו" value={`${data.decisions_with_findings}/${data.decisions_total}`} icon={<FileText className="w-4 h-4" />} />
<Kpi label="ממצאים שאומצו ל-SKILL" value={data.findings_applied} icon={<CheckCircle2 className="w-4 h-4" />} />
<Kpi label="ממוצע ממצאים להחלטה"
value={
data.decisions_with_findings > 0
? (data.total_findings / data.decisions_with_findings).toFixed(1)
: "—"
}
icon={<Brain className="w-4 h-4" />}
/>
</div>
);
}
function Kpi({
label, value, icon,
}: { label: string; value: string | number; icon: React.ReactNode }) {
return (
<Card className="bg-surface border-rule">
<CardContent className="px-4 py-3">
<div className="flex items-center gap-2 text-ink-muted text-[0.78rem]">
{icon}
<span>{label}</span>
</div>
<p className="text-2xl text-navy font-semibold tabular-nums mt-1">{value}</p>
</CardContent>
</Card>
);
}
// ── recent findings ────────────────────────────────────────────────
function RecentFindings() {
const { data, isPending } = useCuratorStats();
if (isPending) {
return <Skeleton className="h-40 w-full" />;
}
if (!data || data.recent_findings.length === 0) {
return (
<Card className="bg-rule-soft/40 border-rule">
<CardContent className="px-6 py-5 text-center text-ink-muted text-sm">
אין עדיין ממצאים של ה-Curator. הוא מופעל אוטומטית כאשר דפנה מסמנת
החלטה כסופית (mark-final), ושומר את ממצאיו כ-decision_lessons עם
source=&quot;curator&quot;.
</CardContent>
</Card>
);
}
return (
<Card className="bg-surface border-rule">
<CardContent className="px-4 py-3">
<h3 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold mb-3">
ממצאים אחרונים של ה-Curator
</h3>
<ul className="space-y-2">
{data.recent_findings.map((f) => (
<li key={f.id} className="border-b border-rule pb-2 last:border-0 last:pb-0">
<div className="flex items-center gap-2 text-[0.72rem] mb-1">
<Badge variant="outline"
className="bg-info-bg text-info border-info/40">
{f.category}
</Badge>
<span className="text-navy font-semibold tabular-nums">
{f.decision_number || "—"}
</span>
{f.applied_to_skill && (
<Badge variant="outline"
className="bg-success-bg text-success border-success/40">
<CheckCircle2 className="w-3 h-3 me-0.5" />
אומץ
</Badge>
)}
<span className="grow text-ink-muted text-end">
<Clock className="w-3 h-3 inline me-1" />
{new Date(f.created_at).toLocaleDateString("he-IL")}
</span>
</div>
<p className="text-sm text-ink leading-relaxed">{f.lesson_text}</p>
</li>
))}
</ul>
</CardContent>
</Card>
);
}
// ── prompts ────────────────────────────────────────────────────────
function CuratorPromptCard() {
const { data, isPending, error } = useCuratorPrompt();
if (isPending) return <Skeleton className="h-96 w-full" />;
if (error) {
return (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-4 text-danger">{error.message}</CardContent>
</Card>
);
}
if (!data) return null;
return (
<Card className="bg-surface border-rule">
<CardContent className="px-5 py-4 space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h3 className="text-navy font-semibold">{data.filename}</h3>
<p className="text-[0.72rem] text-ink-muted">
{data.bytes.toLocaleString("he-IL")} בייטים ·
עודכן: {new Date(data.last_modified * 1000).toLocaleString("he-IL")}
</p>
</div>
<Button asChild variant="outline" size="sm">
<a href={data.gitea_url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="w-3 h-3 me-1" />
ערוך ב-Gitea
</a>
</Button>
</div>
<ScrollArea className="h-[520px] pe-2 border border-rule rounded p-3 bg-rule-soft/30">
<Markdown content={data.content} />
</ScrollArea>
</CardContent>
</Card>
);
}
function StyleAnalyzerPromptCard() {
const { data, isPending } = useStyleAnalyzerPrompts();
if (isPending) return <Skeleton className="h-96 w-full" />;
if (!data) return null;
return (
<Card className="bg-surface border-rule">
<CardContent className="px-5 py-4 space-y-3">
<div>
<h3 className="text-navy font-semibold">פרומפטים של style_analyzer.py</h3>
<p className="text-[0.72rem] text-ink-muted">
רץ ב-Claude Opus (1M context, עד {data.max_input_tokens.toLocaleString("he-IL")} tokens
input) דרך claude CLI מקומי חינמי, ללא API. נקרא ע&quot;י
<code className="px-1 mx-1 bg-rule-soft rounded">POST /api/training/analyze-style</code>
ומכניס דפוסים ל-<code className="px-1 bg-rule-soft rounded">style_patterns</code>.
</p>
</div>
<Tabs defaultValue="analysis" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="analysis">Single-pass (כל הקורפוס)</TabsTrigger>
<TabsTrigger value="single">Multi-pass (החלטה אחת)</TabsTrigger>
<TabsTrigger value="synthesis">Synthesis</TabsTrigger>
</TabsList>
<TabsContent value="analysis" className="mt-3">
<PromptBlock content={data.analysis_prompt} />
</TabsContent>
<TabsContent value="single" className="mt-3">
<PromptBlock content={data.single_decision_prompt} />
</TabsContent>
<TabsContent value="synthesis" className="mt-3">
<PromptBlock content={data.synthesis_prompt} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
function PromptBlock({ content }: { content: string }) {
return (
<ScrollArea className="h-[420px] pe-2 border border-rule rounded p-3 bg-rule-soft/30">
<pre className="text-[0.78rem] whitespace-pre-wrap font-mono text-ink leading-relaxed"
dir="rtl">
{content}
</pre>
</ScrollArea>
);
}
// ── propose change form ────────────────────────────────────────────
function ProposeChangeForm() {
const [title, setTitle] = useState("");
const [proposedChange, setProposedChange] = useState("");
const [rationale, setRationale] = useState("");
const submit = useSubmitCuratorProposal();
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !proposedChange.trim()) {
toast.error("חובה כותרת ושינוי מוצע");
return;
}
try {
const r = await submit.mutateAsync({
title: title.trim(),
proposed_change: proposedChange.trim(),
rationale: rationale.trim(),
});
toast.success(`נשמרה הצעה: ${r.filename}`);
setTitle(""); setProposedChange(""); setRationale("");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל בשמירה");
}
};
return (
<Card className="bg-surface border-rule">
<CardContent className="px-5 py-4">
<h3 className="text-navy font-semibold mb-2">הצעת שינוי לפרומפט ה-Curator</h3>
<p className="text-[0.78rem] text-ink-muted mb-4">
ההצעה תישמר כקובץ Markdown ב-
<code className="px-1 bg-rule-soft rounded">data/curator-proposals/</code>.
חיים יבחן ויאשר ידנית אין שינוי אוטומטי בפרומפט.
</p>
<form onSubmit={onSubmit} className="space-y-3">
<div className="space-y-1">
<Label htmlFor="proposal-title">כותרת השינוי</Label>
<Input id="proposal-title" value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="לדוגמה: הוסף קטגוריה [צ׳קליסט תוכן] לממצאי ה-curator"
dir="rtl" />
</div>
<div className="space-y-1">
<Label htmlFor="proposal-change">השינוי המוצע (Markdown)</Label>
<Textarea id="proposal-change" value={proposedChange} rows={6}
onChange={(e) => setProposedChange(e.target.value)}
placeholder={"תאר במדויק מה לשנות. אפשר להעתיק את הקטע הקיים ולסמן ב-strikethrough + להוסיף את החדש."}
dir="rtl" />
</div>
<div className="space-y-1">
<Label htmlFor="proposal-rationale">נימוק</Label>
<Textarea id="proposal-rationale" value={rationale} rows={3}
onChange={(e) => setRationale(e.target.value)}
placeholder="למה השינוי הזה חשוב? איזה בעיה הוא פותר?"
dir="rtl" />
</div>
<div className="flex justify-end">
<Button type="submit" disabled={submit.isPending}
className="bg-navy text-parchment hover:bg-navy-soft">
{submit.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1" />
) : (
<Send className="w-4 h-4 me-1" />
)}
שלח הצעה
</Button>
</div>
</form>
</CardContent>
</Card>
);
}