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:
2026-05-31 15:36:29 +00:00
parent aee2140b0b
commit 19d3dc81d0
4 changed files with 320 additions and 3 deletions

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 ─────────────────────────────────────────