diff --git a/CLAUDE.md b/CLAUDE.md index 5dd5d7a4..4704340b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -63,7 +63,8 @@ backend/ │ ├── 20260324_add_project_create_idempotency_indexes.py # 项目创建幂等索引:活动项目标题大小写无关唯一 │ ├── 20260327_cleanup_model_routing_legacy_names.py # model_routing 旧函数名清理与重命名迁移 │ ├── 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/ # 后端运维/数据修复脚本 │ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ ├── dev/ # 开发辅助脚本(check_users/seed_all) @@ -196,19 +197,21 @@ backend/ - 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助) - 新增:`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/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/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/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/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_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/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_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_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` 运行时骨架) @@ -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`(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_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_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_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;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/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/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 组合入口) @@ -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/charter-detail/CharterDetailPage.tsx`、`contract-sync-board/ContractSyncBoardPage.tsx`、`dashboard/DashboardPage.tsx`、`knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx`、`login/LoginPage.tsx`、`model-routing-config/ModelRoutingConfigPage.tsx`(剩余核心页面目录化收敛) - 新增:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`、`skill-tasks/SkillTasksPage.tsx`、`skills/SkillsPage.tsx`、`task-brief-center/TaskBriefCenterPage.tsx`、`task-brief-runs/TaskBriefRunsPage.tsx`、`task-brief-template/TaskBriefTemplatePage.tsx`(任务/调度/技能页面目录化收敛) -- 新增:`frontend/src/pages/scheduler-center/components/MountedAISourceStatusPanel.tsx`、`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/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/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;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/InboxPage.tsx`(降级为轻量入口,转发到 `pages/inbox/InboxPage`) - 新增:`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 - `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` 猜当前阶段 +- 新的 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` 的追问节奏必须像自然聊天:上一轮只确认新增信息、下一轮只问一个缺口;初始描述中的“每日/自动”不能过早推断成已确认的更新频率,避免少问关键问题或提前出草稿 - `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` 指令:优先复用上一轮上下文主题并强制深度回答,禁止把这类话术当成新的系统配置查询 - `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 - “高级模型是否启用/模型路由是否启用/当前是否高配”这类运行态问题,只有命中运行态配置、路由快照、引擎配置或真实调用记录时才允许下结论;若证据只有页面目录、代码图谱或功能清单,回复必须明确为“无法仅凭静态资料确认” - 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `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` - 回复质量闭环接口:参考 `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 训练波次) -- 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`) - 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`) @@ -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) - 写操作后主动 invalidate,动态层不缓存 - 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` - 模型默认值与 provider/tier 映射以 `backend/.openclaw/pmflow-routing-rules.json` 为唯一配置源;`.env` / `.env.example` 仅保留 API key 与运行开关,`OPENCLAW_ROUTING_RULES_PATH` 只用于定位这份文件 - 调用层与路由页面只使用 `low/high` 两层语义;`safety` 已废弃并从配置校验、运行时解析、前端选项中移除 diff --git a/backend/alembic/versions/20260406_add_mounted_runtime_active_draft_fields.py b/backend/alembic/versions/20260406_add_mounted_runtime_active_draft_fields.py new file mode 100644 index 00000000..09cb9f58 --- /dev/null +++ b/backend/alembic/versions/20260406_add_mounted_runtime_active_draft_fields.py @@ -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") diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index f94fb736..b74eb427 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -66,6 +66,37 @@ from .ai_prompt_registry import ( # Deprecated compatibility aliases kept outside the registry surface. PROMPT_FEISHU_REQUIREMENT_ANALYSIS = PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION 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: @@ -198,6 +229,7 @@ __all__ = [ "PROMPT_AI_PMO_REQUIREMENT_PLAN_QA", "PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA", + "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT", "PROMPT_REGISTRY", "PROMPT_REGISTRY_MAP", "get_prompt_registry", diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_01.py b/backend/app/config/ai_prompts_catalog/part_scenes_01.py index fe745cdc..50c82617 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_01.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_01.py @@ -10,7 +10,9 @@ FEISHU_QA_RESPONSE_STYLE = _compose_prompt( 2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。 3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。 4. 若信息不足,必须明确说明缺少什么,最多列 3 项。 -5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 +5. 可使用 1-3 个轻量 emoji 做分段,但不要花哨,不要影响专业感。 +6. 飞书输出优先短段落和清晰换行;如果包含“结论/依据/下一步/信息不足”,要让这些段落一眼可扫读。 +7. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 """.strip(), TEXT_OUTPUT_GUARDRAIL, ) @@ -21,7 +23,7 @@ PROMPT_FEISHU_DIRECT_QA = _compose_prompt( """ 回答要求: 1. 仅基于给定上下文回答,优先使用结构化数据。 -2. 如果问题涉及项目状态、进展、成员或任务,请优先给出可验证的事实和数字。 +2. 如果问题涉及项目状态、进展、成员或任务,请优先引用项目画像、检索证据和用户焦点里的可验证事实与数字。 3. 如果问题涉及选择、建议或判断,直接给出明确结论和 1-2 条理由。 4. 信息不足时,直接写“信息不足,缺少:...”并列出关键缺失项。 5. 控制在 220 字以内,适合飞书消息阅读。 @@ -30,10 +32,11 @@ PROMPT_FEISHU_DIRECT_QA = _compose_prompt( 当前项目ID:{project_id} 问题:{question} -System Snapshot:{system_snapshot} -User Snapshot:{user_snapshot} -最近会话:{conversation} -检索上下文:{context_blocks} +项目画像:{project_manifest} +系统摘要:{system_summary} +用户焦点:{user_focus} +最近会话摘要:{conversation} +检索证据:{context_blocks} """.strip(), ) diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_05.py b/backend/app/config/ai_prompts_catalog/part_scenes_05.py index bb18d999..8fe9a5e6 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_05.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_05.py @@ -52,6 +52,21 @@ PROMPT_AI_PMO_REQUIREMENT_PLAN_QA = _compose_prompt( 6. 不能把未发生的创建、入库、执行写成已完成;如果用户没明确要求,不要推进执行。 7. 如果上下文不够,只能说“我先帮你收敛需求”,并指出最关键缺口;不要编造系统已有模块或项目归属。 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} 当前项目:{project_id} diff --git a/backend/app/models/mounted_ai_runtime.py b/backend/app/models/mounted_ai_runtime.py index 5c06c90a..7ac53be9 100644 --- a/backend/app/models/mounted_ai_runtime.py +++ b/backend/app/models/mounted_ai_runtime.py @@ -4,7 +4,7 @@ from __future__ import annotations 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 .base import Base @@ -68,6 +68,9 @@ class MountedDraftWorkspace(Base): draft_payload: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) required_confirmations: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) last_ready_at: Mapped[Optional[TIMESTAMP]] = mapped_column(TIMESTAMP, nullable=True) + 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) diff --git a/backend/app/schemas/mounted_ai_runtime.py b/backend/app/schemas/mounted_ai_runtime.py index a93c2adb..18a3813b 100644 --- a/backend/app/schemas/mounted_ai_runtime.py +++ b/backend/app/schemas/mounted_ai_runtime.py @@ -150,6 +150,20 @@ class MountedAIRunListPayload(BaseModel): 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): model_config = ConfigDict(extra="ignore") @@ -168,10 +182,16 @@ class MountedAIRunDetailPayload(BaseModel): model_config = ConfigDict(extra="ignore") item: MountedAIRunItem + topic: Optional[MountedAITopicItem] = None + draft: Optional[MountedAIDraftItem] = None agent_traces: list[MountedAIAgentTraceItem] = Field(default_factory=list) context_layers_hit: Optional[Any] = None tool_calls: 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): diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py index 9f68513c..5f302053 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py @@ -92,6 +92,178 @@ class AdaptiveRAGDeepGuardAnswerMixin: kept.append(text) 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 def _is_ai_pmo_process_entry(asked_via: Optional[str]) -> bool: channel = str(asked_via or "").strip().lower() @@ -444,6 +616,15 @@ class AdaptiveRAGDeepGuardAnswerMixin: normalized_asked_via = str(asked_via or "").strip().lower() 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 = ( "feishu_direct", await self._render_scene_prompt( @@ -451,10 +632,11 @@ class AdaptiveRAGDeepGuardAnswerMixin: fallback_template=PROMPT_FEISHU_DIRECT_QA, project_id=project_id if project_id is not None else "unknown", question=question, - system_snapshot=system_snapshot_text or "{}", - user_snapshot=user_snapshot_text or "{}", - conversation=conversation_text or "无", - context_blocks=context_blocks_text or "无", + project_manifest=project_manifest_text, + system_summary=system_summary_text, + user_focus=user_focus_text, + conversation=compact_conversation_text, + context_blocks=compact_context_blocks_text, ), ) return self._apply_consultant_mode_preference( diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py index e8fa28e8..6536d4c3 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py @@ -70,6 +70,7 @@ from ..ai_pmo_intent import ( ROUTE_ACTION_ACK, ROUTE_ACTION_CLOSE, ROUTE_ACTION_DISCOVER, + ROUTE_PROJECT_CREATE, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_UPDATE, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py index 282b0f43..4772f6de 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py @@ -3,7 +3,6 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common -from ....workflow_semantic_planner import WorkflowSemanticPlanner for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -11,6 +10,42 @@ for _name, _value in vars(_common).items(): 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( self, *, @@ -94,18 +129,16 @@ class KernelIngressRenderMixin: else None ) if not isinstance(semantic_bundle, dict): - semantic_bundle = WorkflowSemanticPlanner.plan_from_intent( - intent_name=str(intent.name or ""), - message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""), + semantic_bundle = self._build_semantic_bundle_compat( + intent=intent, + workflow_route=workflow_route, source_system=str(getattr(work_item, "source_system", "") or "unknown"), - workflow_route=str(workflow_route or ""), - project_id=_safe_int(work_item.project_id), - metadata=intent.metadata if isinstance(intent.metadata, dict) else {}, - ).to_dict() + message_text=str(getattr(work_item, "raw_message", "") or direct_reply_text or intent.signal_summary or ""), + ) result["semantic_bundle"] = semantic_bundle 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_source"] = "workflow_semantic_planner" + result["semantic_source"] = "direct_metadata_compat" if str(render.get("template_key") or "").strip().lower() == "draft_pending_confirm": 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)) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py index 1083bcbe..e022d1b5 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py @@ -3,7 +3,6 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common -from ....workflow_semantic_planner import WorkflowSemanticPlanner for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -199,26 +198,6 @@ class KernelIntentLLMMixin: if structured_route is not None 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(), }, ) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py index 9b99479e..79b0e491 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py @@ -11,6 +11,26 @@ for _name, _value in vars(_common).items(): class KernelIntentResolveMixin: + _QUERY_FOLLOWUP_TOKENS = ( + "需求", + "mvp", + "方案", + "对应", + "这个", + "它", + "给我", + "补充", + "展开", + "细化", + "重答", + "重新回答", + "高级模型", + "更强模型", + "分析", + "评审", + "怎么做", + ) + @staticmethod def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool: if not isinstance(last_active_at, datetime): @@ -18,6 +38,13 @@ class KernelIntentResolveMixin: 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, @@ -27,6 +54,8 @@ class KernelIntentResolveMixin: ) -> 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 @@ -37,28 +66,7 @@ class KernelIntentResolveMixin: 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", - "方案", - "对应", - "这个", - "它", - "给我", - "补充", - "展开", - "细化", - "重答", - "重新回答", - "高级模型", - "更强模型", - ) - return any(token in normalized for token in followup_tokens) + return cls._looks_like_query_scene_followup(message_text) async def _resolve_intent( self, @@ -101,29 +109,6 @@ class KernelIntentResolveMixin: ) 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(): raise RuntimeError("intent_model_only_mode_requires_llm_enabled") diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py index 16ce8e50..30cf355e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py @@ -110,6 +110,7 @@ class KernelWorldModelSessionMixin: source_message_id: Optional[str] = None, thread_context: Optional[dict[str, Any]] = None, current_project_id: Optional[int], + clear_current_project_id: bool = False, last_intent: Optional[str], last_message_text: Optional[str], ) -> None: @@ -122,7 +123,9 @@ class KernelWorldModelSessionMixin: source_message_id=source_message_id, 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.last_intent = (str(last_intent or "").strip()[:64] or None) trimmed_message = str(last_message_text or "").strip() diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py index 54a38e72..b96dc0f2 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py @@ -209,6 +209,30 @@ class KernelIngressFlowAutoMixin: channel_ref=channel_ref, ) 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) if isinstance(execution_result, dict): execution_result.setdefault("decision_trace", decision_trace) @@ -405,6 +429,262 @@ class KernelIngressFlowAutoMixin: return str(getattr(workflow_route, "action", "") or "") == ROUTE_ACTION_CREATE 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 def _build_capacity_warning_text( *, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index ddf6a23f..9315d67a 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -2,6 +2,11 @@ from __future__ import annotations from .shared import * 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.constants import ( QUERY_SCENE_BUSINESS_REVIEW, @@ -27,6 +32,47 @@ class KernelIngressFlowEntryMixin: QUERY_SCENE_CORRECTION_ESCALATION, 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 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) if value not in (None, ""): 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 + @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 def _looks_like_mixed_candidate(cls, *, text: str) -> bool: normalized = re.sub(r"\s+", "", str(text or "")).lower() @@ -73,30 +129,12 @@ class KernelIngressFlowEntryMixin: semantic_bundle: Optional[dict[str, Any]], ) -> dict[str, Any]: context = rag_request_context if isinstance(rag_request_context, dict) else {} - semantic_diagnostics = ( - 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) + derived = cls._derive_query_scene_metadata(message_text) scene_metadata: dict[str, Any] = { key: value for key, value in derived.items() 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: value = context.get(key) if value in (None, ""): @@ -115,20 +153,14 @@ class KernelIngressFlowEntryMixin: semantic_bundle: Optional[dict[str, Any]], scene_metadata: dict[str, Any], ) -> bool: + _ = semantic_bundle message_intent = str(scene_metadata.get("message_intent") or "").strip().lower() if message_intent not in cls._RAG_QUERY_SCENES: return False if isinstance(rag_request_context, dict): action_mode = str(rag_request_context.get("action_mode") or "auto").strip().lower() or "auto" return action_mode not in {"write", "read_write"} - if 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 + return str(source_system or "").strip().lower() == "feishu" def _coerce_intent_for_query_scene( self, @@ -140,39 +172,27 @@ class KernelIngressFlowEntryMixin: semantic_bundle: Optional[dict[str, Any]], world_model: dict[str, Any], ) -> 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( source_system=source_system, message_text=message_text, 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( source_system=source_system, rag_request_context=rag_request_context, - semantic_bundle=effective_semantic_bundle, + semantic_bundle=semantic_bundle, scene_metadata=scene_metadata, ): return intent 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 = { **(intent.metadata or {}), **scene_metadata, @@ -181,7 +201,6 @@ class KernelIngressFlowEntryMixin: "workflow_family": ROUTE_FAMILY_QUERY, "workflow_action": ROUTE_ACTION_ANSWER, "requires_confirmation": False, - "semantic_bundle": semantic_bundle, } pretty = re.sub(r"\s+", " ", str(message_text or "")).strip() return CanonicalIntent( @@ -466,6 +485,91 @@ class KernelIngressFlowEntryMixin: 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( *, workflow_route: str, @@ -541,8 +645,12 @@ class KernelIngressFlowEntryMixin: ingest_event=ingest_event, ingress_options=ingress_options, ) - if self._should_route_to_draft_confirmation( + if await self._should_route_to_draft_confirmation_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, ingress_options=ingress_options, ): @@ -603,6 +711,26 @@ class KernelIngressFlowEntryMixin: ) await _safe_finalize_mounted_runtime(result=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] = {} if not self._is_short_confirmation_message( message_text=clean_text, @@ -706,6 +834,12 @@ class KernelIngressFlowEntryMixin: workflow_route.metadata = route_metadata perf_intent_name = workflow_route.key _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: world_model["context_source"] = intent_context_source work_item.context_snapshot = world_model @@ -1213,6 +1347,134 @@ class KernelIngressFlowEntryMixin: 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 def _merge_ingress_thread_context( cls, diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index 533d7d48..74124d3d 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -55,7 +55,9 @@ class ExecuteQueryProjectMixin: message_intent: Optional[str], ) -> bool: normalized_intent = str(message_intent or "").strip().lower() - return normalized_intent == "casual_chat" or WorkflowSemanticPlanner._looks_like_casual_chat_message(message_text) + if normalized_intent: + return normalized_intent == "casual_chat" + return WorkflowSemanticPlanner._looks_like_casual_chat_message(message_text) @classmethod def _should_use_requirement_clarify_answer( @@ -67,8 +69,8 @@ class ExecuteQueryProjectMixin: ) -> bool: normalized_intent = str(message_intent or "").strip().lower() normalized_mode = str(consultant_mode_preference or "").strip().lower() - if normalized_intent != "business_review" or normalized_mode != "clarify": - return False + if normalized_intent or normalized_mode: + return normalized_intent == "business_review" and normalized_mode == "clarify" return WorkflowSemanticPlanner._looks_like_requirement_followup_clarify(message_text) @classmethod @@ -78,9 +80,9 @@ class ExecuteQueryProjectMixin: message_text: str, message_intent: Optional[str], consultant_mode_preference: Optional[str], + advise_subtype: Optional[str], ) -> bool: normalized_intent = str(message_intent or "").strip().lower() - normalized_mode = str(consultant_mode_preference or "").strip().lower() if cls._should_use_requirement_clarify_answer( message_text=message_text, message_intent=message_intent, @@ -91,18 +93,20 @@ class ExecuteQueryProjectMixin: message_text=message_text, message_intent=message_intent, consultant_mode_preference=consultant_mode_preference, + advise_subtype=advise_subtype, ): return False if cls._should_use_assignee_recommendation_answer( message_text=message_text, message_intent=message_intent, consultant_mode_preference=consultant_mode_preference, + advise_subtype=advise_subtype, ): return False if normalized_intent == "requirement_plan": return True - if normalized_intent == "business_review" and normalized_mode == "clarify": - return WorkflowSemanticPlanner._should_keep_requirement_discussion_in_query(message_text) + if normalized_intent or str(consultant_mode_preference or "").strip(): + return False return WorkflowSemanticPlanner._should_keep_requirement_discussion_in_query(message_text) @classmethod @@ -112,11 +116,17 @@ class ExecuteQueryProjectMixin: message_text: str, message_intent: Optional[str], consultant_mode_preference: Optional[str], + advise_subtype: Optional[str], ) -> bool: normalized_intent = str(message_intent or "").strip().lower() normalized_mode = str(consultant_mode_preference or "").strip().lower() - if normalized_intent == "business_review" and normalized_mode == "advise": - return True + normalized_subtype = str(advise_subtype or "").strip().lower() + if normalized_intent or normalized_mode or normalized_subtype: + return ( + normalized_intent == "business_review" + and normalized_mode == "advise" + and normalized_subtype != "assignee" + ) return WorkflowSemanticPlanner._looks_like_requirement_scope_advise(message_text) @classmethod @@ -126,11 +136,17 @@ class ExecuteQueryProjectMixin: message_text: str, message_intent: Optional[str], consultant_mode_preference: Optional[str], + advise_subtype: Optional[str], ) -> bool: normalized_intent = str(message_intent or "").strip().lower() normalized_mode = str(consultant_mode_preference or "").strip().lower() - if normalized_intent == "business_review" and normalized_mode == "advise": - return WorkflowSemanticPlanner._looks_like_assignee_recommendation(message_text) + normalized_subtype = str(advise_subtype or "").strip().lower() + if normalized_intent or normalized_mode or normalized_subtype: + return ( + normalized_intent == "business_review" + and normalized_mode == "advise" + and normalized_subtype == "assignee" + ) return WorkflowSemanticPlanner._looks_like_assignee_recommendation(message_text) @staticmethod @@ -148,8 +164,11 @@ class ExecuteQueryProjectMixin: def _build_requirement_clarify_answer(*, message_text: str) -> str: _ = message_text return ( - "可以,我先按轻量版继续理解。你先确认这次最先收窄的是哪一块:目标对象、交付能力,还是覆盖范围;" - "你定一个优先级,我再继续往下收。" + "可以,我先按轻量版继续理解。你先选这次最先收窄的优先级,直接回 A/B/C 就行:\n" + "A. 目标对象:这套自动化最先服务谁\n" + "B. 交付能力:第一版必须先交付什么能力\n" + "C. 覆盖范围:第一版先覆盖哪些部署场景\n" + "你回一个字母,我就顺着这一项继续往下收。" ) @staticmethod @@ -163,7 +182,7 @@ class ExecuteQueryProjectMixin: @classmethod def _detect_requirement_plan_profile(cls, *, message_text: str, card: dict[str, Any]) -> str: existing = str(card.get("plan_profile") or "").strip().lower() - if existing in {"scoring", "generic"}: + if existing in {"scoring", "generic", "it_project"}: return existing normalized = WorkflowSemanticPlanner._normalize_text( " ".join( @@ -173,14 +192,185 @@ class ExecuteQueryProjectMixin: str(card.get("title") or ""), str(card.get("goal") or ""), str(card.get("source_material") or ""), + str(card.get("project_nature") or ""), + str(card.get("tech_route") or ""), + str(card.get("delivery_mode") or ""), ) if str(part or "").strip() ) ) if any(token in normalized for token in ("打分", "评分", "分数", "趋势分")): return "scoring" + if cls._looks_like_it_project_requirement(normalized): + return "it_project" return "generic" + @staticmethod + def _looks_like_it_project_requirement(normalized: str) -> bool: + text = str(normalized or "").strip().lower() + if not text: + return False + strong_tokens = ( + "it项目", + "技术项目", + "openclaw", + "自动化部署", + "批量部署", + "发布治理", + "部署治理", + "发布平台", + "部署平台", + "任务编排", + "环境接入", + "回滚", + "审批", + "平台运维", + "运维团队", + "fastapi", + "react", + "redis", + "celery", + "postgres", + "postgresql", + "k8s", + "kubernetes", + "helm", + "argocd", + "terraform", + "ansible", + "ci/cd", + "cicd", + "jenkins", + ) + if any(token in text for token in strong_tokens): + return True + has_delivery_shape = any(token in text for token in ("部署", "发布", "上线", "交付")) + has_platform_shape = any(token in text for token in ("自动化", "批量", "治理", "回滚", "审批", "环境")) + return has_delivery_shape and has_platform_shape + + @staticmethod + def _extract_requirement_plan_choice(message_text: str) -> Optional[str]: + match = re.match(r"^\s*([ABCabc])(?:[\.\,,。、::)\]】\s]|$)", str(message_text or "")) + if not match: + return None + return match.group(1).strip().upper() + + @classmethod + def _strip_requirement_plan_choice_prefix(cls, message_text: str) -> str: + choice = cls._extract_requirement_plan_choice(message_text) + if not choice: + return str(message_text or "").strip() + return re.sub(r"^\s*[ABCabc](?:[\.\,,。、::)\]】\s]+)?", "", str(message_text or "")).strip() + + @staticmethod + def _it_project_choice_value(*, field: str, choice: str) -> Optional[str]: + mapping = { + "project_nature": { + "A": "内部提效工具", + "B": "发布治理平台", + "C": "云原生交付底座", + }, + "target_user": { + "A": "开发团队", + "B": "平台运维团队", + "C": "项目交付团队", + }, + "tech_route": { + "A": "脚本增强版(FastAPI + Shell/Ansible + PostgreSQL)", + "B": "任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)", + "C": "云原生交付版(Kubernetes + Helm + ArgoCD/Terraform + FastAPI)", + }, + "first_scope": { + "A": "第一版先做批量执行闭环", + "B": "第一版先做环境接入闭环", + "C": "第一版先做治理闭环", + }, + "delivery_mode": { + "A": "内部 PoC", + "B": "项目级试点(带基础审批和回滚)", + "C": "平台化上线", + }, + } + normalized_field = str(field or "").strip() + normalized_choice = str(choice or "").strip().upper() + return mapping.get(normalized_field, {}).get(normalized_choice) + + @staticmethod + def _generic_choice_value(*, field: str, choice: str) -> Optional[str]: + mapping = { + "source_material": { + "A": "用户当前需求描述", + "B": "会议纪要/文档", + "C": "录音/转写/回放", + }, + "target_user": { + "A": "核心执行人/内部 PM", + "B": "负责人/管理者", + "C": "整个团队/终端用户", + }, + "output_form": { + "A": "飞书总结", + "B": "飞书消息", + "C": "单独页面", + }, + "cadence": { + "A": "触发后半小时内", + "B": "当天内", + "C": "第二天", + }, + } + normalized_field = str(field or "").strip() + normalized_choice = str(choice or "").strip().upper() + return mapping.get(normalized_field, {}).get(normalized_choice) + + @staticmethod + def _compact_requirement_plan_label(value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return text.split("(", 1)[0].strip() + + @classmethod + def _summarize_it_project_choice_label(cls, *, field: str, value: Any) -> str: + text = str(value or "").strip() + normalized = WorkflowSemanticPlanner._normalize_text(text) + if field == "project_nature": + if "内部提效工具" in normalized: + return "内部提效工具" + if any(token in normalized for token in ("发布治理平台", "部署治理平台", "治理平台")): + return "发布治理平台" + if any(token in normalized for token in ("云原生交付底座", "交付底座")): + return "云原生交付底座" + if field == "target_user": + if any(token in normalized for token in ("平台运维", "运维团队")): + return "平台运维团队" + if any(token in normalized for token in ("开发团队", "研发团队", "开发")): + return "开发团队" + if any(token in normalized for token in ("项目交付", "交付团队")): + return "项目交付团队" + if field == "tech_route": + if any(token in normalized for token in ("脚本增强", "shell", "ansible")): + return "脚本增强版" + if any(token in normalized for token in ("任务编排平台版", "celery", "redis")): + return "任务编排平台版" + if any(token in normalized for token in ("云原生交付版", "kubernetes", "argocd", "terraform", "helm")): + return "云原生交付版" + if field == "first_scope": + if any(token in normalized for token in ("批量执行闭环", "任务创建", "执行日志", "失败重试")): + return "批量执行闭环" + if any(token in normalized for token in ("环境接入闭环", "目标机器", "环境注册", "凭证管理")): + return "环境接入闭环" + if any(token in normalized for token in ("治理闭环", "审批", "回滚", "审计")): + return "治理闭环" + if field == "delivery_mode": + if any(token in normalized for token in ("内部poc", "内部验证", "poc")): + return "内部 PoC" + if "项目级试点" in normalized or "试点" in normalized: + return "项目级试点" + if any(token in normalized for token in ("平台化上线", "正式上线", "多团队接入")): + return "平台化上线" + return cls._compact_requirement_plan_label(text).rstrip("。.!!??") + @staticmethod def _sanitize_requirement_plan_text(message_text: str) -> str: cleaned = re.sub( @@ -354,6 +544,13 @@ class ExecuteQueryProjectMixin: return "会议纪要待办提取工具" if any(token in normalized for token in ("会议纪要", "会议记录", "纪要")): return "会议纪要整理工具" + if profile == "it_project": + if "openclaw" in normalized and any(token in normalized for token in ("部署", "发布")): + return "OpenClaw 批量自动化部署平台" + if any(token in normalized for token in ("自动化部署", "批量部署")): + return "批量自动化部署平台" + if any(token in normalized for token in ("发布治理", "部署治理")): + return "发布治理平台" if any(token in normalized for token in ("大a", "a股", "沪深")) and any( token in normalized for token in ("打分", "评分", "分数") ): @@ -378,6 +575,10 @@ class ExecuteQueryProjectMixin: ): return "会后自动整理待办,减少信息散落,让负责人能快速跟进。" return "先把第一版核心能力收清楚,再决定是否正式创建。" + if profile == "it_project": + if "openclaw" in normalized and any(token in normalized for token in ("部署", "发布")): + return "先把 OpenClaw 批量部署、执行追踪和回滚治理收成一条可复用流程,再决定是否正式创建。" + return "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。" if any(token in normalized for token in ("大a", "a股", "沪深")) and any( token in normalized for token in ("打分", "评分", "分数") ): @@ -390,6 +591,11 @@ class ExecuteQueryProjectMixin: normalized = WorkflowSemanticPlanner._normalize_text(raw) updates: dict[str, Any] = {} profile = cls._detect_requirement_plan_profile(message_text=raw, card=card) + choice = cls._extract_requirement_plan_choice(raw) + cleaned_raw = cls._strip_requirement_plan_choice_prefix(raw) + current_card = dict(card or {}) + current_card["plan_profile"] = profile + missing_fields = cls._requirement_plan_missing_fields(current_card) updates["plan_profile"] = profile if not card.get("title"): updates["title"] = cls._derive_requirement_plan_title(message_text=raw, card=card) @@ -403,6 +609,17 @@ class ExecuteQueryProjectMixin: updates["source_material"] = "会议录音/转写" elif any(token in normalized for token in ("直播录屏", "回放")): updates["source_material"] = "直播录屏/回放" + elif WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(raw): + topic_tail = "" + if "关于" in normalized: + topic_tail = normalized.partition("关于")[2] + topic_tail = re.sub(r"[@_a-z0-9]+", "", topic_tail) + if len(topic_tail) >= 4: + updates["source_material"] = "用户当前需求描述" + elif choice and missing_fields and missing_fields[0] == "source_material": + mapped = cls._generic_choice_value(field="source_material", choice=choice) + if mapped: + updates["source_material"] = mapped if profile == "scoring" and not card.get("scoring_basis"): if any(token in normalized for token in ("技术指标", "均线", "成交量", "涨跌比", "量价", "macd", "rsi")): @@ -412,7 +629,75 @@ class ExecuteQueryProjectMixin: elif any(token in normalized for token in ("规则", "已有逻辑", "现成逻辑", "自动化")): updates["scoring_basis"] = "沿用现有打分规则并自动化" - if not card.get("target_user"): + if profile == "it_project": + if not card.get("project_nature"): + if any(token in normalized for token in ("内部提效工具", "脚本工具", "提效工具")): + updates["project_nature"] = "内部提效工具" + elif any(token in normalized for token in ("发布治理平台", "部署治理平台", "治理平台")): + updates["project_nature"] = "发布治理平台" + elif any(token in normalized for token in ("云原生交付底座", "交付底座")): + updates["project_nature"] = "云原生交付底座" + elif choice and missing_fields and missing_fields[0] == "project_nature": + mapped = cls._it_project_choice_value(field="project_nature", choice=choice) + if mapped: + updates["project_nature"] = mapped + + if not card.get("target_user"): + target_match = re.search(r"(?:主要)?给([^,。;;]{1,24}?)(?:看|用)", cleaned_raw) + if target_match: + updates["target_user"] = target_match.group(1).strip() + elif any(token in cleaned_raw for token in ("平台运维", "运维团队")): + updates["target_user"] = "平台运维团队" + elif any(token in cleaned_raw for token in ("开发团队", "研发团队", "开发")): + updates["target_user"] = "开发团队" + elif any(token in cleaned_raw for token in ("项目交付", "交付团队")): + updates["target_user"] = "项目交付团队" + elif choice and missing_fields and missing_fields[0] == "target_user": + mapped = cls._it_project_choice_value(field="target_user", choice=choice) + if mapped: + updates["target_user"] = mapped + + if not card.get("tech_route"): + if all(token in normalized for token in ("fastapi", "react")) and any( + token in normalized for token in ("celery", "redis", "postgres", "postgresql") + ): + updates["tech_route"] = "任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" + elif any(token in normalized for token in ("shell", "ansible")) or "脚本增强" in normalized: + updates["tech_route"] = "脚本增强版(FastAPI + Shell/Ansible + PostgreSQL)" + elif any(token in normalized for token in ("k8s", "kubernetes", "helm", "argocd", "terraform")): + updates["tech_route"] = "云原生交付版(Kubernetes + Helm + ArgoCD/Terraform + FastAPI)" + elif "任务编排平台版" in cleaned_raw: + updates["tech_route"] = "任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" + elif choice and missing_fields and missing_fields[0] == "tech_route": + mapped = cls._it_project_choice_value(field="tech_route", choice=choice) + if mapped: + updates["tech_route"] = mapped + + if not card.get("first_scope"): + if any(token in cleaned_raw for token in ("批量执行闭环", "任务创建", "执行日志", "失败重试")): + updates["first_scope"] = cleaned_raw + elif any(token in cleaned_raw for token in ("环境接入闭环", "目标机器", "环境注册", "凭证管理")): + updates["first_scope"] = cleaned_raw + elif any(token in cleaned_raw for token in ("治理闭环", "审批", "回滚", "审计")): + updates["first_scope"] = cleaned_raw + elif choice and missing_fields and missing_fields[0] == "first_scope": + mapped = cls._it_project_choice_value(field="first_scope", choice=choice) + if mapped: + updates["first_scope"] = mapped + + if not card.get("delivery_mode"): + if any(token in normalized for token in ("poc", "验证版", "内部验证", "内部poc")): + updates["delivery_mode"] = "内部 PoC" + elif any(token in cleaned_raw for token in ("项目级试点", "试点")): + updates["delivery_mode"] = "项目级试点(带基础审批和回滚)" + elif any(token in cleaned_raw for token in ("平台化上线", "正式上线", "多团队接入")): + updates["delivery_mode"] = "平台化上线" + elif choice and missing_fields and missing_fields[0] == "delivery_mode": + mapped = cls._it_project_choice_value(field="delivery_mode", choice=choice) + if mapped: + updates["delivery_mode"] = mapped + + if profile != "it_project" and not card.get("target_user"): target_match = re.search(r"(?:主要)?给([^,。;;]{1,24}?)(?:看|用)", raw) if target_match: updates["target_user"] = target_match.group(1).strip() @@ -426,8 +711,12 @@ class ExecuteQueryProjectMixin: updates["target_user"] = raw.strip("。;;") elif "自己" in raw: updates["target_user"] = "自己" + elif profile == "generic" and choice and missing_fields and missing_fields[0] == "target_user": + mapped = cls._generic_choice_value(field="target_user", choice=choice) + if mapped: + updates["target_user"] = mapped - if not card.get("output_form"): + if profile != "it_project" and not card.get("output_form"): if "飞书总结" in raw: updates["output_form"] = "飞书总结" elif "飞书日报" in raw: @@ -438,8 +727,12 @@ class ExecuteQueryProjectMixin: updates["output_form"] = "页面看板" elif "简报" in raw: updates["output_form"] = "简报" + elif profile == "generic" and choice and missing_fields and missing_fields[0] == "output_form": + mapped = cls._generic_choice_value(field="output_form", choice=choice) + if mapped: + updates["output_form"] = mapped - if not card.get("cadence"): + if profile != "it_project" and not card.get("cadence"): if any(token in normalized for token in ("每次会后半小时内", "会后半小时内")): updates["cadence"] = "每次会后半小时内" elif any(token in normalized for token in ("每日收盘后", "每天收盘后", "收盘后")): @@ -454,8 +747,12 @@ class ExecuteQueryProjectMixin: token in normalized for token in ("更新", "发", "推送", "同步", "生成", "刷新", "跑") ): updates["cadence"] = "每日" + elif profile == "generic" and choice and missing_fields and missing_fields[0] == "cadence": + mapped = cls._generic_choice_value(field="cadence", choice=choice) + if mapped: + updates["cadence"] = mapped - if not card.get("first_scope") and any( + if profile != "it_project" and not card.get("first_scope") and any( token in normalized for token in ("先保留", "先只做", "别做太重", "范围够轻", "方向判断", "先抽", "先提", "不做复杂") ) and not any(token in normalized for token in ("先别创建", "别创建", "先收第一版", "先收一版")): @@ -473,6 +770,9 @@ class ExecuteQueryProjectMixin: def _requirement_plan_missing_fields(cls, card: dict[str, Any]) -> list[str]: profile = cls._detect_requirement_plan_profile(message_text="", card=card) ordered_fields = ( + ("project_nature", "target_user", "tech_route", "first_scope", "delivery_mode") + if profile == "it_project" + else ("scoring_basis", "target_user", "output_form", "cadence", "first_scope") if profile == "scoring" else ("source_material", "target_user", "output_form", "cadence", "first_scope") @@ -482,10 +782,26 @@ class ExecuteQueryProjectMixin: @classmethod def _build_requirement_plan_ack(cls, *, updates: dict[str, Any], profile: str) -> str: parts: list[str] = [] + if profile == "it_project": + if str(updates.get("project_nature") or "").strip(): + parts.append(f"项目先按{cls._summarize_it_project_choice_label(field='project_nature', value=updates['project_nature'])}来收") + if str(updates.get("target_user") or "").strip(): + parts.append(f"主要服务{cls._summarize_it_project_choice_label(field='target_user', value=updates['target_user'])}") + if str(updates.get("tech_route") or "").strip(): + parts.append(f"技术路线先按{cls._summarize_it_project_choice_label(field='tech_route', value=updates['tech_route'])}") + if str(updates.get("first_scope") or "").strip(): + parts.append(f"第一版先收在{cls._summarize_it_project_choice_label(field='first_scope', value=updates['first_scope'])}") + if str(updates.get("delivery_mode") or "").strip(): + parts.append(f"推进方式先按{cls._summarize_it_project_choice_label(field='delivery_mode', value=updates['delivery_mode'])}") + if not parts: + return "" + return f"好,我先按这个方向继续记:{';'.join(parts[:2])}。" if profile == "scoring" and str(updates.get("scoring_basis") or "").strip(): parts.append(f"第一版按{updates['scoring_basis']}来打分") - if profile == "generic" and str(updates.get("source_material") or "").strip(): - parts.append(f"输入先按{updates['source_material']}来") + if profile == "generic": + source_material = str(updates.get("source_material") or "").strip() + if source_material and source_material != "用户当前需求描述": + parts.append(f"输入先按{source_material}来") if str(updates.get("target_user") or "").strip(): parts.append(f"主要给{updates['target_user']}用") if str(updates.get("output_form") or "").strip(): @@ -496,8 +812,135 @@ class ExecuteQueryProjectMixin: return "" return f"好,我先记下:{','.join(parts[:2])}。" - @staticmethod - def _build_requirement_plan_next_question(*, missing_field: str, profile: str, message_text: str = "") -> str: + @classmethod + def _build_requirement_plan_next_question( + cls, + *, + missing_field: str, + profile: str, + message_text: str = "", + card: Optional[dict[str, Any]] = None, + ) -> str: + if profile == "it_project": + current_card = dict(card or {}) + current_project_nature = cls._summarize_it_project_choice_label(field="project_nature", value=current_card.get("project_nature") or "当前方向") + current_target_user = cls._summarize_it_project_choice_label(field="target_user", value=current_card.get("target_user") or "当前团队") + current_tech_route = cls._summarize_it_project_choice_label(field="tech_route", value=current_card.get("tech_route") or "当前技术路线") + current_first_scope = cls._summarize_it_project_choice_label(field="first_scope", value=current_card.get("first_scope") or "当前范围") + if missing_field == "project_nature": + return ( + "🧭 我先按“批量自动化部署 OpenClaw”这类 IT 项目来收。\n" + "现在最关键的,不是立刻展开做法,而是先定它更像工具、平台,还是长期底座。\n\n" + "A. 先做内部提效工具\n" + "更适合:先减少重复部署和手工操作\n" + "推荐技术/能力:FastAPI + React + PostgreSQL\n" + "我为什么会考虑这个:开发快,适合先把 MVP 跑通\n" + "代价:后续任务编排和治理能力要继续补\n\n" + "B. 先做发布治理平台\n" + "更适合:多人协作、多环境发布、需要审批和回滚\n" + "推荐技术/能力:FastAPI + React + PostgreSQL + Redis\n" + "我当前更偏这个:更适合承接状态流转、执行追踪和治理规则\n" + "代价:前期设计复杂度高于 A\n\n" + "C. 直接做云原生交付底座\n" + "更适合:目标是多环境、多实例、规模化标准交付\n" + "推荐技术/能力:Kubernetes + Helm + ArgoCD/Terraform\n" + "我为什么暂时不主推:长期上限最高,但基础设施门槛也最大\n" + "代价:基础设施门槛和早期投入最大\n\n" + "⭐ 当前我更推荐:B\n" + "原因:你提的是批量自动化部署,通常核心矛盾不只是提效,而是发布治理和流程标准化。\n\n" + "你回 A / B / C,我就顺着这条继续往下收。" + ) + if missing_field == "target_user": + return ( + f"🧭 我先按“{current_project_nature}”这个方向继续收。\n" + "现在要先把服务对象定住,不然后面做执行体验、治理能力还是项目复制,重心会飘。\n\n" + "A. 开发团队\n" + "更适合:开发自己发起部署、追执行结果\n" + "推荐技术/能力:一键执行、日志查看、失败重试\n" + "我为什么会考虑这个:反馈最快,最适合先验证使用价值\n" + "代价:治理能力会相对弱一些\n\n" + "B. 平台运维团队\n" + "更适合:统一管理环境、凭证、发布窗口和回滚\n" + "推荐技术/能力:批量执行、审批、审计、回滚\n" + "我当前更偏这个:最符合部署治理场景,风险控制也更强\n" + "代价:前期需要更清晰的权限和流程边界\n\n" + "C. 项目交付团队\n" + "更适合:按项目模板复制环境或服务\n" + "推荐技术/能力:项目模板、环境参数、标准交付流程\n" + "我为什么会考虑这个:适合多项目复制交付\n" + "代价:前提是流程已经比较稳定\n\n" + "⭐ 当前我更推荐:B\n" + "原因:先把平台运维场景收清楚,后面的技术路线、权限边界和治理要求会更稳。\n\n" + "你回 A / B / C,我就顺着这条继续往下收。" + ) + if missing_field == "tech_route": + return ( + f"🧭 我先按“{current_target_user}要长期使用”这个方向继续收。\n" + "现在最关键的,不是再补更多背景,而是先把技术路线定住,因为它会直接影响第一版工期和后续扩展方式。\n\n" + "A. 先做脚本增强版\n" + "更适合:先快速验证批量执行闭环\n" + "推荐技术/能力:FastAPI + Shell/Ansible + PostgreSQL\n" + "我为什么会考虑这个:最快能落地,适合先把执行链跑通\n" + "代价:任务编排和可观测性上限一般\n\n" + "B. 先做任务编排平台版\n" + "更适合:长期要做批量任务、状态机、失败重试和执行追踪\n" + "推荐技术/能力:FastAPI + React + Redis + Celery + PostgreSQL\n" + "我当前更偏这个:最平衡,既能落地又保留平台化空间\n" + "代价:系统复杂度高于 A\n\n" + "C. 直接做云原生交付版\n" + "更适合:目标是多环境、多实例和规模化标准交付\n" + "推荐技术/能力:Kubernetes + Helm + ArgoCD/Terraform + FastAPI\n" + "我为什么暂时不主推:环境一致性和规模化能力最强,但门槛也最高\n" + "代价:对基础设施成熟度要求最高\n\n" + "⭐ 当前我更推荐:B\n" + "原因:它最适合把批量自动化部署做成长期可用的平台,而不是一次性脚本工具。\n\n" + "你回 A / B / C,我就顺着这条继续往下收。" + ) + if missing_field == "first_scope": + return ( + f"🧭 我先按“{current_tech_route}”继续往下收。\n" + "这一步要定的,是第一版到底先证明什么价值,避免一上来摊太大。\n\n" + "A. 批量执行闭环\n" + "更适合:先证明平台真正能跑通部署任务\n" + "推荐技术/能力:任务创建、执行日志、失败重试\n" + "我当前更偏这个:最能体现平台核心价值\n" + "代价:环境接入和治理能力会稍后补\n\n" + "B. 环境接入闭环\n" + "更适合:先把服务器、集群、仓库和凭证接进来\n" + "推荐技术/能力:目标注册、凭证管理、环境选择\n" + "我为什么会考虑这个:能为后续自动化执行打基础\n" + "代价:短期可见价值不如 A 直接\n\n" + "C. 治理闭环\n" + "更适合:团队已经进入正式上线阶段\n" + "推荐技术/能力:审批、回滚、审计、权限\n" + "我为什么会考虑这个:能先控制上线风险和责任边界\n" + "代价:没有稳定执行闭环时,容易先做重了\n\n" + "⭐ 当前我更推荐:A\n" + "原因:MVP 最先要证明“能执行、能追踪、失败能恢复”,这样平台主线才会立住。\n\n" + "你回 A / B / C,我就顺着这条继续往下收。" + ) + return ( + f"🧭 我先按“{current_first_scope}”继续收尾。\n" + "现在要拍板的,是先用什么方式把第一版推起来:先轻量验证、先试点,还是直接平台化。\n\n" + "A. 内部 PoC\n" + "更适合:先验证 1-2 个典型部署任务\n" + "推荐技术/能力:轻量试用、快速反馈、低风险验证\n" + "我为什么会考虑这个:最适合快速试错\n" + "代价:对真实治理问题暴露不够充分\n\n" + "B. 项目级试点\n" + "更适合:选一个真实项目先落地\n" + "推荐技术/能力:试点接入、基础审批、基础回滚\n" + "我当前更偏这个:最能检验真实价值,也能尽早暴露流程问题\n" + "代价:需要更明确的项目配合和负责人\n\n" + "C. 平台化上线\n" + "更适合:一开始就计划多团队接入\n" + "推荐技术/能力:统一权限、统一规范、统一环境管理\n" + "我为什么暂时不主推:长期价值最大,但前期投入和协作复杂度也最高\n" + "代价:前期投入和协作复杂度最高\n\n" + "⭐ 当前我更推荐:B\n" + "原因:PoC 太轻,平台化太重,项目级试点最适合项目管理推进和真实验收。\n\n" + "你回 A / B / C,我就顺着这条继续往下收。" + ) if profile == "scoring": if missing_field == "scoring_basis": return "那我先确认一个最关键的问题:这个分数主要想根据什么来打,是技术指标、情绪资金,还是你已经有一套规则想自动化?" @@ -515,21 +958,243 @@ class ExecuteQueryProjectMixin: return "第一版你最想先解决复盘里的哪一步:切片、总结,还是可执行清单?" if any(token in normalized for token in ("雷达", "监控", "预警")): return "第一版你最想先盯哪类信号:政策、舆情,还是竞品动作?" - return "我先确认输入从哪来,是会议纪要、录音转写,还是别的材料?" + return ( + "我先把输入来源定一下,不然后面收范围时容易越聊越散。\n\n" + "A. 先按当前聊天里的需求描述来\n" + "更适合:你先把目标讲清,我边聊边收第一版\n\n" + "B. 先按会议纪要 / 文档来\n" + "更适合:已经有结构化材料,重点是提炼结果\n\n" + "C. 先按录音转写 / 回放来\n" + "更适合:信息还比较散,需要先转成可整理内容\n\n" + "你回 A / B / C,也可以直接说更准确的输入来源。" + ) if missing_field == "target_user": - return "我先确认一下,这个结果主要给谁用,是内部 PM、负责人,还是整个团队?" + return ( + "我先把结果主要给谁用定一下,这会直接影响后面的内容粒度和表达方式。\n\n" + "A. 核心执行人 / 内部 PM\n" + "更适合:先服务负责跟进、拆动作、推动落地的人\n\n" + "B. 负责人 / 管理者\n" + "更适合:先看结论、风险和待拍板项\n\n" + "C. 整个团队 / 终端用户\n" + "更适合:需要直接同步所有相关方,或结果就是给最终使用者看的\n\n" + "你回 A / B / C,也可以直接说更准确的对象。" + ) if missing_field == "output_form": - return "结果你更希望最后怎么给结果,是飞书总结、飞书消息,还是单独页面?" + return ( + "结果最后怎么给你,我先一起定掉,不然后面范围会偏。\n\n" + "A. 飞书总结\n" + "更适合:一次性看完整结论和重点\n\n" + "B. 飞书消息\n" + "更适合:更轻、更快,直接在会话里收结果\n\n" + "C. 单独页面\n" + "更适合:后面要长期查看、筛选或继续编辑\n\n" + "你回 A / B / C,也可以直接说你想要的交付方式。" + ) if missing_field == "cadence": - return "时效我也一起记一下:你希望多久整理出来,是每次会后半小时内、当天内,还是第二天?" + return ( + "时效我也一起记一下,先把你能接受的出结果节奏定住。\n\n" + "A. 触发后半小时内\n" + "更适合:希望尽快看到第一版结果\n\n" + "B. 当天内\n" + "更适合:接受汇总后统一产出\n\n" + "C. 第二天\n" + "更适合:允许补充整理和复核\n\n" + "你回 A / B / C,也可以直接说更具体的时效。" + ) return "第一版先保留哪些字段最有价值,比如待办、责任人、截止时间,哪些先不做?" + @classmethod + def _build_it_project_signal_sources(cls, *, card: dict[str, Any]) -> str: + normalized = WorkflowSemanticPlanner._normalize_text( + " ".join( + str(card.get(key) or "").strip() + for key in ("title", "goal", "project_nature", "tech_route", "first_scope", "delivery_mode") + if str(card.get(key) or "").strip() + ) + ) + sources = ["飞书需求消息"] + if any(token in normalized for token in ("部署", "发布", "openclaw", "回滚", "环境")): + sources.extend( + [ + "部署目标清单(服务/环境/实例)", + "代码仓库/镜像信息", + "环境参数与凭证", + "执行日志与失败日志", + ] + ) + else: + sources.extend(["目标对象清单", "代码与配置来源", "执行反馈日志"]) + deduped = list(dict.fromkeys(sources)) + return f"当前至少要接入:{';'.join(deduped)}" + + @classmethod + def _build_it_project_known_info(cls, *, card: dict[str, Any]) -> str: + facts: list[str] = [] + title = str(card.get("title") or "").strip() + project_nature = str(card.get("project_nature") or "").strip() + target_user = str(card.get("target_user") or "").strip() + tech_route = str(card.get("tech_route") or "").strip() + first_scope = str(card.get("first_scope") or "").strip() + delivery_mode = str(card.get("delivery_mode") or "").strip() + if title: + facts.append(f"当前要解决的是{title}") + if project_nature: + facts.append(f"当前按{project_nature}收敛") + if target_user: + facts.append(f"主要服务{target_user}") + if tech_route: + facts.append(f"技术路线先按{cls._compact_requirement_plan_label(tech_route)}") + if first_scope: + facts.append(f"第一版先按{cls._compact_requirement_plan_label(first_scope)}推进") + if delivery_mode: + facts.append(f"推进方式先按{cls._compact_requirement_plan_label(delivery_mode)}") + return f"目前已经明确:{';'.join(facts)}" if facts else "目前已知信息还比较少,建议先补部署对象、环境清单和现有流程。" + + @classmethod + def _build_it_project_one_line_judgement(cls, *, card: dict[str, Any]) -> str: + target_user = str(card.get("target_user") or "核心团队").strip() + delivery_mode = cls._compact_requirement_plan_label(str(card.get("delivery_mode") or "内部 PoC")) + if not delivery_mode: + delivery_mode = "内部 PoC" + return ( + f"先按面向{target_user}的{delivery_mode}推进," + "先把 OpenClaw 的批量部署、执行追踪和失败恢复跑成一条闭环。" + ) + + @staticmethod + def _trim_sentence_tail(value: Any) -> str: + return str(value or "").strip().rstrip("。.!!??") + + @staticmethod + def _build_it_project_core_flow(*, card: dict[str, Any]) -> str: + _ = card + return "提交部署需求 -> 生成部署任务 -> 选择目标环境 -> 执行部署 -> 回传结果/日志 -> 失败重试或回滚 -> 留痕审计" + + @classmethod + def _build_it_project_staffing(cls, *, card: dict[str, Any]) -> str: + delivery_mode = str(card.get("delivery_mode") or "").strip() + tech_route = str(card.get("tech_route") or "").strip() + if "平台化上线" in delivery_mode: + staffing = [ + "需求负责人/PM 1名", + "后端工程师 2名", + "前端工程师 1名", + "平台/运维工程师 1-2名", + "测试/验收 1名", + "业务/审批代表 1名", + ] + elif "项目级试点" in delivery_mode: + staffing = [ + "需求负责人/PM 1名", + "后端工程师 1名", + "前端工程师 1名", + "平台/运维工程师 1名", + "测试/试点验收 1名", + "试点负责人 1名", + ] + else: + staffing = [ + "需求负责人/PM 1名", + "后端工程师 1名", + "平台/运维工程师 1名", + "前端工程师 0.5-1名", + "试用/验收 1名", + ] + if any(token in tech_route for token in ("Kubernetes", "ArgoCD", "Terraform")): + staffing.append("云原生平台支持 1名") + return f"建议最小配置:{';'.join(staffing)}" + + @classmethod + def _build_it_project_risks_and_dependencies(cls, *, card: dict[str, Any]) -> str: + delivery_mode = str(card.get("delivery_mode") or "").strip() + tech_route = str(card.get("tech_route") or "").strip() + risks = [ + "部署目标清单是否稳定", + "环境访问权限与凭证归属", + "仓库/镜像/配置规范", + "凭证管理", + "失败重试与回滚策略", + ] + if "项目级试点" in delivery_mode: + risks.append("试点项目配合度") + if "平台化上线" in delivery_mode: + risks.append("统一权限与审批链路") + if any(token in tech_route for token in ("Kubernetes", "ArgoCD", "Terraform")): + risks.append("K8s/基础设施成熟度") + return "当前主要风险在" + "、".join(risks) + + @classmethod + def _build_it_project_pending_items(cls, *, card: dict[str, Any]) -> list[str]: + pending: list[str] = [] + if not str(card.get("success_signal") or "").strip(): + pending.append("成功判断标准还没完全定死") + pending.extend( + [ + "部署目标范围(服务/环境/实例)", + "仓库/镜像/配置来源", + "环境权限与凭证归属", + "试点负责人和投入人天", + ] + ) + tech_route = str(card.get("tech_route") or "").strip() + if any(token in tech_route for token in ("Kubernetes", "ArgoCD", "Terraform")): + pending.append("现有 K8s / GitOps 基础设施成熟度") + return list(dict.fromkeys(item for item in pending if str(item or "").strip())) + @classmethod def _build_requirement_plan_draft_text(cls, *, card: dict[str, Any]) -> str: profile = cls._detect_requirement_plan_profile(message_text="", card=card) pending = [] if not str(card.get("success_signal") or "").strip(): pending.append("成功判断标准还没完全定死") + if profile == "it_project": + pending = cls._build_it_project_pending_items(card=card) + lines = [ + "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", + "", + f"🧾【需求名称】{str(card.get('title') or '待补充').strip()}", + "", + f"🧭【一句话判断】{cls._build_it_project_one_line_judgement(card=card)}", + "", + "🎯【需求目标】" + f"{cls._trim_sentence_tail(card.get('goal') or '待补充')}。" + " 这次先不追求把审批、审计、权限一次做满,而是先把主链路跑通。", + "", + f"🏗️【项目性质】这件事更像{str(card.get('project_nature') or '待补充').strip()},不像一次性脚本工具,后面需要承接状态流转和回滚治理。", + "", + f"👥【服务对象】第一版先服务{str(card.get('target_user') or '待补充').strip()},他们会最早发起部署、看执行结果、处理失败和判断是否回滚。", + "", + f"📡【信号源】{cls._build_it_project_signal_sources(card=card)}", + "", + f"📝【当前已知信息】{cls._build_it_project_known_info(card=card)}", + "", + "⚙️【技术路线】" + f"当前更适合走{str(card.get('tech_route') or '待补充').strip()}," + "因为它比脚本方案更稳,也没有一上来重到云原生底座。", + "", + "🧱【第一版范围】" + f"{cls._trim_sentence_tail(card.get('first_scope') or '先收一个最小可跑闭环')}。" + " 这版重点先放在任务创建、执行日志和失败重试。", + "", + f"🔄【建议核心流程】{cls._build_it_project_core_flow(card=card)}", + "", + f"👤【人员配置】{cls._build_it_project_staffing(card=card)}", + "", + "🚀【推进方式】" + f"先按{str(card.get('delivery_mode') or '待补充').strip()}推进," + "这样能尽快在真实项目里验证价值,也能更早暴露流程问题。", + "", + "✅【成功标准】" + f"{cls._trim_sentence_tail(card.get('success_signal') or '先保证能稳定创建部署任务、看到执行结果,并能识别失败原因')}," + "并且能在试点里跑完一次真实闭环。", + "", + f"⚠️【风险与依赖】{cls._build_it_project_risks_and_dependencies(card=card)}。", + "", + f"❓【待确认】还需要你拍板:{';'.join(pending) if pending else '暂无,已经可以继续整理成正式创建草稿'}", + "", + "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + ] + return "\n".join(lines).strip() if profile == "generic": lines = [ "📋 我先把当前收敛结果整理成一版需求草稿,你直接看这版就行:", @@ -564,6 +1229,115 @@ class ExecuteQueryProjectMixin: ] return "\n".join(lines).strip() + @classmethod + def _build_requirement_plan_requirement_items(cls, *, card: dict[str, Any]) -> list[dict[str, str]]: + profile = cls._detect_requirement_plan_profile(message_text="", card=card) + items: list[dict[str, str]] = [] + + def _append(section: str, title: str, value: Any) -> None: + text = str(value or "").strip() + if not text: + return + items.append( + { + "section": section, + "title": title, + "description": text, + } + ) + + _append("需求目标", "需求目标", card.get("goal")) + _append("目标用户", "目标用户", card.get("target_user")) + _append("第一版范围", "第一版范围", card.get("first_scope")) + _append("成功标准", "成功标准", card.get("success_signal")) + if profile == "it_project": + _append("项目性质", "项目性质", card.get("project_nature")) + _append("技术路线", "技术路线", card.get("tech_route")) + _append("推进方式", "推进方式", card.get("delivery_mode")) + elif profile == "scoring": + _append("打分依据", "打分依据", card.get("scoring_basis")) + _append("输出形式", "输出形式", card.get("output_form")) + _append("更新频率", "更新频率", card.get("cadence")) + else: + _append("输入材料", "输入材料", card.get("source_material")) + _append("输出形式", "输出形式", card.get("output_form")) + _append("响应时效", "响应时效", card.get("cadence")) + return items[:12] + + @classmethod + def _build_requirement_plan_missing_info(cls, *, card: dict[str, Any]) -> list[str]: + profile = cls._detect_requirement_plan_profile(message_text="", card=card) + if profile == "it_project": + return cls._build_it_project_pending_items(card=card) + + label_map = { + "source_material": "输入材料", + "target_user": "目标用户", + "output_form": "输出形式", + "cadence": "响应时效", + "scoring_basis": "打分依据", + "first_scope": "第一版范围", + "success_signal": "成功标准", + } + pending: list[str] = [] + for field in cls._requirement_plan_missing_fields(card): + label = label_map.get(str(field or "").strip()) + if label: + pending.append(label) + if not str(card.get("success_signal") or "").strip(): + pending.append("成功标准") + return list(dict.fromkeys(item for item in pending if str(item or "").strip())) + + @classmethod + def _build_requirement_plan_project_creation_draft( + cls, + *, + card: dict[str, Any], + draft_text: str, + ) -> dict[str, Any]: + profile = cls._detect_requirement_plan_profile(message_text="", card=card) + title = str(card.get("title") or "需求收敛草稿").strip() or "需求收敛草稿" + goal = str(card.get("goal") or "").strip() + first_scope = str(card.get("first_scope") or "").strip() + success_signal = str(card.get("success_signal") or "").strip() + target_user = str(card.get("target_user") or "").strip() + summary_parts = [text for text in (goal, first_scope) if text] + summary = ";".join(dict.fromkeys(summary_parts))[:300] or title + positioning = "" + if profile == "it_project": + positioning = cls._build_it_project_one_line_judgement(card=card) + if not positioning: + positioning = first_scope or goal or title + + draft_payload = { + "draft_source": "requirement_plan", + "plan_profile": profile, + "title": title, + "summary": summary, + "positioning_one_liner": positioning[:200], + "target_customers": target_user[:120] or None, + "original_text": str(draft_text or "").strip()[:4000] or summary, + "requirement_items": cls._build_requirement_plan_requirement_items(card=card), + "missing_info": cls._build_requirement_plan_missing_info(card=card), + "mvp_items": [first_scope] if first_scope else [], + "validation_metrics": ( + {"product": [success_signal]} + if success_signal + else {} + ), + "success_signal": success_signal or None, + "draft_text": str(draft_text or "").strip(), + "requirement_card": dict(card), + } + if profile == "it_project": + draft_payload["team_setup"] = [ + { + "role": "建议配置", + "notes": cls._build_it_project_staffing(card=card), + } + ] + return draft_payload + @classmethod def _resolve_requirement_plan_status( cls, @@ -579,20 +1353,76 @@ class ExecuteQueryProjectMixin: return "options_ready" return "collecting" + @classmethod + def _requirement_plan_material_updates(cls, *, message_text: str) -> dict[str, Any]: + return { + key: value + for key, value in cls._extract_requirement_plan_updates(message_text=message_text, card={}).items() + if key not in {"plan_profile", "title", "goal"} + } + + @classmethod + def _topic_expects_requirement_plan_choice_reply(cls, *, topic: Optional[MountedTopicWorkspace]) -> bool: + if topic is None: + return False + plan_status = str(getattr(topic, "plan_status", "") or "").strip().lower() + if plan_status in {"awaiting_execute_confirm", "plan_ready", "suspended"}: + return False + pending_actions = getattr(topic, "pending_actions", None) + if isinstance(pending_actions, (list, tuple, set)) and "confirm_requirement_draft" in { + str(item or "").strip() for item in pending_actions + }: + return False + metadata = getattr(topic, "metadata_json", None) + if not isinstance(metadata, dict): + return False + last_prompt = str(metadata.get("plan_last_prompt") or "").strip() + if not last_prompt: + return False + normalized_prompt = WorkflowSemanticPlanner._normalize_text(last_prompt) + if "你回a/b/c" in normalized_prompt or "直接回a/b/c" in normalized_prompt: + return True + return bool(re.search(r"(^|\n)\s*[ABC]\.\s*", last_prompt)) + + @classmethod + def _should_continue_requirement_plan_from_workspace( + cls, + *, + message_text: str, + topic: Optional[MountedTopicWorkspace] = None, + ) -> bool: + normalized = WorkflowSemanticPlanner._normalize_text(message_text) + if not normalized or WorkflowSemanticPlanner._looks_like_casual_chat_message(message_text): + return False + if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): + return False + # Active plan threads often continue with "A,xxx原因..." rather than a bare letter. + # Once a user is clearly responding to an A/B/C follow-up, keep the flow in plan mode. + if cls._extract_requirement_plan_choice(message_text): + return cls._topic_expects_requirement_plan_choice_reply(topic=topic) + if cls._requirement_plan_material_updates(message_text=message_text): + return True + return any(token in normalized for token in ("整理成草稿", "出草稿", "汇总一下", "按这版", "就这样先收")) + @classmethod def _looks_like_requirement_plan_seed(cls, *, message_text: str) -> bool: normalized = WorkflowSemanticPlanner._normalize_text(message_text) if not normalized: return False - raw_updates = cls._extract_requirement_plan_updates(message_text=message_text, card={}) - material_updates = { - key: value - for key, value in raw_updates.items() - if key not in {"plan_profile", "title", "goal"} - } + material_updates = cls._requirement_plan_material_updates(message_text=message_text) has_updates = bool(material_updates) if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): - return has_updates or WorkflowSemanticPlanner._looks_like_requirement_plan_chat(message_text) or WorkflowSemanticPlanner.looks_like_project_create_request(message_text) + topic_tail = "" + if "关于" in normalized: + topic_tail = normalized.partition("关于")[2] + topic_tail = re.sub(r"[@_a-z0-9]+", "", topic_tail) + has_topic_subject = len(topic_tail) >= 4 + return ( + has_updates + or WorkflowSemanticPlanner._looks_like_requirement_plan_chat(message_text) + or WorkflowSemanticPlanner.looks_like_project_create_request(message_text) + or has_topic_subject + ) if has_updates: return True return ( @@ -603,21 +1433,9 @@ class ExecuteQueryProjectMixin: @classmethod def _looks_like_requirement_plan_continuation(cls, *, message_text: str) -> bool: - normalized = WorkflowSemanticPlanner._normalize_text(message_text) - if not normalized or WorkflowSemanticPlanner._looks_like_casual_chat_message(message_text): - return False - if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): - return False - if WorkflowSemanticPlanner._looks_like_requirement_plan_slot_answer(message_text): + if cls._should_continue_requirement_plan_from_workspace(message_text=message_text): return True - material_updates = { - key: value - for key, value in cls._extract_requirement_plan_updates(message_text=message_text, card={}).items() - if key not in {"plan_profile", "title", "goal"} - } - if material_updates: - return True - return any(token in normalized for token in ("整理成草稿", "出草稿", "汇总一下", "按这版", "就这样先收")) + return WorkflowSemanticPlanner._looks_like_requirement_plan_slot_answer(message_text) @classmethod def _collect_requirement_topic_text_fragments(cls, payload: Any) -> list[str]: @@ -831,13 +1649,25 @@ class ExecuteQueryProjectMixin: .order_by(MountedTopicWorkspace.updated_at.desc(), MountedTopicWorkspace.id.desc()) ) ).scalars().all() - topic = None if force_new_topic else self._select_requirement_plan_topic( - candidates=list(topic_rows), - topic_key=topic_key, - root_message_id=root_message_id, - thread_id=thread_id, - conversation_scope_key=scope_key or conversation_key, - ) + topic: Optional[MountedTopicWorkspace] + if force_new_topic: + normalized_topic_key = str(topic_key or "").strip() + topic = next( + ( + row + for row in topic_rows + if str(getattr(row, "topic_key", "") or "").strip() == normalized_topic_key + ), + None, + ) + else: + topic = self._select_requirement_plan_topic( + candidates=list(topic_rows), + topic_key=topic_key, + root_message_id=root_message_id, + thread_id=thread_id, + conversation_scope_key=scope_key or conversation_key, + ) if topic is None: topic = MountedTopicWorkspace( tenant_id=int(self.tenant_id), @@ -871,6 +1701,13 @@ class ExecuteQueryProjectMixin: topic.thread_key = thread_id or topic.thread_key topic.root_message_id = root_message_id or topic.root_message_id or str(source_message_id or "").strip() or None topic.last_message_id = str(source_message_id or "").strip() or topic.last_message_id + if force_new_topic: + await self._suspend_other_requirement_plan_topics( + source_system=source_system, + conversation_scope_key=scope_key or conversation_key, + keep_topic_id=int(topic.id), + source_message_id=source_message_id, + ) draft = ( await self.db.execute( @@ -984,9 +1821,10 @@ class ExecuteQueryProjectMixin: missing_field=self._requirement_plan_missing_fields(card)[0] if self._requirement_plan_missing_fields(card) else "first_scope", profile=profile, message_text=message_text, + card=card, ) return self._build_direct_query_result( - answer=f"可以,我们先不创建项目,先只聊需求。{next_question}", + answer=f"可以,我们先不创建项目,先把这件事收成一版需求。\n\n{next_question}", scene_label="Mounted Runtime Plan", reason_code="requirement_planning_direct_fallback", query_type="plan", @@ -1055,40 +1893,50 @@ class ExecuteQueryProjectMixin: topic.readiness_score = 1.0 if should_emit_draft else readiness if should_emit_draft: answer = self._build_requirement_plan_draft_text(card=card) + project_creation_draft = self._build_requirement_plan_project_creation_draft( + card=card, + draft_text=answer, + ) draft.state = "ready" draft.draft_version = int(draft.draft_version or 0) + 1 draft.requirement_card = card - draft.draft_payload = { - "plan_profile": profile, - "title": card.get("title"), - "goal": card.get("goal"), - "source_material": card.get("source_material"), - "target_user": card.get("target_user"), - "scoring_basis": card.get("scoring_basis"), - "output_form": card.get("output_form"), - "cadence": card.get("cadence"), - "first_scope": card.get("first_scope"), - "success_signal": card.get("success_signal"), - "draft_text": answer, - } + draft.draft_payload = project_creation_draft + draft.active_work_item_id = None + draft.latest_source_message_id = str(source_message_id or "").strip()[:128] or None + draft.latest_reply_message_id = None + draft.last_ready_at = now_cn_naive() topic.pending_actions = ["confirm_requirement_draft"] topic.open_questions = [] topic.readiness_score = 1.0 await self.db.flush() - return self._build_direct_query_result( + result = self._build_direct_query_result( answer=answer, scene_label="Mounted Runtime Draft", reason_code="requirement_planning_draft_ready", query_type="draft", ) + result["reply_template_key"] = "draft_pending_confirm" + result["draft_version"] = int(draft.draft_version or 0) + result["draft_artifact"] = { + "type": "requirement_plan_draft", + "topic_workspace_id": int(topic.id), + "draft_workspace_id": int(draft.id), + "draft_version": int(draft.draft_version or 0), + "promote_to_confirmation": True, + "workflow_route": ROUTE_PROJECT_CREATE, + "draft_payload": project_creation_draft, + "draft_text": answer, + } + return result next_question = self._build_requirement_plan_next_question( missing_field=missing_fields[0] if missing_fields else "first_scope", profile=profile, message_text=message_text, + card=card, ) ack = self._build_requirement_plan_ack(updates=updates, profile=profile) - answer = f"{ack}{next_question}" if ack else f"可以,我们先不创建项目,先只聊需求。{next_question}" + answer = f"{ack}\n\n{next_question}" if ack else f"可以,我们先不创建项目,先把这件事收成一版需求。\n\n{next_question}" draft.state = "collecting" draft.requirement_card = card draft.draft_payload = None @@ -1303,6 +2151,7 @@ class ExecuteQueryProjectMixin: message_intent = str(rag_request_context.get("message_intent") or "").strip() or None mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None consultant_mode_preference = str(rag_request_context.get("consultant_mode_preference") or "").strip().lower() or None + advise_subtype = str(rag_request_context.get("advise_subtype") or "").strip().lower() or None topic_switch = str(rag_request_context.get("topic_switch") or "").strip().lower() or None else: thread_context = metadata.get("thread_context") if isinstance(metadata.get("thread_context"), dict) else None @@ -1327,17 +2176,18 @@ class ExecuteQueryProjectMixin: ) session_id = None force_new_session = False - thinking_mode = str(semantic_diagnostics.get("force_thinking_mode") or "").strip() or None + thinking_mode = str(metadata.get("force_thinking_mode") or semantic_diagnostics.get("force_thinking_mode") or "").strip() or None action_mode = "read" sync_requirement = False message_intent = str( - semantic_diagnostics.get("message_intent") or metadata.get("message_intent") or "" + metadata.get("message_intent") or semantic_diagnostics.get("message_intent") or "" ).strip() or None mixed_signal_part = None consultant_mode_preference = str( - semantic_diagnostics.get("consultant_mode_preference") or metadata.get("consultant_mode_preference") or "" + metadata.get("consultant_mode_preference") or semantic_diagnostics.get("consultant_mode_preference") or "" ).strip().lower() or None - topic_switch = str(semantic_diagnostics.get("topic_switch") or metadata.get("topic_switch") or "").strip().lower() or None + advise_subtype = str(metadata.get("advise_subtype") or semantic_diagnostics.get("advise_subtype") or "").strip().lower() or None + topic_switch = str(metadata.get("topic_switch") or semantic_diagnostics.get("topic_switch") or "").strip().lower() or None if isinstance(rag_request_context, dict): source_sender_open_id = str(rag_request_context.get("source_sender_open_id") or "").strip() or None thread_context = rag_request_context.get("thread_context") if isinstance(rag_request_context.get("thread_context"), dict) else None @@ -1350,26 +2200,65 @@ class ExecuteQueryProjectMixin: ).strip() or None if not message_intent: message_intent = str( - semantic_diagnostics.get("message_intent") or metadata.get("message_intent") or "" + metadata.get("message_intent") or semantic_diagnostics.get("message_intent") or "" ).strip() or None if not consultant_mode_preference: consultant_mode_preference = str( - semantic_diagnostics.get("consultant_mode_preference") or metadata.get("consultant_mode_preference") or "" + metadata.get("consultant_mode_preference") or semantic_diagnostics.get("consultant_mode_preference") or "" + ).strip().lower() or None + if not advise_subtype: + advise_subtype = str( + rag_request_context.get("advise_subtype") + or metadata.get("advise_subtype") + or semantic_diagnostics.get("advise_subtype") + or "" ).strip().lower() or None if not topic_switch: topic_switch = str( rag_request_context.get("topic_switch") - or semantic_diagnostics.get("topic_switch") or metadata.get("topic_switch") + or semantic_diagnostics.get("topic_switch") or "" ).strip().lower() or None if not thinking_mode: thinking_mode = str( rag_request_context.get("thinking_mode") + or metadata.get("force_thinking_mode") or semantic_diagnostics.get("force_thinking_mode") or "" ).strip() or None - if not topic_switch and WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): + explicit_new_requirement_topic = WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text) + if explicit_new_requirement_topic: + # "新需求 / 另一个需求" is a stronger signal than any stale upstream classify label. + # Force the conversation into a fresh requirement-plan topic so follow-up A/B/C replies + # cannot accidentally continue an unrelated historical draft in the same chat. + topic_switch = "new" + message_intent = "requirement_plan" + consultant_mode_preference = "clarify" + advise_subtype = None + has_active_requirement_plan_workspace = False + active_requirement_plan_topic: Optional[MountedTopicWorkspace] = None + active_requirement_plan_draft: Optional[MountedDraftWorkspace] = None + if not message_intent: + active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( + source_system=source_system, + source_message_id=source_message_id, + source_chat_id=source_chat_id, + sender=None, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + has_active_requirement_plan_workspace = ( + active_requirement_plan_topic is not None and active_requirement_plan_draft is not None + ) + if has_active_requirement_plan_workspace and self._should_continue_requirement_plan_from_workspace( + message_text=message_text, + topic=active_requirement_plan_topic, + ): + message_intent = "requirement_plan" + consultant_mode_preference = consultant_mode_preference or "clarify" + if not topic_switch and explicit_new_requirement_topic: topic_switch = "new" message_intent = message_intent or "requirement_plan" consultant_mode_preference = consultant_mode_preference or "clarify" @@ -1386,17 +2275,25 @@ class ExecuteQueryProjectMixin: message_intent=message_intent, ) if not message_intent and self._looks_like_requirement_plan_continuation(message_text=message_text): - has_active_plan_workspace = await self._has_active_requirement_plan_workspace( - source_system=source_system, - source_message_id=source_message_id, - source_chat_id=source_chat_id, - sender=None, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - conversation_key=conversation_key, - force_new_topic=str(topic_switch or "").strip().lower() == "new", - ) - if has_active_plan_workspace: + if not has_active_requirement_plan_workspace: + active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( + source_system=source_system, + source_message_id=source_message_id, + source_chat_id=source_chat_id, + sender=None, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + has_active_requirement_plan_workspace = ( + str(topic_switch or "").strip().lower() != "new" + and active_requirement_plan_topic is not None + and active_requirement_plan_draft is not None + ) + if has_active_requirement_plan_workspace and self._should_continue_requirement_plan_from_workspace( + message_text=message_text, + topic=active_requirement_plan_topic, + ): message_intent = "requirement_plan" consultant_mode_preference = consultant_mode_preference or "clarify" if self._should_use_lightweight_chat_answer( @@ -1428,6 +2325,7 @@ class ExecuteQueryProjectMixin: message_text=message_text, message_intent=message_intent, consultant_mode_preference=consultant_mode_preference, + advise_subtype=advise_subtype, ): direct_result = await self._build_requirement_planning_direct_result( message_text=message_text, @@ -1446,6 +2344,7 @@ class ExecuteQueryProjectMixin: message_text=message_text, message_intent=message_intent, consultant_mode_preference=consultant_mode_preference, + advise_subtype=advise_subtype, ): direct_result = self._build_direct_query_result( answer=await self._build_assignee_recommendation_answer( @@ -1462,6 +2361,7 @@ class ExecuteQueryProjectMixin: message_text=message_text, message_intent=message_intent, consultant_mode_preference=consultant_mode_preference, + advise_subtype=advise_subtype, ): direct_result = self._build_direct_query_result( answer=self._build_requirement_advise_answer(message_text=message_text), diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index a5faee93..1ef75ef7 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -12,8 +12,10 @@ for _name, _value in vars(_common).items(): globals()[_name] = _value from ..ai_steward_service import AIStewardService +from ..mounted_ai_runtime_write_service import MountedAIRuntimeWriteService from .workflow_semantic_planner import WorkflowSemanticPlanner from ..signal.draft_generator_service import DraftGeneratorService +from ...models import MountedDraftWorkspace, MountedTopicWorkspace from ...models.feishu_reply_outbox import FeishuReplyOutbox @@ -52,6 +54,7 @@ class DraftConfirmHandler: ) -> dict[str, Any]: action, revision_note = self.parse_action_from_text(message_text=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( sender=sender, @@ -62,11 +65,11 @@ class DraftConfirmHandler: thread_context=thread_context, ) 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 { - "handled": False, - "status": "ignored", - "followup_reply": "", + "handled": True, + "status": "failed_safe", + "followup_reply": "当前没有可确认草稿。请先在当前会话重新生成最新草稿,再回复“确认”或“修改”。", "target_work_item_id": None, "target_state": None, "target_intent": None, @@ -646,6 +649,25 @@ class DraftConfirmHandler: explicit_work_item_id: Optional[int], thread_context: Optional[dict[str, Any]], ) -> 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 = ( select(WorkItem, IngestEvent) .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) @@ -659,11 +681,6 @@ class DraftConfirmHandler: .limit(30) ) 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_channel = str(channel_ref or "").strip() @@ -738,6 +755,143 @@ class DraftConfirmHandler: return None 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 def _normalize_thread_context( thread_context: Optional[dict[str, Any]], diff --git a/backend/app/services/ai_pmo/semantic_context/__init__.py b/backend/app/services/ai_pmo/semantic_context/__init__.py new file mode 100644 index 00000000..26172fcf --- /dev/null +++ b/backend/app/services/ai_pmo/semantic_context/__init__.py @@ -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", +] diff --git a/backend/app/services/ai_pmo/semantic_context/assembler.py b/backend/app/services/ai_pmo/semantic_context/assembler.py new file mode 100644 index 00000000..cd3c251a --- /dev/null +++ b/backend/app/services/ai_pmo/semantic_context/assembler.py @@ -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, + ), + ) diff --git a/backend/app/services/ai_pmo/semantic_context/decision_schema.py b/backend/app/services/ai_pmo/semantic_context/decision_schema.py new file mode 100644 index 00000000..8b4124e5 --- /dev/null +++ b/backend/app/services/ai_pmo/semantic_context/decision_schema.py @@ -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 diff --git a/backend/app/services/ai_pmo/semantic_context/gatekeeper.py b/backend/app/services/ai_pmo/semantic_context/gatekeeper.py new file mode 100644 index 00000000..ea103d28 --- /dev/null +++ b/backend/app/services/ai_pmo/semantic_context/gatekeeper.py @@ -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, + ) diff --git a/backend/app/services/ai_pmo/semantic_context/mode_card_service.py b/backend/app/services/ai_pmo/semantic_context/mode_card_service.py new file mode 100644 index 00000000..48fa72ba --- /dev/null +++ b/backend/app/services/ai_pmo/semantic_context/mode_card_service.py @@ -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=["用户提出新的规划或执行需求"], + ) diff --git a/backend/app/services/ai_pmo/semantic_context/packet_schema.py b/backend/app/services/ai_pmo/semantic_context/packet_schema.py new file mode 100644 index 00000000..e5b04691 --- /dev/null +++ b/backend/app/services/ai_pmo/semantic_context/packet_schema.py @@ -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 diff --git a/backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py b/backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py new file mode 100644 index 00000000..0b575939 --- /dev/null +++ b/backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py @@ -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) diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py index 052c7604..c6a369fe 100644 --- a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py +++ b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py @@ -263,6 +263,28 @@ class WorkflowSemanticPlanner: return True 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 def _looks_like_requirement_plan_slot_answer(cls, text: str) -> bool: normalized = cls._normalize_text(text) diff --git a/backend/app/services/context_cache_service/mixin_cache_core.py b/backend/app/services/context_cache_service/mixin_cache_core.py index ab98e321..1ac29c61 100644 --- a/backend/app/services/context_cache_service/mixin_cache_core.py +++ b/backend/app/services/context_cache_service/mixin_cache_core.py @@ -75,10 +75,27 @@ class ContextCacheCoreMixin: pass @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): 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]: tid = _safe_int(tenant_id, 1) @@ -123,6 +140,7 @@ class ContextCacheCoreMixin: platform_profile = self._build_platform_profile() return { "tenant_id": tenant_id, + "schema_version": SYSTEM_SNAPSHOT_SCHEMA_VERSION, "assistant_profile": assistant_profile, "platform_profile": platform_profile, "projects": projects.get("items", []), @@ -130,4 +148,3 @@ class ContextCacheCoreMixin: "pending_signals": pending, "snapshot_at": now_cn_aware().isoformat(), } - diff --git a/backend/app/services/context_cache_service/mixin_profile_route_parser.py b/backend/app/services/context_cache_service/mixin_profile_route_parser.py index 04193a50..f4e2ef77 100644 --- a/backend/app/services/context_cache_service/mixin_profile_route_parser.py +++ b/backend/app/services/context_cache_service/mixin_profile_route_parser.py @@ -190,7 +190,7 @@ class ContextCacheProfileRouteParserMixin: } 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 app_root = backend_root / "app" 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 [], "source_scan": { "backend_root": str(backend_root), + "app_root": str(app_root), + "workspace_root": str(workspace_root), "app_python_files": app_python_files, "feature_catalog_source": str(feature_catalog.get("source") or "unknown"), "scanned_at": now_cn_aware().isoformat(), diff --git a/backend/app/services/context_cache_service/shared.py b/backend/app/services/context_cache_service/shared.py index 394c734a..5652dd84 100644 --- a/backend/app/services/context_cache_service/shared.py +++ b/backend/app/services/context_cache_service/shared.py @@ -27,6 +27,7 @@ except Exception: # pragma: no cover RedisSync = None +SYSTEM_SNAPSHOT_SCHEMA_VERSION = "2026-04-07.context-v2" SYSTEM_TTL_SECONDS = 30 * 60 USER_TTL_SECONDS = 5 * 60 SESSION_TTL_SECONDS = 60 * 60 diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py index a3a3517c..891b1d5a 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py @@ -328,6 +328,22 @@ class FeishuReplyOutboxMixin: row.last_error = None if sent_message_id: 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() perf_t2 = time.perf_counter() perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) @@ -357,11 +373,67 @@ class FeishuReplyOutboxMixin: @classmethod def _normalize_outgoing_reply_text(cls, value: Optional[str]) -> str: - text = cls._clean_reply_text(value) - if (not text) or cls._looks_like_provider_error(text): - return cls._build_error_reply_text() + cleaner = getattr(cls, "_clean_reply_text", None) + if callable(cleaner): + 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] + @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 def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]: source = str(source_message_id or "").strip() diff --git a/backend/app/services/llm_service/mixin_engine_resolution_core.py b/backend/app/services/llm_service/mixin_engine_resolution_core.py index 9ac36509..f7d1a40b 100644 --- a/backend/app/services/llm_service/mixin_engine_resolution_core.py +++ b/backend/app/services/llm_service/mixin_engine_resolution_core.py @@ -5,7 +5,6 @@ from .shared import * class LLMEngineResolutionCoreMixin: _PROTECTED_QUERY_HIGH_FUNCTIONS = frozenset({ "adaptive_rag_answer_deep", - "adaptive_rag_answer_normal", }) def _stub_response(self, prompt: str) -> str: diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py index 5420c574..b5e401eb 100644 --- a/backend/app/services/llm_service/shared_part_01.py +++ b/backend/app/services/llm_service/shared_part_01.py @@ -265,6 +265,29 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { "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": { "required_keys": ("summary", "priority", "execution_plan", "risks", "confidence"), "type_checks": { diff --git a/backend/app/services/mounted_ai_runtime_service.py b/backend/app/services/mounted_ai_runtime_service.py index ca3bcf86..7175bb04 100644 --- a/backend/app/services/mounted_ai_runtime_service.py +++ b/backend/app/services/mounted_ai_runtime_service.py @@ -129,6 +129,19 @@ class MountedAIRuntimeService: "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 def _source_activation_snapshot(row: MountedSourceActivation) -> dict[str, Any]: return { @@ -547,13 +560,56 @@ class MountedAIRuntimeService: .order_by(MountedAgentTrace.id.asc()) ) ).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 { "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], "context_layers_hit": row.context_layers_hit, "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( diff --git a/backend/app/services/mounted_ai_runtime_write_service.py b/backend/app/services/mounted_ai_runtime_write_service.py index b86b3744..4965746c 100644 --- a/backend/app/services/mounted_ai_runtime_write_service.py +++ b/backend/app/services/mounted_ai_runtime_write_service.py @@ -31,6 +31,15 @@ class MountedAIRuntimeWriteService: return None 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 def build_topic_key( cls, @@ -415,6 +424,63 @@ class MountedAIRuntimeWriteService: ) 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( self, *, @@ -435,13 +501,16 @@ class MountedAIRuntimeWriteService: run_row.status = "completed" run_row.active_agent = "ReplyComposer" run_row.final_reply = final_reply or None - run_row.execution_outcome = { - "handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False, - "status": str((result or {}).get("status") or "").strip() if isinstance(result, dict) else "", - "classification": str((result or {}).get("classification") or "").strip() if isinstance(result, dict) else "", - "workflow_route": str((result or {}).get("workflow_route") or "").strip() if isinstance(result, dict) else "", - "work_item_id": (result or {}).get("work_item_id") if isinstance(result, dict) else None, - } + run_row.execution_outcome = self._merge_json_dict( + run_row.execution_outcome, + { + "handled": bool((result or {}).get("handled")) if isinstance(result, dict) else False, + "status": str((result or {}).get("status") or "").strip() if isinstance(result, dict) else "", + "classification": str((result or {}).get("classification") or "").strip() if isinstance(result, dict) else "", + "workflow_route": str((result or {}).get("workflow_route") or "").strip() if isinstance(result, dict) else "", + "work_item_id": (result or {}).get("work_item_id") if isinstance(result, dict) else None, + }, + ) run_row.completed_at = now_at self.db.add( MountedAgentTrace( diff --git a/backend/static/工单全字段报表_2026-04-03.xlsx b/backend/static/工单全字段报表_2026-04-03.xlsx new file mode 100644 index 00000000..f13911ab Binary files /dev/null and b/backend/static/工单全字段报表_2026-04-03.xlsx differ diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 86ca6c18..e51f7978 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -429,6 +429,83 @@ async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None 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 async def test_select_scene_prompt_applies_consultant_mode_preference_wrapper() -> None: class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index 801e9685..42c2e201 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -29,6 +29,9 @@ from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress import ai_pmo_kernel_mixins_ingress_render from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual from app.services.ai_pmo.ai_pmo_kernel_world_model_core import mixin_project +from app.services.ai_pmo.ai_pmo_kernel.mixins.world_model import ( + ai_pmo_kernel_mixins_world_model_session, +) from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_mutations from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent @@ -362,6 +365,214 @@ async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(mo assert finalize_kwargs["result"]["direct_reply_text"] == "已确认草稿并继续执行。" +@pytest.mark.asyncio +async def test_process_ingress_records_semantic_shadow_before_legacy_finalize(monkeypatch) -> None: + from app.services.ai_pmo.semantic_context.decision_schema import ModeCard, SemanticDecision + from app.services.ai_pmo.semantic_context.packet_schema import ( + SemanticContextPacket, + SemanticContextThread, + SemanticContextTopic, + ) + + captured: dict[str, object] = {"calls": []} + + class FakeMountedAIRuntimeWriteService: + def __init__(self, db) -> None: + captured["db"] = db + + async def begin_ingress_run(self, **kwargs): + captured["calls"].append(("begin", kwargs)) + return SimpleNamespace( + topic_workspace=SimpleNamespace(id=191, tenant_id=1, plan_status="idle"), + conversation_run=SimpleNamespace(id=192, source_message_id="s-msg-semantic-shadow"), + context_layers_hit={"current_message": True, "thread": False, "topic": False}, + ) + + async def record_semantic_shadow(self, **kwargs): + captured["calls"].append(("semantic_shadow", kwargs)) + + async def record_orchestrator_decision(self, **kwargs): + captured["calls"].append(("record", kwargs)) + return "chat" + + async def record_agent_trace(self, **kwargs): + captured["calls"].append(("agent_trace", kwargs)) + + async def finalize_run(self, **kwargs): + captured["calls"].append(("finalize", kwargs)) + + class FakeSemanticContextAssembler: + def __init__(self, db) -> None: + captured["semantic_db"] = db + + async def assemble(self, **kwargs): + captured["semantic_packet_kwargs"] = kwargs + return SemanticContextPacket( + tenant_id=1, + operator_user_id=8, + topic_workspace_id=191, + latest_user_message=str(kwargs["latest_user_message"]), + topic=SemanticContextTopic( + topic_workspace_id=191, + topic_key="feishu:chat-shadow:root:om-root-shadow", + source_system="feishu", + channel_ref="chat-shadow", + conversation_scope_key="feishu:chat-shadow:ou-shadow", + thread_key="thread-shadow", + mode="plan", + status="active", + plan_status="collecting", + facts={"title": "语义 shadow"}, + goals=["统一语义入口"], + constraints={"must_not": ["db.write"]}, + metadata={"source": "test"}, + ), + thread=SemanticContextThread( + thread_key="thread-shadow", + conversation_scope_key="feishu:chat-shadow:ou-shadow", + thread_summary="继续整理语义上下文", + recent_turns=[], + open_questions=["是否继续当前 topic"], + ), + mode_card=ModeCard( + workflow_mode="PLAN", + mode_state="ACTIVE", + allowed_actions=["reply.compose", "draft.patch"], + forbidden_actions=["project.create", "db.write"], + exit_conditions=["用户明确要求开始执行"], + ), + ) + + class FakeSemanticDecisionService: + def __init__(self, _llm_service) -> None: + pass + + async def decide(self, *, packet): + captured["semantic_packet"] = packet.model_dump(mode="json") + return SemanticDecision( + topic_relation="continue_current_topic", + mode_decision="stay_plan", + draft_patch={"goal": "继续整理语义上下文"}, + assistant_reply="我先继续整理上下文。", + requires_confirmation=False, + requested_actions=["reply.compose", "draft.patch"], + confidence=0.88, + ) + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.context_assembler = SimpleNamespace(assemble_world_model=self._assemble_world_model) + self.route_resolver = SimpleNamespace(from_canonical=self._route_from_canonical) + self.decision_engine = SimpleNamespace(decide=lambda **_: {"decision": "unused"}) + + async def _assemble_world_model(self, **kwargs): + _ = kwargs + return None, {"capacity": [], "skill_graph": [], "resolved_project_id": None} + + def _route_from_canonical(self, **kwargs): + _ = kwargs + return SimpleNamespace( + key="query.answer", + family="query", + action="answer", + requested_count=1, + requires_confirmation=False, + project_id=None, + metadata={}, + ) + + async def _prepare_ingress_envelope(self, **kwargs): + _ = kwargs + return { + "early_result": None, + "clean_text": "继续整理语义上下文,不直接执行。", + "synthetic_message_id": "s-msg-semantic-shadow", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=200), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=55, state=None, context_snapshot=None, intent=None, project_id=None), + "session": SimpleNamespace(last_intent="query.answer"), + "session_key": "session-shadow", + } + + def _merge_ingress_thread_context(self, **kwargs): + _ = kwargs + return None + + def _should_route_to_draft_confirmation(self, **kwargs): + _ = kwargs + return False + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + return False + + async def _prepare_mixed_sidecar_context(self, **kwargs): + return kwargs["message_text"], {} + + async def _resolve_intent(self, **kwargs): + _ = kwargs + return CanonicalIntent( + name=mixin_entry.INTENT_IGNORE, + confidence=0.91, + project_id=None, + signal_title="", + signal_summary="", + metadata={"message_intent": "requirement_plan"}, + ), "llm" + + def _coerce_intent_for_query_scene(self, **kwargs): + return kwargs["intent"] + + def _enrich_signal_source_intent_from_closure(self, **kwargs): + return kwargs["intent"] + + async def _finalize_ignored_ingress(self, **kwargs): + _ = kwargs + return { + "handled": True, + "status": "ignored", + "classification": "ignored", + "direct_reply_required": False, + "direct_reply_text": "这轮先按旧链路忽略处理。", + } + + def _schedule_portfolio_snapshot_persist(self, **kwargs): + _ = kwargs + return None + + monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) + monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) + + kernel = FakeKernel() + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="feishu-shadow-1", + message_text="继续整理语义上下文,不直接执行。", + sender="tester", + channel_ref="chat-shadow", + source_sender_open_id="ou-shadow", + ) + + assert result["status"] == "ignored" + assert [name for name, _ in captured["calls"]] == [ + "begin", + "semantic_shadow", + "record", + "agent_trace", + "finalize", + ] + semantic_kwargs = captured["calls"][1][1] + assert semantic_kwargs["semantic_packet_summary"]["packet_version"] == "v1" + assert semantic_kwargs["semantic_packet_summary"]["topic_key"] == "feishu:chat-shadow:root:om-root-shadow" + assert semantic_kwargs["semantic_decision"]["mode_decision"] == "stay_plan" + assert semantic_kwargs["semantic_gate_result"]["accepted"] is True + assert semantic_kwargs["semantic_diff"]["legacy_route"] == "query.answer" + + def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) @@ -471,6 +682,141 @@ def test_new_project_statement_does_not_route_to_existing_draft_without_anchor() ) is False +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_does_not_hijack_explicit_new_requirement_topic( + monkeypatch, +) -> None: + called = False + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + nonlocal called + _ = (self, kwargs) + called = True + return True + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + + kernel = FakeKernel() + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), + sender="tester", + source_system="feishu", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", + ingress_options=None, + ) + + assert should_route is False + assert called is False + + +def test_should_inherit_recent_feishu_session_context_skips_explicit_new_requirement_topic() -> None: + session = SimpleNamespace( + sender="feishu:tester", + last_active_at=datetime.now(), + last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM, + ) + + should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context( + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", + session=session, + ) + + assert should_inherit is False + + +@pytest.mark.asyncio +async def test_explicit_new_requirement_clears_inherited_session_project_context() -> None: + class _FakeDB: + async def flush(self): + return None + + class FakeKernel( + mixin_entry.KernelIngressFlowEntryMixin, + ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + kernel = FakeKernel() + session = SimpleNamespace( + current_project_id=104, + last_intent="query.read_only", + last_message_text="A", + ) + + cleared = await kernel._maybe_reset_session_project_context_for_new_requirement( + session=session, + sender="tester", + channel_ref="chat-active", + source_sender_open_id="ou_active", + source_message_id="msg-new-topic", + thread_context=None, + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", + ) + + assert cleared is True + assert session.current_project_id is None + assert session.last_intent == "query.read_only" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("message_text", ["A", "确认", "你自行推荐"]) +async def test_orphan_requirement_followup_returns_direct_reprompt_when_no_active_session(message_text: str) -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + async def _has_active_requirement_plan_workspace(self, **kwargs): + _ = kwargs + return False + + kernel = FakeKernel() + result = await kernel._maybe_handle_orphan_requirement_followup( + source_system="feishu", + source_message_id="msg-orphan-reply", + channel_ref="chat-active", + source_sender_open_id="ou_active", + ingress_options=None, + message_text=message_text, + ) + + assert result is not None + assert result["handled"] is True + assert result["direct_reply_required"] is True + assert "没有进行中的 A/B/C 收敛轮次" in str(result["direct_reply_text"] or "") + + +@pytest.mark.asyncio +async def test_orphan_requirement_followup_skips_guard_when_active_requirement_session_exists() -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + async def _has_active_requirement_plan_workspace(self, **kwargs): + _ = kwargs + return True + + kernel = FakeKernel() + result = await kernel._maybe_handle_orphan_requirement_followup( + source_system="feishu", + source_message_id="msg-plan-reply", + channel_ref="chat-active", + source_sender_open_id="ou_active", + ingress_options=None, + message_text="A", + ) + + assert result is None + + def test_mounted_runtime_query_business_review_uses_plan_mode_and_non_executor_agent() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", @@ -545,16 +891,12 @@ def test_mounted_runtime_query_requirement_plan_uses_plan_coach() -> None: assert agent == "PlanCoach" -def test_mounted_runtime_query_requirement_plan_uses_semantic_bundle_metadata_for_plan_mode() -> None: +def test_mounted_runtime_query_requirement_plan_uses_direct_metadata_for_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - } + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, reply_template_key=None, ) @@ -764,12 +1106,10 @@ def test_coerce_rag_business_review_scene_keeps_rag_ask_on_query_chain() -> None assert coerced.metadata["message_intent"] == "business_review" assert coerced.metadata["consultant_mode_preference"] == "clarify" assert coerced.metadata["workflow_route"] == "query.answer" - semantic_bundle = coerced.metadata["semantic_bundle"] - assert semantic_bundle["diagnostics"]["message_intent"] == "business_review" - assert semantic_bundle["diagnostics"]["workflow_route"] == "query.answer" + assert "semantic_bundle" not in coerced.metadata -def test_coerce_feishu_semantic_business_review_bundle_keeps_query_chain() -> None: +def test_coerce_feishu_direct_business_review_metadata_keeps_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 @@ -781,7 +1121,12 @@ def test_coerce_feishu_semantic_business_review_bundle_keeps_query_chain() -> No project_id=None, signal_title="大模型 API 聚合平台", signal_summary="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,", - metadata={"llm_function": "classify_intent_ai_pmo_kernel"}, + metadata={ + "llm_function": "classify_intent_ai_pmo_kernel", + "message_intent": "business_review", + "review_framework": "five_lenses", + "consultant_mode_preference": "clarify", + }, ) coerced = kernel._coerce_intent_for_query_scene( @@ -789,14 +1134,7 @@ def test_coerce_feishu_semantic_business_review_bundle_keeps_query_chain() -> No source_system="feishu", message_text="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,", rag_request_context=None, - semantic_bundle={ - "primary_workflow": "query_flow", - "diagnostics": { - "message_intent": "business_review", - "review_framework": "five_lenses", - "consultant_mode_preference": "clarify", - }, - }, + semantic_bundle=None, world_model={}, ) @@ -805,7 +1143,7 @@ def test_coerce_feishu_semantic_business_review_bundle_keeps_query_chain() -> No assert coerced.metadata["workflow_route"] == "query.answer" -def test_coerce_feishu_deferred_requirement_discussion_to_query_chain_from_llm_bundle() -> None: +def test_coerce_feishu_deferred_requirement_discussion_to_query_chain_from_direct_metadata() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 @@ -818,13 +1156,8 @@ def test_coerce_feishu_deferred_requirement_discussion_to_query_chain_from_llm_b signal_title="直播复盘工具", signal_summary="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。", metadata={ - "semantic_bundle": mixin_entry.WorkflowSemanticPlanner.plan_from_intent( - intent_name="project.create", - message_text="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。", - source_system="ai_pmo_ingress", - workflow_route="project_create.create", - metadata={"confidence": 0.94, "requires_confirmation": True}, - ).to_dict() + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) @@ -843,7 +1176,7 @@ def test_coerce_feishu_deferred_requirement_discussion_to_query_chain_from_llm_b assert coerced.metadata["workflow_route"] == "query.answer" -def test_coerce_feishu_requirement_plan_without_semantic_bundle_keeps_query_chain() -> None: +def test_coerce_feishu_requirement_plan_from_direct_metadata_keeps_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 @@ -855,7 +1188,11 @@ def test_coerce_feishu_requirement_plan_without_semantic_bundle_keeps_query_chai project_id=None, signal_title="A股趋势日评打分工具", signal_summary="做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", - metadata={"llm_function": "classify_intent_ai_pmo_kernel"}, + metadata={ + "llm_function": "classify_intent_ai_pmo_kernel", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, ) coerced = kernel._coerce_intent_for_query_scene( @@ -873,7 +1210,7 @@ def test_coerce_feishu_requirement_plan_without_semantic_bundle_keeps_query_chai assert coerced.metadata["workflow_route"] == "query.answer" -def test_coerce_feishu_assignee_recommendation_to_query_chain_from_llm_bundle() -> None: +def test_coerce_feishu_assignee_recommendation_to_query_chain_from_direct_metadata() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 @@ -886,13 +1223,8 @@ def test_coerce_feishu_assignee_recommendation_to_query_chain_from_llm_bundle() signal_title="直播复盘工具", signal_summary="那你觉得分配给谁做比较合适?", metadata={ - "semantic_bundle": mixin_entry.WorkflowSemanticPlanner.plan_from_intent( - intent_name="project.create", - message_text="那你觉得分配给谁做比较合适?", - source_system="ai_pmo_ingress", - workflow_route="project_create.create", - metadata={"confidence": 0.88, "requires_confirmation": True}, - ).to_dict() + "message_intent": "business_review", + "consultant_mode_preference": "advise", }, ) @@ -959,10 +1291,11 @@ async def test_kernel_intent_resolve_inherits_recent_feishu_session_context_with @pytest.mark.asyncio -async def test_kernel_intent_resolve_locks_signal_source_request_before_llm() -> None: +async def test_kernel_intent_resolve_uses_llm_for_signal_source_request() -> None: class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 + self.called = False def _is_short_confirmation_message(self, **kwargs): _ = kwargs @@ -974,7 +1307,15 @@ async def test_kernel_intent_resolve_locks_signal_source_request_before_llm() -> async def _resolve_intent_with_llm(self, **kwargs): _ = kwargs - raise AssertionError("explicit signal source request should not hit llm classifier") + self.called = True + return CanonicalIntent( + name="signal_source.onboard", + confidence=0.92, + project_id=2, + signal_title="", + signal_summary="", + metadata={"source_request": True}, + ) resolver = FakeResolver() @@ -985,7 +1326,8 @@ async def test_kernel_intent_resolve_locks_signal_source_request_before_llm() -> thread_context=None, ) - assert source == "rule_signal_source_onboard" + assert source == "llm_only" + assert resolver.called is True assert intent.name == "signal_source.onboard" assert intent.project_id == 2 assert intent.metadata["source_request"] is True @@ -1358,7 +1700,7 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch) @pytest.mark.asyncio -async def test_execute_query_intent_uses_semantic_bundle_rag_context_when_no_explicit_rag_request(monkeypatch) -> None: +async def test_execute_query_intent_uses_direct_scene_metadata_when_no_explicit_rag_request(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: @@ -1382,13 +1724,9 @@ async def test_execute_query_intent_uses_semantic_bundle_rag_context_when_no_exp metadata={ "source_chat_id": "chat-dm-9", "source_sender_open_id": "ou-feishu-9", - "semantic_bundle": { - "diagnostics": { - "message_intent": "correction_escalation", - "consultant_mode_preference": "challenge", - "force_thinking_mode": "deep", - } - }, + "message_intent": "correction_escalation", + "consultant_mode_preference": "challenge", + "force_thinking_mode": "deep", }, ) result = await executor._execute_query_intent( @@ -1542,7 +1880,11 @@ async def test_execute_query_intent_short_circuits_casual_chat_without_rag(monke ) assert result["status"] == "succeeded" - assert "我在线" in str(result["answer"] or "") + answer = str(result["answer"] or "") + assert "我在线" in answer + assert "A." not in answer + assert "B." not in answer + assert "C." not in answer assert str(result["rag_result"]["query_type"]) == "chat" @@ -1585,7 +1927,12 @@ async def test_execute_query_intent_short_circuits_requirement_followup_clarify_ ) assert result["status"] == "succeeded" - assert "轻量版继续理解" in str(result["answer"] or "") + answer = str(result["answer"] or "") + assert "轻量版继续理解" in answer + assert "A." in answer + assert "B." in answer + assert "C." in answer + assert "直接回 A/B/C" in answer assert str(result["rag_result"]["query_type"]) == "clarify" @@ -1644,12 +1991,8 @@ async def test_execute_query_intent_requirement_plan_scene_short_circuits_withou intent = SimpleNamespace( project_id=None, metadata={ - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - } + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) @@ -1695,12 +2038,8 @@ async def test_execute_query_intent_requirement_plan_followup_persists_progress_ metadata={ "source_chat_id": "chat-plan-1", "source_sender_open_id": "ou-plan-1", - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( @@ -1712,12 +2051,8 @@ async def test_execute_query_intent_requirement_plan_followup_persists_progress_ "root_message_id": "msg-plan-root", "reply_to_message_id": "msg-plan-root", }, - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) @@ -1789,12 +2124,8 @@ async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draf metadata={ "source_chat_id": "chat-plan-2", "source_sender_open_id": "ou-plan-2", - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) intent_followup = SimpleNamespace( @@ -1806,12 +2137,8 @@ async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draf "root_message_id": "msg-plan-root-2", "reply_to_message_id": "msg-plan-root-2", }, - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) @@ -1852,6 +2179,8 @@ async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draf assert "【输出形式】" in answer assert "【更新频率】" in answer assert str(final_result["rag_result"]["query_type"]) == "draft" + assert final_result["reply_template_key"] == "draft_pending_confirm" + assert final_result["draft_artifact"]["promote_to_confirmation"] is True draft = ( await requirement_plan_db_session.execute( @@ -1865,6 +2194,9 @@ async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draf ).scalar_one() assert draft.state == "ready" assert "每日 A 股趋势打分工具" in str((draft.draft_payload or {}).get("title") or "") + assert draft.active_work_item_id is None + assert draft.latest_source_message_id == "msg-plan-reply-2c" + assert draft.latest_reply_message_id is None assert topic.plan_status == "awaiting_execute_confirm" assert topic.pending_actions == ["confirm_requirement_draft"] @@ -1895,12 +2227,8 @@ async def test_execute_query_intent_explicit_new_requirement_topic_suspends_prev metadata={ "source_chat_id": "chat-plan-switch", "source_sender_open_id": "ou-plan-switch", - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) explicit_switch_intent = SimpleNamespace( @@ -1908,13 +2236,9 @@ async def test_execute_query_intent_explicit_new_requirement_topic_suspends_prev metadata={ "source_chat_id": "chat-plan-switch", "source_sender_open_id": "ou-plan-switch", - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - "topic_switch": "new", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": "new", }, ) @@ -1949,6 +2273,332 @@ async def test_execute_query_intent_explicit_new_requirement_topic_suspends_prev assert rows[1].plan_status == "collecting" +@pytest.mark.asyncio +async def test_execute_query_intent_explicit_new_it_requirement_overrides_stale_clarify_and_avoids_old_scoring_topic( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("new IT requirement topic should stay in direct plan flow") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-it-switch", + source_sender_open_id="ou-plan-it-switch", + sender=None, + ) + old_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-it-switch:message:msg-old-root", + source_system="feishu", + channel_ref="chat-plan-it-switch", + conversation_scope_key=conversation_scope_key, + root_message_id="msg-old-root", + mode="plan", + status="active", + plan_status="awaiting_execute_confirm", + metadata_json={"plan_mode": True}, + facts_json={"plan_profile": "scoring", "title": "每日 A 股趋势打分工具"}, + ) + requirement_plan_db_session.add(old_topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(old_topic.id), + state="ready", + draft_version=1, + requirement_card={ + "plan_profile": "scoring", + "title": "每日 A 股趋势打分工具", + "goal": "每天产出一个可解释的 A 股趋势分数", + "scoring_basis": "技术指标(均线、成交量、涨跌比等)", + "target_user": "投顾", + "output_form": "飞书日报", + "cadence": "每日", + "first_scope": "第一版先做 A 股趋势分数和一句解释", + }, + draft_payload={"title": "每日 A 股趋势打分工具"}, + required_confirmations=["需求草稿需要人工确认后才允许正式创建"], + ) + ) + await requirement_plan_db_session.flush() + + stale_clarify_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-it-switch", + "source_sender_open_id": "ou-plan-it-switch", + "message_intent": "business_review", + "consultant_mode_preference": "clarify", + }, + ) + followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-it-switch", + "source_sender_open_id": "ou-plan-it-switch", + "thread_context": { + "root_message_id": "msg-new-it-root", + "reply_to_message_id": "msg-new-it-root", + }, + }, + ) + + first = await executor._execute_query_intent( + intent=stale_clarify_intent, + message_text="想跟你聊个新需求关于批量自动化部署openclaw的,先别创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-new-it-root", + ) + second = await executor._execute_query_intent( + intent=followup_intent, + message_text="C,先按云原生交付底座来收。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-new-it-2", + ) + + first_answer = str(first["answer"] or "") + second_answer = str(second["answer"] or "") + + assert "🧭 我先按“批量自动化部署 OpenClaw”这类 IT 项目来收。" in first_answer + assert "云原生交付底座" in first_answer + assert "目标对象" not in first_answer + assert "A股趋势打分" not in first_answer + + assert "🧭 我先按“云原生交付底座”这个方向继续收。" in second_answer + assert "现在要先把服务对象定住" in second_answer + assert "A股趋势打分" not in second_answer + assert "飞书日报" not in second_answer + + rows = ( + await requirement_plan_db_session.execute( + select(MountedTopicWorkspace) + .where(MountedTopicWorkspace.tenant_id == 3) + .order_by(MountedTopicWorkspace.id.asc()) + ) + ).scalars().all() + + assert len(rows) == 2 + assert rows[0].status == "suspended" + assert rows[0].plan_status == "suspended" + assert rows[1].status == "active" + assert rows[1].plan_status == "collecting" + assert rows[1].root_message_id == "msg-new-it-root" + + +@pytest.mark.asyncio +async def test_execute_query_intent_explicit_new_requirement_topic_reuses_current_tracking_topic( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("explicit new topic switching should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-switch-reuse", + source_sender_open_id="ou-plan-switch-reuse", + sender=None, + ) + existing_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-switch-reuse:message:msg-plan-switch-root-reuse", + source_system="feishu", + channel_ref="chat-plan-switch-reuse", + conversation_scope_key=conversation_scope_key, + root_message_id="msg-plan-switch-root-reuse", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(existing_topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(existing_topic.id), + state="collecting", + draft_version=0, + requirement_card={"title": "旧需求"}, + draft_payload=None, + required_confirmations=["需求草稿需要人工确认后才允许正式创建"], + ) + ) + tracking_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-switch-reuse:message:msg-plan-switch-new-reuse", + source_system="feishu", + channel_ref="chat-plan-switch-reuse", + conversation_scope_key=conversation_scope_key, + mode="chat", + status="active", + plan_status="idle", + metadata_json={"tracking": True}, + ) + requirement_plan_db_session.add(tracking_topic) + await requirement_plan_db_session.flush() + + explicit_switch_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-switch-reuse", + "source_sender_open_id": "ou-plan-switch-reuse", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": "new", + }, + ) + + switch_result = await executor._execute_query_intent( + intent=explicit_switch_intent, + message_text="想跟你聊个新需求,关于批量自动化部署openclaw,先不创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-switch-new-reuse", + ) + + rows = ( + await requirement_plan_db_session.execute( + select(MountedTopicWorkspace) + .where(MountedTopicWorkspace.tenant_id == 3) + .order_by(MountedTopicWorkspace.id.asc()) + ) + ).scalars().all() + + assert len(rows) == 2 + assert switch_result["status"] == "succeeded" + assert rows[0].status == "suspended" + assert rows[0].plan_status == "suspended" + assert rows[1].id == tracking_topic.id + assert rows[1].status == "active" + assert rows[1].mode == "plan" + assert rows[1].plan_status == "collecting" + + +@pytest.mark.asyncio +async def test_execute_query_intent_explicit_new_requirement_topic_with_subject_starts_collection_immediately( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("explicit new topic seed should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-switch-subject", + source_sender_open_id="ou-plan-switch-subject", + sender=None, + ) + existing_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-switch-subject:message:msg-plan-switch-subject-root", + source_system="feishu", + channel_ref="chat-plan-switch-subject", + conversation_scope_key=conversation_scope_key, + root_message_id="msg-plan-switch-subject-root", + mode="plan", + status="active", + plan_status="draft_pending_confirm", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(existing_topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(existing_topic.id), + state="draft_pending_confirm", + draft_version=1, + requirement_card={"title": "旧需求"}, + draft_payload={"title": "旧需求"}, + required_confirmations=["需求草稿需要人工确认后才允许正式创建"], + ) + ) + tracking_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-switch-subject:message:msg-plan-switch-subject-new", + source_system="feishu", + channel_ref="chat-plan-switch-subject", + conversation_scope_key=conversation_scope_key, + mode="chat", + status="active", + plan_status="idle", + metadata_json={"tracking": True}, + ) + requirement_plan_db_session.add(tracking_topic) + await requirement_plan_db_session.flush() + + explicit_switch_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-switch-subject", + "source_sender_open_id": "ou-plan-switch-subject", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": "new", + }, + ) + + result = await executor._execute_query_intent( + intent=explicit_switch_intent, + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-switch-subject-new", + ) + + answer = str(result["answer"] or "") + assert "你先发一句这次真正想解决什么问题" not in answer + assert "主要给谁用" in answer + assert "会议纪要" not in answer + assert "输入先按用户当前需求描述来" not in answer + assert "A." in answer + assert "B." in answer + assert "C." in answer + assert "你回 A / B / C" in answer + assert str(result["rag_result"]["query_type"]) == "plan" + + @pytest.mark.asyncio async def test_execute_query_intent_low_overlap_requirement_seed_switches_new_topic( monkeypatch, @@ -2024,12 +2674,8 @@ async def test_execute_query_intent_low_overlap_requirement_seed_switches_new_to metadata={ "source_chat_id": "chat-plan-overlap", "source_sender_open_id": "ou-plan-overlap", - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) @@ -2083,12 +2729,8 @@ async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_an metadata={ "source_chat_id": "chat-plan-3", "source_sender_open_id": "ou-plan-3", - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) fragmented_followup_intent = SimpleNamespace( @@ -2201,12 +2843,8 @@ async def test_execute_query_intent_requirement_plan_generalizes_for_meeting_min metadata={ "source_chat_id": "chat-plan-meeting", "source_sender_open_id": "ou-plan-meeting", - "semantic_bundle": { - "diagnostics": { - "message_intent": "requirement_plan", - "consultant_mode_preference": "clarify", - } - }, + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( @@ -2258,8 +2896,17 @@ async def test_execute_query_intent_requirement_plan_generalizes_for_meeting_min ) assert "主要给谁用" in str(first["answer"] or "") or "主要给谁看" in str(first["answer"] or "") - assert "怎么给结果" in str(second["answer"] or "") or "怎么接收" in str(second["answer"] or "") + assert "A." in str(first["answer"] or "") + assert "B." in str(first["answer"] or "") + assert "C." in str(first["answer"] or "") + assert "怎么给结果" in str(second["answer"] or "") or "怎么接收" in str(second["answer"] or "") or "最后怎么给你" in str(second["answer"] or "") + assert "A." in str(second["answer"] or "") + assert "B." in str(second["answer"] or "") + assert "C." in str(second["answer"] or "") assert "多久整理出来" in str(third["answer"] or "") or "时效" in str(third["answer"] or "") + assert "A." in str(third["answer"] or "") + assert "B." in str(third["answer"] or "") + assert "C." in str(third["answer"] or "") assert "先保留哪些字段" in str(fourth["answer"] or "") or "第一版先保留哪些字段" in str(fourth["answer"] or "") final_answer = str(final_result["answer"] or "") @@ -2285,6 +2932,259 @@ async def test_execute_query_intent_requirement_plan_generalizes_for_meeting_min assert "待办" in str(card["first_scope"] or "") +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_pm_template( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("it project requirement plan should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + root_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-it", + "source_sender_open_id": "ou-plan-it", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-it", + "source_sender_open_id": "ou-plan-it", + "thread_context": { + "root_message_id": "msg-plan-it-root", + "reply_to_message_id": "msg-plan-it-root", + }, + }, + ) + + first = await executor._execute_query_intent( + intent=root_intent, + message_text="想跟你聊个 IT 项目需求,做一个批量自动化部署 OpenClaw 的平台,先别创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-it-root", + ) + second = await executor._execute_query_intent( + intent=followup_intent, + message_text="B,先做发布治理平台。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-it-2", + ) + third = await executor._execute_query_intent( + intent=followup_intent, + message_text="B,主要给平台运维团队用。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-it-3", + ) + fourth = await executor._execute_query_intent( + intent=followup_intent, + message_text="B,走任务编排平台版,用 FastAPI + React + Redis + Celery + Postgres。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-it-4", + ) + fifth = await executor._execute_query_intent( + intent=followup_intent, + message_text="A,第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-it-5", + ) + final_result = await executor._execute_query_intent( + intent=followup_intent, + message_text="B,先做项目级试点,带基础审批和回滚。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-it-6", + ) + + assert "🧭 我先按“批量自动化部署 OpenClaw”这类 IT 项目来收。" in str(first["answer"] or "") + assert "A. 先做内部提效工具" in str(first["answer"] or "") + assert "先定它更像工具、平台,还是长期底座" in str(first["answer"] or "") + assert "⭐ 当前我更推荐:B" in str(first["answer"] or "") + assert "你回 A / B / C" in str(first["answer"] or "") + + assert "🧭 我先按“发布治理平台”这个方向继续收。" in str(second["answer"] or "") + assert "平台运维团队" in str(second["answer"] or "") + assert "⭐ 当前我更推荐:B" in str(second["answer"] or "") + + assert "🧭 我先按“平台运维团队要长期使用”这个方向继续收。" in str(third["answer"] or "") + assert "现在最关键的,不是再补更多背景,而是先把技术路线定住" in str(third["answer"] or "") + assert "FastAPI + React + Redis + Celery + PostgreSQL" in str(third["answer"] or "") + assert "⭐ 当前我更推荐:B" in str(third["answer"] or "") + + assert "🧭 我先按“任务编排平台版”继续往下收。" in str(fourth["answer"] or "") + assert "批量执行闭环" in str(fourth["answer"] or "") + assert "⭐ 当前我更推荐:A" in str(fourth["answer"] or "") + + assert "🧭 我先按“批量执行闭环”继续收尾。" in str(fifth["answer"] or "") + assert "项目级试点" in str(fifth["answer"] or "") + assert "⭐ 当前我更推荐:B" in str(fifth["answer"] or "") + + final_answer = str(final_result["answer"] or "") + assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer + assert "🧭【一句话判断】" in final_answer + assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer + assert "👥【服务对象】第一版先服务平台运维团队" in final_answer + assert "📡【信号源】当前至少要接入:" in final_answer + assert "飞书需求消息" in final_answer + assert "部署目标清单" in final_answer + assert "📝【当前已知信息】目前已经明确:" in final_answer + assert "⚙️【技术路线】当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" in final_answer + assert "🧱【第一版范围】第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。" in final_answer + assert "🔄【建议核心流程】" in final_answer + assert "生成部署任务" in final_answer + assert "👤【人员配置】建议最小配置:" in final_answer + assert "平台/运维工程师" in final_answer + assert "🚀【推进方式】先按项目级试点(带基础审批和回滚)推进" in final_answer + assert "⚠️【风险与依赖】当前主要风险在" in final_answer + assert "凭证管理" in final_answer + assert "❓【待确认】还需要你拍板:" in final_answer + assert "部署目标范围" in final_answer + assert "试点负责人" in final_answer + assert str(final_result["rag_result"]["query_type"]) == "draft" + + draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) + ) + ).scalar_one() + card = draft.requirement_card or {} + assert card["plan_profile"] == "it_project" + assert card["project_nature"] == "发布治理平台" + assert card["target_user"] == "平台运维团队" + assert "FastAPI" in str(card["tech_route"] or "") + assert "批量执行闭环" in str(card["first_scope"] or "") + assert "项目级试点" in str(card["delivery_mode"] or "") + + +@pytest.mark.asyncio +async def test_execute_query_intent_single_choice_does_not_resume_awaiting_requirement_draft( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + return { + "answer": "当前没有进行中的 A/B/C 收敛轮次。你先发一句新需求,我按新的 topic 接着收。", + "query_type": "status", + "confidence": 0.42, + "missing_fields": [], + } + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-old-draft", + source_sender_open_id="ou-old-draft", + sender=None, + ) + old_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-old-draft:message:sim-plan-1431-1", + source_system="feishu", + channel_ref="chat-old-draft", + conversation_scope_key=conversation_scope_key, + root_message_id="sim-plan-1431-1", + mode="plan", + status="active", + plan_status="awaiting_execute_confirm", + pending_actions=["confirm_requirement_draft"], + metadata_json={ + "plan_mode": True, + "plan_question_round": 17, + "plan_last_prompt": "下一个关键问题:你更想怎么接收这个结果,是飞书日报、页面看板,还是只推送一个分数加一句解释?", + }, + facts_json={ + "title": "每日 A 股趋势打分工具", + "plan_profile": "scoring", + "target_user": "投顾", + }, + ) + requirement_plan_db_session.add(old_topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(old_topic.id), + state="ready", + draft_version=1, + requirement_card={ + "plan_profile": "scoring", + "title": "每日 A 股趋势打分工具", + "goal": "每天产出一个可解释的 A 股趋势分数", + "target_user": "投顾", + "scoring_basis": "技术指标(均线、成交量、涨跌比等)", + "output_form": "飞书日报", + "cadence": "每日", + "first_scope": "第一版先做 A 股趋势分数和一句解释", + }, + draft_payload={"title": "每日 A 股趋势打分工具"}, + required_confirmations=["需求草稿需要人工确认后才允许正式创建"], + ) + ) + await requirement_plan_db_session.flush() + + followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-old-draft", + "source_sender_open_id": "ou-old-draft", + }, + ) + + result = await executor._execute_query_intent( + intent=followup_intent, + message_text="C", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-old-draft-c", + ) + + answer = str(result["answer"] or "") + assert "每日 A 股趋势打分工具" not in answer + assert "需求草稿" not in answer + assert "新需求" in answer + assert str(result["rag_result"]["query_type"]) == "status" + + topic = ( + await requirement_plan_db_session.execute( + select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(old_topic.id)) + ) + ).scalar_one() + assert topic.plan_status == "awaiting_execute_confirm" + assert topic.pending_actions == ["confirm_requirement_draft"] + + @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_advise_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: @@ -2395,6 +3295,49 @@ async def test_execute_query_intent_short_circuits_assignee_recommendation_witho assert str(result["rag_result"]["query_type"]) == "recommendation" +@pytest.mark.asyncio +async def test_execute_query_intent_prefers_direct_advise_subtype_for_assignee(monkeypatch) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("direct assignee subtype should bypass heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 3 + + async def _build_assignee_recommendation_answer(self, *, message_text: str, project_id): + _ = (message_text, project_id) + return "如果先给一个 owner,我会优先推荐 AI工程师。" + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=None, + metadata={ + "message_intent": "business_review", + "consultant_mode_preference": "advise", + "advise_subtype": "assignee", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="那你觉得分配给谁做比较合适?", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-chat-3c-direct", + ) + + assert result["status"] == "succeeded" + assert "AI工程师" in str(result["answer"] or "") + assert str(result["rag_result"]["query_type"]) == "recommendation" + + @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_project_existence_lookup_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: @@ -2449,6 +3392,79 @@ async def test_draft_confirmation_route_does_not_hijack_explicit_signal_source_r assert should_route is False +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_uses_active_mounted_draft_without_anchor( + monkeypatch, +) -> None: + captured: dict[str, object] = {} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + _ = self + captured["kwargs"] = kwargs + return True + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + + kernel = FakeKernel() + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent="query.answer"), + sender="tester", + source_system="feishu", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="确认", + ingress_options=None, + ) + + assert should_route is True + assert captured["kwargs"] == { + "sender": "tester", + "source_system": "feishu", + "channel_ref": "chat-active", + "source_sender_open_id": "ou_active", + "thread_context": {}, + } + + +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_does_not_hijack_smalltalk_even_if_active_draft_exists( + monkeypatch, +) -> None: + called = False + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + nonlocal called + _ = (self, kwargs) + called = True + return True + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + + kernel = FakeKernel() + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent="query.answer"), + sender="tester", + source_system="feishu", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="收到,辛苦了", + ingress_options=None, + ) + + assert should_route is False + assert called is False + + def test_mounted_runtime_agent_name_uses_plan_coach_for_scope_advise() -> None: agent_name = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", diff --git a/backend/tests/test_context_cache_profile_catalog.py b/backend/tests/test_context_cache_profile_catalog.py index 77d3af91..37c94946 100644 --- a/backend/tests/test_context_cache_profile_catalog.py +++ b/backend/tests/test_context_cache_profile_catalog.py @@ -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_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: for item in items: 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") assert "/rag/ask" 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 diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index 83832315..b09c01de 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -1,8 +1,10 @@ +import json from types import SimpleNamespace import pytest from app.config.ai_prompts import ( + PROMPT_AI_PMO_REQUIREMENT_PLAN_QA, PROMPT_EXTERNAL_TREND_QA, PROMPT_FEISHU_DIRECT_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" +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: reply = ( "好的,我来帮你创建项目和信号源。\n\n" @@ -950,6 +971,58 @@ async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monk 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: assert callable(mixin_auto._safe_int) 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 +@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: 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_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 "通用行业认知" in PROMPT_EXTERNAL_TREND_QA assert "项目状态分析助手" in PROMPT_PROJECT_STATUS_QA diff --git a/backend/tests/test_model_routing_service.py b/backend/tests/test_model_routing_service.py index 15637645..151417af 100644 --- a/backend/tests/test_model_routing_service.py +++ b/backend/tests/test_model_routing_service.py @@ -204,6 +204,46 @@ async def test_runtime_low_override_does_not_downgrade_protected_adaptive_rag_fu 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 async def test_model_routing_list_includes_current_business_entry_points(db_env) -> None: db_session, _maker = db_env diff --git a/backend/tests/test_mounted_ai_runtime_migration.py b/backend/tests/test_mounted_ai_runtime_migration.py index 48462d70..2e11447c 100644 --- a/backend/tests/test_mounted_ai_runtime_migration.py +++ b/backend/tests/test_mounted_ai_runtime_migration.py @@ -38,6 +38,21 @@ def _load_plan_state_migration_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: migration = _load_migration_module() 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 "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 diff --git a/backend/tests/test_mounted_ai_runtime_models.py b/backend/tests/test_mounted_ai_runtime_models.py index f72a5ef5..24fb1698 100644 --- a/backend/tests/test_mounted_ai_runtime_models.py +++ b/backend/tests/test_mounted_ai_runtime_models.py @@ -96,6 +96,9 @@ def test_mounted_ai_runtime_models_roundtrip_and_defaults() -> None: draft_version=1, requirement_card={"goals": ["整理复盘需求"]}, 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( tenant_id=1, @@ -149,6 +152,9 @@ def test_mounted_ai_runtime_models_roundtrip_and_defaults() -> None: assert saved_topic.status == "active" 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_trace.tool_trace is None assert saved_activation.cutover_mode == "mounted_only" diff --git a/backend/tests/test_mounted_ai_runtime_write_service.py b/backend/tests/test_mounted_ai_runtime_write_service.py index dc731713..68e04f6a 100644 --- a/backend/tests/test_mounted_ai_runtime_write_service.py +++ b/backend/tests/test_mounted_ai_runtime_write_service.py @@ -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.pending_actions == ["confirm_requirement_draft"] 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"] == "先继续整理语义上下文,不要直接执行。" diff --git a/backend/tests/test_semantic_context_assembler.py b/backend/tests/test_semantic_context_assembler.py new file mode 100644 index 00000000..ba4353e8 --- /dev/null +++ b/backend/tests/test_semantic_context_assembler.py @@ -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"] diff --git a/backend/tests/test_semantic_context_runtime_api.py b/backend/tests/test_semantic_context_runtime_api.py new file mode 100644 index 00000000..236c8239 --- /dev/null +++ b/backend/tests/test_semantic_context_runtime_api.py @@ -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 diff --git a/backend/tests/test_semantic_decision_service.py b/backend/tests/test_semantic_decision_service.py new file mode 100644 index 00000000..ecf4c7c3 --- /dev/null +++ b/backend/tests/test_semantic_decision_service.py @@ -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" diff --git a/backend/tmp_acceptance/consultant_dispatch_feishu_runs.jsonl b/backend/tmp_acceptance/consultant_dispatch_feishu_runs.jsonl index 0a307911..7b7654ae 100644 --- a/backend/tmp_acceptance/consultant_dispatch_feishu_runs.jsonl +++ b/backend/tmp_acceptance/consultant_dispatch_feishu_runs.jsonl @@ -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: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-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"} diff --git a/frontend/src/api/mountedAi.ts b/frontend/src/api/mountedAi.ts index 1d4230fc..fcd65098 100644 --- a/frontend/src/api/mountedAi.ts +++ b/frontend/src/api/mountedAi.ts @@ -94,6 +94,7 @@ export interface MountedAITopicItem { thread_key?: string | null; mode: string; status: string; + plan_status?: string | null; readiness_score?: number | null; draft_state?: string | null; draft_version?: number | null; @@ -137,12 +138,49 @@ export interface MountedAIAgentTraceItem { 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 { item: MountedAIRunItem; + topic?: MountedAITopicItem | null; + draft?: MountedAIDraftItem | null; agent_traces: MountedAIAgentTraceItem[]; context_layers_hit?: Record | null; tool_calls?: unknown; execution_outcome?: unknown; + semantic_packet_summary?: MountedAISemanticPacketSummary | null; + semantic_decision?: unknown; + semantic_gate_result?: unknown; + semantic_diff?: unknown; } export interface MountedAICutoverStatusPayload { diff --git a/frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx b/frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx index 0d6a024e..8acdc293 100644 --- a/frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx +++ b/frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx @@ -13,6 +13,9 @@ import { MountedAIHardCasesPanel } from "./components/MountedAIHardCasesPanel"; import { MountedAIOrchestrationGraphPanel } from "./components/MountedAIOrchestrationGraphPanel"; import { MountedAIRunReplayPanel } from "./components/MountedAIRunReplayPanel"; 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 { MountedAISourceStatusPanel } from "./components/MountedAISourceStatusPanel"; import { MountedAIToolGuardrailsPanel } from "./components/MountedAIToolGuardrailsPanel"; @@ -290,6 +293,13 @@ export default function SchedulerCenterPage() { /> +
+ + +
+ + +
diff --git a/frontend/src/pages/scheduler-center/components/MountedAISemanticDecisionPanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAISemanticDecisionPanel.tsx new file mode 100644 index 00000000..65f4cbcd --- /dev/null +++ b/frontend/src/pages/scheduler-center/components/MountedAISemanticDecisionPanel.tsx @@ -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) + : null; + const gate = + detail?.semantic_gate_result && typeof detail.semantic_gate_result === "object" + ? (detail.semantic_gate_result as Record) + : null; + + return ( + + + Semantic Decision / Gate + 展示模型如何判定 topic、mode 与 draft patch,以及 gatekeeper 最终是否放行。 + + + {loading ? ( +
+ 正在加载 semantic decision... +
+ ) : null} + + {!loading && !detail ? ( +
+ 选择一条 run 后,这里会展示 semantic decision 与 gate result。 +
+ ) : null} + + {!loading && detail ? ( + <> +
+
+
Topic Relation
+
{String(decision?.topic_relation || "-")}
+
+
+
Mode Decision
+
{String(decision?.mode_decision || "-")}
+
+
+
Gate Accepted
+
+ + {gate?.accepted ? "accepted" : "blocked"} + +
+
+
+
Next Plan Status
+
{String(gate?.next_plan_status || "-")}
+
+
+ +
+
+
Semantic Decision
+
+                  {prettyJson(detail.semantic_decision)}
+                
+
+
+
Gate Result
+
+                  {prettyJson(detail.semantic_gate_result)}
+                
+
+
+ + ) : null} +
+
+ ); +} diff --git a/frontend/src/pages/scheduler-center/components/MountedAISemanticPacketPanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAISemanticPacketPanel.tsx new file mode 100644 index 00000000..27240beb --- /dev/null +++ b/frontend/src/pages/scheduler-center/components/MountedAISemanticPacketPanel.tsx @@ -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) + : null; + + return ( + + + Semantic Context Packet + 展示当前 shadow 语义包里真正喂给模型的 topic / draft / recent turns / mode card 摘要。 + + + {loading ? ( +
+ 正在加载 semantic packet... +
+ ) : null} + + {!loading && !detail ? ( +
+ 选择一条 run 后,这里会展示 semantic packet。 +
+ ) : null} + + {!loading && detail ? ( + <> +
+
+
Packet Version
+
{packet?.packet_version || "-"}
+
+
+
Workflow Mode
+
{packet?.workflow_mode || detail.topic?.mode || "-"}
+
+
+
Draft Version
+
{packet?.draft_version ?? detail.draft?.draft_version ?? "-"}
+
+
+
Plan Status
+
{detail.topic?.plan_status || "-"}
+
+
+ +
+
+
+
+
Topic / Thread
+
+ 当前挂载 runtime topic 与 thread 边界。 +
+
+ {recentTurns.length} turns +
+
+
+ Topic Key: + {detail.topic?.topic_key || packet?.topic_key || "-"} +
+
+ Thread Key: + {detail.topic?.thread_key || "-"} +
+
+ Draft State: + {detail.draft?.state || detail.topic?.draft_state || "-"} +
+
+
Thread Summary
+
+ {packet?.thread_summary || "暂无"} +
+
+
+
+ +
+
Mode Card
+ {modeCard ? ( +
+
+ {String(modeCard.workflow_mode || "-")} + {String(modeCard.mode_state || "-")} +
+
+                      {prettyJson(modeCard)}
+                    
+
+ ) : ( +
+ 当前 run 尚未记录 mode card。 +
+ )} +
+
+ +
+
+
+
Recent Turns
+ {packet?.recent_turn_count ?? recentTurns.length} +
+
+ {recentTurns.length > 0 ? ( + recentTurns.map((turn, index) => ( +
+
+ {turn.role} +
+
{turn.text}
+
+ )) + ) : ( +
+ 当前 packet 没有 recent turns。 +
+ )} +
+
+ +
+
Open Questions
+
+ {openQuestions.length > 0 ? ( + openQuestions.map((item, index) => ( +
+ {item} +
+ )) + ) : ( +
+ 当前 packet 没有待确认问题。 +
+ )} +
+
+
+ + ) : null} +
+
+ ); +} diff --git a/frontend/src/pages/scheduler-center/components/MountedAIShadowDiffPanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAIShadowDiffPanel.tsx new file mode 100644 index 00000000..68305501 --- /dev/null +++ b/frontend/src/pages/scheduler-center/components/MountedAIShadowDiffPanel.tsx @@ -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) + : null; + const executionOutcome = + detail?.execution_outcome && typeof detail.execution_outcome === "object" + ? (detail.execution_outcome as Record) + : null; + + return ( + + + Shadow Diff + 对比旧链路的 legacy route 与新语义链路的 shadow 结论,用于切主链前验收。 + + + {loading ? ( +
+ 正在加载 shadow diff... +
+ ) : null} + + {!loading && !detail ? ( +
+ 选择一条 run 后,这里会展示 shadow diff。 +
+ ) : null} + + {!loading && detail ? ( + <> +
+
+
Legacy Route
+
{String(diff?.legacy_route || executionOutcome?.workflow_route || "-")}
+
+
+
Legacy Intent
+
{String(diff?.legacy_message_intent || "-")}
+
+
+
Shadow Changed
+
+ + {diff?.changed ? "changed" : "same"} + +
+
+
+
Shadow Error
+
{String(diff?.shadow_error || "-")}
+
+
+ +
+
+
Shadow Diff Payload
+
+                  {prettyJson(detail.semantic_diff)}
+                
+
+
+
Legacy Execution Outcome
+
+                  {prettyJson(detail.execution_outcome)}
+                
+
+
+ + ) : null} +
+
+ ); +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index e91bcd75..5d455ac2 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-config/featureintroguide.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/ai-steward/qamarkdowncontent.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/feedbacksummarycard.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/knowledgegraphcontextboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/pages/ai-config/aiconfigpage.tsx","./src/pages/ai-config/constants.ts","./src/pages/ai-config/types.ts","./src/pages/ai-config/components/governancetoolscard.tsx","./src/pages/ai-config/components/prioritybadge.tsx","./src/pages/ai-config/components/statusbadge.tsx","./src/pages/ai-config/sections/advancedtab.tsx","./src/pages/ai-config/sections/configuretab.tsx","./src/pages/ai-config/sections/knowledgetab.tsx","./src/pages/ai-config/sections/overviewtab.tsx","./src/pages/ai-steward/aistewardpage.tsx","./src/pages/ai-steward/constants.ts","./src/pages/ai-steward/types.ts","./src/pages/ai-steward/components/briefingdetaildrawer.tsx","./src/pages/ai-steward/components/briefingprintdocument.tsx","./src/pages/ai-steward/components/briefingsectioncard.tsx","./src/pages/ai-steward/components/featureoverviewcards.tsx","./src/pages/ai-steward/components/feedback-center/feedbackdetail.tsx","./src/pages/ai-steward/components/feedback-center/feedbacklist.tsx","./src/pages/ai-steward/components/feedback-center/metricspanel.tsx","./src/pages/ai-steward/components/feedback-center/reasonpicker.tsx","./src/pages/ai-steward/components/feedback-center/trendpanel.tsx","./src/pages/ai-steward/sections/blockerstab.tsx","./src/pages/ai-steward/sections/briefingtab.tsx","./src/pages/ai-steward/sections/conflictstab.tsx","./src/pages/ai-steward/sections/createprojecttab.tsx","./src/pages/ai-steward/sections/enginetab.tsx","./src/pages/ai-steward/sections/feedbackcentertab.tsx","./src/pages/ai-steward/sections/overviewtab.tsx","./src/pages/ai-steward/sections/signalstab.tsx","./src/pages/charter-detail/charterdetailpage.tsx","./src/pages/contract-sync-board/contractsyncboardpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/feishu-config/feishuconfigpage.tsx","./src/pages/feishu-config/helpers.ts","./src/pages/feishu-config/components/setupguidecard.tsx","./src/pages/inbox/inboxpage.tsx","./src/pages/inbox/helpers.ts","./src/pages/inbox/components/inboxfilterspanel.tsx","./src/pages/knowledge-graph-context-board/knowledgegraphcontextboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/model-routing-config/modelroutingconfigpage.tsx","./src/pages/project-board/projectboardpage.tsx","./src/pages/project-board/helpers.ts","./src/pages/project-board/components/createcharterdialog.tsx","./src/pages/project-board/components/projectsettingsdialog.tsx","./src/pages/projects/projectspage.tsx","./src/pages/projects/helpers.ts","./src/pages/projects/components/deleteprojectdialog.tsx","./src/pages/projects/components/projectquerydialog.tsx","./src/pages/recipes/recipespage.tsx","./src/pages/recipes/helpers.ts","./src/pages/recipes/components/analyzerecipedialog.tsx","./src/pages/recipes/components/createrecipedialog.tsx","./src/pages/scheduler-center/schedulercenterpage.tsx","./src/pages/scheduler-center/components/mountedaiacceptancepanel.tsx","./src/pages/scheduler-center/components/mountedaicontextlayerspanel.tsx","./src/pages/scheduler-center/components/mountedaievalscoreboardpanel.tsx","./src/pages/scheduler-center/components/mountedaihardcasespanel.tsx","./src/pages/scheduler-center/components/mountedaiorchestrationgraphpanel.tsx","./src/pages/scheduler-center/components/mountedairunreplaypanel.tsx","./src/pages/scheduler-center/components/mountedairunspanel.tsx","./src/pages/scheduler-center/components/mountedaisourceactivationpanel.tsx","./src/pages/scheduler-center/components/mountedaisourcestatuspanel.tsx","./src/pages/scheduler-center/components/mountedaitoolguardrailspanel.tsx","./src/pages/scheduler-center/components/mountedaitopicspanel.tsx","./src/pages/scheduler-center/components/mountedaitrainingopspanel.tsx","./src/pages/skill-tasks/skilltaskspage.tsx","./src/pages/skills/skillspage.tsx","./src/pages/task-brief-center/taskbriefcenterpage.tsx","./src/pages/task-brief-runs/taskbriefrunspage.tsx","./src/pages/task-brief-template/taskbrieftemplatepage.tsx","./src/pages/user-profile/userprofilepage.tsx","./src/pages/user-skills/userskillspage.tsx","./src/pages/users/userspage.tsx","./src/pages/users/helpers.ts","./src/pages/users/components/bulkassigndialog.tsx","./src/pages/users/components/deleteuserdialog.tsx","./src/pages/users/components/importusersdialog.tsx","./src/pages/users/components/resetpassworddialog.tsx","./src/pages/workload-detail/workloaddetailpage.tsx","./src/pages/workloads/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/skillradarchart.tsx","./src/components/themeinit.tsx","./src/components/ai-config/configsubpageheader.tsx","./src/components/ai-config/featureintroguide.tsx","./src/components/ai-steward/floatingqachat.tsx","./src/components/ai-steward/promptrolesettings.tsx","./src/components/ai-steward/qahistorypanel.tsx","./src/components/ai-steward/qamarkdowncontent.tsx","./src/components/budget/budgetcontrolpanel.tsx","./src/components/command/commanditems.ts","./src/components/inbox/inboxactionpanel.tsx","./src/components/inbox/inboxlistitem.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/components/layout/topbar.tsx","./src/components/model-capability/costoptimizationpanel.tsx","./src/components/model-capability/experimentsyncpanel.tsx","./src/components/model-capability/modelcapabilitymatrix.tsx","./src/components/model-capability/modelupdateimpactpanel.tsx","./src/components/sstc/aivisibilitypanel.tsx","./src/components/sstc/feedbacksummarycard.tsx","./src/components/sstc/sstcchainview.tsx","./src/components/sstc/sstccharterstagepanel.tsx","./src/components/sstc/sstcexecutionboardpanel.tsx","./src/components/sstc/sstcglobalheader.tsx","./src/components/sstc/sstcreleasecreatedialog.tsx","./src/components/sstc/sstcreleasestagepanel.tsx","./src/components/sstc/sstcreviewcreatedialog.tsx","./src/components/sstc/sstcreviewstagepanel.tsx","./src/components/sstc/sstcriskfloatpanel.tsx","./src/components/sstc/sstcsignalstagepanel.tsx","./src/components/sstc/sstcstagenav.tsx","./src/components/sstc/sstctaskcreatedialog.tsx","./src/components/sstc/sstctaskstagepanel.tsx","./src/components/sstc/sstcworkspace.tsx","./src/components/sstc/signalcontenthovercard.tsx","./src/components/sstc/chartermodules.ts","./src/components/sstc/sstcutils.ts","./src/components/sstc/usesstcworkspaceorchestration.ts","./src/components/ui/rolebadge.tsx","./src/components/ui/skillbadge.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/users/userformdialog.tsx","./src/hooks/usetheme.ts","./src/lib/api.ts","./src/lib/apierror.ts","./src/lib/datetime.ts","./src/lib/display.ts","./src/lib/feishueventmode.ts","./src/lib/formatinboxtime.ts","./src/lib/inbox.ts","./src/lib/inboxtextmap.ts","./src/lib/rbac.ts","./src/lib/sessionguard.ts","./src/lib/title.ts","./src/lib/utils.ts","./src/lib/zhcn.ts","./src/pages/aiconfigpage.tsx","./src/pages/aistewardpage.tsx","./src/pages/charterdetailpage.tsx","./src/pages/contractsyncboardpage.tsx","./src/pages/dashboardpage.tsx","./src/pages/feishuconfigpage.tsx","./src/pages/inboxpage.tsx","./src/pages/knowledgegraphcontextboardpage.tsx","./src/pages/loginpage.tsx","./src/pages/modelroutingconfigpage.tsx","./src/pages/projectboardpage.tsx","./src/pages/projectspage.tsx","./src/pages/recipespage.tsx","./src/pages/schedulercenterpage.tsx","./src/pages/skilltaskspage.tsx","./src/pages/skillspage.tsx","./src/pages/taskbriefcenterpage.tsx","./src/pages/taskbriefrunspage.tsx","./src/pages/taskbrieftemplatepage.tsx","./src/pages/userprofilepage.tsx","./src/pages/userskillspage.tsx","./src/pages/userspage.tsx","./src/pages/workloaddetailpage.tsx","./src/pages/workloadspage.tsx","./src/pages/ai-config/aiconfigpage.tsx","./src/pages/ai-config/constants.ts","./src/pages/ai-config/types.ts","./src/pages/ai-config/components/governancetoolscard.tsx","./src/pages/ai-config/components/prioritybadge.tsx","./src/pages/ai-config/components/statusbadge.tsx","./src/pages/ai-config/sections/advancedtab.tsx","./src/pages/ai-config/sections/configuretab.tsx","./src/pages/ai-config/sections/knowledgetab.tsx","./src/pages/ai-config/sections/overviewtab.tsx","./src/pages/ai-steward/aistewardpage.tsx","./src/pages/ai-steward/constants.ts","./src/pages/ai-steward/types.ts","./src/pages/ai-steward/components/briefingdetaildrawer.tsx","./src/pages/ai-steward/components/briefingprintdocument.tsx","./src/pages/ai-steward/components/briefingsectioncard.tsx","./src/pages/ai-steward/components/featureoverviewcards.tsx","./src/pages/ai-steward/components/feedback-center/feedbackdetail.tsx","./src/pages/ai-steward/components/feedback-center/feedbacklist.tsx","./src/pages/ai-steward/components/feedback-center/metricspanel.tsx","./src/pages/ai-steward/components/feedback-center/reasonpicker.tsx","./src/pages/ai-steward/components/feedback-center/trendpanel.tsx","./src/pages/ai-steward/sections/blockerstab.tsx","./src/pages/ai-steward/sections/briefingtab.tsx","./src/pages/ai-steward/sections/conflictstab.tsx","./src/pages/ai-steward/sections/createprojecttab.tsx","./src/pages/ai-steward/sections/enginetab.tsx","./src/pages/ai-steward/sections/feedbackcentertab.tsx","./src/pages/ai-steward/sections/overviewtab.tsx","./src/pages/ai-steward/sections/signalstab.tsx","./src/pages/charter-detail/charterdetailpage.tsx","./src/pages/contract-sync-board/contractsyncboardpage.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/feishu-config/feishuconfigpage.tsx","./src/pages/feishu-config/helpers.ts","./src/pages/feishu-config/components/setupguidecard.tsx","./src/pages/inbox/inboxpage.tsx","./src/pages/inbox/helpers.ts","./src/pages/inbox/components/inboxfilterspanel.tsx","./src/pages/knowledge-graph-context-board/knowledgegraphcontextboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/model-routing-config/modelroutingconfigpage.tsx","./src/pages/project-board/projectboardpage.tsx","./src/pages/project-board/helpers.ts","./src/pages/project-board/components/createcharterdialog.tsx","./src/pages/project-board/components/projectsettingsdialog.tsx","./src/pages/projects/projectspage.tsx","./src/pages/projects/helpers.ts","./src/pages/projects/components/deleteprojectdialog.tsx","./src/pages/projects/components/projectquerydialog.tsx","./src/pages/recipes/recipespage.tsx","./src/pages/recipes/helpers.ts","./src/pages/recipes/components/analyzerecipedialog.tsx","./src/pages/recipes/components/createrecipedialog.tsx","./src/pages/scheduler-center/schedulercenterpage.tsx","./src/pages/scheduler-center/components/mountedaiacceptancepanel.tsx","./src/pages/scheduler-center/components/mountedaicontextlayerspanel.tsx","./src/pages/scheduler-center/components/mountedaievalscoreboardpanel.tsx","./src/pages/scheduler-center/components/mountedaihardcasespanel.tsx","./src/pages/scheduler-center/components/mountedaiorchestrationgraphpanel.tsx","./src/pages/scheduler-center/components/mountedairunreplaypanel.tsx","./src/pages/scheduler-center/components/mountedairunspanel.tsx","./src/pages/scheduler-center/components/mountedaisemanticdecisionpanel.tsx","./src/pages/scheduler-center/components/mountedaisemanticpacketpanel.tsx","./src/pages/scheduler-center/components/mountedaishadowdiffpanel.tsx","./src/pages/scheduler-center/components/mountedaisourceactivationpanel.tsx","./src/pages/scheduler-center/components/mountedaisourcestatuspanel.tsx","./src/pages/scheduler-center/components/mountedaitoolguardrailspanel.tsx","./src/pages/scheduler-center/components/mountedaitopicspanel.tsx","./src/pages/scheduler-center/components/mountedaitrainingopspanel.tsx","./src/pages/skill-tasks/skilltaskspage.tsx","./src/pages/skills/skillspage.tsx","./src/pages/task-brief-center/taskbriefcenterpage.tsx","./src/pages/task-brief-runs/taskbriefrunspage.tsx","./src/pages/task-brief-template/taskbrieftemplatepage.tsx","./src/pages/user-profile/userprofilepage.tsx","./src/pages/user-skills/userskillspage.tsx","./src/pages/users/userspage.tsx","./src/pages/users/helpers.ts","./src/pages/users/components/bulkassigndialog.tsx","./src/pages/users/components/deleteuserdialog.tsx","./src/pages/users/components/importusersdialog.tsx","./src/pages/users/components/resetpassworddialog.tsx","./src/pages/workload-detail/workloaddetailpage.tsx","./src/pages/workloads/workloadspage.tsx","./src/router/index.tsx","./src/stores/auth.ts","./src/types/inbox.ts"],"version":"5.6.3"} \ No newline at end of file