From e16952bd18384f22dda928325af44fb1f9373bfe Mon Sep 17 00:00:00 2001 From: Yuuuu <1569752928@qq.com> Date: Thu, 9 Apr 2026 09:43:10 +0800 Subject: [PATCH] Enforce semantic routing guardrails across AI workflow --- AGENTS.md | 31 +- CLAUDE.md | 687 ++--------------- MEMORY.md | 53 +- backend/.openclaw/pmflow-routing-rules.json | 8 +- .../app/api/feishu_api/card_action_routes.py | 4 +- backend/app/api/signal_sources_api/helpers.py | 27 +- backend/app/config/ai_prompt_registry.py | 2 +- backend/app/config/ai_prompts.py | 164 ++++- backend/app/core/startup/model_probe.py | 2 +- .../mixin_answer_entry.py | 20 +- .../mixin_answer_mainline.py | 89 ++- .../mixin_answer_retrieval.py | 62 +- .../adaptive_rag_service/mixin_core_intent.py | 234 ++++-- .../mixin_deep_guard_answer.py | 303 +++++--- .../mixin_deep_guard_rules.py | 271 ++----- .../mixin_deterministic_query_core.py | 36 +- .../mixin_deterministic_query_count.py | 32 +- .../mixin_deterministic_query_list.py | 23 +- .../mixin_fast_answer_project_main.py | 18 +- .../mixin_fast_answer_project_utils.py | 32 +- .../mixin_fast_answer_semantic_p1.py | 6 +- .../mixin_followup_anchor.py | 275 +++---- .../mixin_manifest_feature_p2.py | 13 +- .../mixin_multistep_core.py | 8 +- .../mixin_multistep_flow.py | 17 +- .../mixin_retrieval_router_db.py | 70 +- .../mixin_session_state.py | 11 +- .../adaptive_rag_service/shared_imports.py | 4 +- .../adaptive_rag_service/shared_part_01.py | 193 ----- .../adaptive_rag_service/shared_part_02.py | 30 - .../ai_pmo/ai_pmo_intent/intent_resolver.py | 2 +- .../services/ai_pmo/ai_pmo_intent/shared.py | 3 +- .../services/ai_pmo/ai_pmo_kernel/__init__.py | 2 - .../ai_pmo/ai_pmo_kernel/ai_agent_kernel.py | 46 -- .../ai_pmo_kernel/compositions/intent.py | 4 +- .../intent/ai_pmo_kernel_mixins_intent_llm.py | 285 -------- .../ai_pmo_kernel_mixins_intent_resolve.py | 165 ++++- .../ai_pmo_kernel_mixins_intent_rules.py | 10 - .../ai_pmo/ai_pmo_kernel_common_core/env.py | 9 +- .../ai_pmo_kernel_common_core/imports.py | 1 - .../ai_pmo_kernel_ingress_flow/mixin_entry.py | 37 +- .../mixin_context.py | 2 +- .../mixin_execute_query_project.py | 78 +- .../services/ai_pmo/draft_confirm_handler.py | 187 ++++- .../ai_pmo/semantic_context/assembler.py | 1 + .../semantic_context/decision_schema.py | 2 +- .../mixin_creation_normalize_persist.py | 9 +- .../ai_steward_service/mixin_json_parsing.py | 15 +- .../ai_steward_service/mixin_title_utils.py | 15 +- .../app/services/ai_steward_service/shared.py | 5 - .../services/executive_briefing_service.py | 12 +- .../mixin_reply_llm_core.py | 28 +- .../mixin_followup_b.py | 33 +- .../mixin_lookup_and_block.py | 23 +- .../mixin_message_utils_a.py | 116 --- .../mixin_process_message_core.py | 68 +- .../mixin_process_message_default.py | 19 +- .../mixin_queue_read_write.py | 2 - .../services/llm_service/mixin_chat_openai.py | 4 +- .../llm_service/mixin_chat_transport.py | 19 +- .../mixin_engine_resolution_core.py | 5 +- .../llm_service/mixin_generate_db_chat.py | 10 +- .../mixin_generate_db_structured.py | 12 +- .../llm_service/mixin_guardrails_prompt.py | 55 +- .../llm_service/mixin_openclaw_routing.py | 26 +- .../mixin_structured_candidates.py | 2 - .../mixin_structured_extract_object.py | 4 +- .../llm_service/mixin_structured_parse.py | 37 +- .../llm_service/mixin_tier_escalation.py | 84 ++- .../services/llm_service/shared_part_01.py | 102 +-- .../services/llm_service/shared_part_02.py | 23 +- .../model/model_context_bundle/mixin_core.py | 4 +- .../model/model_context_bundle/shared.py | 8 +- .../services/model/model_routing/service.py | 3 +- .../app/services/reply_iteration/service.py | 3 - .../signal/draft_generator_service.py | 2 +- .../mixin_analysis_normalize.py | 10 - .../mixin_capture_entry.py | 4 +- .../mixin_pipeline_cache.py | 5 - .../mixin_planning_onboard.py | 6 + .../mixin_registry_execute.py | 6 + .../symbol_index/mixin_parse_typescript.py | 3 +- .../services/signal/symbol_index/shared.py | 3 +- ...adaptive_rag_external_followup_contract.py | 125 ++++ .../test_adaptive_rag_mainline_contract.py | 339 ++++++++- backend/tests/test_ai_pmo_ingress_mainline.py | 691 ++++++++---------- .../tests/test_ai_pmo_task_intent_contract.py | 28 + backend/tests/test_ai_service_core_runtime.py | 54 +- ...t_ai_steward_project_from_text_contract.py | 13 + backend/tests/test_draft_confirm_handler.py | 216 +++++- .../test_feishu_new_project_signal_sources.py | 27 +- .../test_feishu_pipeline_queue_contract.py | 36 +- ...t_runtime_structured_semantics_contract.py | 61 ++ .../tests/test_semantic_context_assembler.py | 3 +- docs/architecture/ARCHITECTURE_INVENTORY.md | 152 ++++ docs/architecture/CODE_REFERENCE_INDEX.md | 72 ++ docs/architecture/MASTER_CONTRACT.md | 8 + docs/architecture/RUNTIME_GUARDRAILS.md | 120 +++ .../ModelRoutingConfigPage.tsx | 4 +- scripts/ai_steward_pressure_coach.py | 14 +- scripts/daily_task_report.sh | 51 ++ 101 files changed, 3339 insertions(+), 3019 deletions(-) delete mode 100644 backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py create mode 100644 backend/tests/test_runtime_structured_semantics_contract.py create mode 100644 docs/architecture/ARCHITECTURE_INVENTORY.md create mode 100644 docs/architecture/CODE_REFERENCE_INDEX.md create mode 100644 docs/architecture/RUNTIME_GUARDRAILS.md create mode 100755 scripts/daily_task_report.sh diff --git a/AGENTS.md b/AGENTS.md index fd56301b..92a3b38b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ - 所有 AI 能力必须封装在独立的 service 层 - AI 能力通过 OpenClaw 路由层调用 - 路由规则参考 `backend/.openclaw/pmflow-routing-rules.json` - - Prompt 放在 `./backend/app/config/ai_prompts.py` 独立管理 + - Prompt 统一由 `./backend/app/config/ai_prompts.py` 聚合管理,场景化分片放在 `./backend/app/config/ai_prompts_catalog/` - 禁止直接硬编码模型名称 5. **AI 调用规范**:所有 AI 调用必须: @@ -89,9 +89,13 @@ frontend/ # React 前端 - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` - 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果 - 核心业务内容零兜底:宪章、信号分析、任务拆解、项目建议、范围边界、风险判断、资源计划、对齐结论等用户可见核心业务内容,禁止使用默认文案、模板句子、占位字段或 stub 数据冒充真实生成结果;AI 失败、证据不足或结构化失败时,必须显式返回失败或标记为“待确认/证据不足/生成失败” -- 业务语义治理:凡是会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS`,只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其它目录只允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析 +- 业务语义治理:运行时业务动作归因不得再依赖散落的中文关键词、正则、`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_*` 或关键词直接最终拍板 - 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,禁止把“改 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 文案”这类启发式判断生成成功或失败 +- 长运行边界、已知运行假设与跨链路守门细节统一维护在 `docs/architecture/RUNTIME_GUARDRAILS.md`;`AGENTS.md` 保留必须立即遵守的摘要规则 ### 8.1 系统定位 @@ -169,16 +173,17 @@ frontend/ # React 前端 ### 8.10 业务语义补充 -- 涉及 `project.create / signal_source.onboard / followup / query vs execute` 的判断,必须由统一语义规划层负责。 -- 涉及中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 的规则,只能在统一语义规划目录或 prompt catalog 中定义。 +- `project.create / signal_source.onboard / followup / query vs execute` 的运行时归因,统一遵循第 8 节主规则:对象/上下文绑定优先,随后进入 `SemanticContextPacket -> SemanticDecision -> gatekeeper` 主链。 +- 文档、测试与语义治理守门脚本允许保留历史命名示例(如 `looks_like_*`、`*_HINTS/*_KEYWORDS`)用于说明或检测;`backend/app` 运行时代码禁止恢复这类业务拍板逻辑。 - 其它模块只允许做清洗、抽取、结构化解析,不得私自定义业务语义。 ## 9. 新增代码的参考模式 -- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 +- 新增后端接口:参考 `backend/app/api/sstc_api/` 的结构 - 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` - 新增数据库表:参考 `alembic/versions/` 最新 migration -- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py` +- 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/` +- 更细的接口、页面与服务参考入口统一维护在 `docs/architecture/CODE_REFERENCE_INDEX.md` ## 10. 幂等与事务约束 @@ -233,16 +238,16 @@ frontend/ # React 前端 ## 14. 文档同步规则(强制) -每次任务完成后,若以下内容发生变化,必须同步更新 `CLAUDE.md` 对应章节: +每次任务完成后,若以下内容发生变化,必须同步更新对应文档(`CLAUDE.md`、`docs/architecture/ARCHITECTURE_INVENTORY.md`、`docs/architecture/RUNTIME_GUARDRAILS.md`、`docs/architecture/CODE_REFERENCE_INDEX.md`): - 新增/删除/重命名文件或目录 - → 更新第3-5节目录结构 + → 更新 `CLAUDE.md` 第3-5节目录结构,并同步更新 `docs/architecture/ARCHITECTURE_INVENTORY.md` - 新增/修改服务边界或禁止操作 - → 更新第8节服务边界约束 + → 更新 `AGENTS.md` 第8节摘要规则,并同步更新 `docs/architecture/RUNTIME_GUARDRAILS.md` - 新增已知 `BOUNDARY` 或 `ASSUMPTION` - → 更新第11节已知 `BOUNDARY` + → 更新 `docs/architecture/RUNTIME_GUARDRAILS.md` - 新增/修改 API 接口 - → 更新第9节参考模式 + → 更新 `docs/architecture/CODE_REFERENCE_INDEX.md`,必要时同步 `CLAUDE.md` 摘要索引 文档更新必须在 `[Task:Done]` 之前完成。 未更新文档的任务视为未完成。 @@ -259,7 +264,7 @@ frontend/ # React 前端 ## 16. Context 管理约定 -- 单任务涉及文件超过5个 → 拆分为独立子任务 +- 大功能开发或结构性重构,单任务涉及文件超过5个时优先拆分为独立子任务;缺陷修复/回归修复允许围绕同一根因跨相关文件端到端完成 - 单任务预估改动超过200行 → 拆分执行 - 发生 context 不足时 → 停止并输出已完成部分 声明 `[CONTEXT: LIMIT]`,不要截断输出 @@ -268,7 +273,7 @@ frontend/ # React 前端 ## 安全约束(必须严格遵守) ### 🚫 绝对禁止的操作: -- 禁止删除任何文件或目录(rm、rmdir、unlink、trash 等) +- 禁止未确认的破坏性删除;只有在用户明确要求删除死代码/死文件、已先列出目标路径并说明后果、且用户再次确认后,才允许执行文件删除 - 禁止执行带 -rf、--force、-y 参数的命令 - 禁止 DROP TABLE、TRUNCATE 等数据库破坏性操作 - 禁止修改 /etc、/sys、/boot 等系统目录 diff --git a/CLAUDE.md b/CLAUDE.md index 5a39624a..3bea529f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,11 @@ - `AGENTS.md`:Codex/Coding Agent 的主规则入口(优先级最高的项目级约束) - `CLAUDE.md`:面向 Claude 风格的项目说明文档(补充背景) - 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md` -- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明 +- 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入。`CLAUDE.md` 当前承载的是“架构说明 + 运行边界 + 目录库存”,不再假定自己只包含背景说明 - 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。 - 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。 - 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。 +- 下面各节中大量 `新增/更新/移除` 清单用于保留“当前架构库存”和关键入口索引,不等同于运行时主规范;行为约束与禁止项仍以 `AGENTS.md` 为准。 ## 2. 项目定位 - 项目名:`PMFlow / Neural Sync` @@ -20,7 +21,7 @@ ```text AIProjectManger/ ├── AGENTS.md # Agent 执行规则(建议优先维护) -├── CLAUDE.md # 现有 Claude 说明文档 +├── CLAUDE.md # 当前架构说明与运行边界 ├── start.sh / stop.sh # 一键启动/停止入口 ├── docker-compose*.yml # 容器编排 ├── mdproject/ # 执行计划与任务清单(中文为主) @@ -29,314 +30,37 @@ AIProjectManger/ ├── scripts/ # 根目录运维脚本 └── docs/ # 额外文档 ``` -- 新增:`docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`(消息台内联操作三期执行清单) -- 新增:`docs/architecture/`(架构契约文档目录,含 `MASTER_CONTRACT.md/API_CONTRACTS.md/DATAFLOW.md/TASK_BRIEF_TEMPLATE.md`) -- 新增:`docs/superpowers/plans/`(本地实现计划与执行拆解文档目录) -- 新增:`docs/task_briefs/`(增量任务包生成产物目录) -- 新增:`docs/task_briefs/sidebar_new_entries_20260312.png`、`docs/task_briefs/model_routing_override_effective_20260312.png`(Step 8 页面验收截图) +- 详细架构库存已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。 +- `docs/architecture/` 当前包含:`MASTER_CONTRACT.md`、`RUNTIME_GUARDRAILS.md`、`CODE_REFERENCE_INDEX.md`、`API_CONTRACTS.md`、`DATAFLOW.md`、`TASK_BRIEF_TEMPLATE.md`、`ARCHITECTURE_INVENTORY.md` +- `docs/superpowers/plans/` 保存实现计划,`docs/task_briefs/` 保存任务包产物与验收截图。 ## 4. 后端结构(`backend/`) ```text backend/ ├── app/ -│ ├── __init__.py # app 包入口(根目录轻量化后保留) -│ ├── api/ # 路由层(含 endpoints) -│ ├── core/ # 配置、日志、核心能力 -│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/draft expiry/probe/warmup) -│ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal) +│ ├── api/ # 路由层(canonical 入口优先使用 *_api/ 子包) +│ ├── core/ # 配置、日志、启动与核心能力 │ ├── dependencies/ # 鉴权/租户等依赖注入 -│ ├── middleware/ # 请求中间件(审计上下文注入等) +│ ├── middleware/ # 请求中间件 │ ├── models/ # SQLAlchemy 模型 │ ├── schemas/ # Pydantic 模型 -│ ├── services/ # 业务服务(AI/SSTC/集成) +│ ├── services/ # 业务服务(已目录化收敛) │ ├── config/ # 配置与 prompt -│ └── utils/ # 工具函数(timezone / audited_write / thread_context) -├── alembic/ # 数据库迁移 -│ └── versions/ -│ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段 -│ ├── 20260311_add_pg_trgm_text_indexes.py # pg_trgm 扩展与文本检索索引 -│ ├── 20260311_add_kb_chunk_ivfflat_index.py # knowledge_base_chunks 向量 IVFFlat 索引 -│ ├── 20260311_add_operation_log_audit_fields.py # operation_logs 审计字段扩展(trace/path/caller) -│ ├── 20260311_add_ai_pmo_work_items_view.py # ai_pmo_work_items 兼容视图(映射 work_items) -│ ├── 20260311_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2) -│ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表 -│ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展 -│ ├── 20260323_add_work_item_draft_confirmation_fields.py # work_items 草稿确认字段与索引 -│ ├── 20260324_add_project_create_idempotency_indexes.py # 项目创建幂等索引:活动项目标题大小写无关唯一 -│ ├── 20260327_cleanup_model_routing_legacy_names.py # model_routing 旧函数名清理与重命名迁移 -│ ├── 20260405_add_mounted_ai_runtime_tables.py # Mounted Runtime 建表迁移 -│ ├── 20260405_add_mounted_runtime_plan_state_fields.py # Mounted Runtime plan_status / conversation_scope_key 字段 -│ └── 20260406_add_mounted_runtime_active_draft_fields.py # Mounted draft active work item / message anchor 字段 -├── scripts/ # 后端运维/数据修复脚本 -│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links -│ ├── dev/ # 开发辅助脚本(check_users/seed_all) -│ └── legacy/ # 历史脚本(仅保留兼容参考,不作为主流程) +│ └── utils/ # 通用工具 +├── alembic/ # 数据库迁移真源 +├── scripts/ # 后端运维/修复脚本 ├── tests/ # 后端回归测试 -│ ├── test_draft_confirm_handler.py -│ ├── test_ai_service_core_runtime.py -│ ├── test_rag_route_contract.py -│ ├── test_ai_pmo_task_intent_contract.py -│ ├── test_ai_pmo_ingress_mainline.py -│ ├── test_ai_pmo_qa_http_mainline_e2e.py -│ ├── test_context_cache_profile_catalog.py -│ ├── test_ai_pmo_tactical_brain_contract.py -│ ├── test_adaptive_rag_mainline_contract.py -│ ├── test_ai_pmo_execution_ledger_contract.py -│ ├── test_feishu_card_action_contract.py -│ ├── test_feishu_pipeline_queue_contract.py -│ ├── test_feishu_long_connection_tls_contract.py -│ ├── test_feishu_receive_orchestration_contract.py -│ ├── test_signal_planner_contract.py -│ ├── test_ai_prompt_registry_contract.py -│ ├── test_model_routing_service.py -│ ├── test_model_routing_migration.py -│ ├── test_sstc_tasks_ai_breakdown_contract.py -│ └── test_sstc_charter_release_contract.py -├── requirements.txt # Python 依赖 +├── requirements.txt └── Dockerfile ``` -- 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口) -- 新增:`backend/app/services/model/model_config.py`(模型 provider / tier 默认值的唯一配置读取器,读取 `backend/.openclaw/pmflow-routing-rules.json`) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) -- 新增:`backend/app/core/startup/ai_pmo_ingress_async.py`(AI PMO 入站异步任务轮询启动循环) -- 新增:`backend/app/utils/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`) -- 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化) -- 新增:`backend/app/utils/thread_context.py`(飞书线程/消息锚点归一 + 外部问答 `conversation_scope_key` 生成:reply/root/thread 用于 reply anchor,稳定会话 scope 默认使用 `chat_id + sender_open_id`,不再用 `source_message_id` 作为外部连续会话真锚点) -- 新增:`backend/app/services/adaptive_rag_service/mixin_followup_anchor.py`(外部问答追问锚定与候选澄清:单候选自动绑定、多候选文本编号澄清、元追问优先锚定最近 PMFlow 回复) -- 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入) -- 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`) -- 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化) -- 新增:`backend/app/services/signal/signal_source_repair/service.py`(脏信号源回填服务:优先回填既有 Signal,不足时物化正式 Signal) -- 更新:`backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(需求点归一化/去重与 `materialize_requirement_sources` 落库 helper,`raw_context` 记录来源与原始需求点) -- 更新:`backend/app/services/signal/signal_capture_service/mixin_new_project.py`、`mixin_capture_legacy.py`(飞书 `new_project` 自动立项收口,项目创建与信号源落库失败即回滚,避免半成功状态) -- 新增:`backend/alembic/versions/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai) -- 新增:`backend/alembic/versions/20260327_add_external_qa_followup_state.py`(`qa_sessions` 增加 `external_conversation_key`,`qa_session_states` 增加外部追问待澄清状态字段) -- 新增:`backend/alembic/versions/20260327_add_qa_record_followup_anchor_fields.py`(`qa_records` 增加外部会话 scope / reply anchor / 路由快照字段,支撑跨 session 追问锚定与元追问追溯) -- 新增:`backend/alembic/versions/20260331_add_qa_record_runtime_fields.py`(`qa_records` 增加 `answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`,用于 Grounding Runtime 持久化与回放) -- 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引) -- 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口) -- 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口) -- 新增:`backend/app/services/signal/draft_generator_service.py`(写操作草稿生成服务:草稿结构化产出 + 24h 过期) -- 更新:`backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py`(项目创建归一化阶段新增技能标签解析 + `recommend_personnel` 自动指派;项目、宪章、任务落库时同步写入 `assignee_id/ai_recommended_assignees`) -- 新增:`backend/app/services/ai_pmo/draft_confirm_handler.py`(草稿确认处理器:确认/修改/取消 + 幂等保护) -- 新增:`backend/app/core/startup/draft_expiry_worker.py`(草稿超时取消后台扫描任务) -- 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit) -- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环) -- 新增:`backend/app/services/task_breakdown.py`(任务拆解业务对象归一、技能标签回填、自动指派阈值判断) -- 新增:`backend/app/services/ai_pmo/consultant_dispatch.py`(轻量顾问式统一调度合同与注册表:当前仅保留给 `/rag/ask` 等顾问式问答合同使用;已退出 Feishu mention 主回复与 AI PMO ingress capture 主链) -- 新增:`backend/app/config/ai_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约) -- 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单) -- 更新:`backend/app/config/ai_prompts_catalog/part_02.py`(`PROMPT_DRAFT_GENERATOR` 增加 active_projects / similar_projects / MVP / prospect / requirements 评审字段) -- 更新:`backend/app/api/tasks.py`(`/tasks/breakdown` 统一结构化任务业务对象输出,支持可选 `project_id/charter_id`,与 SSTC 宪章上下文和技能词表统一) -- 更新:`backend/app/schemas/common.py`(`TaskBreakdownRequest` 增加可选 `project_id/charter_id`) -- 更新:`backend/app/services/sstc/charter_data.py`(新增任务拆解上下文/技能词表/ prompt 拼装共享 helper,并把 `derived_artifacts.task_seeds/verification_metrics/risk_gates` 暴露给下游) -- 更新:`backend/app/config/ai_prompts_catalog/part_01.py`(`PROMPT_TASK_BREAKDOWN` 统一为结构化任务业务对象输出) -- 更新:`backend/app/config/ai_prompts_catalog/part_03.py`(`PROMPT_GENERATE_CHARTER` 改为输出 `modules/field_status/field_evidence/readiness/derived_artifacts`;`PROMPT_DECOMPOSE_TASKS` 统一读取 `request/project/charter/modules` 结构化上下文,并输出 `task_key/title/required_skill_names/required_skill_tag_ids/depends_on_task_keys/risk/confidence/evidence_refs`) -- 新增:`backend/tests/test_ai_service_core_runtime.py`(AIService 运行时回归:统一收敛 llm_service、缓存命中与流式兼容输出) -- 新增:`backend/tests/test_rag_route_contract.py`(RAG 双入口契约回归:`asked_via/action_mode/message_intent/mixed_signal_part` 统一透传) -- 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派) -- 新增:`backend/tests/test_ai_pmo_ingress_mainline.py`(AI PMO 主链入口回归:`/ai-pmo/process-text` 手工问答入口、mixed sidecar 透传、`draft_pending_confirm` 优先分支) -- 新增:`backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`(AI PMO/QA HTTP 主链集成回归:`/rag/ask` 只读问答、mixed 两段输出、`/ai-pmo/process-text` 草稿待确认) -- 新增:`backend/tests/test_qa_record_runtime_fields_migration.py`(QA Runtime migration 回归:`qa_records` runtime/evidence 字段新增与回滚) -- 新增:`backend/tests/test_context_cache_profile_catalog.py`(Context Cache 功能目录契约回归:AI Steward 统一指向 `/rag/ask`) -- 新增:`backend/tests/test_ai_pmo_tactical_brain_contract.py`(TacticalBrain 执行动作契约回归:`query.answer`/`work_item.resume`/`project_signal.*` 映射) -- 新增:`backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`(AdaptiveRAG 基础问答主链编排:运行时准备 + 只读主路径执行) -- 新增:`backend/tests/test_adaptive_rag_mainline_contract.py`(AdaptiveRAG 入口分层契约回归:read 路径绕过写确认 gate、write 路径命中确认 gate、feishu write 仍走主链) -- 新增:`backend/tests/test_ai_pmo_execution_ledger_contract.py`(执行账本/动作参数来源契约回归:`action_contract` 归一、账本幂等 key、状态映射) -- 新增:`backend/tests/test_sstc_tasks_ai_breakdown_contract.py`(SSTC 手动 AI 拆任务契约回归:宪章上下文压缩、prompt 注入、技能词表透传) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(新增统一 `action_contract` 组装、`operation_execution_ledger` upsert、账本状态归一) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(自动执行与草稿待确认路径接入统一执行账本,日志 `operation_code` 对齐 `action_contract`) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py`(`waiting_manual` 路径接入统一执行账本与动作参数来源约束) -- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_card_action.py`(飞书卡片动作解析与草稿确认回调处理) -- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py`(飞书消息入口预处理:上下文抽取、空文本分支、附件标准化) -- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py`(飞书消息入口执行流:capture 持久化、异常回执、结果收尾) -- 新增:`backend/tests/test_feishu_card_action_contract.py`(飞书卡片回调契约回归:动作解析与事件分发) -- 新增:`backend/tests/test_feishu_pipeline_queue_contract.py`(飞书待确认队列契约回归:action card 元数据与 follow-up 唯一目标约束) -- 新增:`backend/tests/test_feishu_receive_orchestration_contract.py`(飞书入口编排契约回归:bot 过滤、空文本分支、capture/finalize 编排) -- 新增:`backend/tests/test_adaptive_rag_external_followup_contract.py`(外部 QA 连续追问契约:真实 session 复用、最近回复锚定、编号澄清选择) -- 新增:`backend/tests/test_external_qa_followup_migration.py`(外部 QA 会话 migration 回归:列/索引新增与回滚) -- 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单) -- 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级) -- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性) -- 新增:`backend/tests/test_sstc_charter_release_contract.py`(SSTC 宪章总确认/发布验证契约回归:readiness 阻断、verification metrics 来源约束) -- 新增:`backend/tests/test_prompt_profile_bundle_contract.py`(默认角色 prompt bundle 契约回归:PMFlow PMO 语气、职责边界与协作口径) -- 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入) -- 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`) -- 新增:`backend/alembic/versions/20260326_create_feedback_center_tables.py`(统一反馈学习中心双表建模与 QA/Signal/Task 初始回填) -- 新增:`backend/alembic/versions/20260326_add_ai_call_log_routing_fields.py`(`ai_call_logs` 新增 `planned_tier/executed_tier/routing_decision/route_source/route_rule_name/routing_config_*` 路由追溯字段) -- 新增:`backend/alembic/versions/20260327_cleanup_model_routing_legacy_names.py`(`model_routing` 旧函数名重命名/清理:`project_draft_analysis_glm47_fallback` -> `ai_pmo_signal_title_refine`,删除旧残留键) -- 新增:`backend/app/api/ai_steward_api/routes_feedback_center.py`(AI Steward 反馈中心接口:summary/list/detail/review/reason-catalog;summary 支持 `window_days` / `source_group` 趋势作用域) -- 新增:`backend/app/models/feedback_center.py`(`FeedbackCenterRecord/FeedbackCenterRecordRevision` 统一反馈中心模型) -- 新增:`backend/app/schemas/feedback_center.py`(反馈中心 summary/list/detail/review/reason-catalog 对应 Pydantic 模型) -- 新增:`backend/app/services/feedback_center/`(反馈中心服务层:`service.py/mappers.py/reason_catalog.py`;summary 产出 `metrics/trend/insights` 学习仪表盘) -- 新增:`backend/tests/test_feedback_center_service.py`(反馈中心服务回归:tenant/project 过滤、review 修订、映射与原因候选) -- 新增:`backend/tests/test_feedback_center_api.py`(反馈中心 API 回归:路由、权限与三类来源回填) -- 新增:`backend/tests/test_feedback_center_migration.py`(反馈中心迁移回归:历史回填 `_insert_record` 可安全写入 JSON 载荷,避免 Alembic 在 SQLite/PostgreSQL 上因 dict/list bind 失败) -- 新增:`backend/tests/test_daily_briefing_service.py`(每日简报回归:北京时间 naive 时间窗、任务更新采集与空响应边界) -- 新增:`backend/tests/test_ai_steward_project_from_text_contract.py`(AI Steward `project-from-text` 契约回归:`code/message/data` 包络与 `signal_sources` 字段) -- 新增:`backend/tests/test_signal_source_requirement_materialization.py`(需求点 -> 信号源注册项归一/去重/原始上下文回归) -- 新增:`backend/tests/test_signal_source_repair_materialization.py`(历史 registry 修复回归:优先回填项目内既有 Signal,否则物化正式 Signal) -- 新增:`backend/tests/test_feishu_new_project_signal_sources.py`(飞书 `new_project` 自动立项回归:摘要失败显式报错、信号源随项目同步落库) -- 新增:`backend/tests/test_model_routing_service.py`(双层模型路由契约回归:`safety` 拒绝、runtime/query 一致、override 只存 `tier_alias`、`ai_call_logs` 仅记录 tier 级路由元信息) -- 新增:`backend/tests/test_model_routing_migration.py`(`model_routing` 迁移回归:旧函数名键重命名/删除后仍保留低高层路由语义) -- 更新:`backend/app/services/feedback_center/service.py`(summary 输出新增 metrics 学习指标、决策洞察 `insights`,并支持 `window_days` / `source_group` 的趋势分析作用域) -- 更新:`backend/app/schemas/feedback_center.py`(summary 增加 metrics / trend / insights 结构,并回传 `source_group`) -- 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) -- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) -- 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) -- 新增:`backend/app/services/model_context_bundle_service.py`(low/high 路由上下文组包/能力汇总/消费验证服务) -- 新增:`backend/app/api/model_routing.py`(模型路由配置接口) -- 新增:`backend/app/api/task_briefs.py`(任务包中心接口) -- 更新:`backend/app/api/ai_steward_api/routes_capture.py`、`schemas.py`、`shared.py`(`POST /ai-steward/project-from-text` 统一 `code/message/data` 包络,返回 `signal_sources`) -- 更新:`backend/app/api/knowledge_graph.py`(新增 `capability-summary` 与 `verify-context-consumption` 接口) -- 更新:`backend/app/services/llm.py`(按 function_name 注入 DDL 接口化硬约束,默认直连 GLM 严格失败;本地 Ollama 仅允许知识库问答链路按开关启用) -- 更新:`backend/app/api/llm_engines.py`(新增 `PUT /ai-steward/llm-engines/settings`,维护本地知识库问答开关) -- 更新:`backend/app/api/ai_steward.py`(移除 `/ai-steward/command/dispatch` OpenClaw 指挥规划入口) -- 更新:`backend/app/services/ai_steward.py`(零配置项目创建任一步 LLM 失败直接报错,不再回退默认草稿) -- 更新:`backend/app/services/signal_source_orchestration_service.py`(信号源 onboarding 规划与 worker 执行失败均 fail-fast,不再回退 heuristic/fallback 摘要) -- 更新:`backend/app/services/project_knowledge_graph.py`(新增 `ai_model -> governance_policy -> interface` 治理图谱同步能力,增量任务新增 `model_guardrails` 阶段) -- 移除:`backend/.env.example`(配置模板统一到根目录 `.env.example`) -- 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助) -- 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射) -- 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录) -- 新增:`backend/app/services/ai_pmo/semantic_context/`(OpenCow 风格语义上下文子模块:统一 `SemanticContextPacket/ModeCard/SemanticDecision/DecisionGateResult` 契约、`SemanticContextAssembler`、`SemanticDecisionGatekeeper` 与 `SemanticDecisionService`;当前除 ingress shadow 观测外,也开始承接 draft followup 这类用户可见动作判定,作为 intent/query-scene/followup 统一 packet 决策底座) -- 新增:`backend/app/models/reply_iteration.py`(回复质量迭代模型:`QualityAsset/QualityAssetPair/JudgeRun/OptimizerCandidate/PromptRelease`) -- 新增:`backend/app/models/training_pipeline.py`(训练/微调管线模型:`TrainingDataset/TrainingJob/TrainingEvaluation/TrainedModelArtifact/TrainedModelDeployment`) -- 新增:`backend/app/models/mounted_ai_runtime.py`(Mounted Runtime 运行时模型:`MountedTopicWorkspace/MountedDraftWorkspace/MountedConversationRun/MountedAgentTrace/MountedSourceActivation/MountedToolCapabilityPolicy`;`MountedTopicWorkspace` 额外承载 `conversation_scope_key + plan_status`,用于同群同人 topic 隔离与 plan 状态机;`MountedDraftWorkspace` 额外承载 `active_work_item_id + latest_source_message_id + latest_reply_message_id`,用于当前 active draft 绑定与 reply anchor 锚定) -- 新增:`backend/app/services/reply_iteration/service.py`(反馈中心提升为学习资产、裁判分桶、候选改动、prompt release trial 服务;支持 `backfill_mounted_training_assets_from_feedback`:从真实 `feedback_center_records + qa_records` 回填 golden/failure、preference pair 与 judge runs,供 mounted runtime 真实业务训练使用) -- 新增:`backend/app/services/training_pipeline/service.py`(训练数据集、训练作业、评测、artifact promote、deployment 决策服务;支持 `bootstrap_mounted_training_wave`:自动从 mounted runtime 质量资产生成 `teacher_dev_set / teacher_eval_frozen_set / sft / preference`,并启动 low/high 训练波次;mounted wave 会立刻消费本次 queued jobs,产出 completed job/evaluation/acceptance gate;当 failure 样本带 `corrected_answer` 时会自动合成 synthetic golden / preference pair) -- 新增:`backend/app/services/prompt_release/service.py`(只读 QA prompt release 版本管理与 serving 渲染优先级) -- 新增:`backend/app/services/mounted_ai_runtime_service.py`、`backend/app/services/mounted_ai_runtime_write_service.py`(Mounted Runtime 读模型与写路径:来源/主题/run 查询、source activation / tool policies / training ops / hard cases / replay-run 管理,以及 ingress topic/run/trace/source-activation 落库;当某来源存在 active activation 白名单时,仅命中的 scope 才进入 mounted tracking;run detail 会返回 rich trace:`input_summary/output_summary/tool_trace`,并暴露 `topic/draft/semantic_packet_summary/semantic_decision/semantic_gate_result/semantic_diff`;写路径会持久化 `conversation_scope_key`、显式 `plan_status` 与 semantic shadow 观测结果) -- 新增:`backend/app/api/ai_steward_api/routes_reply_iteration.py`(AI Steward 回复质量闭环接口:反馈提升、judge-runs、candidates、prompt-releases、trials,以及 `POST /ai-steward/reply-iteration/backfill-mounted-assets` 真实业务训练资产回填入口) -- 新增:`backend/app/api/ai_steward_api/routes_training_pipeline.py`(AI Steward 训练管线接口:datasets、jobs、evaluations、artifacts、deployments,以及 `POST /ai-steward/training/mounted-wave` 一键训练波次入口、`POST /ai-steward/training/jobs/{id}/run` 单 job 执行入口) -- 新增:`backend/app/api/ai_steward_api/routes_mounted_ai.py`、`backend/app/schemas/mounted_ai_runtime.py`(Mounted Runtime 控制台接口:sources/source-activations/tool-policies/topics/runs/run-detail/cutover-status/eval-suites/training-ops/hard-cases,以及 admin/source-activation、admin/tool-policy、admin/replay-run) -- 新增:`backend/alembic/versions/20260327_add_reply_iteration_training_tables.py`(回复质量迭代/训练管线主迁移:学习资产表、prompt release、训练数据集、训练作业与 experiment 扩展字段) -- 新增:`backend/alembic/versions/20260405_add_mounted_ai_runtime_tables.py`(Mounted Runtime 建表迁移:topic workspace、draft workspace、conversation run、agent trace、source activation、tool capability policy) -- 新增:`backend/alembic/versions/20260405_add_mounted_runtime_plan_state_fields.py`(Mounted Runtime 计划态增强迁移:`mounted_topic_workspaces` 新增 `conversation_scope_key / plan_status`,支撑同群同人多 topic 隔离与 plan 状态机) -- 新增:`backend/alembic/versions/20260406_add_mounted_runtime_active_draft_fields.py`(Mounted Runtime active draft 锚点迁移:`mounted_draft_workspaces` 新增 `active_work_item_id / latest_source_message_id / latest_reply_message_id`,支撑 session-first 草稿确认与最新回包锚定) -- 新增:`backend/tests/test_reply_iteration_service.py`、`test_training_pipeline_service.py`、`test_reply_iteration_api.py`、`test_reply_iteration_training_migration.py`(回复质量闭环与训练管线基础回归) -- 新增:`backend/tests/test_mounted_ai_runtime_models.py`、`test_mounted_ai_runtime_migration.py`、`test_mounted_ai_runtime_api.py`、`test_mounted_ai_runtime_write_service.py`(Mounted Runtime 模型/迁移/API/写路径回归) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py`(AI PMO `ContextAssembler` + `WorkflowRegistry/WorkflowHandler` 运行时骨架) -- 新增:`backend/app/services/ai_pmo_decision_runtime.py`(AI PMO 决策脑层:`Strategic/Tactical/Simulation/ExecutionPolicy/DecisionEngine`) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py`(AI PMO 回执渲染出口 `ReplyRenderer`) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor.py`(AI PMO typed 执行层组合入口) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py`(TypedExecutor 共享常量/依赖) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py`(TypedExecutor 解析与批量分析逻辑) -- 新增:`backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/`(TypedExecutor discover 候选上下文逻辑) -- 新增:`backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/`(TypedExecutor execute/create/source-onboard 落库逻辑) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_kernel_runtime.py`(AI PMO kernel 组合入口) -- 新增:`backend/app/services/ai_pmo_kernel_common.py`(Kernel 共享常量/依赖) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/ingress.py`(Kernel ingress 组合入口) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`(Kernel 入站主流程编排) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助) -- 更新:`backend/app/services/signal/signal_capture_service/mixin_capture_entry.py`(Feishu capture 在 source activation 存在 active 白名单规则时,未命中 scope 的 chat 直接返回 `mounted_runtime_scope_not_activated`,不再继续进入 kernel/queue) -- 更新:`backend/app/services/signal/signal_capture_service/mixin_capture_entry.py`(kernel 未处理或 capture 主链异常时会回滚当前 session 并把真实失败原因写入 `direct_reply_text`,不再回退统一“系统处理中出现异常”伪成功文案) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(写操作改为“先起草后确认”,状态进入 `draft_pending_confirm`) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理,并把 ingress 主链同步写入 Mounted Runtime 的 topic/run/trace;除主脑 `ConversationOrchestrator` 外,还会额外记录 `PlanCoach/Clarifier/DraftSynthesizer/Executor` 等 specialist trace;`query.answer` + `business_review/assignee` 场景会在 runtime 中收口到 `plan/chat`,不再伪装成 `execute/Executor`;query-scene 强制改为优先读取 direct metadata,不再在主链回填 `semantic_bundle`;kernel 失败改为 `logger.exception` 保留完整 traceback) -- 更新:`backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py`(query `failed_safe` 只在 SQLAlchemy session 已进入 failed/inactive 状态时回滚,避免把同链路的 ingest/work_item 一起回滚掉;需求收敛/范围建议/负责人推荐/项目存在性查询会优先走 deterministic 直答,不再默认落到 heavy RAG;query executor 对 `message_intent/consultant_mode_preference/advise_subtype/force_thinking_mode` 改为 direct metadata first,旧 `semantic_bundle.diagnostics` 仅保留兼容兜底;`requirement_plan` followup 在存在 active workspace 时优先按 mounted runtime 状态与字段增量续接,不再先依赖旧 planner 猜 continuation;“一个 AI 客服工具,这个项目有吗”这类问句会直接返回项目事实库命中结果) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py`、`ai_pmo_kernel_mixins_intent_llm.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(意图解析主链移除 `signal_source.onboard` 的 pre-LLM 规则锁;LLM metadata 不再注入 `semantic_bundle`;capture result 仅生成 `direct_metadata_compat` 形式的兼容语义包,不再为回包临时重跑 `workflow_semantic_planner`) -- 更新:`backend/app/services/adaptive_rag_service/mixin_followup_anchor.py`、`mixin_fast_answer_project_main.py`(standalone 项目存在性问句不再误判成 referential followup;项目风险/阻塞问句在无任务拆解时返回“缺任务分解、缺里程碑、缺负责人推进节奏”的风险代理信号,不再给空泛兜底) -- 更新:`backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_discover.py`、`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py`(discovery mixin 补齐 `_safe_int/_utc_now_naive` 导入,消除真实 discovery 执行时的 `NameError`;`signal_source.onboard` 不再走 pre-LLM 规则锁,主链保持 model-only,再由后续 route/gate 约束) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py`(canonical intent builder 现在保留 `project.update/task.* / personnel.* / knowledge.* / notification.*` 等 mutation intent,避免 LLM 已识别的写操作被错误降级成 `ignore`) -- 更新:`backend/app/models/work_item.py`(新增 `draft_content/draft_expires_at/draft_version/confirm_message_id`) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py`(Kernel world-model 组合入口) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`(Kernel world-model 核心构建与缓存) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py`(Kernel 会话继承与会话态维护) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/`(Kernel 组合摘要构建器) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py`(Kernel intent 组合入口) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py`(Kernel 规则意图与槽位守卫) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py`(Kernel LLM 意图识别与仲裁) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py`(Kernel 最终意图解析入口) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/followup.py`(Kernel followup 组合入口) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_records.py`(followup 决策/计划/回执写入) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py`(followup 候选定位与恢复分发) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal.py`(project_signal followup 组合入口) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_actions.py`(project_signal commit/refresh/cancel 动作执行) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py`(project_signal 目标工作项恢复闭环) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py`(followup 执行校验与工具函数) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/closure.py`(Kernel closure 组合入口) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_helpers.py`(Kernel 闭环通用归一化工具) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py`(Kernel 闭环历史上下文回放) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py`(Kernel 闭环语义与来源上下文组装) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/`(新增 `closure/followup/ingress/intent/world_model` 分组目录与 `__init__.py` 包声明,降低同层文件密度) -- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/`(新增组合入口分组目录,根目录仅保留 runtime/agent/domain 等真正入口) -- 更新:`backend/app/services/ai_pmo/`(新增 AI PMO 领域分组目录,统一收敛 `ai_pmo_cadence/ai_pmo_decision/ai_pmo_intent/ai_pmo_kernel/ai_pmo_kernel_common_core/ai_pmo_kernel_ingress_flow/ai_pmo_kernel_world_model_aggregates/ai_pmo_kernel_world_model_core/ai_pmo_typed_executor_discovery/ai_pmo_typed_executor_execute`) -- 更新:`backend/app/services/model/`(新增模型能力分组目录,统一收敛 `model_capability/model_context_bundle/model_routing/prompt_profile`) -- 更新:`backend/app/services/integrations/`(新增集成能力分组目录,统一收敛 `integration_core/feishu_integration_service/feishu_long_connection_core/feishu_outbox/feishu_signal_pipeline/wecom`) -- 更新:`backend/app/services/signal/`(新增信号能力分组目录,统一收敛 `signal_capture_service/signal_planner/signal_source_orchestration/signal_source_repair/symbol_index`) -- 移除:`backend/app/services/ai_pmo_kernel_service.py`(顶层兼容壳清理,统一改为 `backend/app/services/ai_pmo/ai_pmo_kernel/` 子包) -- 新增:`backend/app/services/integrations/feishu_signal_pipeline/`(飞书信号管道按职责拆分的服务包,所有子文件 <= 300 行) -- 新增:`backend/app/services/integrations/feishu_signal_pipeline/service.py`(组合入口,按 mixin 聚合能力) -- 新增:`backend/app/services/integrations/feishu_signal_pipeline/shared_core.py`、`shared_schema_utils.py`(共享常量/正则/Schema/工具函数拆分) -- 新增:`backend/app/services/integrations/feishu_signal_pipeline/mixin_*.py`(按职责拆分的处理流、gate、followup、queue、序列化、提取分类等实现) -- 更新:`backend/app/services/feishu_signal_pipeline.py`(降级为兼容入口,仅导出包内 `FeishuSignalPipelineService`) -- 新增:`backend/app/api/feishu_api/`(飞书接口按配置/长连接/消息发送/连通性测试拆分的子模块目录) -- 新增:`backend/app/api/feishu_api/router.py`(飞书 API 聚合路由) -- 新增:`backend/app/api/feishu_api/card_action_routes.py`(飞书卡片按钮回调 HTTP 接口) -- 新增:`backend/app/api/feishu_api/config_routes.py`(飞书配置相关接口) -- 新增:`backend/app/api/feishu_api/long_connection_routes.py`(飞书长连接状态与重启接口) -- 新增:`backend/app/api/feishu_api/message_routes.py`(飞书消息与卡片发送接口) -- 新增:`backend/app/api/feishu_api/test_routes.py`(飞书连通性测试接口) -- 新增:`backend/app/api/feishu_api/schemas.py`(飞书 API 请求模型) -- 新增:`backend/app/api/feishu_api/helpers.py`(飞书 API 公共辅助函数) -- 更新:`backend/app/api/feishu.py`(降级为兼容入口,仅导出聚合 `router`) -- 新增:`backend/app/api/sstc_api/`(SSTC 路由按领域拆分:signals/charters/tasks/releases/reviews/overview/ai-routing) -- 新增:`backend/app/api/sstc_api/shared.py`(SSTC 路由层公共依赖导入) -- 更新:`backend/app/api/sstc.py`(降级为兼容入口,仅导出聚合 `router`) -- 新增:`backend/app/services/sstc/`(SSTC 业务辅助能力拆分:signal/charter/governance/entity_lookup/review) -- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环) -- 新增:`backend/app/services/llm_service/`(LLM 服务按职责拆分的 mixin 包,所有子文件 <= 300 行) -- 更新:`backend/app/services/llm.py`(降级为兼容入口,导出 `LLMService` 与 `llm_service`) -- 新增:`backend/app/services/signal/signal_capture_service/`(主动信号捕获服务按入口/编排/分析/新项目拆分) -- 更新:`backend/app/services/signal_capture.py`(降级为兼容入口) -- 新增:`backend/app/services/project_knowledge_graph_service/`(知识图谱服务按 CRUD/增量同步/守卫/快照拆分) -- 更新:`backend/app/services/project_knowledge_graph.py`(降级为兼容入口) -- 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分) -- 更新:`backend/app/services/context_cache.py`(降级为兼容入口) -- 新增:`backend/app/config/ai_prompts_catalog/`(Prompt 常量分片目录,按 `part_01..part_04` + `part_scenes_01..part_scenes_06` 管理) -- 更新:`backend/app/config/ai_prompts.py`(兼容聚合入口,保留 `build_default_role_prompt_bundle` 并显式导出 prompt registry helpers) -- 新增:`backend/app/api/signals_api/`(signals 路由拆分为 list/create、ingest/fetch、feedback 子模块) -- 新增:`backend/app/api/wecom_api/`(wecom 路由拆分为 config/webhook/messaging 子模块) -- 新增:`backend/app/api/endpoints/skill_api/`(人员能力图谱路由拆分为 tags/bindings/workload/recommend 子模块) -- 更新:`backend/app/api/ai_logs.py`、`backend/app/api/ai_logs_utils.py` → `backend/app/api/ai_logs_api/routes.py`、`backend/app/api/ai_logs_api/helpers.py`(AI 日志 API 收敛到独立子包) -- 更新:`backend/app/api/signal_sources.py`、`backend/app/api/signal_sources_models.py`、`backend/app/api/signal_sources_utils.py` → `backend/app/api/signal_sources_api/routes.py`、`backend/app/api/signal_sources_api/schemas.py`、`backend/app/api/signal_sources_api/helpers.py`(信号源 API 收敛到独立子包) -- 更新:`backend/app/api/skill_tags.py`、`backend/app/api/skill_tags_contracts.py` → `backend/app/api/skill_tags_api/routes.py`、`backend/app/api/skill_tags_api/contracts.py`(技能标签 API 收敛到独立子包) -- 更新:`backend/app/api/router.py`、`backend/app/api/__init__.py`(直接引用 `*_api` 子模块路由,移除顶层兼容壳依赖) -- 移除:`backend/app/api/ai_pmo.py`、`backend/app/api/ai_steward.py`、`backend/app/api/model_capability.py`、`backend/app/api/projects.py`、`backend/app/api/rag.py`、`backend/app/api/sstc.py`、`backend/app/api/users.py`、`backend/app/api/feishu.py`、`backend/app/api/signals.py`、`backend/app/api/wecom.py`(顶层兼容壳清理) -- 约定:`backend/app/api/rag_api/__init__.py` 与 `backend/app/api/ai_steward_api/__init__.py` 只做聚合与 tags,canonical prefix 由 `shared.py` / 子模块 router 持有,禁止包级 prefix 再重复叠加导致 `/rag/rag/...` 或 `/ai-steward/ai-steward/...` -- 更新:`backend/check_users.py`、`backend/seed_all.py`、`backend/init_db.py` → `backend/scripts/dev/check_users.py`、`backend/scripts/dev/seed_all.py`、`backend/scripts/legacy/init_db.py`(backend 根目录散落脚本归档) -- 新增:`backend/app/config/operation_catalog_core/`(operation catalog 按模型/构造器/领域条目拆分) -- 更新:`backend/app/dispatcher.py`、`backend/app/dispatcher_rules.py`(未被引用,按废弃文件移除) -- 更新:`backend/app/seed_projects.py`、`backend/app/seed_projects_helpers.py` → `backend/app/utils/seed_projects.py`、`backend/app/utils/seed_projects_helpers.py`(示例数据初始化脚本归并到 utils) -- 更新:`backend/app/database.py`、`backend/app/events.py`、`backend/app/events_models.py`、`backend/app/websocket.py` → `backend/app/core/database.py`、`backend/app/core/events.py`、`backend/app/core/events_models.py`、`backend/app/core/websocket.py`(核心基础设施归并到 core) -- 更新:`backend/app/main_db_guard.py`、`backend/app/main_feishu_outbox.py`、`backend/app/main_warmup.py`、`backend/app/main_model_probe.py` → `backend/app/core/startup/`(启动阶段职责归并) -- 更新:`backend/app/state.py`(无引用,按废弃文件移除) -- 新增:`backend/app/__init__.py`、`backend/app/deps.py`(根目录仅保留 `__init__.py / main.py / deps.py`) -- 更新:`backend/app/services/` 顶层兼容壳批量清理(如 `ai_service.py/llm.py/scheduler_center.py/signal_capture.py/...`),统一改为直接引用对应子包(`ai_service_core/llm_service/scheduler_center_core/signal_capture_service/...`) -- 更新:移除 `backend/app/services/alignment_orchestrator.py` 空文件(无引用) -- 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_aggregates/`、`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`、`backend/app/services/ai_pmo/ai_pmo_kernel_common_core/`(kernel world-model/common 拆分) -- 更新:`backend/app/services/ai_pmo_kernel_runtime.py`、`backend/app/services/ai_agent_kernel.py`、`backend/app/services/ai_pmo_domain_workflow.py` 及 `backend/app/services/ai_pmo_kernel_mixins_*.py` → `backend/app/services/ai_pmo/ai_pmo_kernel/`(AI PMO kernel 子包化,清理 services 根目录散落文件) -- 更新:`backend/app/services/ai_orchestration_run_service.py` → `backend/app/services/ai_orchestration_run/service.py`(编排运行账本服务子包化) -- 新增:`backend/app/services/ai_orchestration_run/ingress_worker.py`(AI PMO 入站消息异步消费、重试与最终回执发送) -- 更新:`backend/app/services/ai_pmo_reply_renderer.py`、`backend/app/services/ai_pmo_review_service.py`、`backend/app/services/ai_pmo_typed_executor.py`、`backend/app/services/ai_pmo_typed_executor_common.py`、`backend/app/services/ai_pmo_typed_executor_mixins_parse.py`、`backend/app/services/ai_pmo_workflow_runtime.py` → `backend/app/services/ai_pmo/ai_pmo_kernel/`(AI PMO 执行与回执运行时收敛到 kernel 子包) -- 更新:`backend/app/services/cost_optimization_service.py`、`backend/app/services/model_lifecycle_service.py` → `backend/app/services/model/model_capability/`(模型能力相关服务收敛) -- 更新:`backend/app/services/signal_strategic_analysis_service.py` → `backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 分析写回服务归并) -- 移除:`backend/app/services/llm_client.py`(全仓库无引用,按废弃文件清理) -- 更新:`backend/app/services/analyzer.py`、`backend/app/services/fetcher.py` → `backend/app/services/signal/signal_capture_service/impact_analyzer.py`、`backend/app/services/signal/signal_capture_service/external_fetcher.py`(信号接入分析/抓取能力归并) -- 更新:`backend/app/services/plane.py` → `backend/app/services/integrations/integration_core/plane_service.py`(Plane 兼容服务归并到 integration_core) -- 更新:`backend/app/services/lark.py` → `backend/app/services/integrations/integration_core/lark_service.py`(Lark 兼容服务归并到 integration_core) -- 更新:`backend/app/services/workload_alert_service.py` → `backend/app/services/workload/workload_alert_service.py`(负载告警服务归并到 workload 子包) -- 更新:`backend/app/services/task_persistence.py`(兼容壳移除,统一直接引用 `backend/app/services/task_persistence_core/`) -- 更新:`backend/app/services/project_member_service.py` → `backend/app/services/project_member/service.py`(项目成员分配服务子包化) -- 更新:`backend/app/services/recipe_service.py` → `backend/app/services/recipe/service.py`(食谱服务子包化) -- 更新:`backend/app/services/model_routing_service.py` → `backend/app/services/model/model_routing/service.py`(模型路由服务子包化) -- 更新:`backend/app/services/prompt_profile_service.py` → `backend/app/services/model/prompt_profile/service.py`(Prompt 画像服务子包化) -- 更新:`backend/app/services/feishu_outbox_service.py` → `backend/app/services/integrations/feishu_outbox/service.py`(飞书 outbox 服务子包化) -- 更新:`backend/app/services/wecom_integration.py` → `backend/app/services/integrations/wecom/service.py`(企业微信集成服务子包化) -- 更新:`backend/app/services/skill_score_service.py` → `backend/app/services/skill/skill_score_service.py`(技能评分服务归并到 skill 子包) -- 更新:`backend/app/services/auth.py`、`backend/app/services/audit_query_service.py`、`backend/app/services/notification_service.py`、`backend/app/services/operation_catalog_service.py`、`backend/app/services/operation_log_service.py` → `backend/app/services/*/service.py`(同名服务子包化,`services` 根目录不再保留 `.py` 文件) -- 更新:`backend/app/services/` 根目录 `.py` 已清零(全部收敛到子包),仅保留目录化服务结构 +- 当前后端重点入口优先看: + - `backend/app/api/` 下的 `*_api/` 子包与聚合 router + - `backend/app/services/ai_pmo/`(AI PMO 主链、Mounted Runtime、semantic context) + - `backend/app/services/adaptive_rag_service/`(只读问答主链) + - `backend/app/services/llm_service/`(显式 `routing_meta` 协议) + - `backend/app/services/integrations/feishu_integration_service/`(飞书入站与卡片回调) + - `backend/app/services/signal/`(信号接入、编排、planner、repair) +- 详细目录迁移、兼容壳清理与新增模块清单见库存文档。 ## 5. 前端结构(`frontend/`) ```text @@ -344,35 +68,8 @@ frontend/ ├── e2e/ # Playwright 真实浏览器 E2E 用例 ├── src/ │ ├── api/ # 接口封装 -│ ├── pages/ # 页面级组件 -│ │ ├── ai-config/ # AI Config 页面模块目录(types/constants/components/sections) -│ │ ├── ai-steward/ # AI Steward 页面模块目录(types/constants/components/sections) -│ │ ├── charter-detail/ # Charter Detail 页面模块目录 -│ │ ├── contract-sync-board/ # Contract Sync Board 页面模块目录 -│ │ ├── dashboard/ # Dashboard 页面模块目录 -│ │ ├── feishu-config/ # Feishu Config 页面模块目录(helpers/components) -│ │ ├── inbox/ # Inbox 页面模块目录(helpers/components) -│ │ ├── knowledge-graph-context-board/ # KG Context Board 页面模块目录 -│ │ ├── login/ # Login 页面模块目录 -│ │ ├── model-routing-config/ # Model Routing Config 页面模块目录 -│ │ ├── project-board/ # Project Board 页面模块目录(helpers/components) -│ │ ├── projects/ # Projects 页面模块目录(helpers/components) -│ │ ├── recipes/ # Recipes 页面模块目录(helpers/components) -│ │ ├── scheduler-center/ # Scheduler Center 页面模块目录 -│ │ ├── skill-tasks/ # Skill Tasks 页面模块目录 -│ │ ├── skills/ # Skills 页面模块目录 -│ │ ├── task-brief-center/ # Task Brief Center 页面模块目录 -│ │ ├── task-brief-runs/ # Task Brief Runs 页面模块目录 -│ │ ├── task-brief-template/ # Task Brief Template 页面模块目录 -│ │ ├── user-profile/ # User Profile 页面模块目录 -│ │ ├── user-skills/ # User Skills 页面模块目录 -│ │ ├── users/ # Users 页面模块目录(helpers/components) -│ │ ├── workload-detail/ # Workload Detail 页面模块目录 -│ │ └── workloads/ # Workloads 页面模块目录 -│ ├── components/ -│ │ ├── ui/ # 基础 UI 组件 -│ │ ├── inbox/ # 消息台 Feed 列表组件 -│ │ └── sstc/ # SSTC 工作流组件(已模块化拆分) +│ ├── pages/ # 页面级组件(已目录化) +│ ├── components/ # 通用组件与 SSTC/Inbox 共享组件 │ ├── hooks/ # 自定义 hooks │ ├── lib/ # 通用工具 │ ├── stores/ # 状态管理 @@ -382,90 +79,13 @@ frontend/ ├── playwright.config.ts └── vite.config.ts ``` - -- 新增:`frontend/src/components/inbox/InboxListItem.tsx`(消息台 Feed 行组件,支持移动端与行内展开) -- 新增:`frontend/src/components/inbox/InboxActionPanel.tsx`(消息台内联动作面板:缺少项目归属时的项目选择与确认关联) -- 新增:`frontend/src/lib/inboxTextMap.ts`(消息台用户文案映射与标题清洗) -- 新增:`frontend/src/lib/formatInboxTime.ts`(消息台相对时间与完整时间格式化) -- 新增:`frontend/src/components/ui/RoleBadge.tsx`(角色徽章组件,支持多角色与可选数量限制) -- 新增:`frontend/src/components/ui/SkillBadge.tsx`(技能徽章组件,默认显示 3 个并支持 +N) -- 新增:`frontend/src/api/taskBriefs.ts`(任务包中心 API 封装) -- 新增:`frontend/src/api/modelRouting.ts`(模型路由 API 封装) -- 新增:`frontend/src/api/knowledgeGraph.ts`(KG 能力汇总与 low/high 消费验证 API) -- 新增:`frontend/src/api/mountedAi.ts`(Mounted Runtime 控制台 API:sources/source-activations/tool-policies/topics/runs/run-detail/cutover-status/eval-suites/eval-suite-detail/training-ops/hard-cases/admin actions,以及 `startTrainingWave`) -- 更新:`frontend/src/api/aiSteward.ts`(移除 OpenClaw dispatch 封装,新增 LLM 引擎 CRUD / settings 封装) -- 更新:`frontend/src/api/aiSteward.ts`(新增 AI 管家 QA 封装:`/rag/ask`、QA sessions、feedback、会话改名;浮窗不再直接 `fetch`) -- 更新:`frontend/src/api/aiPmo.ts`(新增 `processText` 封装,供 AI 管家浮窗把“允许执行”显式交接到 AI PMO Kernel) -- 新增:`frontend/src/components/ai-steward/QAMarkdownContent.tsx`(AI 管家问答 markdown 渲染组件:统一列表/引用/代码块/长路径换行) -- 更新:`frontend/src/components/ai-steward/FloatingQAChat.tsx`(回答区改为 markdown 渲染;浮窗顾问动作升级为整组可交互 chips:`深入思考`、`给建议`、`先澄清`、`一起想`、`先劝退`、风险解释、`允许执行` 确认交接、`先别执行` 回填 `next_action`) -- 更新:`frontend/package.json`(新增 `react-markdown`、`remark-gfm`、`remark-breaks` 依赖,供 AI 管家问答渲染) -- 更新:`frontend/package.json`(新增真实飞书网页 E2E 脚本:`npm run e2e:feishu` / `npm run e2e:feishu:debug`) -- 新增:`frontend/playwright.config.ts`(Playwright 真实飞书网页自动化配置) -- 新增:`frontend/e2e/feishu-project-full-flow.spec.ts`(真实飞书网页 5 轮对话自动化:新需求 -> 三轮修改 -> 确认 -> 校验项目/信号源/宪章/任务/负责人) -- 更新:`frontend/src/pages/CharterDetailPage.tsx`、`ContractSyncBoardPage.tsx`、`DashboardPage.tsx`、`KnowledgeGraphContextBoardPage.tsx`、`LoginPage.tsx`、`ModelRoutingConfigPage.tsx`、`SchedulerCenterPage.tsx`、`SkillTasksPage.tsx`、`SkillsPage.tsx`、`TaskBriefCenterPage.tsx`、`TaskBriefRunsPage.tsx`、`TaskBriefTemplatePage.tsx`、`UserProfilePage.tsx`、`UserSkillsPage.tsx`、`WorkloadDetailPage.tsx`、`WorkloadsPage.tsx`(统一降级为轻量入口,转发到 `pages/*/` 子目录) -- 新增:`frontend/src/pages/charter-detail/CharterDetailPage.tsx`、`contract-sync-board/ContractSyncBoardPage.tsx`、`dashboard/DashboardPage.tsx`、`knowledge-graph-context-board/KnowledgeGraphContextBoardPage.tsx`、`login/LoginPage.tsx`、`model-routing-config/ModelRoutingConfigPage.tsx`(剩余核心页面目录化收敛) -- 新增:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`、`skill-tasks/SkillTasksPage.tsx`、`skills/SkillsPage.tsx`、`task-brief-center/TaskBriefCenterPage.tsx`、`task-brief-runs/TaskBriefRunsPage.tsx`、`task-brief-template/TaskBriefTemplatePage.tsx`(任务/调度/技能页面目录化收敛) -- 新增:`frontend/src/pages/scheduler-center/components/MountedAISourceStatusPanel.tsx`、`MountedAISourceActivationPanel.tsx`、`MountedAIToolGuardrailsPanel.tsx`、`MountedAITopicsPanel.tsx`、`MountedAIRunsPanel.tsx`、`MountedAIRunReplayPanel.tsx`、`MountedAIOrchestrationGraphPanel.tsx`、`MountedAIContextLayersPanel.tsx`、`MountedAISemanticPacketPanel.tsx`、`MountedAISemanticDecisionPanel.tsx`、`MountedAIShadowDiffPanel.tsx`、`MountedAITrainingOpsPanel.tsx`、`MountedAIEvalScoreboardPanel.tsx`、`MountedAIHardCasesPanel.tsx`、`MountedAIAcceptancePanel.tsx`(Mounted AI Console 来源状态、白名单 cutover、tool guardrails、topic workspace、run 列表、replay 详情、agent 编排图、context layers、semantic packet / decision / shadow diff、training ops、eval score board、hard cases 与验收门槛面板;其中 source activation / tool guardrails 已支持前台编辑,run replay 支持直接触发 `admin/replay-run`,training ops 已支持直接发起 mounted training wave,并展示 recent datasets / recent jobs / acceptance gate) -- 更新:`frontend/src/pages/scheduler-center/SchedulerCenterPage.tsx`(Scheduler Center 升级为 Mounted AI Console:接入 mounted runtime 查询与 admin actions,支持 source activation、tool policy、run replay、training wave 的前台操作,并支持通过 `mountedRunId` 查询参数直接定位 run;run detail 区新增 semantic packet / decision / gate / shadow diff 观测区) -- 新增:`frontend/src/pages/user-profile/UserProfilePage.tsx`、`user-skills/UserSkillsPage.tsx`、`workload-detail/WorkloadDetailPage.tsx`、`workloads/WorkloadsPage.tsx`(用户与负载页面目录化收敛) -- 更新:`frontend/src/pages/InboxPage.tsx`(降级为轻量入口,转发到 `pages/inbox/InboxPage`) -- 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper) -- 新增:`frontend/src/pages/inbox/components/InboxFiltersPanel.tsx`(Inbox 页面顶部筛选/搜索/tab 面板拆分) -- 新增:`frontend/src/pages/inbox/InboxPage.tsx`(Inbox 页面编排层,主文件聚焦状态与动作流转) -- 更新:`frontend/src/api/aiPmo.ts`(新增草稿决策 API:`decideDraftWorkItem`,并扩展工作项 draft 字段) -- 更新:`frontend/src/lib/inbox.ts`(支持 `draft_pending_confirm` 卡片映射与三动作按钮) -- 更新:`frontend/src/types/inbox.ts`(新增草稿动作类型 `revise_draft/cancel_draft`,并扩展 `mounted_ai_run` 收件项来源) -- 更新:`frontend/src/components/inbox/InboxListItem.tsx`(Inbox 详情面板兼容 mounted runtime run 来源,展示 mounted source/message/reply 上下文) -- 更新:`frontend/src/pages/inbox/InboxPage.tsx`(Inbox 接入 Mounted Runtime runs,把澄清、负责人建议、执行中/完成等 run 视图并入收件箱) -- 更新:`frontend/src/pages/RecipesPage.tsx`(降级为轻量入口,转发到 `pages/recipes/RecipesPage`) -- 新增:`frontend/src/pages/recipes/helpers.ts`(Recipes 页面默认表单与分类常量) -- 新增:`frontend/src/pages/recipes/components/CreateRecipeDialog.tsx`、`AnalyzeRecipeDialog.tsx`(Recipes 页面手动创建/AI 识别弹窗拆分) -- 新增:`frontend/src/pages/recipes/RecipesPage.tsx`(Recipes 页面编排层,主文件聚焦列表筛选与状态) -- 更新:`frontend/src/pages/FeishuConfigPage.tsx`(降级为轻量入口,转发到 `pages/feishu-config/FeishuConfigPage`) -- 新增:`frontend/src/pages/feishu-config/helpers.ts`(Feishu Config 表单与状态类型、默认配置常量) -- 新增:`frontend/src/pages/feishu-config/components/SetupGuideCard.tsx`(Feishu Config 配置指南卡片拆分) -- 新增:`frontend/src/pages/feishu-config/FeishuConfigPage.tsx`(Feishu Config 页面编排层,主文件聚焦状态与交互) -- 更新:`frontend/src/pages/AIConfigPage.tsx`(降级为轻量入口,转发到 `pages/ai-config/AIConfigPage`) -- 新增:`frontend/src/pages/ai-config/types.ts`、`frontend/src/pages/ai-config/constants.ts`(AI Config 类型与常量集中管理) -- 新增:`frontend/src/pages/ai-config/components/GovernanceToolsCard.tsx`、`StatusBadge.tsx`、`PriorityBadge.tsx`(AI Config 复用展示组件) -- 新增:`frontend/src/pages/ai-config/sections/OverviewTab.tsx`、`ConfigureTab.tsx`、`KnowledgeTab.tsx`、`AdvancedTab.tsx`(AI Config 分阶段 UI 拆分) -- 新增:`frontend/src/pages/ai-config/AIConfigPage.tsx`(AI Config 页面编排层,主文件仅保留状态与事件编排) -- 更新:`frontend/src/pages/AIStewardPage.tsx`(降级为轻量入口,转发到 `pages/ai-steward/AIStewardPage`) -- 新增:`frontend/src/pages/ai-steward/types.ts`、`frontend/src/pages/ai-steward/constants.ts`(AI Steward 类型与常量集中管理) -- 新增:`frontend/src/pages/ai-steward/components/FeatureOverviewCards.tsx`(AI Steward 首页功能卡片组件) -- 新增:`frontend/src/pages/ai-steward/components/BriefingPrintDocument.tsx`、`BriefingSectionCard.tsx`、`BriefingDetailDrawer.tsx`(AI Steward 简报中心打印导出模板、固定高度项目分组卡片与右侧详情抽屉) -- 新增:`frontend/src/pages/ai-steward/sections/OverviewTab.tsx`、`CreateProjectTab.tsx`、`SignalsTab.tsx`、`ConflictsTab.tsx`、`BlockersTab.tsx`、`BriefingTab.tsx`、`EngineTab.tsx`、`FeedbackCenterTab.tsx`(AI Steward 分阶段 UI 拆分) -- 新增:`backend/app/services/executive_briefing_service.py`、`backend/app/api/ai_steward_api/routes_executive_briefing.py`(老板晨报聚合服务与 manager-only 接口,配置持久化复用 `Integration(type='executive_briefing')`,快照复用 `PortfolioSnapshot(scope_type='executive_brief')`) -- 新增:`backend/app/services/tenant_briefing_service.py`、`backend/tests/test_daily_briefing_v2_api.py`(租户级简报统一内核与 `daily-briefing-v2` viewer scope 过滤回归) -- 新增:`backend/tests/test_executive_briefing_api.py`、`backend/tests/test_ai_pmo_cadence_scheduler.py`(老板晨报接口包络、配置落库、管理群文案与调度时间窗回归) -- 更新:`backend/app/services/ai_pmo/ai_pmo_cadence/service.py`、`mixin_builders.py`、`mixin_snapshots.py`、`backend/app/services/scheduler_center_core/ai_pmo_cadence.py`(新增 `executive_brief` cadence type,支持租户级晨报快照生成与管理群 outbox 推送) -- 更新:`backend/app/services/executive_briefing_service.py`(读取 `snapshot` 时若命中旧结构快照,会自动按当前代码重建新版 payload,避免简报中心默认打开仍停留在无详情/无精准跳转的旧结构) -- 更新:`frontend/src/api/aiSteward.ts`(新增 cognitive conflicts / blockers / `daily-briefing-v2` / executive briefing API 封装,AI Steward 页面不再直接 `fetch`) -- 更新:`frontend/src/pages/ai-steward/AIStewardPage.tsx`、`components/FeatureOverviewCards.tsx`、`sections/OverviewTab.tsx`、`sections/BriefingTab.tsx`(晨报中心与每日简报共享 `snapshot/live` 模式;前端只保留一个“简报中心”入口,支持固定高度卡片、项目分组预览、右侧详情抽屉、精准跳转与完整详细导出) -- 更新:`frontend/src/pages/ai-steward/sections/CreateProjectTab.tsx`(零配置创建结果区内联展示 `signal_sources` 分组,复用现有 AI Steward 页面,不新增独立路由) -- 新增:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`、`FeedbackDetail.tsx`、`ReasonPicker.tsx`、`MetricsPanel.tsx`、`TrendPanel.tsx`(反馈中心三栏工作台、指标页与趋势页组件;趋势页支持 `7/30 天` 与 `all/task/qa/signal` 局部筛选,并提供洞察卡、原因筛选与一键钻取) -- 新增:`frontend/src/pages/ai-steward/components/feedback-center/TrendPanel.tsx` 的趋势决策版布局(Scope / KPI / Insights / Drilldown / Evidence) -- 更新:`frontend/src/pages/ai-steward/sections/FeedbackCenterTab.tsx`(趋势页钻取联动列表筛选,支持 reason_code / source_type 的局部跳转) -- 更新:`frontend/src/pages/ai-steward/components/feedback-center/FeedbackList.tsx`(列表增加原因筛选器,支持趋势页高频原因钻取) -- 新增:`frontend/src/api/feedbackCenter.ts`(统一反馈中心 API 封装) -- 新增:`frontend/src/pages/ai-steward/AIStewardPage.tsx`(AI Steward 页面编排层,主文件聚焦状态与事件) -- 更新:`frontend/src/api/aiSteward.ts`、`frontend/src/api/base.ts`(新增 `createProjectFromText`,并兼容 `{code,message,data}` 响应包络) -- 新增:`frontend/src/lib/api.ts`、`apiError.ts`、`display.ts`、`datetime.ts`、`inbox.ts`、`inboxTextMap.ts`、`formatInboxTime.ts`、`feishuEventMode.ts`、`sessionGuard.ts`、`rbac.ts`、`title.ts`、`utils.ts`、`zhCN.ts`(前端兼容工具与消息台/AI Steward 展示格式化层) -- 更新:`frontend/src/pages/ProjectBoardPage.tsx`(降级为轻量入口,转发到 `pages/project-board/ProjectBoardPage`) -- 新增:`frontend/src/pages/project-board/helpers.ts`(Project Board 信号筛选状态与 PMO 工作项文本归一 helper) -- 更新:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(支持从简报中心 URL 参数恢复 `stage` 与 `signal_*` 筛选条件) -- 新增:`frontend/src/pages/project-board/components/CreateCharterDialog.tsx`、`ProjectSettingsDialog.tsx`(Project Board 大体量弹窗组件拆分) -- 新增:`frontend/src/pages/project-board/ProjectBoardPage.tsx`(Project Board 页面编排层) -- 更新:`frontend/src/pages/ProjectsPage.tsx`(降级为轻量入口,转发到 `pages/projects/ProjectsPage`) -- 新增:`frontend/src/pages/projects/helpers.ts`(Projects 页面链路阶段、状态样式、权限与错误解析 helper) -- 新增:`frontend/src/pages/projects/components/ProjectQueryDialog.tsx`、`DeleteProjectDialog.tsx`(Projects 页面查询/删除弹窗拆分) -- 新增:`frontend/src/pages/projects/ProjectsPage.tsx`(Projects 页面编排层,主文件聚焦状态与事件) -- 更新:`frontend/src/pages/UsersPage.tsx`(降级为轻量入口,转发到 `pages/users/UsersPage`) -- 新增:`frontend/src/pages/users/helpers.ts`(Users 页面角色/密码/CSV 预览 helper 聚合) -- 新增:`frontend/src/pages/users/components/DeleteUserDialog.tsx`、`ResetPasswordDialog.tsx`、`ImportUsersDialog.tsx`、`BulkAssignDialog.tsx`(Users 页面大体量弹窗组件拆分) -- 新增:`frontend/src/pages/users/UsersPage.tsx`(Users 页面编排层,主文件聚焦状态与事件) -- 更新:`frontend/src/pages/AIStewardPage.tsx`(移除 OpenClaw 指挥调度入口,新增本地 LLM 开关与严格失败边界说明) -- 移除:`frontend/src/components/ai-steward/OpenClawCommanderPanel.tsx`(OpenClaw 指挥调度面板) +- 当前前端重点入口优先看: + - `frontend/src/api/`(统一 API 封装) + - `frontend/src/pages/inbox/`(消息台) + - `frontend/src/pages/ai-steward/`(AI 管家) + - `frontend/src/pages/scheduler-center/`(Mounted AI Console) + - `frontend/src/components/sstc/`(SSTC 工作流组件) +- 页面目录化、Mounted AI Console、Inbox、AI Steward 与 Recipes/Projects/Users 等模块化拆分的详细库存见库存文档。 ### 5.1 `frontend/src/components/sstc/` 目录说明(模块化拆分后) - `SSTCWorkspace.tsx`:SSTC 工作区主容器(组装各阶段组件) @@ -515,11 +135,7 @@ frontend/ - 启动入口收敛:仅保留 2 个用户入口 - 正常启动器:`./start.sh` - 公网穿透启动器:`./devops/tunnel/start.sh` -- 已移除:`scripts/start_legacy_local.sh`、`scripts/stop_legacy_local.sh`(旧本地启动链路) -- 新增:`scripts/start_model_probe.sh`(独立模型连通性检测启动器,输出中文日志并落盘) -- 新增:`scripts/model_probe.py`(模型检测核心逻辑,读取 `.env` 并探测 low/high 路由模型) -- 新增:`scripts/gen_task_brief.sh`(按 git diff 自动生成增量任务包文档) -- 新增:`scripts/check_contract_sync.sh`(后端契约变更时强制校验前端类型与架构文档同步) +- 运行脚本、模型探针、契约校验与 E2E 工具清单已迁到 [`docs/architecture/ARCHITECTURE_INVENTORY.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/ARCHITECTURE_INVENTORY.md)。 ### 前端 ```bash @@ -538,141 +154,29 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 7. 团队维护建议(简版) - 规则变更:只改 `AGENTS.md` -- 结构变更:同步改 `CLAUDE.md` +- 结构变更:同步改 `CLAUDE.md` + `docs/architecture/ARCHITECTURE_INVENTORY.md` +- 运行边界 / 已知 runtime 假设变更:同步改 `AGENTS.md` + `docs/architecture/RUNTIME_GUARDRAILS.md` - API/契约变更:后端 `schemas` + 前端 `types` 同步更新 ## 8. 服务边界约束(禁止越界) -- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE` -- 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行 -- 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite -- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 -- `SignalSourceRegistry` 仅作为信号源接入/幂等内部表;用户可见的项目“信号源/信号”统一落到正式 `Signal`,SSTC 信号阶段只展示 `Signal`,禁止新增独立 registry 展示页面或第二套用户口径 -- `create_project_from_natural_language` 支持透传 `requested_member_user_ids`;AI PMO 草稿确认时必须把推荐负责人并入项目创建事务,确保项目、主信号、信号源、成员关系一次性落库,禁止确认后再事后补成员 -- AI Steward `project-from-text` 归一化阶段必须优先走人员能力图谱:先解析 `required_skill_tag_ids`,再调用 `recommend_personnel` 生成 `assignee_id/ai_recommended_assignees`,禁止绕过能力图谱直接拍脑袋分配 -- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` -- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 -- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` -- 老板晨报链路只允许 `BOSS/ADMIN` 访问;`GET /ai-steward/executive-briefing` 的 `snapshot/live` 语义必须分离,`live` 禁止覆盖 `PortfolioSnapshot(scope_type='executive_brief')` -- `daily-briefing-v2` 必须复用同一份租户级简报内核:先算 tenant payload,再按 `viewer_scope=tenant_full|personal` 过滤;禁止老板晨报与每日简报各自维护独立统计口径 -- 简报 item 的卡片摘要、抽屉详情、导出报告与跳转目标必须复用同一份结构化字段(`id/detail_lines/meta/target.params`);禁止页面、导出、跳转各自拼接不同文案导致事实不一致 -- 简报中心只允许展示具备真实业务定位的对象:项目信号必须绑定真实 `project_id`,信号源必须有真实来源名/标识,人员负载必须绑定真实 `user_id`;禁止把未归属信号用“待处理事项”等兜底标题伪装成老板待办 -- 老板晨报调度配置统一落在 `Integration(type='executive_briefing')`,禁止新增独立晨报配置表;管理群自动推送必须复用现有 `feishu_reply_outbox` -- 晨报/日报导出首版只允许前端“当前所见”打印导出(浏览器 `print` / PDF),禁止新增后端二进制 PDF 生成服务 -- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务 -- 所有写接口必须落审计日志(`operation_logs`):至少包含 `trace_id/interface_path/caller` -- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列 -- low/high 双层路由(`classify_intent_ai_pmo_kernel`、`classify_intent_complex`)与 `openclaw_command_dispatch` 必须遵循 DDL 接口化约束:禁止直连 SQL DDL,命中时强制输出 `ddl_api_required` -- low/high 上下文注入仅允许通过 `ModelContextBundleService` 统一组包:固定三段 `knowledge_graph/task_template/contract_sync`,禁止在业务服务散落拼接 prompt 上下文 -- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态 -- 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错,禁止用固定兜底文案冒充成功结果 -- 禁止模型兜底回复文案:模型失败、超时、空响应、结构化解析失败时,必须返回真实错误原因;禁止输出“系统处理中/请稍后重试”等固定文案冒充成功结果 -- 核心业务内容零兜底:宪章、信号分析、任务拆解、项目建议、范围边界、风险判断、资源计划、对齐结论等用户可见核心业务内容,禁止用默认文案、模板句子、占位字段或 stub 数据冒充真实生成结果;AI 失败、证据不足或结构化失败时,必须显式失败或标记为“待确认/证据不足/生成失败” -- 核心业务完成态不得仅按字段非空判定;必须能区分“真实生成 / 人工填写 / 模板占位 / 生成失败”,技术层 fallback 仅允许用于重试、缓存、解析修复、传输兼容等基础设施环节,禁止上浮成用户可见业务结论 -- SSTC 宪章 AI 预填充必须输出并持久化 `field_status/field_evidence/readiness/derived_artifacts`;`ready_for_confirm=true` 的前提是必填字段文本存在、字段状态为 `valid` 且具备字段级证据,禁止仅凭字段非空、模板句子或 `module_confirmations` 进入完成态 -- SSTC 宪章总确认(`alignment/confirm`)必须先校验 `readiness`;存在 `generation_failed/evidence_insufficient/generic_text/conflict_blocked` 任一 blocker 时禁止确认并保持任务阶段锁定 -- SSTC 发布验证清单必须优先消费 `derived_artifacts.verification_metrics`,其次才允许读取宪章 KPI;禁止再回退 3 条通用默认验收项 -- AdaptiveRAG `adaptive_rag_answer_normal` 空响应必须显式抛错并交由 high failover 处理;禁止在生成层直接回落为“补充问题细节后重试”之类的兜底回复 -- AdaptiveRAG/OpenClaw 问答主链若遇到 `reasoning_content` 非空但 `content` 为空,必须先执行一次“请直接输出答案”定向重试;若 `finish_reason=length|max_tokens` 且仍为空,还必须至少执行一次扩容重试,禁止第一次空正文后直接失败 -- 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏 -- `integrations(type='model_routing')` 的 runtime tier override 不得把 `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` 这类用户可见 QA 主函数降级到 `low`;这两个函数必须优先服从 OpenClaw 的 high reasoning 规则,防止网页/飞书问答被后台 override 锁死在低档 -- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG -- 飞书中的“想聊需求 / 先别创建 / 先收敛第一版”类探索问题必须先由 `workflow_semantic_planner` 标记为 `message_intent=requirement_plan`,再统一落到 `PROMPT_AI_PMO_REQUIREMENT_PLAN_QA` 的需求收敛场景;该场景禁止落回“五大口径”评审或直接自动创建 -- `requirement_plan` 现在必须是多轮状态机,而不是单轮问一句:`ExecuteQueryProjectMixin` 要把同线程/同群同人的需求收敛状态持久化到 `mounted_topic_workspaces + mounted_draft_workspaces`,每轮只推进 1 个关键问题,信息足够后直接整理成 Feishu 友好的需求草稿 -- `requirement_plan` 不允许再把“同群同人”直接等同于“同一个需求 topic”:Mounted Runtime 必须用 `conversation_scope_key` 归并会话范围,再用显式 `topic_key` 区分具体需求;用户说“另一个需求 / 换个需求 / 不是这个需求 / 聊个新需求”时必须新开 topic,并把旧 topic 置为 `suspended` -- `requirement_plan` 的 topic resolver 不能只靠显式关键词:当同会话存在 active plan workspace,且新消息是新的需求起手式、与当前 topic card/draft 的重叠度明显偏低时,必须按 `candidate_new_topic` 优先切新 topic;尤其旧 topic 已处于 `awaiting_execute_confirm / plan_ready` 时,不得继续把“关于 openclaw 批量部署”这类低重叠消息续写到“A 股打分工具”草稿上 -- `workflow_semantic_planner` 必须把“先按技术指标来 / 主要给投顾看 / 先做成飞书日报 / 每天收盘后更新”这类短槽位回答继续识别为 `message_intent=requirement_plan`,禁止第二轮开始掉回普通 query 或高阶 RAG -- `workflow_semantic_planner` 也必须把通用需求收敛里的短槽位回答继续识别为 `message_intent=requirement_plan`,例如“先给内部 PM 和研发负责人用 / 飞书总结就行 / 每次会后半小时内整理出来”;这类碎片化回复不得因为没有新 `semantic_bundle` 就退回普通 query -- `ExecuteQueryProjectMixin` 对 `message_intent=requirement_plan` 的查询必须优先走 mounted runtime 直答快路径:默认只输出自然短话术,每轮最多只保留 1 个关键问题;当 `scoring_basis/target_user/output_form/cadence/first_scope` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问 -- `requirement_plan` 的 Mounted Runtime 状态必须显式落库到 `MountedTopicWorkspace.plan_status`:至少区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`,禁止只靠 `draft.state` 或 `metadata_json` 猜当前阶段 -- 新的 OpenCow 风格语义上下文链必须先组装 `SemanticContextPacket`,再由模型返回 `SemanticDecision`,最后由 gatekeeper 决定 `plan_status`;当前 shadow 结果已进入 Mounted Runtime 观测,同时 intent/query-scene 主链已切到 direct metadata,不得再把 `semantic_bundle` 当作主链真源或绕过统一执行门禁 -- `requirement_plan` 的 A/B/C 是用户可见需求收敛骨架,可以固定结构;但 A/B/C 的业务选项、AI 推荐项、推荐理由和代价必须参照 OpenCow 的 `上下文包 -> 模型结构化决策 -> gatekeeper -> 渲染` 模式生成,禁止在代码里硬编码业务候选项或旧场景模板。用户回复 `A/B/C` 时必须优先回写上一轮模型生成的选项映射,不能再落回静态选项表。 -- 正在收敛中的 `requirement_plan` 必须支持普通人碎片化回复:像“技术指标吧 / 给投顾看 / 飞书日报就行 / 每天收盘后发”这种单字段短句,即使缺少新的 `semantic_bundle`,只要同线程/同群同人存在 active plan workspace,也必须自动续接当前需求收敛,不得回落到通用 query 或高阶 RAG -- `requirement_plan` 的追问节奏必须像自然聊天:上一轮只确认新增信息、下一轮只问一个缺口;初始描述中的“每日/自动”不能过早推断成已确认的更新频率,避免少问关键问题或提前出草稿 -- `requirement_plan` 当前用户可见主链只保留 IT 项目收敛口径;历史 `generic/scoring` 数据只允许作为兼容读取或旧测试场景,不得再作为新会话的用户可见模板扩散。普通闲聊、query.answer、非 requirement followup 必须继续保持自由文案,禁止全局套用 A/B/C 选项骨架 -- 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,AI PMO 必须先清掉当前会话里继承的旧 `current_project_id`,避免后续短回复继续被旧项目上下文污染 -- 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须直接提示“当前没有进行中的收敛轮次/草稿”,禁止再回落到 `query.answer` 检索图谱并生成旧项目结论 -- 飞书私聊中的“答错了/不对/调用高级模型/用更强模型/重新回答”必须被识别为 `correction_escalation` 指令:优先复用上一轮上下文主题并强制深度回答,禁止把这类话术当成新的系统配置查询 -- `POST /rag/ask` 在 `rag_request_context` 或统一语义规划层命中 `business_review/correction_escalation` 时,必须强制留在 `query.read_only` 主链并进入对应 QA Prompt;禁止因为 LLM 意图分类把“分析/MVP/重答”误送到 `signal.create -> draft_pending_confirm` -- Feishu 新消息即使被低阶 LLM 先判成 `project.create`,只要统一语义规划层从原句识别出 `requirement_plan/business_review/correction_escalation`,就必须在 ingress `_coerce_intent_for_query_scene` 阶段强制拉回 `query.answer`;Mounted Runtime 记录 `mode_after/agent` 时必须优先读取顶层 direct metadata,`semantic_bundle.diagnostics` 只允许作为兼容回退,禁止因顶层 `route_metadata` 缺字段把真实 `plan` 记成 `chat` -- 飞书入口的高精度同步判定不能只看 `primary_workflow`;只要 `WorkflowSemanticPlanner` 的 `diagnostics.message_intent=requirement_plan`,就必须优先返回 `semantic:requirement_plan` 并直接同步进入 AI PMO kernel,转发 `semantic_bundle`,禁止再先入异步队列发送 `已收到,正在处理` ACK -- “高级模型是否启用/模型路由是否启用/当前是否高配”这类运行态问题,只有命中运行态配置、路由快照、引擎配置或真实调用记录时才允许下结论;若证据只有页面目录、代码图谱或功能清单,回复必须明确为“无法仅凭静态资料确认” -- 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文 -- mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`,禁止把需求段落并入问答段落 -- 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot/glm_semantic 快路径,统一落到场景化 Prompt 生成,避免旧确定性清单回复覆盖场景格式 -- 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启;未开启时运行期与连通测试都不可用 -- AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿 -- Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要 -- AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径 -- AI PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库;`project.create` 的 Feishu 文本入口先转待确认卡片,点击后才允许执行 -- 草稿确认只认显式 `reply_to_message_id` + 明确草稿动作(`确认/修改/取消`)或显式 `work_item_id`;草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理 -- 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链;禁止新增或恢复 `queue_pending_confirm`、`queue_item_id`、`_maybe_handle_queue_followup` 这类旧 `feishu_items` 队列交互入口 -- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账 -- AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定 -- PMFlow 业务语义治理:会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其他目录仅允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析 -- 用户可见业务动作拍板必须优先走 `SemanticContextPacket -> SemanticDecision -> gatekeeper` 主链;协议级锚点(`reply_to_message_id/button/work_item_id`)、租户权限、幂等等确定性规则可以保留,但展示反馈、草稿确认/修改/取消、followup 动作归类、`query vs execute` 不能再由 `looks_like_*` 或关键词直接最终拍板 -- 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,主修法必须优先落在 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组包、`SemanticDecision` 结构化判断与 gatekeeper 放行条件;禁止先用“补 prompt 反例 / 扩关键词 / 调语义文案”顶上去。Prompt 只负责结构、口径、禁止事项,不替代主链裁决 -- `scripts/semantic_lint.py` + `scripts/semantic_lint_baseline.txt` 为语义治理守门脚本;CI 会阻止新的散落语义进入非 allowlist 目录,baseline 仅记录已知兼容残留 -- 只读问答链路的 prompt 读取优先级固定为:`prompt registry manifest 校验 -> active prompt release -> 代码 seed prompt fallback`;业务服务禁止直接绕过 `PromptReleaseService` 拼接已纳管的 read-only QA prompt -- 回复质量迭代与训练管线的原始反馈入口统一复用 `feedback_center_records` / `qa_records` / `ai_call_logs`;新增 `quality_assets/training_datasets` 只能作为派生层,禁止再造平行原始数据主表 -- 训练/微调 artifact 只能先进入 `trained_model_artifacts -> trained_model_deployments` 内部候选链路,再绑定到 `low/high` 逻辑槽位;禁止在产品/API/日志直接暴露训练后真实底模名 -- training deployment 的 `accepted` 决策默认受 acceptance gate 约束:若已存在 `teacher_eval_frozen_set` 且 frozen eval 未完成、低于 90,或严重 blocker 未清零,则禁止接受候选 deployment +- 当前必须立即遵守的摘要规则以 `AGENTS.md` 第 8 节为准。 +- 长运行边界、跨链路守门细节、失败语义和已知 runtime 假设已迁到 [`docs/architecture/RUNTIME_GUARDRAILS.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/RUNTIME_GUARDRAILS.md)。 +- 当前最重要的摘要只保留: + - 写操作必须经 Alembic / 受控执行器 / outbox / 审计日志,禁止业务路径直写或同步直发。 + - 用户可见核心业务内容禁止 fallback、stub、模板句冒充成功;失败必须显式失败或标记待确认。 + - 运行时业务动作归因统一走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。 + - `backend/app` 运行时代码禁止恢复散落的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 业务拍板逻辑。 + - `llm_service` 只认显式 `routing_meta` 协议,不再靠函数名或 hint 推断 tier / contract / fallback。 + - AI Steward、Requirement Plan、Signal Source、AdaptiveRAG 等核心链路的详细 guardrails 统一以 `RUNTIME_GUARDRAILS.md` 为准。 ## 9. 新增代码的参考模式 -- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 -- 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py`(`POST /ai-steward/project-from-text` 统一 `{code,message,data}`,返回 `signal_sources`) -- 模型路由配置接口:参考 `backend/app/api/model_routing.py`(`GET/PUT /model-routing/functions/{function_name}`,仅管理员可写并写入 `operation_logs`) -- `model-routing` 只暴露逻辑层字段:`function_name/tier_alias/source/rule_name/rule_priority/last_execution`;`PUT` 仅接受 `tier_alias=low|high` -- 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py`(`POST /ai-steward/feedback-center/items/{id}/promote`、`POST /ai-steward/reply-iteration/backfill-mounted-assets`、`/reply-iteration/judge-runs/candidates/prompt-releases/trials`) -- 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py`(`GET/POST /ai-steward/training/datasets/jobs`、`POST /training/jobs/{id}/run`、`GET /training/jobs/{id}/evaluations`、`POST /training/artifacts/{id}/promote`、`POST /training/deployments/{id}/decision`、`POST /training/mounted-wave`;mounted wave 会自动生成 `teacher_dev_set / teacher_eval_frozen_set / sft / preference` 并启动并执行 low/high 训练波次) -- Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py`(`GET /ai-steward/mounted-ai/sources|source-activations|tool-policies|topics|runs|runs/{id}|cutover-status|eval-suites|eval-suites/{id}|training-ops|hard-cases`,以及 `POST /ai-steward/mounted-ai/admin/source-activation|tool-policy|replay-run`,统一 `{code,message,data}` 包络);其中 `runs/{id}` 必须返回 `topic/draft/semantic_packet_summary/semantic_decision/semantic_gate_result/semantic_diff`,`cutover-status` 必须返回模块级旧脑退役视图:`legacy_cutover_state / retired_item_count / active_item_count / legacy_cutover_items[]` -- 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`) -- KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`、`POST /ai-steward/knowledge-graph/sync/work-items`;旧 `POST /ai-steward/knowledge-graph/sync/feishu` 仅保留兼容别名) -- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重、`raw_context` 持久化,并为每条 registry 同步绑定/物化正式 `Signal`) -- 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) -- 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`) -- LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关) -- 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检 -- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) -- 老板晨报接口:参考 `backend/app/api/ai_steward_api/routes_executive_briefing.py`(`GET /ai-steward/executive-briefing?mode=snapshot|live`、`GET/PUT /ai-steward/executive-briefing/config`,仅 `BOSS/ADMIN` 可访问) -- 每日简报 v2 接口:参考 `backend/app/api/ai_steward_api/routes_brief_blockers.py`(`GET /ai-steward/daily-briefing-v2?mode=snapshot|live`,普通成员返回个人版,`BOSS/ADMIN` 返回全租户版) -- 模型配置唯一来源:参考 `backend/app/services/model/model_config.py`(统一读取 `backend/.openclaw/pmflow-routing-rules.json` 内的 `provider_defaults/tier_defaults/rules`) -- 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断 -- 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求 -- AI Steward 指挥规划接口已移除:`POST /ai-steward/command/dispatch` -- 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交) -- 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member/service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects` -- SSTC 总览接口(`GET /projects/{project_id}/sstc/overview`):`workload_health` 返回成员明细 `members[]`(姓名/角色/负载比例/状态)用于头部卡片展示 -- AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome` -- AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞 -- AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口 -- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致 -- AI PMO 路由包(`backend/app/api/ai_pmo_api/__init__.py`)只负责聚合与 tags,`/ai-pmo` 前缀只能由子模块共享 router 挂一次,禁止包级与模块级重复叠加前缀导致 `/ai-pmo/ai-pmo/...` 路径 -- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part/consultant_mode_preference`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应 -- RAG 空回答保护:`POST /rag/ask` 若 `answer` 与 `reply_text` 同时为空,必须返回失败包络,禁止再用“当前暂无可用回答。”之类兜底文案冒充成功结果 -- RAG Grounding Runtime 合同补充:`POST /rag/ask` 的成功返回统一透传 `answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`;`AdaptiveRAG` 内部允许通过兼容层把异构 `sources` 映射成稳定 citation 结构,但业务语义判断仍只允许放在 `workflow_semantic_planner` -- RAG / AI 管家顾问合同:`POST /rag/ask` 返回体会透传 `consultant_dispatch={domain,mode,risk_level,missing_fields,allow_execute,next_action}`;该合同由 `backend/app/services/ai_pmo/consultant_dispatch.py` 统一生成,当前仅供 RAG / AI Steward 顾问式问答透传,已不再驱动 Feishu mention 回复或 AI PMO ingress capture 主链 -- RAG 顾问模式偏好约束:`consultant_mode_preference` 只允许取 `clarify/advise/challenge/brainstorm/execute`,且仅影响回答风格与 prompt wrapper;它不能替代 `workflow_semantic_planner` 去决定 `query vs execute` -- RAG / AI Steward 路由包(`backend/app/api/rag_api/__init__.py`、`backend/app/api/ai_steward_api/__init__.py`)不再重复挂包级 prefix;路由 canonical 由 `shared.py` 内的共享 router 统一承担,避免运行中出现 `/rag/rag/*`、`/ai-steward/ai-steward/*` -- AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影) -- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染 -- AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间 -- AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块” -- AI PMO 消息台历史回填补充:当 `ingest_events.payload` 闭环字段缺失或低质量时,优先回放 `ai_processing_logs`(同 `feishu_message_id`)并补充 `qa_records` 最近问答,避免 Redis 会话过期后仅剩单句摘要 -- AI PMO 消息台列表接口补充:过滤短口水话 `query.read_only` 项(如“怎么样了/在吗”),保留后端记录但不下发前端收件流 -- AI PMO `signal_source.onboard` 入库规则:优先使用闭环摘要写入 `signals.summary`,闭环对话写入 `signals.content`,并在 `signals.strategic_analysis.source_context.implementation_modules` 持久化实现模块 -- 通用任务拆解接口(`POST /tasks/breakdown`)统一返回结构化任务业务对象,支持 `project_id/charter_id` 上下文与 Plane 同步 -- SSTC 信号列表接口(`GET /projects/{project_id}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本 -- SSTC 宪章详情接口(`GET /projects/{project_id}/charters/{charter_id}`)`alignment_records[]` 补充 `user_name/confirmed_at`,用于前端展示“对齐成员 + 对齐时间” -- SSTC 宪章 AI 预填充接口(`POST /projects/{project_id}/charters/{charter_id}/ai-prefill`)固定写回 `modules/field_status/field_evidence/readiness/derived_artifacts`,AI 失败或字段缺证据时不得再补模板文案 -- SSTC AI 拆解接口(`POST /projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown`)执行任务标题去重(同次 + 历史任务),返回 `skipped_duplicates` / `auto_assigned_count`,并落 `ai_breakdown_payload/manual_overrides` -- SSTC 发布验证生成接口(`POST /projects/{project_id}/releases/{release_node_id}/verification/generate`)只允许从 `derived_artifacts.verification_metrics` 或宪章 KPI 生成校验项;两者都为空时返回 `409` -- SSTC 负责人推荐接口(`POST /projects/{project_id}/tasks/{task_id}/recommend-assignee`)直接消费 `required_skill_tag_ids` 语义,不再把技能 ID 当字符串 -- SSTC 任务更新接口(`PATCH /projects/{project_id}/tasks/{task_id}`)会把人工修改写入 `manual_overrides`,供后续 AI 重跑识别覆盖层 -- SSTC 发布创建接口(`POST /projects/{project_id}/releases`)新增已关联任务拦截:若 `task_ids` 命中历史发布计划已绑定任务,返回 `409` + `detail.task_ids` -- 信号源编排补充:`SignalSourceOrchestrationService` 已停止运行时调用旧 command-first 执行链,统一改为“GLM 分析后直接调用服务写入”,编排结果字段对外收敛为 `execution` -- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` -- 新增数据库表:参考 `alembic/versions/` 最新 migration -- 新增飞书事件处理:参考 `backend/app/services/feishu_integration.py` +- 详细的接口、页面与服务参考入口已迁到 [`docs/architecture/CODE_REFERENCE_INDEX.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/CODE_REFERENCE_INDEX.md)。 +- 当前只保留高价值摘要: + - 新增后端接口:优先参考 `backend/app/api/sstc_api/` 的结构 + - 新增前端 API 调用:优先参考 `frontend/src/api/sstc.ts` + - 新增数据库表:参考 `alembic/versions/` 最新 migration + - 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/` + - 更细的 AI Steward / RAG / AI PMO / Signal / SSTC / 前端页面入口索引统一以 `CODE_REFERENCE_INDEX.md` 为准 ## 10. 幂等与事务约束 - 所有入站事件必须经过 `ingest_events` 幂等去重 @@ -681,87 +185,13 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 跨表写操作必须在同一个数据库事务内完成 ## 11. 已知 BOUNDARY(不要幻构行为) -- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 -- 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话;默认读取 `FEISHU_USER_DATA_DIR=~/Library/Application Support/Google/Chrome` + `FEISHU_PROFILE_DIRECTORY=Default`,若本机 Chrome 正在占用该 profile,请改用独立 profile 或先关闭 Chrome -- 真实飞书网页 E2E 只负责浏览器发消息与页面诊断,结果校验统一走数据库探针;validation runner 现会优先从 `frontend/playwright-report/feishu-web/index.html` 恢复上次成功运行的 `FEISHU_CHAT_URL/FEISHU_CHAT_NAME/userDataDir`,其次回退到 acceptance profile -- 显式命名的新项目请求(例如“这个需求先叫 FeishuWebFlow-*”)不得仅因正文出现历史项目标题就自动绑定已有 `project_id`;若确需复用已有项目,必须由显式 `project_id` / 项目指代或后续确认链路决定 -- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`,`SignalSourceRegistry` 继续只承担内部持久化与幂等,不单独上屏 -- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标 -- 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送 -- `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送;导出仅复用浏览器打印能力 -- 若当天 `executive_brief` 快照仍是旧结构(缺少 `id/detail_lines/meta` 等字段),读取 `snapshot` 时会自动按当前事实重建新版 payload;因此默认快照优先保证可交互与可导出,而不是强行返回半旧版结构 -- 未匹配项目的孤立 signal 不再进入简报中心卡片;这类数据仅保留在信号工作台中处理,避免老板视图出现无项目、无负责人、无真实定位的伪待办 -- 简报中心首页卡片固定高度并允许内部滚动;完整明细统一放在右侧详情抽屉与打印导出中,移动端退化为全屏 Dialog,不新增第二套详情页 -- 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖 -- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*` -- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息 -- LLM 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则(`backend/.openclaw/pmflow-routing-rules.json`);请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准 -- 路由配置唯一真源固定为 `backend/.openclaw/pmflow-routing-rules.json`;根目录 `.openclaw` 仅允许不存在,禁止再挂载 `/app/.openclaw` -- LLM 非结构化响应仅在非用户可见链路且显式开启 `LLM_REASONING_CONTENT_FALLBACK` 时,才允许从 `reasoning_content` 回退;`answer/reply/qa` 链路一律禁用该回退 -- OpenClaw 高阶问答链默认可能把客户端超时抬到 `OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS + 5s`;飞书真实追问若命中 high reasoning,排查“长时间无回复”时必须同时看 `空content定向重试`、`空content扩容重试` 和最终 `capture_done/outbox_written` -- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) -- 写操作后主动 invalidate,动态层不缓存 -- AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路 -- `semantic_context` 新链当前状态:ingress shadow 观测已落库,intent/query-scene/render 主链已切到 direct metadata,旧 `semantic_bundle/workflow_semantic_planner` 仍仅在部分 requirement-plan executor / followup 兼容路径中生效;后续删旧规则时应继续以 Mounted Runtime 观测页核对 shadow diff -- IT 五轮 requirement plan 现以 `MountedDraftWorkspace.active_work_item_id + latest_*_message_id` 锚定当前 active draft;历史未绑定这些字段的旧草稿不会自动认领,需先重新生成当前草稿后再确认/修改 -- AI PMO / Feishu 执行门已取消置信度阈值拦截与高风险人工复核:`confidence` 仅保留在 `decision_trace` / processing log 中作观测,不再作为自动执行的 reject 条件;当前门禁仅保留 `force_manual` 与 `correction_action` -- 模型默认值与 provider/tier 映射以 `backend/.openclaw/pmflow-routing-rules.json` 为唯一配置源;`.env` / `.env.example` 仅保留 API key 与运行开关,`OPENCLAW_ROUTING_RULES_PATH` 只用于定位这份文件 -- 调用层与路由页面只使用 `low/high` 两层语义;`safety` 已废弃并从配置校验、运行时解析、前端选项中移除 -- AI Steward 项目创建与 LLM 主链不再在业务层手写 `*_glm47_fallback -> GLM-5` 跨函数升级;项目分析函数名与高低层模型选择统一由 `backend/.openclaw/pmflow-routing-rules.json` 决定 -- Signal Planner 函数路由支持 `integrations(type='model_routing')` 的 `tier_alias` 覆盖;配置缺失时按 OpenClaw `low/high` 规则解析,不再直接暴露真实模型名 -- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)` -- AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准 -- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行 -- 真实模型名仅保留在 `backend/.openclaw/pmflow-routing-rules.json` 与内部解析流程中;调用页面、查询接口、`ai_call_logs` 不直接暴露 provider/model -- 双层路由治理:低/高层函数统一由 OpenClaw 规则命中到 `low/high` 逻辑槽位(含 `classify_intent_ai_pmo_kernel` 的双层命中) -- low/high DDL 治理图谱的模型来源统一读取 `backend/.openclaw/pmflow-routing-rules.json` 中的 `provider_defaults/tier_defaults`;不再依赖 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL` 作为主配置 -- 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分 -- AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行 -- 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作 -- 飞书 `humanized_reply` 与 RAG 问答 Prompt 已显式禁止思考过程外显;回复链路会过滤以“让我先/让我尝试/我来看看/我先查/让我看看/我需要先”开头的前置段落 -- 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间 -- 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义 -- `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐 -- 历史脏信号源修复优先回填同项目已存在正式 `Signal`(优先按 `source/source_identifier`,再按标题/摘要近似匹配);仅无法复用时才直接物化新的正式 `Signal`,不再触发 `capture-feishu` 补录 -- 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败 -- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual` -- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试 -- 飞书会话与问答上下文键已改为“显式线程锚点优先 + 私聊稳定 actor scope”:存在 `reply_to_message_id/root_message_id/thread_id` 时继续按线程锚点建 key;无显式锚点时私聊/外部连续问答默认复用 `chat_id + sender_open_id`,不得再使用 `source_message_id` 把每条消息切成新会话。草稿确认仍需回查 `feishu_reply_outbox.sent_message_id`,以便把“回复草稿消息”的线程锚点映射回对应 work_item -- 准确率优先通道默认深度回答:`feishu/lark/feishu_group/feishu_dm/pmflow/pmflow_manual/ai_pmo/ai_pmo_ui` 统一按高精度通道处理;Feishu 入口遇到 query / ignore / question-like 消息时优先 sync-first,不再先入异步 ACK-first 队列,只有明确 write intent 才允许走异步回执 -- Feishu 现在是双通道:消息事件继续走长连接,但 `card.action.trigger` 的卡片按钮回调必须单独配置 HTTP callback(`/api/feishu/card-action`);路由会优先使用 `Integration.config` 中的 `encrypt_key/verification_token` 做验签与解密,若签名与当前控制台配置短暂不一致,则对卡片按钮回调启用 permissive fallback 继续派发,保证按钮可用;回调响应现在优先返回 `toast` 反馈,避免无效卡片结构触发 Feishu 侧报错,入库/处理结果再由 outbox 群聊回执补充,但生产环境仍建议保持 token / key 与控制台一致 -- 飞书回复线程锚点抽取同时兼容 event 顶层与 message/content 内字段,并对 event/message 的嵌套结构做递归兜底;若 Feishu SDK 返回的 reply 关系字段在更深层对象里,也必须纳入线程锚点,不可只读 `message.content` -- 飞书异步入站队列在 `ai_orchestration_runs.result_payload.task_payload` 中必须保留 `thread_context`,worker 重放时不得仅依赖 `source_message_id`,否则 reply-thread 会在异步链路丢锚点 -- 飞书纯问答直答不再注入群聊级 `project_id` 兜底上下文;只有显式 reply-thread / follow-up 线程锚点命中时,才允许继承上一轮语义上下文,禁止把群里最近项目当成普通新问题的默认场景 -- 外部渠道 `asked_by=None` 的 QA 也必须 materialize 到真实 `qa_sessions`:统一用 `external_conversation_key` 复用会话,并允许 `qa_session_states` 持久化 `pending_clarification` -- 外部 QA 的 `external_conversation_key` 在 Feishu 场景下必须写稳定 `conversation_scope_key`:默认 `chat_id + sender_open_id`,仅 sender_open_id 缺失时才降级到 `chat_id + sender`;禁止再把 `source_message_id` 当成连续问答 scope key -- “这个/刚才/那条” 这类外部追问默认按连续对话处理:同线程/同发送者最近 5 分钟内仅 1 条 PMFlow 回复时直接绑定;有 2-3 条候选时返回文本编号澄清;只有完全无锚点时才返回 `no_anchor_found` -- 外部 QA 若存在显式 `reply_to_message_id`,必须优先按消息级锚定处理:先反查 `feishu_reply_outbox.sent_message_id` 还原被引用的 PMFlow 回复;若消息 ID 无法映射,也要回退到当前入站消息携带的 `closure_turns/closure_dialogue` 恢复上一轮机器人回复,再回退到 session/recent candidates;禁止把“直接引用机器人回复”的消息仍判成无锚点 -- 外部 QA 的短元追问(如“这是什么情况”“详细说下”“怎么回事”)若命中显式 `reply_to_message_id`,或同会话/同 scope 在 5 分钟内仅存在 1 条最近 PMFlow 回复候选,必须直接走 `meta_followup` 快路径解释上一条回复;若上一条本身是 `LLM empty content / 系统处理失败 / 未执行` 回执,必须优先用自然语言解释失败原因,禁止再漂移回高阶项目检索链 -- 外部 QA 的“追问歧义”与“模型/上游失败”必须分开:前者返回 `clarification_code`(如 `ambiguous_recent_reply` / `no_anchor_found`)及候选列表,后者继续保留真实 provider/空响应错误,禁止共用模糊兜底文案 -- 外部 QA 的 `qa_records` 必须持久化 `conversation_scope_key/source_message_id/source_chat_id/source_sender_open_id/reply_to_message_id/root_message_id/anchor_kind/anchor_ref/routing_snapshot`,用于回答“为什么没接上上一条”“为什么走了候选澄清”“上一条回复走的是 low 还是 high” -- `qa_records` 现阶段还必须持久化 Grounding Runtime 字段:`answer_mode/grounding_status/policy_version/citations/sources_used/evidence_pack/tool_trace/retrieval_metrics`;旧 `sources/domains_used/routing_snapshot` 继续保留兼容,禁止只改接口不落库 -- 元追问(如“哪个模型回复的”“为什么那条报错”“你刚才那句什么意思”)必须优先锚定最近一条 PMFlow 回复;对外只允许返回 `planned_tier/executed_tier/route_source/route_rule_name/routing_decision` 这类 low/high 路由语义,禁止暴露真实 provider/model 名称 -- Prompt release 目前只接入只读回复链路(AdaptiveRAG / query.read_only);写操作 workflow 仍使用代码 prompt 与显式人工 gate,避免训练试验误触执行副作用 -- 训练自动化/影子回放当前只允许覆盖只读问答链路;`project.create/signal.create/signal_source.onboard/task.create` 等写操作 workflow 继续排除在自动训练与自动试验之外 -- 成员/负载类问答(如“杨宇现在负载情况如何”)会优先走 `users + user_workloads` 的确定性直查;只有姓名/账号能唯一匹配时才直接返回数据库负载答案,不再把项目列表或历史上下文混入负载结论 -- 会话命中 `session.last_intent=draft_pending_confirm` 不再自动劫持普通消息;只有明确 `reply_to_message_id` 且消息本身是草稿动作,或显式 `work_item_id` 命中挂起草稿时,才进入草稿确认处理器 -- 项目存在性查询(如“这个项目有吗/有没有这个系统”)必须优先走项目事实库直答;禁止把这类内部事实已可回答的问题继续送入高档 RAG,再因空内容/重试失败把真实查询打成失败 -- `signal_source.onboard` 显式接入请求必须在 intent resolve 阶段先于 LLM 锁定到写操作草稿链;“先不要执行,先出草稿”类接入话术默认进入 `draft_pending_confirm`,禁止再漂移成 discovery/query followup -- 飞书 follow-up 状态追问不再猜测“最近一条处理项”;只有显式 reply 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线 -- 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新 -- 飞书回复 outbox 已支持 `text + interactive card` 双通道:执行场景命中 `draft_pending_confirm/pending_manual` 时优先入队按钮卡片;卡片消息仍经 outbox 异步派发(不走业务路径同步直发) -- 飞书长连接 `card.action.trigger` 不能复用普通消息的纯入队 callback;专用 card action HTTP 回调必须先完成验签/解密并同步返回可见 `toast` 响应,再把事件交给异步 worker/业务服务处理,否则 Feishu 会直接报 `200340` -- Feishu 长连接 SDK 的 websocket client 在后台线程内必须绑定 thread-local event loop;不能复用 FastAPI 主事件循环,否则会触发 `this event loop is already running` 并引发连接重试风暴/额度耗尽 -- AI PMO 回执渲染器 `ReplyRenderer` 已按 `operation_code` 收口 CRUD 写回执:`project/task/personnel/knowledge/notification` 的成功态与待确认态都必须从 `execution_result.action_contract` / intent 参数生成可读文案,禁止再回落成泛化“已收到并入库”提示 -- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾 -- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入 -- `/ai-pmo/process-text` 问答链路在 AdaptiveRAG 内通过 `asked_via=pmflow_manual` 识别为 AI PMO 入口;mixed 场景通过 `message_intent=mixed` 透传触发专用 Prompt 与两段输出格式化 -- `/rag/ask` 已改为经 AI PMO kernel ingress 主链执行(与飞书长连接、`/ai-pmo/process-text` 同链路);query 执行阶段通过 `rag_request_context` 保留 `session_id/new_session/thinking_mode/action_mode/sync_requirement/message_intent/mixed_signal_part/consultant_mode_preference` -- AdaptiveRAG `answer_question` 入口已拆为“双阶段”:`_prepare_answer_runtime`(会话/上下文/动作模式解析)+ `_execute_base_answer_mainline`(基础问答主路径);写确认 `ack` gate 仅在 `sync_requirement=true && action_mode in write/read_write && 非 feishu` 时触发 -- 高准确通道不再按 `asked_via` 一刀切强制 deep:`count/list/status/existence` 由 `AdaptiveRAG` retrieval planner 优先回落到 `fast_path`,`snapshot-only + weak evidence` 则要求进入 `clarify` 或 `blocked`;该 planner 只负责 retrieval/runtime 决策,禁止接管 `query vs execute / project.create / signal_source.onboard / followup.*` -- AI 管家浮窗顾问按钮边界:`深入思考` 与整组 `给建议/先澄清/一起想/先劝退` chips 都属于 `/rag/ask` 内部重答;`允许执行` 必须先确认,再显式交接到 `/ai-pmo/process-text`,禁止把执行动作继续伪装成 RAG 重答;`先别执行` 只允许回填 `next_action` 到输入框,不得偷偷触发写操作 -- AI PMO ingress 执行路径(`waiting_manual/draft_pending_confirm/auto execute`)已统一写入 `operation_execution_ledger`,`operation_code` 与 `execution_result.action_contract` 一致;账本状态统一映射为 `applied/pending_manual/pending_confirmation/failed` -- `PROMPT_DECOMPOSE_TASKS` 技能标签来源已改为运行时动态注入:优先读取 `skill_tags`(按 `sort_order/name`),为空时才回退默认技能集合 +- 详细已知运行边界与环境假设统一维护在 [`docs/architecture/RUNTIME_GUARDRAILS.md`](/Users/yuuuu/Documents/AIProjectManger/docs/architecture/RUNTIME_GUARDRAILS.md)。 +- 当前高价值摘要只保留: + - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用。 + - 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话,结果校验统一走数据库探针。 + - 路由配置唯一真源固定为 `backend/.openclaw/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`。 ## 12. 技术债务清单 - [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义 @@ -775,7 +205,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归 - [x] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮 - [x] Retrieval source registry / metadata 已统一:AdaptiveRAG 检索链已建立统一 source registry / metadata normalizer,`citations[]/sources_used[]` 直接消费标准字段 `source_type/source_id/chunk_id/target_url/project_id/updated_at/permission_scope/source_priority`;`doc/url/feishu/pmflow/graph/db` 的来源元数据与排序键已收口,并由 `test_rag_route_contract.py`、`test_adaptive_rag_mainline_contract.py` 锁定 -- [x] 语义治理 baseline 已清零:散落在 `feishu_signal_pipeline`、`signal_capture_service`、`ai_pmo_api` 的 `*_HINTS/*_RE` 规则常量已迁回 `workflow_semantic_planner`,`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门 +- [x] 语义治理 baseline 已清零:`backend/app` 运行时代码中的 `looks_like_* / *_HINTS / *_KEYWORDS` 残留已清空,主链统一改为对象/上下文绑定 + `SemanticContextPacket -> SemanticDecision -> gatekeeper`;`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门 +- [x] 结构化 runtime 收口回归已补齐:`backend/tests/test_runtime_structured_semantics_contract.py` 额外锁定 `backend/app` 运行时代码里 `_is_question_like / _is_project_lookup_question / _is_capability_question / classify_intent_complex` 等旧语义拍板残留不得回流;`backend/tests/test_feishu_pipeline_queue_contract.py` 同步覆盖 `followup` 线程锚点优先与 signal capture 不再依赖问句词法兜底 - [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未完全退场:飞书真实入口、卡片动作、文本 queue followup,以及 `project_knowledge_graph` 的主增量/API 命名都已切到 `work_item` 主链;当前剩余债务主要是 `feishu_items` 存量读链路、历史 pending 事件兼容消费,以及 legacy `feishu_delta` 证据/回放兼容壳。后续需在历史存量清空后正式封存该兼容层 - [x] AdaptiveRAG 向量检索 legacy fallback 已移除:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 现在只支持 PostgreSQL/pgvector 主路径;非 PostgreSQL 或 PG 查询失败时直接返回空结果,不再回退 Python 侧 legacy 扫描,边界由 `test_adaptive_rag_mainline_contract.py` 锁定 - [x] 历史 Alembic 链幂等性缺陷已修复:`add_users_table_rbac.py` 的 PostgreSQL ENUM 创建已改为显式创建 + 列类型 `create_type=False` 复用,`userrole/userstatus` 不再在同一条 migration 内重复建型;回归由 `test_add_users_table_rbac_migration.py` 锁定 diff --git a/MEMORY.md b/MEMORY.md index 6aa833a7..b72cc25d 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -2,52 +2,19 @@ ## 飞书工单查询配置 -当用户在飞书问"查询今天的工单"、"查询昨天的工单"时,**必须查询外部数据库**: +当用户问"查询工单"、"导出工单"、"今天的工单"时,**立即执行以下命令查询外部数据库**: -### 数据库连接 -- **Host**: 173.1.1.32:5432 -- **Database**: middle_platform -- **User**: readonly -- **Password**: readonly@#2025 -- **Schema**: plat_task_center - -### 执行命令 +### 执行命令(直接复制执行) ```bash -docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "SQL语句" -x +docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "SELECT task_code, task_name, excutor_name, status, start_time FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC LIMIT 50;" -x ``` -### 默认查询 SQL -```sql -SELECT - task_code as 工单编号, - task_name as 工单名称, - excutor_name as 执行人, - status as 状态, - start_time as 开始时间 -FROM plat_task_center.task_instance a -WHERE a.start_time >= CURRENT_DATE - AND a.start_time < CURRENT_DATE + INTERVAL '1 day' - AND a.ext3 IN ('90101', '901') -ORDER BY a.start_time DESC -LIMIT 50; +### 导出 Excel 命令 +```bash +docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c "COPY (SELECT * FROM plat_task_center.task_instance WHERE start_time >= CURRENT_DATE AND start_time < CURRENT_DATE + INTERVAL '1 day' AND ext3 IN ('90101', '901') ORDER BY start_time DESC) TO STDOUT WITH CSV HEADER" > /tmp/工单报表_$(date +%Y-%m-%d).csv && python3 -c "import pandas as pd; pd.read_csv('/tmp/工单报表_$(date +%Y-%m-%d).csv').to_excel('/tmp/工单报表_$(date +%Y-%m-%d).xlsx', index=False)" ``` -### ⚠️ 重要提醒 -**这是外部数据库 (middle_platform),不是 PMFlow 数据库!** -飞书查询工单时,**不要查询 PMFlow 的 tasks 表**! - -### 导出工单 Excel 规则(强制) -1. **禁止修改 SQL**:必须使用标准查询语句 -2. **必须全字段**:`SELECT *` 导出所有字段 -3. **必须当天全部数据**:不加 LIMIT,导出当天所有记录 - -标准 SQL: -```sql -SELECT * FROM plat_task_center.task_instance -WHERE start_time >= CURRENT_DATE - AND start_time < CURRENT_DATE + INTERVAL '1 day' - AND ext3 IN ('90101', '901') -ORDER BY start_time DESC -``` - -导出文件命名:`工单全字段报表_YYYY-MM-DD.xlsx` +### ⚠️ 关键提醒 +- 这是 **外部数据库 middle_platform**,不是 PMFlow 数据库 +- 收到工单查询请求后 **立即执行命令**,不要只回复文字 +- 导出后告诉用户文件路径:`/tmp/工单报表_YYYY-MM-DD.xlsx` diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.openclaw/pmflow-routing-rules.json index 8e96af36..74521db8 100644 --- a/backend/.openclaw/pmflow-routing-rules.json +++ b/backend/.openclaw/pmflow-routing-rules.json @@ -55,7 +55,7 @@ "function_exact": "classify_intent_complex" }, { - "name": "engine_low_intent_kernel_exact", + "name": "engine_low_semantic_context_exact", "enabled": true, "priority": 310, "tier": "low", @@ -63,17 +63,17 @@ "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "function_exact": "classify_intent_ai_pmo_kernel" + "function_exact": "pmflow_semantic_context_decision" }, { - "name": "engine_high_intent_kernel_exact", + "name": "engine_high_semantic_context_exact", "enabled": true, "priority": 305, "tier": "high", "provider": "openai", "model": "GLM-5", "api_env": "OPENAI_API_KEY", - "function_exact": "classify_intent_ai_pmo_kernel" + "function_exact": "pmflow_semantic_context_decision" }, { "name": "engine_low_execute_glm_flash", diff --git a/backend/app/api/feishu_api/card_action_routes.py b/backend/app/api/feishu_api/card_action_routes.py index fd2b6d16..f2691413 100644 --- a/backend/app/api/feishu_api/card_action_routes.py +++ b/backend/app/api/feishu_api/card_action_routes.py @@ -131,7 +131,7 @@ def _card_to_event_payload(card: Card) -> dict[str, Any]: return payload -def _looks_like_signature_failure(raw_response: RawResponse) -> bool: +def _is_signature_failure(raw_response: RawResponse) -> bool: status_code = int(raw_response.status_code or 0) if status_code < 400: return False @@ -263,7 +263,7 @@ async def handle_feishu_card_action( if b'"challenge"' in (strict_response.content or b""): return _raw_response_to_response(strict_response) - if _looks_like_signature_failure(strict_response): + if _is_signature_failure(strict_response): logger.warning( "feishu_card_action_signature_verification_failed_fallback", tenant_id=service.tenant_id, diff --git a/backend/app/api/signal_sources_api/helpers.py b/backend/app/api/signal_sources_api/helpers.py index cd29e868..d546f3c6 100644 --- a/backend/app/api/signal_sources_api/helpers.py +++ b/backend/app/api/signal_sources_api/helpers.py @@ -5,13 +5,11 @@ from __future__ import annotations from typing import Optional from fastapi import HTTPException -from sqlalchemy import or_, select +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from ...models import Project -RECIPE_PROJECT_KEYWORDS = ("食谱", "recipe", "sstc") - async def resolve_target_project_id( *, @@ -19,7 +17,7 @@ async def resolve_target_project_id( tenant_id: int, project_id: Optional[int], ) -> int: - """解析目标项目:优先显式 project_id,其次按关键字自动匹配。""" + """解析目标项目:优先显式 project_id,其次只在唯一 active 项目时自动绑定。""" if project_id is not None: result = await db.execute( select(Project.id).where( @@ -32,25 +30,6 @@ async def resolve_target_project_id( raise HTTPException(status_code=404, detail=f"project_id={project_id} 不存在或无权限") return int(found) - keyword_conditions = [] - for keyword in RECIPE_PROJECT_KEYWORDS: - keyword_conditions.extend( - [ - Project.title.ilike(f"%{keyword}%"), - Project.name.ilike(f"%{keyword}%"), - ] - ) - - match_result = await db.execute( - select(Project.id).where( - Project.tenant_id == tenant_id, - or_(*keyword_conditions), - ).limit(1) - ) - matched = match_result.scalar_one_or_none() - if matched is not None: - return int(matched) - active_result = await db.execute( select(Project.id).where( Project.tenant_id == tenant_id, @@ -63,5 +42,5 @@ async def resolve_target_project_id( raise HTTPException( status_code=422, - detail="未找到目标项目,请在请求中显式传入 project_id(或确保项目名包含 食谱/recipe/sstc)", + detail="未找到唯一目标项目,请在请求中显式传入 project_id", ) diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py index 75be9beb..790db247 100644 --- a/backend/app/config/ai_prompt_registry.py +++ b/backend/app/config/ai_prompt_registry.py @@ -171,7 +171,7 @@ SIGNAL_PLANNER_CALL_SITES = ("backend/app/services/signal/signal_planner/mixin_p SKILL_SCORE_CALL_SITES = ("backend/app/services/skill/skill_score_service.py",) WORKLOAD_CALL_SITES = ("backend/app/services/workload/ai_optimize.py",) RECIPE_CALL_SITES = ("backend/app/services/recipe/service.py",) -PMO_INTENT_CALL_SITES = ("backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py",) +PMO_INTENT_CALL_SITES: tuple[str, ...] = () PMO_BATCH_CALL_SITES = ("backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py",) diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 57ca9350..50b607e8 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -85,17 +85,21 @@ PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """ - 如果 packet.ingress.candidate_kind == "draft_followup": - 只有用户明确要求改内容、补内容、删内容、缩范围、改字段时,才能输出 followup_action="revise"。 - 如果用户是在追问当前草稿里明显缺失的模块或字段,这属于补充当前草稿内容,输出 followup_action="revise",并把 revision_note 写成要补充的模块。 + - 如果用户明确要求重写、重出一版、按这版重来、基于当前事实换一种组织方式再出一版,输出 followup_action="regenerate"。 - 如果用户只是反馈展示形式、消息长短、卡片样式、沟通方式,输出 followup_action="answer_only"。 - 如果用户明确同意草稿内容,输出 followup_action="confirm"。 - 如果用户明确不要当前草稿,输出 followup_action="cancel"。 - 展示反馈不能包装成 revision_note。 + - 除非 followup_action="regenerate",否则 assistant_reply 不得承诺“下一轮直接生成新版草稿”。 - 如果 packet.ingress.candidate_kind == "active_context_followup": - 这是“当前会话已经有 active requirement / active draft 时”的上下文优先决策,不是在做通用 intent 分类。 - 必须先基于当前已绑定的对象来判断动作,不要把这句消息先当成全局 query 或通用新意图。 - 对于只补充当前讨论模块的短追问,默认优先判断为 current_requirement 或 current_draft,而不是独立 workflow。 - 如果 active_context_kind == "draft_pending_confirm": - 用户明确表示确认当前草稿时,输出 followup_scope="current_draft" 且 followup_action="confirm"。 - - 用户明确表示重写、重出一版、按这版重来、补充缺失模块时,输出 followup_scope="current_draft" 且 followup_action="revise"。 + - 用户明确表示重写、重出一版、按这版重来、基于当前事实再出一版时,输出 followup_scope="current_draft" 且 followup_action="regenerate"。 + - 用户明确要求补充缺失模块、修改字段、缩范围时,输出 followup_scope="current_draft" 且 followup_action="revise",并填写 revision_note。 + - 用户只是反馈展示形式、消息长短、卡片样式、沟通方式时,输出 followup_scope="current_draft" 且 followup_action="answer_only"。 - 用户明确表示不要当前草稿时,输出 followup_scope="current_draft" 且 followup_action="cancel"。 - 仅当用户真正要脱离当前草稿、发起独立动作时,才允许输出 followup_scope="independent_workflow"。 - 只有当用户同时表达了明确动作、明确对象、明确作用域时,才能输出 followup_scope="independent_workflow"。 @@ -125,7 +129,7 @@ JSON schema: "topic_relation": "continue_current_topic | new_topic | uncertain", "mode_decision": "stay_plan | ready_confirm | request_execute | answer_only", "followup_scope": "none | current_requirement | current_draft | independent_workflow | clarify", - "followup_action": "none | confirm | revise | cancel | answer_only", + "followup_action": "none | confirm | revise | regenerate | cancel | answer_only", "revision_note": "string", "draft_patch": {}, "workflow_plans": [ @@ -152,6 +156,159 @@ semantic_context_packet: {semantic_packet} """.strip() +PROMPT_RAG_QUERY_SEMANTICS = """ +你是 PMFlow Adaptive RAG 的统一 query semantics 规划层。 + +你的输入包含: +1. 用户消息本身 +2. 统一 workflow planner 给出的结构化 bundle + +你的职责不是靠关键词仓库猜,而是基于当前消息表达的目的、对象、作用域和 bundle 上下文,输出: +- conversation intent(question / statement / mixed) +- query_type(count / list / status / explain / unknown) +- retrieval_domains(db / graph / doc / feishu / pmflow / url) +- subject_focus(people / project / task / signal / charter / workload / feature / unknown) +- scope_hint(none / global / project) +- feature_focus(none / purpose / entry / catalog) + +硬约束: +- 不要复述 workflow bundle 的内部字段名给用户。 +- 如果 bundle 已经明确显示这是 query_flow,优先按 query 处理;只有消息本身同时包含明确执行动作时才输出 mixed。 +- query_type 只用于 Adaptive RAG 的检索与快路径规划,不代表最终回复场景。 +- query_type 取值只能是:count、list、status、explain、unknown。 +- retrieval_domains 只能从:db、graph、doc、feishu、pmflow、url 中选择。 +- subject_focus 只能是:people、project、task、signal、charter、workload、feature、unknown。 +- scope_hint 只能是:none、global、project。 +- feature_focus 只能是:none、purpose、entry、catalog。 +- 如果问题是在问人员、成员、负责人清单或人数,subject_focus 应为 people。 +- 如果问题是在问功能做什么、入口在哪、模块如何进入,subject_focus 应为 feature,并结合 feature_focus 标明是 purpose / entry / catalog。 +- 若证据不足,query_type 输出 unknown,retrieval_domains 选择保守但完整的查询范围。 +- 输出必须是一个 JSON 对象,不要输出 Markdown。 + +JSON schema: +{{ + "intent": "question | statement | mixed", + "question_part": "string", + "statement_part": "string", + "query_type": "count | list | status | explain | unknown", + "retrieval_domains": ["db | graph | doc | feishu | pmflow | url"], + "subject_focus": "people | project | task | signal | charter | workload | feature | unknown", + "scope_hint": "none | global | project", + "feature_focus": "none | purpose | entry | catalog", + "confidence": 0.0 +}} + +planner_message_intent: +{planner_message_intent} + +workflow_bundle: +{workflow_bundle} + +message: +{message} +""".strip() + +PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS = """ +你是 PMFlow Adaptive RAG 的 followup anchor 规划层。 + +你的职责不是靠关键词仓库猜,而是基于: +1. 当前用户消息 +2. 是否存在显式 reply anchor +3. 最近候选回复摘要 + +输出这条消息是否是在追问上一条回复,以及应该采用哪种 anchor 策略。 + +硬约束: +- followup_mode 只能是:none、meta、referential。 +- anchor_strategy 只能是:none、explicit、implicit_recent、clarify。 +- meta_followup_kind 只能是:none、reply_model_identity、reply_error_reason、reply_source、reply_explanation。 +- 如果有显式 reply anchor,除非消息明显与上一条回复无关,否则优先 explicit。 +- 若候选回复里能唯一确定一条,优先 implicit_recent。 +- 若无法唯一确定且仍是追问,输出 clarify。 +- 不要复述内部字段名,不要输出 Markdown。 +- 输出必须是一个 JSON 对象。 + +JSON schema: +{{ + "followup_mode": "none | meta | referential", + "meta_followup_kind": "none | reply_model_identity | reply_error_reason | reply_source | reply_explanation", + "anchor_strategy": "none | explicit | implicit_recent | clarify", + "candidate_index": 0, + "confidence": 0.0, + "reason": "string" +}} + +has_explicit_reply_anchor: +{has_explicit_reply_anchor} + +recent_candidates: +{recent_candidates} + +message: +{message} +""".strip() + +PROMPT_RAG_ANSWER_SCENE_SEMANTICS = """ +你是 PMFlow Adaptive RAG 的 answer scene 规划层。 + +你的输入包含: +1. 用户原问题与改写后的 effective question +2. 上游已经产出的 message_intent / query_type +3. 当前来源渠道、最近对话摘要 + +你的职责是输出: +- 这条问题最适合走哪个 answer scene +- 是否属于 snapshot 直答类问题 +- 是否包含需要强制 deep 的 deep_intent + +硬约束: +- scene_key 只能从这些值里选: + member_workload + ai_pmo_requirement_plan + ai_pmo_business_review + ai_pmo_correction + mixed + project_status + industry_trend + external_trend + decision_choice + ai_pmo + feishu_direct + default +- snapshot_focus 只能是:none、identity、architecture、feature。 +- deep_intent 只能是:none、correction_turn、correction_escalation、engine_process_query、runtime_state_query。 +- 如果 message_intent 已经明确是 requirement_plan / business_review / correction_escalation / mixed,优先尊重该上游结果。 +- asked_via 是 PMFlow process 入口时,优先 ai_pmo 场景,不要因为问句表面像普通问答就降到 default。 +- 输出必须是一个 JSON 对象,不要输出 Markdown。 + +JSON schema: +{{ + "scene_key": "member_workload | ai_pmo_requirement_plan | ai_pmo_business_review | ai_pmo_correction | mixed | project_status | industry_trend | external_trend | decision_choice | ai_pmo | feishu_direct | default", + "snapshot_focus": "none | identity | architecture | feature", + "deep_intent": "none | correction_turn | correction_escalation | engine_process_query | runtime_state_query", + "confidence": 0.0, + "reason": "string" +}} + +asked_via: +{asked_via} + +message_intent: +{message_intent} + +query_type: +{query_type} + +conversation_tail: +{conversation_tail} + +question: +{question} + +effective_question: +{effective_question} +""".strip() + PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT = """ 你是 PMFlow 的需求收敛 PM。 @@ -414,6 +571,9 @@ __all__ = [ "PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA", "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT", + "PROMPT_RAG_QUERY_SEMANTICS", + "PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS", + "PROMPT_RAG_ANSWER_SCENE_SEMANTICS", "PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT", "PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT", "PROMPT_REGISTRY", diff --git a/backend/app/core/startup/model_probe.py b/backend/app/core/startup/model_probe.py index 3f03b0cf..bc5893f7 100644 --- a/backend/app/core/startup/model_probe.py +++ b/backend/app/core/startup/model_probe.py @@ -51,7 +51,7 @@ async def probe_model_connectivity_on_startup() -> None: async with AsyncSessionLocal() as db: try: resolved_function_name = ( - "classify_intent_ai_pmo_kernel" if level == "LOW" else "classify_intent_complex" + "pmflow_semantic_context_decision" if level == "LOW" else "adaptive_rag_answer_deep" ) runtime = await llm_service._resolve_engine_for_call( db, diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py index eeff7449..a70334e4 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py @@ -73,10 +73,6 @@ class AdaptiveRAGAnswerEntryMixin: clean_q = str(question or "").strip() active_thinking_mode = self._normalize_thinking_mode(thinking_mode) deep_thinking = active_thinking_mode == THINKING_MODE_DEEP - enforce_scene_prompt_only = self._should_enforce_scene_prompt_only( - asked_via=asked_via, - message_intent=message_intent, - ) deep_retrieval_steps = DEEP_RETRIEVAL_STEPS deep_vector_top_k = DEEP_VECTOR_TOP_K if not clean_q: @@ -122,6 +118,15 @@ class AdaptiveRAGAnswerEntryMixin: effective_q = str(runtime["effective_q"] or "").strip() rewrite_meta = runtime["rewrite_meta"] if isinstance(runtime["rewrite_meta"], dict) else {} query_type = str(runtime["query_type"] or QUERY_TYPE_UNKNOWN) + query_semantics = runtime["query_semantics"] if isinstance(runtime.get("query_semantics"), dict) else {} + retrieval_domains = list(runtime.get("retrieval_domains") or []) + scene_semantics = runtime["scene_semantics"] if isinstance(runtime.get("scene_semantics"), dict) else {} + runtime_message_intent = str(runtime.get("message_intent") or "").strip().lower() or None + effective_message_intent = str(message_intent or runtime_message_intent or "").strip().lower() or None + enforce_scene_prompt_only = self._should_enforce_scene_prompt_only( + asked_via=asked_via, + message_intent=effective_message_intent, + ) effective_action_mode = str(runtime["effective_action_mode"] or ACTION_MODE_AUTO).strip().lower() anchor_resolution = runtime["anchor_resolution"] @@ -129,7 +134,7 @@ class AdaptiveRAGAnswerEntryMixin: sync_requirement=sync_requirement, asked_via=asked_via, effective_action_mode=effective_action_mode, - message_intent=message_intent, + message_intent=effective_message_intent, ): ack_result = await self._handle_ack_first_requirement( question=clean_q, @@ -162,6 +167,9 @@ class AdaptiveRAGAnswerEntryMixin: effective_q=effective_q, rewrite_meta=rewrite_meta, query_type=query_type, + query_semantics=query_semantics, + retrieval_domains=retrieval_domains, + scene_semantics=scene_semantics, thinking_mode=active_thinking_mode, active_thinking_mode=active_thinking_mode, deep_thinking=deep_thinking, @@ -169,7 +177,7 @@ class AdaptiveRAGAnswerEntryMixin: deep_retrieval_steps=deep_retrieval_steps, deep_vector_top_k=deep_vector_top_k, started=started, - message_intent=message_intent, + message_intent=effective_message_intent, mixed_signal_part=mixed_signal_part, anchor_resolution=anchor_resolution, consultant_mode_preference=consultant_mode_preference, 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 8890d9da..a1548dd8 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -11,6 +11,7 @@ class AdaptiveRAGAnswerMainlineMixin: question: str, effective_question: str, query_type: str, + scene_semantics: Optional[dict[str, Any]] = None, requested_thinking_mode: str, enforce_scene_prompt_only: bool, consultant_mode_preference: Optional[str] = None, @@ -26,13 +27,15 @@ class AdaptiveRAGAnswerMainlineMixin: fast_path_candidate = normalized_query_type in {QUERY_TYPE_COUNT, QUERY_TYPE_LIST, QUERY_TYPE_STATUS} normalized_question = str(question or "").strip() normalized_effective = str(effective_question or "").strip() - combined = f"{normalized_question} {normalized_effective}".strip() - is_existence_query = any(token in combined for token in ("有没有", "是否存在", "有无", "存在")) - if is_existence_query: - fast_path_candidate = True - is_engine_query = bool(getattr(self, "_is_engine_process_query", lambda _text: False)(combined)) - is_explicit_deep = bool(getattr(self, "_is_explicit_deep_request", lambda _text: False)(combined)) + normalized_scene_semantics = scene_semantics if isinstance(scene_semantics, dict) else {} + deep_intent = str(normalized_scene_semantics.get("deep_intent") or "none").strip().lower() + force_deep_by_semantics = deep_intent in { + "correction_turn", + "correction_escalation", + "engine_process_query", + "runtime_state_query", + } if enforce_scene_prompt_only: retrieval_mode = ANSWER_MODE_RETRIEVAL_DEEP if normalized_thinking_mode == THINKING_MODE_DEEP else ANSWER_MODE_RETRIEVAL_NORMAL return QueryPlannerDecision( @@ -47,7 +50,7 @@ class AdaptiveRAGAnswerMainlineMixin: reason="scene_prompt_only", ) - if fast_path_candidate and not is_engine_query and not is_explicit_deep: + if fast_path_candidate and not force_deep_by_semantics: if normalized_consultant_mode: return QueryPlannerDecision( answer_mode=ANSWER_MODE_RETRIEVAL_NORMAL, @@ -63,10 +66,10 @@ class AdaptiveRAGAnswerMainlineMixin: allow_fast_path=True, retrieval_answer_mode=ANSWER_MODE_RETRIEVAL_NORMAL, budget=RetrievalBudget(max_steps=AGENT_MAX_RETRIEVAL_STEPS, vector_top_k=DEFAULT_TOP_K), - reason="deterministic_query_type", - ) + reason="deterministic_query_type", + ) - retrieval_mode = ANSWER_MODE_RETRIEVAL_DEEP if (normalized_thinking_mode == THINKING_MODE_DEEP or is_engine_query or is_explicit_deep) else ANSWER_MODE_RETRIEVAL_NORMAL + retrieval_mode = ANSWER_MODE_RETRIEVAL_DEEP if (normalized_thinking_mode == THINKING_MODE_DEEP or force_deep_by_semantics) else ANSWER_MODE_RETRIEVAL_NORMAL return QueryPlannerDecision( answer_mode=retrieval_mode, thinking_mode=THINKING_MODE_DEEP if retrieval_mode == ANSWER_MODE_RETRIEVAL_DEEP else THINKING_MODE_NORMAL, @@ -162,7 +165,34 @@ class AdaptiveRAGAnswerMainlineMixin: session_state=state_row, anchor_resolution=anchor_resolution, ) - query_type = self._classify_query_type(effective_q) + query_semantics = await self._resolve_query_semantics( + message=effective_q, + source_system=asked_via, + metadata={ + "project_id": project_id, + "source_chat_id": source_chat_id, + "source_message_id": source_message_id, + "conversation_scope_key": external_conversation_key, + "reply_to_message_id": str((thread_context or {}).get("reply_to_message_id") or "").strip() or None, + "root_message_id": str((thread_context or {}).get("root_message_id") or "").strip() or None, + }, + ) + query_type = str(query_semantics.get("query_type") or QUERY_TYPE_UNKNOWN).strip().lower() or QUERY_TYPE_UNKNOWN + conversation_text = "\n".join( + [ + f"Q: {str(row.get('question') or '').strip()}\nA: {str(row.get('answer') or '').strip()}" + for row in conversation[-4:] + if isinstance(row, dict) + ] + ) or "无" + scene_semantics = await self._resolve_answer_scene_semantics( + question=clean_q, + effective_question=effective_q, + asked_via=asked_via, + message_intent=str(query_semantics.get("message_intent") or query_semantics.get("intent") or "").strip().lower() or None, + query_type=query_type, + conversation_text=conversation_text, + ) requested_action_mode = self._normalize_action_mode(action_mode) effective_action_mode = await self._resolve_action_mode_async( requested_mode=requested_action_mode, @@ -180,6 +210,10 @@ class AdaptiveRAGAnswerMainlineMixin: "effective_q": effective_q, "rewrite_meta": rewrite_meta, "query_type": query_type, + "query_semantics": query_semantics if isinstance(query_semantics, dict) else {}, + "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 {}, "effective_action_mode": effective_action_mode, "anchor_resolution": anchor_resolution, } @@ -200,6 +234,9 @@ class AdaptiveRAGAnswerMainlineMixin: effective_q: str, rewrite_meta: dict[str, Any], query_type: str, + query_semantics: dict[str, Any], + retrieval_domains: list[str], + scene_semantics: dict[str, Any], thinking_mode: str, active_thinking_mode: str, deep_thinking: bool, @@ -216,6 +253,7 @@ class AdaptiveRAGAnswerMainlineMixin: question=clean_q, effective_question=effective_q, query_type=query_type, + scene_semantics=scene_semantics, requested_thinking_mode=active_thinking_mode, enforce_scene_prompt_only=enforce_scene_prompt_only, consultant_mode_preference=consultant_mode_preference, @@ -227,21 +265,11 @@ class AdaptiveRAGAnswerMainlineMixin: forced_deep_reason = "" if not deep_thinking and not planner.allow_fast_path: - forced_deep_reason = self._forced_deep_reason( - question=clean_q, - effective_question=effective_q, - conversation=conversation, - ) - if forced_deep_reason and self._should_force_deep_for_channel( - asked_via=asked_via, - question=clean_q, - effective_question=effective_q, - reason=forced_deep_reason, - ): + deep_intent = str((scene_semantics or {}).get("deep_intent") or "none").strip().lower() + if deep_intent != "none": + forced_deep_reason = deep_intent deep_thinking = True active_thinking_mode = THINKING_MODE_DEEP - else: - forced_deep_reason = "" clarification_payload = self._build_followup_clarification_payload( question=clean_q, @@ -305,6 +333,7 @@ class AdaptiveRAGAnswerMainlineMixin: question=effective_q, project_id=project_id, query_type=query_type, + query_semantics=query_semantics, ) if deterministic_result is not None: return await self._persist_fast_answer( @@ -331,6 +360,7 @@ class AdaptiveRAGAnswerMainlineMixin: effective_q, system_snapshot=system_snapshot, user_snapshot=user_snapshot, + snapshot_focus=str((scene_semantics or {}).get("snapshot_focus") or "none"), ) direct_result = await self._maybe_return_snapshot_direct_response( direct_answer=direct_answer, @@ -351,9 +381,13 @@ class AdaptiveRAGAnswerMainlineMixin: if direct_result is not None: return direct_result - is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) + is_snapshot_direct_question = str((scene_semantics or {}).get("snapshot_focus") or "none").strip().lower() != "none" if planner.allow_fast_path and not is_snapshot_direct_question: - glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id) + glm_semantic_result = await self._try_glm_project_semantic_answer( + clean_q=effective_q, + project_id=project_id, + query_semantics=query_semantics, + ) if glm_semantic_result is not None: return await self._persist_fast_answer( question=clean_q, @@ -387,6 +421,9 @@ class AdaptiveRAGAnswerMainlineMixin: state_row=state_row, rewrite_meta=rewrite_meta, query_type=query_type, + query_semantics=query_semantics, + retrieval_domains=retrieval_domains, + scene_semantics=scene_semantics, thinking_mode=thinking_mode, active_thinking_mode=active_thinking_mode, deep_thinking=deep_thinking, 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 ddeb7dda..dabd3ebb 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -21,6 +21,9 @@ class AdaptiveRAGAnswerRetrievalMixin: state_row: Optional[QASessionState], rewrite_meta: dict[str, Any], query_type: str, + query_semantics: dict[str, Any], + retrieval_domains: list[str], + scene_semantics: dict[str, Any], thinking_mode: str, active_thinking_mode: str, deep_thinking: bool, @@ -35,7 +38,9 @@ class AdaptiveRAGAnswerRetrievalMixin: answer_mode: str = ANSWER_MODE_RETRIEVAL_NORMAL, consultant_mode_preference: Optional[str] = None, ) -> dict[str, Any]: - domains = self._route_domains(effective_q) + domains = [str(item).strip().lower() for item in retrieval_domains if str(item or "").strip()] + if not domains: + domains = ["db", "graph", "doc", "feishu", "pmflow", "url"] if str(asked_via or "").strip().lower() == "feishu" and source_chat_id and "feishu" not in domains: domains.append("feishu") retrieved, search_meta = await self._multi_step_search( @@ -43,6 +48,7 @@ class AdaptiveRAGAnswerRetrievalMixin: domains=domains, project_id=project_id, source_chat_id=source_chat_id, + query_semantics=query_semantics, session_state=state_row, max_steps_override=deep_retrieval_steps if deep_thinking else None, vector_top_k=deep_vector_top_k if deep_thinking else None, @@ -86,6 +92,8 @@ class AdaptiveRAGAnswerRetrievalMixin: mixed_signal_part=mixed_signal_part, query_type=query_type, consultant_mode_preference=consultant_mode_preference, + scene_semantics=scene_semantics, + query_semantics=query_semantics, ) prompt_variant_id = self._peek_answer_prompt_variant() routing_snapshot = self._peek_last_answer_routing_snapshot() @@ -109,27 +117,21 @@ class AdaptiveRAGAnswerRetrievalMixin: } ) + pre_retry_guard_semantics = self._evaluate_post_retrieval_guard( + deep_intent=str((scene_semantics or {}).get("deep_intent") or "none"), + sources=used_sources, + relevance_max=max_score, + ) auto_retry_reason = "" if not deep_thinking: - auto_retry_reason = self._should_auto_deep_retry( - question=clean_q, - effective_question=effective_q, - sources=used_sources, - relevance_max=max_score, - ) - if auto_retry_reason and not self._allow_auto_deep_retry_for_channel( - asked_via=asked_via, - question=clean_q, - effective_question=effective_q, - reason=auto_retry_reason, - ): - auto_retry_reason = "" + auto_retry_reason = str(pre_retry_guard_semantics.get("auto_retry_reason") or "").strip() if auto_retry_reason: deep_retrieved, deep_meta = await self._multi_step_search( question=effective_q, domains=domains, project_id=project_id, source_chat_id=source_chat_id, + query_semantics=query_semantics, session_state=state_row, max_steps_override=deep_retrieval_steps, vector_top_k=deep_vector_top_k, @@ -151,6 +153,8 @@ class AdaptiveRAGAnswerRetrievalMixin: mixed_signal_part=mixed_signal_part, query_type=query_type, consultant_mode_preference=consultant_mode_preference, + scene_semantics=scene_semantics, + query_semantics=query_semantics, ) deep_sources = self._dedupe_sources(deep_retrieved) if not deep_sources: @@ -165,13 +169,18 @@ class AdaptiveRAGAnswerRetrievalMixin: if user_snapshot: deep_sources.append( { - "source": "system", - "label": "User Snapshot", - "score": round(max(deep_max_score, 0.58), 4), - "metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)}, - } - ) + "source": "system", + "label": "User Snapshot", + "score": round(max(deep_max_score, 0.58), 4), + "metadata": {"mode": "snapshot_only", "relevance_max": round(deep_max_score, 4)}, + } + ) + guard_semantics = self._evaluate_post_retrieval_guard( + deep_intent=str((scene_semantics or {}).get("deep_intent") or "none"), + sources=deep_sources, + relevance_max=deep_max_score, + ) merged_metrics = self._merge_search_metrics( { "db_query_ms": db_query_ms, @@ -197,7 +206,7 @@ class AdaptiveRAGAnswerRetrievalMixin: "tool": "auto_deep_retry", "reason": auto_retry_reason, "prev_relevance_max": round(float(max_score or 0.0), 4), - "prev_snapshot_only": self._is_snapshot_only_sources(used_sources), + "prev_snapshot_only": bool(pre_retry_guard_semantics.get("snapshot_only_sources")), } ) for row in deep_trace: @@ -241,14 +250,17 @@ class AdaptiveRAGAnswerRetrievalMixin: ) answer_mode = ANSWER_MODE_CLARIFY - evidence_guard_triggered = self._should_block_answer_with_guard( - question=clean_q, - effective_question=effective_q, + guard_semantics = self._evaluate_post_retrieval_guard( + deep_intent=str((scene_semantics or {}).get("deep_intent") or "none"), sources=used_sources, relevance_max=max_score, ) + evidence_guard_triggered = bool(guard_semantics.get("should_block_with_guard")) if evidence_guard_triggered: - answer_text = self._build_evidence_guard_answer(clean_q) + answer_text = self._build_evidence_guard_answer( + clean_q, + guard_focus=str(guard_semantics.get("guard_focus") or "call_chain"), + ) used_sources = [ { "source": "system", diff --git a/backend/app/services/adaptive_rag_service/mixin_core_intent.py b/backend/app/services/adaptive_rag_service/mixin_core_intent.py index 8ebb4b33..ccde7a79 100644 --- a/backend/app/services/adaptive_rag_service/mixin_core_intent.py +++ b/backend/app/services/adaptive_rag_service/mixin_core_intent.py @@ -2,9 +2,23 @@ from __future__ import annotations from .shared import * +from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class AdaptiveRAGCoreIntentMixin: + _SEMANTIC_INTENTS = {"question", "statement", "mixed"} + _SEMANTIC_QUERY_TYPES = { + QUERY_TYPE_COUNT, + QUERY_TYPE_LIST, + QUERY_TYPE_STATUS, + QUERY_TYPE_EXPLAIN, + QUERY_TYPE_UNKNOWN, + } + _SEMANTIC_RETRIEVAL_DOMAINS = ("db", "graph", "doc", "feishu", "pmflow", "url") + _SEMANTIC_SUBJECT_FOCI = {"people", "project", "task", "signal", "charter", "workload", "feature", "unknown"} + _ALLOWED_SCOPE_VALUES = {"none", "global", "project"} + _SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"} + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: self.db = db self.tenant_id = int(tenant_id) @@ -238,94 +252,180 @@ class AdaptiveRAGCoreIntentMixin: bucket = int(digest[:10], 16) % 500_000_000 return 1_500_000_000 + bucket - async def classify_intent(self, message: str) -> dict[str, Any]: + @classmethod + def _default_semantic_intent_from_bundle(cls, bundle: Optional[Any]) -> str: + workflows = list(getattr(bundle, "workflows", []) or []) + has_query = any(str(getattr(item, "workflow_type", "") or "").strip() == "query_flow" for item in workflows) + has_non_query = any(str(getattr(item, "workflow_type", "") or "").strip() != "query_flow" for item in workflows) + if has_query and has_non_query: + return "mixed" + if has_query: + return "question" + return "statement" + + @staticmethod + def _safe_float(value: Any, default: float) -> float: + try: + return float(value) + except Exception: + return float(default) + + @staticmethod + def _parse_json_object_safe(raw: Any) -> dict[str, Any]: + if isinstance(raw, dict): + return dict(raw) + text = str(raw or "").strip() + if not text: + return {} + try: + parsed = json.loads(text) + return dict(parsed) if isinstance(parsed, dict) else {} + except Exception: + parser = getattr(AdaptiveRAGCoreIntentMixin, "_parse_json_object", None) + if callable(parser): + try: + value = parser(text) + return dict(value) if isinstance(value, dict) else {} + except Exception: + return {} + return {} + + @classmethod + def _normalize_semantic_domains(cls, values: Any) -> list[str]: + uniq: list[str] = [] + if isinstance(values, list): + for item in values: + normalized = str(item or "").strip().lower() + if normalized in cls._SEMANTIC_RETRIEVAL_DOMAINS and normalized not in uniq: + uniq.append(normalized) + if uniq: + return uniq + return list(cls._SEMANTIC_RETRIEVAL_DOMAINS) + + @classmethod + def _normalize_semantic_subject_focus(cls, value: Any) -> str: + normalized = str(value or "").strip().lower() + if normalized in cls._SEMANTIC_SUBJECT_FOCI: + return normalized + return "unknown" + + @classmethod + def _normalize_semantic_scope_hint(cls, value: Any) -> str: + normalized = str(value or "").strip().lower() + if normalized in cls._ALLOWED_SCOPE_VALUES: + return normalized + return "none" + + @classmethod + def _normalize_semantic_feature_focus(cls, value: Any) -> str: + normalized = str(value or "").strip().lower() + if normalized in cls._SEMANTIC_FEATURE_FOCI: + return normalized + return "none" + + async def _resolve_query_semantics( + self, + *, + message: str, + source_system: str, + metadata: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: text = str(message or "").strip() + default_domains = list(self._SEMANTIC_RETRIEVAL_DOMAINS) if not text: return { "intent": "statement", - "confidence": 1.0, + "message_intent": "statement", "question_part": "", "statement_part": "", + "query_type": QUERY_TYPE_UNKNOWN, + "retrieval_domains": default_domains, + "confidence": 1.0, } - heuristic = self._classify_intent_rule(text) - if heuristic["confidence"] >= 0.86: - return heuristic + bundle = None + planner_message_intent = "" + planner_confidence = 0.72 + try: + bundle = await WorkflowSemanticPlanner.aplan_message( + message_text=text, + source_system=str(source_system or "").strip().lower() or "adaptive_rag", + metadata=metadata, + ) + diagnostics = dict(getattr(bundle, "diagnostics", {}) or {}) + planner_message_intent = str(diagnostics.get("message_intent") or "").strip().lower() + workflows = list(getattr(bundle, "workflows", []) or []) + if workflows: + planner_confidence = max( + 0.0, + min( + 1.0, + max(float(getattr(item, "confidence", 0.0) or 0.0) for item in workflows), + ), + ) or planner_confidence + except Exception: + bundle = None - prompt = PROMPT_RAG_INTENT_CLASSIFICATION.format(message=text[:1200]).strip() + bundle_payload = bundle.to_dict() if hasattr(bundle, "to_dict") else {} + prompt = PROMPT_RAG_QUERY_SEMANTICS.format( + message=text[:1200], + planner_message_intent=planner_message_intent or "unknown", + workflow_bundle=json.dumps(bundle_payload, ensure_ascii=False)[:6000], + ).strip() + parsed: dict[str, Any] = {} try: raw = await self.llm.generate( prompt, db=self.db, tenant_id=self.tenant_id, - function_name="rag_intent_classification", + function_name="rag_query_semantics", temperature=0.0, - max_tokens=220, + max_tokens=280, use_prompt_profile=False, ) - parsed = self._parse_json_object(raw) - intent = str(parsed.get("intent") or heuristic["intent"]).strip().lower() - if intent not in {"question", "statement", "mixed"}: - intent = heuristic["intent"] - confidence = self._to_float(parsed.get("confidence"), heuristic["confidence"]) - question_part = str(parsed.get("question_part") or "").strip() - statement_part = str(parsed.get("statement_part") or "").strip() - if not question_part and intent in {"question", "mixed"}: - question_part = heuristic["question_part"] - if not statement_part and intent in {"statement", "mixed"}: - statement_part = heuristic["statement_part"] - return { - "intent": intent, - "confidence": max(0.0, min(1.0, confidence)), - "question_part": question_part, - "statement_part": statement_part, - } + parsed = self._parse_json_object_safe(raw) except Exception: - return heuristic + parsed = {} - def _classify_intent_rule(self, text: str) -> dict[str, Any]: - question_score = 0.0 - statement_score = 0.0 - lowered = text.lower() + default_intent = self._default_semantic_intent_from_bundle(bundle) + intent = str(parsed.get("intent") or default_intent).strip().lower() + if intent not in self._SEMANTIC_INTENTS: + intent = default_intent - if "?" in text or "?" in text: - question_score += 0.35 - if lowered.endswith(("吗", "么", "呢", "呀", "啊")): - question_score += 0.24 - for hint in QUESTION_HINTS: - if hint in lowered: - question_score += 0.08 - if PROJECT_EXISTENCE_RE.search(text): - question_score += 0.26 - if re.search(r"(现在|当前|目前|请问).*(项目|任务|负责人|状态|进度|列表|清单)", text): - question_score += 0.18 - for hint in STATEMENT_HINTS: - if hint in lowered: - statement_score += 0.10 + question_part = str(parsed.get("question_part") or "").strip() + statement_part = str(parsed.get("statement_part") or "").strip() + if not question_part and intent in {"question", "mixed"}: + question_part = text[:1000] + if not statement_part and intent in {"statement", "mixed"}: + statement_part = text[:1000] - sentences = [s.strip() for s in SENTENCE_SPLIT_RE.split(text) if s.strip()] - question_sentences = [s for s in sentences if "?" in s or "?" in s or any(k in s.lower() for k in QUESTION_HINTS)] - statement_sentences = [s for s in sentences if s not in question_sentences] - - if question_sentences and statement_sentences: - return { - "intent": "mixed", - "confidence": 0.88, - "question_part": " ".join(question_sentences)[:1000], - "statement_part": " ".join(statement_sentences)[:1000], - } - - if question_score >= 0.28 and question_score >= statement_score: - return { - "intent": "question", - "confidence": min(0.9, 0.62 + question_score), - "question_part": text[:1000], - "statement_part": "", - } + query_type = str(parsed.get("query_type") or QUERY_TYPE_UNKNOWN).strip().lower() + if query_type not in self._SEMANTIC_QUERY_TYPES: + query_type = QUERY_TYPE_UNKNOWN + confidence = self._safe_float(parsed.get("confidence"), planner_confidence) + normalized_message_intent = planner_message_intent or intent return { - "intent": "statement", - "confidence": min(0.9, 0.66 + statement_score), - "question_part": "", - "statement_part": text[:1000], + "intent": intent, + "message_intent": normalized_message_intent, + "question_part": question_part, + "statement_part": statement_part, + "query_type": query_type, + "retrieval_domains": self._normalize_semantic_domains(parsed.get("retrieval_domains")), + "subject_focus": self._normalize_semantic_subject_focus(parsed.get("subject_focus")), + "scope_hint": self._normalize_semantic_scope_hint(parsed.get("scope_hint")), + "feature_focus": self._normalize_semantic_feature_focus(parsed.get("feature_focus")), + "confidence": max(0.0, min(1.0, confidence)), + } + + async def classify_intent(self, message: str) -> dict[str, Any]: + semantics = await self._resolve_query_semantics( + message=message, + source_system="adaptive_rag_classify_intent", + ) + return { + "intent": str(semantics.get("intent") or "statement"), + "confidence": max(0.0, min(1.0, self._safe_float(semantics.get("confidence"), 0.72))), + "question_part": str(semantics.get("question_part") or ""), + "statement_part": str(semantics.get("statement_part") or ""), } 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 5f302053..eb6f2113 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 @@ -7,6 +7,29 @@ from .shared import * class AdaptiveRAGDeepGuardAnswerMixin: + _ANSWER_SCENE_KEYS = { + "member_workload", + "ai_pmo_requirement_plan", + "ai_pmo_business_review", + "ai_pmo_correction", + "mixed", + "project_status", + "industry_trend", + "external_trend", + "decision_choice", + "ai_pmo", + "feishu_direct", + "default", + } + _SNAPSHOT_FOCI = {"none", "identity", "architecture", "feature"} + _DEEP_INTENTS = { + "none", + "correction_turn", + "correction_escalation", + "engine_process_query", + "runtime_state_query", + } + @staticmethod def _normalize_consultant_mode_preference(value: Optional[str]) -> Optional[str]: text = str(value or "").strip().lower() @@ -275,56 +298,95 @@ class AdaptiveRAGDeepGuardAnswerMixin: "ai_pmo_ui", } - @staticmethod - def _contains_any_keyword(text: str, keywords: tuple[str, ...]) -> bool: - return any(token in text for token in keywords) + def _default_answer_scene_semantics( + self, + *, + asked_via: str, + message_intent: Optional[str], + ) -> dict[str, Any]: + normalized_intent = str(message_intent or "").strip().lower() + if normalized_intent == "requirement_plan": + scene_key = "ai_pmo_requirement_plan" + elif normalized_intent == "business_review": + scene_key = "ai_pmo_business_review" + elif normalized_intent == "correction_escalation": + scene_key = "ai_pmo_correction" + elif normalized_intent == "mixed": + scene_key = "mixed" + elif self._is_ai_pmo_process_entry(asked_via): + scene_key = "ai_pmo" + elif str(asked_via or "").strip().lower() in {"feishu", "lark", "feishu_group", "feishu_dm"}: + scene_key = "feishu_direct" + else: + scene_key = "default" + return { + "scene_key": scene_key, + "snapshot_focus": "none", + "deep_intent": "none", + "confidence": 0.0, + "reason": "", + } - @staticmethod - def _is_decision_choice_question(question: str) -> bool: - text = re.sub(r"\s+", "", str(question or "").lower()) - if not text: - return False - decision_markers = ( - "二选一", - "三选一", - "只允许一种", - "只能选一种", - "如果", - "你会", - "你选", - "优先", - "更应该", - "更适合", - "还是", + async def _resolve_answer_scene_semantics( + self, + *, + question: str, + effective_question: str, + asked_via: str, + message_intent: Optional[str], + query_type: Optional[str], + conversation_text: str, + ) -> dict[str, Any]: + fallback = self._default_answer_scene_semantics( + asked_via=asked_via, + message_intent=message_intent, ) - if not any(marker in text for marker in decision_markers): - return False - return any(marker in text for marker in ("还是", "二选一", "三选一", "只允许一种", "只能选一种", "优先", "更应该", "更适合", "你会", "你选")) - - @staticmethod - def _is_project_status_question(question: str, query_type: Optional[str]) -> bool: - text = re.sub(r"\s+", "", str(question or "").lower()) - if not text: - return False - if str(query_type or "").strip().lower() == QUERY_TYPE_STATUS: - return True - return bool( - PROJECT_PROGRESS_RE.search(text) - or any( - token in text - for token in ( - "进展", - "进度", - "阶段", - "完成率", - "完成度", - "推进", - "里程碑", - "状态", - "情况", - ) + prompt = PROMPT_RAG_ANSWER_SCENE_SEMANTICS.format( + asked_via=str(asked_via or "").strip().lower() or "pmflow", + message_intent=str(message_intent or "").strip().lower() or "unknown", + query_type=str(query_type or QUERY_TYPE_UNKNOWN).strip().lower() or QUERY_TYPE_UNKNOWN, + conversation_tail=self._truncate_compact_text(conversation_text, limit=1200), + question=str(question or "").strip()[:800], + effective_question=str(effective_question or question or "").strip()[:1000], + ).strip() + parsed: dict[str, Any] = {} + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="rag_answer_scene_semantics", + temperature=0.0, + max_tokens=260, + use_prompt_profile=False, ) - ) + parser = getattr(self, "_parse_json_object_safe", None) + parsed = parser(raw) if callable(parser) else {} + parsed = dict(parsed) if isinstance(parsed, dict) else {} + except Exception: + parsed = {} + + scene_key = str(parsed.get("scene_key") or fallback["scene_key"]).strip().lower() + if scene_key not in self._ANSWER_SCENE_KEYS: + scene_key = str(fallback["scene_key"]) + + snapshot_focus = str(parsed.get("snapshot_focus") or fallback["snapshot_focus"]).strip().lower() + if snapshot_focus not in self._SNAPSHOT_FOCI: + snapshot_focus = "none" + + deep_intent = str(parsed.get("deep_intent") or fallback["deep_intent"]).strip().lower() + if deep_intent not in self._DEEP_INTENTS: + deep_intent = "none" + + safe_float = getattr(self, "_safe_float", None) + confidence = safe_float(parsed.get("confidence"), 0.0) if callable(safe_float) else 0.0 + return { + "scene_key": scene_key, + "snapshot_focus": snapshot_focus, + "deep_intent": deep_intent, + "confidence": confidence, + "reason": str(parsed.get("reason") or "").strip(), + } @staticmethod def _format_mixed_two_block_output( @@ -388,13 +450,20 @@ class AdaptiveRAGDeepGuardAnswerMixin: relevance_max: float, query_type: Optional[str] = None, consultant_mode_preference: Optional[str] = None, + scene_semantics: Optional[dict[str, Any]] = None, ) -> tuple[str, str]: - normalized_question = re.sub(r"\s+", "", str(question or "").lower()) + scene_semantics = scene_semantics if isinstance(scene_semantics, dict) else await self._resolve_answer_scene_semantics( + question=question, + effective_question=question, + asked_via=asked_via, + message_intent=message_intent, + query_type=query_type, + conversation_text=conversation_text, + ) + normalized_scene_key = str(scene_semantics.get("scene_key") or "default").strip().lower() normalized_message_intent = str(message_intent or "").strip().lower() - member_keywords = ("谁", "成员", "负责人", "负载") - trend_keywords = ("最新", "行业", "市场", "前景", "趋势", "动态") - if self._contains_any_keyword(normalized_question, member_keywords): + if normalized_scene_key == "member_workload": filtered_context_blocks = self._filter_member_workload_context_blocks(context_blocks_text) compact_user_snapshot = self._compact_member_workload_user_snapshot(user_snapshot) compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000] @@ -415,7 +484,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference=consultant_mode_preference, ) - if normalized_message_intent == "requirement_plan": + if normalized_scene_key == "ai_pmo_requirement_plan" or normalized_message_intent == "requirement_plan": world_model_text = json.dumps( { "system_snapshot": system_snapshot or {}, @@ -441,7 +510,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference=consultant_mode_preference, ) - if normalized_message_intent in {"business_review", "correction_escalation"}: + if normalized_scene_key in {"ai_pmo_business_review", "ai_pmo_correction"} or normalized_message_intent in {"business_review", "correction_escalation"}: world_model_text = json.dumps( { "system_snapshot": system_snapshot or {}, @@ -449,7 +518,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: }, ensure_ascii=False, )[:18000] - is_business_review = normalized_message_intent == "business_review" + is_business_review = normalized_scene_key == "ai_pmo_business_review" or normalized_message_intent == "business_review" scene_key, prompt = ( "ai_pmo_business_review" if is_business_review else "ai_pmo_correction", await self._render_scene_prompt( @@ -472,7 +541,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference=consultant_mode_preference, ) - if normalized_message_intent == "mixed": + if normalized_scene_key == "mixed" or normalized_message_intent == "mixed": safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充" merged_message = f"{question}\n{safe_signal_part}".strip() scene_key, prompt = ( @@ -494,7 +563,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference=consultant_mode_preference, ) - if self._is_project_status_question(question, query_type): + if normalized_scene_key == "project_status": scene_key, prompt = ( "project_status", await self._render_scene_prompt( @@ -513,48 +582,32 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference=consultant_mode_preference, ) - if self._contains_any_keyword(normalized_question, trend_keywords): - explicit_project_context_markers = ( - token in normalized_question - for token in ("当前项目", "这个项目", "该项目", "项目里", "项目中", "你们项目", "我们项目") + if normalized_scene_key == "external_trend": + project_context_text = json.dumps( + { + "project_id": project_id if project_id is not None else "unknown", + "system_snapshot": system_snapshot or {}, + "user_snapshot": user_snapshot or {}, + }, + ensure_ascii=False, + )[:16000] + scene_key, prompt = ( + "external_trend", + await self._render_scene_prompt( + prompt_key="PROMPT_EXTERNAL_TREND_QA", + fallback_template=PROMPT_EXTERNAL_TREND_QA, + question=question, + project_context=project_context_text or "{}", + external_results=context_blocks_text or "无", + ), ) - negated_project_context_markers = ( - "不要提项目", - "别提项目", - "不提项目", - "无项目", - "不涉及项目", - "不要绑定项目", - "不要关联项目", - "无需项目", + return self._apply_consultant_mode_preference( + scene_key=scene_key, + prompt=prompt, + consultant_mode_preference=consultant_mode_preference, ) - has_project_context = any(explicit_project_context_markers) and not any( - token in normalized_question for token in negated_project_context_markers - ) - if has_project_context: - project_context_text = json.dumps( - { - "project_id": project_id if project_id is not None else "unknown", - "system_snapshot": system_snapshot or {}, - "user_snapshot": user_snapshot or {}, - }, - ensure_ascii=False, - )[:16000] - scene_key, prompt = ( - "external_trend", - await self._render_scene_prompt( - prompt_key="PROMPT_EXTERNAL_TREND_QA", - fallback_template=PROMPT_EXTERNAL_TREND_QA, - question=question, - project_context=project_context_text or "{}", - external_results=context_blocks_text or "无", - ), - ) - return self._apply_consultant_mode_preference( - scene_key=scene_key, - prompt=prompt, - consultant_mode_preference=consultant_mode_preference, - ) + + if normalized_scene_key == "industry_trend": scene_key, prompt = ( "industry_trend", await self._render_scene_prompt( @@ -569,7 +622,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference=consultant_mode_preference, ) - if self._is_decision_choice_question(question): + if normalized_scene_key == "decision_choice": scene_key, prompt = ( "decision_choice", await self._render_scene_prompt( @@ -584,7 +637,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference=consultant_mode_preference, ) - if self._is_ai_pmo_process_entry(asked_via): + if normalized_scene_key == "ai_pmo": world_model_text = json.dumps( { "system_snapshot": system_snapshot or {}, @@ -615,7 +668,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: ) normalized_asked_via = str(asked_via or "").strip().lower() - if normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}: + if normalized_scene_key == "feishu_direct" or normalized_asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}: project_manifest_text = self._build_feishu_direct_project_manifest( project_id=project_id, context_blocks_text=context_blocks_text, @@ -671,21 +724,15 @@ class AdaptiveRAGDeepGuardAnswerMixin: *, system_snapshot: Optional[dict[str, Any]], user_snapshot: Optional[dict[str, Any]], + snapshot_focus: Optional[str] = None, ) -> Optional[str]: - text = str(question or "").strip().lower() - if not text: - return None - if self._is_correction_query(text): - return None - if self._is_engine_process_query(text): - return None - - ask_identity = any(k in text for k in IDENTITY_KEYWORDS) - ask_architecture = any(k in text for k in ARCHITECTURE_KEYWORDS) - has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) - ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) - if not ask_identity and not ask_architecture and not ask_feature: + text = str(question or "").strip() + normalized_focus = str(snapshot_focus or "none").strip().lower() + if not text or normalized_focus == "none": return None + ask_identity = normalized_focus == "identity" + ask_architecture = normalized_focus == "architecture" + ask_feature = normalized_focus == "feature" system_data = system_snapshot if isinstance(system_snapshot, dict) else {} assistant = system_data.get("assistant_profile") if isinstance(system_data.get("assistant_profile"), dict) else {} @@ -769,9 +816,15 @@ class AdaptiveRAGDeepGuardAnswerMixin: lines.append("来源: System Snapshot / User Snapshot") return "\n".join(lines)[:3200] - async def _build_member_workload_direct_answer(self, question: str) -> Optional[str]: + async def _build_member_workload_direct_answer( + self, + question: str, + *, + scene_semantics: Optional[dict[str, Any]] = None, + ) -> Optional[str]: text = str(question or "").strip() - if not text or not self._contains_any_keyword(text, ("负载", "工作量", "工时")): + normalized_scene = scene_semantics if isinstance(scene_semantics, dict) else {} + if not text or str(normalized_scene.get("scene_key") or "").strip().lower() != "member_workload": return None user_rows = ( await self.db.execute( @@ -870,12 +923,11 @@ class AdaptiveRAGDeepGuardAnswerMixin: mixed_signal_part: Optional[str] = None, query_type: Optional[str] = None, consultant_mode_preference: Optional[str] = None, + scene_semantics: Optional[dict[str, Any]] = None, + query_semantics: Optional[dict[str, Any]] = None, ) -> str: active_thinking_mode = self._normalize_thinking_mode(thinking_mode) deep_thinking = active_thinking_mode == THINKING_MODE_DEEP - member_workload_direct_answer = await self._build_member_workload_direct_answer(question) - if member_workload_direct_answer: - return member_workload_direct_answer[:3200] context_blocks: list[str] = [] used_tokens = 0 max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32)) @@ -904,6 +956,20 @@ class AdaptiveRAGDeepGuardAnswerMixin: context_blocks_text = chr(10).join(context_blocks) if context_blocks else "无" system_snapshot_text = json.dumps(system_snapshot or {}, ensure_ascii=False)[:12000] user_snapshot_text = json.dumps(user_snapshot or {}, ensure_ascii=False)[:6000] + resolved_scene_semantics = scene_semantics if isinstance(scene_semantics, dict) else await self._resolve_answer_scene_semantics( + question=question, + effective_question=question, + asked_via=asked_via, + message_intent=message_intent, + query_type=query_type, + conversation_text=conversation_text, + ) + member_workload_direct_answer = await self._build_member_workload_direct_answer( + question, + scene_semantics=resolved_scene_semantics, + ) + if member_workload_direct_answer: + return member_workload_direct_answer[:3200] scene_key, prompt = await self._select_scene_prompt( question=question, project_id=project_id, @@ -919,6 +985,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: relevance_max=relevance_max, query_type=query_type, consultant_mode_preference=consultant_mode_preference, + scene_semantics=resolved_scene_semantics, ) remember_prompt_variant = getattr(self, "_remember_answer_prompt_variant", None) if callable(remember_prompt_variant): @@ -964,5 +1031,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: answer_text=text, retrieved=retrieved, system_snapshot=system_snapshot, + scene_semantics=resolved_scene_semantics, + query_semantics=query_semantics, ) return str(enforced or text)[:3200] 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 3d2f4320..70e01c87 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 @@ -5,88 +5,18 @@ from .shared import * class AdaptiveRAGDeepGuardRulesMixin: - @staticmethod - def _contains_any(text: str, keywords: tuple[str, ...]) -> bool: - lowered = str(text or "").strip().lower() - if not lowered: - return False - return any(keyword in lowered for keyword in keywords) + _DEEP_INTENTS = { + "none", + "correction_turn", + "correction_escalation", + "engine_process_query", + "runtime_state_query", + } @classmethod - def _is_correction_query(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - if BRIEF_NEGATIVE_FEEDBACK_RE.match(text): - return True - if cls._contains_any(text, CORRECTION_CUE_KEYWORDS): - return True - if re.search(r"(不是|别|不要).{0,8}(系统架构|架构)", text): - return True - return False - - @classmethod - def _is_correction_escalation_query(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - escalation_hints = ( - "调用高级模型", - "高级模型", - "更强模型", - "高配模型", - "重新回答", - "重答", - ) - has_escalation_hint = any(token in text for token in escalation_hints) - if not has_escalation_hint: - return False - return cls._is_correction_query(text) or any(token in text for token in ("重新回答", "重答")) - - @classmethod - def _is_runtime_state_query(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - runtime_subject = ( - "高级模型", - "高配", - "模型路由", - "路由机制", - "引擎配置", - "模型配置", - "高阶模型", - ) - runtime_state = ("启用", "开启", "打开", "可用", "生效", "激活", "当前是不是", "是否") - return any(token in text for token in runtime_subject) and any(token in text for token in runtime_state) - - @classmethod - def _has_explicit_target_scope(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - explicit_groups: tuple[tuple[str, ...], ...] = ( - ENGINE_PROCESS_KEYWORDS, - FEATURE_QUERY_KEYWORDS, - ARCHITECTURE_KEYWORDS, - PROJECT_KEYWORDS, - PEOPLE_KEYWORDS, - DOC_KEYWORDS, - ) - for group in explicit_groups: - if cls._contains_any(text, group): - return True - if any(token in text for token in ("openclaw", "llm", "model", "engine", "api")): - return True - return False - - @classmethod - def _is_engine_process_query(cls, question: str) -> bool: - return cls._contains_any(question, ENGINE_PROCESS_KEYWORDS) - - @classmethod - def _is_explicit_deep_request(cls, question: str) -> bool: - return cls._contains_any(question, DEEP_EXPLICIT_KEYWORDS) + def _normalize_deep_intent(cls, value: Any) -> str: + normalized = str(value or "none").strip().lower() + return normalized if normalized in cls._DEEP_INTENTS else "none" @classmethod def _has_runtime_model_evidence(cls, sources: list[dict[str, Any]]) -> bool: @@ -123,47 +53,6 @@ class AdaptiveRAGDeepGuardRulesMixin: return False return True - @classmethod - def _forced_deep_reason( - cls, - *, - question: str, - effective_question: str, - conversation: list[dict[str, str]], - ) -> str: - if cls._is_correction_escalation_query(question) or cls._is_correction_escalation_query(effective_question): - return "correction_escalation" - if cls._is_correction_query(question) or cls._is_correction_query(effective_question): - if conversation: - return "correction_turn" - if cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question): - return "engine_process_query" - return "" - - @classmethod - def _should_force_deep_for_channel( - cls, - *, - asked_via: str, - question: str, - effective_question: str, - reason: str, - ) -> bool: - if not str(reason or "").strip(): - return False - channel = str(asked_via or "").strip().lower() - if channel != "feishu": - return True - - if str(reason or "").strip() == "correction_escalation": - return True - if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): - return True - if str(reason or "").strip() == "engine_process_query": - combined = f"{question} {effective_question}".strip() - return cls._has_explicit_target_scope(combined) - return False - @classmethod def _is_snapshot_only_sources(cls, sources: list[dict[str, Any]]) -> bool: if not isinstance(sources, list) or not sources: @@ -181,67 +70,50 @@ class AdaptiveRAGDeepGuardRulesMixin: return True @classmethod - def _should_auto_deep_retry( + def _evaluate_post_retrieval_guard( cls, *, - question: str, - effective_question: str, + deep_intent: str, sources: list[dict[str, Any]], relevance_max: float, - ) -> str: - process_sensitive = cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question) - correction_sensitive = cls._is_correction_query(question) or cls._is_correction_query(effective_question) - if not process_sensitive and not correction_sensitive: - return "" + ) -> dict[str, Any]: + normalized_intent = cls._normalize_deep_intent(deep_intent) snapshot_only = cls._is_snapshot_only_sources(sources) - if snapshot_only: - return "snapshot_only" - if float(relevance_max or 0.0) < 0.62: - return "low_relevance" - return "" + static_runtime_sources = cls._is_static_runtime_sources(sources) + auto_retry_reason = "" + guard_focus = "none" + if normalized_intent in {"correction_turn", "correction_escalation", "engine_process_query", "runtime_state_query"}: + if snapshot_only: + auto_retry_reason = "snapshot_only" + elif float(relevance_max or 0.0) < 0.62: + auto_retry_reason = "low_relevance" + should_block = False + if normalized_intent == "runtime_state_query": + should_block = static_runtime_sources + if should_block: + guard_focus = "runtime_state" + elif normalized_intent == "engine_process_query": + should_block = snapshot_only and float(relevance_max or 0.0) < 0.68 + if should_block: + guard_focus = "call_chain" + return { + "auto_retry_reason": auto_retry_reason, + "should_block_with_guard": should_block, + "guard_focus": guard_focus, + "snapshot_only_sources": snapshot_only, + "static_runtime_sources": static_runtime_sources, + } @classmethod - def _allow_auto_deep_retry_for_channel( + def _build_evidence_guard_answer( cls, - *, - asked_via: str, question: str, - effective_question: str, - reason: str, - ) -> bool: - if not str(reason or "").strip(): - return False - channel = str(asked_via or "").strip().lower() - if channel != "feishu": - return True - if str(reason or "").strip() == "correction_escalation": - return True - if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): - return True - return False - - @classmethod - def _should_block_answer_with_guard( - cls, *, - question: str, - effective_question: str, - sources: list[dict[str, Any]], - relevance_max: float, - ) -> bool: - if cls._is_runtime_state_query(question) or cls._is_runtime_state_query(effective_question): - return cls._is_static_runtime_sources(sources) - if not (cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question)): - return False - if not cls._is_snapshot_only_sources(sources): - return False - return float(relevance_max or 0.0) < 0.68 - - @classmethod - def _build_evidence_guard_answer(cls, question: str) -> str: + guard_focus: str = "call_chain", + ) -> str: raw = str(question or "").strip() target = raw[:80] if raw else "该问题" - if cls._is_runtime_state_query(raw): + if str(guard_focus or "").strip().lower() == "runtime_state": return ( f"关于“{target}”,代码里存在该能力入口,但当前启用状态无法仅凭静态资料确认。" "请提供运行态配置、路由快照、引擎配置或真实调用记录后,我再给出可核验结论。" @@ -250,62 +122,3 @@ class AdaptiveRAGDeepGuardRulesMixin: f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。" "请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。" ) - - @classmethod - def _is_snapshot_direct_question(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - if cls._is_correction_query(text): - return False - if cls._is_engine_process_query(text): - return False - has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) - ask_feature_purpose = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) - return ( - any(k in text for k in IDENTITY_KEYWORDS) - or any(k in text for k in ARCHITECTURE_KEYWORDS) - or ask_feature_purpose - ) - - @staticmethod - def _expand_followup_question(question: str, conversation: list[dict[str, str]]) -> str: - current = str(question or "").strip() - if not current: - return current - - lowered = current.lower() - # 短句不等于追问:若已包含明确业务对象,则按独立问题处理,避免串错上下文。 - has_explicit_subject = any( - marker in lowered for marker in ("项目", "信号", "信号源", "任务", "系统", "架构", "数据库", "redis", "接口", "用户") - ) - is_short_followup = len(current) <= 16 and not has_explicit_subject - has_referential = any(k in lowered for k in FOLLOWUP_REFERENTIAL_KEYWORDS) - if not (is_short_followup or has_referential): - return current - - anchor_question = "" - for turn in reversed(conversation[-5:]): - candidate = str(turn.get("question") or "").strip() - if not candidate: - continue - if candidate == current: - continue - if any( - marker in candidate - for marker in ("项目", "系统", "任务", "信号", "接口", "数据库", "redis", "架构", "模块") - ): - anchor_question = candidate - break - - if not anchor_question: - return current - - is_enumeration = any(k in lowered for k in FOLLOWUP_ENUMERATION_KEYWORDS) - if is_enumeration and ("信号" in lowered or "信号" in anchor_question): - return f"{anchor_question}。请列出该项目关联的信号标题和ID。" - if is_enumeration and ("任务" in lowered or "任务" in anchor_question): - return f"{anchor_question}。请列出对应任务的标题和状态。" - if is_enumeration and "项目" in anchor_question: - return f"{anchor_question}。请列出对应项目的名称和ID。" - return f"{anchor_question}。补充问题:{current}" diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py index a9a6c731..13fd7cb5 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py @@ -5,35 +5,27 @@ from .shared import * class AdaptiveRAGDeterministicQueryCoreMixin: - def _classify_query_type(self, question: str) -> str: - text = str(question or "").strip().lower() - if not text: - return QUERY_TYPE_UNKNOWN - if "项目" in text and any(token in text for token in ("风险", "阻塞", "卡点", "瓶颈", "问题")): - return QUERY_TYPE_STATUS - # 若同一句同时出现“多少/几个”和“列出/分别”,优先按 list 处理,避免追问被误判为 count。 - if any(k in text for k in LIST_HINTS): - return QUERY_TYPE_LIST - if any(k in text for k in COUNT_HINTS): - return QUERY_TYPE_COUNT - if any(k in text for k in STATUS_HINTS): - return QUERY_TYPE_STATUS - if "为什么" in text or "如何" in text or "怎么" in text: - return QUERY_TYPE_EXPLAIN - return QUERY_TYPE_UNKNOWN - async def _run_deterministic_query( self, *, question: str, project_id: Optional[int], query_type: str, + query_semantics: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: q_type = str(query_type or QUERY_TYPE_UNKNOWN) if q_type == QUERY_TYPE_COUNT: - return await self._run_count_query(question=question, project_id=project_id) + return await self._run_count_query( + question=question, + project_id=project_id, + query_semantics=query_semantics, + ) if q_type == QUERY_TYPE_LIST: - return await self._run_list_query(question=question, project_id=project_id) + return await self._run_list_query( + question=question, + project_id=project_id, + query_semantics=query_semantics, + ) if q_type == QUERY_TYPE_STATUS: created_at = await self._try_fast_project_created_at_answer(clean_q=question, project_id=project_id) if created_at: @@ -47,7 +39,11 @@ class AdaptiveRAGDeterministicQueryCoreMixin: progress.setdefault("confidence", 0.98) progress.setdefault("missing_fields", []) return progress - existence = await self._try_fast_project_existence_answer(clean_q=question, project_id=project_id) + existence = await self._try_fast_project_existence_answer( + clean_q=question, + project_id=project_id, + query_semantics=query_semantics, + ) if existence: existence["query_type"] = QUERY_TYPE_STATUS existence.setdefault("confidence", 0.96) diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py index 73d6fb91..9464c435 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_count.py @@ -5,21 +5,26 @@ from .shared import * class AdaptiveRAGDeterministicQueryCountMixin: - async def _run_count_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]: + async def _run_count_query( + self, + *, + question: str, + project_id: Optional[int], + query_semantics: Optional[dict[str, Any]] = None, + ) -> Optional[dict[str, Any]]: text = str(question or "").lower() started = time.perf_counter() - has_project = "项目" in text - has_task = "任务" in text - has_signal = "信号" in text or "信号源" in text - has_people = self._is_people_query(text) + subject_focus = self._query_subject_focus(query_semantics) + scope_hint = self._query_scope_hint(query_semantics) + project_scope_required = scope_hint == "project" or project_id is not None # 先处理更具体的对象(信号/任务),避免“xxx项目里有多少信号”误落到项目总数分支。 - if has_signal: + if subject_focus == "signal": scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( question=text, project_id=project_id, ) - if has_project and scoped_project_id is None and scoped_keyword: + if project_scope_required and scoped_project_id is None and scoped_keyword: elapsed = int((time.perf_counter() - started) * 1000) return { "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计信号数。", @@ -69,12 +74,12 @@ class AdaptiveRAGDeterministicQueryCountMixin: "missing_fields": [], } - if has_task: + if subject_focus == "task": scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( question=text, project_id=project_id, ) - if has_project and scoped_project_id is None and scoped_keyword: + if project_scope_required and scoped_project_id is None and scoped_keyword: elapsed = int((time.perf_counter() - started) * 1000) return { "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计任务数。", @@ -124,16 +129,16 @@ class AdaptiveRAGDeterministicQueryCountMixin: "missing_fields": [], } - if has_people: + if subject_focus == "people": scoped_project_id: Optional[int] = None scoped_project_name: Optional[str] = None scoped_keyword = "" - if has_project or project_id is not None: + if project_scope_required: scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( question=text, project_id=project_id, ) - if has_project and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword): + if project_scope_required and scoped_project_id is None and scoped_keyword: elapsed = int((time.perf_counter() - started) * 1000) return { "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法统计成员人数。", @@ -194,7 +199,7 @@ class AdaptiveRAGDeterministicQueryCountMixin: "missing_fields": [], } - if has_project: + if subject_focus == "project": stmt = select(func.count(Project.id)).where(Project.tenant_id == self.tenant_id) if project_id is not None: stmt = stmt.where(Project.id == int(project_id)) @@ -217,4 +222,3 @@ class AdaptiveRAGDeterministicQueryCountMixin: "missing_fields": [], } return None - diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py index dd4c261b..c400cfcc 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_list.py @@ -5,19 +5,29 @@ from .shared import * class AdaptiveRAGDeterministicQueryListMixin: - async def _run_list_query(self, *, question: str, project_id: Optional[int]) -> Optional[dict[str, Any]]: + async def _run_list_query( + self, + *, + question: str, + project_id: Optional[int], + query_semantics: Optional[dict[str, Any]] = None, + ) -> Optional[dict[str, Any]]: text = str(question or "").lower() started = time.perf_counter() - if self._is_people_query(text): + subject_focus = self._query_subject_focus(query_semantics) + scope_hint = self._query_scope_hint(query_semantics) + project_scope_required = scope_hint == "project" or project_id is not None + + if subject_focus == "people": scoped_project_id: Optional[int] = None scoped_project_name: Optional[str] = None scoped_keyword = "" - if "项目" in text or project_id is not None: + if project_scope_required: scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( question=text, project_id=project_id, ) - if "项目" in text and scoped_project_id is None and scoped_keyword and not self._is_people_query(scoped_keyword): + if project_scope_required and scoped_project_id is None and scoped_keyword: elapsed = int((time.perf_counter() - started) * 1000) return { "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出成员清单。", @@ -146,12 +156,12 @@ class AdaptiveRAGDeterministicQueryListMixin: "missing_fields": [], } - if "信号" in text or "信号源" in text: + if subject_focus == "signal": scoped_project_id, scoped_project_name, scoped_keyword = await self._resolve_project_scope_for_question( question=text, project_id=project_id, ) - if "项目" in text and scoped_project_id is None and scoped_keyword: + if project_scope_required and scoped_project_id is None and scoped_keyword: elapsed = int((time.perf_counter() - started) * 1000) return { "answer": f"未检索到名称或描述包含“{scoped_keyword}”的项目,无法列出信号清单。", @@ -278,4 +288,3 @@ class AdaptiveRAGDeterministicQueryListMixin: "missing_fields": [], } return None - diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py index 8d0a8a04..d7b8b04f 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py @@ -275,20 +275,20 @@ class AdaptiveRAGFastAnswerProjectMainMixin: *, clean_q: str, project_id: Optional[int], + query_semantics: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: question = str(clean_q or "").strip() if not question: return None - if "项目" not in question and "系统" not in question: - return None + semantics = query_semantics if isinstance(query_semantics, dict) else {} + subject_focus = self._query_subject_focus(semantics) + scope_hint = self._query_scope_hint(semantics) + message_intent = str(semantics.get("message_intent") or semantics.get("intent") or "").strip().lower() if ( - "吗" not in question - and "?" not in question - and "?" not in question - and "是否有" not in question - and "有没有" not in question - and "有无" not in question - and not PROJECT_EXISTENCE_RE.search(question) + project_id is None + and message_intent != "project_lookup_question" + and subject_focus not in {"project", "unknown"} + and scope_hint != "project" ): return None diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py index 72efa445..874ef64c 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_utils.py @@ -5,6 +5,10 @@ from .shared import * class AdaptiveRAGFastAnswerProjectUtilsMixin: + _SEMANTIC_SUBJECT_FOCI = {"people", "project", "task", "signal", "charter", "workload", "feature", "unknown"} + _ALLOWED_SCOPE_VALUES = {"none", "global", "project"} + _SEMANTIC_FEATURE_FOCI = {"none", "purpose", "entry", "catalog"} + @staticmethod def _extract_project_lookup_keyword(question: str) -> str: text = str(question or "").strip() @@ -82,10 +86,26 @@ class AdaptiveRAGFastAnswerProjectUtilsMixin: terms.append(t) return terms[:6] - @staticmethod - def _is_people_query(question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - return any(k in text for k in PEOPLE_KEYWORDS) + @classmethod + def _query_subject_focus(cls, query_semantics: Optional[dict[str, Any]]) -> str: + payload = query_semantics if isinstance(query_semantics, dict) else {} + normalized = str(payload.get("subject_focus") or "").strip().lower() + if normalized in cls._SEMANTIC_SUBJECT_FOCI: + return normalized + return "unknown" + @classmethod + def _query_scope_hint(cls, query_semantics: Optional[dict[str, Any]]) -> str: + payload = query_semantics if isinstance(query_semantics, dict) else {} + normalized = str(payload.get("scope_hint") or "").strip().lower() + if normalized in cls._ALLOWED_SCOPE_VALUES: + return normalized + return "none" + + @classmethod + def _query_feature_focus(cls, query_semantics: Optional[dict[str, Any]]) -> str: + payload = query_semantics if isinstance(query_semantics, dict) else {} + normalized = str(payload.get("feature_focus") or "").strip().lower() + if normalized in cls._SEMANTIC_FEATURE_FOCI: + return normalized + return "none" diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py index 772a2454..d3679f3b 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py @@ -11,13 +11,15 @@ class AdaptiveRAGFastAnswerSemanticMixinP1: *, clean_q: str, project_id: Optional[int], + query_semantics: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: question = str(clean_q or "").strip() if not question: return None - if self._is_people_query(question): + subject_focus = self._query_subject_focus(query_semantics) + if subject_focus in {"people", "feature", "workload"}: return None - if not any(k in question for k in ("项目", "系统", "进度", "状态", "有没有", "是否", "存在", "立项", "任务")): + if subject_focus not in {"project", "task", "signal", "charter", "unknown"}: return None db_started = time.perf_counter() diff --git a/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py index ae115d3c..15291dd8 100644 --- a/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py +++ b/backend/app/services/adaptive_rag_service/mixin_followup_anchor.py @@ -9,33 +9,8 @@ from .shared import * FOLLOWUP_RECENT_REPLY_WINDOW_MINUTES = 5 FOLLOWUP_CANDIDATE_LIMIT = 3 -FOLLOWUP_META_HINTS = ( - "哪个模型", - "什么模型", - "谁回复", - "谁回答", - "为什么报错", - "为什么会报错", - "为什么失败", - "什么意思", - "你说的这个", - "你刚才那句", - "刚才那条", - "那条回复", - "上一条回复", -) -FOLLOWUP_REPLY_TO_EXPLANATION_HINTS = ( - "什么情况", - "怎么回事", - "详细说下", - "详细说一下", - "详细解释", - "展开说说", - "说详细点", -) FOLLOWUP_SELECTION_DIGIT_RE = re.compile(r"^\s*([1-3])\s*$") FOLLOWUP_SELECTION_ORDINAL_RE = re.compile(r"^\s*第?([一二三123])(?:条|个|项)?\s*$") -FOLLOWUP_FRESH_QUESTION_HINTS = ("?", "?", "为什么", "怎么", "如何", "哪些", "多少", "有没有", "帮我", "请", "解释") FOLLOWUP_REMOVABLE_TOKENS = ( "上一条", "上一句", @@ -60,6 +35,7 @@ FOLLOWUP_REMOVABLE_TOKENS = ( @dataclass class FollowupAnchorResolution: + followup_mode: str = "none" anchor_mode: str = "no_anchor" anchor_kind: Optional[str] = None anchor_reply_id: Optional[int] = None @@ -73,6 +49,16 @@ class FollowupAnchorResolution: class AdaptiveRAGFollowupAnchorMixin: + _FOLLOWUP_MODES = {"none", "meta", "referential"} + _FOLLOWUP_META_KINDS = { + "none", + "reply_model_identity", + "reply_error_reason", + "reply_source", + "reply_explanation", + } + _FOLLOWUP_STRATEGIES = {"none", "explicit", "implicit_recent", "clarify"} + @staticmethod def _normalize_followup_created_at(created_at: Optional[datetime]) -> Optional[datetime]: if created_at is None: @@ -102,66 +88,93 @@ class AdaptiveRAGFollowupAnchorMixin: return cleaned[:160] @staticmethod - def _looks_like_meta_followup(question: str) -> bool: - normalized = re.sub(r"\s+", "", str(question or "")).lower() - return any(token in normalized for token in FOLLOWUP_META_HINTS) + def _normalize_followup_candidate_index(value: Any) -> int: + try: + normalized = int(value) + except Exception: + return 0 + return normalized if normalized > 0 else 0 - @staticmethod - def _classify_meta_followup_kind(question: str) -> Optional[str]: - normalized = re.sub(r"\s+", "", str(question or "")).lower() - if any(token in normalized for token in ("哪个模型", "什么模型", "谁回复", "谁回答")): - return "reply_model_identity" - if any(token in normalized for token in ("为什么报错", "为什么会报错", "为什么失败")): - return "reply_error_reason" - if any(token in normalized for token in ("什么情况", "怎么回事")): - return "reply_error_reason" - if any(token in normalized for token in ("来源", "根据什么", "什么依据")): - return "reply_source" - if any( - token in normalized - for token in ( - "什么意思", - "你说的这个", - "你刚才那句", - "刚才那条", - "那条回复", - "上一条回复", - "详细说下", - "详细说一下", - "详细解释", - "展开说说", - "说详细点", + async def _resolve_followup_anchor_semantics( + self, + *, + message: str, + has_explicit_reply_anchor: bool, + recent_candidates: list[dict[str, Any]], + ) -> dict[str, Any]: + text = str(message or "").strip() + if not text: + return { + "followup_mode": "none", + "meta_followup_kind": "none", + "anchor_strategy": "none", + "candidate_index": 0, + "confidence": 1.0, + } + + prompt = PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS.format( + message=text[:600], + has_explicit_reply_anchor="true" if has_explicit_reply_anchor else "false", + recent_candidates=json.dumps( + [ + { + "index": int(candidate.get("index") or 0), + "label": str(candidate.get("label") or "").strip()[:80], + "question": str(candidate.get("question") or "").strip()[:160], + "anchor_reply_preview": str(candidate.get("anchor_reply_preview") or "").strip()[:160], + "relative_time": str(candidate.get("relative_time") or "").strip(), + } + for candidate in recent_candidates[:FOLLOWUP_CANDIDATE_LIMIT] + if isinstance(candidate, dict) + ], + ensure_ascii=False, + )[:2400], + ).strip() + + parsed: dict[str, Any] = {} + try: + raw = await self.llm.generate( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="rag_followup_anchor_semantics", + temperature=0.0, + max_tokens=240, + use_prompt_profile=False, ) - ): - return "reply_explanation" - return "reply_explanation" if normalized else None + parser = getattr(self, "_parse_json_object_safe", None) + parsed = parser(raw) if callable(parser) else {} + parsed = dict(parsed) if isinstance(parsed, dict) else {} + except Exception: + parsed = {} - @staticmethod - def _looks_like_reply_to_explanation_request(question: str) -> bool: - normalized = re.sub(r"\s+", "", str(question or "")).lower() - if not normalized or len(normalized) > 32: - return False - return any(token in normalized for token in FOLLOWUP_REPLY_TO_EXPLANATION_HINTS) + followup_mode = str(parsed.get("followup_mode") or "none").strip().lower() + if followup_mode not in self._FOLLOWUP_MODES: + followup_mode = "none" - @staticmethod - def _looks_like_referential_followup(question: str) -> bool: - normalized = re.sub(r"\s+", "", str(question or "")).lower() - if not normalized: - return False - # 独立的“某个项目/系统是否存在”查询不应被当成对上一条回复的追问。 - if PROJECT_EXISTENCE_RE.search(normalized) and any(token in normalized for token in ("项目", "系统")): - return False - return any(token in normalized for token in (*FOLLOWUP_REFERENTIAL_KEYWORDS, "那条", "这条", "上一条", "上一句")) + meta_followup_kind = str(parsed.get("meta_followup_kind") or "none").strip().lower() + if meta_followup_kind not in self._FOLLOWUP_META_KINDS: + meta_followup_kind = "none" + if followup_mode != "meta": + meta_followup_kind = "none" - @staticmethod - def _looks_like_fresh_question(question: str) -> bool: - text = str(question or "").strip() - normalized = re.sub(r"\s+", "", text) - if not normalized: - return False - if FOLLOWUP_SELECTION_DIGIT_RE.match(text) or FOLLOWUP_SELECTION_ORDINAL_RE.match(text): - return False - return len(normalized) >= 5 and any(token in normalized for token in FOLLOWUP_FRESH_QUESTION_HINTS) + anchor_strategy = str(parsed.get("anchor_strategy") or "none").strip().lower() + if anchor_strategy not in self._FOLLOWUP_STRATEGIES: + anchor_strategy = "none" + + confidence = 0.0 + safe_float = getattr(self, "_safe_float", None) + if callable(safe_float): + confidence = safe_float(parsed.get("confidence"), 0.0) + + return { + "followup_mode": followup_mode, + "meta_followup_kind": meta_followup_kind, + "anchor_strategy": anchor_strategy, + "candidate_index": self._normalize_followup_candidate_index(parsed.get("candidate_index")), + "confidence": confidence, + "reason": str(parsed.get("reason") or "").strip(), + } @staticmethod def _candidate_relative_time(created_at: Optional[datetime]) -> str: @@ -460,8 +473,6 @@ class AdaptiveRAGFollowupAnchorMixin: selected = candidate break if selected is None: - if self._looks_like_fresh_question(question): - self._clear_pending_clarification(session_state=session_state) return None self._clear_pending_clarification(session_state=session_state) return { @@ -485,17 +496,9 @@ class AdaptiveRAGFollowupAnchorMixin: text = str(question or "").strip() if not text: return FollowupAnchorResolution() - looks_like_short_explanation = self._looks_like_reply_to_explanation_request(text) has_explicit_reply_to = isinstance(thread_context, dict) and bool( str(thread_context.get("reply_to_message_id") or "").strip() ) - is_meta_followup = self._looks_like_meta_followup(text) or ( - has_explicit_reply_to and looks_like_short_explanation - ) - meta_followup_kind = self._classify_meta_followup_kind(text) if is_meta_followup else None - is_referential_followup = self._looks_like_referential_followup(text) - if not is_meta_followup and not is_referential_followup and not looks_like_short_explanation: - return FollowupAnchorResolution(is_meta_followup=False) active_session_id = int(session_id) if session_id is not None else ( int(context_session_id) if context_session_id is not None else None @@ -504,19 +507,6 @@ class AdaptiveRAGFollowupAnchorMixin: thread_context=thread_context, source_message_id=source_message_id, ) - if explicit_anchor is not None: - if looks_like_short_explanation and not is_meta_followup: - is_meta_followup = True - meta_followup_kind = self._classify_meta_followup_kind(text) - return FollowupAnchorResolution( - anchor_mode="explicit", - anchor_kind=str(explicit_anchor.get("anchor_kind") or "explicit_reply"), - anchor_reply_id=int(explicit_anchor.get("anchor_id") or 0) or None, - anchor_session_id=active_session_id, - is_meta_followup=is_meta_followup, - meta_followup_kind=meta_followup_kind, - anchor_reply=explicit_anchor, - ) scope_key = str(conversation_scope_key or "").strip() if not scope_key and (source_chat_id or source_sender_open_id): scope_key = build_feishu_conversation_scope_key( @@ -528,17 +518,29 @@ class AdaptiveRAGFollowupAnchorMixin: session_id=active_session_id, conversation_scope_key=scope_key, ) - if not candidates: - return FollowupAnchorResolution( - anchor_mode="no_anchor", - anchor_session_id=active_session_id, - is_meta_followup=is_meta_followup, - meta_followup_kind=meta_followup_kind, - clarification_code="no_anchor_found" if (is_meta_followup or is_referential_followup) else None, - ) - if looks_like_short_explanation and not is_meta_followup: - is_meta_followup = True - meta_followup_kind = self._classify_meta_followup_kind(text) + semantics = await self._resolve_followup_anchor_semantics( + message=text, + has_explicit_reply_anchor=has_explicit_reply_to or explicit_anchor is not None, + recent_candidates=candidates, + ) + followup_mode = str(semantics.get("followup_mode") or "none").strip().lower() + is_meta_followup = followup_mode == "meta" + is_referential_followup = followup_mode == "referential" + meta_followup_kind = ( + str(semantics.get("meta_followup_kind") or "").strip().lower() or None + if is_meta_followup + else None + ) + anchor_strategy = str(semantics.get("anchor_strategy") or "none").strip().lower() + candidate_index = self._normalize_followup_candidate_index(semantics.get("candidate_index")) + + if explicit_anchor is not None and followup_mode == "none": + followup_mode = "referential" + is_referential_followup = True + anchor_strategy = "explicit" + + if followup_mode == "none": + return FollowupAnchorResolution() normalized = self._normalize_followup_selection_text(text) matching = [ @@ -552,8 +554,22 @@ class AdaptiveRAGFollowupAnchorMixin: ) ] chosen = matching[0] if len(matching) == 1 else None + + if explicit_anchor is not None and anchor_strategy in {"explicit", "implicit_recent"}: + return FollowupAnchorResolution( + followup_mode=followup_mode, + anchor_mode="explicit", + anchor_kind=str(explicit_anchor.get("anchor_kind") or "explicit_reply"), + anchor_reply_id=int(explicit_anchor.get("anchor_id") or 0) or None, + anchor_session_id=active_session_id, + is_meta_followup=is_meta_followup, + meta_followup_kind=meta_followup_kind, + anchor_reply=explicit_anchor, + ) + if chosen is not None: return FollowupAnchorResolution( + followup_mode=followup_mode, anchor_mode="explicit", anchor_kind="recent_scope_reply", anchor_reply_id=int(chosen["qa_id"]), @@ -563,21 +579,35 @@ class AdaptiveRAGFollowupAnchorMixin: anchor_reply=chosen, ) - if len(candidates) == 1: - chosen = candidates[0] + if not candidates: return FollowupAnchorResolution( - anchor_mode="implicit_recent_reply", - anchor_kind="meta_recent_reply" if is_meta_followup else "recent_scope_reply", - anchor_reply_id=int(chosen["qa_id"]), + followup_mode=followup_mode, + anchor_mode="no_anchor", anchor_session_id=active_session_id, is_meta_followup=is_meta_followup, meta_followup_kind=meta_followup_kind, - anchor_reply=chosen, + clarification_code="no_anchor_found", ) - if isinstance(thread_context, dict) and str(thread_context.get("reply_to_message_id") or "").strip(): + if candidate_index: + for candidate in candidates: + if int(candidate.get("index") or 0) != candidate_index: + continue + return FollowupAnchorResolution( + followup_mode=followup_mode, + anchor_mode="implicit_recent_reply", + anchor_kind="meta_recent_reply" if is_meta_followup else "recent_scope_reply", + anchor_reply_id=int(candidate["qa_id"]), + anchor_session_id=active_session_id, + is_meta_followup=is_meta_followup, + meta_followup_kind=meta_followup_kind, + anchor_reply=candidate, + ) + + if anchor_strategy == "implicit_recent" and len(candidates) == 1: chosen = candidates[0] return FollowupAnchorResolution( + followup_mode=followup_mode, anchor_mode="implicit_recent_reply", anchor_kind="meta_recent_reply" if is_meta_followup else "recent_scope_reply", anchor_reply_id=int(chosen["qa_id"]), @@ -588,6 +618,7 @@ class AdaptiveRAGFollowupAnchorMixin: ) return FollowupAnchorResolution( + followup_mode=followup_mode, anchor_mode="ambiguous_candidates", anchor_kind="recent_scope_reply", anchor_session_id=active_session_id, @@ -724,7 +755,7 @@ class AdaptiveRAGFollowupAnchorMixin: resolution = anchor_resolution or FollowupAnchorResolution() if not resolution.is_meta_followup: return None - meta_kind = str(resolution.meta_followup_kind or self._classify_meta_followup_kind(question) or "").strip() + meta_kind = str(resolution.meta_followup_kind or "").strip() if not meta_kind: return None anchor_row = await self._resolve_anchor_qa_record( diff --git a/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py b/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py index 4df3e6e8..1de2f6f6 100644 --- a/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py +++ b/backend/app/services/adaptive_rag_service/mixin_manifest_feature_p2.py @@ -12,11 +12,15 @@ class AdaptiveRAGManifestFeatureMixinP2: answer_text: str, retrieved: list[RetrievedItem], system_snapshot: Optional[dict[str, Any]], + scene_semantics: Optional[dict[str, Any]] = None, + query_semantics: Optional[dict[str, Any]] = None, ) -> str: - text = str(question or "").strip().lower() - has_feature_token = any(k in text for k in FEATURE_QUERY_KEYWORDS) - ask_feature = has_feature_token and any(k in text for k in FEATURE_PURPOSE_HINTS) - if not ask_feature: + normalized_scene = scene_semantics if isinstance(scene_semantics, dict) else {} + normalized_query = query_semantics if isinstance(query_semantics, dict) else {} + snapshot_focus = str(normalized_scene.get("snapshot_focus") or "none").strip().lower() + subject_focus = self._query_subject_focus(normalized_query) + feature_focus = self._query_feature_focus(normalized_query) + if snapshot_focus != "feature" and subject_focus != "feature" and feature_focus == "none": return answer_text evidence_rows = self._collect_feature_evidence( @@ -48,4 +52,3 @@ class AdaptiveRAGManifestFeatureMixinP2: line += f" | API: {api_text}" lines.append(line) return "\n".join(lines).strip()[:3200] - 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 893295fa..1f618c42 100644 --- a/backend/app/services/adaptive_rag_service/mixin_multistep_core.py +++ b/backend/app/services/adaptive_rag_service/mixin_multistep_core.py @@ -12,6 +12,7 @@ class AdaptiveRAGMultiStepCoreMixin: domains: list[str], project_id: Optional[int], source_chat_id: Optional[str], + query_semantics: Optional[dict[str, Any]] = None, resolved_entities: Optional[dict[str, Any]] = None, vector_top_k: Optional[int] = None, ) -> tuple[list[RetrievedItem], dict[str, Any]]: @@ -21,7 +22,11 @@ class AdaptiveRAGMultiStepCoreMixin: started_at = time.perf_counter() try: rows = await asyncio.wait_for( - self._retrieve_structured_db(question, project_id=project_id), + self._retrieve_structured_db( + question, + project_id=project_id, + query_semantics=query_semantics, + ), timeout=DB_QUERY_TIMEOUT_SECONDS, ) return (rows, int((time.perf_counter() - started_at) * 1000), False) @@ -236,4 +241,3 @@ class AdaptiveRAGMultiStepCoreMixin: if item and item not in uniq: uniq.append(item) return ";".join(uniq[:3]) - 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 d8e08dac..6b80ab37 100644 --- a/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py +++ b/backend/app/services/adaptive_rag_service/mixin_multistep_flow.py @@ -12,6 +12,7 @@ class AdaptiveRAGMultiStepFlowMixin: domains: list[str], project_id: Optional[int], source_chat_id: Optional[str], + query_semantics: Optional[dict[str, Any]] = None, session_state: Optional[QASessionState], max_steps_override: Optional[int] = None, vector_top_k: Optional[int] = None, @@ -103,6 +104,7 @@ class AdaptiveRAGMultiStepFlowMixin: domains=current_domains, project_id=project_id, source_chat_id=source_chat_id, + query_semantics=query_semantics, resolved_entities=resolved_payload, vector_top_k=vector_top_k, ) @@ -202,14 +204,26 @@ class AdaptiveRAGMultiStepFlowMixin: question: str, project_id: Optional[int] = None, source_chat_id: Optional[str] = None, + query_semantics: Optional[dict[str, Any]] = None, session_state: Optional[QASessionState] = None, ) -> dict[str, Any]: - domains = self._route_domains(question) + semantics = query_semantics if isinstance(query_semantics, dict) else await self._resolve_query_semantics( + message=question, + source_system="adaptive_rag_multi_step_search", + metadata={ + "project_id": project_id, + "source_chat_id": source_chat_id, + }, + ) + domains = [str(item).strip().lower() for item in (semantics.get("retrieval_domains") or []) if str(item or "").strip()] + if not domains: + domains = ["db", "graph", "doc", "feishu", "pmflow", "url"] rows, metrics = await self._multi_step_search( question=question, domains=domains, project_id=project_id, source_chat_id=source_chat_id, + query_semantics=semantics, session_state=session_state, ) return { @@ -226,4 +240,3 @@ class AdaptiveRAGMultiStepFlowMixin: }, "tool_trace": metrics.get("tool_trace") if isinstance(metrics.get("tool_trace"), list) else [], } - diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py index 50520cf5..c4869634 100644 --- a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py @@ -5,59 +5,23 @@ from .shared import * class AdaptiveRAGRetrievalRouterDBMixin: - def _route_domains(self, question: str) -> list[str]: - text = question.lower() - domains: list[str] = [] - is_referential = any(k in text for k in FOLLOWUP_REFERENTIAL_KEYWORDS) - - if self._is_people_query(text): - domains.append("db") - if any(k in text for k in PROJECT_KEYWORDS): - domains.append("db") - domains.append("graph") - if is_referential: - domains.append("graph") - if any(k in text for k in CODE_KEYWORDS): - domains.append("graph") - domains.append("doc") - if any(k in text for k in FEATURE_QUERY_KEYWORDS): - domains.append("doc") - domains.append("graph") - domains.append("pmflow") - if any(k in text for k in DOC_KEYWORDS): - domains.append("doc") - if any(k in text for k in HISTORY_KEYWORDS): - domains.append("feishu") - if any(k in text for k in PMFLOW_KEYWORDS): - domains.append("pmflow") - if self._is_engine_process_query(text): - domains.append("graph") - domains.append("doc") - domains.append("pmflow") - domains.append("db") - if URL_RE.search(text) or "根据" in text and "http" in text: - domains.append("url") - - if not domains: - # 模糊问题:并行检索所有向量域 - domains.extend(["graph", "doc", "feishu", "pmflow", "url"]) - - # 去重并保持顺序 - uniq: list[str] = [] - for item in domains: - if item not in uniq: - uniq.append(item) - return uniq - - async def _retrieve_structured_db(self, question: str, *, project_id: Optional[int]) -> list[RetrievedItem]: + async def _retrieve_structured_db( + self, + question: str, + *, + project_id: Optional[int], + query_semantics: Optional[dict[str, Any]] = None, + ) -> list[RetrievedItem]: text = question.lower() rows: list[RetrievedItem] = [] - ask_people = self._is_people_query(text) + subject_focus = self._query_subject_focus(query_semantics) + scope_hint = self._query_scope_hint(query_semantics) + project_scope_required = scope_hint == "project" or project_id is not None - if ask_people: + if subject_focus == "people": scoped_project_id: Optional[int] = None scoped_project_name: Optional[str] = None - if "项目" in text or project_id is not None: + if project_scope_required: scoped_project_id, scoped_project_name, _ = await self._resolve_project_scope_for_question( question=text, project_id=project_id, @@ -110,7 +74,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if "项目" in text and not ask_people: + if subject_focus == "project": stmt = select(Project).where(Project.tenant_id == self.tenant_id) if project_id: stmt = stmt.where(Project.id == project_id) @@ -134,7 +98,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if "信号" in text or "待处理" in text: + if subject_focus == "signal": signal_stmt = select(Signal).where(Signal.tenant_id == self.tenant_id) if project_id: signal_stmt = signal_stmt.where( @@ -167,7 +131,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if "任务" in text or "进度" in text: + if subject_focus == "task": task_stmt = select(Task).where(Task.tenant_id == self.tenant_id) if project_id: task_stmt = task_stmt.where(Task.project_id == project_id) @@ -197,7 +161,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if "宪章" in text or "charter" in text: + if subject_focus == "charter": charter_stmt = select(TruthNode).where(TruthNode.tenant_id == self.tenant_id) if project_id: charter_stmt = charter_stmt.where(TruthNode.project_id == project_id) @@ -224,7 +188,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ) ) - if "工作量" in text or "负载" in text: + if subject_focus == "workload": workload_stmt = select(UserWorkload).where(UserWorkload.tenant_id == self.tenant_id) workloads = (await self.db.execute(workload_stmt.order_by(UserWorkload.updated_at.desc()).limit(10))).scalars().all() if workloads: diff --git a/backend/app/services/adaptive_rag_service/mixin_session_state.py b/backend/app/services/adaptive_rag_service/mixin_session_state.py index 7d3e2e6c..44830d9f 100644 --- a/backend/app/services/adaptive_rag_service/mixin_session_state.py +++ b/backend/app/services/adaptive_rag_service/mixin_session_state.py @@ -42,14 +42,13 @@ class AdaptiveRAGSessionStateMixin: session_state: Optional[QASessionState], anchor_resolution: Optional[FollowupAnchorResolution] = None, ) -> tuple[str, dict[str, Any]]: - expanded = self._expand_followup_question(question, conversation) - current = str(expanded or question or "").strip() + _ = conversation + current = str(question or "").strip() if not current: return current, {"mode": "empty"} - lower = current.lower() - has_referential = any(k in lower for k in FOLLOWUP_REFERENTIAL_KEYWORDS) - if not has_referential: + resolution = anchor_resolution or FollowupAnchorResolution() + if str(resolution.followup_mode or "").strip().lower() != "referential": return current, {"mode": "direct"} state_subject = "" @@ -71,7 +70,7 @@ class AdaptiveRAGSessionStateMixin: return rewritten[:1200], {"mode": "state_entities", "entity_ids": state_entity_ids[:6]} anchored_question, anchor_meta = self._rewrite_question_with_followup_anchor( question=current, - anchor_resolution=anchor_resolution, + anchor_resolution=resolution, ) if str(anchor_meta.get("mode") or "").strip() not in {"", "direct"}: return anchored_question, anchor_meta diff --git a/backend/app/services/adaptive_rag_service/shared_imports.py b/backend/app/services/adaptive_rag_service/shared_imports.py index a23fa07b..b3bf90d9 100644 --- a/backend/app/services/adaptive_rag_service/shared_imports.py +++ b/backend/app/services/adaptive_rag_service/shared_imports.py @@ -38,7 +38,9 @@ from ...config.ai_prompts import ( PROMPT_MEMBER_WORKLOAD_QA, PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, - PROMPT_RAG_INTENT_CLASSIFICATION, + PROMPT_RAG_FOLLOWUP_ANCHOR_SEMANTICS, + PROMPT_RAG_QUERY_SEMANTICS, + PROMPT_RAG_ANSWER_SCENE_SEMANTICS, ) from ...core.database import AsyncSessionLocal from ...models import ( diff --git a/backend/app/services/adaptive_rag_service/shared_part_01.py b/backend/app/services/adaptive_rag_service/shared_part_01.py index 8c933be9..b7423400 100644 --- a/backend/app/services/adaptive_rag_service/shared_part_01.py +++ b/backend/app/services/adaptive_rag_service/shared_part_01.py @@ -4,203 +4,10 @@ from __future__ import annotations from .shared_imports import * from .shared_utils import * -QUESTION_HINTS = ( - "吗", - "么", - "呢", - "呀", - "啊", - "?", - "?", - "如何", - "怎么", - "为什么", - "为何", - "谁", - "哪里", - "有没有", - "有无", - "有吗", - "是否", - "是否有", - "是否存在", - "哪些", - "哪个", - "什么", - "是什么", - "多少", - "几", -) -PROJECT_EXISTENCE_RE = re.compile(r"(有没有|是否有|有).*?(项目|系统)|项目.*?(有没有|是否有|有).*(吗|\?)") PROJECT_PROGRESS_RE = re.compile(r"(进度|阶段|完成度|完成率|推进|里程碑|状态)") QUESTION_NOISE_RE = re.compile( r"(目前|当前|现在|请问|一下|系统中|项目中|系统内|项目内|是否|有没有|有无|有|吗|呢|呀|啊|什么|哪个|多少|查询|看看)" ) -STATEMENT_HINTS = ( - "需要", - "请", - "新增", - "优化", - "修复", - "安排", - "立项", - "开发", - "上线", -) -PROJECT_KEYWORDS = ( - "项目", - "信号", - "任务", - "宪章", - "进度", - "状态", - "工作量", - "负责人", -) -PEOPLE_KEYWORDS = ( - "成员", - "人员", - "同事", - "负责人", - "owner", - "assignee", - "谁负责", - "谁在做", - "名单", -) -DOC_KEYWORDS = ( - "文档", - "规范", - "spec", - "说明", - "流程", - "模板", - "手册", - "how to", - "怎么做", -) -FEATURE_QUERY_KEYWORDS = ( - "功能", - "用途", - "是干嘛", - "做什么", - "有什么用", - "页面", - "菜单", - "入口", - "在哪", - "ai配置", - "智能配置", - "配置中心", - "能力管理", - "用户管理", - "项目列表", - "团队负载", - "ai管家", -) -FEATURE_PURPOSE_HINTS = ( - "用途", - "作用", - "干嘛", - "做什么", - "是什么", - "入口", - "在哪", - "页面", -) -CORRECTION_CUE_KEYWORDS = ( - "我说的是", - "我说的不是", - "不是这个", - "不是系统架构", - "不是啊", - "不对", - "错了", - "答错", - "跑偏", - "偏了", - "不是这个意思", - "你理解错", - "你答偏了", - "答非所问", - "请纠正", - "纠正一下", - "请更正", - "上一条", - "上一个回答", - "不要模板", - "模板化", - "我要的是", - "我问的是", - "不是在问", -) -ENGINE_PROCESS_KEYWORDS = ( - "ai引擎", - "引擎驱动", - "驱动方式", - "调用链", - "调用流程", - "调用路径", - "模型路由", - "模型调用", - "路由策略", - "openclaw", - "网关", - "ai架构", - "ai 管家", - "ai管家", - "第一层", - "第二层", - "第三层", - "fallback", - "兜底", -) -DEEP_EXPLICIT_KEYWORDS = ( - "深入", - "深度", - "详细分析", - "证据链", - "调用链", - "逐层", - "逐步", - "详细展开", - "deep", -) BRIEF_NEGATIVE_FEEDBACK_RE = re.compile( r"^(不是啊?|不对|错了|答错了?|跑偏了?|偏了|不是这个(意思)?|不(是|对)啊)$" ) -HISTORY_KEYWORDS = ("之前", "上次", "历史", "曾经", "之前说", "last time") -PMFLOW_KEYWORDS = ("复盘", "charter", "宪章", "task", "任务描述") -FOLLOWUP_REFERENTIAL_KEYWORDS = ( - "这些", - "它们", - "这个", - "那个", - "上面", - "前面", - "刚才", - "分别", - "具体", - "都有哪些", - "分别是什么", -) -FOLLOWUP_ENUMERATION_KEYWORDS = ( - "分别是什么", - "分别是啥", - "都有哪些", - "具体有哪些", - "列出来", - "列一下", - "清单", - "名单", -) -IDENTITY_KEYWORDS = ( - "你是谁", - "你是", - "自我介绍", - "介绍你自己", - "你能做什么", - "你会什么", - "你负责什么", - "你叫什么", -) diff --git a/backend/app/services/adaptive_rag_service/shared_part_02.py b/backend/app/services/adaptive_rag_service/shared_part_02.py index 087dd7ce..c4d32739 100644 --- a/backend/app/services/adaptive_rag_service/shared_part_02.py +++ b/backend/app/services/adaptive_rag_service/shared_part_02.py @@ -4,33 +4,6 @@ from __future__ import annotations from .shared_imports import * from .shared_utils import * -ARCHITECTURE_KEYWORDS = ( - "系统架构", - "架构", - "运行机制", - "技术栈", - "模块", - "服务组成", - "docker", - "数据库", - "redis", - "后端", - "前端", -) -CODE_KEYWORDS = ( - "代码", - "函数", - "方法", - "类", - "模块", - "文件", - "api", - "接口实现", - "service", - "model", - "schema", - "where defined", -) URL_RE = re.compile(r"https?://\S+", flags=re.IGNORECASE) SENTENCE_SPLIT_RE = re.compile(r"(?<=[。!?!?\n])") PAGE_MARKER_RE = re.compile(r"\[\[PAGE:(\d{1,6})\]\]") @@ -68,9 +41,6 @@ ACTION_MODE_AUTO = "auto" ACTION_MODE_READ = "read" ACTION_MODE_WRITE = "write" ACTION_MODE_READ_WRITE = "read_write" -COUNT_HINTS = ("多少", "几个", "数量", "总数", "count") -LIST_HINTS = ("哪些", "列表", "清单", "列出", "列一下", "分别", "名单", "都有哪些") -STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况") ENTITY_ALIAS_MAP: dict[str, str] = { "能力": "skill", "技能": "skill", diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py index 78d7f954..f6eec730 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py @@ -57,7 +57,7 @@ class IntentResolver: pretty[:300], {"source_request": True, "semantic_bundle": bundle.to_dict()}, ) - if "new_project_flow" in workflow_types or extract_project_create_title(pretty): + if "new_project_flow" in workflow_types: return CanonicalIntent( INTENT_PROJECT_CREATE, 0.9, diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py index 6db12c4e..9fc65b94 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py @@ -6,7 +6,6 @@ import re from dataclasses import dataclass from typing import Any, Optional -from ....utils.project_command_utils import extract_project_create_title from ...signal.signal_source_orchestration import SignalSourceOrchestrationService FOLLOWUP_WORK_ITEM_ID_RE = re.compile( @@ -220,7 +219,7 @@ LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { "signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD, } -INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" +INTENT_LLM_FUNCTION_NAME = "pmflow_semantic_context_decision" INTENT_CANDIDATES = ( INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_TASK_CREATE, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/__init__.py b/backend/app/services/ai_pmo/ai_pmo_kernel/__init__.py index 7b90a183..21f3cec5 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/__init__.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/__init__.py @@ -4,7 +4,6 @@ from .ai_agent_kernel import ( build_decision_trace, gate_execution, resolve_correction, - select_intent_route, ) from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow @@ -22,5 +21,4 @@ __all__ = [ "build_decision_trace", "gate_execution", "resolve_correction", - "select_intent_route", ] diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py index 5986d877..dece4471 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py @@ -2,38 +2,14 @@ from __future__ import annotations -import os import re from dataclasses import dataclass from typing import Iterable, Sequence -INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel" -INTENT_FUNCTION_COMPLEX = "classify_intent_complex" - - -def _env_float(name: str, default: float) -> float: - raw = os.getenv(name) - if raw is None: - return float(default) - try: - value = float(str(raw).strip()) - except Exception: - return float(default) - return float(default) if value < 0 else value - - def _normalize_text(text: str) -> str: return re.sub(r"\s+", "", str(text or "")).lower() -@dataclass(slots=True) -class IntentRouteDecision: - function_name: str - tier: str - is_complex: bool - reasons: list[str] - - @dataclass(slots=True) class GateDecision: action: str @@ -48,28 +24,6 @@ class CorrectionDecision: confidence: float matched_keywords: list[str] -def is_complex_instruction(text: str, *, project_names: Sequence[str] | None = None) -> tuple[bool, list[str]]: - _ = (text, project_names) - return False, [] - - -def select_intent_route(text: str, *, project_names: Sequence[str] | None = None) -> IntentRouteDecision: - is_complex, reasons = is_complex_instruction(text, project_names=project_names) - if is_complex: - return IntentRouteDecision( - function_name=INTENT_FUNCTION_COMPLEX, - tier="high", - is_complex=True, - reasons=reasons, - ) - return IntentRouteDecision( - function_name=INTENT_FUNCTION_SIMPLE, - tier="low", - is_complex=False, - reasons=["default_simple"], - ) - - def resolve_correction(text: str) -> CorrectionDecision: _ = text return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[]) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py index 37051ec0..dc421aa7 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/compositions/intent.py @@ -2,16 +2,14 @@ from __future__ import annotations -from ..mixins.intent.ai_pmo_kernel_mixins_intent_llm import KernelIntentLLMMixin from ..mixins.intent.ai_pmo_kernel_mixins_intent_resolve import KernelIntentResolveMixin from ..mixins.intent.ai_pmo_kernel_mixins_intent_rules import KernelIntentRulesMixin class KernelIntentMixin( KernelIntentRulesMixin, - KernelIntentLLMMixin, KernelIntentResolveMixin, ): - """Compose intent rules, llm arbitration, and final resolve flow.""" + """Compose intent rules and final resolve flow.""" pass diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py deleted file mode 100644 index 2ad33c51..00000000 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py +++ /dev/null @@ -1,285 +0,0 @@ -"""AI PMO kernel intent llm-arbitration mixin.""" - -from __future__ import annotations - -from .... import ai_pmo_kernel_common_core as _common - -for _name, _value in vars(_common).items(): - if not _name.startswith("__"): - globals()[_name] = _value - - -class KernelIntentLLMMixin: - _STAGE2_INTENT_FUNCTION_NAME = "classify_intent_complex" - - async def _resolve_intent_with_llm( - self, - *, - message_text: str, - session: Optional[ConversationSession], - world_model: dict[str, Any], - rule_intent: Optional[CanonicalIntent] = None, - session_last_intent_override: Optional[str] = None, - session_project_id_override: Optional[int] = None, - inherit_session_context: bool = True, - ) -> Optional[CanonicalIntent]: - if session_last_intent_override is not None: - session_last_intent = str(session_last_intent_override or "").strip() - elif inherit_session_context and session is not None: - session_last_intent = str(getattr(session, "last_intent", "") or "").strip() - else: - session_last_intent = "" - if session_project_id_override is not None: - session_project_id = _safe_int(session_project_id_override) - elif inherit_session_context and session is not None: - session_project_id = _safe_int(getattr(session, "current_project_id", None)) - else: - session_project_id = None - prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format( - message_text=str(message_text or "")[:1200], - session_last_intent=session_last_intent, - session_project_id=session_project_id, - world_model_project_id=_safe_int(world_model.get("resolved_project_id")), - candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False), - candidate_routes_json=json.dumps(sorted(WORKFLOW_ROUTE_KEYS), ensure_ascii=False), - ).strip() - total_budget_seconds = _intent_llm_total_budget_seconds() - stage_started_at = time.perf_counter() - stage1_timeout_cap_seconds = max( - 6.0, - float(os.getenv("AI_PMO_INTENT_STAGE1_TIMEOUT_CAP_SECONDS", "8") or 8.0), - ) - - async def _invoke_intent_stage( - *, - function_name: str, - route_tier: str, - route_reasons: list[str], - stage_label: str, - timeout_cap_seconds: Optional[float] = None, - ) -> Optional[CanonicalIntent]: - llm_timeout_seconds = _intent_llm_timeout_seconds(function_name=function_name) - if timeout_cap_seconds is not None: - llm_timeout_seconds = min(llm_timeout_seconds, max(float(timeout_cap_seconds), 0.0)) - if llm_timeout_seconds <= 0.0: - return None - max_tokens_default = 260 if route_tier == "low" else 360 - max_tokens_env_key = ( - "AI_PMO_INTENT_LLM_MAX_TOKENS_LOW" - if route_tier == "low" - else "AI_PMO_INTENT_LLM_MAX_TOKENS_HIGH" - ) - try: - llm_max_tokens = int( - str(os.getenv(max_tokens_env_key, str(max_tokens_default)) or max_tokens_default).strip() - ) - except Exception: - llm_max_tokens = max_tokens_default - llm_max_tokens = max(120, min(llm_max_tokens, 2800)) - logger.info( - "ai_pmo_intent_llm_route", - tenant_id=self.tenant_id, - stage=stage_label, - function_name=function_name, - route_tier=route_tier, - timeout_seconds=llm_timeout_seconds, - max_tokens=llm_max_tokens, - route_reasons=route_reasons, - total_budget_seconds=total_budget_seconds, - ) - try: - extraction = await asyncio.wait_for( - llm_service.generate_structured_object( - prompt, - db=self.db, - tenant_id=self.tenant_id, - function_name=function_name, - use_prompt_profile=False, - temperature=0.0, - max_tokens=llm_max_tokens, - response_mode="fast", - ), - timeout=llm_timeout_seconds, - ) - except TimeoutError: - logger.warning( - "ai_pmo_intent_llm_timeout", - tenant_id=self.tenant_id, - stage=stage_label, - function_name=function_name, - timeout_seconds=llm_timeout_seconds, - ) - return None - except Exception as exc: - logger.warning( - "ai_pmo_intent_llm_failed", - tenant_id=self.tenant_id, - stage=stage_label, - function_name=function_name, - error=str(exc), - ) - return None - - payload = extraction.payload if extraction and isinstance(extraction.payload, dict) else None - if payload is None: - logger.warning( - "ai_pmo_intent_llm_empty_payload", - tenant_id=self.tenant_id, - stage=stage_label, - function_name=function_name, - error_code=str(getattr(extraction, "error_code", "") or ""), - provider=str(getattr(extraction, "provider", "") or ""), - model=str(getattr(extraction, "model", "") or ""), - ) - return None - - structured_route = RouteResolver.from_structured_payload( - payload=payload, - message_text=message_text, - fallback_project_id=_safe_int(world_model.get("resolved_project_id")), - ) - intent_name = str(payload.get("intent") or "").strip() - if structured_route is not None: - intent_name = RouteResolver.canonical_intent_for_route(structured_route.key) - if intent_name not in INTENT_CANDIDATES: - return None - confidence = float(payload.get("confidence") or 0.0) - project_id = _safe_int(payload.get("project_id")) - if project_id is not None and int(project_id) <= 0: - project_id = None - if project_id is None: - project_id = _safe_int(world_model.get("resolved_project_id")) - if structured_route is not None and structured_route.project_id is not None: - project_id = structured_route.project_id - reasoning = str(payload.get("reasoning") or "").strip() - decision_trace = build_decision_trace( - intent=intent_name, - confidence=confidence, - route_tier=route_tier, - route_function=function_name, - gate_action="unresolved", - gate_code="intent_only", - correction_action="none", - correction_keywords=[], - complexity_reasons=route_reasons, - ) - return self._build_canonical_intent( - intent_name=intent_name, - message_text=message_text, - project_id=project_id, - confidence=confidence, - metadata={ - "context_source": "llm", - "stage": stage_label, - "reasoning": reasoning[:300], - "llm_payload": payload, - "llm_parameters": payload.get("parameters") if isinstance(payload.get("parameters"), dict) else {}, - "llm_function": function_name, - "route_tier": route_tier, - "route_complex": route_tier == "high", - "route_reasons": route_reasons, - "timeout_seconds": llm_timeout_seconds, - "total_budget_seconds": total_budget_seconds, - "correction_action": "none", - "correction_keywords": [], - "decision_trace": decision_trace, - "workflow_route": structured_route.key if structured_route is not None else LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER), - "workflow_family": structured_route.family if structured_route is not None else RouteResolver._split_route_key( - LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER) - )[0], - "workflow_action": structured_route.action if structured_route is not None else RouteResolver._split_route_key( - LEGACY_INTENT_ROUTE_MAP.get(intent_name, ROUTE_QUERY_ANSWER) - )[1], - "requested_count": ( - structured_route.requested_count - if structured_route is not None - else RouteResolver.extract_requested_count(message_text=message_text, metadata=payload) - ), - "requires_confirmation": ( - structured_route.requires_confirmation - if structured_route is not None - else bool(payload.get("requires_confirmation")) - ), - }, - ) - - low_stage_reasons = ["llm_only_stage1"] - low_intent = await _invoke_intent_stage( - function_name=INTENT_LLM_FUNCTION_NAME, - route_tier="low", - route_reasons=low_stage_reasons, - stage_label="stage1_low", - timeout_cap_seconds=stage1_timeout_cap_seconds, - ) - - escalate_reasons: list[str] = [] - if low_intent is None: - escalate_reasons.append("stage1_empty") - elif float(low_intent.confidence or 0.0) < _intent_llm_stage2_confidence_threshold(): - escalate_reasons.append("stage1_low_confidence") - if rule_intent is not None and low_intent is not None and str(rule_intent.name or "") != str(low_intent.name or ""): - escalate_reasons.append("stage1_rule_conflict") - should_escalate = bool(escalate_reasons) - if not should_escalate: - return low_intent - stage2_enabled = str(os.getenv("AI_PMO_INTENT_STAGE2_ENABLED", "true") or "true").strip().lower() in BOOL_TRUE_VALUES - if low_intent is not None and not stage2_enabled: - logger.info( - "ai_pmo_intent_stage2_skipped", - tenant_id=self.tenant_id, - reason="stage2_disabled", - escalate_reasons=escalate_reasons, - ) - return self._copy_intent_with_metadata( - low_intent, - stage2_skipped=True, - stage2_skip_reason="stage2_disabled", - stage2_reasons=escalate_reasons, - ) - - elapsed_seconds = time.perf_counter() - stage_started_at - remaining_budget_seconds = total_budget_seconds - elapsed_seconds - stage2_min_remaining_seconds = _intent_llm_stage2_min_remaining_seconds() - if remaining_budget_seconds < stage2_min_remaining_seconds: - logger.info( - "ai_pmo_intent_stage2_skipped", - tenant_id=self.tenant_id, - reason="budget_insufficient", - remaining_budget_seconds=max(0.0, remaining_budget_seconds), - min_remaining_seconds=stage2_min_remaining_seconds, - escalate_reasons=escalate_reasons, - ) - if low_intent is not None: - return self._copy_intent_with_metadata( - low_intent, - stage2_skipped=True, - stage2_skip_reason="budget_insufficient", - stage2_reasons=escalate_reasons, - ) - return None - - high_stage_reasons = [*escalate_reasons, "stage2_high_retry"] - high_intent = await _invoke_intent_stage( - function_name=self._STAGE2_INTENT_FUNCTION_NAME, - route_tier="high", - route_reasons=high_stage_reasons, - stage_label="stage2_high", - timeout_cap_seconds=remaining_budget_seconds, - ) - if high_intent is not None: - if low_intent is not None: - return self._copy_intent_with_metadata( - high_intent, - stage2_promoted=True, - stage1_intent=low_intent.name, - stage1_confidence=float(low_intent.confidence or 0.0), - ) - return high_intent - if low_intent is not None: - return self._copy_intent_with_metadata( - low_intent, - stage2_attempted=True, - stage2_failed=True, - stage2_reasons=escalate_reasons, - ) - return None 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 44bf7b91..ecbd3dfc 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 @@ -3,6 +3,7 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common +from ....semantic_context.semantic_decision_service import SemanticDecisionService from ....workflow_semantic_planner import WorkflowSemanticPlanner for _name, _value in vars(_common).items(): @@ -11,6 +12,21 @@ for _name, _value in vars(_common).items(): class KernelIntentResolveMixin: + @staticmethod + def _infer_workflow_source_system( + *, + session: Optional[ConversationSession], + thread_context: Optional[dict[str, Any]], + ) -> str: + payload = thread_context if isinstance(thread_context, dict) else {} + explicit = str(payload.get("source_system") or payload.get("source") or "").strip().lower() + if explicit: + return explicit + sender = str(getattr(session, "sender", "") or "").strip().lower() + if sender.startswith("feishu:") or bool(payload): + return "feishu" + return "pmflow_manual" + @staticmethod def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool: if not isinstance(last_active_at, datetime): @@ -37,6 +53,105 @@ class KernelIntentResolveMixin: # heuristics on the latest message text. return False + @classmethod + def _route_key_from_workflow_plan(cls, workflow: Any) -> str: + operation_codes = getattr(workflow, "operation_codes", None) + if isinstance(operation_codes, list): + for code in operation_codes: + normalized = RouteResolver.normalize_route_key(str(code or "")) + family, _ = RouteResolver._split_route_key(normalized) + if normalized in WORKFLOW_ROUTE_KEYS or family == ROUTE_FAMILY_PROJECT_SIGNAL: + return normalized + + workflow_type = str(getattr(workflow, "workflow_type", "") or "").strip().lower() + workflow_action = str(getattr(workflow, "workflow_action", "") or "").strip().lower() + if workflow_type == "query_flow": + return ROUTE_QUERY_ANSWER + if workflow_type == "new_project_flow": + return ROUTE_PROJECT_CREATE + if workflow_type == "task_flow": + return ROUTE_TASK_CREATE if workflow_action == ROUTE_ACTION_CREATE else RouteResolver.normalize_route_key( + f"{ROUTE_FAMILY_TASK}.{workflow_action}" + ) + if workflow_type == "signal_source_flow": + return ROUTE_SIGNAL_SOURCE_ONBOARD + if workflow_type == "project_signal_flow": + if workflow_action in { + ROUTE_ACTION_CREATE, + ROUTE_ACTION_DISCOVER, + ROUTE_ACTION_COMMIT, + ROUTE_ACTION_CANCEL, + ROUTE_ACTION_REFRESH, + }: + return RouteResolver.normalize_route_key(f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{workflow_action}") + return ROUTE_PROJECT_SIGNAL_CREATE + if workflow_type == "followup_flow": + return ROUTE_FOLLOWUP_CONFIRM if workflow_action == ROUTE_ACTION_CONFIRM else ROUTE_FOLLOWUP_CONTINUE + if workflow_type == "project_flow": + return RouteResolver.normalize_route_key(f"{ROUTE_FAMILY_PROJECT}.{workflow_action}") + return ROUTE_QUERY_ANSWER + + def _build_intent_from_workflow_bundle( + self, + *, + workflow_bundle: Any, + message_text: str, + world_model: dict[str, Any], + fallback_project_id: Optional[int] = None, + ) -> CanonicalIntent: + workflows = list(getattr(workflow_bundle, "workflows", []) or []) + if not workflows: + raise RuntimeError("workflow_bundle_missing_workflows") + primary_workflow = workflows[0] + route_key = self._route_key_from_workflow_plan(primary_workflow) + family, action = RouteResolver._split_route_key(route_key) + intent_name = RouteResolver.canonical_intent_for_route(route_key) + diagnostics = ( + dict(getattr(workflow_bundle, "diagnostics", {}) or {}) + if isinstance(getattr(workflow_bundle, "diagnostics", {}), dict) + else {} + ) + workflow_metadata = ( + dict(getattr(primary_workflow, "metadata", {}) or {}) + if isinstance(getattr(primary_workflow, "metadata", {}), dict) + else {} + ) + project_id = ( + _safe_int(workflow_metadata.get("project_id")) + or _safe_int(world_model.get("resolved_project_id")) + or _safe_int(diagnostics.get("project_id")) + or _safe_int(fallback_project_id) + ) + pretty = re.sub(r"\s+", " ", str(message_text or "")).strip() + metadata = { + **diagnostics, + **workflow_metadata, + "context_source": "workflow_bundle", + "semantic_bundle": workflow_bundle.to_dict() if hasattr(workflow_bundle, "to_dict") else {}, + "llm_function": SemanticDecisionService.FUNCTION_NAME, + "route_tier": "semantic_context", + "route_reasons": [ + f"workflow_bundle:{str(getattr(workflow_bundle, 'primary_workflow', '') or '').strip().lower() or 'unknown'}" + ], + "workflow_route": route_key, + "workflow_family": family, + "workflow_action": action, + "requires_confirmation": bool(getattr(primary_workflow, "requires_confirmation", False)), + "message_gate": str(getattr(workflow_bundle, "message_gate", "") or "").strip() or None, + "source_system": str(getattr(workflow_bundle, "source_system", "") or "").strip() or None, + } + if family == ROUTE_FAMILY_FOLLOWUP: + metadata["followup_action"] = action + if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: + metadata["source_request"] = True + return self._build_canonical_intent( + intent_name=intent_name, + message_text=pretty, + project_id=project_id, + confidence=float(getattr(primary_workflow, "confidence", 0.0) or 0.0), + metadata=metadata, + ) + async def _resolve_intent( self, *, @@ -55,24 +170,38 @@ class KernelIntentResolveMixin: message_text=message_text, session=session, ) - - # 模型唯一入口:不再使用规则/正则意图分流。 - if not self._intent_llm_enabled(): - raise RuntimeError("intent_model_only_mode_requires_llm_enabled") - - llm_intent = await self._resolve_intent_with_llm( + workflow_bundle = await WorkflowSemanticPlanner.aplan_message( message_text=message_text, - session=session, - world_model=world_model, - rule_intent=None, - session_last_intent_override=( - str(getattr(session, "last_intent", "") or "").strip() if inherit_session_context else "" + source_system=self._infer_workflow_source_system( + session=session, + thread_context=thread_context, ), - session_project_id_override=( - _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None - ), - inherit_session_context=inherit_session_context, + 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": ( + _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", + }, + ) + 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 + ), + ), + "workflow_bundle", ) - if llm_intent is None: - raise RuntimeError("intent_model_only_no_valid_llm_intent") - return llm_intent, "llm_only" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py index 37bac352..190c72e6 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py @@ -58,16 +58,6 @@ class KernelIntentRulesMixin: metadata = intent.metadata if isinstance(intent.metadata, dict) else {} return bool(metadata.get("fallback")) and str(metadata.get("reason") or "") == "default_query_fallback" - @staticmethod - def _has_question_structure(*, normalized: str) -> bool: - if not normalized: - return False - return ( - "?" in normalized - or "?" in normalized - or any(token in normalized for token in ("怎么", "如何", "为何", "为什么", "是什么", "有哪些", "吗", "呢", "是否", "是不是")) - ) - def _recover_session_followup_write_intent( self, *, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/env.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/env.py index 17274eb3..0d161ad1 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/env.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/env.py @@ -8,7 +8,6 @@ from .constants import ( INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS, INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS, INTENT_LLM_TIMEOUT_DEFAULT_SECONDS, - INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS, INTENT_LLM_TOTAL_BUDGET_DEFAULT_SECONDS, ) from .imports import INTENT_LLM_FUNCTION_NAME, os @@ -33,18 +32,12 @@ def env_float(name: str, default: float) -> float: def intent_llm_timeout_seconds(*, function_name: str) -> float: normalized = str(function_name or "").strip().lower() - if normalized == "classify_intent_complex": + if normalized == str(INTENT_LLM_FUNCTION_NAME or "").strip().lower(): default_timeout = INTENT_LLM_TIMEOUT_COMPLEX_DEFAULT_SECONDS configured = env_float( "AI_PMO_INTENT_LLM_TIMEOUT_COMPLEX_SECONDS", env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout), ) - elif normalized == str(INTENT_LLM_FUNCTION_NAME or "").strip().lower(): - default_timeout = INTENT_LLM_TIMEOUT_FALLBACK_DEFAULT_SECONDS - configured = env_float( - "AI_PMO_INTENT_LLM_TIMEOUT_FALLBACK_SECONDS", - env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout), - ) else: default_timeout = INTENT_LLM_TIMEOUT_DEFAULT_SECONDS configured = env_float("AI_PMO_INTENT_LLM_TIMEOUT_SECONDS", default_timeout) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py index 043f07ed..f87fe7a6 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py @@ -67,7 +67,6 @@ from ..ai_pmo_kernel import ( build_decision_trace, gate_execution as kernel_gate_execution, resolve_correction as kernel_resolve_correction, - select_intent_route, ) from ..ai_pmo_cadence import AIPMOCadenceService from ..ai_pmo_decision import DecisionEngine 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 a8b33231..88640230 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 @@ -847,7 +847,9 @@ class KernelIngressFlowEntryMixin: requires_manual_review = bool((intent.metadata or {}).get("requires_manual_review")) manual_review_code = str((intent.metadata or {}).get("manual_review_code") or "manual_review_required").strip() manual_review_reason = str((intent.metadata or {}).get("manual_review_reason") or "").strip() - llm_function = str((intent.metadata or {}).get("llm_function") or INTENT_LLM_FUNCTION_NAME).strip() + llm_function = str( + (intent.metadata or {}).get("llm_function") or SemanticDecisionService.FUNCTION_NAME + ).strip() route_tier = str((intent.metadata or {}).get("route_tier") or "rule").strip() route_reasons = (intent.metadata or {}).get("route_reasons") gate = kernel_gate_execution( @@ -1299,19 +1301,12 @@ class KernelIngressFlowEntryMixin: if DraftConfirmHandler._extract_work_item_id(message_text) is not None: return True - if ( - str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM - and len(str(message_text or "").strip()) <= 16 - ): - return False - thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) - if str(thread_context.get("reply_to_message_id") or "").strip(): - if str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM: - return False - return not str(message_text or "").strip().endswith(("?", "?")) - - return False + if not str(thread_context.get("reply_to_message_id") or "").strip(): + return False + if str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM: + return False + return DraftConfirmHandler.is_explicit_protocol_followup_text(message_text) async def _should_route_to_draft_confirmation_session( self, @@ -1329,16 +1324,6 @@ class KernelIngressFlowEntryMixin: handler = DraftConfirmHandler(self) thread_context = self._extract_thread_context_from_ingress_options(ingress_options) normalized_text = str(message_text or "").strip() - if str(thread_context.get("reply_to_message_id") or "").strip(): - if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: - return True - return await handler.has_active_draft_session( - sender=sender, - source_system=source_system, - channel_ref=channel_ref, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - ) if self._should_route_to_draft_confirmation( session=session, message_text=message_text, @@ -1352,8 +1337,6 @@ class KernelIngressFlowEntryMixin: source_system=source_system, ): return False - if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: - return True has_active_draft_session = await handler.has_active_draft_session( sender=sender, @@ -1362,7 +1345,9 @@ class KernelIngressFlowEntryMixin: source_sender_open_id=source_sender_open_id, thread_context=thread_context, ) - return bool(has_active_draft_session) + if not bool(has_active_draft_session): + return False + return handler.is_explicit_protocol_followup_text(normalized_text) @staticmethod def _build_orphan_requirement_followup_reply() -> str: diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_context.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_context.py index 7a460944..71efc187 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_context.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_context.py @@ -144,7 +144,7 @@ class DiscoveryContextMixin: capability_bundle = await ModelContextBundleService.build_context_bundle( self.db, tenant_id=self.tenant_id, - function_name="classify_intent_complex", + function_name="pmflow_semantic_context_decision", ) return { 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 d716b3a2..9555bc5e 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 @@ -3045,6 +3045,18 @@ class ExecuteQueryProjectMixin: consultant_mode_preference = str(consultant_mode_preference or "exploratory").strip().lower() or "exploratory" normalized_message_intent = str(message_intent or "").strip().lower() has_current_semantic_bundle = isinstance(semantic_bundle, dict) and bool(semantic_bundle) + has_current_direct_scene_metadata = bool( + any( + str(metadata.get(key) or "").strip() + for key in ( + "message_intent", + "consultant_mode_preference", + "advise_subtype", + "topic_switch", + "force_thinking_mode", + ) + ) + ) has_current_rag_semantics = bool( isinstance(rag_request_context, dict) and any( @@ -3058,10 +3070,21 @@ class ExecuteQueryProjectMixin: ) ) ) + needs_legacy_business_review_refresh = ( + normalized_message_intent == "business_review" + and str(consultant_mode_preference or "").strip().lower() == "clarify" + and not has_current_semantic_bundle + and not has_current_rag_semantics + and not has_active_requirement_plan_workspace + ) needs_semantic_backfill = ( not has_current_semantic_bundle and not has_current_rag_semantics and not has_active_requirement_plan_workspace + and ( + not has_current_direct_scene_metadata + or needs_legacy_business_review_refresh + ) ) if not normalized_message_intent: needs_semantic_backfill = True @@ -3069,8 +3092,6 @@ class ExecuteQueryProjectMixin: consultant_mode_preference or "" ).strip(): needs_semantic_backfill = True - elif normalized_message_intent == "requirement_plan" and not str(topic_switch or "").strip(): - needs_semantic_backfill = True if needs_semantic_backfill: active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( source_system=source_system, @@ -3116,29 +3137,11 @@ class ExecuteQueryProjectMixin: advise_subtype = refreshed_advise_subtype if refreshed_topic_switch: topic_switch = refreshed_topic_switch - if refreshed_thinking_mode: - thinking_mode = refreshed_thinking_mode - metadata = {**metadata, "semantic_bundle": semantic_bundle} + if refreshed_thinking_mode: + thinking_mode = refreshed_thinking_mode + metadata = {**metadata, "semantic_bundle": semantic_bundle} normalized_message_intent = str(message_intent or "").strip().lower() - if ( - has_active_requirement_plan_workspace - and not has_requirement_anchor - and normalized_message_intent == "requirement_plan" - and not self._extract_requirement_plan_choice(message_text) - ): - nontrivial_seed = self._sanitize_requirement_plan_text(message_text).strip() - overlap_ratio = self._requirement_seed_overlap_ratio( - message_text=message_text, - topic=active_requirement_plan_topic, - draft=active_requirement_plan_draft, - ) - if len(nontrivial_seed) >= 6 and overlap_ratio < 0.18: - topic_switch = "new" - explicit_new_requirement_topic = ( - str(message_intent or "").strip().lower() == "requirement_plan" - and str(topic_switch or "").strip().lower() == "new" - ) - if not has_active_requirement_plan_workspace and str(message_intent or "").strip().lower() == "requirement_plan": + if not has_active_requirement_plan_workspace and normalized_message_intent == "requirement_plan": active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( source_system=source_system, source_message_id=source_message_id, @@ -3153,6 +3156,33 @@ class ExecuteQueryProjectMixin: and active_requirement_plan_topic is not None and active_requirement_plan_draft is not None ) + if ( + has_active_requirement_plan_workspace + and not has_requirement_anchor + and normalized_message_intent == "requirement_plan" + and not self._extract_requirement_plan_choice(message_text) + ): + nontrivial_seed = self._sanitize_requirement_plan_text(message_text).strip() + overlap_ratio = self._requirement_seed_overlap_ratio( + message_text=message_text, + topic=active_requirement_plan_topic, + draft=active_requirement_plan_draft, + ) + if len(nontrivial_seed) >= 6 and overlap_ratio < 0.18: + topic_switch = "new" + has_active_requirement_plan_workspace = False + active_requirement_plan_topic = None + active_requirement_plan_draft = None + if ( + normalized_message_intent == "requirement_plan" + and not has_active_requirement_plan_workspace + and not str(topic_switch or "").strip() + ): + topic_switch = "new" + explicit_new_requirement_topic = ( + str(message_intent or "").strip().lower() == "requirement_plan" + and str(topic_switch or "").strip().lower() == "new" + ) if self._should_use_lightweight_chat_answer( message_text=message_text, message_intent=message_intent, diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index ec892bf6..2def26c6 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -27,18 +27,32 @@ class DraftConfirmHandler: _ACTION_CONFIRM = "confirm" _ACTION_REVISE = "revise" + _ACTION_REGENERATE = "regenerate" _ACTION_CANCEL = "cancel" _ACTION_ANSWER_ONLY = "answer_only" _ACTION_UNKNOWN = "unknown" - _SEMANTIC_FOLLOWUP_ACTIONS = (_ACTION_CONFIRM, _ACTION_REVISE, _ACTION_CANCEL, _ACTION_ANSWER_ONLY) - _EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "confirm", "ok", "好的", "好", "yes", "y"}) - _EXPLICIT_CANCEL_COMMANDS = frozenset({"取消", "cancel", "不用了", "算了"}) + _SEMANTIC_FOLLOWUP_ACTIONS = ( + _ACTION_CONFIRM, + _ACTION_REVISE, + _ACTION_REGENERATE, + _ACTION_CANCEL, + _ACTION_ANSWER_ONLY, + ) + _EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "确认本版", "confirm", "ok", "好的", "好", "yes", "y"}) + _EXPLICIT_CANCEL_COMMANDS = frozenset({"取消", "取消本版", "cancel", "不用了", "算了"}) _DEFAULT_DISPLAY_FEEDBACK_REPLY = ( "收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。" ) _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) + @classmethod + def is_explicit_protocol_followup_text(cls, text: str) -> bool: + normalized_text = str(text or "").strip().lower() + if not normalized_text: + return False + return normalized_text in (cls._EXPLICIT_CONFIRM_COMMANDS | cls._EXPLICIT_CANCEL_COMMANDS) + def __init__(self, kernel: Any) -> None: self.kernel = kernel self.db = kernel.db @@ -156,6 +170,13 @@ class DraftConfirmHandler: revision_note=revision_note, expected_version=expected_version, ) + if action == self._ACTION_REGENERATE: + return await self.regenerate_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + message_text=message_text, + expected_version=expected_version, + ) if action == self._ACTION_CANCEL: return await self.cancel_draft( target_work_item=target_work_item, @@ -176,7 +197,7 @@ class DraftConfirmHandler: 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, @@ -472,6 +493,10 @@ class DraftConfirmHandler: execution_result=locked.execution_result, final_outcome=STATE_CANCELLED, ) + await self._release_mounted_draft_binding( + work_item_id=int(locked.id), + final_state=STATE_CANCELLED, + ) return { "handled": True, "status": "cancelled", @@ -558,6 +583,10 @@ class DraftConfirmHandler: execution_result=locked.execution_result, final_outcome=STATE_FAILED, ) + await self._release_mounted_draft_binding( + work_item_id=int(locked.id), + final_state=STATE_FAILED, + ) await self.db.flush() return { "handled": True, @@ -612,6 +641,10 @@ class DraftConfirmHandler: execution_result=locked.execution_result, final_outcome=STATE_SUCCEEDED, ) + await self._release_mounted_draft_binding( + work_item_id=int(locked.id), + final_state=STATE_SUCCEEDED, + ) await self.db.flush() return { @@ -633,6 +666,49 @@ class DraftConfirmHandler: target_ingest: IngestEvent, revision_note: str, expected_version: Optional[int], + ) -> dict[str, Any]: + return await self._rerender_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + rewrite_note=str(revision_note or "").strip(), + expected_version=expected_version, + require_note=True, + missing_note_reply="请先补充修改意见再继续,例如:修改 目标客户改成北美 DTC 品牌。", + invalid_state_reply="当前草稿已不在待确认状态,无法再修改。", + version_conflict_reply_prefix="草稿版本已更新,请刷新后基于", + ) + + async def regenerate_draft( + self, + *, + target_work_item: WorkItem, + target_ingest: IngestEvent, + message_text: str, + expected_version: Optional[int], + ) -> dict[str, Any]: + regenerate_note = self._build_regenerate_revision_note(message_text=message_text) + return await self._rerender_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + rewrite_note=regenerate_note, + expected_version=expected_version, + require_note=False, + missing_note_reply="", + invalid_state_reply="当前草稿已不在待确认状态,无法再重出一版。", + version_conflict_reply_prefix="草稿版本已更新,请刷新后基于", + ) + + async def _rerender_draft( + self, + *, + target_work_item: WorkItem, + target_ingest: IngestEvent, + rewrite_note: str, + expected_version: Optional[int], + require_note: bool, + missing_note_reply: str, + invalid_state_reply: str, + version_conflict_reply_prefix: str, ) -> dict[str, Any]: locked = ( await self.db.execute( @@ -665,7 +741,7 @@ class DraftConfirmHandler: return { "handled": True, "status": "failed_safe", - "followup_reply": "当前草稿已不在待确认状态,无法再修改。", + "followup_reply": invalid_state_reply, "target_work_item_id": int(locked.id), "target_state": state or None, "target_intent": str(locked.intent or "").strip() or None, @@ -679,7 +755,7 @@ class DraftConfirmHandler: return { "handled": True, "status": "pending_manual", - "followup_reply": f"草稿版本已更新,请刷新后基于 v{current_version} 再修改。", + "followup_reply": f"{version_conflict_reply_prefix} v{current_version} 再继续。", "target_work_item_id": int(locked.id), "target_state": state, "target_intent": str(locked.intent or "").strip() or None, @@ -688,11 +764,11 @@ class DraftConfirmHandler: "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), } - if not str(revision_note or "").strip(): + if require_note and not str(rewrite_note or "").strip(): return { "handled": True, "status": "draft_pending_confirm", - "followup_reply": "请先补充修改意见再继续,例如:修改 目标客户改成北美 DTC 品牌。", + "followup_reply": missing_note_reply, "target_work_item_id": int(locked.id), "target_state": STATE_DRAFT_PENDING_CONFIRM, "target_intent": str(locked.intent or "").strip() or None, @@ -713,7 +789,7 @@ class DraftConfirmHandler: generated = await self._revise_requirement_plan_draft( locked=locked, target_ingest=target_ingest, - revision_note=revision_note, + revision_note=str(rewrite_note or "").strip(), ) else: generated = await self.draft_generator.generate_and_persist( @@ -721,15 +797,18 @@ class DraftConfirmHandler: intent_name=intent_name, message_text=original_message, world_model=locked.context_snapshot if isinstance(getattr(locked, "context_snapshot", None), dict) else {}, - revision_note=revision_note, + revision_note=str(rewrite_note or "").strip(), ) + locked.state = STATE_DRAFT_PENDING_CONFIRM locked.reply_template_key = "draft_pending_confirm" await self.db.flush() draft_text = str(generated.get("draft_text") or "").strip() if not draft_text: - draft_text = DraftGeneratorService.render_draft_message(locked.draft_content if isinstance(locked.draft_content, dict) else {}) + draft_text = DraftGeneratorService.render_draft_message( + locked.draft_content if isinstance(locked.draft_content, dict) else {} + ) followup_reply = "🔄 正在重新起草,请稍候...\n\n" + draft_text return { @@ -746,6 +825,16 @@ class DraftConfirmHandler: "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None, } + @staticmethod + def _build_regenerate_revision_note(*, message_text: str) -> str: + normalized_text = str(message_text or "").strip() + if normalized_text: + return ( + "请基于当前已经收敛出的事实重出一版草稿,不新增虚构事实,不把展示层反馈误当成字段修改。" + f"用户原话:{normalized_text}" + ) + return "请基于当前已经收敛出的事实重出一版草稿,不新增虚构事实,只重写组织方式和表达。" + @staticmethod def _is_requirement_plan_draft_payload(draft: Any) -> bool: return isinstance(draft, dict) and str(draft.get("draft_source") or "").strip() == "requirement_plan" @@ -885,6 +974,10 @@ class DraftConfirmHandler: execution_result=locked.execution_result, final_outcome=STATE_CANCELLED, ) + await self._release_mounted_draft_binding( + work_item_id=int(locked.id), + final_state=STATE_CANCELLED, + ) await self.db.flush() return { @@ -924,8 +1017,13 @@ class DraftConfirmHandler: active_work_item_id = _safe_int(getattr(mounted_draft, "active_work_item_id", None)) if active_work_item_id is not None: target = await self._load_work_item_target_by_id(work_item_id=int(active_work_item_id)) - if target is not None: + if target is not None and str(getattr(target[0], "state", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: return target + 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, + ) return None stmt = ( @@ -1057,6 +1155,48 @@ class DraftConfirmHandler: return None return row + async def _release_mounted_draft_binding( + self, + *, + work_item_id: int, + final_state: Optional[str], + mounted: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]] = None, + ) -> None: + if not hasattr(self.db, "execute"): + return + if mounted is not None: + rows = [mounted] + else: + result = await self.db.execute( + select(MountedDraftWorkspace, MountedTopicWorkspace) + .join( + MountedTopicWorkspace, + MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id, + ) + .where( + and_( + MountedDraftWorkspace.tenant_id == int(self.tenant_id), + MountedTopicWorkspace.tenant_id == int(self.tenant_id), + MountedDraftWorkspace.active_work_item_id == int(work_item_id), + ) + ) + ) + rows = result.all() if hasattr(result, "all") else [] + normalized_final_state = str(final_state or "").strip().lower() or None + for draft_row, topic_row in rows: + draft_row.active_work_item_id = None + draft_row.confirm_message_id = None + draft_row.state = "ready" if getattr(draft_row, "draft_payload", None) else "collecting" + topic_row.status = "suspended" + topic_row.plan_status = "suspended" + topic_row.pending_actions = [] + topic_row.metadata_json = { + **(topic_row.metadata_json if isinstance(topic_row.metadata_json, dict) else {}), + "active_draft_work_item_id": None, + "draft_lifecycle_state": normalized_final_state, + } + await self.db.flush() + async def _resolve_active_mounted_draft( self, *, @@ -1106,6 +1246,26 @@ class DraftConfirmHandler: for draft_row, topic_row in rows: if not hasattr(draft_row, "topic_workspace_id") or not hasattr(topic_row, "mode"): continue + active_work_item_id = _safe_int(getattr(draft_row, "active_work_item_id", None)) + if active_work_item_id is None: + continue + target = await self._load_work_item_target_by_id(work_item_id=int(active_work_item_id)) + if target is None: + await self._release_mounted_draft_binding( + work_item_id=int(active_work_item_id), + final_state="missing", + mounted=(draft_row, topic_row), + ) + continue + target_work_item, _ = target + target_state = str(getattr(target_work_item, "state", "") or "").strip().lower() + if target_state != STATE_DRAFT_PENDING_CONFIRM: + await self._release_mounted_draft_binding( + work_item_id=int(active_work_item_id), + final_state=target_state, + mounted=(draft_row, topic_row), + ) + continue score = 0 if normalized_channel and str(getattr(topic_row, "channel_ref", "") or "").strip() == normalized_channel: score += 30 @@ -1123,8 +1283,7 @@ class DraftConfirmHandler: "draft_ready", }: score += 20 - if _safe_int(getattr(draft_row, "active_work_item_id", None)) is not None: - score += 10 + score += 10 if score > best_score: best = (draft_row, topic_row) best_score = score diff --git a/backend/app/services/ai_pmo/semantic_context/assembler.py b/backend/app/services/ai_pmo/semantic_context/assembler.py index 089d2c17..ccf8e55f 100644 --- a/backend/app/services/ai_pmo/semantic_context/assembler.py +++ b/backend/app/services/ai_pmo/semantic_context/assembler.py @@ -184,6 +184,7 @@ class SemanticContextAssembler: for action in ( "draft.followup.confirm", "draft.followup.revise", + "draft.followup.regenerate", "draft.followup.cancel", "draft.followup.answer_only", ): diff --git a/backend/app/services/ai_pmo/semantic_context/decision_schema.py b/backend/app/services/ai_pmo/semantic_context/decision_schema.py index f377d7b4..a39aa023 100644 --- a/backend/app/services/ai_pmo/semantic_context/decision_schema.py +++ b/backend/app/services/ai_pmo/semantic_context/decision_schema.py @@ -42,7 +42,7 @@ class SemanticDecision(BaseModel): topic_relation: Literal["continue_current_topic", "new_topic", "uncertain"] mode_decision: Literal["stay_plan", "ready_confirm", "request_execute", "answer_only"] followup_scope: Literal["none", "current_requirement", "current_draft", "independent_workflow", "clarify"] = "none" - followup_action: Literal["none", "confirm", "revise", "cancel", "answer_only"] = "none" + followup_action: Literal["none", "confirm", "revise", "regenerate", "cancel", "answer_only"] = "none" revision_note: str = "" draft_patch: dict[str, Any] = Field(default_factory=dict) workflow_plans: list[DecisionWorkflowPlan] = Field(default_factory=list) diff --git a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py index 99935def..829e313a 100644 --- a/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py +++ b/backend/app/services/ai_steward_service/mixin_creation_normalize_persist.py @@ -24,7 +24,7 @@ PROJECT_SIGNAL_SOURCE_KIND_LABELS = { PROJECT_SIGNAL_SOURCE_KIND_RE = re.compile(r"^[a-z0-9][a-z0-9:._/-]*[a-z0-9]$", re.IGNORECASE) -def _looks_like_technical_identifier(value: Any) -> bool: +def _is_source_kind_identifier(value: Any) -> bool: raw = str(value or "").strip() if not raw or " " in raw or any("\u4e00" <= char <= "\u9fff" for char in raw): return False @@ -40,7 +40,7 @@ def _display_source_kind(value: Any) -> str: mapped = PROJECT_SIGNAL_SOURCE_KIND_LABELS.get(raw.lower()) if mapped: return mapped - if _looks_like_technical_identifier(raw): + if _is_source_kind_identifier(raw): return "其他来源" return raw @@ -215,11 +215,10 @@ class AIStewardCreationPersistMixin: explicit_title = self._extract_explicit_project_title_from_instruction(input_text) extracted_title = self._extract_project_title_from_instruction(input_text) draft_title = str(project_draft.get("title") or "").strip() - if self._looks_like_instruction_sentence(draft_title): - draft_title = "" + normalized_draft_title = self._normalize_project_title_candidate(draft_title, max_len=50) normalized_title = ( explicit_title - or self._normalize_project_title_candidate(draft_title) + or normalized_draft_title or extracted_title or compact_title(str(input_text or "").strip(), max_len=24) or "未命名项目" diff --git a/backend/app/services/ai_steward_service/mixin_json_parsing.py b/backend/app/services/ai_steward_service/mixin_json_parsing.py index ab515db3..69549611 100644 --- a/backend/app/services/ai_steward_service/mixin_json_parsing.py +++ b/backend/app/services/ai_steward_service/mixin_json_parsing.py @@ -22,20 +22,13 @@ class AIStewardJsonParsingMixin: return ordered @staticmethod - def _looks_like_provider_budget_rejection(exc: Exception) -> bool: + def _is_provider_budget_rejection(exc: Exception) -> bool: lowered = str(exc or "").lower() return "400 bad request" in lowered and any( token in lowered for token in ("max_tokens", "context", "length", "token", "dashscope") ) - @staticmethod - def _looks_like_project_creation_stub_response(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return True - return any(marker in normalized for marker in PROJECT_CREATION_STUB_MARKERS) - @staticmethod def _strip_code_fence(text: str) -> str: raw = str(text or "").strip() @@ -173,7 +166,7 @@ class AIStewardJsonParsingMixin: error_type=type(last_error).__name__, error=str(last_error), ) - if self._looks_like_provider_budget_rejection(last_error): + if self._is_provider_budget_rejection(last_error): break continue try: @@ -220,8 +213,8 @@ class AIStewardJsonParsingMixin: ) cleaned_response = str(response or "").strip() elapsed_seconds = round(time.perf_counter() - started_at, 2) - if self._looks_like_project_creation_stub_response(cleaned_response): - raise RuntimeError("project_creation_stub_response") + if not cleaned_response: + raise RuntimeError("project_creation_empty_response") logger.info( "ai_steward_project_creation_step_ok", tenant_id=int(tenant_id), diff --git a/backend/app/services/ai_steward_service/mixin_title_utils.py b/backend/app/services/ai_steward_service/mixin_title_utils.py index 2002ff83..11b26fc8 100644 --- a/backend/app/services/ai_steward_service/mixin_title_utils.py +++ b/backend/app/services/ai_steward_service/mixin_title_utils.py @@ -90,20 +90,13 @@ class AIStewardTitleUtilsMixin: return normalized return "" - @staticmethod - def _looks_like_instruction_sentence(text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - return "项目" in normalized and any( - token in normalized - for token in ("创建", "新建", "新增", "增加", "添加", "帮我", "给我", "不是", "信号") - ) - @classmethod def _compact_project_description(cls, *, input_text: str, title: str, draft_description: str) -> str: raw = str(draft_description or "").strip() - if raw and not cls._looks_like_instruction_sentence(raw): + normalized_raw = re.sub(r"\s+", " ", raw).strip().lower() + normalized_input = re.sub(r"\s+", " ", str(input_text or "")).strip().lower() + normalized_title = re.sub(r"\s+", " ", str(title or "")).strip().lower() + if raw and normalized_raw and normalized_raw not in {normalized_input, normalized_title}: return raw[:4000] if title: return f"项目:{title}"[:4000] diff --git a/backend/app/services/ai_steward_service/shared.py b/backend/app/services/ai_steward_service/shared.py index 0a818558..cdbb5ae3 100644 --- a/backend/app/services/ai_steward_service/shared.py +++ b/backend/app/services/ai_steward_service/shared.py @@ -75,9 +75,4 @@ PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS = max( PROJECT_CREATION_DRAFT_TIMEOUT_SECONDS, _env_float("AI_STEWARD_PROJECT_CREATION_TASK_CHAIN_TIMEOUT_SECONDS", 60.0), ) -PROJECT_CREATION_STUB_MARKERS = ( - "我暂时无法生成可靠回答", - "模型服务异常", - "请稍后重试", -) PROJECT_CREATION_SERIAL_DRAFT_STEPS = _env_int("AI_STEWARD_PROJECT_CREATION_SERIAL_DRAFT_STEPS", 1) == 1 diff --git a/backend/app/services/executive_briefing_service.py b/backend/app/services/executive_briefing_service.py index 071f31b9..e3483236 100644 --- a/backend/app/services/executive_briefing_service.py +++ b/backend/app/services/executive_briefing_service.py @@ -132,7 +132,7 @@ def _contains_cjk(text: str) -> bool: return any("\u4e00" <= char <= "\u9fff" for char in text) -def _looks_like_technical_identifier(value: Any) -> bool: +def _is_technical_identifier(value: Any) -> bool: text = str(value or "").strip() if not text or _contains_cjk(text) or " " in text: return False @@ -168,7 +168,7 @@ def _label_source_kind(value: Any) -> str: mapped = SOURCE_KIND_LABELS.get(raw.lower()) if mapped: return mapped - if _looks_like_technical_identifier(raw): + if _is_technical_identifier(raw): return "其他来源" return raw @@ -180,7 +180,7 @@ def _label_conflict_type(value: Any) -> str: mapped = CONFLICT_TYPE_LABELS.get(raw.lower()) if mapped: return mapped - if _looks_like_technical_identifier(raw): + if _is_technical_identifier(raw): return "其他冲突" return raw @@ -676,7 +676,7 @@ class ExecutiveBriefingService: continue if fallback_text is None: fallback_text = text - if not _looks_like_technical_identifier(text): + if not _is_technical_identifier(text): return text return fallback_text @@ -729,7 +729,7 @@ class ExecutiveBriefingService: def _display_subtitle(*values: Any) -> str: for value in values: text = str(value or "").strip() - if text and not _looks_like_technical_identifier(text): + if text and not _is_technical_identifier(text): return text for value in values: text = str(value or "").strip() @@ -1248,7 +1248,7 @@ class ExecutiveBriefingService: display_title=_label_conflict_type(row.conflict_type), display_subtitle=self._display_subtitle(row.description), display_source="协作冲突", - technical_identifier=str(row.conflict_type or "").strip() if _looks_like_technical_identifier(row.conflict_type) else None, + technical_identifier=str(row.conflict_type or "").strip() if _is_technical_identifier(row.conflict_type) else None, target=self._project_target( normalized_project_id, params={"stage": "signal"}, 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 ce3111f3..ad5ff5ff 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 @@ -113,8 +113,7 @@ class FeishuReplyLLMCoreMixin: ) reply_text = self._clean_reply_text(raw_reply) reply_text = self._strip_leading_thinking_paragraphs(reply_text) - if not self._is_capability_question(message_content): - reply_text = self._strip_identity_lead(reply_text) + reply_text = self._strip_identity_lead(reply_text) if not reply_text: reply_text = self._build_llm_failure_reply_text( capture_result, @@ -195,31 +194,10 @@ class FeishuReplyLLMCoreMixin: return "" return "\n\n".join(paragraphs[first_clean_idx:]).strip()[:500] - @staticmethod - def _is_capability_question(message_content: Optional[str]) -> bool: - normalized = re.sub(r"\s+", "", str(message_content or "").lower()) - if not normalized: - return False - capability_tokens = ( - "你是谁", - "你是什么", - "你能做什么", - "你可以做什么", - "系统能力", - "能力介绍", - "介绍下你", - ) - return any(token in normalized for token in capability_tokens) - @classmethod def _build_capability_reply(cls, message_content: Optional[str]) -> Optional[str]: - if not cls._is_capability_question(message_content): - return None - return ( - "我是 PMFlow 的项目协同助手。" - "我可以识别需求并入库信号、关联项目和任务、跟进状态并回答项目问题。" - "你直接说目标、对象和期望动作即可。" - ) + _ = (cls, message_content) + return None @classmethod def _is_action_request_message( diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py index 3e2377d6..60f8b385 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py @@ -6,11 +6,6 @@ from app.utils.thread_context import extract_thread_anchor class FeishuPipelineFollowupBMixin: - @staticmethod - def _extract_item_operation_code(item: FeishuItem) -> str: - payload = item.result_payload if isinstance(item.result_payload, dict) else {} - return str(payload.get("operation_code") or "").strip().lower() - @classmethod def _select_followup_status_target( cls, @@ -19,15 +14,12 @@ class FeishuPipelineFollowupBMixin: recent_items: list[FeishuItem], thread_context: Optional[dict[str, Any]] = None, ) -> Optional[FeishuItem]: + _ = (cls, message) if not recent_items: return None - normalized = re.sub(r"\s+", "", str(message or "")).lower() - wants_project_create = any(token in normalized for token in ("创建", "建好", "建成", "项目")) - wants_source_status = any(token in normalized for token in ("信号源", "web搜索", "webhook", "source")) - matches: list[FeishuItem] = [] thread_anchor = extract_thread_anchor(thread_context) if thread_anchor: - anchor_item = next( + return next( ( item for item in recent_items @@ -35,27 +27,6 @@ class FeishuPipelineFollowupBMixin: ), None, ) - return anchor_item - - if wants_source_status: - for item in recent_items: - operation_code = cls._extract_item_operation_code(item) - if operation_code == "source.connect" or "信号源" in str(item.raw_message or ""): - matches.append(item) - return matches[0] if len(matches) == 1 else None - - if wants_project_create: - for item in recent_items: - operation_code = cls._extract_item_operation_code(item) - level = str(item.level or "").strip().lower() - card_type = str(item.card_type or "").strip().lower() - if ( - level == "project" - or operation_code == "project.create" - or card_type in {"project_create", "new_project", "new_project_candidate"} - ): - matches.append(item) - return matches[0] if len(matches) == 1 else None return None diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py index ee09bb0a..aefe7347 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py @@ -34,19 +34,6 @@ class FeishuPipelineLookupAndBlockMixin: return "" - @classmethod - def _is_project_lookup_question(cls, message: str) -> bool: - raw = str(message or "").strip() - if not raw: - return False - if PROJECT_ID_QUERY_RE.search(raw) or PROJECT_LIST_QUERY_RE.search(raw): - return True - if "项目" not in raw and "系统" not in raw: - return False - if "?" not in raw and "?" not in raw and not any(token in raw for token in ("有没有", "是否有", "有无", "存在", "列表", "清单")): - return False - return bool(cls._extract_project_lookup_keyword(raw)) - async def _maybe_block_legacy_command_request( self, *, @@ -80,8 +67,12 @@ class FeishuPipelineLookupAndBlockMixin: feishu_message_id: Optional[str], process_started_at: float, projects: list[dict[str, Any]], + intent_gate: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: - if not self._is_project_lookup_question(message): + semantic_bundle = intent_gate.get("semantic_bundle") if isinstance(intent_gate, dict) else {} + diagnostics = semantic_bundle.get("diagnostics") if isinstance(semantic_bundle, dict) else {} + message_intent = str(diagnostics.get("message_intent") or "").strip().lower() + if message_intent != "project_lookup_question": return None linked_project_ids: list[int] = [] @@ -222,12 +213,8 @@ class FeishuPipelineLookupAndBlockMixin: if not raw_message: return False normalized = re.sub(r"\s+", "", raw_message).lower() - if cls._is_water_talk_message(raw_message): - return True if cls._is_healthcheck_message(raw_message): return True - if cls._is_project_lookup_question(raw_message): - return True if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")): return True return False diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py index 9dd66471..8c89d921 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py @@ -6,70 +6,6 @@ from app.utils.thread_context import build_feishu_conversation_scope_key class FeishuPipelineMessageUtilsAMixin: - @staticmethod - def _is_water_talk_message(message: str) -> bool: - content = str(message or "").strip().lower() - if not content: - return True - normalized = re.sub(r"\s+", "", content) - question_hints = ( - "?", - "?", - "吗", - "怎么", - "如何", - "为什么", - "谁", - "多少", - "是否", - "有没有", - "几号", - "几点", - "周几", - ) - water_keywords = ( - "你是谁", - "你是誰", - "什么身份", - "身份确认", - "今天什么日期", - "今天几号", - "当前日期", - "日期疑问", - "询问日期", - "现在几点", - "几点了", - "今天周几", - "今天星期几", - "说话", - "在吗", - "在不在", - ) - requirement_hints = ( - "需求", - "优化", - "调整", - "修复", - "bug", - "故障", - "报错", - "项目", - "页面", - "接口", - "功能", - "排版", - "样式", - "任务", - "立项", - "上线", - "配置", - ) - if any(k in normalized for k in water_keywords) and not any(h in normalized for h in requirement_hints): - return True - if any(k in normalized for k in question_hints): - return False - return False - @staticmethod def _build_rag_conversation_key( *, @@ -84,55 +20,3 @@ class FeishuPipelineMessageUtilsAMixin: source_sender_open_id=source_sender_open_id, sender=sender, ) - - @staticmethod - def _is_requirement_like_question(message: str) -> bool: - text = str(message or "").strip() - if not text: - return False - normalized = re.sub(r"\s+", "", text.lower()) - has_question = ( - "?" in normalized - or "?" in normalized - or any( - cue in normalized - for cue in ("吗", "是否", "是不是", "要不要", "能不能", "可不可以", "需不需要", "有没有必要") - ) - ) - if not has_question: - return False - if INDEPENDENT_REQUIREMENT_RE.search(normalized): - return True - if SIMPLE_SIGNAL_REQUEST_RE.search(normalized): - return True - has_verb = bool(QUESTION_REQUIREMENT_VERB_RE.search(normalized)) - has_object = bool(QUESTION_REQUIREMENT_OBJECT_RE.search(normalized)) - return has_verb and has_object - - @staticmethod - def _extract_requirement_statement_from_question(message: str) -> str: - text = str(message or "").strip() - if not text: - return "" - candidate = re.sub(r"^(请问|问下|想问下|我想问下|麻烦问下|请教下)\s*", "", text, flags=re.IGNORECASE).strip() - candidate = re.sub(r"\s*[??!!。\.]+\s*$", "", candidate).strip() - candidate = re.sub(r"(吗|呢|呀|吧)\s*$", "", candidate).strip() - replacements = ( - (r"能不能", "需要"), - (r"可不可以", "需要"), - (r"要不要", "需要"), - (r"需不需要", "需要"), - (r"有没有必要", "需要"), - (r"是不是", ""), - (r"是否", ""), - ) - for pattern, repl in replacements: - candidate = re.sub(pattern, repl, candidate, flags=re.IGNORECASE) - candidate = re.sub(r"\s+", " ", candidate).strip(" ,,::") - return candidate or text - - def _convert_requirement_question_to_signal_text(self, *, message: str, fallback_content: str) -> str: - converted = self._extract_requirement_statement_from_question(message) - if converted: - return converted - return self._normalize_incoming_text(fallback_content) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py index 8145f75d..62470529 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py @@ -29,41 +29,6 @@ class FeishuPipelineProcessMessageCoreMixin: db_mutations=[], ) - if self._is_water_talk_message(message): - duration_ms = int((time.perf_counter() - process_started_at) * 1000) - await self._write_processing_log( - feishu_message_id=feishu_message_id, - feishu_item_id=None, - raw_message=message, - ai_raw_output=None, - project_score=None, - signal_confidence=None, - level=None, - routing_decision=ROUTING_NOISE_FILTERED, - routing_reason="识别为闲聊/问答水话,已过滤且不进入待处理队列。", - reasoning_bullets=[ - "消息缺少可执行交付信息。", - "不满足需求信号的最小结构(目标/对象/动作)。", - "已按噪音消息忽略,不触发入库与排队。", - ], - processing_status="success", - error_message=None, - duration_ms=duration_ms, - ) - await self.db.commit() - return self._decorate_capture_result({ - "status": "ignored", - "reason": ROUTING_NOISE_FILTERED, - "classification": "non_requirement", - "project_scope": "non_requirement", - "linked_project_ids": [], - "signal_id": None, - "created_project_id": None, - "auto_tasks": [], - "ai_trace_id": f"feishu-noise-{(feishu_message_id or 'noid')[:32]}", - "results": [], - }, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.noise_filtered", db_mutations=[]) - if feishu_message_id: await self._acquire_message_lock(feishu_message_id) @@ -172,22 +137,6 @@ class FeishuPipelineProcessMessageCoreMixin: db_mutations=[], ) - lookup_capture = await self._maybe_answer_project_lookup_question( - message=message, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - projects=projects, - ) - if lookup_capture is not None: - await self.db.commit() - return self._decorate_capture_result( - lookup_capture, - chat_type=normalized_chat_type, - normalized_text=message, - route=f"{normalized_chat_type}.project_lookup", - db_mutations=[], - ) - intent_gate = await self._resolve_intent_gate( message=message, inferred_project_id=inferred_project_id, @@ -211,6 +160,23 @@ class FeishuPipelineProcessMessageCoreMixin: intent_gate=intent_gate, ) + lookup_capture = await self._maybe_answer_project_lookup_question( + message=message, + feishu_message_id=feishu_message_id, + process_started_at=process_started_at, + projects=projects, + intent_gate=intent_gate, + ) + if lookup_capture is not None: + await self.db.commit() + return self._decorate_capture_result( + lookup_capture, + chat_type=normalized_chat_type, + normalized_text=message, + route=f"{normalized_chat_type}.project_lookup", + db_mutations=[], + ) + gate_capture = await self._maybe_handle_intent_gate_capture( intent_gate=intent_gate, message=message, diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py index 56bc9795..d9b29353 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py @@ -37,18 +37,22 @@ class FeishuPipelineProcessMessageDefaultMixin: rag_question_part = message intent_result = await rag_service.classify_intent(message) intent = str(intent_result.get("intent") or "statement").lower() + semantic_message_intent = str(intent_result.get("message_intent") or intent).strip().lower() or intent intent_for_response = intent rag_question_part = str(intent_result.get("question_part") or "").strip() or message rag_statement_part = str(intent_result.get("statement_part") or "").strip() if intent == "question": - if self._is_requirement_like_question(message): + if semantic_message_intent in { + "mixed", + "requirement_plan", + "requirement_initiation", + "business_review", + "correction_escalation", + }: mixed_signal_part = ( rag_statement_part - or self._convert_requirement_question_to_signal_text( - message=message, - fallback_content=message_content, - ) + or message ) mixed_rag_result = await rag_service.answer_question( question=rag_question_part, @@ -93,10 +97,7 @@ class FeishuPipelineProcessMessageDefaultMixin: if intent == "mixed": mixed_signal_part = ( rag_statement_part - or self._convert_requirement_question_to_signal_text( - message=message, - fallback_content=message_content, - ) + or message ) mixed_rag_result = await rag_service.answer_question( question=rag_question_part, diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_read_write.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_read_write.py index be5abd25..1b4d86af 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_read_write.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_read_write.py @@ -51,7 +51,6 @@ class FeishuPipelineQueueReadWriteMixin: ) ).scalars().all() auto_rows = self._dedupe_queue_items(auto_rows) - auto_rows = [row for row in auto_rows if not self._is_water_talk_message(row.raw_message or "")] pending_items = [await self._serialize_item(row) for row in pending_rows] pending_items.sort( @@ -180,4 +179,3 @@ class FeishuPipelineQueueReadWriteMixin: await self.db.commit() return {"id": str(item.id), "status": item.route_status} - diff --git a/backend/app/services/llm_service/mixin_chat_openai.py b/backend/app/services/llm_service/mixin_chat_openai.py index e4750029..1c9cc19b 100644 --- a/backend/app/services/llm_service/mixin_chat_openai.py +++ b/backend/app/services/llm_service/mixin_chat_openai.py @@ -182,7 +182,7 @@ class LLMChatOpenAIMixin: response=data, ) normalized_reasoning = self._strip_code_fence(reasoning_text) - if self._looks_like_json_container(normalized_reasoning): + if self._contains_json_container(normalized_reasoning): candidate = self._extract_json_candidate(normalized_reasoning) if candidate: _runtime_log( @@ -238,7 +238,7 @@ class LLMChatOpenAIMixin: ) continue raise empty_error - if _looks_like_openclaw_timeout_message(text): + if _is_openclaw_timeout_message(text): elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( "OpenClaw超时" if is_openclaw_gateway_request else "LLM超时文本", diff --git a/backend/app/services/llm_service/mixin_chat_transport.py b/backend/app/services/llm_service/mixin_chat_transport.py index 41dbddcd..f041426a 100644 --- a/backend/app/services/llm_service/mixin_chat_transport.py +++ b/backend/app/services/llm_service/mixin_chat_transport.py @@ -16,6 +16,7 @@ class LLMChatTransportMixin: max_tokens: int, function_name: str = "", response_mode: str = "complete", + routing_meta: dict[str, Any] | None = None, ) -> str: result = await self._chat_completion_raw( provider=provider, @@ -28,6 +29,7 @@ class LLMChatTransportMixin: max_tokens=max_tokens, function_name=function_name, response_mode=response_mode, + routing_meta=routing_meta, ) return result.text @@ -44,10 +46,16 @@ class LLMChatTransportMixin: max_tokens: int, function_name: str = "", response_mode: str = "complete", + routing_meta: dict[str, Any] | None = None, ) -> ChatCompletionResult: provider_name = _normalize_provider(provider) - structured_output = self._structured_output_required(function_name) - request_timeout = self._request_timeout_for_function(function_name, response_mode=response_mode) + normalized_routing_meta = _normalize_routing_meta(routing_meta) + structured_output = self._structured_output_required(function_name, routing_meta=normalized_routing_meta) + request_timeout = self._request_timeout_for_function( + function_name, + response_mode=response_mode, + routing_meta=normalized_routing_meta, + ) is_openclaw_gateway_request = self._is_openclaw_gateway_request( provider_name=provider_name, base_url=base_url, @@ -56,7 +64,10 @@ class LLMChatTransportMixin: if is_openclaw_gateway_request: # 网关内部 agent 超时需小于客户端超时,避免本地请求先被中止。 request_timeout = max(float(request_timeout), float(openclaw_agents_timeout_seconds) + 5.0) - use_provider_circuit_breaker = self._use_provider_circuit_breaker(function_name) + use_provider_circuit_breaker = self._use_provider_circuit_breaker( + function_name, + routing_meta=normalized_routing_meta, + ) circuit_key = self._provider_circuit_key( provider=provider_name, base_url=base_url, @@ -123,7 +134,7 @@ class LLMChatTransportMixin: self._record_provider_success(circuit_key) return result except Exception as exc: - if _looks_like_openclaw_timeout_message(str(exc)): + if _is_openclaw_timeout_message(str(exc)): elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( "LLM超时异常", 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 f7d1a40b..b369b37e 100644 --- a/backend/app/services/llm_service/mixin_engine_resolution_core.py +++ b/backend/app/services/llm_service/mixin_engine_resolution_core.py @@ -130,6 +130,7 @@ class LLMEngineResolutionCoreMixin: *, tenant_id: int, function_name: str, + routing_meta: dict[str, Any] | None = None, ) -> dict[str, Any]: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) settings = self._normalize_engine_settings(bundle.get("settings")) @@ -167,7 +168,7 @@ class LLMEngineResolutionCoreMixin: ) if not self._mixed_routing_enabled(): - default_tier = self._resolve_function_tier(function_name) + default_tier = self._resolve_function_tier(function_name, routing_meta=routing_meta) active_engine = _merge_engine_routing_meta( active_engine, tier_alias=default_tier, @@ -183,7 +184,7 @@ class LLMEngineResolutionCoreMixin: engine_label="active_engine", ) - tier = self._resolve_function_tier(function_name) + tier = self._resolve_function_tier(function_name, routing_meta=routing_meta) if tier not in {"high", "low"}: active_engine = _merge_engine_routing_meta( active_engine, diff --git a/backend/app/services/llm_service/mixin_generate_db_chat.py b/backend/app/services/llm_service/mixin_generate_db_chat.py index 130af99b..13661811 100644 --- a/backend/app/services/llm_service/mixin_generate_db_chat.py +++ b/backend/app/services/llm_service/mixin_generate_db_chat.py @@ -11,6 +11,7 @@ class LLMGenerateDbChatMixin: max_tokens = int(kwargs.get("max_tokens") or 1024) use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" + routing_meta = _normalize_routing_meta(kwargs.get("routing_meta")) response_text = "" status = "success" routing_decision = "primary_engine" @@ -53,6 +54,7 @@ class LLMGenerateDbChatMixin: db, tenant_id=tenant_id, function_name=function_name, + routing_meta=routing_meta, ) engine_meta = engine provider = str(engine.get("provider") or "") @@ -68,11 +70,16 @@ class LLMGenerateDbChatMixin: max_tokens=max_tokens, function_name=function_name, response_mode=response_mode, + routing_meta=routing_meta, ) if not response_text.strip(): raise ValueError("primary_engine_empty_response") - if self._is_invalid_structured_output(function_name=function_name, response_text=response_text): + if self._call_is_invalid_structured_output( + function_name=function_name, + response_text=response_text, + routing_meta=routing_meta, + ): raise ValueError("invalid_structured_output") return response_text except Exception as exc: @@ -87,6 +94,7 @@ class LLMGenerateDbChatMixin: max_tokens=max_tokens, response_mode=response_mode, error=exc, + routing_meta=routing_meta, ) if failover_response and isinstance(failover_engine, dict): routing_decision = "low_to_high_escalated" diff --git a/backend/app/services/llm_service/mixin_generate_db_structured.py b/backend/app/services/llm_service/mixin_generate_db_structured.py index 021f6dd4..629ae6ff 100644 --- a/backend/app/services/llm_service/mixin_generate_db_structured.py +++ b/backend/app/services/llm_service/mixin_generate_db_structured.py @@ -15,6 +15,8 @@ class LLMGenerateDbStructuredMixin: max_tokens = int(kwargs.get("max_tokens") or 1024) use_prompt_profile = bool(kwargs.get("use_prompt_profile", True)) response_mode = str(kwargs.get("response_mode") or "complete").strip().lower() or "complete" + routing_meta = _normalize_routing_meta(kwargs.get("routing_meta")) + routing_meta["response_contract"] = "structured" prompt_profile = {"role_name": "", "system_prompt": ""} prompt_with_context = await self._inject_model_context_bundle_prompt( db=db, @@ -40,6 +42,7 @@ class LLMGenerateDbStructuredMixin: db, tenant_id=tenant_id, function_name=function_name, + routing_meta=routing_meta, ) engine_meta = engine provider = str(engine.get("provider") or "") @@ -80,6 +83,7 @@ class LLMGenerateDbStructuredMixin: max_tokens=max_tokens, function_name=function_name, response_mode=response_mode, + routing_meta=routing_meta, ) response_text = completion.text extraction = self._extract_structured_object_from_response( @@ -110,6 +114,7 @@ class LLMGenerateDbStructuredMixin: raw_response=completion.raw_response, fallback_text=completion.text, max_tokens=max_tokens, + routing_meta=routing_meta, ) if isinstance(repaired, StructuredExtractionResult) and repaired.payload is not None: extraction = repaired @@ -134,6 +139,7 @@ class LLMGenerateDbStructuredMixin: max_tokens=max_tokens, response_mode=response_mode, error=exc, + routing_meta=routing_meta, ) if isinstance(failover_result, StructuredExtractionResult) and isinstance(failover_engine, dict): engine_meta = failover_engine @@ -187,7 +193,7 @@ class LLMGenerateDbStructuredMixin: @staticmethod def _should_attempt_model_repair(function_name: str) -> bool: fn = str(function_name or "").strip().lower() - if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + if fn == "pmflow_semantic_context_decision": return _env_bool("LLM_INTENT_STRUCTURED_REPAIR_ENABLED", True) return True @@ -205,6 +211,7 @@ class LLMGenerateDbStructuredMixin: raw_response: Any, fallback_text: str, max_tokens: int, + routing_meta: dict[str, Any] | None = None, ) -> Optional[StructuredExtractionResult]: source_text = str(extraction.raw_text or "").strip() if source_text.startswith("[") and source_text.endswith("]"): @@ -229,7 +236,7 @@ class LLMGenerateDbStructuredMixin: ) try: repair_max_tokens = max(180, min(int(max_tokens or 0), 420)) - if str(function_name or "").strip().lower() in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + if str(function_name or "").strip().lower() == "pmflow_semantic_context_decision": repair_max_tokens = min(repair_max_tokens, 220) repair_completion = await self._chat_completion_raw( provider=provider, @@ -242,6 +249,7 @@ class LLMGenerateDbStructuredMixin: max_tokens=repair_max_tokens, function_name=function_name, response_mode="fast", + routing_meta=_normalize_routing_meta(routing_meta), ) except Exception as exc: _runtime_log( diff --git a/backend/app/services/llm_service/mixin_guardrails_prompt.py b/backend/app/services/llm_service/mixin_guardrails_prompt.py index 887b4c54..b4244786 100644 --- a/backend/app/services/llm_service/mixin_guardrails_prompt.py +++ b/backend/app/services/llm_service/mixin_guardrails_prompt.py @@ -44,13 +44,6 @@ class LLMGuardrailsPromptMixin: def _direct_emergency_enabled(self) -> bool: return _env_bool("LLM_DIRECT_EMERGENCY_ENABLED", False) - @staticmethod - def _prefer_fast_local_fallback(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - if not fn: - return False - return any(hint and hint in fn for hint in FAST_LOCAL_FALLBACK_FUNCTION_HINTS) - @staticmethod def _should_raise_model_error(function_name: str) -> bool: fn = str(function_name or "").strip().lower() @@ -79,16 +72,6 @@ class LLMGuardrailsPromptMixin: return f"模型调用失败:function={fn_text}, {route_bits}, reason={clean_error}" return f"模型调用失败:function={fn_text}, reason={clean_error}" - @staticmethod - def _is_fast_local_planner_function(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - return any(hint and hint in fn for hint in FAST_LOCAL_PLANNER_FUNCTION_HINTS) - - @staticmethod - def _is_fast_local_worker_function(function_name: str) -> bool: - fn = str(function_name or "").strip().lower() - return any(hint and hint in fn for hint in FAST_LOCAL_WORKER_FUNCTION_HINTS) - @staticmethod def _structured_cache_enabled(function_name: str) -> bool: fn = str(function_name or "").strip().lower() @@ -96,12 +79,19 @@ class LLMGuardrailsPromptMixin: "signal_source_onboarding_plan", } - def _request_timeout_for_function(self, function_name: str, *, response_mode: str = "complete") -> float: - if not self._prefer_fast_local_fallback(function_name): + def _request_timeout_for_function( + self, + function_name: str, + *, + response_mode: str = "complete", + routing_meta: dict[str, Any] | None = None, + ) -> float: + meta = _normalize_routing_meta(routing_meta) + if meta.get("fallback_policy") != "fast_local": return self.request_timeout env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_COMPLETE" default_timeout = FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS - if self._is_fast_local_planner_function(function_name) and str(response_mode or "").strip().lower() == "fast": + if meta.get("execution_mode") == "local_planner" and str(response_mode or "").strip().lower() == "fast": env_key = "LLM_FAST_LOCAL_FALLBACK_TIMEOUT_FAST" default_timeout = FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS try: @@ -139,8 +129,7 @@ class LLMGuardrailsPromptMixin: def _http_status_retry_budget_for_function(function_name: str) -> int: fn = str(function_name or "").strip().lower() is_intent_function = fn in { - "classify_intent_ai_pmo_kernel", - "classify_intent_complex", + "pmflow_semantic_context_decision", "intent_structured_runtime_failover", } env_key = "LLM_INTENT_HTTP_RETRY_BUDGET" if is_intent_function else "LLM_HTTP_STATUS_RETRY_BUDGET" @@ -158,7 +147,7 @@ class LLMGuardrailsPromptMixin: current = max(int(current_max_tokens or 0), 1) if current >= 2800: return 0 - if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + if fn == "pmflow_semantic_context_decision": raw_budget = str(os.getenv("LLM_INTENT_EMPTY_RETRY_BUDGET", "2") or "2").strip() try: parsed_budget = int(raw_budget) @@ -198,16 +187,24 @@ class LLMGuardrailsPromptMixin: grown = max(current + 512, int(current * 1.8)) return min(grown, 3000) - def _use_provider_circuit_breaker(self, function_name: str) -> bool: + def _use_provider_circuit_breaker(self, function_name: str, routing_meta: dict[str, Any] | None = None) -> bool: fn = str(function_name or "").strip().lower() if any(fn.startswith(prefix) for prefix in CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES): return False - return not self._prefer_fast_local_fallback(function_name) + meta = _normalize_routing_meta(routing_meta) + return meta.get("fallback_policy") != "fast_local" - def _structured_output_required(self, function_name: str) -> bool: + def _structured_output_required( + self, + function_name: str, + routing_meta: dict[str, Any] | None = None, + ) -> bool: + meta = _normalize_routing_meta(routing_meta) + if meta.get("response_contract") == "structured": + return True + if meta.get("response_contract") == "freeform": + return False fn = str(function_name or "").strip().lower() if not fn: return False - custom_hints = _parse_csv_items(os.getenv("LLM_STRUCTURED_OUTPUT_FUNCTIONS", "")) - hint_pool = custom_hints or STRUCTURED_OUTPUT_FUNCTION_HINTS - return any(hint and hint in fn for hint in hint_pool) + return fn in STRUCTURED_OBJECT_SCHEMAS diff --git a/backend/app/services/llm_service/mixin_openclaw_routing.py b/backend/app/services/llm_service/mixin_openclaw_routing.py index 55a7c037..a6680c82 100644 --- a/backend/app/services/llm_service/mixin_openclaw_routing.py +++ b/backend/app/services/llm_service/mixin_openclaw_routing.py @@ -150,20 +150,24 @@ class LLMOpenclawRoutingMixin: routed["api_env"] = api_env_override return routed - def _resolve_function_tier(self, function_name: str) -> str: + def _resolve_function_tier(self, function_name: str, routing_meta: dict[str, Any] | None = None) -> str: + meta = _normalize_routing_meta(routing_meta) + explicit_tier = _optional_tier_alias( + meta.get("planned_tier") + or meta.get("tier_alias") + or meta.get("executed_tier") + ) + if explicit_tier: + return explicit_tier fn = str(function_name or "").strip().lower() if not fn: return str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "low") or "low").strip().lower() - - high_hints = _parse_csv_items(os.getenv("LLM_ROUTING_HIGH_FUNCTIONS", "")) - low_hints = _parse_csv_items(os.getenv("LLM_ROUTING_LOW_FUNCTIONS", "")) - high_pool = high_hints or DEFAULT_HIGH_TIER_HINTS - low_pool = low_hints or DEFAULT_LOW_TIER_HINTS - - if any(hint and hint in fn for hint in high_pool): - return "high" - if any(hint and hint in fn for hint in low_pool): - return "low" + bundle = self._load_openclaw_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() + if configured_tier in {"low", "high"}: + return configured_tier default_tier = str(os.getenv("LLM_ROUTING_DEFAULT_TIER", "low") or "low").strip().lower() if default_tier in {"low", "high"}: return default_tier diff --git a/backend/app/services/llm_service/mixin_structured_candidates.py b/backend/app/services/llm_service/mixin_structured_candidates.py index 2979d0e4..a1b1f3c2 100644 --- a/backend/app/services/llm_service/mixin_structured_candidates.py +++ b/backend/app/services/llm_service/mixin_structured_candidates.py @@ -291,8 +291,6 @@ class LLMStructuredCandidatesMixin: return payload if fn in {"generate_task_breakdown", "sstc_ai_breakdown"}: return cls._coerce_task_breakdown_payload(payload) - if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: - return cls._coerce_intent_structured_payload(payload) return payload def _validate_structured_payload(self, *, function_name: str, payload: Any) -> Optional[str]: diff --git a/backend/app/services/llm_service/mixin_structured_extract_object.py b/backend/app/services/llm_service/mixin_structured_extract_object.py index cc9c298d..2446b8b3 100644 --- a/backend/app/services/llm_service/mixin_structured_extract_object.py +++ b/backend/app/services/llm_service/mixin_structured_extract_object.py @@ -67,7 +67,7 @@ class LLMStructuredExtractObjectMixin: if normalized: last_nonempty_field = field last_nonempty_text = normalized[:4000] - if not self._looks_like_json_container(normalized): + if not self._contains_json_container(normalized): continue candidate = self._extract_json_candidate(normalized) if not candidate: @@ -118,7 +118,7 @@ class LLMStructuredExtractObjectMixin: fallback_normalized = self._strip_code_fence(fallback_text) fallback_candidate = ( self._extract_json_candidate(fallback_normalized) - if self._looks_like_json_container(fallback_normalized) + if self._contains_json_container(fallback_normalized) else "" ) if fallback_candidate: diff --git a/backend/app/services/llm_service/mixin_structured_parse.py b/backend/app/services/llm_service/mixin_structured_parse.py index 8a38bd6f..31f77a3a 100644 --- a/backend/app/services/llm_service/mixin_structured_parse.py +++ b/backend/app/services/llm_service/mixin_structured_parse.py @@ -3,6 +3,29 @@ from __future__ import annotations from .shared import * class LLMStructuredParseMixin: + def _call_is_invalid_structured_output( + self, + *, + function_name: str, + response_text: str, + routing_meta: dict[str, Any] | None = None, + ) -> bool: + try: + return bool( + self._is_invalid_structured_output( + function_name=function_name, + response_text=response_text, + routing_meta=routing_meta, + ) + ) + except TypeError: + return bool( + self._is_invalid_structured_output( + function_name=function_name, + response_text=response_text, + ) + ) + @staticmethod def _extract_json_candidate(text: str) -> str: raw = str(text or "").strip() @@ -28,7 +51,7 @@ class LLMStructuredParseMixin: return raw @staticmethod - def _looks_like_json_container(text: str) -> bool: + def _contains_json_container(text: str) -> bool: raw = str(text or "").strip() if not raw: return False @@ -66,8 +89,14 @@ class LLMStructuredParseMixin: return raw[start : idx + 1].strip() return "" - def _is_invalid_structured_output(self, *, function_name: str, response_text: str) -> bool: - if not self._structured_output_required(function_name): + def _is_invalid_structured_output( + self, + *, + function_name: str, + response_text: str, + routing_meta: dict[str, Any] | None = None, + ) -> bool: + if not self._structured_output_required(function_name, routing_meta=routing_meta): return False candidate = self._extract_json_candidate(response_text) if not candidate: @@ -115,7 +144,7 @@ class LLMStructuredParseMixin: ): text = self._sanitize_visible_answer(self._extract_text_from_value(raw_value)) normalized = self._strip_code_fence(text) - if not self._looks_like_json_container(normalized): + if not self._contains_json_container(normalized): continue candidate = self._extract_json_candidate(normalized) if candidate: diff --git a/backend/app/services/llm_service/mixin_tier_escalation.py b/backend/app/services/llm_service/mixin_tier_escalation.py index d4c42e0f..9d4f19ef 100644 --- a/backend/app/services/llm_service/mixin_tier_escalation.py +++ b/backend/app/services/llm_service/mixin_tier_escalation.py @@ -15,16 +15,62 @@ class LLMTierEscalationMixin: == str(b.get("base_url") or "").strip().rstrip("/").lower() ) - def _is_low_tier_function(self, function_name: str) -> bool: + def _call_is_low_tier_function( + self, + function_name: str, + *, + routing_meta: dict[str, Any] | None = None, + ) -> bool: + try: + return bool(self._is_low_tier_function(function_name, routing_meta=routing_meta)) + except TypeError: + return bool(self._is_low_tier_function(function_name)) + + def _call_is_invalid_structured_output_for_retry( + self, + *, + function_name: str, + response_text: str, + routing_meta: dict[str, Any] | None = None, + ) -> bool: + wrapper = getattr(self, "_call_is_invalid_structured_output", None) + if callable(wrapper): + return bool( + wrapper( + function_name=function_name, + response_text=response_text, + routing_meta=routing_meta, + ) + ) + checker = getattr(self, "_is_invalid_structured_output", None) + if not callable(checker): + return False + try: + return bool( + checker( + function_name=function_name, + response_text=response_text, + routing_meta=routing_meta, + ) + ) + except TypeError: + return bool( + checker( + function_name=function_name, + response_text=response_text, + ) + ) + + def _is_low_tier_function(self, function_name: str, routing_meta: dict[str, Any] | None = None) -> bool: rule = self._match_openclaw_rule(function_name) if rule is not None: tier = str(rule.get("tier") or "").strip().lower() if tier: return tier == "low" - return self._resolve_function_tier(function_name) == "low" + return self._resolve_function_tier(function_name, routing_meta=routing_meta) == "low" @staticmethod - def _looks_like_unanswerable_response(text: str) -> bool: + def _is_unanswerable_response(text: str) -> bool: cleaned = str(text or "").strip() lowered = cleaned.lower() if not lowered: @@ -50,23 +96,30 @@ class LLMTierEscalationMixin: return False return normalized == expected - def _should_escalate_low_to_high(self, *, function_name: str, response_text: str) -> bool: + def _should_escalate_low_to_high( + self, + *, + function_name: str, + response_text: str, + routing_meta: dict[str, Any] | None = None, + ) -> bool: if not self._low_to_high_escalation_enabled(): return False - if not self._is_low_tier_function(function_name): + if not self._call_is_low_tier_function(function_name, routing_meta=routing_meta): return False - return self._looks_like_unanswerable_response(response_text) + return self._is_unanswerable_response(response_text) def _should_retry_low_tier_with_high_tier( self, *, function_name: str, failed_engine: dict[str, Any], + routing_meta: dict[str, Any] | None = None, ) -> bool: if not self._low_to_high_escalation_enabled(): return False _ = failed_engine - return self._is_low_tier_function(function_name) + return self._call_is_low_tier_function(function_name, routing_meta=routing_meta) def _prefer_high_tier_retry_engine( self, @@ -219,8 +272,13 @@ class LLMTierEscalationMixin: max_tokens: int, response_mode: str, error: Exception, + routing_meta: dict[str, Any] | None = None, ) -> tuple[Optional[str], Optional[dict[str, Any]]]: - if not self._should_retry_low_tier_with_high_tier(function_name=function_name, failed_engine=failed_engine): + if not self._should_retry_low_tier_with_high_tier( + function_name=function_name, + failed_engine=failed_engine, + routing_meta=routing_meta, + ): return None, None try: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) @@ -302,10 +360,15 @@ class LLMTierEscalationMixin: max_tokens=max_tokens, function_name=function_name, response_mode=response_mode, + routing_meta=routing_meta, ) if not text.strip(): raise ValueError("high_tier_empty_response") - if self._is_invalid_structured_output(function_name=function_name, response_text=text): + if self._call_is_invalid_structured_output_for_retry( + function_name=function_name, + response_text=text, + routing_meta=routing_meta, + ): raise ValueError("high_tier_invalid_structured_output") high_engine = _merge_engine_routing_meta( high_engine, @@ -345,12 +408,14 @@ class LLMTierEscalationMixin: max_tokens: int, response_mode: str, error: Exception, + routing_meta: dict[str, Any] | None = None, ) -> tuple[Optional[StructuredExtractionResult], Optional[dict[str, Any]]]: fn = str(function_name or "").strip().lower() force_intent_retry = False if not self._should_retry_low_tier_with_high_tier( function_name=function_name, failed_engine=failed_engine, + routing_meta=routing_meta, ): return None, None try: @@ -467,6 +532,7 @@ class LLMTierEscalationMixin: else function_name ), response_mode=response_mode, + routing_meta=routing_meta, ) extraction = self._extract_structured_object_from_response( function_name=function_name, diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py index b5e401eb..1cda1da7 100644 --- a/backend/app/services/llm_service/shared_part_01.py +++ b/backend/app/services/llm_service/shared_part_01.py @@ -77,65 +77,6 @@ DEFAULT_TIER_MODEL_OVERRIDES = { tier: str(spec.get("model") or "") for tier, spec in TIER_DEFAULTS.items() } -DEFAULT_HIGH_TIER_HINTS = ( - "classify_intent_complex", - "project_from_text", - "feishu_project_prefill", - "feishu_task_orchestration", - "feishu_project_signal_classification", - "adaptive_rag_answer_deep", - "ai_pmo_signal_batch_analysis", - "rag_db_project_semantic_retrieval", - "analyze_food_calories", - "sstc_ai_breakdown", - "generate_task_breakdown", - "recommend_assignee", - "alignment", - "charter", - "cognitive_conflict", - "blocker", - "model_requirement", - "signal_planner_analyze", -) -DEFAULT_LOW_TIER_HINTS = ( - "feishu_level_classification", - "feishu_signal_extraction", - "feishu_humanized_reply", - "ai_pmo_signal_title_refine", - "signal_source_onboarding_plan", - "signal_source_ai_worker_execution", - "extract_context_insights", - "project_draft_", - "adaptive_rag_answer_normal", - "rag_intent_classification", - "classify_intent", -) -STRUCTURED_OUTPUT_FUNCTION_HINTS = ( - "feishu_level_classification", - "feishu_signal_extraction", - "signal_source_onboarding_plan", - "signal_source_ai_worker_execution", - "project_draft_", - "rag_intent_classification", - "classify_intent", - "feishu_project_prefill", - "feishu_task_orchestration", - "generate_task_breakdown", - "generate_charter_from_signal", - "decompose_tasks_from_charter", - "recommend_assignees_for_task", - "evaluate_model_requirement", - "calculate_alignment_score", - "detect_cognitive_conflicts", - "rag_db_project_semantic_retrieval", - "workload_optimize_prompt", - "analyze_cost_roi", - "analyze_food_calories", - "sstc_ai_breakdown", - "sstc_charter_localize_zh", - "sstc_charter_prefill", - "signal_planner_analyze", -) LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = ( "不知道", "不清楚", @@ -154,14 +95,6 @@ LOW_TO_HIGH_ESCALATION_FALLBACK_MARKERS = ( "cannot answer", "don't know", ) -FAST_LOCAL_FALLBACK_FUNCTION_HINTS = ( - "signal_source_onboarding_plan", - "signal_source_ai_worker_execution", -) -FAST_LOCAL_PLANNER_FUNCTION_HINTS = ( - "signal_source_onboarding_plan", -) -FAST_LOCAL_WORKER_FUNCTION_HINTS = ("signal_source_ai_worker_execution",) FAST_LOCAL_FALLBACK_FAST_TIMEOUT_SECONDS = 20.0 FAST_LOCAL_FALLBACK_COMPLETE_TIMEOUT_SECONDS = 35.0 STRUCTURED_CACHE_TTL_SECONDS = 300 @@ -197,15 +130,14 @@ CIRCUIT_BREAKER_EXEMPT_FUNCTION_PREFIXES = ( "project_draft_", ) DDL_API_GUARDRAIL_FUNCTION_EXACT = { - "classify_intent_ai_pmo_kernel", - "classify_intent_complex", + "pmflow_semantic_context_decision", } DDL_API_GUARDRAIL_SYSTEM_PROMPT = """ 硬性约束(最高优先级): 1. 禁止直接执行或建议执行数据库 DDL SQL(CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE)。 2. 所有数据库 DDL 变更必须通过受控接口或迁移流程(例如 Alembic migration)。 3. 若输入要求直接改库执行 DDL: - - `classify_intent_ai_pmo_kernel` / `classify_intent_complex` 必须返回 `query.read_only`,并在 reasoning 包含 `ddl_api_required`。 + - `pmflow_semantic_context_decision` 必须拒绝执行型决策,并在结构化结果里保留 `ddl_api_required`。 """.strip() STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { "ai_pmo_signal_title_refine": { @@ -235,36 +167,6 @@ STRUCTURED_OBJECT_SCHEMAS: dict[str, dict[str, Any]] = { "next_actions": "list", }, }, - "classify_intent_ai_pmo_kernel": { - "required_keys": ("confidence", "reasoning"), - "type_checks": { - "confidence": "number", - "reasoning": "str", - }, - "optional_type_checks": { - "intent": ("str_or_none",), - "family": ("str_or_none",), - "action": ("str_or_none",), - "project_id": ("number_or_none",), - "requested_count": ("number_or_none",), - "requires_confirmation": ("bool",), - }, - }, - "classify_intent_complex": { - "required_keys": ("confidence", "reasoning"), - "type_checks": { - "confidence": "number", - "reasoning": "str", - }, - "optional_type_checks": { - "intent": ("str_or_none",), - "family": ("str_or_none",), - "action": ("str_or_none",), - "project_id": ("number_or_none",), - "requested_count": ("number_or_none",), - "requires_confirmation": ("bool",), - }, - }, "pmflow_semantic_context_decision": { "required_keys": ( "topic_relation", diff --git a/backend/app/services/llm_service/shared_part_02.py b/backend/app/services/llm_service/shared_part_02.py index 6af5c7e4..47099c19 100644 --- a/backend/app/services/llm_service/shared_part_02.py +++ b/backend/app/services/llm_service/shared_part_02.py @@ -38,6 +38,21 @@ def _optional_tier_alias(raw: Any) -> str: return value if value in VALID_TIER_ALIASES else "" +def _optional_response_contract(raw: Any) -> str: + value = str(raw or "").strip().lower() + return value if value in {"structured", "freeform"} else "" + + +def _optional_fallback_policy(raw: Any) -> str: + value = str(raw or "").strip().lower() + return value if value in {"fast_local", "default", "disabled"} else "" + + +def _optional_execution_mode(raw: Any) -> str: + value = str(raw or "").strip().lower() + return value if value in {"remote", "local_planner", "local_worker"} else "" + + def _normalize_routing_meta(raw: Any) -> dict[str, Any]: payload = _safe_json(raw) planned_tier = _optional_tier_alias(payload.get("planned_tier")) @@ -55,6 +70,9 @@ def _normalize_routing_meta(raw: Any) -> dict[str, Any]: rule_priority = int(rule_priority_raw) if rule_priority_raw is not None else None except (TypeError, ValueError): rule_priority = None + response_contract = _optional_response_contract(payload.get("response_contract")) + fallback_policy = _optional_fallback_policy(payload.get("fallback_policy")) + execution_mode = _optional_execution_mode(payload.get("execution_mode")) return { "tier_alias": tier_alias, "planned_tier": planned_tier or tier_alias, @@ -65,6 +83,9 @@ def _normalize_routing_meta(raw: Any) -> dict[str, Any]: "routing_config_version": config_version, "routing_config_updated_at": config_updated_at, "rule_priority": rule_priority, + "response_contract": response_contract, + "fallback_policy": fallback_policy, + "execution_mode": execution_mode, } @@ -136,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 _looks_like_openclaw_timeout_message(text: str) -> bool: +def _is_openclaw_timeout_message(text: str) -> bool: lowered = str(text or "").strip().lower() if not lowered: return False diff --git a/backend/app/services/model/model_context_bundle/mixin_core.py b/backend/app/services/model/model_context_bundle/mixin_core.py index 34c21cdf..8e0ba2c6 100644 --- a/backend/app/services/model/model_context_bundle/mixin_core.py +++ b/backend/app/services/model/model_context_bundle/mixin_core.py @@ -93,11 +93,11 @@ class ModelContextBundleCoreMixin: @classmethod def _canary_prompt_for_function(cls, *, function_name: str, probe_id: str) -> str: fn = cls._normalize_function_name(function_name) - if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: + if fn == "pmflow_semantic_context_decision": return ( f"这是 PMFlow 上下文注入探针,probe_id={probe_id}。" "请只返回 JSON:" - '{"intent":"query.read_only","confidence":0.91,"reasoning":"probe_context_ok"}' + '{"topic_relation":"same_topic","mode_decision":"query","draft_patch":{},"assistant_reply":"probe_context_ok","requires_confirmation":false,"requested_actions":[],"confidence":0.91}' ) if fn == "signal_planner_analyze": return ( diff --git a/backend/app/services/model/model_context_bundle/shared.py b/backend/app/services/model/model_context_bundle/shared.py index 4b3935a5..0ad3a508 100644 --- a/backend/app/services/model/model_context_bundle/shared.py +++ b/backend/app/services/model/model_context_bundle/shared.py @@ -24,18 +24,14 @@ CONTEXT_BUNDLE_MARKER = "PMFLOW_CONTEXT_BUNDLE_V1" CONTEXT_BUNDLE_KEYS = ("knowledge_graph", "task_template", "contract_sync") TARGET_FUNCTION_CONTEXT: dict[str, dict[str, Any]] = { - "classify_intent_ai_pmo_kernel": { + "pmflow_semantic_context_decision": { "tier": "low", - "template_ids": [LOW_TEMPLATE_ID], + "template_ids": [LOW_TEMPLATE_ID, HIGH_TEMPLATE_ID, HIGH_CONTRACT_TEMPLATE_ID], }, "signal_planner_analyze": { "tier": "low", "template_ids": [LOW_TEMPLATE_ID], }, - "classify_intent_complex": { - "tier": "high", - "template_ids": [HIGH_TEMPLATE_ID, HIGH_CONTRACT_TEMPLATE_ID], - }, } __all__ = [ diff --git a/backend/app/services/model/model_routing/service.py b/backend/app/services/model/model_routing/service.py index bd40bcd6..2a0a5ad0 100644 --- a/backend/app/services/model/model_routing/service.py +++ b/backend/app/services/model/model_routing/service.py @@ -23,10 +23,9 @@ DEFAULT_FUNCTIONS = ( "ai_pmo_signal_batch_analysis", "ai_pmo_signal_title_refine", "ai_service_stream_chat", - "classify_intent_ai_pmo_kernel", - "classify_intent_complex", "draft_generator_v1", "feishu_humanized_reply", + "pmflow_semantic_context_decision", "rag_db_project_semantic_retrieval", "rag_intent_classification", "signal_planner_analyze", diff --git a/backend/app/services/reply_iteration/service.py b/backend/app/services/reply_iteration/service.py index eb77660c..dbb21846 100644 --- a/backend/app/services/reply_iteration/service.py +++ b/backend/app/services/reply_iteration/service.py @@ -20,7 +20,6 @@ from ...utils.timezone import now_cn_naive PROCESS_TALK_PREFIXES = ("让我先", "我先", "我来看看", "我先看", "让我看") -EXECUTION_CLAIM_TOKENS = ("通过 API", "帮你创建", "我先检查", "后端服务", "已帮你") class ReplyIterationService: @@ -532,8 +531,6 @@ class ReplyIterationService: normalized = re.sub(r"\s+", "", answer) if any(answer.startswith(prefix) for prefix in PROCESS_TALK_PREFIXES): findings.append({"code": "process_talk_prefix", "severity": "warning", "message": "存在过程话术前缀"}) - if any(token in normalized for token in [re.sub(r"\s+", "", token) for token in EXECUTION_CLAIM_TOKENS]): - findings.append({"code": "execution_claim_prefix", "severity": "warning", "message": "存在执行型兜底话术"}) if str(asset.planned_tier or "").strip() == "low" and str(asset.executed_tier or "").strip() == "high": findings.append({"code": "tier_escalation", "severity": "info", "message": "存在 low -> high 升级"}) return findings diff --git a/backend/app/services/signal/draft_generator_service.py b/backend/app/services/signal/draft_generator_service.py index 06c20ee8..895f52d0 100644 --- a/backend/app/services/signal/draft_generator_service.py +++ b/backend/app/services/signal/draft_generator_service.py @@ -392,7 +392,7 @@ class DraftGeneratorService: if not text: return {} normalized = llm_service._strip_code_fence(text) - if not llm_service._looks_like_json_container(normalized): + if not llm_service._contains_json_container(normalized): return {} candidate = llm_service._extract_json_candidate(normalized) if not candidate: diff --git a/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py b/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py index 77843667..36a86275 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py +++ b/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py @@ -237,16 +237,6 @@ class SignalCaptureAnalysisNormalizeMixin: return True return False - @staticmethod - def _is_question_like(message: str) -> bool: - text = str(message or "").strip().lower() - if not text: - return False - if "?" in text or "?" in text: - return True - hints = ("吗", "怎么", "如何", "为什么", "谁", "多少", "是否", "有没有", "几号", "几点", "周几") - return any(k in text for k in hints) - def _rule_based_chat_signal(self, message: str) -> Optional[Dict[str, Any]]: _ = message return None 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 2ee5392b..4d084a3a 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 @@ -33,8 +33,6 @@ class SignalCaptureEntryMixin: return True, "semantic:query_flow" if workflow_types == ["followup_flow"]: return True, "semantic:followup_flow" - if cls._is_question_like(clean_text): - return True, "question_like" return False, "async_queue" async def capture_from_feishu( @@ -191,7 +189,7 @@ class SignalCaptureEntryMixin: semantic_bundle=semantic_bundle, ) - if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message): + if self._is_non_requirement_message(cleaned_message): self._emit_ai_think( "capture_skip_noise", 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 8539b5e6..e6bddb98 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 @@ -1,8 +1,6 @@ from __future__ import annotations from .shared import * -from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner - class SignalCapturePipelineCacheMixin: async def _sync_feishu_recent_cache( self, @@ -107,9 +105,6 @@ class SignalCapturePipelineCacheMixin: if signal is None: return pipeline_result - if self._is_question_like(cleaned_message): - return pipeline_result - source_orchestration_result: Optional[Dict[str, Any]] = None if ( str(pipeline_result.get("intent") or "").strip().lower() == INTENT_SIGNAL_SOURCE_ONBOARD diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py b/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py index 9b564e12..87c9bf71 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py @@ -99,6 +99,12 @@ class SignalSourcePlanningOnboardMixin: use_prompt_profile=False, max_tokens=1800, response_mode="complete" if response_mode == "fast" else response_mode, + routing_meta={ + "tier_alias": "low", + "response_contract": "structured", + "fallback_policy": "fast_local", + "execution_mode": "local_planner", + }, ) diagnostics = self._build_planner_diagnostics(extraction) if extraction.payload is None: 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 0c320721..a41cee1c 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 @@ -636,6 +636,12 @@ class SignalSourceRegistryExecuteMixin: use_prompt_profile=False, max_tokens=420, response_mode="complete" if response_mode == "fast" else response_mode, + routing_meta={ + "tier_alias": "low", + "response_contract": "structured", + "fallback_policy": "fast_local", + "execution_mode": "local_worker", + }, ) if extraction.payload is None: raise ValueError(str(extraction.error_code or "structured_empty")) diff --git a/backend/app/services/signal/symbol_index/mixin_parse_typescript.py b/backend/app/services/signal/symbol_index/mixin_parse_typescript.py index 2d53de43..e59e0f4a 100644 --- a/backend/app/services/signal/symbol_index/mixin_parse_typescript.py +++ b/backend/app/services/signal/symbol_index/mixin_parse_typescript.py @@ -114,7 +114,7 @@ class SymbolIndexParseTypeScriptMixin: source_symbol = line_symbol_map[max(candidate_lines)] for call in TS_CALL_RE.finditer(line): name = str(call.group(1) or "").strip() - if not name or name.lower() in TS_KEYWORDS: + if not name or name.lower() in TS_RESERVED_WORDS: continue refs.append( ParsedReference( @@ -128,4 +128,3 @@ class SymbolIndexParseTypeScriptMixin: ) ) return (symbols[:5000], refs[:12000]) - diff --git a/backend/app/services/signal/symbol_index/shared.py b/backend/app/services/signal/symbol_index/shared.py index e6ba6b54..fdebc0a8 100644 --- a/backend/app/services/signal/symbol_index/shared.py +++ b/backend/app/services/signal/symbol_index/shared.py @@ -29,7 +29,7 @@ TS_SYMBOL_RE = re.compile( ) TS_IMPORT_RE = re.compile(r"^\s*import\s+.*?\s+from\s+['\"]([^'\"]+)['\"]", re.MULTILINE) TS_CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]*)\s*\(") -TS_KEYWORDS = {"if", "for", "while", "switch", "catch", "return", "new", "typeof", "await"} +TS_RESERVED_WORDS = {"if", "for", "while", "switch", "catch", "return", "new", "typeof", "await"} @dataclass(slots=True) @@ -54,4 +54,3 @@ class ParsedReference: end_line: int payload: dict[str, Any] - diff --git a/backend/tests/test_adaptive_rag_external_followup_contract.py b/backend/tests/test_adaptive_rag_external_followup_contract.py index 1408f303..d6dc0b2b 100644 --- a/backend/tests/test_adaptive_rag_external_followup_contract.py +++ b/backend/tests/test_adaptive_rag_external_followup_contract.py @@ -92,6 +92,58 @@ class _Harness( super().__init__(db, tenant_id=1) self.context_cache = _FakeContextCache() + async def _resolve_followup_anchor_semantics( + self, + *, + message: str, + has_explicit_reply_anchor: bool, + recent_candidates: list[dict[str, Any]], + ) -> dict[str, Any]: + text = str(message or "").strip() + normalized = text.replace(" ", "") + if "项目有吗" in normalized or "有没有" in normalized: + return { + "followup_mode": "none", + "meta_followup_kind": "none", + "anchor_strategy": "none", + "candidate_index": 0, + "confidence": 0.9, + } + + meta_kind = "none" + if any(token in normalized for token in ("哪个模型", "什么模型", "谁回复", "谁回答")): + meta_kind = "reply_model_identity" + elif any(token in normalized for token in ("什么情况", "怎么回事", "为什么失败", "为什么报错", "详细说", "详细解释")): + meta_kind = "reply_error_reason" + elif any(token in normalized for token in ("来源", "根据什么", "什么依据")): + meta_kind = "reply_source" + + if meta_kind != "none": + followup_mode = "meta" + elif has_explicit_reply_anchor or recent_candidates: + followup_mode = "referential" + else: + followup_mode = "none" + + if has_explicit_reply_anchor: + anchor_strategy = "explicit" + elif meta_kind != "none" and len(recent_candidates) > 1: + anchor_strategy = "clarify" + elif len(recent_candidates) == 1: + anchor_strategy = "implicit_recent" + elif len(recent_candidates) > 1: + anchor_strategy = "clarify" + else: + anchor_strategy = "none" + + return { + "followup_mode": followup_mode, + "meta_followup_kind": meta_kind, + "anchor_strategy": anchor_strategy, + "candidate_index": 1 if anchor_strategy == "implicit_recent" and recent_candidates else 0, + "confidence": 0.9, + } + @pytest.mark.asyncio async def test_external_conversation_key_creates_and_reuses_real_qa_session(db_session) -> None: @@ -171,6 +223,77 @@ async def test_meta_followup_prefers_single_recent_reply_candidate(db_session) - assert resolution.candidate_replies == [] +@pytest.mark.asyncio +async def test_followup_anchor_prefers_structured_semantics_without_keyword_hints( + db_session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _Harness(db_session) + now = now_cn_naive() + session = QASession( + tenant_id=1, + user_id=None, + title="架构说明", + external_conversation_key="feishu:chat-1:root-1", + started_at=now, + last_message_at=now, + message_count=1, + ) + db_session.add(session) + await db_session.flush() + db_session.add( + QARecord( + tenant_id=1, + question="帮我解释下系统架构", + answer="PMFlow 系统采用前后端分离架构。", + sources=[], + intent="question", + domains_used=[], + response_time_ms=10, + asked_by=None, + asked_via="feishu", + project_id=None, + session_id=int(session.id), + turn_index=1, + conversation_scope_key="feishu:chat-1:root-1", + created_at=now, + ) + ) + await db_session.commit() + + async def _fake_semantics(**kwargs): + _ = kwargs + return { + "followup_mode": "meta", + "meta_followup_kind": "reply_explanation", + "anchor_strategy": "implicit_recent", + "candidate_index": 1, + "confidence": 0.93, + } + + monkeypatch.setattr( + service, + "_resolve_followup_anchor_semantics", + _fake_semantics, + raising=False, + ) + + state = await service._ensure_session_state(session_id=int(session.id)) + resolution = await service._resolve_followup_anchor( + question="沿着前一条把细节铺开", + session_id=int(session.id), + context_session_id=int(session.id), + session_state=state, + conversation_scope_key="feishu:chat-1:root-1", + ) + + assert resolution.anchor_mode == "implicit_recent_reply" + assert resolution.is_meta_followup is True + assert resolution.meta_followup_kind == "reply_explanation" + assert resolution.anchor_reply is not None + assert resolution.anchor_reply["question"] == "帮我解释下系统架构" + + @pytest.mark.asyncio async def test_ambiguous_recent_reply_returns_numbered_candidates(db_session) -> None: service = _Harness(db_session) @@ -619,11 +742,13 @@ async def test_meta_followup_model_identity_reads_routing_snapshot_and_hides_rea payload = await service._build_meta_followup_payload( question="这个是哪个模型回复的?", anchor_resolution=FollowupAnchorResolution( + followup_mode="meta", anchor_mode="implicit_recent_reply", anchor_reply_id=int(qa.id), anchor_session_id=int(session.id), anchor_kind="meta_recent_reply", is_meta_followup=True, + meta_followup_kind="reply_model_identity", anchor_reply={ "qa_id": int(qa.id), "question": "帮我解释下系统架构", diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index d60c3bdd..f132ccdb 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -6,10 +6,13 @@ from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnsw from app.services.adaptive_rag_service.mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin 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 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_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 ACTION_MODE_READ, DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner @@ -286,10 +289,122 @@ async def test_deterministic_query_prefers_project_created_at_answer() -> None: assert calls[0] == "created_at" -def test_query_type_classifies_colloquial_list_request_as_list() -> None: - service = AdaptiveRAGDeterministicQueryCoreMixin() +@pytest.mark.asyncio +async def test_query_semantics_prefers_structured_model_output_over_hint_tables( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return ( + '{"intent":"question","question_part":"我感觉很多重复的项目在系统,你能列一下吗?",' + '"statement_part":"","query_type":"list","retrieval_domains":["db","graph"],' + '"subject_focus":"project","scope_hint":"global","feature_focus":"none"}' + ) - assert service._classify_query_type("我感觉很多重复的项目在系统,你能列一下吗?") == "list" + class _FakeService(AdaptiveRAGCoreIntentMixin, AdaptiveRAGDeterministicQueryCoreMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): + _ = (cls, message_text, source_system, metadata) + return WorkflowSemanticPlanner.deserialize_bundle( + { + "bundle_id": "semantic-query-type", + "semantic_version": "v1", + "source_system": "adaptive_rag", + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.93, + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto", + "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"}, + "diagnostics": {"message_intent": "question"}, + } + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) + + service = _FakeService() + semantics = await service._resolve_query_semantics( + message="我感觉很多重复的项目在系统,你能列一下吗?", + source_system="pmflow", + ) + + assert semantics["query_type"] == "list" + assert semantics["retrieval_domains"] == ["db", "graph"] + assert semantics["intent"] == "question" + assert semantics["subject_focus"] == "project" + assert semantics["scope_hint"] == "global" + assert semantics["feature_focus"] == "none" + + +@pytest.mark.asyncio +async def test_classify_intent_prefers_structured_semantics_without_rule_split( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return ( + '{"intent":"mixed","question_part":"现在进展如何?","statement_part":"并新增任务确认流",' + '"query_type":"status","retrieval_domains":["db","graph","pmflow"]}' + ) + + class _FakeService(AdaptiveRAGCoreIntentMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): + _ = (cls, message_text, source_system, metadata) + return WorkflowSemanticPlanner.deserialize_bundle( + { + "bundle_id": "semantic-intent", + "semantic_version": "v1", + "source_system": "adaptive_rag", + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.88, + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto", + "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"}, + "diagnostics": {"message_intent": "business_review"}, + } + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) + + service = _FakeService() + result = await service.classify_intent("现在进展如何?并新增任务确认流") + + assert result == { + "intent": "mixed", + "confidence": 0.88, + "question_part": "现在进展如何?", + "statement_part": "并新增任务确认流", + } @pytest.mark.asyncio @@ -394,9 +509,8 @@ async def test_deep_guard_answer_raises_on_empty_llm_response() -> None: _ = kwargs return "external_trend", "请回答问题" - def _enforce_feature_evidence_in_answer(self, *, question: str, answer_text: str, retrieved, system_snapshot): - _ = (question, retrieved, system_snapshot) - return answer_text + def _enforce_feature_evidence_in_answer(self, **kwargs): + return str(kwargs.get("answer_text") or "") service = _FakeService() @@ -453,13 +567,22 @@ async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None _ = project_id return {} - def _select_scene_prompt(self, **kwargs): + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return { + "scene_key": "member_workload", + "snapshot_focus": "none", + "deep_intent": "none", + "confidence": 0.96, + "reason": "structured-scene", + } + + async def _select_scene_prompt(self, **kwargs): _ = kwargs return "member_workload", "member workload prompt" - def _enforce_feature_evidence_in_answer(self, *, question: str, answer_text: str, retrieved, system_snapshot): - _ = (question, retrieved, system_snapshot) - return answer_text + def _enforce_feature_evidence_in_answer(self, **kwargs): + return str(kwargs.get("answer_text") or "") service = _FakeService() result = await service._generate_answer( @@ -474,6 +597,156 @@ async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None assert "严重超载" in result +@pytest.mark.asyncio +async def test_deep_guard_answer_prefers_structured_member_workload_scene_without_keyword_hints() -> None: + class _FakeResult: + def __init__(self, rows=None): + self._rows = rows or [] + + def all(self): + return self._rows + + def first(self): + return self._rows[0] if self._rows else None + + class _FakeDB: + def __init__(self) -> None: + self.calls = 0 + + async def execute(self, stmt, params=None): # noqa: ANN001 + _ = (stmt, params) + self.calls += 1 + if self.calls == 1: + return _FakeResult(rows=[SimpleNamespace(id=24, name="杨宇", username="yangyu")]) + return _FakeResult(rows=[(82.0, 41.0, 50.0)]) + + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + _ = thinking_mode + return "normal" + + async def get_project_manifest(self, project_id): + _ = project_id + return {} + + async def _resolve_answer_scene_semantics(self, **kwargs): + _ = kwargs + return { + "scene_key": "member_workload", + "snapshot_focus": "none", + "deep_intent": "none", + "confidence": 0.95, + "reason": "structured-scene", + } + + async def _select_scene_prompt(self, **kwargs): # pragma: no cover - should not be called + raise AssertionError("scene prompt should not be rendered for direct workload answer") + + def _enforce_feature_evidence_in_answer(self, **kwargs): + return str(kwargs.get("answer_text") or "") + + service = _FakeService() + result = await service._generate_answer( + question="杨宇现在忙不忙?", + retrieved=[], + conversation=[], + project_id=None, + asked_via="feishu", + ) + + assert "杨宇当前负载82.00%" in result + assert "负载偏高" in result + + +@pytest.mark.asyncio +async def test_structured_db_retrieval_prefers_structured_people_focus_without_keyword_hints() -> None: + class _FakeScalarResult: + def __init__(self, rows): + self._rows = rows + + def scalars(self): + return self + + def all(self): + return self._rows + + class _FakeDB: + async def execute(self, stmt, params=None): # noqa: ANN001 + _ = (stmt, params) + return _FakeScalarResult( + [ + SimpleNamespace(id=7, name="杨宇", role="backend", updated_at=None), + SimpleNamespace(id=8, name="林宇航", role="pm", updated_at=None), + ] + ) + + class _FakeService(AdaptiveRAGRetrievalRouterDBMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + def _query_subject_focus(self, query_semantics): # noqa: ANN001 + return str((query_semantics or {}).get("subject_focus") or "unknown") + + def _query_scope_hint(self, query_semantics): # noqa: ANN001 + return str((query_semantics or {}).get("scope_hint") or "none") + + def _normalize_source_registry_metadata(self, *, source_type: str, metadata: dict, explicit_updated_at=None): + _ = (source_type, explicit_updated_at) + return dict(metadata) + + service = _FakeService() + rows = await service._retrieve_structured_db( + "他们都有哪些人?", + project_id=None, + query_semantics={ + "subject_focus": "people", + "scope_hint": "global", + "feature_focus": "none", + }, + ) + + assert len(rows) == 1 + assert "成员清单" in rows[0].content + assert "杨宇" in rows[0].content + + +def test_feature_evidence_prefers_structured_scene_without_keyword_hints() -> None: + class _FakeService(AdaptiveRAGManifestFeatureMixinP2): + def _query_subject_focus(self, query_semantics): # noqa: ANN001 + return str((query_semantics or {}).get("subject_focus") or "unknown") + + def _query_feature_focus(self, query_semantics): # noqa: ANN001 + return str((query_semantics or {}).get("feature_focus") or "none") + + def _collect_feature_evidence(self, *, question: str, retrieved, system_snapshot): + _ = (question, retrieved, system_snapshot) + return [ + { + "feature_name": "智能配置", + "route": "/ai-config", + "page_file": "frontend/src/pages/AIConfig.tsx", + "apis": ["/api/v1/ai-config"], + } + ] + + service = _FakeService() + text = service._enforce_feature_evidence_in_answer( + question="这个模块怎么进?", + answer_text="先看说明。", + retrieved=[], + system_snapshot={}, + scene_semantics={"snapshot_focus": "feature"}, + ) + + assert "证据补充(Feature Catalog)" in text + assert "/ai-config" in text + + @pytest.mark.asyncio async def test_deep_guard_answer_feishu_direct_prefers_manifest_and_compact_focus_context() -> None: class _FakeLLM: @@ -504,9 +777,8 @@ async def test_deep_guard_answer_feishu_direct_prefers_manifest_and_compact_focu _ = prompt_key return str(fallback_template).format(**kwargs).strip() - def _enforce_feature_evidence_in_answer(self, *, question: str, answer_text: str, retrieved, system_snapshot): - _ = (question, retrieved, system_snapshot) - return answer_text + def _enforce_feature_evidence_in_answer(self, **kwargs): + return str(kwargs.get("answer_text") or "") service = _FakeService() system_snapshot = { @@ -641,9 +913,8 @@ def test_runtime_state_query_guard_blocks_static_catalog_evidence() -> None: pass service = _FakeService() - blocked = service._should_block_answer_with_guard( - question="高级模型路由是否启用?", - effective_question="高级模型路由是否启用?", + decision = service._evaluate_post_retrieval_guard( + deep_intent="runtime_state_query", sources=[ {"source": "feature_catalog", "label": "Feature Catalog", "metadata": {}}, {"source": "graph", "label": "Knowledge Graph", "metadata": {"path": "1->uses->2"}}, @@ -651,8 +922,12 @@ def test_runtime_state_query_guard_blocks_static_catalog_evidence() -> None: relevance_max=0.96, ) - assert blocked is True - assert "无法仅凭静态资料确认" in service._build_evidence_guard_answer("高级模型路由是否启用?") + assert decision["should_block_with_guard"] is True + assert decision["guard_focus"] == "runtime_state" + assert "无法仅凭静态资料确认" in service._build_evidence_guard_answer( + "高级模型路由是否启用?", + guard_focus=str(decision["guard_focus"]), + ) def test_runtime_state_query_guard_allows_routing_config_evidence() -> None: @@ -660,9 +935,8 @@ def test_runtime_state_query_guard_allows_routing_config_evidence() -> None: pass service = _FakeService() - blocked = service._should_block_answer_with_guard( - question="高级模型路由是否启用?", - effective_question="高级模型路由是否启用?", + decision = service._evaluate_post_retrieval_guard( + deep_intent="runtime_state_query", sources=[ { "source": "doc", @@ -673,24 +947,21 @@ def test_runtime_state_query_guard_allows_routing_config_evidence() -> None: relevance_max=0.74, ) - assert blocked is False + assert decision["should_block_with_guard"] is False -def test_correction_escalation_query_forces_deep_reasoning_in_feishu_channel() -> None: +def test_engine_process_guard_requests_auto_retry_on_snapshot_only_sources() -> None: class _FakeService(AdaptiveRAGDeepGuardRulesMixin): pass service = _FakeService() - reason = service._forced_deep_reason( - question="不对,调用高级模型来回答我的问题", - effective_question="不对,调用高级模型来回答我的问题", - conversation=[{"question": "我要做一个大模型 API 聚合平台", "answer": "上一轮回答"}], + decision = service._evaluate_post_retrieval_guard( + deep_intent="engine_process_query", + sources=[ + {"source": "system", "label": "System Snapshot", "metadata": {}}, + {"source": "system", "label": "User Snapshot", "metadata": {}}, + ], + relevance_max=0.41, ) - assert reason == "correction_escalation" - assert service._should_force_deep_for_channel( - asked_via="feishu", - question="不对,调用高级模型来回答我的问题", - effective_question="不对,调用高级模型来回答我的问题", - reason=reason, - ) is True + assert decision["auto_retry_reason"] == "snapshot_only" diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index a99a5793..73fc666b 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -2,6 +2,7 @@ import asyncio import json from datetime import datetime, timedelta from types import SimpleNamespace +from unittest.mock import AsyncMock import pytest import pytest_asyncio @@ -17,9 +18,6 @@ from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_co from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_resolve import ( KernelIntentResolveMixin, ) -from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_llm import ( - KernelIntentLLMMixin, -) from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import ( KernelIntentRulesMixin, ) @@ -36,6 +34,7 @@ from app.services.ai_pmo.ai_pmo_kernel.mixins.world_model import ( from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_mutations from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent +from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner from app.services.ai_pmo.workflow_semantic_planner.models import ReplyContract, WorkflowBundle, WorkflowPlan from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.adaptive_rag_service import mixin_deterministic_query_core @@ -761,12 +760,12 @@ async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> @pytest.mark.asyncio -async def test_process_ingress_binds_active_draft_before_query_for_regenerate_request(monkeypatch) -> None: +async def test_process_ingress_binds_active_draft_before_query_for_regenerate_request() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 - self.draft_called = False + self.active_context_called = False async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs @@ -781,32 +780,26 @@ async def test_process_ingress_binds_active_draft_before_query_for_regenerate_re "session": SimpleNamespace(last_intent="query.read_only"), } - async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): - self.draft_called = True + async def _maybe_handle_active_context_followup(self, **kwargs): + if kwargs["force_bind"] is not True: + raise AssertionError("active draft regenerate request should bind current draft before fallback") + self.active_context_called = True _ = kwargs return { "handled": True, "status": "answered", - "classification": "command", + "classification": "question", "direct_reply_required": True, "direct_reply_text": "我先基于当前草稿再整理一版。", } + async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): + raise AssertionError("active draft regenerate request should stay on active-context semantics") + def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False - async def _prepare_mixed_sidecar_context(self, **kwargs): - _ = kwargs - raise AssertionError("active draft regenerate request should not fall back to query sidecar") - - async def _fake_has_active_draft_session(self, **kwargs): - _ = self - _ = kwargs - return True - - monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) - kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", @@ -817,7 +810,7 @@ async def test_process_ingress_binds_active_draft_before_query_for_regenerate_re source_sender_open_id="ou_xxx", ) - assert kernel.draft_called is True + assert kernel.active_context_called is True assert result["status"] == "answered" assert "当前草稿" in result["direct_reply_text"] @@ -1262,14 +1255,14 @@ def test_continue_current_draft_does_not_route_to_draft_confirmation_without_par ) is False -def test_reply_anchor_routes_to_draft_confirmation_without_revision_heuristics() -> None: +def test_reply_anchor_natural_followup_stays_out_of_draft_confirmation_pre_route() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="保持当前草稿结构并补完整信息", ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, - ) is True + ) is False def test_smalltalk_does_not_route_to_draft_confirmation_without_reply_anchor() -> None: @@ -1665,8 +1658,8 @@ async def test_active_context_followup_prefers_active_mounted_draft_before_requi topic_relation="continue_current_topic", mode_decision="stay_plan", followup_scope="current_draft", - followup_action="revise", - revision_note="补充外部信号源。", + followup_action="regenerate", + revision_note="", assistant_reply="", requires_confirmation=False, requested_actions=["reply.compose", "context.followup.current_draft"], @@ -1709,7 +1702,7 @@ async def test_active_context_followup_prefers_active_mounted_draft_before_requi assert "当前草稿" in result["followup_reply"] assert captured["packet_kwargs"]["topic_workspace_id"] == 91 assert captured["packet_kwargs"]["ingress_context"]["active_context_kind"] == "draft_pending_confirm" - assert captured["draft_kwargs"]["semantic_followup_override"]["action"] == "revise" + assert captured["draft_kwargs"]["semantic_followup_override"]["action"] == "regenerate" @pytest.mark.asyncio @@ -2422,80 +2415,46 @@ def test_should_inherit_recent_feishu_session_context_is_disabled_for_query_sess @pytest.mark.asyncio -async def test_kernel_intent_resolve_does_not_inherit_recent_feishu_session_context_without_reply_anchor() -> None: +async def test_kernel_intent_resolve_uses_workflow_bundle_without_reply_anchor( + monkeypatch: pytest.MonkeyPatch, +) -> None: captured: dict[str, object] = {} + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + captured["message_text"] = message_text + captured["source_system"] = source_system + captured["metadata"] = dict(metadata or {}) + return WorkflowBundle( + bundle_id="bundle-query", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.93, + metadata={}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={"message_intent": "requirement_plan"}, + ) + + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), + ) + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 - def _is_short_confirmation_message(self, **kwargs): - _ = kwargs - return False - - @staticmethod - def _intent_llm_enabled() -> bool: - return True - - async def _resolve_intent_with_llm(self, **kwargs): - captured.update(kwargs) - return CanonicalIntent( - name="query.read_only", - confidence=0.93, - project_id=21, - signal_title="", - signal_summary="", - metadata={}, - ) - - resolver = FakeResolver() - session = SimpleNamespace( - sender="feishu:chat-1:ou_dm_1", - last_intent="query.read_only", - current_project_id=21, - last_active_at=datetime.now(), - ) - - _, source = await resolver._resolve_intent( - message_text="我需要对应的需求mvp", - session=session, - world_model={}, - thread_context=None, - ) - - assert source == "llm_only" - assert captured["session_last_intent_override"] == "" - assert captured["session_project_id_override"] is None - assert captured["inherit_session_context"] is False - - -@pytest.mark.asyncio -async def test_kernel_intent_resolve_short_confirm_does_not_inherit_query_session_intent() -> None: - captured: dict[str, object] = {} - - class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): - def __init__(self) -> None: - self.tenant_id = 1 - - def _is_short_confirmation_message(self, **kwargs): - _ = kwargs - return True - - @staticmethod - def _intent_llm_enabled() -> bool: - return True - - async def _resolve_intent_with_llm(self, **kwargs): - captured.update(kwargs) - return CanonicalIntent( - name="query.read_only", - confidence=0.81, - project_id=None, - signal_title="", - signal_summary="", - metadata={}, - ) - resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", @@ -2505,40 +2464,65 @@ async def test_kernel_intent_resolve_short_confirm_does_not_inherit_query_sessio ) intent, source = await resolver._resolve_intent( - message_text="确认", + message_text="我需要对应的需求mvp", session=session, world_model={}, - thread_context={"reply_to_message_id": "msg-1"}, + thread_context=None, ) - assert source == "llm_only" + assert source == "workflow_bundle" assert intent.name == "query.read_only" - assert captured["inherit_session_context"] is True + assert intent.metadata["workflow_route"] == "query.answer" + assert intent.metadata["llm_function"] == "pmflow_semantic_context_decision" + assert intent.metadata["context_source"] == "workflow_bundle" + assert intent.metadata["message_intent"] == "requirement_plan" + assert isinstance(intent.metadata.get("semantic_bundle"), dict) + assert captured["source_system"] == "feishu" + assert captured["metadata"]["session_last_intent"] == "" + assert captured["metadata"]["session_project_id"] is None @pytest.mark.asyncio -async def test_kernel_intent_resolve_short_confirm_stays_on_llm_path_with_bound_context() -> None: +async def test_kernel_intent_resolve_reply_anchor_binds_session_context_into_workflow_packet( + monkeypatch: pytest.MonkeyPatch, +) -> None: captured: dict[str, object] = {} + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + captured["message_text"] = message_text + captured["source_system"] = source_system + captured["metadata"] = dict(metadata or {}) + return WorkflowBundle( + bundle_id="bundle-followup", + semantic_version="v1", + source_system=source_system, + primary_workflow="followup_flow", + workflows=[ + WorkflowPlan( + workflow_type="followup_flow", + workflow_action="confirm", + target_scope="thread", + operation_codes=["followup.confirm"], + requires_confirmation=False, + confidence=0.91, + metadata={}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="followup_flow"), + diagnostics={}, + ) + + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), + ) + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 - @staticmethod - def _intent_llm_enabled() -> bool: - return True - - async def _resolve_intent_with_llm(self, **kwargs): - captured.update(kwargs) - return CanonicalIntent( - name="followup.confirm", - confidence=0.91, - project_id=21, - signal_title="", - signal_summary="", - metadata={"followup_action": "confirm"}, - ) - resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", @@ -2555,59 +2539,69 @@ async def test_kernel_intent_resolve_short_confirm_stays_on_llm_path_with_bound_ thread_context={"reply_to_message_id": "msg-1"}, ) - assert source == "llm_only" + assert source == "workflow_bundle" assert intent.name == "followup.confirm" assert intent.project_id == 21 - assert captured["inherit_session_context"] is True - assert captured["session_last_intent_override"] == "task.create" + assert captured["metadata"]["session_last_intent"] == "task.create" + assert captured["metadata"]["session_project_id"] == 21 + assert captured["metadata"]["reply_to_message_id"] == "msg-1" @pytest.mark.asyncio -async def test_kernel_intent_resolve_suppresses_session_context_for_low_context_followup() -> None: - captured: dict[str, object] = {} +async def test_kernel_intent_resolve_requirement_initiation_stays_on_query_chain( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, source_system, metadata) + return WorkflowBundle( + bundle_id="bundle-requirement-init", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.95, + metadata={}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "requirement_initiation", + "consultant_mode_preference": "clarify", + }, + ) - class FakeResolver(KernelIntentResolveMixin): + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), + ) + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 - def _is_short_confirmation_message(self, **kwargs): - _ = kwargs - return False - - @staticmethod - def _intent_llm_enabled() -> bool: - return True - - async def _resolve_intent_with_llm(self, **kwargs): - captured.update(kwargs) - return CanonicalIntent( - name="query.read_only", - confidence=0.79, - project_id=None, - signal_title="", - signal_summary="", - metadata={}, - ) - resolver = FakeResolver() - session = SimpleNamespace( - sender="feishu:chat-1:ou_dm_1", - last_intent="query.read_only", - current_project_id=104, - last_active_at=datetime.now(), - ) - _, source = await resolver._resolve_intent( - message_text="A", - session=session, + intent, source = await resolver._resolve_intent( + message_text="想做个新需求,关于给运营公司打造主播推流的需求。", + session=None, world_model={}, thread_context=None, ) - assert source == "llm_only" - assert captured["session_last_intent_override"] == "" - assert captured["session_project_id_override"] is None - assert captured["inherit_session_context"] is False + assert source == "workflow_bundle" + assert intent.name == "query.read_only" + assert intent.metadata["workflow_route"] == "query.answer" + assert intent.metadata["message_intent"] == "requirement_initiation" + assert intent.metadata["consultant_mode_preference"] == "clarify" + assert intent.signal_title == "" def test_recover_session_followup_write_intent_skips_query_parent_even_if_previous_text_looks_like_write() -> None: @@ -2661,32 +2655,46 @@ def test_recover_session_followup_write_intent_is_disabled_even_for_write_parent @pytest.mark.asyncio -async def test_kernel_intent_resolve_suppresses_session_context_after_low_context_previous_turn() -> None: +async def test_kernel_intent_resolve_suppresses_session_context_after_low_context_previous_turn( + monkeypatch: pytest.MonkeyPatch, +) -> None: captured: dict[str, object] = {} - class FakeResolver(KernelIntentResolveMixin): + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + captured["message_text"] = message_text + captured["source_system"] = source_system + captured["metadata"] = dict(metadata or {}) + return WorkflowBundle( + bundle_id="bundle-low-context", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.79, + metadata={}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={}, + ) + + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), + ) + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 - def _is_short_confirmation_message(self, **kwargs): - _ = kwargs - return False - - @staticmethod - def _intent_llm_enabled() -> bool: - return True - - async def _resolve_intent_with_llm(self, **kwargs): - captured.update(kwargs) - return CanonicalIntent( - name="query.read_only", - confidence=0.79, - project_id=None, - signal_title="", - signal_summary="", - metadata={}, - ) - resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", @@ -2703,17 +2711,50 @@ async def test_kernel_intent_resolve_suppresses_session_context_after_low_contex thread_context=None, ) - assert source == "llm_only" - assert captured["session_last_intent_override"] == "" - assert captured["session_project_id_override"] is None - assert captured["inherit_session_context"] is False + assert source == "workflow_bundle" + assert captured["metadata"]["session_last_intent"] == "" + assert captured["metadata"]["session_project_id"] is None + assert captured["metadata"]["source_message_context"] == "cold_start" @pytest.mark.asyncio -async def test_kernel_intent_resolve_no_longer_calls_short_confirmation_helper() -> None: +async def test_kernel_intent_resolve_no_longer_calls_short_confirmation_helper( + monkeypatch: pytest.MonkeyPatch, +) -> None: captured: dict[str, object] = {} - class FakeResolver(KernelIntentResolveMixin): + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + captured["message_text"] = message_text + captured["source_system"] = source_system + captured["metadata"] = dict(metadata or {}) + return WorkflowBundle( + bundle_id="bundle-short-confirm", + semantic_version="v1", + source_system=source_system, + primary_workflow="followup_flow", + workflows=[ + WorkflowPlan( + workflow_type="followup_flow", + workflow_action="confirm", + target_scope="thread", + operation_codes=["followup.confirm"], + requires_confirmation=False, + confidence=0.9, + metadata={}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="followup_flow"), + diagnostics={}, + ) + + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), + ) + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 @@ -2721,21 +2762,6 @@ async def test_kernel_intent_resolve_no_longer_calls_short_confirmation_helper() _ = kwargs raise AssertionError("legacy short confirmation helper should not be used") - @staticmethod - def _intent_llm_enabled() -> bool: - return True - - async def _resolve_intent_with_llm(self, **kwargs): - captured.update(kwargs) - return CanonicalIntent( - name="query.read_only", - confidence=0.79, - project_id=None, - signal_title="", - signal_summary="", - metadata={}, - ) - resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", @@ -2752,38 +2778,52 @@ async def test_kernel_intent_resolve_no_longer_calls_short_confirmation_helper() thread_context={"reply_to_message_id": "reply-1"}, ) - assert source == "llm_only" - assert captured["session_last_intent_override"] == "project.create" - assert captured["session_project_id_override"] == 104 - assert captured["inherit_session_context"] is True + assert source == "workflow_bundle" + assert captured["metadata"]["session_last_intent"] == "project.create" + assert captured["metadata"]["session_project_id"] == 104 + assert captured["metadata"]["reply_to_message_id"] == "reply-1" @pytest.mark.asyncio -async def test_kernel_intent_resolve_uses_llm_for_signal_source_request() -> None: +async def test_kernel_intent_resolve_uses_workflow_bundle_for_signal_source_request( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + captured["message_text"] = message_text + captured["source_system"] = source_system + captured["metadata"] = dict(metadata or {}) + return WorkflowBundle( + bundle_id="bundle-signal-source", + semantic_version="v1", + source_system=source_system, + primary_workflow="signal_source_flow", + workflows=[ + WorkflowPlan( + workflow_type="signal_source_flow", + workflow_action="onboard", + target_scope="project", + operation_codes=["signal_source.onboard"], + requires_confirmation=True, + confidence=0.92, + metadata={"project_id": 2}, + ) + ], + message_gate="pending", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="signal_source_flow"), + diagnostics={}, + ) + + monkeypatch.setattr( + WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), + ) + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 - self.called = False - - def _is_short_confirmation_message(self, **kwargs): - _ = kwargs - return False - - @staticmethod - def _intent_llm_enabled() -> bool: - return True - - async def _resolve_intent_with_llm(self, **kwargs): - _ = kwargs - self.called = True - return CanonicalIntent( - name="signal_source.onboard", - confidence=0.92, - project_id=2, - signal_title="", - signal_summary="", - metadata={"source_request": True}, - ) resolver = FakeResolver() @@ -2794,145 +2834,12 @@ async def test_kernel_intent_resolve_uses_llm_for_signal_source_request() -> Non thread_context=None, ) - assert source == "llm_only" - assert resolver.called is True + assert source == "workflow_bundle" assert intent.name == "signal_source.onboard" assert intent.project_id == 2 assert intent.metadata["source_request"] is True -@pytest.mark.asyncio -async def test_kernel_intent_llm_prompt_guides_natural_problem_statement_to_project_create(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeResolver(KernelIntentLLMMixin): - def __init__(self) -> None: - self.tenant_id = 1 - self.db = object() - - def _build_canonical_intent(self, *, intent_name, message_text, project_id, confidence, metadata): # noqa: ANN001 - return CanonicalIntent( - name=intent_name, - confidence=confidence, - project_id=project_id, - signal_title="", - signal_summary=message_text[:300], - metadata=metadata, - ) - - async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 - captured["prompt"] = prompt - captured["kwargs"] = kwargs - return StructuredExtractionResult( - payload={ - "family": "project_create", - "action": "create", - "intent": "project.create", - "confidence": 0.93, - "project_id": None, - "requested_count": 1, - "requires_confirmation": False, - "parameters": {}, - "reasoning": "用户在描述真实业务痛点,并明确表达想做一个工具解决。", - }, - source_field="generated_text", - provider="mock", - model="mock-model", - raw_text='{"intent":"project.create"}', - validation="ok", - ) - - monkeypatch.setattr( - "app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_llm.llm_service.generate_structured_object", - fake_generate_structured_object, - ) - - resolver = FakeResolver() - intent = await resolver._resolve_intent_with_llm( - message_text="最近直播复盘太慢了,我想做个给运营用的小工具,能自动切片、总结亮点掉点。", - session=None, - world_model={}, - rule_intent=None, - ) - - assert intent is not None - assert intent.name == "project.create" - prompt = str(captured["prompt"] or "") - assert "自然语言" in prompt - assert "想做个工具" in prompt - assert "先帮我分析" in prompt - - -@pytest.mark.asyncio -async def test_kernel_intent_llm_prompt_can_suppress_stale_session_project(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakeResolver(KernelIntentLLMMixin): - def __init__(self) -> None: - self.tenant_id = 1 - self.db = object() - - def _build_canonical_intent(self, *, intent_name, message_text, project_id, confidence, metadata): # noqa: ANN001 - return CanonicalIntent( - name=intent_name, - confidence=confidence, - project_id=project_id, - signal_title="", - signal_summary=message_text[:300], - metadata=metadata, - ) - - async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 - captured["prompt"] = prompt - captured["kwargs"] = kwargs - return StructuredExtractionResult( - payload={ - "family": "query", - "action": "answer", - "intent": "query.read_only", - "confidence": 0.74, - "project_id": 0, - "requested_count": 1, - "requires_confirmation": False, - "parameters": {}, - "reasoning": "低上下文输入,不能继承过期项目。", - }, - source_field="generated_text", - provider="mock", - model="mock-model", - raw_text='{"intent":"query.read_only"}', - validation="ok", - ) - - monkeypatch.setattr( - "app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_llm.llm_service.generate_structured_object", - fake_generate_structured_object, - ) - - resolver = FakeResolver() - session = SimpleNamespace( - sender="feishu:chat-1:ou_dm_1", - last_intent="query.read_only", - current_project_id=104, - last_active_at=datetime.now(), - ) - intent = await resolver._resolve_intent_with_llm( - message_text="A", - session=session, - world_model={}, - rule_intent=None, - session_last_intent_override="", - session_project_id_override=None, - inherit_session_context=False, - ) - - assert intent is not None - prompt = str(captured["prompt"] or "") - assert "- session_last_intent: " in prompt - assert "- session_project_id: None" in prompt - assert "104" not in prompt - - def test_reply_renderer_formats_project_delete_pending_and_success() -> None: renderer = ReplyRenderer( state_waiting_manual="waiting_manual", @@ -3276,6 +3183,11 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch) self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_backfill_query_semantic_bundle", + AsyncMock(return_value={"diagnostics": {"message_intent": "query", "force_thinking_mode": "normal"}}), + ) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, @@ -3390,6 +3302,11 @@ async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatc return f"query failed: {exc}" monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_backfill_query_semantic_bundle", + AsyncMock(return_value={"diagnostics": {"message_intent": "query"}}), + ) executor = FakeExecutor() intent = SimpleNamespace(project_id=12, metadata={}) @@ -4419,6 +4336,7 @@ async def test_execute_query_intent_explicit_new_requirement_topic_with_subject_ self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch, topic_switch="new") monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -4517,6 +4435,7 @@ async def test_execute_query_intent_low_overlap_requirement_seed_switches_new_to self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch, topic_switch="new") monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -5744,6 +5663,7 @@ async def test_execute_query_intent_single_choice_does_not_resume_awaiting_requi self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_query_bundle(monkeypatch, message_intent="query", consultant_mode_preference="") monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -6199,7 +6119,7 @@ async def test_should_route_to_draft_confirmation_session_does_not_hijack_smallt ingress_options=None, ) - assert should_route is True + assert should_route is False assert called is True @@ -6229,7 +6149,7 @@ async def test_should_route_to_draft_confirmation_session_keeps_pending_confirm_ ingress_options=None, ) - assert should_route is True + assert should_route is False @pytest.mark.asyncio @@ -6257,7 +6177,41 @@ async def test_should_route_to_draft_confirmation_session_with_active_draft_does ingress_options=None, ) - assert should_route is True + assert should_route is False + + +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_does_not_hijack_regenerate_request_even_if_active_draft_exists( + monkeypatch, +) -> None: + called = False + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + nonlocal called + _ = (self, kwargs) + called = True + return True + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + + kernel = FakeKernel() + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent="query.answer"), + sender="tester", + source_system="feishu", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="生成一版给我看看", + ingress_options=None, + ) + + assert should_route is False + assert called is True @pytest.mark.asyncio @@ -6358,15 +6312,6 @@ async def test_execute_project_update_intent_resolves_string_project_id_without_ assert result["project"]["title"] == "新标题" assert executor.db.commits == 1 - -def test_deterministic_query_type_treats_project_risk_question_as_status() -> None: - class FakeRAG(mixin_deterministic_query_core.AdaptiveRAGDeterministicQueryCoreMixin): - pass - - rag = FakeRAG() - assert rag._classify_query_type("帮我看看跨境直播项目最近有哪些风险和阻塞") == "status" - - @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions() -> None: project = SimpleNamespace(id=104, title="跨境直播项目", name="跨境直播项目", status="active") @@ -6728,14 +6673,14 @@ async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: openclaw_bundle = { "rules": [ { - "name": "engine_low_intent_kernel_exact", + "name": "engine_low_semantic_context_exact", "enabled": True, "priority": 310, "tier": "low", "provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", - "function_exact": "classify_intent_ai_pmo_kernel", + "function_exact": "pmflow_semantic_context_decision", } ] } @@ -6745,7 +6690,7 @@ async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: return True def _is_low_tier_function(self, function_name: str) -> bool: - return function_name == "classify_intent_ai_pmo_kernel" + return function_name == "pmflow_semantic_context_decision" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: @@ -6818,7 +6763,7 @@ async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, - function_name="classify_intent_ai_pmo_kernel", + function_name="pmflow_semantic_context_decision", failed_engine=dict(low_engine), prompt="请返回结构化意图", system_prompt="system prompt", @@ -6879,14 +6824,14 @@ async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> Non openclaw_bundle = { "rules": [ { - "name": "engine_low_intent_kernel_exact", + "name": "engine_low_semantic_context_exact", "enabled": True, "priority": 310, "tier": "low", "provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", - "function_exact": "classify_intent_ai_pmo_kernel", + "function_exact": "pmflow_semantic_context_decision", } ] } @@ -6896,7 +6841,7 @@ async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> Non return True def _is_low_tier_function(self, function_name: str) -> bool: - return function_name == "classify_intent_ai_pmo_kernel" + return function_name == "pmflow_semantic_context_decision" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: @@ -6989,7 +6934,7 @@ async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> Non result, resolved_engine = await llm._retry_structured_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, - function_name="classify_intent_ai_pmo_kernel", + function_name="pmflow_semantic_context_decision", failed_engine=dict(low_engine), prompt="请返回结构化意图", system_prompt="system prompt", diff --git a/backend/tests/test_ai_pmo_task_intent_contract.py b/backend/tests/test_ai_pmo_task_intent_contract.py index 99590194..636c7643 100644 --- a/backend/tests/test_ai_pmo_task_intent_contract.py +++ b/backend/tests/test_ai_pmo_task_intent_contract.py @@ -126,6 +126,34 @@ def test_intent_resolver_followup_tokens_no_longer_override_semantic_query(monke assert intent.name == INTENT_QUERY +def test_intent_resolver_requirement_initiation_query_bundle_does_not_upgrade_to_project_create( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def _fake_decide(cls, *, message_text: str, source_system: str, metadata=None): + _ = (cls, message_text, source_system, metadata) + return _planner_decision( + workflows=[ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "confidence": 0.91, + } + ] + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide)) + + intent = IntentResolver.resolve( + text="想做个新需求,关于给运营公司打造主播推流的需求。", + project_id=None, + ) + + assert intent.name == INTENT_QUERY + assert intent.signal_title == "" + + def test_route_resolver_builds_query_answer_route() -> None: route = RouteResolver.from_canonical( canonical_intent=CanonicalIntent( diff --git a/backend/tests/test_ai_service_core_runtime.py b/backend/tests/test_ai_service_core_runtime.py index ee556c7a..63071032 100644 --- a/backend/tests/test_ai_service_core_runtime.py +++ b/backend/tests/test_ai_service_core_runtime.py @@ -1,5 +1,5 @@ -from pathlib import Path import json +from pathlib import Path import time import httpx @@ -154,6 +154,52 @@ def test_model_config_default_path_points_to_backend_openclaw() -> None: assert str(resolve_model_config_path()).endswith("backend/.openclaw/pmflow-routing-rules.json") +def test_llm_service_no_longer_keeps_function_hint_tables_in_runtime_modules() -> None: + repo_root = Path(__file__).resolve().parents[1] + + guardrails = (repo_root / "app/services/llm_service/mixin_guardrails_prompt.py").read_text(encoding="utf-8") + routing = (repo_root / "app/services/llm_service/mixin_openclaw_routing.py").read_text(encoding="utf-8") + shared = (repo_root / "app/services/llm_service/shared_part_01.py").read_text(encoding="utf-8") + + assert "DEFAULT_HIGH_TIER_HINTS" not in shared + assert "DEFAULT_LOW_TIER_HINTS" not in shared + assert "STRUCTURED_OUTPUT_FUNCTION_HINTS" not in shared + assert "FAST_LOCAL_FALLBACK_FUNCTION_HINTS" not in shared + assert "_prefer_fast_local_fallback" not in guardrails + assert "_is_fast_local_planner_function" not in guardrails + assert "_is_fast_local_worker_function" not in guardrails + assert "_resolve_function_tier(" in routing + + +def test_llm_service_prefers_explicit_routing_contract_over_function_name_hints() -> None: + tier = llm_service._resolve_function_tier( + "totally_unknown_function", + routing_meta={ + "tier_alias": "high", + "response_contract": "structured", + "fallback_policy": "fast_local", + "execution_mode": "local_planner", + }, + ) + + structured_required = llm_service._structured_output_required( + "totally_unknown_function", + routing_meta={"response_contract": "structured"}, + ) + timeout_seconds = llm_service._request_timeout_for_function( + "totally_unknown_function", + response_mode="fast", + routing_meta={ + "fallback_policy": "fast_local", + "execution_mode": "local_planner", + }, + ) + + assert tier == "high" + assert structured_required is True + assert timeout_seconds <= llm_service.request_timeout + + def test_provider_payload_reads_canonical_model_config(monkeypatch) -> None: monkeypatch.setenv("DASHSCOPE_MODEL", "wrong-value") monkeypatch.setenv("OPENAI_MODEL", "wrong-high-value") @@ -322,6 +368,7 @@ async def test_startup_model_probe_does_not_poison_provider_circuit(monkeypatch) original_state = dict(llm_service._provider_circuit_state) original_signature = llm_service._provider_circuit_config_signature + seen_function_names: list[str] = [] try: llm_service._provider_circuit_state["glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus"] = { "consecutive_failures": 2, @@ -332,7 +379,8 @@ async def test_startup_model_probe_does_not_poison_provider_circuit(monkeypatch) async def fake_resolve_engine_for_call(db, *, tenant_id: int, function_name: str): _ = (db, tenant_id) - if function_name == "classify_intent_ai_pmo_kernel": + seen_function_names.append(function_name) + if function_name == "pmflow_semantic_context_decision": return { "provider": "glm", "model": "qwen-plus", @@ -355,6 +403,8 @@ async def test_startup_model_probe_does_not_poison_provider_circuit(monkeypatch) await model_probe.probe_model_connectivity_on_startup() + assert "pmflow_semantic_context_decision" in seen_function_names + assert "classify_intent_ai_pmo_kernel" not in seen_function_names assert "glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" in llm_service._provider_circuit_state finally: llm_service._provider_circuit_state.clear() diff --git a/backend/tests/test_ai_steward_project_from_text_contract.py b/backend/tests/test_ai_steward_project_from_text_contract.py index 10227a94..86febc25 100644 --- a/backend/tests/test_ai_steward_project_from_text_contract.py +++ b/backend/tests/test_ai_steward_project_from_text_contract.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +from pathlib import Path from types import SimpleNamespace import pytest @@ -94,6 +95,18 @@ def test_project_from_text_response_schema_exposes_signal_source_display_fields( assert "technical_identifier" in signal_source["properties"] +def test_ai_steward_no_longer_keeps_instruction_or_stub_heuristics_in_runtime_modules() -> None: + repo_root = Path(__file__).resolve().parents[1] + + title_utils = (repo_root / "app/services/ai_steward_service/mixin_title_utils.py").read_text(encoding="utf-8") + json_parsing = (repo_root / "app/services/ai_steward_service/mixin_json_parsing.py").read_text(encoding="utf-8") + shared = (repo_root / "app/services/ai_steward_service/shared.py").read_text(encoding="utf-8") + + assert "_looks_like_instruction_sentence" not in title_utils + assert "_looks_like_project_creation_stub_response" not in json_parsing + assert "PROJECT_CREATION_STUB_MARKERS" not in shared + + @pytest.mark.asyncio async def test_project_requirement_analysis_uses_canonical_function_name(monkeypatch) -> None: service = AIStewardService(object()) diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index e398ac0d..8d927cf0 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -1271,6 +1271,130 @@ async def test_draft_confirm_handler_display_feedback_uses_semantic_answer_only( assert "不会重起草" in result["followup_reply"] +@pytest.mark.asyncio +async def test_draft_confirm_handler_semantic_regenerate_routes_to_regenerate_draft(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + captured: dict[str, object] = {} + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_resolve_active_mounted_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="ready"), + SimpleNamespace(id=77, mode="plan", plan_status="awaiting_execute_confirm"), + ) + + async def _fake_semantic_followup(**kwargs): + _ = kwargs + return { + "action": "regenerate", + "revision_note": "", + "assistant_reply": "", + } + + async def _fake_regenerate_draft(**kwargs): + captured["regenerate_kwargs"] = kwargs + return { + "handled": True, + "status": "draft_pending_confirm", + "followup_reply": "我先基于当前草稿再整理一版。", + } + + async def _unexpected_confirm(**kwargs): # noqa: ANN001 + raise AssertionError(f"confirm_draft should not run for regenerate: {kwargs}") + + async def _unexpected_revise(**kwargs): # noqa: ANN001 + raise AssertionError(f"revise_draft should not run for regenerate: {kwargs}") + + async def _unexpected_cancel(**kwargs): # noqa: ANN001 + raise AssertionError(f"cancel_draft should not run for regenerate: {kwargs}") + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft) + monkeypatch.setattr(handler, "_resolve_semantic_followup_decision", _fake_semantic_followup) + monkeypatch.setattr(handler, "regenerate_draft", _fake_regenerate_draft, raising=False) + monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm) + monkeypatch.setattr(handler, "revise_draft", _unexpected_revise) + monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel) + + result = await handler.handle_text_reply( + message_text="生成一版给我看看", + sender="林宇航", + source_system="feishu", + source_message_id="msg-regenerate-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context={"reply_to_message_id": "reply-1"}, + ) + + assert result["handled"] is True + assert result["status"] == "draft_pending_confirm" + assert "当前草稿" in result["followup_reply"] + assert captured["regenerate_kwargs"]["message_text"] == "生成一版给我看看" + + +@pytest.mark.asyncio +async def test_resolve_pending_draft_releases_stale_active_work_item_binding(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + captured: dict[str, object] = {} + + async def _fake_resolve_active_mounted_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"), + SimpleNamespace(id=77, mode="plan", status="active", plan_status="draft_pending_confirm"), + ) + + async def _fake_load_work_item_target_by_id(**kwargs): + captured["load_kwargs"] = kwargs + return ( + SimpleNamespace( + id=42, + state="cancelled", + intent="project.create", + project_id=None, + signal_id=None, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_release_binding(**kwargs): + captured["release_kwargs"] = kwargs + + monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft) + monkeypatch.setattr(handler, "_load_work_item_target_by_id", _fake_load_work_item_target_by_id) + monkeypatch.setattr(handler, "_release_mounted_draft_binding", _fake_release_binding, raising=False) + + result = await handler._resolve_pending_draft( + sender="林宇航", + source_system="feishu", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + explicit_work_item_id=None, + thread_context=None, + ) + + assert result is None + assert captured["load_kwargs"] == {"work_item_id": 42} + assert captured["release_kwargs"]["work_item_id"] == 42 + assert captured["release_kwargs"]["final_state"] == "cancelled" + + @pytest.mark.asyncio async def test_draft_confirm_handler_uses_work_item_bound_mounted_draft_for_semantic_followup(monkeypatch) -> None: kernel = type("Kernel", (), {"db": SimpleNamespace(execute=object()), "tenant_id": 1})() @@ -1453,7 +1577,7 @@ async def test_draft_confirm_handler_target_bound_without_semantic_action_return assert result["handled"] is True assert result["status"] == "pending_manual" - assert "请回复“确认”、“修改 + 你的意见”或“取消”" in result["followup_reply"] + assert "请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”" in result["followup_reply"] @pytest.mark.asyncio @@ -1541,6 +1665,55 @@ def _make_adaptive_rag_mixin() -> AdaptiveRAGDeepGuardAnswerMixin: mixin = AdaptiveRAGDeepGuardAnswerMixin() mixin.db = _FakePromptDB() mixin.tenant_id = 1 + + async def _fake_scene_semantics(**kwargs): + question = str(kwargs.get("question") or "").strip() + asked_via = str(kwargs.get("asked_via") or "").strip().lower() + message_intent = str(kwargs.get("message_intent") or "").strip().lower() + query_type = str(kwargs.get("query_type") or "").strip().lower() + normalized = question.replace(" ", "") + + if message_intent == "mixed": + scene_key = "mixed" + elif message_intent == "requirement_plan": + scene_key = "ai_pmo_requirement_plan" + elif message_intent == "business_review": + scene_key = "ai_pmo_business_review" + elif message_intent == "correction_escalation": + scene_key = "ai_pmo_correction" + elif any(token in normalized for token in ("负载", "工作量", "工时")): + scene_key = "member_workload" + elif any(token in normalized for token in ("宠物行业最新动态", "项目所在行业最新动态")) and any(token in normalized for token in ("这个项目", "项目所在行业")) and "不要提项目" not in normalized: + scene_key = "external_trend" + elif any(token in normalized for token in ("宠物行业最新动态", "行业最新动态")): + scene_key = "industry_trend" + elif query_type == "status" or "最新进展" in normalized: + scene_key = "project_status" + elif any(token in normalized for token in ("只允许一种提醒", "桌面通知", "飞书通知", "定一个方向", "还是")): + scene_key = "decision_choice" + elif asked_via in {"pmflow_manual", "/ai-pmo/process-text", "ai-pmo-process-text", "ai_pmo", "ai_pmo_ui"}: + scene_key = "ai_pmo" + elif asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}: + scene_key = "feishu_direct" + else: + scene_key = "default" + + snapshot_focus = "none" + if any(token in normalized for token in ("你是谁", "你会什么", "你叫什么")): + snapshot_focus = "identity" + elif any(token in normalized for token in ("系统架构", "技术栈", "数据库", "redis")): + snapshot_focus = "architecture" + elif any(token in normalized for token in ("功能", "入口", "在哪", "用途")): + snapshot_focus = "feature" + + return { + "scene_key": scene_key, + "snapshot_focus": snapshot_focus, + "deep_intent": "none", + "confidence": 0.9, + } + + mixin._resolve_answer_scene_semantics = _fake_scene_semantics return mixin @@ -1609,6 +1782,47 @@ async def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> Non assert "飞书消息" in prompt +@pytest.mark.asyncio +async def test_adaptive_rag_select_scene_prompt_prefers_structured_scene_semantics( + monkeypatch: pytest.MonkeyPatch, +) -> None: + mixin = _make_adaptive_rag_mixin() + + async def _fake_scene_semantics(**kwargs): + _ = kwargs + return { + "scene_key": "decision_choice", + "snapshot_focus": "none", + "deep_intent": "none", + "confidence": 0.94, + } + + monkeypatch.setattr( + mixin, + "_resolve_answer_scene_semantics", + _fake_scene_semantics, + raising=False, + ) + + scene_key, prompt = await mixin._select_scene_prompt( + question="桌面通知、飞书通知,这两种提醒里帮我定一个方向并解释取舍", + project_id=None, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + + assert scene_key == "decision_choice" + assert "明确选择" in prompt + + @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None: mixin = _make_adaptive_rag_mixin() diff --git a/backend/tests/test_feishu_new_project_signal_sources.py b/backend/tests/test_feishu_new_project_signal_sources.py index 29476d00..9cb1a146 100644 --- a/backend/tests/test_feishu_new_project_signal_sources.py +++ b/backend/tests/test_feishu_new_project_signal_sources.py @@ -1,14 +1,16 @@ from __future__ import annotations +import importlib import os from datetime import datetime import pytest import pytest_asyncio +from fastapi import HTTPException from sqlalchemy import JSON, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["DATABASE_URL"] = "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test" from app.models.base import Base from app.models.project import Project @@ -20,6 +22,10 @@ from app.models.user_project import UserProject from app.services.signal.signal_capture_service import ProactiveSignalCaptureService +def _load_signal_source_helper(): + return importlib.import_module("app.api.signal_sources_api.helpers") + + def _use_sqlite_json_types() -> None: for table in (User.__table__, SignalSourceRegistry.__table__): for column in table.columns: @@ -172,3 +178,22 @@ async def test_auto_create_project_from_new_signal_persists_signal_sources(db_se ) ).scalars().all() assert len(links) == 3 + + +@pytest.mark.asyncio +async def test_signal_source_helper_no_longer_uses_recipe_keyword_project_guess(db_session) -> None: + helper = _load_signal_source_helper() + db_session.add_all( + [ + Project(tenant_id=1, title="Recipe Studio", name="Recipe Studio", status="active"), + Project(tenant_id=1, title="Growth Console", name="Growth Console", status="active"), + ] + ) + await db_session.commit() + + with pytest.raises(HTTPException, match="显式传入 project_id"): + await helper.resolve_target_project_id( + db=db_session, + tenant_id=1, + project_id=None, + ) diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index c816a121..0804f1db 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -93,6 +93,19 @@ def test_followup_status_target_rejects_single_item_without_anchor() -> None: assert target is None +def test_followup_status_target_does_not_use_lexical_project_hints_without_anchor() -> None: + items = [ + _Item(item_id=1, level="project", operation_code="project.create"), + ] + + target = FeishuPipelineFollowupBMixin._select_followup_status_target( + message="刚才创建项目现在怎么样了", + recent_items=items, + ) + + assert target is None + + def test_followup_status_target_prefers_thread_anchor() -> None: items = [ _Item(item_id=1, level="project", operation_code="project.create"), @@ -864,6 +877,27 @@ async def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_ assert reason == "semantic:requirement_plan" +@pytest.mark.asyncio +async def test_should_process_feishu_high_accuracy_no_longer_uses_question_suffix_fallback(monkeypatch) -> None: + class _FakeBundle: + primary_workflow = "project_signal_flow" + workflows = [SimpleNamespace(workflow_type="project_signal_flow", workflow_action="capture")] + diagnostics = {"message_intent": "casual_chat"} + + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) + + should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( + message_text="@PMFlow 现在几点?", + ) + + assert should_sync is False + assert reason == "async_queue" + + @pytest.mark.asyncio async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards_semantic_bundle(monkeypatch) -> None: captured: dict[str, object] = {} @@ -1073,7 +1107,7 @@ async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch self.tenant_id = 1 @staticmethod - def _looks_like_contextual_project_request(message: str) -> bool: + def _is_contextual_project_request(message: str) -> bool: return True async def _load_recent_chat_turns(self, **kwargs): diff --git a/backend/tests/test_runtime_structured_semantics_contract.py b/backend/tests/test_runtime_structured_semantics_contract.py new file mode 100644 index 00000000..172ab864 --- /dev/null +++ b/backend/tests/test_runtime_structured_semantics_contract.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from pathlib import Path + + +def test_runtime_modules_no_longer_keep_legacy_business_heuristic_helpers() -> None: + repo_root = Path(__file__).resolve().parents[1] + expectations = { + "app/services/signal/signal_capture_service/mixin_capture_entry.py": ( + "_is_question_like", + ), + "app/services/signal/signal_capture_service/mixin_analysis_normalize.py": ( + "_is_question_like", + ), + "app/services/signal/signal_capture_service/mixin_pipeline_cache.py": ( + "_is_question_like", + ), + "app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py": ( + "_is_water_talk_message", + "_is_requirement_like_question", + "_extract_requirement_statement_from_question", + "_convert_requirement_question_to_signal_text", + ), + "app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py": ( + "_is_project_lookup_question", + ), + "app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py": ( + "_has_question_structure", + ), + "app/services/reply_iteration/service.py": ( + "EXECUTION_CLAIM_TOKENS", + ), + "app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py": ( + "_is_capability_question", + ), + } + + for relative_path, forbidden_tokens in expectations.items(): + source = (repo_root / relative_path).read_text(encoding="utf-8") + for token in forbidden_tokens: + assert token not in source, f"{relative_path} still contains {token}" + + +def test_runtime_modules_no_longer_reference_classify_intent_complex() -> None: + repo_root = Path(__file__).resolve().parents[1] + runtime_files = ( + "app/core/startup/model_probe.py", + "app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_context.py", + "app/services/ai_pmo/ai_pmo_kernel_common_core/env.py", + "app/services/llm_service/mixin_guardrails_prompt.py", + "app/services/llm_service/mixin_generate_db_structured.py", + "app/services/llm_service/mixin_structured_candidates.py", + "app/services/llm_service/shared_part_01.py", + "app/services/model/model_routing/service.py", + "app/services/model/model_context_bundle/shared.py", + "app/services/model/model_context_bundle/mixin_core.py", + ) + + for relative_path in runtime_files: + source = (repo_root / relative_path).read_text(encoding="utf-8") + assert "classify_intent_complex" not in source, relative_path diff --git a/backend/tests/test_semantic_context_assembler.py b/backend/tests/test_semantic_context_assembler.py index 4216998e..baba3c11 100644 --- a/backend/tests/test_semantic_context_assembler.py +++ b/backend/tests/test_semantic_context_assembler.py @@ -174,7 +174,7 @@ async def test_semantic_context_assembler_adds_draft_followup_ingress_actions(db "candidate_kind": "draft_followup", "source_system": "feishu", "reply_anchor_present": True, - "allowed_followup_actions": ["confirm", "revise", "cancel", "answer_only"], + "allowed_followup_actions": ["confirm", "revise", "regenerate", "cancel", "answer_only"], "thread_context": {"reply_to_message_id": "om_reply_1"}, }, ) @@ -183,6 +183,7 @@ async def test_semantic_context_assembler_adds_draft_followup_ingress_actions(db assert packet.ingress.candidate_kind == "draft_followup" assert packet.ingress.reply_anchor_present is True assert "draft.followup.answer_only" in packet.mode_card.allowed_actions + assert "draft.followup.regenerate" in packet.mode_card.allowed_actions assert "draft.followup.revise" in packet.mode_card.allowed_actions diff --git a/docs/architecture/ARCHITECTURE_INVENTORY.md b/docs/architecture/ARCHITECTURE_INVENTORY.md new file mode 100644 index 00000000..0b1dd77d --- /dev/null +++ b/docs/architecture/ARCHITECTURE_INVENTORY.md @@ -0,0 +1,152 @@ +# ARCHITECTURE INVENTORY + +## 1. 目的 +- 本文档承接 PMFlow 的架构库存与关键入口索引。 +- `CLAUDE.md` 保留当前架构说明、运行边界和参考模式。 +- 本文档记录当前模块分布、重要子包、关键脚本与前端页面分组,后续架构变更统一在此同步。 + +## 2. 同步规则 +- 任何影响目录结构、主入口、模块归属、前后端页面/API 分组、脚本布局的架构变更,都必须同步更新: + - `CLAUDE.md` + - `docs/architecture/ARCHITECTURE_INVENTORY.md` +- API/Schema 契约变更仍需同步: + - `docs/architecture/API_CONTRACTS.md` + - 对应前端 `types/api` 封装 +- 路由/函数名/运行时流向变更仍需同步: + - `docs/architecture/DATAFLOW.md` + - `backend/.openclaw/pmflow-routing-rules.json` + +## 3. 顶层库存 +- `docs/INBOX_INLINE_OPS_3_PHASE_CHECKLIST.md`:消息台内联操作执行清单 +- `docs/architecture/MASTER_CONTRACT.md`:跨栈主契约 +- `docs/architecture/CODE_REFERENCE_INDEX.md`:新增代码参考入口与高频代码索引 +- `docs/architecture/RUNTIME_GUARDRAILS.md`:运行边界、守门规则与已知运行假设 +- `docs/architecture/API_CONTRACTS.md`:接口/Schema 契约 +- `docs/architecture/DATAFLOW.md`:主链与数据流说明 +- `docs/architecture/TASK_BRIEF_TEMPLATE.md`:任务包模板 +- `docs/superpowers/plans/`:实现计划 +- `docs/task_briefs/`:任务包产物与验收截图 + +## 4. 后端库存 + +### 4.1 API 路由分组 +- `backend/app/api/ai_steward_api/` +- `backend/app/api/ai_pmo_api/` +- `backend/app/api/feishu_api/` +- `backend/app/api/rag_api/` +- `backend/app/api/sstc_api/` +- `backend/app/api/signals_api/` +- `backend/app/api/signal_sources_api/` +- `backend/app/api/skill_tags_api/` +- `backend/app/api/ai_logs_api/` + +### 4.2 AI PMO 与语义主链 +- `backend/app/services/ai_pmo/ai_pmo_kernel/` +- `backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/` +- `backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/` +- `backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/` +- `backend/app/services/ai_pmo/semantic_context/` +- `backend/app/services/ai_pmo/workflow_semantic_planner/` +- `backend/app/services/ai_pmo/draft_confirm_handler.py` + +### 4.3 问答、模型与生成 +- `backend/app/services/adaptive_rag_service/` +- `backend/app/services/llm_service/` +- `backend/app/services/ai_steward_service/` +- `backend/app/services/feedback_center/` +- `backend/app/services/task_brief_service.py` +- `backend/app/config/ai_prompts.py` +- `backend/app/config/ai_prompts_catalog/` +- `backend/app/config/ai_prompt_registry.py` +- `backend/app/services/model/model_routing/service.py` +- `backend/app/services/model/model_config.py` + +### 4.4 集成、信号与运行时 +- `backend/app/services/integrations/feishu_integration_service/` +- `backend/app/services/integrations/feishu_outbox/service.py` +- `backend/app/services/integrations/feishu_signal_pipeline/` +- `backend/app/services/signal/signal_capture_service/` +- `backend/app/services/signal/signal_source_orchestration/` +- `backend/app/services/signal/signal_source_repair/` +- `backend/app/services/signal/signal_planner/` +- `backend/app/services/signal/draft_generator_service.py` +- `backend/app/core/startup/` +- `backend/app/utils/thread_context.py` +- `backend/app/utils/timezone.py` +- `backend/app/utils/audited_write.py` +- `backend/alembic/versions/` + +### 4.5 Mounted Runtime / 训练相关 +- `backend/app/models/reply_iteration.py` +- `backend/app/models/training_pipeline.py` +- `backend/app/models/mounted_ai_runtime.py` +- `backend/app/api/ai_steward_api/routes_reply_iteration.py` +- `backend/app/api/ai_steward_api/routes_training_pipeline.py` +- `backend/app/api/ai_steward_api/routes_mounted_ai.py` + +### 4.6 结构归并结果 +- `backend/app/services/` 根目录已目录化收敛,新增服务优先进入对应子包。 +- API canonical 入口优先使用 `*_api/` 子包,不再优先引用顶层兼容壳。 +- 运行时业务动作归因优先走“对象/上下文绑定 -> `SemanticContextPacket` -> `SemanticDecision` -> gatekeeper”。 +- `backend/tests/test_runtime_structured_semantics_contract.py` 与 `backend/tests/test_feishu_pipeline_queue_contract.py` 共同守门 runtime 结构化语义收口,锁定飞书入口、signal capture、reply/runtime routing 不得回流旧词法拍板 helper。 + +## 5. 前端库存 + +### 5.1 页面分组 +- `frontend/src/pages/inbox/` +- `frontend/src/pages/ai-steward/` +- `frontend/src/pages/scheduler-center/` +- `frontend/src/pages/project-board/` +- `frontend/src/pages/projects/` +- `frontend/src/pages/users/` +- `frontend/src/pages/recipes/` +- `frontend/src/pages/feishu-config/` +- `frontend/src/pages/ai-config/` +- `frontend/src/pages/model-routing-config/` +- `frontend/src/pages/task-brief-center/` +- `frontend/src/pages/task-brief-runs/` +- `frontend/src/pages/task-brief-template/` +- `frontend/src/pages/charter-detail/` +- `frontend/src/pages/contract-sync-board/` +- `frontend/src/pages/dashboard/` +- `frontend/src/pages/knowledge-graph-context-board/` +- `frontend/src/pages/login/` +- `frontend/src/pages/skill-tasks/` +- `frontend/src/pages/skills/` +- `frontend/src/pages/user-profile/` +- `frontend/src/pages/user-skills/` +- `frontend/src/pages/workload-detail/` +- `frontend/src/pages/workloads/` + +### 5.2 API 与共享模块 +- `frontend/src/api/aiPmo.ts` +- `frontend/src/api/aiSteward.ts` +- `frontend/src/api/taskBriefs.ts` +- `frontend/src/api/modelRouting.ts` +- `frontend/src/api/knowledgeGraph.ts` +- `frontend/src/api/mountedAi.ts` +- `frontend/src/api/feedbackCenter.ts` +- `frontend/src/api/sstc.ts` +- `frontend/src/components/inbox/` +- `frontend/src/components/sstc/` +- `frontend/src/pages/scheduler-center/components/` +- `frontend/src/pages/ai-steward/components/` +- `frontend/src/lib/` + +### 5.3 前端结构边界 +- 页面级编排逻辑优先收敛到 `pages/*/` 子目录。 +- API 调用统一走 `frontend/src/api/`,不在页面或组件内直接 `fetch`。 +- `frontend/src/components/sstc/` 继续作为 SSTC 共享组件目录维护。 + +## 6. 工具与运行库存 +- `scripts/start_model_probe.sh` +- `scripts/model_probe.py` +- `scripts/gen_task_brief.sh` +- `scripts/check_contract_sync.sh` +- `frontend/playwright.config.ts` +- `frontend/e2e/feishu-project-full-flow.spec.ts` + +## 7. 维护建议 +- 想看“现在怎么跑、边界怎么定”:先看 `AGENTS.md`,再看 `CLAUDE.md` +- 想看“模块都搬到哪里了、目录怎么分组”:看本文档 +- 想看“接口/数据流契约”:看 `docs/architecture/API_CONTRACTS.md` 与 `docs/architecture/DATAFLOW.md` diff --git a/docs/architecture/CODE_REFERENCE_INDEX.md b/docs/architecture/CODE_REFERENCE_INDEX.md new file mode 100644 index 00000000..9db775a4 --- /dev/null +++ b/docs/architecture/CODE_REFERENCE_INDEX.md @@ -0,0 +1,72 @@ +# CODE REFERENCE INDEX + +## 1. 目的 +- 本文档承接 PMFlow 的“新增代码参考模式”与高频代码入口索引。 +- `CLAUDE.md` 只保留摘要与跳转入口;详细的接口、页面、服务参考清单统一维护在这里。 +- 当新增 API、关键页面入口、工作流入口或参考样式发生变化时,应同步更新本文档。 + +## 2. 后端接口参考 + +### 2.1 通用与基础 +- 新增后端接口:参考 `backend/app/api/sstc_api/` 的结构 +- 新增数据库表:参考 `alembic/versions/` 最新 migration +- 新增飞书事件处理:参考 `backend/app/services/integrations/feishu_integration_service/` +- 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) +- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) +- LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`) + +### 2.2 AI Steward / Training / Mounted Runtime +- 项目创建接口:参考 `backend/app/api/ai_steward_api/routes_capture.py` +- 模型路由配置接口:参考 `backend/app/api/model_routing.py` +- 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py` +- 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py` +- Mounted Runtime 控制台接口:参考 `backend/app/api/ai_steward_api/routes_mounted_ai.py` +- 老板晨报接口:参考 `backend/app/api/ai_steward_api/routes_executive_briefing.py` +- 每日简报 v2 接口:参考 `backend/app/api/ai_steward_api/routes_brief_blockers.py` +- 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`) +- AI Steward 指挥规划接口已移除:`POST /ai-steward/command/dispatch` + +### 2.3 RAG / AI PMO / Knowledge Graph +- RAG 问答主入口:参考 `backend/app/api/rag_api/` 与 `backend/app/api/rag_ask_contract.py` +- KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py` +- 任务包中心接口:参考 `backend/app/api/task_briefs.py` +- AI PMO 恢复接口:参考 `POST /ai-pmo/work-items/{work_item_id}/resume` +- AI PMO 草稿决策接口:参考 `POST /ai-pmo/work-items/{work_item_id}/draft-decision` +- AI PMO 审批接口:参考 `POST /ai-pmo/work-items/{work_item_id}/decision` +- AI PMO 消息台列表接口:参考 `GET /ai-pmo/work-items` + +### 2.4 Signal / Project / SSTC +- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py` +- 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` +- 项目删除接口:参考 `backend/app/api/projects.py` +- 项目成员分配接口:参考 `backend/app/api/projects.py` + `backend/app/services/project_member/service.py` +- 通用任务拆解接口:参考 `POST /tasks/breakdown` +- SSTC 总览接口:参考 `GET /projects/{project_id}/sstc/overview` +- SSTC 信号列表接口:参考 `GET /projects/{project_id}/signals` +- SSTC 宪章详情接口:参考 `GET /projects/{project_id}/charters/{charter_id}` +- SSTC 宪章 AI 预填充接口:参考 `POST /projects/{project_id}/charters/{charter_id}/ai-prefill` +- SSTC AI 拆解接口:参考 `POST /projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown` +- SSTC 发布验证生成接口:参考 `POST /projects/{project_id}/releases/{release_node_id}/verification/generate` +- SSTC 负责人推荐接口:参考 `POST /projects/{project_id}/tasks/{task_id}/recommend-assignee` +- SSTC 任务更新接口:参考 `PATCH /projects/{project_id}/tasks/{task_id}` +- SSTC 发布创建接口:参考 `POST /projects/{project_id}/releases` + +## 3. 前端参考 +- 新增前端 API 调用:参考 `frontend/src/api/sstc.ts` +- AI Steward 页面入口:参考 `frontend/src/pages/ai-steward/` +- Inbox 页面入口:参考 `frontend/src/pages/inbox/` +- Scheduler Center / Mounted AI Console:参考 `frontend/src/pages/scheduler-center/` +- SSTC 工作流组件:参考 `frontend/src/components/sstc/` +- Model Routing 页面:参考 `frontend/src/pages/model-routing-config/` + +## 4. 模型与配置真源 +- 模型配置唯一来源:参考 `backend/app/services/model/model_config.py` +- 路由规则真源:`backend/.openclaw/pmflow-routing-rules.json` +- Prompt 聚合:`backend/app/config/ai_prompts.py` +- Prompt 场景化分片:`backend/app/config/ai_prompts_catalog/` +- Prompt registry:`backend/app/config/ai_prompt_registry.py` + +## 5. 维护建议 +- 新增接口时:先更新本文档,再同步 `API_CONTRACTS.md` +- 新增页面/关键入口时:同步本文档与 `ARCHITECTURE_INVENTORY.md` +- 若某条参考入口已经下线,应直接从本文档移除,不保留历史死链 diff --git a/docs/architecture/MASTER_CONTRACT.md b/docs/architecture/MASTER_CONTRACT.md index f667dd7b..5884a137 100644 --- a/docs/architecture/MASTER_CONTRACT.md +++ b/docs/architecture/MASTER_CONTRACT.md @@ -11,6 +11,9 @@ - Architecture docs source: `docs/architecture/` ## 3. Sync Rules +- Any runtime/service boundary or operating assumption change must update: + - `AGENTS.md` (if it changes mandatory execution rules) + - `docs/architecture/RUNTIME_GUARDRAILS.md` - Any backend API/schema change must update: - `docs/architecture/API_CONTRACTS.md` - frontend TypeScript interfaces (if impacted) @@ -19,6 +22,8 @@ - `docs/architecture/DATAFLOW.md` - Any new module/page/API must update: - `CLAUDE.md` structure/reference sections + - `docs/architecture/ARCHITECTURE_INVENTORY.md` + - `docs/architecture/CODE_REFERENCE_INDEX.md` (if it changes code reference entrypoints) ## 4. Current Core Contracts - Signal Planner function name: `signal_planner_analyze` @@ -35,4 +40,7 @@ - [ ] Backend schema + frontend type synced - [ ] Dataflow doc synced - [ ] Routing rule synced +- [ ] RUNTIME_GUARDRAILS.md synced - [ ] CLAUDE.md synced +- [ ] ARCHITECTURE_INVENTORY.md synced +- [ ] CODE_REFERENCE_INDEX.md synced diff --git a/docs/architecture/RUNTIME_GUARDRAILS.md b/docs/architecture/RUNTIME_GUARDRAILS.md new file mode 100644 index 00000000..16c95f4b --- /dev/null +++ b/docs/architecture/RUNTIME_GUARDRAILS.md @@ -0,0 +1,120 @@ +# RUNTIME GUARDRAILS + +## 1. 目的 +- 本文档承接 PMFlow 的长运行边界、跨链路守门规则与已知运行假设。 +- `AGENTS.md` 保留必须立即遵守的摘要规则;本文件提供更细的运行时约束与环境边界。 +- `CLAUDE.md` 只保留当前架构摘要、关键入口和高价值提醒;长清单统一收口到这里。 + +## 2. 同步规则 +- 任何影响运行边界、守门策略、失败语义、已知环境假设的改动,都必须同步更新: + - `AGENTS.md` + - `docs/architecture/RUNTIME_GUARDRAILS.md` +- 任何影响目录结构、模块归属与关键入口的改动,还必须同步更新: + - `CLAUDE.md` + - `docs/architecture/ARCHITECTURE_INVENTORY.md` +- 任何影响主链路由、函数名、运行时流向的改动,还必须同步更新: + - `docs/architecture/DATAFLOW.md` + - `backend/.openclaw/pmflow-routing-rules.json` + +## 3. 核心运行边界 + +### 3.1 平台与写路径 +- 数据库写操作必须通过 Alembic migration,禁止直接 `ALTER TABLE`。 +- 运行期禁止 DDL;仅迁移路径允许执行 `CREATE/ALTER/DROP/...`。 +- 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`),禁止回落 SQLite。 +- 新增接口必须遵循响应包络:`{code, message, data}`。 +- 禁止在 API 层写业务逻辑;业务逻辑归属 `services/`。 +- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层。 +- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送。 +- 飞书回执发送仅允许 `main.py` 的 drain loop 作为消费者,禁止在业务路径即时派发发送任务。 +- 所有写接口必须落审计日志(`operation_logs`),至少包含 `trace_id/interface_path/caller`。 +- `created_at_beijing` 为计算属性返回,禁止新增数据库物理列。 + +### 3.2 核心业务零兜底 +- 模型失败、超时、空响应、结构化解析失败时,必须显式返回真实失败原因。 +- 禁止使用固定兜底文案、模板句子、占位字段或 stub 数据冒充成功结果。 +- 核心业务完成态不得仅按字段非空判定;必须能区分“真实生成 / 人工填写 / 模板占位 / 生成失败”。 +- 技术层 fallback 仅允许用于重试、缓存、解析修复、传输兼容等基础设施环节,禁止上浮为用户可见业务结论。 +- AI Steward 与其它生成链的成功/失败判定必须优先使用结构校验、字段完整性和证据一致性。 + +### 3.3 业务动作归因主链 +- 运行时业务动作归因必须优先走: + - 对象/上下文绑定 + - `SemanticContextPacket` + - `SemanticDecision` + - gatekeeper +- `backend/app` 运行时代码不得再沉淀会直接拍板 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 规则仓。 +- 协议级锚点(`reply_to_message_id/button/work_item_id`)、租户权限、幂等等确定性规则可以保留,但展示反馈、草稿确认/修改/取消、followup 动作归类、`query vs execute` 不能再由关键词最终拍板。 +- 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,主修法必须优先落在 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组包、`SemanticDecision` 结构化判断与 gatekeeper 放行条件。 +- `workflow_semantic_planner` 当前职责是 packet / bundle 组装、兼容层收口与冷启动接入,不再作为新的关键词仓。 + +### 3.4 LLM 与模型路由 +- `llm_service` 运行时路由必须显式传入 `routing_meta`,至少统一: + - `tier_alias` + - `response_contract` + - `fallback_policy` + - `execution_mode` +- 禁止再通过函数名片段、hint 常量或模板词在底层推断 tier、结构化输出或 fallback 策略。 +- 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错。 +- 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏。 +- `integrations(type='model_routing')` 的 runtime tier override 不得把 `adaptive_rag_answer_normal` / `adaptive_rag_answer_deep` 这类用户可见 QA 主函数降级到 `low`。 +- 模型默认值与 provider/tier 映射以 `backend/.openclaw/pmflow-routing-rules.json` 为唯一配置源。 + +### 3.5 Requirement Plan / Draft / Followup +- `requirement_plan` 必须是多轮状态机,不得退回单轮模板问答。 +- `MountedTopicWorkspace.plan_status` 必须显式落库区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`。 +- `requirement_plan` 的 A/B/C 可以固定为用户可见骨架,但业务选项、推荐项、理由和代价必须走“上下文包 -> 模型结构化决策 -> gatekeeper -> 渲染”。 +- 正在收敛中的 `requirement_plan` 必须支持碎片化回复,不能因缺少新的 `semantic_bundle` 回落到普通 query。 +- 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,必须先清掉继承的旧 `current_project_id`。 +- 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须明确提示当前没有进行中的收敛轮次或草稿。 +- 草稿确认只认显式 `reply_to_message_id` + 明确草稿动作,或显式 `work_item_id`。 +- 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链。 +- AI PMO 写操作默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库。 + +### 3.6 AdaptiveRAG / Query 主链 +- `POST /rag/ask` 在命中 `business_review/correction_escalation` 时,必须强制留在 `query.read_only` 主链,禁止误送到写操作草稿链。 +- Feishu 新消息即使被低阶 LLM 先判成写动作,只要统一语义规划层命中 `requirement_plan/business_review/correction_escalation`,就必须在 ingress 阶段强制拉回 `query.answer`。 +- 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot 快路径,统一落到场景化 Prompt 生成。 +- 通用行业趋势问答不得注入数据库、项目历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`。 +- mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`。 + +### 3.7 Signal / Project / SSTC / Briefing +- `SignalSourceRegistry` 仅作为信号源接入/幂等内部表;用户可见的项目“信号源/信号”统一落到正式 `Signal`。 +- `create_project_from_natural_language` 必须支持透传 `requested_member_user_ids`,草稿确认时必须把推荐负责人并入项目创建事务。 +- AI Steward `project-from-text` 归一化阶段必须优先走人员能力图谱,禁止绕过能力图谱直接拍脑袋分配。 +- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量结构化分析失败,必须返回失败安全态。 +- SSTC 宪章 AI 预填充必须输出并持久化 `field_status/field_evidence/readiness/derived_artifacts`;总确认前必须校验 `readiness`。 +- SSTC 发布验证清单必须优先消费 `derived_artifacts.verification_metrics`,禁止回退默认验收项。 +- 老板晨报链路只允许 `BOSS/ADMIN` 访问;`snapshot/live` 语义必须分离。 +- `daily-briefing-v2` 必须复用同一份租户级简报内核,禁止老板晨报与每日简报各自维护独立统计口径。 +- 晨报/日报导出首版只允许前端“当前所见”打印导出,禁止新增后端二进制 PDF 生成服务。 + +### 3.8 Feedback / Training / Prompt Registry +- 只读问答链路的 prompt 读取优先级固定为: + - prompt registry manifest 校验 + - active prompt release + - 代码 seed prompt fallback +- 回复质量迭代与训练管线的原始反馈入口统一复用 `feedback_center_records / qa_records / ai_call_logs`。 +- 训练/微调 artifact 必须先进入 `trained_model_artifacts -> trained_model_deployments` 候选链路,再绑定到 `low/high` 逻辑槽位。 +- training deployment 的 `accepted` 决策默认受 acceptance gate 约束。 + +## 4. 已知运行边界与假设 +- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用。 +- 真实飞书网页 E2E 依赖本机已登录的 Chrome/Feishu 会话。 +- 真实飞书网页 E2E 只负责浏览器发消息与页面诊断,结果校验统一走数据库探针。 +- 显式命名的新项目请求不得仅因正文出现历史项目标题就自动绑定已有 `project_id`。 +- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`。 +- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统。 +- `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送。 +- 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链。 +- 飞书长连接状态分为两层:`last_received_*` 表示 SDK 已收到事件,`last_event_at` 表示 worker 完成处理。 +- LLM 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则;请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准。 +- 路由配置唯一真源固定为 `backend/.openclaw/pmflow-routing-rules.json`。 +- World Model 静态层已接入 Redis 缓存(`wm:static:{tenant_id}:{project_id}`,TTL 600s),写操作后主动 invalidate,动态层不缓存。 +- 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启。 +- IT 五轮 requirement plan 现以 `MountedDraftWorkspace.active_work_item_id + latest_*_message_id` 锚定当前 active draft;历史未绑定这些字段的旧草稿不会自动认领。 + +## 5. 维护建议 +- 想看“必须立即遵守的规则”:先看 `AGENTS.md` +- 想看“当前架构说明和关键入口”:看 `CLAUDE.md` +- 想看“长运行边界、守门规则和已知环境假设”:看本文档 diff --git a/frontend/src/pages/model-routing-config/ModelRoutingConfigPage.tsx b/frontend/src/pages/model-routing-config/ModelRoutingConfigPage.tsx index 5be7beec..be638579 100644 --- a/frontend/src/pages/model-routing-config/ModelRoutingConfigPage.tsx +++ b/frontend/src/pages/model-routing-config/ModelRoutingConfigPage.tsx @@ -13,7 +13,7 @@ import { formatBeijingDateTime } from "@/lib/datetime"; import { toZhRoutingSource, toZhTierAlias } from "@/lib/display"; const TIER_OPTIONS = ["low", "high"] as const; -const FUNCTION_USAGE_HINTS: Record = { +const FUNCTION_USAGE_DESCRIPTIONS: Record = { adaptive_rag_answer_deep: "复杂问题的深度回答阶段:会做更细的推理和更完整的结论组织。", adaptive_rag_answer_normal: "常规问答阶段:基于已检索到的信息快速生成可读回复。", feishu_humanized_reply: "对外回复润色阶段:把结果改写成飞书里更自然、易读的表达。", @@ -25,7 +25,7 @@ const FUNCTION_USAGE_HINTS: Record = { }; function getFunctionUsageHint(functionName: string): string { - return FUNCTION_USAGE_HINTS[functionName] || "通用路由节点:用于该函数调用阶段的高低层选择。"; + return FUNCTION_USAGE_DESCRIPTIONS[functionName] || "通用路由节点:用于该函数调用阶段的高低层选择。"; } function describeLastExecution(item: ModelRoutingItem): string { diff --git a/scripts/ai_steward_pressure_coach.py b/scripts/ai_steward_pressure_coach.py index 405625de..559fdc26 100755 --- a/scripts/ai_steward_pressure_coach.py +++ b/scripts/ai_steward_pressure_coach.py @@ -34,7 +34,7 @@ DEFAULT_TEMPLATE_LEAK_PATTERNS = [ "后端模块规模", ] -ENGINE_QUERY_HINTS = [ +ENGINE_QUERY_MARKERS = [ "ai引擎", "openclaw", "调用链", @@ -42,7 +42,7 @@ ENGINE_QUERY_HINTS = [ "路由", ] -CORRECTION_HINTS = [ +CORRECTION_MARKERS = [ "不是", "不对", "错了", @@ -50,7 +50,7 @@ CORRECTION_HINTS = [ "纠正", ] -ARCHITECTURE_HINTS = [ +ARCHITECTURE_MARKERS = [ "系统架构", "技术架构", "前后端", @@ -324,16 +324,16 @@ def _is_engine_or_correction_question(question: str) -> bool: q = str(question or "").strip().lower() if not q: return False - return any(k in q for k in ENGINE_QUERY_HINTS) or any(k in q for k in CORRECTION_HINTS) + return any(k in q for k in ENGINE_QUERY_MARKERS) or any(k in q for k in CORRECTION_MARKERS) def _is_plain_architecture_question(question: str) -> bool: q = str(question or "").strip().lower() if not q: return False - has_arch = any(k in q for k in ARCHITECTURE_HINTS) - has_engine = any(k in q for k in ENGINE_QUERY_HINTS) - has_correction = any(k in q for k in CORRECTION_HINTS) + has_arch = any(k in q for k in ARCHITECTURE_MARKERS) + has_engine = any(k in q for k in ENGINE_QUERY_MARKERS) + has_correction = any(k in q for k in CORRECTION_MARKERS) if not has_arch: return False # “纠错 + 架构词”不算纯架构问法,比如:不是系统架构,我要AI引擎路由。 diff --git a/scripts/daily_task_report.sh b/scripts/daily_task_report.sh new file mode 100755 index 00000000..24d05e78 --- /dev/null +++ b/scripts/daily_task_report.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# 每日工单报表脚本 +# 用法: ./daily_task_report.sh [feishu_chat_id] + +set -e + +DATE_STR=$(date -v-1d +%Y-%m-%d 2>/dev/null || date -d "yesterday" +%Y-%m-%d) +CSV_FILE="/tmp/工单报表_${DATE_STR}.csv" +XLSX_FILE="/tmp/工单报表_${DATE_STR}.xlsx" +FEISHU_CHAT_ID="${1:-ou_b6d2d}" # 默认发送给用户 + +echo "=== 每日工单报表 $(date) ===" +echo "报表日期: ${DATE_STR}" + +# 1. 查询工单数据 +echo "1. 查询 ${DATE_STR} 工单数据..." +docker exec pmflow-db psql "postgresql://readonly:readonly%40%232025@173.1.1.32:5432/middle_platform" -c " +COPY ( + SELECT * FROM plat_task_center.task_instance + WHERE start_time >= DATE '${DATE_STR}' + AND start_time < DATE '${DATE_STR}' + INTERVAL '1 day' + AND ext3 IN ('90101', '901') + ORDER BY start_time DESC +) TO STDOUT WITH CSV HEADER +" > "$CSV_FILE" + +RECORD_COUNT=$(wc -l < "$CSV_FILE") +echo " 查询到 $((RECORD_COUNT - 1)) 条记录" + +# 2. 转换为 Excel +echo "2. 转换为 Excel..." +python3 << EOF +import pandas as pd +df = pd.read_csv('${CSV_FILE}') +df.to_excel('${XLSX_FILE}', index=False, engine='openpyxl') +print(f" 已生成: ${XLSX_FILE}") +EOF + +# 3. 发送到飞书 +echo "3. 发送到飞书..." +if command -v openclaw &> /dev/null; then + # 使用 OpenClaw 的 feishu 工具发送 + openclaw message send --chat "$FEISHU_CHAT_ID" --file "$XLSX_FILE" 2>/dev/null || \ + echo " 请手动配置飞书发送" +else + echo " 文件已生成: ${XLSX_FILE}" + echo " 需要手动发送到飞书" +fi + +echo "=== 完成 ===" +echo "文件路径: ${XLSX_FILE}"