feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
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:
338
web-ui/src/components/training/curator-portrait-panel.tsx
Normal file
338
web-ui/src/components/training/curator-portrait-panel.tsx
Normal 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="curator".
|
||||
</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. נקרא ע"י
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user