#!/usr/bin/env python3 """飞书执行链路回放评测。 目标: 1) 回放样本消息,驱动 FeishuSignalPipelineService。 2) 断言 DB 结果 + route_status + reply_text 一致。 3) 输出结构化报告,支持 CI 门禁。 """ from __future__ import annotations import argparse import asyncio import contextvars import json import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional from sqlalchemy import and_, delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database import AsyncSessionLocal from app.models import ( AIProcessingLog, FeishuItem, Notification, Project, ProjectSignalLink, ProjectSuggestion, Signal, User, ) from app.services.adaptive_rag_service import AdaptiveRAGService from app.services.feishu_integration import FeishuIntegrationService from app.services.feishu_signal_pipeline import FeishuSignalPipelineService CURRENT_CASE_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar( "feishu_replay_case_id", default=None, ) DEFAULT_REPLAY_TENANT_ID = 9901 @dataclass class ReplayCase: id: str message_content: str sender: str source_chat_id: str feishu_message_id: str mock: dict[str, Any] expected: dict[str, Any] def _load_cases(path: Path) -> list[ReplayCase]: rows: list[ReplayCase] = [] with path.open("r", encoding="utf-8") as f: for idx, raw in enumerate(f, start=1): line = raw.strip() if not line: continue payload = json.loads(line) case_id = str(payload.get("id") or f"CASE-{idx:03d}").strip() rows.append( ReplayCase( id=case_id, message_content=str(payload.get("message_content") or "").strip(), sender=str(payload.get("sender") or "回放用户").strip(), source_chat_id=str(payload.get("source_chat_id") or "oc-replay-chat").strip(), feishu_message_id=str(payload.get("feishu_message_id") or "").strip(), mock=payload.get("mock") if isinstance(payload.get("mock"), dict) else {}, expected=payload.get("expected") if isinstance(payload.get("expected"), dict) else {}, ) ) return rows def _lint_cases(cases: list[ReplayCase]) -> dict[str, Any]: unique_case_ids = len({c.id for c in cases}) non_empty_messages = sum(1 for c in cases if c.message_content) with_duplicates = sum(1 for c in cases if c.expected.get("status") == "ignored" and c.expected.get("reason") == "duplicate_filtered") with_qa = sum(1 for c in cases if c.expected.get("scene") == "qa") with_execute = sum(1 for c in cases if c.expected.get("scene") == "execute") with_multi_write = sum(1 for c in cases if bool(c.expected.get("multi_write"))) valid = ( len(cases) >= 50 and unique_case_ids == len(cases) and non_empty_messages == len(cases) and with_duplicates > 0 and with_qa > 0 and with_execute > 0 and with_multi_write > 0 ) return { "count": len(cases), "unique_case_ids": unique_case_ids, "non_empty_messages": non_empty_messages, "duplicate_cases": with_duplicates, "qa_cases": with_qa, "execute_cases": with_execute, "multi_write_cases": with_multi_write, "valid": valid, } def _default_level_signal() -> dict[str, Any]: return { "build_intent_score": 0.2, "scope_score": 0.2, "timeline_score": 0.1, "owner_score": 0.1, "project_score": 0.2, "level": "signal", "level_confidence": 0.88, "level_reasoning": "回放样本:明确执行动作,按 signal 处理。", "should_split": False, "split_items": [], } def _default_signal(project_id: int = 1001, confidence: float = 0.92) -> dict[str, Any]: return { "title": "新增信号源", "type": "新需求", "priority": "P1", "matched_project_id": int(project_id), "summary": "新增一个可执行的信号源需求。", "reasoning": ["目标明确", "动作清晰"], "confidence": float(confidence), "recommended_action": "enter_only", } def _default_level_project() -> dict[str, Any]: return { "build_intent_score": 0.95, "scope_score": 0.86, "timeline_score": 0.8, "owner_score": 0.75, "project_score": 0.9, "level": "project", "level_confidence": 0.9, "level_reasoning": "回放样本:范围较大,按 project 处理。", "should_split": False, "split_items": [], } def _default_project_prefill(index: int = 1) -> dict[str, Any]: return { "suggested_name": f"回放项目-{index}", "suggested_description": "回放样本自动生成项目建议。", "suggested_owner": "回放负责人", "suggested_timeline": "2周", "confidence": 0.86, "reasoning": ["目标明确", "时间可控"], } def _default_routing_policy() -> dict[str, Any]: return { "cold_start_enabled": False, "signal_auto_enter_threshold": 0.8, "project_auto_create_threshold": 0.85, "project_uncertain_threshold": 0.6, "chat_context_auto_link_enabled": False, } def _case_by_id(cases: list[ReplayCase]) -> dict[str, ReplayCase]: return {row.id: row for row in cases} class ReplayPatcher: """在回放期间替换外部依赖(RAG/LLM)为样本驱动。""" def __init__(self, cases: list[ReplayCase]) -> None: self._case_map = _case_by_id(cases) self._project_id_map: dict[int, int] = {1001: 1001, 1002: 1002, 1003: 1003} self._orig_classify_intent = AdaptiveRAGService.classify_intent self._orig_answer_question = AdaptiveRAGService.answer_question self._orig_classify_level = FeishuSignalPipelineService._classify_level self._orig_extract_signal = FeishuSignalPipelineService._extract_signal self._orig_extract_project_prefill = FeishuSignalPipelineService._extract_project_prefill self._orig_load_routing_policy = FeishuSignalPipelineService._load_routing_policy self._orig_notify_project_created = FeishuSignalPipelineService._notify_project_created self._orig_notify_signal_auto_entered = FeishuSignalPipelineService._notify_signal_auto_entered def _current_case(self) -> ReplayCase: case_id = CURRENT_CASE_ID.get() if not case_id: raise RuntimeError("missing_current_case_id") row = self._case_map.get(case_id) if row is None: raise RuntimeError(f"unknown_case_id:{case_id}") return row def set_project_id_map(self, value: dict[int, int]) -> None: mapped = {int(k): int(v) for k, v in (value or {}).items()} if mapped: self._project_id_map = mapped def _map_project_id(self, raw_value: Any) -> Optional[int]: if raw_value is None: return None try: source = int(raw_value) except Exception: return None return int(self._project_id_map.get(source, source)) def install(self) -> None: async def fake_classify_intent( _: AdaptiveRAGService, message: str, ) -> dict[str, Any]: case = self._current_case() mock = case.mock intent = str(mock.get("intent") or "statement").strip().lower() if intent == "question": return { "intent": "question", "question_part": message, "statement_part": "", } if intent == "mixed": question_part = str(mock.get("question_part") or message).strip() statement_part = str(mock.get("statement_part") or message).strip() return { "intent": "mixed", "question_part": question_part, "statement_part": statement_part, } return { "intent": "statement", "question_part": "", "statement_part": message, } async def fake_answer_question( _: AdaptiveRAGService, *, question: str, asked_by: Optional[int], asked_via: str, project_id: Optional[int], source_chat_id: Optional[str], session_id: Optional[int] = None, ) -> dict[str, Any]: _ = (asked_by, asked_via, project_id, source_chat_id, session_id) case = self._current_case() mock = case.mock answer = str(mock.get("qa_answer") or f"回放答案:{question[:30]}").strip() sources = mock.get("qa_sources") if not isinstance(sources, list): sources = [{"label": "Replay Source"}] return { "answer": answer, "sources": sources, "query_type": "question", "confidence": 0.9, } async def fake_classify_level( _: FeishuSignalPipelineService, *, message: str, projects: list[dict[str, Any]], ) -> dict[str, Any]: _ = (message, projects) case = self._current_case() if bool(case.mock.get("force_schema_error")): raise ValueError("level_classification_schema_invalid: replay_forced") level = case.mock.get("level") if isinstance(level, dict): return level return _default_level_signal() async def fake_extract_signal( _: FeishuSignalPipelineService, message: str, *, projects: list[dict[str, Any]], ) -> dict[str, Any]: _ = (message, projects) case = self._current_case() signal = case.mock.get("signal") if isinstance(signal, dict): payload = dict(signal) mapped_project_id = self._map_project_id(payload.get("matched_project_id")) if mapped_project_id is not None: payload["matched_project_id"] = mapped_project_id return payload payload = _default_signal() mapped_project_id = self._map_project_id(payload.get("matched_project_id")) if mapped_project_id is not None: payload["matched_project_id"] = mapped_project_id return payload async def fake_extract_project_prefill( _: FeishuSignalPipelineService, message: str, ) -> dict[str, Any]: _ = message case = self._current_case() prefill = case.mock.get("project_prefill") if isinstance(prefill, dict): return prefill return _default_project_prefill() async def fake_load_routing_policy(_: FeishuSignalPipelineService) -> dict[str, Any]: case = self._current_case() policy = case.mock.get("routing_policy") merged = _default_routing_policy() if isinstance(policy, dict): merged.update(policy) return merged async def fake_notify_project_created( _: FeishuSignalPipelineService, *, project: Project, ) -> None: _ = project return None async def fake_notify_signal_auto_entered( _: FeishuSignalPipelineService, *, project_id: int, signal: Any, ) -> None: _ = (project_id, signal) return None AdaptiveRAGService.classify_intent = fake_classify_intent # type: ignore[assignment] AdaptiveRAGService.answer_question = fake_answer_question # type: ignore[assignment] FeishuSignalPipelineService._classify_level = fake_classify_level # type: ignore[assignment] FeishuSignalPipelineService._extract_signal = fake_extract_signal # type: ignore[assignment] FeishuSignalPipelineService._extract_project_prefill = fake_extract_project_prefill # type: ignore[assignment] FeishuSignalPipelineService._load_routing_policy = fake_load_routing_policy # type: ignore[assignment] FeishuSignalPipelineService._notify_project_created = fake_notify_project_created # type: ignore[assignment] FeishuSignalPipelineService._notify_signal_auto_entered = fake_notify_signal_auto_entered # type: ignore[assignment] def restore(self) -> None: AdaptiveRAGService.classify_intent = self._orig_classify_intent # type: ignore[assignment] AdaptiveRAGService.answer_question = self._orig_answer_question # type: ignore[assignment] FeishuSignalPipelineService._classify_level = self._orig_classify_level # type: ignore[assignment] FeishuSignalPipelineService._extract_signal = self._orig_extract_signal # type: ignore[assignment] FeishuSignalPipelineService._extract_project_prefill = self._orig_extract_project_prefill # type: ignore[assignment] FeishuSignalPipelineService._load_routing_policy = self._orig_load_routing_policy # type: ignore[assignment] FeishuSignalPipelineService._notify_project_created = self._orig_notify_project_created # type: ignore[assignment] FeishuSignalPipelineService._notify_signal_auto_entered = self._orig_notify_signal_auto_entered # type: ignore[assignment] async def _seed_minimal_data(session: AsyncSession, *, tenant_id: int) -> dict[int, int]: users = ( await session.execute( select(User).where( and_( User.tenant_id == tenant_id, User.username.in_(("replay_admin", "replay_owner")), ) ) ) ).scalars().all() if not users: session.add( User( tenant_id=tenant_id, username="replay_admin", email="replay_admin@example.com", password_hash="x", name="Replay Admin", role="admin", assigned_domains=[], workload=0, status="active", is_active=True, ) ) session.add( User( tenant_id=tenant_id, username="replay_owner", email="replay_owner@example.com", password_hash="x", name="Replay Owner", role="ai_engineer", assigned_domains=[], workload=0, status="active", is_active=True, ) ) await session.flush() alias_to_name = { 1001: "回放项目-健身", 1002: "回放项目-路由", 1003: "回放项目-知识库", } project_map: dict[int, int] = {} for alias, title in alias_to_name.items(): existing = ( await session.execute( select(Project).where( and_( Project.tenant_id == tenant_id, Project.name == title, ) ).limit(1) ) ).scalar_one_or_none() if existing is None: existing = Project( tenant_id=tenant_id, name=title, title=title, description="回放种子项目", owner_id=None, status="active", domain_id=None, assigned_role="ai_engineer", signal_count=0, ) session.add(existing) await session.flush() project_map[int(alias)] = int(existing.id) await session.commit() return project_map async def _count_items_by_message_id( session: AsyncSession, *, tenant_id: int, message_id: str, ) -> int: return int( ( await session.execute( select(func.count(FeishuItem.id)).where( and_( FeishuItem.tenant_id == tenant_id, FeishuItem.feishu_message_id == message_id, ) ) ) ).scalar() or 0 ) async def _latest_item_route_status( session: AsyncSession, *, tenant_id: int, message_id: str, ) -> Optional[str]: row = ( await session.execute( select(FeishuItem.route_status) .where( and_( FeishuItem.tenant_id == tenant_id, FeishuItem.feishu_message_id == message_id, ) ) .order_by(FeishuItem.id.desc()) .limit(1) ) ).scalar_one_or_none() if row is None: return None return str(row) async def _cleanup_tenant_replay_data(session: AsyncSession, *, tenant_id: int) -> None: await session.execute(delete(ProjectSignalLink).where(ProjectSignalLink.tenant_id == tenant_id)) await session.execute(delete(ProjectSuggestion).where(ProjectSuggestion.tenant_id == tenant_id)) await session.execute(delete(Signal).where(Signal.tenant_id == tenant_id)) await session.execute( delete(AIProcessingLog).where( and_( AIProcessingLog.tenant_id == tenant_id, AIProcessingLog.feishu_message_id.like("om_replay_%"), ) ) ) await session.execute( delete(AIProcessingLog).where( and_( AIProcessingLog.tenant_id == tenant_id, AIProcessingLog.feishu_message_id.like("feishu-replay-%"), ) ) ) await session.execute(delete(FeishuItem).where(FeishuItem.tenant_id == tenant_id)) await session.execute(delete(Notification).where(Notification.tenant_id == tenant_id)) await session.execute(delete(Project).where(Project.tenant_id == tenant_id)) await session.execute(delete(User).where(User.tenant_id == tenant_id)) await session.commit() def _build_reply_text( *, service: FeishuIntegrationService, message_content: str, capture_result: dict[str, Any], trace_id: str, ) -> Optional[str]: scene = service._classify_message_scene(capture_result=capture_result) if scene == "execute": text = service._build_execution_receipt_reply( message_content=message_content, capture_result=capture_result, ) return service._guard_execute_reply_text(text, trace_id=trace_id) if scene == "qa": if bool(capture_result.get("direct_reply_required")): return str(capture_result.get("direct_reply_text") or "").strip() return None if scene == "error": return service._build_error_reply_text() return None def _match_reply(expected: dict[str, Any], reply: Optional[str]) -> bool: expected_equals = expected.get("reply_text") if expected_equals is not None: return str(reply or "") == str(expected_equals) expected_contains = expected.get("reply_contains") if expected_contains is None: return True if isinstance(expected_contains, str): expected_contains = [expected_contains] if not isinstance(expected_contains, list): return True text = str(reply or "") return all(str(token) in text for token in expected_contains) def _build_report( *, results: list[dict[str, Any]], started_at: datetime, ) -> dict[str, Any]: total = len(results) passed = sum(1 for row in results if row.get("passed")) failed = total - passed return { "version": "feishu-replay-v1", "generated_at": datetime.now(timezone.utc).isoformat(), "started_at": started_at.isoformat(), "metrics": { "total": total, "passed": passed, "failed": failed, "pass_rate": round((passed / total) if total else 0.0, 4), }, "cases": results, } def _write_report(report: dict[str, Any], output_dir: Path) -> Path: output_dir.mkdir(parents=True, exist_ok=True) ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") report_path = output_dir / f"feishu_replay_eval_{ts}.json" report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") latest = output_dir / "feishu_replay_latest.json" latest.write_text(report_path.read_text(encoding="utf-8"), encoding="utf-8") return report_path async def _run_replay( cases: list[ReplayCase], *, tenant_id: int, allow_tenant_1: bool = False, ) -> dict[str, Any]: if int(tenant_id) == 1 and not allow_tenant_1: raise RuntimeError("tenant_id=1 is blocked for replay cleanup; use dedicated replay tenant") started_at = datetime.now(timezone.utc) patcher = ReplayPatcher(cases) patcher.install() case_results: list[dict[str, Any]] = [] try: async with AsyncSessionLocal() as db: await _cleanup_tenant_replay_data(db, tenant_id=tenant_id) project_id_map = await _seed_minimal_data(db, tenant_id=tenant_id) patcher.set_project_id_map(project_id_map) pipeline = FeishuSignalPipelineService(db, tenant_id=tenant_id) integration = FeishuIntegrationService(db) integration.tenant_id = tenant_id for case in cases: token = CURRENT_CASE_ID.set(case.id) before_count = await _count_items_by_message_id( db, tenant_id=tenant_id, message_id=case.feishu_message_id, ) mismatches: list[str] = [] try: result = await pipeline.process_message( message_content=case.message_content, sender=case.sender, source_chat_id=case.source_chat_id, source_sender_open_id="ou_replay_user", feishu_message_id=case.feishu_message_id, captured_at=datetime.now(timezone.utc), ) except Exception as exc: result = {"status": "error", "classification": "error", "error": str(exc)} if str(case.expected.get("status") or "").strip().lower() != "error": mismatches.append(f"process_exception:{exc}") finally: CURRENT_CASE_ID.reset(token) after_count = await _count_items_by_message_id( db, tenant_id=tenant_id, message_id=case.feishu_message_id, ) item_delta = after_count - before_count route_status = None result_rows = result.get("results") if isinstance(result, dict) else None result_count = len(result_rows) if isinstance(result_rows, list) else 0 result_levels = sorted( { str((row or {}).get("level") or "").strip().lower() for row in (result_rows or []) if isinstance(row, dict) and str((row or {}).get("level") or "").strip() } ) if isinstance(result_rows, list) and result_rows: first = result_rows[0] if isinstance(result_rows[0], dict) else {} route_status = str((first or {}).get("route_status") or "").strip() or None if route_status is None: route_status = await _latest_item_route_status( db, tenant_id=tenant_id, message_id=case.feishu_message_id, ) scene = integration._classify_message_scene(capture_result=result if isinstance(result, dict) else {}) reply_text = _build_reply_text( service=integration, message_content=case.message_content, capture_result=result if isinstance(result, dict) else {}, trace_id=f"replay-{case.id}", ) expected = case.expected expected_status = expected.get("status") if expected_status is not None and str(result.get("status")) != str(expected_status): mismatches.append(f"status expected={expected_status} actual={result.get('status')}") expected_classification = expected.get("classification") if expected_classification is not None and str(result.get("classification")) != str(expected_classification): mismatches.append( f"classification expected={expected_classification} actual={result.get('classification')}" ) expected_reason = expected.get("reason") if expected_reason is not None and str(result.get("reason")) != str(expected_reason): mismatches.append(f"reason expected={expected_reason} actual={result.get('reason')}") expected_scene = expected.get("scene") if expected_scene is not None and str(scene) != str(expected_scene): mismatches.append(f"scene expected={expected_scene} actual={scene}") expected_route_status = expected.get("route_status") if expected_route_status is not None and str(route_status) != str(expected_route_status): mismatches.append(f"route_status expected={expected_route_status} actual={route_status}") expected_item_delta = expected.get("item_delta") if expected_item_delta is not None and int(item_delta) != int(expected_item_delta): mismatches.append(f"item_delta expected={expected_item_delta} actual={item_delta}") expected_result_count = expected.get("result_count") if expected_result_count is not None and int(result_count) != int(expected_result_count): mismatches.append(f"result_count expected={expected_result_count} actual={result_count}") expected_levels = expected.get("result_levels") if isinstance(expected_levels, list): expected_levels_set = sorted({str(x).strip().lower() for x in expected_levels if str(x).strip()}) if expected_levels_set != result_levels: mismatches.append(f"result_levels expected={expected_levels_set} actual={result_levels}") if bool(expected.get("multi_write")): if result_count < 2: mismatches.append(f"multi_write expected=true actual_result_count={result_count}") if len(result_levels) < 2: mismatches.append(f"multi_write expected mixed levels actual_levels={result_levels}") if not _match_reply(expected, reply_text): mismatches.append(f"reply mismatch expected={expected.get('reply_text') or expected.get('reply_contains')} actual={reply_text}") case_results.append( { "id": case.id, "passed": len(mismatches) == 0, "mismatches": mismatches, "expected": expected, "actual": { "status": result.get("status"), "classification": result.get("classification"), "reason": result.get("reason"), "scene": scene, "route_status": route_status, "item_delta": item_delta, "result_count": result_count, "result_levels": result_levels, "reply_text": reply_text, }, } ) await _cleanup_tenant_replay_data(db, tenant_id=tenant_id) finally: patcher.restore() return _build_report(results=case_results, started_at=started_at) async def _main_async(args: argparse.Namespace) -> int: cases = _load_cases(Path(args.input)) if args.case_ids: tokens = [token.strip() for token in str(args.case_ids).split(",") if token.strip()] wanted = set(tokens) filtered = [row for row in cases if row.id in wanted] missing = sorted(wanted - {row.id for row in filtered}) if missing: print(json.dumps({"error": "case_not_found", "missing": missing}, ensure_ascii=False, indent=2)) return 2 cases = filtered lint = _lint_cases(cases) if args.lint_only: print(json.dumps({"lint": lint}, ensure_ascii=False, indent=2)) return 0 if lint["valid"] else 2 if not lint["valid"] and not args.case_ids: print(json.dumps({"lint": lint}, ensure_ascii=False, indent=2)) return 2 tenant_id = int(args.tenant_id) if tenant_id <= 0: print(json.dumps({"error": "invalid_tenant_id", "tenant_id": args.tenant_id}, ensure_ascii=False, indent=2)) return 2 if tenant_id == 1 and not bool(args.allow_tenant_1): print( json.dumps( { "error": "tenant_id_blocked", "message": "tenant_id=1 默认禁用,避免误删主租户数据;请改用回放专用租户(例如 9901)。", }, ensure_ascii=False, indent=2, ) ) return 2 report = await _run_replay( cases, tenant_id=tenant_id, allow_tenant_1=bool(args.allow_tenant_1), ) report_path = _write_report(report, Path(args.output_dir)) metrics = report.get("metrics", {}) print( f"[feishu-replay] total={metrics.get('total')} passed={metrics.get('passed')} " f"failed={metrics.get('failed')} pass_rate={metrics.get('pass_rate')} " f"report={report_path}" ) if int(metrics.get("failed") or 0) > 0 and args.strict: return 2 return 0 def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Feishu replay eval") parser.add_argument( "--input", default="backend/evals/golden_feishu_replay_v1.jsonl", help="Replay dataset path (.jsonl)", ) parser.add_argument( "--output-dir", default="backend/evals/reports", help="Report output directory", ) parser.add_argument( "--tenant-id", default=str(DEFAULT_REPLAY_TENANT_ID), help="Replay tenant id", ) parser.add_argument( "--allow-tenant-1", action="store_true", help="Allow tenant_id=1 (dangerous, only for isolated test db).", ) parser.add_argument( "--lint-only", action="store_true", help="Only validate dataset structure", ) parser.add_argument( "--strict", action="store_true", help="Exit non-zero when replay has failures", ) parser.add_argument( "--case-ids", default="", help="Only run selected case ids, comma separated", ) return parser.parse_args() def main() -> None: args = parse_args() code = asyncio.run(_main_async(args)) raise SystemExit(code) if __name__ == "__main__": main()