feat(ui): chair approval center — one page for every pending human-gate (#63 follow-up)
Dafna asked for a single page under the prod site listing everything she needs
to approve, so nothing is forgotten — the visible embodiment of INV-G10 (human
gates) and INV-QA1 (halacha backlog must be visible).
Backend — GET /api/chair/pending aggregates every pending chair gate, each as a
direct source query (count + sample + action link):
- halachot review backlog (review_status='pending_review') + oldest
- open missing precedents
- unresolved chair_feedback
- qa_failed cases
- gold-set review (FU-5, file-based, best-effort: total vs source='chair')
Frontend — /approvals page ("מרכז אישורים"):
- src/lib/api/chair.ts — usePendingApprovals() (hand-typed until next api:types)
- src/app/approvals/page.tsx — card per category, severity-coloured count, sample
rows, oldest-pending date, link to where each is handled; live (60s refetch)
- app-shell nav: "מרכז אישורים" in the work group + total-pending badge (quiet at 0)
Live counts at build time surfaced the value immediately: 226 open missing
precedents, 178 pending halachot, 20 unapplied feedback notes, 1 qa_failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
161
web-ui/src/app/approvals/page.tsx
Normal file
161
web-ui/src/app/approvals/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
import { GlobalSearch } from "@/components/global-search";
|
import { GlobalSearch } from "@/components/global-search";
|
||||||
import { headerSubtitle } from "@/components/header-context";
|
import { headerSubtitle } from "@/components/header-context";
|
||||||
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
|
import { useMissingPrecedentsOpenCount } from "@/lib/api/missing-precedents";
|
||||||
|
import { usePendingApprovals } from "@/lib/api/chair";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ezer Mishpati navigation shell — two-row header.
|
* Ezer Mishpati navigation shell — two-row header.
|
||||||
@@ -39,8 +40,9 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
{
|
{
|
||||||
id: "work",
|
id: "work",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/", label: "בית" },
|
{ href: "/", label: "בית" },
|
||||||
{ href: "/archive", label: "ארכיון" },
|
{ href: "/approvals", label: "מרכז אישורים" },
|
||||||
|
{ href: "/archive", label: "ארכיון" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -244,6 +246,7 @@ function NavLink({ item, active }: { item: NavItem; active: boolean }) {
|
|||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
|
{item.href === "/missing-precedents" ? <MissingPrecedentsBadge /> : null}
|
||||||
|
{item.href === "/approvals" ? <ApprovalsBadge /> : null}
|
||||||
{active && (
|
{active && (
|
||||||
<span
|
<span
|
||||||
className="absolute -bottom-[19px] inset-x-2 h-[2px] bg-gold"
|
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
|
/* Small open-count badge next to "פסיקה חסרה" — only renders when >0
|
||||||
* so the nav stays quiet in normal operation. */
|
* so the nav stays quiet in normal operation. */
|
||||||
function MissingPrecedentsBadge() {
|
function MissingPrecedentsBadge() {
|
||||||
|
|||||||
41
web-ui/src/lib/api/chair.ts
Normal file
41
web-ui/src/lib/api/chair.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
97
web/app.py
97
web/app.py
@@ -12,7 +12,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
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 pathlib import Path
|
||||||
from uuid import UUID, uuid4
|
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)}
|
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 ─────────────────────────────────────────
|
# ── Background Processing ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user