#!/usr/bin/env python3 """AI 管家金标准评测脚本。 支持三种模式: 1) lint-only:仅校验数据集结构与覆盖率。 2) online:调用 /rag/ask 实际评测。 3) offline:读取 answers 文件评测。 """ from __future__ import annotations import argparse import json import os import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any from backend.app.utils.timezone import now_cn_aware try: import httpx except Exception: # pragma: no cover httpx = None @dataclass class EvalCase: id: str question: str expected_any: list[str] expected_all: list[str] forbidden_any: list[str] expected_query_type: str | None is_followup: bool group: str | None @dataclass class EvalResult: case_id: str passed: bool answer: str query_type: str score: float hit_any: bool hit_all: bool hit_query_type: bool forbidden_hit: bool hallucination_hit: bool expected_any: list[str] expected_all: list[str] forbidden_any: list[str] def _load_jsonl_cases(path: Path) -> list[EvalCase]: rows: list[EvalCase] = [] with path.open("r", encoding="utf-8") as f: for idx, raw in enumerate(f, start=1): line = raw.strip() if not line: continue data = json.loads(line) case_id = str(data.get("id") or f"L{idx:03d}") question = str(data.get("question") or "").strip() if not question: raise ValueError(f"line {idx} missing question") rows.append( EvalCase( id=case_id, question=question, expected_any=[str(x) for x in (data.get("expected_any") or []) if str(x).strip()], expected_all=[str(x) for x in (data.get("expected_all") or []) if str(x).strip()], forbidden_any=[str(x) for x in (data.get("forbidden_any") or []) if str(x).strip()], expected_query_type=(str(data.get("expected_query_type")).strip() if data.get("expected_query_type") else None), is_followup=bool(data.get("is_followup", False)), group=(str(data.get("group")).strip() if data.get("group") else None), ) ) return rows def _lint_cases(cases: list[EvalCase]) -> dict[str, Any]: unique_ids = len({c.id for c in cases}) followups = sum(1 for c in cases if c.is_followup) with_query_type = sum(1 for c in cases if c.expected_query_type) question_lens = [len(c.question) for c in cases] avg_len = round(sum(question_lens) / len(question_lens), 2) if question_lens else 0 return { "count": len(cases), "unique_ids": unique_ids, "followups": followups, "with_query_type": with_query_type, "avg_question_len": avg_len, "valid": len(cases) >= 50 and unique_ids == len(cases) and followups > 0, } def _load_offline_answers(path: Path) -> dict[str, dict[str, Any]]: payload = json.loads(path.read_text(encoding="utf-8")) if isinstance(payload, dict) and "answers" in payload and isinstance(payload["answers"], list): rows = payload["answers"] elif isinstance(payload, list): rows = payload else: raise ValueError("offline answers 必须是 list 或包含 answers 字段") result: dict[str, dict[str, Any]] = {} for row in rows: if not isinstance(row, dict): continue rid = str(row.get("id") or "").strip() if not rid: continue result[rid] = { "answer": str(row.get("answer") or ""), "query_type": str(row.get("query_type") or "unknown"), "confidence": float(row.get("confidence") or 0.0), } return result async def _ask_online( *, api_url: str, token: str | None, question: str, project_id: int | None, timeout_seconds: float, ) -> dict[str, Any]: if httpx is None: raise RuntimeError("缺少 httpx 依赖,无法执行在线评测") headers = {"Content-Type": "application/json"} if token: headers["Authorization"] = f"Bearer {token}" body: dict[str, Any] = { "question": question, "asked_via": "pmflow", } if project_id is not None: body["project_id"] = int(project_id) async with httpx.AsyncClient(timeout=timeout_seconds, trust_env=False) as client: resp = await client.post(api_url, headers=headers, json=body) resp.raise_for_status() data = resp.json() if isinstance(data, dict) and "data" in data and isinstance(data.get("data"), dict): return data["data"] if isinstance(data, dict): return data return {} def _contains_any(text: str, keywords: list[str]) -> bool: if not keywords: return True lowered = text.lower() return any(str(k).lower() in lowered for k in keywords) def _contains_all(text: str, keywords: list[str]) -> bool: if not keywords: return True lowered = text.lower() return all(str(k).lower() in lowered for k in keywords) def _find_forbidden(text: str, keywords: list[str]) -> bool: if not keywords: return False lowered = text.lower() return any(str(k).lower() in lowered for k in keywords) def _is_hallucination(answer: str, forbidden: list[str]) -> bool: lowered = str(answer or "").lower() if _find_forbidden(lowered, forbidden): return True return not lowered.strip() def _evaluate_case(case: EvalCase, payload: dict[str, Any]) -> EvalResult: answer = str(payload.get("answer") or "").strip() query_type = str(payload.get("query_type") or "unknown").strip().lower() confidence = float(payload.get("confidence") or 0.0) hit_any = _contains_any(answer, case.expected_any) hit_all = _contains_all(answer, case.expected_all) forbidden_hit = _find_forbidden(answer, case.forbidden_any) hallucination_hit = _is_hallucination(answer, case.forbidden_any) hit_query_type = True if case.expected_query_type: hit_query_type = query_type == str(case.expected_query_type).strip().lower() passed = bool(hit_any and hit_all and (not forbidden_hit) and hit_query_type) score = 1.0 if passed else max(0.0, min(0.99, confidence * 0.6)) return EvalResult( case_id=case.id, passed=passed, answer=answer, query_type=query_type, score=round(score, 4), hit_any=hit_any, hit_all=hit_all, hit_query_type=hit_query_type, forbidden_hit=forbidden_hit, hallucination_hit=hallucination_hit, expected_any=case.expected_any, expected_all=case.expected_all, forbidden_any=case.forbidden_any, ) def _build_report( *, cases: list[EvalCase], results: list[EvalResult], thresholds: dict[str, float], mode: str, api_url: str | None, ) -> dict[str, Any]: total = len(results) passed = sum(1 for r in results if r.passed) hallucinations = sum(1 for r in results if r.hallucination_hit) follow_cases = [c for c in cases if c.is_followup] follow_ids = {c.id for c in follow_cases} follow_results = [r for r in results if r.case_id in follow_ids] follow_passed = sum(1 for r in follow_results if r.passed) qt_cases = [c for c in cases if c.expected_query_type] qt_ids = {c.id for c in qt_cases} qt_results = [r for r in results if r.case_id in qt_ids] qt_passed = sum(1 for r in qt_results if r.hit_query_type) accuracy = round((passed / total) if total else 0.0, 4) hallucination_rate = round((hallucinations / total) if total else 0.0, 4) followup_hit_rate = round((follow_passed / len(follow_results)) if follow_results else 0.0, 4) query_type_hit_rate = round((qt_passed / len(qt_results)) if qt_results else 0.0, 4) pass_gate = ( accuracy >= thresholds["accuracy"] and hallucination_rate <= thresholds["hallucination_rate"] and followup_hit_rate >= thresholds["followup_hit_rate"] and query_type_hit_rate >= thresholds["query_type_hit_rate"] ) return { "version": "v1", "generated_at": now_cn_aware().isoformat(), "mode": mode, "api_url": api_url, "thresholds": thresholds, "metrics": { "total": total, "passed": passed, "accuracy": accuracy, "hallucination_rate": hallucination_rate, "followup_total": len(follow_results), "followup_hit_rate": followup_hit_rate, "query_type_total": len(qt_results), "query_type_hit_rate": query_type_hit_rate, "pass_gate": pass_gate, }, "cases": [ { "id": r.case_id, "passed": r.passed, "score": r.score, "query_type": r.query_type, "hit_any": r.hit_any, "hit_all": r.hit_all, "hit_query_type": r.hit_query_type, "forbidden_hit": r.forbidden_hit, "hallucination_hit": r.hallucination_hit, "answer": r.answer, "expected_any": r.expected_any, "expected_all": r.expected_all, "forbidden_any": r.forbidden_any, } for r in results ], } def _write_report(report: dict[str, Any], output_dir: Path) -> Path: output_dir.mkdir(parents=True, exist_ok=True) ts = now_cn_aware().strftime("%Y%m%dT%H%M%SZ") report_path = output_dir / f"ai_steward_eval_{ts}.json" report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") latest = output_dir / "latest.json" latest.write_text(report_path.read_text(encoding="utf-8"), encoding="utf-8") return report_path def _load_latest_report(output_dir: Path) -> dict[str, Any] | None: latest = output_dir / "latest.json" if not latest.exists(): return None try: return json.loads(latest.read_text(encoding="utf-8")) except Exception: return None def _print_summary(report: dict[str, Any], previous: dict[str, Any] | None) -> None: metrics = report.get("metrics", {}) print("====") print(f"total={metrics.get('total')} passed={metrics.get('passed')}") print(f"accuracy={metrics.get('accuracy')}") print(f"hallucination_rate={metrics.get('hallucination_rate')}") print(f"followup_hit_rate={metrics.get('followup_hit_rate')}") print(f"query_type_hit_rate={metrics.get('query_type_hit_rate')}") print(f"pass_gate={metrics.get('pass_gate')}") if previous: prev_m = previous.get("metrics", {}) print("----") print(f"prev_accuracy={prev_m.get('accuracy')}") print(f"prev_hallucination_rate={prev_m.get('hallucination_rate')}") print(f"prev_followup_hit_rate={prev_m.get('followup_hit_rate')}") print(">>>> REPLACE") async def _run(args: argparse.Namespace) -> int: input_path = Path(args.input).resolve() if not input_path.exists(): print(f"[AUDIT-WARN] golden set not found: {input_path}") return 2 cases = _load_jsonl_cases(input_path) lint = _lint_cases(cases) if args.lint_only: print(json.dumps({"lint": lint}, ensure_ascii=False, indent=2)) return 0 if lint.get("valid") else 2 if not lint.get("valid"): print(json.dumps({"lint": lint, "error": "golden set invalid"}, ensure_ascii=False, indent=2)) return 2 offline_answers: dict[str, dict[str, Any]] = {} mode = "online" api_url = args.api_url if args.answers_file: mode = "offline" offline_answers = _load_offline_answers(Path(args.answers_file).resolve()) api_url = None results: list[EvalResult] = [] for case in cases: if mode == "offline": payload = offline_answers.get(case.id, {"answer": "", "query_type": "unknown", "confidence": 0.0}) else: payload = await _ask_online( api_url=args.api_url, token=args.token, question=case.question, project_id=args.project_id, timeout_seconds=args.timeout, ) results.append(_evaluate_case(case, payload)) thresholds = { "accuracy": float(args.threshold_accuracy), "hallucination_rate": float(args.threshold_hallucination), "followup_hit_rate": float(args.threshold_followup), "query_type_hit_rate": float(args.threshold_query_type), } output_dir = Path(args.output_dir).resolve() previous = _load_latest_report(output_dir) report = _build_report(cases=cases, results=results, thresholds=thresholds, mode=mode, api_url=api_url) report_path = _write_report(report, output_dir) print(f"report={report_path}") _print_summary(report, previous) if args.fail_on_threshold and not bool(report.get("metrics", {}).get("pass_gate", False)): return 2 return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="AI 管家评测与门禁") parser.add_argument("--input", default="backend/evals/golden_qa_v1.jsonl") parser.add_argument("--api-url", default=f"http://localhost:{os.getenv('BACKEND_PORT', '8000')}/rag/ask") parser.add_argument("--token", default=None) parser.add_argument("--project-id", type=int, default=None) parser.add_argument("--timeout", type=float, default=45.0) parser.add_argument("--answers-file", default=None) parser.add_argument("--output-dir", default="backend/evals/reports") parser.add_argument("--lint-only", action="store_true") parser.add_argument("--fail-on-threshold", action="store_true") parser.add_argument("--threshold-accuracy", type=float, default=0.80) parser.add_argument("--threshold-hallucination", type=float, default=0.12) parser.add_argument("--threshold-followup", type=float, default=0.75) parser.add_argument("--threshold-query-type", type=float, default=0.80) return parser def main() -> int: parser = build_parser() args = parser.parse_args() try: import asyncio return asyncio.run(_run(args)) except KeyboardInterrupt: return 130 except Exception as exc: print(f"[AUDIT-WARN] eval failed: {exc}") return 2 if __name__ == "__main__": raise SystemExit(main())