Files
legal-ai/mcp-server/src/legal_mcp/services/metrics.py
Chaim a4b4ebbbb1
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 5s
feat(halacha): #84.7 — queue throughput + quality metrics
הרחבת 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>
2026-06-11 16:42:22 +00:00

280 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""מדדי הצלחה (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,
}