Harden PMFlow context binding and world model snapshots
This commit is contained in:
parent
bd5a2813c3
commit
38adaed6e6
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -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` 已废弃并从配置校验、运行时解析、前端选项中移除
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,38 +11,7 @@ for _name, _value in vars(_common).items():
|
||||||
|
|
||||||
|
|
||||||
class KernelIntentResolveMixin:
|
class KernelIntentResolveMixin:
|
||||||
@staticmethod
|
_QUERY_FOLLOWUP_TOKENS = (
|
||||||
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
|
|
||||||
if not isinstance(last_active_at, datetime):
|
|
||||||
return False
|
|
||||||
delta = _utc_now_naive() - last_active_at
|
|
||||||
return 0 <= delta.total_seconds() <= float(window_seconds)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _should_inherit_recent_feishu_session_context(
|
|
||||||
cls,
|
|
||||||
*,
|
|
||||||
message_text: str,
|
|
||||||
session: Optional[ConversationSession],
|
|
||||||
) -> bool:
|
|
||||||
if session is None:
|
|
||||||
return False
|
|
||||||
sender = str(getattr(session, "sender", "") or "").strip().lower()
|
|
||||||
if not sender.startswith("feishu:"):
|
|
||||||
return False
|
|
||||||
if not cls._within_recent_window(
|
|
||||||
getattr(session, "last_active_at", None),
|
|
||||||
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
if not str(getattr(session, "last_intent", "") or "").strip():
|
|
||||||
return False
|
|
||||||
if WorkflowSemanticPlanner._derive_query_scene_metadata(message_text):
|
|
||||||
return True
|
|
||||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
|
||||||
if not normalized:
|
|
||||||
return False
|
|
||||||
followup_tokens = (
|
|
||||||
"需求",
|
"需求",
|
||||||
"mvp",
|
"mvp",
|
||||||
"方案",
|
"方案",
|
||||||
|
|
@ -57,8 +26,47 @@ class KernelIntentResolveMixin:
|
||||||
"重新回答",
|
"重新回答",
|
||||||
"高级模型",
|
"高级模型",
|
||||||
"更强模型",
|
"更强模型",
|
||||||
|
"分析",
|
||||||
|
"评审",
|
||||||
|
"怎么做",
|
||||||
)
|
)
|
||||||
return any(token in normalized for token in followup_tokens)
|
|
||||||
|
@staticmethod
|
||||||
|
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
|
||||||
|
if not isinstance(last_active_at, datetime):
|
||||||
|
return False
|
||||||
|
delta = _utc_now_naive() - last_active_at
|
||||||
|
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
|
||||||
|
def _should_inherit_recent_feishu_session_context(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
message_text: str,
|
||||||
|
session: Optional[ConversationSession],
|
||||||
|
) -> bool:
|
||||||
|
if session is None:
|
||||||
|
return False
|
||||||
|
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
|
||||||
|
return False
|
||||||
|
sender = str(getattr(session, "sender", "") or "").strip().lower()
|
||||||
|
if not sender.startswith("feishu:"):
|
||||||
|
return False
|
||||||
|
if not cls._within_recent_window(
|
||||||
|
getattr(session, "last_active_at", None),
|
||||||
|
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if not str(getattr(session, "last_intent", "") or "").strip():
|
||||||
|
return False
|
||||||
|
return cls._looks_like_query_scene_followup(message_text)
|
||||||
|
|
||||||
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")
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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]],
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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=["用户提出新的规划或执行需求"],
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
run_row.execution_outcome,
|
||||||
|
{
|
||||||
"handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False,
|
"handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False,
|
||||||
"status": str((result or {}).get("status") or "").strip() if isinstance(result, dict) else "",
|
"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 "",
|
"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 "",
|
"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,
|
"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.
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"] == "先继续整理语义上下文,不要直接执行。"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in New Issue