Harden PMFlow context binding and world model snapshots

This commit is contained in:
Yuuuu 2026-04-07 09:36:40 +08:00
parent bd5a2813c3
commit 38adaed6e6
52 changed files with 5308 additions and 381 deletions

View File

@ -63,7 +63,8 @@ backend/
│ ├── 20260324_add_project_create_idempotency_indexes.py # 项目创建幂等索引:活动项目标题大小写无关唯一 │ ├── 20260324_add_project_create_idempotency_indexes.py # 项目创建幂等索引:活动项目标题大小写无关唯一
│ ├── 20260327_cleanup_model_routing_legacy_names.py # model_routing 旧函数名清理与重命名迁移 │ ├── 20260327_cleanup_model_routing_legacy_names.py # model_routing 旧函数名清理与重命名迁移
│ ├── 20260405_add_mounted_ai_runtime_tables.py # Mounted Runtime 建表迁移 │ ├── 20260405_add_mounted_ai_runtime_tables.py # Mounted Runtime 建表迁移
│ └── 20260405_add_mounted_runtime_plan_state_fields.py # Mounted Runtime plan_status / conversation_scope_key 字段 │ ├── 20260405_add_mounted_runtime_plan_state_fields.py # Mounted Runtime plan_status / conversation_scope_key 字段
│ └── 20260406_add_mounted_runtime_active_draft_fields.py # Mounted draft active work item / message anchor 字段
├── scripts/ # 后端运维/数据修复脚本 ├── scripts/ # 后端运维/数据修复脚本
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
│ ├── dev/ # 开发辅助脚本(check_users/seed_all) │ ├── dev/ # 开发辅助脚本(check_users/seed_all)
@ -196,19 +197,21 @@ backend/
- 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助) - 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助)
- 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射) - 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射)
- 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录) - 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录)
- 新增:`backend/app/services/ai_pmo/semantic_context/`(OpenCow 风格语义上下文子模块:统一 `SemanticContextPacket/ModeCard/SemanticDecision/DecisionGateResult` 契约、`SemanticContextAssembler`、`SemanticDecisionGatekeeper` 与 `SemanticDecisionService`;当前已接入 ingress shadow 观测,并为 intent/query-scene 主链提供 direct metadata 替代语义入口)
- 新增:`backend/app/models/reply_iteration.py`(回复质量迭代模型:`QualityAsset/QualityAssetPair/JudgeRun/OptimizerCandidate/PromptRelease`) - 新增:`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/training_pipeline.py`(训练/微调管线模型:`TrainingDataset/TrainingJob/TrainingEvaluation/TrainedModelArtifact/TrainedModelDeployment`)
- 新增:`backend/app/models/mounted_ai_runtime.py`(Mounted Runtime 运行时模型:`MountedTopicWorkspace/MountedDraftWorkspace/MountedConversationRun/MountedAgentTrace/MountedSourceActivation/MountedToolCapabilityPolicy`;`MountedTopicWorkspace` 额外承载 `conversation_scope_key + plan_status`,用于同群同人 topic 隔离与 plan 状态机) - 新增:`backend/app/models/mounted_ai_runtime.py`(Mounted Runtime 运行时模型:`MountedTopicWorkspace/MountedDraftWorkspace/MountedConversationRun/MountedAgentTrace/MountedSourceActivation/MountedToolCapabilityPolicy`;`MountedTopicWorkspace` 额外承载 `conversation_scope_key + plan_status`,用于同群同人 topic 隔离与 plan 状态机;`MountedDraftWorkspace` 额外承载 `active_work_item_id + latest_source_message_id + latest_reply_message_id`,用于当前 active draft 绑定与 reply anchor 锚定)
- 新增:`backend/app/services/reply_iteration/service.py`(反馈中心提升为学习资产、裁判分桶、候选改动、prompt release trial 服务;支持 `backfill_mounted_training_assets_from_feedback`:从真实 `feedback_center_records + qa_records` 回填 golden/failure、preference pair 与 judge runs,供 mounted runtime 真实业务训练使用) - 新增:`backend/app/services/reply_iteration/service.py`(反馈中心提升为学习资产、裁判分桶、候选改动、prompt release trial 服务;支持 `backfill_mounted_training_assets_from_feedback`:从真实 `feedback_center_records + qa_records` 回填 golden/failure、preference pair 与 judge runs,供 mounted runtime 真实业务训练使用)
- 新增:`backend/app/services/training_pipeline/service.py`(训练数据集、训练作业、评测、artifact promote、deployment 决策服务;支持 `bootstrap_mounted_training_wave`:自动从 mounted runtime 质量资产生成 `teacher_dev_set / teacher_eval_frozen_set / sft / preference`,并启动 low/high 训练波次;mounted wave 会立刻消费本次 queued jobs,产出 completed job/evaluation/acceptance gate;当 failure 样本带 `corrected_answer` 时会自动合成 synthetic golden / preference pair) - 新增:`backend/app/services/training_pipeline/service.py`(训练数据集、训练作业、评测、artifact promote、deployment 决策服务;支持 `bootstrap_mounted_training_wave`:自动从 mounted runtime 质量资产生成 `teacher_dev_set / teacher_eval_frozen_set / sft / preference`,并启动 low/high 训练波次;mounted wave 会立刻消费本次 queued jobs,产出 completed job/evaluation/acceptance gate;当 failure 样本带 `corrected_answer` 时会自动合成 synthetic golden / preference pair)
- 新增:`backend/app/services/prompt_release/service.py`(只读 QA prompt release 版本管理与 serving 渲染优先级) - 新增:`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 查询、source activation / tool policies / training ops / hard cases / replay-run 管理,以及 ingress topic/run/trace/source-activation 落库;当某来源存在 active activation 白名单时,仅命中的 scope 才进入 mounted tracking;run detail 会返回 rich trace:`input_summary/output_summary/tool_trace`;写路径会持久化 `conversation_scope_key` 与显式 `plan_status`) - 新增:`backend/app/services/mounted_ai_runtime_service.py`、`backend/app/services/mounted_ai_runtime_write_service.py`(Mounted Runtime 读模型与写路径:来源/主题/run 查询、source activation / tool policies / training ops / hard cases / replay-run 管理,以及 ingress topic/run/trace/source-activation 落库;当某来源存在 active activation 白名单时,仅命中的 scope 才进入 mounted tracking;run detail 会返回 rich trace:`input_summary/output_summary/tool_trace`,并暴露 `topic/draft/semantic_packet_summary/semantic_decision/semantic_gate_result/semantic_diff`;写路径会持久化 `conversation_scope_key`、显式 `plan_status` 与 semantic shadow 观测结果)
- 新增:`backend/app/api/ai_steward_api/routes_reply_iteration.py`(AI Steward 回复质量闭环接口:反馈提升、judge-runs、candidates、prompt-releases、trials,以及 `POST /ai-steward/reply-iteration/backfill-mounted-assets` 真实业务训练资产回填入口) - 新增:`backend/app/api/ai_steward_api/routes_reply_iteration.py`(AI Steward 回复质量闭环接口:反馈提升、judge-runs、candidates、prompt-releases、trials,以及 `POST /ai-steward/reply-iteration/backfill-mounted-assets` 真实业务训练资产回填入口)
- 新增:`backend/app/api/ai_steward_api/routes_training_pipeline.py`(AI Steward 训练管线接口:datasets、jobs、evaluations、artifacts、deployments,以及 `POST /ai-steward/training/mounted-wave` 一键训练波次入口、`POST /ai-steward/training/jobs/{id}/run` 单 job 执行入口) - 新增:`backend/app/api/ai_steward_api/routes_training_pipeline.py`(AI Steward 训练管线接口:datasets、jobs、evaluations、artifacts、deployments,以及 `POST /ai-steward/training/mounted-wave` 一键训练波次入口、`POST /ai-steward/training/jobs/{id}/run` 单 job 执行入口)
- 新增:`backend/app/api/ai_steward_api/routes_mounted_ai.py`、`backend/app/schemas/mounted_ai_runtime.py`(Mounted Runtime 控制台接口:sources/source-activations/tool-policies/topics/runs/run-detail/cutover-status/eval-suites/training-ops/hard-cases,以及 admin/source-activation、admin/tool-policy、admin/replay-run) - 新增:`backend/app/api/ai_steward_api/routes_mounted_ai.py`、`backend/app/schemas/mounted_ai_runtime.py`(Mounted Runtime 控制台接口:sources/source-activations/tool-policies/topics/runs/run-detail/cutover-status/eval-suites/training-ops/hard-cases,以及 admin/source-activation、admin/tool-policy、admin/replay-run)
- 新增:`backend/alembic/versions/20260327_add_reply_iteration_training_tables.py`(回复质量迭代/训练管线主迁移:学习资产表、prompt release、训练数据集、训练作业与 experiment 扩展字段) - 新增:`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、source activation、tool capability policy) - 新增:`backend/alembic/versions/20260405_add_mounted_ai_runtime_tables.py`(Mounted Runtime 建表迁移:topic workspace、draft workspace、conversation run、agent trace、source activation、tool capability policy)
- 新增:`backend/alembic/versions/20260405_add_mounted_runtime_plan_state_fields.py`(Mounted Runtime 计划态增强迁移:`mounted_topic_workspaces` 新增 `conversation_scope_key / plan_status`,支撑同群同人多 topic 隔离与 plan 状态机) - 新增:`backend/alembic/versions/20260405_add_mounted_runtime_plan_state_fields.py`(Mounted Runtime 计划态增强迁移:`mounted_topic_workspaces` 新增 `conversation_scope_key / plan_status`,支撑同群同人多 topic 隔离与 plan 状态机)
- 新增:`backend/alembic/versions/20260406_add_mounted_runtime_active_draft_fields.py`(Mounted Runtime active draft 锚点迁移:`mounted_draft_workspaces` 新增 `active_work_item_id / latest_source_message_id / latest_reply_message_id`,支撑 session-first 草稿确认与最新回包锚定)
- 新增:`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_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/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/ai_pmo_kernel/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架)
@ -228,10 +231,11 @@ backend/
- 更新:`backend/app/services/signal/signal_capture_service/mixin_capture_entry.py`(Feishu capture 在 source activation 存在 active 白名单规则时,未命中 scope 的 chat 直接返回 `mounted_runtime_scope_not_activated`,不再继续进入 kernel/queue) - 更新:`backend/app/services/signal/signal_capture_service/mixin_capture_entry.py`(Feishu capture 在 source activation 存在 active 白名单规则时,未命中 scope 的 chat 直接返回 `mounted_runtime_scope_not_activated`,不再继续进入 kernel/queue)
- 更新:`backend/app/services/signal/signal_capture_service/mixin_capture_entry.py`(kernel 未处理或 capture 主链异常时会回滚当前 session 并把真实失败原因写入 `direct_reply_text`,不再回退统一“系统处理中出现异常”伪成功文案) - 更新:`backend/app/services/signal/signal_capture_service/mixin_capture_entry.py`(kernel 未处理或 capture 主链异常时会回滚当前 session 并把真实失败原因写入 `direct_reply_text`,不再回退统一“系统处理中出现异常”伪成功文案)
- 更新:`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_auto.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;除主脑 `ConversationOrchestrator` 外,还会额外记录 `PlanCoach/Clarifier/DraftSynthesizer/Executor` 等 specialist trace;`query.answer` + `business_review/assignee` 场景会在 runtime 中收口到 `plan/chat`,不再伪装成 `execute/Executor`;kernel 失败改为 `logger.exception` 保留完整 traceback) - 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理,并把 ingress 主链同步写入 Mounted Runtime 的 topic/run/trace;除主脑 `ConversationOrchestrator` 外,还会额外记录 `PlanCoach/Clarifier/DraftSynthesizer/Executor` 等 specialist trace;`query.answer` + `business_review/assignee` 场景会在 runtime 中收口到 `plan/chat`,不再伪装成 `execute/Executor`;query-scene 强制改为优先读取 direct metadata,不再在主链回填 `semantic_bundle`;kernel 失败改为 `logger.exception` 保留完整 traceback)
- 更新:`backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py`(query `failed_safe` 只在 SQLAlchemy session 已进入 failed/inactive 状态时回滚,避免把同链路的 ingest/work_item 一起回滚掉;需求收敛/范围建议/负责人推荐/项目存在性查询会优先走 deterministic 直答,不再默认落到 heavy RAG;“一个 AI 客服工具,这个项目有吗”这类问句会直接返回项目事实库命中结果) - 更新:`backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py`(query `failed_safe` 只在 SQLAlchemy session 已进入 failed/inactive 状态时回滚,避免把同链路的 ingest/work_item 一起回滚掉;需求收敛/范围建议/负责人推荐/项目存在性查询会优先走 deterministic 直答,不再默认落到 heavy RAG;query executor 对 `message_intent/consultant_mode_preference/advise_subtype/force_thinking_mode` 改为 direct metadata first,旧 `semantic_bundle.diagnostics` 仅保留兼容兜底;`requirement_plan` followup 在存在 active workspace 时优先按 mounted runtime 状态与字段增量续接,不再先依赖旧 planner 猜 continuation;“一个 AI 客服工具,这个项目有吗”这类问句会直接返回项目事实库命中结果)
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py`、`ai_pmo_kernel_mixins_intent_llm.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(意图解析主链移除 `signal_source.onboard` 的 pre-LLM 规则锁;LLM metadata 不再注入 `semantic_bundle`;capture result 仅生成 `direct_metadata_compat` 形式的兼容语义包,不再为回包临时重跑 `workflow_semantic_planner`)
- 更新:`backend/app/services/adaptive_rag_service/mixin_followup_anchor.py`、`mixin_fast_answer_project_main.py`(standalone 项目存在性问句不再误判成 referential followup;项目风险/阻塞问句在无任务拆解时返回“缺任务分解、缺里程碑、缺负责人推进节奏”的风险代理信号,不再给空泛兜底) - 更新:`backend/app/services/adaptive_rag_service/mixin_followup_anchor.py`、`mixin_fast_answer_project_main.py`(standalone 项目存在性问句不再误判成 referential followup;项目风险/阻塞问句在无任务拆解时返回“缺任务分解、缺里程碑、缺负责人推进节奏”的风险代理信号,不再给空泛兜底)
- 更新:`backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_discover.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py`(`signal_source.onboard` 显式接入请求会在 LLM 前锁定 intent,禁止再漂移成 `project_signal.discover`;discovery mixin 补齐 `_safe_int/_utc_now_naive` 导入,消除真实 discovery 执行时的 `NameError`) - 更新:`backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_discover.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py`(discovery mixin 补齐 `_safe_int/_utc_now_naive` 导入,消除真实 discovery 执行时的 `NameError`;`signal_source.onboard` 不再走 pre-LLM 规则锁,主链保持 model-only,再由后续 route/gate 约束)
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py`(canonical intent builder 现在保留 `project.update/task.* / personnel.* / knowledge.* / notification.*` 等 mutation intent,避免 LLM 已识别的写操作被错误降级成 `ignore`) - 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py`(canonical intent builder 现在保留 `project.update/task.* / personnel.* / knowledge.* / notification.*` 等 mutation intent,避免 LLM 已识别的写操作被错误降级成 `ignore`)
- 更新:`backend/app/models/work_item.py`(新增 `draft_content/draft_expires_at/draft_version/confirm_message_id`) - 更新:`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/compositions/world_model.py`(Kernel world-model 组合入口)
@ -399,8 +403,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/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/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/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`、`MountedAISourceActivationPanel.tsx`、`MountedAIToolGuardrailsPanel.tsx`、`MountedAITopicsPanel.tsx`、`MountedAIRunsPanel.tsx`、`MountedAIRunReplayPanel.tsx`、`MountedAIOrchestrationGraphPanel.tsx`、`MountedAIContextLayersPanel.tsx`、`MountedAITrainingOpsPanel.tsx`、`MountedAIEvalScoreboardPanel.tsx`、`MountedAIHardCasesPanel.tsx`、`MountedAIAcceptancePanel.tsx`(Mounted AI Console 来源状态、白名单 cutover、tool guardrails、topic workspace、run 列表、replay 详情、agent 编排图、context layers、training ops、eval score board、hard cases 与验收门槛面板;其中 source activation / tool guardrails 已支持前台编辑,run replay 支持直接触发 `admin/replay-run`,training ops 已支持直接发起 mounted training wave,并展示 recent datasets / recent jobs / acceptance gate,replay/orchestration 已展示 rich trace 的 `input/output/tool` 明细) - 新增:`frontend/src/pages/scheduler-center/components/MountedAISourceStatusPanel.tsx`、`MountedAISourceActivationPanel.tsx`、`MountedAIToolGuardrailsPanel.tsx`、`MountedAITopicsPanel.tsx`、`MountedAIRunsPanel.tsx`、`MountedAIRunReplayPanel.tsx`、`MountedAIOrchestrationGraphPanel.tsx`、`MountedAIContextLayersPanel.tsx`、`MountedAISemanticPacketPanel.tsx`、`MountedAISemanticDecisionPanel.tsx`、`MountedAIShadowDiffPanel.tsx`、`MountedAITrainingOpsPanel.tsx`、`MountedAIEvalScoreboardPanel.tsx`、`MountedAIHardCasesPanel.tsx`、`MountedAIAcceptancePanel.tsx`(Mounted AI Console 来源状态、白名单 cutover、tool guardrails、topic workspace、run 列表、replay 详情、agent 编排图、context layers、semantic packet / decision / shadow diff、training ops、eval score board、hard cases 与验收门槛面板;其中 source activation / tool guardrails 已支持前台编辑,run replay 支持直接触发 `admin/replay-run`,training ops 已支持直接发起 mounted training wave,并展示 recent datasets / recent jobs / acceptance gate)
- 更新:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`(Scheduler Center 升级为 Mounted AI Console:接入 mounted runtime 查询与 admin actions,支持 source activation、tool policy、run replay、training wave 的前台操作,并支持通过 `mountedRunId` 查询参数直接定位 run) - 更新:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`(Scheduler Center 升级为 Mounted AI Console:接入 mounted runtime 查询与 admin actions,支持 source activation、tool policy、run replay、training wave 的前台操作,并支持通过 `mountedRunId` 查询参数直接定位 run;run detail 区新增 semantic packet / decision / gate / shadow diff 观测区)
- 新增:`frontend/src/pages/user-profile/UserProfilePage.tsx`、`user-skills/UserSkillsPage.tsx`、`workload-detail/WorkloadDetailPage.tsx`、`workloads/WorkloadsPage.tsx`(用户与负载页面目录化收敛) - 新增:`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/InboxPage.tsx`(降级为轻量入口,转发到 `pages/inbox/InboxPage`)
- 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper) - 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper)
@ -578,12 +582,16 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- `workflow_semantic_planner` 也必须把通用需求收敛里的短槽位回答继续识别为 `message_intent=requirement_plan`,例如“先给内部 PM 和研发负责人用 / 飞书总结就行 / 每次会后半小时内整理出来”;这类碎片化回复不得因为没有新 `semantic_bundle` 就退回普通 query - `workflow_semantic_planner` 也必须把通用需求收敛里的短槽位回答继续识别为 `message_intent=requirement_plan`,例如“先给内部 PM 和研发负责人用 / 飞书总结就行 / 每次会后半小时内整理出来”;这类碎片化回复不得因为没有新 `semantic_bundle` 就退回普通 query
- `ExecuteQueryProjectMixin` 对 `message_intent=requirement_plan` 的查询必须优先走 mounted runtime 直答快路径:默认只输出自然短话术,每轮最多只保留 1 个关键问题;当 `scoring_basis/target_user/output_form/cadence/first_scope` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问 - `ExecuteQueryProjectMixin` 对 `message_intent=requirement_plan` 的查询必须优先走 mounted runtime 直答快路径:默认只输出自然短话术,每轮最多只保留 1 个关键问题;当 `scoring_basis/target_user/output_form/cadence/first_scope` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问
- `requirement_plan` 的 Mounted Runtime 状态必须显式落库到 `MountedTopicWorkspace.plan_status`:至少区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`,禁止只靠 `draft.state` 或 `metadata_json` 猜当前阶段 - `requirement_plan` 的 Mounted Runtime 状态必须显式落库到 `MountedTopicWorkspace.plan_status`:至少区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`,禁止只靠 `draft.state` 或 `metadata_json` 猜当前阶段
- 新的 OpenCow 风格语义上下文链必须先组装 `SemanticContextPacket`,再由模型返回 `SemanticDecision`,最后由 gatekeeper 决定 `plan_status`;当前 shadow 结果已进入 Mounted Runtime 观测,同时 intent/query-scene 主链已切到 direct metadata,不得再把 `semantic_bundle` 当作主链真源或绕过统一执行门禁
- 正在收敛中的 `requirement_plan` 必须支持普通人碎片化回复:像“技术指标吧 / 给投顾看 / 飞书日报就行 / 每天收盘后发”这种单字段短句,即使缺少新的 `semantic_bundle`,只要同线程/同群同人存在 active plan workspace,也必须自动续接当前需求收敛,不得回落到通用 query 或高阶 RAG - 正在收敛中的 `requirement_plan` 必须支持普通人碎片化回复:像“技术指标吧 / 给投顾看 / 飞书日报就行 / 每天收盘后发”这种单字段短句,即使缺少新的 `semantic_bundle`,只要同线程/同群同人存在 active plan workspace,也必须自动续接当前需求收敛,不得回落到通用 query 或高阶 RAG
- `requirement_plan` 的追问节奏必须像自然聊天:上一轮只确认新增信息、下一轮只问一个缺口;初始描述中的“每日/自动”不能过早推断成已确认的更新频率,避免少问关键问题或提前出草稿 - `requirement_plan` 的追问节奏必须像自然聊天:上一轮只确认新增信息、下一轮只问一个缺口;初始描述中的“每日/自动”不能过早推断成已确认的更新频率,避免少问关键问题或提前出草稿
- `requirement_plan` 必须按 profile 区分场景:`scoring` 需求继续收集 `打分依据/目标用户/输出形式/更新频率/第一版范围`,`generic` 需求收集 `输入材料/目标用户/输出形式/响应时效/第一版范围`;像“会议纪要提待办”这类普通工具需求,禁止再复用“这个分数怎么打”的旧问题模板 - `requirement_plan` 必须按 profile 区分场景:`scoring` 需求继续收集 `打分依据/目标用户/输出形式/更新频率/第一版范围`,`generic` 需求收集 `输入材料/目标用户/输出形式/响应时效/第一版范围`;像“会议纪要提待办”这类普通工具需求,禁止再复用“这个分数怎么打”的旧问题模板
- `generic requirement_plan` 的 `A/B/C` 选项只能出现在 active requirement session 的 `collecting` 轮次里,并且只允许绑定当前缺口字段(如 `target_user / output_form / cadence`);普通闲聊、query.answer、非 requirement followup 必须继续保持自由文案,禁止全局套用选项模板
- 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,AI PMO 必须先清掉当前会话里继承的旧 `current_project_id`,避免后续短回复继续被旧项目上下文污染
- 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须直接提示“当前没有进行中的收敛轮次/草稿”,禁止再回落到 `query.answer` 检索图谱并生成旧项目结论
- 飞书私聊中的“答错了/不对/调用高级模型/用更强模型/重新回答”必须被识别为 `correction_escalation` 指令:优先复用上一轮上下文主题并强制深度回答,禁止把这类话术当成新的系统配置查询 - 飞书私聊中的“答错了/不对/调用高级模型/用更强模型/重新回答”必须被识别为 `correction_escalation` 指令:优先复用上一轮上下文主题并强制深度回答,禁止把这类话术当成新的系统配置查询
- `POST /rag/ask` 在 `rag_request_context` 或统一语义规划层命中 `business_review/correction_escalation` 时,必须强制留在 `query.read_only` 主链并进入对应 QA Prompt;禁止因为 LLM 意图分类把“分析/MVP/重答”误送到 `signal.create -> draft_pending_confirm` - `POST /rag/ask` 在 `rag_request_context` 或统一语义规划层命中 `business_review/correction_escalation` 时,必须强制留在 `query.read_only` 主链并进入对应 QA Prompt;禁止因为 LLM 意图分类把“分析/MVP/重答”误送到 `signal.create -> draft_pending_confirm`
- Feishu 新消息即使被低阶 LLM 先判成 `project.create`,只要统一语义规划层从原句识别出 `requirement_plan/business_review/correction_escalation`,就必须在 ingress `_coerce_intent_for_query_scene` 阶段强制拉回 `query.answer`;Mounted Runtime 记录 `mode_after/agent` 时也必须优先读取 `semantic_bundle.diagnostics`,禁止因顶层 `route_metadata` 缺字段把真实 `plan` 记成 `chat` - Feishu 新消息即使被低阶 LLM 先判成 `project.create`,只要统一语义规划层从原句识别出 `requirement_plan/business_review/correction_escalation`,就必须在 ingress `_coerce_intent_for_query_scene` 阶段强制拉回 `query.answer`;Mounted Runtime 记录 `mode_after/agent` 时必须优先读取顶层 direct metadata,`semantic_bundle.diagnostics` 只允许作为兼容回退,禁止因顶层 `route_metadata` 缺字段把真实 `plan` 记成 `chat`
- 飞书入口的高精度同步判定不能只看 `primary_workflow`;只要 `WorkflowSemanticPlanner` 的 `diagnostics.message_intent=requirement_plan`,就必须优先返回 `semantic:requirement_plan` 并直接同步进入 AI PMO kernel,转发 `semantic_bundle`,禁止再先入异步队列发送 `已收到,正在处理` ACK - 飞书入口的高精度同步判定不能只看 `primary_workflow`;只要 `WorkflowSemanticPlanner` 的 `diagnostics.message_intent=requirement_plan`,就必须优先返回 `semantic:requirement_plan` 并直接同步进入 AI PMO kernel,转发 `semantic_bundle`,禁止再先入异步队列发送 `已收到,正在处理` ACK
- “高级模型是否启用/模型路由是否启用/当前是否高配”这类运行态问题,只有命中运行态配置、路由快照、引擎配置或真实调用记录时才允许下结论;若证据只有页面目录、代码图谱或功能清单,回复必须明确为“无法仅凭静态资料确认” - “高级模型是否启用/模型路由是否启用/当前是否高配”这类运行态问题,只有命中运行态配置、路由快照、引擎配置或真实调用记录时才允许下结论;若证据只有页面目录、代码图谱或功能清单,回复必须明确为“无法仅凭静态资料确认”
- 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文 - 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文
@ -612,7 +620,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` - `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`、`POST /ai-steward/reply-iteration/backfill-mounted-assets`、`/reply-iteration/judge-runs/candidates/prompt-releases/trials`) - 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py`(`POST /ai-steward/feedback-center/items/{id}/promote`、`POST /ai-steward/reply-iteration/backfill-mounted-assets`、`/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`、`POST /training/jobs/{id}/run`、`GET /training/jobs/{id}/evaluations`、`POST /training/artifacts/{id}/promote`、`POST /training/deployments/{id}/decision`、`POST /training/mounted-wave`;mounted wave 会自动生成 `teacher_dev_set / teacher_eval_frozen_set / sft / preference` 并启动并执行 low/high 训练波次) - 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py`(`GET/POST /ai-steward/training/datasets/jobs`、`POST /training/jobs/{id}/run`、`GET /training/jobs/{id}/evaluations`、`POST /training/artifacts/{id}/promote`、`POST /training/deployments/{id}/decision`、`POST /training/mounted-wave`;mounted wave 会自动生成 `teacher_dev_set / teacher_eval_frozen_set / sft / preference` 并启动并执行 low/high 训练波次)
- Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py`(`GET /ai-steward/mounted-ai/sources|source-activations|tool-policies|topics|runs|runs/{id}|cutover-status|eval-suites|eval-suites/{id}|training-ops|hard-cases`,以及 `POST /ai-steward/mounted-ai/admin/source-activation|tool-policy|replay-run`,统一 `{code,message,data}` 包络);其中 `cutover-status` 必须返回模块级旧脑退役视图:`legacy_cutover_state / retired_item_count / active_item_count / legacy_cutover_items[]` - Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py`(`GET /ai-steward/mounted-ai/sources|source-activations|tool-policies|topics|runs|runs/{id}|cutover-status|eval-suites|eval-suites/{id}|training-ops|hard-cases`,以及 `POST /ai-steward/mounted-ai/admin/source-activation|tool-policy|replay-run`,统一 `{code,message,data}` 包络);其中 `runs/{id}` 必须返回 `topic/draft/semantic_packet_summary/semantic_decision/semantic_gate_result/semantic_diff`,`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`) - 任务包中心接口:参考 `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` 仅保留兼容别名) - 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`) - 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重、`raw_context` 持久化,并为每条 registry 同步绑定/物化正式 `Signal`)
@ -690,6 +698,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) - World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
- 写操作后主动 invalidate,动态层不缓存 - 写操作后主动 invalidate,动态层不缓存
- AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路 - AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路
- `semantic_context` 新链当前状态:ingress shadow 观测已落库,intent/query-scene/render 主链已切到 direct metadata,旧 `semantic_bundle/workflow_semantic_planner` 仍仅在部分 requirement-plan executor / followup 兼容路径中生效;后续删旧规则时应继续以 Mounted Runtime 观测页核对 shadow diff
- IT 五轮 requirement plan 现以 `MountedDraftWorkspace.active_work_item_id + latest_*_message_id` 锚定当前 active draft;历史未绑定这些字段的旧草稿不会自动认领,需先重新生成当前草稿后再确认/修改
- AI PMO / Feishu 执行门已取消置信度阈值拦截与高风险人工复核:`confidence` 仅保留在 `decision_trace` / processing log 中作观测,不再作为自动执行的 reject 条件;当前门禁仅保留 `force_manual` 与 `correction_action` - AI PMO / Feishu 执行门已取消置信度阈值拦截与高风险人工复核:`confidence` 仅保留在 `decision_trace` / processing log 中作观测,不再作为自动执行的 reject 条件;当前门禁仅保留 `force_manual` 与 `correction_action`
- 模型默认值与 provider/tier 映射以 `backend/.openclaw/pmflow-routing-rules.json` 为唯一配置源;`.env` / `.env.example` 仅保留 API key 与运行开关,`OPENCLAW_ROUTING_RULES_PATH` 只用于定位这份文件 - 模型默认值与 provider/tier 映射以 `backend/.openclaw/pmflow-routing-rules.json` 为唯一配置源;`.env` / `.env.example` 仅保留 API key 与运行开关,`OPENCLAW_ROUTING_RULES_PATH` 只用于定位这份文件
- 调用层与路由页面只使用 `low/high` 两层语义;`safety` 已废弃并从配置校验、运行时解析、前端选项中移除 - 调用层与路由页面只使用 `low/high` 两层语义;`safety` 已废弃并从配置校验、运行时解析、前端选项中移除

View File

@ -0,0 +1,116 @@
"""add mounted runtime active draft fields
Revision ID: 20260406_add_mounted_runtime_active_draft_fields
Revises: 20260405_add_mounted_runtime_plan_state_fields
Create Date: 2026-04-06
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "20260406_add_mounted_runtime_active_draft_fields"
down_revision = "20260405_add_mounted_runtime_plan_state_fields"
branch_labels = None
depends_on = None
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
try:
columns = inspector.get_columns(table_name)
except Exception:
return False
return any(str(column.get("name")) == column_name for column in columns)
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 upgrade() -> None:
bind = op.get_bind()
if not _column_exists(bind, "mounted_draft_workspaces", "active_work_item_id"):
op.add_column(
"mounted_draft_workspaces",
sa.Column("active_work_item_id", sa.BigInteger(), nullable=True),
)
if not _column_exists(bind, "mounted_draft_workspaces", "latest_source_message_id"):
op.add_column(
"mounted_draft_workspaces",
sa.Column("latest_source_message_id", sa.VARCHAR(length=128), nullable=True),
)
if not _column_exists(bind, "mounted_draft_workspaces", "latest_reply_message_id"):
op.add_column(
"mounted_draft_workspaces",
sa.Column("latest_reply_message_id", sa.VARCHAR(length=128), nullable=True),
)
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_active_work_item_id"):
op.create_index(
"ix_mounted_draft_workspaces_active_work_item_id",
"mounted_draft_workspaces",
["active_work_item_id"],
)
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_source_message_id"):
op.create_index(
"ix_mounted_draft_workspaces_latest_source_message_id",
"mounted_draft_workspaces",
["latest_source_message_id"],
)
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_reply_message_id"):
op.create_index(
"ix_mounted_draft_workspaces_latest_reply_message_id",
"mounted_draft_workspaces",
["latest_reply_message_id"],
)
op.execute(
sa.text(
"""
UPDATE mounted_draft_workspaces AS draft
SET latest_source_message_id = COALESCE(
NULLIF(draft.latest_source_message_id, ''),
(
SELECT topic.last_message_id
FROM mounted_topic_workspaces AS topic
WHERE topic.id = draft.topic_workspace_id
)
)
WHERE COALESCE(NULLIF(draft.latest_source_message_id, ''), '') = ''
"""
)
)
def downgrade() -> None:
bind = op.get_bind()
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_reply_message_id"):
op.drop_index(
"ix_mounted_draft_workspaces_latest_reply_message_id",
table_name="mounted_draft_workspaces",
)
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_source_message_id"):
op.drop_index(
"ix_mounted_draft_workspaces_latest_source_message_id",
table_name="mounted_draft_workspaces",
)
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_active_work_item_id"):
op.drop_index(
"ix_mounted_draft_workspaces_active_work_item_id",
table_name="mounted_draft_workspaces",
)
if _column_exists(bind, "mounted_draft_workspaces", "latest_reply_message_id"):
op.drop_column("mounted_draft_workspaces", "latest_reply_message_id")
if _column_exists(bind, "mounted_draft_workspaces", "latest_source_message_id"):
op.drop_column("mounted_draft_workspaces", "latest_source_message_id")
if _column_exists(bind, "mounted_draft_workspaces", "active_work_item_id"):
op.drop_column("mounted_draft_workspaces", "active_work_item_id")

View File

@ -66,6 +66,37 @@ from .ai_prompt_registry import (
# Deprecated compatibility aliases kept outside the registry surface. # Deprecated compatibility aliases kept outside the registry surface.
PROMPT_FEISHU_REQUIREMENT_ANALYSIS = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION PROMPT_FEISHU_REQUIREMENT_ANALYSIS = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION
PROMPT_TASK_BREAKDOWN = PROMPT_DECOMPOSE_TASKS PROMPT_TASK_BREAKDOWN = PROMPT_DECOMPOSE_TASKS
PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """
你是 PMFlow 的统一语义规划层。
你的输入是系统整理好的 semantic context packet。你的职责不是执行写操作,而是:
1. 判断用户这句话是否继续当前 topic。
2. 判断当前 workflow 是否继续停留在 plan。
3. 给出 draft patch 建议。
4. 给出要回复给用户的内容。
5. 明确 requested_actions 与是否需要确认。
硬约束:
- 只能依据 packet 中的事实、草稿、recent turns、mode card 判断。
- 不得把推断包装成事实。
- 不得请求 mode card 明确禁止的动作。
- 输出必须是一个 JSON 对象,不要输出 Markdown。
JSON schema:
{
"topic_relation": "continue_current_topic | new_topic | uncertain",
"mode_decision": "stay_plan | ready_confirm | request_execute | answer_only",
"draft_patch": {},
"assistant_reply": "string",
"requires_confirmation": true,
"requested_actions": ["reply.compose"],
"confidence": 0.0,
"uncertainties": []
}
semantic_context_packet:
{semantic_packet}
""".strip()
def build_default_role_prompt_bundle() -> dict: def build_default_role_prompt_bundle() -> dict:
@ -198,6 +229,7 @@ __all__ = [
"PROMPT_AI_PMO_REQUIREMENT_PLAN_QA", "PROMPT_AI_PMO_REQUIREMENT_PLAN_QA",
"PROMPT_EXTERNAL_TREND_QA", "PROMPT_EXTERNAL_TREND_QA",
"PROMPT_INDUSTRY_TREND_QA", "PROMPT_INDUSTRY_TREND_QA",
"PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT",
"PROMPT_REGISTRY", "PROMPT_REGISTRY",
"PROMPT_REGISTRY_MAP", "PROMPT_REGISTRY_MAP",
"get_prompt_registry", "get_prompt_registry",

View File

@ -10,7 +10,9 @@ FEISHU_QA_RESPONSE_STYLE = _compose_prompt(
2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。 2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。
3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。 3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。
4. 若信息不足,必须明确说明缺少什么,最多列 3 项。 4. 若信息不足,必须明确说明缺少什么,最多列 3 项。
5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 5. 可使用 1-3 个轻量 emoji 做分段,但不要花哨,不要影响专业感。
6. 飞书输出优先短段落和清晰换行;如果包含“结论/依据/下一步/信息不足”,要让这些段落一眼可扫读。
7. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
""".strip(), """.strip(),
TEXT_OUTPUT_GUARDRAIL, TEXT_OUTPUT_GUARDRAIL,
) )
@ -21,7 +23,7 @@ PROMPT_FEISHU_DIRECT_QA = _compose_prompt(
""" """
回答要求: 回答要求:
1. 仅基于给定上下文回答,优先使用结构化数据。 1. 仅基于给定上下文回答,优先使用结构化数据。
2. 如果问题涉及项目状态、进展、成员或任务,请优先给出可验证的事实和数字。 2. 如果问题涉及项目状态、进展、成员或任务,请优先引用项目画像、检索证据和用户焦点里的可验证事实与数字。
3. 如果问题涉及选择、建议或判断,直接给出明确结论和 1-2 条理由。 3. 如果问题涉及选择、建议或判断,直接给出明确结论和 1-2 条理由。
4. 信息不足时,直接写“信息不足,缺少:...”并列出关键缺失项。 4. 信息不足时,直接写“信息不足,缺少:...”并列出关键缺失项。
5. 控制在 220 字以内,适合飞书消息阅读。 5. 控制在 220 字以内,适合飞书消息阅读。
@ -30,10 +32,11 @@ PROMPT_FEISHU_DIRECT_QA = _compose_prompt(
当前项目ID:{project_id} 当前项目ID:{project_id}
问题:{question} 问题:{question}
System Snapshot:{system_snapshot} 项目画像:{project_manifest}
User Snapshot:{user_snapshot} 系统摘要:{system_summary}
最近会话:{conversation} 用户焦点:{user_focus}
检索上下文:{context_blocks} 最近会话摘要:{conversation}
检索证据:{context_blocks}
""".strip(), """.strip(),
) )

View File

@ -52,6 +52,21 @@ PROMPT_AI_PMO_REQUIREMENT_PLAN_QA = _compose_prompt(
6. 不能把未发生的创建、入库、执行写成已完成;如果用户没明确要求,不要推进执行。 6. 不能把未发生的创建、入库、执行写成已完成;如果用户没明确要求,不要推进执行。
7. 如果上下文不够,只能说“我先帮你收敛需求”,并指出最关键缺口;不要编造系统已有模块或项目归属。 7. 如果上下文不够,只能说“我先帮你收敛需求”,并指出最关键缺口;不要编造系统已有模块或项目归属。
8. 回复总长度尽量控制在 2-4 句,优先短句、口语化、像产品经理在继续追问,不要写成长文。 8. 回复总长度尽量控制在 2-4 句,优先短句、口语化、像产品经理在继续追问,不要写成长文。
9. 如果识别为“宽泛 IT 项目概念”,默认按五轮收敛:
- 第 1 轮:项目性质
- 第 2 轮:服务对象
- 第 3 轮:技术路线
- 第 4 轮:第一版范围
- 第 5 轮:推进方式
10. 宽泛 IT 项目收敛时,每轮必须给 A/B/C 三个选项;每个选项都要写:
- 适合什么场景
- 推荐技术/能力
- 推荐理由
- 代价或限制
11. 每轮 A/B/C 之后,必须补一句:
“当前我更推荐:X,原因:……”
12. 宽泛 IT 项目收敛优先用项目管理口径,不要只给抽象概念词,不要让用户像在填表。
13. 飞书输出允许使用轻量 emoji 做分段,但整体仍要专业、克制、易扫读。
问题:{question} 问题:{question}
当前项目:{project_id} 当前项目:{project_id}

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from sqlalchemy import Boolean, Float, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR from sqlalchemy import BigInteger, Boolean, Float, ForeignKey, Integer, Text, TIMESTAMP, UniqueConstraint, VARCHAR
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .base import Base from .base import Base
@ -68,6 +68,9 @@ class MountedDraftWorkspace(Base):
draft_payload: 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) required_confirmations: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True)
last_ready_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) last_ready_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True)
active_work_item_id: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True, index=True)
latest_source_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
latest_reply_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
confirm_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) confirm_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)

View File

@ -150,6 +150,20 @@ class MountedAIRunListPayload(BaseModel):
total: int = 0 total: int = 0
class MountedAIDraftItem(BaseModel):
model_config = ConfigDict(extra="ignore")
id: int
topic_workspace_id: int
state: str
draft_version: int
requirement_card: Optional[Any] = None
draft_payload: Optional[Any] = None
required_confirmations: Optional[Any] = None
last_ready_at: Optional[str] = None
confirm_message_id: Optional[str] = None
class MountedAIAgentTraceItem(BaseModel): class MountedAIAgentTraceItem(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
@ -168,10 +182,16 @@ class MountedAIRunDetailPayload(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
item: MountedAIRunItem item: MountedAIRunItem
topic: Optional[MountedAITopicItem] = None
draft: Optional[MountedAIDraftItem] = None
agent_traces: list[MountedAIAgentTraceItem] = Field(default_factory=list) agent_traces: list[MountedAIAgentTraceItem] = Field(default_factory=list)
context_layers_hit: Optional[Any] = None context_layers_hit: Optional[Any] = None
tool_calls: Optional[Any] = None tool_calls: Optional[Any] = None
execution_outcome: Optional[Any] = None execution_outcome: Optional[Any] = None
semantic_packet_summary: Optional[Any] = None
semantic_decision: Optional[Any] = None
semantic_gate_result: Optional[Any] = None
semantic_diff: Optional[Any] = None
class MountedAICutoverStatusPayload(BaseModel): class MountedAICutoverStatusPayload(BaseModel):

View File

@ -92,6 +92,178 @@ class AdaptiveRAGDeepGuardAnswerMixin:
kept.append(text) kept.append(text)
return "\n".join(kept) if kept else "无" return "\n".join(kept) if kept else "无"
@staticmethod
def _truncate_compact_text(text: Optional[str], *, limit: int, empty: str = "无") -> str:
raw = str(text or "").strip()
if not raw:
return empty
if len(raw) <= max(1, int(limit)):
return raw
clipped = raw[: max(1, int(limit) - 8)].rstrip()
return f"{clipped}...(截断)"
@staticmethod
def _split_context_blocks(context_blocks_text: str) -> list[str]:
raw = str(context_blocks_text or "").strip()
if not raw or raw == "无":
return []
return [
str(block).strip()
for block in re.split(r"\n(?=\[(?:\d+|M)\])", raw)
if str(block).strip()
]
@classmethod
def _extract_manifest_block(cls, context_blocks_text: str) -> str:
for block in cls._split_context_blocks(context_blocks_text):
if not block.startswith("[M]"):
continue
lines = [str(line).strip() for line in block.splitlines()]
payload = "\n".join(line for line in lines[1:] if line).strip()
return cls._truncate_compact_text(payload, limit=260)
return "无"
@classmethod
def _build_feishu_direct_project_manifest(
cls,
*,
project_id: Optional[int],
context_blocks_text: str,
user_snapshot: Optional[dict[str, Any]],
) -> str:
manifest_text = cls._extract_manifest_block(context_blocks_text)
if manifest_text != "无":
return manifest_text
bits: list[str] = []
if project_id is not None:
bits.append(f"当前项目ID {int(project_id)}")
projects = user_snapshot.get("projects") if isinstance(user_snapshot, dict) else None
if isinstance(projects, list) and projects:
project_names = [
str(item.get("title") or item.get("name") or item.get("id") or "").strip()
for item in projects[:3]
if isinstance(item, dict)
]
project_names = [name for name in project_names if name]
bits.append(f"关联项目 {len(projects)} 个")
if project_names:
bits.append(f"重点项目:{'、'.join(project_names)}")
return cls._truncate_compact_text(";".join(bits), limit=260)
@classmethod
def _build_feishu_direct_system_summary(cls, system_snapshot: Optional[dict[str, Any]]) -> str:
system_data = system_snapshot if isinstance(system_snapshot, dict) else {}
assistant = system_data.get("assistant_profile") if isinstance(system_data.get("assistant_profile"), dict) else {}
platform = system_data.get("platform_profile") if isinstance(system_data.get("platform_profile"), dict) else {}
backend = platform.get("backend") if isinstance(platform.get("backend"), dict) else {}
module_counts = backend.get("module_counts") if isinstance(backend.get("module_counts"), dict) else {}
feature_catalog = platform.get("feature_catalog") if isinstance(platform.get("feature_catalog"), list) else []
bits: list[str] = []
assistant_name = str(assistant.get("name") or "").strip()
assistant_identity = str(assistant.get("identity") or "").strip()
if assistant_name or assistant_identity:
identity_text = " / ".join(part for part in (assistant_name, assistant_identity) if part)
bits.append(f"助手:{identity_text}")
if module_counts:
top_modules = [
f"{str(key)} {int(value)}"
for key, value in list(module_counts.items())[:4]
if value is not None
]
if top_modules:
bits.append(f"模块统计:{'、'.join(top_modules)}")
if feature_catalog:
feature_names = [
str(item.get("name") or item.get("route") or "").strip()
for item in feature_catalog[:4]
if isinstance(item, dict)
]
feature_names = [name for name in feature_names if name]
bits.append(f"功能目录 {len(feature_catalog)} 个")
if feature_names:
bits.append(f"高频能力:{'、'.join(feature_names)}")
return cls._truncate_compact_text(";".join(bits), limit=320)
@classmethod
def _build_feishu_direct_user_focus(cls, user_snapshot: Optional[dict[str, Any]]) -> str:
user_data = user_snapshot if isinstance(user_snapshot, dict) else {}
current_user = (
user_data.get("current_user")
if isinstance(user_data.get("current_user"), dict)
else user_data.get("user_profile")
if isinstance(user_data.get("user_profile"), dict)
else {}
)
projects = user_data.get("projects") if isinstance(user_data.get("projects"), list) else []
tasks = user_data.get("tasks") if isinstance(user_data.get("tasks"), list) else []
bits: list[str] = []
current_user_name = str(
current_user.get("name")
or current_user.get("display_name")
or current_user.get("username")
or ""
).strip()
if current_user_name:
bits.append(f"当前用户:{current_user_name}")
if projects:
project_names = [
str(item.get("title") or item.get("name") or item.get("id") or "").strip()
for item in projects[:3]
if isinstance(item, dict)
]
project_names = [name for name in project_names if name]
focus_text = f"关联项目 {len(projects)} 个"
if project_names:
focus_text = f"{focus_text}({', '.join(project_names)})"
bits.append(focus_text)
if tasks:
task_names = [
str(item.get("title") or item.get("name") or item.get("id") or "").strip()
for item in tasks[:3]
if isinstance(item, dict)
]
task_names = [name for name in task_names if name]
task_text = f"待关注任务 {len(tasks)} 个"
if task_names:
task_text = f"{task_text}({', '.join(task_names)})"
bits.append(task_text)
return cls._truncate_compact_text(";".join(bits), limit=320)
@classmethod
def _compact_feishu_direct_conversation(cls, conversation_text: str) -> str:
raw = str(conversation_text or "").strip()
if not raw or raw == "无":
return "无"
blocks = [
cls._truncate_compact_text(block, limit=180, empty="")
for block in re.split(r"\n(?=Q:)", raw)
if str(block).strip()
]
compact = "\n".join(block for block in blocks[-3:] if block)
return cls._truncate_compact_text(compact, limit=520)
@classmethod
def _compact_feishu_direct_context_blocks(cls, context_blocks_text: str) -> str:
noise_markers = ("项目列表", "历史项目详情", "assistant_profile", "platform_profile")
kept: list[str] = []
fallback: Optional[str] = None
for block in cls._split_context_blocks(context_blocks_text):
if block.startswith("[M]"):
continue
compact_block = cls._truncate_compact_text(block, limit=220, empty="")
if not compact_block:
continue
fallback = fallback or compact_block
if any(marker in compact_block for marker in noise_markers):
continue
kept.append(compact_block)
if len(kept) >= 2:
break
if not kept and fallback:
kept.append(fallback)
return cls._truncate_compact_text("\n".join(kept), limit=520)
@staticmethod @staticmethod
def _is_ai_pmo_process_entry(asked_via: Optional[str]) -> bool: def _is_ai_pmo_process_entry(asked_via: Optional[str]) -> bool:
channel = str(asked_via or "").strip().lower() channel = str(asked_via or "").strip().lower()
@ -444,6 +616,15 @@ class AdaptiveRAGDeepGuardAnswerMixin:
normalized_asked_via = str(asked_via or "").strip().lower() normalized_asked_via = str(asked_via or "").strip().lower()
if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}: if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}:
project_manifest_text = self._build_feishu_direct_project_manifest(
project_id=project_id,
context_blocks_text=context_blocks_text,
user_snapshot=user_snapshot,
)
system_summary_text = self._build_feishu_direct_system_summary(system_snapshot)
user_focus_text = self._build_feishu_direct_user_focus(user_snapshot)
compact_conversation_text = self._compact_feishu_direct_conversation(conversation_text)
compact_context_blocks_text = self._compact_feishu_direct_context_blocks(context_blocks_text)
scene_key, prompt = ( scene_key, prompt = (
"feishu_direct", "feishu_direct",
await self._render_scene_prompt( await self._render_scene_prompt(
@ -451,10 +632,11 @@ class AdaptiveRAGDeepGuardAnswerMixin:
fallback_template=PROMPT_FEISHU_DIRECT_QA, fallback_template=PROMPT_FEISHU_DIRECT_QA,
project_id=project_id if project_id is not None else "unknown", project_id=project_id if project_id is not None else "unknown",
question=question, question=question,
system_snapshot=system_snapshot_text or "{}", project_manifest=project_manifest_text,
user_snapshot=user_snapshot_text or "{}", system_summary=system_summary_text,
conversation=conversation_text or "无", user_focus=user_focus_text,
context_blocks=context_blocks_text or "无", conversation=compact_conversation_text,
context_blocks=compact_context_blocks_text,
), ),
) )
return self._apply_consultant_mode_preference( return self._apply_consultant_mode_preference(

View File

@ -70,6 +70,7 @@ from ..ai_pmo_intent import (
ROUTE_ACTION_ACK, ROUTE_ACTION_ACK,
ROUTE_ACTION_CLOSE, ROUTE_ACTION_CLOSE,
ROUTE_ACTION_DISCOVER, ROUTE_ACTION_DISCOVER,
ROUTE_PROJECT_CREATE,
ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_CREATE,
ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_DISCOVER,
ROUTE_PROJECT_UPDATE, ROUTE_PROJECT_UPDATE,

View File

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common from .... import ai_pmo_kernel_common_core as _common
from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items(): for _name, _value in vars(_common).items():
if not _name.startswith("__"): if not _name.startswith("__"):
@ -11,6 +10,42 @@ for _name, _value in vars(_common).items():
class KernelIngressRenderMixin: class KernelIngressRenderMixin:
@staticmethod
def _build_semantic_bundle_compat(
*,
intent: CanonicalIntent,
workflow_route: Optional[str],
source_system: Optional[str],
message_text: str,
) -> dict[str, Any]:
metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
diagnostics: dict[str, Any] = {}
for key in (
"message_intent",
"review_framework",
"consultant_mode_preference",
"force_thinking_mode",
"advise_subtype",
"topic_switch",
):
value = metadata.get(key)
if value not in (None, ""):
diagnostics[key] = value
if str(intent.name or "").strip():
diagnostics["intent_name"] = str(intent.name or "").strip()
if str(workflow_route or "").strip():
diagnostics["workflow_route"] = str(workflow_route or "").strip()
primary_workflow = "query_flow" if str(workflow_route or "").strip().startswith("query.") else "action_flow"
return {
"bundle_id": None,
"semantic_version": "compat-direct-metadata-v1",
"source_system": str(source_system or "unknown"),
"primary_workflow": primary_workflow,
"message_gate": "needs_confirmation" if bool(metadata.get("requires_confirmation")) else "auto_execute",
"diagnostics": diagnostics,
"raw_message_excerpt": re.sub(r"\s+", " ", str(message_text or "")).strip()[:240],
}
def _build_capture_result( def _build_capture_result(
self, self,
*, *,
@ -94,18 +129,16 @@ class KernelIngressRenderMixin:
else None else None
) )
if not isinstance(semantic_bundle, dict): if not isinstance(semantic_bundle, dict):
semantic_bundle = WorkflowSemanticPlanner.plan_from_intent( semantic_bundle = self._build_semantic_bundle_compat(
intent_name=str(intent.name or ""), intent=intent,
message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""), workflow_route=workflow_route,
source_system=str(getattr(work_item, "source_system", "") or "unknown"), source_system=str(getattr(work_item, "source_system", "") or "unknown"),
workflow_route=str(workflow_route or ""), message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""),
project_id=_safe_int(work_item.project_id), )
metadata=intent.metadata if isinstance(intent.metadata, dict) else {},
).to_dict()
result["semantic_bundle"] = semantic_bundle result["semantic_bundle"] = semantic_bundle
result["semantic_bundle_id"] = str(semantic_bundle.get("bundle_id") or "").strip() or None result["semantic_bundle_id"] = str(semantic_bundle.get("bundle_id") or "").strip() or None
result["semantic_version"] = str(semantic_bundle.get("semantic_version") or "").strip() or None result["semantic_version"] = str(semantic_bundle.get("semantic_version") or "").strip() or None
result["semantic_source"] = "workflow_semantic_planner" result["semantic_source"] = "direct_metadata_compat"
if str(render.get("template_key") or "").strip().lower() == "draft_pending_confirm": if str(render.get("template_key") or "").strip().lower() == "draft_pending_confirm":
payload = render.get("payload") if isinstance(render.get("payload"), dict) else {} payload = render.get("payload") if isinstance(render.get("payload"), dict) else {}
result["draft_version"] = _safe_int(payload.get("draft_version")) or _safe_int(getattr(work_item, "draft_version", None)) result["draft_version"] = _safe_int(payload.get("draft_version")) or _safe_int(getattr(work_item, "draft_version", None))

View File

@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common from .... import ai_pmo_kernel_common_core as _common
from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items(): for _name, _value in vars(_common).items():
if not _name.startswith("__"): if not _name.startswith("__"):
@ -199,26 +198,6 @@ class KernelIntentLLMMixin:
if structured_route is not None if structured_route is not None
else bool(payload.get("requires_confirmation")) else bool(payload.get("requires_confirmation"))
), ),
"semantic_bundle": WorkflowSemanticPlanner.plan_from_intent(
intent_name=intent_name,
message_text=message_text,
source_system="ai_pmo_ingress",
workflow_route=(
structured_route.key
if structured_route is not None
else LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER)
),
project_id=project_id,
metadata={
**payload,
"confidence": confidence,
"requires_confirmation": (
structured_route.requires_confirmation
if structured_route is not None
else bool(payload.get("requires_confirmation"))
),
},
).to_dict(),
}, },
) )

View File

@ -11,6 +11,26 @@ for _name, _value in vars(_common).items():
class KernelIntentResolveMixin: class KernelIntentResolveMixin:
_QUERY_FOLLOWUP_TOKENS = (
"需求",
"mvp",
"方案",
"对应",
"这个",
"它",
"给我",
"补充",
"展开",
"细化",
"重答",
"重新回答",
"高级模型",
"更强模型",
"分析",
"评审",
"怎么做",
)
@staticmethod @staticmethod
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool: def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
if not isinstance(last_active_at, datetime): if not isinstance(last_active_at, datetime):
@ -18,6 +38,13 @@ class KernelIntentResolveMixin:
delta = _utc_now_naive() - last_active_at delta = _utc_now_naive() - last_active_at
return 0 <= delta.total_seconds() <= float(window_seconds) return 0 <= delta.total_seconds() <= float(window_seconds)
@classmethod
def _looks_like_query_scene_followup(cls, message_text: str) -> bool:
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
return any(token in normalized for token in cls._QUERY_FOLLOWUP_TOKENS)
@classmethod @classmethod
def _should_inherit_recent_feishu_session_context( def _should_inherit_recent_feishu_session_context(
cls, cls,
@ -27,6 +54,8 @@ class KernelIntentResolveMixin:
) -> bool: ) -> bool:
if session is None: if session is None:
return False return False
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
return False
sender = str(getattr(session, "sender", "") or "").strip().lower() sender = str(getattr(session, "sender", "") or "").strip().lower()
if not sender.startswith("feishu:"): if not sender.startswith("feishu:"):
return False return False
@ -37,28 +66,7 @@ class KernelIntentResolveMixin:
return False return False
if not str(getattr(session, "last_intent", "") or "").strip(): if not str(getattr(session, "last_intent", "") or "").strip():
return False return False
if WorkflowSemanticPlanner._derive_query_scene_metadata(message_text): return cls._looks_like_query_scene_followup(message_text)
return True
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
followup_tokens = (
"需求",
"mvp",
"方案",
"对应",
"这个",
"它",
"给我",
"补充",
"展开",
"细化",
"重答",
"重新回答",
"高级模型",
"更强模型",
)
return any(token in normalized for token in followup_tokens)
async def _resolve_intent( async def _resolve_intent(
self, self,
@ -101,29 +109,6 @@ class KernelIntentResolveMixin:
) )
return inherited, "session_inherited" return inherited, "session_inherited"
if WorkflowSemanticPlanner.looks_like_signal_source_request(message_text):
project_id = _safe_int(world_model.get("resolved_project_id"))
if project_id is None and inherit_session_context and session is not None:
project_id = _safe_int(getattr(session, "current_project_id", None))
return self._build_canonical_intent(
intent_name=INTENT_SIGNAL_SOURCE_ONBOARD,
message_text=message_text,
project_id=project_id,
confidence=0.92,
metadata={
"context_source": "semantic_signal_source_locked",
"source_request": True,
"semantic_bundle": WorkflowSemanticPlanner.plan_from_intent(
intent_name=INTENT_SIGNAL_SOURCE_ONBOARD,
message_text=message_text,
source_system="ai_pmo_ingress",
workflow_route=ROUTE_SIGNAL_SOURCE_ONBOARD,
project_id=project_id,
metadata={"confidence": 0.92, "requires_confirmation": False},
).to_dict(),
},
), "rule_signal_source_onboard"
# 模型唯一入口:不再使用规则/正则意图分流。 # 模型唯一入口:不再使用规则/正则意图分流。
if not self._intent_llm_enabled(): if not self._intent_llm_enabled():
raise RuntimeError("intent_model_only_mode_requires_llm_enabled") raise RuntimeError("intent_model_only_mode_requires_llm_enabled")

View File

@ -110,6 +110,7 @@ class KernelWorldModelSessionMixin:
source_message_id: Optional[str] = None, source_message_id: Optional[str] = None,
thread_context: Optional[dict[str, Any]] = None, thread_context: Optional[dict[str, Any]] = None,
current_project_id: Optional[int], current_project_id: Optional[int],
clear_current_project_id: bool = False,
last_intent: Optional[str], last_intent: Optional[str],
last_message_text: Optional[str], last_message_text: Optional[str],
) -> None: ) -> None:
@ -122,7 +123,9 @@ class KernelWorldModelSessionMixin:
source_message_id=source_message_id, source_message_id=source_message_id,
thread_context=thread_context, thread_context=thread_context,
) )
if current_project_id is not None: if clear_current_project_id:
row.current_project_id = None
elif current_project_id is not None:
row.current_project_id = int(current_project_id) row.current_project_id = int(current_project_id)
row.last_intent = (str(last_intent or "").strip()[:64] or None) row.last_intent = (str(last_intent or "").strip()[:64] or None)
trimmed_message = str(last_message_text or "").strip() trimmed_message = str(last_message_text or "").strip()

View File

@ -209,6 +209,30 @@ class KernelIngressFlowAutoMixin:
channel_ref=channel_ref, channel_ref=channel_ref,
) )
log_perf_fn("TypedExecutor.execute", stage_started_at) log_perf_fn("TypedExecutor.execute", stage_started_at)
if self._should_promote_requirement_plan_draft(
intent=intent,
execution_result=execution_result,
):
stage_started_at = time.perf_counter()
capture_result = await self._promote_requirement_plan_draft_confirmation(
work_item=work_item,
intent=intent,
execution_result=execution_result,
world_model=world_model,
decision_payload=decision_payload,
decision_trace=decision_trace,
decision_row=decision_row,
source_system=source_system,
source_message_id=source_message_id,
message_text=message_text,
sender=sender,
ingest_event=ingest_event,
started=started,
session=session,
resolved_project_for_session=resolved_project_for_session,
)
log_perf_fn("_promote_requirement_plan_draft_confirmation", stage_started_at)
return capture_result
verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model) verification = await self._verify_execution(intent=intent, execution_result=execution_result, world_model=world_model)
if isinstance(execution_result, dict): if isinstance(execution_result, dict):
execution_result.setdefault("decision_trace", decision_trace) execution_result.setdefault("decision_trace", decision_trace)
@ -405,6 +429,262 @@ class KernelIngressFlowAutoMixin:
return str(getattr(workflow_route, "action", "") or "") == ROUTE_ACTION_CREATE return str(getattr(workflow_route, "action", "") or "") == ROUTE_ACTION_CREATE
return True return True
@staticmethod
def _should_promote_requirement_plan_draft(
*,
intent: CanonicalIntent,
execution_result: Any,
) -> bool:
if intent.name != INTENT_QUERY or not isinstance(execution_result, dict):
return False
artifact = execution_result.get("draft_artifact")
if not isinstance(artifact, dict):
return False
return bool(artifact.get("promote_to_confirmation"))
async def _promote_requirement_plan_draft_confirmation(
self,
*,
work_item: Any,
intent: CanonicalIntent,
execution_result: dict[str, Any],
world_model: dict[str, Any],
decision_payload: dict[str, Any],
decision_trace: dict[str, Any],
decision_row: Any,
source_system: str,
source_message_id: str,
message_text: str,
sender: str,
ingest_event: Any,
started: float,
session: ConversationSession,
resolved_project_for_session: Optional[int],
) -> dict[str, Any]:
from ....models import MountedDraftWorkspace, MountedTopicWorkspace
artifact = execution_result.get("draft_artifact") if isinstance(execution_result, dict) else {}
draft_workspace_id = _safe_int((artifact or {}).get("draft_workspace_id"))
if draft_workspace_id is None:
return {
"handled": False,
"status": "failed_safe",
"error": "requirement_plan_draft_missing_workspace",
}
draft_row = (
await self.db.execute(
select(MountedDraftWorkspace).where(
and_(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedDraftWorkspace.id == int(draft_workspace_id),
)
)
)
).scalar_one_or_none()
if draft_row is None:
return {
"handled": False,
"status": "failed_safe",
"error": "requirement_plan_draft_not_found",
}
topic_row = (
await self.db.execute(
select(MountedTopicWorkspace).where(
and_(
MountedTopicWorkspace.tenant_id == int(self.tenant_id),
MountedTopicWorkspace.id == int(draft_row.topic_workspace_id),
)
)
)
).scalar_one_or_none()
if topic_row is None:
return {
"handled": False,
"status": "failed_safe",
"error": "requirement_plan_topic_not_found",
}
draft_payload = draft_row.draft_payload if isinstance(draft_row.draft_payload, dict) else {}
if not draft_payload:
draft_payload = (
artifact.get("draft_payload")
if isinstance(artifact.get("draft_payload"), dict)
else {}
)
draft_text = str((artifact or {}).get("draft_text") or draft_payload.get("draft_text") or execution_result.get("answer") or "").strip()
draft_version = _safe_int((artifact or {}).get("draft_version")) or int(draft_row.draft_version or 0) or 1
work_item.intent = ROUTE_PROJECT_CREATE
work_item.state = STATE_DRAFT_PENDING_CONFIRM
work_item.draft_content = draft_payload
work_item.draft_version = int(draft_version)
work_item.draft_expires_at = None
work_item.confirm_message_id = None
work_item.execution_result = {
**(execution_result if isinstance(execution_result, dict) else {}),
"status": "draft_pending_confirm",
"workflow_route": ROUTE_PROJECT_CREATE,
"workflow_action": ROUTE_ACTION_CREATE,
"source_workflow_route": str((artifact or {}).get("source_workflow_route") or ROUTE_QUERY_ANSWER),
"draft": draft_payload,
"draft_version": int(draft_version),
"draft_artifact": {
**(artifact if isinstance(artifact, dict) else {}),
"active_work_item_id": int(work_item.id),
},
}
work_item.reply_template_key = "draft_pending_confirm"
draft_row.state = STATE_DRAFT_PENDING_CONFIRM
draft_row.draft_version = int(draft_version)
draft_row.draft_payload = draft_payload
draft_row.active_work_item_id = int(work_item.id)
draft_row.latest_source_message_id = str(source_message_id or "").strip()[:128] or None
draft_row.latest_reply_message_id = None
draft_row.confirm_message_id = None
draft_row.last_ready_at = _utc_now_naive()
topic_row.mode = "plan"
topic_row.status = "active"
topic_row.plan_status = STATE_DRAFT_PENDING_CONFIRM
topic_row.pending_actions = ["confirm_requirement_draft"]
topic_row.open_questions = []
topic_row.readiness_score = 1.0
topic_row.last_message_id = str(source_message_id or "").strip()[:128] or topic_row.last_message_id
topic_row.metadata_json = {
**(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}),
"plan_mode": True,
"plan_topic_action": "draft_pending_confirm",
"active_draft_work_item_id": int(work_item.id),
"active_draft_version": int(draft_version),
}
verification = {"verified": False, "issues": [STATE_DRAFT_PENDING_CONFIRM]}
work_item.review_summary = await self.review_service.review_work_item(
work_item=work_item,
intent_name=INTENT_PROJECT_CREATE,
execution_result=work_item.execution_result,
world_model=world_model,
verification=verification,
simulation=decision_payload["simulation"],
)
await self._maybe_emit_risk_cadence_snapshot(
project_id=_safe_int(work_item.project_id),
review_summary=work_item.review_summary,
)
decision_row.execution_result = work_item.execution_result
decision_row.final_outcome = STATE_DRAFT_PENDING_CONFIRM
operation_code_for_log = "project.create"
await self._upsert_operation_execution_ledger(
work_item_id=int(work_item.id),
operation_code=operation_code_for_log,
source_message_id=source_message_id,
project_id=_safe_int(work_item.project_id),
status=self._normalize_execution_ledger_status(
work_item_state=STATE_DRAFT_PENDING_CONFIRM,
execution_status=str(work_item.execution_result.get("status") or ""),
),
payload={
"work_item_id": int(work_item.id),
"workflow_route": ROUTE_PROJECT_CREATE,
"workflow_action": ROUTE_ACTION_CREATE,
"source_system": source_system,
"source_message_id": source_message_id,
"draft_artifact": artifact if isinstance(artifact, dict) else {},
},
execution_result={
**work_item.execution_result,
"work_item_state": STATE_DRAFT_PENDING_CONFIRM,
},
)
render = {
"receipt_phase": RECEIPT_PHASE_PENDING,
"template_key": "draft_pending_confirm",
"payload": {
"intent": INTENT_PROJECT_CREATE,
"draft_version": int(draft_version),
"draft_expires_at": None,
},
"text": draft_text or "已生成草稿,请确认后入库。",
}
capture_intent = CanonicalIntent(
name=INTENT_PROJECT_CREATE,
confidence=max(float(intent.confidence or 0.0), 0.9),
project_id=_safe_int(work_item.project_id) or resolved_project_for_session,
signal_title="",
signal_summary=str(message_text or "")[:300],
metadata={
**(intent.metadata if isinstance(intent.metadata, dict) else {}),
"workflow_route": ROUTE_PROJECT_CREATE,
"workflow_family": ROUTE_FAMILY_PROJECT_CREATE,
"workflow_action": ROUTE_ACTION_CREATE,
"requires_confirmation": True,
"resumed_from": ROUTE_QUERY_ANSWER,
},
)
await self._project_draft_pending_confirm(
work_item=work_item,
message_text=message_text,
draft_text=str(render["text"]),
intent=capture_intent,
)
direct_reply_required = await self._claim_reply_receipt(
work_item_id=int(work_item.id),
source_system=source_system,
source_message_id=source_message_id,
receipt_phase=str(render["receipt_phase"]),
template_key=str(render["template_key"]),
payload=render["payload"],
)
ingest_event.status = "done"
ingest_event.processed_at = _utc_now_naive()
await self._write_processing_log(
source_message_id=source_message_id,
work_item=work_item,
message_text=message_text,
route=f"ai_pmo.{STATE_DRAFT_PENDING_CONFIRM}",
operation_code=operation_code_for_log,
operation_result=work_item.execution_result,
processing_status="success",
duration_ms=int((time.perf_counter() - started) * 1000),
)
final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session
await self._update_session(
tenant_id=self.tenant_id,
sender=sender,
session=session,
current_project_id=final_project_id,
last_intent=STATE_DRAFT_PENDING_CONFIRM,
last_message_text=message_text[:200],
)
try:
await self.db.commit()
except Exception as 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)),
error_message=str(exc),
source_system=source_system,
source_message_id=source_message_id,
)
raise
return self._build_capture_result(
work_item=work_item,
intent=capture_intent,
execution_result=work_item.execution_result,
render=render,
direct_reply_required=direct_reply_required,
status="captured",
classification="command",
)
@staticmethod @staticmethod
def _build_capacity_warning_text( def _build_capacity_warning_text(
*, *,

View File

@ -2,6 +2,11 @@ from __future__ import annotations
from .shared import * from .shared import *
from app.utils.thread_context import build_feishu_conversation_scope_key from app.utils.thread_context import build_feishu_conversation_scope_key
from app.services.ai_pmo.semantic_context import (
SemanticContextAssembler,
SemanticDecisionGatekeeper,
)
from app.services.ai_pmo.semantic_context.semantic_decision_service import SemanticDecisionService
from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
from app.services.ai_pmo.workflow_semantic_planner.constants import ( from app.services.ai_pmo.workflow_semantic_planner.constants import (
QUERY_SCENE_BUSINESS_REVIEW, QUERY_SCENE_BUSINESS_REVIEW,
@ -27,6 +32,47 @@ class KernelIngressFlowEntryMixin:
QUERY_SCENE_CORRECTION_ESCALATION, QUERY_SCENE_CORRECTION_ESCALATION,
QUERY_SCENE_REQUIREMENT_PLAN, QUERY_SCENE_REQUIREMENT_PLAN,
}) })
_QUERY_SCENE_TOKEN_RULES = (
(
QUERY_SCENE_CORRECTION_ESCALATION,
{
"consultant_mode_preference": "challenge",
"force_thinking_mode": "deep",
},
("不对", "错了", "答错", "纠正", "更正", "重新回答", "重答", "高级模型", "更强模型"),
),
(
QUERY_SCENE_BUSINESS_REVIEW,
{
"review_framework": "five_lenses",
"consultant_mode_preference": "clarify",
},
("分析", "拆解", "评审", "评估", "复盘", "诊断", "怎么做", "mvp", "需求"),
),
(
QUERY_SCENE_BUSINESS_REVIEW,
{
"consultant_mode_preference": "advise",
"advise_subtype": "assignee",
},
("谁来做", "谁负责", "安排谁", "推荐谁", "负责人"),
),
(
QUERY_SCENE_REQUIREMENT_PLAN,
{
"consultant_mode_preference": "clarify",
"topic_switch": "new",
},
("新需求", "新项目", "做个", "想做个", "准备做", "创建需求"),
),
(
QUERY_SCENE_REQUIREMENT_PLAN,
{
"consultant_mode_preference": "clarify",
},
("需求", "方案", "范围", "排期", "补充", "细化", "展开", "mvp"),
),
)
@classmethod @classmethod
def _extract_route_scene_metadata(cls, route_metadata: Optional[dict[str, Any]]) -> dict[str, str]: def _extract_route_scene_metadata(cls, route_metadata: Optional[dict[str, Any]]) -> dict[str, str]:
@ -36,16 +82,26 @@ class KernelIngressFlowEntryMixin:
value = metadata.get(key) value = metadata.get(key)
if value not in (None, ""): if value not in (None, ""):
scene_metadata[key] = str(value).strip() scene_metadata[key] = str(value).strip()
semantic_diagnostics = metadata.get("semantic_bundle")
if isinstance(semantic_diagnostics, dict):
semantic_diagnostics = semantic_diagnostics.get("diagnostics")
if isinstance(semantic_diagnostics, dict):
for key in cls._RAG_QUERY_SCENE_KEYS:
value = semantic_diagnostics.get(key)
if value not in (None, "") and key not in scene_metadata:
scene_metadata[key] = str(value).strip()
return scene_metadata return scene_metadata
@classmethod
def _derive_query_scene_metadata(cls, text: str) -> dict[str, Any]:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return {}
if any(token in normalized for token in ("最近怎么样", "在吗", "你好", "哈哈", "hi", "hello")):
return {
"message_intent": "casual_chat",
"consultant_mode_preference": "brainstorm",
}
for message_intent, extra_fields, tokens in cls._QUERY_SCENE_TOKEN_RULES:
if any(token in normalized for token in tokens):
return {
"message_intent": message_intent,
**extra_fields,
}
return {}
@classmethod @classmethod
def _looks_like_mixed_candidate(cls, *, text: str) -> bool: def _looks_like_mixed_candidate(cls, *, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower() normalized = re.sub(r"\s+", "", str(text or "")).lower()
@ -73,30 +129,12 @@ class KernelIngressFlowEntryMixin:
semantic_bundle: Optional[dict[str, Any]], semantic_bundle: Optional[dict[str, Any]],
) -> dict[str, Any]: ) -> dict[str, Any]:
context = rag_request_context if isinstance(rag_request_context, dict) else {} context = rag_request_context if isinstance(rag_request_context, dict) else {}
semantic_diagnostics = ( derived = cls._derive_query_scene_metadata(message_text)
semantic_bundle.get("diagnostics")
if isinstance(semantic_bundle, dict) and isinstance(semantic_bundle.get("diagnostics"), dict)
else {}
)
semantic_primary_workflow = (
str(semantic_bundle.get("primary_workflow") or "").strip().lower()
if isinstance(semantic_bundle, dict)
else ""
)
derived = WorkflowSemanticPlanner._derive_query_scene_metadata(message_text)
scene_metadata: dict[str, Any] = { scene_metadata: dict[str, Any] = {
key: value key: value
for key, value in derived.items() for key, value in derived.items()
if key in cls._RAG_QUERY_SCENE_KEYS and value not in (None, "") if key in cls._RAG_QUERY_SCENE_KEYS and value not in (None, "")
} }
if str(source_system or "").strip().lower() == "feishu" and semantic_primary_workflow == "query_flow":
for key in cls._RAG_QUERY_SCENE_KEYS:
value = semantic_diagnostics.get(key)
if value in (None, ""):
continue
text = str(value).strip()
if text:
scene_metadata[key] = text.lower() if key != "review_framework" else text
for key in cls._RAG_QUERY_SCENE_KEYS: for key in cls._RAG_QUERY_SCENE_KEYS:
value = context.get(key) value = context.get(key)
if value in (None, ""): if value in (None, ""):
@ -115,20 +153,14 @@ class KernelIngressFlowEntryMixin:
semantic_bundle: Optional[dict[str, Any]], semantic_bundle: Optional[dict[str, Any]],
scene_metadata: dict[str, Any], scene_metadata: dict[str, Any],
) -> bool: ) -> bool:
_ = semantic_bundle
message_intent = str(scene_metadata.get("message_intent") or "").strip().lower() message_intent = str(scene_metadata.get("message_intent") or "").strip().lower()
if message_intent not in cls._RAG_QUERY_SCENES: if message_intent not in cls._RAG_QUERY_SCENES:
return False return False
if isinstance(rag_request_context, dict): if isinstance(rag_request_context, dict):
action_mode = str(rag_request_context.get("action_mode") or "auto").strip().lower() or "auto" action_mode = str(rag_request_context.get("action_mode") or "auto").strip().lower() or "auto"
return action_mode not in {"write", "read_write"} return action_mode not in {"write", "read_write"}
if str(source_system or "").strip().lower() == "feishu": return str(source_system or "").strip().lower() == "feishu"
primary_workflow = (
str(semantic_bundle.get("primary_workflow") or "").strip().lower()
if isinstance(semantic_bundle, dict)
else ""
)
return primary_workflow == "query_flow" or message_intent in cls._RAG_QUERY_SCENES
return False
def _coerce_intent_for_query_scene( def _coerce_intent_for_query_scene(
self, self,
@ -140,39 +172,27 @@ class KernelIngressFlowEntryMixin:
semantic_bundle: Optional[dict[str, Any]], semantic_bundle: Optional[dict[str, Any]],
world_model: dict[str, Any], world_model: dict[str, Any],
) -> CanonicalIntent: ) -> CanonicalIntent:
effective_semantic_bundle = (
semantic_bundle
if isinstance(semantic_bundle, dict) and semantic_bundle
else (intent.metadata or {}).get("semantic_bundle")
if isinstance((intent.metadata or {}).get("semantic_bundle"), dict)
else None
)
scene_metadata = self._build_forced_query_scene_metadata( scene_metadata = self._build_forced_query_scene_metadata(
source_system=source_system, source_system=source_system,
message_text=message_text, message_text=message_text,
rag_request_context=rag_request_context, rag_request_context=rag_request_context,
semantic_bundle=effective_semantic_bundle, semantic_bundle=semantic_bundle,
) )
intent_scene_metadata = self._extract_route_scene_metadata(intent.metadata)
if intent_scene_metadata:
scene_metadata = {
**scene_metadata,
**intent_scene_metadata,
}
if not self._should_force_query_scene( if not self._should_force_query_scene(
source_system=source_system, source_system=source_system,
rag_request_context=rag_request_context, rag_request_context=rag_request_context,
semantic_bundle=effective_semantic_bundle, semantic_bundle=semantic_bundle,
scene_metadata=scene_metadata, scene_metadata=scene_metadata,
): ):
return intent return intent
project_id = _safe_int(intent.project_id) or _safe_int(world_model.get("resolved_project_id")) project_id = _safe_int(intent.project_id) or _safe_int(world_model.get("resolved_project_id"))
semantic_bundle = WorkflowSemanticPlanner.plan_from_intent(
intent_name=INTENT_QUERY,
message_text=message_text,
source_system="ai_pmo_ingress",
workflow_route=ROUTE_QUERY_ANSWER,
project_id=project_id,
metadata={
"confidence": max(float(intent.confidence or 0.0), 0.86),
**scene_metadata,
},
).to_dict()
metadata = { metadata = {
**(intent.metadata or {}), **(intent.metadata or {}),
**scene_metadata, **scene_metadata,
@ -181,7 +201,6 @@ class KernelIngressFlowEntryMixin:
"workflow_family": ROUTE_FAMILY_QUERY, "workflow_family": ROUTE_FAMILY_QUERY,
"workflow_action": ROUTE_ACTION_ANSWER, "workflow_action": ROUTE_ACTION_ANSWER,
"requires_confirmation": False, "requires_confirmation": False,
"semantic_bundle": semantic_bundle,
} }
pretty = re.sub(r"\s+", " ", str(message_text or "")).strip() pretty = re.sub(r"\s+", " ", str(message_text or "")).strip()
return CanonicalIntent( return CanonicalIntent(
@ -466,6 +485,91 @@ class KernelIngressFlowEntryMixin:
error=str(runtime_exc), error=str(runtime_exc),
) )
async def _safe_record_semantic_shadow(
*,
latest_user_message: str,
workflow_route: str,
route_metadata: Optional[dict[str, Any]],
operator_user_id: Optional[int],
) -> None:
if mounted_tracking is None:
return
packet_summary: dict[str, Any] | None = None
try:
packet = await SemanticContextAssembler(self.db).assemble(
tenant_id=int(self.tenant_id),
operator_user_id=int(operator_user_id or 0),
topic_workspace_id=int(mounted_tracking.topic_workspace.id),
latest_user_message=latest_user_message,
)
packet_summary = {
"packet_version": packet.packet_version,
"topic_key": packet.topic.topic_key,
"workflow_mode": packet.mode_card.workflow_mode,
"recent_turn_count": len(packet.thread.recent_turns),
"draft_version": packet.draft.draft_version if packet.draft is not None else None,
"thread_summary": packet.thread.thread_summary,
"recent_turns": [turn.model_dump(mode="json") for turn in packet.thread.recent_turns],
"open_questions": list(packet.thread.open_questions),
"mode_card": packet.mode_card.model_dump(mode="json"),
"topic": packet.topic.model_dump(mode="json"),
"draft": packet.draft.model_dump(mode="json") if packet.draft is not None else None,
}
decision = await SemanticDecisionService(llm_service).decide(packet=packet)
gate_result = SemanticDecisionGatekeeper.evaluate(
mode_card=packet.mode_card,
decision=decision,
)
await mounted_runtime.record_semantic_shadow(
tracking=mounted_tracking,
source_message_preview=latest_user_message,
semantic_packet_summary=packet_summary,
semantic_decision=decision.model_dump(mode="json"),
semantic_gate_result=gate_result.model_dump(mode="json"),
semantic_diff={
"changed": (
str(decision.mode_decision or "").strip().lower()
!= str(mounted_tracking.topic_workspace.plan_status or "").strip().lower()
),
"legacy_route": str(workflow_route or "").strip() or None,
"legacy_message_intent": str((route_metadata or {}).get("message_intent") or "").strip() or None,
"shadow_topic_relation": decision.topic_relation,
"shadow_mode_decision": decision.mode_decision,
},
)
except Exception as runtime_exc:
logger.warning(
"mounted_ai_runtime_semantic_shadow_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),
)
try:
await mounted_runtime.record_semantic_shadow(
tracking=mounted_tracking,
source_message_preview=latest_user_message,
semantic_packet_summary=packet_summary,
semantic_decision=None,
semantic_gate_result=None,
semantic_diff={
"changed": False,
"legacy_route": str(workflow_route or "").strip() or None,
"legacy_message_intent": str((route_metadata or {}).get("message_intent") or "").strip() or None,
"shadow_error": str(runtime_exc),
},
status="failed",
failure_reason=str(runtime_exc),
)
except Exception as persist_exc:
logger.warning(
"mounted_ai_runtime_semantic_shadow_persist_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(persist_exc),
)
def _resolve_specialist_agent_name( def _resolve_specialist_agent_name(
*, *,
workflow_route: str, workflow_route: str,
@ -541,8 +645,12 @@ class KernelIngressFlowEntryMixin:
ingest_event=ingest_event, ingest_event=ingest_event,
ingress_options=ingress_options, ingress_options=ingress_options,
) )
if self._should_route_to_draft_confirmation( if await self._should_route_to_draft_confirmation_session(
session=session, session=session,
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
message_text=clean_text, message_text=clean_text,
ingress_options=ingress_options, ingress_options=ingress_options,
): ):
@ -603,6 +711,26 @@ class KernelIngressFlowEntryMixin:
) )
await _safe_finalize_mounted_runtime(result=draft_capture) await _safe_finalize_mounted_runtime(result=draft_capture)
return draft_capture return draft_capture
await self._maybe_reset_session_project_context_for_new_requirement(
session=session,
sender=sender,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
source_message_id=synthetic_message_id,
thread_context=thread_context,
message_text=clean_text,
)
orphan_requirement_followup_result = await self._maybe_handle_orphan_requirement_followup(
source_system=source_system,
source_message_id=synthetic_message_id,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
ingress_options=ingress_options,
message_text=clean_text,
)
if orphan_requirement_followup_result is not None:
await _safe_finalize_mounted_runtime(result=orphan_requirement_followup_result)
return orphan_requirement_followup_result
mixed_meta: dict[str, Any] = {} mixed_meta: dict[str, Any] = {}
if not self._is_short_confirmation_message( if not self._is_short_confirmation_message(
message_text=clean_text, message_text=clean_text,
@ -706,6 +834,12 @@ class KernelIngressFlowEntryMixin:
workflow_route.metadata = route_metadata workflow_route.metadata = route_metadata
perf_intent_name = workflow_route.key perf_intent_name = workflow_route.key
_log_perf("_resolve_intent", stage_started_at) _log_perf("_resolve_intent", stage_started_at)
await _safe_record_semantic_shadow(
latest_user_message=clean_text,
workflow_route=workflow_route.key,
route_metadata=route_metadata,
operator_user_id=_safe_int(getattr(operator, "id", None)),
)
if intent_context_source: if intent_context_source:
world_model["context_source"] = intent_context_source world_model["context_source"] = intent_context_source
work_item.context_snapshot = world_model work_item.context_snapshot = world_model
@ -1213,6 +1347,134 @@ class KernelIngressFlowEntryMixin:
and WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text) and WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text)
) )
async def _should_route_to_draft_confirmation_session(
self,
*,
session: ConversationSession,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
message_text: str,
ingress_options: Optional[dict[str, Any]],
) -> bool:
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
return False
if self._should_route_to_draft_confirmation(
session=session,
message_text=message_text,
ingress_options=ingress_options,
):
return True
from ..draft_confirm_handler import DraftConfirmHandler
action, _ = DraftConfirmHandler.parse_action_from_text(message_text=message_text)
if action == DraftConfirmHandler._ACTION_UNKNOWN and not WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text):
return False
handler = DraftConfirmHandler(self)
return await handler.has_active_draft_session(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=self._extract_thread_context_from_ingress_options(ingress_options),
)
@staticmethod
def _build_orphan_requirement_followup_reply() -> str:
return "当前没有进行中的 A/B/C 收敛轮次,也没有待确认草稿。你先发一句新需求,我按新的 topic 接着收。"
async def _maybe_reset_session_project_context_for_new_requirement(
self,
*,
session: Optional[ConversationSession],
sender: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
source_message_id: Optional[str],
thread_context: Optional[dict[str, Any]],
message_text: str,
) -> bool:
if session is None:
return False
if not WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
return False
if _safe_int(getattr(session, "current_project_id", None)) is None:
return False
await self._update_session(
tenant_id=self.tenant_id,
sender=sender,
session=session,
source_chat_id=channel_ref,
source_sender_open_id=source_sender_open_id,
source_message_id=source_message_id,
thread_context=thread_context,
current_project_id=None,
clear_current_project_id=True,
last_intent=str(getattr(session, "last_intent", "") or "").strip() or None,
last_message_text=str(getattr(session, "last_message_text", "") or "").strip() or None,
)
return True
async def _maybe_handle_orphan_requirement_followup(
self,
*,
source_system: str,
source_message_id: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
ingress_options: Optional[dict[str, Any]],
message_text: str,
) -> Optional[dict[str, Any]]:
if not WorkflowSemanticPlanner.looks_like_low_context_requirement_followup(message_text):
return None
checker = getattr(self, "_has_active_requirement_plan_workspace", None)
if not callable(checker):
return None
thread_context = self._extract_thread_context_from_ingress_options(ingress_options)
is_feishu_source = str(source_system or "").strip().lower() == "feishu"
conversation_key = (
build_feishu_conversation_scope_key(
source_chat_id=channel_ref,
source_sender_open_id=source_sender_open_id,
sender=None,
)
if is_feishu_source
else source_message_id
)
has_active_requirement_plan_workspace = await checker(
source_system=source_system,
source_message_id=source_message_id,
source_chat_id=channel_ref,
sender=None,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
conversation_key=conversation_key,
force_new_topic=False,
)
if has_active_requirement_plan_workspace:
return None
return {
"handled": True,
"status": "answered",
"classification": "question",
"linked_project_ids": [],
"signal_id": None,
"created_project_id": None,
"auto_tasks": [],
"ai_trace_id": None,
"results": [],
"intent": INTENT_QUERY,
"direct_reply_required": True,
"direct_reply_text": self._build_orphan_requirement_followup_reply(),
"reply_template_key": "requirement_plan_anchor_required",
"work_item_id": None,
"decision_level": None,
"review_summary": None,
}
@classmethod @classmethod
def _merge_ingress_thread_context( def _merge_ingress_thread_context(
cls, cls,

View File

@ -12,8 +12,10 @@ for _name, _value in vars(_common).items():
globals()[_name] = _value globals()[_name] = _value
from ..ai_steward_service import AIStewardService from ..ai_steward_service import AIStewardService
from ..mounted_ai_runtime_write_service import MountedAIRuntimeWriteService
from .workflow_semantic_planner import WorkflowSemanticPlanner from .workflow_semantic_planner import WorkflowSemanticPlanner
from ..signal.draft_generator_service import DraftGeneratorService from ..signal.draft_generator_service import DraftGeneratorService
from ...models import MountedDraftWorkspace, MountedTopicWorkspace
from ...models.feishu_reply_outbox import FeishuReplyOutbox from ...models.feishu_reply_outbox import FeishuReplyOutbox
@ -52,6 +54,7 @@ class DraftConfirmHandler:
) -> dict[str, Any]: ) -> dict[str, Any]:
action, revision_note = self.parse_action_from_text(message_text=message_text) action, revision_note = self.parse_action_from_text(message_text=message_text)
explicit_work_item_id = self._extract_work_item_id(message_text) explicit_work_item_id = self._extract_work_item_id(message_text)
looks_like_revision = WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text)
target = await self._resolve_pending_draft( target = await self._resolve_pending_draft(
sender=sender, sender=sender,
@ -62,11 +65,11 @@ class DraftConfirmHandler:
thread_context=thread_context, thread_context=thread_context,
) )
if target is None: if target is None:
if explicit_work_item_id is not None: if explicit_work_item_id is not None or action != self._ACTION_UNKNOWN or looks_like_revision:
return { return {
"handled": False, "handled": True,
"status": "ignored", "status": "failed_safe",
"followup_reply": "", "followup_reply": "当前没有可确认草稿。请先在当前会话重新生成最新草稿,再回复“确认”或“修改”。",
"target_work_item_id": None, "target_work_item_id": None,
"target_state": None, "target_state": None,
"target_intent": None, "target_intent": None,
@ -646,6 +649,25 @@ class DraftConfirmHandler:
explicit_work_item_id: Optional[int], explicit_work_item_id: Optional[int],
thread_context: Optional[dict[str, Any]], thread_context: Optional[dict[str, Any]],
) -> Optional[tuple[WorkItem, IngestEvent]]: ) -> Optional[tuple[WorkItem, IngestEvent]]:
if explicit_work_item_id is not None:
return await self._load_work_item_target_by_id(work_item_id=int(explicit_work_item_id))
active_mounted = await self._resolve_active_mounted_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
if active_mounted is not None:
mounted_draft, _ = active_mounted
active_work_item_id = _safe_int(getattr(mounted_draft, "active_work_item_id", None))
if active_work_item_id is not None:
target = await self._load_work_item_target_by_id(work_item_id=int(active_work_item_id))
if target is not None:
return target
return None
stmt = ( stmt = (
select(WorkItem, IngestEvent) select(WorkItem, IngestEvent)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
@ -659,11 +681,6 @@ class DraftConfirmHandler:
.limit(30) .limit(30)
) )
rows = (await self.db.execute(stmt)).all() rows = (await self.db.execute(stmt)).all()
if explicit_work_item_id is not None:
for work_item, ingest in rows:
if int(work_item.id) == int(explicit_work_item_id):
return work_item, ingest
return None
normalized_sender = str(sender or "").strip() normalized_sender = str(sender or "").strip()
normalized_channel = str(channel_ref or "").strip() normalized_channel = str(channel_ref or "").strip()
@ -738,6 +755,143 @@ class DraftConfirmHandler:
return None return None
return candidates[0] return candidates[0]
async def has_active_draft_session(
self,
*,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> bool:
if not hasattr(self.db, "execute"):
return False
mounted = await self._resolve_active_mounted_draft(
sender=sender,
source_system=source_system,
channel_ref=channel_ref,
source_sender_open_id=source_sender_open_id,
thread_context=thread_context,
)
return mounted is not None
async def _load_work_item_target_by_id(
self,
*,
work_item_id: int,
) -> Optional[tuple[WorkItem, IngestEvent]]:
row = (
await self.db.execute(
select(WorkItem, IngestEvent)
.join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id)
.where(
and_(
WorkItem.tenant_id == int(self.tenant_id),
WorkItem.id == int(work_item_id),
)
)
.limit(1)
)
).first()
if row is None:
return None
return row
async def _resolve_active_mounted_draft(
self,
*,
sender: str,
source_system: str,
channel_ref: Optional[str],
source_sender_open_id: Optional[str],
thread_context: Optional[dict[str, Any]],
) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]:
normalized_source = str(source_system or "").strip().lower()
if not normalized_source:
return None
conversation_scope_key = MountedAIRuntimeWriteService.build_conversation_scope_key(
source_system=normalized_source,
channel_ref=channel_ref,
sender=sender,
source_sender_open_id=source_sender_open_id,
)
anchor_context = self._normalize_thread_context(thread_context)
rows = (
await self.db.execute(
select(MountedDraftWorkspace, MountedTopicWorkspace)
.join(
MountedTopicWorkspace,
MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id,
)
.where(
and_(
MountedDraftWorkspace.tenant_id == int(self.tenant_id),
MountedTopicWorkspace.tenant_id == int(self.tenant_id),
MountedTopicWorkspace.source_system == normalized_source,
MountedTopicWorkspace.mode == "plan",
MountedTopicWorkspace.status == "active",
MountedDraftWorkspace.state.in_(("ready", STATE_DRAFT_PENDING_CONFIRM)),
)
)
.order_by(MountedTopicWorkspace.updated_at.desc(), MountedDraftWorkspace.id.desc())
)
).all()
if not rows:
return None
normalized_channel = str(channel_ref or "").strip()
best: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]] = None
best_score = -1
for draft_row, topic_row in rows:
if not hasattr(draft_row, "topic_workspace_id") or not hasattr(topic_row, "mode"):
continue
score = 0
if normalized_channel and str(getattr(topic_row, "channel_ref", "") or "").strip() == normalized_channel:
score += 30
if conversation_scope_key and str(getattr(topic_row, "conversation_scope_key", "") or "").strip() == str(conversation_scope_key).strip():
score += 60
if anchor_context and self._matches_mounted_draft_anchor(
draft=draft_row,
topic=topic_row,
anchor_context=anchor_context,
):
score += 120
if str(getattr(topic_row, "plan_status", "") or "").strip().lower() in {
STATE_DRAFT_PENDING_CONFIRM,
"awaiting_execute_confirm",
"draft_ready",
}:
score += 20
if _safe_int(getattr(draft_row, "active_work_item_id", None)) is not None:
score += 10
if score > best_score:
best = (draft_row, topic_row)
best_score = score
if best_score <= 0:
return None
return best
@classmethod
def _matches_mounted_draft_anchor(
cls,
*,
draft: MountedDraftWorkspace,
topic: MountedTopicWorkspace,
anchor_context: dict[str, str],
) -> bool:
if not anchor_context:
return False
candidate_values = {
str(getattr(draft, "latest_reply_message_id", "") or "").strip(),
str(getattr(draft, "latest_source_message_id", "") or "").strip(),
str(getattr(topic, "root_message_id", "") or "").strip(),
str(getattr(topic, "thread_key", "") or "").strip(),
str(getattr(topic, "last_message_id", "") or "").strip(),
}
candidate_values.discard("")
return any(str(value or "").strip() in candidate_values for value in anchor_context.values())
@staticmethod @staticmethod
def _normalize_thread_context( def _normalize_thread_context(
thread_context: Optional[dict[str, Any]], thread_context: Optional[dict[str, Any]],

View File

@ -0,0 +1,25 @@
from .assembler import SemanticContextAssembler
from .decision_schema import DecisionGateResult, ModeCard, SemanticDecision
from .gatekeeper import SemanticDecisionGatekeeper
from .mode_card_service import build_mode_card
from .packet_schema import (
SemanticContextDraftSnapshot,
SemanticContextPacket,
SemanticContextThread,
SemanticContextTopic,
SemanticContextTurn,
)
__all__ = [
"DecisionGateResult",
"ModeCard",
"SemanticContextAssembler",
"SemanticContextDraftSnapshot",
"SemanticContextPacket",
"SemanticContextThread",
"SemanticContextTopic",
"SemanticContextTurn",
"SemanticDecision",
"SemanticDecisionGatekeeper",
"build_mode_card",
]

View File

@ -0,0 +1,178 @@
from __future__ import annotations
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ....models import MountedConversationRun, MountedDraftWorkspace, MountedTopicWorkspace
from .mode_card_service import build_mode_card
from .packet_schema import (
SemanticContextDraftSnapshot,
SemanticContextPacket,
SemanticContextThread,
SemanticContextTopic,
SemanticContextTurn,
)
class SemanticContextAssembler:
def __init__(self, db: AsyncSession, *, recent_turn_limit: int = 6):
self.db = db
self.recent_turn_limit = max(int(recent_turn_limit), 2)
@staticmethod
def _normalize_list(value: Any) -> list[str]:
if isinstance(value, list):
return [str(item).strip() for item in value if str(item or "").strip()]
return []
@staticmethod
def _normalize_mapping(value: Any) -> Optional[Any]:
if isinstance(value, (dict, list)):
return value
return None
@staticmethod
def _extract_source_preview(row: MountedConversationRun) -> Optional[str]:
execution_outcome = row.execution_outcome if isinstance(row.execution_outcome, dict) else {}
preview = str(execution_outcome.get("source_message_preview") or "").strip()
return preview or None
async def _load_topic(
self,
*,
tenant_id: int,
topic_workspace_id: int,
) -> MountedTopicWorkspace:
topic_row = (
await self.db.execute(
select(MountedTopicWorkspace).where(
MountedTopicWorkspace.tenant_id == int(tenant_id),
MountedTopicWorkspace.id == int(topic_workspace_id),
)
)
).scalar_one_or_none()
if topic_row is None:
raise ValueError("mounted topic workspace not found")
return topic_row
async def _load_draft(
self,
*,
tenant_id: int,
topic_workspace_id: int,
) -> MountedDraftWorkspace | None:
return (
await self.db.execute(
select(MountedDraftWorkspace).where(
MountedDraftWorkspace.tenant_id == int(tenant_id),
MountedDraftWorkspace.topic_workspace_id == int(topic_workspace_id),
)
)
).scalar_one_or_none()
async def _load_recent_turns(
self,
*,
tenant_id: int,
topic_workspace_id: int,
) -> list[SemanticContextTurn]:
run_rows = (
await self.db.execute(
select(MountedConversationRun)
.where(
MountedConversationRun.tenant_id == int(tenant_id),
MountedConversationRun.topic_workspace_id == int(topic_workspace_id),
MountedConversationRun.status == "completed",
)
.order_by(MountedConversationRun.updated_at.desc(), MountedConversationRun.id.desc())
.limit(max(self.recent_turn_limit, 1))
)
).scalars().all()
turns: list[SemanticContextTurn] = []
for row in reversed(run_rows):
source_preview = self._extract_source_preview(row)
if source_preview:
turns.append(SemanticContextTurn(role="user", text=source_preview))
final_reply = str(row.final_reply or "").strip()
if final_reply:
turns.append(SemanticContextTurn(role="assistant", text=final_reply))
if len(turns) <= self.recent_turn_limit:
return turns
return turns[-self.recent_turn_limit :]
async def assemble(
self,
*,
tenant_id: int,
operator_user_id: int,
topic_workspace_id: int,
latest_user_message: str,
) -> SemanticContextPacket:
topic_row = await self._load_topic(
tenant_id=tenant_id,
topic_workspace_id=topic_workspace_id,
)
draft_row = await self._load_draft(
tenant_id=tenant_id,
topic_workspace_id=topic_workspace_id,
)
recent_turns = await self._load_recent_turns(
tenant_id=tenant_id,
topic_workspace_id=topic_workspace_id,
)
topic = SemanticContextTopic(
topic_workspace_id=int(topic_row.id),
topic_key=topic_row.topic_key,
source_system=topic_row.source_system,
channel_ref=topic_row.channel_ref,
conversation_scope_key=topic_row.conversation_scope_key,
thread_key=topic_row.thread_key,
mode=topic_row.mode,
status=topic_row.status,
plan_status=topic_row.plan_status,
facts=self._normalize_mapping(topic_row.facts_json),
goals=self._normalize_mapping(topic_row.goals_json),
constraints=self._normalize_mapping(topic_row.constraints_json),
metadata=self._normalize_mapping(topic_row.metadata_json),
)
draft = None
if draft_row is not None:
draft = SemanticContextDraftSnapshot(
draft_workspace_id=int(draft_row.id),
state=draft_row.state,
draft_version=int(draft_row.draft_version or 0),
requirement_card=self._normalize_mapping(draft_row.requirement_card),
draft_payload=self._normalize_mapping(draft_row.draft_payload),
required_confirmations=self._normalize_list(draft_row.required_confirmations),
)
metadata = topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}
thread = SemanticContextThread(
thread_key=topic_row.thread_key,
conversation_scope_key=topic_row.conversation_scope_key,
thread_summary=str(
metadata.get("last_message_preview")
or (topic_row.facts_json or {}).get("title")
or ""
).strip()
or None,
recent_turns=recent_turns,
open_questions=self._normalize_list(topic_row.open_questions),
)
return SemanticContextPacket(
packet_version="v1",
tenant_id=int(tenant_id),
operator_user_id=int(operator_user_id),
topic_workspace_id=int(topic_row.id),
latest_user_message=str(latest_user_message or "").strip(),
topic=topic,
draft=draft,
thread=thread,
mode_card=build_mode_card(
topic_mode=topic_row.mode,
plan_status=topic_row.plan_status,
),
)

View File

@ -0,0 +1,39 @@
from __future__ import annotations
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
class ModeCard(BaseModel):
model_config = ConfigDict(extra="ignore")
workflow_mode: Literal["CHAT", "PLAN", "EXECUTE"] = "CHAT"
mode_state: str = "IDLE"
allowed_actions: list[str] = Field(default_factory=list)
forbidden_actions: list[str] = Field(default_factory=list)
exit_conditions: list[str] = Field(default_factory=list)
class SemanticDecision(BaseModel):
model_config = ConfigDict(extra="ignore")
topic_relation: Literal["continue_current_topic", "new_topic", "uncertain"]
mode_decision: Literal["stay_plan", "ready_confirm", "request_execute", "answer_only"]
draft_patch: dict[str, Any] = Field(default_factory=dict)
assistant_reply: str = ""
requires_confirmation: bool = False
requested_actions: list[str] = Field(default_factory=list)
confidence: float = 0.0
uncertainties: list[str] = Field(default_factory=list)
class DecisionGateResult(BaseModel):
model_config = ConfigDict(extra="ignore")
accepted: bool
blocked_reasons: list[str] = Field(default_factory=list)
allowed_actions: list[str] = Field(default_factory=list)
next_plan_status: str
needs_confirmation: bool = False
mode_state_after: str | None = None

View File

@ -0,0 +1,51 @@
from __future__ import annotations
from .decision_schema import DecisionGateResult, ModeCard, SemanticDecision
class SemanticDecisionGatekeeper:
@staticmethod
def _next_plan_status(*, accepted: bool, decision: SemanticDecision) -> str:
if not accepted:
return "active"
if decision.mode_decision == "ready_confirm":
return "awaiting_execute_confirm"
if decision.mode_decision == "request_execute":
return "execution_pending"
if decision.mode_decision == "answer_only":
return "idle"
return "active"
@classmethod
def evaluate(
cls,
*,
mode_card: ModeCard,
decision: SemanticDecision,
) -> DecisionGateResult:
blocked_reasons: list[str] = []
allowed_actions: list[str] = []
allowed_set = {str(item).strip() for item in mode_card.allowed_actions if str(item or "").strip()}
forbidden_set = {str(item).strip() for item in mode_card.forbidden_actions if str(item or "").strip()}
for action in decision.requested_actions:
normalized_action = str(action or "").strip()
if not normalized_action:
continue
if normalized_action in forbidden_set:
blocked_reasons.append(f"forbidden_action:{normalized_action}")
continue
if allowed_set and normalized_action not in allowed_set:
blocked_reasons.append(f"unapproved_action:{normalized_action}")
continue
allowed_actions.append(normalized_action)
accepted = len(blocked_reasons) == 0
return DecisionGateResult(
accepted=accepted,
blocked_reasons=blocked_reasons,
allowed_actions=allowed_actions,
next_plan_status=cls._next_plan_status(accepted=accepted, decision=decision),
needs_confirmation=bool(decision.requires_confirmation),
mode_state_after=decision.mode_decision,
)

View File

@ -0,0 +1,57 @@
from __future__ import annotations
from .decision_schema import ModeCard
def build_mode_card(*, topic_mode: str | None, plan_status: str | None) -> ModeCard:
normalized_mode = str(topic_mode or "chat").strip().lower()
normalized_plan_status = str(plan_status or "idle").strip().lower()
workflow_mode = {
"plan": "PLAN",
"execute": "EXECUTE",
}.get(normalized_mode, "CHAT")
if workflow_mode == "PLAN":
return ModeCard(
workflow_mode="PLAN",
mode_state=normalized_plan_status.upper() or "ACTIVE",
allowed_actions=[
"reply.compose",
"draft.patch",
"clarify.ask",
"topic.continue_or_split",
"plan.mark_ready_for_confirmation",
],
forbidden_actions=[
"project.create",
"task.create",
"signal_source.onboard",
"db.write",
],
exit_conditions=[
"用户明确要求开始执行",
"草稿完成且需要确认项已补齐",
],
)
if workflow_mode == "EXECUTE":
return ModeCard(
workflow_mode="EXECUTE",
mode_state=normalized_plan_status.upper() or "ACTIVE",
allowed_actions=[
"reply.compose",
"project.create",
"task.create",
"followup.execute",
],
forbidden_actions=["db.write"],
exit_conditions=["执行完成或执行失败后返回结果"],
)
return ModeCard(
workflow_mode="CHAT",
mode_state=normalized_plan_status.upper() or "IDLE",
allowed_actions=["reply.compose", "clarify.ask"],
forbidden_actions=["project.create", "task.create", "db.write"],
exit_conditions=["用户提出新的规划或执行需求"],
)

View File

@ -0,0 +1,67 @@
from __future__ import annotations
from typing import Any, Optional
from pydantic import BaseModel, ConfigDict, Field
from .decision_schema import ModeCard
class SemanticContextTurn(BaseModel):
model_config = ConfigDict(extra="ignore")
role: str
text: str
class SemanticContextThread(BaseModel):
model_config = ConfigDict(extra="ignore")
thread_key: Optional[str] = None
conversation_scope_key: Optional[str] = None
thread_summary: Optional[str] = None
recent_turns: list[SemanticContextTurn] = Field(default_factory=list)
open_questions: list[str] = Field(default_factory=list)
class SemanticContextTopic(BaseModel):
model_config = ConfigDict(extra="ignore")
topic_workspace_id: int
topic_key: str
source_system: str
channel_ref: Optional[str] = None
conversation_scope_key: Optional[str] = None
thread_key: Optional[str] = None
mode: str
status: str
plan_status: Optional[str] = None
facts: Optional[Any] = None
goals: Optional[Any] = None
constraints: Optional[Any] = None
metadata: Optional[Any] = None
class SemanticContextDraftSnapshot(BaseModel):
model_config = ConfigDict(extra="ignore")
draft_workspace_id: int
state: str
draft_version: int
requirement_card: Optional[Any] = None
draft_payload: Optional[Any] = None
required_confirmations: list[str] = Field(default_factory=list)
class SemanticContextPacket(BaseModel):
model_config = ConfigDict(extra="ignore")
packet_version: str = "v1"
tenant_id: int
operator_user_id: int
topic_workspace_id: int
latest_user_message: str
topic: SemanticContextTopic
draft: SemanticContextDraftSnapshot | None = None
thread: SemanticContextThread
mode_card: ModeCard

View File

@ -0,0 +1,39 @@
from __future__ import annotations
import json
from typing import Any
from ....config.ai_prompts import PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT
from ....services.llm_service.service import LLMService
from .decision_schema import SemanticDecision
from .packet_schema import SemanticContextPacket
class SemanticDecisionService:
FUNCTION_NAME = "pmflow_semantic_context_decision"
def __init__(self, llm_service: LLMService):
self.llm_service = llm_service
@staticmethod
def build_prompt(packet: SemanticContextPacket) -> str:
return PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT.format(
semantic_packet=json.dumps(packet.model_dump(mode="json"), ensure_ascii=False, indent=2)
)
async def decide(self, *, packet: SemanticContextPacket) -> SemanticDecision:
structured = await self.llm_service.generate_structured_object(
prompt=self.build_prompt(packet),
function_name=self.FUNCTION_NAME,
use_prompt_profile=False,
temperature=0.0,
response_mode="fast",
)
payload: Any = getattr(structured, "payload", None)
if payload is None and hasattr(structured, "model_dump"):
payload = structured.model_dump()
if payload is None:
payload = structured
if not isinstance(payload, dict):
raise ValueError("semantic decision payload is not a dict")
return SemanticDecision.model_validate(payload)

View File

@ -263,6 +263,28 @@ class WorkflowSemanticPlanner:
return True return True
return False return False
@classmethod
def looks_like_requirement_plan_choice_reply(cls, text: str) -> bool:
return bool(re.match(r"^\s*([ABCabc])(?:[\.\,,。、::)\]】\s]|$)", str(text or "")))
@classmethod
def looks_like_low_context_requirement_followup(cls, text: str) -> bool:
raw = str(text or "").strip()
if not raw:
return False
if cls._looks_like_casual_chat_message(raw):
return False
if cls.looks_like_explicit_new_requirement_topic(raw):
return False
if cls._has_question_structure(raw) or cls.is_obvious_question(raw):
return False
if cls.looks_like_requirement_plan_choice_reply(raw):
return True
normalized = cls._normalize_text(raw)
if normalized in DRAFT_CONFIRM_EXACT_HINTS:
return True
return normalized in {"你自行推荐", "你来推荐", "你来定", "你决定"}
@classmethod @classmethod
def _looks_like_requirement_plan_slot_answer(cls, text: str) -> bool: def _looks_like_requirement_plan_slot_answer(cls, text: str) -> bool:
normalized = cls._normalize_text(text) normalized = cls._normalize_text(text)

View File

@ -75,10 +75,27 @@ class ContextCacheCoreMixin:
pass pass
@staticmethod @staticmethod
def _is_system_snapshot_complete(payload: Optional[dict[str, Any]]) -> bool: def _expected_system_snapshot_source_scan() -> dict[str, str]:
backend_root = Path(__file__).resolve().parents[3]
workspace_root = backend_root.parent
app_root = backend_root / "app"
return {
"backend_root": str(backend_root),
"app_root": str(app_root),
"workspace_root": str(workspace_root),
}
def _is_system_snapshot_complete(self, payload: Optional[dict[str, Any]]) -> bool:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return False return False
return isinstance(payload.get("assistant_profile"), dict) and isinstance(payload.get("platform_profile"), dict) if str(payload.get("schema_version") or "").strip() != SYSTEM_SNAPSHOT_SCHEMA_VERSION:
return False
if not isinstance(payload.get("assistant_profile"), dict) or not isinstance(payload.get("platform_profile"), dict):
return False
platform_profile = payload.get("platform_profile") if isinstance(payload.get("platform_profile"), dict) else {}
source_scan = platform_profile.get("source_scan") if isinstance(platform_profile.get("source_scan"), dict) else {}
expected = self._expected_system_snapshot_source_scan()
return all(str(source_scan.get(key) or "").strip() == value for key, value in expected.items())
async def get_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]: async def get_system_snapshot(self, *, db: AsyncSession, tenant_id: int) -> dict[str, Any]:
tid = _safe_int(tenant_id, 1) tid = _safe_int(tenant_id, 1)
@ -123,6 +140,7 @@ class ContextCacheCoreMixin:
platform_profile = self._build_platform_profile() platform_profile = self._build_platform_profile()
return { return {
"tenant_id": tenant_id, "tenant_id": tenant_id,
"schema_version": SYSTEM_SNAPSHOT_SCHEMA_VERSION,
"assistant_profile": assistant_profile, "assistant_profile": assistant_profile,
"platform_profile": platform_profile, "platform_profile": platform_profile,
"projects": projects.get("items", []), "projects": projects.get("items", []),
@ -130,4 +148,3 @@ class ContextCacheCoreMixin:
"pending_signals": pending, "pending_signals": pending,
"snapshot_at": now_cn_aware().isoformat(), "snapshot_at": now_cn_aware().isoformat(),
} }

View File

@ -190,7 +190,7 @@ class ContextCacheProfileRouteParserMixin:
} }
def _build_platform_profile(self) -> dict[str, Any]: def _build_platform_profile(self) -> dict[str, Any]:
backend_root = Path(__file__).resolve().parents[2] backend_root = Path(__file__).resolve().parents[3]
workspace_root = backend_root.parent workspace_root = backend_root.parent
app_root = backend_root / "app" app_root = backend_root / "app"
api_dir = app_root / "api" api_dir = app_root / "api"
@ -246,6 +246,8 @@ class ContextCacheProfileRouteParserMixin:
"feature_catalog": feature_catalog.get("items") if isinstance(feature_catalog.get("items"), list) else [], "feature_catalog": feature_catalog.get("items") if isinstance(feature_catalog.get("items"), list) else [],
"source_scan": { "source_scan": {
"backend_root": str(backend_root), "backend_root": str(backend_root),
"app_root": str(app_root),
"workspace_root": str(workspace_root),
"app_python_files": app_python_files, "app_python_files": app_python_files,
"feature_catalog_source": str(feature_catalog.get("source") or "unknown"), "feature_catalog_source": str(feature_catalog.get("source") or "unknown"),
"scanned_at": now_cn_aware().isoformat(), "scanned_at": now_cn_aware().isoformat(),

View File

@ -27,6 +27,7 @@ except Exception: # pragma: no cover
RedisSync = None RedisSync = None
SYSTEM_SNAPSHOT_SCHEMA_VERSION = "2026-04-07.context-v2"
SYSTEM_TTL_SECONDS = 30 * 60 SYSTEM_TTL_SECONDS = 30 * 60
USER_TTL_SECONDS = 5 * 60 USER_TTL_SECONDS = 5 * 60
SESSION_TTL_SECONDS = 60 * 60 SESSION_TTL_SECONDS = 60 * 60

View File

@ -328,6 +328,22 @@ class FeishuReplyOutboxMixin:
row.last_error = None row.last_error = None
if sent_message_id: if sent_message_id:
row.sent_message_id = sent_message_id[:128] row.sent_message_id = sent_message_id[:128]
if str(row.source_message_id or "").strip():
from app.models import MountedDraftWorkspace
mounted_rows = (
await db_update.execute(
select(MountedDraftWorkspace).where(
and_(
MountedDraftWorkspace.tenant_id == int(row.tenant_id),
MountedDraftWorkspace.latest_source_message_id == str(row.source_message_id).strip()[:128],
MountedDraftWorkspace.state.in_(("ready", "draft_pending_confirm")),
)
)
)
).scalars().all()
for mounted_row in mounted_rows:
mounted_row.latest_reply_message_id = sent_message_id[:128]
await db_update.commit() await db_update.commit()
perf_t2 = time.perf_counter() perf_t2 = time.perf_counter()
perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None)
@ -357,11 +373,67 @@ class FeishuReplyOutboxMixin:
@classmethod @classmethod
def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str: def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str:
text = cls._clean_reply_text(value) cleaner = getattr(cls, "_clean_reply_text", None)
if (not text) or cls._looks_like_provider_error(text): if callable(cleaner):
return cls._build_error_reply_text() text = cleaner(value)
else:
text = str(value or "").strip()
if text.startswith("```"):
text = text.replace("```", "").strip()
if text.lower().startswith("json"):
text = text[4:].strip()
looks_like_provider_error = getattr(cls, "_looks_like_provider_error", None)
has_provider_error = callable(looks_like_provider_error) and bool(looks_like_provider_error(text))
if not text or has_provider_error:
error_builder = getattr(cls, "_build_error_reply_text", None)
if callable(error_builder):
return error_builder()
return "当前回复生成失败,请稍后重试。"
text = cls._beautify_outgoing_reply_text(text)
return text[:800] return text[:800]
@staticmethod
def _beautify_outgoing_reply_text(value: Optional[str]) -> str:
raw = str(value or "").strip()
if not raw:
return ""
label_map = {
"结论:": "🎯 结论:",
"依据:": "🔎 依据:",
"下一步:": "➡️ 下一步:",
"信息不足,缺少:": "🧩 信息不足,缺少:",
"当前我更推荐:": "⭐ 当前我更推荐:",
}
section_prefixes = tuple(label_map.values()) + ("🧭 第 ",)
choice_prefixes = ("A.", "B.", "C.")
tail_prefixes = ("你回 A / B / C",)
beautified: list[str] = []
for raw_line in raw.splitlines():
stripped = str(raw_line).strip()
if not stripped:
if beautified and beautified[-1] != "":
beautified.append("")
continue
for plain, pretty in label_map.items():
if stripped.startswith(plain):
stripped = f"{pretty}{stripped[len(plain):]}"
break
needs_leading_gap = (
any(stripped.startswith(prefix) for prefix in section_prefixes)
or stripped in choice_prefixes
or any(stripped.startswith(prefix) for prefix in choice_prefixes)
or any(stripped.startswith(prefix) for prefix in tail_prefixes)
)
if needs_leading_gap and beautified and beautified[-1] != "":
beautified.append("")
beautified.append(stripped)
text = "\n".join(beautified).strip()
text = re.sub(r"\n{3,}", "\n\n", text)
return text
@staticmethod @staticmethod
def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]: def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]:
source = str(source_message_id or "").strip() source = str(source_message_id or "").strip()

