מוסיף מסלול ייעודי לקליטת ההחלטה החתומה של היו"ר, ומפעיל אותו דרך שני
שלבים אוטומטיים מדורגים עם פאנלי-סוכנים (אוטו-אישור + אסקלציה ליו"ר).
Backend (web/):
- POST /api/cases/{case}/final/upload — קליטת final חיצוני: שמירה קנונית
(סופי-{case}.docx + עותק קורפוס-סגנון תחת case_number מלא כדי שבל"מ לא
יתנגש עם ערר באותו מספר), פתיחת draft_final_pairs (final_received). לא נוגע
ב-active_draft ולא מריץ retrofit (נבדל מ-exports/upload ו-mark-final → לא G2).
- POST .../final/run-learning + .../final/run-halacha — שלבים מדורגים שמעירים
worker מקומי (claude/DeepSeek/Gemini מקומיים בלבד) דרך הרחבת
wake_curator_for_final עם param task=learning|halacha.
פאנל-סגנון חדש (scripts/style_lesson_panel.py): שני שופטים (DeepSeek+Gemini)
על-גבי דיסטילציית-ה-Opus; הסכמה 2/2-keep → decision_lesson
(source=panel:deepseek+gemini); substance מדולג (INV-LRN5); הפיך + גיבוי CSV.
פאנל-הלכות: docstring/SCRIPTS.md עודכנו (--apply מחווט).
Frontend (web-ui/): כפתור "העלאת החלטה סופית של היו"ר" + שני כפתורים מדורגים
"הרץ למידת-קול"/"הרץ אימות-הלכות" ב-drafts-panel; כל התוויות בעברית
(badge מקור-לקח: "פאנל: דיפסיק+גמיני", "הרמס (סקירה)"...).
Spec: docs/spec/07-learning.md §0.6. Invariants: INV-LRN1/LRN4/LRN5, G10
(שער-יו"ר ידני להטמעה ל-SKILL.md/lessons.md — הפאנלים יוצרים הצעות בלבד);
G2 (מסלול-סופי הוא יכולת חסרה, לא מסלול-מקביל).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
9.9 KiB
TypeScript
273 lines
9.9 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
|
||
* - 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;
|
||
|
||
// 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" };
|
||
|
||
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 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>
|
||
);
|
||
}
|