feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
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>
This commit is contained in:
2026-05-27 10:06:22 +00:00
parent 0629f19d5f
commit bb0cd7c6a2
23 changed files with 4568 additions and 75 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { Trash2 } from "lucide-react";
import { useState } from "react";
import { Trash2, Sparkles } from "lucide-react";
import { toast } from "sonner";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
@@ -9,12 +10,20 @@ 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, with a
* single destructive action (remove from corpus). Uses browser confirm() for
* the confirmation — a full shadcn AlertDialog would be overkill for an
* admin-only destructive action with a server-side safety net.
* 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) {
@@ -30,9 +39,12 @@ function formatDate(iso: string) {
}
}
function Row({ item }: { item: CorpusDecision }) {
function Row({
item, onOpen,
}: { item: CorpusDecision; onOpen: () => void }) {
const del = useDeleteCorpusEntry();
const onDelete = async () => {
const onDelete = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!window.confirm(`למחוק את החלטה ${item.decision_number} מהקורפוס?`)) return;
try {
await del.mutateAsync(item.id);
@@ -43,7 +55,10 @@ function Row({ item }: { item: CorpusDecision }) {
};
return (
<TableRow className="border-rule hover:bg-gold-wash/30">
<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>
@@ -55,20 +70,39 @@ function Row({ item }: { item: CorpusDecision }) {
<span className="text-ink-light"></span>
) : (
<div className="flex flex-wrap gap-1">
{item.subject_categories.map((s) => (
<Badge
key={s}
variant="outline"
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
>
{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)}
@@ -91,6 +125,7 @@ function Row({ item }: { item: CorpusDecision }) {
export function CorpusPanel() {
const { data, isPending, error } = useCorpus();
const [selected, setSelected] = useState<CorpusDecision | null>(null);
if (error) {
return (
@@ -101,40 +136,50 @@ export function CorpusPanel() {
}
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" />
</TableRow>
</TableHeader>
<TableBody>
{isPending ? (
[...Array(4)].map((_, i) => (
<TableRow key={i} className="border-rule">
{[...Array(6)].map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-24" />
</TableCell>
))}
</TableRow>
))
) : data?.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-ink-muted py-12">
הקורפוס ריק
</TableCell>
<>
<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>
) : (
data?.map((item) => <Row key={item.id} item={item} />)
)}
</TableBody>
</Table>
</div>
</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); }}
/>
</>
);
}