Files
legal-ai/web-ui/src/components/training/lessons-tab.tsx
Chaim 4b01283e3b
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 12s
feat(learning): שער-אישור ל-decision_lessons — רק לקח מאושר זורם לכותב (INV-LRN1, #126)
אודיט #122 חשף שלקחי-הפאנל (decision_lessons) זרמו לכותב אוטומטית
(block_writer → get_recent_decision_lessons) ללא סינון-אישור — הפאנל כתב,
והכותב צרך מיד, בעקיפת שער-היו"ר (INV-LRN1/G10). מנגד, מה שהיו"ר אישר ב-promote
הלך לערוץ נפרד (appeal_type_rules). תוצאה: דליפה — תוכן לא-מאושר השפיע על הכתיבה.

התיקון — שער-אישור מפורש:
- עמודת review_status (proposed|approved|rejected) ל-decision_lessons (SCHEMA_V34).
- get_recent_decision_lessons (צרכן-הכותב) מחזיר רק review_status='approved'.
- הפאנל (style_lesson_panel) כותב 'proposed' (ברירת-מחדל) → לא זורם עד אישור.
- לקח שהיו"ר מקליד ידנית ב-/training = 'approved' מיידית (מדלג על שער-ההצעה).
- UI (lessons-tab, טאב "קורפוס" ב-/training): תג-סטטוס + כפתורי אשר/דחה/בטל-אישור.

הכרעת-יו"ר (2026-06-11): כל הלקחים שקדמו לשער (41) מתאפסים ל-'proposed' —
שום לקח לא זורם עד אישור מפורש (ברירת-המחדל של העמודה מיישמת זאת על הקיימים).

Invariants:
- INV-LRN1 / G10 (מקיים) — עדכון-ידע לערוץ-הכותב דורש אישור-יו"ר מפורש; אין auto-commit.
- INV-LRN5 (נשמר) — substance ממילא מסונן בפאנל; השער הוא על style_method בלבד.
- G1 (מקיים) — סינון-במקור (get_recent) ולא תיקון-בקריאה אצל הכותב.
- G2 (מקיים) — אותו פנקס decision_lessons; אין מסלול מקביל.

api:types: להריץ npm run api:types אחרי deploy (review_status נוסף ל-payload;
הטיפוסים הידניים ב-training.ts כבר מעודכנים, tsc עובר).

ref: #122 · #126 · data/audit/learning-loop-activity-20260611.md

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

331 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
* - 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, 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" },
};
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" },
};
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 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>
<Badge variant="outline" className={reviewBadge.cls}>
{reviewBadge.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">
{/* 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={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>
);
}