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

@@ -12,6 +12,7 @@ import { CorpusPanel } from "@/components/training/corpus-panel";
import { ComparePanel } from "@/components/training/compare-panel";
import { CuratorPortraitPanel } from "@/components/training/curator-portrait-panel";
import { ChatPanel } from "@/components/training/chat-panel";
import { LearningPanel } from "@/components/training/learning-panel";
import { TrainingUploadDialog } from "@/components/training/upload-dialog";
export default function TrainingPage() {
@@ -53,6 +54,7 @@ export default function TrainingPage() {
<TabsTrigger value="report">פורטרט סגנון</TabsTrigger>
<TabsTrigger value="corpus">קורפוס</TabsTrigger>
<TabsTrigger value="compare">השוואה</TabsTrigger>
<TabsTrigger value="learning">למידה</TabsTrigger>
<TabsTrigger value="curator">הסוכן</TabsTrigger>
<TabsTrigger value="chat">שיחה</TabsTrigger>
</TabsList>
@@ -69,6 +71,10 @@ export default function TrainingPage() {
<ComparePanel />
</TabsContent>
<TabsContent value="learning" className="mt-5">
<LearningPanel />
</TabsContent>
<TabsContent value="curator" className="mt-5">
<CuratorPortraitPanel />
</TabsContent>

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>
);
}

View File

@@ -0,0 +1,69 @@
/**
* Style-acquisition learning surface (T6/T13) — reconciliation ledger + style-distance.
* Backs the /training "למידה" tab. Endpoints under /api/learning.
*/
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "./client";
export type DraftFinalPair = {
id: string;
case_id: string | null;
case_number: string;
title: string;
status: "final_received" | "analyzed" | "lessons_folded" | string;
change_percent: number | null;
created_at: string | null;
updated_at: string | null;
};
export type StyleDistance = {
case_number: string;
outcome: string;
golden_ratio_adherence: {
outcome: string;
total_words: number;
sections: Record<string, { actual_pct: number; target: [number, number]; deviation_pp: number }>;
max_deviation: number | null;
};
anti_pattern_hits: { total: number; by_pattern: Record<string, { count: number; note: string }> };
draft_to_final_diff: { change_percent?: number } | null;
pair_status: string | null;
summary: {
ratio_max_deviation_pp: number | null;
anti_pattern_total: number;
change_percent: number | null;
};
error?: string;
};
export const learningKeys = {
all: ["learning"] as const,
pairs: (status: string) => [...learningKeys.all, "pairs", status] as const,
distance: (caseNumber: string) => [...learningKeys.all, "distance", caseNumber] as const,
};
export function useReconciliationLedger(status = "") {
return useQuery({
queryKey: learningKeys.pairs(status),
queryFn: ({ signal }) =>
apiRequest<{ items: DraftFinalPair[]; count: number }>(
`/api/learning/pairs${status ? `?status=${status}` : ""}`,
{ signal },
),
staleTime: 15_000,
});
}
export function useStyleDistance(caseNumber: string | null) {
return useQuery({
queryKey: learningKeys.distance(caseNumber ?? ""),
queryFn: ({ signal }) =>
apiRequest<StyleDistance>(
`/api/learning/style-distance/${encodeURIComponent(caseNumber!)}`,
{ signal },
),
enabled: Boolean(caseNumber),
staleTime: 15_000,
});
}