All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
Six-phase upgrade of /training from a read-only dashboard into a full Style Studio for managing Daphna's style corpus. - Upload Sheet on /training: file → proofread preview → commit (no more CLI-only `upload-training` skill). - Rich corpus metadata: GET /api/training/corpus returns summary, outcome, key_principles, page_count, parties (regex), legal_citation, lessons_count. PATCH endpoint for chair edits. CorpusDetailDrawer with 4 tabs (details /content/lessons/patterns) replaces the bare table row. - LLM metadata enrichment: style_metadata_extractor + MCP tools (style_corpus_enrich, style_corpus_pending_enrichment) fill summary /outcome/key_principles via claude_session (free, host-side). - Per-decision lessons: new decision_lessons table + 4 REST endpoints + LessonsTab in drawer; hermes-curator now auto-posts findings as decision_lessons(source=curator). - Curator Portrait tab: prompt rendered with link to Gitea, recent curator findings, style_analyzer training prompts, propose-change form that writes proposals to data/curator-proposals/ for manual chair review (no auto-mutation of the agent file). - Style chat tab: SSE-streamed conversations with the style agent. New host-side pm2 service (legal-chat-service, port 8770) wraps claude CLI with stream-json + --resume continuation; FastAPI proxies via host.docker.internal. Zero API cost — uses chaim's claude.ai subscription. chat_conversations + chat_messages persist history. Architecture: keeps the existing rule that claude_session only runs on the host (not the container). The new legal-chat-service is the canonical bridge between the container and the local CLI for the chat feature; everything else (upload, metadata, lessons) stays within the container's existing capabilities. Audit script (scripts/audit_training_corpus.py) included for verifying which corpus rows still need enrichment. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
186 lines
6.3 KiB
TypeScript
186 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { Trash2, Sparkles } from "lucide-react";
|
||
import { toast } from "sonner";
|
||
import {
|
||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||
} from "@/components/ui/table";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { useCorpus, useDeleteCorpusEntry, type CorpusDecision } from "@/lib/api/training";
|
||
import { CorpusDetailDrawer } from "./corpus-detail-drawer";
|
||
|
||
/*
|
||
* Corpus tab: table of all decisions currently in the style corpus.
|
||
*
|
||
* Click any row → opens CorpusDetailDrawer with the enriched metadata
|
||
* + edit UI. The trash button is now in its own narrow column and uses
|
||
* stopPropagation so deleting a row doesn't also open the drawer.
|
||
*
|
||
* We use browser confirm() for the destructive action rather than a
|
||
* full shadcn AlertDialog because this is a single admin operation
|
||
* gated by an API-level safety net (FK cascade is best-effort but
|
||
* style_corpus DELETE returns 404 on missing rows, so the worst case
|
||
* is a no-op).
|
||
*/
|
||
|
||
function formatChars(n: number) {
|
||
return `${(n / 1000).toFixed(1)}K`;
|
||
}
|
||
|
||
function formatDate(iso: string) {
|
||
if (!iso) return "—";
|
||
try {
|
||
return new Date(iso).toLocaleDateString("he-IL");
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function Row({
|
||
item, onOpen,
|
||
}: { item: CorpusDecision; onOpen: () => void }) {
|
||
const del = useDeleteCorpusEntry();
|
||
const onDelete = async (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (!window.confirm(`למחוק את החלטה ${item.decision_number} מהקורפוס?`)) return;
|
||
try {
|
||
await del.mutateAsync(item.id);
|
||
toast.success("נמחק מהקורפוס");
|
||
} catch (e) {
|
||
toast.error(e instanceof Error ? e.message : "שגיאה במחיקה");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<TableRow
|
||
className="border-rule hover:bg-gold-wash/30 cursor-pointer"
|
||
onClick={onOpen}
|
||
>
|
||
<TableCell className="font-semibold text-navy tabular-nums">
|
||
{item.decision_number || "—"}
|
||
</TableCell>
|
||
<TableCell className="text-ink-muted tabular-nums">
|
||
{formatDate(item.decision_date)}
|
||
</TableCell>
|
||
<TableCell>
|
||
{item.subject_categories.length === 0 ? (
|
||
<span className="text-ink-light">—</span>
|
||
) : (
|
||
<div className="flex flex-wrap gap-1">
|
||
{item.subject_categories.slice(0, 3).map((s) => (
|
||
<Badge key={s} variant="outline"
|
||
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40">
|
||
{s}
|
||
</Badge>
|
||
))}
|
||
{item.subject_categories.length > 3 && (
|
||
<span className="text-[0.7rem] text-ink-muted">
|
||
+{item.subject_categories.length - 3}
|
||
</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-[0.78rem] text-ink-soft">
|
||
<div className="flex items-center gap-2">
|
||
<span className="truncate">{item.legal_citation || "—"}</span>
|
||
{item.lessons_count > 0 && (
|
||
<Badge variant="outline"
|
||
className="text-[0.7rem] bg-info-bg text-info border-info/40 shrink-0">
|
||
<Sparkles className="w-3 h-3 me-0.5" />
|
||
{item.lessons_count}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-ink-soft tabular-nums">
|
||
{formatChars(item.chars)}
|
||
{item.page_count > 0 && (
|
||
<span className="text-ink-muted text-[0.72rem] ms-1">
|
||
· {item.page_count} ע׳
|
||
</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-ink-muted tabular-nums text-[0.78rem]">
|
||
{formatDate(item.created_at)}
|
||
</TableCell>
|
||
<TableCell className="text-end">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={onDelete}
|
||
disabled={del.isPending}
|
||
aria-label={`הסר את ${item.decision_number || "החלטה זו"} מהקורפוס`}
|
||
className="text-danger hover:text-danger hover:bg-danger-bg"
|
||
>
|
||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
);
|
||
}
|
||
|
||
export function CorpusPanel() {
|
||
const { data, isPending, error } = useCorpus();
|
||
const [selected, setSelected] = useState<CorpusDecision | null>(null);
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="rounded bg-danger-bg border border-danger/40 px-6 py-5 text-danger text-center">
|
||
{error.message}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="rounded-lg border border-rule bg-surface shadow-sm overflow-hidden">
|
||
<Table>
|
||
<TableHeader className="bg-rule-soft/60">
|
||
<TableRow className="border-rule">
|
||
<TableHead className="text-navy text-right">מס׳ החלטה</TableHead>
|
||
<TableHead className="text-navy text-right">תאריך</TableHead>
|
||
<TableHead className="text-navy text-right">נושאים</TableHead>
|
||
<TableHead className="text-navy text-right">מראה מקום</TableHead>
|
||
<TableHead className="text-navy text-right">תווים / עמודים</TableHead>
|
||
<TableHead className="text-navy text-right">נוסף בתאריך</TableHead>
|
||
<TableHead className="text-navy" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{isPending ? (
|
||
[...Array(4)].map((_, i) => (
|
||
<TableRow key={i} className="border-rule">
|
||
{[...Array(7)].map((_, j) => (
|
||
<TableCell key={j}>
|
||
<Skeleton className="h-4 w-24" />
|
||
</TableCell>
|
||
))}
|
||
</TableRow>
|
||
))
|
||
) : data?.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={7} className="text-center text-ink-muted py-12">
|
||
הקורפוס ריק
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
data?.map((item) => (
|
||
<Row key={item.id} item={item} onOpen={() => setSelected(item)} />
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<CorpusDetailDrawer
|
||
decision={selected}
|
||
onOpenChange={(open) => { if (!open) setSelected(null); }}
|
||
/>
|
||
</>
|
||
);
|
||
}
|