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:
267
web-ui/src/components/training/lessons-tab.tsx
Normal file
267
web-ui/src/components/training/lessons-tab.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
"use client";
|
||||
|
||||
/*
|
||||
* Per-decision lessons editor — lives inside CorpusDetailDrawer's
|
||||
* "מה למדנו" tab. Lessons are persisted in the decision_lessons table
|
||||
* (one-to-many on style_corpus) and consumed by hermes-curator and
|
||||
* future style_analyzer runs as context.
|
||||
*
|
||||
* The chair can:
|
||||
* - Add a lesson typed manually (category = "general" by default)
|
||||
* - Edit / delete existing lessons
|
||||
* - Mark a lesson as "applied_to_skill" (informational — doesn't
|
||||
* auto-commit anything to SKILL.md; chair still curates that file
|
||||
* manually in git).
|
||||
*
|
||||
* Lessons from the curator arrive with source="curator" and are visually
|
||||
* distinguished by a badge so the chair can audit auto-suggestions.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, Save, Trash2, Loader2, CheckCircle2, Sparkles } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
useAddLesson,
|
||||
useCorpusLessons,
|
||||
useDeleteLesson,
|
||||
usePatchLesson,
|
||||
type DecisionLesson,
|
||||
} from "@/lib/api/training";
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "general", label: "כללי" },
|
||||
{ value: "style", label: "סגנון" },
|
||||
{ value: "structure", label: "מבנה" },
|
||||
{ value: "lexicon", label: "לקסיקון" },
|
||||
{ value: "tabular", label: "טבלאי" },
|
||||
] as const;
|
||||
|
||||
const SOURCE_BADGE: Record<DecisionLesson["source"], { label: string; cls: string }> = {
|
||||
manual: { label: "ידני", cls: "bg-rule-soft text-ink-soft" },
|
||||
chair: { label: "יו״ר", cls: "bg-gold-wash text-gold-deep" },
|
||||
curator: { label: "Curator", cls: "bg-info-bg text-info" },
|
||||
style_analyzer: { label: "Analyzer", cls: "bg-success-bg text-success" },
|
||||
};
|
||||
|
||||
export function LessonsTab({ corpusId }: { corpusId: string }) {
|
||||
const { data, isPending } = useCorpusLessons(corpusId);
|
||||
const add = useAddLesson(corpusId);
|
||||
const [draftText, setDraftText] = useState("");
|
||||
const [draftCategory, setDraftCategory] = useState<DecisionLesson["category"]>("general");
|
||||
|
||||
const onAdd = async () => {
|
||||
const text = draftText.trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
await add.mutateAsync({ lesson_text: text, category: draftCategory });
|
||||
setDraftText("");
|
||||
setDraftCategory("general");
|
||||
toast.success("הלקח נוסף");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "כשל בשמירה");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Composer */}
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-4 py-3 space-y-2">
|
||||
<h4 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold">
|
||||
הוסף לקח להחלטה
|
||||
</h4>
|
||||
<Textarea
|
||||
value={draftText}
|
||||
onChange={(e) => setDraftText(e.target.value)}
|
||||
placeholder="מה למדנו מההחלטה הזו? למשל: 'דפנה מעדיפה הוצאות מתונות (5K-10K ₪) גם בערר שהתקבל במלואו'"
|
||||
rows={3}
|
||||
dir="rtl"
|
||||
disabled={add.isPending}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={draftCategory}
|
||||
onValueChange={(v) => setDraftCategory(v as DecisionLesson["category"])}
|
||||
disabled={add.isPending}
|
||||
dir="rtl"
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grow" />
|
||||
<Button onClick={onAdd} disabled={add.isPending || !draftText.trim()}
|
||||
className="bg-navy text-parchment hover:bg-navy-soft">
|
||||
{add.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin me-1" />
|
||||
) : (
|
||||
<Plus className="w-4 h-4 me-1" />
|
||||
)}
|
||||
שמור לקח
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* List */}
|
||||
{isPending ? (
|
||||
<div className="space-y-2">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !data || data.length === 0 ? (
|
||||
<p className="text-center text-ink-muted text-sm py-6">
|
||||
אין עדיין לקחים להחלטה זו. הוסף לקח ראשון מלמעלה.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{data.map((lesson) => (
|
||||
<LessonItem key={lesson.id} lesson={lesson} corpusId={corpusId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LessonItem({
|
||||
lesson, corpusId,
|
||||
}: { lesson: DecisionLesson; corpusId: string }) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [text, setText] = useState(lesson.lesson_text);
|
||||
const [category, setCategory] = useState<DecisionLesson["category"]>(lesson.category);
|
||||
const patch = usePatchLesson(corpusId);
|
||||
const del = useDeleteLesson(corpusId);
|
||||
|
||||
const sourceBadge = SOURCE_BADGE[lesson.source];
|
||||
const dirty = text !== lesson.lesson_text || category !== lesson.category;
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
await patch.mutateAsync({
|
||||
id: lesson.id,
|
||||
patch: dirty ? { lesson_text: text, category } : {},
|
||||
});
|
||||
setEditing(false);
|
||||
toast.success("הלקח עודכן");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "כשל בעדכון");
|
||||
}
|
||||
};
|
||||
|
||||
const onToggleApplied = async () => {
|
||||
try {
|
||||
await patch.mutateAsync({
|
||||
id: lesson.id,
|
||||
patch: { applied_to_skill: !lesson.applied_to_skill },
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "כשל בעדכון");
|
||||
}
|
||||
};
|
||||
|
||||
const onDelete = async () => {
|
||||
if (!window.confirm("למחוק את הלקח?")) return;
|
||||
try {
|
||||
await del.mutateAsync(lesson.id);
|
||||
toast.success("נמחק");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "כשל במחיקה");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-surface border-rule">
|
||||
<CardContent className="px-4 py-3 space-y-2">
|
||||
<div className="flex items-center gap-2 text-[0.72rem]">
|
||||
<Badge variant="outline"
|
||||
className="bg-rule-soft text-ink-soft">
|
||||
{CATEGORIES.find((c) => c.value === lesson.category)?.label || lesson.category}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={sourceBadge.cls}>
|
||||
{sourceBadge.label}
|
||||
</Badge>
|
||||
{lesson.applied_to_skill && (
|
||||
<Badge variant="outline"
|
||||
className="bg-success-bg text-success border-success/40">
|
||||
<CheckCircle2 className="w-3 h-3 me-1" />
|
||||
אומץ
|
||||
</Badge>
|
||||
)}
|
||||
<span className="grow text-ink-muted tabular-nums">
|
||||
{new Date(lesson.created_at).toLocaleDateString("he-IL")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<>
|
||||
<Textarea value={text} onChange={(e) => setText(e.target.value)}
|
||||
rows={3} dir="rtl" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={category}
|
||||
onValueChange={(v) => setCategory(v as DecisionLesson["category"])}
|
||||
dir="rtl">
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORIES.map((c) => (
|
||||
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="grow" />
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => { setEditing(false); setText(lesson.lesson_text); setCategory(lesson.category); }}>
|
||||
ביטול
|
||||
</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={patch.isPending}
|
||||
className="bg-navy text-parchment hover:bg-navy-soft">
|
||||
<Save className="w-3 h-3 me-1" />
|
||||
שמור
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-ink leading-relaxed whitespace-pre-wrap"
|
||||
onClick={() => setEditing(true)}
|
||||
style={{ cursor: "text" }}>
|
||||
{lesson.lesson_text}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onToggleApplied}
|
||||
disabled={patch.isPending}>
|
||||
<Sparkles className="w-3 h-3 me-1" />
|
||||
{lesson.applied_to_skill ? "בטל סימון 'אומץ'" : "סמן כ'אומץ ל-SKILL'"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
|
||||
ערוך
|
||||
</Button>
|
||||
<div className="grow" />
|
||||
<Button variant="ghost" size="sm" onClick={onDelete}
|
||||
disabled={del.isPending}
|
||||
className="text-danger hover:text-danger hover:bg-danger-bg">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user