מרכז אישורים — chair approval center (everything Dafna must approve, in one page) #19

Merged
chaim merged 1 commits from feat/chair-approval-center into main 2026-05-31 15:37:00 +00:00
4 changed files with 320 additions and 3 deletions
Showing only changes of commit 19d3dc81d0 - Show all commits

View File

@@ -0,0 +1,161 @@
"use client";
import Link from "next/link";
import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
usePendingApprovals,
type ApprovalCategory,
type ApprovalSeverity,
} from "@/lib/api/chair";
/**
* מרכז אישורים — דפנה (INV-G10).
*
* עמוד אחד שמרכז את כל השערים האנושיים הממתינים להכרעת היו"ר: אישור הלכות,
* פסיקה חסרה, הערות שטרם יושמו, תיקים שנכשלו ב-QA, וסקירת gold-set. המטרה:
* שאף פריט הדורש את אישורך לא יישכח. הנתונים נשלפים חי מ-/api/chair/pending.
*/
const SEVERITY_BADGE: Record<ApprovalSeverity, string> = {
high: "bg-gold text-navy border-transparent",
medium: "bg-gold-wash text-gold-deep border-gold/40",
low: "bg-rule-soft text-ink-muted border-rule",
ok: "bg-emerald-50 text-emerald-800 border-emerald-300/60",
};
function formatDate(iso?: string | null): string {
if (!iso) return "";
try {
return new Date(iso).toLocaleDateString("he-IL", {
day: "numeric",
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
function ApprovalCard({ cat }: { cat: ApprovalCategory }) {
const cleared = cat.count === 0;
return (
<Card className="bg-surface border-rule shadow-sm flex flex-col">
<CardContent className="px-6 py-5 flex flex-col gap-3 grow">
<div className="flex items-start justify-between gap-3">
<h2 className="text-navy text-lg mb-0 leading-snug">{cat.label}</h2>
<span
className={`inline-flex items-center justify-center min-w-[2.25rem] h-7 px-2 rounded-full border text-sm font-semibold ${SEVERITY_BADGE[cat.severity]}`}
aria-label={`${cat.count} פריטים ממתינים`}
>
{cat.count}
</span>
</div>
<p className="text-ink-muted text-[0.85rem] leading-relaxed mb-0">
{cat.description}
</p>
{cat.oldest_at && cat.count > 0 ? (
<p className="text-[0.78rem] text-gold-deep mb-0">
הישן ביותר ממתין מ־{formatDate(cat.oldest_at)}
</p>
) : null}
{cat.extra ? (
<p className="text-[0.78rem] text-ink-muted mb-0">
סך {cat.extra.total} שאילתות · {cat.extra.reviewed} אושרו על־ידך
</p>
) : null}
{cleared ? (
<p className="text-[0.85rem] text-emerald-700 mb-0">אין פריטים ממתינים </p>
) : cat.sample && cat.sample.length > 0 ? (
<ul className="space-y-1.5 mt-1">
{cat.sample.map((s, i) => (
<li key={i} className="text-[0.82rem] text-ink leading-snug border-s-2 border-rule ps-2.5">
<span className="line-clamp-2">{s.text || "—"}</span>
{s.source ? (
<span className="text-ink-muted text-[0.72rem]"> · {s.source}</span>
) : null}
</li>
))}
</ul>
) : null}
<div className="mt-auto pt-2">
{cat.href ? (
<Button asChild variant="outline" size="sm" className="border-rule">
<Link href={cat.href}>לטיפול </Link>
</Button>
) : (
<span className="text-[0.75rem] text-ink-muted">סקירה ידנית (ראה דוח FU-5)</span>
)}
</div>
</CardContent>
</Card>
);
}
export default function ApprovalsPage() {
const { data, isPending, error } = usePendingApprovals();
return (
<AppShell>
<section className="space-y-6">
<header className="space-y-1.5">
<nav className="text-[0.78rem] text-ink-muted mb-1">
<Link href="/" className="hover:text-gold-deep">בית</Link>
<span aria-hidden> · </span>
<span className="text-navy">מרכז אישורים</span>
</nav>
<div className="flex items-end justify-between gap-4 flex-wrap">
<div className="space-y-1">
<div className="text-[0.75rem] uppercase tracking-[0.12em] text-gold-deep">
שערים אנושיים · יו״ר הוועדה
</div>
<h1 className="text-navy mb-0">מרכז אישורים</h1>
<p className="text-ink-muted text-sm mt-1 max-w-2xl leading-relaxed">
כל מה שממתין להכרעתך במקום אחד כדי שאף פריט לא יישכח. מתעדכן חי.
</p>
</div>
{typeof data?.total_pending === "number" ? (
<div className="text-end">
<div className="text-3xl font-semibold text-navy leading-none">
{data.total_pending}
</div>
<div className="text-[0.72rem] uppercase tracking-[0.08em] text-ink-muted mt-1">
פריטים ממתינים
</div>
</div>
) : null}
</div>
</header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
{error ? (
<Card className="bg-surface border-rule">
<CardContent className="px-6 py-5 text-ink-muted text-sm">
שגיאה בטעינת מרכז האישורים. נסה לרענן.
</CardContent>
</Card>
) : isPending ? (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{[0, 1, 2, 3, 4].map((i) => (
<Card key={i} className="bg-surface border-rule shadow-sm">
<CardContent className="px-6 py-5 h-44 animate-pulse" />
</Card>
))}
</div>
) : (
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
{(data?.categories ?? []).map((cat) => (
<ApprovalCard key={cat.key} cat={cat} />
))}
</div>
)}
</section>
</AppShell>
);
}

View File

@@ -16,6 +16,7 @@ import {
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.
@@ -39,8 +40,9 @@ const NAV_GROUPS: NavGroup[] = [
{
id: "work",
items: [
{ href: "/", label: "בית" },
{ href: "/archive", label: "ארכיון" },
{ href: "/", label: "בית" },
{ href: "/approvals", label: "מרכז אישורים" },
{ href: "/archive", label: "ארכיון" },
],
},
{
@@ -244,6 +246,7 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
>
<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"
@@ -254,6 +257,23 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
);
}
/* 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() {

View File

@@ -0,0 +1,41 @@
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "./client";
/**
* Chair approval center (INV-G10) — aggregates every pending human-gate item
* (halacha approvals, missing precedents, unapplied feedback, QA-failed cases,
* gold-set review) so nothing Dafna must approve is forgotten.
*
* Hand-typed (not from the generated types.ts) because /api/chair/pending is a
* new endpoint; switch to the generated type after the next `npm run api:types`.
*/
export type ApprovalSeverity = "high" | "medium" | "low" | "ok";
export type ApprovalSample = { text: string; source: string };
export type ApprovalCategory = {
key: string;
label: string;
description: string;
count: number;
severity: ApprovalSeverity;
href: string | null;
oldest_at?: string | null;
sample?: ApprovalSample[];
extra?: { total: number; reviewed: number };
};
export type PendingApprovals = {
total_pending: number;
generated_at: string;
categories: ApprovalCategory[];
};
export function usePendingApprovals() {
return useQuery({
queryKey: ["chair", "pending"],
queryFn: () => apiRequest<PendingApprovals>("/api/chair/pending"),
refetchInterval: 60_000,
staleTime: 30_000,
});
}

View File

@@ -12,7 +12,7 @@ import subprocess
import sys
import time
from contextlib import asynccontextmanager
from datetime import date as date_type
from datetime import date as date_type, datetime, timezone
from pathlib import Path
from uuid import UUID, uuid4
@@ -4892,6 +4892,101 @@ async def api_chair_feedback_weekly_summary(days: int = 7, limit: int = 100):
return {"summary": "\n".join(lines), "entry_count": len(rows)}
@app.get("/api/chair/pending")
async def api_chair_pending():
"""מרכז אישורים — דפנה: מאגד את כל השערים האנושיים (INV-G10) הממתינים להכרעת
היו"ר במקום אחד, כדי שאף פריט לא יישכח. כל קטגוריה מחזירה ספירה + מדגם + קישור
למקום הטיפול. כל ספירה היא שאילתת-מקור ישירה (לא נגזרת מטמונה)."""
from pathlib import Path as _Path
pool = await db.get_pool()
categories: list[dict] = []
async with pool.acquire() as conn:
# 1) הלכות הממתינות לאישור (INV-QA1 / G10)
h_count = await conn.fetchval(
"SELECT count(*) FROM halachot WHERE review_status='pending_review'")
h_oldest = await conn.fetchval(
"SELECT min(created_at) FROM halachot WHERE review_status='pending_review'")
h_sample = await conn.fetch(
"SELECT h.rule_statement, coalesce(cl.case_name,'') AS case_name "
"FROM halachot h LEFT JOIN case_law cl ON cl.id=h.case_law_id "
"WHERE h.review_status='pending_review' ORDER BY h.created_at ASC LIMIT 5")
categories.append({
"key": "halachot", "label": "הלכות הממתינות לאישור",
"description": "הלכות שחולצו אוטומטית מפסיקה — נראות בחיפוש רק לאחר אישורך.",
"count": h_count, "severity": "high" if h_count else "ok",
"href": "/precedents", "oldest_at": h_oldest.isoformat() if h_oldest else None,
"sample": [{"text": (r["rule_statement"] or "")[:120], "source": r["case_name"]} for r in h_sample],
})
# 2) פסיקה חסרה בקורפוס (פתוחה)
mp_count = await conn.fetchval(
"SELECT count(*) FROM missing_precedents WHERE status='open'")
mp_sample = await conn.fetch(
"SELECT coalesce(citation,'') AS cite, coalesce(legal_topic, case_name, '') AS topic "
"FROM missing_precedents WHERE status='open' ORDER BY created_at DESC LIMIT 5")
categories.append({
"key": "missing_precedents", "label": "פסיקה חסרה בקורפוס",
"description": "ציטוטים מכתבי-טענות שעדיין אינם בקורפוס — להעלאה/סגירה.",
"count": mp_count, "severity": "medium" if mp_count else "ok",
"href": "/missing-precedents",
"sample": [{"text": r["cite"][:120], "source": r["topic"]} for r in mp_sample],
})
# 3) הערות יו"ר שטרם יושמו
cf_count = await conn.fetchval("SELECT count(*) FROM chair_feedback WHERE NOT resolved")
cf_sample = await conn.fetch(
"SELECT cf.feedback_text, coalesce(c.case_number,'') AS case_number "
"FROM chair_feedback cf LEFT JOIN cases c ON c.id=cf.case_id "
"WHERE NOT cf.resolved ORDER BY cf.created_at DESC LIMIT 5")
categories.append({
"key": "chair_feedback", "label": "הערות יו\"ר שטרם יושמו",
"description": "הערות שרשמת על טיוטות וטרם הופקו מהן לקחים/תיקונים.",
"count": cf_count, "severity": "medium" if cf_count else "ok",
"href": "/", "sample": [{"text": (r["feedback_text"] or "")[:120], "source": r["case_number"]} for r in cf_sample],
})
# 4) תיקים שנכשלו ב-QA
qa_rows = await conn.fetch(
"SELECT case_number, coalesce(title,'') AS title FROM cases WHERE status='qa_failed' ORDER BY updated_at DESC")
categories.append({
"key": "qa_failed", "label": "תיקים שנכשלו ב-QA",
"description": "תיקים שבדיקת-האיכות חסמה — דורשים התייחסותך לפני המשך.",
"count": len(qa_rows), "severity": "high" if qa_rows else "ok", "href": "/",
"sample": [{"text": r["case_number"], "source": r["title"]} for r in qa_rows[:5]],
})
# 5) סקירת gold-set (איכות-אחזור, FU-5) — מבוסס-קובץ, best-effort
try:
gp = _Path(__file__).resolve().parent.parent / "data" / "eval" / "gold-set.jsonl"
if gp.exists():
total = chair = 0
for line in gp.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
total += 1
if '"source": "chair"' in line or '"source":"chair"' in line:
chair += 1
unreviewed = total - chair
categories.append({
"key": "gold_set", "label": "סקירת gold-set (איכות אחזור)",
"description": "שאילתות-מבחן לאיכות-האחזור — לאשר/לתקן אילו תוצאות נכונות.",
"count": unreviewed, "severity": "low" if unreviewed else "ok",
"href": None, "extra": {"total": total, "reviewed": chair},
"sample": [],
})
except Exception:
logger.exception("chair/pending: gold-set read failed (skipped)")
total_pending = sum(c["count"] for c in categories if c["key"] != "gold_set")
return {
"total_pending": total_pending,
"generated_at": datetime.now(timezone.utc).isoformat(),
"categories": categories,
}
# ── Background Processing ─────────────────────────────────────────