Storage stays UTC (DB TIMESTAMPTZ, API ISO-UTC) — only the display layer is localized, and now deterministically: every timestamp renders pinned to Asia/Jerusalem via a single Intl-based formatter, so SSR (UTC container) and the browser agree on any runtime. No layout/visible-format change — only the tz. - New single date formatter web-ui/src/lib/format-date.ts (G2): formatDate / formatDateShort / formatDateLong / formatDateTime / formatDateTimeFull / formatTime / formatIsoDate + Israel helpers getIsraelYear / israelDayKey / israelMidnightMs / israelParts + formatRelative (long/short/tight wording). - Routed all ad-hoc toLocaleDateString/toLocaleTimeString/toLocaleString + hand-rolled new Date(...).get*() / toISOString().slice(0,10) timestamp call sites (30 files) through it. Number toLocaleString left untouched. - Spec: added INV-UI9 to docs/spec/X6 (UTC storage, Asia/Jerusalem display). Display-only; no layout/design/IA change → Claude Design gate N/A. Invariants: G2 (single date formatter, no parallel ad-hoc formatting), INV-UI9. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
438 lines
19 KiB
TypeScript
438 lines
19 KiB
TypeScript
"use client";
|
||
|
||
/*
|
||
* Curator-Portrait tab — shows everything about the agent that learns
|
||
* Daphna's style:
|
||
* 1. Snapshot stats (curator findings to date, % applied)
|
||
* 2. Recent curator findings (last 10) — linked by decision number
|
||
* 3. The hermes-curator system prompt, rendered + linked to Gitea
|
||
* 4. The style_analyzer training prompts (different lifecycle — runs
|
||
* over the corpus at training time, not per-decision)
|
||
* 5. Propose-change form — writes a markdown file to disk for chair
|
||
* review (no auto-commit)
|
||
*
|
||
* The prompts are deliberately read-only here: they're symlinked into
|
||
* Paperclip and load-bearing for every curator wake. Editing them from
|
||
* the UI would silently fork the source of truth.
|
||
*/
|
||
|
||
import { useState } from "react";
|
||
import {
|
||
Sparkles, ExternalLink, Send, Loader2, FileText, Brain,
|
||
CheckCircle2, Clock,
|
||
} from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import { Card, CardContent } from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import { Markdown } from "@/components/ui/markdown";
|
||
import { formatDateShort, formatDateTimeFull } from "@/lib/format-date";
|
||
import {
|
||
useCuratorPrompt,
|
||
useCuratorStats,
|
||
useStyleAnalyzerPrompts,
|
||
useSubmitCuratorProposal,
|
||
} from "@/lib/api/training";
|
||
|
||
export function CuratorPortraitPanel() {
|
||
return (
|
||
<div className="space-y-6">
|
||
<StatsCard />
|
||
<RecentFindings />
|
||
|
||
<div className="bg-info-bg border border-info/40 rounded-lg px-4 py-3 flex items-start gap-2.5 text-[0.78rem] text-ink-soft leading-relaxed">
|
||
<span>🔒</span>
|
||
<div>
|
||
<b className="text-navy">שער-היו״ר (INV-LRN1 / G10):</b> כל הערוצים <b>מציעים</b> בלבד.
|
||
ממצא או לקח משפיע על הכתיבה רק אחרי שדפנה אישרה אותו. האוצֵר עצמו read-only על התוכן —
|
||
מזהה דפוסים ומגיש, לעולם לא משנה קבצים או סגנון בעצמו.
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs defaultValue="curator-prompt" dir="rtl">
|
||
<TabsList className="bg-rule-soft/60">
|
||
<TabsTrigger value="curator-prompt">פרומפט ה-Curator</TabsTrigger>
|
||
<TabsTrigger value="analyzer-prompt">פרומפט אימון הסגנון</TabsTrigger>
|
||
<TabsTrigger value="propose">הצעת שינוי</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="curator-prompt" className="mt-4">
|
||
<CuratorPromptCard />
|
||
</TabsContent>
|
||
<TabsContent value="analyzer-prompt" className="mt-4">
|
||
<StyleAnalyzerPromptCard />
|
||
</TabsContent>
|
||
<TabsContent value="propose" className="mt-4">
|
||
<ProposeChangeForm />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── stats card ─────────────────────────────────────────────────────
|
||
|
||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<div className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-bold mb-3">
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** One of the three learning channels — engine, backing store, count, flow gate. */
|
||
function ChannelCard({
|
||
letter, title, engine, target, store, value, unit, flows, flowLabel, meta,
|
||
}: {
|
||
letter: string; title: string; engine: string; target: string; store: string;
|
||
value: number; unit: string; flows: boolean; flowLabel: string; meta: string;
|
||
}) {
|
||
return (
|
||
<Card className="bg-surface border-rule">
|
||
<CardContent className="px-[18px] py-4 flex flex-col gap-2.5">
|
||
<div className="flex items-baseline gap-2">
|
||
<span className="w-[22px] h-[22px] rounded-md flex items-center justify-center font-bold text-xs text-parchment shrink-0 bg-navy">
|
||
{letter}
|
||
</span>
|
||
<div>
|
||
<h3 className="text-[0.9rem] text-navy font-semibold leading-tight">{title}</h3>
|
||
<div className="text-[0.72rem] text-ink-muted">{engine}</div>
|
||
</div>
|
||
</div>
|
||
<div className="text-[0.72rem] text-ink-soft bg-rule-soft rounded-md px-2.5 py-1.5">
|
||
← {target} <code className="text-[0.68rem] text-gold-deep">{store}</code>
|
||
</div>
|
||
<div className="flex items-baseline gap-1.5">
|
||
<span className="text-[1.9rem] leading-none font-bold text-navy tabular-nums">{value}</span>
|
||
<span className="text-[0.78rem] text-ink-muted">{unit}</span>
|
||
</div>
|
||
<span
|
||
className={`inline-flex items-center gap-1.5 rounded-full text-[0.72rem] font-semibold px-2.5 py-0.5 w-fit border ${
|
||
flows
|
||
? "bg-success-bg text-success border-success/40"
|
||
: "bg-gold-wash text-gold-deep border-gold/40"
|
||
}`}
|
||
>
|
||
<span className="text-[0.5rem]">●</span>{flowLabel}
|
||
</span>
|
||
<div className="text-[0.72rem] text-ink-muted leading-relaxed">{meta}</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function StatsCard() {
|
||
const { data, isPending } = useCuratorStats();
|
||
|
||
if (isPending) {
|
||
return (
|
||
<div className="space-y-4">
|
||
<Skeleton className="h-16 w-full" />
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{[...Array(3)].map((_, i) => <Skeleton key={i} className="h-44 w-full" />)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (!data) return null;
|
||
|
||
const { distillation: a, panel: b, curator: c } = data.channels;
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<div className="bg-gold-wash border border-gold rounded-lg px-5 py-4 flex items-start gap-3">
|
||
<span className="text-gold-deep text-xl leading-tight">★</span>
|
||
<p className="text-ink-soft text-sm leading-relaxed">
|
||
הלמידה זורמת לכותב ב<b className="text-gold-deep font-semibold">שלושה ערוצים נפרדים</b>, כל אחד
|
||
מופעל כשדפנה מסמנת החלטה כסופית. הכרטיס מראה כמה כל ערוץ תרם ומה כבר עובר בפועל לכותב —
|
||
רק ידע ש<b className="text-gold-deep font-semibold">אושר בשער-היו״ר</b> משפיע על הטיוטות (INV-LRN1).
|
||
</p>
|
||
</div>
|
||
|
||
<div>
|
||
<SectionLabel>שלושת ערוצי-ההזנה לכותב</SectionLabel>
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
<ChannelCard
|
||
letter="א" title="דיסטילציה" engine="Claude · השוואת טיוטה↔סופי"
|
||
target="כללי-מתודולוגיה" store="appeal_type_rules"
|
||
value={a.items_total} unit="פריטים זורמים"
|
||
flows={a.items_total > 0} flowLabel="זורם לכותב"
|
||
meta={`${a.discussion_rules} כללי-דיון + ${a.transition_phrases} ביטויי-מעבר · אושרו ב-${a.pairs_folded} זוגות דרך טאב ״למידה״.`}
|
||
/>
|
||
<ChannelCard
|
||
letter="ב" title="פאנל דו-סוכני" engine="DeepSeek + Gemini · הצבעה 2/2"
|
||
target="לקחי-סגנון" store="decision_lessons"
|
||
value={b.total} unit="לקחים"
|
||
flows={b.approved > 0} flowLabel={`${b.approved} אושרו · ${b.proposed} ממתינים`}
|
||
meta={`על פני ${b.decisions} החלטות · אישור per-החלטה בטאב הקורפוס.`}
|
||
/>
|
||
<ChannelCard
|
||
letter="ג" title="ממצאי-אוצֵר" engine="האוצֵר · קריאת הסופי + דפוסים"
|
||
target="ממצאי-סגנון" store="source=curator"
|
||
value={c.total} unit="ממצאים"
|
||
flows={c.approved > 0} flowLabel={`${c.approved} אושרו · ${c.proposed} ממתינים`}
|
||
meta="3–5 דפוסים מתויגים להחלטה (סגנון/מבנה/לקסיקון/טבלאי) — נתפסים מבנית, לא רק כהערה."
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<SectionLabel>ערוץ האוצֵר — מבט מקרוב</SectionLabel>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||
<Kpi label="סך ממצאי-אוצֵר" value={c.total} icon={<Sparkles className="w-4 h-4" />} />
|
||
<Kpi label="החלטות שנסקרו" value={`${c.decisions_reviewed}/${data.decisions_total}`} icon={<FileText className="w-4 h-4" />} />
|
||
<Kpi label="מאושרים · זורמים לכותב" value={c.approved} tone="success" icon={<CheckCircle2 className="w-4 h-4" />} />
|
||
<Kpi label="ממתינים לאישור" value={c.proposed} tone="gold" icon={<Brain className="w-4 h-4" />} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Kpi({
|
||
label, value, icon, tone,
|
||
}: { label: string; value: string | number; icon: React.ReactNode; tone?: "success" | "gold" }) {
|
||
const valueCls = tone === "success" ? "text-success" : tone === "gold" ? "text-gold-deep" : "text-navy";
|
||
return (
|
||
<Card className="bg-surface border-rule">
|
||
<CardContent className="px-4 py-3">
|
||
<div className="flex items-center gap-2 text-ink-muted text-[0.78rem]">
|
||
{icon}
|
||
<span>{label}</span>
|
||
</div>
|
||
<p className={`text-2xl font-semibold tabular-nums mt-1 ${valueCls}`}>{value}</p>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ── recent findings ────────────────────────────────────────────────
|
||
|
||
function RecentFindings() {
|
||
const { data, isPending } = useCuratorStats();
|
||
|
||
if (isPending) {
|
||
return <Skeleton className="h-40 w-full" />;
|
||
}
|
||
if (!data || data.recent_findings.length === 0) {
|
||
return (
|
||
<Card className="bg-rule-soft/40 border-rule">
|
||
<CardContent className="px-6 py-5 text-center text-ink-muted text-sm leading-relaxed">
|
||
אין עדיין ממצאי-אוצֵר. כשדפנה מסמנת החלטה כסופית, האוצֵר קורא את הסופי, מזהה
|
||
3–5 דפוסי-סגנון, ושומר אותם כ-<code>decision_lessons</code> (source="curator")
|
||
הממתינים לאישורה — בנפרד מלקחי-הפאנל ומכללי-המתודולוגיה שלמעלה.
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Card className="bg-surface border-rule">
|
||
<CardContent className="px-4 py-3">
|
||
<h3 className="text-[0.78rem] uppercase tracking-wider text-gold-deep font-semibold mb-3">
|
||
ממצאים אחרונים של ה-Curator
|
||
</h3>
|
||
<ul className="space-y-2">
|
||
{data.recent_findings.map((f) => (
|
||
<li key={f.id} className="border-b border-rule pb-2 last:border-0 last:pb-0">
|
||
<div className="flex items-center gap-2 text-[0.72rem] mb-1">
|
||
<Badge variant="outline"
|
||
className="bg-info-bg text-info border-info/40">
|
||
{f.category}
|
||
</Badge>
|
||
<span className="text-navy font-semibold tabular-nums">
|
||
{f.decision_number || "—"}
|
||
</span>
|
||
{f.review_status === "approved" && (
|
||
<Badge variant="outline"
|
||
className="bg-success-bg text-success border-success/40">
|
||
<CheckCircle2 className="w-3 h-3 me-0.5" />
|
||
מאושר
|
||
</Badge>
|
||
)}
|
||
<span className="grow text-ink-muted text-end">
|
||
<Clock className="w-3 h-3 inline me-1" />
|
||
{formatDateShort(f.created_at)}
|
||
</span>
|
||
</div>
|
||
<p className="text-sm text-ink leading-relaxed">{f.lesson_text}</p>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
// ── prompts ────────────────────────────────────────────────────────
|
||
|
||
function CuratorPromptCard() {
|
||
const { data, isPending, error } = useCuratorPrompt();
|
||
|
||
if (isPending) return <Skeleton className="h-96 w-full" />;
|
||
if (error) {
|
||
return (
|
||
<Card className="bg-danger-bg border-danger/40">
|
||
<CardContent className="px-6 py-4 text-danger">{error.message}</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
if (!data) return null;
|
||
|
||
return (
|
||
<Card className="bg-surface border-rule">
|
||
<CardContent className="px-5 py-4 space-y-3">
|
||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||
<div>
|
||
<h3 className="text-navy font-semibold">{data.filename}</h3>
|
||
<p className="text-[0.72rem] text-ink-muted">
|
||
{data.bytes.toLocaleString("he-IL")} בייטים ·
|
||
עודכן: {formatDateTimeFull(data.last_modified * 1000)}
|
||
</p>
|
||
</div>
|
||
<Button asChild variant="outline" size="sm">
|
||
<a href={data.gitea_url} target="_blank" rel="noopener noreferrer">
|
||
<ExternalLink className="w-3 h-3 me-1" />
|
||
ערוך ב-Gitea
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
<ScrollArea className="h-[520px] pe-2 border border-rule rounded p-3 bg-rule-soft/30">
|
||
<Markdown content={data.content} />
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function StyleAnalyzerPromptCard() {
|
||
const { data, isPending } = useStyleAnalyzerPrompts();
|
||
|
||
if (isPending) return <Skeleton className="h-96 w-full" />;
|
||
if (!data) return null;
|
||
|
||
return (
|
||
<Card className="bg-surface border-rule">
|
||
<CardContent className="px-5 py-4 space-y-3">
|
||
<div>
|
||
<h3 className="text-navy font-semibold">פרומפטים של style_analyzer.py</h3>
|
||
<p className="text-[0.72rem] text-ink-muted">
|
||
רץ ב-Claude Opus (1M context, עד {data.max_input_tokens.toLocaleString("he-IL")} tokens
|
||
input) דרך claude CLI מקומי — חינמי, ללא API. נקרא ע"י
|
||
<code className="px-1 mx-1 bg-rule-soft rounded">POST /api/training/analyze-style</code>
|
||
ומכניס דפוסים ל-<code className="px-1 bg-rule-soft rounded">style_patterns</code>.
|
||
</p>
|
||
</div>
|
||
|
||
<Tabs defaultValue="analysis" dir="rtl">
|
||
<TabsList className="bg-rule-soft/60">
|
||
<TabsTrigger value="analysis">Single-pass (כל הקורפוס)</TabsTrigger>
|
||
<TabsTrigger value="single">Multi-pass (החלטה אחת)</TabsTrigger>
|
||
<TabsTrigger value="synthesis">Synthesis</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="analysis" className="mt-3">
|
||
<PromptBlock content={data.analysis_prompt} />
|
||
</TabsContent>
|
||
<TabsContent value="single" className="mt-3">
|
||
<PromptBlock content={data.single_decision_prompt} />
|
||
</TabsContent>
|
||
<TabsContent value="synthesis" className="mt-3">
|
||
<PromptBlock content={data.synthesis_prompt} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function PromptBlock({ content }: { content: string }) {
|
||
return (
|
||
<ScrollArea className="h-[420px] pe-2 border border-rule rounded p-3 bg-rule-soft/30">
|
||
<pre className="text-[0.78rem] whitespace-pre-wrap font-mono text-ink leading-relaxed"
|
||
dir="rtl">
|
||
{content}
|
||
</pre>
|
||
</ScrollArea>
|
||
);
|
||
}
|
||
|
||
// ── propose change form ────────────────────────────────────────────
|
||
|
||
function ProposeChangeForm() {
|
||
const [title, setTitle] = useState("");
|
||
const [proposedChange, setProposedChange] = useState("");
|
||
const [rationale, setRationale] = useState("");
|
||
const submit = useSubmitCuratorProposal();
|
||
|
||
const onSubmit = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!title.trim() || !proposedChange.trim()) {
|
||
toast.error("חובה כותרת ושינוי מוצע");
|
||
return;
|
||
}
|
||
try {
|
||
const r = await submit.mutateAsync({
|
||
title: title.trim(),
|
||
proposed_change: proposedChange.trim(),
|
||
rationale: rationale.trim(),
|
||
});
|
||
toast.success(`נשמרה הצעה: ${r.filename}`);
|
||
setTitle(""); setProposedChange(""); setRationale("");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "כשל בשמירה");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Card className="bg-surface border-rule">
|
||
<CardContent className="px-5 py-4">
|
||
<h3 className="text-navy font-semibold mb-2">הצעת שינוי לפרומפט ה-Curator</h3>
|
||
<p className="text-[0.78rem] text-ink-muted mb-4">
|
||
ההצעה תישמר כקובץ Markdown ב-
|
||
<code className="px-1 bg-rule-soft rounded">data/curator-proposals/</code>.
|
||
חיים יבחן ויאשר ידנית — אין שינוי אוטומטי בפרומפט.
|
||
</p>
|
||
<form onSubmit={onSubmit} className="space-y-3">
|
||
<div className="space-y-1">
|
||
<Label htmlFor="proposal-title">כותרת השינוי</Label>
|
||
<Input id="proposal-title" value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="לדוגמה: הוסף קטגוריה [צ׳קליסט תוכן] לממצאי ה-curator"
|
||
dir="rtl" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="proposal-change">השינוי המוצע (Markdown)</Label>
|
||
<Textarea id="proposal-change" value={proposedChange} rows={6}
|
||
onChange={(e) => setProposedChange(e.target.value)}
|
||
placeholder={"תאר במדויק מה לשנות. אפשר להעתיק את הקטע הקיים ולסמן ב-strikethrough + להוסיף את החדש."}
|
||
dir="rtl" />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label htmlFor="proposal-rationale">נימוק</Label>
|
||
<Textarea id="proposal-rationale" value={rationale} rows={3}
|
||
onChange={(e) => setRationale(e.target.value)}
|
||
placeholder="למה השינוי הזה חשוב? איזה בעיה הוא פותר?"
|
||
dir="rtl" />
|
||
</div>
|
||
<div className="flex justify-end">
|
||
<Button type="submit" disabled={submit.isPending}
|
||
className="bg-navy text-parchment hover:bg-navy-soft">
|
||
{submit.isPending ? (
|
||
<Loader2 className="w-4 h-4 animate-spin me-1" />
|
||
) : (
|
||
<Send className="w-4 h-4 me-1" />
|
||
)}
|
||
שלח הצעה
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|