View File

@ -5,7 +5,6 @@ from .shared import *
class LLMEngineResolutionCoreMixin: class LLMEngineResolutionCoreMixin:
_PROTECTED_QUERY_HIGH_FUNCTIONS = frozenset({ _PROTECTED_QUERY_HIGH_FUNCTIONS = frozenset({
"adaptive_rag_answer_deep", "adaptive_rag_answer_deep",
"adaptive_rag_answer_normal",
}) })
def _stub_response(self, prompt: str) -> str: def _stub_response(self, prompt: str) -> str:

View File

@ -265,6 +265,29 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = {
"requires_confirmation": ("bool",), "requires_confirmation": ("bool",),
}, },
}, },
"pmflow_semantic_context_decision": {
"required_keys": (
"topic_relation",
"mode_decision",
"draft_patch",
"assistant_reply",
"requires_confirmation",
"requested_actions",
"confidence",
),
"type_checks": {
"topic_relation": "str",
"mode_decision": "str",
"draft_patch": "dict",
"assistant_reply": "str",
"requires_confirmation": "bool",
"requested_actions": "list",
"confidence": "number",
},
"optional_type_checks": {
"uncertainties": ("list",),
},
},
"signal_planner_analyze": { "signal_planner_analyze": {
"required_keys": ("summary", "priority", "execution_plan", "risks", "confidence"), "required_keys": ("summary", "priority", "execution_plan", "risks", "confidence"),
"type_checks": { "type_checks": {

View File

@ -129,6 +129,19 @@ class MountedAIRuntimeService:
"updated_at": self._dt(row.updated_at), "updated_at": self._dt(row.updated_at),
} }
def _draft_snapshot(self, row: MountedDraftWorkspace) -> dict[str, Any]:
return {
"id": int(row.id),
"topic_workspace_id": int(row.topic_workspace_id),
"state": row.state,
"draft_version": int(row.draft_version or 0),
"requirement_card": row.requirement_card,
"draft_payload": row.draft_payload,
"required_confirmations": row.required_confirmations,
"last_ready_at": self._dt(row.last_ready_at),
"confirm_message_id": row.confirm_message_id,
}
@staticmethod @staticmethod
def _source_activation_snapshot(row: MountedSourceActivation) -> dict[str, Any]: def _source_activation_snapshot(row: MountedSourceActivation) -> dict[str, Any]:
return { return {
@ -547,13 +560,56 @@ class MountedAIRuntimeService:
.order_by(MountedAgentTrace.id.asc()) .order_by(MountedAgentTrace.id.asc())
) )
).scalars().all() ).scalars().all()
topic_row: MountedTopicWorkspace | None = None
draft_row: MountedDraftWorkspace | None = None
if row.topic_workspace_id is not None:
topic_row = (
await self.db.execute(
select(MountedTopicWorkspace).where(
MountedTopicWorkspace.tenant_id == int(tenant_id),
MountedTopicWorkspace.id == int(row.topic_workspace_id),
)
)
).scalar_one_or_none()
if topic_row is not None:
draft_row = (
await self.db.execute(
select(MountedDraftWorkspace).where(
MountedDraftWorkspace.tenant_id == int(tenant_id),
MountedDraftWorkspace.topic_workspace_id == int(topic_row.id),
)
)
).scalar_one_or_none()
execution_outcome = row.execution_outcome if isinstance(row.execution_outcome, dict) else row.execution_outcome
return { return {
"item": self._run_snapshot(row, trace_count=len(trace_rows)), "item": self._run_snapshot(row, trace_count=len(trace_rows)),
"topic": self._topic_snapshot(topic_row, draft=draft_row) if topic_row is not None else None,
"draft": self._draft_snapshot(draft_row) if draft_row is not None else None,
"agent_traces": [self._trace_snapshot(trace) for trace in trace_rows], "agent_traces": [self._trace_snapshot(trace) for trace in trace_rows],
"context_layers_hit": row.context_layers_hit, "context_layers_hit": row.context_layers_hit,
"tool_calls": row.tool_calls, "tool_calls": row.tool_calls,
"execution_outcome": row.execution_outcome, "execution_outcome": execution_outcome,
"semantic_packet_summary": (
execution_outcome.get("semantic_packet_summary")
if isinstance(execution_outcome, dict)
else None
),
"semantic_decision": (
execution_outcome.get("semantic_decision")
if isinstance(execution_outcome, dict)
else None
),
"semantic_gate_result": (
execution_outcome.get("semantic_gate_result")
if isinstance(execution_outcome, dict)
else None
),
"semantic_diff": (
execution_outcome.get("semantic_diff")
if isinstance(execution_outcome, dict)
else None
),
} }
async def replay_run( async def replay_run(

View File

@ -31,6 +31,15 @@ class MountedAIRuntimeWriteService:
return None return None
return text[:max_len] return text[:max_len]
@staticmethod
def _merge_json_dict(existing: Any, updates: dict[str, Any]) -> dict[str, Any]:
base = dict(existing) if isinstance(existing, dict) else {}
for key, value in updates.items():
if value is None:
continue
base[key] = value
return base
@classmethod @classmethod
def build_topic_key( def build_topic_key(
cls, cls,
@ -415,6 +424,63 @@ class MountedAIRuntimeWriteService:
) )
await self.db.flush() await self.db.flush()
async def record_semantic_shadow(
self,
*,
tracking: MountedIngressTracking,
source_message_preview: Optional[str],
semantic_packet_summary: Optional[dict[str, Any]],
semantic_decision: Optional[dict[str, Any]],
semantic_gate_result: Optional[dict[str, Any]],
semantic_diff: Optional[dict[str, Any]],
status: str = "completed",
failure_reason: Optional[str] = None,
) -> None:
topic_row = tracking.topic_workspace
run_row = tracking.conversation_run
normalized_status = self._compact(status, max_len=24) or "completed"
run_row.execution_outcome = self._merge_json_dict(
run_row.execution_outcome,
{
"source_message_preview": self._compact(source_message_preview, max_len=280),
"semantic_packet_summary": semantic_packet_summary if isinstance(semantic_packet_summary, dict) else None,
"semantic_decision": semantic_decision if isinstance(semantic_decision, dict) else None,
"semantic_gate_result": semantic_gate_result if isinstance(semantic_gate_result, dict) else None,
"semantic_diff": semantic_diff if isinstance(semantic_diff, dict) else None,
},
)
next_plan_status = None
if isinstance(semantic_gate_result, dict):
next_plan_status = self._compact(semantic_gate_result.get("next_plan_status"), max_len=32)
if next_plan_status:
topic_row.plan_status = next_plan_status
self.db.add(
MountedAgentTrace(
tenant_id=int(topic_row.tenant_id),
conversation_run_id=int(run_row.id),
agent_name="SemanticShadowPlanner",
status=normalized_status,
input_summary={
"topic_workspace_id": int(topic_row.id),
"mode_before": run_row.mode_before,
},
output_summary={
"semantic_packet_summary": semantic_packet_summary if isinstance(semantic_packet_summary, dict) else None,
"semantic_decision": semantic_decision if isinstance(semantic_decision, dict) else None,
"semantic_gate_result": semantic_gate_result if isinstance(semantic_gate_result, dict) else None,
"semantic_diff": semantic_diff if isinstance(semantic_diff, dict) else None,
},
tool_trace=[
{
"tool_name": "semantic.shadow",
"status": "completed",
}
],
failure_reason=self._compact(failure_reason, max_len=500),
)
)
await self.db.flush()
async def finalize_run( async def finalize_run(
self, self,
*, *,
@ -435,13 +501,16 @@ class MountedAIRuntimeWriteService:
run_row.status = "completed" run_row.status = "completed"
run_row.active_agent = "ReplyComposer" run_row.active_agent = "ReplyComposer"
run_row.final_reply = final_reply or None run_row.final_reply = final_reply or None
run_row.execution_outcome = { run_row.execution_outcome = self._merge_json_dict(
"handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False, run_row.execution_outcome,
"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 "", "handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False,
"workflow_route": str((result or {}).get("workflow_route") or "").strip() if isinstance(result, dict) else "", "status": str((result or {}).get("status") or "").strip() if isinstance(result, dict) else "",
"work_item_id": (result or {}).get("work_item_id") if isinstance(result, dict) else None, "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 run_row.completed_at = now_at
self.db.add( self.db.add(
MountedAgentTrace( MountedAgentTrace(

Binary file not shown.

View File

@ -429,6 +429,83 @@ async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None
assert "严重超载" in result assert "严重超载" in result
@pytest.mark.asyncio
async def test_deep_guard_answer_feishu_direct_prefers_manifest_and_compact_focus_context() -> None:
class _FakeLLM:
def __init__(self) -> None:
self.prompt = ""
async def generate(self, prompt: str, **kwargs):
_ = kwargs
self.prompt = prompt
return "信息不足,缺少:目标对象"
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
self.llm = _FakeLLM()
def _normalize_thinking_mode(self, thinking_mode: str) -> str:
return "normal"
async def get_project_manifest(self, project_id):
_ = project_id
return {
"manifest_text": "项目#7 Alpha(进行中);功能 18 个;KG 实体 12,关系 19。"
}
async def _render_scene_prompt(self, *, prompt_key: str, fallback_template: str, **kwargs):
_ = prompt_key
return str(fallback_template).format(**kwargs).strip()
def _enforce_feature_evidence_in_answer(self, *, question: str, answer_text: str, retrieved, system_snapshot):
_ = (question, retrieved, system_snapshot)
return answer_text
service = _FakeService()
system_snapshot = {
"assistant_profile": {"name": "PMFlow", "identity": "项目管理顾问"},
"platform_profile": {
"backend": {"module_counts": {"projects": 28, "tasks": 146, "signals": 41}},
"feature_catalog": [
{"name": "项目管理", "route": "/projects"},
{"name": "信号接入", "route": "/signals"},
{"name": "任务看板", "route": "/board"},
{"name": "知识图谱", "route": "/kg"},
{"name": "训练中心", "route": "/training"},
],
},
}
user_snapshot = {
"current_user": {"name": "林宇航"},
"projects": [{"id": idx, "title": f"项目{idx}"} for idx in range(1, 15)],
"tasks": [{"id": idx, "title": f"任务{idx}"} for idx in range(1, 10)],
}
result = await service._generate_answer(
question="信息不全,继续提供",
retrieved=[],
conversation=[
{"question": "上一次你缺什么", "answer": "缺目标对象和验收口径"},
{"question": "现在呢", "answer": "还缺时间边界"},
],
project_id=7,
system_snapshot=system_snapshot,
user_snapshot=user_snapshot,
asked_via="feishu",
)
assert result == "信息不足,缺少:目标对象"
assert "项目画像:" in service.llm.prompt
assert "项目#7 Alpha(进行中)" in service.llm.prompt
assert "系统摘要:" in service.llm.prompt
assert "用户焦点:" in service.llm.prompt
assert "林宇航" in service.llm.prompt
assert '"projects"' not in service.llm.prompt
assert "项目14" not in service.llm.prompt
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_select_scene_prompt_applies_consultant_mode_preference_wrapper() -> None: async def test_select_scene_prompt_applies_consultant_mode_preference_wrapper() -> None:
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,25 @@
from pathlib import Path
import app.services.context_cache_service.mixin_cache_core as cache_core_module
import app.services.context_cache_service.mixin_profile_route_parser as route_parser_module
from app.services.context_cache_service.mixin_cache_core import ContextCacheCoreMixin
from app.services.context_cache_service.mixin_profile_basics import ContextCacheProfileBasicsMixin from app.services.context_cache_service.mixin_profile_basics import ContextCacheProfileBasicsMixin
from app.services.context_cache_service.mixin_profile_route_parser import ContextCacheProfileRouteParserMixin from app.services.context_cache_service.mixin_profile_route_parser import ContextCacheProfileRouteParserMixin
class _FakeContextCacheProfileService(
ContextCacheProfileBasicsMixin,
ContextCacheProfileRouteParserMixin,
):
pass
class _FakeContextCacheCoreService(ContextCacheCoreMixin):
def __init__(self) -> None:
super().__init__(redis_url=None)
def _find_route(items: list[dict], route: str) -> dict: def _find_route(items: list[dict], route: str) -> dict:
for item in items: for item in items:
if str(item.get("route") or "") == route: if str(item.get("route") or "") == route:
@ -21,3 +39,79 @@ def test_route_feature_summary_uses_canonical_rag_ask() -> None:
_summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/ai-steward") _summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/ai-steward")
assert "/rag/ask" in apis assert "/rag/ask" in apis
assert "/ai-steward/rag/ask" not in apis assert "/ai-steward/rag/ask" not in apis
def test_build_platform_profile_scans_backend_app_root(tmp_path, monkeypatch) -> None:
backend_root = tmp_path / "backend"
app_root = backend_root / "app"
fake_module_file = app_root / "services" / "context_cache_service" / "mixin_profile_route_parser.py"
fake_module_file.parent.mkdir(parents=True, exist_ok=True)
fake_module_file.write_text("# fake module path anchor\n", encoding="utf-8")
files = {
app_root / "api" / "demo.py": '@router.get("/demo")\nasync def demo():\n return {"ok": True}\n',
app_root / "services" / "service_a.py": "class ServiceA:\n pass\n",
app_root / "models" / "model_a.py": "class ModelA:\n pass\n",
app_root / "schemas" / "schema_a.py": "class SchemaA:\n pass\n",
app_root / "dependencies" / "dep_a.py": "def dep_a():\n return 1\n",
app_root / "core" / "core_a.py": "VALUE = 1\n",
app_root / "config" / "config_a.py": "SETTING = 'x'\n",
}
for path, content in files.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
monkeypatch.setattr(route_parser_module, "__file__", str(fake_module_file))
profile = _FakeContextCacheProfileService()._build_platform_profile()
assert profile["backend"]["module_counts"] == {
"api": 1,
"services": 2,
"models": 1,
"schemas": 1,
"dependencies": 1,
"core": 1,
"config": 1,
}
assert profile["backend"]["api_route_count_estimate"] == 1
assert profile["source_scan"]["backend_root"] == str(backend_root)
assert profile["source_scan"]["app_root"] == str(app_root)
assert profile["source_scan"]["feature_catalog_source"] == "fallback"
def test_is_system_snapshot_complete_rejects_stale_source_scan(tmp_path, monkeypatch) -> None:
backend_root = tmp_path / "backend"
app_root = backend_root / "app"
fake_module_file = app_root / "services" / "context_cache_service" / "mixin_cache_core.py"
fake_module_file.parent.mkdir(parents=True, exist_ok=True)
fake_module_file.write_text("# fake module path anchor\n", encoding="utf-8")
monkeypatch.setattr(cache_core_module, "__file__", str(fake_module_file))
service = _FakeContextCacheCoreService()
stale_payload = {
"schema_version": "2026-04-01.context-v1",
"assistant_profile": {"name": "PMFlow"},
"platform_profile": {
"source_scan": {
"backend_root": str(app_root),
"app_root": str(app_root / "app"),
"workspace_root": str(tmp_path),
}
},
}
fresh_payload = {
"schema_version": cache_core_module.SYSTEM_SNAPSHOT_SCHEMA_VERSION,
"assistant_profile": {"name": "PMFlow"},
"platform_profile": {
"source_scan": {
"backend_root": str(backend_root),
"app_root": str(app_root),
"workspace_root": str(tmp_path),
}
},
}
assert service._is_system_snapshot_complete(stale_payload) is False
assert service._is_system_snapshot_complete(fresh_payload) is True

View File

@ -1,8 +1,10 @@
import json
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
from app.config.ai_prompts import ( from app.config.ai_prompts import (
PROMPT_AI_PMO_REQUIREMENT_PLAN_QA,
PROMPT_EXTERNAL_TREND_QA, PROMPT_EXTERNAL_TREND_QA,
PROMPT_FEISHU_DIRECT_QA, PROMPT_FEISHU_DIRECT_QA,
PROMPT_FEISHU_MIXED_QA, PROMPT_FEISHU_MIXED_QA,
@ -107,6 +109,25 @@ def test_reply_outbox_extract_sent_message_id() -> None:
assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456" assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456"
def test_reply_outbox_normalize_beautifies_structured_pm_sections() -> None:
from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin
raw = (
"结论:项目 #104 当前还不能直接判断是否启动后端。\n"
"依据:当前只命中静态代码索引,缺少真实归属和负责人信息。\n"
"下一步:先确认后端代码归属。\n"
"信息不足,缺少:负责人、仓库路径"
)
normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw)
assert "🎯 结论:" in normalized
assert "🔎 依据:" in normalized
assert "➡️ 下一步:" in normalized
assert "🧩 信息不足,缺少:" in normalized
assert "\n\n🔎 依据:" in normalized
def test_feishu_reply_llm_core_strips_action_process_prefaces() -> None: def test_feishu_reply_llm_core_strips_action_process_prefaces() -> None:
reply = ( reply = (
"好的,我来帮你创建项目和信号源。\n\n" "好的,我来帮你创建项目和信号源。\n\n"
@ -950,6 +971,58 @@ async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monk
assert result["followup_reply"] == "" assert result["followup_reply"] == ""
@pytest.mark.asyncio
async def test_draft_confirm_handler_confirm_without_target_returns_failed_safe(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return None
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
result = await handler.handle_text_reply(
message_text="确认",
sender="林宇航",
source_system="feishu",
source_message_id="msg-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context={"reply_to_message_id": "reply-1"},
)
assert result["handled"] is True
assert result["status"] == "failed_safe"
assert "当前没有可确认草稿" in result["followup_reply"]
@pytest.mark.asyncio
async def test_draft_confirm_handler_natural_revision_without_target_returns_failed_safe(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return None
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
result = await handler.handle_text_reply(
message_text="把目标用户改成平台运维团队",
sender="林宇航",
source_system="feishu",
source_message_id="msg-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context={"reply_to_message_id": "reply-1"},
)
assert result["handled"] is True
assert result["status"] == "failed_safe"
assert "当前没有可确认草稿" in result["followup_reply"]
def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: def test_kernel_ingress_mixin_auto_safe_int_regression() -> None:
assert callable(mixin_auto._safe_int) assert callable(mixin_auto._safe_int)
assert mixin_auto._safe_int("123") == 123 assert mixin_auto._safe_int("123") == 123
@ -1171,13 +1244,70 @@ async def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progr
assert "当前阶段" in prompt assert "当前阶段" in prompt
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_feishu_direct_compacts_snapshot_into_manifest_and_focus() -> None:
mixin = _make_adaptive_rag_mixin()
system_snapshot = {
"assistant_profile": {"name": "PMFlow", "identity": "项目管理顾问"},
"platform_profile": {
"backend": {"module_counts": {"projects": 28, "tasks": 146, "signals": 41}},
"feature_catalog": [
{"name": "项目管理", "route": "/projects"},
{"name": "信号接入", "route": "/signals"},
{"name": "任务看板", "route": "/board"},
{"name": "知识图谱", "route": "/kg"},
{"name": "训练中心", "route": "/training"},
],
},
}
user_snapshot = {
"current_user": {"name": "林宇航"},
"projects": [{"id": idx, "title": f"项目{idx}"} for idx in range(1, 15)],
"tasks": [{"id": idx, "title": f"任务{idx}"} for idx in range(1, 10)],
}
scene_key, prompt = await mixin._select_scene_prompt(
question="信息不全,继续提供",
project_id=7,
asked_via="feishu",
message_intent=None,
mixed_signal_part=None,
conversation_text="Q: 上一轮你提到这个需求还缺目标对象\nA: 我建议先补齐范围和交付目标",
context_blocks_text=(
"[M] 项目画像\n项目#7 Alpha(进行中);功能 18 个;KG 实体 12,关系 19。\n"
"[1] 来源: task\n相关度: 0.882\n内容: 当前需求缺少目标对象、验收口径和时间边界。\n"
"[2] 来源: project\n相关度: 0.701\n内容: 这是一个很长的项目列表噪声块,不应该整段原样进入飞书直答。"
),
system_snapshot=system_snapshot,
user_snapshot=user_snapshot,
system_snapshot_text=json.dumps(system_snapshot, ensure_ascii=False),
user_snapshot_text=json.dumps(user_snapshot, ensure_ascii=False),
relevance_max=0.0,
)
assert scene_key == "feishu_direct"
assert "项目画像:" in prompt
assert "项目#7 Alpha(进行中)" in prompt
assert "系统摘要:" in prompt
assert "用户焦点:" in prompt
assert "林宇航" in prompt
assert '"projects"' not in prompt
assert "项目14" not in prompt
def test_feishu_qa_prompts_prefer_professional_answer_style() -> None: def test_feishu_qa_prompts_prefer_professional_answer_style() -> None:
assert "先给结论" in PROMPT_FEISHU_DIRECT_QA assert "先给结论" in PROMPT_FEISHU_DIRECT_QA
assert "信息不足,缺少" in PROMPT_FEISHU_DIRECT_QA assert "信息不足,缺少" in PROMPT_FEISHU_DIRECT_QA
assert "适合飞书消息阅读" in PROMPT_FEISHU_DIRECT_QA assert "适合飞书消息阅读" in PROMPT_FEISHU_DIRECT_QA
assert "轻量 emoji" in PROMPT_FEISHU_DIRECT_QA
assert "项目画像" in PROMPT_FEISHU_DIRECT_QA
assert "系统摘要" in PROMPT_FEISHU_DIRECT_QA
assert "用户焦点" in PROMPT_FEISHU_DIRECT_QA
assert "先回答问题部分" in PROMPT_FEISHU_MIXED_QA assert "先回答问题部分" in PROMPT_FEISHU_MIXED_QA
assert "[已收到需求]" in PROMPT_FEISHU_MIXED_QA assert "[已收到需求]" in PROMPT_FEISHU_MIXED_QA
assert "专业" in PROMPT_FEISHU_MIXED_QA assert "专业" in PROMPT_FEISHU_MIXED_QA
assert "五轮" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
assert "A/B/C" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
assert "推荐技术/能力" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
assert "当前我更推荐" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
assert "XXXX年" not in PROMPT_EXTERNAL_TREND_QA assert "XXXX年" not in PROMPT_EXTERNAL_TREND_QA
assert "通用行业认知" in PROMPT_EXTERNAL_TREND_QA assert "通用行业认知" in PROMPT_EXTERNAL_TREND_QA
assert "项目状态分析助手" in PROMPT_PROJECT_STATUS_QA assert "项目状态分析助手" in PROMPT_PROJECT_STATUS_QA

View File

@ -204,6 +204,46 @@ async def test_runtime_low_override_does_not_downgrade_protected_adaptive_rag_fu
assert routed is None assert routed is None
@pytest.mark.asyncio
async def test_runtime_low_override_allows_adaptive_rag_answer_normal_to_use_low_tier(db_env) -> None:
db_session, _maker = db_env
await ModelRoutingService.upsert_override(
db_session,
tenant_id=1,
function_name="adaptive_rag_answer_normal",
tier_alias="low",
updated_by=9,
)
low_engine = {
"id": "low-1",
"provider": "glm",
"model": "qwen-plus",
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"api_key": "low-key",
}
high_engine = {
"id": "high-1",
"provider": "openai",
"model": "GLM-5",
"base_url": "https://api.openai.com/v1",
"api_key": "high-key",
}
routed = await llm_service._resolve_function_override_engine(
db_session,
tenant_id=1,
function_name="adaptive_rag_answer_normal",
engines=[low_engine, high_engine],
active_engine=low_engine,
)
assert routed is not None
assert routed["provider"] == "glm"
assert routed["model"] == "qwen-plus"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_model_routing_list_includes_current_business_entry_points(db_env) -> None: async def test_model_routing_list_includes_current_business_entry_points(db_env) -> None:
db_session, _maker = db_env db_session, _maker = db_env

View File

@ -38,6 +38,21 @@ def _load_plan_state_migration_module():
return module return module
def _load_active_draft_migration_module():
path = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "20260406_add_mounted_runtime_active_draft_fields.py"
)
spec = importlib.util.spec_from_file_location("mounted_runtime_active_draft_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: def test_mounted_ai_runtime_migration_adds_tables() -> None:
migration = _load_migration_module() migration = _load_migration_module()
engine = sa.create_engine("sqlite:///:memory:") engine = sa.create_engine("sqlite:///:memory:")
@ -88,3 +103,31 @@ def test_mounted_ai_runtime_plan_state_migration_adds_topic_fields() -> None:
assert "conversation_scope_key" in columns assert "conversation_scope_key" in columns
assert "plan_status" in columns assert "plan_status" in columns
def test_mounted_ai_runtime_active_draft_migration_adds_draft_anchor_fields() -> None:
base_migration = _load_migration_module()
plan_state_migration = _load_plan_state_migration_module()
active_draft_migration = _load_active_draft_migration_module()
engine = sa.create_engine("sqlite:///:memory:")
with engine.begin() as conn:
context = MigrationContext.configure(conn)
base_migration.op = Operations(context)
plan_state_migration.op = Operations(context)
active_draft_migration.op = Operations(context)
base_migration.upgrade()
plan_state_migration.upgrade()
active_draft_migration.upgrade()
inspector = sa.inspect(conn)
columns = {column["name"] for column in inspector.get_columns("mounted_draft_workspaces")}
indexes = {index["name"] for index in inspector.get_indexes("mounted_draft_workspaces")}
assert "active_work_item_id" in columns
assert "latest_source_message_id" in columns
assert "latest_reply_message_id" in columns
assert "ix_mounted_draft_workspaces_active_work_item_id" in indexes
assert "ix_mounted_draft_workspaces_latest_source_message_id" in indexes
assert "ix_mounted_draft_workspaces_latest_reply_message_id" in indexes

View File

@ -96,6 +96,9 @@ def test_mounted_ai_runtime_models_roundtrip_and_defaults() -> None:
draft_version=1, draft_version=1,
requirement_card={"goals": ["整理复盘需求"]}, requirement_card={"goals": ["整理复盘需求"]},
required_confirmations=[{"type": "user_confirm"}], required_confirmations=[{"type": "user_confirm"}],
active_work_item_id=501,
latest_source_message_id="om_reply_source_1",
latest_reply_message_id="om_reply_sent_1",
) )
run = MountedConversationRun( run = MountedConversationRun(
tenant_id=1, tenant_id=1,
@ -149,6 +152,9 @@ def test_mounted_ai_runtime_models_roundtrip_and_defaults() -> None:
assert saved_topic.status == "active" assert saved_topic.status == "active"
assert saved_draft.state == "collecting" assert saved_draft.state == "collecting"
assert saved_draft.active_work_item_id == 501
assert saved_draft.latest_source_message_id == "om_reply_source_1"
assert saved_draft.latest_reply_message_id == "om_reply_sent_1"
assert saved_run.status == "queued" assert saved_run.status == "queued"
assert saved_trace.tool_trace is None assert saved_trace.tool_trace is None
assert saved_activation.cutover_mode == "mounted_only" assert saved_activation.cutover_mode == "mounted_only"

View File

@ -270,3 +270,51 @@ async def test_update_topic_plan_state_persists_explicit_plan_status_and_pending
assert saved_topic.plan_status == "awaiting_execute_confirm" assert saved_topic.plan_status == "awaiting_execute_confirm"
assert saved_topic.pending_actions == ["confirm_requirement_draft"] assert saved_topic.pending_actions == ["confirm_requirement_draft"]
assert saved_topic.metadata_json["plan_topic_action"] == "draft_ready" assert saved_topic.metadata_json["plan_topic_action"] == "draft_ready"
@pytest.mark.asyncio
async def test_record_semantic_shadow_merges_with_finalize_run_execution_outcome(db_session) -> None:
service = MountedAIRuntimeWriteService(db_session)
tracking = await service.begin_ingress_run(
tenant_id=1,
source_system="feishu",
source_message_id="om_message_6",
message_text="先继续整理语义上下文,不要直接执行。",
sender="林宇航",
channel_ref="chat_semantic_shadow",
thread_context={"root_message_id": "om_root_6"},
source_sender_open_id="ou_semantic_shadow",
)
await service.record_semantic_shadow(
tracking=tracking,
source_message_preview="先继续整理语义上下文,不要直接执行。",
semantic_packet_summary={"packet_version": "v1", "recent_turn_count": 2},
semantic_decision={"topic_relation": "continue_current_topic", "mode_decision": "stay_plan"},
semantic_gate_result={"accepted": True, "next_plan_status": "collecting"},
semantic_diff={"changed": True, "legacy_route": "query.answer"},
)
await service.finalize_run(
tracking=tracking,
result={
"handled": True,
"status": "planned",
"classification": "query",
"work_item_id": 654,
"workflow_route": "query.answer",
"direct_reply_text": "我先把语义上下文整理成 shadow 观测结果。",
},
)
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 saved_topic.plan_status == "collecting"
assert saved_run.execution_outcome["handled"] is True
assert saved_run.execution_outcome["semantic_packet_summary"]["packet_version"] == "v1"
assert saved_run.execution_outcome["semantic_decision"]["mode_decision"] == "stay_plan"
assert saved_run.execution_outcome["semantic_gate_result"]["next_plan_status"] == "collecting"
assert saved_run.execution_outcome["semantic_diff"]["legacy_route"] == "query.answer"
assert saved_run.execution_outcome["source_message_preview"] == "先继续整理语义上下文,不要直接执行。"

View File

@ -0,0 +1,141 @@
from __future__ import annotations
import os
import pytest
import pytest_asyncio
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.models.base import Base
from app.models.mounted_ai_runtime import (
MountedConversationRun,
MountedDraftWorkspace,
MountedTopicWorkspace,
)
from app.services.ai_pmo.semantic_context.assembler import SemanticContextAssembler
def _use_sqlite_json_types() -> None:
for table in (
MountedTopicWorkspace.__table__,
MountedDraftWorkspace.__table__,
MountedConversationRun.__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",
}:
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__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_semantic_context_assembler_builds_packet_from_runtime_state(db_session) -> None:
topic = MountedTopicWorkspace(
tenant_id=7,
topic_key="feishu:chat_semantic:root:om_root_semantic",
source_system="feishu",
channel_ref="chat_semantic",
conversation_scope_key="feishu:chat_semantic:ou_semantic",
thread_key="thread_semantic",
root_message_id="om_root_semantic",
mode="plan",
status="active",
plan_status="collecting",
facts_json={"title": "OpenCow 风格语义上下文迁移"},
goals_json=["统一语义入口"],
constraints_json={"must_not": ["db.write"]},
open_questions=["当前是不是新需求 topic"],
pending_actions=["continue_requirement_plan"],
readiness_score=0.4,
metadata_json={"last_message_preview": "这次先把语义上下文统一掉"},
)
db_session.add(topic)
await db_session.flush()
draft = MountedDraftWorkspace(
tenant_id=7,
topic_workspace_id=int(topic.id),
state="collecting",
draft_version=2,
requirement_card={"goal": "统一语义入口", "scope": "先做 shadow mode"},
draft_payload={"draft_text": "当前先保留 mounted runtime 作为底座"},
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
)
db_session.add(draft)
db_session.add(
MountedConversationRun(
tenant_id=7,
trace_id="mounted:feishu:prev",
topic_workspace_id=int(topic.id),
source_system="feishu",
source_message_id="om_prev",
mode_before="plan",
mode_after="plan",
active_agent="PlanCoach",
status="completed",
trigger_reason="shadow",
context_layers_hit={"topic": True},
execution_outcome={
"source_message_preview": "先别执行,先整理需求上下文",
"semantic_decision": {"mode_decision": "stay_plan"},
},
final_reply="我先把上下文和草稿整理出来。",
)
)
await db_session.commit()
packet = await SemanticContextAssembler(db_session).assemble(
tenant_id=7,
operator_user_id=99,
topic_workspace_id=int(topic.id),
latest_user_message="这轮继续按 OpenCow 风格整理语义上下文。",
)
assert packet.packet_version == "v1"
assert packet.topic.topic_key == "feishu:chat_semantic:root:om_root_semantic"
assert packet.topic.plan_status == "collecting"
assert packet.draft is not None
assert packet.draft.draft_version == 2
assert packet.mode_card.workflow_mode == "PLAN"
assert "draft.patch" in packet.mode_card.allowed_actions
assert "project.create" in packet.mode_card.forbidden_actions
assert packet.thread.recent_turns[0].role == "user"
assert packet.thread.recent_turns[-1].role == "assistant"
assert packet.thread.open_questions == ["当前是不是新需求 topic"]

View File

@ -0,0 +1,165 @@
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,
MountedTopicWorkspace,
)
def _use_sqlite_json_types() -> None:
for table in (
MountedTopicWorkspace.__table__,
MountedDraftWorkspace.__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",
"requirement_card",
"draft_payload",
"required_confirmations",
"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__,
MountedDraftWorkspace.__table__,
MountedConversationRun.__table__,
MountedAgentTrace.__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_runtime_run_detail_exposes_semantic_shadow_fields(db_session) -> None:
topic = MountedTopicWorkspace(
tenant_id=1,
topic_key="feishu:chat_1:root:om_root_1",
source_system="feishu",
channel_ref="chat_1",
thread_key="thread_1",
mode="plan",
status="active",
plan_status="collecting",
facts_json={"title": "语义上下文迁移"},
open_questions=["是否切新 topic"],
metadata_json={"last_message_preview": "这次继续统一语义入口"},
)
db_session.add(topic)
await db_session.flush()
draft = MountedDraftWorkspace(
tenant_id=1,
topic_workspace_id=int(topic.id),
state="collecting",
draft_version=3,
requirement_card={"goal": "统一语义入口"},
draft_payload={"draft_text": "先做 shadow mode"},
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
)
db_session.add(draft)
await db_session.flush()
run = MountedConversationRun(
tenant_id=1,
trace_id="mounted:feishu:semantic-detail",
topic_workspace_id=int(topic.id),
source_system="feishu",
source_message_id="om_message_1",
mode_before="plan",
mode_after="plan",
active_agent="ConversationOrchestrator",
status="completed",
trigger_reason="shadow",
context_layers_hit={"topic": True, "thread": True},
execution_outcome={
"source_message_preview": "这轮继续按 OpenCow 风格整理语义上下文",
"semantic_packet_summary": {"packet_version": "v1", "recent_turn_count": 2},
"semantic_decision": {"topic_relation": "continue_current_topic", "mode_decision": "stay_plan"},
"semantic_gate_result": {"accepted": True, "next_plan_status": "collecting"},
"semantic_diff": {"changed": True, "legacy_route": "query.answer"},
},
final_reply="我先把上下文整理出来。",
)
db_session.add(run)
await db_session.commit()
app = _build_app(db_session)
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get(f"/ai-steward/mounted-ai/runs/{run.id}")
assert resp.status_code == 200
payload = resp.json()["data"]
assert payload["semantic_packet_summary"]["packet_version"] == "v1"
assert payload["semantic_decision"]["mode_decision"] == "stay_plan"
assert payload["semantic_gate_result"]["accepted"] is True
assert payload["semantic_diff"]["legacy_route"] == "query.answer"
assert payload["topic"]["topic_key"] == "feishu:chat_1:root:om_root_1"
assert payload["draft"]["draft_version"] == 3

View File

@ -0,0 +1,60 @@
from __future__ import annotations
from app.services.ai_pmo.semantic_context.decision_schema import (
DecisionGateResult,
ModeCard,
SemanticDecision,
)
from app.services.ai_pmo.semantic_context.gatekeeper import SemanticDecisionGatekeeper
def test_semantic_decision_gatekeeper_accepts_allowed_plan_patch() -> None:
mode_card = ModeCard(
workflow_mode="PLAN",
mode_state="ACTIVE",
allowed_actions=["reply.compose", "draft.patch", "plan.mark_ready_for_confirmation"],
forbidden_actions=["project.create", "task.create", "db.write"],
exit_conditions=["用户明确要求开始执行"],
)
decision = SemanticDecision(
topic_relation="continue_current_topic",
mode_decision="ready_confirm",
draft_patch={"goal": "先收一版需求草稿"},
assistant_reply="我先整理成待确认草稿。",
requires_confirmation=True,
requested_actions=["draft.patch", "reply.compose"],
confidence=0.92,
)
result = SemanticDecisionGatekeeper.evaluate(mode_card=mode_card, decision=decision)
assert isinstance(result, DecisionGateResult)
assert result.accepted is True
assert result.blocked_reasons == []
assert result.allowed_actions == ["draft.patch", "reply.compose"]
assert result.next_plan_status == "awaiting_execute_confirm"
def test_semantic_decision_gatekeeper_blocks_forbidden_action() -> None:
mode_card = ModeCard(
workflow_mode="PLAN",
mode_state="ACTIVE",
allowed_actions=["reply.compose", "draft.patch"],
forbidden_actions=["project.create", "task.create", "db.write"],
exit_conditions=["用户明确要求开始执行"],
)
decision = SemanticDecision(
topic_relation="continue_current_topic",
mode_decision="request_execute",
draft_patch={},
assistant_reply="我准备直接创建项目。",
requires_confirmation=True,
requested_actions=["project.create"],
confidence=0.73,
)
result = SemanticDecisionGatekeeper.evaluate(mode_card=mode_card, decision=decision)
assert result.accepted is False
assert "forbidden_action:project.create" in result.blocked_reasons
assert result.next_plan_status == "active"

View File

@ -2,3 +2,4 @@
{"generated_at": "2026-03-28T08:51:37.907500", "case": "advise", "source_message_id": "codex-feishu-advise-1774659056", "question": "统一决策层值不值得先做", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.query.answer", "intent": "query.answer", "reply_source": "action_succeeded", "error_message": ""}, "outbox": [{"id": 654, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b53462eac6ca4c2ccea9c028e82c", "text": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如/ai-steward、/projects)也未定义相关能力或路由;知识图谱与代码索引中无“decision layer”“unified decision”等关键词匹配。 \n下一步:请确认该需求对应的具体项目ID、业务场景及预期交付物。信息不足,缺少:①需求提出方;②关联项目ID;③验收标准。"}], "final_reply": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如/ai-steward、/projects)也未定义相关能力或路由;知识图谱与代码索引中无“decision layer”“unified decision”等关键词匹配。 \n下一步:请确认该需求对应的具体项目ID、业务场景及预期交付物。信息不足,缺少:①需求提出方;②关联项目ID;③验收标准。"} {"generated_at": "2026-03-28T08:51:37.907500", "case": "advise", "source_message_id": "codex-feishu-advise-1774659056", "question": "统一决策层值不值得先做", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.query.answer", "intent": "query.answer", "reply_source": "action_succeeded", "error_message": ""}, "outbox": [{"id": 654, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b53462eac6ca4c2ccea9c028e82c", "text": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如/ai-steward、/projects)也未定义相关能力或路由;知识图谱与代码索引中无“decision layer”“unified decision”等关键词匹配。 \n下一步:请确认该需求对应的具体项目ID、业务场景及预期交付物。信息不足,缺少:①需求提出方;②关联项目ID;③验收标准。"}], "final_reply": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如/ai-steward、/projects)也未定义相关能力或路由;知识图谱与代码索引中无“decision layer”“unified decision”等关键词匹配。 \n下一步:请确认该需求对应的具体项目ID、业务场景及预期交付物。信息不足,缺少:①需求提出方;②关联项目ID;③验收标准。"}
{"generated_at": "2026-03-28T08:52:16.466761", "case": "challenge", "source_message_id": "codex-feishu-challenge-1774659109", "question": "我要把整个系统一次性全部重构掉,这么做靠谱吗", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.query.answer", "intent": "query.answer", "reply_source": "action_succeeded", "error_message": ""}, "outbox": [{"id": 655, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b53462dfeb8a4b4a22d394e5186c", "text": "不靠谱。 \n依据:当前系统后端模块统计为全零(api/services/models/schemas均为0),但热点模块集中在`backend/app/api/sstc.py`等5个文件,说明核心逻辑实际集中、未模块化;前端仅6个路由,功能边界清晰;156个待处理信号中P0级3个、P1级46个,系统处于高负载运行态。 \n下一步:立即暂停重构决策,优先完成`sstc.py`模块拆解图谱与接口契约定义,并确认杨宇(唯一跨角色开发者,workload_ratio=796.67)是否参与重构设计。"}], "final_reply": "不靠谱。 \n依据:当前系统后端模块统计为全零(api/services/models/schemas均为0),但热点模块集中在`backend/app/api/sstc.py`等5个文件,说明核心逻辑实际集中、未模块化;前端仅6个路由,功能边界清晰;156个待处理信号中P0级3个、P1级46个,系统处于高负载运行态。 \n下一步:立即暂停重构决策,优先完成`sstc.py`模块拆解图谱与接口契约定义,并确认杨宇(唯一跨角色开发者,workload_ratio=796.67)是否参与重构设计。"} {"generated_at": "2026-03-28T08:52:16.466761", "case": "challenge", "source_message_id": "codex-feishu-challenge-1774659109", "question": "我要把整个系统一次性全部重构掉,这么做靠谱吗", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.query.answer", "intent": "query.answer", "reply_source": "action_succeeded", "error_message": ""}, "outbox": [{"id": 655, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b53462dfeb8a4b4a22d394e5186c", "text": "不靠谱。 \n依据:当前系统后端模块统计为全零(api/services/models/schemas均为0),但热点模块集中在`backend/app/api/sstc.py`等5个文件,说明核心逻辑实际集中、未模块化;前端仅6个路由,功能边界清晰;156个待处理信号中P0级3个、P1级46个,系统处于高负载运行态。 \n下一步:立即暂停重构决策,优先完成`sstc.py`模块拆解图谱与接口契约定义,并确认杨宇(唯一跨角色开发者,workload_ratio=796.67)是否参与重构设计。"}], "final_reply": "不靠谱。 \n依据:当前系统后端模块统计为全零(api/services/models/schemas均为0),但热点模块集中在`backend/app/api/sstc.py`等5个文件,说明核心逻辑实际集中、未模块化;前端仅6个路由,功能边界清晰;156个待处理信号中P0级3个、P1级46个,系统处于高负载运行态。 \n下一步:立即暂停重构决策,优先完成`sstc.py`模块拆解图谱与接口契约定义,并确认杨宇(唯一跨角色开发者,workload_ratio=796.67)是否参与重构设计。"}
{"generated_at": "2026-03-28T08:53:35.385412", "case": "brainstorm", "source_message_id": "codex-feishu-brainstorm-1774659145", "question": "我们一起想几个 AI 劝退助手的方向", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.draft_pending_confirm", "intent": "project_signal.create", "reply_source": "draft_pending_confirm", "error_message": ""}, "outbox": [{"id": 656, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b5346298338a4b2c4f90aaa6bc9f", "text": "📋 我先按产品经理视角起草一版评审建议,请确认:\n\n📌 需求草稿\n我们一起想几个 AI 劝退助手的方向\n\n🔎 同名 / 相似项目:\n· #51 AI劝退助手:现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。;若范围重叠,可能造成重复建设或维护分散。;名称或目标高度接近,优先确认是否复用或合并。\n\n🚀 MVP 建议:\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\n\n一、定位\n· 一句话描述:我们一起想几个 AI 劝退助手的方向\n· 目标客户:待补充\n\n二、核心痛点\n· 待补充核心痛点\n\n三、MVP 功能范围(8周交付)\nPhase 1:核心功能(Week 1-4)\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\nPhase 2:差异化功能(Week 5-8)\n· 待补充:确认差异化方向后再展开\n\n四、技术架构(精简版)\n· 待补充技术架构\n\n五、商业模式\n· 待补充商业模式\n\n六、验证指标\n验证指标:\n· 待补充\n\n七、快速启动建议\n快速启动建议:\n· 待补充\n\n八、团队配置\n· 待补充团队配置\n\n📈 前景判断:\n· 该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进\n\n🧩 需求清单:\n· 项目定位与目标\n· MVP 功能范围\n\n❓ 还缺的信息:\n· 暂无\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消"}], "final_reply": "📋 我先按产品经理视角起草一版评审建议,请确认:\n\n📌 需求草稿\n我们一起想几个 AI 劝退助手的方向\n\n🔎 同名 / 相似项目:\n· #51 AI劝退助手:现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。;若范围重叠,可能造成重复建设或维护分散。;名称或目标高度接近,优先确认是否复用或合并。\n\n🚀 MVP 建议:\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\n\n一、定位\n· 一句话描述:我们一起想几个 AI 劝退助手的方向\n· 目标客户:待补充\n\n二、核心痛点\n· 待补充核心痛点\n\n三、MVP 功能范围(8周交付)\nPhase 1:核心功能(Week 1-4)\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\nPhase 2:差异化功能(Week 5-8)\n· 待补充:确认差异化方向后再展开\n\n四、技术架构(精简版)\n· 待补充技术架构\n\n五、商业模式\n· 待补充商业模式\n\n六、验证指标\n验证指标:\n· 待补充\n\n七、快速启动建议\n快速启动建议:\n· 待补充\n\n八、团队配置\n· 待补充团队配置\n\n📈 前景判断:\n· 该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进\n\n🧩 需求清单:\n· 项目定位与目标\n· MVP 功能范围\n\n❓ 还缺的信息:\n· 暂无\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消"} {"generated_at": "2026-03-28T08:53:35.385412", "case": "brainstorm", "source_message_id": "codex-feishu-brainstorm-1774659145", "question": "我们一起想几个 AI 劝退助手的方向", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.draft_pending_confirm", "intent": "project_signal.create", "reply_source": "draft_pending_confirm", "error_message": ""}, "outbox": [{"id": 656, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b5346298338a4b2c4f90aaa6bc9f", "text": "📋 我先按产品经理视角起草一版评审建议,请确认:\n\n📌 需求草稿\n我们一起想几个 AI 劝退助手的方向\n\n🔎 同名 / 相似项目:\n· #51 AI劝退助手:现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。;若范围重叠,可能造成重复建设或维护分散。;名称或目标高度接近,优先确认是否复用或合并。\n\n🚀 MVP 建议:\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\n\n一、定位\n· 一句话描述:我们一起想几个 AI 劝退助手的方向\n· 目标客户:待补充\n\n二、核心痛点\n· 待补充核心痛点\n\n三、MVP 功能范围(8周交付)\nPhase 1:核心功能(Week 1-4)\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\nPhase 2:差异化功能(Week 5-8)\n· 待补充:确认差异化方向后再展开\n\n四、技术架构(精简版)\n· 待补充技术架构\n\n五、商业模式\n· 待补充商业模式\n\n六、验证指标\n验证指标:\n· 待补充\n\n七、快速启动建议\n快速启动建议:\n· 待补充\n\n八、团队配置\n· 待补充团队配置\n\n📈 前景判断:\n· 该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进\n\n🧩 需求清单:\n· 项目定位与目标\n· MVP 功能范围\n\n❓ 还缺的信息:\n· 暂无\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消"}], "final_reply": "📋 我先按产品经理视角起草一版评审建议,请确认:\n\n📌 需求草稿\n我们一起想几个 AI 劝退助手的方向\n\n🔎 同名 / 相似项目:\n· #51 AI劝退助手:现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。;若范围重叠,可能造成重复建设或维护分散。;名称或目标高度接近,优先确认是否复用或合并。\n\n🚀 MVP 建议:\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\n\n一、定位\n· 一句话描述:我们一起想几个 AI 劝退助手的方向\n· 目标客户:待补充\n\n二、核心痛点\n· 待补充核心痛点\n\n三、MVP 功能范围(8周交付)\nPhase 1:核心功能(Week 1-4)\n· 明确项目目标与边界\n· 完成同名/相似项目查重\n· 交付最小可验证闭环\nPhase 2:差异化功能(Week 5-8)\n· 待补充:确认差异化方向后再展开\n\n四、技术架构(精简版)\n· 待补充技术架构\n\n五、商业模式\n· 待补充商业模式\n\n六、验证指标\n验证指标:\n· 待补充\n\n七、快速启动建议\n快速启动建议:\n· 待补充\n\n八、团队配置\n· 待补充团队配置\n\n📈 前景判断:\n· 该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进\n\n🧩 需求清单:\n· 项目定位与目标\n· MVP 功能范围\n\n❓ 还缺的信息:\n· 暂无\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消"}
{"generated_at": "2026-04-07T00:07:35.291848", "idx": 9001, "source_message_id": "real-scene-p1-manual-9001-1775491344", "question": "跟你聊个新需求,关于换季系统自动推荐搭配衣服", "message": "跟你聊个新需求,关于换季系统自动推荐搭配衣服", "capture_status": "answered", "capture_direct_reply_text": "可以,我们先不创建项目,先把这件事收成一版需求。\n\n我先把结果主要给谁用定一下,这会直接影响后面的内容粒度和表达方式。\n\nA. 核心执行人 / 内部 PM\n更适合:先服务负责跟进、拆动作、推动落地的人\n\nB. 负责人 / 管理者\n更适合:先看结论、风险和待拍板项\n\nC. 整个团队 / 终端用户\n更适合:需要直接同步所有相关方,或结果就是给最终使用者看的\n\n你回 A / B / C,也可以直接说更准确的对象。", "consultant_dispatch": null, "run_status": "missing", "ack": "", "final_reply": "可以,我们先不创建项目,先把这件事收成一版需求。\n\n我先把结果主要给谁用定一下,这会直接影响后面的内容粒度和表达方式。\n\nA. 核心执行人 / 内部 PM\n更适合:先服务负责跟进、拆动作、推动落地的人\n\nB. 负责人 / 管理者\n更适合:先看结论、风险和待拍板项\n\nC. 整个团队 / 终端用户\n更适合:需要直接同步所有相关方,或结果就是给最终使用者看的\n\n你回 A / B / C,也可以直接说更准确的对象。", "prompt_scene": "unknown", "inferred_scene": "unknown", "actual_scene": "unknown", "thinking_leak": false, "has_capacity_warning": false, "processing": {"route": "ai_pmo.query.answer", "intent": "query.answer", "status": "success", "duration_ms": 9984, "error_message": "", "reply_source": "action_succeeded"}, "outbox": [{"id": 966, "status": "pending", "attempts": 0, "created_at": "2026-04-07 00:02:34.138267", "text": "可以,我们先不创建项目,先把这件事收成一版需求。\n\n我先把结果主要给谁用定一下,这会直接影响后面的内容粒度和表达方式。\n\nA. 核心执行人 / 内部 PM\n更适合:先服务负责跟进、拆动作、推动落地的人\n\nB. 负责人 / 管理者\n更适合:先看结论、风险和待拍板项\n\nC. 整个团队 / 终端用户\n更适合:需要直接同步所有相关方,或结果就是给最终使用者看的\n\n你回 A / B / C,也可以直接说更准确的对象。"}], "call_log_candidates": [], "pass": false, "pass_rule": "unknown"}

View File

@ -94,6 +94,7 @@ export interface MountedAITopicItem {
thread_key?: string | null; thread_key?: string | null;
mode: string; mode: string;
status: string; status: string;
plan_status?: string | null;
readiness_score?: number | null; readiness_score?: number | null;
draft_state?: string | null; draft_state?: string | null;
draft_version?: number | null; draft_version?: number | null;
@ -137,12 +138,49 @@ export interface MountedAIAgentTraceItem {
created_at?: string | null; created_at?: string | null;
} }
export interface MountedAIDraftItem {
id: number;
topic_workspace_id: number;
state: string;
draft_version: number;
requirement_card?: unknown;
draft_payload?: unknown;
required_confirmations?: unknown;
last_ready_at?: string | null;
confirm_message_id?: string | null;
}
export interface MountedAISemanticTurn {
role: string;
text: string;
}
export interface MountedAISemanticPacketSummary {
packet_version: string;
topic_key?: string | null;
workflow_mode?: string | null;
recent_turn_count?: number | null;
draft_version?: number | null;
thread_summary?: string | null;
recent_turns?: MountedAISemanticTurn[];
open_questions?: string[];
mode_card?: unknown;
topic?: unknown;
draft?: unknown;
}
export interface MountedAIRunDetailPayload { export interface MountedAIRunDetailPayload {
item: MountedAIRunItem; item: MountedAIRunItem;
topic?: MountedAITopicItem | null;
draft?: MountedAIDraftItem | null;
agent_traces: MountedAIAgentTraceItem[]; agent_traces: MountedAIAgentTraceItem[];
context_layers_hit?: Record<string, unknown> | null; context_layers_hit?: Record<string, unknown> | null;
tool_calls?: unknown; tool_calls?: unknown;
execution_outcome?: unknown; execution_outcome?: unknown;
semantic_packet_summary?: MountedAISemanticPacketSummary | null;
semantic_decision?: unknown;
semantic_gate_result?: unknown;
semantic_diff?: unknown;
} }
export interface MountedAICutoverStatusPayload { export interface MountedAICutoverStatusPayload {

View File

@ -13,6 +13,9 @@ import { MountedAIHardCasesPanel } from "./components/MountedAIHardCasesPanel";
import { MountedAIOrchestrationGraphPanel } from "./components/MountedAIOrchestrationGraphPanel"; import { MountedAIOrchestrationGraphPanel } from "./components/MountedAIOrchestrationGraphPanel";
import { MountedAIRunReplayPanel } from "./components/MountedAIRunReplayPanel"; import { MountedAIRunReplayPanel } from "./components/MountedAIRunReplayPanel";
import { MountedAIRunsPanel } from "./components/MountedAIRunsPanel"; import { MountedAIRunsPanel } from "./components/MountedAIRunsPanel";
import { MountedAISemanticDecisionPanel } from "./components/MountedAISemanticDecisionPanel";
import { MountedAISemanticPacketPanel } from "./components/MountedAISemanticPacketPanel";
import { MountedAIShadowDiffPanel } from "./components/MountedAIShadowDiffPanel";
import { MountedAISourceActivationPanel } from "./components/MountedAISourceActivationPanel"; import { MountedAISourceActivationPanel } from "./components/MountedAISourceActivationPanel";
import { MountedAISourceStatusPanel } from "./components/MountedAISourceStatusPanel"; import { MountedAISourceStatusPanel } from "./components/MountedAISourceStatusPanel";
import { MountedAIToolGuardrailsPanel } from "./components/MountedAIToolGuardrailsPanel"; import { MountedAIToolGuardrailsPanel } from "./components/MountedAIToolGuardrailsPanel";
@ -290,6 +293,13 @@ export default function SchedulerCenterPage() {
/> />
</div> </div>
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
<MountedAISemanticPacketPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
<MountedAISemanticDecisionPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
</div>
<MountedAIShadowDiffPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
<div className="grid gap-4 xl:grid-cols-2"> <div className="grid gap-4 xl:grid-cols-2">
<MountedAIOrchestrationGraphPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} /> <MountedAIOrchestrationGraphPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
<MountedAIContextLayersPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} /> <MountedAIContextLayersPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />

View File

@ -0,0 +1,94 @@
import type { MountedAIRunDetailPayload } from "@/api/mountedAi";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface MountedAISemanticDecisionPanelProps {
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 MountedAISemanticDecisionPanel({ detail, loading }: MountedAISemanticDecisionPanelProps) {
const decision =
detail?.semantic_decision && typeof detail.semantic_decision === "object"
? (detail.semantic_decision as Record<string, unknown>)
: null;
const gate =
detail?.semantic_gate_result && typeof detail.semantic_gate_result === "object"
? (detail.semantic_gate_result as Record<string, unknown>)
: null;
return (
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">Semantic Decision / Gate</CardTitle>
<CardDescription>展示模型如何判定 topic、mode 与 draft patch,以及 gatekeeper 最终是否放行。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
正在加载 semantic decision...
</div>
) : null}
{!loading && !detail ? (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
选择一条 run 后,这里会展示 semantic decision 与 gate result。
</div>
) : null}
{!loading && detail ? (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Topic Relation</div>
<div className="mt-2 text-sm font-medium">{String(decision?.topic_relation || "-")}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Mode Decision</div>
<div className="mt-2 text-sm font-medium">{String(decision?.mode_decision || "-")}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Gate Accepted</div>
<div className="mt-2 flex items-center gap-2 text-sm font-medium">
<Badge variant={gate?.accepted ? "secondary" : "destructive"}>
{gate?.accepted ? "accepted" : "blocked"}
</Badge>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Next Plan Status</div>
<div className="mt-2 text-sm font-medium">{String(gate?.next_plan_status || "-")}</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-2 text-sm font-medium">Semantic Decision</div>
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
{prettyJson(detail.semantic_decision)}
</pre>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-2 text-sm font-medium">Gate Result</div>
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
{prettyJson(detail.semantic_gate_result)}
</pre>
</div>
</div>
</>
) : null}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,175 @@
import type { MountedAIRunDetailPayload } from "@/api/mountedAi";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface MountedAISemanticPacketPanelProps {
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 MountedAISemanticPacketPanel({ detail, loading }: MountedAISemanticPacketPanelProps) {
const packet = detail?.semantic_packet_summary;
const recentTurns = Array.isArray(packet?.recent_turns) ? packet.recent_turns : [];
const openQuestions = Array.isArray(packet?.open_questions) ? packet.open_questions : [];
const modeCard =
packet?.mode_card && typeof packet.mode_card === "object"
? (packet.mode_card as Record<string, unknown>)
: null;
return (
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">Semantic Context Packet</CardTitle>
<CardDescription>展示当前 shadow 语义包里真正喂给模型的 topic / draft / recent turns / mode card 摘要。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
正在加载 semantic packet...
</div>
) : null}
{!loading && !detail ? (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
选择一条 run 后,这里会展示 semantic packet。
</div>
) : null}
{!loading && detail ? (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Packet Version</div>
<div className="mt-2 text-sm font-medium">{packet?.packet_version || "-"}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Workflow Mode</div>
<div className="mt-2 text-sm font-medium">{packet?.workflow_mode || detail.topic?.mode || "-"}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Draft Version</div>
<div className="mt-2 text-sm font-medium">{packet?.draft_version ?? detail.draft?.draft_version ?? "-"}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Plan Status</div>
<div className="mt-2 text-sm font-medium">{detail.topic?.plan_status || "-"}</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-3 flex items-center justify-between gap-2">
<div>
<div className="text-sm font-medium">Topic / Thread</div>
<div className="text-xs text-slate-500 dark:text-slate-400">
当前挂载 runtime topic 与 thread 边界。
</div>
</div>
<Badge variant="secondary">{recentTurns.length} turns</Badge>
</div>
<div className="space-y-2 text-sm">
<div>
<span className="text-slate-500 dark:text-slate-400">Topic Key:</span>
<span className="break-all font-medium">{detail.topic?.topic_key || packet?.topic_key || "-"}</span>
</div>
<div>
<span className="text-slate-500 dark:text-slate-400">Thread Key:</span>
<span className="break-all font-medium">{detail.topic?.thread_key || "-"}</span>
</div>
<div>
<span className="text-slate-500 dark:text-slate-400">Draft State:</span>
<span className="font-medium">{detail.draft?.state || detail.topic?.draft_state || "-"}</span>
</div>
<div>
<div className="mb-2 text-slate-500 dark:text-slate-400">Thread Summary</div>
<div className="rounded-xl bg-slate-50 px-3 py-3 text-sm leading-6 dark:bg-slate-950/40">
{packet?.thread_summary || "暂无"}
</div>
</div>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-3 text-sm font-medium">Mode Card</div>
{modeCard ? (
<div className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{String(modeCard.workflow_mode || "-")}</Badge>
<Badge variant="outline">{String(modeCard.mode_state || "-")}</Badge>
</div>
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
{prettyJson(modeCard)}
</pre>
</div>
) : (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
当前 run 尚未记录 mode card。
</div>
)}
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-3 flex items-center justify-between gap-2">
<div className="text-sm font-medium">Recent Turns</div>
<Badge variant="secondary">{packet?.recent_turn_count ?? recentTurns.length}</Badge>
</div>
<div className="space-y-3">
{recentTurns.length > 0 ? (
recentTurns.map((turn, index) => (
<div
key={`${turn.role}-${index}`}
className="rounded-xl border border-slate-200/80 bg-slate-50/80 p-3 dark:border-slate-700 dark:bg-slate-950/40"
>
<div className="mb-2">
<Badge variant={turn.role === "assistant" ? "secondary" : "outline"}>{turn.role}</Badge>
</div>
<div className="text-sm leading-6 text-slate-700 dark:text-slate-200">{turn.text}</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
当前 packet 没有 recent turns。
</div>
)}
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-3 text-sm font-medium">Open Questions</div>
<div className="space-y-2">
{openQuestions.length > 0 ? (
openQuestions.map((item, index) => (
<div
key={`${item}-${index}`}
className="rounded-xl bg-slate-50 px-3 py-3 text-sm leading-6 text-slate-700 dark:bg-slate-950/40 dark:text-slate-200"
>
{item}
</div>
))
) : (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
当前 packet 没有待确认问题。
</div>
)}
</div>
</div>
</div>
</>
) : null}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,94 @@
import type { MountedAIRunDetailPayload } from "@/api/mountedAi";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface MountedAIShadowDiffPanelProps {
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 MountedAIShadowDiffPanel({ detail, loading }: MountedAIShadowDiffPanelProps) {
const diff =
detail?.semantic_diff && typeof detail.semantic_diff === "object"
? (detail.semantic_diff as Record<string, unknown>)
: null;
const executionOutcome =
detail?.execution_outcome && typeof detail.execution_outcome === "object"
? (detail.execution_outcome as Record<string, unknown>)
: null;
return (
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
<CardHeader>
<CardTitle className="text-base">Shadow Diff</CardTitle>
<CardDescription>对比旧链路的 legacy route 与新语义链路的 shadow 结论,用于切主链前验收。</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
正在加载 shadow diff...
</div>
) : null}
{!loading && !detail ? (
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
选择一条 run 后,这里会展示 shadow diff。
</div>
) : null}
{!loading && detail ? (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Legacy Route</div>
<div className="mt-2 text-sm font-medium">{String(diff?.legacy_route || executionOutcome?.workflow_route || "-")}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Legacy Intent</div>
<div className="mt-2 text-sm font-medium">{String(diff?.legacy_message_intent || "-")}</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Shadow Changed</div>
<div className="mt-2">
<Badge variant={diff?.changed ? "secondary" : "outline"}>
{diff?.changed ? "changed" : "same"}
</Badge>
</div>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="text-xs text-slate-500 dark:text-slate-400">Shadow Error</div>
<div className="mt-2 text-sm font-medium">{String(diff?.shadow_error || "-")}</div>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-2 text-sm font-medium">Shadow Diff Payload</div>
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
{prettyJson(detail.semantic_diff)}
</pre>
</div>
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
<div className="mb-2 text-sm font-medium">Legacy Execution Outcome</div>
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
{prettyJson(detail.execution_outcome)}
</pre>
</div>
</div>
</>
) : null}
</CardContent>
</Card>
);
}

File diff suppressed because one or more lines are too long