from __future__ import annotations from pathlib import Path from typing import Any, Optional from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( MountedAgentTrace, MountedConversationRun, MountedDraftWorkspace, MountedToolCapabilityPolicy, MountedTopicWorkspace, TrainingDataset, TrainingEvaluation, TrainingJob, ) class MountedAIRuntimeService: def __init__(self, db: AsyncSession): self.db = db @staticmethod def _dt(value: Any) -> Optional[str]: return value.isoformat() if value is not None else None @staticmethod def _backend_root() -> Path: return Path(__file__).resolve().parents[2] @classmethod def _read_backend_source(cls, relative_path: str) -> str: try: return (cls._backend_root() / relative_path).read_text(encoding="utf-8") except OSError: return "" @staticmethod def _legacy_cutover_item( *, key: str, title: str, retired: bool, summary: str, evidence: str, ) -> dict[str, Any]: return { "key": key, "title": title, "status": "retired" if retired else "active", "summary": summary, "evidence": evidence, } @classmethod def _build_legacy_cutover_items(cls) -> list[dict[str, Any]]: ingress_render = cls._read_backend_source("app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py") feishu_reply = cls._read_backend_source("app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py") signal_capture_service = cls._read_backend_source("app/services/signal/signal_capture_service/service.py") feishu_pipeline_service = cls._read_backend_source("app/services/integrations/feishu_signal_pipeline/service.py") feishu_process_core = cls._read_backend_source("app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py") feishu_followup_a = cls._read_backend_source("app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py") return [ cls._legacy_cutover_item( key="ai_pmo_ingress_consultant_capture", title="AI PMO ingress consultant capture", retired="build_consultant_dispatch_contract" not in ingress_render and '"consultant_dispatch"' not in ingress_render, summary="capture result 不再生成或透传 consultant_dispatch 到 AI PMO ingress 主链。", evidence="ai_pmo_kernel_mixins_ingress_render.py 不再包含 consultant dispatch contract/import 与 rag_result 注入。", ), cls._legacy_cutover_item( key="feishu_mention_consultant_reply", title="Feishu mention consultant reply", retired="build_channel_consultant_reply" not in feishu_reply and "_build_consultant_dispatch_reply(capture_result)" not in feishu_reply, summary="飞书 mention 主回复不再走 consultant reply 分支。", evidence="mixin_reply_llm_core.py 已移除 consultant reply import 和 active reply branch;helper 仅保留 no-op。", ), cls._legacy_cutover_item( key="signal_capture_legacy_entry", title="Signal capture legacy entry", retired="SignalCaptureLegacyMixin" not in signal_capture_service, summary="主动信号捕获服务不再继承 legacy capture mixin。", evidence="signal_capture_service/service.py 的 ProactiveSignalCaptureService 继承链已去掉 SignalCaptureLegacyMixin。", ), cls._legacy_cutover_item( key="legacy_feishu_queue_followup", title="Legacy Feishu queue follow-up", retired=( "FeishuPipelineFollowupAMixin" not in feishu_pipeline_service and "FeishuPipelineFollowupBMixin" not in feishu_pipeline_service and "_maybe_handle_queue_followup(" not in feishu_process_core and "return None" in feishu_followup_a and "Legacy feishu_items queue follow-up is retired." in feishu_followup_a ), summary="旧 feishu_items 队列 continue/status follow-up 已从真实入口退役。", evidence="service.py 不再混入 FollowupA/B,process_message_core 不再调用 queue followup,mixin_followup_a 已降为 retired stub。", ), ] def _topic_snapshot( self, row: MountedTopicWorkspace, *, draft: Optional[MountedDraftWorkspace] = None, ) -> dict[str, Any]: return { "id": int(row.id), "topic_key": row.topic_key, "source_system": row.source_system, "channel_ref": row.channel_ref, "thread_key": row.thread_key, "mode": row.mode, "status": row.status, "readiness_score": float(row.readiness_score or 0.0) if row.readiness_score is not None else None, "draft_state": draft.state if draft is not None else None, "draft_version": int(draft.draft_version) if draft is not None else None, "updated_at": self._dt(row.updated_at), } def _run_snapshot(self, row: MountedConversationRun, *, trace_count: int = 0) -> dict[str, Any]: return { "id": int(row.id), "trace_id": row.trace_id, "source_system": row.source_system, "source_message_id": row.source_message_id, "mode_before": row.mode_before, "mode_after": row.mode_after, "active_agent": row.active_agent, "status": row.status, "trigger_reason": row.trigger_reason, "final_reply": row.final_reply, "trace_count": int(trace_count), "updated_at": self._dt(row.updated_at), } @staticmethod def _trace_snapshot(row: MountedAgentTrace) -> dict[str, Any]: return { "id": int(row.id), "agent_name": row.agent_name, "status": row.status, "latency_ms": int(row.latency_ms) if row.latency_ms is not None else None, "failure_reason": row.failure_reason, "created_at": row.created_at.isoformat() if row.created_at else None, } @staticmethod def _evaluation_snapshot(row: TrainingEvaluation) -> dict[str, Any]: return { "evaluation_type": row.evaluation_type, "status": row.status, "score": float(row.score or 0.0) if row.score is not None else None, } @staticmethod def _eval_suite_snapshot( row: TrainingDataset, *, latest_score: Optional[float], last_evaluated_at: Optional[Any], ) -> dict[str, Any]: selection_rules = row.selection_rules if isinstance(row.selection_rules, dict) else {} suite_kind = str(selection_rules.get("suite_kind") or "custom").strip() or "custom" return { "id": int(row.id), "dataset_type": row.dataset_type, "version": row.version, "status": row.status, "suite_kind": suite_kind, "sample_count": int(row.sample_count or 0), "latest_score": latest_score, "last_evaluated_at": MountedAIRuntimeService._dt(last_evaluated_at), } async def list_sources(self, *, tenant_id: int) -> dict[str, Any]: source_map: dict[str, dict[str, Any]] = {} run_rows = ( await self.db.execute( select( MountedConversationRun.source_system, func.count(MountedConversationRun.id).label("run_count"), func.max(MountedConversationRun.updated_at).label("last_seen_at"), ) .where(MountedConversationRun.tenant_id == int(tenant_id)) .group_by(MountedConversationRun.source_system) ) ).all() for row in run_rows: source_system = str(row.source_system or "").strip() source_map[source_system] = { "source_system": source_system, "run_count": int(row.run_count or 0), "topic_count": 0, "active_topic_count": 0, "last_seen_at": self._dt(row.last_seen_at), } topic_rows = ( await self.db.execute( select( MountedTopicWorkspace.source_system, func.count(MountedTopicWorkspace.id).label("topic_count"), func.sum( case((MountedTopicWorkspace.status == "active", 1), else_=0) ).label("active_topic_count"), func.max(MountedTopicWorkspace.updated_at).label("topic_last_seen_at"), ) .where(MountedTopicWorkspace.tenant_id == int(tenant_id)) .group_by(MountedTopicWorkspace.source_system) ) ).all() for row in topic_rows: source_system = str(row.source_system or "").strip() payload = source_map.setdefault( source_system, { "source_system": source_system, "run_count": 0, "topic_count": 0, "active_topic_count": 0, "last_seen_at": None, }, ) payload["topic_count"] = int(row.topic_count or 0) payload["active_topic_count"] = int(row.active_topic_count or 0) topic_last_seen_at = self._dt(row.topic_last_seen_at) if topic_last_seen_at and not payload.get("last_seen_at"): payload["last_seen_at"] = topic_last_seen_at items = sorted(source_map.values(), key=lambda item: (item["source_system"] or "")) return {"items": items} async def list_topics( self, *, tenant_id: int, limit: int, offset: int, mode: Optional[str] = None, ) -> dict[str, Any]: base_stmt = select(MountedTopicWorkspace).where(MountedTopicWorkspace.tenant_id == int(tenant_id)) if mode: base_stmt = base_stmt.where(MountedTopicWorkspace.mode == str(mode).strip()) total = ( await self.db.execute( select(func.count()).select_from(base_stmt.order_by(None).subquery()) ) ).scalar_one() rows = ( await self.db.execute( base_stmt.order_by(MountedTopicWorkspace.updated_at.desc(), MountedTopicWorkspace.id.desc()) .offset(int(offset)) .limit(int(limit)) ) ).scalars().all() topic_ids = [int(row.id) for row in rows] draft_map: dict[int, MountedDraftWorkspace] = {} if topic_ids: draft_rows = ( await self.db.execute( select(MountedDraftWorkspace).where( MountedDraftWorkspace.tenant_id == int(tenant_id), MountedDraftWorkspace.topic_workspace_id.in_(topic_ids), ) ) ).scalars().all() draft_map = {int(row.topic_workspace_id): row for row in draft_rows} return { "items": [self._topic_snapshot(row, draft=draft_map.get(int(row.id))) for row in rows], "total": int(total or 0), } async def list_runs( self, *, tenant_id: int, limit: int, offset: int, status: Optional[str] = None, ) -> dict[str, Any]: base_stmt = select(MountedConversationRun).where(MountedConversationRun.tenant_id == int(tenant_id)) if status: base_stmt = base_stmt.where(MountedConversationRun.status == str(status).strip()) total = ( await self.db.execute( select(func.count()).select_from(base_stmt.order_by(None).subquery()) ) ).scalar_one() rows = ( await self.db.execute( base_stmt.order_by(MountedConversationRun.updated_at.desc(), MountedConversationRun.id.desc()) .offset(int(offset)) .limit(int(limit)) ) ).scalars().all() run_ids = [int(row.id) for row in rows] trace_counts: dict[int, int] = {} if run_ids: trace_rows = ( await self.db.execute( select( MountedAgentTrace.conversation_run_id, func.count(MountedAgentTrace.id).label("trace_count"), ) .where( MountedAgentTrace.tenant_id == int(tenant_id), MountedAgentTrace.conversation_run_id.in_(run_ids), ) .group_by(MountedAgentTrace.conversation_run_id) ) ).all() trace_counts = {int(row.conversation_run_id): int(row.trace_count or 0) for row in trace_rows} return { "items": [self._run_snapshot(row, trace_count=trace_counts.get(int(row.id), 0)) for row in rows], "total": int(total or 0), } async def get_run_detail(self, *, tenant_id: int, run_id: int) -> dict[str, Any]: row = ( await self.db.execute( select(MountedConversationRun).where( MountedConversationRun.tenant_id == int(tenant_id), MountedConversationRun.id == int(run_id), ) ) ).scalar_one_or_none() if row is None: raise ValueError("mounted ai run not found") trace_rows = ( await self.db.execute( select(MountedAgentTrace) .where( MountedAgentTrace.tenant_id == int(tenant_id), MountedAgentTrace.conversation_run_id == int(row.id), ) .order_by(MountedAgentTrace.id.asc()) ) ).scalars().all() return { "item": self._run_snapshot(row, trace_count=len(trace_rows)), "agent_traces": [self._trace_snapshot(trace) for trace in trace_rows], "context_layers_hit": row.context_layers_hit, "tool_calls": row.tool_calls, "execution_outcome": row.execution_outcome, } async def get_cutover_status(self, *, tenant_id: int) -> dict[str, Any]: topic_workspace_count = ( await self.db.execute( select(func.count(MountedTopicWorkspace.id)).where( MountedTopicWorkspace.tenant_id == int(tenant_id) ) ) ).scalar_one() conversation_run_count = ( await self.db.execute( select(func.count(MountedConversationRun.id)).where( MountedConversationRun.tenant_id == int(tenant_id) ) ) ).scalar_one() tool_policy_count = ( await self.db.execute( select(func.count(MountedToolCapabilityPolicy.id)).where( MountedToolCapabilityPolicy.tenant_id == int(tenant_id) ) ) ).scalar_one() observed_sources = ( await self.db.execute( select(MountedConversationRun.source_system) .where(MountedConversationRun.tenant_id == int(tenant_id)) .distinct() .order_by(MountedConversationRun.source_system.asc()) ) ).scalars().all() legacy_cutover_items = self._build_legacy_cutover_items() active_item_count = sum(1 for item in legacy_cutover_items if item["status"] == "active") retired_item_count = sum(1 for item in legacy_cutover_items if item["status"] == "retired") legacy_cutover_state = "retired" if active_item_count == 0 else "active_legacy_paths_remaining" return { "mounted_runtime_ready": True, "topic_workspace_count": int(topic_workspace_count or 0), "conversation_run_count": int(conversation_run_count or 0), "tool_policy_count": int(tool_policy_count or 0), "observed_source_systems": [str(item) for item in observed_sources if str(item or "").strip()], "legacy_cutover_state": legacy_cutover_state, "retired_item_count": int(retired_item_count), "active_item_count": int(active_item_count), "legacy_cutover_items": legacy_cutover_items, } async def list_eval_suites(self, *, tenant_id: int) -> dict[str, Any]: datasets = ( await self.db.execute( select(TrainingDataset) .where( TrainingDataset.tenant_id == int(tenant_id), TrainingDataset.dataset_type == "eval", ) .order_by(TrainingDataset.id.desc()) ) ).scalars().all() if not datasets: return {"items": [], "total": 0} dataset_ids = [int(row.id) for row in datasets] jobs = ( await self.db.execute( select(TrainingJob) .where( TrainingJob.tenant_id == int(tenant_id), TrainingJob.dataset_id.in_(dataset_ids), ) .order_by(TrainingJob.id.desc()) ) ).scalars().all() latest_job_by_dataset: dict[int, TrainingJob] = {} for job in jobs: latest_job_by_dataset.setdefault(int(job.dataset_id), job) job_ids = [int(job.id) for job in latest_job_by_dataset.values()] evaluations = [] if job_ids: evaluations = ( await self.db.execute( select(TrainingEvaluation) .where( TrainingEvaluation.tenant_id == int(tenant_id), TrainingEvaluation.training_job_id.in_(job_ids), ) .order_by(TrainingEvaluation.id.desc()) ) ).scalars().all() evaluations_by_job: dict[int, list[TrainingEvaluation]] = {} for item in evaluations: evaluations_by_job.setdefault(int(item.training_job_id), []).append(item) items: list[dict[str, Any]] = [] for dataset in datasets: latest_job = latest_job_by_dataset.get(int(dataset.id)) latest_evaluations = evaluations_by_job.get(int(latest_job.id), []) if latest_job is not None else [] completed_scores = [ float(row.score or 0.0) for row in latest_evaluations if row.score is not None and str(row.status or "").strip().lower() == "completed" ] latest_score = round(sum(completed_scores) / len(completed_scores), 2) if completed_scores else None last_evaluated_at = None if latest_evaluations: last_evaluated_at = latest_evaluations[0].updated_at items.append( self._eval_suite_snapshot( dataset, latest_score=latest_score, last_evaluated_at=last_evaluated_at, ) ) return {"items": items, "total": len(items)} async def get_eval_suite_detail(self, *, tenant_id: int, dataset_id: int) -> dict[str, Any]: dataset = ( await self.db.execute( select(TrainingDataset).where( TrainingDataset.tenant_id == int(tenant_id), TrainingDataset.id == int(dataset_id), TrainingDataset.dataset_type == "eval", ) ) ).scalar_one_or_none() if dataset is None: raise ValueError("mounted ai eval suite not found") latest_job = ( await self.db.execute( select(TrainingJob) .where( TrainingJob.tenant_id == int(tenant_id), TrainingJob.dataset_id == int(dataset.id), ) .order_by(TrainingJob.id.desc()) .limit(1) ) ).scalar_one_or_none() evaluations: list[TrainingEvaluation] = [] if latest_job is not None: evaluations = ( await self.db.execute( select(TrainingEvaluation) .where( TrainingEvaluation.tenant_id == int(tenant_id), TrainingEvaluation.training_job_id == int(latest_job.id), ) .order_by(TrainingEvaluation.id.asc()) ) ).scalars().all() completed_scores = [ float(row.score or 0.0) for row in evaluations if row.score is not None and str(row.status or "").strip().lower() == "completed" ] latest_score = round(sum(completed_scores) / len(completed_scores), 2) if completed_scores else None last_evaluated_at = evaluations[-1].updated_at if evaluations else None return { "item": self._eval_suite_snapshot( dataset, latest_score=latest_score, last_evaluated_at=last_evaluated_at, ), "selection_rules": dataset.selection_rules if isinstance(dataset.selection_rules, dict) else {}, "notes": dataset.notes, "evaluations": [self._evaluation_snapshot(row) for row in evaluations], }