Native, Obsidian-graph-view-like network of the precedent corpus, rendered
in web-ui from a read-only projection of the live DB. Replaces the idea of
exporting to an external Obsidian vault (which would be a parallel, drifting
copy of the corpus — the exact root cause G2 forbids).
The graph edges already existed in the data model; this only surfaces them:
nodes = precedents (case_law) + synthesized topic/practice-area hubs;
edges = cites (precedent_internal_citations) + same_chain (case_law_relations)
+ tagged/in_area (subject_tags / practice_area membership). Node size =
incoming-citation count (index-backed GROUP BY on idx_pic_target). Click a
node → local-graph neighborhood focus; panel deep-links to /precedents/[id].
Backend (read-only, SELECT only — G2):
- web/graph_api.py — Pydantic models (CorpusGraph/GraphNode/GraphEdge, so
OpenAPI emits real types — UI2) + SQL assembly over the shared db.get_pool().
- web/app.py — GET /api/graph/corpus, GET /api/graph/node/{id}/neighborhood,
both with explicit response_model. practice_area validated against the
closed enum (G5); both endpoints write nothing.
Frontend:
- react-force-graph-2d (canvas/d3-force), loaded via next/dynamic ssr:false.
- /graph page + nav entry; graph.ts TanStack hooks; filter panel (practice_area
/ source / min-citations / search / node-type toggles), node detail panel,
hover+selection neighborhood highlight. Explicit error handling (UI4).
Not a retrieval path (03-retrieval): returns graph topology, never ranked
search results. Halacha nodes + corroboration/equivalence edges are Phase 2,
already gated behind the node_types param (no contract change needed).
SQL validated read-only against the live DB (142 precedents, 85 resolved
citations, JSONB tag expansion, ANY(uuid[]) edge + BFS queries). web-ui lint
+ build pass; /graph in the route table.
Invariants: keeps G2 (single source of truth — live projection, no parallel
store), G5 (corpus separation filtered server-side), UI2 (response models),
UI4 (no swallowed UI errors).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
"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: "/graph", label: "מפת הקורפוס" },
|
||
{ href: "/digests", 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>
|
||
);
|
||
}
|