feat(training): Style Studio — upload, rich corpus, lessons, curator portrait, chat
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m7s
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:
@@ -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); }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user