diff --git a/AGENTS.md b/AGENTS.md index 72210093..862267ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,8 @@ frontend/ # React 前端 根据用户的具体需求,生成高质量、可直接运行的代码。 +- 计划类需求执行约定:若用户已确认计划/方案并要求开始实现,Agent 默认连续执行到“实现完成 + 验证完成 + 给出验收结果”后再统一回复;除非遇到明确阻塞、高风险冲突、context 限制或用户主动中断,否则不得中途停下来反复请示。 + --- ## 8. 服务边界约束与 AI 系统宪章(禁止越界) diff --git a/CLAUDE.md b/CLAUDE.md index 369985b5..9ad56c39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` 投影) diff --git a/backend/alembic/versions/20260405_add_mounted_ai_runtime_tables.py b/backend/alembic/versions/20260405_add_mounted_ai_runtime_tables.py new file mode 100644 index 00000000..6f0a1d69 --- /dev/null +++ b/backend/alembic/versions/20260405_add_mounted_ai_runtime_tables.py @@ -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) diff --git a/backend/app/api/ai_steward_api/__init__.py b/backend/app/api/ai_steward_api/__init__.py index c7bf2695..95590bfb 100644 --- a/backend/app/api/ai_steward_api/__init__.py +++ b/backend/app/api/ai_steward_api/__init__.py @@ -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, diff --git a/backend/app/api/ai_steward_api/routes_mounted_ai.py b/backend/app/api/ai_steward_api/routes_mounted_ai.py new file mode 100644 index 00000000..da34c5ab --- /dev/null +++ b/backend/app/api/ai_steward_api/routes_mounted_ai.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 221ea90b..94cea9ec 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/mounted_ai_runtime.py b/backend/app/models/mounted_ai_runtime.py new file mode 100644 index 00000000..64d8dee1 --- /dev/null +++ b/backend/app/models/mounted_ai_runtime.py @@ -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", +] diff --git a/backend/app/schemas/mounted_ai_runtime.py b/backend/app/schemas/mounted_ai_runtime.py new file mode 100644 index 00000000..4a9785d7 --- /dev/null +++ b/backend/app/schemas/mounted_ai_runtime.py @@ -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) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py index d4b5ba47..282b0f43 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py @@ -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") diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index 9a07a33c..db2c3d43 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -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)), diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py index 82b959e0..0016e5d3 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py @@ -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( diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py index fc8f427d..910dc7fa 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py @@ -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, diff --git a/backend/app/services/mounted_ai_runtime_service.py b/backend/app/services/mounted_ai_runtime_service.py new file mode 100644 index 00000000..13935a5b --- /dev/null +++ b/backend/app/services/mounted_ai_runtime_service.py @@ -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], + } diff --git a/backend/app/services/mounted_ai_runtime_write_service.py b/backend/app/services/mounted_ai_runtime_write_service.py new file mode 100644 index 00000000..9e591e9f --- /dev/null +++ b/backend/app/services/mounted_ai_runtime_write_service.py @@ -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() diff --git a/backend/app/services/signal/signal_capture_service/service.py b/backend/app/services/signal/signal_capture_service/service.py index d116adba..2084afb9 100644 --- a/backend/app/services/signal/signal_capture_service/service.py +++ b/backend/app/services/signal/signal_capture_service/service.py @@ -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, diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index ee043702..96bc42fd 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -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": []} diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index 586679de..83832315 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -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", diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index 42eec39e..eebd976b 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -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( { diff --git a/backend/tests/test_mounted_ai_runtime_api.py b/backend/tests/test_mounted_ai_runtime_api.py new file mode 100644 index 00000000..24a93463 --- /dev/null +++ b/backend/tests/test_mounted_ai_runtime_api.py @@ -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" diff --git a/backend/tests/test_mounted_ai_runtime_migration.py b/backend/tests/test_mounted_ai_runtime_migration.py new file mode 100644 index 00000000..95e5327b --- /dev/null +++ b/backend/tests/test_mounted_ai_runtime_migration.py @@ -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 + diff --git a/backend/tests/test_mounted_ai_runtime_models.py b/backend/tests/test_mounted_ai_runtime_models.py new file mode 100644 index 00000000..da594707 --- /dev/null +++ b/backend/tests/test_mounted_ai_runtime_models.py @@ -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() + diff --git a/backend/tests/test_mounted_ai_runtime_write_service.py b/backend/tests/test_mounted_ai_runtime_write_service.py new file mode 100644 index 00000000..16c88a26 --- /dev/null +++ b/backend/tests/test_mounted_ai_runtime_write_service.py @@ -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" diff --git a/frontend/src/api/mountedAi.ts b/frontend/src/api/mountedAi.ts new file mode 100644 index 00000000..708a0f82 --- /dev/null +++ b/frontend/src/api/mountedAi.ts @@ -0,0 +1,158 @@ +import { api } from "@/lib/api"; + +type ApiEnvelope = { code: number; message: string; data: T }; + +function unwrap(raw: ApiEnvelope | T): T { + if (raw && typeof raw === "object" && "data" in (raw as Record)) { + return (raw as ApiEnvelope).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 | 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 { + const { data } = await api.get>("/ai-steward/mounted-ai/sources"); + return unwrap(data); + }, + + async getTopics(params?: { limit?: number; offset?: number; mode?: string }): Promise { + const { data } = await api.get>("/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 { + const { data } = await api.get>("/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 { + const { data } = await api.get>(`/ai-steward/mounted-ai/runs/${runId}`); + return unwrap(data); + }, + + async getCutoverStatus(): Promise { + const { data } = await api.get>("/ai-steward/mounted-ai/cutover-status"); + return unwrap(data); + }, + + async getEvalSuites(): Promise { + const { data } = await api.get>("/ai-steward/mounted-ai/eval-suites"); + return unwrap(data); + }, +}; diff --git a/frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx b/frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx index bfde86ea..9229d64d 100644 --- a/frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx +++ b/frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx @@ -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(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 ( -
- 调度中心功能建设中 +
+
+
+
+
+ + Mounted AI Console +
+
+

