529 lines
22 KiB
Python
529 lines
22 KiB
Python
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],
|
||
}
|