"""Eval integration manager — multi-sink orchestration.""" from __future__ import annotations import json from pathlib import Path from typing import Any, cast from butler.contracts.eval_ports import ScoreSinkPort, SuiteRunResult from butler.eval_integration.report_schema import build_unified_report from butler.eval_integration.suite_registry import get_suite, list_suite_ids from butler.eval_integration.sinks.audit_sink import AuditSink from butler.eval_integration.sinks.deepeval_sink import DeepEvalSink from butler.eval_integration.sinks.junit_sink import JUnitSink from butler.eval_integration.sinks.langfuse_sink import LangFuseSink from butler.eval_integration.sinks.ragas_sink import RagasSink from butler.eval_integration.sync_policy import evaluate_sync ROOT = Path(__file__).resolve().parents[2] DEFAULT_REPORT = ROOT / ".butler" / "reports" / "eval-unified.json" class EvalIntegrationManager: def __init__(self, sinks: list[ScoreSinkPort] | None = None) -> None: self._sinks: list[ScoreSinkPort] = sinks or [ AuditSink(), JUnitSink(), LangFuseSink(), DeepEvalSink(), RagasSink(), ] def list_suites(self) -> list[str]: return cast(list[str], list_suite_ids()) def run_suites( self, suite_ids: list[str], *, warn_only: bool = False, sync_dataset: bool = False, push_langfuse: bool | None = None, ) -> list[SuiteRunResult]: results: list[SuiteRunResult] = [] for sid in suite_ids: suite = get_suite(sid) result = suite.run( warn_only=warn_only, sync_dataset=sync_dataset, push_langfuse=push_langfuse, ) payload = {"suite_id": sid, "metrics": result.metrics, "ok": result.ok} for sink in self._sinks: if sink.backend_id == "langfuse" and sid not in ( "agent_weekly", "regression", "wechat_corpus", "tcr", "memory_mb", "b9_oracle", ): continue if sink.backend_id == "deepeval" and sid != "deepeval_agent": continue if sink.backend_id == "ragas" and sid != "ragas_memory": continue from butler.eval_integration.manager_ops import write_suite_result_safe ref = write_suite_result_safe(sink, result, payload) if ref: result.sink_refs[sink.backend_id] = ref results.append(result) return results def build_report( self, results: list[SuiteRunResult], ) -> dict[str, Any]: sink_status = {s.backend_id: {"registered": True} for s in self._sinks} return cast( dict[str, Any], build_unified_report(results, sink_status=sink_status), ) def run_and_write( self, suite_ids: list[str], *, out: Path = DEFAULT_REPORT, warn_only: bool = False, sync_dataset: bool = False, push_langfuse: bool | None = None, ) -> tuple[dict[str, Any], list[SuiteRunResult]]: results = self.run_suites( suite_ids, warn_only=warn_only, sync_dataset=sync_dataset, push_langfuse=push_langfuse, ) for r in results: if r.suite_id == "tcr": rate = r.metrics.get("trajectory_compliance_rate") if rate is not None: from butler.eval_integration.manager_ops import analyse_transform_signals_safe analyse_transform_signals_safe(tcr_rate=float(rate)) report = self.build_report(results) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") return report, results def sync_check(self, suite_id: str) -> dict[str, Any]: """Weak consistency check across sinks.""" rows: dict[str, dict[str, Any] | None] = {} for sink in self._sinks: rows[sink.backend_id] = sink.read_latest(suite_id) return cast(dict[str, Any], evaluate_sync(suite_id, rows))