Files
legal-ai/web-ui/src/components/training/learning-panel.tsx
Chaim 6f3c3963a4
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
feat(operations): show real claude.ai subscription usage % on /operations
Surfaces the 5-hour / weekly / weekly-Opus utilization the Claude Code status
bar shows — the authoritative number, not a token estimate. Design approved via
the Claude Design gate (card 02c-operations-usage.html).

Three layers:
- court-fetch-service (host bridge): new GET /usage reads the OAuth token from
  ~/.claude/.credentials.json and proxies /api/oauth/usage with the required
  claude-code User-Agent. Read-only, no auth (like /pm2). Host-only — the token
  never enters the container.
- web/app.py: _ops_subscription_usage() proxies the bridge /usage; the
  /api/operations snapshot gains a `subscription_usage` field (null when the
  undocumented endpoint is unreachable).
- web-ui: SubscriptionUsagePanel renders three meters (label · % · bar · reset)
  at the top of /operations; bar turns amber >75% / red >90%; hidden when usage
  is null. Types added to operations.ts (hand-maintained snapshot type).

Also fixes a pre-existing react/no-unescaped-entities lint error in
learning-panel.tsx (escaped a `"` in Hebrew text — renders identically).

tsc --noEmit passes; lint error count 0. (Full next build is blocked only by the
manual-worktree node_modules symlink — the Docker build has real node_modules.)

Invariants: G2 (usage surfaced through the existing host bridge + /api/operations
snapshot — no parallel control path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 10:34:50 +00:00

202 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { Loader2, ChevronLeft, Check } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
useReconciliationLedger, useStyleDistance, usePairDetail, usePromoteLearning,
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 SelectableItem({ text, selected, onToggle }: {
text: string; selected: boolean; onToggle: () => void;
}) {
return (
<button type="button" onClick={onToggle}
className={`w-full flex items-start gap-2 rounded-md border p-2 text-right text-[0.78rem] transition-colors ${
selected ? "border-navy bg-navy-soft/15" : "border-rule bg-surface hover:bg-rule-soft/30"
}`}>
<span className={`mt-0.5 w-4 h-4 shrink-0 rounded border flex items-center justify-center ${
selected ? "bg-navy border-navy text-parchment" : "border-rule"
}`}>
{selected && <Check className="w-3 h-3" />}
</span>
<span className="flex-1">{text}</span>
</button>
);
}
/** T14 — chair approval gate (INV-G10/LRN1): review the curator's style_method
* proposal and fold approved lessons/phrases into writer-consumed channels. */
function ProposalReview({ pairId }: { pairId: string }) {
const { data, isPending, error } = usePairDetail(pairId);
const promote = usePromoteLearning(pairId);
const [lessons, setLessons] = useState<Set<string>>(new Set());
const [phrases, setPhrases] = useState<Set<string>>(new Set());
if (isPending) return <Loader2 className="w-4 h-4 animate-spin text-ink-muted" />;
if (error || !data) return <p className="text-danger text-[0.75rem]">שגיאה בטעינת ההצעה.</p>;
const lessonTexts = data.changes.map((c) => c.lesson).filter((l): l is string => Boolean(l));
const toggle = (set: Set<string>, setFn: (s: Set<string>) => void, v: string) => {
const next = new Set(set);
next.has(v) ? next.delete(v) : next.add(v);
setFn(next);
};
const total = lessons.size + phrases.size;
return (
<div className="rounded-md bg-gold-wash/30 border border-gold/40 p-3 space-y-3 text-[0.8rem]">
<p className="text-ink-muted text-[0.75rem]">
הצעת הדיסטילציה (סגנון/שיטה בלבד INV-LRN5). בחר/י מה לאמץ; ייכתב לערוצים שהכותב צורך (שער-יו&quot;ר).
</p>
{data.overall_assessment && (
<p className="italic text-ink-muted">{data.overall_assessment}</p>
)}
{lessonTexts.length > 0 && (
<div className="space-y-1.5">
<div className="font-semibold text-navy text-[0.75rem]">לקחי-סגנון כללי-דיון</div>
{lessonTexts.map((l, i) => (
<SelectableItem key={`l${i}`} text={l} selected={lessons.has(l)}
onToggle={() => toggle(lessons, setLessons, l)} />
))}
</div>
)}
{data.new_expressions.length > 0 && (
<div className="space-y-1.5">
<div className="font-semibold text-navy text-[0.75rem]">ביטויי-מעבר חדשים</div>
{data.new_expressions.map((p, i) => (
<SelectableItem key={`p${i}`} text={p} selected={phrases.has(p)}
onToggle={() => toggle(phrases, setPhrases, p)} />
))}
</div>
)}
{lessonTexts.length === 0 && data.new_expressions.length === 0 && (
<p className="text-ink-muted">אין הצעות style_method.</p>
)}
<div className="flex justify-end">
<Button size="sm" disabled={total === 0 || promote.isPending}
className="bg-navy text-parchment hover:bg-navy-soft"
onClick={() => promote.mutate(
{ lessons: [...lessons], phrases: [...phrases] },
{
onSuccess: (r) => toast.success(`הוטמעו ${r.folded_lessons} לקחים + ${r.folded_phrases} ביטויים`),
onError: (e) => toast.error(e instanceof Error ? e.message : "שגיאה"),
},
)}>
{promote.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin me-1" />
: <Check className="w-3.5 h-3.5 me-1" />}
אשר והטמע {total > 0 ? `(${total})` : ""}
</Button>
</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 && (
<div className="px-4 pb-3 space-y-3">
{pair.status === "analyzed" && <ProposalReview pairId={pair.id} />}
{pair.case_number && <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>
);
}