מרכז אישורים — chair approval center (everything Dafna must approve, in one page) #19
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 { 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() {
|
||||
|
||||
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 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 ─────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user