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:
@@ -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>
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
69
web-ui/src/lib/api/learning.ts
Normal file
69
web-ui/src/lib/api/learning.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
35
web/app.py
35
web/app.py
@@ -4116,6 +4116,41 @@ async def api_reset_methodology(category: str, key: str):
|
||||
return {"key": key, "value": _METHODOLOGY_DEFAULTS[category][key], "is_override": False}
|
||||
|
||||
|
||||
# ── Style-acquisition learning surface (T6/T13) ────────────────────
|
||||
|
||||
@app.get("/api/learning/pairs")
|
||||
async def api_learning_pairs(status: str = "", limit: int = 200):
|
||||
"""פנקס-ההתאמה (INV-LRN4) — כל ההחלטות וסטטוס ההשוואה מול הסופי.
|
||||
status אופציונלי: final_received / analyzed / lessons_folded."""
|
||||
rows = await db.list_draft_final_pairs(status=status or None, limit=limit)
|
||||
items = []
|
||||
for r in rows:
|
||||
ds = r.get("diff_stats")
|
||||
if isinstance(ds, str):
|
||||
try:
|
||||
ds = json.loads(ds)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
ds = None
|
||||
items.append({
|
||||
"id": str(r["id"]),
|
||||
"case_id": str(r["case_id"]) if r.get("case_id") else None,
|
||||
"case_number": r.get("case_number") or "",
|
||||
"title": r.get("title") or "",
|
||||
"status": r.get("status") or "",
|
||||
"change_percent": (ds or {}).get("change_percent") if ds else None,
|
||||
"created_at": r["created_at"].isoformat() if r.get("created_at") else None,
|
||||
"updated_at": r["updated_at"].isoformat() if r.get("updated_at") else None,
|
||||
})
|
||||
return {"items": items, "count": len(items)}
|
||||
|
||||
|
||||
@app.get("/api/learning/style-distance/{case_number}")
|
||||
async def api_learning_style_distance(case_number: str):
|
||||
"""מדד מרחק-סגנון (T7) לתיק — האם הטיוטה מתכנסת לדפנה."""
|
||||
from legal_mcp.services import style_distance as _sd
|
||||
return await _sd.style_distance(case_number)
|
||||
|
||||
|
||||
# ── Skill Management API ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user