All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
הרחבת metrics.halacha_backlog (G2 — אותה פונקציה, אין מסלול-מטריקות מקביל; כבר מוגשת דרך /api/system/diagnostics) במדדי-תור שחסרו: - throughput_24h / throughput_7d — קצב-ההחלטות (reviewed_at בחלון). - approve/reject/defer ratios (קודם רק approve). - median_seconds_per_decision — זמן-חציוני-לפריט, מחושב רק על פערים [1ש',30דק'] כדי לבטא קצב-אנושי אינטראקטיבי (פער-0 של batch panel/auto מוחרג, וגם פערים >30דק' בין sessions). 41.4s בייצור; None כשהתור כולו batch. - by_reviewer — פילוח panel/auto/chair/other (מי החליט). spot-check post-hoc כבר מכוסה ע"י halacha_panel_audit.py (re-judge של מאושרי-פאנל). _median חולץ כ-helper טהור ובדיק. invariants: G2 (הרחבת מטריקה קיימת) · INV-QA1/G10 (נראות שער-האנוש — גם מהירות וגם איכות). tests: 4 offline (_median) + אומת חי על ה-DB (476 pending, throughput 115/956, median 41.4s). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
280 lines
12 KiB
Python
280 lines
12 KiB
Python
"""מדדי הצלחה (KPIs) לתהליך כתיבת החלטות.
|
||
|
||
מדדים:
|
||
1. אחוז שינוי — השוואת טיוטה לגרסה סופית (יעד: <10%)
|
||
2. אפס הזיות — ספירת הפניות לא מבוססות
|
||
3. מענה לכל טענה — כיסוי טענות בדיון
|
||
4. משקלות בטווח — עמידה ביחסי הזהב
|
||
5. רקע ניטרלי — ללא מילות שיפוט
|
||
6. זמן עיבוד — מקליטה עד טיוטה
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import logging
|
||
from datetime import datetime
|
||
from uuid import UUID
|
||
|
||
from legal_mcp.services import db
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
async def get_case_metrics(case_id: UUID) -> dict:
|
||
"""חישוב מדדים לתיק בודד."""
|
||
case = await db.get_case(case_id)
|
||
if not case:
|
||
raise ValueError(f"Case {case_id} not found")
|
||
|
||
decision = await db.get_decision_by_case(case_id)
|
||
pool = await db.get_pool()
|
||
|
||
metrics = {
|
||
"case_number": case["case_number"],
|
||
"title": case.get("title", ""),
|
||
"status": case.get("status", ""),
|
||
}
|
||
|
||
# 1. Change percentage (if final version exists)
|
||
if decision and decision.get("status") == "final":
|
||
async with pool.acquire() as conn:
|
||
# Get draft word count
|
||
draft_words = await conn.fetchval(
|
||
"SELECT SUM(word_count) FROM decision_blocks WHERE decision_id = $1",
|
||
UUID(decision["id"]),
|
||
)
|
||
metrics["draft_words"] = draft_words or 0
|
||
# Change percent is stored during learning loop
|
||
metrics["change_percent"] = None # populated from learning_loop results
|
||
else:
|
||
metrics["draft_words"] = 0
|
||
metrics["change_percent"] = None
|
||
|
||
# 2. QA results
|
||
async with pool.acquire() as conn:
|
||
qa_rows = await conn.fetch(
|
||
"SELECT check_name, passed, severity, errors FROM qa_results WHERE case_id = $1",
|
||
case_id,
|
||
)
|
||
|
||
if qa_rows:
|
||
qa_results = {}
|
||
for row in qa_rows:
|
||
errors = json.loads(row["errors"]) if isinstance(row["errors"], str) else row["errors"]
|
||
qa_results[row["check_name"]] = {
|
||
"passed": row["passed"],
|
||
"severity": row["severity"],
|
||
"error_count": len(errors) if errors else 0,
|
||
}
|
||
metrics["qa"] = qa_results
|
||
metrics["qa_passed"] = all(r["passed"] for r in qa_results.values())
|
||
metrics["qa_critical_failures"] = sum(
|
||
1 for r in qa_results.values()
|
||
if not r["passed"] and r["severity"] == "critical"
|
||
)
|
||
else:
|
||
metrics["qa"] = None
|
||
metrics["qa_passed"] = None
|
||
|
||
# 3. Claims coverage
|
||
claims = await db.get_claims(case_id)
|
||
metrics["total_claims"] = len(claims)
|
||
|
||
# 4. Documents
|
||
docs = await db.list_documents(case_id)
|
||
metrics["total_documents"] = len(docs)
|
||
|
||
# 5. Processing time
|
||
if docs and decision:
|
||
first_doc_time = min(
|
||
d.get("created_at", datetime.max) for d in docs
|
||
if d.get("created_at")
|
||
)
|
||
decision_time = decision.get("created_at")
|
||
if first_doc_time and decision_time:
|
||
delta = decision_time - first_doc_time
|
||
metrics["processing_hours"] = round(delta.total_seconds() / 3600, 1)
|
||
else:
|
||
metrics["processing_hours"] = None
|
||
else:
|
||
metrics["processing_hours"] = None
|
||
|
||
return metrics
|
||
|
||
|
||
def _median(values: list[float]) -> float | None:
|
||
"""Median of a numeric list (None if empty). Pure — unit-tested."""
|
||
s = sorted(v for v in values if v is not None)
|
||
if not s:
|
||
return None
|
||
mid = len(s) // 2
|
||
return s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2
|
||
|
||
|
||
async def halacha_backlog(conn) -> dict:
|
||
"""תור אישור-ההלכות (GAP-14 / INV-QA1 / G10) — נראות ה-backlog האנושי.
|
||
|
||
הלכות נכנסות כ-`pending_review` ובלתי-נראות לחיפוש עד אישור היו"ר; בלי ספירה
|
||
גלויה, אישור-חסר נשאר סמוי (10/19 התגלה במקרה). מקבל connection פתוח כדי
|
||
שאפשר יהיה לשלב בסנאפ-שוט קיים (get_dashboard, /api/system/diagnostics).
|
||
|
||
כולל גם מדדי-תור (#84.7): throughput (24ש'/7ימים), יחסי approve/reject/defer,
|
||
זמן-חציוני-לפריט (פער בין החלטות עוקבות בתוך session של 30 דק'), ופילוח
|
||
מי-החליט (panel/auto/chair) — כדי לראות גם מהירות וגם איכות, לא רק backlog.
|
||
"""
|
||
rows = await conn.fetch(
|
||
"SELECT review_status, COUNT(*) AS n FROM halachot GROUP BY review_status"
|
||
)
|
||
counts = {r["review_status"]: r["n"] for r in rows}
|
||
oldest = await conn.fetchval(
|
||
"SELECT MIN(created_at) FROM halachot WHERE review_status = 'pending_review'"
|
||
)
|
||
# #84.7 — split the pending bucket: how many are genuine candidates (clean)
|
||
# vs flagged 'needs extraction fix', and the breakdown by flag, so the chair
|
||
# sees how much of the backlog is real review vs extraction noise.
|
||
pending_clean = await conn.fetchval(
|
||
"SELECT COUNT(*) FROM halachot WHERE review_status = 'pending_review' "
|
||
"AND COALESCE(array_length(quality_flags, 1), 0) = 0"
|
||
)
|
||
flag_rows = await conn.fetch(
|
||
"SELECT flag, COUNT(*) AS n FROM ("
|
||
" SELECT unnest(quality_flags) AS flag FROM halachot "
|
||
" WHERE review_status = 'pending_review'"
|
||
") t GROUP BY flag ORDER BY n DESC"
|
||
)
|
||
pending_total = counts.get("pending_review", 0)
|
||
reviewed = counts.get("approved", 0) + counts.get("rejected", 0) + counts.get("published", 0)
|
||
|
||
# ── #84.7 queue throughput + quality ──────────────────────────────────────
|
||
# throughput windows (decisions = anything with a reviewed_at stamp)
|
||
tp = await conn.fetchrow(
|
||
"SELECT COUNT(*) FILTER (WHERE reviewed_at >= now() - interval '24 hours') AS d24, "
|
||
" COUNT(*) FILTER (WHERE reviewed_at >= now() - interval '7 days') AS d7 "
|
||
"FROM halachot WHERE reviewed_at IS NOT NULL"
|
||
)
|
||
# who decided — panel (tri-model), auto (confidence gate), chair (human), other
|
||
who_rows = await conn.fetch(
|
||
"SELECT CASE "
|
||
" WHEN reviewer LIKE 'panel:%' THEN 'panel' "
|
||
" WHEN reviewer LIKE 'auto-approved%' THEN 'auto' "
|
||
" WHEN reviewer LIKE 'chair%' THEN 'chair' "
|
||
" ELSE 'other' END AS who, COUNT(*) AS n "
|
||
"FROM halachot WHERE reviewed_at IS NOT NULL GROUP BY 1"
|
||
)
|
||
by_reviewer = {r["who"]: r["n"] for r in who_rows}
|
||
# time-per-item proxy: median seconds between consecutive HAND-PACED
|
||
# decisions — gaps in [1s, 30min]. Excludes 0-second gaps (batch operations
|
||
# like panel/auto stamp many rows with the same reviewed_at) and >30-min gaps
|
||
# (between sessions), so the number reflects interactive review pacing, not
|
||
# machine throughput. None when the queue is entirely batch-decided.
|
||
gap_rows = await conn.fetch(
|
||
"SELECT EXTRACT(EPOCH FROM (reviewed_at - prev)) AS gap FROM ("
|
||
" SELECT reviewed_at, LAG(reviewed_at) OVER (ORDER BY reviewed_at) AS prev "
|
||
" FROM halachot WHERE reviewed_at IS NOT NULL"
|
||
") t WHERE prev IS NOT NULL "
|
||
"AND reviewed_at - prev BETWEEN interval '1 second' AND interval '30 minutes'"
|
||
)
|
||
median_secs = _median([float(r["gap"]) for r in gap_rows if r["gap"] is not None])
|
||
|
||
return {
|
||
"pending_review": pending_total,
|
||
"pending_clean": pending_clean, # real review candidates (#84.1)
|
||
"pending_flagged": pending_total - pending_clean, # needs-fix bucket
|
||
"approved": counts.get("approved", 0),
|
||
"rejected": counts.get("rejected", 0),
|
||
"deferred": counts.get("deferred", 0),
|
||
"published": counts.get("published", 0),
|
||
"total": sum(counts.values()),
|
||
"reviewed_total": reviewed,
|
||
"approve_ratio": round(counts.get("approved", 0) / reviewed, 3) if reviewed else None,
|
||
"reject_ratio": round(counts.get("rejected", 0) / reviewed, 3) if reviewed else None,
|
||
"defer_ratio": (round(counts.get("deferred", 0) / (reviewed + counts.get("deferred", 0)), 3)
|
||
if (reviewed + counts.get("deferred", 0)) else None),
|
||
"pending_by_flag": {r["flag"]: r["n"] for r in flag_rows},
|
||
"oldest_pending_at": oldest.isoformat() if oldest else None,
|
||
# #84.7 throughput + quality
|
||
"throughput_24h": tp["d24"] if tp else 0,
|
||
"throughput_7d": tp["d7"] if tp else 0,
|
||
"median_seconds_per_decision": round(median_secs, 1) if median_secs is not None else None,
|
||
"by_reviewer": by_reviewer,
|
||
}
|
||
|
||
|
||
async def get_dashboard() -> dict:
|
||
"""דשבורד כולל — סיכום מדדים על כל התיקים."""
|
||
pool = await db.get_pool()
|
||
|
||
async with pool.acquire() as conn:
|
||
# Case counts by status
|
||
status_rows = await conn.fetch(
|
||
"SELECT status, COUNT(*) as cnt FROM cases GROUP BY status ORDER BY cnt DESC"
|
||
)
|
||
cases_by_status = {r["status"]: r["cnt"] for r in status_rows}
|
||
|
||
# Total counts
|
||
total_cases = await conn.fetchval("SELECT COUNT(*) FROM cases")
|
||
total_docs = await conn.fetchval("SELECT COUNT(*) FROM documents")
|
||
total_claims = await conn.fetchval("SELECT COUNT(*) FROM claims")
|
||
total_chunks = await conn.fetchval("SELECT COUNT(*) FROM document_chunks")
|
||
total_decisions = await conn.fetchval("SELECT COUNT(*) FROM decisions")
|
||
total_corpus = await conn.fetchval("SELECT COUNT(*) FROM style_corpus")
|
||
total_patterns = await conn.fetchval("SELECT COUNT(*) FROM style_patterns")
|
||
total_case_law = await conn.fetchval("SELECT COUNT(*) FROM case_law")
|
||
non_searchable_case_law = await conn.fetchval(
|
||
"SELECT COUNT(*) FROM case_law WHERE NOT searchable"
|
||
)
|
||
cases_with_stale_blocks = await conn.fetchval(
|
||
"SELECT COUNT(*) FROM cases WHERE blocks_stale"
|
||
)
|
||
stale_embedding_case_law = await conn.fetchval(
|
||
"SELECT COUNT(*) FROM case_law "
|
||
"WHERE coalesce(full_text,'') <> '' AND content_hash IS DISTINCT FROM indexed_hash")
|
||
|
||
# QA summary
|
||
qa_total = await conn.fetchval("SELECT COUNT(DISTINCT case_id) FROM qa_results")
|
||
qa_passed = await conn.fetchval(
|
||
"""SELECT COUNT(DISTINCT case_id) FROM qa_results
|
||
WHERE case_id NOT IN (
|
||
SELECT case_id FROM qa_results WHERE passed = false AND severity = 'critical'
|
||
)"""
|
||
)
|
||
|
||
# Final decisions
|
||
final_count = await conn.fetchval(
|
||
"SELECT COUNT(*) FROM decisions WHERE status = 'final'"
|
||
)
|
||
|
||
# Average words per decision
|
||
avg_words = await conn.fetchval(
|
||
"SELECT AVG(total_words) FROM decisions WHERE total_words > 0"
|
||
)
|
||
|
||
# Halacha review backlog (GAP-14 / INV-QA1 / G10)
|
||
backlog = await halacha_backlog(conn)
|
||
|
||
return {
|
||
"summary": {
|
||
"total_cases": total_cases,
|
||
"total_documents": total_docs,
|
||
"total_claims": total_claims,
|
||
"total_chunks": total_chunks,
|
||
"total_decisions": total_decisions,
|
||
"final_decisions": final_count,
|
||
"style_corpus": total_corpus,
|
||
"style_patterns": total_patterns,
|
||
"case_law_entries": total_case_law,
|
||
"non_searchable_case_law": non_searchable_case_law,
|
||
"cases_with_stale_blocks": cases_with_stale_blocks,
|
||
"stale_embedding_case_law": stale_embedding_case_law,
|
||
},
|
||
"cases_by_status": cases_by_status,
|
||
"halacha_backlog": backlog,
|
||
"qa": {
|
||
"cases_validated": qa_total,
|
||
"cases_passed": qa_passed,
|
||
"pass_rate": round(qa_passed / qa_total * 100, 1) if qa_total else None,
|
||
},
|
||
"avg_decision_words": round(avg_words) if avg_words else None,
|
||
}
|