Files
legal-ai/web-ui/src/components/training/lessons-tab.tsx
Chaim 987da57da4
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 4s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
feat(ui): synthesis/superseded badges + merge provenance in lessons-tab (#162)
עוצב ואושר דרך שער-העיצוב (Claude Design 12c-training-lessons.html).
- lessons-tab: SOURCE_BADGE['synthesis']="סינתזה" (navy מלא) · REVIEW_BADGE['superseded']="מוזג"
  (אפור-מעומעם, לא נדחה); שורות superseded מעומעמות (opacity).
- provenance: לקח-על מציג "⤷ מוזג מ-N לקחים · דחייה תשחזר אותם".
- backend: list_decision_lessons + _lesson_to_json מחזירים synthesized_from; training.ts
  DecisionLesson — 'synthesis'/'superseded' ל-unions + synthesized_from?: string[].

הבאדג'ים היו מוגנים-fallback קודם (ללא קריסה) — כעת מתויגים נכון. closes #162.

בדיקות: py_compile ✓ · leak-guard ✓ · tsc --noEmit ✓.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 22:59:31 +00:00

322 lines
13 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";
/*
* 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
* - Approve / reject a lesson (review_status — INV-LRN1/G10): only an
* approved lesson flows to the writer. This is the SINGLE writer gate;
* the old informative-only applied_to_skill flag was removed (LRN-1/INV-IA3).
*
* 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, Check, X, Undo2,
} 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;
// All labels in Hebrew. Keyed loosely (string) so new sources — e.g. the
// two-judge style panel — render correctly with a graceful fallback.
const SOURCE_BADGE: Record<string, { label: string; cls: string }> = {
manual: { label: "ידני", cls: "bg-rule-soft text-ink-soft" },
chair: { label: "יו״ר", cls: "bg-gold-wash text-gold-deep" },
curator: { label: "הרמס (סקירה)", cls: "bg-info-bg text-info" },
style_analyzer: { label: "מנתח-סגנון", cls: "bg-success-bg text-success" },
"panel:deepseek+gemini": { label: "פאנל: דיפסיק+גמיני", cls: "bg-gold-wash text-gold-deep" },
// #158/INV-LRN8 — a super-lesson merging overlapping lessons; navy-filled so it
// reads as the consolidated/elevated one.
synthesis: { label: "סינתזה", cls: "bg-navy text-parchment" },
};
const SOURCE_BADGE_FALLBACK = { label: "פאנל", cls: "bg-rule-soft text-ink-soft" };
// review gate (INV-LRN1/G10): only "approved" lessons flow to the writer.
const REVIEW_BADGE: Record<string, { label: string; cls: string }> = {
proposed: { label: "ממתין לאישור", cls: "bg-gold-wash text-gold-deep border-gold/40" },
approved: { label: "מאושר · זורם לכותב", cls: "bg-success-bg text-success border-success/40" },
rejected: { label: "נדחה", cls: "bg-rule-soft text-ink-muted line-through" },
// #158/INV-LRN8 — merged into a synthesis super-lesson; kept as provenance, no
// longer fed to the writer. Muted (not struck — it wasn't rejected).
superseded: { label: "מוזג", cls: "bg-rule-soft text-ink-muted" },
};
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] ?? SOURCE_BADGE_FALLBACK;
const reviewBadge = REVIEW_BADGE[lesson.review_status] ?? REVIEW_BADGE.proposed;
const dirty = text !== lesson.lesson_text || category !== lesson.category;
const onSetReview = async (review_status: DecisionLesson["review_status"]) => {
try {
await patch.mutateAsync({ id: lesson.id, patch: { review_status } });
toast.success(
review_status === "approved" ? "אושר — הלקח יזרום לכותב"
: review_status === "rejected" ? "נדחה — לא יזרום לכותב"
: "הוחזר להמתנה",
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל בעדכון");
}
};
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 onDelete = async () => {
if (!window.confirm("למחוק את הלקח?")) return;
try {
await del.mutateAsync(lesson.id);
toast.success("נמחק");
} catch (e) {
toast.error(e instanceof Error ? e.message : "כשל במחיקה");
}
};
const mergedCount = lesson.source === "synthesis" ? (lesson.synthesized_from?.length ?? 0) : 0;
return (
<Card className={`bg-surface border-rule ${lesson.review_status === "superseded" ? "opacity-70" : ""}`}>
<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>
<Badge variant="outline" className={reviewBadge.cls}>
{reviewBadge.label}
</Badge>
<span className="grow text-ink-muted tabular-nums">
{new Date(lesson.created_at).toLocaleDateString("he-IL")}
</span>
</div>
{mergedCount > 0 && (
<div className="text-[0.72rem] text-ink-muted">
מוזג מ-<b className="text-navy font-semibold">{mergedCount} לקחים</b> · דחייה תשחזר אותם
</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">
{/* review gate — only an approved lesson flows to the writer */}
{lesson.review_status === "approved" ? (
<Button variant="ghost" size="sm" onClick={() => onSetReview("proposed")}
disabled={patch.isPending}
className="text-ink-muted hover:text-ink">
<Undo2 className="w-3 h-3 me-1" />
בטל אישור
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={() => onSetReview("approved")}
disabled={patch.isPending}
className="text-success hover:text-success hover:bg-success-bg">
<Check className="w-3 h-3 me-1" />
אשר
</Button>
{lesson.review_status === "rejected" ? (
<Button variant="ghost" size="sm" onClick={() => onSetReview("proposed")}
disabled={patch.isPending} className="text-ink-muted hover:text-ink">
<Undo2 className="w-3 h-3 me-1" />
שחזר
</Button>
) : (
<Button variant="ghost" size="sm" onClick={() => onSetReview("rejected")}
disabled={patch.isPending}
className="text-danger hover:text-danger hover:bg-danger-bg">
<X className="w-3 h-3 me-1" />
דחה
</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>
);
}