Harden PMFlow context binding and world model snapshots
This commit is contained in:
parent
bd5a2813c3
commit
38adaed6e6
30
CLAUDE.md
30
CLAUDE.md
|
|
@ -63,7 +63,8 @@ backend/
|
|||
│ ├── 20260324_add_project_create_idempotency_indexes.py # 项目创建幂等索引:活动项目标题大小写无关唯一
|
||||
│ ├── 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` 已废弃并从配置校验、运行时解析、前端选项中移除
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
"""add mounted runtime active draft fields
|
||||
|
||||
Revision ID: 20260406_add_mounted_runtime_active_draft_fields
|
||||
Revises: 20260405_add_mounted_runtime_plan_state_fields
|
||||
Create Date: 2026-04-06
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = "20260406_add_mounted_runtime_active_draft_fields"
|
||||
down_revision = "20260405_add_mounted_runtime_plan_state_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(bind, table_name: str, column_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
columns = inspector.get_columns(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(column.get("name")) == column_name for column in columns)
|
||||
|
||||
|
||||
def _index_exists(bind, table_name: str, index_name: str) -> bool:
|
||||
inspector = sa.inspect(bind)
|
||||
try:
|
||||
indexes = inspector.get_indexes(table_name)
|
||||
except Exception:
|
||||
return False
|
||||
return any(str(index.get("name")) == index_name for index in indexes)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _column_exists(bind, "mounted_draft_workspaces", "active_work_item_id"):
|
||||
op.add_column(
|
||||
"mounted_draft_workspaces",
|
||||
sa.Column("active_work_item_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
if not _column_exists(bind, "mounted_draft_workspaces", "latest_source_message_id"):
|
||||
op.add_column(
|
||||
"mounted_draft_workspaces",
|
||||
sa.Column("latest_source_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
)
|
||||
if not _column_exists(bind, "mounted_draft_workspaces", "latest_reply_message_id"):
|
||||
op.add_column(
|
||||
"mounted_draft_workspaces",
|
||||
sa.Column("latest_reply_message_id", sa.VARCHAR(length=128), nullable=True),
|
||||
)
|
||||
|
||||
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_active_work_item_id"):
|
||||
op.create_index(
|
||||
"ix_mounted_draft_workspaces_active_work_item_id",
|
||||
"mounted_draft_workspaces",
|
||||
["active_work_item_id"],
|
||||
)
|
||||
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_source_message_id"):
|
||||
op.create_index(
|
||||
"ix_mounted_draft_workspaces_latest_source_message_id",
|
||||
"mounted_draft_workspaces",
|
||||
["latest_source_message_id"],
|
||||
)
|
||||
if not _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_reply_message_id"):
|
||||
op.create_index(
|
||||
"ix_mounted_draft_workspaces_latest_reply_message_id",
|
||||
"mounted_draft_workspaces",
|
||||
["latest_reply_message_id"],
|
||||
)
|
||||
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE mounted_draft_workspaces AS draft
|
||||
SET latest_source_message_id = COALESCE(
|
||||
NULLIF(draft.latest_source_message_id, ''),
|
||||
(
|
||||
SELECT topic.last_message_id
|
||||
FROM mounted_topic_workspaces AS topic
|
||||
WHERE topic.id = draft.topic_workspace_id
|
||||
)
|
||||
)
|
||||
WHERE COALESCE(NULLIF(draft.latest_source_message_id, ''), '') = ''
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_reply_message_id"):
|
||||
op.drop_index(
|
||||
"ix_mounted_draft_workspaces_latest_reply_message_id",
|
||||
table_name="mounted_draft_workspaces",
|
||||
)
|
||||
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_latest_source_message_id"):
|
||||
op.drop_index(
|
||||
"ix_mounted_draft_workspaces_latest_source_message_id",
|
||||
table_name="mounted_draft_workspaces",
|
||||
)
|
||||
if _index_exists(bind, "mounted_draft_workspaces", "ix_mounted_draft_workspaces_active_work_item_id"):
|
||||
op.drop_index(
|
||||
"ix_mounted_draft_workspaces_active_work_item_id",
|
||||
table_name="mounted_draft_workspaces",
|
||||
)
|
||||
if _column_exists(bind, "mounted_draft_workspaces", "latest_reply_message_id"):
|
||||
op.drop_column("mounted_draft_workspaces", "latest_reply_message_id")
|
||||
if _column_exists(bind, "mounted_draft_workspaces", "latest_source_message_id"):
|
||||
op.drop_column("mounted_draft_workspaces", "latest_source_message_id")
|
||||
if _column_exists(bind, "mounted_draft_workspaces", "active_work_item_id"):
|
||||
op.drop_column("mounted_draft_workspaces", "active_work_item_id")
|
||||
|
|
@ -66,6 +66,37 @@ from .ai_prompt_registry import (
|
|||
# Deprecated compatibility aliases kept outside the registry surface.
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,38 +11,7 @@ for _name, _value in vars(_common).items():
|
|||
|
||||
|
||||
class KernelIntentResolveMixin:
|
||||
@staticmethod
|
||||
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
|
||||
if not isinstance(last_active_at, datetime):
|
||||
return False
|
||||
delta = _utc_now_naive() - last_active_at
|
||||
return 0 <= delta.total_seconds() <= float(window_seconds)
|
||||
|
||||
@classmethod
|
||||
def _should_inherit_recent_feishu_session_context(
|
||||
cls,
|
||||
*,
|
||||
message_text: str,
|
||||
session: Optional[ConversationSession],
|
||||
) -> bool:
|
||||
if session is None:
|
||||
return False
|
||||
sender = str(getattr(session, "sender", "") or "").strip().lower()
|
||||
if not sender.startswith("feishu:"):
|
||||
return False
|
||||
if not cls._within_recent_window(
|
||||
getattr(session, "last_active_at", None),
|
||||
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
|
||||
):
|
||||
return False
|
||||
if not str(getattr(session, "last_intent", "") or "").strip():
|
||||
return False
|
||||
if WorkflowSemanticPlanner._derive_query_scene_metadata(message_text):
|
||||
return True
|
||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||||
if not normalized:
|
||||
return False
|
||||
followup_tokens = (
|
||||
_QUERY_FOLLOWUP_TOKENS = (
|
||||
"需求",
|
||||
"mvp",
|
||||
"方案",
|
||||
|
|
@ -57,8 +26,47 @@ class KernelIntentResolveMixin:
|
|||
"重新回答",
|
||||
"高级模型",
|
||||
"更强模型",
|
||||
"分析",
|
||||
"评审",
|
||||
"怎么做",
|
||||
)
|
||||
return any(token in normalized for token in followup_tokens)
|
||||
|
||||
@staticmethod
|
||||
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
|
||||
if not isinstance(last_active_at, datetime):
|
||||
return False
|
||||
delta = _utc_now_naive() - last_active_at
|
||||
return 0 <= delta.total_seconds() <= float(window_seconds)
|
||||
|
||||
@classmethod
|
||||
def _looks_like_query_scene_followup(cls, message_text: str) -> bool:
|
||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
||||
if not normalized:
|
||||
return False
|
||||
return any(token in normalized for token in cls._QUERY_FOLLOWUP_TOKENS)
|
||||
|
||||
@classmethod
|
||||
def _should_inherit_recent_feishu_session_context(
|
||||
cls,
|
||||
*,
|
||||
message_text: str,
|
||||
session: Optional[ConversationSession],
|
||||
) -> bool:
|
||||
if session is None:
|
||||
return False
|
||||
if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text):
|
||||
return False
|
||||
sender = str(getattr(session, "sender", "") or "").strip().lower()
|
||||
if not sender.startswith("feishu:"):
|
||||
return False
|
||||
if not cls._within_recent_window(
|
||||
getattr(session, "last_active_at", None),
|
||||
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
|
||||
):
|
||||
return False
|
||||
if not str(getattr(session, "last_intent", "") or "").strip():
|
||||
return False
|
||||
return cls._looks_like_query_scene_followup(message_text)
|
||||
|
||||
async def _resolve_intent(
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
*,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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]],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
from .assembler import SemanticContextAssembler
|
||||
from .decision_schema import DecisionGateResult, ModeCard, SemanticDecision
|
||||
from .gatekeeper import SemanticDecisionGatekeeper
|
||||
from .mode_card_service import build_mode_card
|
||||
from .packet_schema import (
|
||||
SemanticContextDraftSnapshot,
|
||||
SemanticContextPacket,
|
||||
SemanticContextThread,
|
||||
SemanticContextTopic,
|
||||
SemanticContextTurn,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DecisionGateResult",
|
||||
"ModeCard",
|
||||
"SemanticContextAssembler",
|
||||
"SemanticContextDraftSnapshot",
|
||||
"SemanticContextPacket",
|
||||
"SemanticContextThread",
|
||||
"SemanticContextTopic",
|
||||
"SemanticContextTurn",
|
||||
"SemanticDecision",
|
||||
"SemanticDecisionGatekeeper",
|
||||
"build_mode_card",
|
||||
]
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ....models import MountedConversationRun, MountedDraftWorkspace, MountedTopicWorkspace
|
||||
from .mode_card_service import build_mode_card
|
||||
from .packet_schema import (
|
||||
SemanticContextDraftSnapshot,
|
||||
SemanticContextPacket,
|
||||
SemanticContextThread,
|
||||
SemanticContextTopic,
|
||||
SemanticContextTurn,
|
||||
)
|
||||
|
||||
|
||||
class SemanticContextAssembler:
|
||||
def __init__(self, db: AsyncSession, *, recent_turn_limit: int = 6):
|
||||
self.db = db
|
||||
self.recent_turn_limit = max(int(recent_turn_limit), 2)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_list(value: Any) -> list[str]:
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item or "").strip()]
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mapping(value: Any) -> Optional[Any]:
|
||||
if isinstance(value, (dict, list)):
|
||||
return value
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_source_preview(row: MountedConversationRun) -> Optional[str]:
|
||||
execution_outcome = row.execution_outcome if isinstance(row.execution_outcome, dict) else {}
|
||||
preview = str(execution_outcome.get("source_message_preview") or "").strip()
|
||||
return preview or None
|
||||
|
||||
async def _load_topic(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
topic_workspace_id: int,
|
||||
) -> MountedTopicWorkspace:
|
||||
topic_row = (
|
||||
await self.db.execute(
|
||||
select(MountedTopicWorkspace).where(
|
||||
MountedTopicWorkspace.tenant_id == int(tenant_id),
|
||||
MountedTopicWorkspace.id == int(topic_workspace_id),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if topic_row is None:
|
||||
raise ValueError("mounted topic workspace not found")
|
||||
return topic_row
|
||||
|
||||
async def _load_draft(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
topic_workspace_id: int,
|
||||
) -> MountedDraftWorkspace | None:
|
||||
return (
|
||||
await self.db.execute(
|
||||
select(MountedDraftWorkspace).where(
|
||||
MountedDraftWorkspace.tenant_id == int(tenant_id),
|
||||
MountedDraftWorkspace.topic_workspace_id == int(topic_workspace_id),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
async def _load_recent_turns(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
topic_workspace_id: int,
|
||||
) -> list[SemanticContextTurn]:
|
||||
run_rows = (
|
||||
await self.db.execute(
|
||||
select(MountedConversationRun)
|
||||
.where(
|
||||
MountedConversationRun.tenant_id == int(tenant_id),
|
||||
MountedConversationRun.topic_workspace_id == int(topic_workspace_id),
|
||||
MountedConversationRun.status == "completed",
|
||||
)
|
||||
.order_by(MountedConversationRun.updated_at.desc(), MountedConversationRun.id.desc())
|
||||
.limit(max(self.recent_turn_limit, 1))
|
||||
)
|
||||
).scalars().all()
|
||||
turns: list[SemanticContextTurn] = []
|
||||
for row in reversed(run_rows):
|
||||
source_preview = self._extract_source_preview(row)
|
||||
if source_preview:
|
||||
turns.append(SemanticContextTurn(role="user", text=source_preview))
|
||||
final_reply = str(row.final_reply or "").strip()
|
||||
if final_reply:
|
||||
turns.append(SemanticContextTurn(role="assistant", text=final_reply))
|
||||
if len(turns) <= self.recent_turn_limit:
|
||||
return turns
|
||||
return turns[-self.recent_turn_limit :]
|
||||
|
||||
async def assemble(
|
||||
self,
|
||||
*,
|
||||
tenant_id: int,
|
||||
operator_user_id: int,
|
||||
topic_workspace_id: int,
|
||||
latest_user_message: str,
|
||||
) -> SemanticContextPacket:
|
||||
topic_row = await self._load_topic(
|
||||
tenant_id=tenant_id,
|
||||
topic_workspace_id=topic_workspace_id,
|
||||
)
|
||||
draft_row = await self._load_draft(
|
||||
tenant_id=tenant_id,
|
||||
topic_workspace_id=topic_workspace_id,
|
||||
)
|
||||
recent_turns = await self._load_recent_turns(
|
||||
tenant_id=tenant_id,
|
||||
topic_workspace_id=topic_workspace_id,
|
||||
)
|
||||
|
||||
topic = SemanticContextTopic(
|
||||
topic_workspace_id=int(topic_row.id),
|
||||
topic_key=topic_row.topic_key,
|
||||
source_system=topic_row.source_system,
|
||||
channel_ref=topic_row.channel_ref,
|
||||
conversation_scope_key=topic_row.conversation_scope_key,
|
||||
thread_key=topic_row.thread_key,
|
||||
mode=topic_row.mode,
|
||||
status=topic_row.status,
|
||||
plan_status=topic_row.plan_status,
|
||||
facts=self._normalize_mapping(topic_row.facts_json),
|
||||
goals=self._normalize_mapping(topic_row.goals_json),
|
||||
constraints=self._normalize_mapping(topic_row.constraints_json),
|
||||
metadata=self._normalize_mapping(topic_row.metadata_json),
|
||||
)
|
||||
draft = None
|
||||
if draft_row is not None:
|
||||
draft = SemanticContextDraftSnapshot(
|
||||
draft_workspace_id=int(draft_row.id),
|
||||
state=draft_row.state,
|
||||
draft_version=int(draft_row.draft_version or 0),
|
||||
requirement_card=self._normalize_mapping(draft_row.requirement_card),
|
||||
draft_payload=self._normalize_mapping(draft_row.draft_payload),
|
||||
required_confirmations=self._normalize_list(draft_row.required_confirmations),
|
||||
)
|
||||
|
||||
metadata = topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}
|
||||
thread = SemanticContextThread(
|
||||
thread_key=topic_row.thread_key,
|
||||
conversation_scope_key=topic_row.conversation_scope_key,
|
||||
thread_summary=str(
|
||||
metadata.get("last_message_preview")
|
||||
or (topic_row.facts_json or {}).get("title")
|
||||
or ""
|
||||
).strip()
|
||||
or None,
|
||||
recent_turns=recent_turns,
|
||||
open_questions=self._normalize_list(topic_row.open_questions),
|
||||
)
|
||||
return SemanticContextPacket(
|
||||
packet_version="v1",
|
||||
tenant_id=int(tenant_id),
|
||||
operator_user_id=int(operator_user_id),
|
||||
topic_workspace_id=int(topic_row.id),
|
||||
latest_user_message=str(latest_user_message or "").strip(),
|
||||
topic=topic,
|
||||
draft=draft,
|
||||
thread=thread,
|
||||
mode_card=build_mode_card(
|
||||
topic_mode=topic_row.mode,
|
||||
plan_status=topic_row.plan_status,
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ModeCard(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
workflow_mode: Literal["CHAT", "PLAN", "EXECUTE"] = "CHAT"
|
||||
mode_state: str = "IDLE"
|
||||
allowed_actions: list[str] = Field(default_factory=list)
|
||||
forbidden_actions: list[str] = Field(default_factory=list)
|
||||
exit_conditions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SemanticDecision(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
topic_relation: Literal["continue_current_topic", "new_topic", "uncertain"]
|
||||
mode_decision: Literal["stay_plan", "ready_confirm", "request_execute", "answer_only"]
|
||||
draft_patch: dict[str, Any] = Field(default_factory=dict)
|
||||
assistant_reply: str = ""
|
||||
requires_confirmation: bool = False
|
||||
requested_actions: list[str] = Field(default_factory=list)
|
||||
confidence: float = 0.0
|
||||
uncertainties: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class DecisionGateResult(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
accepted: bool
|
||||
blocked_reasons: list[str] = Field(default_factory=list)
|
||||
allowed_actions: list[str] = Field(default_factory=list)
|
||||
next_plan_status: str
|
||||
needs_confirmation: bool = False
|
||||
mode_state_after: str | None = None
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .decision_schema import DecisionGateResult, ModeCard, SemanticDecision
|
||||
|
||||
|
||||
class SemanticDecisionGatekeeper:
|
||||
@staticmethod
|
||||
def _next_plan_status(*, accepted: bool, decision: SemanticDecision) -> str:
|
||||
if not accepted:
|
||||
return "active"
|
||||
if decision.mode_decision == "ready_confirm":
|
||||
return "awaiting_execute_confirm"
|
||||
if decision.mode_decision == "request_execute":
|
||||
return "execution_pending"
|
||||
if decision.mode_decision == "answer_only":
|
||||
return "idle"
|
||||
return "active"
|
||||
|
||||
@classmethod
|
||||
def evaluate(
|
||||
cls,
|
||||
*,
|
||||
mode_card: ModeCard,
|
||||
decision: SemanticDecision,
|
||||
) -> DecisionGateResult:
|
||||
blocked_reasons: list[str] = []
|
||||
allowed_actions: list[str] = []
|
||||
allowed_set = {str(item).strip() for item in mode_card.allowed_actions if str(item or "").strip()}
|
||||
forbidden_set = {str(item).strip() for item in mode_card.forbidden_actions if str(item or "").strip()}
|
||||
|
||||
for action in decision.requested_actions:
|
||||
normalized_action = str(action or "").strip()
|
||||
if not normalized_action:
|
||||
continue
|
||||
if normalized_action in forbidden_set:
|
||||
blocked_reasons.append(f"forbidden_action:{normalized_action}")
|
||||
continue
|
||||
if allowed_set and normalized_action not in allowed_set:
|
||||
blocked_reasons.append(f"unapproved_action:{normalized_action}")
|
||||
continue
|
||||
allowed_actions.append(normalized_action)
|
||||
|
||||
accepted = len(blocked_reasons) == 0
|
||||
return DecisionGateResult(
|
||||
accepted=accepted,
|
||||
blocked_reasons=blocked_reasons,
|
||||
allowed_actions=allowed_actions,
|
||||
next_plan_status=cls._next_plan_status(accepted=accepted, decision=decision),
|
||||
needs_confirmation=bool(decision.requires_confirmation),
|
||||
mode_state_after=decision.mode_decision,
|
||||
)
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from .decision_schema import ModeCard
|
||||
|
||||
|
||||
def build_mode_card(*, topic_mode: str | None, plan_status: str | None) -> ModeCard:
|
||||
normalized_mode = str(topic_mode or "chat").strip().lower()
|
||||
normalized_plan_status = str(plan_status or "idle").strip().lower()
|
||||
workflow_mode = {
|
||||
"plan": "PLAN",
|
||||
"execute": "EXECUTE",
|
||||
}.get(normalized_mode, "CHAT")
|
||||
|
||||
if workflow_mode == "PLAN":
|
||||
return ModeCard(
|
||||
workflow_mode="PLAN",
|
||||
mode_state=normalized_plan_status.upper() or "ACTIVE",
|
||||
allowed_actions=[
|
||||
"reply.compose",
|
||||
"draft.patch",
|
||||
"clarify.ask",
|
||||
"topic.continue_or_split",
|
||||
"plan.mark_ready_for_confirmation",
|
||||
],
|
||||
forbidden_actions=[
|
||||
"project.create",
|
||||
"task.create",
|
||||
"signal_source.onboard",
|
||||
"db.write",
|
||||
],
|
||||
exit_conditions=[
|
||||
"用户明确要求开始执行",
|
||||
"草稿完成且需要确认项已补齐",
|
||||
],
|
||||
)
|
||||
|
||||
if workflow_mode == "EXECUTE":
|
||||
return ModeCard(
|
||||
workflow_mode="EXECUTE",
|
||||
mode_state=normalized_plan_status.upper() or "ACTIVE",
|
||||
allowed_actions=[
|
||||
"reply.compose",
|
||||
"project.create",
|
||||
"task.create",
|
||||
"followup.execute",
|
||||
],
|
||||
forbidden_actions=["db.write"],
|
||||
exit_conditions=["执行完成或执行失败后返回结果"],
|
||||
)
|
||||
|
||||
return ModeCard(
|
||||
workflow_mode="CHAT",
|
||||
mode_state=normalized_plan_status.upper() or "IDLE",
|
||||
allowed_actions=["reply.compose", "clarify.ask"],
|
||||
forbidden_actions=["project.create", "task.create", "db.write"],
|
||||
exit_conditions=["用户提出新的规划或执行需求"],
|
||||
)
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .decision_schema import ModeCard
|
||||
|
||||
|
||||
class SemanticContextTurn(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
role: str
|
||||
text: str
|
||||
|
||||
|
||||
class SemanticContextThread(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
thread_key: Optional[str] = None
|
||||
conversation_scope_key: Optional[str] = None
|
||||
thread_summary: Optional[str] = None
|
||||
recent_turns: list[SemanticContextTurn] = Field(default_factory=list)
|
||||
open_questions: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SemanticContextTopic(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
topic_workspace_id: int
|
||||
topic_key: str
|
||||
source_system: str
|
||||
channel_ref: Optional[str] = None
|
||||
conversation_scope_key: Optional[str] = None
|
||||
thread_key: Optional[str] = None
|
||||
mode: str
|
||||
status: str
|
||||
plan_status: Optional[str] = None
|
||||
facts: Optional[Any] = None
|
||||
goals: Optional[Any] = None
|
||||
constraints: Optional[Any] = None
|
||||
metadata: Optional[Any] = None
|
||||
|
||||
|
||||
class SemanticContextDraftSnapshot(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
draft_workspace_id: int
|
||||
state: str
|
||||
draft_version: int
|
||||
requirement_card: Optional[Any] = None
|
||||
draft_payload: Optional[Any] = None
|
||||
required_confirmations: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SemanticContextPacket(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
packet_version: str = "v1"
|
||||
tenant_id: int
|
||||
operator_user_id: int
|
||||
topic_workspace_id: int
|
||||
latest_user_message: str
|
||||
topic: SemanticContextTopic
|
||||
draft: SemanticContextDraftSnapshot | None = None
|
||||
thread: SemanticContextThread
|
||||
mode_card: ModeCard
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from ....config.ai_prompts import PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT
|
||||
from ....services.llm_service.service import LLMService
|
||||
from .decision_schema import SemanticDecision
|
||||
from .packet_schema import SemanticContextPacket
|
||||
|
||||
|
||||
class SemanticDecisionService:
|
||||
FUNCTION_NAME = "pmflow_semantic_context_decision"
|
||||
|
||||
def __init__(self, llm_service: LLMService):
|
||||
self.llm_service = llm_service
|
||||
|
||||
@staticmethod
|
||||
def build_prompt(packet: SemanticContextPacket) -> str:
|
||||
return PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT.format(
|
||||
semantic_packet=json.dumps(packet.model_dump(mode="json"), ensure_ascii=False, indent=2)
|
||||
)
|
||||
|
||||
async def decide(self, *, packet: SemanticContextPacket) -> SemanticDecision:
|
||||
structured = await self.llm_service.generate_structured_object(
|
||||
prompt=self.build_prompt(packet),
|
||||
function_name=self.FUNCTION_NAME,
|
||||
use_prompt_profile=False,
|
||||
temperature=0.0,
|
||||
response_mode="fast",
|
||||
)
|
||||
payload: Any = getattr(structured, "payload", None)
|
||||
if payload is None and hasattr(structured, "model_dump"):
|
||||
payload = structured.model_dump()
|
||||
if payload is None:
|
||||
payload = structured
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("semantic decision payload is not a dict")
|
||||
return SemanticDecision.model_validate(payload)
|
||||
|
|
@ -263,6 +263,28 @@ class WorkflowSemanticPlanner:
|
|||
return True
|
||||
return 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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
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(
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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):
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,25 @@
|
|||
from pathlib import Path
|
||||
|
||||
import app.services.context_cache_service.mixin_cache_core as cache_core_module
|
||||
import app.services.context_cache_service.mixin_profile_route_parser as route_parser_module
|
||||
|
||||
from app.services.context_cache_service.mixin_cache_core import ContextCacheCoreMixin
|
||||
from app.services.context_cache_service.mixin_profile_basics import ContextCacheProfileBasicsMixin
|
||||
from app.services.context_cache_service.mixin_profile_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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] == "先继续整理语义上下文,不要直接执行。"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.mounted_ai_runtime import (
|
||||
MountedConversationRun,
|
||||
MountedDraftWorkspace,
|
||||
MountedTopicWorkspace,
|
||||
)
|
||||
from app.services.ai_pmo.semantic_context.assembler import SemanticContextAssembler
|
||||
|
||||
|
||||
def _use_sqlite_json_types() -> None:
|
||||
for table in (
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedDraftWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
):
|
||||
for column in table.columns:
|
||||
if column.name in {
|
||||
"facts_json",
|
||||
"goals_json",
|
||||
"constraints_json",
|
||||
"options_json",
|
||||
"open_questions",
|
||||
"pending_actions",
|
||||
"bound_entities",
|
||||
"metadata_json",
|
||||
"requirement_card",
|
||||
"draft_payload",
|
||||
"required_confirmations",
|
||||
"context_layers_hit",
|
||||
"tool_calls",
|
||||
"execution_outcome",
|
||||
}:
|
||||
column.type = JSON()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
_use_sqlite_json_types()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: Base.metadata.create_all(
|
||||
sync_conn,
|
||||
tables=[
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedDraftWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
],
|
||||
)
|
||||
)
|
||||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with maker() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_semantic_context_assembler_builds_packet_from_runtime_state(db_session) -> None:
|
||||
topic = MountedTopicWorkspace(
|
||||
tenant_id=7,
|
||||
topic_key="feishu:chat_semantic:root:om_root_semantic",
|
||||
source_system="feishu",
|
||||
channel_ref="chat_semantic",
|
||||
conversation_scope_key="feishu:chat_semantic:ou_semantic",
|
||||
thread_key="thread_semantic",
|
||||
root_message_id="om_root_semantic",
|
||||
mode="plan",
|
||||
status="active",
|
||||
plan_status="collecting",
|
||||
facts_json={"title": "OpenCow 风格语义上下文迁移"},
|
||||
goals_json=["统一语义入口"],
|
||||
constraints_json={"must_not": ["db.write"]},
|
||||
open_questions=["当前是不是新需求 topic"],
|
||||
pending_actions=["continue_requirement_plan"],
|
||||
readiness_score=0.4,
|
||||
metadata_json={"last_message_preview": "这次先把语义上下文统一掉"},
|
||||
)
|
||||
db_session.add(topic)
|
||||
await db_session.flush()
|
||||
|
||||
draft = MountedDraftWorkspace(
|
||||
tenant_id=7,
|
||||
topic_workspace_id=int(topic.id),
|
||||
state="collecting",
|
||||
draft_version=2,
|
||||
requirement_card={"goal": "统一语义入口", "scope": "先做 shadow mode"},
|
||||
draft_payload={"draft_text": "当前先保留 mounted runtime 作为底座"},
|
||||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||||
)
|
||||
db_session.add(draft)
|
||||
db_session.add(
|
||||
MountedConversationRun(
|
||||
tenant_id=7,
|
||||
trace_id="mounted:feishu:prev",
|
||||
topic_workspace_id=int(topic.id),
|
||||
source_system="feishu",
|
||||
source_message_id="om_prev",
|
||||
mode_before="plan",
|
||||
mode_after="plan",
|
||||
active_agent="PlanCoach",
|
||||
status="completed",
|
||||
trigger_reason="shadow",
|
||||
context_layers_hit={"topic": True},
|
||||
execution_outcome={
|
||||
"source_message_preview": "先别执行,先整理需求上下文",
|
||||
"semantic_decision": {"mode_decision": "stay_plan"},
|
||||
},
|
||||
final_reply="我先把上下文和草稿整理出来。",
|
||||
)
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
packet = await SemanticContextAssembler(db_session).assemble(
|
||||
tenant_id=7,
|
||||
operator_user_id=99,
|
||||
topic_workspace_id=int(topic.id),
|
||||
latest_user_message="这轮继续按 OpenCow 风格整理语义上下文。",
|
||||
)
|
||||
|
||||
assert packet.packet_version == "v1"
|
||||
assert packet.topic.topic_key == "feishu:chat_semantic:root:om_root_semantic"
|
||||
assert packet.topic.plan_status == "collecting"
|
||||
assert packet.draft is not None
|
||||
assert packet.draft.draft_version == 2
|
||||
assert packet.mode_card.workflow_mode == "PLAN"
|
||||
assert "draft.patch" in packet.mode_card.allowed_actions
|
||||
assert "project.create" in packet.mode_card.forbidden_actions
|
||||
assert packet.thread.recent_turns[0].role == "user"
|
||||
assert packet.thread.recent_turns[-1].role == "assistant"
|
||||
assert packet.thread.open_questions == ["当前是不是新需求 topic"]
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy import JSON
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test"
|
||||
|
||||
from app.api.ai_steward_api import routes_mounted_ai
|
||||
from app.core.database import get_db
|
||||
from app.dependencies.auth import get_current_user
|
||||
from app.dependencies.tenant import get_tenant_id
|
||||
from app.models.base import Base
|
||||
from app.models.mounted_ai_runtime import (
|
||||
MountedAgentTrace,
|
||||
MountedConversationRun,
|
||||
MountedDraftWorkspace,
|
||||
MountedTopicWorkspace,
|
||||
)
|
||||
|
||||
|
||||
def _use_sqlite_json_types() -> None:
|
||||
for table in (
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedDraftWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
MountedAgentTrace.__table__,
|
||||
):
|
||||
for column in table.columns:
|
||||
if column.name in {
|
||||
"facts_json",
|
||||
"goals_json",
|
||||
"constraints_json",
|
||||
"options_json",
|
||||
"open_questions",
|
||||
"pending_actions",
|
||||
"bound_entities",
|
||||
"metadata_json",
|
||||
"requirement_card",
|
||||
"draft_payload",
|
||||
"required_confirmations",
|
||||
"context_layers_hit",
|
||||
"tool_calls",
|
||||
"execution_outcome",
|
||||
"input_summary",
|
||||
"output_summary",
|
||||
"tool_trace",
|
||||
}:
|
||||
column.type = JSON()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_session():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
_use_sqlite_json_types()
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(
|
||||
lambda sync_conn: Base.metadata.create_all(
|
||||
sync_conn,
|
||||
tables=[
|
||||
MountedTopicWorkspace.__table__,
|
||||
MountedDraftWorkspace.__table__,
|
||||
MountedConversationRun.__table__,
|
||||
MountedAgentTrace.__table__,
|
||||
],
|
||||
)
|
||||
)
|
||||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with maker() as session:
|
||||
yield session
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _build_app(db_session):
|
||||
app = FastAPI()
|
||||
app.include_router(routes_mounted_ai.router)
|
||||
|
||||
async def _fake_get_db():
|
||||
yield db_session
|
||||
|
||||
app.dependency_overrides[get_db] = _fake_get_db
|
||||
app.dependency_overrides[get_tenant_id] = lambda: 1
|
||||
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
|
||||
id=9001,
|
||||
tenant_id=1,
|
||||
role="ADMIN",
|
||||
name="管理员",
|
||||
username="admin",
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mounted_runtime_run_detail_exposes_semantic_shadow_fields(db_session) -> None:
|
||||
topic = MountedTopicWorkspace(
|
||||
tenant_id=1,
|
||||
topic_key="feishu:chat_1:root:om_root_1",
|
||||
source_system="feishu",
|
||||
channel_ref="chat_1",
|
||||
thread_key="thread_1",
|
||||
mode="plan",
|
||||
status="active",
|
||||
plan_status="collecting",
|
||||
facts_json={"title": "语义上下文迁移"},
|
||||
open_questions=["是否切新 topic"],
|
||||
metadata_json={"last_message_preview": "这次继续统一语义入口"},
|
||||
)
|
||||
db_session.add(topic)
|
||||
await db_session.flush()
|
||||
|
||||
draft = MountedDraftWorkspace(
|
||||
tenant_id=1,
|
||||
topic_workspace_id=int(topic.id),
|
||||
state="collecting",
|
||||
draft_version=3,
|
||||
requirement_card={"goal": "统一语义入口"},
|
||||
draft_payload={"draft_text": "先做 shadow mode"},
|
||||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||||
)
|
||||
db_session.add(draft)
|
||||
await db_session.flush()
|
||||
|
||||
run = MountedConversationRun(
|
||||
tenant_id=1,
|
||||
trace_id="mounted:feishu:semantic-detail",
|
||||
topic_workspace_id=int(topic.id),
|
||||
source_system="feishu",
|
||||
source_message_id="om_message_1",
|
||||
mode_before="plan",
|
||||
mode_after="plan",
|
||||
active_agent="ConversationOrchestrator",
|
||||
status="completed",
|
||||
trigger_reason="shadow",
|
||||
context_layers_hit={"topic": True, "thread": True},
|
||||
execution_outcome={
|
||||
"source_message_preview": "这轮继续按 OpenCow 风格整理语义上下文",
|
||||
"semantic_packet_summary": {"packet_version": "v1", "recent_turn_count": 2},
|
||||
"semantic_decision": {"topic_relation": "continue_current_topic", "mode_decision": "stay_plan"},
|
||||
"semantic_gate_result": {"accepted": True, "next_plan_status": "collecting"},
|
||||
"semantic_diff": {"changed": True, "legacy_route": "query.answer"},
|
||||
},
|
||||
final_reply="我先把上下文整理出来。",
|
||||
)
|
||||
db_session.add(run)
|
||||
await db_session.commit()
|
||||
|
||||
app = _build_app(db_session)
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get(f"/ai-steward/mounted-ai/runs/{run.id}")
|
||||
|
||||
assert resp.status_code == 200
|
||||
payload = resp.json()["data"]
|
||||
assert payload["semantic_packet_summary"]["packet_version"] == "v1"
|
||||
assert payload["semantic_decision"]["mode_decision"] == "stay_plan"
|
||||
assert payload["semantic_gate_result"]["accepted"] is True
|
||||
assert payload["semantic_diff"]["legacy_route"] == "query.answer"
|
||||
assert payload["topic"]["topic_key"] == "feishu:chat_1:root:om_root_1"
|
||||
assert payload["draft"]["draft_version"] == 3
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.services.ai_pmo.semantic_context.decision_schema import (
|
||||
DecisionGateResult,
|
||||
ModeCard,
|
||||
SemanticDecision,
|
||||
)
|
||||
from app.services.ai_pmo.semantic_context.gatekeeper import SemanticDecisionGatekeeper
|
||||
|
||||
|
||||
def test_semantic_decision_gatekeeper_accepts_allowed_plan_patch() -> None:
|
||||
mode_card = ModeCard(
|
||||
workflow_mode="PLAN",
|
||||
mode_state="ACTIVE",
|
||||
allowed_actions=["reply.compose", "draft.patch", "plan.mark_ready_for_confirmation"],
|
||||
forbidden_actions=["project.create", "task.create", "db.write"],
|
||||
exit_conditions=["用户明确要求开始执行"],
|
||||
)
|
||||
decision = SemanticDecision(
|
||||
topic_relation="continue_current_topic",
|
||||
mode_decision="ready_confirm",
|
||||
draft_patch={"goal": "先收一版需求草稿"},
|
||||
assistant_reply="我先整理成待确认草稿。",
|
||||
requires_confirmation=True,
|
||||
requested_actions=["draft.patch", "reply.compose"],
|
||||
confidence=0.92,
|
||||
)
|
||||
|
||||
result = SemanticDecisionGatekeeper.evaluate(mode_card=mode_card, decision=decision)
|
||||
|
||||
assert isinstance(result, DecisionGateResult)
|
||||
assert result.accepted is True
|
||||
assert result.blocked_reasons == []
|
||||
assert result.allowed_actions == ["draft.patch", "reply.compose"]
|
||||
assert result.next_plan_status == "awaiting_execute_confirm"
|
||||
|
||||
|
||||
def test_semantic_decision_gatekeeper_blocks_forbidden_action() -> None:
|
||||
mode_card = ModeCard(
|
||||
workflow_mode="PLAN",
|
||||
mode_state="ACTIVE",
|
||||
allowed_actions=["reply.compose", "draft.patch"],
|
||||
forbidden_actions=["project.create", "task.create", "db.write"],
|
||||
exit_conditions=["用户明确要求开始执行"],
|
||||
)
|
||||
decision = SemanticDecision(
|
||||
topic_relation="continue_current_topic",
|
||||
mode_decision="request_execute",
|
||||
draft_patch={},
|
||||
assistant_reply="我准备直接创建项目。",
|
||||
requires_confirmation=True,
|
||||
requested_actions=["project.create"],
|
||||
confidence=0.73,
|
||||
)
|
||||
|
||||
result = SemanticDecisionGatekeeper.evaluate(mode_card=mode_card, decision=decision)
|
||||
|
||||
assert result.accepted is False
|
||||
assert "forbidden_action:project.create" in result.blocked_reasons
|
||||
assert result.next_plan_status == "active"
|
||||
|
|
@ -2,3 +2,4 @@
|
|||
{"generated_at": "2026-03-28T08:51:37.907500", "case": "advise", "source_message_id": "codex-feishu-advise-1774659056", "question": "统一决策层值不值得先做", "run_status": "missing", "processing": {"status": "success", "route": "ai_pmo.query.answer", "intent": "query.answer", "reply_source": "action_succeeded", "error_message": ""}, "outbox": [{"id": 654, "status": "sent", "attempts": 1, "sent_message_id": "om_x100b53462eac6ca4c2ccea9c028e82c", "text": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如/ai-steward、/projects)也未定义相关能力或路由;知识图谱与代码索引中无“decision layer”“unified decision”等关键词匹配。 \n下一步:请确认该需求对应的具体项目ID、业务场景及预期交付物。信息不足,缺少:①需求提出方;②关联项目ID;③验收标准。"}], "final_reply": "不值得优先做。 \n依据:当前无项目明确关联“统一决策层”需求;系统中22个活跃项目均未在名称、信号或任务中体现该概念;核心功能模块(如/ai-steward、/projects)也未定义相关能力或路由;知识图谱与代码索引中无“decision layer”“unified decision”等关键词匹配。 \n下一步:请确认该需求对应的具体项目ID、业务场景及预期交付物。信息不足,缺少:①需求提出方;②关联项目ID;③验收标准。"}
|
||||
{"generated_at": "2026-03-28T08: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"}
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> | 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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<MountedAISemanticPacketPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
|
||||
<MountedAISemanticDecisionPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
|
||||
</div>
|
||||
|
||||
<MountedAIShadowDiffPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<MountedAIOrchestrationGraphPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
|
||||
<MountedAIContextLayersPanel detail={selectedRunDetailQuery.data} loading={selectedRunDetailQuery.isLoading} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
import type { MountedAIRunDetailPayload } from "@/api/mountedAi";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface MountedAISemanticDecisionPanelProps {
|
||||
detail?: MountedAIRunDetailPayload;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
if (value == null) {
|
||||
return "暂无";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function MountedAISemanticDecisionPanel({ detail, loading }: MountedAISemanticDecisionPanelProps) {
|
||||
const decision =
|
||||
detail?.semantic_decision && typeof detail.semantic_decision === "object"
|
||||
? (detail.semantic_decision as Record<string, unknown>)
|
||||
: null;
|
||||
const gate =
|
||||
detail?.semantic_gate_result && typeof detail.semantic_gate_result === "object"
|
||||
? (detail.semantic_gate_result as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Semantic Decision / Gate</CardTitle>
|
||||
<CardDescription>展示模型如何判定 topic、mode 与 draft patch,以及 gatekeeper 最终是否放行。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
正在加载 semantic decision...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !detail ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
选择一条 run 后,这里会展示 semantic decision 与 gate result。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && detail ? (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Topic Relation</div>
|
||||
<div className="mt-2 text-sm font-medium">{String(decision?.topic_relation || "-")}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Mode Decision</div>
|
||||
<div className="mt-2 text-sm font-medium">{String(decision?.mode_decision || "-")}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Gate Accepted</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Badge variant={gate?.accepted ? "secondary" : "destructive"}>
|
||||
{gate?.accepted ? "accepted" : "blocked"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Next Plan Status</div>
|
||||
<div className="mt-2 text-sm font-medium">{String(gate?.next_plan_status || "-")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-2 text-sm font-medium">Semantic Decision</div>
|
||||
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
|
||||
{prettyJson(detail.semantic_decision)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-2 text-sm font-medium">Gate Result</div>
|
||||
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
|
||||
{prettyJson(detail.semantic_gate_result)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import type { MountedAIRunDetailPayload } from "@/api/mountedAi";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface MountedAISemanticPacketPanelProps {
|
||||
detail?: MountedAIRunDetailPayload;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
if (value == null) {
|
||||
return "暂无";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function MountedAISemanticPacketPanel({ detail, loading }: MountedAISemanticPacketPanelProps) {
|
||||
const packet = detail?.semantic_packet_summary;
|
||||
const recentTurns = Array.isArray(packet?.recent_turns) ? packet.recent_turns : [];
|
||||
const openQuestions = Array.isArray(packet?.open_questions) ? packet.open_questions : [];
|
||||
const modeCard =
|
||||
packet?.mode_card && typeof packet.mode_card === "object"
|
||||
? (packet.mode_card as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Semantic Context Packet</CardTitle>
|
||||
<CardDescription>展示当前 shadow 语义包里真正喂给模型的 topic / draft / recent turns / mode card 摘要。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
正在加载 semantic packet...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !detail ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
选择一条 run 后,这里会展示 semantic packet。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && detail ? (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Packet Version</div>
|
||||
<div className="mt-2 text-sm font-medium">{packet?.packet_version || "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Workflow Mode</div>
|
||||
<div className="mt-2 text-sm font-medium">{packet?.workflow_mode || detail.topic?.mode || "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Draft Version</div>
|
||||
<div className="mt-2 text-sm font-medium">{packet?.draft_version ?? detail.draft?.draft_version ?? "-"}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Plan Status</div>
|
||||
<div className="mt-2 text-sm font-medium">{detail.topic?.plan_status || "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium">Topic / Thread</div>
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">
|
||||
当前挂载 runtime topic 与 thread 边界。
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary">{recentTurns.length} turns</Badge>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500 dark:text-slate-400">Topic Key:</span>
|
||||
<span className="break-all font-medium">{detail.topic?.topic_key || packet?.topic_key || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 dark:text-slate-400">Thread Key:</span>
|
||||
<span className="break-all font-medium">{detail.topic?.thread_key || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 dark:text-slate-400">Draft State:</span>
|
||||
<span className="font-medium">{detail.draft?.state || detail.topic?.draft_state || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-slate-500 dark:text-slate-400">Thread Summary</div>
|
||||
<div className="rounded-xl bg-slate-50 px-3 py-3 text-sm leading-6 dark:bg-slate-950/40">
|
||||
{packet?.thread_summary || "暂无"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-3 text-sm font-medium">Mode Card</div>
|
||||
{modeCard ? (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">{String(modeCard.workflow_mode || "-")}</Badge>
|
||||
<Badge variant="outline">{String(modeCard.mode_state || "-")}</Badge>
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
|
||||
{prettyJson(modeCard)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
当前 run 尚未记录 mode card。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium">Recent Turns</div>
|
||||
<Badge variant="secondary">{packet?.recent_turn_count ?? recentTurns.length}</Badge>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{recentTurns.length > 0 ? (
|
||||
recentTurns.map((turn, index) => (
|
||||
<div
|
||||
key={`${turn.role}-${index}`}
|
||||
className="rounded-xl border border-slate-200/80 bg-slate-50/80 p-3 dark:border-slate-700 dark:bg-slate-950/40"
|
||||
>
|
||||
<div className="mb-2">
|
||||
<Badge variant={turn.role === "assistant" ? "secondary" : "outline"}>{turn.role}</Badge>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-slate-700 dark:text-slate-200">{turn.text}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
当前 packet 没有 recent turns。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-3 text-sm font-medium">Open Questions</div>
|
||||
<div className="space-y-2">
|
||||
{openQuestions.length > 0 ? (
|
||||
openQuestions.map((item, index) => (
|
||||
<div
|
||||
key={`${item}-${index}`}
|
||||
className="rounded-xl bg-slate-50 px-3 py-3 text-sm leading-6 text-slate-700 dark:bg-slate-950/40 dark:text-slate-200"
|
||||
>
|
||||
{item}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
当前 packet 没有待确认问题。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import type { MountedAIRunDetailPayload } from "@/api/mountedAi";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface MountedAIShadowDiffPanelProps {
|
||||
detail?: MountedAIRunDetailPayload;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function prettyJson(value: unknown): string {
|
||||
if (value == null) {
|
||||
return "暂无";
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function MountedAIShadowDiffPanel({ detail, loading }: MountedAIShadowDiffPanelProps) {
|
||||
const diff =
|
||||
detail?.semantic_diff && typeof detail.semantic_diff === "object"
|
||||
? (detail.semantic_diff as Record<string, unknown>)
|
||||
: null;
|
||||
const executionOutcome =
|
||||
detail?.execution_outcome && typeof detail.execution_outcome === "object"
|
||||
? (detail.execution_outcome as Record<string, unknown>)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card className="border-slate-200/80 bg-white/90 shadow-sm dark:border-slate-800 dark:bg-slate-900/85">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Shadow Diff</CardTitle>
|
||||
<CardDescription>对比旧链路的 legacy route 与新语义链路的 shadow 结论,用于切主链前验收。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
正在加载 shadow diff...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && !detail ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 px-4 py-8 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
选择一条 run 后,这里会展示 shadow diff。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!loading && detail ? (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Legacy Route</div>
|
||||
<div className="mt-2 text-sm font-medium">{String(diff?.legacy_route || executionOutcome?.workflow_route || "-")}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Legacy Intent</div>
|
||||
<div className="mt-2 text-sm font-medium">{String(diff?.legacy_message_intent || "-")}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Shadow Changed</div>
|
||||
<div className="mt-2">
|
||||
<Badge variant={diff?.changed ? "secondary" : "outline"}>
|
||||
{diff?.changed ? "changed" : "same"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400">Shadow Error</div>
|
||||
<div className="mt-2 text-sm font-medium">{String(diff?.shadow_error || "-")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-2 text-sm font-medium">Shadow Diff Payload</div>
|
||||
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
|
||||
{prettyJson(detail.semantic_diff)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 p-4 dark:border-slate-700">
|
||||
<div className="mb-2 text-sm font-medium">Legacy Execution Outcome</div>
|
||||
<pre className="overflow-x-auto rounded-xl bg-slate-950 p-3 text-xs leading-6 text-slate-100">
|
||||
{prettyJson(detail.execution_outcome)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue