Files
legal-ai/web-ui/src/components/app-shell.tsx
Chaim ac279220c4 feat(goldset): interactive gold-set tagging page (#81.7/#81.8)
Replaces the CSV-edit workflow with an in-app tagging page so the chair/Dafna
can label the extraction-quality gold-set by clicking, and see validator
precision/recall live.

Schema (V29): halacha_goldset — a stratified, human-tagged evaluation batch
(is_holding / correct_type / quote_complete, NULL until tagged).

db.py:
- goldset_create_sample (stratified round-robin over case×rule_type, idempotent),
- goldset_list (items + halacha content + the machine's own labels),
- goldset_tag (partial — one field at a time for keyboard tagging),
- goldset_score (ports the script's P/R/F1: each validator scored as a
  not-a-holding detector against the human tags — the #81.8 input).

API: GET /api/goldset, POST /api/goldset/sample, GET /api/goldset/score,
PATCH /api/goldset/{id}.

web-ui:
- lib/api/goldset.ts (hooks),
- components/goldset/goldset-panel.tsx — card-per-item, keyboard-first
  (J/K nav, H/N holding, C/X quote), progress bar, hide-tagged toggle, and a
  collapsible live score table,
- app/goldset/page.tsx + nav link "מדגם-זהב" under ידע ולמידה.

Methodology guard kept explicit in UI + docstrings: tags are HUMAN ground truth,
no AI pre-fill (circular bias). Populated a 150-item stratified batch.

Verified: backend create/list/tag/score against the live DB; tsc --noEmit 0;
py_compile ok. (Local Turbopack build blocked by worktree symlink — CI builds clean.)

Invariants: G1 (eval set modeled at source in its own table); G2 (reuses the same
halacha_quality validators the extractor runs — no parallel scoring logic).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 21:52:05 +00:00

293 lines
11 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 type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Settings } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { GlobalSearch } from "@/components/global-search";
import { headerSubtitle } from "@/components/header-context";
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
import { usePendingApprovals } from "@/lib/api/chair";
/**
* Ezer Mishpati navigation shell — two-row header.
*
* Row 1 (brand): logo + dynamic context subtitle · global search · agent boards
* Row 2 (nav): work group · knowledge group · admin dropdown
*
* Editorial/judicial aesthetic preserved:
* - Navy background with a gold hairline rule (border-b-3)
* - Parchment text, gold accents on hover/active
* - Hebrew RTL throughout (set on <html> in layout.tsx)
* - Active item gets `aria-current="page"` and a gold underline anchored
* to the bottom border, so screen readers announce the section and
* sighted users see where they are.
*/
type NavItem = { href: string; label: string };
type NavGroup = { id: string; items: NavItem[] };
const NAV_GROUPS: NavGroup[] = [
{
id: "work",
items: [
{ href: "/", label: "בית" },
{ href: "/approvals", label: "מרכז אישורים" },
{ href: "/feedback", label: "הערות יו״ר" },
{ href: "/archive", label: "ארכיון" },
],
},
{
id: "knowledge",
items: [
{ href: "/precedents", label: "ספריית פסיקה" },
{ href: "/missing-precedents", label: "פסיקה חסרה" },
{ href: "/goldset", label: "מדגם-זהב" },
{ href: "/training", label: "אימון סגנון" },
{ href: "/methodology", label: "מתודולוגיה" },
],
},
];
const ADMIN_ITEMS: NavItem[] = [
{ href: "/skills", label: "מיומנויות" },
{ href: "/diagnostics", label: "אבחון" },
{ href: "/settings", label: "הגדרות" },
];
type AgentBoard = { prefix: string; label: string; hint: string };
const AGENT_BOARDS: AgentBoard[] = [
{ prefix: "CMP", label: "רישוי ובניה", hint: "תיקי 1xxx" },
{ prefix: "CMPA", label: "היטלי השבחה", hint: "תיקי 8xxx / 9xxx" },
];
const PAPERCLIP_BASE = "https://pc.nautilus.marcusgroup.org";
function isActive(pathname: string, href: string): boolean {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
}
export function AppShell({ children }: { children: ReactNode }) {
const pathname = usePathname();
const subtitle = headerSubtitle(pathname);
const adminActive = ADMIN_ITEMS.some((i) => isActive(pathname, i.href));
return (
<>
<header
className="
relative z-10 flex flex-col
bg-navy text-parchment
border-b-[3px] border-gold
shadow-md
"
>
{/* ─── Row 1 — brand bar (3-column grid) ─── */}
{/* Side columns flex 1fr each so the search column stays centered on
the viewport regardless of how wide the brand or agent labels grow. */}
<div className="grid grid-cols-[minmax(0,1fr)_minmax(280px,460px)_minmax(0,1fr)] items-center gap-4 px-10 pt-[14px] pb-2">
<Link
href="/"
className="flex items-baseline gap-3 hover:text-parchment min-w-0 justify-self-start"
>
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment whitespace-nowrap">
עוזר משפטי
</span>
<span
className="text-gold-soft text-sm font-medium truncate"
aria-live="polite"
>
{subtitle}
</span>
</Link>
<div className="w-full justify-self-center">
<GlobalSearch />
</div>
<DropdownMenu>
<DropdownMenuTrigger
className="
justify-self-end flex items-baseline gap-2 px-3 py-1.5 rounded
transition-colors outline-none
text-parchment/80 hover:text-parchment hover:bg-navy-soft/60
focus-visible:ring-2 focus-visible:ring-gold/60
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
"
aria-label="ניהול סוכנים — בחר ועדה"
>
<span className="font-display text-[1.45rem] font-bold tracking-[0.02em] text-parchment whitespace-nowrap">
ניהול סוכנים
</span>
<ChevronDown className="size-4 self-center text-gold-soft" aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={10} className="min-w-[240px]">
<DropdownMenuLabel className="text-xs text-muted-foreground text-center">
Paperclip פתח דאשבורד
</DropdownMenuLabel>
<DropdownMenuSeparator />
{AGENT_BOARDS.map((board) => (
<DropdownMenuItem key={board.prefix} asChild>
<a
href={`${PAPERCLIP_BASE}/${board.prefix}/dashboard`}
target="_blank"
rel="noreferrer noopener"
className="flex flex-col gap-0.5 cursor-pointer py-1.5"
>
<span className="font-medium whitespace-nowrap">{board.label}</span>
<span className="text-xs text-muted-foreground tracking-wide whitespace-nowrap">
<span className="font-mono">{board.prefix}</span> · {board.hint}
</span>
</a>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* ─── Row 2 — section nav ─── */}
<div className="flex items-center gap-3 px-10 pt-1 pb-[18px]">
<nav className="flex items-center gap-3" aria-label="ניווט ראשי">
{NAV_GROUPS.map((group, idx) => (
<div key={group.id} className="flex items-center">
{idx > 0 && (
<span
className="mx-2 h-4 w-px bg-parchment/20"
aria-hidden="true"
/>
)}
<div className="flex items-center gap-1">
{group.items.map((item) => (
<NavLink key={item.href} item={item} active={isActive(pathname, item.href)} />
))}
</div>
</div>
))}
</nav>
<DropdownMenu>
<DropdownMenuTrigger
className={`
relative ms-auto shrink-0 flex items-center gap-1.5
px-3 py-1.5 rounded text-sm transition-colors outline-none
focus-visible:ring-2 focus-visible:ring-gold/60
${adminActive
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
data-[state=open]:bg-navy-soft/80 data-[state=open]:text-parchment
`}
aria-label="הגדרות מערכת"
>
<Settings className="size-4" aria-hidden="true" />
<ChevronDown className="size-3" aria-hidden="true" />
{adminActive && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={10} className="min-w-[180px]">
<DropdownMenuLabel className="text-xs text-muted-foreground">
מערכת
</DropdownMenuLabel>
<DropdownMenuSeparator />
{ADMIN_ITEMS.map((item) => {
const active = isActive(pathname, item.href);
return (
<DropdownMenuItem key={item.href} asChild>
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`cursor-pointer ${active ? "font-semibold" : ""}`}
>
{item.label}
</Link>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main
id="main"
className="flex-1 w-full max-w-[1400px] mx-auto px-10 py-10"
>
{children}
</main>
</>
);
}
function NavLink({ item, active }: { item: NavItem; active: boolean }) {
return (
<Link
href={item.href}
aria-current={active ? "page" : undefined}
className={`
relative px-3 py-1.5 rounded text-sm transition-colors
${active
? "text-parchment font-semibold bg-navy-soft/80"
: "text-parchment/80 hover:text-parchment hover:bg-navy-soft/60"}
`}
>
<span>{item.label}</span>
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
{item.href === "/approvals" ? <ApprovalsBadge /> : null}
{active && (
<span
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
aria-hidden="true"
/>
)}
</Link>
);
}
/* Total pending-approvals badge next to "מרכז אישורים" — Dafna's outstanding
* human-gate items (halachot + missing precedents + feedback + qa_failed).
* Renders only when >0 so the nav stays quiet when everything is cleared. */
function ApprovalsBadge() {
const { data } = usePendingApprovals();
const total = data?.total_pending ?? 0;
if (!total) return null;
return (
<span
className="ms-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full bg-gold text-navy text-[0.65rem] font-semibold"
aria-label={`${total} פריטים ממתינים לאישורך`}
>
{total}
</span>
);
}
/* Small open-count badge next to "פסיקה חסרה" — only renders when >0
* so the nav stays quiet in normal operation. */
function MissingPrecedentsBadge() {
const { data: openCount } = useMissingPrecedentsOpenCount();
if (!openCount) return null;
return (
<span
className="ms-1 inline-flex items-center justify-center min-w-[1.25rem] h-4 px-1 rounded-full bg-gold text-navy text-[0.65rem] font-semibold"
aria-label={`${openCount} פסיקות חסרות פתוחות`}
>
{openCount}
</span>
);
}