עוצב ואושר דרך שער-העיצוב (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>
322 lines
13 KiB
TypeScript
322 lines
13 KiB
TypeScript
"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>
|
||
);
|
||
}
|