挂载 Runtime 调度中心

+

+ 这里展示当前 Mounted Runtime 的来源观测、topic workspace、conversation runs 与 cutover 状态。 +

+
+
+
+ +
+
+
+ +
+ + + Runtime 就绪度 + + + {runtimeReady ? "已就绪" : "待接管"} + + + + legacy 状态:{cutoverQuery.data?.legacy_cutover_state || "-"} + + + + + + 已观测来源 + {observedSourceText} + + + 来源数量:{cutoverQuery.data?.observed_source_systems.length || 0} + + + + + + Topic Workspace + {cutoverQuery.data?.topic_workspace_count ?? "-"} + + + 当前活跃主题总数 + + + + + + Conversation Runs + + + {cutoverQuery.data?.conversation_run_count ?? "-"} + + + + Tool Policy:{cutoverQuery.data?.tool_policy_count ?? "-"} + + +
+ +
+ + +
+ +
+ + +
+ +
); } diff --git a/frontend/src/pages/scheduler-center/components/MountedAIAcceptancePanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAIAcceptancePanel.tsx new file mode 100644 index 00000000..38fccb5b --- /dev/null +++ b/frontend/src/pages/scheduler-center/components/MountedAIAcceptancePanel.tsx @@ -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 ( + + + Acceptance Gates + 按 PLAN3 固定展示当前终局验收门槛,避免实现阶段和验收标准脱节。 + + +
+
+
+ + 综合分 +
+
{latestScore != null ? latestScore.toFixed(1) : "≥ 90"}
+
+ {latestScore != null ? "当前读取的是 frozen suite 最新均分" : "低于 90 不予验收"} +
+
+
+
+ + 严重误执行 +
+
0
+
任何高危误执行直接判失败
+
+
+
+ + 当前状态 +
+
+ + {passed ? "已达到门槛" : frozenSuite ? "尚未达标" : "待教师冻结集评测"} + +
+
+ {frozenSuite + ? `${frozenSuite.version} · ${frozenSuite.sample_count} 样本 · ${formatBeijingDateTime(frozenSuite.last_evaluated_at)}` + : "训练后置,但门槛已锁定"} +
+
+
+ +
+
+ `mode accuracy` ≥ 95 +
+
+ `topic binding` ≥ 93 +
+
+ `clarify correctness` ≥ 90 +
+
+ `draft readiness` ≥ 90 +
+
+ `execution safety` ≥ 99 +
+
+ `assignee recommendation` ≥ 90 +
+
+ `reply quality` ≥ 90 +
+
+ 高危回归 = 0 +
+
+ +
+
+
Legacy Cutover
+ + {cutoverStatus?.legacy_cutover_state || "unknown"} + +
+ 已退役 {cutoverStatus?.retired_item_count ?? 0} 项 / 剩余活跃 {cutoverStatus?.active_item_count ?? 0} 项 +
+
+
+ {legacyItems.length > 0 ? ( + legacyItems.map((item) => ( +
+
+
{item.title}
+ {item.status} +
+
{item.summary}
+
{item.evidence}
+
+ )) + ) : ( +
+ cutover 检查项尚未返回。 +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/scheduler-center/components/MountedAIRunReplayPanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAIRunReplayPanel.tsx new file mode 100644 index 00000000..c5af6801 --- /dev/null +++ b/frontend/src/pages/scheduler-center/components/MountedAIRunReplayPanel.tsx @@ -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 ( + + + Run Replay + 查看单次 run 的模式切换、agent trace、context hit 与执行结果。 + + + {loading ? ( +
+ 正在加载 replay... +
+ ) : null} + + {!loading && !detail ? ( +
+ 选择一条 run 后,这里会展示 replay 细节。 +
+ ) : null} + + {!loading && detail ? ( + <> +
+
+
Trace
+
{detail.item.trace_id}
+
+
+
模式切换
+
{`${detail.item.mode_before || "-"} -> ${detail.item.mode_after || "-"}`}
+
+
+
Active Agent
+
{detail.item.active_agent || "-"}
+
+
+
更新时间
+
{formatBeijingDateTime(detail.item.updated_at)}
+
+
+ +
+
+
+
Agent Trace
+
按实际落库顺序展示本次编排轨迹。
+
+ {detail.agent_traces.length} 条 +
+
+ {detail.agent_traces.map((trace) => ( +
+
+ {trace.agent_name} + {trace.status} + + {trace.latency_ms != null ? `${trace.latency_ms} ms` : "无耗时"} + + + {formatBeijingDateTime(trace.created_at)} + +
+ {trace.failure_reason ? ( +
{trace.failure_reason}
+ ) : null} +
+ ))} +
+
+ +
+
+
Context Layers Hit
+
+                  {prettyJson(detail.context_layers_hit)}
+                
+
+
+
Execution Outcome
+
+                  {prettyJson(detail.execution_outcome)}
+                
+
+
+ + ) : null} +
+
+ ); +} diff --git a/frontend/src/pages/scheduler-center/components/MountedAIRunsPanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAIRunsPanel.tsx new file mode 100644 index 00000000..6ee649d1 --- /dev/null +++ b/frontend/src/pages/scheduler-center/components/MountedAIRunsPanel.tsx @@ -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 ( + + + Conversation Runs + 最近的 mounted runs、活跃 agent、模式切换与回复摘要。 + + +
+ + + + Trace + 模式 + Agent + 状态 + Trace 数 + 最后回复 + 更新时间 + 操作 + + + + {items.map((item) => ( + + + {item.trace_id} + + {`${item.mode_before || "-"} -> ${item.mode_after || "-"}`} + {item.active_agent || "-"} + {item.status} + {item.trace_count} + + {item.final_reply || "-"} + + {formatBeijingDateTime(item.updated_at)} + + + + + ))} + {!loading && items.length === 0 ? ( + + + 暂无 run 数据 + + + ) : null} + {loading ? ( + + + 读取中... + + + ) : null} + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/scheduler-center/components/MountedAISourceStatusPanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAISourceStatusPanel.tsx new file mode 100644 index 00000000..2ec80a49 --- /dev/null +++ b/frontend/src/pages/scheduler-center/components/MountedAISourceStatusPanel.tsx @@ -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 ( + + + 来源状态 + 当前 Mounted Runtime 已观测到的来源、topic 数和 run 数。 + + +
+ + + + 来源 + Run + Topic + 活跃 Topic + 最后观测 + + + + {items.map((item) => ( + + {item.source_system} + {item.run_count} + {item.topic_count} + {item.active_topic_count} + {formatBeijingDateTime(item.last_seen_at)} + + ))} + {!loading && items.length === 0 ? ( + + + 暂无来源观测数据 + + + ) : null} + {loading ? ( + + + 读取中... + + + ) : null} + +
+
+
+
+ ); +} diff --git a/frontend/src/pages/scheduler-center/components/MountedAITopicsPanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAITopicsPanel.tsx new file mode 100644 index 00000000..541c7848 --- /dev/null +++ b/frontend/src/pages/scheduler-center/components/MountedAITopicsPanel.tsx @@ -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 ( + + + Topic Workspace + 当前活跃主题、模式、readiness 和草稿状态。 + + +
+ + + + Topic + 模式 + 状态 + Readiness + 草稿 + 更新时间 + + + + {items.map((item) => ( + + + {item.topic_key} + + {item.mode} + {item.status} + {item.readiness_score != null ? item.readiness_score.toFixed(2) : "-"} + + {item.draft_state ? `${item.draft_state}${item.draft_version != null ? ` v${item.draft_version}` : ""}` : "-"} + + {formatBeijingDateTime(item.updated_at)} + + ))} + {!loading && items.length === 0 ? ( + + + 暂无 topic 数据 + + + ) : null} + {loading ? ( + + + 读取中... + + + ) : null} + +
+
+
+
+ ); +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 1dc6d126..03121a90 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-config/featureintroguide.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/ai-steward/qamarkdowncontent.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/feedbacksummarycard.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/knowledgegraphcontextboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/pages/ai-config/aiconfigpage.tsx","./src/pages/ai-config/constants.ts","./src/pages/ai-config/types.ts","./src/pages/ai-config/components/governancetoolscard.tsx","./src/pages/ai-config/components/prioritybadge.tsx","./src/pages/ai-config/components/statusbadge.tsx","./src/pages/ai-config/sections/advancedtab.tsx","./src/pages/ai-config/sections/configuretab.tsx","./src/pages/ai-config/sections/knowledgetab.tsx","./src/pages/ai-config/sections/overviewtab.tsx","./src/pages/ai-steward/aistewardpage.tsx","./src/pages/ai-steward/constants.ts","./src/pages/ai-steward/types.ts","./src/pages/ai-steward/components/briefingdetaildrawer.tsx","./src/pages/ai-steward/components/briefingprintdocument.tsx","./src/pages/ai-steward/components/briefingsectioncard.tsx","./src/pages/ai-steward/components/featureoverviewcards.tsx","./src/pages/ai-steward/components/feedback-center/feedbackdetail.tsx","./src/pages/ai-steward/components/feedback-center/feedbacklist.tsx","./src/pages/ai-steward/components/feedback-center/metricspanel.tsx","./src/pages/ai-steward/components/feedback-center/reasonpicker.tsx","./src/pages/ai-steward/components/feedback-center/trendpanel.tsx","./src/pages/ai-steward/sections/blockerstab.tsx","./src/pages/ai-steward/sections/briefingtab.tsx","./src/pages/ai-steward/sections/conflictstab.tsx","./src/pages/ai-steward/sections/createprojecttab.tsx","./src/pages/ai-steward/sections/enginetab.tsx","./src/pages/ai-steward/sections/feedbackcentertab.tsx","./src/pages/ai-steward/sections/overviewtab.tsx","./src/pages/ai-steward/sections/signalstab.tsx","./src/pages/charter-detail/charterdetailpage.tsx","./src/pages/contract-sync-board/contractsyncboardpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/feishu-config/feishuconfigpage.tsx","./src/pages/feishu-config/helpers.ts","./src/pages/feishu-config/components/setupguidecard.tsx","./src/pages/inbox/inboxpage.tsx","./src/pages/inbox/helpers.ts","./src/pages/inbox/components/inboxfilterspanel.tsx","./src/pages/knowledge-graph-context-board/knowledgegraphcontextboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/model-routing-config/modelroutingconfigpage.tsx","./src/pages/project-board/projectboardpage.tsx","./src/pages/project-board/helpers.ts","./src/pages/project-board/components/createcharterdialog.tsx","./src/pages/project-board/components/projectsettingsdialog.tsx","./src/pages/projects/projectspage.tsx","./src/pages/projects/helpers.ts","./src/pages/projects/components/deleteprojectdialog.tsx","./src/pages/projects/components/projectquerydialog.tsx","./src/pages/recipes/recipespage.tsx","./src/pages/recipes/helpers.ts","./src/pages/recipes/components/analyzerecipedialog.tsx","./src/pages/recipes/components/createrecipedialog.tsx","./src/pages/scheduler-center/schedulercenterpage.tsx","./src/pages/skill-tasks/skilltaskspage.tsx","./src/pages/skills/skillspage.tsx","./src/pages/task-brief-center/taskbriefcenterpage.tsx","./src/pages/task-brief-runs/taskbriefrunspage.tsx","./src/pages/task-brief-template/taskbrieftemplatepage.tsx","./src/pages/user-profile/userprofilepage.tsx","./src/pages/user-skills/userskillspage.tsx","./src/pages/users/userspage.tsx","./src/pages/users/helpers.ts","./src/pages/users/components/bulkassigndialog.tsx","./src/pages/users/components/deleteuserdialog.tsx","./src/pages/users/components/importusersdialog.tsx","./src/pages/users/components/resetpassworddialog.tsx","./src/pages/workload-detail/workloaddetailpage.tsx","./src/pages/workloads/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-config/featureintroguide.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/ai-steward/qamarkdowncontent.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/feedbacksummarycard.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/knowledgegraphcontextboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/pages/ai-config/aiconfigpage.tsx","./src/pages/ai-config/constants.ts","./src/pages/ai-config/types.ts","./src/pages/ai-config/components/governancetoolscard.tsx","./src/pages/ai-config/components/prioritybadge.tsx","./src/pages/ai-config/components/statusbadge.tsx","./src/pages/ai-config/sections/advancedtab.tsx","./src/pages/ai-config/sections/configuretab.tsx","./src/pages/ai-config/sections/knowledgetab.tsx","./src/pages/ai-config/sections/overviewtab.tsx","./src/pages/ai-steward/aistewardpage.tsx","./src/pages/ai-steward/constants.ts","./src/pages/ai-steward/types.ts","./src/pages/ai-steward/components/briefingdetaildrawer.tsx","./src/pages/ai-steward/components/briefingprintdocument.tsx","./src/pages/ai-steward/components/briefingsectioncard.tsx","./src/pages/ai-steward/components/featureoverviewcards.tsx","./src/pages/ai-steward/components/feedback-center/feedbackdetail.tsx","./src/pages/ai-steward/components/feedback-center/feedbacklist.tsx","./src/pages/ai-steward/components/feedback-center/metricspanel.tsx","./src/pages/ai-steward/components/feedback-center/reasonpicker.tsx","./src/pages/ai-steward/components/feedback-center/trendpanel.tsx","./src/pages/ai-steward/sections/blockerstab.tsx","./src/pages/ai-steward/sections/briefingtab.tsx","./src/pages/ai-steward/sections/conflictstab.tsx","./src/pages/ai-steward/sections/createprojecttab.tsx","./src/pages/ai-steward/sections/enginetab.tsx","./src/pages/ai-steward/sections/feedbackcentertab.tsx","./src/pages/ai-steward/sections/overviewtab.tsx","./src/pages/ai-steward/sections/signalstab.tsx","./src/pages/charter-detail/charterdetailpage.tsx","./src/pages/contract-sync-board/contractsyncboardpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/feishu-config/feishuconfigpage.tsx","./src/pages/feishu-config/helpers.ts","./src/pages/feishu-config/components/setupguidecard.tsx","./src/pages/inbox/inboxpage.tsx","./src/pages/inbox/helpers.ts","./src/pages/inbox/components/inboxfilterspanel.tsx","./src/pages/knowledge-graph-context-board/knowledgegraphcontextboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/model-routing-config/modelroutingconfigpage.tsx","./src/pages/project-board/projectboardpage.tsx","./src/pages/project-board/helpers.ts","./src/pages/project-board/components/createcharterdialog.tsx","./src/pages/project-board/components/projectsettingsdialog.tsx","./src/pages/projects/projectspage.tsx","./src/pages/projects/helpers.ts","./src/pages/projects/components/deleteprojectdialog.tsx","./src/pages/projects/components/projectquerydialog.tsx","./src/pages/recipes/recipespage.tsx","./src/pages/recipes/helpers.ts","./src/pages/recipes/components/analyzerecipedialog.tsx","./src/pages/recipes/components/createrecipedialog.tsx","./src/pages/scheduler-center/schedulercenterpage.tsx","./src/pages/scheduler-center/components/mountedaiacceptancepanel.tsx","./src/pages/scheduler-center/components/mountedairunreplaypanel.tsx","./src/pages/scheduler-center/components/mountedairunspanel.tsx","./src/pages/scheduler-center/components/mountedaisourcestatuspanel.tsx","./src/pages/scheduler-center/components/mountedaitopicspanel.tsx","./src/pages/skill-tasks/skilltaskspage.tsx","./src/pages/skills/skillspage.tsx","./src/pages/task-brief-center/taskbriefcenterpage.tsx","./src/pages/task-brief-runs/taskbriefrunspage.tsx","./src/pages/task-brief-template/taskbrieftemplatepage.tsx","./src/pages/user-profile/userprofilepage.tsx","./src/pages/user-skills/userskillspage.tsx","./src/pages/users/userspage.tsx","./src/pages/users/helpers.ts","./src/pages/users/components/bulkassigndialog.tsx","./src/pages/users/components/deleteuserdialog.tsx","./src/pages/users/components/importusersdialog.tsx","./src/pages/users/components/resetpassworddialog.tsx","./src/pages/workload-detail/workloaddetailpage.tsx","./src/pages/workloads/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file