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:
2026-06-06 19:13:10 +00:00
parent e4fbda6c1f
commit ee76455a9a
4 changed files with 221 additions and 0 deletions

View 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>שינוי draftfinal: <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>
);
}