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:
@@ -1,30 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Upload } from "lucide-react";
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { StyleReportPanel } from "@/components/training/style-report-panel";
|
||||
import { CorpusPanel } from "@/components/training/corpus-panel";
|
||||
import { ComparePanel } from "@/components/training/compare-panel";
|
||||
import { CuratorPortraitPanel } from "@/components/training/curator-portrait-panel";
|
||||
import { ChatPanel } from "@/components/training/chat-panel";
|
||||
import { TrainingUploadDialog } from "@/components/training/upload-dialog";
|
||||
|
||||
export default function TrainingPage() {
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<section className="space-y-6">
|
||||
<header>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">אימון סגנון</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת,
|
||||
ביטויי חתימה, וכלי השוואה בין שתי החלטות.
|
||||
</p>
|
||||
<header className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<nav className="text-[0.78rem] text-ink-muted mb-1">
|
||||
<Link href="/" className="hover:text-gold-deep">בית</Link>
|
||||
<span aria-hidden> · </span>
|
||||
<span className="text-navy">אימון סגנון</span>
|
||||
</nav>
|
||||
<h1 className="text-navy mb-0">הפורטרט הסגנוני של דפנה</h1>
|
||||
<p className="text-ink-muted text-sm mt-1 max-w-2xl">
|
||||
לוח בקרה של קורפוס האימון — סטטיסטיקות, אנטומיית החלטה ממוצעת,
|
||||
ביטויי חתימה, וכלי השוואה בין שתי החלטות.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setUploadOpen(true)}
|
||||
className="bg-navy text-parchment hover:bg-navy-soft shrink-0"
|
||||
>
|
||||
<Upload className="w-4 h-4 me-1" />
|
||||
העלה החלטה
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<TrainingUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} />
|
||||
|
||||
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
|
||||
|
||||
<Card className="bg-surface border-rule shadow-sm">
|
||||
@@ -34,6 +53,8 @@ export default function TrainingPage() {
|
||||
<TabsTrigger value="report">פורטרט סגנון</TabsTrigger>
|
||||
<TabsTrigger value="corpus">קורפוס</TabsTrigger>
|
||||
<TabsTrigger value="compare">השוואה</TabsTrigger>
|
||||
<TabsTrigger value="curator">הסוכן</TabsTrigger>
|
||||
<TabsTrigger value="chat">שיחה</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="report" className="mt-5">
|
||||
@@ -47,6 +68,14 @@ export default function TrainingPage() {
|
||||
<TabsContent value="compare" className="mt-5">
|
||||
<ComparePanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="curator" className="mt-5">
|
||||
<CuratorPortraitPanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="chat" className="mt-5">
|
||||
<ChatPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
434
web-ui/src/components/training/chat-panel.tsx
Normal file
434
web-ui/src/components/training/chat-panel.tsx
Normal 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">
|
||||
התחל בשאלה — למשל: "מה מאפיין את הפתיחות של דפנה בעררי 1xxx?"
|
||||
</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>שירות הצ'אט אינו זמין</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">
|
||||
על המכונה המקומית הפעל:
|
||||
<code className="px-1 bg-rule-soft rounded">
|
||||
pm2 start /home/chaim/legal-ai/scripts/legal-chat-service.config.cjs
|
||||
</code>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
402
web-ui/src/components/training/corpus-detail-drawer.tsx
Normal file
402
web-ui/src/components/training/corpus-detail-drawer.tsx
Normal 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]">
|
||||
(חולץ אוטומטית מתחילת הטקסט — תקן ע"י עריכת ה-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>
|
||||
);
|
||||
}
|
||||
@@ -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); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
328
web-ui/src/components/training/upload-dialog.tsx
Normal file
328
web-ui/src/components/training/upload-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,13 @@
|
||||
* - GET /corpus → flat list of decisions for the corpus tab / compare tool
|
||||
* - GET /compare?a=UUID&b=UUID → side-by-side comparison
|
||||
* - DELETE /corpus/{id} → remove a decision from the corpus
|
||||
* - POST /api/upload → multipart file → returns sanitized filename
|
||||
* - POST /analyze → proofread + extract metadata for preview
|
||||
* - POST /upload → commit a proofread decision to the corpus (task_id)
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { apiRequest } from "./client";
|
||||
import { ApiError, apiRequest } from "./client";
|
||||
|
||||
export type StyleReport = {
|
||||
corpus: {
|
||||
@@ -69,6 +72,29 @@ export type CorpusDecision = {
|
||||
subject_categories: string[];
|
||||
chars: number;
|
||||
created_at: string;
|
||||
// Enriched metadata (added in the corpus-page upgrade).
|
||||
summary: string;
|
||||
outcome: string;
|
||||
key_principles: string[];
|
||||
appeal_subtype: string;
|
||||
practice_area: string;
|
||||
page_count: number;
|
||||
document_id: string | null;
|
||||
doc_title: string;
|
||||
parties: { appellant: string; respondent: string };
|
||||
legal_citation: string;
|
||||
lessons_count: number;
|
||||
};
|
||||
|
||||
export type CorpusDecisionPatch = {
|
||||
decision_number?: string;
|
||||
decision_date?: string;
|
||||
subject_categories?: string[];
|
||||
summary?: string;
|
||||
outcome?: string;
|
||||
key_principles?: string[];
|
||||
appeal_subtype?: string;
|
||||
practice_area?: string;
|
||||
};
|
||||
|
||||
export type CompareResult = {
|
||||
@@ -149,3 +175,407 @@ export function useDeleteCorpusEntry() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Style-agent chat ─────────────────────────────────────────────
|
||||
|
||||
export type ChatConversation = {
|
||||
id: string;
|
||||
title: string;
|
||||
style_corpus_id: string | null;
|
||||
decision_number: string;
|
||||
claude_session_id: string | null;
|
||||
message_count: number;
|
||||
created_at: string;
|
||||
last_message_at: string;
|
||||
};
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type ChatHealth = {
|
||||
reachable: boolean;
|
||||
status?: number;
|
||||
url: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export const chatKeys = {
|
||||
conversations: () => [...trainingKeys.all, "chat", "conversations"] as const,
|
||||
conversation: (id: string) =>
|
||||
[...trainingKeys.all, "chat", "conversations", id] as const,
|
||||
health: () => [...trainingKeys.all, "chat", "health"] as const,
|
||||
};
|
||||
|
||||
export function useChatConversations() {
|
||||
return useQuery({
|
||||
queryKey: chatKeys.conversations(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<ChatConversation[]>("/api/training/chat/conversations", { signal }),
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useChatConversation(convId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: chatKeys.conversation(convId ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<{ conversation: ChatConversation; messages: ChatMessage[] }>(
|
||||
`/api/training/chat/conversations/${encodeURIComponent(convId!)}`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(convId),
|
||||
staleTime: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useChatHealth() {
|
||||
return useQuery({
|
||||
queryKey: chatKeys.health(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<ChatHealth>("/api/training/chat/health", { signal }),
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateChat() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: { title?: string; style_corpus_id?: string | null }) =>
|
||||
apiRequest<ChatConversation>("/api/training/chat/conversations", {
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.conversations() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteChat() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ deleted: boolean }>(
|
||||
`/api/training/chat/conversations/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: chatKeys.conversations() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Curator portrait ──────────────────────────────────────────────
|
||||
|
||||
export type CuratorPrompt = {
|
||||
content: string;
|
||||
filename: string;
|
||||
bytes: number;
|
||||
last_modified: number;
|
||||
gitea_url: string;
|
||||
};
|
||||
|
||||
export type StyleAnalyzerPrompts = {
|
||||
analysis_prompt: string;
|
||||
single_decision_prompt: string;
|
||||
synthesis_prompt: string;
|
||||
max_input_tokens: number;
|
||||
};
|
||||
|
||||
export type CuratorFinding = {
|
||||
id: string;
|
||||
lesson_text: string;
|
||||
category: string;
|
||||
applied_to_skill: boolean;
|
||||
decision_number: string;
|
||||
decision_date: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CuratorStats = {
|
||||
total_findings: number;
|
||||
decisions_with_findings: number;
|
||||
decisions_total: number;
|
||||
findings_applied: number;
|
||||
recent_findings: CuratorFinding[];
|
||||
};
|
||||
|
||||
export type CuratorProposalInput = {
|
||||
title: string;
|
||||
proposed_change: string;
|
||||
rationale: string;
|
||||
};
|
||||
|
||||
export type CuratorProposalFile = {
|
||||
filename: string;
|
||||
bytes: number;
|
||||
modified_at: number;
|
||||
};
|
||||
|
||||
export const curatorKeys = {
|
||||
prompt: () => [...trainingKeys.all, "curator", "prompt"] as const,
|
||||
analyzerPrompt: () => [...trainingKeys.all, "curator", "analyzer-prompt"] as const,
|
||||
stats: () => [...trainingKeys.all, "curator", "stats"] as const,
|
||||
proposals: () => [...trainingKeys.all, "curator", "proposals"] as const,
|
||||
};
|
||||
|
||||
export function useCuratorPrompt() {
|
||||
return useQuery({
|
||||
queryKey: curatorKeys.prompt(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CuratorPrompt>("/api/training/curator/prompt", { signal }),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useStyleAnalyzerPrompts() {
|
||||
return useQuery({
|
||||
queryKey: curatorKeys.analyzerPrompt(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<StyleAnalyzerPrompts>(
|
||||
"/api/training/curator/style-analyzer-prompt",
|
||||
{ signal },
|
||||
),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCuratorStats() {
|
||||
return useQuery({
|
||||
queryKey: curatorKeys.stats(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CuratorStats>("/api/training/curator/stats", { signal }),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCuratorProposals() {
|
||||
return useQuery({
|
||||
queryKey: curatorKeys.proposals(),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<CuratorProposalFile[]>("/api/training/curator/proposals", { signal }),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitCuratorProposal() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CuratorProposalInput) =>
|
||||
apiRequest<{ saved: boolean; filename: string }>(
|
||||
"/api/training/curator/proposals",
|
||||
{ method: "POST", body },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: curatorKeys.proposals() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Upload flow ──────────────────────────────────────────────────
|
||||
// Three-step pipeline:
|
||||
// 1. useUploadFile → POST /api/upload (multipart) → { filename }
|
||||
// 2. useAnalyzeFile → POST /api/training/analyze (form) → preview + extracted metadata
|
||||
// 3. useCommitUpload → POST /api/training/upload (json) → { task_id }
|
||||
// Track task_id via useProgress() from documents.ts.
|
||||
|
||||
export type UploadFileResponse = {
|
||||
filename: string; // sanitized, time-prefixed name in UPLOAD_DIR
|
||||
original_name: string;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type AnalyzeTrainingResponse = {
|
||||
filename: string;
|
||||
clean_text: string;
|
||||
preview: string;
|
||||
decision_number: string;
|
||||
decision_date: string; // ISO YYYY-MM-DD or ""
|
||||
subject_categories: string[];
|
||||
stats: Record<string, unknown>;
|
||||
chars: number;
|
||||
};
|
||||
|
||||
export type CommitTrainingRequest = {
|
||||
filename: string;
|
||||
decision_number: string;
|
||||
decision_date: string; // YYYY-MM-DD or ""
|
||||
subject_categories: string[];
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type CommitTrainingResponse = { task_id: string };
|
||||
|
||||
export function useUploadFile() {
|
||||
return useMutation({
|
||||
mutationFn: async (file: File): Promise<UploadFileResponse> => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
const parsed = contentType.includes("application/json")
|
||||
? await res.json().catch(() => null)
|
||||
: await res.text().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
typeof parsed === "object" && parsed && "detail" in parsed
|
||||
? String((parsed as { detail: unknown }).detail)
|
||||
: `Upload failed with ${res.status}`,
|
||||
res.status,
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
return parsed as UploadFileResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAnalyzeTraining() {
|
||||
return useMutation({
|
||||
mutationFn: async (filename: string): Promise<AnalyzeTrainingResponse> => {
|
||||
const fd = new FormData();
|
||||
fd.append("filename", filename);
|
||||
const res = await fetch("/api/training/analyze", {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
const parsed = contentType.includes("application/json")
|
||||
? await res.json().catch(() => null)
|
||||
: await res.text().catch(() => null);
|
||||
if (!res.ok) {
|
||||
throw new ApiError(
|
||||
typeof parsed === "object" && parsed && "detail" in parsed
|
||||
? String((parsed as { detail: unknown }).detail)
|
||||
: `Analyze failed with ${res.status}`,
|
||||
res.status,
|
||||
parsed,
|
||||
);
|
||||
}
|
||||
return parsed as AnalyzeTrainingResponse;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Per-decision lessons ─────────────────────────────────────────
|
||||
|
||||
export type DecisionLesson = {
|
||||
id: string;
|
||||
style_corpus_id: string;
|
||||
lesson_text: string;
|
||||
category: "style" | "structure" | "lexicon" | "tabular" | "general";
|
||||
source: "manual" | "curator" | "chair" | "style_analyzer";
|
||||
applied_to_skill: boolean;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type LessonCreate = {
|
||||
lesson_text: string;
|
||||
category?: DecisionLesson["category"];
|
||||
source?: DecisionLesson["source"];
|
||||
};
|
||||
|
||||
export type LessonPatch = {
|
||||
lesson_text?: string;
|
||||
category?: DecisionLesson["category"];
|
||||
applied_to_skill?: boolean;
|
||||
};
|
||||
|
||||
export const lessonsKeys = {
|
||||
forCorpus: (corpusId: string) =>
|
||||
[...trainingKeys.all, "lessons", corpusId] as const,
|
||||
};
|
||||
|
||||
export function useCorpusLessons(corpusId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: lessonsKeys.forCorpus(corpusId ?? ""),
|
||||
queryFn: ({ signal }) =>
|
||||
apiRequest<DecisionLesson[]>(
|
||||
`/api/training/corpus/${encodeURIComponent(corpusId!)}/lessons`,
|
||||
{ signal },
|
||||
),
|
||||
enabled: Boolean(corpusId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddLesson(corpusId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: LessonCreate) =>
|
||||
apiRequest<DecisionLesson>(
|
||||
`/api/training/corpus/${encodeURIComponent(corpusId)}/lessons`,
|
||||
{ method: "POST", body },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: lessonsKeys.forCorpus(corpusId) });
|
||||
// lessons_count on the corpus row is computed server-side, so
|
||||
// invalidate the list too — otherwise the badge stays stale.
|
||||
qc.invalidateQueries({ queryKey: trainingKeys.corpus() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchLesson(corpusId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: LessonPatch }) =>
|
||||
apiRequest<{ updated: boolean }>(
|
||||
`/api/training/lessons/${encodeURIComponent(id)}`,
|
||||
{ method: "PATCH", body: patch },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: lessonsKeys.forCorpus(corpusId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteLesson(corpusId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiRequest<{ deleted: boolean }>(
|
||||
`/api/training/lessons/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: lessonsKeys.forCorpus(corpusId) });
|
||||
qc.invalidateQueries({ queryKey: trainingKeys.corpus() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePatchCorpus() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, patch }: { id: string; patch: CorpusDecisionPatch }) =>
|
||||
apiRequest<{ updated: boolean; id: string }>(
|
||||
`/api/training/corpus/${encodeURIComponent(id)}`,
|
||||
{ method: "PATCH", body: patch },
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: trainingKeys.corpus() });
|
||||
qc.invalidateQueries({ queryKey: trainingKeys.report() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCommitTrainingUpload() {
|
||||
// No onSuccess invalidation here — the row only appears after the
|
||||
// background task finishes. The dialog watches useProgress(task_id)
|
||||
// and invalidates trainingKeys when status === "completed".
|
||||
return useMutation({
|
||||
mutationFn: (body: CommitTrainingRequest) =>
|
||||
apiRequest<CommitTrainingResponse>("/api/training/upload", {
|
||||
method: "POST",
|
||||
body,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user