Files
legal-ai/web-ui/src/components/training/upload-dialog.tsx
Chaim bb0cd7c6a2
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
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>
2026-05-27 10:06:22 +00:00

329 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}