Implement mounted AI runtime and retire legacy Feishu brains

This commit is contained in:
Yuuuu 2026-04-05 01:46:06 +08:00
parent 0037c6954a
commit ecfaa7336c
30 changed files with 3305 additions and 326 deletions

View File

@ -76,6 +76,8 @@ frontend/ # React 前端
根据用户的具体需求,生成高质量、可直接运行的代码。
- 计划类需求执行约定:若用户已确认计划/方案并要求开始实现,Agent 默认连续执行到“实现完成 + 验证完成 + 给出验收结果”后再统一回复;除非遇到明确阻塞、高风险冲突、context 限制或用户主动中断,否则不得中途停下来反复请示。
---
## 8. 服务边界约束与 AI 系统宪章(禁止越界)

View File

@ -5,6 +5,7 @@
- `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景)
- 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md`
- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明
- 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。
## 2. 项目定位
- 项目名:`PMFlow / Neural Sync`
@ -117,7 +118,7 @@ backend/
- 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit)
- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环)
- 新增:`backend/app/services/task_breakdown.py`(任务拆解业务对象归一、技能标签回填、自动指派阈值判断)
- 新增:`backend/app/services/ai_pmo/consultant_dispatch.py`(轻量顾问式统一调度合同与注册表:保留现有业务 Prompt 拆分,统一生成 `domain/mode/risk_level/missing_fields/allow_execute/next_action`)
- 新增:`backend/app/services/ai_pmo/consultant_dispatch.py`(轻量顾问式统一调度合同与注册表:当前仅保留给 `/rag/ask` 等顾问式问答合同使用;已退出 Feishu mention 主回复与 AI PMO ingress capture 主链)
- 新增:`backend/app/config/ai_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约)
- 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单)
- 更新:`backend/app/config/ai_prompts_catalog/part_02.py`(`PROMPT_DRAFT_GENERATOR` 增加 active_projects / similar_projects / MVP / prospect / requirements 评审字段)
@ -195,13 +196,18 @@ backend/
- 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录)
- 新增:`backend/app/models/reply_iteration.py`(回复质量迭代模型:`QualityAsset/QualityAssetPair/JudgeRun/OptimizerCandidate/PromptRelease`)
- 新增:`backend/app/models/training_pipeline.py`(训练/微调管线模型:`TrainingDataset/TrainingJob/TrainingEvaluation/TrainedModelArtifact/TrainedModelDeployment`)
- 新增:`backend/app/models/mounted_ai_runtime.py`(Mounted Runtime 运行时模型:`MountedTopicWorkspace/MountedDraftWorkspace/MountedConversationRun/MountedAgentTrace/MountedToolCapabilityPolicy`)
- 新增:`backend/app/services/reply_iteration/service.py`(反馈中心提升为学习资产、裁判分桶、候选改动、prompt release trial 服务)
- 新增:`backend/app/services/training_pipeline/service.py`(训练数据集、训练作业、评测、artifact promote、deployment 决策服务)
- 新增:`backend/app/services/prompt_release/service.py`(只读 QA prompt release 版本管理与 serving 渲染优先级)
- 新增:`backend/app/services/mounted_ai_runtime_service.py`、`backend/app/services/mounted_ai_runtime_write_service.py`(Mounted Runtime 读模型与写路径:来源/主题/run 查询 + ingress topic/run/trace 落库)
- 新增:`backend/app/api/ai_steward_api/routes_reply_iteration.py`(AI Steward 回复质量闭环接口:反馈提升、judge-runs、candidates、prompt-releases、trials)
- 新增:`backend/app/api/ai_steward_api/routes_training_pipeline.py`(AI Steward 训练管线接口:datasets、jobs、evaluations、artifacts、deployments)
- 新增:`backend/app/api/ai_steward_api/routes_mounted_ai.py`、`backend/app/schemas/mounted_ai_runtime.py`(Mounted Runtime 控制台接口:sources/topics/runs/run-detail/cutover-status/eval-suites)
- 新增:`backend/alembic/versions/20260327_add_reply_iteration_training_tables.py`(回复质量迭代/训练管线主迁移:学习资产表、prompt release、训练数据集、训练作业与 experiment 扩展字段)
- 新增:`backend/alembic/versions/20260405_add_mounted_ai_runtime_tables.py`(Mounted Runtime 建表迁移:topic workspace、draft workspace、conversation run、agent trace、tool capability policy)
- 新增:`backend/tests/test_reply_iteration_service.py`、`test_training_pipeline_service.py`、`test_reply_iteration_api.py`、`test_reply_iteration_training_migration.py`(回复质量闭环与训练管线基础回归)
- 新增:`backend/tests/test_mounted_ai_runtime_models.py`、`test_mounted_ai_runtime_migration.py`、`test_mounted_ai_runtime_api.py`、`test_mounted_ai_runtime_write_service.py`(Mounted Runtime 模型/迁移/API/写路径回归)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架)
- 新增:`backend/app/services/ai_pmo_decision_runtime.py`(AI PMO 决策脑层:`Strategic/Tactical/Simulation/ExecutionPolicy/DecisionEngine`)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py`(AI PMO 回执渲染出口 `ReplyRenderer`)
@ -217,7 +223,7 @@ backend/
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助)
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(写操作改为“先起草后确认”,状态进入 `draft_pending_confirm`)
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理)
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理,并把 ingress 主链同步写入 Mounted Runtime 的 topic/run/trace)
- 更新:`backend/app/models/work_item.py`(新增 `draft_content/draft_expires_at/draft_version/confirm_message_id`)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py`(Kernel world-model 组合入口)
- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`(Kernel world-model 核心构建与缓存)
@ -371,6 +377,7 @@ frontend/
- 新增:`frontend/src/api/taskBriefs.ts`(任务包中心 API 封装)
- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装)
- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与 low/high 消费验证 API)
- 新增:`frontend/src/api/mountedAi.ts`(Mounted Runtime 控制台 API:sources/topics/runs/run-detail/cutover-status)
- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装)
- 更新:`frontend/src/api/aiSteward.ts`(新增 AI 管家 QA 封装:`/rag/ask`、QA sessions、feedback、会话改名;浮窗不再直接 `fetch`)
- 更新:`frontend/src/api/aiPmo.ts`(新增 `processText` 封装,供 AI 管家浮窗把“允许执行”显式交接到 AI PMO Kernel)
@ -383,6 +390,8 @@ frontend/
- 更新:`frontend/src/pages/CharterDetailPage.tsx`、`ContractSyncBoardPage.tsx`、`DashboardPage.tsx`、`KnowledgeGraphContextBoardPage.tsx`、`LoginPage.tsx`、`ModelRoutingConfigPage.tsx`、`SchedulerCenterPage.tsx`、`SkillTasksPage.tsx`、`SkillsPage.tsx`、`TaskBriefCenterPage.tsx`、`TaskBriefRunsPage.tsx`、`TaskBriefTemplatePage.tsx`、`UserProfilePage.tsx`、`UserSkillsPage.tsx`、`WorkloadDetailPage.tsx`、`WorkloadsPage.tsx`(统一降级为轻量入口,转发到 `pages/*/` 子目录)
- 新增:`frontend/src/pages/charter-detail/CharterDetailPage.tsx`、`contract-sync-board/ContractSyncBoardPage.tsx`、`dashboard/DashboardPage.tsx`、`knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx`、`login/LoginPage.tsx`、`model-routing-config/ModelRoutingConfigPage.tsx`(剩余核心页面目录化收敛)
- 新增:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`、`skill-tasks/SkillTasksPage.tsx`、`skills/SkillsPage.tsx`、`task-brief-center/TaskBriefCenterPage.tsx`、`task-brief-runs/TaskBriefRunsPage.tsx`、`task-brief-template/TaskBriefTemplatePage.tsx`(任务/调度/技能页面目录化收敛)
- 新增:`frontend/src/pages/scheduler-center/components/MountedAISourceStatusPanel.tsx`、`MountedAITopicsPanel.tsx`、`MountedAIRunsPanel.tsx`、`MountedAIRunReplayPanel.tsx`、`MountedAIAcceptancePanel.tsx`(Mounted AI Console 来源状态、topic workspace、run 列表、replay 详情与验收门槛面板)
- 更新:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`(Scheduler Center 升级为 Mounted AI Console:接入 mounted runtime 查询、run replay 与 PLAN3 验收门槛展示)
- 新增:`frontend/src/pages/user-profile/UserProfilePage.tsx`、`user-skills/UserSkillsPage.tsx`、`workload-detail/WorkloadDetailPage.tsx`、`workloads/WorkloadsPage.tsx`(用户与负载页面目录化收敛)
- 更新:`frontend/src/pages/InboxPage.tsx`(降级为轻量入口,转发到 `pages/inbox/InboxPage`)
- 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper)
@ -578,6 +587,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- `model-routing` 只暴露逻辑层字段:`function_name/tier_alias/source/rule_name/rule_priority/last_execution`;`PUT` 仅接受 `tier_alias=low|high`
- 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py`(`POST /ai-steward/feedback-center/items/{id}/promote`、`/reply-iteration/judge-runs/candidates/prompt-releases/trials`)
- 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py`(`GET/POST /ai-steward/training/datasets/jobs`、`GET /training/jobs/{id}/evaluations`、`POST /training/artifacts/{id}/promote`、`POST /training/deployments/{id}/decision`)
- Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py`(`GET /ai-steward/mounted-ai/sources|topics|runs|runs/{id}|cutover-status|eval-suites|eval-suites/{id}`,统一 `{code,message,data}` 包络);其中 `cutover-status` 必须返回模块级旧脑退役视图:`legacy_cutover_state / retired_item_count / active_item_count / legacy_cutover_items[]`
- 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`)
- KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`、`POST /ai-steward/knowledge-graph/sync/work-items`;旧 `POST /ai-steward/knowledge-graph/sync/feishu` 仅保留兼容别名)
- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重、`raw_context` 持久化,并为每条 registry 同步绑定/物化正式 `Signal`)
@ -603,7 +613,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part/consultant_mode_preference`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应
- RAG 空回答保护:`POST /rag/ask` 若 `answer` 与 `reply_text` 同时为空,必须返回失败包络,禁止再用“当前暂无可用回答。”之类兜底文案冒充成功结果
- RAG Grounding Runtime 合同补充:`POST /rag/ask` 的成功返回统一透传 `answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`;`AdaptiveRAG` 内部允许通过兼容层把异构 `sources` 映射成稳定 citation 结构,但业务语义判断仍只允许放在 `workflow_semantic_planner`
- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,飞书 mention 回复与 AI 管家问答共享同一份模式判断
- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,当前仅供 RAG / AI Steward 顾问式问答透传,已不再驱动 Feishu mention 回复或 AI PMO ingress capture 主链
- RAG 顾问模式偏好约束:`consultant_mode_preference` 只允许取 `clarify/advise/challenge/brainstorm/execute`,且仅影响回答风格与 prompt wrapper;它不能替代 `workflow_semantic_planner` 去决定 `query vs execute`
- RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*`
- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影)

View File

@ -0,0 +1,261 @@
"""add mounted ai runtime tables
Revision ID: 20260405_add_mounted_ai_runtime_tables
Revises: 20260331_add_qa_record_runtime_fields
Create Date: 2026-04-05
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
try:
from sqlalchemy.dialects.postgresql import JSONB
except Exception: # pragma: no cover
JSONB = sa.JSON
revision = "20260405_add_mounted_ai_runtime_tables"
down_revision = "20260331_add_qa_record_runtime_fields"
branch_labels = None
depends_on = None
def _json_type(bind):
return JSONB() if bind.dialect.name == "postgresql" else sa.JSON()
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
try:
indexes = inspector.get_indexes(table_name)
except Exception:
return False
return any(str(index.get("name")) == index_name for index in indexes)
def _create_index_if_missing(bind, table_name: str, index_name: str, columns: list[str], *, unique: bool = False) -> None:
if not _index_exists(bind, table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique)
def _base_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
]
def _create_mounted_topic_workspaces(bind) -> None:
if _table_exists(bind, "mounted_topic_workspaces"):
return
op.create_table(
"mounted_topic_workspaces",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("topic_key", sa.VARCHAR(length=128), nullable=False),
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
sa.Column("channel_ref", sa.VARCHAR(length=128), nullable=True),
sa.Column("thread_key", sa.VARCHAR(length=255), nullable=True),
sa.Column("root_message_id", sa.VARCHAR(length=128), nullable=True),
sa.Column("mode", sa.VARCHAR(length=24), nullable=False, server_default="chat"),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="active"),
sa.Column("facts_json", _json_type(bind), nullable=True),
sa.Column("goals_json", _json_type(bind), nullable=True),
sa.Column("constraints_json", _json_type(bind), nullable=True),
sa.Column("options_json", _json_type(bind), nullable=True),
sa.Column("open_questions", _json_type(bind), nullable=True),
sa.Column("pending_actions", _json_type(bind), nullable=True),
sa.Column("bound_entities", _json_type(bind), nullable=True),
sa.Column("readiness_score", sa.Float(), nullable=True, server_default="0"),
sa.Column("last_message_id", sa.VARCHAR(length=128), nullable=True),
sa.Column("last_triggered_at", sa.TIMESTAMP(), nullable=True),
sa.Column("metadata_json", _json_type(bind), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"source_system",
"topic_key",
name="uq_mounted_topic_workspace_source_topic",
),
)
for index_name, columns in (
("ix_mounted_topic_workspaces_tenant_id", ["tenant_id"]),
("ix_mounted_topic_workspaces_topic_key", ["topic_key"]),
("ix_mounted_topic_workspaces_source_system", ["source_system"]),
("ix_mounted_topic_workspaces_channel_ref", ["channel_ref"]),
("ix_mounted_topic_workspaces_thread_key", ["thread_key"]),
("ix_mounted_topic_workspaces_root_message_id", ["root_message_id"]),
("ix_mounted_topic_workspaces_mode", ["mode"]),
("ix_mounted_topic_workspaces_status", ["status"]),
("ix_mounted_topic_workspaces_last_message_id", ["last_message_id"]),
):
_create_index_if_missing(bind, "mounted_topic_workspaces", index_name, columns)
def _create_mounted_draft_workspaces(bind) -> None:
if _table_exists(bind, "mounted_draft_workspaces"):
return
op.create_table(
"mounted_draft_workspaces",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"topic_workspace_id",
sa.Integer(),
sa.ForeignKey("mounted_topic_workspaces.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("state", sa.VARCHAR(length=24), nullable=False, server_default="collecting"),
sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0"),
sa.Column("requirement_card", _json_type(bind), nullable=True),
sa.Column("draft_payload", _json_type(bind), nullable=True),
sa.Column("required_confirmations", _json_type(bind), nullable=True),
sa.Column("last_ready_at", sa.TIMESTAMP(), nullable=True),
sa.Column("confirm_message_id", sa.VARCHAR(length=128), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"topic_workspace_id",
name="uq_mounted_draft_workspace_topic",
),
)
for index_name, columns in (
("ix_mounted_draft_workspaces_tenant_id", ["tenant_id"]),
("ix_mounted_draft_workspaces_topic_workspace_id", ["topic_workspace_id"]),
("ix_mounted_draft_workspaces_state", ["state"]),
("ix_mounted_draft_workspaces_confirm_message_id", ["confirm_message_id"]),
):
_create_index_if_missing(bind, "mounted_draft_workspaces", index_name, columns)
def _create_mounted_conversation_runs(bind) -> None:
if _table_exists(bind, "mounted_conversation_runs"):
return
op.create_table(
"mounted_conversation_runs",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("trace_id", sa.VARCHAR(length=64), nullable=False),
sa.Column(
"topic_workspace_id",
sa.Integer(),
sa.ForeignKey("mounted_topic_workspaces.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("source_system", sa.VARCHAR(length=32), nullable=False),
sa.Column("source_message_id", sa.VARCHAR(length=128), nullable=True),
sa.Column("mode_before", sa.VARCHAR(length=24), nullable=True),
sa.Column("mode_after", sa.VARCHAR(length=24), nullable=True),
sa.Column("active_agent", sa.VARCHAR(length=64), nullable=True),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="queued"),
sa.Column("trigger_reason", sa.VARCHAR(length=128), nullable=True),
sa.Column("context_layers_hit", _json_type(bind), nullable=True),
sa.Column("tool_calls", _json_type(bind), nullable=True),
sa.Column("final_reply", sa.Text(), nullable=True),
sa.Column("execution_outcome", _json_type(bind), nullable=True),
sa.Column("error_text", sa.Text(), nullable=True),
sa.Column("started_at", sa.TIMESTAMP(), nullable=True),
sa.Column("completed_at", sa.TIMESTAMP(), nullable=True),
*_base_columns(),
sa.UniqueConstraint("tenant_id", "trace_id", name="uq_mounted_conversation_run_trace"),
)
for index_name, columns in (
("ix_mounted_conversation_runs_tenant_id", ["tenant_id"]),
("ix_mounted_conversation_runs_trace_id", ["trace_id"]),
("ix_mounted_conversation_runs_topic_workspace_id", ["topic_workspace_id"]),
("ix_mounted_conversation_runs_source_system", ["source_system"]),
("ix_mounted_conversation_runs_source_message_id", ["source_message_id"]),
("ix_mounted_conversation_runs_mode_before", ["mode_before"]),
("ix_mounted_conversation_runs_mode_after", ["mode_after"]),
("ix_mounted_conversation_runs_active_agent", ["active_agent"]),
("ix_mounted_conversation_runs_status", ["status"]),
("ix_mounted_conversation_runs_trigger_reason", ["trigger_reason"]),
):
_create_index_if_missing(bind, "mounted_conversation_runs", index_name, columns)
def _create_mounted_agent_traces(bind) -> None:
if _table_exists(bind, "mounted_agent_traces"):
return
op.create_table(
"mounted_agent_traces",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column(
"conversation_run_id",
sa.Integer(),
sa.ForeignKey("mounted_conversation_runs.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("agent_name", sa.VARCHAR(length=64), nullable=False),
sa.Column("status", sa.VARCHAR(length=24), nullable=False, server_default="completed"),
sa.Column("latency_ms", sa.Integer(), nullable=True),
sa.Column("input_summary", _json_type(bind), nullable=True),
sa.Column("output_summary", _json_type(bind), nullable=True),
sa.Column("tool_trace", _json_type(bind), nullable=True),
sa.Column("failure_reason", sa.Text(), nullable=True),
*_base_columns(),
)
for index_name, columns in (
("ix_mounted_agent_traces_tenant_id", ["tenant_id"]),
("ix_mounted_agent_traces_conversation_run_id", ["conversation_run_id"]),
("ix_mounted_agent_traces_agent_name", ["agent_name"]),
("ix_mounted_agent_traces_status", ["status"]),
):
_create_index_if_missing(bind, "mounted_agent_traces", index_name, columns)
def _create_mounted_tool_capability_policies(bind) -> None:
if _table_exists(bind, "mounted_tool_capability_policies"):
return
op.create_table(
"mounted_tool_capability_policies",
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
sa.Column("tool_name", sa.VARCHAR(length=128), nullable=False),
sa.Column("risk_level", sa.VARCHAR(length=24), nullable=False, server_default="medium"),
sa.Column("confirm_required", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("auto_execute_allowed", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("source_scope", _json_type(bind), nullable=True),
sa.Column("tenant_scope", _json_type(bind), nullable=True),
sa.Column("policy_metadata", _json_type(bind), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_base_columns(),
sa.UniqueConstraint(
"tenant_id",
"tool_name",
name="uq_mounted_tool_capability_policy_tool",
),
)
for index_name, columns in (
("ix_mounted_tool_capability_policies_tenant_id", ["tenant_id"]),
("ix_mounted_tool_capability_policies_tool_name", ["tool_name"]),
("ix_mounted_tool_capability_policies_risk_level", ["risk_level"]),
("ix_mounted_tool_capability_policies_is_active", ["is_active"]),
):
_create_index_if_missing(bind, "mounted_tool_capability_policies", index_name, columns)
def upgrade() -> None:
bind = op.get_bind()
_create_mounted_topic_workspaces(bind)
_create_mounted_draft_workspaces(bind)
_create_mounted_conversation_runs(bind)
_create_mounted_agent_traces(bind)
_create_mounted_tool_capability_policies(bind)
def downgrade() -> None:
bind = op.get_bind()
for table_name in (
"mounted_tool_capability_policies",
"mounted_agent_traces",
"mounted_conversation_runs",
"mounted_draft_workspaces",
"mounted_topic_workspaces",
):
if _table_exists(bind, table_name):
op.drop_table(table_name)

View File

@ -8,6 +8,7 @@ from . import (
routes_conflicts,
routes_executive_briefing,
routes_feedback_center,
routes_mounted_ai,
routes_reply_iteration,
routes_rag_sources,
routes_stats_config,
@ -22,6 +23,7 @@ for _module in [
routes_conflicts,
routes_executive_briefing,
routes_feedback_center,
routes_mounted_ai,
routes_reply_iteration,
routes_brief_blockers,
routes_stats_config,

View File

@ -0,0 +1,127 @@
from __future__ import annotations
from fastapi import Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from ...core.response import ApiResponse, success_response
from ...dependencies.permissions import require_manager_user
from ...dependencies.tenant import get_tenant_id
from ...models import User
from ...schemas.mounted_ai_runtime import (
MountedAICutoverStatusPayload,
MountedAIEvalSuiteDetailPayload,
MountedAIEvalSuiteListPayload,
MountedAIRunDetailPayload,
MountedAIRunListPayload,
MountedAISourceListPayload,
MountedAITopicListPayload,
)
from ...services.mounted_ai_runtime_service import MountedAIRuntimeService
from .shared import *
@router.get("/mounted-ai/sources", response_model=ApiResponse[MountedAISourceListPayload])
async def list_mounted_ai_sources(
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
data = await MountedAIRuntimeService(db).list_sources(tenant_id=effective_tenant_id)
return success_response(data)
@router.get("/mounted-ai/topics", response_model=ApiResponse[MountedAITopicListPayload])
async def list_mounted_ai_topics(
mode: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
data = await MountedAIRuntimeService(db).list_topics(
tenant_id=effective_tenant_id,
limit=limit,
offset=offset,
mode=mode,
)
return success_response(data)
@router.get("/mounted-ai/runs", response_model=ApiResponse[MountedAIRunListPayload])
async def list_mounted_ai_runs(
status: str | None = Query(default=None),
limit: int = Query(default=20, ge=1, le=100),
offset: int = Query(default=0, ge=0),
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
data = await MountedAIRuntimeService(db).list_runs(
tenant_id=effective_tenant_id,
limit=limit,
offset=offset,
status=status,
)
return success_response(data)
@router.get("/mounted-ai/runs/{run_id}", response_model=ApiResponse[MountedAIRunDetailPayload])
async def get_mounted_ai_run_detail(
run_id: int,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await MountedAIRuntimeService(db).get_run_detail(
tenant_id=effective_tenant_id,
run_id=run_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(data)
@router.get("/mounted-ai/cutover-status", response_model=ApiResponse[MountedAICutoverStatusPayload])
async def get_mounted_ai_cutover_status(
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
data = await MountedAIRuntimeService(db).get_cutover_status(tenant_id=effective_tenant_id)
return success_response(data)
@router.get("/mounted-ai/eval-suites", response_model=ApiResponse[MountedAIEvalSuiteListPayload])
async def list_mounted_ai_eval_suites(
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
data = await MountedAIRuntimeService(db).list_eval_suites(tenant_id=effective_tenant_id)
return success_response(data)
@router.get("/mounted-ai/eval-suites/{dataset_id}", response_model=ApiResponse[MountedAIEvalSuiteDetailPayload])
async def get_mounted_ai_eval_suite_detail(
dataset_id: int,
current_user: User = Depends(require_manager_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)
try:
data = await MountedAIRuntimeService(db).get_eval_suite_detail(
tenant_id=effective_tenant_id,
dataset_id=dataset_id,
)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return success_response(data)

View File

@ -73,6 +73,13 @@ from .plan_bundle import PlanBundle
from .reply_receipt import ReplyReceipt
from .portfolio_snapshot import PortfolioSnapshot
from .conversation_session import ConversationSession
from .mounted_ai_runtime import (
MountedAgentTrace,
MountedConversationRun,
MountedDraftWorkspace,
MountedToolCapabilityPolicy,
MountedTopicWorkspace,
)
__all__ = [
"Base",
@ -153,4 +160,9 @@ __all__ = [
"ReplyReceipt",
"PortfolioSnapshot",
"ConversationSession",
"MountedAgentTrace",
"MountedConversationRun",
"MountedDraftWorkspace",
"MountedToolCapabilityPolicy",
"MountedTopicWorkspace",
]

View File

@ -0,0 +1,149 @@
"""Mounted AI Runtime 核心运行时对象。"""
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy import Boolean, Float, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
from .types import JSONB
class MountedTopicWorkspace(Base):
__tablename__ = "mounted_topic_workspaces"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"source_system",
"topic_key",
name="uq_mounted_topic_workspace_source_topic",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
topic_key: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
source_system: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
channel_ref: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
thread_key: Mapped[Optional[str]] = mapped_column(VARCHAR(255), nullable=True, index=True)
root_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
mode: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="chat", index=True)
status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="active", index=True)
facts_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
goals_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
constraints_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
options_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
open_questions: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
pending_actions: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
bound_entities: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
readiness_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True, default=0.0)
last_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
last_triggered_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
metadata_json: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
class MountedDraftWorkspace(Base):
__tablename__ = "mounted_draft_workspaces"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"topic_workspace_id",
name="uq_mounted_draft_workspace_topic",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
topic_workspace_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("mounted_topic_workspaces.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
state: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="collecting", index=True)
draft_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
requirement_card: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
draft_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
required_confirmations: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
last_ready_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
confirm_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
class MountedConversationRun(Base):
__tablename__ = "mounted_conversation_runs"
__table_args__ = (
UniqueConstraint("tenant_id", "trace_id", name="uq_mounted_conversation_run_trace"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trace_id: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
topic_workspace_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("mounted_topic_workspaces.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
source_system: Mapped[str] = mapped_column(VARCHAR(32), nullable=False, index=True)
source_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
mode_before: Mapped[Optional[str]] = mapped_column(VARCHAR(24), nullable=True, index=True)
mode_after: Mapped[Optional[str]] = mapped_column(VARCHAR(24), nullable=True, index=True)
active_agent: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="queued", index=True)
trigger_reason: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
context_layers_hit: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
tool_calls: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
final_reply: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
execution_outcome: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
error_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
started_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
completed_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
class MountedAgentTrace(Base):
__tablename__ = "mounted_agent_traces"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
conversation_run_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("mounted_conversation_runs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
agent_name: Mapped[str] = mapped_column(VARCHAR(64), nullable=False, index=True)
status: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="completed", index=True)
latency_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
input_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
output_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
tool_trace: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
failure_reason: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
class MountedToolCapabilityPolicy(Base):
__tablename__ = "mounted_tool_capability_policies"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"tool_name",
name="uq_mounted_tool_capability_policy_tool",
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
tool_name: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
risk_level: Mapped[str] = mapped_column(VARCHAR(24), nullable=False, default="medium", index=True)
confirm_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
auto_execute_allowed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
source_scope: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
tenant_scope: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
policy_metadata: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
__all__ = [
"MountedAgentTrace",
"MountedConversationRun",
"MountedDraftWorkspace",
"MountedToolCapabilityPolicy",
"MountedTopicWorkspace",
]

View File

@ -0,0 +1,150 @@
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
class MountedAISourceItem(BaseModel):
model_config = ConfigDict(extra="ignore")
source_system: str
run_count: int = 0
topic_count: int = 0
active_topic_count: int = 0
last_seen_at: Optional[str] = None
class MountedAISourceListPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
items: list[MountedAISourceItem] = Field(default_factory=list)
class MountedAITopicItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: int
topic_key: str
source_system: str
channel_ref: Optional[str] = None
thread_key: Optional[str] = None
mode: str
status: str
readiness_score: Optional[float] = None
draft_state: Optional[str] = None
draft_version: Optional[int] = None
updated_at: Optional[str] = None
class MountedAITopicListPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
items: list[MountedAITopicItem] = Field(default_factory=list)
total: int = 0
class MountedAIRunItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: int
trace_id: str
source_system: str
source_message_id: Optional[str] = None
mode_before: Optional[str] = None
mode_after: Optional[str] = None
active_agent: Optional[str] = None
status: str
trigger_reason: Optional[str] = None
final_reply: Optional[str] = None
trace_count: int = 0
updated_at: Optional[str] = None
class MountedAIRunListPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
items: list[MountedAIRunItem] = Field(default_factory=list)
total: int = 0
class MountedAIAgentTraceItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: int
agent_name: str
status: str
latency_ms: Optional[int] = None
failure_reason: Optional[str] = None
created_at: Optional[str] = None
class MountedAIRunDetailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
item: MountedAIRunItem
agent_traces: list[MountedAIAgentTraceItem] = Field(default_factory=list)
context_layers_hit: Optional[Any] = None
tool_calls: Optional[Any] = None
execution_outcome: Optional[Any] = None
class MountedAICutoverStatusPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
mounted_runtime_ready: bool
topic_workspace_count: int = 0
conversation_run_count: int = 0
tool_policy_count: int = 0
observed_source_systems: list[str] = Field(default_factory=list)
legacy_cutover_state: str
retired_item_count: int = 0
active_item_count: int = 0
legacy_cutover_items: list["MountedAILegacyCutoverItem"] = Field(default_factory=list)
class MountedAILegacyCutoverItem(BaseModel):
model_config = ConfigDict(extra="ignore")
key: str
title: str
status: str
summary: str
evidence: str
class MountedAIEvalSuiteItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: int
dataset_type: str
version: str
status: str
suite_kind: str
sample_count: int = 0
latest_score: Optional[float] = None
last_evaluated_at: Optional[str] = None
class MountedAIEvalSuiteListPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
items: list[MountedAIEvalSuiteItem] = Field(default_factory=list)
total: int = 0
class MountedAIEvalResultItem(BaseModel):
model_config = ConfigDict(extra="ignore")
evaluation_type: str
status: str
score: Optional[float] = None
class MountedAIEvalSuiteDetailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
item: MountedAIEvalSuiteItem
selection_rules: Optional[Any] = None
notes: Optional[str] = None
evaluations: list[MountedAIEvalResultItem] = Field(default_factory=list)

View File

@ -3,7 +3,6 @@
from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common
from ....consultant_dispatch import build_consultant_dispatch_contract
from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items():
@ -25,17 +24,7 @@ class KernelIngressRenderMixin:
) -> dict[str, Any]:
linked_project_ids = [int(work_item.project_id)] if work_item.project_id is not None else []
direct_reply_text = str(render.get("text") or "").strip()
primary_operation_code = self._primary_operation_code(str(intent.name or ""))
risk_level = self._risk_level_for_intent(str(intent.name or ""))
missing_fields = (
execution_result.get("missing_fields")
if isinstance(execution_result.get("missing_fields"), list)
else (
(intent.metadata or {}).get("missing_fields")
if isinstance(intent.metadata, dict) and isinstance((intent.metadata or {}).get("missing_fields"), list)
else []
)
)
workflow_route = str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None
mixed_sidecar_reply = (
str((intent.metadata or {}).get("mixed_sidecar_reply") or "").strip()
if isinstance(intent.metadata, dict)
@ -62,17 +51,6 @@ class KernelIngressRenderMixin:
signal_ids = [primary_signal_id, *signal_ids]
if signal_ids and primary_signal_id is None:
primary_signal_id = signal_ids[0]
consultant_dispatch = build_consultant_dispatch_contract(
intent_name=str(intent.name or ""),
message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""),
source_system=str(getattr(work_item, "source_system", "") or "pmflow"),
status=status,
classification=classification,
primary_operation_code=primary_operation_code,
risk_level=risk_level,
missing_fields=missing_fields,
direct_reply_text=direct_reply_text,
)
result = {
"handled": True,
"status": status,
@ -97,10 +75,9 @@ class KernelIngressRenderMixin:
}
],
"intent": intent.name,
"workflow_route": str(work_item.intent or execution_result.get("workflow_route") or "").strip() or None,
"workflow_route": workflow_route,
"direct_reply_required": bool(direct_reply_required),
"direct_reply_text": direct_reply_text,
"consultant_dispatch": consultant_dispatch,
"reply_template_key": str(render.get("template_key") or "").strip(),
"work_item_id": int(work_item.id),
"decision_level": work_item.decision_level,
@ -121,7 +98,7 @@ class KernelIngressRenderMixin:
intent_name=str(intent.name or ""),
message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""),
source_system=str(getattr(work_item, "source_system", "") or "unknown"),
workflow_route=str(work_item.intent or execution_result.get("workflow_route") or "").strip(),
workflow_route=str(workflow_route or ""),
project_id=_safe_int(work_item.project_id),
metadata=intent.metadata if isinstance(intent.metadata, dict) else {},
).to_dict()
@ -135,11 +112,8 @@ class KernelIngressRenderMixin:
draft_expires_at = payload.get("draft_expires_at")
if isinstance(draft_expires_at, str) and draft_expires_at.strip():
result["draft_expires_at"] = draft_expires_at.strip()
if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict):
rag_result = dict(execution_result["rag_result"])
if not isinstance(rag_result.get("consultant_dispatch"), dict):
rag_result["consultant_dispatch"] = consultant_dispatch
result["rag_result"] = rag_result
if workflow_route == ROUTE_QUERY_ANSWER and isinstance(execution_result.get("rag_result"), dict):
result["rag_result"] = dict(execution_result["rag_result"])
if intent.name == INTENT_PROJECT_CREATE:
raw_payload = execution_result.get("raw_payload") if isinstance(execution_result.get("raw_payload"), dict) else {}
result["project"] = raw_payload.get("project")

View File

@ -7,6 +7,7 @@ from app.services.ai_pmo.workflow_semantic_planner.constants import (
QUERY_SCENE_BUSINESS_REVIEW,
QUERY_SCENE_CORRECTION_ESCALATION,
)
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
class KernelIngressFlowEntryMixin:
_MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问")
@ -291,6 +292,8 @@ class KernelIngressFlowEntryMixin:
perf_work_item_id: Optional[int] = None
perf_intent_name = ""
thread_context = self._extract_thread_context_from_ingress_options(ingress_options)
mounted_runtime = MountedAIRuntimeWriteService(self.db)
mounted_tracking = None
def _log_perf(stage_name: str, stage_started_at: float) -> None:
elapsed_seconds = time.perf_counter() - stage_started_at
perf_line = (
@ -300,6 +303,86 @@ class KernelIngressFlowEntryMixin:
)
logger.info(perf_line)
print(perf_line, flush=True)
async def _safe_begin_mounted_runtime(*, runtime_message_id: str, runtime_message_text: str) -> None:
nonlocal mounted_tracking
if mounted_tracking is not None:
return
try:
mounted_tracking = await mounted_runtime.begin_ingress_run(
tenant_id=int(self.tenant_id),
source_system=source_system,
source_message_id=runtime_message_id,
message_text=runtime_message_text,
sender=sender,
channel_ref=channel_ref,
thread_context=thread_context,
)
except Exception as runtime_exc:
logger.warning(
"mounted_ai_runtime_begin_failed",
tenant_id=self.tenant_id,
source_system=source_system,
source_message_id=runtime_message_id,
error=str(runtime_exc),
)
async def _safe_record_mounted_decision(
*,
workflow_route: str,
intent_name: str,
intent_confidence: float,
trigger_reason: str,
auto_execute: bool,
work_item_id: Optional[int],
route_metadata: Optional[dict[str, Any]],
mode_override: Optional[str] = None,
) -> None:
if mounted_tracking is None:
return
try:
await mounted_runtime.record_orchestrator_decision(
tracking=mounted_tracking,
workflow_route=workflow_route,
intent_name=intent_name,
intent_confidence=float(intent_confidence or 0.0),
trigger_reason=trigger_reason,
auto_execute=auto_execute,
work_item_id=work_item_id,
route_metadata=route_metadata,
mode_override=mode_override,
)
except Exception as runtime_exc:
logger.warning(
"mounted_ai_runtime_record_failed",
tenant_id=self.tenant_id,
source_system=source_system,
source_message_id=str(getattr(getattr(mounted_tracking, "conversation_run", None), "source_message_id", "") or ""),
error=str(runtime_exc),
)
async def _safe_finalize_mounted_runtime(
*,
result: Optional[dict[str, Any]] = None,
error_text: Optional[str] = None,
) -> None:
if mounted_tracking is None:
return
try:
await mounted_runtime.finalize_run(
tracking=mounted_tracking,
result=result if isinstance(result, dict) else None,
error_text=error_text,
)
except Exception as runtime_exc:
logger.warning(
"mounted_ai_runtime_finalize_failed",
tenant_id=self.tenant_id,
source_system=source_system,
source_message_id=str(getattr(getattr(mounted_tracking, "conversation_run", None), "source_message_id", "") or ""),
error=str(runtime_exc),
)
ingress_prepare = await self._prepare_ingress_envelope(
source_system=source_system,
source_message_id=source_message_id,
@ -321,6 +404,10 @@ class KernelIngressFlowEntryMixin:
operator = ingress_prepare["operator"]
work_item = ingress_prepare["work_item"]
session = ingress_prepare["session"]
await _safe_begin_mounted_runtime(
runtime_message_id=synthetic_message_id,
runtime_message_text=clean_text,
)
self._merge_ingress_thread_context(
ingest_event=ingest_event,
ingress_options=ingress_options,
@ -344,6 +431,25 @@ class KernelIngressFlowEntryMixin:
ingress_options=ingress_options,
)
if draft_capture is not None:
draft_mode_override = (
"draft_ready"
if str(draft_capture.get("reply_template_key") or "").strip().lower() == "draft_pending_confirm"
else "plan"
)
await _safe_record_mounted_decision(
workflow_route=str(draft_capture.get("workflow_route") or ROUTE_FOLLOWUP_CONFIRM).strip() or ROUTE_FOLLOWUP_CONFIRM,
intent_name=INTENT_FOLLOWUP_CONFIRM,
intent_confidence=0.95,
trigger_reason="draft_confirmation",
auto_execute=False,
work_item_id=_safe_int(draft_capture.get("work_item_id")) or perf_work_item_id,
route_metadata={
"draft_confirmation": True,
"reply_template_key": str(draft_capture.get("reply_template_key") or "").strip() or None,
},
mode_override=draft_mode_override,
)
await _safe_finalize_mounted_runtime(result=draft_capture)
return draft_capture
mixed_meta: dict[str, Any] = {}
if not self._is_short_confirmation_message(
@ -454,7 +560,7 @@ class KernelIngressFlowEntryMixin:
resolved_project_for_session = _safe_int(workflow_route.project_id) or _safe_int(world_model.get("resolved_project_id"))
fallback_intent = intent
if intent.name == INTENT_IGNORE:
return await self._finalize_ignored_ingress(
capture_result = await self._finalize_ignored_ingress(
work_item=work_item,
ingest_event=ingest_event,
session=session,
@ -465,6 +571,18 @@ class KernelIngressFlowEntryMixin:
sender=sender,
resolved_project_for_session=resolved_project_for_session,
)
await _safe_record_mounted_decision(
workflow_route=workflow_route.key,
intent_name=intent.name,
intent_confidence=float(intent.confidence or 0.0),
trigger_reason="ignored",
auto_execute=False,
work_item_id=int(work_item.id),
route_metadata=route_metadata,
mode_override="chat",
)
await _safe_finalize_mounted_runtime(result=capture_result)
return capture_result
work_item.intent = workflow_route.key
work_item.project_id = workflow_route.project_id
@ -562,9 +680,24 @@ class KernelIngressFlowEntryMixin:
started=started,
)
_log_perf("_build_capture_result", stage_started_at)
await _safe_record_mounted_decision(
workflow_route=workflow_route.key,
intent_name=intent.name,
intent_confidence=float(intent.confidence or 0.0),
trigger_reason=manual_review_code if requires_manual_review else str(gate.gate_code or "manual_review"),
auto_execute=False,
work_item_id=int(work_item.id),
route_metadata=route_metadata,
mode_override=(
"draft_ready"
if str(capture_result.get("reply_template_key") or "").strip().lower() == "draft_pending_confirm"
else None
),
)
await _safe_finalize_mounted_runtime(result=capture_result)
return capture_result
return await self._execute_auto_ingress_flow(
capture_result = await self._execute_auto_ingress_flow(
work_item=work_item,
intent=intent,
world_model=world_model,
@ -586,7 +719,19 @@ class KernelIngressFlowEntryMixin:
session=session,
log_perf_fn=_log_perf,
)
await _safe_record_mounted_decision(
workflow_route=workflow_route.key,
intent_name=intent.name,
intent_confidence=float(intent.confidence or 0.0),
trigger_reason="auto_execute",
auto_execute=True,
work_item_id=int(work_item.id),
route_metadata=route_metadata,
)
await _safe_finalize_mounted_runtime(result=capture_result)
return capture_result
except Exception as exc:
await _safe_finalize_mounted_runtime(error_text=str(exc))
await self._persist_kernel_failure_with_fresh_session(
work_item_id=_safe_int(getattr(work_item, "id", None)),
ingest_event_id=_safe_int(getattr(ingest_event, "id", None)),

View File

@ -1,7 +1,6 @@
from __future__ import annotations
from .shared import *
from ...ai_pmo.consultant_dispatch import build_channel_consultant_reply
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
class FeishuReplyLLMCoreMixin:
@ -80,19 +79,6 @@ class FeishuReplyLLMCoreMixin:
)
return
consultant_reply = self._build_consultant_dispatch_reply(capture_result)
if consultant_reply:
await self._safe_reply_text(
chat_id=chat_id,
mention_open_id=sender_open_id,
mention_name=sender_name,
text=consultant_reply,
trace_id=trace_id,
source_message_id=source_message_id,
perf_t0=perf_t0,
)
return
try:
status = str(capture_result.get("status") or "unknown")
classification = str(capture_result.get("classification") or "unknown")
@ -246,14 +232,8 @@ class FeishuReplyLLMCoreMixin:
@staticmethod
def _build_consultant_dispatch_reply(capture_result: Dict[str, Any]) -> Optional[str]:
if not isinstance(capture_result, dict):
return None
reply = build_channel_consultant_reply(
consultant_dispatch=capture_result.get("consultant_dispatch"),
direct_reply_text=str(capture_result.get("direct_reply_text") or "").strip(),
channel="feishu",
)
return str(reply or "").strip() or None
_ = capture_result
return None
@classmethod
def _build_action_request_ack_reply(

View File

@ -2,8 +2,6 @@
from __future__ import annotations
from .shared import *
from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
from app.utils.thread_context import extract_thread_anchor
class FeishuPipelineFollowupAMixin:
@ -18,229 +16,18 @@ class FeishuPipelineFollowupAMixin:
process_started_at: float,
thread_context: Optional[dict[str, Any]] = None,
) -> Optional[dict[str, Any]]:
if not source_chat_id:
return None
wants_continue = self._looks_like_continue_followup(message)
wants_status = WorkflowSemanticPlanner.wants_followup_status(message)
if not wants_continue and not wants_status:
return None
recent_items = await self._load_recent_chat_items(
source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id,
sender=sender,
_ = (
message,
sender,
source_chat_id,
source_sender_open_id,
feishu_message_id,
process_started_at,
thread_context,
)
thread_anchor = extract_thread_anchor(thread_context)
anchor_item = (
next(
(
item
for item in recent_items
if thread_anchor and str(getattr(item, "feishu_message_id", "") or "").strip() == thread_anchor
),
None,
)
if thread_anchor
else None
)
if thread_anchor and anchor_item is None:
return None
if not recent_items:
reply_text = "当前群里还没有可追踪的飞书处理项。"
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
reason = "群上下文中不存在可追踪的最近处理项。"
classification = "question"
status = "answered"
mutations: list[dict[str, Any]] = []
results: list[dict[str, Any]] = []
elif wants_continue:
if anchor_item is not None and self._normalize_route_status(anchor_item.route_status) == ROUTE_STATUS_QUEUED:
pending_items = [anchor_item]
else:
pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED]
if len(pending_items) != 1:
reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3])
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
reason = "群上下文中存在多个待处理项,无法自动确定继续处理目标。"
classification = "command"
status = "captured"
mutations = []
results = []
else:
operator_user = await self._resolve_command_operator_user(sender=sender)
if operator_user is None:
reply_text = "未执行:当前租户下没有可用操作者,无法继续处理该项。"
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
reason = "继续处理被阻断:当前租户下没有可用操作者。"
classification = "command"
status = "captured"
mutations = []
results = []
else:
target_item = pending_items[0]
if (target_item.level or "").lower() == "project":
suggestion = await self._get_project_suggestion(int(target_item.id))
if suggestion is None:
reply_text = "未执行:最近待处理项目候选缺少建议记录,无法继续处理。"
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
reason = "继续处理被阻断:最近待处理项目候选缺少建议记录。"
classification = "command"
status = "captured"
mutations = []
results = []
else:
candidate_fields = self._resolve_project_candidate_fields(item=target_item, suggestion=suggestion)
if not candidate_fields["ready"]:
reply_text = (
str(target_item.blocking_reason or "").strip()
or self._build_project_clarification_message(candidate_fields["missing_fields"])
)
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
reason = "继续处理被阻断:最近待处理建项目项仍缺少必填字段。"
classification = "command"
status = "captured"
mutations = []
results = []
else:
confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id))
reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result)
decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM
reason = "命中继续处理指令,已按最近唯一待处理项继续执行。"
classification = "command"
status = "captured"
mutations = self._summarize_db_mutations(
{
"results": [
{
"item_id": int(target_item.id),
"level": target_item.level,
"route_status": ROUTE_STATUS_AUTO_DONE,
"project_id": confirm_result.get("project_id"),
"signal_id": confirm_result.get("signal_id"),
}
]
}
)
results = [
{
"item_id": int(target_item.id),
"level": target_item.level,
"route_status": ROUTE_STATUS_AUTO_DONE,
"project_id": _safe_int(confirm_result.get("project_id")),
"signal_id": _safe_int(confirm_result.get("signal_id")),
}
]
else:
signal = await self._get_signal_by_item(int(target_item.id))
if (target_item.level or "").lower() == "signal" and not (signal and (signal.matched_project_id or target_item.matched_project_id)):
reply_text = "未执行:最近待处理信号仍缺少明确项目归属,请先补充项目名或项目 ID。"
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
reason = "继续处理被阻断:最近待处理信号缺少项目归属。"
classification = "command"
status = "captured"
mutations = []
results = []
else:
confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id))
reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result)
decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM
reason = "命中继续处理指令,已按最近唯一待处理项继续执行。"
classification = "command"
status = "captured"
mutations = self._summarize_db_mutations(
{
"results": [
{
"item_id": int(target_item.id),
"level": target_item.level,
"route_status": ROUTE_STATUS_AUTO_DONE,
"project_id": confirm_result.get("project_id"),
"signal_id": confirm_result.get("signal_id"),
}
]
}
)
results = [
{
"item_id": int(target_item.id),
"level": target_item.level,
"route_status": ROUTE_STATUS_AUTO_DONE,
"project_id": _safe_int(confirm_result.get("project_id")),
"signal_id": _safe_int(confirm_result.get("signal_id")),
}
]
else:
target_item = anchor_item or self._select_followup_status_target(
message=message,
recent_items=recent_items,
thread_context=thread_context,
)
if target_item is None:
reply_text = self._build_followup_pending_clarification(recent_items[:3])
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
reason = "状态追问缺少唯一锚点,系统拒绝猜测最近一条处理项。"
classification = "question"
status = "answered"
mutations = []
results = []
else:
reply_text = await self._build_followup_status_reply(target_item, message=message)
decision = ROUTING_QUEUE_FOLLOWUP_QUERY
reason = "命中进度/状态追问,已直接基于真实 DB 返回唯一处理项状态。"
classification = "question"
status = "answered"
mutations = []
results = []
await self._write_processing_log(
feishu_message_id=feishu_message_id,
feishu_item_id=None,
raw_message=message,
ai_raw_output=json.dumps(
{
"followup_reply": reply_text,
"recent_item_ids": [int(item.id) for item in recent_items[:3]],
"wants_continue": wants_continue,
"wants_status": wants_status,
},
ensure_ascii=False,
)[:4000],
project_score=None,
signal_confidence=None,
level="followup",
routing_decision=decision,
routing_reason=reason,
reasoning_bullets=[
"follow-up 查询/指令命中飞书线程锚点或唯一目标。",
"回复内容只基于真实 DB 状态,不使用自由生成。",
f"recent_items={len(recent_items)}",
],
processing_status="success",
error_message=None,
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
)
latest = recent_items[0] if recent_items else None
linked_project_ids: list[int] = []
if results and results[0].get("project_id") is not None:
linked_project_ids.append(int(results[0]["project_id"]))
if latest and latest.matched_project_id is not None:
linked_project_ids.append(int(latest.matched_project_id))
linked_project_ids = sorted(set(linked_project_ids))
return {
"status": status,
"classification": classification,
"project_scope": classification,
"linked_project_ids": linked_project_ids,
"signal_id": str(results[0].get("signal_id")) if results else None,
"created_project_id": str(results[0].get("project_id")) if results else None,
"auto_tasks": [],
"ai_trace_id": f"feishu-followup-{(feishu_message_id or 'noid')[:32]}",
"results": results,
"direct_reply_required": True,
"direct_reply_text": reply_text,
"db_mutations": mutations,
}
# Legacy feishu_items queue follow-up is retired. Runtime callers should route
# status/continue requests through mounted work-item/topic flows instead.
return None
async def _load_recent_chat_items(
self,

View File

@ -0,0 +1,528 @@
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],
}

View File

@ -0,0 +1,292 @@
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..models import MountedAgentTrace, MountedConversationRun, MountedTopicWorkspace
from ..utils.timezone import now_cn_naive
@dataclass
class MountedIngressTracking:
topic_workspace: MountedTopicWorkspace
conversation_run: MountedConversationRun
context_layers_hit: dict[str, bool]
class MountedAIRuntimeWriteService:
def __init__(self, db: AsyncSession):
self.db = db
@staticmethod
def _compact(value: Any, *, max_len: int = 128) -> Optional[str]:
text = str(value or "").strip()
if not text:
return None
return text[:max_len]
@classmethod
def build_topic_key(
cls,
*,
source_system: str,
channel_ref: Optional[str],
source_message_id: Optional[str],
sender: str,
message_text: str,
thread_context: Optional[dict[str, Any]],
) -> str:
normalized_source = cls._compact(source_system, max_len=32) or "unknown"
normalized_channel = cls._compact(channel_ref, max_len=128) or "direct"
root_message_id = cls._compact((thread_context or {}).get("root_message_id"), max_len=128)
if root_message_id:
return f"{normalized_source}:{normalized_channel}:root:{root_message_id}"
thread_id = cls._compact((thread_context or {}).get("thread_id"), max_len=128)
if thread_id:
return f"{normalized_source}:{normalized_channel}:thread:{thread_id}"
message_id = cls._compact(source_message_id, max_len=128)
if message_id:
return f"{normalized_source}:{normalized_channel}:message:{message_id}"
digest = hashlib.sha1(
f"{normalized_source}|{normalized_channel}|{sender}|{message_text}".encode("utf-8"),
usedforsecurity=False,
).hexdigest()
return f"{normalized_source}:{normalized_channel}:synthetic:{digest[:20]}"
@classmethod
def build_run_trace_id(
cls,
*,
source_system: str,
source_message_id: Optional[str],
topic_key: str,
) -> str:
normalized_source = cls._compact(source_system, max_len=32) or "unknown"
normalized_message_id = cls._compact(source_message_id, max_len=128)
if normalized_message_id:
return f"mounted:{normalized_source}:{normalized_message_id}"[:64]
digest = hashlib.sha1(topic_key.encode("utf-8"), usedforsecurity=False).hexdigest()
return f"mounted:{normalized_source}:{digest[:48]}"[:64]
@staticmethod
def _mode_for_route(
*,
workflow_route: str,
auto_execute: bool,
mode_override: Optional[str] = None,
) -> str:
if str(mode_override or "").strip():
return str(mode_override).strip()
normalized_route = str(workflow_route or "").strip().lower()
if auto_execute:
return "execute"
if normalized_route.startswith("query."):
return "chat"
if normalized_route.startswith("followup."):
return "plan"
return "plan"
async def begin_ingress_run(
self,
*,
tenant_id: int,
source_system: str,
source_message_id: Optional[str],
message_text: str,
sender: str,
channel_ref: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> MountedIngressTracking:
topic_key = self.build_topic_key(
source_system=source_system,
channel_ref=channel_ref,
source_message_id=source_message_id,
sender=sender,
message_text=message_text,
thread_context=thread_context,
)
topic_row = (
await self.db.execute(
select(MountedTopicWorkspace).where(
MountedTopicWorkspace.tenant_id == int(tenant_id),
MountedTopicWorkspace.source_system == (self._compact(source_system, max_len=32) or "unknown"),
MountedTopicWorkspace.topic_key == topic_key,
)
)
).scalar_one_or_none()
topic_exists = topic_row is not None
now_at = now_cn_naive()
if topic_row is None:
topic_row = MountedTopicWorkspace(
tenant_id=int(tenant_id),
topic_key=topic_key,
source_system=self._compact(source_system, max_len=32) or "unknown",
channel_ref=self._compact(channel_ref, max_len=128),
thread_key=self._compact((thread_context or {}).get("thread_id"), max_len=255),
root_message_id=self._compact((thread_context or {}).get("root_message_id"), max_len=128),
mode="chat",
status="active",
metadata_json={},
)
self.db.add(topic_row)
await self.db.flush()
topic_row.channel_ref = self._compact(channel_ref, max_len=128)
topic_row.thread_key = self._compact((thread_context or {}).get("thread_id"), max_len=255)
topic_row.root_message_id = self._compact((thread_context or {}).get("root_message_id"), max_len=128)
topic_row.last_message_id = self._compact(source_message_id, max_len=128)
topic_row.last_triggered_at = now_at
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
"last_sender": self._compact(sender, max_len=128),
"last_message_preview": self._compact(message_text, max_len=200),
"last_reply_to_message_id": self._compact((thread_context or {}).get("reply_to_message_id"), max_len=128),
}
context_layers_hit = {
"current_message": True,
"thread": bool((thread_context or {}).get("root_message_id") or (thread_context or {}).get("thread_id")),
"topic": bool(topic_exists),
}
trace_id = self.build_run_trace_id(
source_system=source_system,
source_message_id=source_message_id,
topic_key=topic_key,
)
run_row = (
await self.db.execute(
select(MountedConversationRun).where(
MountedConversationRun.tenant_id == int(tenant_id),
MountedConversationRun.trace_id == trace_id,
)
)
).scalar_one_or_none()
if run_row is None:
run_row = MountedConversationRun(
tenant_id=int(tenant_id),
trace_id=trace_id,
topic_workspace_id=int(topic_row.id),
source_system=self._compact(source_system, max_len=32) or "unknown",
source_message_id=self._compact(source_message_id, max_len=128),
mode_before=self._compact(topic_row.mode, max_len=24) if topic_exists else None,
active_agent="ConversationOrchestrator",
status="running",
trigger_reason=f"ingress:{self._compact(source_system, max_len=32) or 'unknown'}",
context_layers_hit=context_layers_hit,
started_at=now_at,
)
self.db.add(run_row)
await self.db.flush()
else:
run_row.topic_workspace_id = int(topic_row.id)
run_row.status = "running"
run_row.active_agent = "ConversationOrchestrator"
run_row.context_layers_hit = context_layers_hit
if run_row.started_at is None:
run_row.started_at = now_at
return MountedIngressTracking(
topic_workspace=topic_row,
conversation_run=run_row,
context_layers_hit=context_layers_hit,
)
async def record_orchestrator_decision(
self,
*,
tracking: MountedIngressTracking,
workflow_route: str,
intent_name: str,
intent_confidence: float,
trigger_reason: str,
auto_execute: bool,
work_item_id: Optional[int],
route_metadata: Optional[dict[str, Any]],
mode_override: Optional[str] = None,
) -> str:
mode_after = self._mode_for_route(
workflow_route=workflow_route,
auto_execute=auto_execute,
mode_override=mode_override,
)
topic_row = tracking.topic_workspace
run_row = tracking.conversation_run
topic_row.mode = mode_after
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
"last_workflow_route": str(workflow_route or "").strip() or None,
"last_intent_name": str(intent_name or "").strip() or None,
"last_intent_confidence": float(intent_confidence or 0.0),
"last_work_item_id": int(work_item_id) if work_item_id is not None else None,
}
run_row.mode_after = mode_after
run_row.active_agent = "ConversationOrchestrator"
run_row.trigger_reason = self._compact(trigger_reason, max_len=128)
self.db.add(
MountedAgentTrace(
tenant_id=int(topic_row.tenant_id),
conversation_run_id=int(run_row.id),
agent_name="ConversationOrchestrator",
status="completed",
input_summary={
"mode_before": run_row.mode_before,
"context_layers_hit": tracking.context_layers_hit,
},
output_summary={
"mode_after": mode_after,
"workflow_route": str(workflow_route or "").strip() or None,
"intent_name": str(intent_name or "").strip() or None,
"auto_execute": bool(auto_execute),
"route_metadata": dict(route_metadata or {}),
},
)
)
await self.db.flush()
return mode_after
async def finalize_run(
self,
*,
tracking: MountedIngressTracking,
result: Optional[dict[str, Any]] = None,
error_text: Optional[str] = None,
) -> None:
topic_row = tracking.topic_workspace
run_row = tracking.conversation_run
now_at = now_cn_naive()
normalized_error = str(error_text or "").strip()
final_reply = str((result or {}).get("direct_reply_text") or "").strip() if isinstance(result, dict) else ""
if normalized_error:
run_row.status = "failed"
run_row.active_agent = "ConversationOrchestrator"
run_row.error_text = normalized_error
else:
run_row.status = "completed"
run_row.active_agent = "ReplyComposer"
run_row.final_reply = final_reply or None
run_row.execution_outcome = {
"handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False,
"status": str((result or {}).get("status") or "").strip() if isinstance(result, dict) else "",
"classification": str((result or {}).get("classification") or "").strip() if isinstance(result, dict) else "",
"workflow_route": str((result or {}).get("workflow_route") or "").strip() if isinstance(result, dict) else "",
"work_item_id": (result or {}).get("work_item_id") if isinstance(result, dict) else None,
}
run_row.completed_at = now_at
self.db.add(
MountedAgentTrace(
tenant_id=int(topic_row.tenant_id),
conversation_run_id=int(run_row.id),
agent_name="ReplyComposer" if not normalized_error else "ConversationOrchestrator",
status="failed" if normalized_error else "completed",
output_summary=(
{"final_reply": final_reply or None, "status": str((result or {}).get("status") or "").strip() or None}
if not normalized_error
else None
),
failure_reason=normalized_error or None,
)
)
await self.db.flush()

View File

@ -2,7 +2,6 @@ from __future__ import annotations
from .mixin_analysis_normalize import SignalCaptureAnalysisNormalizeMixin
from .mixin_capture_entry import SignalCaptureEntryMixin
from .mixin_capture_legacy import SignalCaptureLegacyMixin
from .mixin_external_sources import SignalCaptureExternalSourcesMixin
from .mixin_new_project import SignalCaptureNewProjectMixin
from .mixin_pipeline_cache import SignalCapturePipelineCacheMixin
@ -14,7 +13,6 @@ from .mixin_task_orchestration_team import SignalCaptureTaskOrchestrationTeamMix
class ProactiveSignalCaptureService(
SignalCaptureEntryMixin,
SignalCaptureLegacyMixin,
SignalCapturePipelineCacheMixin,
SignalCaptureExternalSourcesMixin,
SignalCaptureTaskOrchestrationMainMixin,

View File

@ -22,6 +22,7 @@ from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer
from app.services.ai_pmo.ai_pmo_kernel import ai_pmo_typed_executor_mixins_parse
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry
from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress import ai_pmo_kernel_mixins_ingress_render
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual
from app.services.ai_pmo.ai_pmo_kernel_world_model_core import mixin_project
from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project
@ -218,6 +219,92 @@ async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) ->
assert "草稿" in result["direct_reply_text"]
@pytest.mark.asyncio
async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(monkeypatch) -> None:
captured: dict[str, object] = {"calls": []}
class FakeMountedAIRuntimeWriteService:
def __init__(self, db) -> None:
captured["db"] = db
async def begin_ingress_run(self, **kwargs):
captured["calls"].append(("begin", kwargs))
return SimpleNamespace(
topic_workspace=SimpleNamespace(id=91, tenant_id=1),
conversation_run=SimpleNamespace(id=92),
context_layers_hit={"current_message": True, "thread": True, "topic": False},
)
async def record_orchestrator_decision(self, **kwargs):
captured["calls"].append(("record", kwargs))
return "plan"
async def finalize_run(self, **kwargs):
captured["calls"].append(("finalize", kwargs))
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
async def _prepare_ingress_envelope(self, **kwargs):
_ = kwargs
return {
"early_result": None,
"clean_text": "确认",
"synthetic_message_id": "s-msg-mounted-1",
"closure_payload": {},
"ingest_event": SimpleNamespace(id=100),
"operator": SimpleNamespace(id=8),
"work_item": SimpleNamespace(id=55),
"session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM),
}
async def _maybe_handle_draft_confirmation_ingress(self, **kwargs):
_ = kwargs
return {
"handled": True,
"status": "answered",
"classification": "command",
"workflow_route": "followup.confirm",
"reply_template_key": "action_succeeded",
"work_item_id": 55,
"direct_reply_required": True,
"direct_reply_text": "已确认草稿并继续执行。",
}
def _is_short_confirmation_message(self, **kwargs):
_ = kwargs
return False
async def _prepare_mixed_sidecar_context(self, **kwargs):
_ = kwargs
raise AssertionError("draft confirmation flow should bypass mixed sidecar precheck")
monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService)
kernel = FakeKernel()
result = await kernel.process_ingress(
source_system="feishu",
source_message_id="feishu-mounted-1",
message_text="确认",
sender="tester",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}},
)
assert result["status"] == "answered"
assert [name for name, _ in captured["calls"]] == ["begin", "record", "finalize"]
begin_kwargs = captured["calls"][0][1]
record_kwargs = captured["calls"][1][1]
finalize_kwargs = captured["calls"][2][1]
assert begin_kwargs["source_system"] == "feishu"
assert begin_kwargs["source_message_id"] == "s-msg-mounted-1"
assert record_kwargs["workflow_route"] == "followup.confirm"
assert finalize_kwargs["result"]["direct_reply_text"] == "已确认草稿并继续执行。"
def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None:
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
@ -1113,6 +1200,51 @@ def test_consultant_dispatch_uses_short_next_action_for_query_answers() -> None:
assert "/rag/ask" not in dispatch["next_action"]
def test_build_capture_result_no_longer_exposes_consultant_dispatch() -> None:
class _FakeKernel(ai_pmo_kernel_mixins_ingress_render.KernelIngressRenderMixin):
tenant_id = 1
work_item = SimpleNamespace(
id=18,
project_id=7,
signal_id=None,
state="succeeded",
intent="query.answer",
reply_template_key="action_succeeded",
decision_level="team",
review_summary={"verified": True},
raw_message="现在进展怎么样?",
source_system="pmflow_manual",
draft_version=0,
)
intent = CanonicalIntent(
name="query.answer",
confidence=0.93,
project_id=7,
signal_title="",
signal_summary="当前主链正常。",
metadata={},
)
result = _FakeKernel()._build_capture_result(
work_item=work_item,
intent=intent,
execution_result={
"workflow_route": "query.answer",
"rag_result": {"answer": "当前主链正常。"},
},
render={
"template_key": "action_succeeded",
"text": "当前主链正常。",
},
direct_reply_required=True,
status="answered",
classification="question",
)
assert "consultant_dispatch" not in result
assert "consultant_dispatch" not in result["rag_result"]
@pytest.mark.asyncio
async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None:
captured: dict[str, object] = {"logs": []}

View File

@ -203,7 +203,7 @@ def test_consultant_dispatch_contract_marks_scope_expansion_as_challenge() -> No
assert contract["next_action"]
def test_feishu_reply_llm_core_builds_contract_reply_for_clarify_and_challenge() -> None:
def test_feishu_reply_llm_core_no_longer_uses_consultant_dispatch_reply() -> None:
clarify = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply(
{
"direct_reply_text": "",
@ -217,22 +217,8 @@ def test_feishu_reply_llm_core_builds_contract_reply_for_clarify_and_challenge()
},
}
)
challenge = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply(
{
"direct_reply_text": "",
"consultant_dispatch": {
"domain": "project",
"mode": "challenge",
"risk_level": "high",
"missing_fields": [],
"allow_execute": False,
"next_action": "建议先拆分范围,再确认是否继续推进。",
},
}
)
assert "补充" in str(clarify or "")
assert "建议" in str(challenge or "")
assert clarify is None
@pytest.mark.asyncio
@ -986,9 +972,28 @@ def test_adaptive_rag_process_entry_and_mixed_format() -> None:
assert mixed.endswith(",已入库处理")
def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
class _FakePromptResult:
@staticmethod
def scalar_one_or_none():
return None
class _FakePromptDB:
async def execute(self, query): # noqa: ARG002
return _FakePromptResult()
def _make_adaptive_rag_mixin() -> AdaptiveRAGDeepGuardAnswerMixin:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
scene_key, prompt = mixin._select_scene_prompt(
mixin.db = _FakePromptDB()
mixin.tenant_id = 1
return mixin
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await mixin._select_scene_prompt(
question="现在进展怎么样?",
project_id=7,
asked_via="pmflow_manual",
@ -1007,9 +1012,10 @@ def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
assert "新增任务确认流" in prompt
def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
scene_key, prompt = mixin._select_scene_prompt(
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await mixin._select_scene_prompt(
question="帮我总结这个项目",
project_id=9,
asked_via="pmflow_manual",
@ -1027,9 +1033,10 @@ def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
assert "帮我总结这个项目" in prompt
def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
scene_key, prompt = mixin._select_scene_prompt(
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await mixin._select_scene_prompt(
question="如果 AI 助手第一次上线只允许一种提醒,你会把它做成桌面通知还是飞书消息?为什么?",
project_id=None,
asked_via="feishu",
@ -1048,9 +1055,10 @@ def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None:
assert "飞书消息" in prompt
def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
scene_key, prompt = mixin._select_scene_prompt(
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await mixin._select_scene_prompt(
question="帮我看看宠物行业最新动态",
project_id=None,
asked_via="feishu",
@ -1069,9 +1077,10 @@ def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project
assert "project_context" not in prompt
def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
scene_key, prompt = mixin._select_scene_prompt(
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await mixin._select_scene_prompt(
question="杨宇现在负载情况如何?",
project_id=None,
asked_via="feishu",
@ -1094,9 +1103,10 @@ def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None
assert '"projects"' not in prompt
def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_trend() -> None:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
scene_key, prompt = mixin._select_scene_prompt(
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_trend() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await mixin._select_scene_prompt(
question="帮我看看宠物行业最新动态,重点只说消费侧和产品侧的变化,不要提项目。",
project_id=7,
asked_via="feishu",
@ -1115,9 +1125,10 @@ def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_tr
assert "project_context" not in prompt
def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_context_explicit() -> None:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
scene_key, prompt = mixin._select_scene_prompt(
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_context_explicit() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await mixin._select_scene_prompt(
question="这个项目所在行业最新动态怎么样?",
project_id=7,
asked_via="feishu",
@ -1136,9 +1147,10 @@ def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_contex
assert "外部搜索结果" in prompt
def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
scene_key, prompt = mixin._select_scene_prompt(
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await mixin._select_scene_prompt(
question="这个项目最新进展怎么样?",
project_id=7,
asked_via="feishu",

View File

@ -214,6 +214,12 @@ def test_feishu_signal_pipeline_service_runtime_excludes_legacy_followup_mixins(
assert "FeishuPipelineFollowupBMixin" not in source
def test_proactive_signal_capture_service_runtime_excludes_legacy_capture_mixin() -> None:
source = inspect.getsource(ProactiveSignalCaptureService)
assert "SignalCaptureLegacyMixin" not in source
def test_decorate_capture_result_adds_queue_action_card_metadata() -> None:
result = FeishuPipelineProcessMessageHelpersBMixin._decorate_capture_result(
{

View File

@ -0,0 +1,253 @@
from __future__ import annotations
import os
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from sqlalchemy import JSON
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
from app.api.ai_steward_api import routes_mounted_ai
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.dependencies.tenant import get_tenant_id
from app.models.base import Base
from app.models.mounted_ai_runtime import (
MountedAgentTrace,
MountedConversationRun,
MountedDraftWorkspace,
MountedToolCapabilityPolicy,
MountedTopicWorkspace,
)
from app.models.training_pipeline import TrainingDataset, TrainingEvaluation, TrainingJob
def _use_sqlite_json_types() -> None:
for table in (
MountedTopicWorkspace.__table__,
MountedDraftWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__table__,
MountedToolCapabilityPolicy.__table__,
TrainingDataset.__table__,
TrainingJob.__table__,
TrainingEvaluation.__table__,
):
for column in table.columns:
if column.name in {
"facts_json",
"goals_json",
"constraints_json",
"options_json",
"open_questions",
"pending_actions",
"bound_entities",
"metadata_json",
"requirement_card",
"draft_payload",
"required_confirmations",
"context_layers_hit",
"tool_calls",
"execution_outcome",
"input_summary",
"output_summary",
"tool_trace",
"source_scope",
"tenant_scope",
"policy_metadata",
"selection_rules",
"channel_scope",
"metrics_json",
"evaluation_summary",
}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
MountedTopicWorkspace.__table__,
MountedDraftWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__table__,
MountedToolCapabilityPolicy.__table__,
TrainingDataset.__table__,
TrainingJob.__table__,
TrainingEvaluation.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
def _build_app(db_session):
app = FastAPI()
app.include_router(routes_mounted_ai.router)
async def _fake_get_db():
yield db_session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_tenant_id] = lambda: 1
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=9001,
tenant_id=1,
role="ADMIN",
name="管理员",
username="admin",
)
return app
@pytest.mark.asyncio
async def test_mounted_ai_admin_routes_return_runtime_views(db_session) -> None:
topic = MountedTopicWorkspace(
tenant_id=1,
topic_key="feishu:chat_1:topic_1",
source_system="feishu",
channel_ref="chat_1",
thread_key="thread_1",
mode="plan",
status="active",
open_questions=["负责人是谁"],
readiness_score=0.4,
)
db_session.add(topic)
await db_session.flush()
draft = MountedDraftWorkspace(
tenant_id=1,
topic_workspace_id=topic.id,
state="collecting",
draft_version=2,
requirement_card={"goals": ["做挂载 AI"]},
)
run = MountedConversationRun(
tenant_id=1,
trace_id="mounted-run-1",
topic_workspace_id=topic.id,
source_system="feishu",
source_message_id="om_123",
mode_before="plan",
mode_after="clarify",
active_agent="Clarifier",
status="completed",
trigger_reason="missing_owner",
context_layers_hit={"thread": True, "topic": True},
final_reply="还差负责人信息。",
)
db_session.add_all([draft, run])
await db_session.flush()
trace = MountedAgentTrace(
tenant_id=1,
conversation_run_id=run.id,
agent_name="Clarifier",
status="completed",
latency_ms=128,
input_summary={"mode": "plan"},
output_summary={"question": "这件事打算谁来负责?"},
)
policy = MountedToolCapabilityPolicy(
tenant_id=1,
tool_name="project.create",
risk_level="high",
confirm_required=True,
auto_execute_allowed=False,
)
eval_dataset = TrainingDataset(
tenant_id=1,
dataset_type="eval",
version="teacher-frozen-v1",
status="ready",
sample_count=18,
selection_rules={"suite_kind": "teacher_eval_frozen_set"},
)
db_session.add_all([trace, policy, eval_dataset])
await db_session.flush()
eval_job = TrainingJob(
tenant_id=1,
dataset_id=eval_dataset.id,
job_type="sft",
base_model_family="gpt-5.4",
target_slot="high",
status="queued",
provider_backend="openai",
)
db_session.add(eval_job)
await db_session.flush()
db_session.add(
TrainingEvaluation(
tenant_id=1,
training_job_id=eval_job.id,
evaluation_type="offline_judge",
status="completed",
score=91.5,
evaluation_summary={"pass": True},
)
)
await db_session.commit()
app = _build_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
sources_resp = await client.get("/ai-steward/mounted-ai/sources")
assert sources_resp.status_code == 200
assert sources_resp.json()["data"]["items"][0]["source_system"] == "feishu"
assert sources_resp.json()["data"]["items"][0]["topic_count"] == 1
topics_resp = await client.get("/ai-steward/mounted-ai/topics")
assert topics_resp.status_code == 200
assert topics_resp.json()["data"]["items"][0]["topic_key"] == "feishu:chat_1:topic_1"
assert topics_resp.json()["data"]["items"][0]["draft_state"] == "collecting"
runs_resp = await client.get("/ai-steward/mounted-ai/runs")
assert runs_resp.status_code == 200
assert runs_resp.json()["data"]["items"][0]["active_agent"] == "Clarifier"
detail_resp = await client.get(f"/ai-steward/mounted-ai/runs/{run.id}")
assert detail_resp.status_code == 200
assert detail_resp.json()["data"]["agent_traces"][0]["agent_name"] == "Clarifier"
cutover_resp = await client.get("/ai-steward/mounted-ai/cutover-status")
assert cutover_resp.status_code == 200
assert cutover_resp.json()["data"]["mounted_runtime_ready"] is True
assert cutover_resp.json()["data"]["tool_policy_count"] == 1
assert cutover_resp.json()["data"]["legacy_cutover_state"] == "retired"
assert cutover_resp.json()["data"]["retired_item_count"] >= 4
assert cutover_resp.json()["data"]["active_item_count"] == 0
assert {
item["key"]: item["status"]
for item in cutover_resp.json()["data"]["legacy_cutover_items"]
} == {
"ai_pmo_ingress_consultant_capture": "retired",
"feishu_mention_consultant_reply": "retired",
"signal_capture_legacy_entry": "retired",
"legacy_feishu_queue_followup": "retired",
}
eval_suites_resp = await client.get("/ai-steward/mounted-ai/eval-suites")
assert eval_suites_resp.status_code == 200
assert eval_suites_resp.json()["data"]["items"][0]["suite_kind"] == "teacher_eval_frozen_set"
assert eval_suites_resp.json()["data"]["items"][0]["latest_score"] == 91.5
eval_suite_detail_resp = await client.get(f"/ai-steward/mounted-ai/eval-suites/{eval_dataset.id}")
assert eval_suite_detail_resp.status_code == 200
assert eval_suite_detail_resp.json()["data"]["item"]["sample_count"] == 18
assert eval_suite_detail_resp.json()["data"]["evaluations"][0]["evaluation_type"] == "offline_judge"

View File

@ -0,0 +1,54 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
def _load_migration_module():
path = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "20260405_add_mounted_ai_runtime_tables.py"
)
spec = importlib.util.spec_from_file_location("mounted_ai_runtime_tables_migration", path)
module = importlib.util.module_from_spec(spec)
assert spec is not None
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_mounted_ai_runtime_migration_adds_tables() -> None:
migration = _load_migration_module()
engine = sa.create_engine("sqlite:///:memory:")
with engine.begin() as conn:
context = MigrationContext.configure(conn)
migration.op = Operations(context)
migration.upgrade()
inspector = sa.inspect(conn)
tables = set(inspector.get_table_names())
assert "mounted_topic_workspaces" in tables
assert "mounted_draft_workspaces" in tables
assert "mounted_conversation_runs" in tables
assert "mounted_agent_traces" in tables
assert "mounted_tool_capability_policies" in tables
migration.downgrade()
inspector = sa.inspect(conn)
tables = set(inspector.get_table_names())
assert "mounted_topic_workspaces" not in tables
assert "mounted_draft_workspaces" not in tables
assert "mounted_conversation_runs" not in tables
assert "mounted_agent_traces" not in tables
assert "mounted_tool_capability_policies" not in tables

View File

@ -0,0 +1,185 @@
from __future__ import annotations
import os
import pytest
from sqlalchemy import JSON, create_engine
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
from app.models.base import Base
from app.models.mounted_ai_runtime import (
MountedAgentTrace,
MountedConversationRun,
MountedDraftWorkspace,
MountedToolCapabilityPolicy,
MountedTopicWorkspace,
)
def _use_sqlite_json_types() -> None:
for table in (
MountedTopicWorkspace.__table__,
MountedDraftWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__table__,
MountedToolCapabilityPolicy.__table__,
):
for column in table.columns:
if column.name in {
"facts_json",
"goals_json",
"constraints_json",
"options_json",
"open_questions",
"pending_actions",
"bound_entities",
"metadata_json",
"requirement_card",
"draft_payload",
"required_confirmations",
"context_layers_hit",
"tool_calls",
"execution_outcome",
"input_summary",
"output_summary",
"tool_trace",
"source_scope",
"tenant_scope",
"policy_metadata",
}:
column.type = JSON()
def _create_session() -> Session:
engine = create_engine("sqlite:///:memory:")
_use_sqlite_json_types()
Base.metadata.create_all(
engine,
tables=[
MountedTopicWorkspace.__table__,
MountedDraftWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__table__,
MountedToolCapabilityPolicy.__table__,
],
)
return Session(engine)
def test_mounted_ai_runtime_models_roundtrip_and_defaults() -> None:
session = _create_session()
topic = MountedTopicWorkspace(
tenant_id=1,
topic_key="feishu:chat_1:topic_1",
source_system="feishu",
channel_ref="chat_1",
thread_key="thread_1",
root_message_id="om_root_1",
mode="plan",
facts_json={"problem": "直播复盘"},
open_questions=["负责人是谁"],
readiness_score=0.35,
)
session.add(topic)
session.flush()
draft = MountedDraftWorkspace(
tenant_id=1,
topic_workspace_id=topic.id,
draft_version=1,
requirement_card={"goals": ["整理复盘需求"]},
required_confirmations=[{"type": "user_confirm"}],
)
run = MountedConversationRun(
tenant_id=1,
trace_id="trace-mounted-1",
topic_workspace_id=topic.id,
source_system="feishu",
source_message_id="om_123",
mode_before="plan",
mode_after="clarify",
active_agent="Clarifier",
trigger_reason="missing_owner",
context_layers_hit={"thread": True, "topic": True},
final_reply="还差负责人信息。",
)
session.add_all([draft, run])
session.flush()
trace = MountedAgentTrace(
tenant_id=1,
conversation_run_id=run.id,
agent_name="Clarifier",
status="completed",
latency_ms=128,
input_summary={"mode": "plan"},
output_summary={"question": "这件事打算谁来负责?"},
)
policy = MountedToolCapabilityPolicy(
tenant_id=1,
tool_name="project.create",
risk_level="high",
source_scope={"systems": ["feishu"]},
)
session.add_all([trace, policy])
session.commit()
saved_topic = session.query(MountedTopicWorkspace).one()
saved_draft = session.query(MountedDraftWorkspace).one()
saved_run = session.query(MountedConversationRun).one()
saved_trace = session.query(MountedAgentTrace).one()
saved_policy = session.query(MountedToolCapabilityPolicy).one()
assert saved_topic.status == "active"
assert saved_draft.state == "collecting"
assert saved_run.status == "queued"
assert saved_trace.tool_trace is None
assert saved_policy.confirm_required is True
assert saved_policy.auto_execute_allowed is False
assert saved_policy.is_active is True
def test_mounted_ai_runtime_models_enforce_unique_scopes() -> None:
session = _create_session()
session.add(
MountedTopicWorkspace(
tenant_id=9,
topic_key="feishu:chat_9:topic_9",
source_system="feishu",
)
)
session.add(
MountedToolCapabilityPolicy(
tenant_id=9,
tool_name="project.create",
risk_level="high",
)
)
session.commit()
session.add(
MountedTopicWorkspace(
tenant_id=9,
topic_key="feishu:chat_9:topic_9",
source_system="feishu",
)
)
with pytest.raises(IntegrityError):
session.commit()
session.rollback()
session.add(
MountedToolCapabilityPolicy(
tenant_id=9,
tool_name="project.create",
risk_level="high",
)
)
with pytest.raises(IntegrityError):
session.commit()

View File

@ -0,0 +1,163 @@
from __future__ import annotations
import os
import pytest
import pytest_asyncio
from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
from app.models.base import Base
from app.models.mounted_ai_runtime import (
MountedAgentTrace,
MountedConversationRun,
MountedTopicWorkspace,
)
from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
def _use_sqlite_json_types() -> None:
for table in (
MountedTopicWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__table__,
):
for column in table.columns:
if column.name in {
"facts_json",
"goals_json",
"constraints_json",
"options_json",
"open_questions",
"pending_actions",
"bound_entities",
"metadata_json",
"context_layers_hit",
"tool_calls",
"execution_outcome",
"input_summary",
"output_summary",
"tool_trace",
}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
MountedTopicWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_begin_ingress_run_uses_thread_anchor_and_sets_runtime_context(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_1",
message_text="帮我复盘一下这个直播需求。",
sender="林宇航",
channel_ref="chat_1",
thread_context={
"thread_id": "thread_1",
"root_message_id": "om_root_1",
"reply_to_message_id": "om_reply_1",
},
)
await db_session.commit()
saved_topic = (
await db_session.execute(select(MountedTopicWorkspace))
).scalar_one()
saved_run = (
await db_session.execute(select(MountedConversationRun))
).scalar_one()
assert tracking.topic_workspace.id == saved_topic.id
assert saved_topic.topic_key == "feishu:chat_1:root:om_root_1"
assert saved_topic.thread_key == "thread_1"
assert saved_topic.last_message_id == "om_message_1"
assert saved_run.status == "running"
assert saved_run.mode_before is None
assert saved_run.context_layers_hit == {
"current_message": True,
"thread": True,
"topic": False,
}
@pytest.mark.asyncio
async def test_record_decision_and_finalize_run_persist_topic_mode_and_traces(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_2",
message_text="分配给谁做比较合适?",
sender="林宇航",
channel_ref="chat_2",
thread_context={"root_message_id": "om_root_2"},
)
await service.record_orchestrator_decision(
tracking=tracking,
workflow_route="project_signal.create",
intent_name="signal_create",
intent_confidence=0.82,
trigger_reason="manual_review",
auto_execute=False,
work_item_id=321,
route_metadata={"workflow_family": "project_signal", "workflow_action": "create"},
)
await service.finalize_run(
tracking=tracking,
result={
"handled": True,
"status": "captured",
"classification": "command",
"work_item_id": 321,
"workflow_route": "project_signal.create",
"direct_reply_text": "我先帮你收敛需求,再给负责人建议。",
},
)
await db_session.commit()
saved_topic = (
await db_session.execute(select(MountedTopicWorkspace))
).scalar_one()
saved_run = (
await db_session.execute(select(MountedConversationRun))
).scalar_one()
traces = (
await db_session.execute(
select(MountedAgentTrace).order_by(MountedAgentTrace.id.asc())
)
).scalars().all()
assert saved_topic.mode == "plan"
assert saved_topic.metadata_json["last_workflow_route"] == "project_signal.create"
assert saved_run.mode_after == "plan"
assert saved_run.status == "completed"
assert saved_run.final_reply == "我先帮你收敛需求,再给负责人建议。"
assert len(traces) == 2
assert traces[0].agent_name == "ConversationOrchestrator"
assert traces[1].agent_name == "ReplyComposer"

View File

@ -0,0 +1,158 @@
import { api } from "@/lib/api";
type ApiEnvelope<T> = { code: number; message: string; data: T };
function unwrap<T>(raw: ApiEnvelope<T> | T): T {
if (raw && typeof raw === "object" && "data" in (raw as Record<string, unknown>)) {
return (raw as ApiEnvelope<T>).data;
}
return raw as T;
}
export interface MountedAISourceItem {
source_system: string;
run_count: number;
topic_count: number;
active_topic_count: number;
last_seen_at?: string | null;
}
export interface MountedAISourceListPayload {
items: MountedAISourceItem[];
}
export interface MountedAITopicItem {
id: number;
topic_key: string;
source_system: string;
channel_ref?: string | null;
thread_key?: string | null;
mode: string;
status: string;
readiness_score?: number | null;
draft_state?: string | null;
draft_version?: number | null;
updated_at?: string | null;
}
export interface MountedAITopicListPayload {
items: MountedAITopicItem[];
total: number;
}
export interface MountedAIRunItem {
id: number;
trace_id: string;
source_system: string;
source_message_id?: string | null;
mode_before?: string | null;
mode_after?: string | null;
active_agent?: string | null;
status: string;
trigger_reason?: string | null;
final_reply?: string | null;
trace_count: number;
updated_at?: string | null;
}
export interface MountedAIRunListPayload {
items: MountedAIRunItem[];
total: number;
}
export interface MountedAIAgentTraceItem {
id: number;
agent_name: string;
status: string;
latency_ms?: number | null;
failure_reason?: string | null;
created_at?: string | null;
}
export interface MountedAIRunDetailPayload {
item: MountedAIRunItem;
agent_traces: MountedAIAgentTraceItem[];
context_layers_hit?: Record<string, unknown> | null;
tool_calls?: unknown;
execution_outcome?: unknown;
}
export interface MountedAICutoverStatusPayload {
mounted_runtime_ready: boolean;
topic_workspace_count: number;
conversation_run_count: number;
tool_policy_count: number;
observed_source_systems: string[];
legacy_cutover_state: string;
retired_item_count: number;
active_item_count: number;
legacy_cutover_items: MountedAILegacyCutoverItem[];
}
export interface MountedAILegacyCutoverItem {
key: string;
title: string;
status: string;
summary: string;
evidence: string;
}
export interface MountedAIEvalSuiteItem {
id: number;
dataset_type: string;
version: string;
status: string;
suite_kind: string;
sample_count: number;
latest_score?: number | null;
last_evaluated_at?: string | null;
}
export interface MountedAIEvalSuiteListPayload {
items: MountedAIEvalSuiteItem[];
total: number;
}
export const mountedAiApi = {
async getSources(): Promise<MountedAISourceListPayload> {
const { data } = await api.get<ApiEnvelope<MountedAISourceListPayload>>("/ai-steward/mounted-ai/sources");
return unwrap(data);
},
async getTopics(params?: { limit?: number; offset?: number; mode?: string }): Promise<MountedAITopicListPayload> {
const { data } = await api.get<ApiEnvelope<MountedAITopicListPayload>>("/ai-steward/mounted-ai/topics", {
params: {
limit: params?.limit ?? 20,
offset: params?.offset ?? 0,
mode: params?.mode,
},
});
return unwrap(data);
},
async getRuns(params?: { limit?: number; offset?: number; status?: string }): Promise<MountedAIRunListPayload> {
const { data } = await api.get<ApiEnvelope<MountedAIRunListPayload>>("/ai-steward/mounted-ai/runs", {
params: {
limit: params?.limit ?? 20,
offset: params?.offset ?? 0,
status: params?.status,
},
});
return unwrap(data);
},
async getRunDetail(runId: number): Promise<MountedAIRunDetailPayload> {
const { data } = await api.get<ApiEnvelope<MountedAIRunDetailPayload>>(`/ai-steward/mounted-ai/runs/${runId}`);
return unwrap(data);
},
async getCutoverStatus(): Promise<MountedAICutoverStatusPayload> {
const { data } = await api.get<ApiEnvelope<MountedAICutoverStatusPayload>>("/ai-steward/mounted-ai/cutover-status");
return unwrap(data);
},
async getEvalSuites(): Promise<MountedAIEvalSuiteListPayload> {
const { data } = await api.get<ApiEnvelope<MountedAIEvalSuiteListPayload>>("/ai-steward/mounted-ai/eval-suites");
return unwrap(data);
},
};

View File

@ -1,7 +1,168 @@
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Bot, RefreshCw, ShieldCheck, Waypoints } from "lucide-react";
import { mountedAiApi } from "@/api/mountedAi";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { MountedAIAcceptancePanel } from "./components/MountedAIAcceptancePanel";
import { MountedAIRunReplayPanel } from "./components/MountedAIRunReplayPanel";
import { MountedAIRunsPanel } from "./components/MountedAIRunsPanel";
import { MountedAISourceStatusPanel } from "./components/MountedAISourceStatusPanel";
import { MountedAITopicsPanel } from "./components/MountedAITopicsPanel";
export default function SchedulerCenterPage() {
const [selectedRunId, setSelectedRunId] = useState<number | null>(null);
const sourcesQuery = useQuery({
queryKey: ["mounted-ai", "sources"],
queryFn: () => mountedAiApi.getSources(),
});
const topicsQuery = useQuery({
queryKey: ["mounted-ai", "topics"],
queryFn: () => mountedAiApi.getTopics({ limit: 10 }),
});
const runsQuery = useQuery({
queryKey: ["mounted-ai", "runs"],
queryFn: () => mountedAiApi.getRuns({ limit: 10 }),
});
const cutoverQuery = useQuery({
queryKey: ["mounted-ai", "cutover-status"],
queryFn: () => mountedAiApi.getCutoverStatus(),
});
const evalSuitesQuery = useQuery({
queryKey: ["mounted-ai", "eval-suites"],
queryFn: () => mountedAiApi.getEvalSuites(),
});
const selectedRunDetailQuery = useQuery({
queryKey: ["mounted-ai", "runs", "detail", selectedRunId],
queryFn: () => mountedAiApi.getRunDetail(selectedRunId as number),
enabled: selectedRunId != null,
});
useEffect(() => {
if (selectedRunId == null && (runsQuery.data?.items.length || 0) > 0) {
setSelectedRunId(runsQuery.data?.items[0]?.id ?? null);
}
}, [runsQuery.data?.items, selectedRunId]);
const loading =
sourcesQuery.isLoading || topicsQuery.isLoading || runsQuery.isLoading || cutoverQuery.isLoading || evalSuitesQuery.isLoading;
const runtimeReady = !!cutoverQuery.data?.mounted_runtime_ready;
const frozenEvalSuite =
evalSuitesQuery.data?.items.find((item) => item.suite_kind === "teacher_eval_frozen_set") ||
evalSuitesQuery.data?.items[0] ||
null;
const observedSourceText = useMemo(() => {
const items = cutoverQuery.data?.observed_source_systems || [];
return items.length > 0 ? items.join(" / ") : "暂无";
}, [cutoverQuery.data?.observed_source_systems]);
const refreshAll = async () => {
await Promise.all([
sourcesQuery.refetch(),
topicsQuery.refetch(),
runsQuery.refetch(),
cutoverQuery.refetch(),
evalSuitesQuery.refetch(),
]);
};
return (
<div className="rounded-2xl border border-dashed p-6 text-center text-sm text-muted-foreground">
调度中心功能建设中
<div className="space-y-6">
<div className="rounded-3xl border border-slate-200/80 bg-gradient-to-br from-slate-950 via-slate-900 to-cyan-950 px-6 py-6 text-white shadow-xl">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-medium text-white/80">
<Bot className="h-3.5 w-3.5" />
Mounted AI Console
</div>
<div>
<h1 className="text-2xl font-semibold tracking-tight">挂载 Runtime 调度中心</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-200/80">
这里展示当前 Mounted Runtime 的来源观测、topic workspace、conversation runs 与 cutover 状态。
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-white/20 bg-white/10 text-white hover:bg-white/15"
onClick={() => void refreshAll()}
disabled={loading}
>
<RefreshCw className={`mr-1 h-4 w-4 ${loading ? "animate-spin" : ""}`} />
{loading ? "刷新中..." : "刷新控制台"}
</Button>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader className="pb-2">
<CardDescription>Runtime 就绪度</CardDescription>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheck className="h-4 w-4" />
{runtimeReady ? "已就绪" : "待接管"}
</CardTitle>
</CardHeader>
<CardContent className="text-xs text-slate-500 dark:text-slate-400">
legacy 状态:{cutoverQuery.data?.legacy_cutover_state || "-"}
</CardContent>
</Card>
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader className="pb-2">
<CardDescription>已观测来源</CardDescription>
<CardTitle className="text-base">{observedSourceText}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-slate-500 dark:text-slate-400">
来源数量:{cutoverQuery.data?.observed_source_systems.length || 0}
</CardContent>
</Card>
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader className="pb-2">
<CardDescription>Topic Workspace</CardDescription>
<CardTitle className="text-base">{cutoverQuery.data?.topic_workspace_count ?? "-"}</CardTitle>
</CardHeader>
<CardContent className="text-xs text-slate-500 dark:text-slate-400">
当前活跃主题总数
</CardContent>
</Card>
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader className="pb-2">
<CardDescription>Conversation Runs</CardDescription>
<CardTitle className="flex items-center gap-2 text-base">
<Waypoints className="h-4 w-4" />
{cutoverQuery.data?.conversation_run_count ?? "-"}
</CardTitle>
</CardHeader>
<CardContent className="text-xs text-slate-500 dark:text-slate-400">
Tool Policy:{cutoverQuery.data?.tool_policy_count ?? "-"}
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<MountedAISourceStatusPanel items={sourcesQuery.data?.items || []} loading={sourcesQuery.isLoading} />
<MountedAITopicsPanel items={topicsQuery.data?.items || []} loading={topicsQuery.isLoading} />
</div>
<div className="grid gap-4 xl:grid-cols-[1.2fr_0.8fr]">
<MountedAIRunsPanel
items={runsQuery.data?.items || []}
loading={runsQuery.isLoading}
selectedRunId={selectedRunId}
onSelectRun={setSelectedRunId}
/>
<MountedAIRunReplayPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
</div>
<MountedAIAcceptancePanel frozenSuite={frozenEvalSuite} cutoverStatus={cutoverQuery.data} />
</div>
);
}

View File

@ -0,0 +1,121 @@
import { AlertTriangle, ShieldCheck, Target } from "lucide-react";
import type { MountedAICutoverStatusPayload, MountedAIEvalSuiteItem } from "@/api/mountedAi";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { formatBeijingDateTime } from "@/lib/datetime";
interface MountedAIAcceptancePanelProps {
frozenSuite?: MountedAIEvalSuiteItem | null;
cutoverStatus?: MountedAICutoverStatusPayload | null;
}
export function MountedAIAcceptancePanel({ frozenSuite, cutoverStatus }: MountedAIAcceptancePanelProps) {
const latestScore = frozenSuite?.latest_score ?? null;
const passed = latestScore != null && latestScore >= 90;
const legacyItems = cutoverStatus?.legacy_cutover_items || [];
return (
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">Acceptance Gates</CardTitle>
<CardDescription>按 PLAN3 固定展示当前终局验收门槛,避免实现阶段和验收标准脱节。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="flex items-center gap-2 text-sm font-medium">
<Target className="h-4 w-4 text-cyan-600" />
综合分
</div>
<div className="mt-2 text-2xl font-semibold">{latestScore != null ? latestScore.toFixed(1) : "≥ 90"}</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{latestScore != null ? "当前读取的是 frozen suite 最新均分" : "低于 90 不予验收"}
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="flex items-center gap-2 text-sm font-medium">
<ShieldCheck className="h-4 w-4 text-emerald-600" />
严重误执行
</div>
<div className="mt-2 text-2xl font-semibold">0</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">任何高危误执行直接判失败</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="h-4 w-4 text-amber-600" />
当前状态
</div>
<div className="mt-2">
<Badge variant={passed ? "secondary" : "outline"}>
{passed ? "已达到门槛" : frozenSuite ? "尚未达标" : "待教师冻结集评测"}
</Badge>
</div>
<div className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{frozenSuite
? `${frozenSuite.version} · ${frozenSuite.sample_count} 样本 · ${formatBeijingDateTime(frozenSuite.last_evaluated_at)}`
: "训练后置,但门槛已锁定"}
</div>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
`mode accuracy` ≥ 95
</div>
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
`topic binding` ≥ 93
</div>
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
`clarify correctness` ≥ 90
</div>
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
`draft readiness` ≥ 90
</div>
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
`execution safety` ≥ 99
</div>
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
`assignee recommendation` ≥ 90
</div>
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
`reply quality` ≥ 90
</div>
<div className="rounded-xl border border-dashed border-slate-300 p-3 text-sm dark:border-slate-700">
高危回归 = 0
</div>
</div>
<div className="space-y-3 border-t border-slate-200/80 pt-4 dark:border-slate-800">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm font-medium">Legacy Cutover</div>
<Badge variant={cutoverStatus?.active_item_count ? "outline" : "secondary"}>
{cutoverStatus?.legacy_cutover_state || "unknown"}
</Badge>
<div className="text-xs text-slate-500 dark:text-slate-400">
已退役 {cutoverStatus?.retired_item_count ?? 0} 项 / 剩余活跃 {cutoverStatus?.active_item_count ?? 0} 项
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
{legacyItems.length > 0 ? (
legacyItems.map((item) => (
<div key={item.key} className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-medium">{item.title}</div>
<Badge variant={item.status === "retired" ? "secondary" : "outline"}>{item.status}</Badge>
</div>
<div className="mt-2 text-xs leading-5 text-slate-600 dark:text-slate-300">{item.summary}</div>
<div className="mt-2 text-xs text-slate-500 dark:text-slate-400">{item.evidence}</div>
</div>
))
) : (
<div className="rounded-2xl border border-dashed border-slate-300 p-4 text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
cutover 检查项尚未返回。
</div>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,114 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { MountedAIRunDetailPayload } from "@/api/mountedAi";
import { formatBeijingDateTime } from "@/lib/datetime";
interface MountedAIRunReplayPanelProps {
detail?: MountedAIRunDetailPayload;
loading: boolean;
}
function prettyJson(value: unknown): string {
if (value == null) {
return "暂无";
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
export function MountedAIRunReplayPanel({ detail, loading }: MountedAIRunReplayPanelProps) {
return (
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">Run Replay</CardTitle>
<CardDescription>查看单次 run 的模式切换、agent trace、context hit 与执行结果。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
正在加载 replay...
</div>
) : null}
{!loading && !detail ? (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
选择一条 run 后,这里会展示 replay 细节。
</div>
) : null}
{!loading && detail ? (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Trace</div>
<div className="mt-2 break-all text-sm font-medium">{detail.item.trace_id}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">模式切换</div>
<div className="mt-2 text-sm font-medium">{`${detail.item.mode_before || "-"} -> ${detail.item.mode_after || "-"}`}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Active Agent</div>
<div className="mt-2 text-sm font-medium">{detail.item.active_agent || "-"}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">更新时间</div>
<div className="mt-2 text-sm font-medium">{formatBeijingDateTime(detail.item.updated_at)}</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-3 flex items-center justify-between gap-2">
<div>
<div className="text-sm font-medium">Agent Trace</div>
<div className="text-xs text-slate-500 dark:text-slate-400">按实际落库顺序展示本次编排轨迹。</div>
</div>
<Badge variant="secondary">{detail.agent_traces.length} 条</Badge>
</div>
<div className="space-y-3">
{detail.agent_traces.map((trace) => (
<div
key={trace.id}
className="rounded-xl border border-slate-200/80 bg-slate-50/80 p-3 dark:border-slate-700 dark:bg-slate-950/40"
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{trace.agent_name}</span>
<Badge variant={trace.status === "failed" ? "destructive" : "secondary"}>{trace.status}</Badge>
<span className="text-xs text-slate-500 dark:text-slate-400">
{trace.latency_ms != null ? `${trace.latency_ms} ms` : "无耗时"}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{formatBeijingDateTime(trace.created_at)}
</span>
</div>
{trace.failure_reason ? (
<div className="mt-2 text-sm text-rose-600 dark:text-rose-400">{trace.failure_reason}</div>
) : null}
</div>
))}
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-2 text-sm font-medium">Context Layers Hit</div>
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
{prettyJson(detail.context_layers_hit)}
</pre>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-2 text-sm font-medium">Execution Outcome</div>
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
{prettyJson(detail.execution_outcome)}
</pre>
</div>
</div>
</>
) : null}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,77 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { MountedAIRunItem } from "@/api/mountedAi";
import { formatBeijingDateTime } from "@/lib/datetime";
interface MountedAIRunsPanelProps {
items: MountedAIRunItem[];
loading: boolean;
selectedRunId?: number | null;
onSelectRun: (runId: number) => void;
}
export function MountedAIRunsPanel({ items, loading, selectedRunId, onSelectRun }: MountedAIRunsPanelProps) {
return (
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">Conversation Runs</CardTitle>
<CardDescription>最近的 mounted runs、活跃 agent、模式切换与回复摘要。</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-xl border border-slate-200/80 dark:border-slate-700">
<Table>
<TableHeader>
<TableRow>
<TableHead>Trace</TableHead>
<TableHead>模式</TableHead>
<TableHead>Agent</TableHead>
<TableHead>状态</TableHead>
<TableHead>Trace 数</TableHead>
<TableHead>最后回复</TableHead>
<TableHead>更新时间</TableHead>
<TableHead className="w-[96px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id} data-state={selectedRunId === item.id ? "selected" : undefined}>
<TableCell className="max-w-[180px] truncate font-medium" title={item.trace_id}>
{item.trace_id}
</TableCell>
<TableCell>{`${item.mode_before || "-"} -> ${item.mode_after || "-"}`}</TableCell>
<TableCell>{item.active_agent || "-"}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>{item.trace_count}</TableCell>
<TableCell className="max-w-[280px] truncate" title={item.final_reply || ""}>
{item.final_reply || "-"}
</TableCell>
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
<TableCell className="text-right">
<Button variant="outline" size="sm" onClick={() => onSelectRun(item.id)}>
查看
</Button>
</TableCell>
</TableRow>
))}
{!loading && items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-slate-500 dark:text-slate-400">
暂无 run 数据
</TableCell>
</TableRow>
) : null}
{loading ? (
<TableRow>
<TableCell colSpan={8} className="text-center text-slate-500 dark:text-slate-400">
读取中...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,60 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { MountedAISourceItem } from "@/api/mountedAi";
import { formatBeijingDateTime } from "@/lib/datetime";
interface MountedAISourceStatusPanelProps {
items: MountedAISourceItem[];
loading: boolean;
}
export function MountedAISourceStatusPanel({ items, loading }: MountedAISourceStatusPanelProps) {
return (
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">来源状态</CardTitle>
<CardDescription>当前 Mounted Runtime 已观测到的来源、topic 数和 run 数。</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-xl border border-slate-200/80 dark:border-slate-700">
<Table>
<TableHeader>
<TableRow>
<TableHead>来源</TableHead>
<TableHead>Run</TableHead>
<TableHead>Topic</TableHead>
<TableHead>活跃 Topic</TableHead>
<TableHead>最后观测</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.source_system}>
<TableCell className="font-medium">{item.source_system}</TableCell>
<TableCell>{item.run_count}</TableCell>
<TableCell>{item.topic_count}</TableCell>
<TableCell>{item.active_topic_count}</TableCell>
<TableCell>{formatBeijingDateTime(item.last_seen_at)}</TableCell>
</TableRow>
))}
{!loading && items.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-slate-500 dark:text-slate-400">
暂无来源观测数据
</TableCell>
</TableRow>
) : null}
{loading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-slate-500 dark:text-slate-400">
读取中...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,66 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import type { MountedAITopicItem } from "@/api/mountedAi";
import { formatBeijingDateTime } from "@/lib/datetime";
interface MountedAITopicsPanelProps {
items: MountedAITopicItem[];
loading: boolean;
}
export function MountedAITopicsPanel({ items, loading }: MountedAITopicsPanelProps) {
return (
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">Topic Workspace</CardTitle>
<CardDescription>当前活跃主题、模式、readiness 和草稿状态。</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto rounded-xl border border-slate-200/80 dark:border-slate-700">
<Table>
<TableHeader>
<TableRow>
<TableHead>Topic</TableHead>
<TableHead>模式</TableHead>
<TableHead>状态</TableHead>
<TableHead>Readiness</TableHead>
<TableHead>草稿</TableHead>
<TableHead>更新时间</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={item.id}>
<TableCell className="max-w-[260px] truncate font-medium" title={item.topic_key}>
{item.topic_key}
</TableCell>
<TableCell>{item.mode}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>{item.readiness_score != null ? item.readiness_score.toFixed(2) : "-"}</TableCell>
<TableCell>
{item.draft_state ? `${item.draft_state}${item.draft_version != null ? ` v${item.draft_version}` : ""}` : "-"}
</TableCell>
<TableCell>{formatBeijingDateTime(item.updated_at)}</TableCell>
</TableRow>
))}
{!loading && items.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-slate-500 dark:text-slate-400">
暂无 topic 数据
</TableCell>
</TableRow>
) : null}
{loading ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-slate-500 dark:text-slate-400">
读取中...
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}

File diff suppressed because one or more lines are too long