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,434 @@
"use client";
/*
* Style-agent chat panel — the new "שיחה" tab on /training.
*
* Layout: two columns.
* - Sidebar: list of conversations + "+ שיחה חדשה" button
* - Main: thread of messages + composer with SSE streaming
*
* Each message is persisted to the legal-ai DB; the LLM call goes
* out via FastAPI → host's legal-chat-service → claude CLI. There
* is no API cost — the claude CLI uses Daphna's claude.ai
* subscription via the host's auth.
*
* Health gate: if /api/training/chat/health reports the host service
* is unreachable, the composer is replaced by a setup notice telling
* the chair to start the pm2 service.
*/
import { useEffect, useRef, useState } from "react";
import {
Send, Plus, Trash2, Loader2, MessageSquare, Sparkles, AlertTriangle,
} from "lucide-react";
import { toast } from "sonner";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
chatKeys,
useChatConversation,
useChatConversations,
useChatHealth,
useCorpus,
useCreateChat,
useDeleteChat,
type ChatMessage,
} from "@/lib/api/training";
import { useQueryClient } from "@tanstack/react-query";
export function ChatPanel() {
const [activeId, setActiveId] = useState<string | null>(null);
const health = useChatHealth();
return (
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<ConversationsSidebar activeId={activeId} onSelect={setActiveId} />
<div className="space-y-3">
{health.data && !health.data.reachable && (
<ChatServiceWarning health={health.data} />
)}
{activeId ? (
<ChatThread convId={activeId} />
) : (
<Card className="bg-rule-soft/40 border-rule">
<CardContent className="px-6 py-10 text-center text-ink-muted text-sm space-y-2">
<MessageSquare className="w-8 h-8 mx-auto opacity-50" />
<p>בחר שיחה קיימת או פתח חדשה כדי להתחיל לדבר עם סוכן הסגנון.</p>
<p className="text-[0.78rem]">
הסוכן רץ על claude CLI מקומי דרך legal-chat-service. אין עלות API.
</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}
// ── Sidebar: list + new ────────────────────────────────────────────
function ConversationsSidebar({
activeId, onSelect,
}: {
activeId: string | null;
onSelect: (id: string | null) => void;
}) {
const { data: convs, isPending } = useChatConversations();
const { data: corpus } = useCorpus();
const create = useCreateChat();
const del = useDeleteChat();
const [creating, setCreating] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newCorpusId, setNewCorpusId] = useState<string>("__none__");
const onCreate = async () => {
try {
const conv = await create.mutateAsync({
title: newTitle.trim() || "שיחה חדשה",
style_corpus_id: newCorpusId === "__none__" ? null : newCorpusId,
});
onSelect(conv.id);
setCreating(false);
setNewTitle("");
setNewCorpusId("__none__");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל ביצירת שיחה");
}
};
const onDelete = async (id: string) => {
if (!window.confirm("למחוק את השיחה? פעולה זו לא ניתנת לביטול.")) return;
try {
await del.mutateAsync(id);
if (activeId === id) onSelect(null);
toast.success("השיחה נמחקה");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל במחיקה");
}
};
return (
<Card className="bg-surface border-rule">
<CardContent className="px-3 py-3 space-y-2">
{!creating ? (
<Button
onClick={() => setCreating(true)}
className="w-full bg-navy text-parchment hover:bg-navy-soft"
size="sm"
>
<Plus className="w-4 h-4 me-1" />
שיחה חדשה
</Button>
) : (
<div className="space-y-2 border border-rule rounded p-2 bg-rule-soft/30">
<Textarea
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="כותרת לשיחה (אופציונלי)"
rows={2} dir="rtl"
/>
<Select value={newCorpusId} onValueChange={setNewCorpusId} dir="rtl">
<SelectTrigger>
<SelectValue placeholder="צמד להחלטה (אופציונלי)" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
<SelectItem value="__none__"> שיחה כללית </SelectItem>
{corpus?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.decision_number || "—"}
{c.decision_date ? ` · ${c.decision_date}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="sm"
onClick={() => { setCreating(false); setNewTitle(""); setNewCorpusId("__none__"); }}>
ביטול
</Button>
<Button size="sm" onClick={onCreate} disabled={create.isPending}
className="bg-navy text-parchment hover:bg-navy-soft">
צור
</Button>
</div>
</div>
)}
<ScrollArea className="h-[520px]">
<ul className="space-y-1">
{isPending && (
<>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</>
)}
{convs?.length === 0 && (
<p className="text-center text-ink-muted text-[0.78rem] py-6">
אין עדיין שיחות
</p>
)}
{convs?.map((c) => {
const active = c.id === activeId;
return (
<li key={c.id}>
<button
onClick={() => onSelect(c.id)}
className={
"w-full text-end rounded-md px-2 py-2 transition " +
(active
? "bg-gold-wash border border-gold/40"
: "hover:bg-rule-soft/60 border border-transparent")
}
>
<div className="text-sm text-navy font-semibold truncate">
{c.title}
</div>
<div className="flex items-center gap-1 text-[0.7rem] text-ink-muted">
{c.decision_number && (
<Badge variant="outline"
className="text-[0.65rem] bg-info-bg text-info border-info/40">
{c.decision_number}
</Badge>
)}
<span className="tabular-nums">{c.message_count}</span>
<MessageSquare className="w-3 h-3" />
<span className="grow text-end">
{new Date(c.last_message_at).toLocaleDateString("he-IL")}
</span>
<button
onClick={(e) => { e.stopPropagation(); onDelete(c.id); }}
className="hover:text-danger"
aria-label="מחק שיחה"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</button>
</li>
);
})}
</ul>
</ScrollArea>
</CardContent>
</Card>
);
}
// ── Thread + composer ──────────────────────────────────────────────
function ChatThread({ convId }: { convId: string }) {
const { data, isPending } = useChatConversation(convId);
const qc = useQueryClient();
const [draft, setDraft] = useState("");
const [streaming, setStreaming] = useState(false);
const [streamingText, setStreamingText] = useState("");
const [streamError, setStreamError] = useState("");
const scrollRef = useRef<HTMLDivElement | null>(null);
/* Auto-scroll to bottom when new messages arrive. */
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
}, [data?.messages.length, streamingText]);
const onSend = async () => {
const text = draft.trim();
if (!text || streaming) return;
setDraft("");
setStreaming(true);
setStreamingText("");
setStreamError("");
try {
const res = await fetch(
`/api/training/chat/conversations/${encodeURIComponent(convId)}/messages`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: text }),
},
);
if (!res.ok || !res.body) {
const body = await res.text();
throw new Error(`HTTP ${res.status}: ${body.slice(0, 200)}`);
}
// Parse SSE line-by-line. EventSource would be cleaner but it
// doesn't support POST bodies; the manual reader is small.
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let accumulated = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let nl: number;
while ((nl = buffer.indexOf("\n\n")) !== -1) {
const event = buffer.slice(0, nl);
buffer = buffer.slice(nl + 2);
if (!event.startsWith("data: ")) continue;
try {
const payload = JSON.parse(event.slice("data: ".length));
if (payload.type === "text_delta" && payload.text) {
accumulated += payload.text;
setStreamingText(accumulated);
} else if (payload.type === "error") {
setStreamError(String(payload.message || "שגיאה לא ידועה"));
} else if (payload.type === "done") {
if (payload.text && !accumulated) {
accumulated = payload.text;
setStreamingText(accumulated);
}
}
} catch {
/* ignore non-JSON */
}
}
}
} catch (e) {
setStreamError(e instanceof Error ? e.message : "שגיאה בשיחה");
} finally {
setStreaming(false);
setStreamingText("");
// Refetch the conversation so the persisted assistant turn shows up.
qc.invalidateQueries({ queryKey: chatKeys.conversation(convId) });
qc.invalidateQueries({ queryKey: chatKeys.conversations() });
}
};
if (isPending) return <Skeleton className="h-[560px] w-full" />;
if (!data) return null;
return (
<Card className="bg-surface border-rule">
<CardContent className="px-4 py-3 space-y-3">
<header className="flex items-center gap-2 border-b border-rule pb-2">
<Sparkles className="w-4 h-4 text-gold-deep" />
<h3 className="text-navy font-semibold grow">{data.conversation.title}</h3>
{data.conversation.decision_number && (
<Badge variant="outline" className="bg-info-bg text-info border-info/40">
{data.conversation.decision_number}
</Badge>
)}
</header>
<div ref={scrollRef} className="h-[440px] overflow-y-auto space-y-3 pe-1">
{data.messages.length === 0 && !streaming && (
<p className="text-center text-ink-muted text-sm py-8">
התחל בשאלה למשל: &quot;מה מאפיין את הפתיחות של דפנה בעררי 1xxx?&quot;
</p>
)}
{data.messages.map((m) => <MessageBubble key={m.id} message={m} />)}
{streaming && (
<MessageBubble
message={{
id: "streaming",
role: "assistant",
content: streamingText || "(מקליד…)",
created_at: "",
}}
isStreaming
/>
)}
{streamError && (
<div className="rounded-lg border border-danger/40 bg-danger-bg p-3 text-danger text-sm">
{streamError}
</div>
)}
</div>
<div className="border-t border-rule pt-3 space-y-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="שאל את הסוכן… (Shift+Enter לשורה חדשה)"
rows={3} dir="rtl"
disabled={streaming}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void onSend();
}
}}
/>
<div className="flex items-center gap-2">
<p className="text-[0.72rem] text-ink-muted grow">
{data.conversation.claude_session_id
? "שיחה ממשיכה (--resume) — אין צורך לטעון מחדש את ה-system prompt"
: "שיחה חדשה — system prompt ייטען (שני מסמכי ייחוס + רשימת קורפוס)"}
</p>
<Button onClick={onSend} disabled={streaming || !draft.trim()}
className="bg-navy text-parchment hover:bg-navy-soft">
{streaming ? (
<Loader2 className="w-4 h-4 animate-spin me-1" />
) : (
<Send className="w-4 h-4 me-1" />
)}
שלח
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
function MessageBubble({
message, isStreaming = false,
}: { message: ChatMessage; isStreaming?: boolean }) {
const isUser = message.role === "user";
return (
<div className={isUser ? "flex justify-start" : "flex justify-end"}>
<div
className={
"max-w-[85%] rounded-lg px-3 py-2 text-sm leading-relaxed whitespace-pre-wrap " +
(isUser
? "bg-gold-wash text-ink border border-gold/40"
: "bg-rule-soft text-ink border border-rule")
}
dir="rtl"
>
{message.content}
{isStreaming && (
<span className="inline-block w-1.5 h-3.5 bg-navy/60 align-middle ms-1 animate-pulse" />
)}
</div>
</div>
);
}
// ── Service-down warning ──────────────────────────────────────────
function ChatServiceWarning({
health,
}: { health: { reachable: boolean; url: string; error?: string } }) {
return (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-4 py-3 space-y-1">
<div className="flex items-center gap-2 text-danger">
<AlertTriangle className="w-4 h-4" />
<strong>שירות הצ&apos;אט אינו זמין</strong>
</div>
<p className="text-[0.78rem] text-danger">
לא ניתן להגיע ל-legal-chat-service בכתובת
<code className="px-1 mx-1 bg-rule-soft rounded">{health.url}</code>.
{health.error && (<> פירוט: <code className="px-1 bg-rule-soft rounded">{health.error}</code></>)}
</p>
<p className="text-[0.72rem] text-ink-muted">
על המכונה המקומית הפעל:&nbsp;
<code className="px-1 bg-rule-soft rounded">
pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs
</code>
</p>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,402 @@
"use client";
/*
* Side-drawer for inspecting + editing a single style_corpus entry.
*
* Tabs:
* - "פרטים" — show + edit the enriched metadata (decision_number, date,
* subjects, summary, outcome, key_principles, appeal_subtype). Saving
* issues a PATCH /api/training/corpus/{id} and invalidates the list.
* - "תוכן" — read-only full_text view (truncated to 5K with "show more").
* We never let the chair edit full_text from the UI; corrections happen
* by re-uploading via the Upload dialog.
* - "מה למדנו" — per-decision lessons (Phase 4 placeholder for now).
* - "דפוסים" — style_patterns scoped by appeal_subtype.
*
* Why a Sheet, not a Dialog: the drawer needs to coexist with the corpus
* table so the chair can scan multiple decisions without losing context.
* Sheet (side: "left" in RTL = right edge in LTR) gives that without
* stealing the entire viewport.
*/
import { useEffect, useState } from "react";
import { Save, FileText, Tag, Calendar, BookOpen, Loader2 } from "lucide-react";
import { toast } from "sonner";
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
} from "@/components/ui/sheet";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
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 { ScrollArea } from "@/components/ui/scroll-area";
import {
usePatchCorpus,
type CorpusDecision,
type CorpusDecisionPatch,
} from "@/lib/api/training";
import { LessonsTab } from "./lessons-tab";
type Props = {
decision: CorpusDecision | null;
onOpenChange: (open: boolean) => void;
};
export function CorpusDetailDrawer({ decision, onOpenChange }: Props) {
// Local editable state for the "details" tab. Re-seeds whenever the
// selected decision changes so the form reflects the row the chair
// clicked.
const [draft, setDraft] = useState<CorpusDecisionPatch>({});
const patch = usePatchCorpus();
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
if (!decision) {
setDraft({});
return;
}
setDraft({
decision_number: decision.decision_number,
decision_date: decision.decision_date,
subject_categories: decision.subject_categories,
summary: decision.summary,
outcome: decision.outcome,
key_principles: decision.key_principles,
appeal_subtype: decision.appeal_subtype,
practice_area: decision.practice_area,
});
}, [decision]);
/* eslint-enable react-hooks/set-state-in-effect */
const open = decision !== null;
if (!decision) return null;
// Diff against the originally loaded row — only PATCH fields the chair
// actually changed, so concurrent edits to other fields stay intact.
const diff: CorpusDecisionPatch = {};
if (draft.decision_number !== decision.decision_number)
diff.decision_number = draft.decision_number;
if (draft.decision_date !== decision.decision_date)
diff.decision_date = draft.decision_date;
if (draft.summary !== decision.summary)
diff.summary = draft.summary;
if (draft.outcome !== decision.outcome)
diff.outcome = draft.outcome;
if (draft.appeal_subtype !== decision.appeal_subtype)
diff.appeal_subtype = draft.appeal_subtype;
if (draft.practice_area !== decision.practice_area)
diff.practice_area = draft.practice_area;
if (
JSON.stringify(draft.subject_categories) !==
JSON.stringify(decision.subject_categories)
)
diff.subject_categories = draft.subject_categories;
if (
JSON.stringify(draft.key_principles) !==
JSON.stringify(decision.key_principles)
)
diff.key_principles = draft.key_principles;
const isDirty = Object.keys(diff).length > 0;
const onSave = async () => {
if (!isDirty) return;
try {
await patch.mutateAsync({ id: decision.id, patch: diff });
toast.success("המטא-דאטה עודכן");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל בשמירה");
}
};
const setSubjects = (raw: string) =>
setDraft((d) => ({
...d,
subject_categories: raw.split(/[,،]/).map((s) => s.trim()).filter(Boolean),
}));
const setPrinciples = (raw: string) =>
setDraft((d) => ({
...d,
key_principles: raw.split("\n").map((s) => s.trim()).filter(Boolean),
}));
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-full sm:max-w-3xl overflow-y-auto" dir="rtl">
<SheetHeader>
<SheetTitle className="text-navy flex items-center gap-2">
<BookOpen className="w-4 h-4 shrink-0" />
{decision.legal_citation || decision.decision_number || "—"}
</SheetTitle>
<SheetDescription className="text-ink-muted">
{decision.doc_title || "החלטה בקורפוס הסגנוני"}
</SheetDescription>
</SheetHeader>
{/* Summary strip — fast-scan info, always visible above the tabs. */}
<div className="px-6 mt-3 grid grid-cols-2 md:grid-cols-4 gap-3 text-[0.78rem]">
<DataPoint icon={<Calendar className="w-3 h-3" />} label="תאריך"
value={decision.decision_date || "—"} />
<DataPoint icon={<FileText className="w-3 h-3" />} label="תווים"
value={`${(decision.chars / 1000).toFixed(1)}K`} />
<DataPoint icon={<FileText className="w-3 h-3" />} label="עמודים"
value={decision.page_count > 0 ? String(decision.page_count) : "—"} />
<DataPoint icon={<Tag className="w-3 h-3" />} label="תת-סוג"
value={decision.appeal_subtype || "—"} />
</div>
<div className="px-6 pb-6 mt-4">
<Tabs defaultValue="details" dir="rtl">
<TabsList className="bg-rule-soft/60">
<TabsTrigger value="details">פרטים</TabsTrigger>
<TabsTrigger value="content">תוכן</TabsTrigger>
<TabsTrigger value="lessons">מה למדנו</TabsTrigger>
<TabsTrigger value="patterns">דפוסים</TabsTrigger>
</TabsList>
{/* ── Tab: editable metadata ─────────────────────────── */}
<TabsContent value="details" className="mt-4 space-y-4">
<div className="grid grid-cols-2 gap-3">
<Field label="מספר ההחלטה">
<Input value={draft.decision_number ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, decision_number: e.target.value }))}
dir="rtl" />
</Field>
<Field label="תאריך">
<Input type="date" value={draft.decision_date ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, decision_date: e.target.value }))} />
</Field>
</div>
<Field label="נושאים (מופרדים בפסיקים)">
<Input value={(draft.subject_categories ?? []).join(", ")}
onChange={(e) => setSubjects(e.target.value)} dir="rtl" />
{decision.subject_categories.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{decision.subject_categories.map((s) => (
<Badge key={s} variant="outline"
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40">
{s}
</Badge>
))}
</div>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="תת-סוג ערר">
<Input value={draft.appeal_subtype ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, appeal_subtype: e.target.value }))}
placeholder="building_permit / betterment_levy / compensation_197"
dir="rtl" />
</Field>
<Field label="תחום משפט">
<Input value={draft.practice_area ?? ""}
onChange={(e) => setDraft((d) => ({ ...d, practice_area: e.target.value }))}
dir="rtl" />
</Field>
</div>
<Field label="תקציר (summary)">
<Textarea value={draft.summary ?? ""} rows={3}
onChange={(e) => setDraft((d) => ({ ...d, summary: e.target.value }))}
placeholder="תקציר חופשי — מי, מה, איך הוכרע"
dir="rtl" />
</Field>
<Field label="התוצאה (outcome)">
<Textarea value={draft.outcome ?? ""} rows={2}
onChange={(e) => setDraft((d) => ({ ...d, outcome: e.target.value }))}
placeholder="קבלה / קבלה חלקית / דחייה — בקצרה"
dir="rtl" />
</Field>
<Field label="עקרונות מרכזיים (שורה לכל אחד)">
<Textarea value={(draft.key_principles ?? []).join("\n")} rows={4}
onChange={(e) => setPrinciples(e.target.value)}
placeholder={"דוגמה:\nשיקול דעת מוגבל לחריגות קטנות\nריפוי פגם רק בנסיבות חריגות"}
dir="rtl" />
</Field>
{decision.parties.appellant && (
<Card className="bg-rule-soft/40 border-rule">
<CardContent className="px-4 py-3 text-[0.78rem] text-ink-soft">
<p><strong className="text-navy">עורר/ת:</strong> {decision.parties.appellant}</p>
{decision.parties.respondent && (
<p className="mt-1"><strong className="text-navy">משיב/ה:</strong> {decision.parties.respondent}</p>
)}
<p className="mt-2 text-ink-muted text-[0.72rem]">
(חולץ אוטומטית מתחילת הטקסט תקן ע&quot;י עריכת ה-full_text במקור.)
</p>
</CardContent>
</Card>
)}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-rule">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
סגור
</Button>
<Button onClick={onSave} disabled={!isDirty || patch.isPending}
className="bg-navy text-parchment hover:bg-navy-soft">
{patch.isPending ? (
<Loader2 className="w-4 h-4 animate-spin me-1" />
) : (
<Save className="w-4 h-4 me-1" />
)}
שמור שינויים
</Button>
</div>
</TabsContent>
{/* ── Tab: full_text (read-only) ─────────────────────── */}
<TabsContent value="content" className="mt-4">
<Card className="bg-surface border-rule">
<CardContent className="px-4 py-3">
<p className="text-[0.72rem] text-ink-muted mb-2">
{decision.chars.toLocaleString("he-IL")} תווים · קריאה בלבד
</p>
<ScrollArea className="h-[480px] pe-2">
<p className="text-sm text-ink leading-relaxed whitespace-pre-wrap">
<FullTextLazy id={decision.id} />
</p>
</ScrollArea>
</CardContent>
</Card>
</TabsContent>
{/* ── Tab: lessons (per-decision) ────────────────────── */}
<TabsContent value="lessons" className="mt-4">
<LessonsTab corpusId={decision.id} />
</TabsContent>
{/* ── Tab: patterns scoped by appeal_subtype ─────────── */}
<TabsContent value="patterns" className="mt-4">
<PatternsForSubtype subtype={decision.appeal_subtype} />
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
);
}
// ── helpers ────────────────────────────────────────────────────────
function DataPoint({
icon, label, value,
}: { icon: React.ReactNode; label: string; value: string }) {
return (
<div className="flex items-center gap-1 text-ink-muted">
{icon}
<span>{label}:</span>
<span className="font-semibold text-navy tabular-nums truncate">{value}</span>
</div>
);
}
function Field({
label, children,
}: { label: string; children: React.ReactNode }) {
return (
<div className="space-y-1">
<Label className="text-[0.78rem]">{label}</Label>
{children}
</div>
);
}
/* The corpus-list endpoint deliberately doesn't return full_text (too big).
* We fetch it on demand only when the content tab opens.
*
* Implementation note: we don't have a dedicated /api/training/corpus/{id}
* GET endpoint yet. As a thin stopgap we hit a planned `/full-text` shortcut
* via apiRequest; if the endpoint isn't deployed yet the UI just shows the
* fallback message instead of crashing. The full-text endpoint lands with
* the next backend deploy.
*/
function FullTextLazy({ id }: { id: string }) {
const [text, setText] = useState<string>("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
fetch(`/api/training/corpus/${encodeURIComponent(id)}/full-text`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
.then((d: { full_text: string }) => {
if (cancelled) return;
setText(d.full_text || "");
})
.catch((e: Error) => {
if (cancelled) return;
setError(e.message);
})
.finally(() => !cancelled && setLoading(false));
return () => { cancelled = true; };
}, [id]);
/* eslint-enable react-hooks/set-state-in-effect */
if (loading) return <span className="text-ink-muted">טוען</span>;
if (error) return <span className="text-ink-muted">לא נמצא ({error})</span>;
return text;
}
function PatternsForSubtype({ subtype }: { subtype: string }) {
// Filtered patterns endpoint isn't built yet — we fall back to /patterns
// and filter client-side. The result is mediocre when many subtypes share
// patterns; better filtering ships in the metadata-enrichment iteration.
const [data, setData] = useState<Record<string, { pattern_text: string; frequency: number }[]> | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
fetch("/api/training/patterns")
.then((r) => r.json())
.then((d: { by_type: Record<string, { pattern_text: string; frequency: number }[]> }) => {
if (!cancelled) setData(d.by_type);
})
.catch(() => !cancelled && setData({}))
.finally(() => !cancelled && setLoading(false));
return () => { cancelled = true; };
}, []);
if (loading) return <p className="text-ink-muted text-sm text-center py-6">טוען</p>;
if (!data || Object.keys(data).length === 0) {
return <p className="text-ink-muted text-sm text-center py-6">אין דפוסים שמורים הרץ ניתוח סגנון.</p>;
}
return (
<div className="space-y-3">
{subtype && (
<p className="text-[0.78rem] text-ink-muted">
דפוסים בכלל הקורפוס. סינון לפי תת-סוג {subtype} ייושם בעדכון הבא.
</p>
)}
{Object.entries(data).slice(0, 4).map(([type, items]) => (
<Card key={type} className="bg-surface border-rule">
<CardContent className="px-4 py-3">
<h4 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold mb-2">
{type}
</h4>
<ul className="space-y-1 text-sm text-ink">
{items.slice(0, 6).map((p, i) => (
<li key={i} className="flex items-start gap-2">
<span className="text-[0.72rem] tabular-nums text-ink-muted shrink-0 mt-0.5">
×{p.frequency}
</span>
<span>{p.pattern_text}</span>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -1,6 +1,7 @@
"use client";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import { Trash2, Sparkles } from "lucide-react";
import { toast } from "sonner";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
@@ -9,12 +10,20 @@ 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, 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.
* 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) {
@@ -30,9 +39,12 @@ function formatDate(iso: string) {
}
}
function Row({ item }: { item: CorpusDecision }) {
function Row({
item, onOpen,
}: { item: CorpusDecision; onOpen: () => void }) {
const del = useDeleteCorpusEntry();
const onDelete = async () => {
const onDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!window.confirm(`למחוק את החלטה ${item.decision_number} מהקורפוס?`)) return;
try {
await del.mutateAsync(item.id);
@@ -43,7 +55,10 @@ function Row({ item }: { item: CorpusDecision }) {
};
return (
<TableRow className="border-rule hover:bg-gold-wash/30">
<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>
@@ -55,20 +70,39 @@ function Row({ item }: { item: CorpusDecision }) {
<span className="text-ink-light"></span>
) : (
<div className="flex flex-wrap gap-1">
{item.subject_categories.map((s) => (
<Badge
key={s}
variant="outline"
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
>
{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)}
@@ -91,6 +125,7 @@ function Row({ item }: { item: CorpusDecision }) {
export function CorpusPanel() {
const { data, isPending, error } = useCorpus();
const [selected, setSelected] = useState<CorpusDecision | null>(null);
if (error) {
return (
@@ -101,40 +136,50 @@ export function CorpusPanel() {
}
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" />
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
[...Array(4)].map((_, i) => (
<TableRow key={i} className="border-rule">
{[...Array(6)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-24" />
</TableCell>
))}
</TableRow>
))
) : data?.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-ink-muted py-12">
הקורפוס ריק
</TableCell>
<>
<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>
) : (
data?.map((item) => <Row key={item.id} item={item} />)
)}
</TableBody>
</Table>
</div>
</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); }}
/>
</>
);
}

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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,328 @@
"use client";
/*
* Upload a Daphna decision into the style corpus, from the /training page.
*
* The flow is three explicit steps inside the same sheet:
* 1. file picker → POST /api/upload (gets sanitized filename)
* 2. preview → POST /api/training/analyze (proofread + auto-extracted meta)
* chair can correct decision_number / decision_date / subjects
* 3. commit → POST /api/training/upload (background task)
* progress watched via SSE; on completion we invalidate
* corpus + style-report so the new row appears.
*
* The Sheet UX mirrors precedent-upload-sheet.tsx: same dir="rtl", same
* loading + error patterns, same toast on success. The reason this isn't
* a single one-click upload is that style-corpus rows are write-once
* (we don't allow editing full_text), so the chair MUST see the proofread
* preview before committing — otherwise a bad OCR/proofread can silently
* pollute the style portrait.
*/
import { useEffect, useState } from "react";
import { Upload, Loader2, CheckCircle2, AlertCircle, FileText } from "lucide-react";
import { toast } from "sonner";
import { useQueryClient } from "@tanstack/react-query";
import {
Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import {
trainingKeys,
useAnalyzeTraining,
useCommitTrainingUpload,
useUploadFile,
type AnalyzeTrainingResponse,
} from "@/lib/api/training";
import { useProgress } from "@/lib/api/documents";
const ACCEPT = ".pdf,.docx,.doc,.rtf,.txt,.md";
type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
type Stage = "pick" | "analyzing" | "preview" | "committing" | "done" | "error";
export function TrainingUploadDialog({ open, onOpenChange }: Props) {
const [stage, setStage] = useState<Stage>("pick");
const [file, setFile] = useState<File | null>(null);
const [analysis, setAnalysis] = useState<AnalyzeTrainingResponse | null>(null);
// editable copies of the auto-extracted metadata
const [decisionNumber, setDecisionNumber] = useState("");
const [decisionDate, setDecisionDate] = useState("");
const [subjectsRaw, setSubjectsRaw] = useState("");
const [title, setTitle] = useState("");
const [taskId, setTaskId] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState("");
const uploadFile = useUploadFile();
const analyze = useAnalyzeTraining();
const commit = useCommitTrainingUpload();
const progress = useProgress(taskId);
const qc = useQueryClient();
// Reset everything when the sheet closes — important because Sheet keeps
// the component mounted between opens. The cascade-render warning is the
// intended behavior (reset is the side effect we want).
useEffect(() => {
if (open) return;
/* eslint-disable react-hooks/set-state-in-effect */
setStage("pick"); setFile(null); setAnalysis(null);
setDecisionNumber(""); setDecisionDate(""); setSubjectsRaw("");
setTitle(""); setTaskId(null); setErrorMsg("");
/* eslint-enable react-hooks/set-state-in-effect */
}, [open]);
// Watch background task. When complete, invalidate corpus + report so the
// new row + updated stats show up automatically. The setStage call here
// is the deliberate UX (success card → auto-close) — synchronizing UI
// with the external SSE stream is exactly what effects are for.
useEffect(() => {
if (!progress) return;
if (progress.status === "completed") {
qc.invalidateQueries({ queryKey: trainingKeys.corpus() });
qc.invalidateQueries({ queryKey: trainingKeys.report() });
// eslint-disable-next-line react-hooks/set-state-in-effect
setStage("done");
toast.success(`החלטה ${decisionNumber || analysis?.decision_number || ""} נוספה לקורפוס`);
const t = window.setTimeout(() => onOpenChange(false), 1500);
return () => window.clearTimeout(t);
}
if (progress.status === "failed") {
setStage("error");
setErrorMsg(progress.error || "כשל בעיבוד");
}
}, [progress, analysis, decisionNumber, qc, onOpenChange]);
const onPickFile = async (f: File | null) => {
setFile(f);
setErrorMsg("");
if (!f) return;
setStage("analyzing");
try {
const { filename } = await uploadFile.mutateAsync(f);
const result = await analyze.mutateAsync(filename);
setAnalysis(result);
setDecisionNumber(result.decision_number);
setDecisionDate(result.decision_date);
setSubjectsRaw(result.subject_categories.join(", "));
// Default title from the original filename stem (chair can override).
const stem = f.name.replace(/\.[^.]+$/, "");
setTitle(stem);
setStage("preview");
} catch (e) {
setStage("error");
setErrorMsg(e instanceof Error ? e.message : "כשל בקריאת הקובץ");
}
};
const onCommit = async () => {
if (!analysis) return;
setStage("committing");
setErrorMsg("");
try {
const subjects = subjectsRaw
.split(/[,،]/)
.map((s) => s.trim())
.filter(Boolean);
const res = await commit.mutateAsync({
filename: analysis.filename,
decision_number: decisionNumber.trim(),
decision_date: decisionDate || "",
subject_categories: subjects,
title: title.trim() || undefined,
});
setTaskId(res.task_id);
} catch (e) {
setStage("error");
// 409 = duplicate decision_number — surface the backend's Hebrew message.
setErrorMsg(e instanceof Error ? e.message : "כשל בהעלאה");
}
};
const isProcessing =
stage === "analyzing" || stage === "committing" ||
(taskId !== null && progress?.status !== "completed" && progress?.status !== "failed");
const progressStep = (progress as { step?: string } | null)?.step;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-full sm:max-w-2xl overflow-y-auto" dir="rtl">
<SheetHeader>
<SheetTitle className="text-navy">העלאת החלטה לקורפוס הסגנון</SheetTitle>
<SheetDescription className="text-ink-muted">
הקובץ יעבור הגהה (סינון Nevo, ניקוד), חילוץ אוטומטי של מספר תיק, תאריך
ונושאים, ויוטמע ב-style_corpus עם chunks ו-embeddings. תוכל לתקן את
פרטי המטא-דאטה לפני שמירה.
</SheetDescription>
</SheetHeader>
<div className="px-6 pb-6 mt-4 space-y-4">
{/* Step 1: pick */}
{stage === "pick" && (
<div className="space-y-2">
<Label htmlFor="t-file">קובץ ההחלטה (PDF / DOCX / DOC / RTF / TXT / MD)</Label>
<Input
id="t-file" type="file" accept={ACCEPT}
onChange={(e) => onPickFile(e.target.files?.[0] ?? null)}
/>
<p className="text-[0.78rem] text-ink-muted">
המערכת תחלץ מהקובץ את מספר התיק, התאריך והנושאים. תוכל לערוך
לפני השמירה.
</p>
</div>
)}
{/* Stage 2: analyzing the file */}
{stage === "analyzing" && (
<div className="rounded-lg border border-rule bg-rule-soft/40 p-6 space-y-2 text-center">
<Loader2 className="w-5 h-5 animate-spin mx-auto text-navy" />
<p className="text-sm text-navy">מבצע הגהה וחילוץ מטא-דאטה</p>
<p className="text-[0.78rem] text-ink-muted">
{file?.name}
</p>
</div>
)}
{/* Stage 3: preview + editable metadata */}
{stage === "preview" && analysis && (
<form
className="space-y-4"
onSubmit={(e) => { e.preventDefault(); onCommit(); }}
>
<div className="rounded-lg border border-rule bg-surface px-4 py-3">
<h3 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold mb-2">
תצוגה מקדימה של הטקסט הנקי
</h3>
<p className="text-sm text-ink leading-relaxed line-clamp-6 whitespace-pre-wrap">
{analysis.preview}
</p>
<div className="mt-2 flex items-center gap-3 text-[0.72rem] text-ink-muted tabular-nums">
<span className="flex items-center gap-1">
<FileText className="w-3 h-3" />
{analysis.chars.toLocaleString("he-IL")} תווים
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="t-decision-number">מספר ההחלטה</Label>
<Input
id="t-decision-number"
value={decisionNumber}
onChange={(e) => setDecisionNumber(e.target.value)}
placeholder="1130-25"
dir="rtl"
/>
</div>
<div className="space-y-1">
<Label htmlFor="t-decision-date">תאריך ההחלטה</Label>
<Input
id="t-decision-date" type="date"
value={decisionDate}
onChange={(e) => setDecisionDate(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="t-title">כותרת קצרה (אופציונלי)</Label>
<Input
id="t-title" value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="ARAR-25-1130 - כרמל יצחק" dir="rtl"
/>
</div>
<div className="space-y-1">
<Label htmlFor="t-subjects">נושאים (מופרדים בפסיקים)</Label>
<Input
id="t-subjects" value={subjectsRaw}
onChange={(e) => setSubjectsRaw(e.target.value)}
placeholder="חניה, קווי בניין, שימוש חורג" dir="rtl"
/>
{analysis.subject_categories.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
<span className="text-[0.72rem] text-ink-muted">חולץ אוטומטית:</span>
{analysis.subject_categories.map((s) => (
<Badge key={s} variant="outline"
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40">
{s}
</Badge>
))}
</div>
)}
</div>
{errorMsg && (
<div className="rounded-lg border border-danger/40 bg-danger-bg p-3 flex items-center gap-2 text-danger text-sm">
<AlertCircle className="w-4 h-4 shrink-0" />
{errorMsg}
</div>
)}
<div className="flex gap-2 justify-end pt-2">
<Button type="button" variant="ghost"
onClick={() => onOpenChange(false)}
disabled={isProcessing}>
ביטול
</Button>
<Button type="submit" disabled={isProcessing || !decisionNumber.trim()}
className="bg-navy text-parchment hover:bg-navy-soft">
<Upload className="w-4 h-4 me-1" />
שמור בקורפוס
</Button>
</div>
</form>
)}
{/* Stage 4: committing — background task progress */}
{(stage === "committing" || (taskId && stage !== "done" && stage !== "error")) && (
<div className="rounded-lg border border-rule bg-rule-soft/40 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm text-navy">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{progressStep || "מעבד את ההחלטה לקורפוס"}</span>
</div>
<Progress value={progressStep ? 60 : 30} className="h-1.5" />
</div>
)}
{/* Stage 5: success */}
{stage === "done" && (
<div className="rounded-lg border border-gold/40 bg-gold-wash p-4 flex items-center gap-2 text-gold-deep text-sm">
<CheckCircle2 className="w-4 h-4" />
ההחלטה נוספה לקורפוס בהצלחה.
</div>
)}
{/* Stage 6: error (after a failed analyze or upload) */}
{stage === "error" && (
<div className="space-y-3">
<div className="rounded-lg border border-danger/40 bg-danger-bg p-4 flex items-center gap-2 text-danger text-sm">
<AlertCircle className="w-4 h-4 shrink-0" />
{errorMsg || "שגיאה לא ידועה"}
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost"
onClick={() => onOpenChange(false)}>
סגור
</Button>
<Button type="button"
onClick={() => { setStage("pick"); setErrorMsg(""); setFile(null); }}>
נסה קובץ אחר
</Button>
</div>
</div>
)}
</div>
</SheetContent>
</Sheet>
);
}