Harden control-plane routing and external fact chat guardrails

This commit is contained in:
Yuuuu 2026-04-14 01:14:00 +08:00
parent 5724cdc2dc
commit db3c806117
91 changed files with 6507 additions and 721 deletions

View File

@ -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=

139
AGENTS.md
View File

@ -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. 等待用户输入"确认"后才能执行

View File

@ -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`。

View File

@ -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`.

89
USER.md
View File

@ -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 警告,后续需要统一升级或兼容处理。

View File

@ -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": {

View File

@ -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",

View File

@ -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)

View File

@ -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"}

View File

@ -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:

View File

@ -13,6 +13,7 @@ FEISHU_QA_RESPONSE_STYLE = _compose_prompt(
5. 可使用 1-3 个轻量 emoji 做分段,但不要花哨,不要影响专业感。
6. 飞书输出优先短段落和清晰换行;如果包含“结论/依据/下一步/信息不足”,要让这些段落一眼可扫读。
7. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
8. 禁止输出内部函数名、文件路径、代码行号、模块名或调试字段。
""".strip(),
TEXT_OUTPUT_GUARDRAIL,
)

View File

@ -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,
)

View File

@ -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)

View File

@ -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

View File

@ -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 {},

View File

@ -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:

View File

@ -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'<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
r'<a[^>]+href="([^"]+)"[^>]+class="[^"]*result-link[^"]*"[^>]*>(.*?)</a>',
r'<a[^>]+class="[^"]*result-link[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
)
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

View File

@ -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,

View File

@ -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}”对应的最新结论。"
"现有内部资料不能作为外部最新事实依据;如果需要,我可以在接入实时来源后再帮你核实。"
)

View File

@ -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] = []

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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]] = []

View File

@ -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",
)

View File

@ -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,

View File

@ -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(

View File

@ -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")

View File

@ -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()

View File

@ -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", {}))

View File

@ -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

View File

@ -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,

View File

@ -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):

View File

@ -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):

View File

@ -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):

View File

@ -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)}"

View File

@ -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} "

View File

@ -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()

View File

@ -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:

View File

@ -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:

View File

@ -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,

View File

@ -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}"
),

View File

@ -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,

View File

@ -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()

View File

@ -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

View File

@ -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")),

View File

@ -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):

View File

@ -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",
]
)

View File

@ -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,

View File

@ -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]] = []

View File

@ -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,

View File

@ -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,
)

View File

@ -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 {

View File

@ -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"

View File

@ -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,

View File

@ -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]] = []

View File

@ -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

View File

@ -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('<a class="result__a" href="https://example.com/post">OpenAI Codex latest model</a>')
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 = "<html><body><h1>Fresh external fact</h1></body></html>"
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

File diff suppressed because it is too large Load Diff

View File

@ -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()

View File

@ -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")

View File

@ -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:

View File

@ -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 需求收敛"

View File

@ -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}

View File

@ -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,

View File

@ -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 训练波次",
},

View File

@ -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="继续优化支付失败重试",

View File

@ -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",
)

View File

@ -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

View File

@ -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}

View File

@ -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"]

View File

@ -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`)

View File

@ -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 后

View File

@ -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:未开始,保持待办。

View File

@ -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`:对人状态全部来自投影,且能追溯到底层事实链。

View File

@ -2,7 +2,7 @@
## Summary
将现有“飞书旧捕获链路 + consultant 话术层 + 多套 draft/对话脑”升级为一个真正的挂载式 AI runtime。
目标形态对齐 Codex CLI / OpenClaw:持续镜像上下文、统一主题工作区、明确会话状态、按 agent 编排执行、对外只输出自然短话术。
目标形态对齐 Codex CLI / PMFlow 本地路由层:持续镜像上下文、统一主题工作区、明确会话状态、按 agent 编排执行、对外只输出自然短话术。
本次按**直接替换**设计,不保留长期并行旧方案;架构按**多来源**设计,但首个验收来源只要求 **Feishu**。
默认行为:

View File

@ -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 本地路由层 路由规则、知识图谱能力、执行边界
- 结合你的业务形态提炼为教师规则、标签模板、禁止动作与验收标准
- 基于真实对话与运行结果沉淀高标准样本

View File

@ -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=<OPENCLAW_GATEWAY_TOKEN>
OPENAI_BASE_URL=http://control-plane:18789/v1
OPENAI_API_KEY=<CONTROL_PLANE_GATEWAY_TOKEN>
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. 启用低阶失败自动升级高阶(已具备,可保持开启)。

View File

@ -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`:消息台内联操作执行清单

View File

@ -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`

View File

@ -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.

View File

@ -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

View File

@ -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;历史未绑定这些字段的旧草稿不会自动认领。

View File

@ -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"
```

View File

@ -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",

View File

@ -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 (
<div className="rounded-2xl border border-slate-200/80 bg-gradient-to-b from-slate-50 to-white p-4 shadow-[0_1px_3px_rgba(15,23,42,0.08)]">
<div className="overflow-x-auto pb-1 [-webkit-overflow-scrolling:touch]">
@ -71,6 +87,16 @@ export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange
<button
type="button"
onClick={() => onChange(stage.key)}
onMouseEnter={(event: MouseEvent<HTMLButtonElement>) => {
if (showSignalHover) {
showSignalHoverPreview(event.currentTarget);
}
}}
onMouseLeave={() => {
if (showSignalHover) {
hideSignalHoverPreview();
}
}}
className={cn(
"relative h-full w-full rounded-xl px-3 text-left transition-all",
active
@ -113,27 +139,36 @@ export function SSTCStageNav({ stages, activeStage, signalPreview = [], onChange
<span className="absolute right-2 top-2 inline-flex h-2.5 w-2.5 rounded-full bg-rose-500" />
)}
</button>
{showSignalHover && (
<div className="pointer-events-none invisible absolute left-0 top-full z-30 mt-2 w-[340px] rounded-xl border border-slate-200 bg-white p-3 opacity-0 shadow-[0_10px_28px_rgba(15,23,42,0.15)] transition-opacity group-hover:visible group-hover:opacity-100">
<p className="text-xs font-semibold text-slate-700">上游有效信号(最近 6 条)</p>
<div className="mt-2 space-y-1.5">
{signalPreview.slice(0, 6).map((signal, signalIndex) => (
<div key={`${signal.id}-${signalIndex}`} className="rounded-md bg-slate-50 px-2 py-1.5">
<p className="line-clamp-1 text-[11px] font-medium text-slate-700">
信号 #{signal.id.match(/\d+/)?.[0] || signalIndex + 1} · {signal.title || "未命名信号"}
</p>
<p className="line-clamp-1 text-[11px] text-slate-500">{signal.content}</p>
</div>
))}
</div>
</div>
)}
{idx < stages.length - 1 && <ChevronRight className="mx-1 h-4 w-4 text-muted-foreground/70" />}
</div>
);
})}
</div>
</div>
{typeof document !== "undefined" && signalHoverAnchor && signalPreview.length > 0
? createPortal(
<div
className="pointer-events-none fixed z-50 w-[340px] rounded-xl border border-slate-200 bg-white p-3 shadow-[0_10px_28px_rgba(15,23,42,0.15)]"
style={{
left: signalHoverAnchor.left,
top: signalHoverAnchor.top,
}}
>
<p className="text-xs font-semibold text-slate-700">上游有效信号(最近 6 条)</p>
<div className="mt-2 space-y-1.5">
{signalPreview.slice(0, 6).map((signal, signalIndex) => (
<div key={`${signal.id}-${signalIndex}`} className="rounded-md bg-slate-50 px-2 py-1.5">
<p className="line-clamp-1 text-[11px] font-medium text-slate-700">
信号 #{signal.id.match(/\d+/)?.[0] || signalIndex + 1} · {signal.title || "未命名信号"}
</p>
<p className="line-clamp-1 text-[11px] text-slate-500">{signal.content}</p>
</div>
))}
</div>
</div>,
document.body
)
: null}
</div>
);
}

View File

@ -36,7 +36,7 @@ export function MountedAITrainingOpsPanel({
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<MountedAITrainingWavePayload | null>(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,
});

View File

@ -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,

View File

@ -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,

View File

@ -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 "=== 完成 ==="