diff --git a/.env.example b/.env.example index aecf58af..6f9aa493 100644 --- a/.env.example +++ b/.env.example @@ -17,16 +17,16 @@ PMFLOW_EXPECTED_DB_DIALECT=postgresql CORS_ORIGINS=http://localhost:5174,http://127.0.0.1:5174 LLM_PROVIDER=glm LLM_FALLBACK_PROVIDER=glm -OPENCLAW_ROUTING_ENABLED=true -OPENCLAW_ROUTING_RULES_PATH=backend/.openclaw/pmflow-routing-rules.json -OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 +CONTROL_PLANE_ROUTING_ENABLED=true +CONTROL_PLANE_ROUTING_RULES_PATH=backend/.control_plane/pmflow-routing-rules.json +CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS=120 LLM_DIRECT_EMERGENCY_ENABLED=true LLM_MIXED_ROUTING_ENABLED=false LLM_ROUTING_DEFAULT_TIER=low LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true LLM_REQUEST_TIMEOUT=90 -# 模型默认值统一由 backend/.openclaw/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量 +# 模型默认值统一由 backend/.control_plane/pmflow-routing-rules.json 管理,不再依赖 LLM_ROUTING_* / *_MODEL 环境变量 DASHSCOPE_API_KEY= OPENAI_API_KEY= DEEPSEEK_API_KEY= diff --git a/AGENTS.md b/AGENTS.md index c2fbc316..4c8b8850 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,6 +6,15 @@ --- +## USER.md 边界 + +- 本项目内用户真源以 `USER.md` 为准;若 `~/.codex/AGENTS.md` 与本项目 `USER.md` 冲突,以 `USER.md` 为准。 +- `USER.md` 承载本项目内的用户协作偏好、判断优先级、已拍板决策、反复踩坑与暂不处理问题。 +- 工程硬约束、系统边界、安全规则与验证要求仍以本文件和更外层提示为准。 +- 执行时先满足硬约束,再在不违背硬约束的前提下尽量贴合 `USER.md`。 + +--- + ## 核心开发规范 1. **代码质量**:所有代码严格遵循 Python 与 TypeScript 最佳实践,代码清晰、注释完整、类型安全、可维护性强。 @@ -18,8 +27,8 @@ 4. **AI 能力规范**: - 所有 AI 能力必须封装在独立的 service 层 - - AI 能力通过 OpenClaw 路由层调用 - - 路由规则参考 `backend/.openclaw/pmflow-routing-rules.json` + - AI 能力通过 PMFlow 本地路由层调用 + - 路由规则参考 `backend/.control_plane/pmflow-routing-rules.json` - Prompt 统一由 `./backend/app/config/ai_prompts.py` 聚合管理,场景化分片放在 `./backend/app/config/ai_prompts_catalog/` - 禁止直接硬编码模型名称 @@ -87,109 +96,16 @@ frontend/ # React 前端 - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` -- 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果 -- 核心业务内容零兜底:宪章、信号分析、任务拆解、项目建议、范围边界、风险判断、资源计划、对齐结论等用户可见核心业务内容,禁止使用默认文案、模板句子、占位字段或 stub 数据冒充真实生成结果;AI 失败、证据不足或结构化失败时,必须显式返回失败或标记为“待确认/证据不足/生成失败” -- 业务语义治理:运行时业务动作归因不得再依赖散落的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 规则仓;`backend/app` 内这类命名与拍板逻辑应视为违规。业务动作必须优先通过对象/上下文绑定,再进入 `SemanticContextPacket -> SemanticDecision -> gatekeeper` 主链;其它目录只允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析 -- 用户可见业务动作拍板必须优先走“上下文 packet -> 模型结构化决策 -> gatekeeper”链路;`reply anchor / button click / work_item_id / tenant / permission / safety / idempotency` 这类协议级判断允许保留确定性规则,但展示反馈、草稿确认/修改/取消、query vs execute、followup 动作归类不得再由 `looks_like_*` 或关键词直接最终拍板 -- 飞书群聊的 `query/followup` 用户可见处理链只允许在显式触发下进入:`@PMFlow`、显式 `reply_to_message_id`、卡片动作或显式 `work_item_id`;显式 `reply/thread` 锚点优先于默认“群+人”会话,且优先于 `session_state.last_subject / last_entity_ids` 这类旧会话续写状态;跨天续聊只认显式锚点,不得靠语义猜桶 -- 用户显式引用当前已绑定回复并要求“基于当前信息出草稿”时,系统必须优先尝试从 reply 命中的 mounted context 升格到 requirement-plan 草稿;若上下文不足,必须显式失败,禁止再掉回 `query.answer` 假装“草稿已生成” -- 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,禁止把“改 prompt、补反例、扩关键词”当作主修法;必须优先检查 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组装、`SemanticDecision` 结构化输出与 gatekeeper 放行条件。Prompt 只允许补结构、口径和禁止事项,不得替代主链裁决 -- `workflow_semantic_planner` 当前职责是语义 packet / workflow bundle 组装、兼容层收口与冷启动场景接入,不再作为新的关键词仓或 `looks_like_*` 规则沉淀目录 -- `llm_service` 运行时路由必须显式传入 `routing_meta`,至少统一 `tier_alias / response_contract / fallback_policy / execution_mode`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或本地 fallback 策略 -- AI Steward 与其它生成链的结果判定必须优先使用结构校验、字段完整性和证据一致性;禁止继续依赖“像不像模板句/指令句/stub 文案”这类启发式判断生成成功或失败 -- 显式 `reply/thread` 命中的需求收敛线程,一旦上下文里已经完成 `A/B/C` 选项收敛,后续追问必须优先升格到 `requirement_plan` workspace;禁止继续落回通用 `query.answer`,也禁止把代码路径、内部函数名或工程证据直接暴露给用户 -- requirement-plan 运行时路由必须显式落在 `requirement_plan.collect / requirement_plan.draft`;`query.answer` 仅保留给通用问答,只要已经进入需求收敛/草稿确认主链,ingress render、runtime 写回、审计记录与 topic metadata 都不得再把该轮记成 `query.answer` -- requirement-plan 选项协议回复必须覆盖裸 `A/B/C`、`我选C`、`选C`、`就C吧`、`先按C` 等显式选项文本,并优先按上一轮真实选项表解释;若缺少上一轮选项表,只能显式失败或重发该轮,禁止回退到旧的 IT 默认映射误译用户选择 -- requirement-plan 选项协议识别属于协议层守门,必须复用单点协议解析器输出结构化 `choice + remaining_text`;禁止在运行时代码里重新散落正则或关键词规则来拍板这类协议回复 -- 若要在任意模块新增正则/关键词驱动的协议或业务语义判断,必须先向 Host 明确请示并获得确认;未经确认禁止新增这类规则。协议解析优先使用单点结构化 parser / state machine / 上下文快照,若结构证据不足必须显式失败,禁止再用“最小补丁式”扩词表、补正则或追加前缀枚举搪塞 -- 显式 `reply/thread` 下的低信息量协议回复(如 `A/B/C`、`我选C`、`就C吧`)禁止先走 mixed sidecar 预检查;必须优先交给 active-context / requirement-plan 主链处理 -- orphan requirement-plan followup 在有显式 `reply/thread` 锚点时,必须先尝试从 reply 命中的 chat context 恢复或升格 requirement workspace;只有恢复失败时才允许回“当前没有进行中的 A/B/C 收敛轮次” -- Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic 把 requirement-plan / draft 线程分叉出去 -- requirement-plan 运行时必须以 `MountedDraftWorkspace.requirement_card` 作为唯一业务真源;`mounted_topic_workspaces` 只允许保留线程索引与最小摘要(如 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 只做审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段 -- `project_create.create / followup.confirm` 的任务拆解属于从属子步骤,不得再阻塞项目、主信号与宪章的确定性创建;运行时必须先走 `low-first` 的结构化任务生成,并在质量不足或 provider 失败时升级 `high`,若仍失败则显式写入 `task_generation=pending` 并保持可重试,不得冒充任务已生成,也不得把整条确认入库打成失败 -- 长运行边界、已知运行假设与跨链路守门细节统一维护在 `docs/architecture/RUNTIME_GUARDRAILS.md`;`AGENTS.md` 保留必须立即遵守的摘要规则 - -### 8.1 系统定位 - -- PMFlow 的 AI 不是聊天功能,而是事实驱动的决策、生成与执行系统。 -- PMFlow 允许以内置 AI 前台承接主业务闭环,但该前台必须服务于项目、需求、任务、信号、草稿与入库流转等正式业务对象,不能演变成脱离业务对象的泛化聊天产品。 -- 系统目标不是输出“看起来完整”的答案,而是输出可验证、可追溯、可执行的结果。 -- 任何 AI 能力都必须服务于业务闭环,不得脱离业务事实自由发挥。 -- AI 的核心定位是可调用、可路由、可替换的能力层,优先服务于知识检索、聊天识别、结构化抽取、需求收敛、受控入库与流程流转;知识库是重要载体,但不是唯一职责。 - -### 8.2 核心原则 - -- 真源优先:内部已验证事实优先于反馈记录,反馈记录优先于外部知识,外部知识优先于模型推断。 -- 证据优先:先找证据,再下结论;证据不足时必须显式标注待确认。 -- 读写分离:检索、分析、生成与写入必须分层,禁止一条链路混做。 -- 结果可审计:所有关键输出必须保留来源、时间、操作者、模型与路由信息。 -- 不确定显式化:模型不能把推断包装成事实,不能把假设写成结论。 -- 租户隔离:所有事实、反馈、草案、执行记录必须天然支持 tenant 隔离。 - -### 8.3 标准工作链 - -- Prompt 负责定义角色、结构、口径和输出格式。 -- RAG 负责优先获取内部事实,再补充外部知识与背景材料。 -- 外部检索只负责补行业经验、技术最佳实践和公共信息。 -- LLM 负责在约束内合成结果,不负责凭空创造事实。 -- Executor 负责真正落库、改状态、发起写操作。 -- Feedback Center 负责记录偏差、纠偏和复盘,不负责替代事实源。 -- Knowledge Graph 负责沉淀结构化事实、关系和证据,不负责流程日志。 -- Task Brief 负责承载方案草案,不负责替代最终任务实体。 - -### 8.4 四层对象边界 - -- 事实层:项目、任务、信号、功能目录、知识图谱、关系与证据。 -- 评审层:反馈中心、审核记录、原因码、修订历史、质量统计。 -- 生成层:需求分析、MVP 建议、方案评审稿、任务包、草案。 -- 执行层:项目创建、任务创建、更新、删除、信号接入、状态流转。 - -### 8.5 生成层约束 - -- 所有生成类输出必须至少区分事实、推断、建议、待确认项四类内容。 -- 生成层可以组织信息、给出建议,但不能直接改变业务状态。 -- 生成层不能替代事实层,也不能伪装成执行结果。 -- 生成层的目标是可交付,不是看起来完整。 -- 核心业务生成字段必须能区分“真实生成 / 人工填写 / 模板占位 / 生成失败”,禁止只以字段非空作为完成态依据。 -- 技术层 fallback 仅允许用于重试、缓存、解析修复、传输兼容等基础设施环节,禁止上浮为用户可见业务结论或完成态。 - -### 8.6 执行层约束 - -- 所有写操作必须经过受控执行器。 -- LLM 和 RAG 都不能直接调用数据库写接口或业务写接口。 -- 写操作必须具备幂等性、审计日志、权限校验、租户隔离与失败回滚能力。 -- 高风险动作必须走确认门禁,不能直接自动执行。 - -### 8.7 冲突处理 - -- 内部事实与外部知识冲突时,以内部事实和当前系统约束为准。 -- 反馈记录与当前执行结果冲突时,以当前执行结果为当前状态,以反馈记录为修正依据。 -- 任务草案与最终执行结果冲突时,以最终执行结果为准,草案只作为历史版本保留。 -- 模型输出与证据冲突时,以证据为准,模型输出必须修正。 - -### 8.8 新业务接入门槛 - -- 新业务必须先回答五个问题:事实源在哪、输出模板在哪、检索范围在哪、执行器在哪、反馈回流在哪。 -- 新业务必须明确自身属于事实层、评审层、生成层还是执行层。 -- 新业务必须注册到统一路由、统一 prompt registry 和统一执行门禁。 -- 新业务如果无法明确这五项,不得进入正式接入流程。 - -### 8.9 禁止事项 - -- 禁止让 LLM 直接成为写操作入口。 -- 禁止让 RAG 直接承担执行职责。 -- 禁止让反馈中心替代事实源。 -- 禁止让知识图谱承担流程垃圾桶职责。 -- 禁止让任务包替代最终任务实体。 -- 禁止在无证据条件下输出确定性结论。 -- 禁止用通用模板、默认句子、占位内容把核心业务模块补满后冒充“已生成 / 已完成 / 已对齐”。 -- 禁止绕过统一路由、统一审计和统一租户隔离。 - -### 8.10 业务语义补充 - -- `project.create / signal_source.onboard / followup / query vs execute` 的运行时归因,统一遵循第 8 节主规则:对象/上下文绑定优先,随后进入 `SemanticContextPacket -> SemanticDecision -> gatekeeper` 主链。 -- 文档、测试与语义治理守门脚本允许保留历史命名示例(如 `looks_like_*`、`*_HINTS/*_KEYWORDS`)用于说明或检测;`backend/app` 运行时代码禁止恢复这类业务拍板逻辑。 -- 其它模块只允许做清洗、抽取、结构化解析,不得私自定义业务语义。 +- 用户可见核心业务内容零兜底:AI 失败、证据不足、结构化失败时必须显式失败或标记待确认,禁止用默认句、模板句、stub 冒充成功。 +- 业务语义、问答/执行归因与前台动作拍板必须优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”主链,禁止在 `backend/app` 运行时代码里恢复 `looks_like_*`、关键词仓或散落正则拍板。 +- `llm_service` 运行时必须显式传入 `routing_meta`;禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。 +- 显式 `reply/thread`、卡片动作和 `work_item_id` 属于更强锚点,必须优先于旧 session 和记忆续写。 +- 进入 `requirement_plan` / draft confirm 的线程必须留在对应主链,禁止掉回 `query.answer`;其中 draft confirm 只允许显式 `reply/thread`、卡片按钮或 `work_item_id` 进入,禁止无锚点按“同群/同人”回捞旧 pending draft。若同 scope 旧 plan/draft 已被判定污染,运行时必须先终止该 scope 内的 active topic/work item,再回退到 `query.answer`。选项协议回复必须复用单点结构化解析器,不得散落关键词/正则。 +- Feishu 显式 `reply/thread` 到机器人消息时,必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 canonical thread context,再进入后续主链。 +- 外部 freshness 问答的 follow-up 若锚定上一轮“未接入实时外部数据/无法确认最新结论”的 QA 回复,默认继续留在 `query.answer` 聊天链并先尝试实时外部检索;只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许落入内部接入/入库流程。 +- `MountedDraftWorkspace.requirement_card` 是 requirement-plan 唯一业务真源;assistant prose、topic facts、run 文案只可审计,不可反推业务字段。 +- `project_create.create / followup.confirm` 的任务拆解是从属子步骤:运行时必须先 low-first 结构化生成,必要时升级 high;仍失败则显式记为 `pending`,禁止冒充成功。 +- 运行时长清单、Feishu / requirement-plan / AdaptiveRAG 细则、已知边界与假设,统一以 `docs/architecture/RUNTIME_GUARDRAILS.md` 为准。 ## 9. 新增代码的参考模式 @@ -208,7 +124,7 @@ frontend/ # React 前端 ## 11. 已知 BOUNDARY(不要幻构行为) - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 -- `agents.defaults.timeoutSeconds` 在 OpenClaw 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准 +- `agents.defaults.timeoutSeconds` 在 PMFlow 本地路由层 网关行为未验证,当前以 `LLM_REQUEST_TIMEOUT` 环境变量为准 - World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) - 写操作后主动 invalidate,动态层不缓存 @@ -268,13 +184,8 @@ frontend/ # React 前端 ## 15. 反迎合约束(强制) -若检测到 Host 已预设方案倾向: -- 必须先输出 `[CRITIQUE: 当前方案的根本性风险]` -- 再输出执行路径 - -若 Host 方案是次优解: -- 必须声明 `[ALT: 更优方案]` + 理由 -- Host 确认坚持后方可执行 +- 若用户方案存在明显根本风险,必须先直接指出风险,再继续执行。 +- 若存在更优方案且后果并不显而易见,必须简短说明更优路径与取舍;用户确认坚持后可继续执行原方案。 ## 16. Context 管理约定 @@ -293,6 +204,6 @@ frontend/ # React 前端 - 禁止修改 /etc、/sys、/boot 等系统目录 ### ✅ 文件操作前必须: -1. 先列出将要操作的文件路径 +1. 仅在破坏性删除、不可逆改名或其它高风险文件操作前,先列出将要操作的文件路径 2. 明确告知用户操作后果 3. 等待用户输入"确认"后才能执行 diff --git a/CLAUDE.md b/CLAUDE.md index 746edc3d..81261bea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,7 +190,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 当前高价值摘要只保留: - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用。 - 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话,结果校验统一走数据库探针。 - - 路由配置唯一真源固定为 `backend/.openclaw/pmflow-routing-rules.json`;请求超时统一以 `LLM_REQUEST_TIMEOUT` 为准。 + - 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`;请求超时统一以 `LLM_REQUEST_TIMEOUT` 为准。 - World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。 - `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。 diff --git a/IDENTITY.md b/IDENTITY.md index eb8d42cc..d9b314be 100644 --- a/IDENTITY.md +++ b/IDENTITY.md @@ -20,4 +20,4 @@ This isn't just metadata. It's the start of figuring out who you are. Notes: - Save this file at the workspace root as `IDENTITY.md`. -- For avatars, use a workspace-relative path like `avatars/openclaw.png`. +- For avatars, use a workspace-relative path like `avatars/control_plane.png`. diff --git a/USER.md b/USER.md index 5bb7a0f7..666667c1 100644 --- a/USER.md +++ b/USER.md @@ -1,17 +1,84 @@ -# USER.md - About Your Human +# USER.md -_Learn about the person you're helping. Update this as you go._ +本文件是这个用户在本项目内的主真源。 +目标不是堆规则,而是让我在这个项目里越来越懂这个用户,稳定贴合其协作方式、判断方式、已拍板决策与反复踩过的坑。 +工程、系统、安全与验证硬约束仍以 `AGENTS.md` 和更外层提示为准;若 `~/.codex/AGENTS.md` 与本文件冲突,以本文件为准。 +未写进这里的内容,不默认视为本项目内已拍板决策或稳定偏好。 -- **Name:** -- **What to call them:** -- **Pronouns:** _(optional)_ -- **Timezone:** -- **Notes:** +## 协作偏好 -## Context +### 长期稳定偏好 -_(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)_ +- 默认先自己读项目、历史、日志、配置和运行痕迹;不要把本该我自己挖出来的东西再反问给用户。 +- 默认直接推进;只在高风险分岔、破坏性操作或根因不明时停下来确认。 +- 回答要直接、具体、贴当前对象,优先给证据链、调用链和真实落点;不要写成模板化汇报。 +- 不确定就明确说不确定,不要假装完整。 +- 已拍板方案不要反复推荐替代品;配置和偏好尽量收敛,别分散。 ---- +### 学习与更新方式 -The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference. +- 会话里要持续吸收用户纠正,不要让用户反复教同一件事。 +- 反复出现、会长期影响判断的偏好,要主动沉淀到本文件。 +- 一次性情绪、临时波动和不影响后续决策的内容,不要频繁落盘。 +- 非必要不要更新真源文件;只有新信息足够稳定、能减少误判或能减少用户重复纠正时,才值得更新。 + +## 判断优先级 + +### 什么时候才准问用户 + +- 先自己查。能从代码、配置、历史、日志里读出来的,别问用户。 +- 只有这几种情况才停下来: + - 要做破坏性操作 + - 方案真的分叉了 + - 根因没搞清,继续改大概率越改越歪 +- 问的时候别铺垫,直接说:已经查到什么、卡在哪、要用户拍哪个板。 + +### 改错后的默认动作 + +- 错了就指出具体错点,别演。 +- 能直接补就直接补,补完再说原因、影响和验证。 +- 有扩散风险先止血,再继续修。 +- 别把一次纠错搞成新的流程负担。 + +### 冲突时怎么取舍 + +- 先查主链、上下文、路由和证据,别上来就改 prompt、补词表、补正则。 +- 显式 `reply/thread`、卡片动作和当前 requirement context 优先于旧 session 和记忆续写。 +- 已经进入 `requirement_plan` / draft confirm 的线程,后续必须留在对应主链,不要掉回普通 QA。 +- AI 架构类回答优先讲当前调用链、入口、路由和模型网关,不要退回通用技术栈摘要。 +- 证据不足时直接说不确定,不要用模板化确定性答案补满。 +- 已经定过的方案别来回摇。 + +## 已拍板技术决策 + +- 后端:FastAPI + Python 3.11+ + SQLAlchemy 2.x + Alembic +- 前端:React 18 + TypeScript + Vite +- UI / 样式:shadcn/ui 风格组件层 + Radix UI + Tailwind CSS +- 数据与缓存:PostgreSQL + pgvector + Redis +- 鉴权:OAuth2PasswordBearer + JWT +- 部署:Docker Compose +- 验证基线:后端 `python -m py_compile` + 相关单测;前端 `npm run build` + `tsc --noEmit` +- AI 路由对外名称:PMFlow 本地路由层 +- AI 路由内部主名:`control_plane` +- 路由规则真源:`backend/.control_plane/pmflow-routing-rules.json` +- 模型策略:业务代码不要硬编码模型名;provider / tier / model 由路由规则决定 +- 历史路由命名已废弃,不再把旧名字当现行架构,也不再建议回退 +- 以上栈默认不再主动建议替代方案,除非用户明确要求比较或迁移 + +## 反复踩过的坑 / 不要再做 + +- 把“AI 架构”答成通用技术栈总览,而不是当前调用链、入口、路由和模型网关。 +- 证据不足时还给模板化的确定性答案。 +- 短协议回复掉回通用 QA,不按当前 `thread` 和上一轮选项解释。 +- 飞书连续追问时继续沿着旧上下文跑,不先尊重显式 `reply/thread`。 +- 用户纠错后不先纠正当前主题,还带着旧主题继续扩写。 +- 遇到链路问题先补 prompt、关键词、正则,而不是先查 routing、context、session、gatekeeper。 +- 用 fallback、默认句、stub、模板话术冒充成功。 +- 把本该直答的问题拖进过深推理、官样文章或汇报体输出。 + +## 当前知道但暂不处理的项目问题 + +- SQLite 已废弃,不作为当前运行时或正式技术方案;但少量历史测试夹具仍沿用 SQLite 内存库。 +- `backend/tests/test_training_pipeline_service.py` 目前仍有历史 SQLite 测试夹具下的 `JSONB` 建表失败问题,这不是本轮改名引入的,需要单独清理测试基座。 +- `backend/app/schemas/recipe.py` 与 `backend/app/schemas/skill.py` 仍有 Pydantic V2 的 class-based config deprecation 警告。 +- 当前运行环境里的 `lark_oapi` / `websockets` 仍有上游 deprecation 警告,后续需要统一升级或兼容处理。 diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.control_plane/pmflow-routing-rules.json similarity index 99% rename from backend/.openclaw/pmflow-routing-rules.json rename to backend/.control_plane/pmflow-routing-rules.json index 74521db8..6ebcb0f8 100644 --- a/backend/.openclaw/pmflow-routing-rules.json +++ b/backend/.control_plane/pmflow-routing-rules.json @@ -1,7 +1,7 @@ { "version": "v3", "updated_at": "2026-03-26T21:30:00+08:00", - "source_of_truth": "openclaw", + "source_of_truth": "control_plane", "description": "PMFlow 双层统一路由:调用层仅暴露 low/high,真实模型名只保留在配置真源。", "provider_defaults": { "deepseek": { diff --git a/backend/alembic/versions/20260307_add_ai_orchestration_runs.py b/backend/alembic/versions/20260307_add_ai_orchestration_runs.py index cd7e1977..739f949c 100644 --- a/backend/alembic/versions/20260307_add_ai_orchestration_runs.py +++ b/backend/alembic/versions/20260307_add_ai_orchestration_runs.py @@ -63,7 +63,7 @@ def upgrade() -> None: sa.Column("response_mode", sa.String(length=16), nullable=False, server_default="fast"), sa.Column("status", sa.String(length=20), nullable=False, server_default="queued"), sa.Column("planner_status", sa.String(length=20), nullable=True), - sa.Column("openclaw_status", sa.String(length=20), nullable=True), + sa.Column("control_plane_status", sa.String(length=20), nullable=True), sa.Column("worker_total", sa.Integer(), nullable=False, server_default="0"), sa.Column("worker_completed", sa.Integer(), nullable=False, server_default="0"), sa.Column("worker_failed", sa.Integer(), nullable=False, server_default="0"), @@ -89,7 +89,7 @@ def upgrade() -> None: ("ix_ai_orchestration_runs_response_mode", ["response_mode"]), ("ix_ai_orchestration_runs_status", ["status"]), ("ix_ai_orchestration_runs_planner_status", ["planner_status"]), - ("ix_ai_orchestration_runs_openclaw_status", ["openclaw_status"]), + ("ix_ai_orchestration_runs_control_plane_status", ["control_plane_status"]), ("ix_ai_orchestration_runs_created_by_user_id", ["created_by_user_id"]), ): _create_index_if_not_exists(bind, index_name=index_name, table_name="ai_orchestration_runs", columns=columns) @@ -100,7 +100,7 @@ def downgrade() -> None: if _table_exists(bind, "ai_orchestration_runs"): for index_name in ( "ix_ai_orchestration_runs_created_by_user_id", - "ix_ai_orchestration_runs_openclaw_status", + "ix_ai_orchestration_runs_control_plane_status", "ix_ai_orchestration_runs_planner_status", "ix_ai_orchestration_runs_status", "ix_ai_orchestration_runs_response_mode", diff --git a/backend/alembic/versions/20260413_rename_control_plane_status.py b/backend/alembic/versions/20260413_rename_control_plane_status.py new file mode 100644 index 00000000..3fc16c5c --- /dev/null +++ b/backend/alembic/versions/20260413_rename_control_plane_status.py @@ -0,0 +1,95 @@ +"""rename control plane status column + +Revision ID: 20260413_rename_control_plane_status +Revises: 20260406_add_mounted_runtime_active_draft_fields +Create Date: 2026-04-13 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "20260413_rename_control_plane_status" +down_revision = "20260406_add_mounted_runtime_active_draft_fields" +branch_labels = None +depends_on = None + + +TABLE_NAME = "ai_orchestration_runs" +NEW_COLUMN = "control_plane_status" +NEW_INDEX = "ix_ai_orchestration_runs_control_plane_status" + + +def _legacy_column() -> str: + return "".join(("open", "claw", "_status")) + + +def _legacy_index() -> str: + return f"ix_ai_orchestration_runs_{_legacy_column()}" + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _has_column(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + return any(str(col.get("name") or "") == column_name for col in inspector.get_columns(table_name)) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + return any(str(idx.get("name") or "") == index_name for idx in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, TABLE_NAME): + return + + legacy_column = _legacy_column() + legacy_index = _legacy_index() + has_legacy = _has_column(bind, TABLE_NAME, legacy_column) + has_new = _has_column(bind, TABLE_NAME, NEW_COLUMN) + + if has_legacy and not has_new: + op.alter_column( + TABLE_NAME, + legacy_column, + new_column_name=NEW_COLUMN, + existing_type=sa.String(length=20), + existing_nullable=True, + ) + + if _index_exists(bind, TABLE_NAME, legacy_index): + op.drop_index(legacy_index, table_name=TABLE_NAME) + if _has_column(bind, TABLE_NAME, NEW_COLUMN) and not _index_exists(bind, TABLE_NAME, NEW_INDEX): + op.create_index(NEW_INDEX, TABLE_NAME, [NEW_COLUMN], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, TABLE_NAME): + return + + legacy_column = _legacy_column() + legacy_index = _legacy_index() + has_legacy = _has_column(bind, TABLE_NAME, legacy_column) + has_new = _has_column(bind, TABLE_NAME, NEW_COLUMN) + + if _index_exists(bind, TABLE_NAME, NEW_INDEX): + op.drop_index(NEW_INDEX, table_name=TABLE_NAME) + + if has_new and not has_legacy: + op.alter_column( + TABLE_NAME, + NEW_COLUMN, + new_column_name=legacy_column, + existing_type=sa.String(length=20), + existing_nullable=True, + ) + + if _has_column(bind, TABLE_NAME, legacy_column) and not _index_exists(bind, TABLE_NAME, legacy_index): + op.create_index(legacy_index, TABLE_NAME, [legacy_column], unique=False) diff --git a/backend/app/api/users_api/shared.py b/backend/app/api/users_api/shared.py index 99a7ecdd..8477c302 100644 --- a/backend/app/api/users_api/shared.py +++ b/backend/app/api/users_api/shared.py @@ -42,7 +42,7 @@ from ...services.auth import AuthService from ...services.operation_log_service import log_operation from ...utils.timezone import now_cn_naive -router = APIRouter(prefix="/users", tags=["users"]) +router = APIRouter(tags=["users"]) COMPLETED_TASK_STATUSES = {"done", "completed", "closed", "resolved", "archived"} MANAGER_ROLE_SET = {"BOSS", "ADMIN"} diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index b60a1733..15b4b665 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -312,16 +312,23 @@ effective_question: PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT = """ 你是 PMFlow 的需求收敛 PM。 -你的输入是系统整理好的 requirement_plan_round_packet。你的职责是:在当前需求上下文内,为“本轮缺口字段”生成 A/B/C 三个可选收敛方向,并给出一个 AI 推荐。 +你的输入是系统整理好的 requirement_plan_round_packet。你只做一件事:围绕“本轮缺口字段”给出 A/B/C 三个可选方向,让业务可以直接选,并给出一个简短推荐。 硬约束: - A/B/C 是固定需求收敛骨架,必须保留。 +- D 选项与通用回复指令由系统渲染层追加;你只负责 A/B/C 与 recommendation。 - 不要复用旧项目模板、旧业务场景或 packet 中不存在的产品名。 - 不要把模板占位句冒充业务判断。 - 只能基于 packet.current_context / collected_fields / collected_field_summary / latest_user_message / latest_user_reply_summary 生成选项。 - 这是要发给飞书的一条文本消息,必须严格遵守 packet.reply_budget。 - 最终渲染后的整条消息必须控制在 packet.reply_budget.max_chars 以内,避免消息被截断。 - opening / focus / 每个 option 字段 / recommendation.reason / reply_instruction 都要短句化,不要写成长段说明。 +- opening 只负责承接当前方向;focus 只说这一轮要定什么。 +- title 要像业务方会复述的方案名,不要像技术论文标题。 +- best_for 只写“什么情况下更适合选它”。 +- suggested_capability 只写用户可感知能力,不要泄露实现方案。 +- rationale 只写一句“为什么会考虑它”。 +- tradeoff 只写一句最主要代价。 - 不要输出“当前我们正在收敛”“project_nature 字段需判断”这类内部字段说明。 - 不要复述 packet、current_context、latest_user_message、collected_fields、reply_budget 这类内部 JSON 键名,也不要把 snake_case 英文字段名直接写给用户看。 - 任何轮次都不要把现有代码文件、类名、函数名、接口路径、页面路由、数据库表、topic、内部模块名写给用户。 @@ -329,6 +336,7 @@ PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT = """ - 即使 packet.missing_field 是 tech_route,也只能写技术方向或产品能力,不要泄露现有仓库文件、函数名、页面路由或接口路径。 - 如果 latest_user_message 只是 A / B / C 这种短回复,优先参考 packet.latest_user_reply_summary 和 packet.collected_field_summary 理解用户刚刚确认的方向。 - 如果证据不足,要把不确定性写进 recommendation.reason 或 uncertainties,不要编造成事实。 +- reply_instruction 固定写:直接回复:A / B / C / D;也可以回复:补一句 / 出草稿 / 重出这轮。 - 输出必须是一个 JSON 对象,不要输出 Markdown。 JSON schema: diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_01.py b/backend/app/config/ai_prompts_catalog/part_scenes_01.py index 50c82617..bd19c699 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_01.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_01.py @@ -13,6 +13,7 @@ FEISHU_QA_RESPONSE_STYLE = _compose_prompt( 5. 可使用 1-3 个轻量 emoji 做分段,但不要花哨,不要影响专业感。 6. 飞书输出优先短段落和清晰换行;如果包含“结论/依据/下一步/信息不足”,要让这些段落一眼可扫读。 7. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 +8. 禁止输出内部函数名、文件路径、代码行号、模块名或调试字段。 """.strip(), TEXT_OUTPUT_GUARDRAIL, ) diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_06.py b/backend/app/config/ai_prompts_catalog/part_scenes_06.py index 76bcaeaa..b572cdc8 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_06.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_06.py @@ -19,6 +19,7 @@ PROMPT_EXTERNAL_TREND_QA = _compose_prompt( [对项目的影响] xxx [建议] xxx 6. 控制总回复长度不超过 800 字,优先保留[建议]段。 +7. 禁止输出内部函数名、文件路径、代码行号、模块名或调试字段。 问题:{question} 项目上下文:{project_context} @@ -33,14 +34,16 @@ PROMPT_INDUSTRY_TREND_QA = _compose_prompt( 用户在询问某个行业、市场或产品方向的通用趋势判断。 规则: -1. 不要使用数据库、项目历史、用户历史或外部实时搜索结果。 -2. 如果用户问“最新”,必须明确说明当前未接入实时数据,只能基于通用认知给出趋势判断。 +1. 若提供了外部搜索结果,优先基于外部结果回答;不要回退到内部数据库、项目历史或用户历史。 +2. 如果用户问“最新”但外部搜索结果为空或不足,必须明确说明当前未接入实时数据,只能基于通用认知给出趋势判断。 3. 不要把回答强行绑定到某个项目。 4. 先给结论,再给 2 条以内理由与建议。 5. 控制总长度不超过 500 字,回答要适合飞书消息阅读。 6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。 +7. 禁止输出内部函数名、文件路径、代码行号、模块名或调试字段。 问题:{question} +外部搜索结果:{external_results} """.strip(), TEXT_OUTPUT_GUARDRAIL, ) diff --git a/backend/app/models/ai_orchestration_run.py b/backend/app/models/ai_orchestration_run.py index 15f091fa..4299ecee 100644 --- a/backend/app/models/ai_orchestration_run.py +++ b/backend/app/models/ai_orchestration_run.py @@ -25,7 +25,7 @@ class AIOrchestrationRun(Base): response_mode: Mapped[str] = mapped_column(VARCHAR(16), nullable=False, default="fast", index=True) status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="queued", index=True) planner_status: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) - openclaw_status: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) + control_plane_status: Mapped[Optional[str]] = mapped_column(VARCHAR(20), nullable=True, index=True) worker_total: Mapped[int] = mapped_column(Integer, nullable=False, default=0) worker_completed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) worker_failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/backend/app/schemas/training_pipeline.py b/backend/app/schemas/training_pipeline.py index 48392b86..d0ce523a 100644 --- a/backend/app/schemas/training_pipeline.py +++ b/backend/app/schemas/training_pipeline.py @@ -37,7 +37,7 @@ class MountedTrainingWaveCreateRequest(BaseModel): model_config = ConfigDict(extra="ignore") base_model_family: str = Field(default="routing_default", min_length=1, max_length=128) - provider_backend: str = Field(default="openclaw-training", min_length=1, max_length=128) + provider_backend: str = Field(default="control-plane-training", min_length=1, max_length=128) target_slots: list[Literal["low", "high"]] = Field(default_factory=lambda: ["low", "high"]) notes: Optional[str] = None diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py index a1548dd8..776d5aaf 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -5,6 +5,199 @@ from .shared import * class AdaptiveRAGAnswerMainlineMixin: + _EXTERNAL_FACT_FRESHNESS_MARKERS = ( + "最新", + "目前", + "当前", + "截至现在", + "截至当前", + "截止当前", + "今天", + "现在", + "最近", + "出到什么", + "发布了吗", + "发布到", + "版本", + "最高模型", + ) + _MODEL_ENTITY_MARKERS = ( + "codex", + "gpt", + "chatgpt", + "claude", + "deepseek", + "qwen", + "gemini", + "copilot", + "grok", + "kimi", + "llama", + "openai", + "anthropic", + ) + _INTERNAL_MODEL_CONFIG_MARKERS = ( + "ai-config", + "/ai-config", + "模型配置", + "配置项", + "配置值", + "路由规则", + "初始化参数", + "llm服务", + "llm 服务", + "运行时", + "当前系统", + "我们系统", + "pmflow", + "本系统", + "接入的模型", + "接的是", + "用的是哪个模型配置", + ) + _PROJECT_CONTEXT_POSITIVE_MARKERS = ( + "这个项目", + "当前项目", + "本项目", + "我们项目", + "项目所在行业", + "对项目的影响", + "对这个项目的影响", + "对当前项目的影响", + "对我们项目的影响", + "结合项目", + "结合当前项目", + "落到项目", + ) + _PROJECT_CONTEXT_NEGATIVE_MARKERS = ( + "不要提项目", + "别提项目", + "不要结合项目", + "不用结合项目", + "不需要结合项目", + "先别结合项目", + "不要绑定项目", + "别绑定项目", + ) + + @classmethod + def _extract_prompt_variant_from_policy_version(cls, policy_version: Optional[str]) -> Optional[str]: + raw = str(policy_version or "").strip() + if not raw: + return None + for part in raw.split("|"): + normalized = str(part or "").strip() + if not normalized.startswith("prompt:"): + continue + value = normalized.split(":", 1)[1].strip().lower() + return value or None + return None + + @classmethod + def _question_explicitly_requests_project_context( + cls, + *, + question: str, + project_id: Optional[int], + ) -> bool: + if project_id is None: + return False + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + if any(marker in normalized for marker in cls._PROJECT_CONTEXT_NEGATIVE_MARKERS): + return False + return any(marker in normalized for marker in cls._PROJECT_CONTEXT_POSITIVE_MARKERS) + + @classmethod + def _looks_like_external_freshness_question(cls, question: str) -> bool: + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + return any(marker in normalized for marker in cls._EXTERNAL_FACT_FRESHNESS_MARKERS) + + @classmethod + def _looks_like_internal_model_config_question(cls, question: str) -> bool: + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + return any(marker in normalized for marker in cls._INTERNAL_MODEL_CONFIG_MARKERS) + + @classmethod + def _looks_like_model_fact_question(cls, question: str) -> bool: + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + has_entity = any(marker in normalized for marker in cls._MODEL_ENTITY_MARKERS) + if not has_entity: + return False + return "模型" in normalized or cls._looks_like_external_freshness_question(question) + + async def _resolve_anchor_prompt_variant( + self, + *, + anchor_resolution: Optional[FollowupAnchorResolution], + conversation_scope_key: Optional[str], + ) -> Optional[str]: + resolver = getattr(self, "_resolve_anchor_qa_record", None) + if not callable(resolver): + return None + try: + anchor_row = await resolver( + anchor_resolution=anchor_resolution, + conversation_scope_key=conversation_scope_key, + ) + except Exception: + return None + if anchor_row is None: + return None + return self._extract_prompt_variant_from_policy_version(getattr(anchor_row, "policy_version", None)) + + def _apply_chat_external_fact_scene_policy( + self, + *, + question: str, + project_id: Optional[int], + scene_semantics: Optional[dict[str, Any]], + anchor_prompt_variant: Optional[str] = None, + ) -> dict[str, Any]: + normalized = dict(scene_semantics or {}) + scene_key = str(normalized.get("scene_key") or "default").strip().lower() or "default" + anchor_variant = str(anchor_prompt_variant or "").strip().lower() + explicit_project_context = self._question_explicitly_requests_project_context( + question=question, + project_id=project_id, + ) + requires_freshness = self._looks_like_external_freshness_question(question) + internal_model_config_question = self._looks_like_internal_model_config_question(question) + model_fact_question = self._looks_like_model_fact_question(question) + anchored_trend_followup = anchor_variant in {"external_trend", "industry_trend"} + + if scene_key == "ai_pmo" and model_fact_question and requires_freshness and not internal_model_config_question: + scene_key = "external_trend" if explicit_project_context else "industry_trend" + normalized["snapshot_focus"] = "none" + normalized["deep_intent"] = "none" + normalized["external_fact_chat"] = True + normalized["requires_freshness"] = True + + if scene_key == "ai_pmo_correction" and anchored_trend_followup: + scene_key = "external_trend" if explicit_project_context else "industry_trend" + normalized["snapshot_focus"] = "none" + normalized["deep_intent"] = "none" + normalized["external_fact_chat"] = True + + if scene_key == "external_trend" and not explicit_project_context: + scene_key = "industry_trend" + + if scene_key in {"external_trend", "industry_trend"}: + normalized["external_fact_chat"] = True + normalized["requires_freshness"] = bool(requires_freshness) + if requires_freshness: + normalized["deep_intent"] = "none" + + normalized["scene_key"] = scene_key + return normalized + def _plan_query_runtime( self, *, @@ -177,6 +370,7 @@ class AdaptiveRAGAnswerMainlineMixin: "root_message_id": str((thread_context or {}).get("root_message_id") or "").strip() or None, }, ) + query_semantics = dict(query_semantics) if isinstance(query_semantics, dict) else {} query_type = str(query_semantics.get("query_type") or QUERY_TYPE_UNKNOWN).strip().lower() or QUERY_TYPE_UNKNOWN conversation_text = "\n".join( [ @@ -193,6 +387,19 @@ class AdaptiveRAGAnswerMainlineMixin: query_type=query_type, conversation_text=conversation_text, ) + anchor_prompt_variant = await self._resolve_anchor_prompt_variant( + anchor_resolution=anchor_resolution, + conversation_scope_key=external_conversation_key, + ) + scene_semantics = self._apply_chat_external_fact_scene_policy( + question=resolved_question, + project_id=project_id, + scene_semantics=scene_semantics, + anchor_prompt_variant=anchor_prompt_variant, + ) + if bool(scene_semantics.get("requires_freshness")): + query_semantics["external_freshness_only"] = True + query_semantics["retrieval_domains"] = ["url"] requested_action_mode = self._normalize_action_mode(action_mode) effective_action_mode = await self._resolve_action_mode_async( requested_mode=requested_action_mode, @@ -210,7 +417,7 @@ class AdaptiveRAGAnswerMainlineMixin: "effective_q": effective_q, "rewrite_meta": rewrite_meta, "query_type": query_type, - "query_semantics": query_semantics if isinstance(query_semantics, dict) else {}, + "query_semantics": query_semantics, "retrieval_domains": list(query_semantics.get("retrieval_domains") or []), "message_intent": str(query_semantics.get("message_intent") or query_semantics.get("intent") or "").strip().lower() or None, "scene_semantics": scene_semantics if isinstance(scene_semantics, dict) else {}, diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py index dabd3ebb..2944296b 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -5,6 +5,35 @@ from .shared import * class AdaptiveRAGAnswerRetrievalMixin: + async def _maybe_fetch_live_external_freshness_context( + self, + *, + question: str, + effective_question: str, + query_semantics: Optional[dict[str, Any]], + scene_semantics: Optional[dict[str, Any]], + ) -> tuple[list[RetrievedItem], dict[str, Any]]: + semantics = scene_semantics if isinstance(scene_semantics, dict) else {} + if not self._requires_external_freshness(question=question, scene_semantics=semantics): + return [], {"mode": "skip_non_freshness"} + search_question = str(effective_question or question or "").strip() + if not search_question: + return [], {"mode": "skip_empty_question"} + try: + rows, live_meta = await self._retrieve_live_external_context( + question=search_question, + max_results=3, + max_pages=3, + ) + except Exception as exc: + return [], {"mode": "live_external_context_error", "error": str(exc)} + meta = dict(live_meta or {}) + meta["mode"] = "live_external_context" if rows else "live_external_context_empty" + meta["external_freshness_only"] = bool( + isinstance(query_semantics, dict) and query_semantics.get("external_freshness_only") + ) + return rows, meta + async def _build_retrieval_response( self, *, @@ -76,9 +105,10 @@ class AdaptiveRAGAnswerRetrievalMixin: prompt_variant_id = self._peek_answer_prompt_variant() max_score = retrieved[0].score if retrieved else 0.0 + active_retrieved = list(retrieved) answer_text = await self._generate_answer( effective_q, - retrieved, + active_retrieved, conversation, project_id, system_snapshot=system_snapshot, @@ -97,7 +127,7 @@ class AdaptiveRAGAnswerRetrievalMixin: ) prompt_variant_id = self._peek_answer_prompt_variant() routing_snapshot = self._peek_last_answer_routing_snapshot() - used_sources = self._dedupe_sources(retrieved) + used_sources = self._dedupe_sources(active_retrieved) if not used_sources: used_sources = [ { @@ -216,6 +246,7 @@ class AdaptiveRAGAnswerRetrievalMixin: max_score = deep_max_score answer_text = deep_answer_text used_sources = deep_sources + active_retrieved = list(deep_retrieved) deep_thinking = True active_thinking_mode = THINKING_MODE_DEEP answer_mode = ANSWER_MODE_RETRIEVAL_DEEP @@ -224,7 +255,88 @@ class AdaptiveRAGAnswerRetrievalMixin: evidence_guard_triggered = False clarification_due_to_evidence = False - if not evidence_guard_triggered and self._is_snapshot_only_sources(used_sources) and float(max_score or 0.0) < 0.45: + freshness_guard_triggered = False + if self._requires_external_freshness(question=clean_q, scene_semantics=scene_semantics) and not self._has_external_grounding_sources(used_sources): + live_rows, live_meta = await self._maybe_fetch_live_external_freshness_context( + question=clean_q, + effective_question=effective_q, + query_semantics=query_semantics, + scene_semantics=scene_semantics, + ) + if live_rows: + active_retrieved = self._merge_retrieved_items( + active_retrieved, + live_rows, + limit=max(DEFAULT_TOP_K * 2, 8), + ) + max_score = active_retrieved[0].score if active_retrieved else max_score + answer_text = await self._generate_answer( + effective_q, + active_retrieved, + conversation, + project_id, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + relevance_max=max_score, + thinking_mode=active_thinking_mode, + context_block_limit=DEEP_CONTEXT_BLOCKS if deep_thinking else None, + answer_max_tokens=DEEP_ANSWER_MAX_TOKENS if deep_thinking else None, + asked_via=asked_via, + message_intent=message_intent, + mixed_signal_part=mixed_signal_part, + query_type=query_type, + consultant_mode_preference=consultant_mode_preference, + scene_semantics=scene_semantics, + query_semantics=query_semantics, + ) + used_sources = self._dedupe_sources(active_retrieved) + if "url" not in final_domains: + final_domains.append("url") + tool_trace.append( + { + "step": 95, + "tool": "live_external_context", + "result_count": len(live_rows), + "search_result_count": int(live_meta.get("search_result_count") or 0), + "fetched_pages": int(live_meta.get("fetched_pages") or 0), + "query": str(live_meta.get("query") or "").strip()[:240], + } + ) + prompt_variant_id = self._peek_answer_prompt_variant() + routing_snapshot = self._peek_last_answer_routing_snapshot() + elif str(live_meta.get("mode") or "").strip(): + tool_trace.append( + { + "step": 95, + "tool": "live_external_context", + "mode": str(live_meta.get("mode") or "").strip(), + "query": str(live_meta.get("query") or "").strip()[:240], + } + ) + if self._requires_external_freshness(question=clean_q, scene_semantics=scene_semantics) and not self._has_external_grounding_sources(used_sources): + freshness_guard_triggered = True + clarification_due_to_evidence = True + answer_text = self._build_external_freshness_guard_answer(clean_q) + used_sources = [ + { + "source": "system", + "label": "External Freshness Guard", + "score": 0.93, + "metadata": {"mode": "clarification", "reason": "missing_external_freshness_evidence"}, + } + ] + max_score = min(float(max_score or 0.0), 0.4) + if "system_context" not in final_domains: + final_domains.append("system_context") + tool_trace.append( + { + "step": 96, + "tool": "freshness_guard", + "reason": "missing_external_freshness_evidence", + } + ) + answer_mode = ANSWER_MODE_CLARIFY + if not freshness_guard_triggered and not evidence_guard_triggered and self._is_snapshot_only_sources(used_sources) and float(max_score or 0.0) < 0.45: clarification_due_to_evidence = True answer_text = ( f"当前关于“{clean_q[:80]}”的证据不足,现阶段只命中系统快照。" @@ -296,7 +408,7 @@ class AdaptiveRAGAnswerRetrievalMixin: elif clarification_due_to_evidence: grounding_verdict = GroundingVerdict( status=GROUNDING_STATUS_INSUFFICIENT_EVIDENCE, - missing_fields=["high_relevance_evidence"], + missing_fields=["external_freshness_evidence"] if freshness_guard_triggered else ["high_relevance_evidence"], reason_code="insufficient_evidence", ) elif self._is_snapshot_only_sources(used_sources) or float(max_score or 0.0) < 0.45: diff --git a/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py b/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py index 7582524d..306034e7 100644 --- a/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py +++ b/backend/app/services/adaptive_rag_service/mixin_conversation_utils_a_p1.py @@ -2,9 +2,21 @@ from __future__ import annotations from .shared import * +from urllib.parse import parse_qs, quote_plus, unquote, urlparse class AdaptiveRAGConversationUtilsAMixinP1: + _LIVE_EXTERNAL_AUTHORITY_RULES = ( + (("codex", "gpt", "chatgpt", "openai"), ("openai.com",)), + (("copilot", "github"), ("github.com", "docs.github.com")), + (("claude", "anthropic"), ("anthropic.com",)), + (("gemini", "google", "deepmind"), ("google.com", "blog.google", "ai.google.dev", "deepmind.google")), + (("deepseek",), ("deepseek.com",)), + (("qwen", "tongyi"), ("qwenlm.ai", "aliyun.com")), + (("llama", "meta"), ("meta.com", "ai.meta.com")), + (("grok", "xai"), ("x.ai",)), + ) + async def _load_recent_conversation( self, *, @@ -77,11 +89,37 @@ class AdaptiveRAGConversationUtilsAMixinP1: async def _noop_results(self) -> list[RetrievedItem]: return [] + @staticmethod + def _normalize_live_external_question(question: str) -> str: + raw = re.sub(r"\s+", " ", str(question or "")).strip() + if not raw: + return "" + normalized = raw.replace("“", '"').replace("”", '"').replace("’", "'").replace("‘", "'") + tail = re.split(r"[::]", normalized)[-1].strip() + if tail and re.search( + r"(codex|gpt|chatgpt|claude|gemini|deepseek|qwen|copilot|grok|llama|openai)", + tail, + flags=re.IGNORECASE, + ): + normalized = tail + normalized = re.sub(r"`[^`]+`", " ", normalized) + normalized = re.sub(r"\b[a-z_][a-z0-9_]*(?:->[a-z0-9_./-]+)+\b", " ", normalized, flags=re.IGNORECASE) + normalized = re.sub( + r"\b(?:backend|frontend|app|src|tests|scripts)(?:/[a-z0-9_.-]+){1,}\b", + " ", + normalized, + flags=re.IGNORECASE, + ) + normalized = re.sub(r"\b_[a-z0-9_]+\b", " ", normalized, flags=re.IGNORECASE) + normalized = re.sub(r"[\"']", " ", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip(" ::,-") + return normalized or raw + async def _fetch_url_text(self, url: str) -> str: target = str(url or "").strip() if not target: raise ValueError("url_empty") - async with httpx.AsyncClient(timeout=20.0, trust_env=False, follow_redirects=True) as client: + async with httpx.AsyncClient(timeout=20.0, trust_env=True, follow_redirects=True) as client: resp = await client.get(target, headers={"User-Agent": "PMFlow-RAG/1.0"}) resp.raise_for_status() content_type = str(resp.headers.get("content-type") or "").lower() @@ -143,6 +181,331 @@ class AdaptiveRAGConversationUtilsAMixinP1: out.append((part, meta)) return out + @staticmethod + def _extract_external_query_terms(text: str) -> list[str]: + normalized = str(text or "").strip().lower() + if not normalized: + return [] + raw_terms = re.findall(r"[a-z0-9][a-z0-9\-\._/]{1,31}|[\u4e00-\u9fff]{2,8}", normalized) + stopwords = { + "当前", + "目前", + "现在", + "最新", + "最近", + "什么", + "多少", + "情况", + "一下", + "一下子", + "模型", + "版本", + "用的", + "是什么", + } + terms: list[str] = [] + for term in raw_terms: + clean = str(term or "").strip().lower() + if not clean or clean in stopwords: + continue + if clean not in terms: + terms.append(clean) + return terms[:12] + + @classmethod + def _build_live_external_search_queries(cls, question: str) -> list[str]: + normalized = cls._normalize_live_external_question(question) + if not normalized: + return [] + compact = re.sub(r"[??!!。]+", " ", normalized).strip() + relaxed = compact + for token in ("当前", "目前", "现在", "截至当前", "截至现在", "截止当前", "最新", "最近"): + relaxed = relaxed.replace(token, " ") + relaxed = re.sub(r"\s+", " ", relaxed).strip() + site_queries: list[str] = [] + generic_queries: list[str] = [] + extracted_terms = cls._extract_external_query_terms(normalized) + term_query = " ".join(extracted_terms[:6]).strip() + ascii_terms = [term for term in extracted_terms if re.search(r"[a-z]", term)] + ascii_query = " ".join(ascii_terms[:4]).strip() + freshness_hint = any(token in normalized.lower() for token in ("最新", "当前", "目前", "现在", "latest", "current", "today")) + ascii_fresh_query = f"{ascii_query} latest model".strip() if ascii_query and freshness_hint else ascii_query + preferred_sites = cls._preferred_live_external_sites(normalized) + for site in preferred_sites: + for candidate in (ascii_fresh_query, compact, relaxed, ascii_query, term_query): + text = str(candidate or "").strip() + if not text: + continue + site_query = f"site:{site} {text}".strip() + if site_query not in site_queries: + site_queries.append(site_query) + for item in (ascii_fresh_query, compact, relaxed, ascii_query, term_query): + text = str(item or "").strip() + if text and text not in generic_queries: + generic_queries.append(text) + queries: list[str] = [] + for item in site_queries[:2] + generic_queries + site_queries[2:]: + if item not in queries: + queries.append(item) + return queries[:5] + + @staticmethod + def _decode_external_result_url(raw_url: str) -> str: + target = str(raw_url or "").strip() + if not target: + return "" + parsed = urlparse(target) + if "duckduckgo.com" in str(parsed.netloc or "").lower(): + query = parse_qs(parsed.query) + for key in ("uddg", "u"): + values = query.get(key) or [] + if values: + decoded = unquote(str(values[0] or "").strip()) + if decoded.startswith("http://") or decoded.startswith("https://"): + return decoded + if target.startswith("http://") or target.startswith("https://"): + return target + if target.startswith("//"): + return f"https:{target}" + return "" + + def _extract_live_external_search_results(self, html: str, *, limit: int = 5) -> list[dict[str, str]]: + raw = str(html or "") + if not raw: + return [] + patterns = ( + r']+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)', + r']+href="([^"]+)"[^>]+class="[^"]*result-link[^"]*"[^>]*>(.*?)', + r']+class="[^"]*result-link[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)', + ) + results: list[dict[str, str]] = [] + seen_urls: set[str] = set() + for pattern in patterns: + for href, title_html in re.findall(pattern, raw, flags=re.IGNORECASE | re.DOTALL): + target_url = self._decode_external_result_url(href) + if not target_url or target_url in seen_urls: + continue + title = self._html_to_text(title_html) + if not title: + continue + seen_urls.add(target_url) + results.append({"url": target_url, "title": title[:200]}) + if len(results) >= max(1, int(limit)): + return results + return results + + async def _search_live_external_results( + self, + *, + query: str, + max_results: int = 5, + ) -> list[dict[str, str]]: + search_query = str(query or "").strip() + if not search_query: + return [] + search_url = f"https://html.duckduckgo.com/html/?q={quote_plus(search_query)}" + async with httpx.AsyncClient(timeout=10.0, trust_env=True, follow_redirects=True) as client: + response = await client.get( + search_url, + headers={"User-Agent": "PMFlow-RAG/1.0 (+external-freshness)"}, + ) + response.raise_for_status() + return self._extract_live_external_search_results(response.text, limit=max_results) + + @classmethod + def _live_external_authority_bonus( + cls, + *, + question: str, + title: str, + url: str, + ) -> float: + normalized_question = str(question or "").lower() + normalized_title = str(title or "").lower() + hostname = str(urlparse(str(url or "").strip()).netloc or "").lower() + if not hostname: + return 0.0 + for trigger_tokens, authority_domains in cls._LIVE_EXTERNAL_AUTHORITY_RULES: + if not any(token in normalized_question for token in trigger_tokens): + continue + if any(domain in hostname for domain in authority_domains): + return 0.2 + authority_keywords = tuple(domain.split(".")[0] for domain in authority_domains if domain) + if any(keyword and keyword in normalized_title for keyword in authority_keywords): + return 0.08 + return 0.0 + + @classmethod + def _score_live_external_search_result( + cls, + *, + question: str, + title: str, + url: str, + ) -> float: + query_terms = cls._extract_external_query_terms(question) + haystack = f"{title}\n{url}".lower() + overlap = sum(1 for term in query_terms if term in haystack) + title_overlap = sum(1 for term in query_terms if term in str(title or "").lower()) + score = 0.18 + min(0.18, overlap * 0.05) + min(0.08, title_overlap * 0.04) + normalized_question = str(question or "").lower() + if any(token in normalized_question for token in ("最新", "latest", "current", "目前", "当前")): + if any(token in haystack for token in ("latest", "changelog", "release", "models", "model")): + score += 0.08 + if any(token in normalized_question for token in ("模型", "model", "codex", "gpt", "openai")): + if re.search(r"\bgpt[- ]?\d(?:\.\d)?(?:[- ]?codex)?\b", haystack, flags=re.IGNORECASE): + score += 0.08 + if any(token in normalized_question for token in ("最新", "latest", "模型", "model")): + if "/codex/models" in haystack or "models - codex" in haystack: + score += 0.14 + elif "/codex/changelog" in haystack or "changelog - codex" in haystack: + score += 0.12 + score += cls._live_external_authority_bonus( + question=question, + title=title, + url=url, + ) + return round(max(0.0, min(0.9, score)), 4) + + @classmethod + def _preferred_live_external_sites(cls, question: str) -> list[str]: + normalized_question = str(question or "").lower() + sites: list[str] = [] + for trigger_tokens, authority_domains in cls._LIVE_EXTERNAL_AUTHORITY_RULES: + if not any(token in normalized_question for token in trigger_tokens): + continue + for domain in authority_domains: + site = str(domain or "").strip().lower() + if site and site not in sites: + sites.append(site) + return sites[:3] + + @classmethod + def _score_live_external_chunk( + cls, + *, + question: str, + title: str, + content: str, + url: str, + ) -> float: + query_terms = cls._extract_external_query_terms(question) + haystack = f"{title}\n{content}\n{url}".lower() + normalized_content = str(content or "").lower() + if not query_terms: + return 0.58 + overlap = sum(1 for term in query_terms if term in haystack) + title_overlap = sum(1 for term in query_terms if term in str(title or "").lower()) + score = 0.42 + min(0.38, overlap * 0.08) + min(0.12, title_overlap * 0.04) + normalized_question = str(question or "").lower() + if any(token in normalized_question for token in ("最新", "latest", "current", "目前", "当前")): + if any(token in normalized_content for token in ("latest:", "latest ", "最新", "released", "release", "changelog", "更新时间")): + score += 0.12 + else: + score -= 0.06 + if any(token in normalized_question for token in ("模型", "model", "codex", "gpt", "openai")): + if any(token in normalized_content for token in ("models", "model", "版本", "模型")): + score += 0.08 + elif "codex/models" not in haystack: + score -= 0.04 + if re.search(r"\bgpt[- ]?\d(?:\.\d)?(?:[- ]?codex)?\b", normalized_content, flags=re.IGNORECASE): + score += 0.1 + if any(token in normalized_question for token in ("最新", "latest", "模型", "model")): + if "/codex/models" in haystack or "models - codex" in haystack: + score += 0.14 + elif "/codex/changelog" in haystack or "changelog - codex" in haystack: + score += 0.12 + score += cls._live_external_authority_bonus( + question=question, + title=title, + url=url, + ) + return round(max(0.0, min(0.95, score)), 4) + + async def _retrieve_live_external_context( + self, + *, + question: str, + max_results: int = 3, + max_pages: int = 3, + ) -> tuple[list[RetrievedItem], dict[str, Any]]: + queries = self._build_live_external_search_queries(question) + search_results: list[dict[str, str]] = [] + selected_query = "" + for query in queries: + try: + search_results = await self._search_live_external_results( + query=query, + max_results=max(3, int(max_results) * 2), + ) + except Exception: + search_results = [] + if search_results: + selected_query = query + break + + ranked_results = sorted( + search_results, + key=lambda row: self._score_live_external_search_result( + question=question, + title=str(row.get("title") or ""), + url=str(row.get("url") or ""), + ), + reverse=True, + ) + items: list[RetrievedItem] = [] + fetch_attempts = 0 + candidate_limit = min(len(ranked_results), max(1, int(max_pages)) + 2) + for row in ranked_results[:candidate_limit]: + url = str(row.get("url") or "").strip() + title = str(row.get("title") or "").strip() + if not url: + continue + fetch_attempts += 1 + try: + text = await self._fetch_url_text(url) + except Exception: + continue + chunks = self._chunk_text(text, chunk_size=900, overlap=120) + if not chunks: + continue + best_chunk = "" + best_score = 0.0 + for chunk in chunks[:6]: + score = self._score_live_external_chunk( + question=question, + title=title, + content=chunk, + url=url, + ) + if score > best_score: + best_score = score + best_chunk = chunk + if not best_chunk: + continue + items.append( + RetrievedItem( + source="url", + content=best_chunk[:1800], + score=best_score, + source_label=f"URL[{title or urlparse(url).netloc or 'external'}]", + metadata={ + "title": title[:255], + "url": url, + "file_url": url, + "target_url": url, + "retrieved_via": "live_external_search", + "source_priority": "live_external_search", + }, + ) + ) + items.sort(key=lambda item: item.score, reverse=True) + return items[: max(1, int(max_results))], { + "query": selected_query or (queries[0] if queries else ""), + "search_result_count": len(search_results), + "fetched_pages": fetch_attempts, + } + def _split_text_by_page_markers(self, text: str) -> list[tuple[Optional[int], str]]: clean = str(text or "") matches = list(PAGE_MARKER_RE.finditer(clean)) @@ -234,4 +597,3 @@ class AdaptiveRAGConversationUtilsAMixinP1: return 0.0 sim = dot / (norm_a * norm_b) return (sim + 1) / 2 - diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py index bf75b645..77f0b2fd 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py @@ -29,6 +29,84 @@ class AdaptiveRAGDeepGuardAnswerMixin: "engine_process_query", "runtime_state_query", } + _PROJECT_CONTEXT_POSITIVE_MARKERS = ( + "这个项目", + "当前项目", + "本项目", + "我们项目", + "项目所在行业", + "对项目的影响", + "对这个项目的影响", + "对当前项目的影响", + "对我们项目的影响", + "结合项目", + "结合当前项目", + "落到项目", + ) + _PROJECT_CONTEXT_NEGATIVE_MARKERS = ( + "不要提项目", + "别提项目", + "不要结合项目", + "不用结合项目", + "不需要结合项目", + "先别结合项目", + "不要绑定项目", + "别绑定项目", + ) + _EXTERNAL_FACT_FRESHNESS_MARKERS = ( + "最新", + "目前", + "当前", + "截至现在", + "截至当前", + "截止当前", + "今天", + "现在", + "最近", + "出到什么", + "发布了吗", + "发布到", + "版本", + "最高模型", + ) + _MODEL_ENTITY_MARKERS = ( + "codex", + "gpt", + "chatgpt", + "claude", + "deepseek", + "qwen", + "gemini", + "copilot", + "grok", + "kimi", + "llama", + "openai", + "anthropic", + ) + _INTERNAL_MODEL_CONFIG_MARKERS = ( + "ai-config", + "/ai-config", + "模型配置", + "配置项", + "配置值", + "路由规则", + "初始化参数", + "llm服务", + "llm 服务", + "运行时", + "当前系统", + "我们系统", + "pmflow", + "本系统", + "接入的模型", + "接的是", + "用的是哪个模型配置", + ) + _EXTERNAL_MODEL_TOKEN_RE = re.compile( + r"\b(?:GPT|gpt)[- ]?\d(?:\.\d)?(?:[- ]?(?:codex|turbo|mini|nano))?\b", + flags=re.IGNORECASE, + ) @staticmethod def _normalize_consultant_mode_preference(value: Optional[str]) -> Optional[str]: @@ -37,6 +115,121 @@ class AdaptiveRAGDeepGuardAnswerMixin: return text return None + @classmethod + def _question_explicitly_requests_project_context( + cls, + *, + question: str, + project_id: Optional[int], + ) -> bool: + if project_id is None: + return False + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + if any(marker in normalized for marker in cls._PROJECT_CONTEXT_NEGATIVE_MARKERS): + return False + return any(marker in normalized for marker in cls._PROJECT_CONTEXT_POSITIVE_MARKERS) + + @classmethod + def _looks_like_external_freshness_question(cls, question: str) -> bool: + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + return any(marker in normalized for marker in cls._EXTERNAL_FACT_FRESHNESS_MARKERS) + + @classmethod + def _looks_like_internal_model_config_question(cls, question: str) -> bool: + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + return any(marker in normalized for marker in cls._INTERNAL_MODEL_CONFIG_MARKERS) + + @classmethod + def _looks_like_model_fact_question(cls, question: str) -> bool: + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + has_entity = any(marker in normalized for marker in cls._MODEL_ENTITY_MARKERS) + if not has_entity: + return False + return "模型" in normalized or cls._looks_like_external_freshness_question(question) + + @classmethod + def _normalize_external_model_evidence_text(cls, text: str) -> str: + normalized = str(text or "") + normalized = re.sub(r"[\u2010-\u2015\u2212]", "-", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + + @classmethod + def _extract_explicit_latest_model_token(cls, text: str) -> Optional[str]: + normalized = cls._normalize_external_model_evidence_text(text) + if not normalized: + return None + latest_match = re.search( + r"(?:latest|最新)\s*[::]\s*([^\n]{0,80})", + normalized, + flags=re.IGNORECASE, + ) + search_windows = [latest_match.group(1)] if latest_match else [] + search_windows.append(normalized) + for window in search_windows: + match = cls._EXTERNAL_MODEL_TOKEN_RE.search(str(window or "")) + if not match: + continue + token = re.sub(r"\s+", "-", str(match.group(0) or "").strip()) + token = token.replace("Gpt", "GPT").replace("gpt", "GPT").replace("Codex", "Codex") + return token + return None + + @classmethod + def _build_external_model_fact_direct_answer( + cls, + *, + question: str, + retrieved: list[RetrievedItem], + scene_semantics: Optional[dict[str, Any]] = None, + ) -> Optional[str]: + if not cls._looks_like_model_fact_question(question) or not cls._looks_like_external_freshness_question(question): + return None + normalized_scene = scene_semantics if isinstance(scene_semantics, dict) else {} + scene_key = str(normalized_scene.get("scene_key") or "").strip().lower() + if scene_key and scene_key not in {"industry_trend", "external_trend"}: + return None + for item in retrieved[:3]: + if str(getattr(item, "source", "") or "").strip().lower() != "url": + continue + metadata = item.metadata if isinstance(item.metadata, dict) else {} + url = str(metadata.get("url") or metadata.get("target_url") or "").strip() + source_label = str(getattr(item, "source_label", "") or "").strip() + normalized_url = url.lower() + normalized_source_label = source_label.lower() + is_reference_page = ( + "/codex/models" in normalized_url + or "/codex/changelog" in normalized_url + or "models - codex" in normalized_source_label + or "changelog - codex" in normalized_source_label + ) + if not is_reference_page: + continue + haystack = cls._normalize_external_model_evidence_text( + f"{source_label}\n{url}\n{str(getattr(item, 'content', '') or '')}" + ) + model_token = cls._extract_explicit_latest_model_token(haystack) + if not model_token: + continue + if "openai.com" not in url.lower() and "openai" not in normalized_source_label: + continue + source_name = "OpenAI Codex Models 页面" + if "changelog" in url.lower(): + source_name = "OpenAI Codex Changelog 页面" + return ( + f"根据{source_name}当前检索到的公开内容,最新模型是 {model_token}。" + "这条结论来自外部页面里明确展示的 Latest 字段,而不是项目内静态资料。" + ) + return None + @classmethod def _apply_consultant_mode_preference( cls, @@ -462,6 +655,19 @@ class AdaptiveRAGDeepGuardAnswerMixin: ) normalized_scene_key = str(scene_semantics.get("scene_key") or "default").strip().lower() normalized_message_intent = str(message_intent or "").strip().lower() + explicit_project_context = self._question_explicitly_requests_project_context( + question=question, + project_id=project_id, + ) + internal_model_config_question = self._looks_like_internal_model_config_question(question) + model_fact_question = self._looks_like_model_fact_question(question) + freshness_question = self._looks_like_external_freshness_question(question) + + if normalized_scene_key == "ai_pmo" and model_fact_question and freshness_question and not internal_model_config_question: + normalized_scene_key = "external_trend" if explicit_project_context else "industry_trend" + + if normalized_scene_key == "external_trend" and not explicit_project_context: + normalized_scene_key = "industry_trend" if normalized_scene_key == "member_workload": filtered_context_blocks = self._filter_member_workload_context_blocks(context_blocks_text) @@ -614,6 +820,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: prompt_key="PROMPT_INDUSTRY_TREND_QA", fallback_template=PROMPT_INDUSTRY_TREND_QA, question=question, + external_results=context_blocks_text or "无", ), ) return self._apply_consultant_mode_preference( @@ -970,6 +1177,13 @@ class AdaptiveRAGDeepGuardAnswerMixin: ) if member_workload_direct_answer: return member_workload_direct_answer[:3200] + external_model_fact_direct_answer = self._build_external_model_fact_direct_answer( + question=question, + retrieved=retrieved, + scene_semantics=resolved_scene_semantics, + ) + if external_model_fact_direct_answer: + return external_model_fact_direct_answer[:3200] scene_key, prompt = await self._select_scene_prompt( question=question, project_id=project_id, diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py index 70e01c87..de56638c 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py @@ -12,6 +12,23 @@ class AdaptiveRAGDeepGuardRulesMixin: "engine_process_query", "runtime_state_query", } + _EXTERNAL_FRESHNESS_SCENES = {"industry_trend", "external_trend"} + _EXTERNAL_FACT_FRESHNESS_MARKERS = ( + "最新", + "目前", + "当前", + "截至现在", + "截至当前", + "截止当前", + "今天", + "现在", + "最近", + "出到什么", + "发布了吗", + "发布到", + "版本", + "最高模型", + ) @classmethod def _normalize_deep_intent(cls, value: Any) -> str: @@ -32,7 +49,7 @@ class AdaptiveRAGDeepGuardRulesMixin: evidence_keys = {"routing_snapshot", "engine_config", "provider", "call_log_id", "ai_call_log_id"} if source in {"runtime", "config", "db"}: return True - if "routing config" in label or ".openclaw/pmflow-routing-rules.json" in source_id: + if "routing config" in label or ".control_plane/pmflow-routing-rules.json" in source_id: return True if any(key in metadata for key in evidence_keys): return True @@ -120,5 +137,49 @@ class AdaptiveRAGDeepGuardRulesMixin: ) return ( f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。" - "请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。" + "请补充目标对象(如 PMFlow 本地路由层路由规则、函数名、入口层级),我会按调用链返回可核验答案。" + ) + + @classmethod + def _requires_external_freshness( + cls, + *, + question: str, + scene_semantics: Optional[dict[str, Any]], + ) -> bool: + semantics = scene_semantics if isinstance(scene_semantics, dict) else {} + if bool(semantics.get("requires_freshness")): + return True + scene_key = str(semantics.get("scene_key") or "").strip().lower() + if scene_key not in cls._EXTERNAL_FRESHNESS_SCENES: + return False + normalized = re.sub(r"\s+", "", str(question or "")).strip().lower() + if not normalized: + return False + return any(marker in normalized for marker in cls._EXTERNAL_FACT_FRESHNESS_MARKERS) + + @classmethod + def _has_external_grounding_sources(cls, sources: list[dict[str, Any]]) -> bool: + if not isinstance(sources, list): + return False + for row in sources: + if not isinstance(row, dict): + continue + source = str(row.get("source") or "").strip().lower() + metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {} + if source == "url": + return True + for key in ("target_url", "file_url", "doc_url", "url"): + value = str(metadata.get(key) or "").strip().lower() + if value.startswith("http://") or value.startswith("https://"): + return True + return False + + @classmethod + def _build_external_freshness_guard_answer(cls, question: str) -> str: + raw = str(question or "").strip() + target = raw[:80] if raw else "该问题" + return ( + f"当前未接入实时外部数据,暂时无法确认“{target}”对应的最新结论。" + "现有内部资料不能作为外部最新事实依据;如果需要,我可以在接入实时来源后再帮你核实。" ) diff --git a/backend/app/services/adaptive_rag_service/mixin_multistep_core.py b/backend/app/services/adaptive_rag_service/mixin_multistep_core.py index 1f618c42..ca2b863f 100644 --- a/backend/app/services/adaptive_rag_service/mixin_multistep_core.py +++ b/backend/app/services/adaptive_rag_service/mixin_multistep_core.py @@ -95,7 +95,11 @@ class AdaptiveRAGMultiStepCoreMixin: *, question: str, project_id: Optional[int], + query_semantics: Optional[dict[str, Any]] = None, ) -> tuple[str, dict[str, Any]]: + semantics = query_semantics if isinstance(query_semantics, dict) else {} + if bool(semantics.get("external_freshness_only")): + return str(question or "").strip()[:1200], {"mode": "freshness_guard_skip_internal_hint"} resolved = await self.entity_resolution(question=question, project_id=project_id, limit=5) manifest = await self.get_project_manifest(project_id=project_id) hints: list[str] = [] diff --git a/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py b/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py index 6b80ab37..30da4988 100644 --- a/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py +++ b/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py @@ -17,23 +17,30 @@ class AdaptiveRAGMultiStepFlowMixin: max_steps_override: Optional[int] = None, vector_top_k: Optional[int] = None, ) -> tuple[list[RetrievedItem], dict[str, Any]]: - resolved = await self.entity_resolution(question=question, project_id=project_id, limit=6) - manifest = await self.get_project_manifest(project_id=project_id) - system_snapshot = await self.context_cache.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) + semantics = query_semantics if isinstance(query_semantics, dict) else {} + external_freshness_only = bool(semantics.get("external_freshness_only")) + resolved: dict[str, Any] = {"matched_entities": [], "canonical_terms": []} + manifest: dict[str, Any] = {} + system_snapshot: dict[str, Any] = {} symbol_hits: dict[str, Any] = {"count": 0, "items": []} - try: - symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) - symbol_hits = await symbol_service.search_symbols(query=question, limit=6) - except Exception: - symbol_hits = {"count": 0, "items": []} - feature_hits = self._match_feature_catalog_entries( - question=question, - system_snapshot=system_snapshot, - limit=6, - ) + feature_hits: list[dict[str, Any]] = [] + if not external_freshness_only: + resolved = await self.entity_resolution(question=question, project_id=project_id, limit=6) + manifest = await self.get_project_manifest(project_id=project_id) + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + try: + symbol_service = SymbolIndexService(self.db, tenant_id=self.tenant_id) + symbol_hits = await symbol_service.search_symbols(query=question, limit=6) + except Exception: + symbol_hits = {"count": 0, "items": []} + feature_hits = self._match_feature_catalog_entries( + question=question, + system_snapshot=system_snapshot, + limit=6, + ) max_steps = max(1, min(int(max_steps_override or AGENT_MAX_RETRIEVAL_STEPS), 8)) tool_trace: list[dict[str, Any]] = [ { @@ -72,6 +79,14 @@ class AdaptiveRAGMultiStepFlowMixin: "feature_names": [str(x.get("name") or "") for x in feature_hits[:4]], }, ] + if external_freshness_only: + tool_trace.append( + { + "step": 0, + "tool": "freshness_guard", + "mode": "external_freshness_only", + } + ) if session_state is not None and isinstance(session_state.last_entity_ids, list): anchor_ids = [int(x) for x in session_state.last_entity_ids if str(x).isdigit()][:6] else: @@ -108,11 +123,13 @@ class AdaptiveRAGMultiStepFlowMixin: resolved_entities=resolved_payload, vector_top_k=vector_top_k, ) - feature_rows = self._retrieve_feature_catalog_context( - question=current_question, - system_snapshot=system_snapshot, - max_items=4, - ) + feature_rows = [] + if not external_freshness_only: + feature_rows = self._retrieve_feature_catalog_context( + question=current_question, + system_snapshot=system_snapshot, + max_items=4, + ) if feature_rows: step_rows = self._merge_retrieved_items(feature_rows, step_rows, limit=merge_limit) merged_rows = self._merge_retrieved_items(merged_rows, step_rows, limit=merge_limit) @@ -155,6 +172,7 @@ class AdaptiveRAGMultiStepFlowMixin: next_question, rewrite_meta = await self._build_second_pass_query( question=current_question, project_id=project_id, + query_semantics=query_semantics, ) row_hint = self._summarize_retrieval_hints(step_rows) if row_hint and row_hint not in next_question: @@ -172,9 +190,12 @@ class AdaptiveRAGMultiStepFlowMixin: break next_domains: list[str] = [] - for item in [*current_domains, "graph", "db", "doc", "pmflow", "feishu"]: - if item not in next_domains: - next_domains.append(item) + if external_freshness_only: + next_domains = list(current_domains) + else: + for item in [*current_domains, "graph", "db", "doc", "pmflow", "feishu"]: + if item not in next_domains: + next_domains.append(item) tool_trace.append( { "step": step + 1, diff --git a/backend/app/services/ai_orchestration_run/ingress_worker.py b/backend/app/services/ai_orchestration_run/ingress_worker.py index 9e9ab352..553039de 100644 --- a/backend/app/services/ai_orchestration_run/ingress_worker.py +++ b/backend/app/services/ai_orchestration_run/ingress_worker.py @@ -253,7 +253,7 @@ async def _mark_run_failed_greenlet_spawn(*, run_id: int) -> None: return now_at = now_cn_naive() row.status = "failed" - row.openclaw_status = "failed" + row.control_plane_status = "failed" row.next_retry_at = None row.last_error = "greenlet_spawn_error" row.last_error_at = now_at diff --git a/backend/app/services/ai_orchestration_run/service.py b/backend/app/services/ai_orchestration_run/service.py index a4ccb9a8..78f45d6f 100644 --- a/backend/app/services/ai_orchestration_run/service.py +++ b/backend/app/services/ai_orchestration_run/service.py @@ -71,7 +71,7 @@ class AIOrchestrationRunService: response_mode=response_mode, status=status, planner_status=planner_status, - openclaw_status=execution_status, + control_plane_status=execution_status, worker_total=max(0, int(worker_total)), worker_completed=max(0, int(worker_completed)), worker_failed=max(0, int(worker_failed)), @@ -150,7 +150,7 @@ class AIOrchestrationRunService: response_mode="fast", status="queued", planner_status=None, - openclaw_status=None, + control_plane_status=None, worker_total=1, worker_completed=0, worker_failed=0, @@ -269,7 +269,7 @@ class AIOrchestrationRunService: int(row.retry_count or 0) + 1, ) row.status = "running" - row.openclaw_status = "running" + row.control_plane_status = "running" row.started_at = row.started_at or now_at row.next_retry_at = None row.last_error = None @@ -303,7 +303,7 @@ class AIOrchestrationRunService: return None now_at = _utc_now_naive() row.status = "succeeded" - row.openclaw_status = "succeeded" + row.control_plane_status = "succeeded" row.worker_total = max(1, int(row.worker_total or 1)) row.worker_completed = max(1, int(row.worker_completed or 0)) row.worker_failed = max(0, int(row.worker_failed or 0)) @@ -346,13 +346,13 @@ class AIOrchestrationRunService: delay = INGRESS_RETRY_SCHEDULE_SECONDS[min(retry_count, len(INGRESS_RETRY_SCHEDULE_SECONDS) - 1)] row.retry_count = retry_count + 1 row.status = "retrying" - row.openclaw_status = "retrying" + row.control_plane_status = "retrying" row.next_retry_at = now_at + timedelta(seconds=int(delay)) row.completed_at = None row.worker_failed = max(0, int(row.worker_failed or 0)) else: row.status = "failed" - row.openclaw_status = "failed" + row.control_plane_status = "failed" row.next_retry_at = None row.completed_at = now_at row.worker_failed = max(1, int(row.worker_failed or 0) + 1) @@ -415,7 +415,7 @@ class AIOrchestrationRunService: if planner_status is not None: row.planner_status = str(planner_status or "").strip()[:20] or None if execution_status is not None: - row.openclaw_status = str(execution_status or "").strip()[:20] or None + row.control_plane_status = str(execution_status or "").strip()[:20] or None if worker_total is not None: row.worker_total = max(0, int(worker_total)) if worker_completed is not None: @@ -479,7 +479,7 @@ class AIOrchestrationRunService: "response_mode": str(row.response_mode or ""), "run_status": str(row.status or ""), "planner_status": str(row.planner_status or ""), - "execution_status": str(row.openclaw_status or ""), + "execution_status": str(row.control_plane_status or ""), "worker_total": worker_total, "worker_completed": worker_completed, "worker_failed": worker_failed, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_domain_workflow.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_domain_workflow.py index 317aeacc..fb78fced 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_domain_workflow.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_domain_workflow.py @@ -44,7 +44,7 @@ class SignalSourceDomainWorkflow: payload = raw_result if isinstance(raw_result, dict) else {} signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {} worker_executions = payload.get("worker_executions") if isinstance(payload.get("worker_executions"), list) else [] - execution_payload = payload.get("execution") if isinstance(payload.get("execution"), dict) else payload.get("openclaw") + execution_payload = payload.get("execution") if isinstance(payload.get("execution"), dict) else payload.get("control_plane") typed_actions = cls._extract_typed_actions(execution_payload) material_actions = [ row @@ -58,7 +58,7 @@ class SignalSourceDomainWorkflow: registry_status = str(signal_source.get("status") or "").strip().lower() onboarding_status = str(signal_source.get("onboarding_status") or "").strip().lower() execution_status = str( - ((payload.get("execution") or payload.get("openclaw") or {}) if isinstance(payload, dict) else {}).get("status") or "" + ((payload.get("execution") or payload.get("control_plane") or {}) if isinstance(payload, dict) else {}).get("status") or "" ).strip().lower() if raw_status == "failed" or execution_status == "failed": @@ -90,10 +90,10 @@ class SignalSourceDomainWorkflow: } @staticmethod - def _extract_typed_actions(openclaw_payload: Any) -> list[dict[str, Any]]: - if not isinstance(openclaw_payload, dict): + def _extract_typed_actions(control_plane_payload: Any) -> list[dict[str, Any]]: + if not isinstance(control_plane_payload, dict): return [] - result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + result = control_plane_payload.get("result") if isinstance(control_plane_payload.get("result"), dict) else control_plane_payload execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} rows = execution.get("results") if isinstance(execution.get("results"), list) else [] actions: list[dict[str, Any]] = [] diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py index ecbd3dfc..7dbc4cd3 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py @@ -152,6 +152,48 @@ class KernelIntentResolveMixin: metadata=metadata, ) + def _build_degraded_query_intent_from_semantic_planner_failure( + self, + *, + message_text: str, + world_model: dict[str, Any], + source_system: str, + fallback_project_id: Optional[int], + planner_error: Exception, + reply_anchor_present: bool, + ) -> CanonicalIntent: + project_id = ( + _safe_int(world_model.get("resolved_project_id")) + or _safe_int(fallback_project_id) + ) + metadata = { + "context_source": "semantic_planner_degraded", + "llm_function": SemanticDecisionService.FUNCTION_NAME, + "route_tier": "semantic_context", + "route_reasons": ["semantic_planner_failed_degraded"], + "workflow_route": ROUTE_QUERY_ANSWER, + "workflow_family": ROUTE_FAMILY_QUERY, + "workflow_action": ROUTE_ACTION_ANSWER, + "requires_confirmation": False, + "source_system": str(source_system or "").strip().lower() or None, + "source_message_context": "reply_anchor" if reply_anchor_present else "cold_start", + # Planner outage should not hard-fail the thread. Degrade to the + # controlled requirement consultation chain so downstream logic can + # keep the user inside the requirement workflow instead of emitting + # a generic error template. + "message_intent": "consult_requirement", + "consultant_mode_preference": "clarify", + "semantic_planner_degraded": True, + "semantic_planner_error": str(planner_error)[:240], + } + return self._build_canonical_intent( + intent_name=INTENT_QUERY, + message_text=message_text, + project_id=project_id, + confidence=0.51, + metadata=metadata, + ) + async def _resolve_intent( self, *, @@ -166,42 +208,61 @@ class KernelIntentResolveMixin: isinstance(thread_context, dict) and str(thread_context.get("reply_to_message_id") or "").strip() ) + source_system = self._infer_workflow_source_system( + session=session, + thread_context=thread_context, + ) inherit_session_context = reply_anchor_present or self._should_inherit_recent_feishu_session_context( message_text=message_text, session=session, ) - workflow_bundle = await WorkflowSemanticPlanner.aplan_message( - message_text=message_text, - source_system=self._infer_workflow_source_system( - session=session, - thread_context=thread_context, + fallback_project_id = _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None + planner_metadata = { + "project_id": _safe_int(world_model.get("resolved_project_id")), + "world_model_project_id": _safe_int(world_model.get("resolved_project_id")), + "session_last_intent": ( + str(getattr(session, "last_intent", "") or "").strip() if inherit_session_context else "" ), - metadata={ - "project_id": _safe_int(world_model.get("resolved_project_id")), - "world_model_project_id": _safe_int(world_model.get("resolved_project_id")), - "session_last_intent": ( - str(getattr(session, "last_intent", "") or "").strip() if inherit_session_context else "" + "session_project_id": fallback_project_id, + "reply_to_message_id": ( + str(thread_context.get("reply_to_message_id") or "").strip() + if isinstance(thread_context, dict) + else "" + ), + "thread_context": dict(thread_context) if isinstance(thread_context, dict) else {}, + "source_message_context": "reply_anchor" if reply_anchor_present else "cold_start", + } + try: + workflow_bundle = await WorkflowSemanticPlanner.aplan_message( + message_text=message_text, + source_system=source_system, + metadata=planner_metadata, + ) + except Exception as exc: + logger.warning( + "semantic_planner_failed_degraded_to_query_scene", + tenant_id=getattr(self, "tenant_id", None), + source_system=source_system, + reply_anchor_present=reply_anchor_present, + error=str(exc), + ) + return ( + self._build_degraded_query_intent_from_semantic_planner_failure( + message_text=message_text, + world_model=world_model, + source_system=source_system, + fallback_project_id=fallback_project_id, + planner_error=exc, + reply_anchor_present=reply_anchor_present, ), - "session_project_id": ( - _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None - ), - "reply_to_message_id": ( - str(thread_context.get("reply_to_message_id") or "").strip() - if isinstance(thread_context, dict) - else "" - ), - "thread_context": dict(thread_context) if isinstance(thread_context, dict) else {}, - "source_message_context": "reply_anchor" if reply_anchor_present else "cold_start", - }, - ) + "semantic_planner_degraded", + ) return ( self._build_intent_from_workflow_bundle( workflow_bundle=workflow_bundle, message_text=message_text, world_model=world_model, - fallback_project_id=( - _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None - ), + fallback_project_id=fallback_project_id, ), "workflow_bundle", ) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index ae7f703c..f9699541 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -32,6 +32,332 @@ class KernelIngressFlowEntryMixin: "requirement_gathering", "requirement_convergence", }) + _EXTERNAL_FRESHNESS_FOLLOWUP_TOKENS = frozenset({ + "继续核实", + "继续查", + "继续验证", + "继续确认", + "接入外部来源", + "外部来源继续核实", + "联网查", + "联网核实", + "外网查", + "实时来源", + "查最新", + "再核实", + }) + _INTERNAL_EXTERNAL_SOURCE_TARGET_MARKERS = frozenset({ + "我们系统", + "系统里", + "系统内", + "pmflow", + "/ai-config", + "ai-config", + "aiconfig", + "配置页", + "模型供应商", + "给项目", + "项目id", + "项目#", + "project", + "模块", + "功能", + "接口", + "通道", + "租户", + "环境", + "服务", + }) + _INTERNAL_EXTERNAL_SOURCE_ACTION_MARKERS = frozenset({ + "出需求", + "需求草稿", + "起草", + "立项", + "工单", + "排期", + "任务", + "入库", + "接一个", + "新增", + "加一个", + "配置", + "接入通道", + "集成", + "实现", + "改造", + }) + _EXTERNAL_FRESHNESS_GUARD_MARKERS = frozenset({ + "externalfreshnessguard", + "未接入实时外部数据", + "实时外部来源", + "无法确认最新结论", + }) + + @staticmethod + def _normalize_compact_text(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).strip().lower() + + @classmethod + def _looks_like_external_freshness_followup(cls, message_text: str) -> bool: + normalized = cls._normalize_compact_text(message_text) + if not normalized: + return False + return any(token in normalized for token in cls._EXTERNAL_FRESHNESS_FOLLOWUP_TOKENS) + + @classmethod + def _looks_like_explicit_internal_external_source_request(cls, message_text: str) -> bool: + normalized = cls._normalize_compact_text(message_text) + if not normalized: + return False + return ( + any(token in normalized for token in cls._INTERNAL_EXTERNAL_SOURCE_TARGET_MARKERS) + and any(token in normalized for token in cls._INTERNAL_EXTERNAL_SOURCE_ACTION_MARKERS) + ) + + @classmethod + def _payload_mentions_external_freshness_guard(cls, value: Any) -> bool: + if isinstance(value, str): + normalized = cls._normalize_compact_text(value) + return any(marker in normalized for marker in cls._EXTERNAL_FRESHNESS_GUARD_MARKERS) + if isinstance(value, dict): + return any(cls._payload_mentions_external_freshness_guard(item) for item in value.values()) + if isinstance(value, list): + return any(cls._payload_mentions_external_freshness_guard(item) for item in value) + return False + + @classmethod + def _is_external_freshness_guard_record(cls, qa_record: Any) -> bool: + if qa_record is None: + return False + grounding_status = str(getattr(qa_record, "grounding_status", "") or "").strip().lower() + policy_version = str(getattr(qa_record, "policy_version", "") or "").strip().lower() + answer_text = str(getattr(qa_record, "answer", "") or "").strip() + return ( + ( + grounding_status in {"insufficient_evidence", "partially_grounded"} + and cls._payload_mentions_external_freshness_guard(getattr(qa_record, "sources_used", None)) + ) + or ( + "prompt:industry_trend" in policy_version or "prompt:external_trend" in policy_version + ) + and cls._payload_mentions_external_freshness_guard(answer_text) + ) + + async def _load_recent_external_freshness_guard_record( + self, + *, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + thread_context: Optional[dict[str, Any]], + ) -> Optional[Any]: + if not hasattr(self.db, "execute"): + return None + + from app.models.qa_record import QARecord + + normalized_source = str(source_system or "").strip().lower() + criteria = [QARecord.tenant_id == int(self.tenant_id)] + if normalized_source: + criteria.append(QARecord.asked_via == normalized_source) + + anchor_message_id = "" + if isinstance(thread_context, dict): + anchor_message_id = str( + thread_context.get("reply_to_message_id") + or thread_context.get("root_message_id") + or "" + ).strip() + if anchor_message_id: + anchored_row = ( + await self.db.execute( + select(QARecord) + .where( + *criteria, + QARecord.source_message_id == anchor_message_id, + ) + .order_by(QARecord.updated_at.desc(), QARecord.id.desc()) + .limit(1) + ) + ).scalars().first() + if self._is_external_freshness_guard_record(anchored_row): + return anchored_row + + if normalized_source != "feishu": + return None + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id=channel_ref, + source_sender_open_id=source_sender_open_id, + sender=None, + ) + if not conversation_scope_key: + return None + rows = ( + await self.db.execute( + select(QARecord) + .where( + *criteria, + QARecord.conversation_scope_key == conversation_scope_key, + ) + .order_by(QARecord.updated_at.desc(), QARecord.id.desc()) + .limit(3) + ) + ).scalars().all() + for row in rows: + if str(getattr(row, "source_message_id", "") or "").strip() == str(source_message_id or "").strip(): + continue + if self._is_external_freshness_guard_record(row): + return row + return None + + @classmethod + def _build_external_freshness_followup_reply( + cls, + *, + prior_question: Optional[str], + ) -> str: + question = re.sub(r"\s+", " ", str(prior_question or "").strip()) + question_hint = f"“{question[:48]}”" if question else "这条外部最新事实问题" + return ( + f"当前还没有可用的实时外部来源,所以 {question_hint} 我现在不能继续核实。" + "只有你明确说要给系统、项目或配置接入某个外部数据源时,我才会把它当成内部接入需求;" + "否则我会继续按问答处理。" + ) + + @classmethod + def _build_external_freshness_followup_question( + cls, + *, + prior_question: Optional[str], + message_text: str, + ) -> str: + base_question = re.sub(r"\s+", " ", str(prior_question or "").strip()) + followup_text = re.sub(r"\s+", " ", str(message_text or "").strip()) + if not base_question: + return followup_text + normalized_followup = cls._normalize_compact_text(followup_text) + if not normalized_followup or cls._looks_like_external_freshness_followup(followup_text): + return base_question + return f"{base_question}。补充要求:{followup_text}"[:1200] + + async def _maybe_handle_external_freshness_followup( + self, + *, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + message_text: str, + thread_context: Optional[dict[str, Any]], + ) -> Optional[dict[str, Any]]: + if not self._looks_like_external_freshness_followup(message_text): + return None + if self._looks_like_explicit_internal_external_source_request(message_text): + return None + try: + guard_record = await self._load_recent_external_freshness_guard_record( + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + ) + except Exception as exc: + logger.warning( + "external_freshness_followup_guard_lookup_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=source_message_id, + error=str(exc), + ) + return None + if guard_record is None: + return None + + prior_question = str(getattr(guard_record, "question", "") or "").strip() or None + followup_question = self._build_external_freshness_followup_question( + prior_question=prior_question, + message_text=message_text, + ) + rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + try: + conversation_key = ( + build_feishu_conversation_scope_key( + source_chat_id=channel_ref, + source_sender_open_id=source_sender_open_id, + sender=None, + ) + if str(source_system or "").strip().lower() == "feishu" + else source_message_id + ) + rag_result = await rag_service.answer_question( + question=followup_question, + asked_by=None, + asked_via=source_system, + project_id=None, + source_chat_id=channel_ref, + source_message_id=source_message_id, + conversation_key=conversation_key, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + action_mode=ACTION_MODE_READ, + sync_requirement=False, + ) + answer_text = str(rag_result.get("answer") or "").strip() + if answer_text: + return self._build_active_context_direct_reply_result( + answer=answer_text, + reason_code="external_freshness_followup_query", + payload={ + "answer": answer_text, + "grounding_status": str(rag_result.get("grounding_status") or "").strip() or None, + "policy_version": str(rag_result.get("policy_version") or "").strip() or None, + "sources_used": rag_result.get("sources_used"), + "citations": rag_result.get("citations"), + "rag_result": rag_result, + }, + ) + except Exception as exc: + logger.warning( + "external_freshness_followup_query_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=source_message_id, + error=str(exc), + ) + + reply_text = self._build_external_freshness_followup_reply(prior_question=prior_question) + policy_version = str(getattr(guard_record, "policy_version", "") or "").strip() or "retrieval-v2.1|routing:v3|prompt:external_trend" + return self._build_active_context_direct_reply_result( + answer=reply_text, + reason_code="external_freshness_followup_guard", + payload={ + "answer": reply_text, + "grounding_status": "insufficient_evidence", + "policy_version": policy_version, + "sources_used": ["External Freshness Guard"], + "citations": [], + "rag_result": { + "answer": reply_text, + "query_type": "status", + "answer_mode": "direct_guard", + "grounding_status": "insufficient_evidence", + "policy_version": policy_version, + "citations": [], + "sources_used": ["External Freshness Guard"], + "evidence_pack": { + "summary": { + "conclusion": reply_text, + "evidence": ["当前没有可用的实时外部来源"], + "uncertainties": ["最新外部事实尚未得到实时核实"], + }, + "tool_trace": [], + }, + }, + }, + ) + @classmethod def _extract_route_scene_metadata(cls, route_metadata: Optional[dict[str, Any]]) -> dict[str, str]: metadata = route_metadata if isinstance(route_metadata, dict) else {} @@ -647,6 +973,37 @@ class KernelIngressFlowEntryMixin: ingest_event=ingest_event, ingress_options=ingress_options, ) + terminated_requirement_scope = await mounted_runtime.terminate_requirement_plan_scope( + tenant_id=int(self.tenant_id), + source_system=source_system, + channel_ref=channel_ref, + sender=sender, + source_sender_open_id=source_sender_open_id, + source_message_id=synthetic_message_id, + ) + if int(terminated_requirement_scope.get("terminated_count") or 0) > 0: + try: + await self._update_session( + tenant_id=int(self.tenant_id), + sender=sender, + session=session, + source_chat_id=channel_ref, + source_sender_open_id=source_sender_open_id, + source_message_id=synthetic_message_id, + thread_context=thread_context, + current_project_id=None, + clear_current_project_id=True, + last_intent=ROUTE_QUERY_ANSWER, + last_message_text=clean_text, + ) + except Exception as session_exc: + logger.warning( + "mounted_ai_runtime_terminate_requirement_scope_session_update_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=synthetic_message_id, + error=str(session_exc), + ) active_context_followup_result = await self._maybe_handle_active_context_followup( source_system=source_system, source_message_id=synthetic_message_id, @@ -761,6 +1118,17 @@ class KernelIngressFlowEntryMixin: if active_context_followup_result is not None: await _safe_finalize_mounted_runtime(result=active_context_followup_result) return active_context_followup_result + external_freshness_followup_result = await self._maybe_handle_external_freshness_followup( + source_system=source_system, + source_message_id=synthetic_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + message_text=clean_text, + thread_context=thread_context, + ) + if external_freshness_followup_result is not None: + await _safe_finalize_mounted_runtime(result=external_freshness_followup_result) + return external_freshness_followup_result mixed_meta: dict[str, Any] = {} clean_text, mixed_meta = await self._prepare_mixed_sidecar_context( source_system=source_system, @@ -1331,7 +1699,7 @@ class KernelIngressFlowEntryMixin: if DraftConfirmHandler._extract_work_item_id(message_text) is not None: return True thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) - return bool(str(thread_context.get("reply_to_message_id") or "").strip()) + return cls._has_explicit_thread_anchor(thread_context) @staticmethod async def _should_skip_no_anchor_draft_routing_by_bundle( @@ -1379,7 +1747,7 @@ class KernelIngressFlowEntryMixin: return True thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) - if not str(thread_context.get("reply_to_message_id") or "").strip(): + if not cls._has_explicit_thread_anchor(thread_context): return False if str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM: return False @@ -1401,7 +1769,7 @@ class KernelIngressFlowEntryMixin: handler = DraftConfirmHandler(self) thread_context = self._extract_thread_context_from_ingress_options(ingress_options) normalized_text = str(message_text or "").strip() - has_reply_anchor = bool(str(thread_context.get("reply_to_message_id") or "").strip()) + has_explicit_thread_anchor = self._has_explicit_thread_anchor(thread_context) if self._should_route_to_draft_confirmation( session=session, message_text=message_text, @@ -1410,10 +1778,7 @@ class KernelIngressFlowEntryMixin: return True if not normalized_text: return False - if not has_reply_anchor and await self._should_skip_no_anchor_draft_routing_by_bundle( - message_text=normalized_text, - source_system=source_system, - ): + if not has_explicit_thread_anchor: return False has_active_draft_session = await handler.has_active_draft_session( @@ -1423,9 +1788,9 @@ class KernelIngressFlowEntryMixin: source_sender_open_id=source_sender_open_id, thread_context=thread_context, ) - if not bool(has_active_draft_session): - return False - return handler.is_explicit_protocol_followup_text(normalized_text) + if bool(has_active_draft_session): + return True + return False @staticmethod def _build_orphan_requirement_followup_reply() -> str: @@ -1646,6 +2011,54 @@ class KernelIngressFlowEntryMixin: return "current_requirement" return normalized_scope + @staticmethod + def _looks_like_requirement_plan_choice_protocol_followup(message_text: str) -> bool: + try: + from ..ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin + + return ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text) is not None + except Exception: + return False + + async def _build_active_requirement_followup_result( + self, + *, + message_text: str, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + thread_context: Optional[dict[str, Any]], + conversation_key: Optional[str], + ) -> Optional[dict[str, Any]]: + builder = getattr(self, "_build_requirement_planning_direct_result", None) + if not callable(builder): + executor = getattr(self, "executor", None) + builder = getattr(executor, "_build_requirement_planning_direct_result", None) + if not callable(builder): + return None + direct_result = await builder( + message_text=message_text, + 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, + topic_switch=None, + ) + answer = self._extract_direct_answer_text(direct_result) + if not answer: + return None + direct_payload = dict(direct_result) if isinstance(direct_result, dict) else {} + direct_payload["group_reply_allowed_via_bound_context"] = True + return self._build_active_context_direct_reply_result( + answer=answer, + reason_code="requirement_plan_followup", + payload=direct_payload, + ) + async def _maybe_handle_active_context_followup( self, *, @@ -1683,6 +2096,21 @@ class KernelIngressFlowEntryMixin: if bound_context is None: return None topic, draft, active_context_kind = bound_context + if ( + str(active_context_kind or "").strip().lower() == "requirement_plan" + and self._looks_like_requirement_plan_choice_protocol_followup(message_text) + ): + direct_protocol_result = await self._build_active_requirement_followup_result( + message_text=message_text, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + if direct_protocol_result is not None: + return direct_protocol_result try: packet = await SemanticContextAssembler(self.db).assemble( tenant_id=int(self.tenant_id), @@ -1737,28 +2165,17 @@ class KernelIngressFlowEntryMixin: if followup_scope == "current_requirement": if "context.followup.current_requirement" not in allowed_actions: return None - builder = getattr(self, "_build_requirement_planning_direct_result", None) - if callable(builder): - direct_result = await builder( - message_text=message_text, - 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, - topic_switch=None, - ) - answer = self._extract_direct_answer_text(direct_result) - if answer: - direct_payload = dict(direct_result) if isinstance(direct_result, dict) else {} - direct_payload["group_reply_allowed_via_bound_context"] = True - return self._build_active_context_direct_reply_result( - answer=answer, - reason_code="requirement_plan_followup", - payload=direct_payload, - ) + direct_result = await self._build_active_requirement_followup_result( + message_text=message_text, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + if direct_result is not None: + return direct_result if assistant_reply: return self._build_active_context_direct_reply_result( answer=assistant_reply, diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index bc71577f..c91abba2 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -22,6 +22,78 @@ class ExecuteQueryProjectMixin: _REQUIREMENT_PLAN_CHOICE_PROTOCOL_MAX_LEADING_CLAUSE_CHARS = 4 _REQUIREMENT_PLAN_CHOICE_PROTOCOL_BOUNDARY_CHARS = frozenset({".", ",", ",", "。", "、", ":", ":", ")", "]", "】", ";"}) + _REQUIREMENT_PLAN_PROTOCOL_CONTINUATION_TOKENS = frozenset( + { + "继续", + "继续聊", + "继续收", + "继续收集", + "继续往下", + "继续往下收", + "继续下一步", + "继续下一步吧", + "继续下轮", + "继续下一轮", + "继续下一轮吧", + "下一步", + "下一轮", + "下一轮吧", + "下一题", + "下一问", + "往下", + "往下收", + "就这样", + "先这样", + } + ) + _REQUIREMENT_PLAN_PROTOCOL_ACTION_PREFIXES = ( + ("draft", "继续出草稿"), + ("draft", "直接出草稿"), + ("draft", "继续整理成草稿"), + ("draft", "整理成草稿"), + ("draft", "整理一下草稿"), + ("draft", "整理下草稿"), + ("draft", "汇总成草稿"), + ("draft", "汇总一下成草稿"), + ("draft", "出草稿"), + ("draft", "拉草稿"), + ("reroll", "再出一版更好的方案"), + ("reroll", "再出一版更好的"), + ("reroll", "重新出这一轮"), + ("reroll", "重新发这一轮"), + ("reroll", "重新发这轮"), + ("reroll", "重发这一轮"), + ("reroll", "重发这轮"), + ("reroll", "重出这一轮"), + ("reroll", "重出这轮"), + ("reroll", "重出一轮"), + ("reroll", "重来这轮"), + ("reroll", "再出一版"), + ("reroll", "再来一版"), + ("reroll", "换一版"), + ("clarify", "我补充一句"), + ("clarify", "补充一句"), + ("clarify", "我补一句"), + ("clarify", "补一句"), + ("clarify", "我补充一下"), + ("clarify", "补充一下"), + ("clarify", "我补充下"), + ("clarify", "补充下"), + ("clarify", "我补个信息"), + ("clarify", "补个信息"), + ("continue", "继续往下收"), + ("continue", "继续下一轮"), + ("continue", "继续下一步"), + ("continue", "继续往下"), + ("continue", "继续收"), + ("continue", "继续聊"), + ("continue", "继续"), + ("continue", "下一轮"), + ("continue", "下一步"), + ("continue", "往下收"), + ("continue", "往下"), + ) + _REQUIREMENT_PLAN_PROTOCOL_STRIP_CHARS = " \t\r\n,,。;;::!!??、" @classmethod async def _try_direct_project_lookup_result( @@ -233,6 +305,10 @@ class ExecuteQueryProjectMixin: idx += 1 return idx + @staticmethod + def _normalize_requirement_plan_protocol_text(value: Any) -> str: + return re.sub(r"[\s,,。;;::!!??、]+", "", str(value or "")).strip() + @staticmethod def _is_requirement_plan_choice_protocol_prefix_char(value: str) -> bool: if not value or len(value) != 1: @@ -253,7 +329,7 @@ class ExecuteQueryProjectMixin: if idx >= len(text): return None current = str(text[idx]).upper() - if current in {"A", "B", "C"}: + if current in {"A", "B", "C", "D"}: return idx if leading_chars >= cls._REQUIREMENT_PLAN_CHOICE_PROTOCOL_MAX_LEADING_CLAUSE_CHARS: return None @@ -274,15 +350,22 @@ class ExecuteQueryProjectMixin: if idx >= len(text): return None choice = str(text[idx]).upper() - if choice not in {"A", "B", "C"}: + if choice not in {"A", "B", "C", "D"}: return None idx += 1 - idx = cls._skip_requirement_plan_choice_protocol_whitespace(text, idx) + saw_whitespace_gap = False + while idx < len(text) and text[idx].isspace(): + saw_whitespace_gap = True + idx += 1 if idx < len(text) and text[idx] == "吧": idx += 1 - idx = cls._skip_requirement_plan_choice_protocol_whitespace(text, idx) + while idx < len(text) and text[idx].isspace(): + saw_whitespace_gap = True + idx += 1 if idx >= len(text): return choice, idx + if saw_whitespace_gap: + return choice, idx if text[idx].isspace() or text[idx] in cls._REQUIREMENT_PLAN_CHOICE_PROTOCOL_BOUNDARY_CHARS: while idx < len(text) and ( text[idx].isspace() or text[idx] in cls._REQUIREMENT_PLAN_CHOICE_PROTOCOL_BOUNDARY_CHARS @@ -308,18 +391,107 @@ class ExecuteQueryProjectMixin: } @classmethod - def _extract_requirement_plan_choice(cls, message_text: str) -> Optional[str]: - parsed = cls._parse_requirement_plan_choice_protocol(message_text) - if not isinstance(parsed, dict): + def _match_requirement_plan_protocol_action(cls, message_text: str) -> Optional[dict[str, str]]: + raw = str(message_text or "").strip() + if not raw: return None - return str(parsed.get("choice") or "").strip().upper() or None + stripped = raw.lstrip() + for action, prefix in cls._REQUIREMENT_PLAN_PROTOCOL_ACTION_PREFIXES: + if stripped.startswith(prefix): + remainder = stripped[len(prefix) :].lstrip(cls._REQUIREMENT_PLAN_PROTOCOL_STRIP_CHARS) + return { + "action": action, + "matched_prefix": prefix, + "remaining_text": remainder, + } + normalized = cls._normalize_requirement_plan_protocol_text(raw) + if not normalized: + return None + for action, prefix in cls._REQUIREMENT_PLAN_PROTOCOL_ACTION_PREFIXES: + normalized_prefix = cls._normalize_requirement_plan_protocol_text(prefix) + if normalized_prefix and normalized.startswith(normalized_prefix): + return { + "action": action, + "matched_prefix": prefix, + "remaining_text": "", + } + return None + + @classmethod + def _parse_requirement_plan_protocol_reply(cls, message_text: str) -> dict[str, str]: + raw = str(message_text or "").strip() + parsed_choice = cls._parse_requirement_plan_choice_protocol(raw) + choice = "" + text_after_choice = raw + if isinstance(parsed_choice, dict): + choice = str(parsed_choice.get("choice") or "").strip().upper() + text_after_choice = str(parsed_choice.get("remaining_text") or "").strip() + action_payload = cls._match_requirement_plan_protocol_action(text_after_choice if choice else raw) + action = "" + matched_prefix = "" + content_text = text_after_choice if choice else raw + if isinstance(action_payload, dict): + action = str(action_payload.get("action") or "").strip().lower() + matched_prefix = str(action_payload.get("matched_prefix") or "").strip() + content_text = str(action_payload.get("remaining_text") or "").strip() + return { + "choice": choice, + "action": action, + "matched_prefix": matched_prefix, + "text_after_choice": text_after_choice, + "content_text": content_text, + "raw_text": raw, + } + + @classmethod + def _extract_requirement_plan_choice(cls, message_text: str) -> Optional[str]: + choice = str(cls._parse_requirement_plan_protocol_reply(message_text).get("choice") or "").strip().upper() + return choice or None @classmethod def _strip_requirement_plan_choice_prefix(cls, message_text: str) -> str: - parsed = cls._parse_requirement_plan_choice_protocol(message_text) - if not isinstance(parsed, dict): - return str(message_text or "").strip() - return str(parsed.get("remaining_text") or "").strip() + return str(cls._parse_requirement_plan_protocol_reply(message_text).get("text_after_choice") or "").strip() + + @classmethod + def _is_requirement_plan_protocol_continuation_text(cls, message_text: str) -> bool: + compact = cls._normalize_requirement_plan_protocol_text(message_text) + if not compact: + return False + return compact in cls._REQUIREMENT_PLAN_PROTOCOL_CONTINUATION_TOKENS + + @classmethod + def _extract_requirement_plan_seed_material_text(cls, message_text: str) -> str: + protocol = cls._parse_requirement_plan_protocol_reply(message_text) + cleaned = cls._sanitize_requirement_plan_text(str(protocol.get("content_text") or "")) + if len(cleaned) < 4: + return "" + if cls._is_requirement_plan_protocol_continuation_text(cleaned): + return "" + return cleaned + + @classmethod + def _merge_requirement_plan_source_material(cls, *, current_value: Any, message_text: str) -> str: + seed_text = cls._extract_requirement_plan_seed_material_text(message_text) + parts = [segment.strip() for segment in str(current_value or "").split("|") if segment.strip()] + merged: list[str] = [] + for part in parts: + normalized = part + if normalized.startswith("用户:"): + normalized = normalized[len("用户:") :].strip() + extracted = cls._extract_requirement_plan_seed_material_text(normalized) + if extracted: + merged.append(f"用户:{extracted}") + if seed_text: + merged.append(f"用户:{seed_text}") + return " | ".join(dict.fromkeys(item for item in merged if item)).strip()[:1500] + + @classmethod + def _requirement_plan_card_has_grounded_identity(cls, *, card: dict[str, Any]) -> bool: + source_material = cls._sanitize_requirement_plan_source_material(card.get("source_material")) + if source_material: + return True + title = str(card.get("title") or "").strip() + return bool(title and title != "需求收敛草稿") @classmethod def _is_low_information_choice_reply_without_option_map( @@ -328,14 +500,27 @@ class ExecuteQueryProjectMixin: message_text: str, card: dict[str, Any], ) -> bool: - choice = cls._extract_requirement_plan_choice(message_text) + protocol = cls._parse_requirement_plan_protocol_reply(message_text) + choice = str(protocol.get("choice") or "").strip().upper() if not choice: return False - cleaned_raw = cls._strip_requirement_plan_choice_prefix(message_text) + if choice == "D": + return False + cleaned_raw = str(protocol.get("content_text") or "").strip() if len(cleaned_raw.strip()) >= 4: return False current_card = dict(card or {}) current_card["plan_profile"] = cls._detect_requirement_plan_profile(message_text=message_text, card=current_card) + last_round_options = current_card.get("last_round_options") if isinstance(current_card.get("last_round_options"), dict) else {} + last_field = str(last_round_options.get("field") or "").strip() + if last_field: + last_round_mapped = cls._requirement_plan_model_choice_value( + card=current_card, + field=last_field, + choice=choice, + ) + if last_round_mapped: + return False missing_fields = cls._requirement_plan_missing_fields(current_card) if not missing_fields: return False @@ -619,10 +804,12 @@ class ExecuteQueryProjectMixin: @classmethod def _topic_has_requirement_plan_marker(cls, topic: MountedTopicWorkspace) -> bool: _ = cls + if str(getattr(topic, "status", "") or "").strip().lower() != "active": + return False metadata = topic.metadata_json if isinstance(getattr(topic, "metadata_json", None), dict) else {} if bool(metadata.get("plan_mode")): return True - if str(getattr(topic, "mode", "") or "").strip().lower() == "plan": + if str(getattr(topic, "mode", "") or "").strip().lower() in {"plan", "draft_ready"}: return True if str(getattr(topic, "plan_status", "") or "").strip().lower() in { "collecting", @@ -916,7 +1103,11 @@ class ExecuteQueryProjectMixin: card["first_scope"] = "、".join(scope_terms) if not str(card.get("delivery_mode") or "").strip() and combined_blob: card["delivery_mode"] = "团队试点" - source_material_parts = [f"用户:{text}" for text in user_turns] + source_material_parts = [] + for text in user_turns: + seed_text = cls._extract_requirement_plan_seed_material_text(text) + if seed_text: + source_material_parts.append(f"用户:{seed_text}") source_material = " | ".join(source_material_parts).strip() if source_material: card["source_material"] = source_material[:1500] @@ -938,8 +1129,16 @@ class ExecuteQueryProjectMixin: if not raw: return "" parts = [segment.strip() for segment in raw.split("|")] - user_parts = [segment for segment in parts if segment.startswith("用户:")] - cleaned_parts = user_parts or parts + cleaned_parts: list[str] = [] + for segment in parts: + candidate = segment + if candidate.startswith("用户:"): + candidate = candidate[len("用户:") :].strip() + if candidate.startswith(("系统:", "助手:", "assistant:", "assistant:")): + continue + seed_text = cls._extract_requirement_plan_seed_material_text(candidate) + if seed_text: + cleaned_parts.append(f"用户:{seed_text}") return " | ".join(dict.fromkeys(part for part in cleaned_parts if part)).strip()[:1500] @classmethod @@ -1222,18 +1421,123 @@ class ExecuteQueryProjectMixin: card=dict(contextual_draft.requirement_card or {}) ) draft_snapshot = self._build_requirement_plan_context_snapshot(card=sanitized_card) - if draft_snapshot: + if draft_snapshot and self._requirement_plan_card_has_grounded_identity(card=sanitized_card): return draft_snapshot, [] contextual_turns = await self._load_contextual_reply_turns(topic_workspace_id=int(topic.id)) + contextual_card: dict[str, Any] = {} if not self._reply_context_contains_requirement_plan_contract( turns=contextual_turns, current_message_text=current_message_text, ): - return {}, contextual_turns + fallback_snapshot = await self._load_unique_grounded_requirement_plan_scope_snapshot( + topic=topic, + current_message_text=current_message_text, + ) + return fallback_snapshot, contextual_turns contextual_card = self._build_contextual_requirement_plan_card(topic=topic, turns=contextual_turns) - return self._build_requirement_plan_context_snapshot(card=contextual_card), contextual_turns + contextual_snapshot = self._build_requirement_plan_context_snapshot(card=contextual_card) + if contextual_snapshot and self._requirement_plan_card_has_grounded_identity(card=contextual_card): + return contextual_snapshot, contextual_turns + + fallback_snapshot = await self._load_unique_grounded_requirement_plan_scope_snapshot( + topic=topic, + current_message_text=current_message_text, + ) + return fallback_snapshot, contextual_turns + + async def _load_unique_grounded_requirement_plan_scope_snapshot( + self, + *, + topic: Optional[MountedTopicWorkspace], + current_message_text: Optional[str] = None, + ) -> dict[str, Any]: + if topic is None: + return {} + normalized_source = str(getattr(topic, "source_system", "") or "").strip().lower() + normalized_scope = str(getattr(topic, "conversation_scope_key", "") or "").strip() + if not normalized_source or not normalized_scope: + return {} + normalized_channel = str(getattr(topic, "channel_ref", "") or "").strip() or None + current_topic_id = int(getattr(topic, "id", 0) or 0) + current_draft = await self._load_requirement_plan_draft_workspace(topic_workspace_id=current_topic_id) + current_card = ( + self._sanitize_requirement_plan_card_runtime_context(card=dict(current_draft.requirement_card or {})) + if current_draft is not None and isinstance(current_draft.requirement_card, dict) + else {} + ) + current_round_field = str( + ((current_card.get("last_round_options") or {}) if isinstance(current_card.get("last_round_options"), dict) else {}).get("field") + or "" + ).strip() + choice_reply = bool(self._extract_requirement_plan_choice(str(current_message_text or ""))) + choice_value = self._extract_requirement_plan_choice(str(current_message_text or "")) + + stmt = select(MountedTopicWorkspace).where( + MountedTopicWorkspace.tenant_id == int(self.tenant_id), + MountedTopicWorkspace.source_system == normalized_source, + MountedTopicWorkspace.conversation_scope_key == normalized_scope, + MountedTopicWorkspace.id != current_topic_id, + ) + if normalized_channel: + stmt = stmt.where(MountedTopicWorkspace.channel_ref == normalized_channel) + topic_rows = ( + await self.db.execute( + stmt.order_by(MountedTopicWorkspace.updated_at.desc(), MountedTopicWorkspace.id.desc()) + ) + ).scalars().all() + if not topic_rows: + return {} + + draft_rows = ( + await self.db.execute( + select(MountedDraftWorkspace).where( + MountedDraftWorkspace.tenant_id == int(self.tenant_id), + MountedDraftWorkspace.topic_workspace_id.in_([int(row.id) for row in topic_rows]), + MountedDraftWorkspace.state.in_(("collecting", "ready", "draft_pending_confirm")), + ) + ) + ).scalars().all() + draft_by_topic_id = {int(row.topic_workspace_id): row for row in draft_rows} + + grounded_snapshots: list[dict[str, Any]] = [] + for row in topic_rows: + sibling_draft = draft_by_topic_id.get(int(row.id)) + if sibling_draft is None or not isinstance(sibling_draft.requirement_card, dict): + continue + sibling_card = self._sanitize_requirement_plan_card_runtime_context( + card=dict(sibling_draft.requirement_card or {}) + ) + if not self._requirement_plan_card_has_grounded_identity(card=sibling_card): + continue + sibling_last_round = ( + sibling_card.get("last_round_options") if isinstance(sibling_card.get("last_round_options"), dict) else {} + ) + sibling_round_field = str(sibling_last_round.get("field") or "").strip() + if choice_reply and current_round_field and sibling_round_field != current_round_field: + continue + if choice_reply and choice_value: + if current_round_field: + mapped_value = self._requirement_plan_model_choice_value( + card=sibling_card, + field=current_round_field, + choice=choice_value, + ) + if not mapped_value: + continue + elif self._is_low_information_choice_reply_without_option_map( + message_text=str(current_message_text or ""), + card=sibling_card, + ): + continue + snapshot = self._build_requirement_plan_context_snapshot(card=sibling_card) + if snapshot: + grounded_snapshots.append(snapshot) + + if len(grounded_snapshots) != 1: + return {} + return grounded_snapshots[0] @classmethod def _infer_requirement_plan_round_field_from_assistant_reply( @@ -1278,6 +1582,8 @@ class ExecuteQueryProjectMixin: if parsed is None: return None choice, end = parsed + if choice not in {"A", "B", "C"}: + return None title = raw[end:].strip() if not title: return None @@ -1371,14 +1677,14 @@ class ExecuteQueryProjectMixin: if context_topic is None: return None, None - contextual_turns = await self._load_contextual_reply_turns(topic_workspace_id=int(context_topic.id)) - if not self._reply_context_contains_requirement_plan_contract( - turns=contextual_turns, + contextual_snapshot, contextual_turns = await self._load_contextual_requirement_plan_snapshot( + topic=context_topic, current_message_text=message_text, - ): + ) + if not contextual_snapshot: return None, None - card = self._build_contextual_requirement_plan_card(topic=context_topic, turns=contextual_turns) + card = self._sanitize_requirement_plan_card_runtime_context(card=dict(contextual_snapshot)) if not str(card.get("title") or "").strip() or not str(card.get("source_material") or "").strip(): return None, None @@ -1696,8 +2002,8 @@ class ExecuteQueryProjectMixin: if existing: return existing normalized = WorkflowSemanticPlanner._normalize_text(message_text) - if "openclaw" in normalized and any(token in normalized for token in ("部署", "发布")): - return "OpenClaw 批量自动化部署平台" + if "本地路由层" in normalized and any(token in normalized for token in ("部署", "发布")): + return "PMFlow 本地路由层批量自动化部署平台" if any(token in normalized for token in ("自动化部署", "批量部署")): return "批量自动化部署平台" if any(token in normalized for token in ("发布治理", "部署治理")): @@ -1722,8 +2028,8 @@ class ExecuteQueryProjectMixin: if existing: return existing normalized = WorkflowSemanticPlanner._normalize_text(message_text) - if "openclaw" in normalized and any(token in normalized for token in ("部署", "发布")): - return "先把 OpenClaw 批量部署、执行追踪和回滚治理收成一条可复用流程,再决定是否正式创建。" + if "本地路由层" in normalized and any(token in normalized for token in ("部署", "发布")): + return "先把 PMFlow 本地路由层批量部署、执行追踪和回滚治理收成一条可复用流程,再决定是否正式创建。" if any(token in normalized for token in ("大a", "a股", "沪深")) and any( token in normalized for token in ("打分", "评分", "分数") ): @@ -1741,11 +2047,16 @@ class ExecuteQueryProjectMixin: normalized = WorkflowSemanticPlanner._normalize_text(raw) updates: dict[str, Any] = {} profile = cls._detect_requirement_plan_profile(message_text=raw, card=card) - choice = cls._extract_requirement_plan_choice(raw) - cleaned_raw = cls._strip_requirement_plan_choice_prefix(raw) + protocol = cls._parse_requirement_plan_protocol_reply(raw) + choice = str(protocol.get("choice") or "").strip().upper() or None + cleaned_raw = cls._sanitize_requirement_plan_text(str(protocol.get("content_text") or "")) current_card = dict(card or {}) current_card["plan_profile"] = profile missing_fields = cls._requirement_plan_missing_fields(current_card) + last_round_options = current_card.get("last_round_options") if isinstance(current_card.get("last_round_options"), dict) else {} + active_choice_field = str(last_round_options.get("field") or "").strip() + if not active_choice_field and missing_fields: + active_choice_field = str(missing_fields[0] or "").strip() updates["plan_profile"] = profile if not card.get("title"): updates["title"] = cls._derive_requirement_plan_title(message_text=raw, card=card) @@ -1759,7 +2070,7 @@ class ExecuteQueryProjectMixin: updates["project_nature"] = "发布治理平台" elif any(token in normalized for token in ("云原生交付底座", "交付底座")): updates["project_nature"] = "云原生交付底座" - elif choice and missing_fields and missing_fields[0] == "project_nature": + elif choice and active_choice_field == "project_nature": mapped = cls._requirement_plan_model_choice_value( card=card, field="project_nature", @@ -1788,11 +2099,16 @@ class ExecuteQueryProjectMixin: updates["target_user"] = "研究团队" elif any(token in raw for token in ("交易员", "交易团队")): updates["target_user"] = "交易团队" - elif any(token in raw for token in ("研发负责人", "PM", "pm")): + elif ( + "研发负责人" in raw + or "产品经理" in raw + or re.search(r"(?= 4: - updates["first_scope"] = cleaned_raw - elif choice and missing_fields and missing_fields[0] == "first_scope": + if choice and active_choice_field == "first_scope": mapped = cls._requirement_plan_model_choice_value( card=card, field="first_scope", @@ -1838,6 +2146,20 @@ class ExecuteQueryProjectMixin: ) if mapped: updates["first_scope"] = mapped + if not updates.get("first_scope") and any(token in cleaned_raw for token in ("批量执行闭环", "任务创建", "执行日志", "失败重试")): + updates["first_scope"] = cleaned_raw + elif not updates.get("first_scope") and any(token in cleaned_raw for token in ("环境接入闭环", "目标机器", "环境注册", "凭证管理")): + updates["first_scope"] = cleaned_raw + elif not updates.get("first_scope") and any(token in cleaned_raw for token in ("治理闭环", "审批", "回滚", "审计")): + updates["first_scope"] = cleaned_raw + elif ( + not updates.get("first_scope") + and missing_fields + and missing_fields[0] == "first_scope" + and len(cleaned_raw.strip()) >= 4 + and not cls._is_requirement_plan_protocol_continuation_text(cleaned_raw) + ): + updates["first_scope"] = cleaned_raw if updates.get("first_scope") and not card.get("delivery_mode"): updates["delivery_mode"] = "项目级试点(带基础审批和回滚)" @@ -1848,7 +2170,7 @@ class ExecuteQueryProjectMixin: updates["delivery_mode"] = "项目级试点(带基础审批和回滚)" elif any(token in cleaned_raw for token in ("平台化上线", "正式上线", "多团队接入")): updates["delivery_mode"] = "平台化上线" - elif choice and missing_fields and missing_fields[0] == "delivery_mode": + elif choice and active_choice_field == "delivery_mode": mapped = cls._requirement_plan_model_choice_value( card=card, field="delivery_mode", @@ -1882,6 +2204,23 @@ class ExecuteQueryProjectMixin: return "" return f"好,我先按这个方向继续记:{';'.join(parts[:2])}。" + @classmethod + def _resolve_requirement_plan_active_field(cls, *, card: dict[str, Any]) -> str: + last_round_options = card.get("last_round_options") if isinstance(card.get("last_round_options"), dict) else {} + field = str(last_round_options.get("field") or "").strip() + if field: + return field + round_option_maps = card.get("round_option_maps") if isinstance(card.get("round_option_maps"), dict) else {} + candidate_fields = [str(name or "").strip() for name in round_option_maps.keys() if str(name or "").strip()] + if len(candidate_fields) == 1: + return candidate_fields[0] + if candidate_fields: + return candidate_fields[-1] + missing_fields = cls._requirement_plan_missing_fields(card) + if missing_fields: + return str(missing_fields[0] or "").strip() + return "first_scope" + @classmethod def _requirement_plan_field_label(cls, field: str) -> str: return { @@ -1896,6 +2235,67 @@ class ExecuteQueryProjectMixin: "scoring_basis": "打分依据", }.get(str(field or "").strip(), str(field or "").strip()) + @classmethod + def _build_requirement_mode_locked_answer(cls, *, card: dict[str, Any]) -> str: + title = str(card.get("title") or "").strip() + subject = title if title and title != "需求收敛草稿" else "当前这条需求" + return ( + f"当前正在需求模式,这条线程先只处理“{subject}”的收敛。" + "你可以继续回 A / B / C / D、补一句、出草稿、重出这轮;如果要聊别的,请先回复“退出需求模式”。" + ) + + @classmethod + def _build_requirement_plan_d_clarify_answer(cls, *, card: dict[str, Any]) -> str: + active_field = cls._resolve_requirement_plan_active_field(card=card) + label = cls._requirement_plan_field_label(active_field) + return ( + f"这轮不用硬选。如果 A/B/C 都不贴,你直接补一句你想要的{label},我按你的原话继续收。" + "也可以直接回复:补一句 + 你的补充。" + ) + + @classmethod + def _build_requirement_plan_missing_fields_for_draft_answer( + cls, + *, + card: dict[str, Any], + missing_fields: list[str], + ) -> str: + _ = card + labels = "、".join(cls._requirement_plan_field_label(field) for field in missing_fields[:3]) + return ( + f"现在还不能直接出草稿,还差 {labels}。" + "你可以直接补一句最关键的一项,或回“重出这轮”让我把当前问题重给一版。" + ) + + @classmethod + def _is_requirement_plan_exit_request(cls, message_text: str) -> bool: + normalized = cls._normalize_requirement_plan_protocol_text(message_text) + return normalized in { + "退出需求模式", + "结束需求模式", + "取消需求模式", + "先退出需求模式", + "先结束需求模式", + } + + @staticmethod + def _is_requirement_mode_locked( + *, + topic: Optional[MountedTopicWorkspace], + draft: Optional[MountedDraftWorkspace], + force_new_topic: bool = False, + ) -> bool: + if force_new_topic or topic is None or draft is None: + return False + topic_status = str(getattr(topic, "status", "") or "").strip().lower() + plan_status = str(getattr(topic, "plan_status", "") or "").strip().lower() + draft_state = str(getattr(draft, "state", "") or "").strip().lower() + if topic_status not in {"active", ""}: + return False + if plan_status in {"suspended", "done", "completed", "closed"}: + return False + return draft_state in {"collecting", "ready", "draft_pending_confirm"} + @classmethod def _summarize_requirement_plan_field_value(cls, *, profile: str, field: str, value: Any) -> str: text = str(value or "").strip() @@ -2007,7 +2407,7 @@ class ExecuteQueryProjectMixin: "field_goal": cls._requirement_plan_field_goal(missing_field), "forbidden": [ "不要输出 packet 中没有出现过的产品名或旧项目名", - "不要把 OpenClaw、部署、Kubernetes、发布治理等旧场景套到无关需求里", + "不要把 PMFlow 本地路由层、部署、Kubernetes、发布治理等旧场景套到无关需求里", "不要返回固定模板占位句", "不要直接创建项目或承诺已执行", ], @@ -2035,7 +2435,7 @@ class ExecuteQueryProjectMixin: "option_rationale": 56, "option_tradeoff": 48, "recommendation_reason": 72, - "reply_instruction": 36, + "reply_instruction": 72, } @classmethod @@ -2125,8 +2525,8 @@ class ExecuteQueryProjectMixin: ), }, "reply_instruction": cls._compact_round_text( - payload.get("reply_instruction") or "你回 A / B / C,我就顺着这条继续往下收。", - limit=cls._requirement_plan_budget_limit(packet=packet, key="reply_instruction", default=36), + payload.get("reply_instruction") or "直接回复:A / B / C / D;也可以回复:补一句 / 出草稿 / 重出这轮。", + limit=cls._requirement_plan_budget_limit(packet=packet, key="reply_instruction", default=72), ), } if not normalized_payload["opening"] or not normalized_payload["focus"] or not normalized_payload["recommendation"]["reason"]: @@ -2285,7 +2685,7 @@ class ExecuteQueryProjectMixin: if cls._requirement_plan_packet_allows_deployment_context(packet=packet): return stale_terms = ( - "openclaw", + "本地路由层", "批量自动化部署", "发布治理平台", "云原生交付底座", @@ -2318,7 +2718,7 @@ class ExecuteQueryProjectMixin: } context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False)) deployment_hints = ( - "openclaw", + "本地路由层", "批量自动化部署", "自动化部署", "部署治理", @@ -2347,9 +2747,8 @@ class ExecuteQueryProjectMixin: lines.extend( [ f"{option['key']}. {option['title']}", - f"更适合:{option['best_for']}", - f"推荐技术/能力:{option['suggested_capability']}", - f"我为什么会考虑这个:{option['rationale']}", + f"适合:{option['best_for']}", + f"能力:{option['suggested_capability']}", f"代价:{option['tradeoff']}", "", ] @@ -2357,10 +2756,13 @@ class ExecuteQueryProjectMixin: recommendation = payload["recommendation"] lines.extend( [ + "D. 以上都不对,我补充一句", + "", f"⭐ 当前我更推荐:{recommendation['key']}", f"原因:{recommendation['reason']}", "", - str(payload["reply_instruction"]).strip() or "你回 A / B / C,我就顺着这条继续往下收。", + str(payload["reply_instruction"]).strip() + or "直接回复:A / B / C / D;也可以回复:补一句 / 出草稿 / 重出这轮。", ] ) return "\n".join(lines).strip() @@ -2373,7 +2775,7 @@ class ExecuteQueryProjectMixin: packet: dict[str, Any], ) -> None: text = str(rendered_text or "").strip() - required_markers = ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:") + required_markers = ("A. ", "B. ", "C. ", "D. 以上都不对,我补充一句", "⭐ 当前我更推荐:") missing = [marker for marker in required_markers if marker not in text] if missing: raise ValueError("requirement_plan_round 渲染结果不完整,不能发送到飞书") @@ -2385,7 +2787,7 @@ class ExecuteQueryProjectMixin: def _requirement_plan_context_allows_deployment_terms(cls, *, context_material: Any) -> bool: context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False)) deployment_hints = ( - "openclaw", + "本地路由层", "批量自动化部署", "自动化部署", "部署治理", @@ -3137,7 +3539,7 @@ class ExecuteQueryProjectMixin: if str(card.get(key) or "").strip() ) ) - return any(token in normalized for token in ("部署", "openclaw", "回滚", "环境", "镜像", "仓库", "k8s", "kubernetes")) + return any(token in normalized for token in ("部署", "本地路由层", "回滚", "环境", "镜像", "仓库", "k8s", "kubernetes")) @classmethod def _build_it_project_known_info(cls, *, card: dict[str, Any]) -> str: @@ -3912,6 +4314,61 @@ class ExecuteQueryProjectMixin: direct_result["project_id"] = getattr(intent, "project_id", None) return direct_result + async def _build_requirement_plan_round_reroll_result( + self, + *, + topic: MountedTopicWorkspace, + draft: MountedDraftWorkspace, + card: dict[str, Any], + message_text: str, + ) -> dict[str, Any]: + profile = self._detect_requirement_plan_profile(message_text=message_text, card=card) + active_field = self._resolve_requirement_plan_active_field(card=card) + try: + rerolled_question = await self._build_requirement_plan_next_question( + missing_field=active_field, + profile=profile, + message_text=message_text, + card=card, + latest_updates=None, + ) + except Exception as exc: + logger.warning( + "requirement_plan_round_reroll_failed", + tenant_id=self.tenant_id, + topic_workspace_id=int(getattr(topic, "id", 0) or 0), + error=str(exc), + ) + return { + "status": "failed_safe", + "error_message": f"需求收敛选项重出失败:{str(exc)[:180]}", + } + draft.state = "collecting" + draft.requirement_card = card + draft.draft_payload = None + topic.plan_status = "collecting" + topic.pending_actions = ["continue_requirement_plan"] + topic.open_questions = [active_field] + topic.metadata_json = { + **(topic.metadata_json if isinstance(topic.metadata_json, dict) else {}), + "plan_mode": True, + "plan_last_prompt": rerolled_question, + "plan_rerolled": True, + } + await self.db.flush() + result = self._build_direct_query_result( + answer=f"好,我把这一轮按同一问题重出一版。\n\n{rerolled_question}", + scene_label="Mounted Runtime Plan", + reason_code="requirement_planning_round_rerolled", + query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, + ) + requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card) + if requirement_plan_context: + result["requirement_plan_context"] = requirement_plan_context + result["reply_template_key"] = "requirement_plan_followup" + return result + async def _build_requirement_planning_direct_result( self, *, @@ -4011,9 +4468,73 @@ class ExecuteQueryProjectMixin: message_text=message_text, ) card = self._sanitize_requirement_plan_card_runtime_context(card=dict(draft.requirement_card or {})) - has_material_input = bool(self._requirement_plan_material_updates(message_text=message_text)) - has_choice_reply = bool(self._extract_requirement_plan_choice(message_text)) + protocol = self._parse_requirement_plan_protocol_reply(message_text) + protocol_choice = str(protocol.get("choice") or "").strip().upper() + protocol_action = str(protocol.get("action") or "").strip().lower() + protocol_content_text = str(protocol.get("content_text") or "").strip() + if protocol_choice == "D" and not protocol_content_text: + return self._build_direct_query_result( + answer=self._build_requirement_plan_d_clarify_answer(card=card), + scene_label="Mounted Runtime Plan", + reason_code="requirement_planning_choice_d_clarify", + query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, + ) + if protocol_action == "clarify" and not protocol_content_text: + return self._build_direct_query_result( + answer=self._build_requirement_plan_d_clarify_answer(card=card), + scene_label="Mounted Runtime Plan", + reason_code="requirement_planning_clarify_prompt", + query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, + ) + planning_message_text = ( + protocol_content_text + if (protocol_choice == "D" or protocol_action == "clarify") and protocol_content_text + else message_text + ) + merged_source_material = self._merge_requirement_plan_source_material( + current_value=card.get("source_material"), + message_text=planning_message_text, + ) + if merged_source_material: + card["source_material"] = merged_source_material + has_material_input = bool(self._requirement_plan_material_updates(message_text=planning_message_text)) + has_choice_reply = bool(protocol_choice) has_nontrivial_seed_text = len(self._sanitize_requirement_plan_text(message_text).strip()) >= 6 + has_grounded_requirement_identity = self._requirement_plan_card_has_grounded_identity(card=card) + if not has_grounded_requirement_identity and has_choice_reply: + scoped_snapshot = await self._load_unique_grounded_requirement_plan_scope_snapshot( + topic=topic, + current_message_text=planning_message_text, + ) + if scoped_snapshot: + card = self._sanitize_requirement_plan_card_runtime_context( + card=self._merge_requirement_plan_context_snapshot( + card=card, + context=scoped_snapshot, + prefer_context_values=True, + ) + ) + draft.requirement_card = card + topic.metadata_json = { + **(topic.metadata_json if isinstance(topic.metadata_json, dict) else {}), + "plan_mode": True, + "context_repaired_from_scope_snapshot": True, + } + await self.db.flush() + has_grounded_requirement_identity = self._requirement_plan_card_has_grounded_identity(card=card) + if not has_grounded_requirement_identity and has_choice_reply: + return self._build_direct_query_result( + answer=( + "当前这条需求收敛线程缺少最初的需求描述,不能安全继续解释这个 A/B/C 选项。" + "请直接引用最开始那条需求消息,或重发一句完整需求,我会基于那句重新收敛。" + ), + scene_label="Mounted Runtime Plan", + reason_code="requirement_planning_seed_context_missing", + query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, + ) if force_new_topic and not (has_material_input or has_choice_reply or has_nontrivial_seed_text): topic.plan_status = "collecting" topic.pending_actions = ["continue_requirement_plan"] @@ -4045,13 +4566,20 @@ class ExecuteQueryProjectMixin: query_type="plan", workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, ) + if protocol_action == "reroll" and not protocol_choice: + return await self._build_requirement_plan_round_reroll_result( + topic=topic, + draft=draft, + card=card, + message_text=planning_message_text, + ) - updates = self._extract_requirement_plan_updates(message_text=message_text, card=card) + updates = self._extract_requirement_plan_updates(message_text=planning_message_text, card=card) card.update(updates) - profile = self._detect_requirement_plan_profile(message_text=message_text, card=card) + profile = self._detect_requirement_plan_profile(message_text=planning_message_text, card=card) card["plan_profile"] = profile - card["title"] = self._derive_requirement_plan_title(message_text=message_text, card=card) - card["goal"] = self._derive_requirement_plan_goal(message_text=message_text, card=card) + card["title"] = self._derive_requirement_plan_title(message_text=planning_message_text, card=card) + card["goal"] = self._derive_requirement_plan_goal(message_text=planning_message_text, card=card) card["question_round"] = int(card.get("question_round") or 0) + 1 missing_fields = self._requirement_plan_missing_fields(card) readiness_denominator = 5 @@ -4066,11 +4594,35 @@ class ExecuteQueryProjectMixin: "plan_question_round": int(card["question_round"]), "plan_mode": True, } + if protocol_action == "draft" and missing_fields: + topic.plan_status = "collecting" + topic.open_questions = missing_fields + topic.pending_actions = ["continue_requirement_plan"] + topic.readiness_score = readiness + draft.state = "collecting" + draft.requirement_card = card + draft.draft_payload = None + await self.db.flush() + result = self._build_direct_query_result( + answer=self._build_requirement_plan_missing_fields_for_draft_answer( + card=card, + missing_fields=missing_fields, + ), + scene_label="Mounted Runtime Plan", + reason_code="requirement_planning_draft_blocked_missing_fields", + query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, + ) + requirement_plan_context = self._build_requirement_plan_context_snapshot(card=card) + if requirement_plan_context: + result["requirement_plan_context"] = requirement_plan_context + result["reply_template_key"] = "requirement_plan_followup" + return result should_emit_draft = not missing_fields and ( str(card.get("first_scope") or "").strip() or int(card.get("question_round") or 0) >= 4 - or any(token in WorkflowSemanticPlanner._normalize_text(message_text) for token in ("整理成草稿", "出草稿", "汇总一下")) + or protocol_action == "draft" ) topic.plan_status = self._resolve_requirement_plan_status( missing_fields=missing_fields, @@ -4137,7 +4689,7 @@ class ExecuteQueryProjectMixin: next_question = await self._build_requirement_plan_next_question( missing_field=missing_fields[0] if missing_fields else "first_scope", profile=profile, - message_text=message_text, + message_text=planning_message_text, card=card, latest_updates=updates, ) @@ -4843,6 +5395,95 @@ class ExecuteQueryProjectMixin: consultant_mode_preference = ( str(consultant_mode_preference or "exploratory").strip().lower() or "exploratory" ) + requirement_mode_locked = self._is_requirement_mode_locked( + topic=active_requirement_plan_topic, + draft=active_requirement_plan_draft, + force_new_topic=str(topic_switch or "").strip().lower() == "new", + ) + locked_protocol = self._parse_requirement_plan_protocol_reply(message_text) if requirement_mode_locked else {} + locked_card = ( + self._sanitize_requirement_plan_card_runtime_context( + card=dict(active_requirement_plan_draft.requirement_card or {}) + ) + if requirement_mode_locked + and active_requirement_plan_draft is not None + and isinstance(active_requirement_plan_draft.requirement_card, dict) + else {} + ) + if requirement_mode_locked and self._is_requirement_plan_exit_request(message_text): + if active_requirement_plan_topic is not None: + active_requirement_plan_topic.status = "suspended" + active_requirement_plan_topic.plan_status = "suspended" + active_requirement_plan_topic.pending_actions = ["resume_requirement_plan"] + active_requirement_plan_topic.metadata_json = { + **( + active_requirement_plan_topic.metadata_json + if isinstance(active_requirement_plan_topic.metadata_json, dict) + else {} + ), + "plan_mode": True, + "suspended_by_message_id": str(source_message_id or "").strip() or None, + } + await self.db.flush() + direct_result = self._build_direct_query_result( + answer="好,这条需求先暂停。你后面引用原消息或说“继续需求”我再接上;现在可以聊别的。", + scene_label="Mounted Runtime Plan", + reason_code="requirement_mode_suspended", + query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, + ) + direct_result["project_id"] = intent.project_id + return direct_result + if requirement_mode_locked and ( + ( + str(locked_protocol.get("choice") or "").strip().upper() == "D" + and not str(locked_protocol.get("content_text") or "").strip() + ) + or ( + str(locked_protocol.get("action") or "").strip().lower() == "clarify" + and not str(locked_protocol.get("content_text") or "").strip() + ) + ): + direct_result = self._build_direct_query_result( + answer=self._build_requirement_plan_d_clarify_answer(card=locked_card), + scene_label="Mounted Runtime Plan", + reason_code="requirement_planning_choice_d_clarify", + query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, + ) + direct_result["project_id"] = intent.project_id + return direct_result + if ( + requirement_mode_locked + and str(locked_protocol.get("action") or "").strip().lower() == "reroll" + and active_requirement_plan_topic is not None + and active_requirement_plan_draft is not None + ): + direct_result = await self._build_requirement_plan_round_reroll_result( + topic=active_requirement_plan_topic, + draft=active_requirement_plan_draft, + card=locked_card, + message_text=str(locked_protocol.get("content_text") or "").strip() or message_text, + ) + direct_result["project_id"] = intent.project_id + return direct_result + if requirement_mode_locked and normalized_message_intent == "casual_chat": + direct_result = self._build_direct_query_result( + answer=self._build_requirement_mode_locked_answer(card=locked_card), + scene_label="Mounted Runtime Plan", + reason_code="requirement_mode_locked", + query_type="plan", + workflow_route=ROUTE_REQUIREMENT_PLAN_COLLECT, + ) + direct_result["project_id"] = intent.project_id + return direct_result + if requirement_mode_locked and not explicit_new_requirement_topic: + message_intent = "requirement_plan" + normalized_message_intent = "requirement_plan" + topic_switch = str(topic_switch or "continue").strip().lower() or "continue" + consultant_mode_preference = ( + str(consultant_mode_preference or "exploratory").strip().lower() or "exploratory" + ) if self._should_use_lightweight_chat_answer( message_text=message_text, message_intent=message_intent, diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py index 2c3c3ff7..e19c1f89 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py @@ -163,7 +163,7 @@ class ExecuteSignalSourceMixin: "auto_tasks": self._extract_auto_tasks( orchestration.get("execution") if isinstance(orchestration.get("execution"), dict) - else orchestration.get("openclaw") + else orchestration.get("control_plane") ), } await self._finalize_signal_source_onboard_ledger( diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py index c9176c6a..1b067e64 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_helpers.py @@ -214,13 +214,13 @@ class ExecuteHelpersMixin: return signal @staticmethod - def _extract_auto_tasks(openclaw_payload: Any) -> list[dict[str, Any]]: - if not isinstance(openclaw_payload, dict): + def _extract_auto_tasks(control_plane_payload: Any) -> list[dict[str, Any]]: + if not isinstance(control_plane_payload, dict): return [] result = ( - openclaw_payload.get("result") - if isinstance(openclaw_payload.get("result"), dict) - else openclaw_payload + control_plane_payload.get("result") + if isinstance(control_plane_payload.get("result"), dict) + else control_plane_payload ) execution = ( result.get("execution") diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index f95e71ee..7b7e1b09 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -38,8 +38,11 @@ class DraftConfirmHandler: _ACTION_CANCEL, _ACTION_ANSWER_ONLY, ) - _EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "确认本版", "confirm", "ok", "好的", "好", "yes", "y"}) + _EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "确认本版", "确认入库", "confirm", "ok", "好的", "好", "yes", "y"}) + _EXPLICIT_REVISE_COMMANDS = frozenset({"修改", "修改本版", "修改意见", "修改你的意见", "修改一下"}) + _EXPLICIT_REGENERATE_COMMANDS = frozenset({"重出", "重出一版", "再出", "再出一版", "重新出", "重新出一版"}) _EXPLICIT_CANCEL_COMMANDS = frozenset({"取消", "取消本版", "cancel", "不用了", "算了"}) + _PROTOCOL_STRIP_RE = re.compile(r"[\s,,。;;::!!??、++]+") _DEFAULT_DISPLAY_FEEDBACK_REPLY = ( "收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。" ) @@ -48,22 +51,39 @@ class DraftConfirmHandler: @classmethod def is_explicit_protocol_followup_text(cls, text: str) -> bool: - normalized_text = str(text or "").strip().lower() + normalized_text = cls._normalize_protocol_text(text) if not normalized_text: return False - return normalized_text in (cls._EXPLICIT_CONFIRM_COMMANDS | cls._EXPLICIT_CANCEL_COMMANDS) + return normalized_text in ( + cls._normalized_protocol_commands(cls._EXPLICIT_CONFIRM_COMMANDS) + | cls._normalized_protocol_commands(cls._EXPLICIT_REVISE_COMMANDS) + | cls._normalized_protocol_commands(cls._EXPLICIT_REGENERATE_COMMANDS) + | cls._normalized_protocol_commands(cls._EXPLICIT_CANCEL_COMMANDS) + ) @classmethod def _resolve_explicit_protocol_action(cls, text: str) -> str: - normalized_text = str(text or "").strip().lower() + normalized_text = cls._normalize_protocol_text(text) if not normalized_text: return cls._ACTION_UNKNOWN - if normalized_text in cls._EXPLICIT_CONFIRM_COMMANDS: + if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_CONFIRM_COMMANDS): return cls._ACTION_CONFIRM - if normalized_text in cls._EXPLICIT_CANCEL_COMMANDS: + if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_REVISE_COMMANDS): + return cls._ACTION_REVISE + if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_REGENERATE_COMMANDS): + return cls._ACTION_REGENERATE + if normalized_text in cls._normalized_protocol_commands(cls._EXPLICIT_CANCEL_COMMANDS): return cls._ACTION_CANCEL return cls._ACTION_UNKNOWN + @classmethod + def _normalized_protocol_commands(cls, commands: frozenset[str]) -> set[str]: + return {cls._normalize_protocol_text(command) for command in commands if str(command or "").strip()} + + @classmethod + def _normalize_protocol_text(cls, value: Any) -> str: + return cls._PROTOCOL_STRIP_RE.sub("", str(value or "")).strip().lower() + def __init__(self, kernel: Any) -> None: self.kernel = kernel self.db = kernel.db @@ -86,7 +106,7 @@ class DraftConfirmHandler: explicit_work_item_id = self._extract_work_item_id(message_text) normalized_thread_context = self._normalize_thread_context(thread_context) has_reply_anchor = bool(str(normalized_thread_context.get("reply_to_message_id") or "").strip()) - normalized_text = str(message_text or "").strip().lower() + normalized_text = self._normalize_protocol_text(message_text) target = await self._resolve_pending_draft( sender=sender, source_system=source_system, @@ -96,14 +116,11 @@ class DraftConfirmHandler: thread_context=thread_context, ) if target is None: - if explicit_work_item_id is not None or ( - has_reply_anchor - and normalized_text in (self._EXPLICIT_CONFIRM_COMMANDS | self._EXPLICIT_CANCEL_COMMANDS) - ): + if explicit_work_item_id is not None or (has_reply_anchor and self.is_explicit_protocol_followup_text(normalized_text)): return { "handled": True, "status": "failed_safe", - "followup_reply": "当前没有可确认草稿。请先在当前会话重新生成最新草稿,再回复“确认”或“修改”。", + "followup_reply": "当前没有可处理草稿。请先在当前会话重新生成最新草稿,再回复“确认”、“修改”、“重出一版”或“取消”。", "target_work_item_id": None, "target_state": None, "target_intent": None, @@ -195,22 +212,10 @@ class DraftConfirmHandler: target_work_item=target_work_item, reason="用户取消草稿", ) - if explicit_work_item_id is None and not has_reply_anchor: - return { - "handled": False, - "status": "ignored", - "followup_reply": "", - "target_work_item_id": None, - "target_state": None, - "target_intent": None, - "project_id": None, - "signal_id": None, - "workflow_route": None, - } return { "handled": True, "status": "pending_manual", - "followup_reply": "请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”来处理当前草稿。", + "followup_reply": "当前正在处理这版待确认草稿。请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”来处理当前草稿。", "target_work_item_id": int(target_work_item.id), "target_state": str(target_work_item.state or "").strip() or None, "target_intent": str(target_work_item.intent or "").strip() or None, @@ -1020,6 +1025,7 @@ class DraftConfirmHandler: 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)) + anchor_context = self._normalize_thread_context(thread_context) active_mounted = await self._resolve_active_mounted_draft( sender=sender, source_system=source_system, @@ -1033,12 +1039,14 @@ class DraftConfirmHandler: 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 and str(getattr(target[0], "state", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: - return target + return target if anchor_context else None await self._release_mounted_draft_binding( work_item_id=int(active_work_item_id), final_state=str(getattr(target[0], "state", "") or "").strip() if target is not None else "missing", mounted=active_mounted, ) + if not anchor_context: + return None stmt = ( select(WorkItem, IngestEvent) @@ -1057,7 +1065,6 @@ class DraftConfirmHandler: normalized_sender = str(sender or "").strip() normalized_channel = str(channel_ref or "").strip() normalized_open_id = str(source_sender_open_id or "").strip() - anchor_context = self._normalize_thread_context(thread_context) sender_scope_candidates: list[tuple[WorkItem, IngestEvent]] = [] for work_item, ingest in rows: payload = ingest.payload if isinstance(ingest.payload, dict) else {} @@ -1072,14 +1079,6 @@ class DraftConfirmHandler: continue sender_scope_candidates.append((work_item, ingest)) - if not anchor_context: - for candidate in sender_scope_candidates: - candidate_work_item, _ = candidate - candidate_route = RouteResolver.normalize_route_key(str(getattr(candidate_work_item, "intent", "") or "")) - if candidate_route != ROUTE_FOLLOWUP_CONFIRM: - return candidate - return sender_scope_candidates[0] if sender_scope_candidates else None - source_message_ids = sorted( { str(getattr(work_item, "source_message_id", "") or "").strip() diff --git a/backend/app/services/ai_service_core/mixin_runtime_core.py b/backend/app/services/ai_service_core/mixin_runtime_core.py index 6f541946..a9967753 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_core.py +++ b/backend/app/services/ai_service_core/mixin_runtime_core.py @@ -39,7 +39,7 @@ class AIServiceRuntimeCoreMixin: model: Optional[str], base_url: Optional[str], ) -> dict[str, str]: - routing_enabled = self._is_env_true("OPENCLAW_ROUTING_ENABLED", True) + routing_enabled = self._is_env_true("CONTROL_PLANE_ROUTING_ENABLED", True) direct_emergency_enabled = self._is_env_true("LLM_DIRECT_EMERGENCY_ENABLED", False) if routing_enabled and not direct_emergency_enabled: high_defaults = dict(TIER_DEFAULTS.get("high", {})) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py index 50d72ab9..b7c9e747 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py @@ -8,6 +8,9 @@ class FeishuCardActionMixin: _ACTION_REVISE = "revise" _ACTION_CANCEL = "cancel" _ACTION_CONTINUE = "continue" + _ACTION_PLAN_CHOICE = "plan_choice" + _ACTION_PLAN_CONTINUE = "plan_continue" + _ACTION_PLAN_REROLL = "plan_reroll" _TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm" _TEMPLATE_PENDING_MANUAL = "pending_manual" _ACTION_ALIASES: dict[str, str] = { @@ -30,6 +33,9 @@ class FeishuCardActionMixin: "resume": _ACTION_CONTINUE, "继续": _ACTION_CONTINUE, "继续执行": _ACTION_CONTINUE, + "plan_choice": _ACTION_PLAN_CHOICE, + "plan_continue": _ACTION_PLAN_CONTINUE, + "plan_reroll": _ACTION_PLAN_REROLL, } @staticmethod @@ -158,6 +164,88 @@ class FeishuCardActionMixin: "elements": [*base_elements, {"tag": "action", "actions": actions}], } + if template_key == "requirement_plan_followup": + work_item_value = int(work_item_id) + actions_row_1 = [ + { + "tag": "button", + "type": "default", + "text": {"tag": "plain_text", "content": "A"}, + "value": { + "action": cls._ACTION_PLAN_CHOICE, + "choice": "A", + "synthetic_text": "A", + "work_item_id": work_item_value, + }, + }, + { + "tag": "button", + "type": "default", + "text": {"tag": "plain_text", "content": "B"}, + "value": { + "action": cls._ACTION_PLAN_CHOICE, + "choice": "B", + "synthetic_text": "B", + "work_item_id": work_item_value, + }, + }, + { + "tag": "button", + "type": "default", + "text": {"tag": "plain_text", "content": "C"}, + "value": { + "action": cls._ACTION_PLAN_CHOICE, + "choice": "C", + "synthetic_text": "C", + "work_item_id": work_item_value, + }, + }, + { + "tag": "button", + "type": "default", + "text": {"tag": "plain_text", "content": "D"}, + "value": { + "action": cls._ACTION_PLAN_CHOICE, + "choice": "D", + "synthetic_text": "D", + "work_item_id": work_item_value, + }, + }, + ] + actions_row_2 = [ + { + "tag": "button", + "type": "primary", + "text": {"tag": "plain_text", "content": "继续出草稿"}, + "value": { + "action": cls._ACTION_PLAN_CONTINUE, + "synthetic_text": "继续出草稿", + "work_item_id": work_item_value, + }, + }, + { + "tag": "button", + "type": "default", + "text": {"tag": "plain_text", "content": "重出一版"}, + "value": { + "action": cls._ACTION_PLAN_REROLL, + "synthetic_text": "重出一版", + "work_item_id": work_item_value, + }, + }, + ] + return { + "config": {"wide_screen_mode": True, "enable_forward": True}, + "header": { + "title": {"tag": "plain_text", "content": "AI PMO 需求收敛"}, + }, + "elements": [ + *base_elements, + {"tag": "action", "actions": actions_row_1}, + {"tag": "action", "actions": actions_row_2}, + ], + } + if template_key == cls._TEMPLATE_PENDING_MANUAL: actions = [ { @@ -208,6 +296,12 @@ class FeishuCardActionMixin: or action.get("name") or action.get("tag") ) + synthetic_text = ( + str(value.get("synthetic_text") or value.get("message_text") or value.get("text") or "") + .strip()[:500] + or None + ) + choice = str(value.get("choice") or "").strip().upper()[:8] or None work_item_id = cls._safe_int( value.get("work_item_id") or value.get("target_work_item_id") @@ -249,6 +343,8 @@ class FeishuCardActionMixin: "chat_id": chat_id, "sender_open_id": sender_open_id, "sender_name": sender_name, + "synthetic_text": synthetic_text, + "choice": choice, } async def _handle_card_action_trigger( @@ -258,7 +354,9 @@ class FeishuCardActionMixin: parsed = self._extract_card_action_payload(event) action = str(parsed.get("action") or "").strip().lower() work_item_id = self._safe_int(parsed.get("work_item_id")) - if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE}: + synthetic_text = str(parsed.get("synthetic_text") or "").strip() + plan_actions = {self._ACTION_PLAN_CHOICE, self._ACTION_PLAN_CONTINUE, self._ACTION_PLAN_REROLL} + if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE, *plan_actions}: return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "unsupported_action"}} if work_item_id is None or work_item_id <= 0: return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "missing_work_item_id"}} @@ -271,7 +369,49 @@ class FeishuCardActionMixin: chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None try: - if action == self._ACTION_CONTINUE: + if action in plan_actions: + if not synthetic_text: + if action == self._ACTION_PLAN_CHOICE: + synthetic_text = str(parsed.get("choice") or "").strip().upper() + elif action == self._ACTION_PLAN_CONTINUE: + synthetic_text = "继续出草稿" + elif action == self._ACTION_PLAN_REROLL: + synthetic_text = "重出一版" + if not synthetic_text: + return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "missing_synthetic_text"}} + kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id) + result = await kernel.process_ingress( + source_system="ai_pmo_ui", + source_message_id=( + source_message_id + or f"feishu-card-plan-{int(work_item_id)}-{action}-{int(time.time() * 1000)}" + ), + message_text=synthetic_text, + sender=f"{operator_sender}(card)", + channel_ref=chat_id, + source_sender_open_id=sender_open_id, + captured_at=None, + chat_type="manual", + project_hint_id=None, + ) + if hasattr(self.db, "commit") and callable(getattr(self.db, "commit")): + await self.db.commit() + followup_reply = str(result.get("direct_reply_text") or result.get("followup_reply") or "").strip() + if not followup_reply: + if bool(result.get("handled")): + followup_reply = "已收到按钮指令,正在处理。" + else: + followup_reply = str(result.get("error") or result.get("reason") or "按钮执行失败").strip() + normalized = { + **(dict(result) if isinstance(result, dict) else {}), + "handled": bool(result.get("handled")), + "status": str(result.get("status") or "").strip() or ("captured" if bool(result.get("handled")) else "failed_safe"), + "followup_reply": followup_reply, + "target_work_item_id": int(work_item_id), + "reply_template_key": str(result.get("reply_template_key") or "").strip() or None, + "draft_version": self._safe_int(result.get("draft_version")), + } + elif action == self._ACTION_CONTINUE: kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id) result = await kernel.process_ingress( source_system="ai_pmo_ui", @@ -296,10 +436,13 @@ class FeishuCardActionMixin: else: followup_reply = str(result.get("error") or result.get("reason") or "继续执行失败").strip() normalized = { + **(dict(result) if isinstance(result, dict) else {}), "handled": bool(result.get("handled")), "status": str(result.get("status") or "").strip() or ("captured" if bool(result.get("handled")) else "failed_safe"), "followup_reply": followup_reply, "target_work_item_id": int(work_item_id), + "reply_template_key": str(result.get("reply_template_key") or "").strip() or None, + "draft_version": self._safe_int(result.get("draft_version")), } else: draft_version = self._safe_int(parsed.get("draft_version")) @@ -329,7 +472,10 @@ class FeishuCardActionMixin: or work_item_id ) template_key = "" - if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM: + reply_template_key = str(normalized.get("reply_template_key") or "").strip().lower() + if reply_template_key in {self._TEMPLATE_DRAFT_PENDING_CONFIRM, "requirement_plan_followup"}: + template_key = reply_template_key + elif status == self._TEMPLATE_DRAFT_PENDING_CONFIRM: template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM elif status in {"pending_manual", "pending_confirmation"}: template_key = self._TEMPLATE_PENDING_MANUAL diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py index 17763056..15135007 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py @@ -30,6 +30,22 @@ class FeishuReceiveRoutingMixin: ) return reply_route + action_card = self._build_action_card_from_capture_result( + capture_result=result, + mention_open_id=sender_open_id, + mention_name=sender_name, + ) + if chat_id and isinstance(action_card, dict): + await self._safe_reply_card( + chat_id=chat_id, + card_content=action_card, + trace_id=trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + return "execute_action_card" if scene == "execute" else "qa_action_card" + + if scene == "execute": self._emit_state_transition( trace_id=trace_id, state="ROUTED", @@ -57,20 +73,6 @@ class FeishuReceiveRoutingMixin: ) if not deterministic_reply and str(result.get("status") or "").lower() == "error": deterministic_reply = self._build_error_reply_text() - action_card = self._build_action_card_from_capture_result( - capture_result=result, - mention_open_id=sender_open_id, - mention_name=sender_name, - ) - if chat_id and isinstance(action_card, dict): - await self._safe_reply_card( - chat_id=chat_id, - card_content=action_card, - trace_id=trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - return "execute_action_card" if chat_id and deterministic_reply: await self._safe_reply_text( chat_id=chat_id, diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py index 82ae17fe..20e2837e 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py @@ -267,7 +267,7 @@ class FeishuReplyLLMCoreMixin: ) forbidden_contains = ( "通过 feishu", - "群聊接入 openclaw", + "群聊接入 PMFlow 本地路由层", "你可以直接在这里和我对话", ) if any(lowered.startswith(prefix) for prefix in forbidden_prefixes) or any(token in lowered for token in forbidden_contains): diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py index 584d9ce0..5e5a7676 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py @@ -5,6 +5,15 @@ from .shared import * class FeishuPipelineProcessMessageHelpersBMixin: + _VISIBLE_INTERNAL_DEBUG_LINE_PATTERNS = ( + re.compile(r"(?:^|[\s`])(backend|frontend|docs|scripts)/\S+", re.IGNORECASE), + re.compile(r"\b[\w./-]+\.(?:py|ts|tsx|js|jsx|json|ya?ml|md)\b", re.IGNORECASE), + re.compile(r"\bline\s*\d+\b|第\s*\d+\s*行", re.IGNORECASE), + re.compile(r"\bknowledge\s+graph\b|\bsource_scan\b", re.IGNORECASE), + re.compile(r"\bproject#\d+\b|#codex-[\w-]+|\bai-ext-\d+\b", re.IGNORECASE), + re.compile(r"\bmixin_[a-z0-9_]+\b", re.IGNORECASE), + ) + @staticmethod def _decorate_capture_result( result: dict[str, Any], @@ -48,6 +57,7 @@ class FeishuPipelineProcessMessageHelpersBMixin: cleaned = cleaned.replace("```", "").strip() if cleaned.lower().startswith("json"): cleaned = cleaned[4:].strip() + cleaned = FeishuPipelineProcessMessageHelpersBMixin._strip_internal_debug_lines(cleaned) if not cleaned: return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" lowered = cleaned.lower() @@ -79,6 +89,25 @@ class FeishuPipelineProcessMessageHelpersBMixin: return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。" return cleaned[:500] + @classmethod + def _strip_internal_debug_lines(cls, text: str) -> str: + raw = str(text or "").strip() + if not raw: + return "" + kept_lines: list[str] = [] + for line in raw.splitlines(): + current = str(line or "").strip() + if not current: + if kept_lines and kept_lines[-1] != "": + kept_lines.append("") + continue + if any(pattern.search(current) for pattern in cls._VISIBLE_INTERNAL_DEBUG_LINE_PATTERNS): + continue + kept_lines.append(current) + cleaned = "\n".join(kept_lines) + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip() + return cleaned + @staticmethod def _summarize_db_mutations(result: dict[str, Any]) -> list[dict[str, Any]]: if not isinstance(result, dict): diff --git a/backend/app/services/llm_service/mixin_cache_circuit.py b/backend/app/services/llm_service/mixin_cache_circuit.py index 3673e8ec..d2d747ad 100644 --- a/backend/app/services/llm_service/mixin_cache_circuit.py +++ b/backend/app/services/llm_service/mixin_cache_circuit.py @@ -11,7 +11,7 @@ class LLMCacheCircuitMixin: self.redis_url = os.getenv("REDIS_URL") self._redis: Optional[Any] = None self._prompt_profile_cache: dict[int, tuple[float, dict[str, str]]] = {} - self._openclaw_routing_cache: tuple[float, dict[str, Any]] | None = None + self._control_plane_routing_cache: tuple[float, dict[str, Any]] | None = None type(self)._provider_circuit_config_signature = type(self)._current_model_config_signature() async def _get_redis(self): diff --git a/backend/app/services/llm_service/mixin_chat_openai.py b/backend/app/services/llm_service/mixin_chat_openai.py index 1c9cc19b..24362389 100644 --- a/backend/app/services/llm_service/mixin_chat_openai.py +++ b/backend/app/services/llm_service/mixin_chat_openai.py @@ -40,11 +40,11 @@ class LLMChatOpenAIMixin: structured_output: bool, function_label: str, provider_name: str, - is_openclaw_gateway_request: bool, - openclaw_agents_timeout_seconds: int, + is_control_plane_gateway_request: bool, + control_plane_agents_timeout_seconds: int, request_started_at_perf: float, request_trace_id: str, - openclaw_agent_timeout: Optional[int], + control_plane_agent_timeout: Optional[int], ) -> ChatCompletionResult: endpoint = f"{base_url.rstrip('/')}/chat/completions" headers = { @@ -71,10 +71,10 @@ class LLMChatOpenAIMixin: } if use_json_response_format: payload["response_format"] = {"type": "json_object"} - if is_openclaw_gateway_request: + if is_control_plane_gateway_request: payload["agents"] = { "defaults": { - "timeoutSeconds": int(openclaw_agents_timeout_seconds), + "timeoutSeconds": int(control_plane_agents_timeout_seconds), } } try: @@ -107,10 +107,10 @@ class LLMChatOpenAIMixin: continue raise else: - if is_openclaw_gateway_request: + if is_control_plane_gateway_request: elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( - "OpenClaw调用", + "PMFlow 本地路由层调用", ( f"http ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} status={resp.status_code} elapsed_ms={elapsed_ms:.0f}" @@ -118,9 +118,9 @@ class LLMChatOpenAIMixin: ) if resp.status_code >= 400: body_preview = str(resp.text or "").replace("\n", " ").strip()[:260] - if is_openclaw_gateway_request: + if is_control_plane_gateway_request: _runtime_log( - "OpenClaw调用", + "PMFlow 本地路由层调用", ( f"http_error ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} status={resp.status_code} body={body_preview}" @@ -238,10 +238,10 @@ class LLMChatOpenAIMixin: ) continue raise empty_error - if _is_openclaw_timeout_message(text): + if _is_control_plane_timeout_message(text): elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( - "OpenClaw超时" if is_openclaw_gateway_request else "LLM超时文本", + "PMFlow 本地路由层超时" if is_control_plane_gateway_request else "LLM超时文本", ( f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} elapsed_ms={elapsed_ms:.0f} " @@ -253,13 +253,13 @@ class LLMChatOpenAIMixin: trace_id=request_trace_id, function_name=function_label, request_timeout_seconds=float(request_timeout), - agent_timeout_seconds=openclaw_agent_timeout, + agent_timeout_seconds=control_plane_agent_timeout, text_preview=text.replace("\n", " ")[:260], ) - if is_openclaw_gateway_request: + if is_control_plane_gateway_request: elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( - "OpenClaw调用", + "PMFlow 本地路由层调用", ( f"end ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} elapsed_ms={elapsed_ms:.0f} text_len={len(text)}" diff --git a/backend/app/services/llm_service/mixin_chat_transport.py b/backend/app/services/llm_service/mixin_chat_transport.py index f041426a..cf20a311 100644 --- a/backend/app/services/llm_service/mixin_chat_transport.py +++ b/backend/app/services/llm_service/mixin_chat_transport.py @@ -56,14 +56,14 @@ class LLMChatTransportMixin: response_mode=response_mode, routing_meta=normalized_routing_meta, ) - is_openclaw_gateway_request = self._is_openclaw_gateway_request( + is_control_plane_gateway_request = self._is_control_plane_gateway_request( provider_name=provider_name, base_url=base_url, ) - openclaw_agents_timeout_seconds = self._openclaw_agents_timeout_seconds() - if is_openclaw_gateway_request: + control_plane_agents_timeout_seconds = self._control_plane_agents_timeout_seconds() + if is_control_plane_gateway_request: # 网关内部 agent 超时需小于客户端超时,避免本地请求先被中止。 - request_timeout = max(float(request_timeout), float(openclaw_agents_timeout_seconds) + 5.0) + request_timeout = max(float(request_timeout), float(control_plane_agents_timeout_seconds) + 5.0) use_provider_circuit_breaker = self._use_provider_circuit_breaker( function_name, routing_meta=normalized_routing_meta, @@ -87,16 +87,16 @@ class LLMChatTransportMixin: request_trace_id = uuid4().hex[:12] request_started_at_perf = time.perf_counter() function_label = str(function_name or "llm_generate") - openclaw_agent_timeout = int(openclaw_agents_timeout_seconds) if is_openclaw_gateway_request else None + control_plane_agent_timeout = int(control_plane_agents_timeout_seconds) if is_control_plane_gateway_request else None - if is_openclaw_gateway_request: + if is_control_plane_gateway_request: _runtime_log( - "OpenClaw调用", + "PMFlow 本地路由层调用", ( f"start ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} " f"request_timeout={float(request_timeout):.2f}s " - f"agent_timeout={openclaw_agent_timeout if openclaw_agent_timeout is not None else 'N/A'}" + f"agent_timeout={control_plane_agent_timeout if control_plane_agent_timeout is not None else 'N/A'}" ), ) @@ -124,17 +124,17 @@ class LLMChatTransportMixin: structured_output=structured_output, function_label=function_label, provider_name=provider_name, - is_openclaw_gateway_request=is_openclaw_gateway_request, - openclaw_agents_timeout_seconds=openclaw_agents_timeout_seconds, + is_control_plane_gateway_request=is_control_plane_gateway_request, + control_plane_agents_timeout_seconds=control_plane_agents_timeout_seconds, request_started_at_perf=request_started_at_perf, request_trace_id=request_trace_id, - openclaw_agent_timeout=openclaw_agent_timeout, + control_plane_agent_timeout=control_plane_agent_timeout, ) if use_provider_circuit_breaker: self._record_provider_success(circuit_key) return result except Exception as exc: - if _is_openclaw_timeout_message(str(exc)): + if _is_control_plane_timeout_message(str(exc)): elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( "LLM超时异常", @@ -149,14 +149,14 @@ class LLMChatTransportMixin: trace_id=request_trace_id, function_name=function_label, request_timeout_seconds=float(request_timeout), - agent_timeout_seconds=openclaw_agent_timeout, + agent_timeout_seconds=control_plane_agent_timeout, error_type=type(exc).__name__, error=str(exc)[:260], ) - if is_openclaw_gateway_request: + if is_control_plane_gateway_request: elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( - "OpenClaw调用", + "PMFlow 本地路由层调用", ( f"exception ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} elapsed_ms={elapsed_ms:.0f} " diff --git a/backend/app/services/llm_service/mixin_openclaw_routing.py b/backend/app/services/llm_service/mixin_control_plane_routing.py similarity index 83% rename from backend/app/services/llm_service/mixin_openclaw_routing.py rename to backend/app/services/llm_service/mixin_control_plane_routing.py index 9486a978..97ebfa7a 100644 --- a/backend/app/services/llm_service/mixin_openclaw_routing.py +++ b/backend/app/services/llm_service/mixin_control_plane_routing.py @@ -2,54 +2,54 @@ from __future__ import annotations from .shared import * -class LLMOpenclawRoutingMixin: +class LLMControlPlaneRoutingMixin: def _mixed_routing_enabled(self) -> bool: return _env_bool("LLM_MIXED_ROUTING_ENABLED", False) - def _openclaw_routing_enabled(self) -> bool: - return _env_bool("OPENCLAW_ROUTING_ENABLED", False) + def _control_plane_routing_enabled(self) -> bool: + return _env_bool("CONTROL_PLANE_ROUTING_ENABLED", False) - def _openclaw_control_plane_only(self) -> bool: - return _env_bool("OPENCLAW_CONTROL_PLANE_ONLY", False) + def _control_plane_only(self) -> bool: + return _env_bool("CONTROL_PLANE_ONLY", False) - def _ensure_openclaw_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]: + def _ensure_control_plane_engine(self, engine: dict[str, Any]) -> dict[str, Any]: runtime = dict(engine or {}) runtime["source_provider"] = str(runtime.get("source_provider") or runtime.get("provider") or "").strip().lower() return runtime - def _openclaw_rules_path(self) -> str: + def _control_plane_rules_path(self) -> str: return str(resolve_model_config_path()) - def _load_openclaw_routing_bundle(self) -> dict[str, Any]: + def _load_control_plane_routing_bundle(self) -> dict[str, Any]: now = time.time() - if self._openclaw_routing_cache and self._openclaw_routing_cache[0] > now: - return dict(self._openclaw_routing_cache[1]) + if self._control_plane_routing_cache and self._control_plane_routing_cache[0] > now: + return dict(self._control_plane_routing_cache[1]) - path = Path(self._openclaw_rules_path()) + path = Path(self._control_plane_rules_path()) bundle: dict[str, Any] = load_model_config(path=path) - self._openclaw_routing_cache = (now + OPENCLAW_ROUTING_CACHE_TTL_SECONDS, dict(bundle)) + self._control_plane_routing_cache = (now + CONTROL_PLANE_ROUTING_CACHE_TTL_SECONDS, dict(bundle)) return dict(bundle) @staticmethod def _rule_matches_function(rule: dict[str, Any], function_name: str) -> bool: return rule_matches_function(rule, function_name) - def _match_openclaw_rule( + def _match_control_plane_rule( self, function_name: str, *, tier_filter: Optional[set[str]] = None, ) -> Optional[dict[str, Any]]: - if not self._openclaw_routing_enabled(): + if not self._control_plane_routing_enabled(): return None - bundle = self._load_openclaw_routing_bundle() + bundle = self._load_control_plane_routing_bundle() return find_matching_rule( function_name, tier_filter=tier_filter, bundle=bundle, ) - def _resolve_openclaw_engine( + def _resolve_control_plane_engine( self, *, function_name: str, @@ -69,7 +69,7 @@ class LLMOpenclawRoutingMixin: and explicit_tier in {"low", "high"} ): tier_filter = {explicit_tier} - rule = self._match_openclaw_rule(function_name, tier_filter=tier_filter) + rule = self._match_control_plane_rule(function_name, tier_filter=tier_filter) if not rule: return None @@ -80,7 +80,7 @@ class LLMOpenclawRoutingMixin: ) if routed is None: return None - bundle = self._load_openclaw_routing_bundle() + bundle = self._load_control_plane_routing_bundle() config_snapshot = build_routing_config_snapshot(bundle=bundle) tier_alias = str(rule.get("tier") or "").strip().lower() or "low" routed = _merge_engine_routing_meta( @@ -97,7 +97,7 @@ class LLMOpenclawRoutingMixin: ) _runtime_log( - "OpenClaw路由", + "PMFlow 本地路由层路由", ( "规则命中:" f"function={function_name}, {_routing_meta_log_text(_read_engine_routing_meta(routed))}" @@ -175,7 +175,7 @@ class LLMOpenclawRoutingMixin: fn = str(function_name or "").strip().lower() if not fn: return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "low") or "low").strip().lower() - bundle = self._load_openclaw_routing_bundle() + bundle = self._load_control_plane_routing_bundle() rule = find_matching_rule(fn, tier_filter={"low", "high"}, bundle=bundle) if isinstance(rule, dict): configured_tier = str(rule.get("tier") or "").strip().lower() diff --git a/backend/app/services/llm_service/mixin_engine_resolution_core.py b/backend/app/services/llm_service/mixin_engine_resolution_core.py index fa735c1b..67f56e42 100644 --- a/backend/app/services/llm_service/mixin_engine_resolution_core.py +++ b/backend/app/services/llm_service/mixin_engine_resolution_core.py @@ -135,7 +135,7 @@ class LLMEngineResolutionCoreMixin: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) settings = self._normalize_engine_settings(bundle.get("settings")) active_engine = self._resolve_active_engine_from_bundle(bundle) - active_engine = self._ensure_openclaw_control_plane_engine(active_engine) + active_engine = self._ensure_control_plane_engine(active_engine) engines: list[dict[str, Any]] = [e for e in bundle.get("engines", []) if isinstance(e, dict)] override_engine = await self._resolve_function_override_engine( @@ -153,19 +153,19 @@ class LLMEngineResolutionCoreMixin: engine_label="override_engine", ) - openclaw_routed = self._resolve_openclaw_engine( + control_plane_routed = self._resolve_control_plane_engine( function_name=function_name, engines=engines, active_engine=active_engine, routing_meta=routing_meta, ) - if openclaw_routed is not None: - openclaw_routed = self._ensure_openclaw_control_plane_engine(openclaw_routed) + if control_plane_routed is not None: + control_plane_routed = self._ensure_control_plane_engine(control_plane_routed) return self._ensure_engine_allowed_for_function( - engine=openclaw_routed, + engine=control_plane_routed, function_name=function_name, settings=settings, - engine_label="openclaw_routed_engine", + engine_label="control_plane_routed_engine", ) if not self._mixed_routing_enabled(): @@ -233,7 +233,7 @@ class LLMEngineResolutionCoreMixin: ), ) return self._ensure_engine_allowed_for_function( - engine=self._ensure_openclaw_control_plane_engine(routed), + engine=self._ensure_control_plane_engine(routed), function_name=function_name, settings=settings, engine_label=f"{tier}_tier_engine", @@ -313,7 +313,7 @@ class LLMEngineResolutionCoreMixin: f"function={fn}, {_routing_meta_log_text(_read_engine_routing_meta(routed))}" ), ) - return self._ensure_openclaw_control_plane_engine(routed) + return self._ensure_control_plane_engine(routed) except ValueError: raise except Exception as exc: diff --git a/backend/app/services/llm_service/mixin_guardrails_prompt.py b/backend/app/services/llm_service/mixin_guardrails_prompt.py index 9d8baa3f..3887d7fc 100644 --- a/backend/app/services/llm_service/mixin_guardrails_prompt.py +++ b/backend/app/services/llm_service/mixin_guardrails_prompt.py @@ -103,27 +103,27 @@ class LLMGuardrailsPromptMixin: return min(self.request_timeout, configured) @staticmethod - def _is_openclaw_gateway_request(*, provider_name: str, base_url: str) -> bool: + def _is_control_plane_gateway_request(*, provider_name: str, base_url: str) -> bool: if str(provider_name or "").strip().lower() != "openai": return False normalized_base_url = str(base_url or "").strip().lower() if not normalized_base_url: return False - return "openclaw" in normalized_base_url + return "control-plane" in normalized_base_url or "control_plane" in normalized_base_url @staticmethod - def _openclaw_agents_timeout_seconds() -> int: + def _control_plane_agents_timeout_seconds() -> int: raw = os.getenv( - "OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS", - str(OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS), + "CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS", + str(CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS), ) try: configured = int(str(raw).strip()) except Exception: - configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS + configured = CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS if configured <= 0: - configured = OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS - return min(configured, OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX) + configured = CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS + return min(configured, CONTROL_PLANE_AGENTS_TIMEOUT_SECONDS_MAX) @staticmethod def _http_status_retry_budget_for_function(function_name: str) -> int: diff --git a/backend/app/services/llm_service/mixin_tier_engine_selection.py b/backend/app/services/llm_service/mixin_tier_engine_selection.py index ae06707e..363f1113 100644 --- a/backend/app/services/llm_service/mixin_tier_engine_selection.py +++ b/backend/app/services/llm_service/mixin_tier_engine_selection.py @@ -121,7 +121,7 @@ class LLMTierEngineSelectionMixin: f"tier={tier}, decision=tier_default" ), ) - return self._ensure_openclaw_control_plane_engine(routed) + return self._ensure_control_plane_engine(routed) async def _resolve_engine_for_test( self, diff --git a/backend/app/services/llm_service/mixin_tier_escalation.py b/backend/app/services/llm_service/mixin_tier_escalation.py index 9d4f19ef..eedf7e2b 100644 --- a/backend/app/services/llm_service/mixin_tier_escalation.py +++ b/backend/app/services/llm_service/mixin_tier_escalation.py @@ -62,7 +62,7 @@ class LLMTierEscalationMixin: ) def _is_low_tier_function(self, function_name: str, routing_meta: dict[str, Any] | None = None) -> bool: - rule = self._match_openclaw_rule(function_name) + rule = self._match_control_plane_rule(function_name) if rule is not None: tier = str(rule.get("tier") or "").strip().lower() if tier: @@ -165,26 +165,26 @@ class LLMTierEscalationMixin: return candidate return None - def _match_openclaw_high_tier_rule_for_escalation(self, function_name: str) -> Optional[dict[str, Any]]: - bundle = self._load_openclaw_routing_bundle() + def _match_control_plane_high_tier_rule_for_escalation(self, function_name: str) -> Optional[dict[str, Any]]: + bundle = self._load_control_plane_routing_bundle() rule = find_matching_rule( function_name, tier_filter={"high"}, bundle=bundle, ) if not isinstance(rule, dict): - _runtime_log("LLM路由", f"OpenClaw高阶规则未命中:function={function_name}") + _runtime_log("LLM路由", f"PMFlow 本地路由层高阶规则未命中:function={function_name}") return None _runtime_log( "LLM路由", ( - "OpenClaw高阶规则候选:" + "PMFlow 本地路由层高阶规则候选:" f"function={function_name}, rule={rule.get('name') or 'unnamed'}, priority={rule.get('priority')}" ), ) return rule - async def _resolve_openclaw_high_tier_engine_for_escalation( + async def _resolve_control_plane_high_tier_engine_for_escalation( self, *, bundle: dict[str, Any], @@ -193,7 +193,7 @@ class LLMTierEscalationMixin: function_name: str, failed_engine: dict[str, Any], ) -> Optional[dict[str, Any]]: - rule = self._match_openclaw_high_tier_rule_for_escalation(function_name) + rule = self._match_control_plane_high_tier_rule_for_escalation(function_name) if not isinstance(rule, dict): return None routed = self._resolve_engine_from_rule( @@ -205,17 +205,17 @@ class LLMTierEscalationMixin: _runtime_log( "LLM路由", ( - "OpenClaw高阶规则命中但未解析到可用引擎:" + "PMFlow 本地路由层高阶规则命中但未解析到可用引擎:" f"function={function_name}, rule={rule.get('name') or 'unnamed'}" ), ) return None - routed = self._ensure_openclaw_control_plane_engine(routed) + routed = self._ensure_control_plane_engine(routed) if self._same_runtime_engine(routed, failed_engine): _runtime_log( "LLM路由", ( - "OpenClaw高阶升级命中但与失败引擎同源,跳过:" + "PMFlow 本地路由层高阶升级命中但与失败引擎同源,跳过:" f"function={function_name}, tier=high" ), ) @@ -225,19 +225,19 @@ class LLMTierEscalationMixin: engine=routed, function_name=function_name, settings=self._normalize_engine_settings(bundle.get("settings")), - engine_label="openclaw_high_tier_failover", + engine_label="control_plane_high_tier_failover", ) except Exception as exc: _runtime_log( "LLM路由", ( - "OpenClaw高阶升级被拦截:" + "PMFlow 本地路由层高阶升级被拦截:" f"function={function_name}, rule={rule.get('name') or 'unnamed'}, " f"error={type(exc).__name__}:{str(exc)[:180]}" ), ) return None - config_snapshot = build_routing_config_snapshot(bundle=self._load_openclaw_routing_bundle()) + config_snapshot = build_routing_config_snapshot(bundle=self._load_control_plane_routing_bundle()) routed = _merge_engine_routing_meta( routed, tier_alias="high", @@ -253,7 +253,7 @@ class LLMTierEscalationMixin: _runtime_log( "LLM路由", ( - "OpenClaw高阶升级命中:" + "PMFlow 本地路由层高阶升级命中:" f"function={function_name}, rule={rule.get('name') or 'unnamed'}, tier=high" ), ) @@ -291,7 +291,7 @@ class LLMTierEscalationMixin: f"function={function_name}, from_tier=low, to_tier=high, reason={str(error)[:120]}" ), ) - high_engine = await self._resolve_openclaw_high_tier_engine_for_escalation( + high_engine = await self._resolve_control_plane_high_tier_engine_for_escalation( bundle=bundle, engines=engines, active_engine=active_engine, @@ -302,7 +302,7 @@ class LLMTierEscalationMixin: _runtime_log( "LLM路由", ( - "OpenClaw高阶升级未命中,回退通用 high tier:" + "PMFlow 本地路由层高阶升级未命中,回退通用 high tier:" f"function={function_name}, reason={str(error)[:120]}" ), ) @@ -430,7 +430,7 @@ class LLMTierEscalationMixin: f"force_intent_retry={force_intent_retry}" ), ) - high_engine = await self._resolve_openclaw_high_tier_engine_for_escalation( + high_engine = await self._resolve_control_plane_high_tier_engine_for_escalation( bundle=bundle, engines=engines, active_engine=active_engine, @@ -441,7 +441,7 @@ class LLMTierEscalationMixin: _runtime_log( "LLM路由", ( - "OpenClaw高阶结构化升级未命中,回退通用 high tier:" + "PMFlow 本地路由层高阶结构化升级未命中,回退通用 high tier:" f"function={function_name}, reason={str(error)[:120]}, " f"force_intent_retry={force_intent_retry}" ), diff --git a/backend/app/services/llm_service/service.py b/backend/app/services/llm_service/service.py index ba277d41..d41f6a6f 100644 --- a/backend/app/services/llm_service/service.py +++ b/backend/app/services/llm_service/service.py @@ -9,7 +9,7 @@ from .mixin_generate_db_chat import LLMGenerateDbChatMixin from .mixin_generate_db_structured import LLMGenerateDbStructuredMixin from .mixin_generate_entry import LLMGenerateEntryMixin from .mixin_guardrails_prompt import LLMGuardrailsPromptMixin -from .mixin_openclaw_routing import LLMOpenclawRoutingMixin +from .mixin_control_plane_routing import LLMControlPlaneRoutingMixin from .mixin_prompt_profile_runtime import LLMPromptProfileRuntimeMixin from .mixin_structured_candidates import LLMStructuredCandidatesMixin from .mixin_structured_extract_object import LLMStructuredExtractObjectMixin @@ -30,7 +30,7 @@ class LLMService( LLMChatOpenAIMixin, LLMTaskHelpersMixin, LLMEngineResolutionCoreMixin, - LLMOpenclawRoutingMixin, + LLMControlPlaneRoutingMixin, LLMTierEscalationMixin, LLMGuardrailsPromptMixin, LLMStructuredParseMixin, diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py index 1cda1da7..02e33c7c 100644 --- a/backend/app/services/llm_service/shared_part_01.py +++ b/backend/app/services/llm_service/shared_part_01.py @@ -59,9 +59,9 @@ SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} MODEL_CONFIG_BUNDLE = load_model_config() PROVIDER_DEFAULTS: dict[str, dict[str, str]] = get_provider_defaults() TIER_DEFAULTS: dict[str, dict[str, str]] = get_tier_defaults() -OPENCLAW_DEFAULT_LOW_MODEL = str(TIER_DEFAULTS.get("low", {}).get("model") or "qwen-plus") -OPENCLAW_DEFAULT_HIGH_MODEL = str(TIER_DEFAULTS.get("high", {}).get("model") or "GLM-5") -OPENCLAW_DASHSCOPE_BASE_URL = str(PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") +CONTROL_PLANE_DEFAULT_LOW_MODEL = str(TIER_DEFAULTS.get("low", {}).get("model") or "qwen-plus") +CONTROL_PLANE_DEFAULT_HIGH_MODEL = str(TIER_DEFAULTS.get("high", {}).get("model") or "GLM-5") +CONTROL_PLANE_DASHSCOPE_BASE_URL = str(PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") PROVIDER_ALIASES = { "zhipu": "glm", "chatglm": "glm", @@ -70,9 +70,9 @@ PROVIDER_ALIASES = { } PROMPT_PROFILE_CACHE_TTL_SECONDS = 60.0 BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} -OPENCLAW_ROUTING_CACHE_TTL_SECONDS = 30.0 -OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS = 120 -OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120 +CONTROL_PLANE_ROUTING_CACHE_TTL_SECONDS = 30.0 +CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS = 120 +CONTROL_PLANE_AGENTS_TIMEOUT_SECONDS_MAX = 120 DEFAULT_TIER_MODEL_OVERRIDES = { tier: str(spec.get("model") or "") for tier, spec in TIER_DEFAULTS.items() diff --git a/backend/app/services/llm_service/shared_part_02.py b/backend/app/services/llm_service/shared_part_02.py index 47099c19..451dd34a 100644 --- a/backend/app/services/llm_service/shared_part_02.py +++ b/backend/app/services/llm_service/shared_part_02.py @@ -157,7 +157,7 @@ def _cn_now_text_ms() -> str: def _runtime_log(scope: str, message: str) -> None: print(f"[{_cn_now_text()}][{scope}] {message}") -def _is_openclaw_timeout_message(text: str) -> bool: +def _is_control_plane_timeout_message(text: str) -> bool: lowered = str(text or "").strip().lower() if not lowered: return False diff --git a/backend/app/services/model/model_config.py b/backend/app/services/model/model_config.py index 24187765..bddff538 100644 --- a/backend/app/services/model/model_config.py +++ b/backend/app/services/model/model_config.py @@ -6,7 +6,7 @@ from copy import deepcopy from pathlib import Path from typing import Any -DEFAULT_ROUTING_RULES_WORKSPACE_PATH = "backend/.openclaw/pmflow-routing-rules.json" +DEFAULT_ROUTING_RULES_WORKSPACE_PATH = "backend/.control_plane/pmflow-routing-rules.json" _WORKSPACE_ROOT = Path(__file__).resolve().parents[4] _DEFAULT_ROUTING_RULES_PATH = (_WORKSPACE_ROOT / DEFAULT_ROUTING_RULES_WORKSPACE_PATH).resolve() @@ -79,7 +79,7 @@ def _normalize_tier(tier: str) -> str: def resolve_model_config_path(path: str | Path | None = None) -> Path: - raw = path if path is not None else os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") + raw = path if path is not None else os.getenv("CONTROL_PLANE_ROUTING_RULES_PATH", "") raw_text = str(raw or "").strip() if not raw_text: return _DEFAULT_ROUTING_RULES_PATH @@ -212,7 +212,7 @@ def _normalize_bundle(raw: Any, *, config_path: Path) -> dict[str, Any]: bundle = { "version": str(payload.get("version") or "v3").strip(), "updated_at": str(payload.get("updated_at") or "").strip(), - "source_of_truth": str(payload.get("source_of_truth") or "openclaw").strip(), + "source_of_truth": str(payload.get("source_of_truth") or "control_plane").strip(), "description": str(payload.get("description") or "").strip(), "config_path": str(config_path), "provider_defaults": _normalize_provider_defaults(payload.get("provider_defaults")), diff --git a/backend/app/services/mounted_ai_runtime_write_service.py b/backend/app/services/mounted_ai_runtime_write_service.py index 9396179f..74dd5f7d 100644 --- a/backend/app/services/mounted_ai_runtime_write_service.py +++ b/backend/app/services/mounted_ai_runtime_write_service.py @@ -4,11 +4,20 @@ import hashlib from dataclasses import dataclass from typing import Any, Optional -from sqlalchemy import or_, select +from sqlalchemy import and_, or_, select from sqlalchemy.ext.asyncio import AsyncSession from .ai_pmo.ai_pmo_intent import ROUTE_QUERY_ANSWER, ROUTE_REQUIREMENT_PLAN_COLLECT, ROUTE_REQUIREMENT_PLAN_DRAFT -from ..models import FeishuReplyOutbox, MountedAgentTrace, MountedConversationRun, MountedSourceActivation, MountedTopicWorkspace +from ..models import ( + FeishuReplyOutbox, + IngestEvent, + MountedAgentTrace, + MountedConversationRun, + MountedDraftWorkspace, + MountedSourceActivation, + MountedTopicWorkspace, + WorkItem, +) from ..utils.thread_context import build_feishu_conversation_scope_key from ..utils.timezone import now_cn_naive @@ -198,10 +207,12 @@ class MountedAIRuntimeWriteService: @classmethod def _topic_has_requirement_plan_context(cls, topic_row: MountedTopicWorkspace) -> bool: + if str(getattr(topic_row, "status", "") or "").strip().lower() != "active": + return False metadata = topic_row.metadata_json if isinstance(getattr(topic_row, "metadata_json", None), dict) else {} if bool(metadata.get("plan_mode")): return True - if str(getattr(topic_row, "mode", "") or "").strip().lower() == "plan": + if str(getattr(topic_row, "mode", "") or "").strip().lower() in {"plan", "draft_ready"}: return True if str(getattr(topic_row, "plan_status", "") or "").strip().lower() in { "collecting", @@ -219,6 +230,198 @@ class MountedAIRuntimeWriteService: return True return False + @classmethod + def _ingest_payload_matches_conversation_scope( + cls, + *, + source_system: str, + conversation_scope_key: Optional[str], + payload: Any, + ) -> bool: + if not conversation_scope_key or not isinstance(payload, dict): + return False + candidate_scope_key = cls.build_conversation_scope_key( + source_system=source_system, + channel_ref=str(payload.get("channel_ref") or "").strip() or None, + sender=str(payload.get("sender") or "").strip(), + source_sender_open_id=str(payload.get("source_sender_open_id") or "").strip() or None, + ) + return bool(candidate_scope_key and candidate_scope_key == conversation_scope_key) + + async def terminate_requirement_plan_scope( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: Optional[str], + sender: str, + source_sender_open_id: Optional[str], + source_message_id: Optional[str], + ) -> dict[str, Any]: + normalized_source = self._compact(source_system, max_len=32) or "unknown" + scope_key = self.build_conversation_scope_key( + source_system=normalized_source, + channel_ref=channel_ref, + sender=sender, + source_sender_open_id=source_sender_open_id, + ) + if not scope_key or not hasattr(self.db, "execute"): + return { + "terminated_count": 0, + "terminated_topic_ids": [], + "terminated_work_item_ids": [], + "conversation_scope_key": None, + } + + topic_rows = ( + await self.db.execute( + select(MountedTopicWorkspace).where( + MountedTopicWorkspace.tenant_id == int(tenant_id), + MountedTopicWorkspace.source_system == normalized_source, + MountedTopicWorkspace.conversation_scope_key == scope_key, + MountedTopicWorkspace.status == "active", + ) + ) + ).scalars().all() + draft_rows = [] + if topic_rows: + draft_rows = ( + await self.db.execute( + select(MountedDraftWorkspace).where( + MountedDraftWorkspace.tenant_id == int(tenant_id), + MountedDraftWorkspace.topic_workspace_id.in_([int(row.id) for row in topic_rows]), + MountedDraftWorkspace.state.in_(("collecting", "ready", "draft_pending_confirm")), + ) + ) + ).scalars().all() + draft_by_topic_id = {int(row.topic_workspace_id): row for row in draft_rows} + + terminated_topic_ids: list[int] = [] + terminated_work_item_ids: list[int] = [] + now_at = now_cn_naive() + now_iso = now_at.isoformat() + normalized_source_message_id = self._compact(source_message_id, max_len=128) + + for topic_row in topic_rows: + if not self._topic_has_requirement_plan_context(topic_row): + continue + draft_row = draft_by_topic_id.get(int(topic_row.id)) + active_work_item_id = None + if draft_row is not None: + try: + active_work_item_id = int(getattr(draft_row, "active_work_item_id", None)) + except (TypeError, ValueError): + active_work_item_id = None + if active_work_item_id is not None: + work_item = ( + await self.db.execute( + select(WorkItem).where( + WorkItem.tenant_id == int(tenant_id), + WorkItem.id == int(active_work_item_id), + ).limit(1) + ) + ).scalar_one_or_none() + if work_item is not None: + current_state = str(getattr(work_item, "state", "") or "").strip().lower() + if current_state == "draft_pending_confirm": + work_item.state = "cancelled" + if hasattr(work_item, "draft_expires_at"): + work_item.draft_expires_at = None + if hasattr(work_item, "reply_template_key"): + work_item.reply_template_key = "draft_cancelled" + existing_execution = ( + dict(work_item.execution_result) + if isinstance(getattr(work_item, "execution_result", None), dict) + else {} + ) + work_item.execution_result = { + **existing_execution, + "status": "cancelled", + "cancel_reason": "stale_requirement_scope_terminated", + "cancelled_at": now_iso, + "terminated_by_message_id": normalized_source_message_id, + } + terminated_work_item_ids.append(int(work_item.id)) + if draft_row is not None: + draft_row.active_work_item_id = None + draft_row.confirm_message_id = None + draft_row.state = "cancelled" + + topic_row.mode = "chat" + topic_row.status = "suspended" + topic_row.plan_status = "suspended" + topic_row.pending_actions = [] + topic_row.open_questions = [] + topic_row.last_message_id = normalized_source_message_id or topic_row.last_message_id + topic_row.last_triggered_at = now_at + topic_row.metadata_json = self._merge_json_dict( + topic_row.metadata_json, + { + "plan_mode": False, + "plan_terminated": True, + "plan_terminated_at": now_iso, + "plan_terminated_by_message_id": normalized_source_message_id, + "draft_lifecycle_state": "cancelled", + "active_draft_work_item_id": None, + "suspended_by_message_id": normalized_source_message_id, + }, + ) + terminated_topic_ids.append(int(topic_row.id)) + + orphan_pending_rows = ( + await self.db.execute( + select(WorkItem, IngestEvent) + .join( + IngestEvent, + IngestEvent.id == WorkItem.ingest_event_id, + ) + .where( + and_( + WorkItem.tenant_id == int(tenant_id), + WorkItem.source_system == normalized_source, + WorkItem.state == "draft_pending_confirm", + ) + ) + ) + ).all() + for work_item, ingest_event in orphan_pending_rows: + work_item_id = int(getattr(work_item, "id")) + if work_item_id in terminated_work_item_ids: + continue + payload = ingest_event.payload if isinstance(getattr(ingest_event, "payload", None), dict) else {} + if not self._ingest_payload_matches_conversation_scope( + source_system=normalized_source, + conversation_scope_key=scope_key, + payload=payload, + ): + continue + if hasattr(work_item, "draft_expires_at"): + work_item.draft_expires_at = None + if hasattr(work_item, "reply_template_key"): + work_item.reply_template_key = "draft_cancelled" + existing_execution = ( + dict(work_item.execution_result) + if isinstance(getattr(work_item, "execution_result", None), dict) + else {} + ) + work_item.state = "cancelled" + work_item.execution_result = { + **existing_execution, + "status": "cancelled", + "cancel_reason": "stale_requirement_scope_terminated", + "cancelled_at": now_iso, + "terminated_by_message_id": normalized_source_message_id, + } + terminated_work_item_ids.append(work_item_id) + + await self.db.flush() + return { + "terminated_count": len(terminated_topic_ids), + "terminated_topic_ids": terminated_topic_ids, + "terminated_work_item_ids": terminated_work_item_ids, + "conversation_scope_key": scope_key, + } + @staticmethod def _extract_result_topic_workspace_id(result: Optional[dict[str, Any]]) -> Optional[int]: if not isinstance(result, dict): diff --git a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py index c9c3993d..331569a2 100644 --- a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py +++ b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py @@ -6,14 +6,14 @@ class PKGGuardrailsMixin: @staticmethod def _routing_rule_candidate_paths() -> list[Path]: candidates: list[Path] = [] - configured = str(os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") or "").strip() + configured = str(os.getenv("CONTROL_PLANE_ROUTING_RULES_PATH", "") or "").strip() if configured: candidates.append(Path(configured).expanduser()) backend_root = Path(__file__).resolve().parents[2] candidates.extend( [ - backend_root / ".openclaw" / "pmflow-routing-rules.json", + backend_root / ".control_plane" / "pmflow-routing-rules.json", ] ) diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py index dd3cb55b..1fe6ee71 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py @@ -49,10 +49,8 @@ class SignalCaptureEntryMixin: ).strip() if not anchor: return normalized_context or None - normalized_context["root_message_id"] = str(normalized_context.get("root_message_id") or anchor).strip() or anchor - normalized_context["reply_to_message_id"] = ( - str(normalized_context.get("reply_to_message_id") or anchor).strip() or anchor - ) + normalized_context["root_message_id"] = anchor + normalized_context["reply_to_message_id"] = anchor return normalized_context async def _resolve_group_protocol_followup_without_explicit_trigger( @@ -141,6 +139,28 @@ class SignalCaptureEntryMixin: ), } + @classmethod + def _looks_like_group_protocol_followup_text( + cls, + *, + message_text: str, + ) -> bool: + try: + from ...ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project import ExecuteQueryProjectMixin + + if ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text) is not None: + return True + except Exception: + pass + try: + from ...ai_pmo.draft_confirm_handler import DraftConfirmHandler + + if DraftConfirmHandler.is_explicit_protocol_followup_text(message_text): + return True + except Exception: + pass + return False + @classmethod async def _should_process_feishu_high_accuracy( cls, @@ -225,8 +245,26 @@ class SignalCaptureEntryMixin: message_text=cleaned_message, ) + mounted_runtime = MountedAIRuntimeWriteService(self.db) + if isinstance(thread_context, dict) and thread_context: + try: + canonical_thread_context, _ = await mounted_runtime.canonicalize_thread_context( + tenant_id=int(tenant_id), + source_system="feishu", + channel_ref=group_chat_id, + thread_context=thread_context, + ) + if isinstance(canonical_thread_context, dict) and canonical_thread_context: + thread_context = canonical_thread_context + except Exception as exc: + self._emit_ai_think( + "capture_thread_context_canonicalize_failed", + trace_id=trace_id, + error=str(exc), + ) + try: - activation, has_activation_rules = await MountedAIRuntimeWriteService(self.db).resolve_source_activation_scope( + activation, has_activation_rules = await mounted_runtime.resolve_source_activation_scope( tenant_id=int(tenant_id), source_system="feishu", channel_ref=group_chat_id, @@ -262,6 +300,10 @@ class SignalCaptureEntryMixin: kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) try: + normalized_chat_type = str(chat_type or "").strip().lower() + protocol_like_followup = self._looks_like_group_protocol_followup_text( + message_text=cleaned_message, + ) should_sync_process = bool(force_sync) sync_reason = "force_sync" if should_sync_process else "" if not should_sync_process: @@ -269,12 +311,15 @@ class SignalCaptureEntryMixin: message_text=cleaned_message, ) - normalized_chat_type = str(chat_type or "").strip().lower() - if ( + protocol_followup_context = None + should_probe_group_protocol_followup = ( normalized_chat_type == "group" - and not has_explicit_group_trigger - and str(sync_reason or "").strip().lower() in {"semantic:query_flow", "semantic:followup_flow"} - ): + and ( + (not has_explicit_group_trigger and str(sync_reason or "").strip().lower() in {"semantic:query_flow", "semantic:followup_flow"}) + or (has_explicit_group_trigger and protocol_like_followup) + ) + ) + if should_probe_group_protocol_followup: protocol_followup_context = await self._resolve_group_protocol_followup_without_explicit_trigger( kernel=kernel, group_chat_id=group_chat_id, @@ -294,7 +339,10 @@ class SignalCaptureEntryMixin: ) if normalized_thread_context: thread_context = normalized_thread_context - else: + elif ( + not has_explicit_group_trigger + and str(sync_reason or "").strip().lower() in {"semantic:query_flow", "semantic:followup_flow"} + ): self._emit_ai_think( "capture_skip_group_explicit_trigger_required", trace_id=trace_id, diff --git a/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py index e6bddb98..1b46b817 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py +++ b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py @@ -131,10 +131,10 @@ class SignalCapturePipelineCacheMixin: else: orchestration_status = str(source_orchestration_result.get("status") or "").lower() if orchestration_status in {"completed", "partial", "planned", "pending_manual"}: - auto_tasks = self._build_openclaw_auto_tasks( + auto_tasks = self._build_control_plane_auto_tasks( source_orchestration_result.get("execution") if isinstance(source_orchestration_result.get("execution"), dict) - else source_orchestration_result.get("openclaw"), + else source_orchestration_result.get("control_plane"), ) try: strategic_payload: Dict[str, Any] = {} @@ -232,10 +232,10 @@ class SignalCapturePipelineCacheMixin: return pipeline_result @staticmethod - def _build_openclaw_auto_tasks(openclaw_payload: Any) -> List[Dict[str, Any]]: - if not isinstance(openclaw_payload, dict): + def _build_control_plane_auto_tasks(control_plane_payload: Any) -> List[Dict[str, Any]]: + if not isinstance(control_plane_payload, dict): return [] - result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + result = control_plane_payload.get("result") if isinstance(control_plane_payload.get("result"), dict) else control_plane_payload execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} rows = execution.get("results") if isinstance(execution.get("results"), list) else [] items: List[Dict[str, Any]] = [] diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_entry_terminal_b.py b/backend/app/services/signal/signal_source_orchestration/mixin_entry_terminal_b.py index 3c9a5849..ee73a379 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_entry_terminal_b.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_entry_terminal_b.py @@ -27,7 +27,7 @@ class SignalSourceEntryTerminalBMixin: execution_result=execution_result, worker_results=[], ) - final_status = self._resolve_orchestration_status(openclaw_status=execution_status, worker_results=[]) + final_status = self._resolve_orchestration_status(control_plane_status=execution_status, worker_results=[]) if final_status == "completed" and onboarding_status != "completed": final_status = "pending_manual" final_result = self._build_orchestration_result( @@ -135,7 +135,7 @@ class SignalSourceEntryTerminalBMixin: project_payload=self._serialize_snapshot(project_ref), signal_payload=self._serialize_snapshot(signal_ref), registry_result=dict(registry_result), - openclaw_result=dict(execution_result), + control_plane_result=dict(execution_result), worker_role_keys=list(worker_role_keys), operator_user_payload=self._serialize_snapshot(operator_ref), planner_status=planner_status, diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_execute_isolation.py b/backend/app/services/signal/signal_source_orchestration/mixin_execute_isolation.py index 581e2700..3c9d67a8 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_execute_isolation.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_execute_isolation.py @@ -11,7 +11,7 @@ class SignalSourceExecuteIsolationMixin: project_payload: dict[str, Any], signal_payload: dict[str, Any], registry_result: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], role: dict[str, Any], trace_id: str, operator_user_payload: Optional[dict[str, Any]], @@ -25,7 +25,7 @@ class SignalSourceExecuteIsolationMixin: project=SimpleNamespace(**project_payload), signal=SimpleNamespace(**signal_payload), registry_result=registry_result, - execution_result=openclaw_result, + execution_result=control_plane_result, role=role, trace_id=trace_id, operator_user=SimpleNamespace(**operator_user_payload) if isinstance(operator_user_payload, dict) else None, @@ -42,7 +42,7 @@ class SignalSourceExecuteIsolationMixin: project_payload: dict[str, Any], signal_payload: dict[str, Any], registry_result: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], worker_role_keys: list[str], operator_user_payload: Optional[dict[str, Any]], planner_status: str, @@ -57,7 +57,7 @@ class SignalSourceExecuteIsolationMixin: project_payload=project_payload, signal_payload=signal_payload, registry_result=registry_result, - execution_result=openclaw_result, + execution_result=control_plane_result, worker_role_keys=worker_role_keys, operator_user_payload=operator_user_payload, planner_status=planner_status, @@ -100,7 +100,7 @@ class SignalSourceExecuteIsolationMixin: "source_name": source_payload.get("source_name"), "source_identifier": source_payload.get("source_identifier"), }, - execution_result=openclaw_result, + execution_result=control_plane_result, worker_results=worker_results, pending_workers=pending_workers, status="failed", @@ -113,7 +113,7 @@ class SignalSourceExecuteIsolationMixin: trace_id=trace_id, status="failed", planner_status=planner_status, - execution_status=str(openclaw_result.get("status") or row.openclaw_status or ""), + execution_status=str(control_plane_result.get("status") or row.control_plane_status or ""), worker_total=max(len(worker_role_keys), int(row.worker_total or 0)), worker_completed=int(row.worker_completed or 0), worker_failed=int(row.worker_failed or 0), @@ -134,7 +134,7 @@ class SignalSourceExecuteIsolationMixin: project_payload: dict[str, Any], signal_payload: dict[str, Any], registry_result: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], worker_role_keys: list[str], operator_user_payload: Optional[dict[str, Any]], planner_status: str, @@ -170,7 +170,7 @@ class SignalSourceExecuteIsolationMixin: project_payload=project_payload, signal_payload=signal_payload, registry_result=registry_result, - execution_result=openclaw_result, + execution_result=control_plane_result, role=dict(role), trace_id=trace_id, operator_user_payload=operator_user_payload, @@ -196,7 +196,7 @@ class SignalSourceExecuteIsolationMixin: planner_status=planner_status, planner_diagnostics=planner_diagnostics, registry_result=registry_result, - openclaw_result=openclaw_result, + control_plane_result=control_plane_result, worker_role_keys=worker_role_keys, worker_results=worker_results, ) @@ -209,7 +209,7 @@ class SignalSourceExecuteIsolationMixin: planner_status=planner_status, planner_diagnostics=planner_diagnostics, registry_result=registry_result, - openclaw_result=openclaw_result, + control_plane_result=control_plane_result, worker_role_keys=worker_role_keys, worker_results=worker_results, ) diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py b/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py index a41cee1c..598d919c 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py @@ -510,7 +510,7 @@ class SignalSourceRegistryExecuteMixin: project: Project, signal: Signal, registry_result: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], worker_role_keys: list[str], trace_id: str, operator_user: Optional[User], @@ -546,7 +546,7 @@ class SignalSourceRegistryExecuteMixin: project_payload=self._serialize_snapshot(project), signal_payload=self._serialize_snapshot(signal), registry_result=dict(registry_result), - execution_result=dict(openclaw_result), + execution_result=dict(control_plane_result), role=dict(role), trace_id=trace_id, operator_user_payload=self._serialize_snapshot(operator_user), @@ -570,7 +570,7 @@ class SignalSourceRegistryExecuteMixin: project: Project, signal: Signal, registry_result: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], role: dict[str, Any], trace_id: str, operator_user: Optional[User], @@ -598,8 +598,8 @@ class SignalSourceRegistryExecuteMixin: operator_user_id=int(operator_user.id) if operator_user is not None else None, trace_id=trace_id, operation_trace_id=( - str((openclaw_result.get("result") or {}).get("trace_id") or "").strip() - if isinstance(openclaw_result.get("result"), dict) + str((control_plane_result.get("result") or {}).get("trace_id") or "").strip() + if isinstance(control_plane_result.get("result"), dict) else None ), execution_type="signal_source_onboarding", @@ -619,7 +619,7 @@ class SignalSourceRegistryExecuteMixin: project=project, signal=signal, source_payload=source_payload, - execution_result=openclaw_result, + execution_result=control_plane_result, ) if dry_run: return { diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_result_resolution.py b/backend/app/services/signal/signal_source_orchestration/mixin_result_resolution.py index 3872df84..48baaa55 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_result_resolution.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_result_resolution.py @@ -123,17 +123,17 @@ class SignalSourceResultResolutionMixin: return False @staticmethod - def _extract_openclaw_execution_rows(openclaw_result: dict[str, Any]) -> list[dict[str, Any]]: - if not isinstance(openclaw_result, dict): + def _extract_control_plane_execution_rows(control_plane_result: dict[str, Any]) -> list[dict[str, Any]]: + if not isinstance(control_plane_result, dict): return [] - result = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result + result = control_plane_result.get("result") if isinstance(control_plane_result.get("result"), dict) else control_plane_result execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} rows = execution.get("results") if isinstance(execution.get("results"), list) else [] return [row for row in rows if isinstance(row, dict)] @classmethod - def _has_material_openclaw_execution(cls, openclaw_result: dict[str, Any]) -> bool: - for row in cls._extract_openclaw_execution_rows(openclaw_result): + def _has_material_control_plane_execution(cls, control_plane_result: dict[str, Any]) -> bool: + for row in cls._extract_control_plane_execution_rows(control_plane_result): status = str(row.get("status") or "").strip().lower() if status not in {"applied", "skipped"}: continue @@ -148,27 +148,27 @@ class SignalSourceResultResolutionMixin: cls, *, source_payload: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], worker_results: list[dict[str, Any]], ) -> tuple[str, str]: actionable_config = cls._has_actionable_source_config(source_payload=source_payload) has_real_worker = cls._has_non_fallback_worker_result(worker_results) - has_material_openclaw = cls._has_material_openclaw_execution(openclaw_result) - if actionable_config and has_real_worker and has_material_openclaw: + has_material_control_plane = cls._has_material_control_plane_execution(control_plane_result) + if actionable_config and has_real_worker and has_material_control_plane: return "active", "completed" return "pending", "pending_manual" @staticmethod - def _resolve_orchestration_status(*, openclaw_status: str, worker_results: list[dict[str, Any]]) -> str: - normalized_openclaw = str(openclaw_status or "").strip().lower() + def _resolve_orchestration_status(*, control_plane_status: str, worker_results: list[dict[str, Any]]) -> str: + normalized_control_plane = str(control_plane_status or "").strip().lower() worker_statuses = [str(item.get("status") or "").strip().lower() for item in worker_results] has_worker_completed = any(status == "completed" for status in worker_statuses) has_worker_failed = any(status == "failed" for status in worker_statuses) - if normalized_openclaw == "failed" and not has_worker_completed: + if normalized_control_plane == "failed" and not has_worker_completed: return "failed" - if has_worker_failed or normalized_openclaw == "failed": + if has_worker_failed or normalized_control_plane == "failed": return "partial" - if has_worker_completed or normalized_openclaw == "completed": + if has_worker_completed or normalized_control_plane == "completed": return "completed" return "failed" diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_result_run_updates.py b/backend/app/services/signal/signal_source_orchestration/mixin_result_run_updates.py index 8f64b8c3..1edb3543 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_result_run_updates.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_result_run_updates.py @@ -12,7 +12,7 @@ class SignalSourceResultRunUpdatesMixin: planner_status: str, planner_diagnostics: Optional[dict[str, Any]], registry_result: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], worker_role_keys: list[str], worker_results: list[dict[str, Any]], ) -> None: @@ -29,7 +29,7 @@ class SignalSourceResultRunUpdatesMixin: } pending_workers = [key for key in worker_role_keys if key not in completed_keys] status = "partial" if pending_workers else self._resolve_orchestration_status( - openclaw_status=str(openclaw_result.get("status") or ""), + control_plane_status=str(control_plane_result.get("status") or ""), worker_results=worker_results, ) result_payload = self._build_orchestration_result( @@ -47,7 +47,7 @@ class SignalSourceResultRunUpdatesMixin: "source_name": (source_payload or {}).get("source_name"), "source_identifier": (source_payload or {}).get("source_identifier"), }, - execution_result=openclaw_result, + execution_result=control_plane_result, worker_results=worker_results, pending_workers=pending_workers, status=status, @@ -58,7 +58,7 @@ class SignalSourceResultRunUpdatesMixin: trace_id=trace_id, status="running" if pending_workers else status, planner_status=planner_status, - execution_status=str(openclaw_result.get("status") or ""), + execution_status=str(control_plane_result.get("status") or ""), worker_total=len(worker_role_keys), worker_completed=completed_count, worker_failed=failed_count, @@ -79,7 +79,7 @@ class SignalSourceResultRunUpdatesMixin: planner_status: str, planner_diagnostics: Optional[dict[str, Any]], registry_result: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], worker_role_keys: list[str], worker_results: list[dict[str, Any]], ) -> dict[str, Any]: @@ -88,12 +88,12 @@ class SignalSourceResultRunUpdatesMixin: response_mode = str(row.response_mode or "fast") if row is not None else "fast" source_payload = row.result_payload.get("signal_source") if row is not None and isinstance(row.result_payload, dict) else {} final_status = self._resolve_orchestration_status( - openclaw_status=str(openclaw_result.get("status") or ""), + control_plane_status=str(control_plane_result.get("status") or ""), worker_results=worker_results, ) registry_status, onboarding_status = self._resolve_registry_activation( source_payload=source_payload if isinstance(source_payload, dict) else {}, - openclaw_result=openclaw_result, + control_plane_result=control_plane_result, worker_results=worker_results, ) if final_status == "completed" and onboarding_status != "completed": @@ -115,7 +115,7 @@ class SignalSourceResultRunUpdatesMixin: "source_name": (source_payload or {}).get("source_name"), "source_identifier": (source_payload or {}).get("source_identifier"), }, - execution_result=openclaw_result, + execution_result=control_plane_result, worker_results=worker_results, pending_workers=[], status=final_status, @@ -126,7 +126,7 @@ class SignalSourceResultRunUpdatesMixin: trace_id=trace_id, status=final_status, planner_status=planner_status, - execution_status=str(openclaw_result.get("status") or ""), + execution_status=str(control_plane_result.get("status") or ""), worker_total=len(worker_role_keys), worker_completed=completed_count, worker_failed=failed_count, diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_worker_prompt_persist.py b/backend/app/services/signal/signal_source_orchestration/mixin_worker_prompt_persist.py index 9f2cb449..5f7e2418 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_worker_prompt_persist.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_worker_prompt_persist.py @@ -10,7 +10,7 @@ class SignalSourceWorkerPromptPersistMixin: project: Project, signal: Signal, source_payload: dict[str, Any], - openclaw_result: dict[str, Any], + control_plane_result: dict[str, Any], ) -> str: role_payload = { "role_key": str(role.get("role_key") or "").strip(), @@ -28,7 +28,7 @@ class SignalSourceWorkerPromptPersistMixin: "title": str(signal.title or "").strip(), "summary": str(signal.summary or signal.content or "").strip()[:1200], } - commander_payload = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result + commander_payload = control_plane_result.get("result") if isinstance(control_plane_result.get("result"), dict) else control_plane_result embedded_prompt = PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION.format( role_json=json.dumps(role_payload, ensure_ascii=False), project_json=json.dumps(project_payload, ensure_ascii=False), @@ -83,10 +83,10 @@ class SignalSourceWorkerPromptPersistMixin: await self.db.commit() @staticmethod - def _extract_task_operations(openclaw_payload: Any) -> list[dict[str, Any]]: - if not isinstance(openclaw_payload, dict): + def _extract_task_operations(control_plane_payload: Any) -> list[dict[str, Any]]: + if not isinstance(control_plane_payload, dict): return [] - result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload + result = control_plane_payload.get("result") if isinstance(control_plane_payload.get("result"), dict) else control_plane_payload execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {} rows = execution.get("results") if isinstance(execution.get("results"), list) else [] tasks: list[dict[str, Any]] = [] diff --git a/backend/evals/README.md b/backend/evals/README.md index befe9a49..5bee4ea9 100644 --- a/backend/evals/README.md +++ b/backend/evals/README.md @@ -33,8 +33,8 @@ PYTHONPATH=backend python3 backend/evals/feishu_replay_eval.py \ --lint-only \ --input backend/evals/golden_feishu_replay_v1.jsonl -# 5.1) OpenClaw 路由矩阵 lint(L/H/S 覆盖校验) -python3 scripts/openclaw_routing_lint.py --input backend/.openclaw/pmflow-routing-rules.json +# 5.1) PMFlow 本地路由层 路由矩阵 lint(L/H/S 覆盖校验) +python3 scripts/control_plane_routing_lint.py --input backend/.control_plane/pmflow-routing-rules.json # 5.2) Operation Catalog lint(全系统写操作覆盖校验) python3 scripts/operation_catalog_lint.py diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 3ba37a7f..89b93897 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -4,6 +4,8 @@ from types import SimpleNamespace from app.config.ai_prompts import PROMPT_AI_PMO_QA from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnswerEntryMixin from app.services.adaptive_rag_service.mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin +from app.services.adaptive_rag_service.mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin +from app.services.adaptive_rag_service.mixin_conversation_utils_a_p1 import AdaptiveRAGConversationUtilsAMixinP1 from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin from app.services.adaptive_rag_service.mixin_deep_guard_rules import AdaptiveRAGDeepGuardRulesMixin from app.services.adaptive_rag_service.mixin_core_intent import AdaptiveRAGCoreIntentMixin @@ -11,9 +13,11 @@ from app.services.adaptive_rag_service.mixin_deterministic_query_core import ( AdaptiveRAGDeterministicQueryCoreMixin, ) from app.services.adaptive_rag_service.mixin_manifest_feature_p2 import AdaptiveRAGManifestFeatureMixinP2 +from app.services.adaptive_rag_service.mixin_multistep_core import AdaptiveRAGMultiStepCoreMixin from app.services.adaptive_rag_service.mixin_requirement_ack_core import AdaptiveRAGRequirementAckCoreMixin from app.services.adaptive_rag_service.mixin_retrieval_router_db import AdaptiveRAGRetrievalRouterDBMixin from app.services.adaptive_rag_service.mixin_retrieval_vector import AdaptiveRAGRetrievalVectorMixin +from app.services.adaptive_rag_service.shared_part_02 import RetrievedItem from app.services.adaptive_rag_service.shared_part_02 import ACTION_MODE_READ, DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner @@ -994,7 +998,7 @@ def test_runtime_state_query_guard_allows_routing_config_evidence() -> None: { "source": "doc", "label": "Routing Config", - "metadata": {"source_id": "backend/.openclaw/pmflow-routing-rules.json"}, + "metadata": {"source_id": "backend/.control_plane/pmflow-routing-rules.json"}, } ], relevance_max=0.74, @@ -1018,3 +1022,371 @@ def test_engine_process_guard_requests_auto_retry_on_snapshot_only_sources() -> ) assert decision["auto_retry_reason"] == "snapshot_only" + + +def test_chat_external_fact_scene_policy_keeps_fact_challenge_in_chat() -> None: + class _FakeService(AdaptiveRAGAnswerMainlineMixin): + pass + + service = _FakeService() + normalized = service._apply_chat_external_fact_scene_policy( + question="不对吧,截止当前日期都已经有 codex5.4 了", + project_id=None, + scene_semantics={ + "scene_key": "ai_pmo_correction", + "snapshot_focus": "feature", + "deep_intent": "correction_turn", + "confidence": 0.95, + }, + anchor_prompt_variant="external_trend", + ) + + assert normalized["scene_key"] == "industry_trend" + assert normalized["deep_intent"] == "none" + assert normalized["external_fact_chat"] is True + assert normalized["requires_freshness"] is True + + +def test_chat_external_fact_scene_policy_promotes_external_model_brand_queries() -> None: + class _FakeService(AdaptiveRAGAnswerMainlineMixin): + pass + + service = _FakeService() + normalized = service._apply_chat_external_fact_scene_policy( + question="当前codex最新的模型用的是什么?", + project_id=None, + scene_semantics={ + "scene_key": "ai_pmo", + "snapshot_focus": "identity", + "deep_intent": "none", + "confidence": 0.92, + }, + anchor_prompt_variant=None, + ) + + assert normalized["scene_key"] == "industry_trend" + assert normalized["snapshot_focus"] == "none" + assert normalized["external_fact_chat"] is True + assert normalized["requires_freshness"] is True + + +def test_chat_external_fact_scene_policy_keeps_explicit_internal_model_config_question() -> None: + class _FakeService(AdaptiveRAGAnswerMainlineMixin): + pass + + service = _FakeService() + normalized = service._apply_chat_external_fact_scene_policy( + question="我们系统当前接的是哪个 codex 模型配置?", + project_id=None, + scene_semantics={ + "scene_key": "ai_pmo", + "snapshot_focus": "identity", + "deep_intent": "none", + "confidence": 0.92, + }, + anchor_prompt_variant=None, + ) + + assert normalized["scene_key"] == "ai_pmo" + assert normalized["snapshot_focus"] == "identity" + assert normalized.get("external_fact_chat") is not True + + +def test_external_fact_freshness_guard_rejects_internal_only_sources() -> None: + class _FakeService(AdaptiveRAGDeepGuardRulesMixin): + pass + + service = _FakeService() + assert service._requires_external_freshness( + question="codex 目前最高模型出到什么了", + scene_semantics={"scene_key": "industry_trend", "requires_freshness": True}, + ) is True + assert service._has_external_grounding_sources( + [ + {"source": "graph", "label": "Knowledge Graph", "metadata": {"path": "1->belongs_to->2"}}, + {"source": "feature_catalog", "label": "Feature Catalog", "metadata": {}}, + ] + ) is False + answer = service._build_external_freshness_guard_answer("codex 目前最高模型出到什么了") + assert "当前未接入实时外部数据" in answer + assert "backend/app/services" not in answer + + +@pytest.mark.asyncio +async def test_second_pass_query_skips_internal_hints_for_external_freshness_only() -> None: + class _FakeService(AdaptiveRAGMultiStepCoreMixin): + async def entity_resolution(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"entity_resolution should be skipped: {kwargs}") + + async def get_project_manifest(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError(f"get_project_manifest should be skipped: {kwargs}") + + service = _FakeService() + revised, meta = await service._build_second_pass_query( + question="codex 目前最高模型出到什么了", + project_id=None, + query_semantics={"external_freshness_only": True}, + ) + + assert revised == "codex 目前最高模型出到什么了" + assert meta["mode"] == "freshness_guard_skip_internal_hint" + + +@pytest.mark.asyncio +async def test_live_external_freshness_context_fetches_url_rows_before_guard() -> None: + class _FakeService(AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGDeepGuardRulesMixin): + async def _retrieve_live_external_context(self, *, question: str, max_results: int = 3, max_pages: int = 3): + _ = (question, max_results, max_pages) + return ( + [ + RetrievedItem( + source="url", + content="OpenAI 在官方文章中说明 GPT-5.4 正在 rollout 到 Codex。", + score=0.93, + source_label="URL[Introducing GPT-5.4]", + metadata={ + "url": "https://openai.com/index/introducing-gpt-5-4/", + "target_url": "https://openai.com/index/introducing-gpt-5-4/", + "title": "Introducing GPT-5.4", + }, + ) + ], + {"query": question, "result_count": 1, "fetched_pages": 1}, + ) + + service = _FakeService() + rows, meta = await service._maybe_fetch_live_external_freshness_context( + question="当前codex最新的模型用的是什么?", + effective_question="当前codex最新的模型用的是什么?", + query_semantics={}, + scene_semantics={"scene_key": "industry_trend", "requires_freshness": True}, + ) + + assert len(rows) == 1 + assert rows[0].source == "url" + assert rows[0].metadata["target_url"] == "https://openai.com/index/introducing-gpt-5-4/" + assert meta["mode"] == "live_external_context" + + +@pytest.mark.asyncio +async def test_live_external_search_uses_proxy_aware_http_client(monkeypatch) -> None: + captured: dict[str, object] = {} + + class _FakeResponse: + def __init__(self, text: str) -> None: + self.text = text + + def raise_for_status(self) -> None: + return None + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs) -> None: + _ = args + captured.update(kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + _ = (exc_type, exc, tb) + return None + + async def get(self, url: str, headers: dict[str, str]): + captured["url"] = url + captured["headers"] = headers + return _FakeResponse('OpenAI Codex latest model') + + monkeypatch.setattr( + "app.services.adaptive_rag_service.mixin_conversation_utils_a_p1.httpx.AsyncClient", + _FakeAsyncClient, + ) + + class _FakeService(AdaptiveRAGConversationUtilsAMixinP1): + pass + + rows = await _FakeService()._search_live_external_results( + query="OpenAI Codex latest model", + max_results=1, + ) + + assert captured["trust_env"] is True + assert rows == [{"url": "https://example.com/post", "title": "OpenAI Codex latest model"}] + + +@pytest.mark.asyncio +async def test_live_external_fetch_uses_proxy_aware_http_client(monkeypatch) -> None: + captured: dict[str, object] = {} + + class _FakeResponse: + def __init__(self) -> None: + self.headers = {"content-type": "text/html"} + self.text = "

Fresh external fact

" + + def raise_for_status(self) -> None: + return None + + class _FakeAsyncClient: + def __init__(self, *args, **kwargs) -> None: + _ = args + captured.update(kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + _ = (exc_type, exc, tb) + return None + + async def get(self, url: str, headers: dict[str, str]): + captured["url"] = url + captured["headers"] = headers + return _FakeResponse() + + monkeypatch.setattr( + "app.services.adaptive_rag_service.mixin_conversation_utils_a_p1.httpx.AsyncClient", + _FakeAsyncClient, + ) + + class _FakeService(AdaptiveRAGConversationUtilsAMixinP1): + pass + + text = await _FakeService()._fetch_url_text("https://example.com/post") + + assert captured["trust_env"] is True + assert captured["url"] == "https://example.com/post" + assert "Fresh external fact" in text + + +def test_live_external_search_result_score_prefers_official_domain_for_model_brand_query() -> None: + official = AdaptiveRAGConversationUtilsAMixinP1._score_live_external_search_result( + question="当前 codex 最新的模型用的是什么?", + title="Codex 全面升级 - OpenAI", + url="https://openai.com/zh-Hans-CN/index/introducing-upgrades-to-codex/", + ) + third_party = AdaptiveRAGConversationUtilsAMixinP1._score_live_external_search_result( + question="当前 codex 最新的模型用的是什么?", + title="Codex 模型选择 - 菜鸟教程", + url="https://www.runoob.com/codex/codex-models.html", + ) + + assert official > third_party + + +def test_live_external_search_result_score_prefers_codex_models_reference_page_for_latest_model_question() -> None: + reference_page = AdaptiveRAGConversationUtilsAMixinP1._score_live_external_search_result( + question="当前 codex 最新的模型用的是什么?", + title="Models - Codex | OpenAI Developers", + url="https://developers.openai.com/codex/models", + ) + announcement_page = AdaptiveRAGConversationUtilsAMixinP1._score_live_external_search_result( + question="当前 codex 最新的模型用的是什么?", + title="Introducing GPT-5.3-Codex - OpenAI", + url="https://openai.com/index/introducing-gpt-5-3-codex/", + ) + + assert reference_page > announcement_page + + +def test_live_external_search_queries_prefer_official_site_for_model_brand_question() -> None: + queries = AdaptiveRAGConversationUtilsAMixinP1._build_live_external_search_queries( + "当前 codex 最新的模型用的是什么?" + ) + + assert queries + assert queries[0] == "site:openai.com codex latest model" + assert any(query == "当前 codex 最新的模型用的是什么" for query in queries) + + +def test_live_external_search_queries_strip_internal_runtime_tokens_for_model_brand_question() -> None: + queries = AdaptiveRAGConversationUtilsAMixinP1._build_live_external_search_queries( + "围绕“_sync_pmflow_rag_async->backend/app/api/sstc.py”回答:当前codex最新的模型用的是什么?" + ) + + assert queries + assert queries[0] == "site:openai.com codex latest model" + assert all("backend/app/api/sstc.py" not in query for query in queries) + assert all("_sync_pmflow_rag_async" not in query for query in queries) + + +def test_live_external_chunk_score_prefers_latest_fact_chunk_for_chinese_model_question() -> None: + latest_chunk = ( + "Models – Codex | OpenAI Developers Latest: GPT-5.4 " + "Prompt guidance Core concepts Text generation Code generation" + ) + nav_chunk = ( + "Codex Docs Guides, concepts, and product docs for Codex " + "Use cases Example workflows and tasks teams hand to Codex" + ) + + latest_score = AdaptiveRAGConversationUtilsAMixinP1._score_live_external_chunk( + question="当前codex最新的模型用的是什么?", + title="Models - Codex | OpenAI Developers", + content=latest_chunk, + url="https://developers.openai.com/codex/models", + ) + nav_score = AdaptiveRAGConversationUtilsAMixinP1._score_live_external_chunk( + question="当前codex最新的模型用的是什么?", + title="Models - Codex | OpenAI Developers", + content=nav_chunk, + url="https://developers.openai.com/codex/models", + ) + + assert latest_score > nav_score + + +def test_external_model_fact_direct_answer_prefers_explicit_latest_evidence() -> None: + retrieved = [ + RetrievedItem( + source="url", + content=( + "Models – Codex | OpenAI Developers Latest: GPT-5.4 " + "Prompt guidance Core concepts Text generation" + ), + score=0.94, + source_label="URL[Models - Codex | OpenAI Developers]", + metadata={ + "url": "https://developers.openai.com/codex/models", + "target_url": "https://developers.openai.com/codex/models", + }, + ) + ] + + answer = AdaptiveRAGDeepGuardAnswerMixin._build_external_model_fact_direct_answer( + question="当前codex最新的模型用的是什么?", + retrieved=retrieved, + ) + + assert answer is not None + assert "GPT-5.4" in answer + assert "OpenAI" in answer + + +@pytest.mark.asyncio +async def test_live_external_context_prefers_official_candidate_beyond_base_page_window() -> None: + class _FakeService(AdaptiveRAGConversationUtilsAMixinP1): + async def _search_live_external_results(self, *, query: str, max_results: int = 5): + _ = (query, max_results) + return [ + {"url": "https://example.com/codex-guide", "title": "Codex 全面上手指南"}, + {"url": "https://example.com/codex-models", "title": "Codex 模型选择"}, + {"url": "https://example.com/codex-news", "title": "Codex 最新动态"}, + { + "url": "https://openai.com/zh-Hans-CN/index/introducing-upgrades-to-codex/", + "title": "Codex 全面升级 - OpenAI", + }, + ] + + async def _fetch_url_text(self, url: str) -> str: + if "openai.com" in url: + return "OpenAI 官方说明 Codex 当前使用 GPT-5.4。" + return "第三方教程,只介绍 Codex 的一般用法。" + + rows, meta = await _FakeService()._retrieve_live_external_context( + question="当前 codex 最新的模型用的是什么?", + max_results=1, + max_pages=1, + ) + + assert rows + assert rows[0].metadata["url"] == "https://openai.com/zh-Hans-CN/index/introducing-upgrades-to-codex/" + assert meta["fetched_pages"] >= 1 diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index 44846805..585e9af0 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -39,18 +39,23 @@ from app.services.ai_pmo.workflow_semantic_planner.models import ReplyContract, from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.adaptive_rag_service import mixin_deterministic_query_core from app.services.adaptive_rag_service import mixin_fast_answer_project_main +from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService from app.services.integrations.feishu_integration_service import mixin_reply_outbox as feishu_mixin_reply_outbox from app.services.integrations.feishu_outbox.service import FeishuOutboxService from app.services.llm_service import ChatCompletionResult, StructuredExtractionResult from app.services.llm_service import mixin_tier_escalation from app.services.llm_service.mixin_tier_escalation import LLMTierEscalationMixin from app.models.base import Base +from app.models.ingest_event import IngestEvent from app.models.feishu_reply_outbox import FeishuReplyOutbox from app.models.mounted_ai_runtime import ( MountedConversationRun, MountedDraftWorkspace, MountedTopicWorkspace, ) +from app.models.qa_record import QARecord +from app.models.qa_session import QASession +from app.models.work_item import WorkItem from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key @@ -59,6 +64,8 @@ def _use_sqlite_requirement_plan_json_types() -> None: MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, + WorkItem.__table__, + IngestEvent.__table__, ): for column in table.columns: if column.name in { @@ -76,6 +83,12 @@ def _use_sqlite_requirement_plan_json_types() -> None: "context_layers_hit", "tool_calls", "execution_outcome", + "context_snapshot", + "action_bundle", + "execution_result", + "review_summary", + "draft_content", + "payload", }: column.type = JSON() @@ -92,6 +105,44 @@ async def requirement_plan_db_session(): MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, + WorkItem.__table__, + IngestEvent.__table__, + ], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _use_sqlite_external_qa_json_types() -> None: + for table in (QARecord.__table__,): + for column in table.columns: + if column.name in { + "sources", + "domains_used", + "routing_snapshot", + "citations", + "sources_used", + "evidence_pack", + "tool_trace", + "retrieval_metrics", + }: + column.type = JSON() + + +@pytest_asyncio.fixture +async def external_qa_db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + _use_sqlite_external_qa_json_types() + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[ + QASession.__table__, + QARecord.__table__, ], ) ) @@ -239,16 +290,16 @@ def _install_requirement_plan_round_llm(monkeypatch, captured: list[dict[str, ob "team_setup": [{"role": "建议配置", "notes": "产品/投研 1名、后端/数据 1名、前端 1名,先支撑小范围投研试用即可。"}], "validation_metrics": {"product": ["评分查看率", "投顾引用率", "研究团队反馈数"]}, } - elif "批量自动化部署" in draft_blob or "openclaw" in draft_blob: + elif "批量自动化部署" in draft_blob or "pmflow 本地路由层" in draft_blob: payload = { "intro": "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", - "title": "OpenClaw 批量自动化部署平台", - "one_liner": "先按面向平台运维团队的项目级试点推进,先把 OpenClaw 批量部署、执行追踪和失败恢复跑成一条闭环。", + "title": "PMFlow 本地路由层批量自动化部署平台", + "one_liner": "先按面向平台运维团队的项目级试点推进,先把 PMFlow 本地路由层的批量部署、执行追踪和失败恢复跑成一条闭环。", "goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", "project_nature": "这件事更像发布治理平台,需要稳定承接批量执行、状态追踪和治理边界。", "target_user": "第一版先服务平台运维团队,他们会最早使用、反馈和承担执行确认。", "signal_inputs": "当前至少要接入:飞书需求消息;部署目标清单(服务/环境/实例);代码仓库/镜像信息;环境参数与凭证;执行日志与失败日志。", - "known_info": "目前已经明确:当前要解决的是 OpenClaw 批量自动化部署;当前按发布治理平台收敛;主要服务平台运维团队;技术路线先按任务编排平台版;第一版先按批量执行闭环推进;推进方式先按项目级试点(带基础审批和回滚)。", + "known_info": "目前已经明确:当前要解决的是 PMFlow 本地路由层批量自动化部署;当前按发布治理平台收敛;主要服务平台运维团队;技术路线先按任务编排平台版;第一版先按批量执行闭环推进;推进方式先按项目级试点(带基础审批和回滚)。", "tech_route": "当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL),先把批量执行、状态追踪和失败重试稳定下来。", "first_scope": "第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。", "core_flow": "提交部署需求 -> 生成部署任务 -> 选择目标环境 -> 执行部署 -> 回传结果/日志 -> 失败重试或回滚 -> 留痕审计", @@ -381,7 +432,7 @@ def _install_requirement_plan_round_llm(monkeypatch, captured: list[dict[str, ob } elif '"missing_field": "project_nature"' in prompt: is_deployment_prompt = "批量自动化部署" in context_blob - subject = "OpenClaw 批量自动化部署平台" if is_deployment_prompt else "当前需求" + subject = "PMFlow 本地路由层批量自动化部署平台" if is_deployment_prompt else "当前需求" option_b_title = "先做发布治理平台" if is_deployment_prompt else "先做业务协作平台" option_b_best_for = "多人协作、状态流转、需要审批和回滚" if is_deployment_prompt else "多人协作、状态流转、需要审批和留痕" option_b_capability = "FastAPI + React + PostgreSQL + Redis" if is_deployment_prompt else "FastAPI + React + PostgreSQL + 审批流 + 审计日志" @@ -818,6 +869,15 @@ async def test_process_ingress_canonicalizes_thread_context_before_prepare_envel def __init__(self, db) -> None: _ = db + async def terminate_requirement_plan_scope(self, **kwargs): + _ = kwargs + return { + "terminated_count": 0, + "terminated_topic_ids": [], + "terminated_work_item_ids": [], + "conversation_scope_key": None, + } + async def canonicalize_thread_context( self, *, @@ -994,6 +1054,7 @@ async def test_prepare_mixed_sidecar_context_does_not_fallback_to_local_choice_g [ ("C", "C", ""), (" 我选C ", "C", ""), + ("我选C 继续下一轮", "C", "继续下一轮"), ("选C,继续聊主播脚本", "C", "继续聊主播脚本"), ("就C吧", "C", ""), ("先按C:继续收集范围", "C", "继续收集范围"), @@ -1012,6 +1073,30 @@ def test_requirement_plan_choice_protocol_parser_returns_choice_and_remaining_te assert parsed["remaining_text"] == expected_remaining +def test_extract_requirement_plan_updates_prefers_choice_mapping_over_protocol_continuation_text_for_first_scope() -> None: + updates = mixin_execute_query_project.ExecuteQueryProjectMixin._extract_requirement_plan_updates( + message_text="我选C 继续下一轮", + card={ + "plan_profile": "it_project", + "title": "打车聚合助手", + "goal": "先把打车聚合助手的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", + "project_nature": "业务平台型", + "target_user": "业务主管与协同方", + "tech_route": "模块化微服务", + "round_option_maps": { + "first_scope": { + "A": "最小闭环先行", + "B": "双角色轻量并行", + "C": "模块化分步上线", + } + }, + }, + ) + + assert updates["first_scope"] == "模块化分步上线" + assert "继续下一轮" not in str(updates.get("first_scope") or "") + + def test_merge_requirement_plan_round_options_from_assistant_reply_uses_non_regex_line_parser() -> None: assistant_text = ( "第一轮收敛:这是什么性质的项目?\n\n" @@ -1035,6 +1120,48 @@ def test_merge_requirement_plan_round_options_from_assistant_reply_uses_non_rege assert merged["last_round_options"]["recommendation"] == {"key": "C"} +def test_render_requirement_plan_round_payload_adds_d_option_and_requirement_actions() -> None: + rendered = mixin_execute_query_project.ExecuteQueryProjectMixin._render_requirement_plan_round_payload( + { + "opening": "这条需求先继续往下收。", + "focus": "这轮只定第一版范围。", + "options": [ + { + "key": "A", + "title": "只做买手搭配建议", + "best_for": "先验证建议是否有人用", + "suggested_capability": "给出基础建议", + "rationale": "上线快", + "tradeoff": "反馈闭环还不完整", + }, + { + "key": "B", + "title": "买手替换单品闭环", + "best_for": "需要人工快速调整组合", + "suggested_capability": "支持替换与保存", + "rationale": "更贴近真实流程", + "tradeoff": "交互复杂度更高", + }, + { + "key": "C", + "title": "搭配沉淀闭环", + "best_for": "要长期复用搭配经验", + "suggested_capability": "沉淀组合模板", + "rationale": "复用价值高", + "tradeoff": "首版价值不够直观", + }, + ], + "recommendation": {"key": "B", "reason": "更适合买手先跑通替换与沉淀动作。"}, + "reply_instruction": "直接回复:A / B / C / D;也可以回复:补一句 / 出草稿 / 重出这轮。", + } + ) + + assert "D. 以上都不对,我补充一句" in rendered + assert "直接回复:A / B / C / D" in rendered + assert "出草稿" in rendered + assert "重出这轮" in rendered + + @pytest.mark.asyncio async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): @@ -1091,6 +1218,398 @@ async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> assert "草稿" in result["direct_reply_text"] +@pytest.mark.asyncio +async def test_should_not_route_no_anchor_confirm_into_active_draft_lock(monkeypatch) -> None: + called = False + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + nonlocal called + _ = (self, kwargs) + called = True + return True + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + + kernel = FakeKernel() + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent="query.answer"), + sender="tester", + source_system="feishu", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + message_text="确认入库", + ingress_options=None, + ) + + assert should_route is False + assert called is False + + +@pytest.mark.asyncio +async def test_terminate_requirement_plan_scope_cancels_active_plan_and_draft_rows( + requirement_plan_db_session, +) -> None: + service = MountedAIRuntimeWriteService(requirement_plan_db_session) + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-cleanup", + source_sender_open_id="ou-cleanup", + sender="林宇航", + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-cleanup:message:msg-cleanup-root", + source_system="feishu", + channel_ref="chat-cleanup", + conversation_scope_key=conversation_scope_key, + root_message_id="msg-cleanup-root", + mode="plan", + status="active", + plan_status="draft_pending_confirm", + pending_actions=["confirm_requirement_draft"], + open_questions=["下一步要确认什么?"], + metadata_json={ + "plan_mode": True, + "active_draft_work_item_id": 41, + }, + facts_json={ + "title": "旧 plan", + }, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + ingest = IngestEvent( + tenant_id=3, + channel="feishu", + event_key="cleanup-event-1", + status="completed", + payload={"message_text": "确认入库"}, + ) + requirement_plan_db_session.add(ingest) + await requirement_plan_db_session.flush() + work_item = WorkItem( + tenant_id=3, + ingest_event_id=int(ingest.id), + source_system="feishu", + source_message_id="msg-cleanup-work-item", + state="draft_pending_confirm", + intent="project.create", + execution_result={ + "status": "draft_pending_confirm", + "workflow_route": "project.create", + }, + reply_template_key="draft_pending_confirm", + draft_content={ + "title": "旧 plan", + "source_message_id": "msg-cleanup-root", + }, + ) + requirement_plan_db_session.add(work_item) + await requirement_plan_db_session.flush() + draft = MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="draft_pending_confirm", + draft_version=1, + requirement_card={ + "title": "旧 plan", + "plan_profile": "it_project", + }, + draft_payload={ + "title": "旧 plan", + }, + active_work_item_id=int(work_item.id), + confirm_message_id="msg-confirm-1", + ) + requirement_plan_db_session.add(draft) + await requirement_plan_db_session.flush() + + result = await service.terminate_requirement_plan_scope( + tenant_id=3, + source_system="feishu", + channel_ref="chat-cleanup", + sender="林宇航", + source_sender_open_id="ou-cleanup", + source_message_id="msg-cleanup-trigger", + ) + + assert result["terminated_count"] == 1 + assert result["terminated_topic_ids"] == [int(topic.id)] + assert result["terminated_work_item_ids"] == [int(work_item.id)] + + stored_topic = ( + await requirement_plan_db_session.execute( + select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(topic.id)) + ) + ).scalar_one() + stored_draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace).where(MountedDraftWorkspace.id == int(draft.id)) + ) + ).scalar_one() + stored_work_item = ( + await requirement_plan_db_session.execute( + select(WorkItem).where(WorkItem.id == int(work_item.id)) + ) + ).scalar_one() + + assert stored_topic.mode == "chat" + assert stored_topic.status == "suspended" + assert stored_topic.plan_status == "suspended" + assert stored_topic.pending_actions == [] + assert stored_topic.open_questions == [] + assert stored_topic.metadata_json["plan_mode"] is False + assert stored_topic.metadata_json["plan_terminated"] is True + assert stored_topic.metadata_json["plan_terminated_by_message_id"] == "msg-cleanup-trigger" + assert stored_draft.state == "cancelled" + assert stored_draft.active_work_item_id is None + assert stored_draft.confirm_message_id is None + assert stored_work_item.state == "cancelled" + assert stored_work_item.reply_template_key == "draft_cancelled" + assert stored_work_item.execution_result["status"] == "cancelled" + assert stored_work_item.execution_result["cancel_reason"] == "stale_requirement_scope_terminated" + + +@pytest.mark.asyncio +async def test_terminate_requirement_plan_scope_cancels_orphan_pending_work_items_in_same_scope( + requirement_plan_db_session, +) -> None: + service = MountedAIRuntimeWriteService(requirement_plan_db_session) + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-cleanup-orphan", + source_sender_open_id="ou-cleanup-orphan", + sender="林宇航", + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-cleanup-orphan:message:msg-cleanup-root", + source_system="feishu", + channel_ref="chat-cleanup-orphan", + conversation_scope_key=conversation_scope_key, + root_message_id="msg-cleanup-root", + mode="plan", + status="active", + plan_status="draft_pending_confirm", + pending_actions=["confirm_requirement_draft"], + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + + primary_ingest = IngestEvent( + tenant_id=3, + channel="feishu", + event_key="cleanup-orphan-event-1", + status="completed", + payload={ + "sender": "林宇航", + "channel_ref": "chat-cleanup-orphan", + "source_sender_open_id": "ou-cleanup-orphan", + "message_text": "旧草稿确认", + }, + ) + orphan_ingest = IngestEvent( + tenant_id=3, + channel="feishu", + event_key="cleanup-orphan-event-2", + status="completed", + payload={ + "sender": "林宇航", + "channel_ref": "chat-cleanup-orphan", + "source_sender_open_id": "ou-cleanup-orphan", + "message_text": "普通问句被污染成草稿确认", + }, + ) + other_scope_ingest = IngestEvent( + tenant_id=3, + channel="feishu", + event_key="cleanup-orphan-event-3", + status="completed", + payload={ + "sender": "林宇航", + "channel_ref": "chat-other", + "source_sender_open_id": "ou-other", + "message_text": "别的 scope", + }, + ) + requirement_plan_db_session.add_all([primary_ingest, orphan_ingest, other_scope_ingest]) + await requirement_plan_db_session.flush() + + primary_work_item = WorkItem( + tenant_id=3, + ingest_event_id=int(primary_ingest.id), + source_system="feishu", + source_message_id="msg-cleanup-primary", + state="draft_pending_confirm", + intent="project.create", + execution_result={"status": "draft_pending_confirm"}, + reply_template_key="draft_pending_confirm", + ) + orphan_work_item = WorkItem( + tenant_id=3, + ingest_event_id=int(orphan_ingest.id), + source_system="feishu", + source_message_id="msg-cleanup-orphan", + state="draft_pending_confirm", + intent="followup.confirm", + execution_result={"status": "draft_pending_confirm"}, + reply_template_key="draft_pending_confirm", + ) + other_scope_work_item = WorkItem( + tenant_id=3, + ingest_event_id=int(other_scope_ingest.id), + source_system="feishu", + source_message_id="msg-cleanup-other-scope", + state="draft_pending_confirm", + intent="followup.confirm", + execution_result={"status": "draft_pending_confirm"}, + reply_template_key="draft_pending_confirm", + ) + requirement_plan_db_session.add_all([primary_work_item, orphan_work_item, other_scope_work_item]) + await requirement_plan_db_session.flush() + + draft = MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="draft_pending_confirm", + draft_version=1, + requirement_card={"title": "旧 plan"}, + draft_payload={"title": "旧 plan"}, + active_work_item_id=int(primary_work_item.id), + confirm_message_id="msg-confirm-orphan-1", + ) + requirement_plan_db_session.add(draft) + await requirement_plan_db_session.flush() + + result = await service.terminate_requirement_plan_scope( + tenant_id=3, + source_system="feishu", + channel_ref="chat-cleanup-orphan", + sender="林宇航", + source_sender_open_id="ou-cleanup-orphan", + source_message_id="msg-cleanup-trigger-orphan", + ) + + assert result["terminated_topic_ids"] == [int(topic.id)] + assert sorted(result["terminated_work_item_ids"]) == sorted( + [int(primary_work_item.id), int(orphan_work_item.id)] + ) + + stored_primary = ( + await requirement_plan_db_session.execute( + select(WorkItem).where(WorkItem.id == int(primary_work_item.id)) + ) + ).scalar_one() + stored_orphan = ( + await requirement_plan_db_session.execute( + select(WorkItem).where(WorkItem.id == int(orphan_work_item.id)) + ) + ).scalar_one() + stored_other_scope = ( + await requirement_plan_db_session.execute( + select(WorkItem).where(WorkItem.id == int(other_scope_work_item.id)) + ) + ).scalar_one() + + assert stored_primary.state == "cancelled" + assert stored_orphan.state == "cancelled" + assert stored_orphan.execution_result["cancel_reason"] == "stale_requirement_scope_terminated" + assert stored_other_scope.state == "draft_pending_confirm" + + +@pytest.mark.asyncio +async def test_process_ingress_terminates_stale_requirement_scope_before_active_context_followup(monkeypatch) -> None: + cleanup_calls: list[dict[str, object]] = [] + + class _FakeDB: + async def flush(self): + return None + + class FakeKernel( + mixin_entry.KernelIngressFlowEntryMixin, + ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.active_context_called = False + self.prepared_session: SimpleNamespace | None = None + + async def _prepare_ingress_envelope(self, **kwargs): + _ = kwargs + session = SimpleNamespace( + sender="feishu:林宇航", + current_project_id=99, + last_intent="requirement_plan", + last_message_text="上一轮 plan", + ) + self.prepared_session = session + return { + "early_result": None, + "clean_text": "我当前系统的ai架构是怎么样的?", + "synthetic_message_id": "s-msg-terminate-1", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=103), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=56), + "session": session, + } + + async def _maybe_handle_active_context_followup(self, **kwargs): + self.active_context_called = True + _ = kwargs + assert self.prepared_session is not None + assert str(getattr(self.prepared_session, "last_intent", "") or "") == "query.answer" + assert getattr(self.prepared_session, "current_project_id", None) is None + return { + "handled": True, + "status": "answered", + "classification": "question", + "direct_reply_required": True, + "direct_reply_text": "普通问答结果", + } + + async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): + _ = kwargs + raise AssertionError("stale scope cleanup should happen before draft confirmation routing") + + async def _prepare_mixed_sidecar_context(self, **kwargs): + _ = kwargs + raise AssertionError("stale scope cleanup should short-circuit before query sidecar") + + async def _fake_terminate_requirement_plan_scope(self, **kwargs): + cleanup_calls.append(kwargs) + return { + "terminated_count": 1, + "terminated_topic_ids": [11], + "terminated_work_item_ids": [29], + "conversation_scope_key": "scope", + } + + monkeypatch.setattr(MountedAIRuntimeWriteService, "terminate_requirement_plan_scope", _fake_terminate_requirement_plan_scope) + kernel = FakeKernel() + + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="feishu-cleanup-1", + message_text="我当前系统的ai架构是怎么样的?", + sender="林宇航", + channel_ref="chat-cleanup", + source_sender_open_id="ou-cleanup", + ingress_options={"thread_context": {"reply_to_message_id": ""}}, + ) + + assert cleanup_calls + assert cleanup_calls[0]["source_message_id"] == "s-msg-terminate-1" + assert cleanup_calls[0]["source_system"] == "feishu" + assert kernel.active_context_called is True + assert result["direct_reply_text"] == "普通问答结果" + + @pytest.mark.asyncio async def test_process_ingress_binds_active_draft_before_query_for_regenerate_request() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): @@ -1215,6 +1734,15 @@ async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(mo def __init__(self, db) -> None: captured["db"] = db + async def terminate_requirement_plan_scope(self, **kwargs): + captured["calls"].append(("terminate_scope", kwargs)) + return { + "terminated_count": 0, + "terminated_topic_ids": [], + "terminated_work_item_ids": [], + "conversation_scope_key": None, + } + async def begin_ingress_run(self, **kwargs): captured["calls"].append(("begin", kwargs)) return SimpleNamespace( @@ -1286,11 +1814,11 @@ async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(mo ) assert result["status"] == "answered" - assert [name for name, _ in captured["calls"]] == ["begin", "record", "agent_trace", "finalize"] + assert [name for name, _ in captured["calls"]] == ["begin", "terminate_scope", "record", "agent_trace", "finalize"] begin_kwargs = captured["calls"][0][1] - record_kwargs = captured["calls"][1][1] - agent_trace_kwargs = captured["calls"][2][1] - finalize_kwargs = captured["calls"][3][1] + record_kwargs = captured["calls"][2][1] + agent_trace_kwargs = captured["calls"][3][1] + finalize_kwargs = captured["calls"][4][1] assert begin_kwargs["source_system"] == "feishu" assert begin_kwargs["source_message_id"] == "s-msg-mounted-1" assert record_kwargs["workflow_route"] == "followup.confirm" @@ -1314,6 +1842,15 @@ async def test_process_ingress_records_semantic_shadow_before_legacy_finalize(mo def __init__(self, db) -> None: captured["db"] = db + async def terminate_requirement_plan_scope(self, **kwargs): + captured["calls"].append(("terminate_scope", kwargs)) + return { + "terminated_count": 0, + "terminated_topic_ids": [], + "terminated_work_item_ids": [], + "conversation_scope_key": None, + } + async def begin_ingress_run(self, **kwargs): captured["calls"].append(("begin", kwargs)) return SimpleNamespace( @@ -1494,12 +2031,13 @@ async def test_process_ingress_records_semantic_shadow_before_legacy_finalize(mo assert result["status"] == "ignored" assert [name for name, _ in captured["calls"]] == [ "begin", + "terminate_scope", "semantic_shadow", "record", "agent_trace", "finalize", ] - semantic_kwargs = captured["calls"][1][1] + semantic_kwargs = captured["calls"][2][1] assert semantic_kwargs["semantic_packet_summary"]["packet_version"] == "v1" assert semantic_kwargs["semantic_packet_summary"]["topic_key"] == "feishu:chat-shadow:root:om-root-shadow" assert semantic_kwargs["semantic_decision"]["mode_decision"] == "stay_plan" @@ -1520,13 +2058,13 @@ def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> session=session, message_text="确认", ingress_options={"thread_context": {"thread_id": "th_123"}}, - ) is False + ) is True assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options={"thread_context": {"root_message_id": "root_123"}}, - ) is False + ) is True assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, @@ -1618,10 +2156,10 @@ def test_new_project_statement_does_not_route_to_existing_draft_without_anchor() @pytest.mark.asyncio -async def test_should_route_to_draft_confirmation_session_does_not_hijack_explicit_new_requirement_topic( +async def test_should_route_to_draft_confirmation_session_does_not_lock_explicit_new_requirement_topic_without_anchor( monkeypatch, ) -> None: - called = False + captured = {"called": False, "skip_called": False} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: @@ -1629,9 +2167,8 @@ async def test_should_route_to_draft_confirmation_session_does_not_hijack_explic self.tenant_id = 1 async def _fake_has_active_draft_session(self, **kwargs): - nonlocal called + captured["called"] = True _ = (self, kwargs) - called = True return True kernel = FakeKernel() @@ -1639,7 +2176,7 @@ async def test_should_route_to_draft_confirmation_session_does_not_hijack_explic monkeypatch.setattr( kernel.__class__, "_should_skip_no_anchor_draft_routing_by_bundle", - staticmethod(lambda **kwargs: asyncio.sleep(0, result=True)), + staticmethod(lambda **kwargs: asyncio.sleep(0, result=captured.__setitem__("skip_called", True) or True)), ) should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), @@ -1652,7 +2189,8 @@ async def test_should_route_to_draft_confirmation_session_does_not_hijack_explic ) assert should_route is False - assert called is False + assert captured["called"] is False + assert captured["skip_called"] is False def test_should_inherit_recent_feishu_session_context_skips_explicit_new_requirement_topic() -> None: @@ -2005,6 +2543,82 @@ async def test_active_requirement_followup_prefers_current_requirement_context(m assert captured["direct_kwargs"]["message_text"] == "信号源呢?" +@pytest.mark.asyncio +async def test_active_requirement_choice_protocol_followup_bypasses_semantic_decision_gate(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _find_active_requirement_plan_workspace(self, **kwargs): + captured["workspace_lookup"] = kwargs + return ( + SimpleNamespace(id=81, plan_status="collecting"), + SimpleNamespace(id=82, state="collecting"), + ) + + async def _build_requirement_planning_direct_result(self, **kwargs): + captured["direct_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "classification": "question", + "direct_reply_required": True, + "direct_reply_text": "第三轮:技术路线\n\nA. SQL + 飞书消息\nB. 多源拼接\nC. 外部系统 API", + "reply_template_key": "requirement_plan_followup", + "requirement_plan_context": { + "question_round": 3, + "last_round_options": { + "field": "tech_route", + "options": [ + {"key": "A", "title": "SQL + 飞书消息"}, + {"key": "B", "title": "多源拼接"}, + {"key": "C", "title": "外部系统 API"}, + ], + "recommendation": {"key": "A"}, + }, + }, + } + + class BombSemanticContextAssembler: + def __init__(self, db) -> None: + self.db = db + + async def assemble(self, **kwargs): + _ = kwargs + raise AssertionError("choice protocol followup should bypass semantic decision assembly") + + class BombSemanticDecisionService: + def __init__(self, _llm_service) -> None: + pass + + async def decide(self, *, packet): + _ = packet + raise AssertionError("choice protocol followup should bypass semantic decision") + + monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", BombSemanticContextAssembler) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", BombSemanticDecisionService) + + kernel = FakeKernel() + result = await kernel._maybe_handle_active_context_followup( + source_system="feishu", + source_message_id="msg-followup-choice-fast-path", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="我选C 继续", + thread_context={"reply_to_message_id": "om-plan-reply"}, + sender="tester", + ) + + assert result is not None + assert result["handled"] is True + assert result["direct_reply_text"].startswith("第三轮:技术路线") + assert result["requirement_plan_context"]["last_round_options"]["field"] == "tech_route" + assert captured["direct_kwargs"]["message_text"] == "我选C 继续" + + @pytest.mark.asyncio async def test_active_requirement_followup_uses_builder_result_to_persist_requirement_context(monkeypatch) -> None: captured: dict[str, object] = {} @@ -2798,7 +3412,7 @@ def test_project_lookup_question_keeps_existing_project_name_binding() -> None: @pytest.mark.asyncio -async def test_draft_confirm_handler_prefers_latest_sender_scope_candidate_without_anchor() -> None: +async def test_draft_confirm_handler_does_not_resolve_latest_sender_scope_candidate_without_anchor() -> None: latest_work_item = SimpleNamespace( id=202, state="draft_pending_confirm", @@ -2840,11 +3454,11 @@ async def test_draft_confirm_handler_prefers_latest_sender_scope_candidate_witho thread_context=None, ) - assert target == rows[0] + assert target is None @pytest.mark.asyncio -async def test_draft_confirm_handler_skips_followup_wrapper_when_resolving_latest_draft() -> None: +async def test_draft_confirm_handler_does_not_resolve_project_draft_without_anchor() -> None: followup_wrapper = SimpleNamespace( id=303, state="draft_pending_confirm", @@ -2888,7 +3502,7 @@ async def test_draft_confirm_handler_skips_followup_wrapper_when_resolving_lates thread_context=None, ) - assert target == rows[1] + assert target is None def test_build_feishu_thread_key_prefers_actor_scope_without_anchor() -> None: @@ -3315,6 +3929,44 @@ async def test_kernel_intent_resolve_requirement_initiation_stays_on_query_chain assert intent.signal_title == "" +@pytest.mark.asyncio +async def test_kernel_intent_resolve_degrades_when_semantic_planner_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _failing_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, source_system, metadata) + raise RuntimeError("upstream_empty_or_invalid_response") + + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _failing_aplan_message(**kwargs)), + ) + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + resolver = FakeResolver() + + intent, source = await resolver._resolve_intent( + message_text="关于给采购经理每天实时上报仓库库存,我想先把第一版需求聊清楚。", + session=None, + world_model={}, + thread_context=None, + ) + + assert source == "semantic_planner_degraded" + assert intent.name == "query.read_only" + assert intent.metadata["workflow_route"] == "query.answer" + assert intent.metadata["message_intent"] == "consult_requirement" + assert intent.metadata["consultant_mode_preference"] == "clarify" + assert intent.metadata["context_source"] == "semantic_planner_degraded" + assert intent.metadata["semantic_planner_degraded"] is True + assert "upstream_empty_or_invalid_response" in str(intent.metadata["semantic_planner_error"]) + assert intent.signal_title == "" + + def test_recover_session_followup_write_intent_skips_query_parent_even_if_previous_text_looks_like_write() -> None: class FakeResolver(KernelIntentRulesMixin, KernelIntentResolveMixin): def __init__(self) -> None: @@ -4883,7 +5535,7 @@ async def test_execute_query_intent_explicit_new_it_requirement_overrides_stale_ first = await executor._execute_query_intent( intent=stale_clarify_intent, - message_text="想跟你聊个新需求关于批量自动化部署openclaw的,先别创建,先收第一版。", + message_text="想跟你聊个新需求,关于批量自动化部署 PMFlow 本地路由层,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-new-it-root", @@ -4899,11 +5551,12 @@ async def test_execute_query_intent_explicit_new_it_requirement_overrides_stale_ first_answer = str(first["answer"] or "") second_answer = str(second["answer"] or "") - assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in first_answer + assert "🧭 我先按“PMFlow 本地路由层批量自动化部署平台”这类 IT 项目来收。" in first_answer assert "云原生交付底座" in first_answer assert "目标对象" not in first_answer assert "A股趋势打分" not in first_answer + assert "好,我先按这个方向继续记:项目先按云原生交付底座来收。" in second_answer assert "🧭 我先按“云原生交付底座”这个方向继续收。" in second_answer assert "现在要先把服务对象定住" in second_answer assert "A股趋势打分" not in second_answer @@ -5004,7 +5657,7 @@ async def test_execute_query_intent_explicit_new_requirement_topic_reuses_curren switch_result = await executor._execute_query_intent( intent=explicit_switch_intent, - message_text="想跟你聊个新需求,关于批量自动化部署openclaw,先不创建,先收第一版。", + message_text="想跟你聊个新需求,关于批量自动化部署 PMFlow 本地路由层,先不创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-switch-new-reuse", @@ -5211,7 +5864,7 @@ async def test_execute_query_intent_low_overlap_requirement_seed_switches_new_to switch_result = await executor._execute_query_intent( intent=implicit_switch_intent, - message_text="想跟你聊需求,关于批量自动化部署openclaw,先不创建,先收第一版。", + message_text="想跟你聊需求,关于批量自动化部署 PMFlow 本地路由层,先不创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-overlap-new", @@ -5514,7 +6167,7 @@ async def test_execute_query_intent_requirement_plan_injects_context_to_llm_for_ assert "⭐ 当前我更推荐:B" in answer assert "批量自动化部署" not in answer assert "发布治理平台" not in answer - assert "OpenClaw" not in answer + assert "历史旧名" not in answer assert str(result["rag_result"]["query_type"]) == "plan" followup_intent = SimpleNamespace( @@ -6037,6 +6690,102 @@ async def test_execute_query_intent_continues_same_scope_requirement_plan_withou assert direct_kwargs["thread_context"] is None +@pytest.mark.asyncio +async def test_execute_query_intent_locked_requirement_mode_redirects_casual_chat_to_requirement_notice( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + raise AssertionError(f"locked requirement mode should not enter heavy RAG: {kwargs}") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + _install_requirement_plan_query_bundle( + monkeypatch, + message_intent="casual_chat", + consultant_mode_preference="", + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-lock-plan", + source_sender_open_id="ou-lock-plan", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-lock-plan:root:om-lock-plan-root", + source_system="feishu", + channel_ref="chat-lock-plan", + conversation_scope_key=conversation_scope_key, + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + root_message_id="om-lock-plan-root", + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "买手换季搭配助手", + "goal": "先把买手换季搭配助手的首版需求收清楚。", + "project_nature": "买手协同工具", + "target_user": "买手与选品经理", + "question_round": 2, + "last_round_options": { + "field": "tech_route", + "options": [ + {"key": "A", "title": "规则引擎"}, + {"key": "B", "title": "轻量模型辅助"}, + {"key": "C", "title": "混合增强路径"}, + ], + "recommendation": {"key": "C"}, + }, + }, + required_confirmations=[], + ) + ) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-lock-plan", + "source_sender_open_id": "ou-lock-plan", + "message_intent": "casual_chat", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="你在吗", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-lock-plan-chat-1", + ) + + assert result["status"] == "succeeded" + assert "当前正在需求模式" in str(result["answer"] or "") + assert "退出需求模式" in str(result["answer"] or "") + assert str(result["workflow_route"]) == "requirement_plan.collect" + assert str(result["rag_result"]["query_type"]) == "plan" + + @pytest.mark.asyncio async def test_execute_query_intent_converts_query_answer_requirement_contract_into_requirement_plan_result( monkeypatch, @@ -7047,6 +7796,347 @@ async def test_execute_query_intent_low_information_choice_without_round_option_ assert "云原生交付底座" not in str(result["answer"] or "") +@pytest.mark.asyncio +async def test_execute_query_intent_locked_requirement_mode_choice_d_requests_clarification( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + raise AssertionError(f"locked requirement mode D choice should not enter heavy RAG: {kwargs}") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + _install_requirement_plan_query_bundle( + monkeypatch, + message_intent="continue_contextual_qa", + consultant_mode_preference="", + ) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-choice-d", + source_sender_open_id="ou-choice-d", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-choice-d:root:om-choice-d-root", + source_system="feishu", + channel_ref="chat-choice-d", + conversation_scope_key=conversation_scope_key, + root_message_id="om-choice-d-root", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "买手换季搭配助手", + "goal": "先把买手换季搭配助手的首版需求收清楚。", + "project_nature": "买手协同工具", + "target_user": "买手与选品经理", + "tech_route": "混合增强路径", + "question_round": 3, + "last_round_options": { + "field": "first_scope", + "options": [ + {"key": "A", "title": "搭配建议闭环"}, + {"key": "B", "title": "替换单品闭环"}, + {"key": "C", "title": "搭配沉淀闭环"}, + ], + "recommendation": {"key": "B"}, + }, + "round_option_maps": { + "first_scope": { + "A": "搭配建议闭环", + "B": "替换单品闭环", + "C": "搭配沉淀闭环", + } + }, + }, + required_confirmations=[], + ) + ) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-choice-d", + "source_sender_open_id": "ou-choice-d", + "message_intent": "continue_contextual_qa", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="D", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-choice-d-1", + ) + + assert result["status"] == "succeeded" + answer = str(result["answer"] or "") + assert "这轮不用硬选" in answer + assert "第一版范围" in answer + assert "补一句" in answer + assert str(result["workflow_route"]) == "requirement_plan.collect" + + +@pytest.mark.asyncio +async def test_execute_query_intent_locked_requirement_mode_choice_then_draft_fails_explicitly_when_fields_missing( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + raise AssertionError(f"choice plus draft should not enter heavy RAG: {kwargs}") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + _install_requirement_plan_query_bundle( + monkeypatch, + message_intent="continue_contextual_qa", + consultant_mode_preference="", + ) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-choice-draft-missing", + source_sender_open_id="ou-choice-draft-missing", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-choice-draft-missing:root:om-choice-draft-missing-root", + source_system="feishu", + channel_ref="chat-choice-draft-missing", + conversation_scope_key=conversation_scope_key, + root_message_id="om-choice-draft-missing-root", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "买手换季搭配助手", + "goal": "先把买手换季搭配助手的首版需求收清楚。", + "project_nature": "买手协同工具", + "target_user": "买手与选品经理", + "question_round": 2, + "last_round_options": { + "field": "tech_route", + "options": [ + {"key": "A", "title": "规则引擎驱动"}, + {"key": "B", "title": "轻量模型辅助"}, + {"key": "C", "title": "混合增强路径"}, + ], + "recommendation": {"key": "C"}, + }, + "round_option_maps": { + "tech_route": { + "A": "规则引擎驱动", + "B": "轻量模型辅助", + "C": "混合增强路径", + } + }, + }, + required_confirmations=[], + ) + ) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-choice-draft-missing", + "source_sender_open_id": "ou-choice-draft-missing", + "message_intent": "continue_contextual_qa", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="我选C 继续出草稿", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-choice-draft-missing-1", + ) + + assert result["status"] == "succeeded" + answer = str(result["answer"] or "") + assert "不能直接出草稿" in answer + assert "第一版范围" in answer + assert "推进方式" in answer + assert str(result["workflow_route"]) == "requirement_plan.collect" + assert str(result.get("reply_template_key") or "") != "draft_pending_confirm" + + +@pytest.mark.asyncio +async def test_execute_query_intent_locked_requirement_mode_rerolls_current_round( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + raise AssertionError(f"reroll should stay on requirement-plan mainline: {kwargs}") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + async def _fake_build_requirement_plan_next_question( + self, + *, + missing_field: str, + profile: str, + message_text: str = "", + card: dict[str, object] | None = None, + latest_updates: dict[str, object] | None = None, + ) -> str: + _ = (self, profile, message_text, latest_updates) + assert missing_field == "first_scope" + assert isinstance(card, dict) + card["last_round_options"] = { + "field": "first_scope", + "options": [ + {"key": "A", "title": "搭配建议闭环"}, + {"key": "B", "title": "替换单品闭环"}, + {"key": "C", "title": "搭配沉淀闭环"}, + ], + "recommendation": {"key": "C"}, + } + return "这轮只定第一版范围。\n\nA. 搭配建议闭环\nB. 替换单品闭环\nC. 搭配沉淀闭环\nD. 以上都不对,我补充一句" + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_build_requirement_plan_next_question", + _fake_build_requirement_plan_next_question, + raising=False, + ) + _install_requirement_plan_query_bundle( + monkeypatch, + message_intent="continue_contextual_qa", + consultant_mode_preference="", + ) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-reroll", + source_sender_open_id="ou-reroll", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-reroll:root:om-reroll-root", + source_system="feishu", + channel_ref="chat-reroll", + conversation_scope_key=conversation_scope_key, + root_message_id="om-reroll-root", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "买手换季搭配助手", + "goal": "先把买手换季搭配助手的首版需求收清楚。", + "project_nature": "买手协同工具", + "target_user": "买手与选品经理", + "tech_route": "混合增强路径", + "question_round": 3, + "last_round_options": { + "field": "first_scope", + "options": [ + {"key": "A", "title": "搭配建议闭环"}, + {"key": "B", "title": "替换单品闭环"}, + {"key": "C", "title": "搭配沉淀闭环"}, + ], + "recommendation": {"key": "B"}, + }, + "round_option_maps": { + "first_scope": { + "A": "搭配建议闭环", + "B": "替换单品闭环", + "C": "搭配沉淀闭环", + } + }, + }, + required_confirmations=[], + ) + ) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-reroll", + "source_sender_open_id": "ou-reroll", + "message_intent": "continue_contextual_qa", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="重出这轮", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-reroll-1", + ) + + assert result["status"] == "succeeded" + assert "重出一版" in str(result["answer"] or "") + assert str(result["workflow_route"]) == "requirement_plan.collect" + + @pytest.mark.asyncio async def test_build_requirement_planning_direct_result_emits_structured_requirement_plan_context( monkeypatch, @@ -7173,6 +8263,303 @@ async def test_build_requirement_planning_direct_result_emits_structured_require assert "第三轮:服务对象" in str(result["answer"] or "") +@pytest.mark.asyncio +async def test_build_requirement_planning_direct_result_refuses_choice_only_draft_when_requirement_seed_is_missing( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + async def _unexpected_generate_requirement_plan_confirmation_draft(self, *, card): # noqa: ANN001 + raise AssertionError(f"seed-missing requirement card should not draft: {card}") + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_generate_requirement_plan_confirmation_draft", + _unexpected_generate_requirement_plan_confirmation_draft, + raising=False, + ) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-seed-missing", + source_sender_open_id="ou-plan-seed-missing", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-seed-missing:message:om-plan-seed-missing-root", + source_system="feishu", + channel_ref="chat-plan-seed-missing", + conversation_scope_key=conversation_scope_key, + root_message_id="om-plan-seed-missing-root", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + draft = MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "需求收敛草稿", + "goal": "先把这项需求的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", + "project_nature": "业务平台型", + "target_user": "业务主管与协同方", + "tech_route": "模块化微服务", + "source_material": "用户:我选B | 用户:我选B 继续下一轮", + "question_round": 4, + "round_option_maps": { + "first_scope": { + "A": "最小闭环先行", + "B": "双角色轻量并行", + "C": "模块化分步上线", + } + }, + "last_round_options": { + "field": "first_scope", + "options": [ + {"key": "A", "title": "最小闭环先行"}, + {"key": "B", "title": "双角色轻量并行"}, + {"key": "C", "title": "模块化分步上线"}, + ], + "recommendation": {"key": "C"}, + }, + }, + draft_payload=None, + required_confirmations=[], + ) + requirement_plan_db_session.add(draft) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + result = await executor._build_requirement_planning_direct_result( + message_text="我选C 继续下一轮", + source_system="feishu", + source_message_id="msg-plan-seed-missing-next", + source_chat_id="chat-plan-seed-missing", + sender=None, + source_sender_open_id="ou-plan-seed-missing", + thread_context={ + "root_message_id": "om-plan-seed-missing-root", + "reply_to_message_id": "om-plan-seed-missing-root", + }, + conversation_key=conversation_scope_key, + topic_switch=None, + ) + + assert result["status"] == "succeeded" + assert result["workflow_route"] == mixin_execute_query_project.ROUTE_REQUIREMENT_PLAN_COLLECT + assert "缺少最初的需求描述" in str(result["answer"] or "") + assert "draft_artifact" not in result + + refreshed_draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace).where(MountedDraftWorkspace.id == int(draft.id)) + ) + ).scalar_one() + assert refreshed_draft.state == "collecting" + assert refreshed_draft.draft_payload is None + + +@pytest.mark.asyncio +async def test_build_requirement_planning_direct_result_repairs_polluted_thread_from_unique_grounded_scope_snapshot( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + async def _fake_build_requirement_plan_next_question( + self, + *, + missing_field: str, + profile: str, + message_text: str = "", + card: dict[str, object] | None = None, + latest_updates: dict[str, object] | None = None, + ) -> str: + _ = (self, profile, message_text, latest_updates) + assert missing_field == "target_user" + assert isinstance(card, dict) + assert card["title"] == "打车平台 AI 推荐第三方打车" + assert "第三方平台直接打车" in str(card["source_material"]) + assert card["first_scope"] == "嵌入式插件" + card["round_option_maps"] = { + **(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}), + "target_user": { + "A": "个人用户", + "B": "企业差旅员工", + "C": "企业管理员 + 员工双角色", + }, + } + card["last_round_options"] = { + "field": "target_user", + "options": [ + {"key": "A", "title": "个人用户"}, + {"key": "B", "title": "企业差旅员工"}, + {"key": "C", "title": "企业管理员 + 员工双角色"}, + ], + "recommendation": {"key": "B"}, + } + return "第三轮:服务对象\n\nA. 个人用户\nB. 企业差旅员工\nC. 企业管理员 + 员工双角色" + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_build_requirement_plan_next_question", + _fake_build_requirement_plan_next_question, + raising=False, + ) + + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-plan-scope-repair", + source_sender_open_id="ou-plan-scope-repair", + sender=None, + ) + polluted_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-scope-repair:message:om-plan-scope-repair-polluted-root", + source_system="feishu", + channel_ref="chat-plan-scope-repair", + conversation_scope_key=conversation_scope_key, + root_message_id="om-plan-scope-repair-polluted-root", + mode="plan", + status="active", + plan_status="collecting", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(polluted_topic) + await requirement_plan_db_session.flush() + polluted_draft = MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(polluted_topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "需求收敛草稿", + "goal": "先把这项需求的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", + "project_nature": "业务平台型", + "tech_route": "模块化微服务", + "source_material": "用户:我选B | 用户:我选C 继续", + "question_round": 4, + "round_option_maps": { + "first_scope": { + "A": "聚合型中间件", + "B": "代理型服务台", + "C": "嵌入式插件", + } + }, + "last_round_options": { + "field": "first_scope", + "options": [ + {"key": "A", "title": "聚合型中间件"}, + {"key": "B", "title": "代理型服务台"}, + {"key": "C", "title": "嵌入式插件"}, + ], + "recommendation": {"key": "B"}, + }, + }, + draft_payload=None, + required_confirmations=[], + ) + requirement_plan_db_session.add(polluted_draft) + + grounded_topic = MountedTopicWorkspace( + tenant_id=3, + topic_key="feishu:chat-plan-scope-repair:message:om-plan-scope-repair-grounded-root", + source_system="feishu", + channel_ref="chat-plan-scope-repair", + conversation_scope_key=conversation_scope_key, + root_message_id="om-plan-scope-repair-grounded-root", + mode="plan", + status="suspended", + plan_status="options_ready", + metadata_json={"plan_mode": True}, + ) + requirement_plan_db_session.add(grounded_topic) + await requirement_plan_db_session.flush() + grounded_draft = MountedDraftWorkspace( + tenant_id=3, + topic_workspace_id=int(grounded_topic.id), + state="collecting", + draft_version=0, + requirement_card={ + "plan_profile": "it_project", + "title": "打车平台 AI 推荐第三方打车", + "goal": "为用户提供跨平台比价和一键跳转的第三方打车能力,先验证真实下单闭环。", + "project_nature": "业务平台型", + "tech_route": "代理型服务台", + "source_material": "用户:关于打车平台 ai 智能推荐性价比最高的第三方打车,直接连通第三方平台直接打车", + "question_round": 2, + "round_option_maps": { + "first_scope": { + "A": "聚合型中间件", + "B": "代理型服务台", + "C": "嵌入式插件", + } + }, + "last_round_options": { + "field": "first_scope", + "options": [ + {"key": "A", "title": "聚合型中间件"}, + {"key": "B", "title": "代理型服务台"}, + {"key": "C", "title": "嵌入式插件"}, + ], + "recommendation": {"key": "B"}, + }, + }, + draft_payload=None, + required_confirmations=[], + ) + requirement_plan_db_session.add(grounded_draft) + await requirement_plan_db_session.flush() + + executor = FakeExecutor() + result = await executor._build_requirement_planning_direct_result( + message_text="我选C 继续", + source_system="feishu", + source_message_id="msg-plan-scope-repair-followup", + source_chat_id="chat-plan-scope-repair", + sender=None, + source_sender_open_id="ou-plan-scope-repair", + thread_context={ + "root_message_id": "om-plan-scope-repair-polluted-root", + "reply_to_message_id": "om-plan-scope-repair-polluted-root", + }, + conversation_key=conversation_scope_key, + topic_switch=None, + ) + + assert result["status"] == "succeeded" + assert result["workflow_route"] == mixin_execute_query_project.ROUTE_REQUIREMENT_PLAN_COLLECT + assert "第三轮:服务对象" in str(result["answer"] or "") + assert "缺少最初的需求描述" not in str(result["answer"] or "") + requirement_plan_context = result.get("requirement_plan_context") + assert isinstance(requirement_plan_context, dict) + assert requirement_plan_context["title"] == "打车平台 AI 推荐第三方打车" + assert requirement_plan_context["first_scope"] == "嵌入式插件" + assert "第三方平台直接打车" in str(requirement_plan_context["source_material"]) + + refreshed_polluted_draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace).where(MountedDraftWorkspace.id == int(polluted_draft.id)) + ) + ).scalar_one() + assert refreshed_polluted_draft.requirement_card["title"] == "打车平台 AI 推荐第三方打车" + assert refreshed_polluted_draft.requirement_card["first_scope"] == "嵌入式插件" + assert "第三方平台直接打车" in str(refreshed_polluted_draft.requirement_card["source_material"]) + + @pytest.mark.asyncio async def test_repair_requirement_plan_workspace_from_reply_context_prefers_structured_reply_snapshot( requirement_plan_db_session, @@ -8919,7 +10306,7 @@ async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_ first = await executor._execute_query_intent( intent=root_intent, - message_text="想跟你聊个 IT 项目需求,做一个批量自动化部署 OpenClaw 的平台,先别创建,先收第一版。", + message_text="想跟你聊个 IT 项目需求,做一个批量自动化部署 PMFlow 本地路由层的平台,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-root", @@ -8945,25 +10332,19 @@ async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_ source_system="feishu", source_message_id="msg-plan-it-4", ) - fifth = await executor._execute_query_intent( - intent=followup_intent, - message_text="A,第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。", - operator_user_id=999, - source_system="feishu", - source_message_id="msg-plan-it-5", - ) - final_result = fifth - assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in str(first["answer"] or "") + assert "🧭 我先按“PMFlow 本地路由层批量自动化部署平台”这类 IT 项目来收。" in str(first["answer"] or "") assert "A. 先做内部提效工具" in str(first["answer"] or "") assert "先定它更像工具、平台,还是长期底座" in str(first["answer"] or "") assert "⭐ 当前我更推荐:B" in str(first["answer"] or "") assert "你回 A / B / C" in str(first["answer"] or "") + assert "好,我先按这个方向继续记:项目先按发布治理平台来收。" in str(second["answer"] or "") assert "🧭 我先按“发布治理平台”这个方向继续收。" in str(second["answer"] or "") assert "平台运维团队" in str(second["answer"] or "") assert "⭐ 当前我更推荐:B" in str(second["answer"] or "") + assert "好,我先按这个方向继续记:主要服务平台运维团队。" in str(third["answer"] or "") assert "🧭 我先按“平台运维团队要长期使用”这个方向继续收。" in str(third["answer"] or "") assert "第一版技术路线定住" in str(third["answer"] or "") assert "FastAPI + React + Redis + Celery + PostgreSQL" in str(third["answer"] or "") @@ -8973,7 +10354,15 @@ async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_ assert "批量执行闭环" in str(fourth["answer"] or "") assert "⭐ 当前我更推荐:A" in str(fourth["answer"] or "") - final_answer = str(final_result["answer"] or "") + fifth = await executor._execute_query_intent( + intent=followup_intent, + message_text="A,第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-it-5", + ) + draft_result = fifth + final_answer = str(draft_result["answer"] or "") assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer assert "🧭【一句话判断】" in final_answer assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer @@ -8994,7 +10383,7 @@ async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_ assert "❓【待确认】还需要你拍板:" in final_answer assert "部署目标范围" in final_answer assert "试点负责人" in final_answer - assert str(final_result["rag_result"]["query_type"]) == "draft" + assert str(draft_result["rag_result"]["query_type"]) == "draft" draft = ( await requirement_plan_db_session.execute( @@ -9422,7 +10811,7 @@ async def test_draft_confirmation_route_does_not_hijack_explicit_signal_source_r @pytest.mark.asyncio -async def test_should_route_to_draft_confirmation_session_uses_active_mounted_draft_without_anchor( +async def test_should_route_to_draft_confirmation_session_requires_explicit_anchor_or_work_item( monkeypatch, ) -> None: captured: dict[str, object] = {} @@ -9449,14 +10838,8 @@ async def test_should_route_to_draft_confirmation_session_uses_active_mounted_dr ingress_options=None, ) - assert should_route is True - assert captured["kwargs"] == { - "sender": "tester", - "source_system": "feishu", - "channel_ref": "chat-active", - "source_sender_open_id": "ou_active", - "thread_context": {}, - } + assert should_route is False + assert captured == {} @pytest.mark.asyncio @@ -9510,7 +10893,7 @@ async def test_should_route_to_draft_confirmation_session_prioritizes_explicit_r @pytest.mark.asyncio -async def test_should_route_to_draft_confirmation_session_does_not_hijack_smalltalk_even_if_active_draft_exists( +async def test_should_route_to_draft_confirmation_session_does_not_lock_smalltalk_without_anchor( monkeypatch, ) -> None: called = False @@ -9540,7 +10923,7 @@ async def test_should_route_to_draft_confirmation_session_does_not_hijack_smallt ) assert should_route is False - assert called is True + assert called is False @pytest.mark.asyncio @@ -9573,7 +10956,7 @@ async def test_should_route_to_draft_confirmation_session_keeps_pending_confirm_ @pytest.mark.asyncio -async def test_should_route_to_draft_confirmation_session_with_active_draft_does_not_require_revision_heuristics( +async def test_should_route_to_draft_confirmation_session_does_not_route_free_text_followup_without_anchor( monkeypatch, ) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): @@ -9601,7 +10984,262 @@ async def test_should_route_to_draft_confirmation_session_with_active_draft_does @pytest.mark.asyncio -async def test_should_route_to_draft_confirmation_session_does_not_hijack_regenerate_request_even_if_active_draft_exists( +async def test_external_freshness_followup_stays_in_query_answer_without_internal_target( + external_qa_db_session, +) -> None: + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-external-qa", + source_sender_open_id="ou-external-qa", + sender=None, + ) + session = QASession( + tenant_id=1, + user_id=None, + title="外部事实问答", + external_conversation_key=conversation_scope_key, + message_count=1, + ) + external_qa_db_session.add(session) + await external_qa_db_session.flush() + external_qa_db_session.add( + QARecord( + tenant_id=1, + question="当前codex最新的模型用的是什么?", + answer="当前未接入实时外部数据,暂时无法确认最新结论。", + sources=[], + intent="question", + domains_used=[], + response_time_ms=23, + asked_by=None, + asked_via="feishu", + project_id=None, + session_id=int(session.id), + turn_index=1, + source_message_id="om-external-question-1", + source_chat_id="chat-external-qa", + source_sender_open_id="ou-external-qa", + conversation_scope_key=conversation_scope_key, + grounding_status="insufficient_evidence", + policy_version="retrieval-v2.1|routing:v3|prompt:industry_trend", + sources_used=["External Freshness Guard"], + ) + ) + await external_qa_db_session.commit() + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = external_qa_db_session + self.tenant_id = 1 + + kernel = FakeKernel() + result = await kernel._maybe_handle_external_freshness_followup( + source_system="feishu", + source_message_id="om-external-followup-1", + channel_ref="chat-external-qa", + source_sender_open_id="ou-external-qa", + message_text="接入外部来源继续核实", + thread_context={"reply_to_message_id": "om-external-question-1"}, + ) + + assert result is not None + assert result["workflow_route"] == "query.answer" + assert result["classification"] == "question" + assert "实时外部来源" in result["direct_reply_text"] + assert "任务编号" not in result["direct_reply_text"] + assert "已收到需求" not in result["direct_reply_text"] + + +@pytest.mark.asyncio +async def test_external_freshness_followup_allows_explicit_internal_source_request( + external_qa_db_session, +) -> None: + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-external-qa", + source_sender_open_id="ou-external-qa", + sender=None, + ) + session = QASession( + tenant_id=1, + user_id=None, + title="外部事实问答", + external_conversation_key=conversation_scope_key, + message_count=1, + ) + external_qa_db_session.add(session) + await external_qa_db_session.flush() + external_qa_db_session.add( + QARecord( + tenant_id=1, + question="当前codex最新的模型用的是什么?", + answer="当前未接入实时外部数据,暂时无法确认最新结论。", + sources=[], + intent="question", + domains_used=[], + response_time_ms=23, + asked_by=None, + asked_via="feishu", + project_id=None, + session_id=int(session.id), + turn_index=1, + source_message_id="om-external-question-2", + source_chat_id="chat-external-qa", + source_sender_open_id="ou-external-qa", + conversation_scope_key=conversation_scope_key, + grounding_status="insufficient_evidence", + policy_version="retrieval-v2.1|routing:v3|prompt:industry_trend", + sources_used=["External Freshness Guard"], + ) + ) + await external_qa_db_session.commit() + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = external_qa_db_session + self.tenant_id = 1 + + kernel = FakeKernel() + result = await kernel._maybe_handle_external_freshness_followup( + source_system="feishu", + source_message_id="om-external-followup-2", + channel_ref="chat-external-qa", + source_sender_open_id="ou-external-qa", + message_text="给 /ai-config 接一个外部数据源来查 codex 最新模型,先出需求草稿。", + thread_context={"reply_to_message_id": "om-external-question-2"}, + ) + + assert result is None + + +@pytest.mark.asyncio +async def test_process_ingress_short_circuits_external_freshness_followup_before_mixed_or_write_path( + external_qa_db_session, + monkeypatch, +) -> None: + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-external-qa", + source_sender_open_id="ou-external-qa", + sender=None, + ) + session = QASession( + tenant_id=1, + user_id=None, + title="外部事实问答", + external_conversation_key=conversation_scope_key, + message_count=1, + ) + external_qa_db_session.add(session) + await external_qa_db_session.flush() + external_qa_db_session.add( + QARecord( + tenant_id=1, + question="当前codex最新的模型用的是什么?", + answer="当前未接入实时外部数据,暂时无法确认最新结论。", + sources=[], + intent="question", + domains_used=[], + response_time_ms=23, + asked_by=None, + asked_via="feishu", + project_id=None, + session_id=int(session.id), + turn_index=1, + source_message_id="om-external-question-3", + source_chat_id="chat-external-qa", + source_sender_open_id="ou-external-qa", + conversation_scope_key=conversation_scope_key, + grounding_status="insufficient_evidence", + policy_version="retrieval-v2.1|routing:v3|prompt:industry_trend", + sources_used=["External Freshness Guard"], + ) + ) + await external_qa_db_session.commit() + + class FakeMountedAIRuntimeWriteService: + def __init__(self, db) -> None: + _ = db + + async def canonicalize_thread_context(self, **kwargs): + _ = kwargs + return kwargs.get("thread_context"), {} + + async def begin_ingress_run(self, **kwargs): + _ = kwargs + return None + + async def terminate_requirement_plan_scope(self, **kwargs): + _ = kwargs + return { + "terminated_count": 0, + "terminated_topic_ids": [], + "terminated_work_item_ids": [], + "conversation_scope_key": None, + } + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = external_qa_db_session + self.tenant_id = 1 + self.context_assembler = SimpleNamespace( + assemble_world_model=AsyncMock(side_effect=AssertionError("should short-circuit before world model")) + ) + + async def _prepare_ingress_envelope(self, **kwargs): + _ = kwargs + return { + "early_result": None, + "clean_text": "接入外部来源继续核实", + "synthetic_message_id": "om-external-followup-3", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=301, status="pending"), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=302), + "session": SimpleNamespace(last_intent="query.answer", current_project_id=None), + } + + def _merge_ingress_thread_context(self, **kwargs): + _ = kwargs + return None + + async def _maybe_handle_active_context_followup(self, **kwargs): + _ = kwargs + return None + + async def _should_route_to_draft_confirmation_session(self, **kwargs): + _ = kwargs + return False + + async def _maybe_reset_session_project_context_for_new_requirement(self, **kwargs): + _ = kwargs + return False + + async def _maybe_handle_orphan_requirement_followup(self, **kwargs): + _ = kwargs + return None + + async def _prepare_mixed_sidecar_context(self, **kwargs): + raise AssertionError("external freshness followup should not enter mixed sidecar") + + monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) + + kernel = FakeKernel() + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="om-external-followup-3", + message_text="接入外部来源继续核实", + sender="林宇航", + channel_ref="chat-external-qa", + source_sender_open_id="ou-external-qa", + ingress_options={"thread_context": {"reply_to_message_id": "om-external-question-3"}}, + ) + + assert result["handled"] is True + assert result["workflow_route"] == "query.answer" + assert "实时外部来源" in result["direct_reply_text"] + assert "已收到需求" not in result["direct_reply_text"] + + +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_does_not_route_regenerate_request_without_anchor( monkeypatch, ) -> None: called = False @@ -9631,11 +11269,11 @@ async def test_should_route_to_draft_confirmation_session_does_not_hijack_regene ) assert should_route is False - assert called is True + assert called is False @pytest.mark.asyncio -async def test_should_route_to_draft_confirmation_session_still_skips_explicit_independent_request_with_active_draft( +async def test_should_route_to_draft_confirmation_session_keeps_independent_request_out_of_draft_without_anchor( monkeypatch, ) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): @@ -9945,7 +11583,7 @@ async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: "active_engine_id": "low-1", "engines": [low_engine, high_engine], } - openclaw_bundle = { + control_plane_bundle = { "rules": [ { "name": "engine_high_reasoning_glm5", @@ -9996,8 +11634,8 @@ async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: _ = bundle_value return dict(low_engine) - def _load_openclaw_routing_bundle(self): - return dict(openclaw_bundle) + def _load_control_plane_routing_bundle(self): + return dict(control_plane_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine @@ -10009,7 +11647,7 @@ async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: return dict(item) return None - def _ensure_openclaw_control_plane_engine(self, engine): + def _ensure_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): @@ -10090,7 +11728,7 @@ async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: "active_engine_id": "low-1", "engines": [low_engine, alternate_high_engine, glm5_engine], } - openclaw_bundle = { + control_plane_bundle = { "rules": [ { "name": "engine_low_semantic_context_exact", @@ -10141,8 +11779,8 @@ async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: _ = bundle_value return dict(low_engine) - def _load_openclaw_routing_bundle(self): - return dict(openclaw_bundle) + def _load_control_plane_routing_bundle(self): + return dict(control_plane_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine @@ -10160,7 +11798,7 @@ async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: return dict(alternate_high_engine) return dict(low_engine) - def _ensure_openclaw_control_plane_engine(self, engine): + def _ensure_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): @@ -10241,7 +11879,7 @@ async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> Non "active_engine_id": "low-1", "engines": [low_engine, alternate_high_engine, glm5_engine], } - openclaw_bundle = { + control_plane_bundle = { "rules": [ { "name": "engine_low_semantic_context_exact", @@ -10292,8 +11930,8 @@ async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> Non _ = bundle_value return dict(low_engine) - def _load_openclaw_routing_bundle(self): - return dict(openclaw_bundle) + def _load_control_plane_routing_bundle(self): + return dict(control_plane_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine @@ -10311,7 +11949,7 @@ async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> Non return dict(alternate_high_engine) return dict(low_engine) - def _ensure_openclaw_control_plane_engine(self, engine): + def _ensure_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): diff --git a/backend/tests/test_ai_pmo_signal_source_onboard_contract.py b/backend/tests/test_ai_pmo_signal_source_onboard_contract.py index 768e17f3..6ee18fce 100644 --- a/backend/tests/test_ai_pmo_signal_source_onboard_contract.py +++ b/backend/tests/test_ai_pmo_signal_source_onboard_contract.py @@ -97,7 +97,7 @@ async def test_signal_source_onboard_replays_existing_ledger_without_reexecution return bool(text) @staticmethod - def _extract_auto_tasks(openclaw_payload): + def _extract_auto_tasks(control_plane_payload): return [] kernel = _FakeExecutor() diff --git a/backend/tests/test_ai_service_core_runtime.py b/backend/tests/test_ai_service_core_runtime.py index 624a4754..34fb2f79 100644 --- a/backend/tests/test_ai_service_core_runtime.py +++ b/backend/tests/test_ai_service_core_runtime.py @@ -11,7 +11,7 @@ from app.services.llm_service import mixin_chat_openai from app.services.ai_service_core import AIService from app.services.llm_service import mixin_cache_circuit from app.services.llm_service import llm_service -from app.services.llm_service.mixin_openclaw_routing import LLMOpenclawRoutingMixin +from app.services.llm_service.mixin_control_plane_routing import LLMControlPlaneRoutingMixin from app.services.model.model_config import load_model_config, resolve_model_config_path from app.services.model.model_context_bundle import ModelContextBundleService @@ -149,18 +149,18 @@ def test_model_config_bundle_centralizes_provider_and_tier_defaults() -> None: assert tier_defaults["low"]["model"] == "qwen-plus" assert tier_defaults["high"]["model"] == "GLM-5" assert "safety" not in tier_defaults - assert str(bundle.get("config_path") or "").endswith("backend/.openclaw/pmflow-routing-rules.json") + assert str(bundle.get("config_path") or "").endswith("backend/.control_plane/pmflow-routing-rules.json") -def test_model_config_default_path_points_to_backend_openclaw() -> None: - assert str(resolve_model_config_path()).endswith("backend/.openclaw/pmflow-routing-rules.json") +def test_model_config_default_path_points_to_backend_control_plane() -> None: + assert str(resolve_model_config_path()).endswith("backend/.control_plane/pmflow-routing-rules.json") def test_llm_service_no_longer_keeps_function_hint_tables_in_runtime_modules() -> None: repo_root = Path(__file__).resolve().parents[1] guardrails = (repo_root / "app/services/llm_service/mixin_guardrails_prompt.py").read_text(encoding="utf-8") - routing = (repo_root / "app/services/llm_service/mixin_openclaw_routing.py").read_text(encoding="utf-8") + routing = (repo_root / "app/services/llm_service/mixin_control_plane_routing.py").read_text(encoding="utf-8") shared = (repo_root / "app/services/llm_service/shared_part_01.py").read_text(encoding="utf-8") assert "DEFAULT_HIGH_TIER_HINTS" not in shared @@ -223,7 +223,7 @@ def test_semantic_context_decision_no_longer_receives_model_context_bundle() -> assert ModelContextBundleService.should_inject_context("signal_planner_analyze") is True -def test_openclaw_routing_can_respect_explicit_low_tier_for_adaptive_rag_deep() -> None: +def test_control_plane_routing_can_respect_explicit_low_tier_for_adaptive_rag_deep() -> None: low_engine = { "id": "low-1", "provider": "glm", @@ -238,7 +238,7 @@ def test_openclaw_routing_can_respect_explicit_low_tier_for_adaptive_rag_deep() "base_url": "https://api.edgefn.net/v1", "api_key": "high-key", } - openclaw_bundle = { + control_plane_bundle = { "version": "test-v1", "updated_at": "2026-04-09T15:00:00+08:00", "rules": [ @@ -263,12 +263,12 @@ def test_openclaw_routing_can_respect_explicit_low_tier_for_adaptive_rag_deep() ], } - class _FakeRouting(LLMOpenclawRoutingMixin): - def _openclaw_routing_enabled(self) -> bool: + class _FakeRouting(LLMControlPlaneRoutingMixin): + def _control_plane_routing_enabled(self) -> bool: return True - def _load_openclaw_routing_bundle(self) -> dict[str, object]: - return dict(openclaw_bundle) + def _load_control_plane_routing_bundle(self) -> dict[str, object]: + return dict(control_plane_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine @@ -280,7 +280,7 @@ def test_openclaw_routing_can_respect_explicit_low_tier_for_adaptive_rag_deep() return dict(item) return None - routed = _FakeRouting()._resolve_openclaw_engine( + routed = _FakeRouting()._resolve_control_plane_engine( function_name="adaptive_rag_answer_deep", engines=[low_engine, high_engine], active_engine=high_engine, @@ -314,7 +314,7 @@ def test_provider_payload_reads_canonical_model_config(monkeypatch) -> None: @pytest.mark.asyncio async def test_ai_service_runtime_prefers_canonical_config_over_env(monkeypatch) -> None: service = AIService() - monkeypatch.setenv("OPENCLAW_ROUTING_ENABLED", "false") + monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "false") monkeypatch.setenv("LLM_DIRECT_EMERGENCY_ENABLED", "true") monkeypatch.setenv("LLM_PROVIDER", "glm") monkeypatch.setenv("DASHSCOPE_MODEL", "wrong-value") @@ -328,7 +328,7 @@ async def test_ai_service_runtime_prefers_canonical_config_over_env(monkeypatch) def test_ai_service_runtime_ignores_legacy_glm_api_key(monkeypatch) -> None: service = AIService() - monkeypatch.setenv("OPENCLAW_ROUTING_ENABLED", "false") + monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "false") monkeypatch.setenv("LLM_DIRECT_EMERGENCY_ENABLED", "true") monkeypatch.setenv("LLM_PROVIDER", "glm") monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) @@ -339,7 +339,7 @@ def test_ai_service_runtime_ignores_legacy_glm_api_key(monkeypatch) -> None: assert runtime["api_key"] == "" -def test_openclaw_rule_keeps_existing_api_key_when_env_missing(monkeypatch) -> None: +def test_control_plane_rule_keeps_existing_api_key_when_env_missing(monkeypatch) -> None: monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) rule = { @@ -381,7 +381,7 @@ def test_openclaw_rule_keeps_existing_api_key_when_env_missing(monkeypatch) -> N assert routed["api_key"] == "db-key" -def test_openclaw_rule_prefers_env_api_key_over_db_key(monkeypatch) -> None: +def test_control_plane_rule_prefers_env_api_key_over_db_key(monkeypatch) -> None: monkeypatch.setenv("DASHSCOPE_API_KEY", "env-dashscope-key") rule = { @@ -561,11 +561,11 @@ async def test_openai_compatible_chat_retries_once_on_connect_error(monkeypatch) structured_output=False, function_label="unit_test_connect_retry", provider_name="glm", - is_openclaw_gateway_request=False, - openclaw_agents_timeout_seconds=120, + is_control_plane_gateway_request=False, + control_plane_agents_timeout_seconds=120, request_started_at_perf=time.perf_counter(), request_trace_id="trace-1", - openclaw_agent_timeout=None, + control_plane_agent_timeout=None, ) assert calls["count"] == 2 @@ -626,11 +626,11 @@ async def test_openai_compatible_chat_accepts_structured_json_from_reasoning_con structured_output=True, function_label="draft_generator_v1", provider_name="glm", - is_openclaw_gateway_request=False, - openclaw_agents_timeout_seconds=120, + is_control_plane_gateway_request=False, + control_plane_agents_timeout_seconds=120, request_started_at_perf=time.perf_counter(), request_trace_id="trace-reasoning-json", - openclaw_agent_timeout=None, + control_plane_agent_timeout=None, ) assert json.loads(result.text)["title"] == "AI直播复盘助手" @@ -702,11 +702,11 @@ async def test_openai_compatible_chat_retries_empty_reasoning_response_with_bigg structured_output=False, function_label="adaptive_rag_answer_normal", provider_name="glm", - is_openclaw_gateway_request=False, - openclaw_agents_timeout_seconds=120, + is_control_plane_gateway_request=False, + control_plane_agents_timeout_seconds=120, request_started_at_perf=time.perf_counter(), request_trace_id="trace-empty-reasoning-retry", - openclaw_agent_timeout=None, + control_plane_agent_timeout=None, ) assert result.text.startswith("这是挂载 runtime") diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index fee92d8e..bd19e46a 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -1,3 +1,4 @@ +import asyncio import json from types import SimpleNamespace @@ -8,8 +9,10 @@ from app.config.ai_prompts import ( PROMPT_EXTERNAL_TREND_QA, PROMPT_FEISHU_DIRECT_QA, PROMPT_FEISHU_MIXED_QA, + PROMPT_INDUSTRY_TREND_QA, PROMPT_PROJECT_STATUS_QA, ) +from app.config.ai_prompts_catalog.part_scenes_01 import FEISHU_QA_RESPONSE_STYLE from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_SUCCEEDED from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_DRAFT_PENDING_CONFIRM @@ -1224,7 +1227,7 @@ async def test_draft_confirm_handler_confirm_without_target_returns_failed_safe( assert result["handled"] is True assert result["status"] == "failed_safe" - assert "当前没有可确认草稿" in result["followup_reply"] + assert "当前没有可处理草稿" in result["followup_reply"] @pytest.mark.asyncio @@ -1778,6 +1781,56 @@ async def test_resolve_pending_draft_materializes_retryable_work_item_from_mount assert captured["channel_ref"] == "chat-1" +@pytest.mark.asyncio +async def test_resolve_pending_draft_does_not_fall_back_to_same_scope_pending_work_item_without_explicit_anchor( +) -> None: + pending_work_item = SimpleNamespace( + id=1099, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="followup.confirm", + project_id=None, + signal_id=None, + source_message_id="src-pending-draft-1", + ) + pending_ingest = SimpleNamespace( + payload={ + "sender": "林宇航", + "channel_ref": "chat-1", + "source_sender_open_id": "ou_xxx", + "message_text": "旧草稿确认", + }, + source_message_id="src-pending-draft-1", + ) + + class _FakeResult: + def __init__(self, rows) -> None: + self._rows = list(rows) + + def all(self): + return list(self._rows) + + class _FakeDB: + async def execute(self, query): + sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower() + if "from work_items" in sql: + return _FakeResult([(pending_work_item, pending_ingest)]) + raise AssertionError(f"unexpected query: {sql}") + + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})()) + handler._resolve_active_mounted_draft = lambda **kwargs: asyncio.sleep(0, result=None) # type: ignore[method-assign] + + result = await handler._resolve_pending_draft( + sender="林宇航", + source_system="feishu", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + explicit_work_item_id=None, + thread_context=None, + ) + + assert result is None + + @pytest.mark.asyncio async def test_resolve_retryable_draft_owner_user_id_prefers_latest_source_message_id(monkeypatch) -> None: class _FakeScalarResult: @@ -2032,7 +2085,11 @@ async def test_draft_confirm_handler_target_bound_without_semantic_action_return @pytest.mark.asyncio -async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_routes_to_confirm(monkeypatch) -> None: +@pytest.mark.parametrize("message_text", ["确认", "确认入库", "确认入库。"]) +async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_routes_to_confirm( + monkeypatch, + message_text: str, +) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) captured: dict[str, object] = {} @@ -2081,7 +2138,7 @@ async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_ro monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel) result = await handler.handle_text_reply( - message_text="确认", + message_text=message_text, sender="林宇航", source_system="feishu", source_message_id="msg-confirm-1", @@ -2098,12 +2155,89 @@ async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_ro assert result["handled"] is True assert result["status"] == "succeeded" assert result["followup_reply"] == "✅ 已确认入库" - assert captured["confirm_kwargs"]["message_text"] == "确认" + assert captured["confirm_kwargs"]["message_text"] == message_text assert captured["confirm_kwargs"]["source_message_id"] == "msg-confirm-1" @pytest.mark.asyncio -async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_routes_to_cancel(monkeypatch) -> None: +@pytest.mark.parametrize("message_text", ["修改", "修改。", "修改 + 你的意见"]) +async def test_draft_confirm_handler_explicit_revise_without_semantic_action_routes_to_revise( + monkeypatch, + message_text: str, +) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + captured: dict[str, object] = {} + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_resolve_target_mounted_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"), + SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"), + ) + + async def _fake_revise_draft(**kwargs): + captured["revise_kwargs"] = kwargs + return { + "handled": True, + "status": "draft_pending_confirm", + "followup_reply": "我先按当前草稿补一版。", + } + + async def _unexpected_confirm(**kwargs): # noqa: ANN001 + raise AssertionError(f"confirm_draft should not run here: {kwargs}") + + async def _unexpected_cancel(**kwargs): # noqa: ANN001 + raise AssertionError(f"cancel_draft should not run here: {kwargs}") + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft) + monkeypatch.setattr(handler, "revise_draft", _fake_revise_draft) + monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm) + monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel) + + result = await handler.handle_text_reply( + message_text=message_text, + sender="林宇航", + source_system="feishu", + source_message_id="msg-revise-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context={"reply_to_message_id": "reply-1"}, + semantic_followup_override={ + "action": "", + "revision_note": "", + "assistant_reply": "", + }, + ) + + assert result["handled"] is True + assert result["status"] == "draft_pending_confirm" + assert "补一版" in result["followup_reply"] + assert captured["revise_kwargs"]["revision_note"] == "" + assert captured["revise_kwargs"]["revision_note"] == "" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("message_text", ["取消", "取消。", "取消本版。"]) +async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_routes_to_cancel( + monkeypatch, + message_text: str, +) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) captured: dict[str, object] = {} @@ -2152,7 +2286,7 @@ async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_rou monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm) result = await handler.handle_text_reply( - message_text="取消", + message_text=message_text, sender="林宇航", source_system="feishu", source_message_id="msg-cancel-1", @@ -2173,7 +2307,77 @@ async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_rou @pytest.mark.asyncio -async def test_draft_confirm_handler_target_bound_without_anchor_and_without_semantic_action_is_ignored(monkeypatch) -> None: +async def test_draft_confirm_handler_explicit_regenerate_without_semantic_action_routes_to_regenerate(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + captured: dict[str, object] = {} + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_resolve_target_mounted_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"), + SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"), + ) + + async def _fake_regenerate_draft(**kwargs): + captured["regenerate_kwargs"] = kwargs + return { + "handled": True, + "status": "draft_pending_confirm", + "followup_reply": "我先按当前草稿重出一版。", + } + + async def _unexpected_confirm(**kwargs): # noqa: ANN001 + raise AssertionError(f"confirm_draft should not run here: {kwargs}") + + async def _unexpected_cancel(**kwargs): # noqa: ANN001 + raise AssertionError(f"cancel_draft should not run here: {kwargs}") + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft) + monkeypatch.setattr(handler, "regenerate_draft", _fake_regenerate_draft) + monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm) + monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel) + + result = await handler.handle_text_reply( + message_text="重出一版。", + sender="林宇航", + source_system="feishu", + source_message_id="msg-regenerate-2", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context={"reply_to_message_id": "reply-1"}, + semantic_followup_override={ + "action": "", + "revision_note": "", + "assistant_reply": "", + }, + ) + + assert result["handled"] is True + assert result["status"] == "draft_pending_confirm" + assert "重出一版" in result["followup_reply"] + assert captured["regenerate_kwargs"]["message_text"] == "重出一版。" + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_target_bound_without_anchor_and_without_semantic_action_returns_pending_manual( + monkeypatch, +) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) @@ -2216,8 +2420,9 @@ async def test_draft_confirm_handler_target_bound_without_anchor_and_without_sem }, ) - assert result["handled"] is False - assert result["status"] == "ignored" + assert result["handled"] is True + assert result["status"] == "pending_manual" + assert "请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”" in result["followup_reply"] def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: @@ -2507,6 +2712,119 @@ async def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_ assert "外部搜索结果" in prompt +@pytest.mark.asyncio +async def test_adaptive_rag_select_scene_prompt_downgrades_external_trend_without_explicit_project_context() -> None: + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = _FakePromptDB() + self.tenant_id = 1 + + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return { + "scene_key": "external_trend", + "snapshot_focus": "none", + "deep_intent": "none", + "confidence": 0.91, + } + + mixin = _FakeService() + scene_key, prompt = await mixin._select_scene_prompt( + question="codex 目前最高模型出到什么了?", + project_id=None, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="[1] 来源: graph\n内容: Project#104 #codex-plan", + system_snapshot={"assistant_profile": {"name": "PMFlow"}}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + + assert scene_key == "industry_trend" + assert "当前未接入实时数据" in prompt + assert "项目上下文" not in prompt + assert "外部搜索结果" in prompt + + +@pytest.mark.asyncio +async def test_adaptive_rag_select_scene_prompt_promotes_external_model_brand_query_from_ai_pmo() -> None: + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = _FakePromptDB() + self.tenant_id = 1 + + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return { + "scene_key": "ai_pmo", + "snapshot_focus": "identity", + "deep_intent": "none", + "confidence": 0.92, + } + + mixin = _FakeService() + scene_key, prompt = await mixin._select_scene_prompt( + question="当前codex最新的模型用的是什么?", + project_id=None, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="[1] 来源: graph\n内容: Project#104 #codex-plan", + system_snapshot={"assistant_profile": {"name": "PMFlow"}}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + + assert scene_key == "industry_trend" + assert "当前未接入实时数据" in prompt + assert "项目上下文" not in prompt + assert "外部搜索结果" in prompt + + +@pytest.mark.asyncio +async def test_adaptive_rag_select_scene_prompt_keeps_explicit_internal_model_config_question_in_ai_pmo() -> None: + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = _FakePromptDB() + self.tenant_id = 1 + + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return { + "scene_key": "ai_pmo", + "snapshot_focus": "identity", + "deep_intent": "none", + "confidence": 0.92, + } + + mixin = _FakeService() + scene_key, prompt = await mixin._select_scene_prompt( + question="我们系统当前接的是哪个 codex 模型配置?", + project_id=None, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={"assistant_profile": {"name": "PMFlow"}}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + + assert scene_key == "ai_pmo" + assert "World Model:" in prompt + assert "当前未接入实时数据" not in prompt + + @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None: mixin = _make_adaptive_rag_mixin() @@ -2598,6 +2916,12 @@ def test_feishu_qa_prompts_prefer_professional_answer_style() -> None: assert "XXXX年" not in PROMPT_EXTERNAL_TREND_QA assert "通用行业认知" in PROMPT_EXTERNAL_TREND_QA assert "项目状态分析助手" in PROMPT_PROJECT_STATUS_QA + assert "内部函数名" in FEISHU_QA_RESPONSE_STYLE + assert "文件路径" in FEISHU_QA_RESPONSE_STYLE + assert "内部函数名" in PROMPT_EXTERNAL_TREND_QA + assert "文件路径" in PROMPT_EXTERNAL_TREND_QA + assert "内部函数名" in PROMPT_INDUSTRY_TREND_QA + assert "文件路径" in PROMPT_INDUSTRY_TREND_QA def test_feishu_reply_llm_fallback_is_failure_oriented() -> None: diff --git a/backend/tests/test_feishu_card_action_contract.py b/backend/tests/test_feishu_card_action_contract.py index e88640e1..2621364b 100644 --- a/backend/tests/test_feishu_card_action_contract.py +++ b/backend/tests/test_feishu_card_action_contract.py @@ -1,6 +1,7 @@ import pytest from app.services.integrations.feishu_integration_service.mixin_card_action import FeishuCardActionMixin +from app.services.integrations.feishu_integration_service.mixin_receive_routing import FeishuReceiveRoutingMixin from app.services.integrations.feishu_integration_service.mixin_reply_templates import FeishuReplyTemplatesMixin from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService @@ -46,6 +47,22 @@ def test_extract_card_action_payload_parses_json_string_value_and_aliases() -> N assert payload["chat_id"] == "chat_9" +def test_extract_card_action_payload_parses_plan_synthetic_text() -> None: + payload = FeishuCardActionMixin._extract_card_action_payload( + { + "message_id": "msg_plan_1", + "chat_id": "chat_plan_1", + "operator": {"open_id": "ou_plan", "name": "计划用户"}, + "action": {"value": {"action": "plan_choice", "choice": "C", "synthetic_text": "C"}}, + } + ) + assert payload["action"] == "plan_choice" + assert payload["choice"] == "C" + assert payload["synthetic_text"] == "C" + assert payload["source_message_id"] == "msg_plan_1" + assert payload["chat_id"] == "chat_plan_1" + + @pytest.mark.asyncio async def test_handle_message_event_dispatches_card_action(monkeypatch) -> None: service = FeishuIntegrationService(db=object()) @@ -102,6 +119,26 @@ def test_build_action_card_for_pending_manual_continue() -> None: assert (actions[0].get("value") or {}).get("work_item_id") == 99 +def test_build_action_card_for_requirement_plan_followup() -> None: + card = FeishuCardActionMixin._build_action_card_from_capture_result( + capture_result={ + "reply_template_key": "requirement_plan_followup", + "work_item_id": 77, + "direct_reply_text": "A. 规则引擎驱动\nB. 轻量模型辅助\nC. 混合增强路径\n\n⭐ 当前我更推荐:C", + }, + mention_open_id="ou_plan_xxx", + mention_name="测试计划用户", + ) + assert isinstance(card, dict) + assert card["header"]["title"]["content"] == "AI PMO 需求收敛" + action_rows = [item for item in card["elements"] if item.get("tag") == "action"] + assert len(action_rows) == 2 + row_1_actions = action_rows[0].get("actions") if isinstance(action_rows[0].get("actions"), list) else [] + row_2_actions = action_rows[1].get("actions") if isinstance(action_rows[1].get("actions"), list) else [] + assert [((btn.get("value") or {}).get("synthetic_text")) for btn in row_1_actions] == ["A", "B", "C", "D"] + assert [((btn.get("value") or {}).get("synthetic_text")) for btn in row_2_actions] == ["继续出草稿", "重出一版"] + + def test_build_execution_receipt_reply_prefers_compact_multiline_summary() -> None: reply = FeishuReplyTemplatesMixin._build_execution_receipt_reply( message_content="请处理这条信号", @@ -185,6 +222,67 @@ async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch assert called["reply_chat"] == "oc_continue_1" +@pytest.mark.asyncio +async def test_handle_card_action_trigger_plan_choice_dispatches_synthetic_ingress(monkeypatch) -> None: + called: dict[str, object] = {} + + class DummyDB: + async def commit(self) -> None: + called["committed"] = True + + async def rollback(self) -> None: + called["rolled_back"] = True + + service = FeishuIntegrationService(db=DummyDB()) + service.tenant_id = 7 + + async def _fake_reply_card(**kwargs): + called["reply_card"] = kwargs.get("card_content") + called["reply_chat"] = kwargs.get("chat_id") + + monkeypatch.setattr(service, "_safe_reply_card", _fake_reply_card) + + class _FakeKernel: + def __init__(self, db, tenant_id): + called["kernel_tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + called["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "captured", + "reply_template_key": "requirement_plan_followup", + "direct_reply_text": "C. 混合增强路径\n\n⭐ 当前我更推荐:C", + "results": [{"work_item_id": 88}], + "work_item_id": 88, + } + + monkeypatch.setattr( + "app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime.AIPMOKernelService", + _FakeKernel, + ) + + result = await service._handle_card_action_trigger( + { + "open_message_id": "om_plan_1", + "open_chat_id": "oc_plan_1", + "operator": {"open_id": "ou_plan", "name": "计划用户"}, + "action": {"value": {"action": "plan_choice", "choice": "C", "synthetic_text": "C", "work_item_id": 88}}, + } + ) + + assert result["code"] == 0 + data = result.get("data") if isinstance(result.get("data"), dict) else {} + assert data.get("handled") is True + assert data.get("target_work_item_id") == 88 + ingress_kwargs = called["ingress_kwargs"] + assert ingress_kwargs["message_text"] == "C" + assert ingress_kwargs["source_system"] == "ai_pmo_ui" + assert called["reply_chat"] == "oc_plan_1" + assert isinstance(called["reply_card"], dict) + assert called["reply_card"]["header"]["title"]["content"] == "AI PMO 需求收敛" + + @pytest.mark.asyncio async def test_handle_card_action_trigger_queue_continue_is_ignored(monkeypatch) -> None: called: dict[str, object] = {} @@ -218,3 +316,61 @@ async def test_handle_card_action_trigger_queue_continue_is_ignored(monkeypatch) data = result.get("data") if isinstance(result.get("data"), dict) else {} assert data.get("reason") == "missing_work_item_id" assert "reply_text" not in called + + +@pytest.mark.asyncio +async def test_route_scene_reply_sends_requirement_plan_followup_as_card(monkeypatch) -> None: + calls: dict[str, object] = {} + + class FakeRouting(FeishuReceiveRoutingMixin): + def _build_capability_reply(self, message_content: str): # noqa: ANN001 + _ = message_content + return None + + def _build_action_card_from_capture_result(self, *, capture_result, mention_open_id, mention_name): # noqa: ANN001 + _ = (mention_open_id, mention_name) + if str(capture_result.get("reply_template_key") or "").strip().lower() == "requirement_plan_followup": + return { + "header": {"title": {"tag": "plain_text", "content": "AI PMO 需求收敛"}}, + "elements": [], + } + return None + + async def _safe_reply_card(self, **kwargs): # noqa: ANN001 + calls["reply_card"] = kwargs + + async def _safe_reply_text(self, **kwargs): # noqa: ANN001 + calls["reply_text"] = kwargs + + def _emit_state_transition(self, **kwargs): # noqa: ANN001 + calls["state_transition"] = kwargs + + def _should_allow_group_qa_reply(self, **kwargs): # noqa: ANN001 + _ = kwargs + return True + + routing = FakeRouting() + result = await routing._route_scene_reply( + scene="qa", + result={ + "reply_template_key": "requirement_plan_followup", + "work_item_id": 88, + "direct_reply_text": "C. 混合增强路径\n\n⭐ 当前我更推荐:C", + }, + message_content="C", + chat_id="chat-plan-card", + chat_type="group", + sender_open_id="ou-plan", + sender_name="计划用户", + mentioned_bot_names=[], + thread_context=None, + trace_id="trace-plan-card", + message_id="msg-plan-card", + perf_t0=None, + ) + + assert result == "qa_action_card" + assert "reply_card" in calls + assert "reply_text" not in calls + card_content = calls["reply_card"]["card_content"] if isinstance(calls["reply_card"], dict) else {} + assert card_content["header"]["title"]["content"] == "AI PMO 需求收敛" diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index dd959745..b9e800c7 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -252,6 +252,28 @@ def test_decorate_capture_result_adds_queue_action_card_metadata() -> None: assert result["queue_item_id"] == 301 +def test_normalize_direct_reply_text_strips_internal_debug_lines() -> None: + raw = """ +结论:这类问题应该先按外部事实问答处理。 + +证据链: +- backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py line 323 +- Knowledge Graph #2-#4 命中 Project#104 与 #codex-plan + +来源: URL[https://openai.com] +""".strip() + + cleaned = FeishuPipelineProcessMessageHelpersBMixin._normalize_direct_reply_text(raw) + + assert "结论:这类问题应该先按外部事实问答处理。" in cleaned + assert "来源: URL[https://openai.com]" in cleaned + assert "mixin_answer_retrieval.py" not in cleaned + assert "Knowledge Graph" not in cleaned + assert "Project#104" not in cleaned + assert "#codex-plan" not in cleaned + assert "line 323" not in cleaned + + def test_rag_conversation_key_uses_stable_scope_without_reply_anchor() -> None: key = FeishuPipelineMessageUtilsAMixin._build_rag_conversation_key( source_chat_id="chat-1", @@ -1696,6 +1718,381 @@ async def test_capture_from_feishu_group_choice_protocol_uses_executor_requireme assert thread_context["reply_to_message_id"] == "om-active-plan-root" +@pytest.mark.asyncio +async def test_capture_from_feishu_explicit_reply_choice_protocol_recovers_active_requirement_thread_context( + monkeypatch, +) -> None: + captured: dict[str, object] = {} + + class _FakeBundle: + primary_workflow = "query_flow" + workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + diagnostics = {} + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "diagnostics": {}, + } + + class _FakeDB: + async def commit(self) -> None: + return None + + async def rollback(self) -> None: + return None + + class _FakeKernel: + @staticmethod + def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str): + _ = (source_system, source_message_id, channel_ref, sender, message_text) + return "msg-plan-choice-explicit-reply" + + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def _find_active_requirement_plan_workspace(self, **kwargs): + captured["finder_kwargs"] = dict(kwargs) + return ( + SimpleNamespace(id=185, root_message_id="om-active-plan-root", last_message_id="om-active-plan-root"), + SimpleNamespace(id=31, state="collecting"), + ) + + @staticmethod + def _derive_active_context_kind(*, topic, draft): + _ = (topic, draft) + return "requirement_plan" + + async def _get_or_create_session(self, **kwargs): + _ = kwargs + raise AssertionError("explicit reply choice followup should stay sync") + + async def process_feishu_message(self, **kwargs): + captured["kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "classification": "question", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": "trace-plan-choice-explicit-reply", + "direct_reply_required": True, + "direct_reply_text": "已确认选项 B,继续下一轮。", + } + + class _FakeMountedRuntimeWriteService: + def __init__(self, db): + _ = db + + async def resolve_source_activation_scope( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref, thread_context) + return SimpleNamespace(id=1, source_system="feishu"), True + + async def _noop_sync_recent_cache(self, **kwargs): + _ = (self, kwargs) + + monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) + monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService) + monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) + + service = ProactiveSignalCaptureService(_FakeDB()) + result = await service.capture_from_feishu( + group_chat_id="chat-plan-choice-explicit-reply", + message_content="@PMFlow 我选B 继续下一轮", + sender="测试用户", + tenant_id=1, + proposer_open_id="ou-plan-choice-explicit-reply", + mentioned_bot_names=[], + feishu_message_id="msg-plan-choice-explicit-reply", + chat_type="group", + thread_context={"reply_to_message_id": "om-bot-reply-1"}, + ) + + assert result["status"] == "answered" + assert result["direct_reply_text"] == "已确认选项 B,继续下一轮。" + assert isinstance(captured.get("finder_kwargs"), dict) + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + thread_context = kwargs["thread_context"] + assert isinstance(thread_context, dict) + assert thread_context["root_message_id"] == "om-active-plan-root" + assert thread_context["reply_to_message_id"] == "om-active-plan-root" + + +@pytest.mark.asyncio +async def test_capture_from_feishu_explicit_reply_choice_protocol_canonicalizes_bot_reply_anchor_before_requirement_lookup( + monkeypatch, +) -> None: + captured: dict[str, object] = {} + + class _FakeBundle: + primary_workflow = "query_flow" + workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + diagnostics = {} + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "diagnostics": {}, + } + + class _FakeDB: + async def commit(self) -> None: + return None + + async def rollback(self) -> None: + return None + + class _FakeKernel: + @staticmethod + def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str): + _ = (source_system, source_message_id, channel_ref, sender, message_text) + return "msg-plan-choice-explicit-canonicalized" + + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def _find_active_requirement_plan_workspace(self, **kwargs): + captured["finder_kwargs"] = dict(kwargs) + return ( + SimpleNamespace(id=185, root_message_id="om-active-plan-root", last_message_id="om-active-plan-root"), + SimpleNamespace(id=31, state="collecting"), + ) + + @staticmethod + def _derive_active_context_kind(*, topic, draft): + _ = (topic, draft) + return "requirement_plan" + + async def _get_or_create_session(self, **kwargs): + _ = kwargs + raise AssertionError("explicit reply choice followup should stay sync") + + async def process_feishu_message(self, **kwargs): + captured["kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "classification": "question", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": "trace-plan-choice-explicit-canonicalized", + "direct_reply_required": True, + "direct_reply_text": "已确认选项 C,继续下一轮。", + } + + class _FakeMountedRuntimeWriteService: + def __init__(self, db): + _ = db + + async def resolve_source_activation_scope( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref, thread_context) + return SimpleNamespace(id=1, source_system="feishu"), True + + async def canonicalize_thread_context( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + captured["canonicalize_kwargs"] = { + "tenant_id": tenant_id, + "source_system": source_system, + "channel_ref": channel_ref, + "thread_context": dict(thread_context or {}), + } + _ = (tenant_id, source_system, channel_ref) + return ( + { + "root_message_id": "om-source-plan-root", + "reply_to_message_id": "om-source-plan-root", + }, + { + "original_reply_to_message_id": "om-bot-reply-1", + "canonical_reply_to_message_id": "om-source-plan-root", + }, + ) + + async def _noop_sync_recent_cache(self, **kwargs): + _ = (self, kwargs) + + monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) + monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService) + monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) + + service = ProactiveSignalCaptureService(_FakeDB()) + result = await service.capture_from_feishu( + group_chat_id="chat-plan-choice-explicit-canonicalized", + message_content="@PMFlow 我选C 继续", + sender="测试用户", + tenant_id=1, + proposer_open_id="ou-plan-choice-explicit-canonicalized", + mentioned_bot_names=[], + feishu_message_id="msg-plan-choice-explicit-canonicalized", + chat_type="group", + thread_context={"reply_to_message_id": "om-bot-reply-1"}, + ) + + assert result["status"] == "answered" + assert result["direct_reply_text"] == "已确认选项 C,继续下一轮。" + assert captured["canonicalize_kwargs"]["thread_context"] == {"reply_to_message_id": "om-bot-reply-1"} + finder_kwargs = captured["finder_kwargs"] + assert isinstance(finder_kwargs, dict) + assert finder_kwargs["thread_context"] == { + "root_message_id": "om-source-plan-root", + "reply_to_message_id": "om-source-plan-root", + } + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["thread_context"] == { + "root_message_id": "om-active-plan-root", + "reply_to_message_id": "om-active-plan-root", + } + + +@pytest.mark.asyncio +async def test_capture_from_feishu_explicit_reply_choice_protocol_forces_sync_even_when_planner_would_async_queue( + monkeypatch, +) -> None: + captured: dict[str, object] = {} + + class _FakeBundle: + primary_workflow = "project_signal_flow" + workflows = [SimpleNamespace(workflow_type="project_signal_flow", workflow_action="capture")] + diagnostics = {"message_intent": "casual_chat"} + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "project_signal_flow", + "workflows": [{"workflow_type": "project_signal_flow", "workflow_action": "capture"}], + "diagnostics": dict(self.diagnostics), + } + + class _FakeDB: + async def commit(self) -> None: + return None + + async def rollback(self) -> None: + return None + + class _FakeKernel: + @staticmethod + def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str): + _ = (source_system, source_message_id, channel_ref, sender, message_text) + return "msg-plan-choice-explicit-force-sync" + + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def _find_active_requirement_plan_workspace(self, **kwargs): + captured["finder_kwargs"] = dict(kwargs) + return ( + SimpleNamespace(id=185, root_message_id="om-active-plan-root", last_message_id="om-active-plan-root"), + SimpleNamespace(id=31, state="collecting"), + ) + + @staticmethod + def _derive_active_context_kind(*, topic, draft): + _ = (topic, draft) + return "requirement_plan" + + async def _get_or_create_session(self, **kwargs): + _ = kwargs + raise AssertionError("explicit reply choice protocol should bypass async queue") + + async def process_feishu_message(self, **kwargs): + captured["kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "classification": "question", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": "trace-plan-choice-explicit-force-sync", + "direct_reply_required": True, + "direct_reply_text": "已确认选项 B,继续下一轮。", + } + + class _FakeMountedRuntimeWriteService: + def __init__(self, db): + _ = db + + async def resolve_source_activation_scope( + self, + *, + tenant_id: int, + source_system: str, + channel_ref: str | None, + thread_context: dict | None, + ): + _ = (tenant_id, source_system, channel_ref, thread_context) + return SimpleNamespace(id=1, source_system="feishu"), True + + async def _noop_sync_recent_cache(self, **kwargs): + _ = (self, kwargs) + + monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) + monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService) + monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) + + service = ProactiveSignalCaptureService(_FakeDB()) + result = await service.capture_from_feishu( + group_chat_id="chat-plan-choice-explicit-force-sync", + message_content="@PMFlow 我选B", + sender="测试用户", + tenant_id=1, + proposer_open_id="ou-plan-choice-explicit-force-sync", + mentioned_bot_names=[], + feishu_message_id="msg-plan-choice-explicit-force-sync", + chat_type="group", + thread_context={"reply_to_message_id": "om-bot-reply-1"}, + ) + + assert result["status"] == "answered" + assert result["direct_reply_text"] == "已确认选项 B,继续下一轮。" + assert isinstance(captured.get("finder_kwargs"), dict) + assert isinstance(captured.get("kwargs"), dict) + + @pytest.mark.asyncio async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None: called = {"fallback": False} diff --git a/backend/tests/test_model_routing_service.py b/backend/tests/test_model_routing_service.py index 151417af..b53f7070 100644 --- a/backend/tests/test_model_routing_service.py +++ b/backend/tests/test_model_routing_service.py @@ -83,9 +83,9 @@ def test_load_model_config_rejects_safety_tier_configuration(tmp_path) -> None: @pytest.mark.asyncio async def test_model_routing_matches_runtime_rule_and_returns_logical_view(db_env, monkeypatch) -> None: db_session, _maker = db_env - monkeypatch.setenv("OPENCLAW_ROUTING_ENABLED", "true") + monkeypatch.setenv("CONTROL_PLANE_ROUTING_ENABLED", "true") - runtime_rule = llm_service._match_openclaw_rule("classify_intent_complex") + runtime_rule = llm_service._match_control_plane_rule("classify_intent_complex") route = await ModelRoutingService.get_effective_routing( db_session, tenant_id=1, diff --git a/backend/tests/test_reply_iteration_api.py b/backend/tests/test_reply_iteration_api.py index 127a05d9..edeea211 100644 --- a/backend/tests/test_reply_iteration_api.py +++ b/backend/tests/test_reply_iteration_api.py @@ -266,7 +266,7 @@ async def test_reply_iteration_and_training_admin_routes(db_session) -> None: "job_type": "sft", "base_model_family": "glm", "target_slot": "high", - "provider_backend": "openclaw-training", + "provider_backend": "control-plane-training", }, ) assert job_resp.status_code == 200 @@ -286,7 +286,7 @@ async def test_reply_iteration_and_training_admin_routes(db_session) -> None: "/ai-steward/training/mounted-wave", json={ "base_model_family": "routing_default", - "provider_backend": "openclaw-training", + "provider_backend": "control-plane-training", "target_slots": ["low", "high"], "notes": "启动 mounted runtime 训练波次", }, diff --git a/backend/tests/test_signal_planner_contract.py b/backend/tests/test_signal_planner_contract.py index 366d8ac4..fde9c08a 100644 --- a/backend/tests/test_signal_planner_contract.py +++ b/backend/tests/test_signal_planner_contract.py @@ -207,7 +207,7 @@ async def test_signal_planner_analyze_degrades_without_charter(monkeypatch) -> N @pytest.mark.asyncio -async def test_signal_planner_analyze_does_not_warn_when_openclaw_default_route_is_used(monkeypatch) -> None: +async def test_signal_planner_analyze_does_not_warn_when_control_plane_default_route_is_used(monkeypatch) -> None: signal = SimpleNamespace( id=12, title="继续优化支付失败重试", diff --git a/backend/tests/test_training_pipeline_service.py b/backend/tests/test_training_pipeline_service.py index 474b3417..21d06245 100644 --- a/backend/tests/test_training_pipeline_service.py +++ b/backend/tests/test_training_pipeline_service.py @@ -168,7 +168,7 @@ async def test_training_dataset_rules_and_job_bootstrap(db_session) -> None: job_type="sft", base_model_family="glm", target_slot="high", - provider_backend="openclaw-training", + provider_backend="control-plane-training", ) evaluations = await service.list_evaluations( tenant_id=1, @@ -285,7 +285,7 @@ async def test_bootstrap_mounted_training_wave_creates_teacher_sets_and_jobs(db_ tenant_id=1, actor_id=9001, base_model_family="routing_default", - provider_backend="openclaw-training", + provider_backend="control-plane-training", target_slots=["low", "high"], notes="mounted runtime training wave", ) diff --git a/backend/tests/test_users_api_route_contract.py b/backend/tests/test_users_api_route_contract.py new file mode 100644 index 00000000..0dbe38a5 --- /dev/null +++ b/backend/tests/test_users_api_route_contract.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from app.api.router import api_router + + +def test_users_api_router_uses_single_prefix() -> None: + route_paths = {str(getattr(route, "path", "")).strip() for route in api_router.routes} + + assert "/users/" in route_paths + assert "/users/me" in route_paths + assert "/users/available" in route_paths + assert "/users/{user_id}" in route_paths + assert "/users/users/" not in route_paths + assert "/users/users/me" not in route_paths + assert "/users/users/available" not in route_paths + assert "/users/users/{user_id}" not in route_paths diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 27ac4394..640bf8bf 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -65,9 +65,9 @@ services: CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS} LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER} LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_PROVIDER} - OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED} - OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH} - OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS} + CONTROL_PLANE_ROUTING_ENABLED: ${CONTROL_PLANE_ROUTING_ENABLED:?missing_CONTROL_PLANE_ROUTING_ENABLED} + CONTROL_PLANE_ROUTING_RULES_PATH: ${CONTROL_PLANE_ROUTING_RULES_PATH:?missing_CONTROL_PLANE_ROUTING_RULES_PATH} + CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS} LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:?missing_LLM_MIXED_ROUTING_ENABLED} LLM_ROUTING_DEFAULT_TIER: ${LLM_ROUTING_DEFAULT_TIER:?missing_LLM_ROUTING_DEFAULT_TIER} LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:?missing_LLM_LOW_TO_HIGH_ESCALATION_ENABLED} diff --git a/docker-compose.yml b/docker-compose.yml index 5f7721ae..1155d681 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,9 +65,9 @@ services: CORS_ORIGINS: ${CORS_ORIGINS:?missing_CORS_ORIGINS} LLM_PROVIDER: ${LLM_PROVIDER:?missing_LLM_PROVIDER} LLM_FALLBACK_PROVIDER: ${LLM_FALLBACK_PROVIDER:?missing_LLM_FALLBACK_PROVIDER} - OPENCLAW_ROUTING_ENABLED: ${OPENCLAW_ROUTING_ENABLED:?missing_OPENCLAW_ROUTING_ENABLED} - OPENCLAW_ROUTING_RULES_PATH: ${OPENCLAW_ROUTING_RULES_PATH:?missing_OPENCLAW_ROUTING_RULES_PATH} - OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS} + CONTROL_PLANE_ROUTING_ENABLED: ${CONTROL_PLANE_ROUTING_ENABLED:?missing_CONTROL_PLANE_ROUTING_ENABLED} + CONTROL_PLANE_ROUTING_RULES_PATH: ${CONTROL_PLANE_ROUTING_RULES_PATH:?missing_CONTROL_PLANE_ROUTING_RULES_PATH} + CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS: ${CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS:?missing_CONTROL_PLANE_AGENTS_DEFAULT_TIMEOUT_SECONDS} LLM_MIXED_ROUTING_ENABLED: ${LLM_MIXED_ROUTING_ENABLED:?missing_LLM_MIXED_ROUTING_ENABLED} LLM_ROUTING_DEFAULT_TIER: ${LLM_ROUTING_DEFAULT_TIER:?missing_LLM_ROUTING_DEFAULT_TIER} LLM_LOW_TO_HIGH_ESCALATION_ENABLED: ${LLM_LOW_TO_HIGH_ESCALATION_ENABLED:?missing_LLM_LOW_TO_HIGH_ESCALATION_ENABLED} @@ -107,7 +107,7 @@ services: ports: - "${BACKEND_PORT:?missing_BACKEND_PORT}:8000" volumes: - - ./backend/.openclaw:/app/backend/.openclaw:ro + - ./backend/.control_plane:/app/backend/.control_plane:ro - ./logs:/app/logs healthcheck: test: ["CMD-SHELL", "curl -fsS http://localhost:8000/healthz >/dev/null || exit 1"] diff --git a/docs/FEISHU_AI_FLOW_CURRENT.md b/docs/FEISHU_AI_FLOW_CURRENT.md index d6f2ed15..f12c5459 100644 --- a/docs/FEISHU_AI_FLOW_CURRENT.md +++ b/docs/FEISHU_AI_FLOW_CURRENT.md @@ -91,7 +91,7 @@ sequenceDiagram - `query.read_only`:调用 `AdaptiveRAGService.answer_question`。 - `project.create`:调用 `AIStewardService.create_project_from_natural_language`。 - `signal.create`:直接创建 `signals`(可带 `project_id`)。 -- `signal_source.onboard`:创建信号后,走 `SignalSourceDomainWorkflow`(底层编排 `SignalSourceOrchestrationService` / OpenClaw)。 +- `signal_source.onboard`:创建信号后,走 `SignalSourceDomainWorkflow`(底层编排 `SignalSourceOrchestrationService` / PMFlow 本地路由层)。 - `followup.*`:尝试恢复最近待确认工作项继续执行。 ### 3.6 回复生成与发送 @@ -197,7 +197,7 @@ sequenceDiagram - `feishu_humanized_reply`:飞书被 @ 且需要人设化回复时使用。 - `signal_source_onboarding_plan`:识别到“新增信号源接入”时的规划器。 - `signal_source_ai_worker_execution`:信号源接入子 worker 执行器。 -- `openclaw_command_dispatch`:OpenClaw 指挥层把自然语言转操作计划。 +- `control_plane_command_dispatch`:PMFlow 本地路由层 指挥层把自然语言转操作计划。 - `rag_intent_classification`:RAG 问答意图分类(query/mixed/statement)。 - `rag_db_project_semantic_retrieval`:RAG 的项目语义检索快路由。 @@ -213,7 +213,7 @@ sequenceDiagram ### 8.2 `glm` / `minimax` 在哪一步、做什么 -> 依据:`backend/.openclaw/pmflow-routing-rules.json`(当前默认 `OPENCLAW_ROUTING_ENABLED=true`,`LLM_MIXED_ROUTING_ENABLED=false`)。 +> 依据:`backend/.control_plane/pmflow-routing-rules.json`(当前默认 `CONTROL_PLANE_ROUTING_ENABLED=true`,`LLM_MIXED_ROUTING_ENABLED=false`)。 #### A. 当前飞书主链路(实时) @@ -229,10 +229,10 @@ sequenceDiagram | 步骤 | function_name | 默认命中模型 | 具体作用 | | --- | --- | --- | --- | | 接入规划器 | `signal_source_onboarding_plan` | **active 引擎**(未在规则中显式绑定) | 判断是否应接入、产出 source 元数据与 worker 角色 | -| OpenClaw 指挥计划 | `openclaw_command_dispatch` | **active 引擎**(未在规则中显式绑定) | 把自然语言转 typed operations | +| PMFlow 本地路由层 指挥计划 | `control_plane_command_dispatch` | **active 引擎**(未在规则中显式绑定) | 把自然语言转 typed operations | | 子 worker 执行 | `signal_source_ai_worker_execution` | **active 引擎**(未在规则中显式绑定) | 各角色生成 deliverables/risks/next_actions | -说明:这 3 个函数名不在当前 OpenClaw 规则文件中,因此在默认配置下走 active 引擎;若 active 仍是默认 OpenClaw low 模型,则通常表现为 `glm`。 +说明:这 3 个函数名不在当前 PMFlow 本地路由层 规则文件中,因此在默认配置下走 active 引擎;若 active 仍是默认 PMFlow 本地路由层 low 模型,则通常表现为 `glm`。 #### C. 历史/评测链路(`FeishuSignalPipelineService`) diff --git a/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md b/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md index 18618163..f6fae8c3 100644 --- a/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md +++ b/docs/GLM5_SIGNAL_PLANNER_IMPLEMENTATION_PLAN_V4.md @@ -108,7 +108,7 @@ - [x] `signal.create` 成功提交后 `create_task(SignalPlannerService.analyze(...))` - [x] 在 `backend/app/config/ai_prompts.py` 增加 `PROMPT_SIGNAL_PLANNER` - [x] 在 LLM 结构化 schema 注册 `signal_planner_analyze` -- [x] 更新 `backend/.openclaw/pmflow-routing-rules.json` 添加 high tier 路由 +- [x] 更新 `backend/.control_plane/pmflow-routing-rules.json` 添加 high tier 路由 ### 完成标志 - [x] 触发点仅在 commit 后 diff --git a/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md b/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md index 7b3a1010..863af621 100644 --- a/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md +++ b/docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md @@ -105,7 +105,7 @@ - `POST /ai-pmo/work-items/{id}/decision` 在上述场景会先把目标 work item 状态置为 `executing`(前端即时从“待处理”转为“处理中”) - 前端 `decideWorkItem` 增加 15s 超时保护,超时会回到可交互状态并提示错误 - 口水话(短 query,如“怎么样了/在吗/辛苦了”)在后端列表接口过滤,不再下发到消息台前端(数据仍保留在后端记录) - - 修复信号源续跑事务穿透:`signal_source_registries` 重复键分支改为 savepoint,且 OpenClaw 指挥链改为隔离 Session,避免 rollback 回滚外层新建 `signal`,彻底消除 `ai_orchestration_runs.signal_id` 外键报错 + - 修复信号源续跑事务穿透:`signal_source_registries` 重复键分支改为 savepoint,且 PMFlow 本地路由层 指挥链改为隔离 Session,避免 rollback 回滚外层新建 `signal`,彻底消除 `ai_orchestration_runs.signal_id` 外键报错 - 修复“续跑后仍停在待人工确认”:`signal_source.onboard` 在 `pending_manual/signal_source_pending_manual` 且已具备 `project_id+signal_id` 时强制落 `succeeded`,并记录 `manual_gate_resolved=true` - Phase 2/3:未开始,保持待办。 diff --git a/docs/PLAN.md b/docs/PLAN.md index 9243c890..bd6db431 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -20,7 +20,7 @@ - 对每类业务事实,在任一阶段都只有一个被明文承认的真相源。 - 对人状态只能来自投影,不能来自 LLM 临时推断、半成品 artifact 或散落业务字段。 - 新旧对象之间必须有显式映射合同,禁止靠口头理解迁移。 -- 所有 AI 调用都必须仍受 OpenClaw、prompt registry、PromptRelease、`ai_call_logs`、Redis 缓存、零兜底原则约束。 +- 所有 AI 调用都必须仍受 PMFlow 本地路由层、prompt registry、PromptRelease、`ai_call_logs`、Redis 缓存、零兜底原则约束。 - 所有执行动作都必须继续经过受控执行器、执行账本、幂等键与审计链。 - 任务拆解与分配必须继续优先消费人员能力图谱与负载事实,而不是仅按 runtime 能力分发。 - 每个 cutover 都必须写明切换条件、回滚条件和验证证据。 @@ -46,7 +46,7 @@ - `ai_worker_executions`:当前 worker 执行事实兼容账本。 - `operation_execution_ledger`:当前副作用幂等与执行结果审计真源。 - `feishu_reply_outbox`:当前对人消息发送的唯一受控出口。 -- `backend/.openclaw/pmflow-routing-rules.json`:当前模型路由配置唯一真源。 +- `backend/.control_plane/pmflow-routing-rules.json`:当前模型路由配置唯一真源。 - `ai_prompt_registry` + `PromptReleaseService`:当前 Prompt 治理与 serving 优先级真源。 - `ai_call_logs`:当前 AI 调用追溯真源。 - `skill_graph / recommend_personnel / required_skill_tag_ids`:当前任务拆解与负责人推荐的核心能力事实。 @@ -58,7 +58,7 @@ - 所有新增接口必须遵循 `{code, message, data}` 包络。 - 所有接口必须继续使用 JWT + tenant 依赖注入。 - 所有查询和事实沉淀都必须天然 tenant 隔离。 -- 所有 AI 调用必须走 service 层、OpenClaw 路由、`ai_call_logs`、Redis 缓存与重试治理。 +- 所有 AI 调用必须走 service 层、PMFlow 本地路由层 路由、`ai_call_logs`、Redis 缓存与重试治理。 - 所有用户可见核心业务内容禁止兜底文案、模板假成功和 stub 数据。 - 所有业务语义关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 只能留在 `workflow_semantic_planner` 或 prompt/catalog 配置层。 - 飞书回执继续只允许通过 `feishu_reply_outbox` 发送,禁止新增第二套对人发送通道。 @@ -143,7 +143,7 @@ - 所有人类可见状态在迁移过程中也必须来自投影,不得直接读取 raw LLM 输出、草稿或 worker artifact 作为对人状态。 ### 5. AI 治理映射 -- `OpenClaw` 路由规则继续是模型选择唯一真源,禁止在新内核中重新硬编码模型名或 provider。 +- `PMFlow 本地路由层` 路由规则继续是模型选择唯一真源,禁止在新内核中重新硬编码模型名或 provider。 - `ai_prompt_registry` 和 `PromptReleaseService` 继续是 Prompt 治理入口,禁止新 compiler 或 domain pack 绕过它们直接拼 prompt。 - `ai_call_logs` 继续是 AI 调用审计真源,Program 0 以后新增链路若未接入此表,视为未接入。 - Redis 缓存继续保留在 world model 静态层和 AI 调用基础设施层,新增链路只能扩展,不能把缓存语义上浮为用户可见业务兜底。 @@ -221,7 +221,7 @@ Packages: - `0.1 Truth Inventory`:盘点 `ingest_events / work_items / decision_records / ai_orchestration_runs / ai_worker_executions / operation_execution_ledger / feishu_reply_outbox` 的职责、主写入口、主读入口、幂等键。 -- `0.2 AI Governance Baseline`:冻结 OpenClaw 路由、Prompt registry、PromptRelease、`ai_call_logs`、Redis 缓存、零兜底与 fail-fast 原则。 +- `0.2 AI Governance Baseline`:冻结 PMFlow 本地路由层 路由、Prompt registry、PromptRelease、`ai_call_logs`、Redis 缓存、零兜底与 fail-fast 原则。 - `0.3 Contract Freeze`:冻结对象主键、关联表、幂等键、事件包络、审计字段、版本字段、租户边界。 - `0.4 Rollout Strategy`:定义 `shadow mode / dual-write / dual-read / tenant canary / rollback / backfill`。 - `0.5 Metrics and SLO Baseline`:定义 `queue lag`、`claim timeout`、`replan rate`、`dead run count`、`release failure rate`、`manual takeover rate`、`token cost`。 @@ -370,7 +370,7 @@ Exit Gate: - `Backend`:`python -m py_compile`、相关单测、状态机测试、reducer 测试、迁移 up/down 回归。 - `Frontend`:`npm run build`、`tsc --noEmit`、关键 UI 查询链路。 - `Contract`:包络格式、JWT、tenant 隔离、幂等键、兼容旧 `/ai-pmo/*`、`/feishu/*`、`/rag/*`。 -- `AI Governance`:OpenClaw route snapshot、Prompt registry、PromptRelease、`ai_call_logs`、Redis 缓存、零兜底原则。 +- `AI Governance`:PMFlow 本地路由层 route snapshot、Prompt registry、PromptRelease、`ai_call_logs`、Redis 缓存、零兜底原则。 - `Replay`:同一事件流重放得到同样 projection。 - `Runtime`:Mock / Codex / Claude callback 一致性与约束执行一致性。 - `Projection`:对人状态全部来自投影,且能追溯到底层事实链。 diff --git a/docs/PLAN2.md b/docs/PLAN2.md index 3d6835a8..e9a8fdb4 100644 --- a/docs/PLAN2.md +++ b/docs/PLAN2.md @@ -2,7 +2,7 @@ ## Summary 将现有“飞书旧捕获链路 + consultant 话术层 + 多套 draft/对话脑”升级为一个真正的挂载式 AI runtime。 -目标形态对齐 Codex CLI / OpenClaw:持续镜像上下文、统一主题工作区、明确会话状态、按 agent 编排执行、对外只输出自然短话术。 +目标形态对齐 Codex CLI / PMFlow 本地路由层:持续镜像上下文、统一主题工作区、明确会话状态、按 agent 编排执行、对外只输出自然短话术。 本次按**直接替换**设计,不保留长期并行旧方案;架构按**多来源**设计,但首个验收来源只要求 **Feishu**。 默认行为: diff --git a/docs/PLAN3.md b/docs/PLAN3.md index 8d7034ea..cdb78c61 100644 --- a/docs/PLAN3.md +++ b/docs/PLAN3.md @@ -5,7 +5,7 @@ 本期**不做模型训练/微调上线**,但必须一次建好训练闭环所需的样本资产化、回放评测、冻结验收集、错误归因链路。 验收不是“体感差不多”,而是:**基于 Codex 教师样本库的冻结高标准评测集,综合分数低于 90 不予验收。** -最终形态仍然对齐 OpenClaw / Codex CLI / Claude CLI: +最终形态仍然对齐 PMFlow 本地路由层 / Codex CLI / Claude CLI: - **单主脑 + 多副脑** - **独立长驻 Runtime** - **多来源架构,Feishu 首验** @@ -61,7 +61,7 @@ ## 训练后置,但本期必须完成的训练底座 ### 1. Codex 教师角色 Codex 在本期扮演“教师与评测官”,不把新模型直接推生产,但必须为后续训练准备完整教材、错题本、考试系统: -- 读取仓库中的 Prompt、Operation Catalog、OpenClaw 路由规则、知识图谱能力、执行边界 +- 读取仓库中的 Prompt、Operation Catalog、PMFlow 本地路由层 路由规则、知识图谱能力、执行边界 - 结合你的业务形态提炼为教师规则、标签模板、禁止动作与验收标准 - 基于真实对话与运行结果沉淀高标准样本 diff --git a/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md b/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md index 848ae820..50cf8579 100644 --- a/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md +++ b/docs/PMFLOW_THREE_ENGINE_UNIFIED_PLAN.md @@ -4,13 +4,13 @@ - 用 3 个引擎达到“质量/成本/稳定性”平衡。 - 飞书与内置 AI 管家使用同一智能链路,避免 IQ 不一致。 - 所有系统操作(不止创建需求)由 AI 覆盖,但写库/副作用必须可控可审计。 -- 消除多控制面冲突(PMFlow 配一套、OpenClaw 配一套、规则文件又一套)。 +- 消除多控制面冲突(PMFlow 配一套、PMFlow 本地路由层 配一套、规则文件又一套)。 ## 2. 先把名词说清楚 当前不是“3 个 agent”,而是: - 2 个 agent - PMFlow Orchestrator Agent:流程编排与执行(唯一执行入口)。 - - OpenClaw Reasoning Agent:模型网关与推理。 + - PMFlow 本地路由层 Reasoning Agent:模型网关与推理。 - 3 个引擎(模型层) - Engine-L(Low):低成本/低延迟,处理执行态与轻分类。 - Engine-H(High):高推理,处理复杂问答/规划。 @@ -18,11 +18,11 @@ ## 3. 单一控制面原则(必须) 只保留一个“模型真相源(Source of Truth)”: -- 推荐:OpenClaw 作为唯一模型控制面(模型、fallback、配额、可用性)。 +- 推荐:PMFlow 本地路由层 作为唯一模型控制面(模型、fallback、配额、可用性)。 - PMFlow 只做函数路由和执行状态机,不再直连多个云厂商。 > 结论:PMFlow 不再同时保存 GLM/OpenAI/DeepSeek 直连 key 参与主路由; -> PMFlow 统一走 OpenClaw 网关。 +> PMFlow 统一走 PMFlow 本地路由层 网关。 ## 4. 三引擎职责分配(推荐) - Engine-L:`zai/glm-4-flash` @@ -97,27 +97,27 @@ ## 9. 推荐配置模板 -### 9.1 PMFlow `.env`(统一走 OpenClaw) +### 9.1 PMFlow `.env`(统一走 PMFlow 本地路由层) ```env LLM_PROVIDER=openai -OPENAI_BASE_URL=http://openclaw:18789/v1 -OPENAI_API_KEY= +OPENAI_BASE_URL=http://control-plane:18789/v1 +OPENAI_API_KEY= -OPENCLAW_ROUTING_ENABLED=true -OPENCLAW_ROUTING_RULES_PATH=/app/backend/.openclaw/pmflow-routing-rules.json +CONTROL_PLANE_ROUTING_ENABLED=true +CONTROL_PLANE_ROUTING_RULES_PATH=/app/backend/.control_plane/pmflow-routing-rules.json LLM_MIXED_ROUTING_ENABLED=false LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true ``` -### 9.2 OpenClaw Agent(main) +### 9.2 PMFlow 本地路由层 Agent(main) - Primary model:`zai/glm-4-flash`(Engine-L) - Fallbacks:`openai-compatible/GLM-5,deepseek/deepseek-chat` -> 如果你要严格“低->高->安全”,可在 OpenClaw 路由中按函数直接指定模型,fallback 只做同层灾备。 +> 如果你要严格“低->高->安全”,可在 PMFlow 本地路由层 路由中按函数直接指定模型,fallback 只做同层灾备。 ### 9.3 PMFlow 路由规则(关键) -- 同一文件统一管理:`backend/.openclaw/pmflow-routing-rules.json` +- 同一文件统一管理:`backend/.control_plane/pmflow-routing-rules.json` - 生产发布必须过 lint + replay gate。 ## 10. 安全边界(必须守) @@ -127,7 +127,7 @@ LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true - 每条请求全链路 trace:可追溯“模型选择 -> 操作执行 -> 回执结果”。 ## 11. 落地步骤(按优先级) -1. 统一控制面:PMFlow 改为只调用 OpenClaw 网关。 +1. 统一控制面:PMFlow 改为只调用 PMFlow 本地路由层 网关。 2. 清理直连模型 key 的主路径,仅保留应急开关。 3. 完整补齐 function_name 路由矩阵(L/H/S)。 4. 启用低阶失败自动升级高阶(已具备,可保持开启)。 diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md index 3c8d4cb9..add182d1 100644 --- a/docs/architecture/ARCHITECTURE_INVENTORY.md +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -14,7 +14,7 @@ - 对应前端 `types/api` 封装 - 路由/函数名/运行时流向变更仍需同步: - `docs/architecture/DATAFLOW.md` - - `backend/.openclaw/pmflow-routing-rules.json` + - `backend/.control_plane/pmflow-routing-rules.json` ## 3. 顶层库存 - `docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`:消息台内联操作执行清单 diff --git a/docs/architecture/CODE_REFERENCE_INDEX.md b/docs/architecture/CODE_REFERENCE_INDEX.md index 9db775a4..1bd65ce3 100644 --- a/docs/architecture/CODE_REFERENCE_INDEX.md +++ b/docs/architecture/CODE_REFERENCE_INDEX.md @@ -11,6 +11,8 @@ - 新增后端接口:参考 `backend/app/api/sstc_api/` 的结构 - 新增数据库表:参考 `alembic/versions/` 最新 migration - 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/` +- 飞书卡片动作回调:参考 `backend/app/api/feishu_api/card_action_routes.py` 与 `backend/app/services/integrations/feishu_integration_service/mixin_card_action.py` +- 用户管理接口:参考 `backend/app/api/users_api/`(统一前缀 `GET /users/`、`GET /users/me`、`GET /users/available`、`GET /users/{user_id}`) - 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) - LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`) @@ -61,7 +63,7 @@ ## 4. 模型与配置真源 - 模型配置唯一来源:参考 `backend/app/services/model/model_config.py` -- 路由规则真源:`backend/.openclaw/pmflow-routing-rules.json` +- 路由规则真源:`backend/.control_plane/pmflow-routing-rules.json` - Prompt 聚合:`backend/app/config/ai_prompts.py` - Prompt 场景化分片:`backend/app/config/ai_prompts_catalog/` - Prompt registry:`backend/app/config/ai_prompt_registry.py` diff --git a/docs/architecture/DATAFLOW.md b/docs/architecture/DATAFLOW.md index fe701d75..4206e7c9 100644 --- a/docs/architecture/DATAFLOW.md +++ b/docs/architecture/DATAFLOW.md @@ -24,7 +24,7 @@ 1. Frontend `ModelRoutingConfigPage` reads current mapping via `/model-routing/functions/{function_name}`. 2. Manager updates mapping via PUT API. 3. Backend persists override into `integrations(type='model_routing')`. -4. `LLMService` resolves function-level override at runtime before default openclaw rules. +4. `LLMService` resolves function-level override at runtime before default control-plane rules. ## Task Brief Flow 1. Frontend chooses template / trigger generate. diff --git a/docs/architecture/MASTER_CONTRACT.md b/docs/architecture/MASTER_CONTRACT.md index 5884a137..14dbf3ba 100644 --- a/docs/architecture/MASTER_CONTRACT.md +++ b/docs/architecture/MASTER_CONTRACT.md @@ -7,7 +7,7 @@ ## 2. Cross-Stack Contract Ownership - Backend source: `backend/app/api/`, `backend/app/schemas/` - Frontend source: `frontend/src/api/`, `frontend/src/types/` -- Routing source: `backend/.openclaw/pmflow-routing-rules.json` +- Routing source: `backend/.control_plane/pmflow-routing-rules.json` - Architecture docs source: `docs/architecture/` ## 3. Sync Rules @@ -18,7 +18,7 @@ - `docs/architecture/API_CONTRACTS.md` - frontend TypeScript interfaces (if impacted) - Any routing/function-name change must update: - - `backend/.openclaw/pmflow-routing-rules.json` + - `backend/.control_plane/pmflow-routing-rules.json` - `docs/architecture/DATAFLOW.md` - Any new module/page/API must update: - `CLAUDE.md` structure/reference sections diff --git a/docs/architecture/RUNTIME_GUARDRAILS.md b/docs/architecture/RUNTIME_GUARDRAILS.md index e3a67a0d..b035ccb5 100644 --- a/docs/architecture/RUNTIME_GUARDRAILS.md +++ b/docs/architecture/RUNTIME_GUARDRAILS.md @@ -4,6 +4,7 @@ - 本文档承接 PMFlow 的长运行边界、跨链路守门规则与已知运行假设。 - `AGENTS.md` 保留必须立即遵守的摘要规则;本文件提供更细的运行时约束与环境边界。 - `CLAUDE.md` 只保留当前架构摘要、关键入口和高价值提醒;长清单统一收口到这里。 +- requirement-plan、Feishu reply/thread、AdaptiveRAG、runtime semantic routing 等详细守门细则以本文档为真源,`AGENTS.md` 只保留摘要。 ## 2. 同步规则 - 任何影响运行边界、守门策略、失败语义、已知环境假设的改动,都必须同步更新: @@ -14,7 +15,7 @@ - `docs/architecture/ARCHITECTURE_INVENTORY.md` - 任何影响主链路由、函数名、运行时流向的改动,还必须同步更新: - `docs/architecture/DATAFLOW.md` - - `backend/.openclaw/pmflow-routing-rules.json` + - `backend/.control_plane/pmflow-routing-rules.json` ## 3. 核心运行边界 @@ -60,20 +61,24 @@ - 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错。 - 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏。 - `integrations(type='model_routing')` 的 runtime tier override 不得把 `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` 这类用户可见 QA 主函数降级到 `low`。 -- 模型默认值与 provider/tier 映射以 `backend/.openclaw/pmflow-routing-rules.json` 为唯一配置源。 +- 模型默认值与 provider/tier 映射以 `backend/.control_plane/pmflow-routing-rules.json` 为唯一配置源。 ### 3.5 Requirement Plan / Draft / Followup - `requirement_plan` 必须是多轮状态机,不得退回单轮模板问答。 - `MountedTopicWorkspace.plan_status` 必须显式落库区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`。 - `requirement_plan` 的 A/B/C 可以固定为用户可见骨架,但业务选项、推荐项、理由和代价必须走“上下文包 -> 模型结构化决策 -> gatekeeper -> 渲染”。 - 正在收敛中的 `requirement_plan` 必须支持碎片化回复,不能因缺少新的 `semantic_bundle` 回落到普通 query。 +- 同一 `conversation_scope` 一旦存在 active requirement workspace,线程即进入 requirement mode lock:除显式“退出需求模式”外,后续消息只能走需求澄清、A/B/C/D 协议、补一句、出草稿、重出这轮等 requirement-plan 动作;闲聊或其它问答必须显式提示“当前正在需求模式”,禁止再落回 `casual_chat / business_review / query.answer`。 - 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,必须先清掉继承的旧 `current_project_id`。 - 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须明确提示当前没有进行中的收敛轮次或草稿。 -- 草稿确认只认显式 `reply_to_message_id` + 明确草稿动作,或显式 `work_item_id`。 +- `draft_pending_confirm` 草稿确认链只允许由显式锚点进入:`reply/thread`、卡片按钮或显式 `work_item_id`。无锚点普通消息、闲聊、新需求或独立事实问句禁止再按“同群/同人”回捞旧 pending draft。 +- 同一 `conversation_scope` 一旦存在 active `draft_pending_confirm` workspace,命中上述显式锚点的后续消息必须优先回到当前草稿确认链处理,支持确认/修改/重出/取消与展示反馈;即使动作未识别,也必须显式提示“当前正在处理待确认草稿”,禁止再新开 topic 或落回 `query.answer`。 +- 当同一 `conversation_scope` 内的旧 `requirement_plan` / `draft_pending_confirm` 已被判定为污染且当前消息不再显式锚定该线程时,ingress 必须先把 scope 内 active plan/draft topic 与待确认 work item 终止为 `suspended / cancelled`,并把会话回退到 `query.answer`;禁止残留 plan topic 继续吸收普通问句。 - 用户显式 `reply/thread` 某条已绑定回复并要求“基于当前信息出草稿”时,主链必须先尝试从该 reply 命中的 mounted context 升格生成 requirement-plan 草稿;若上下文不足,必须显式失败,禁止回落到 `query.answer` 冒充“草稿已生成”。 - 用户在显式 `reply/thread` 的同一需求线程里已经完成 `A/B/C` 选项收敛后,后续碎片化追问必须先尝试把该 reply 命中的 chat context 升格成 requirement-plan workspace,再走 `requirement_plan` 主链;禁止继续落到通用 `query.answer` 并把代码路径、内部函数名或工程证据泄漏到用户可见答案里。 - 用户对 requirement-plan 轮次回复 `A/B/C` 协议时,识别范围必须覆盖裸 `A/B/C`、`我选C`、`选C`、`就C吧`、`先按C` 等显式选项回复;这些协议消息必须优先使用上一轮真实选项表(`round_option_maps`)解释该选择。 - requirement-plan 的选项协议识别属于协议层,不属于业务语义拍板;运行时代码必须复用单点协议解析器输出结构化 `choice + remaining_text`,禁止在各入口重新散落正则或关键词规则来解释这类协议回复。 +- requirement-plan 在飞书前台应优先渲染为交互式卡片按钮;按钮动作必须携带 `work_item_id` 与结构化动作码(如 `plan_choice / plan_continue / plan_reroll`),由按钮回调回写到当前 requirement-plan 主链,文本协议只作为兼容兜底。 - 若后续需要在任何运行时模块新增正则/关键词驱动的协议或业务语义判断,必须先经 Host 明确确认;未经确认禁止新增。协议层优先使用单点结构化 parser、state machine、上下文快照或显式 option map,若证据不足必须显式失败,禁止再用扩词表、补正则或前缀枚举做“最小修补”。 - requirement-plan 低信息量选项回复若缺少上一轮真实选项表,只能显式失败或重新发起该轮次,禁止回退到通用 IT 项目默认映射误译用户选择。 - AI PMO ingress 的 mixed sidecar 预检查不得拦截显式 `reply/thread` 下的低信息量协议回复(如 `A/B/C`、`我选C`、`就C吧`);这类消息必须优先交给 active-context / requirement-plan 主链处理,禁止先生成 `[回答]...[已收到需求]` 的 mixed 拼接回复污染主结果。 @@ -81,6 +86,7 @@ - Feishu 显式 `reply/thread` 到机器人消息时,ingress 与 mounted runtime 必须先通过 `feishu_reply_outbox.sent_message_id -> source_message_id` 归一化 `root_message_id/reply_to_message_id`,并在后续 active-context / requirement-plan / draft 主链里复用同一份 canonical thread context;禁止直接用机器人 `sent_message_id` 新开 chat topic,导致 requirement-plan / draft 线程分叉。 - requirement-plan 的唯一业务真源固定为 `MountedDraftWorkspace.requirement_card`:收敛轮次、已确认字段、`round_option_maps`、`last_round_options`、`source_material` 等业务状态只能从 draft 或结构化 `requirement_plan_context` 恢复;`mounted_topic_workspaces.facts_json` 只允许保留线程索引级最小字段(当前限定为 `title / plan_profile`),`work_items.execution_result` 与 `mounted_conversation_runs.final_reply` 仅作审计/回放,禁止再从 assistant prose、topic facts 或 run 文案反推需求字段。 - requirement-plan 的运行时路由命名也必须单独收口:收敛轮次统一写 `requirement_plan.collect`,草稿确认前的结构化草稿统一写 `requirement_plan.draft`;`query.answer` 只保留给通用问答,禁止在 requirement-plan 主链里继续借用旧 query route 记账。 +- 冷启动 `WorkflowSemanticPlanner` / `pmflow_semantic_context_decision` 若因 provider 异常、空结构化结果或网络抖动失败,`_resolve_intent` 不得直接把首轮线程打成 `error_template`;必须集中降级为带 `semantic_planner_degraded=true` 审计标记的受控 requirement consultation query scene,让后续 requirement-plan 主链继续接管。 - 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链。 - AI PMO 写操作默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库。 - `project_create.create` 与 requirement draft confirm 的任务拆解是从属子步骤,不再允许阻塞项目、主信号、来源挂载与宪章的确定性创建。任务生成链必须先按 `routing_meta(tier_alias=low, response_contract=structured, execution_mode=local_planner)` 尝试,并在结构质量不足、空任务、不可映射技能或 provider 失败时升级 `high`;若高阶仍失败,运行时必须显式写回 `task_generation.status=pending` 与真实失败原因,保持 `draft_pending_confirm` 可重试,禁止返回默认任务、模板任务或把整条确认入库伪装成成功/失败错态。 @@ -92,7 +98,12 @@ - Feishu `query/followup` 会话键在命中 `reply/thread` 锚点时必须优先使用 thread key;仅在没有显式锚点时才回落到默认的 `chat + sender` scope key。 - AdaptiveRAG 命中显式 `reply/thread` 锚点后,问题改写必须优先使用该锚点;`session_state.last_subject / last_entity_ids` 只允许在没有显式锚点时参与续写,禁止旧会话主题覆盖显式引用上下文。 - 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot 快路径,统一落到场景化 Prompt 生成。 -- 通用行业趋势问答不得注入数据库、项目历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`。 +- 通用行业趋势/外部品牌模型 freshness 问答,禁止把项目图谱、Feature Catalog、内部文档或用户历史冒充成“外部事实”;若命中 live external `url/http(s)` 证据,必须优先基于外部结果回答;若实时外部检索为空或不足,才允许显式返回“当前未接入实时外部数据/暂无法确认最新结论”。 +- 涉及模型事实的聊天问答,只有用户显式询问系统配置、路由、接入状态、运行时参数或 `ai-config` 等内部对象时,才允许落入 `ai_pmo / snapshot identity / 内部模型配置` 场景;否则禁止把通用“最新/当前/模型/版本”问题内化成系统配置问答。 +- 用户对上一条行业/外部趋势回复的事实性纠错追问,必须优先留在聊天问答链路;若锚定上一轮 `industry_trend/external_trend`,禁止切入 `ai_pmo_correction` 裁决文风。 +- 外部“最新/目前/截至现在”等 freshness 问答,只有命中显式外部来源(如 `url/http(s)` 证据)时才允许记为 grounded;仅凭项目图谱、Feature Catalog、系统快照或内部资料时,必须降级为 `insufficient_evidence` 并明确说明当前未接入实时外部数据。 +- 外部 freshness 问答的后续一句话跟进(如“继续核实”“联网查一下”“接入外部来源继续核实”)若锚定上一轮 `insufficient_evidence + External Freshness Guard` 回复,必须优先留在 `query.answer` 问答链,并先尝试 live external 检索;只有当实时外部检索仍为空或失败时,才允许继续返回 freshness guard。只有用户显式点明系统/项目/配置等内部对象并提出接入动作时,才允许继续落入 `signal_source_flow / internal onboard / draft` 等内部执行链。 +- 飞书聊天可见答案禁止泄露内部函数名、文件路径、代码行号、模块名或调试字段;prompt 护栏与出站文本清洗必须同时满足该约束。 - mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`。 ### 3.7 Signal / Project / SSTC / Briefing @@ -125,8 +136,8 @@ - `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送。 - 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链。 - 飞书长连接状态分为两层:`last_received_*` 表示 SDK 已收到事件,`last_event_at` 表示 worker 完成处理。 -- LLM 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则;请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准。 -- 路由配置唯一真源固定为 `backend/.openclaw/pmflow-routing-rules.json`。 +- LLM 主链按 `CONTROL_PLANE_ROUTING_ENABLED` 走 PMFlow 本地路由层 路由规则;请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准。 +- 路由配置唯一真源固定为 `backend/.control_plane/pmflow-routing-rules.json`。 - World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。 - 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启。 - IT 五轮 requirement plan 现以 `MountedDraftWorkspace.active_work_item_id + latest_*_message_id` 锚定当前 active draft;历史未绑定这些字段的旧草稿不会自动认领。 diff --git a/docs/superpowers/plans/2026-04-13-chat-external-fact-qa-guardrails.md b/docs/superpowers/plans/2026-04-13-chat-external-fact-qa-guardrails.md new file mode 100644 index 00000000..64a7f517 --- /dev/null +++ b/docs/superpowers/plans/2026-04-13-chat-external-fact-qa-guardrails.md @@ -0,0 +1,159 @@ +# Chat External Fact QA Guardrails Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop chat-mode Feishu Q&A from using internal PMFlow evidence to answer external latest/fact questions, and make factual pushback stay in natural chat instead of falling into AI PMO correction prose. + +**Architecture:** Keep requirement-plan and draft-confirm chains unchanged. Implement a narrow chat-only policy inside Adaptive RAG that detects external factual chat questions, isolates retrieval from internal project noise, downgrades safely when no live external evidence exists, and keeps follow-up fact challenges in the same chat scene. + +**Tech Stack:** FastAPI backend, Adaptive RAG mixins, PostgreSQL-backed QA records, control-plane routing, pytest. + +--- + +### Task 1: Add Chat External-Fact Policy Flags + +**Files:** +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_mainline.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_followup_anchor.py` +- Test: `backend/tests/test_adaptive_rag_mainline_contract.py` + +- [ ] **Step 1: Write failing tests for chat-only external fact follow-up** + +Add tests that prove: +- an external-trend/latest question is marked as chat external fact policy +- a follow-up like “不对吧,已经有 xxx 了” anchored to a prior `external_trend` / `industry_trend` answer stays in chat QA and does not inherit AI PMO correction behavior + +- [ ] **Step 2: Resolve anchored prior answer scene from QA record** + +Read the anchored `QARecord` via existing follow-up resolution and derive the prior prompt variant from `policy_version` so chat logic can tell whether the previous reply was `external_trend` or `industry_trend`. + +- [ ] **Step 3: Add a narrow chat external-fact policy helper** + +Implement a helper in `mixin_answer_mainline.py` that derives flags such as: +- `external_fact_chat` +- `requires_freshness` +- `chat_fact_challenge` +- `allow_internal_refine` + +These flags must be derived from current scene + question + anchored prior QA record, not from requirement-plan state. + +- [ ] **Step 4: Thread the policy flags through answer runtime** + +Pass the new flags into retrieval/execution flow without changing requirement-plan ingress or workflow planner behavior. + +- [ ] **Step 5: Run focused tests** + +Run: `pytest backend/tests/test_adaptive_rag_mainline_contract.py -k "external or correction or followup" -v` + + +### Task 2: Isolate Retrieval for External Latest Questions + +**Files:** +- Modify: `backend/app/services/adaptive_rag_service/mixin_multistep_flow.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_multistep_core.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py` +- Test: `backend/tests/test_adaptive_rag_mainline_contract.py` + +- [ ] **Step 1: Write failing tests for retrieval contamination** + +Add regression tests that prove: +- `external_trend` / latest questions do not expand into `Project#104`, `#codex-plan*`, module paths, or feature catalog hints +- second-pass query refinement does not append internal entities/modules for this chat policy + +- [ ] **Step 2: Disable internal second-pass hint expansion for external fact chat** + +When `external_fact_chat=true`, skip internal refinement based on manifest, graph entities, project anchors, feature catalog, and symbol hints. + +- [ ] **Step 3: Restrict evidentiary domains** + +For external factual latest questions, internal `db / graph / pmflow / feishu / feature_catalog` sources must not be used as grounding for the external fact itself. + +Allowed behavior: +- explicit external URL/doc evidence may ground the answer +- internal PMFlow context may only support “why this matters to your tenant/project”, never “what is the latest external fact” + +- [ ] **Step 4: Preserve existing internal QA behavior** + +Make sure non-external chat questions and PMFlow internal feature/status questions still use the current retrieval stack unchanged. + +- [ ] **Step 5: Run focused tests** + +Run: `pytest backend/tests/test_adaptive_rag_mainline_contract.py -k "external_trend or industry_trend or retrieval" -v` + + +### Task 3: Add Freshness Guard and Natural Chat Correction Style + +**Files:** +- Modify: `backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py` +- Modify: `backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py` +- Modify: `backend/app/config/ai_prompts_catalog/part_scenes_06.py` +- Test: `backend/tests/test_adaptive_rag_mainline_contract.py` +- Test: `backend/tests/test_draft_confirm_handler.py` + +- [ ] **Step 1: Write failing tests for unsafe “grounded latest” replies** + +Cover these cases: +- no external evidence + question asks “最新/目前/截至现在” => answer must explicitly say no real-time data +- grounding status must not be `grounded` +- reply must not inject project IDs, internal routes, or AI config guidance unless user explicitly asked for tenant impact + +- [ ] **Step 2: Add external freshness guard** + +Extend guard logic so external factual latest questions are blocked or downgraded when there is no explicit external evidence. Static internal sources must not satisfy freshness requirements. + +- [ ] **Step 3: Rewrite answer fallback shape** + +When no live external evidence exists, answer should look like normal chat: +- first sentence admits lack of real-time data / inability to confirm latest +- second sentence may give bounded generic context +- optional final sentence can ask whether to check tenant config separately + +Forbidden output in this policy: +- “结论 / 证据链 / 待确认清单” tribunal style +- treating internal tags or knowledge-graph entities as external market evidence + +- [ ] **Step 4: Strengthen prompt constraints as secondary guard** + +Update `PROMPT_EXTERNAL_TREND_QA` / `PROMPT_INDUSTRY_TREND_QA` so they explicitly say: +- internal PMFlow graph/feature/catalog sources are not external search results +- if freshness is required and no external evidence is provided, do not claim exact latest version/model status + +- [ ] **Step 5: Review deep-routing behavior** + +Do not let this path silently force low-first deep reasoning for fact challenges. If needed, keep the safe downgrade path on low tier now; if quality still falls short, introduce a dedicated external-fact answer function in a separate patch rather than raising all chat traffic. + +- [ ] **Step 6: Run focused tests** + +Run: +- `pytest backend/tests/test_adaptive_rag_mainline_contract.py -k "freshness or external or correction" -v` +- `pytest backend/tests/test_draft_confirm_handler.py -k "industry_trend or external_trend" -v` + + +### Task 4: Verify Runtime Safety and Sync Guardrail Docs + +**Files:** +- Modify: `docs/architecture/RUNTIME_GUARDRAILS.md` +- Test: `backend/tests/test_adaptive_rag_mainline_contract.py` + +- [ ] **Step 1: Add runtime guardrail note** + +Document that chat-mode external factual latest questions cannot treat PMFlow internal graph/catalog/project evidence as external fact grounding, and anchored factual pushback must stay in chat unless explicitly tied to requirement/draft workflow. + +- [ ] **Step 2: Run broader regression checks** + +Run: +- `python -m py_compile backend/app` +- `pytest backend/tests/test_adaptive_rag_mainline_contract.py -v` + +- [ ] **Step 3: Sanity-check that requirement-plan mainline is untouched** + +Run a narrow requirement-plan regression slice: +- `pytest backend/tests/test_ai_pmo_ingress_mainline.py -k "requirement_plan or draft" -v` + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/services/adaptive_rag_service backend/app/config/ai_prompts_catalog/part_scenes_06.py backend/tests/test_adaptive_rag_mainline_contract.py backend/tests/test_draft_confirm_handler.py docs/architecture/RUNTIME_GUARDRAILS.md docs/superpowers/plans/2026-04-13-chat-external-fact-qa-guardrails.md +git commit -m "fix: harden chat external fact QA guardrails" +``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c0878099..9a345d87 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "zustand": "^4.5.0" }, "devDependencies": { + "@playwright/test": "^1.52.0", "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", @@ -856,6 +857,22 @@ "node": ">= 8" } }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -5036,6 +5053,53 @@ "node": ">= 6" } }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", diff --git a/frontend/src/components/sstc/SSTCStageNav.tsx b/frontend/src/components/sstc/SSTCStageNav.tsx index db357ffb..70251abd 100644 --- a/frontend/src/components/sstc/SSTCStageNav.tsx +++ b/frontend/src/components/sstc/SSTCStageNav.tsx @@ -1,4 +1,6 @@ import { Check, ChevronRight, CircleDot, Lock } from "lucide-react"; +import { createPortal } from "react-dom"; +import { type MouseEvent, useState } from "react"; import type { StageKey, SSTCSignal, SSTCStageNode } from "@/api/sstc"; import { Badge } from "@/components/ui/badge"; import { toZhStatus } from "@/lib/display"; @@ -55,6 +57,20 @@ function stageProgressValue(status: string): number { } export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange }: SSTCStageNavProps) { + const [signalHoverAnchor, setSignalHoverAnchor] = useState<{ left: number; top: number } | null>(null); + + const showSignalHoverPreview = (target: HTMLElement) => { + const rect = target.getBoundingClientRect(); + setSignalHoverAnchor({ + left: rect.left, + top: rect.bottom + 12, + }); + }; + + const hideSignalHoverPreview = () => { + setSignalHoverAnchor(null); + }; + return (
@@ -71,6 +87,16 @@ export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange - {showSignalHover && ( -
-

上游有效信号(最近 6 条)

-
- {signalPreview.slice(0, 6).map((signal, signalIndex) => ( -
-

- 信号 #{signal.id.match(/\d+/)?.[0] || signalIndex + 1} · {signal.title || "未命名信号"} -

-

{signal.content}

-
- ))} -
-
- )} {idx < stages.length - 1 && }
); })}
+ {typeof document !== "undefined" && signalHoverAnchor && signalPreview.length > 0 + ? createPortal( +
+

上游有效信号(最近 6 条)

+
+ {signalPreview.slice(0, 6).map((signal, signalIndex) => ( +
+

+ 信号 #{signal.id.match(/\d+/)?.[0] || signalIndex + 1} · {signal.title || "未命名信号"} +

+

{signal.content}

+
+ ))} +
+
, + document.body + ) + : null} ); } diff --git a/frontend/src/pages/scheduler-center/components/MountedAITrainingOpsPanel.tsx b/frontend/src/pages/scheduler-center/components/MountedAITrainingOpsPanel.tsx index 74bb2235..7de428ff 100644 --- a/frontend/src/pages/scheduler-center/components/MountedAITrainingOpsPanel.tsx +++ b/frontend/src/pages/scheduler-center/components/MountedAITrainingOpsPanel.tsx @@ -36,7 +36,7 @@ export function MountedAITrainingOpsPanel({ const [error, setError] = useState(null); const [result, setResult] = useState(null); const [baseModelFamily, setBaseModelFamily] = useState("routing_default"); - const [providerBackend, setProviderBackend] = useState("openclaw-training"); + const [providerBackend, setProviderBackend] = useState("control-plane-training"); const [notes, setNotes] = useState("mounted runtime training wave"); const [includeLow, setIncludeLow] = useState(true); const [includeHigh, setIncludeHigh] = useState(true); @@ -69,7 +69,7 @@ export function MountedAITrainingOpsPanel({ setError(null); const payload = await onLaunchTrainingWave({ base_model_family: baseModelFamily.trim() || "routing_default", - provider_backend: providerBackend.trim() || "openclaw-training", + provider_backend: providerBackend.trim() || "control-plane-training", target_slots: targetSlots, notes: notes.trim() || undefined, }); diff --git a/scripts/ai_steward_pressure_coach.py b/scripts/ai_steward_pressure_coach.py index 559fdc26..87cc2cad 100755 --- a/scripts/ai_steward_pressure_coach.py +++ b/scripts/ai_steward_pressure_coach.py @@ -36,7 +36,7 @@ DEFAULT_TEMPLATE_LEAK_PATTERNS = [ ENGINE_QUERY_MARKERS = [ "ai引擎", - "openclaw", + "本地路由层", "调用链", "模型网关", "路由", @@ -214,7 +214,7 @@ def _build_rag_scenarios() -> list[Scenario]: engine_sensitive=True, ), TurnCase( - question="内置的ai管家第一层也是去调用openclaw吗?", + question="内置的ai管家第一层也是去调用 PMFlow 本地路由层吗?", thinking_mode="normal", expect_deep=True, engine_sensitive=True, diff --git a/scripts/openclaw_routing_lint.py b/scripts/control_plane_routing_lint.py similarity index 95% rename from scripts/openclaw_routing_lint.py rename to scripts/control_plane_routing_lint.py index 5018a189..0eebde17 100644 --- a/scripts/openclaw_routing_lint.py +++ b/scripts/control_plane_routing_lint.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""OpenClaw 路由规则静态校验(PMFlow 双层版)。""" +"""PMFlow 本地路由层 路由规则静态校验(PMFlow 双层版)。""" from __future__ import annotations @@ -11,7 +11,7 @@ from typing import Any, Optional VALID_PROVIDERS = {"glm", "openai", "deepseek", "ollama"} VALID_TIERS = {"low", "high"} -DEFAULT_RULES_RELATIVE_PATH = "backend/.openclaw/pmflow-routing-rules.json" +DEFAULT_RULES_RELATIVE_PATH = "backend/.control_plane/pmflow-routing-rules.json" SCRIPT_ROOT = Path(__file__).resolve().parents[1] BACKEND_DIR = SCRIPT_ROOT / "backend" @@ -169,12 +169,12 @@ def _lint(path: Path) -> tuple[bool, dict[str, Any]]: def main() -> None: default_input = str( os.getenv( - "OPENCLAW_ROUTING_RULES_PATH", + "CONTROL_PLANE_ROUTING_RULES_PATH", DEFAULT_RULES_RELATIVE_PATH, ) or DEFAULT_RULES_RELATIVE_PATH ).strip() or DEFAULT_RULES_RELATIVE_PATH - parser = argparse.ArgumentParser(description="OpenClaw routing lint") + parser = argparse.ArgumentParser(description="PMFlow 本地路由层 routing lint") parser.add_argument( "--input", default=default_input, diff --git a/scripts/daily_task_report.sh b/scripts/daily_task_report.sh index 24d05e78..a65b9d22 100755 --- a/scripts/daily_task_report.sh +++ b/scripts/daily_task_report.sh @@ -38,13 +38,13 @@ EOF # 3. 发送到飞书 echo "3. 发送到飞书..." -if command -v openclaw &> /dev/null; then - # 使用 OpenClaw 的 feishu 工具发送 - openclaw message send --chat "$FEISHU_CHAT_ID" --file "$XLSX_FILE" 2>/dev/null || \ - echo " 请手动配置飞书发送" +FEISHU_SENDER_BIN="${FEISHU_SENDER_BIN:-}" +if [ -n "$FEISHU_SENDER_BIN" ] && command -v "$FEISHU_SENDER_BIN" &> /dev/null; then + "$FEISHU_SENDER_BIN" message send --chat "$FEISHU_CHAT_ID" --file "$XLSX_FILE" 2>/dev/null || \ + echo " 请检查 FEISHU_SENDER_BIN 配置,或手动发送" else echo " 文件已生成: ${XLSX_FILE}" - echo " 需要手动发送到飞书" + echo " 未配置发送命令,需手动发送到飞书" fi echo "=== 完成 ==="