diff --git a/web-ui/src/app/approvals/page.tsx b/web-ui/src/app/approvals/page.tsx new file mode 100644 index 0000000..0480560 --- /dev/null +++ b/web-ui/src/app/approvals/page.tsx @@ -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 = { + 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 ( + + +
+

{cat.label}

+ + {cat.count} + +
+ +

+ {cat.description} +

+ + {cat.oldest_at && cat.count > 0 ? ( +

+ הישן ביותר ממתין מ־{formatDate(cat.oldest_at)} +

+ ) : null} + + {cat.extra ? ( +

+ סך {cat.extra.total} שאילתות · {cat.extra.reviewed} אושרו על־ידך +

+ ) : null} + + {cleared ? ( +

אין פריטים ממתינים ✓

+ ) : cat.sample && cat.sample.length > 0 ? ( +
    + {cat.sample.map((s, i) => ( +
  • + {s.text || "—"} + {s.source ? ( + · {s.source} + ) : null} +
  • + ))} +
+ ) : null} + +
+ {cat.href ? ( + + ) : ( + סקירה ידנית (ראה דוח FU-5) + )} +
+
+
+ ); +} + +export default function ApprovalsPage() { + const { data, isPending, error } = usePendingApprovals(); + + return ( + +
+
+ +
+
+
+ שערים אנושיים · יו״ר הוועדה +
+

מרכז אישורים

+

+ כל מה שממתין להכרעתך במקום אחד — כדי שאף פריט לא יישכח. מתעדכן חי. +

+
+ {typeof data?.total_pending === "number" ? ( +
+
+ {data.total_pending} +
+
+ פריטים ממתינים +
+
+ ) : null} +
+
+ +
+ + {error ? ( + + + שגיאה בטעינת מרכז האישורים. נסה לרענן. + + + ) : isPending ? ( +
+ {[0, 1, 2, 3, 4].map((i) => ( + + + + ))} +
+ ) : ( +
+ {(data?.categories ?? []).map((cat) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/web-ui/src/components/app-shell.tsx b/web-ui/src/components/app-shell.tsx index b13669d..91cc20d 100644 --- a/web-ui/src/components/app-shell.tsx +++ b/web-ui/src/components/app-shell.tsx @@ -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 }) { > {item.label} {item.href === "/missing-precedents" ? : null} + {item.href === "/approvals" ? : null} {active && ( 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 ( + + {total} + + ); +} + /* Small open-count badge next to "פסיקה חסרה" — only renders when >0 * so the nav stays quiet in normal operation. */ function MissingPrecedentsBadge() { diff --git a/web-ui/src/lib/api/chair.ts b/web-ui/src/lib/api/chair.ts new file mode 100644 index 0000000..50281d9 --- /dev/null +++ b/web-ui/src/lib/api/chair.ts @@ -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("/api/chair/pending"), + refetchInterval: 60_000, + staleTime: 30_000, + }); +} diff --git a/web/app.py b/web/app.py index 364c54d..9d44865 100644 --- a/web/app.py +++ b/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 ─────────────────────────────────────────