PMFlow/backend/app/services/mounted_ai_runtime_service.py

529 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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],
}