feat(style-acq T6+T13): פנקס-התאמה + מדד מרחק-סגנון ב-UI
ה"איך מנהלים/רואים את הלמידה": טאב "למידה" ב-/training.
- app.py: GET /api/learning/pairs (פנקס-ההתאמה — כל ההחלטות + סטטוס draft↔final,
INV-LRN4) + GET /api/learning/style-distance/{case} (מדד T7).
- web-ui: learning.ts hooks + LearningPanel (טבלת פנקס; לחיצה על תיק →
מדד מרחק-הסגנון: שינוי draft→final, סטיית יחסי-זהב, אנטי-דפוסים) + טאב ב-/training.
מכסה גם את T6 (רשימת כל ההחלטות הנסגרות מול הסופי). ללא endpoint-schema חדש
לטיפוסים מחוללים (טיפוסים ידניים). G9, INV-LRN4.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
111
web-ui/src/components/training/learning-panel.tsx
Normal file
111
web-ui/src/components/training/learning-panel.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, ChevronLeft } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
useReconciliationLedger, useStyleDistance,
|
||||
type DraftFinalPair,
|
||||
} from "@/lib/api/learning";
|
||||
|
||||
/**
|
||||
* /training "למידה" tab (T6/T13) — the reconciliation ledger (INV-LRN4): every
|
||||
* decision and its draft↔final comparison status, plus per-case style-distance
|
||||
* (T7). The list is the chair's "have we closed each decision against Dafna's
|
||||
* final?" surface.
|
||||
*/
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
final_received: "התקבל סופי — ממתין לניתוח",
|
||||
analyzed: "נותח (הצעות ממתינות)",
|
||||
lessons_folded: "לקחים הוטמעו",
|
||||
};
|
||||
const STATUS_CLASS: Record<string, string> = {
|
||||
final_received: "bg-gold-wash text-gold-deep border-gold/40",
|
||||
analyzed: "bg-navy-soft/20 text-navy",
|
||||
lessons_folded: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
|
||||
};
|
||||
|
||||
function fmtDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
try { return new Date(iso).toLocaleDateString("he-IL"); } catch { return iso; }
|
||||
}
|
||||
|
||||
function StyleDistanceDetail({ caseNumber }: { caseNumber: string }) {
|
||||
const { data, isPending, error } = useStyleDistance(caseNumber);
|
||||
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
|
||||
if (error || data?.error) return <p className="text-danger text-[0.75rem]">{data?.error || "שגיאה"}</p>;
|
||||
if (!data) return null;
|
||||
const s = data.summary;
|
||||
return (
|
||||
<div className="rounded-md bg-rule-soft/30 border border-rule p-3 space-y-2 text-[0.8rem]">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<span>שינוי draft→final: <b className="tabular-nums">{s.change_percent ?? "—"}%</b></span>
|
||||
<span>סטיית יחסי-זהב מקס׳: <b className="tabular-nums">{s.ratio_max_deviation_pp ?? "—"} נק׳</b></span>
|
||||
<span>אנטי-דפוסים: <b className="tabular-nums">{s.anti_pattern_total}</b></span>
|
||||
</div>
|
||||
{Object.keys(data.golden_ratio_adherence.sections).length > 0 && (
|
||||
<div className="flex gap-3 flex-wrap text-[0.72rem] text-ink-muted">
|
||||
{Object.entries(data.golden_ratio_adherence.sections).map(([sec, v]) => (
|
||||
<span key={sec} className={v.deviation_pp > 0 ? "text-danger" : ""}>
|
||||
{sec}: {v.actual_pct}% (יעד {v.target[0]}-{v.target[1]})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{data.anti_pattern_hits.total > 0 && (
|
||||
<div className="text-[0.72rem] text-danger">
|
||||
{Object.entries(data.anti_pattern_hits.by_pattern).map(([n, v]) => (
|
||||
<span key={n} className="me-3">{n}: {v.count}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ pair }: { pair: DraftFinalPair }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="rounded-lg border border-rule bg-surface overflow-hidden">
|
||||
<button type="button" onClick={() => setOpen((o) => !o)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-right hover:bg-gold-wash/30 transition-colors">
|
||||
<ChevronLeft className={`w-4 h-4 text-ink-muted shrink-0 transition-transform ${open ? "-rotate-90" : ""}`} />
|
||||
<div className="flex-1 min-w-0 text-right">
|
||||
<div className="font-semibold text-navy truncate">{pair.case_number || "—"}{pair.title ? ` — ${pair.title}` : ""}</div>
|
||||
<div className="text-[0.72rem] text-ink-muted">עודכן {fmtDate(pair.updated_at)}</div>
|
||||
</div>
|
||||
{pair.change_percent != null && (
|
||||
<Badge variant="outline" className="tabular-nums text-[0.65rem]">Δ {pair.change_percent}%</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className={`text-[0.65rem] ${STATUS_CLASS[pair.status] ?? ""}`}>
|
||||
{STATUS_LABEL[pair.status] ?? pair.status}
|
||||
</Badge>
|
||||
</button>
|
||||
{open && pair.case_number && (
|
||||
<div className="px-4 pb-3"><StyleDistanceDetail caseNumber={pair.case_number} /></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LearningPanel() {
|
||||
const { data, isPending, error } = useReconciliationLedger();
|
||||
if (error) return <p className="text-danger text-sm">שגיאה בטעינת הפנקס.</p>;
|
||||
if (isPending) return <Loader2 className="w-5 h-5 animate-spin text-ink-muted" />;
|
||||
|
||||
const items = data?.items ?? [];
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-[0.8rem] text-ink-muted">
|
||||
פנקס-ההתאמה (INV-LRN4): כל החלטה נסגרת מול הסופי של דפנה; כל סופי מנותח מול הטיוטה.
|
||||
לחיצה על תיק → מדד מרחק-הסגנון שלו.
|
||||
</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-ink-muted text-sm py-8 text-center">אין עדיין השוואות. הן נוצרות כשמסמנים החלטה כסופית.</p>
|
||||
) : (
|
||||
items.map((p) => <Row key={p.id} pair={p} />)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user