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