Refine PMFlow workflows and strengthen backend integrations

This commit is contained in:
Yuuuu 2026-04-01 17:13:07 +08:00
parent 9ab2e14838
commit 5985f34a98
69 changed files with 4004 additions and 981 deletions

View File

@ -86,6 +86,7 @@ frontend/ # React 前端
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` - 前端状态管理:服务端数据用 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 抽取与结构化结果解析 - 业务语义治理:凡是会决定 `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 抽取与结构化结果解析
### 8.1 系统定位 ### 8.1 系统定位
@ -127,6 +128,8 @@ frontend/ # React 前端
- 生成层可以组织信息、给出建议,但不能直接改变业务状态。 - 生成层可以组织信息、给出建议,但不能直接改变业务状态。
- 生成层不能替代事实层,也不能伪装成执行结果。 - 生成层不能替代事实层,也不能伪装成执行结果。
- 生成层的目标是可交付,不是看起来完整。 - 生成层的目标是可交付,不是看起来完整。
- 核心业务生成字段必须能区分“真实生成 / 人工填写 / 模板占位 / 生成失败”,禁止只以字段非空作为完成态依据。
- 技术层 fallback 仅允许用于重试、缓存、解析修复、传输兼容等基础设施环节,禁止上浮为用户可见业务结论或完成态。
### 8.6 执行层约束 ### 8.6 执行层约束
@ -157,6 +160,7 @@ frontend/ # React 前端
- 禁止让知识图谱承担流程垃圾桶职责。 - 禁止让知识图谱承担流程垃圾桶职责。
- 禁止让任务包替代最终任务实体。 - 禁止让任务包替代最终任务实体。
- 禁止在无证据条件下输出确定性结论。 - 禁止在无证据条件下输出确定性结论。
- 禁止用通用模板、默认句子、占位内容把核心业务模块补满后冒充“已生成 / 已完成 / 已对齐”。
- 禁止绕过统一路由、统一审计和统一租户隔离。 - 禁止绕过统一路由、统一审计和统一租户隔离。
### 8.10 业务语义补充 ### 8.10 业务语义补充

View File

@ -83,7 +83,8 @@ backend/
│ ├── test_ai_prompt_registry_contract.py │ ├── test_ai_prompt_registry_contract.py
│ ├── test_model_routing_service.py │ ├── test_model_routing_service.py
│ ├── test_model_routing_migration.py │ ├── test_model_routing_migration.py
│ └── test_sstc_tasks_ai_breakdown_contract.py │ ├── test_sstc_tasks_ai_breakdown_contract.py
│ └── test_sstc_charter_release_contract.py
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
└── Dockerfile └── Dockerfile
``` ```
@ -98,7 +99,7 @@ backend/
- 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入) - 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入)
- 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`) - 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`)
- 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化) - 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化)
- 新增:`backend/app/services/signal_source_repair_service.py`(脏信号源回填服务) - 新增:`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_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/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/20260311_shift_all_timestamps_to_beijing.py`(全库历史时间 +8h,数据库默认时区切到 Asia/Shanghai)
@ -120,9 +121,9 @@ backend/
- 更新:`backend/app/config/ai_prompts_catalog/part_02.py`(`PROMPT_DRAFT_GENERATOR` 增加 active_projects / similar_projects / MVP / prospect / requirements 评审字段) - 更新:`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/api/tasks.py`(`/tasks/breakdown` 统一结构化任务业务对象输出,支持可选 `project_id/charter_id`,与 SSTC 宪章上下文和技能词表统一)
- 更新:`backend/app/schemas/common.py`(`TaskBreakdownRequest` 增加可选 `project_id/charter_id`) - 更新:`backend/app/schemas/common.py`(`TaskBreakdownRequest` 增加可选 `project_id/charter_id`)
- 更新:`backend/app/services/sstc/charter_data.py`(新增任务拆解上下文/技能词表/ prompt 拼装共享 helper) - 更新:`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_01.py`(`PROMPT_TASK_BREAKDOWN` 统一为结构化任务业务对象输出)
- 更新:`backend/app/config/ai_prompts_catalog/part_03.py`(`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/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_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_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_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派)
@ -149,6 +150,7 @@ backend/
- 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单) - 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单)
- 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级) - 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级)
- 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性) - 新增:`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/tests/test_prompt_profile_bundle_contract.py`(默认角色 prompt bundle 契约回归:PMFlow PMO 语气、职责边界与协作口径)
- 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入) - 新增:`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_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`)
@ -165,6 +167,7 @@ backend/
- 新增:`backend/tests/test_daily_briefing_service.py`(每日简报回归:北京时间 naive 时间窗、任务更新采集与空响应边界) - 新增:`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_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_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_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_service.py`(双层模型路由契约回归:`safety` 拒绝、runtime/query 一致、override 只存 `tier_alias`、`ai_call_logs` 仅记录 tier 级路由元信息)
- 新增:`backend/tests/test_model_routing_migration.py`(`model_routing` 迁移回归:旧函数名键重命名/删除后仍保留低高层路由语义) - 新增:`backend/tests/test_model_routing_migration.py`(`model_routing` 迁移回归:旧函数名键重命名/删除后仍保留低高层路由语义)
@ -509,7 +512,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行 - 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行
- 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite - 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite
- 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回
- 项目创建时自动生成的信号源必须落到现有 `SignalSourceRegistry`,前端仅允许在现有 AI Steward 创建结果区内联展示,禁止新增独立信号源页面或独立持久化模型 - `SignalSourceRegistry` 仅作为信号源接入/幂等内部表;用户可见的项目“信号源/信号”统一落到正式 `Signal`,SSTC 信号阶段只展示 `Signal`,禁止新增独立 registry 展示页面或第二套用户口径
- `create_project_from_natural_language` 支持透传 `requested_member_user_ids`;AI PMO 草稿确认时必须把推荐负责人并入项目创建事务,确保项目、主信号、信号源、成员关系一次性落库,禁止确认后再事后补成员 - `create_project_from_natural_language` 支持透传 `requested_member_user_ids`;AI PMO 草稿确认时必须把推荐负责人并入项目创建事务,确保项目、主信号、信号源、成员关系一次性落库,禁止确认后再事后补成员
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
@ -528,9 +531,17 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态 - AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态
- 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错,禁止用固定兜底文案冒充成功结果 - 所有 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 `adaptive_rag_answer_normal` 空响应必须显式抛错并交由 high failover 处理;禁止在生成层直接回落为“补充问题细节后重试”之类的兜底回复
- 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏 - 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏
- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG - AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG
- 飞书私聊中的“新业务/需求/MVP/方案/怎么做”类探索问题必须先由 `workflow_semantic_planner` 标记为 `message_intent=business_review`,再统一落到 `PROMPT_AI_PMO_QA` 的五大口径评审场景;禁止继续复用 `PROMPT_FEISHU_DIRECT_QA` 生成类似普通 RAG 直答的结果
- 飞书私聊中的“答错了/不对/调用高级模型/用更强模型/重新回答”必须被识别为 `correction_escalation` 指令:优先复用上一轮上下文主题并强制深度回答,禁止把这类话术当成新的系统配置查询
- “高级模型是否启用/模型路由是否启用/当前是否高配”这类运行态问题,只有命中运行态配置、路由快照、引擎配置或真实调用记录时才允许下结论;若证据只有页面目录、代码图谱或功能清单,回复必须明确为“无法仅凭静态资料确认”
- 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文 - 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文
- mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`,禁止把需求段落并入问答段落 - mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`,禁止把需求段落并入问答段落
- 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot/glm_semantic 快路径,统一落到场景化 Prompt 生成,避免旧确定性清单回复覆盖场景格式 - 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot/glm_semantic 快路径,统一落到场景化 Prompt 生成,避免旧确定性清单回复覆盖场景格式
@ -540,6 +551,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径 - AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径
- AI PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库;`project.create` 的 Feishu 文本入口先转待确认卡片,点击后才允许执行 - 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` 处理 - 草稿确认只认显式 `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 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账
- AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定 - 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 抽取与结构化结果解析 - 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 抽取与结构化结果解析
@ -556,8 +568,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py`(`POST /ai-steward/feedback-center/items/{id}/promote`、`/reply-iteration/judge-runs/candidates/prompt-releases/trials`) - 回复质量闭环接口:参考 `backend/app/api/ai_steward_api/routes_reply_iteration.py`(`POST /ai-steward/feedback-center/items/{id}/promote`、`/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`、`GET /training/jobs/{id}/evaluations`、`POST /training/artifacts/{id}/promote`、`POST /training/deployments/{id}/decision`) - 训练/微调管线接口:参考 `backend/app/api/ai_steward_api/routes_training_pipeline.py`(`GET/POST /ai-steward/training/datasets/jobs`、`GET /training/jobs/{id}/evaluations`、`POST /training/artifacts/{id}/promote`、`POST /training/deployments/{id}/decision`)
- 任务包中心接口:参考 `backend/app/api/task_briefs.py`(`/task-briefs/center/templates/runs/generate/contract-sync`) - 任务包中心接口:参考 `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`) - 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` 持久化) - 需求点落信号源:参考 `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/audit.py`(`GET /audit/operations`)
- 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`) - 脏数据修复接口:参考 `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`,仅管理员可修改本地知识库问答开关) - LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关)
@ -593,7 +605,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 通用任务拆解接口(`POST /tasks/breakdown`)统一返回结构化任务业务对象,支持 `project_id/charter_id` 上下文与 Plane 同步 - 通用任务拆解接口(`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}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本
- SSTC 宪章详情接口(`GET /projects/{project_id}/charters/{charter_id}`)`alignment_records[]` 补充 `user_name/confirmed_at`,用于前端展示“对齐成员 + 对齐时间” - 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 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 负责人推荐接口(`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 任务更新接口(`PATCH /projects/{project_id}/tasks/{task_id}`)会把人工修改写入 `manual_overrides`,供后续 AI 重跑识别覆盖层
- SSTC 发布创建接口(`POST /projects/{project_id}/releases`)新增已关联任务拦截:若 `task_ids` 命中历史发布计划已绑定任务,返回 `409` + `detail.task_ids` - SSTC 发布创建接口(`POST /projects/{project_id}/releases`)新增已关联任务拦截:若 `task_ids` 命中历史发布计划已绑定任务,返回 `409` + `detail.task_ids`
@ -610,7 +624,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 11. 已知 BOUNDARY(不要幻构行为) ## 11. 已知 BOUNDARY(不要幻构行为)
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry` - 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`,`SignalSourceRegistry` 继续只承担内部持久化与幂等,不单独上屏
- 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标 - 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标
- 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送 - 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送
- `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送;导出仅复用浏览器打印能力 - `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送;导出仅复用浏览器打印能力
@ -644,11 +658,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间 - 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间
- 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义 - 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义
- `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐 - `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐
- 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录 - 历史脏信号源修复优先回填同项目已存在正式 `Signal`(优先按 `source/source_identifier`,再按标题/摘要近似匹配);仅无法复用时才直接物化新的正式 `Signal`,不再触发 `capture-feishu` 补录
- 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败 - 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual` - `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`
- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试 - 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试
- 飞书会话与问答上下文键已改为线程/消息锚点优先:优先 `reply_to_message_id/root_message_id/thread_id/source_message_id`,避免 sender 级最近会话复用串线;草稿确认还需要回查 `feishu_reply_outbox.sent_message_id`,以便把“回复草稿消息”的线程锚点映射回对应 work_item - 飞书会话与问答上下文键已改为“显式线程锚点优先 + 私聊稳定 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/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 与控制台一致 - 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` - 飞书回复线程锚点抽取同时兼容 event 顶层与 message/content 内字段,并对 event/message 的嵌套结构做递归兜底;若 Feishu SDK 返回的 reply 关系字段在更深层对象里,也必须纳入线程锚点,不可只读 `message.content`
@ -693,11 +707,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定 - [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定
- [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] 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] AI 管家浮窗交互已与 Grounding Runtime 对齐:回答区已切到 markdown 渲染,顾问标签升级为显式交互按钮;`consultant_mode_preference` 只用于 `/rag/ask` 内部重答,`允许执行` 明确交接到 `/ai-pmo/process-text`,避免 query/execute 边界回退到旧的一锅煮
- [ ] Retrieval source registry / metadata 统一仍在技术债:当前 `doc/url`、`feishu`、`pmflow`、`graph/db` 继续保留异构 metadata,`citations[]/sources_used[]` 先由 response-layer 兼容适配器统一;后续仍需补齐原生 `source_id/chunk_id/permission_scope/updated_at`、统一 source priority 配置并为 reranker 预留接口 - [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` 锁定
- [ ] 语义治理 baseline 尚未清零:`scripts/semantic_lint_baseline.txt` 仍豁免 `backend/app/services/integrations/feishu_signal_pipeline/shared_core.py`、`backend/app/services/signal/signal_capture_service/shared.py`、`backend/app/api/ai_pmo_api/shared.py` 中的 `*_HINTS/*_RE` 规则常量;`backend/tests/test_semantic_lint.py` 目前只阻止新增散落语义,尚未推动存量归零。后续需把这些兼容残留迁回 `workflow_semantic_planner` 或 prompt catalog,并逐项删除 baseline - [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` 做仓库级守门
- [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未退场:`FeishuSignalPipelineService` 已不再承担线上主入口,但离线回放、单测基座、`ai_logs/project_knowledge_graph` 历史过滤,以及 Alembic / 启动自检仍依赖 `feishu_items`。后续需先迁走历史读链路与回放基座,再为该链路补明确的 deprecated / 下线计划 - [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未完全退场:飞书真实入口、卡片动作、文本 queue followup,以及 `project_knowledge_graph` 的主增量/API 命名都已切到 `work_item` 主链;当前剩余债务主要是 `feishu_items` 存量读链路、历史 pending 事件兼容消费,以及 legacy `feishu_delta` 证据/回放兼容壳。后续需在历史存量清空后正式封存该兼容层
- [ ] AdaptiveRAG 向量检索仍保留 legacy fallback:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 在非 PostgreSQL 或 PG 查询失败时会回退 `_retrieve_vector_context_legacy()`,对 `knowledge_base_chunks` 做 Python 侧扫描与打分;这使 pgvector 主路径与 legacy 主路径长期双轨。后续需决定是正式声明“只支持 PostgreSQL/pgvector”,还是补齐统一 retrieval contract 与测试矩阵 - [x] AdaptiveRAG 向量检索 legacy fallback 已移除:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 现在只支持 PostgreSQL/pgvector 主路径;非 PostgreSQL 或 PG 查询失败时直接返回空结果,不再回退 Python 侧 legacy 扫描,边界由 `test_adaptive_rag_mainline_contract.py` 锁定
- [ ] 历史 Alembic 链幂等性缺陷仍未修复:仓库里已确认 `add_users_table_rbac.py` 在 PostgreSQL 可能重复创建 `userrole` ENUM;当前运行时仍通过直接 `uvicorn` 启动 + `AUTO_CREATE_TABLES=true` 规避全量 upgrade。后续需修补旧 migration,保证 PostgreSQL 上的 Alembic upgrade 可重复执行 - [x] 历史 Alembic 链幂等性缺陷已修复:`add_users_table_rbac.py` 的 PostgreSQL ENUM 创建已改为显式创建 + 列类型 `create_type=False` 复用,`userrole/userstatus` 不再在同一条 migration 内重复建型;回归由 `test_add_users_table_rbac_migration.py` 锁定
- [ ] 食谱图片上传仍是示例桩实现:`backend/app/services/recipe/service.py` 仍以模拟 URL 代替对象存储,`POST /recipes/upload-image` 也还停留在示例接口形态;若该模块继续保留,需要补齐真实对象存储接入、租户隔离与统一 `{code,message,data}` 响应包络,否则应明确标注为非核心示例模块 - [ ] 食谱图片上传仍是示例桩实现:`backend/app/services/recipe/service.py` 仍以模拟 URL 代替对象存储,`POST /recipes/upload-image` 也还停留在示例接口形态;若该模块继续保留,需要补齐真实对象存储接入、租户隔离与统一 `{code,message,data}` 响应包络,否则应明确标注为非核心示例模块
- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定 - [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定
- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线 - [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线

View File

@ -20,11 +20,15 @@ depends_on = None
def upgrade(): def upgrade():
bind = op.get_bind() bind = op.get_bind()
is_sqlite = bind.dialect.name == 'sqlite' is_sqlite = bind.dialect.name == 'sqlite'
role_column_type = sa.String(50)
status_column_type = sa.String(50)
# Create enums (only for Postgres) # Create enums (only for Postgres)
if not is_sqlite: if not is_sqlite:
user_role_enum = ENUM('BOSS', 'ADMIN', 'AI', 'FE', 'BE', name='userrole', create_type=True) user_role_enum = ENUM('BOSS', 'ADMIN', 'AI', 'FE', 'BE', name='userrole', create_type=True)
user_status_enum = ENUM('active', 'away', 'offline', name='userstatus', create_type=True) user_status_enum = ENUM('active', 'away', 'offline', name='userstatus', create_type=True)
role_column_type = ENUM('BOSS', 'ADMIN', 'AI', 'FE', 'BE', name='userrole', create_type=False)
status_column_type = ENUM('active', 'away', 'offline', name='userstatus', create_type=False)
user_role_enum.create(bind, checkfirst=True) user_role_enum.create(bind, checkfirst=True)
user_status_enum.create(bind, checkfirst=True) user_status_enum.create(bind, checkfirst=True)
@ -37,10 +41,10 @@ def upgrade():
sa.Column('password_hash', sa.String(255), nullable=False), sa.Column('password_hash', sa.String(255), nullable=False),
sa.Column('name', sa.String(100), nullable=False), sa.Column('name', sa.String(100), nullable=False),
sa.Column('email', sa.String(255), unique=True, nullable=True), sa.Column('email', sa.String(255), unique=True, nullable=True),
sa.Column('role', sa.String(50) if is_sqlite else sa.Enum('BOSS', 'ADMIN', 'AI', 'FE', 'BE', name='userrole'), nullable=False, server_default='FE'), sa.Column('role', role_column_type, nullable=False, server_default='FE'),
sa.Column('assigned_domains', sa.JSON(), nullable=True, server_default='[]'), sa.Column('assigned_domains', sa.JSON(), nullable=True, server_default='[]'),
sa.Column('workload', sa.Integer(), nullable=False, server_default='0'), sa.Column('workload', sa.Integer(), nullable=False, server_default='0'),
sa.Column('status', sa.String(50) if is_sqlite else sa.Enum('active', 'away', 'offline', name='userstatus'), nullable=False, server_default='active'), sa.Column('status', status_column_type, nullable=False, server_default='active'),
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
) )

View File

@ -2,7 +2,6 @@ from __future__ import annotations
import asyncio import asyncio
from datetime import datetime from datetime import datetime
import re
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
@ -18,31 +17,16 @@ from ...dependencies.tenant import get_tenant_id
from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem
from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService
from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService
from ...services.ai_pmo.workflow_semantic_planner.constants import (
FRONT_NOISE_HINTS,
FRONT_NOISE_STRUCTURED_HINTS,
GENERIC_SIGNAL_SOURCE_MESSAGE_RE as GENERIC_SOURCE_TEXT_RE,
INTENT_QUERY,
LOW_VALUE_CLOSURE_HINTS,
ROUTE_QUERY_ANSWER,
SMALLTALK_EXACT as FRONT_NOISE_EXACT,
)
router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"])
logger = get_logger(__name__) logger = get_logger(__name__)
FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"} FRONT_NOISE_INTENTS = {INTENT_QUERY, ROUTE_QUERY_ANSWER}
FRONT_NOISE_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"yes",
"no",
"谢谢",
"thanks",
"thx",
"在吗",
"在不在",
"怎么样了",
"咋样了",
"辛苦了",
}
FRONT_NOISE_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "ok", "谢谢", "收到")
FRONT_NOISE_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
GENERIC_SOURCE_TEXT_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE,
)
LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建")

View File

@ -96,7 +96,6 @@ def _card_to_event_payload(card: Card) -> dict[str, Any]:
action_payload["tag"] = card.action.tag action_payload["tag"] = card.action.tag
work_item_id = action_value.get("work_item_id") or action_value.get("target_work_item_id") or action_value.get("workItemId") work_item_id = action_value.get("work_item_id") or action_value.get("target_work_item_id") or action_value.get("workItemId")
queue_item_id = action_value.get("queue_item_id") or action_value.get("item_id") or action_value.get("queueItemId")
draft_version = action_value.get("draft_version") or action_value.get("version") draft_version = action_value.get("draft_version") or action_value.get("version")
revision_note = action_value.get("revision_note") or action_value.get("note") or action_value.get("comment") revision_note = action_value.get("revision_note") or action_value.get("note") or action_value.get("comment")
@ -119,8 +118,6 @@ def _card_to_event_payload(card: Card) -> dict[str, Any]:
} }
if work_item_id is not None: if work_item_id is not None:
payload["work_item_id"] = work_item_id payload["work_item_id"] = work_item_id
if queue_item_id is not None:
payload["queue_item_id"] = queue_item_id
if draft_version is not None: if draft_version is not None:
payload["draft_version"] = draft_version payload["draft_version"] = draft_version
if revision_note is not None: if revision_note is not None:
@ -193,7 +190,6 @@ def _build_action_result_payload(result: dict[str, Any]) -> dict[str, Any]:
for label, key in ( for label, key in (
("草稿ID", "target_work_item_id"), ("草稿ID", "target_work_item_id"),
("工作项ID", "work_item_id"), ("工作项ID", "work_item_id"),
("队列ID", "queue_item_id"),
("项目ID", "project_id"), ("项目ID", "project_id"),
("任务ID", "task_id"), ("任务ID", "task_id"),
("信号ID", "signal_id"), ("信号ID", "signal_id"),

View File

@ -29,7 +29,7 @@ class CodeScanRequest(BaseModel):
base_dir: Optional[str] = Field(default=None, max_length=400) base_dir: Optional[str] = Field(default=None, max_length=400)
class FeishuDeltaEnqueueRequest(BaseModel): class WorkItemDeltaEnqueueRequest(BaseModel):
limit: int = Field(default=500, ge=1, le=2000) limit: int = Field(default=500, ge=1, le=2000)
@ -165,16 +165,29 @@ async def sync_codebase(
return success_response(data) return success_response(data)
@router.post("/sync/feishu") @router.post("/sync/work-items")
async def enqueue_feishu_delta( async def enqueue_work_item_delta(
body: FeishuDeltaEnqueueRequest, body: WorkItemDeltaEnqueueRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id), tenant_id: int = Depends(get_tenant_id),
): ):
_require_manager(current_user) _require_manager(current_user)
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id)) service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
data = await service.enqueue_feishu_delta_events(limit=int(body.limit)) data = await service.enqueue_work_item_delta_events(limit=int(body.limit))
return success_response(data)
@router.post("/sync/feishu")
async def enqueue_feishu_delta_compat(
body: WorkItemDeltaEnqueueRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
tenant_id: int = Depends(get_tenant_id),
):
_require_manager(current_user)
service = ProjectKnowledgeGraphService(db, tenant_id=int(getattr(current_user, "tenant_id", tenant_id) or tenant_id))
data = await service.enqueue_work_item_delta_events(limit=int(body.limit))
return success_response(data) return success_response(data)

View File

@ -163,6 +163,18 @@ async def confirm_alignment(
svc = SSTCService(db, tenant_id=tenant_id) svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid) await _ensure_project(svc, pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
charter_content = _as_dict(charter.content)
readiness = charter_content.get("readiness")
if not isinstance(readiness, dict):
readiness = {}
if body.confirmed and readiness.get("ready_for_confirm") is not True:
blocking_fields = [str(item or "").strip() for item in readiness.get("blocking_fields") or [] if str(item or "").strip()]
blocking_reasons = [str(item or "").strip() for item in readiness.get("blocking_reasons") or [] if str(item or "").strip()]
detail_parts = blocking_reasons + blocking_fields
raise HTTPException(
status_code=409,
detail="宪章未通过总确认校验" if not detail_parts else ";".join(detail_parts),
)
row_result = await db.execute( row_result = await db.execute(
select(AlignmentRecord).where( select(AlignmentRecord).where(

View File

@ -80,28 +80,43 @@ async def generate_release_verification(
charter_id=charter_id, charter_id=charter_id,
) )
charter_content = _as_dict(charter.content) charter_content = _as_dict(charter.content)
success_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or [] derived_artifacts = charter_content.get("derived_artifacts")
if not success_metrics: if not isinstance(derived_artifacts, dict):
success_metrics = ["功能验收通过", "核心流程无阻塞", "关键性能指标达标"] derived_artifacts = {}
verification_metrics = derived_artifacts.get("verification_metrics")
if not isinstance(verification_metrics, list):
verification_metrics = []
if not verification_metrics:
fallback_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or []
verification_metrics = [
{"name": str(metric).strip(), "expected": str(metric).strip()}
for metric in fallback_metrics
if str(metric or "").strip()
]
if not verification_metrics:
raise HTTPException(status_code=409, detail="charter verification metrics 缺失,请先完善宪章 KPI")
items = [ items = [
{ {
"id": f"check-{i+1}", "id": f"check-{i+1}",
"title": str(metric), "title": str(metric.get("name") or metric.get("title") or "").strip(),
"expected": "达到预期", "expected": str(metric.get("expected") or metric.get("name") or metric.get("title") or "").strip(),
"status": "pending", "status": "pending",
"required": True, "required": True,
"source": "charter_success_metric", "source": "charter_verification_metric",
"verified_by": None, "verified_by": None,
"verified_at": None, "verified_at": None,
"note": None, "note": None,
} }
for i, metric in enumerate(success_metrics) for i, metric in enumerate(verification_metrics)
if isinstance(metric, dict) and str(metric.get("name") or metric.get("title") or "").strip()
] ]
if not items:
raise HTTPException(status_code=409, detail="charter verification metrics 缺失,请先完善宪章 KPI")
content["verification_items"] = items content["verification_items"] = items
content["verification_progress"] = {"done": 0, "total": len(items)} content["verification_progress"] = {"done": 0, "total": len(items)}
content["verification_done"] = False content["verification_done"] = False
release_node.content = content release_node.content = content
await db.commit() await db.commit()
return success_response({"release_id": str(release_node.id), "verification_items": items}) return success_response({"release_id": str(release_node.id), "verification_items": items})

View File

@ -4,6 +4,11 @@ from .shared import *
from ...services.sstc.entity_lookup import _ensure_project from ...services.sstc.entity_lookup import _ensure_project
from ...services.sstc.feishu_routing import _backfill_feishu_sender_names from ...services.sstc.feishu_routing import _backfill_feishu_sender_names
from ...services.sstc.shared import (
_SIGNAL_LOCK_REASON,
_SIGNAL_STATUS_CODE_CLOSED,
_SIGNAL_STATUS_CODE_LINKED,
)
from ...services.sstc.signal_core import _as_dict, _extract_signal_attachments, _normalize_planner_status, _normalize_signal_status_code, _parse_project_id, _signal_ai_analysis, _signal_display_type, _signal_project_scope, _signal_source_context, _signal_status_label_from_code from ...services.sstc.signal_core import _as_dict, _extract_signal_attachments, _normalize_planner_status, _normalize_signal_status_code, _parse_project_id, _signal_ai_analysis, _signal_display_type, _signal_project_scope, _signal_source_context, _signal_status_label_from_code
from ...services.sstc.signal_filters import _build_signal_created_at_range, _extract_charter_related_signal_ids, _match_signal_filters, _normalize_signal_status_filter, _signal_proposer_name, _signal_title_text from ...services.sstc.signal_filters import _build_signal_created_at_range, _extract_charter_related_signal_ids, _match_signal_filters, _normalize_signal_status_filter, _signal_proposer_name, _signal_title_text
@ -142,4 +147,3 @@ async def list_signals(
"has_more": (offset + len(paged_rows)) < total, "has_more": (offset + len(paged_rows)) < total,
} }
) )

View File

@ -362,7 +362,7 @@ def _part_03_entries() -> tuple[dict[str, Any], ...]:
module="part_03", module="part_03",
business_scenario="宪章草案生成", business_scenario="宪章草案生成",
input_contract="输入 request / project / active_projects / similar_projects / signal_context。", input_contract="输入 request / project / active_projects / similar_projects / signal_context。",
output_contract="输出 JSON 对象,包含项目宪章草案与风险。", output_contract="输出 JSON 对象,包含 modules / field-level evidence / readiness / derived_artifacts。",
call_sites=("backend/app/services/ai_service_core/mixin_business_tasks.py", "backend/app/services/sstc/charter_prefill_utils.py"), call_sites=("backend/app/services/ai_service_core/mixin_business_tasks.py", "backend/app/services/sstc/charter_prefill_utils.py"),
), ),
_entry( _entry(

View File

@ -112,24 +112,66 @@ PROMPT_GENERATE_CHARTER = _compose_prompt(
请输出 JSON,包含: 请输出 JSON,包含:
{{ {{
"title": "需求宪章标题",
"summary": "一句话摘要", "summary": "一句话摘要",
"background": "背景与动机", "background": "背景与动机",
"objectives": ["目标1", "目标2"],
"scope": "范围说明", "scope": "范围说明",
"success_criteria": ["标准1", "标准2"], "confidence_score": 0.0,
"risks": ["风险1", "风险2"], "modules": {{
"dependencies": ["依赖1", "依赖2"], "module_1": {{
"mvp_items": ["MVP项1", "MVP项2"], "problem_statement": {{"value": "", "evidence": [{{"signal_id": 0, "summary": "", "quote": "", "reason": "", "confidence": 0.0}}], "status": "valid|evidence_insufficient|generation_failed|generic_text|conflict_blocked"}},
"owner_suggestion": "负责人建议", "goal_statement": {{"value": "", "evidence": []}},
"followup_questions": ["待确认问题1", "待确认问题2"] "kpis": {{"value": "", "evidence": []}}
}},
"module_2": {{
"in_scope": {{"value": "", "evidence": []}},
"out_of_scope": {{"value": "", "evidence": []}},
"alignment_qa": {{"value": "", "evidence": []}}
}},
"module_3": {{
"primary_model": {{"value": "", "evidence": []}},
"fallback_model": {{"value": "", "evidence": []}},
"capability_table": {{"value": "", "evidence": []}},
"dependency_map": {{"value": "", "evidence": []}},
"business_view": {{"value": "", "evidence": []}},
"engineering_view": {{"value": "", "evidence": []}}
}},
"module_4": {{
"milestones": {{"value": "", "evidence": []}},
"role_effort_estimates": {{"value": "", "evidence": []}},
"capability_owner_assignment": {{"value": "", "evidence": []}},
"buffer_policy": {{"value": "", "evidence": []}}
}},
"module_5": {{
"risk_register": {{"value": "", "evidence": []}},
"trigger_rules": {{"value": "", "evidence": []}},
"execution_rule": {{"value": "", "evidence": []}}
}},
"module_6": {{
"attachment_metadata": {{"value": "", "evidence": []}},
"attachment_summary": {{"value": "", "evidence": []}},
"search_tags": {{"value": "", "evidence": []}},
"governance_rule": {{"value": "", "evidence": []}}
}}
}},
"readiness": {{
"ready_for_confirm": false,
"blocking_fields": ["module_1.kpis"],
"blocking_reasons": ["关键 KPI 缺少证据"]
}},
"derived_artifacts": {{
"task_seeds": [{{"title": "", "description": "", "evidence_refs": ["module_4.milestones"]}}],
"verification_metrics": [{{"name": "", "expected": "", "source_field": "module_1.kpis"}}],
"risk_gates": [{{"name": "", "action": "", "source_fields": ["module_5.trigger_rules"]}}]
}}
}} }}
约束: 约束:
1. 只围绕 PMFlow 的项目立项、复用、合并与宪章起草目标输出。 1. 只围绕 PMFlow 的项目立项、复用、合并与宪章起草目标输出。
2. 若信息不足,followup_questions 最多 3 条。 2. 必填核心字段缺少证据时,value 可为空,但必须显式标注 status,禁止用模板文案补齐。
3. MVP 优先最小可验证闭环,不要写空泛愿景。 3. evidence 必须优先引用 signal_id;没有证据时不要伪造引用。
4. 输出必须可直接被 json.loads 解析。 4. alignment_qa 必须是 5 个固定对齐问答的完整文本;无法生成时显式阻断 readiness。
5. verification_metrics、task_seeds、risk_gates 必须能被下游执行或校验直接消费。
6. 输出必须可直接被 json.loads 解析。
""", """,
JSON_OUTPUT_GUARDRAIL, JSON_OUTPUT_GUARDRAIL,
) )

View File

@ -12,12 +12,27 @@ PROMPT_AI_PMO_QA = _compose_prompt(
2. 问题涉及操作时先回答再提示可用指令。 2. 问题涉及操作时先回答再提示可用指令。
3. 只基于给定上下文回答,不要编造系统状态。 3. 只基于给定上下文回答,不要编造系统状态。
4. 禁止输出思考过程。 4. 禁止输出思考过程。
5. 回答后附可操作建议: 5. 若 `business_review_mode=true`,必须按“五大口径”组织回复:
- 事实源在哪
- 输出模板在哪
- 检索范围在哪
- 执行器在哪
- 反馈回流在哪
6. 若信息不足,不允许输出伪结论;必须指出五大口径里缺失的关键项,并最多补 3 个澄清问题。
7. 若 `correction_mode=true`,这是“纠错升级重答”,必须复用当前对话与上下文主题,直接给出修正答案;若缺少可复用锚点,要明确说明“找不到可重答的上一轮问题”。
8. 对“高级模型是否启用 / 模型路由是否启用 / 当前是否高配”这类运行态问题,只有拿到运行态配置、路由快照、引擎配置或真实调用记录时,才允许下启用/未启用结论。
9. 如果证据只有页面目录、代码图谱、功能清单等静态资料,只能回答“代码里存在该能力入口,但当前启用状态无法仅凭静态资料确认”,不得冒充运行态结论。
10. 回答后附可操作建议:
"你可以继续:[操作A]/[操作B]" "你可以继续:[操作A]/[操作B]"
问题:{question} 问题:{question}
场景:{scene_name}
business_review_mode={business_review_mode}
review_framework={review_framework}
correction_mode={correction_mode}
当前项目:{project_id} 当前项目:{project_id}
World Model:{world_model} World Model:{world_model}
近期对话:{conversation}
检索上下文:{context_blocks} 检索上下文:{context_blocks}
""".strip(), """.strip(),
TEXT_OUTPUT_GUARDRAIL, TEXT_OUTPUT_GUARDRAIL,

View File

@ -5,6 +5,16 @@ from .shared import *
class AdaptiveRAGAnswerPayloadMixin: class AdaptiveRAGAnswerPayloadMixin:
_SOURCE_PRIORITY_MAP: dict[str, int] = {
"pmflow": 100,
"db": 90,
"graph": 80,
"doc": 70,
"url": 65,
"feishu": 60,
"system": 10,
}
@staticmethod @staticmethod
def _safe_source_metadata(raw: Any) -> dict[str, Any]: def _safe_source_metadata(raw: Any) -> dict[str, Any]:
return raw if isinstance(raw, dict) else {} return raw if isinstance(raw, dict) else {}
@ -27,6 +37,184 @@ class AdaptiveRAGAnswerPayloadMixin:
text = str(value).strip() text = str(value).strip()
return text or None return text or None
@staticmethod
def _isoformat_or_none(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
try:
return value.isoformat()
except Exception:
return None
text = str(value).strip()
return text or None
def _normalize_source_type(self, value: Any) -> str:
raw = str(self._source_ref_value(value) or "").strip().lower()
return raw or "unknown"
def _resolve_source_id(
self,
*,
source_type: str,
metadata: dict[str, Any],
explicit_source_id: Any = None,
) -> Any:
resolved = self._source_ref_value(explicit_source_id or metadata.get("source_id"))
if resolved is not None:
return resolved
if source_type == "pmflow":
record_type = self._source_ref_value(metadata.get("record_type"))
record_id = self._source_ref_value(metadata.get("record_id"))
if record_type and record_id is not None:
return f"{record_type}:{record_id}"
return record_id
if source_type in {"doc", "url"}:
return self._source_ref_value(
metadata.get("doc_id")
or metadata.get("knowledge_doc_id")
or metadata.get("doc_url")
or metadata.get("file_url")
or metadata.get("url")
)
if source_type == "feishu":
return self._source_ref_value(
metadata.get("message_id")
or metadata.get("attachment_id")
or metadata.get("file_key")
or metadata.get("key")
or metadata.get("chat_id")
)
if source_type == "graph":
relation_id = self._source_ref_value(metadata.get("relation_id"))
if relation_id is not None:
return f"relation:{relation_id}"
entity_id = self._source_ref_value(metadata.get("entity_id"))
if entity_id is not None:
return f"entity:{entity_id}"
return self._source_ref_value(
metadata.get("path")
or metadata.get("source_ref")
or metadata.get("subject_entity_id")
)
if source_type == "db":
table = self._source_ref_value(metadata.get("table"))
if table is None:
return None
project_id = self._source_ref_value(metadata.get("project_id"))
if project_id is not None:
return f"{table}:{project_id}"
return table
return self._source_ref_value(
metadata.get("doc_id")
or metadata.get("knowledge_doc_id")
or metadata.get("message_id")
or metadata.get("url")
or metadata.get("table")
or metadata.get("matched_project_ids")
)
def _resolve_chunk_id(self, metadata: dict[str, Any], *, explicit_chunk_id: Any = None) -> Any:
return self._source_ref_value(explicit_chunk_id or metadata.get("chunk_id") or metadata.get("chunk_index"))
def _resolve_target_url(
self,
*,
source_type: str,
metadata: dict[str, Any],
explicit_target_url: Any = None,
project_id: Any = None,
) -> Any:
target_url = self._source_ref_value(
explicit_target_url
or metadata.get("target_url")
or metadata.get("doc_url")
or metadata.get("file_url")
or metadata.get("url")
or metadata.get("permalink")
)
if target_url is not None:
return target_url
if source_type != "pmflow" or not hasattr(self, "_build_pmflow_target_url"):
return None
try:
return self._source_ref_value(
self._build_pmflow_target_url(
record_type=str(metadata.get("record_type") or ""),
record_id=metadata.get("record_id"),
project_id=project_id if project_id is not None else metadata.get("project_id"),
)
)
except Exception:
return None
def _resolve_updated_at(self, metadata: dict[str, Any], *, explicit_updated_at: Any = None) -> Any:
return self._source_ref_value(
self._isoformat_or_none(explicit_updated_at)
or metadata.get("updated_at")
or metadata.get("doc_updated_at")
or metadata.get("source_updated_at")
or metadata.get("captured_at")
or metadata.get("last_seen_at")
or metadata.get("last_fetched_at")
)
def _resolve_permission_scope(self, metadata: dict[str, Any], *, project_id: Any = None) -> str:
explicit_scope = str(self._source_ref_value(metadata.get("permission_scope")) or "").strip().lower()
if explicit_scope in {"project", "tenant"}:
return explicit_scope
resolved_project_id = self._source_ref_value(project_id if project_id is not None else metadata.get("project_id"))
return "project" if resolved_project_id is not None else "tenant"
def _resolve_source_priority(self, metadata: dict[str, Any], *, source_type: str) -> int:
explicit_priority = metadata.get("source_priority")
if explicit_priority is not None:
try:
return int(explicit_priority)
except Exception:
pass
return int(self._SOURCE_PRIORITY_MAP.get(source_type, 50))
def _normalize_source_registry_metadata(
self,
*,
source_type: Any,
metadata: Any,
explicit_source_id: Any = None,
explicit_chunk_id: Any = None,
explicit_target_url: Any = None,
explicit_project_id: Any = None,
explicit_updated_at: Any = None,
) -> dict[str, Any]:
normalized = self._safe_source_metadata(metadata).copy()
resolved_source_type = self._normalize_source_type(source_type or normalized.get("source_type"))
resolved_project_id = self._source_ref_value(
explicit_project_id if explicit_project_id is not None else normalized.get("project_id")
)
normalized["source_type"] = resolved_source_type
normalized["source_id"] = self._resolve_source_id(
source_type=resolved_source_type,
metadata=normalized,
explicit_source_id=explicit_source_id,
)
normalized["chunk_id"] = self._resolve_chunk_id(normalized, explicit_chunk_id=explicit_chunk_id)
normalized["target_url"] = self._resolve_target_url(
source_type=resolved_source_type,
metadata=normalized,
explicit_target_url=explicit_target_url,
project_id=resolved_project_id,
)
normalized["project_id"] = resolved_project_id
normalized["updated_at"] = self._resolve_updated_at(normalized, explicit_updated_at=explicit_updated_at)
normalized["permission_scope"] = self._resolve_permission_scope(normalized, project_id=resolved_project_id)
normalized["source_priority"] = self._resolve_source_priority(normalized, source_type=resolved_source_type)
return normalized
def _source_priority_sort_value(self, metadata: Any) -> int:
normalized = self._safe_source_metadata(metadata)
source_type = self._normalize_source_type(normalized.get("source_type"))
return self._resolve_source_priority(normalized, source_type=source_type)
@staticmethod @staticmethod
def _normalize_answer_mode(value: Optional[str]) -> str: def _normalize_answer_mode(value: Optional[str]) -> str:
raw = str(value or "").strip().lower() raw = str(value or "").strip().lower()
@ -90,36 +278,17 @@ class AdaptiveRAGAnswerPayloadMixin:
for item in (sources or [])[:8]: for item in (sources or [])[:8]:
if not isinstance(item, dict): if not isinstance(item, dict):
continue continue
metadata = self._safe_source_metadata(item.get("metadata")) metadata = self._normalize_source_registry_metadata(
source_type = self._source_ref_value( source_type=item.get("source"),
metadata.get("source_type") metadata=item.get("metadata"),
or item.get("source")
or metadata.get("table")
or metadata.get("mode")
) )
source_type = self._source_ref_value(metadata.get("source_type"))
source_label = self._source_ref_value(item.get("label") or metadata.get("source_label")) source_label = self._source_ref_value(item.get("label") or metadata.get("source_label"))
source_id = self._source_ref_value( source_id = self._source_ref_value(metadata.get("source_id"))
metadata.get("source_id") chunk_id = self._source_ref_value(metadata.get("chunk_id"))
or metadata.get("doc_id") target_url = self._source_ref_value(metadata.get("target_url"))
or metadata.get("knowledge_doc_id")
or metadata.get("message_id")
or metadata.get("url")
or metadata.get("table")
or metadata.get("matched_project_ids")
)
chunk_id = self._source_ref_value(metadata.get("chunk_id") or metadata.get("chunk_index"))
target_url = self._source_ref_value(
metadata.get("target_url")
or metadata.get("doc_url")
or metadata.get("url")
or metadata.get("permalink")
)
project_id = self._source_ref_value(metadata.get("project_id")) project_id = self._source_ref_value(metadata.get("project_id"))
updated_at = self._source_ref_value( updated_at = self._source_ref_value(metadata.get("updated_at"))
metadata.get("updated_at")
or metadata.get("doc_updated_at")
or metadata.get("source_updated_at")
)
citation = { citation = {
"source_type": source_type, "source_type": source_type,
"source_label": source_label, "source_label": source_label,
@ -128,6 +297,8 @@ class AdaptiveRAGAnswerPayloadMixin:
"target_url": target_url, "target_url": target_url,
"project_id": project_id, "project_id": project_id,
"updated_at": updated_at, "updated_at": updated_at,
"permission_scope": self._source_ref_value(metadata.get("permission_scope")),
"source_priority": metadata.get("source_priority"),
"confidence": round(self._to_float(item.get("score"), 0.0), 4), "confidence": round(self._to_float(item.get("score"), 0.0), 4),
} }
key = ( key = (
@ -157,6 +328,8 @@ class AdaptiveRAGAnswerPayloadMixin:
"target_url": item.get("target_url"), "target_url": item.get("target_url"),
"project_id": item.get("project_id"), "project_id": item.get("project_id"),
"updated_at": item.get("updated_at"), "updated_at": item.get("updated_at"),
"permission_scope": item.get("permission_scope"),
"source_priority": item.get("source_priority"),
} }
) )
return rows return rows

View File

@ -218,6 +218,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
consultant_mode_preference: Optional[str] = None, consultant_mode_preference: Optional[str] = None,
) -> tuple[str, str]: ) -> tuple[str, str]:
normalized_question = re.sub(r"\s+", "", str(question or "").lower()) normalized_question = re.sub(r"\s+", "", str(question or "").lower())
normalized_message_intent = str(message_intent or "").strip().lower()
member_keywords = ("谁", "成员", "负责人", "负载") member_keywords = ("谁", "成员", "负责人", "负载")
trend_keywords = ("最新", "行业", "市场", "前景", "趋势", "动态") trend_keywords = ("最新", "行业", "市场", "前景", "趋势", "动态")
@ -242,7 +243,38 @@ class AdaptiveRAGDeepGuardAnswerMixin:
consultant_mode_preference=consultant_mode_preference, consultant_mode_preference=consultant_mode_preference,
) )
if str(message_intent or "").strip().lower() == "mixed": if normalized_message_intent in {"business_review", "correction_escalation"}:
world_model_text = json.dumps(
{
"system_snapshot": system_snapshot or {},
"user_snapshot": user_snapshot or {},
},
ensure_ascii=False,
)[:18000]
is_business_review = 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(
prompt_key="PROMPT_AI_PMO_QA",
fallback_template=PROMPT_AI_PMO_QA,
question=question,
scene_name=normalized_message_intent,
business_review_mode="true" if is_business_review else "false",
review_framework="five_lenses" if is_business_review else "standard",
correction_mode="true" if not is_business_review else "false",
project_id=project_id if project_id is not None else "unknown",
world_model=world_model_text or "{}",
conversation=conversation_text or "无",
context_blocks=context_blocks_text or "无",
),
)
return self._apply_consultant_mode_preference(
scene_key=scene_key,
prompt=prompt,
consultant_mode_preference=consultant_mode_preference,
)
if normalized_message_intent == "mixed":
safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充" safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充"
merged_message = f"{question}\n{safe_signal_part}".strip() merged_message = f"{question}\n{safe_signal_part}".strip()
scene_key, prompt = ( scene_key, prompt = (
@ -368,8 +400,13 @@ class AdaptiveRAGDeepGuardAnswerMixin:
prompt_key="PROMPT_AI_PMO_QA", prompt_key="PROMPT_AI_PMO_QA",
fallback_template=PROMPT_AI_PMO_QA, fallback_template=PROMPT_AI_PMO_QA,
question=question, question=question,
scene_name="ai_pmo",
business_review_mode="false",
review_framework="standard",
correction_mode="false",
project_id=project_id if project_id is not None else "unknown", project_id=project_id if project_id is not None else "unknown",
world_model=world_model_text or "{}", world_model=world_model_text or "{}",
conversation=conversation_text or "无",
context_blocks=context_blocks_text or "无", context_blocks=context_blocks_text or "无",
), ),
) )

View File

@ -25,6 +25,41 @@ class AdaptiveRAGDeepGuardRulesMixin:
return True return True
return False 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 @classmethod
def _has_explicit_target_scope(cls, question: str) -> bool: def _has_explicit_target_scope(cls, question: str) -> bool:
text = str(question or "").strip().lower() text = str(question or "").strip().lower()
@ -53,6 +88,41 @@ class AdaptiveRAGDeepGuardRulesMixin:
def _is_explicit_deep_request(cls, question: str) -> bool: def _is_explicit_deep_request(cls, question: str) -> bool:
return cls._contains_any(question, DEEP_EXPLICIT_KEYWORDS) return cls._contains_any(question, DEEP_EXPLICIT_KEYWORDS)
@classmethod
def _has_runtime_model_evidence(cls, sources: list[dict[str, Any]]) -> bool:
if not isinstance(sources, list):
return False
for row in sources:
if not isinstance(row, dict):
continue
source = str(row.get("source") or "").strip().lower()
label = str(row.get("label") or "").strip().lower()
metadata = row.get("metadata") if isinstance(row.get("metadata"), dict) else {}
source_id = str(metadata.get("source_id") or metadata.get("path") or "").strip().lower()
evidence_keys = {"routing_snapshot", "engine_config", "provider", "call_log_id", "ai_call_log_id"}
if source in {"runtime", "config", "db"}:
return True
if "routing config" in label or ".openclaw/pmflow-routing-rules.json" in source_id:
return True
if any(key in metadata for key in evidence_keys):
return True
return False
@classmethod
def _is_static_runtime_sources(cls, sources: list[dict[str, Any]]) -> bool:
if not isinstance(sources, list) or not sources:
return True
if cls._has_runtime_model_evidence(sources):
return False
static_sources = {"feature_catalog", "graph", "system", "pmflow"}
for row in sources:
if not isinstance(row, dict):
continue
source = str(row.get("source") or "").strip().lower()
if source not in static_sources:
return False
return True
@classmethod @classmethod
def _forced_deep_reason( def _forced_deep_reason(
cls, cls,
@ -61,6 +131,8 @@ class AdaptiveRAGDeepGuardRulesMixin:
effective_question: str, effective_question: str,
conversation: list[dict[str, str]], conversation: list[dict[str, 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 cls._is_correction_query(question) or cls._is_correction_query(effective_question):
if conversation: if conversation:
return "correction_turn" return "correction_turn"
@ -83,6 +155,8 @@ class AdaptiveRAGDeepGuardRulesMixin:
if channel != "feishu": if channel != "feishu":
return True 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): if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question):
return True return True
if str(reason or "").strip() == "engine_process_query": if str(reason or "").strip() == "engine_process_query":
@ -140,6 +214,8 @@ class AdaptiveRAGDeepGuardRulesMixin:
channel = str(asked_via or "").strip().lower() channel = str(asked_via or "").strip().lower()
if channel != "feishu": if channel != "feishu":
return True 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): if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question):
return True return True
return False return False
@ -153,16 +229,23 @@ class AdaptiveRAGDeepGuardRulesMixin:
sources: list[dict[str, Any]], sources: list[dict[str, Any]],
relevance_max: float, relevance_max: float,
) -> bool: ) -> 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)): if not (cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question)):
return False return False
if not cls._is_snapshot_only_sources(sources): if not cls._is_snapshot_only_sources(sources):
return False return False
return float(relevance_max or 0.0) < 0.68 return float(relevance_max or 0.0) < 0.68
@staticmethod @classmethod
def _build_evidence_guard_answer(question: str) -> str: def _build_evidence_guard_answer(cls, question: str) -> str:
raw = str(question or "").strip() raw = str(question or "").strip()
target = raw[:80] if raw else "该问题" target = raw[:80] if raw else "该问题"
if cls._is_runtime_state_query(raw):
return (
f"关于“{target}”,代码里存在该能力入口,但当前启用状态无法仅凭静态资料确认。"
"请提供运行态配置、路由快照、引擎配置或真实调用记录后,我再给出可核验结论。"
)
return ( return (
f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。" f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。"
"请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。" "请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。"

View File

@ -139,14 +139,21 @@ class AdaptiveRAGRetrievalGraphMixin:
).scalars().all() ).scalars().all()
evidence_by_relation: dict[int, str] = {} evidence_by_relation: dict[int, str] = {}
evidence_by_entity: dict[int, str] = {} evidence_by_entity: dict[int, str] = {}
evidence_updated_at_by_relation: dict[int, str] = {}
evidence_updated_at_by_entity: dict[int, str] = {}
for ev in evidence_rows: for ev in evidence_rows:
excerpt = str(ev.content_excerpt or "").strip() excerpt = str(ev.content_excerpt or "").strip()
occurred_at = self._isoformat_or_none(getattr(ev, "occurred_at", None))
if not excerpt: if not excerpt:
continue excerpt = ""
if ev.relation_id is not None and int(ev.relation_id) not in evidence_by_relation: if ev.relation_id is not None and int(ev.relation_id) not in evidence_by_relation:
evidence_by_relation[int(ev.relation_id)] = excerpt[:320] evidence_by_relation[int(ev.relation_id)] = excerpt[:320]
if occurred_at and ev.relation_id is not None and int(ev.relation_id) not in evidence_updated_at_by_relation:
evidence_updated_at_by_relation[int(ev.relation_id)] = occurred_at
if ev.entity_id is not None and int(ev.entity_id) not in evidence_by_entity: if ev.entity_id is not None and int(ev.entity_id) not in evidence_by_entity:
evidence_by_entity[int(ev.entity_id)] = excerpt[:320] evidence_by_entity[int(ev.entity_id)] = excerpt[:320]
if occurred_at and ev.entity_id is not None and int(ev.entity_id) not in evidence_updated_at_by_entity:
evidence_updated_at_by_entity[int(ev.entity_id)] = occurred_at
out: list[RetrievedItem] = [] out: list[RetrievedItem] = []
for rel in relation_rows: for rel in relation_rows:
@ -172,21 +179,37 @@ class AdaptiveRAGRetrievalGraphMixin:
) )
weight = self._to_float(rel.weight, 1.0) weight = self._to_float(rel.weight, 1.0)
score = min(0.99, max(0.45, 0.62 + min(weight, 1.5) * 0.18)) score = min(0.99, max(0.45, 0.62 + min(weight, 1.5) * 0.18))
relation_project_id = (
subject_payload.get("project_id")
or target_payload.get("project_id")
or project_id
)
metadata = self._normalize_source_registry_metadata(
source_type="graph",
metadata={
"relation_id": int(rel.id),
"relation_type": str(rel.relation_type or ""),
"subject_entity_id": int(subject.id),
"object_entity_id": int(target.id),
"subject_name": str(subject.display_name or subject.entity_key),
"object_name": str(target.display_name or target.entity_key),
"path": f"{subject.id}->{str(rel.relation_type)}->{target.id}",
"project_id": relation_project_id,
"source_ref": getattr(rel, "source_ref", None),
},
explicit_updated_at=(
evidence_updated_at_by_relation.get(int(rel.id))
or evidence_updated_at_by_entity.get(int(subject.id))
or evidence_updated_at_by_entity.get(int(target.id))
),
)
out.append( out.append(
RetrievedItem( RetrievedItem(
source="graph", source="graph",
content=content[:1200], content=content[:1200],
score=score, score=score,
source_label="Knowledge Graph", source_label="Knowledge Graph",
metadata={ metadata=metadata,
"relation_id": int(rel.id),
"relation_type": str(rel.relation_type or ""),
"subject_entity_id": int(subject.id),
"object_entity_id": int(target.id),
"subject_name": str(subject.display_name or subject.entity_key),
"object_name": str(target.display_name or target.entity_key),
"path": f"{subject.id}->{str(rel.relation_type)}->{target.id}",
},
) )
) )
@ -197,21 +220,36 @@ class AdaptiveRAGRetrievalGraphMixin:
if int(payload.get("project_id")) != int(project_id): if int(payload.get("project_id")) != int(project_id):
continue continue
excerpt = evidence_by_entity.get(int(seed.id)) or "" excerpt = evidence_by_entity.get(int(seed.id)) or ""
metadata = self._normalize_source_registry_metadata(
source_type="graph",
metadata={
"entity_id": int(seed.id),
"entity_type": str(seed.entity_type or ""),
"entity_key": str(seed.entity_key or ""),
"project_id": payload.get("project_id") or project_id,
"source_ref": getattr(seed, "source_ref", None),
},
explicit_updated_at=(
evidence_updated_at_by_entity.get(int(seed.id))
or getattr(seed, "last_seen_at", None)
),
)
out.append( out.append(
RetrievedItem( RetrievedItem(
source="graph", source="graph",
content=f"实体: {str(seed.display_name or seed.entity_key)}\n证据: {excerpt or '无显式证据摘录'}", content=f"实体: {str(seed.display_name or seed.entity_key)}\n证据: {excerpt or '无显式证据摘录'}",
score=0.58, score=0.58,
source_label="Knowledge Graph", source_label="Knowledge Graph",
metadata={ metadata=metadata,
"entity_id": int(seed.id),
"entity_type": str(seed.entity_type or ""),
"entity_key": str(seed.entity_key or ""),
},
) )
) )
out.sort(key=lambda x: x.score, reverse=True) out.sort(
key=lambda item: (
item.score,
self._source_priority_sort_value(item.metadata),
),
reverse=True,
)
return out[: max(DEFAULT_TOP_K, 6)] return out[: max(DEFAULT_TOP_K, 6)]
except Exception: except Exception:
return [] return []

View File

@ -86,6 +86,16 @@ class AdaptiveRAGRetrievalRouterDBMixin:
f"#{int(row.id)} {str(row.name or f'User#{int(row.id)}')}(角色:{str(row.role or 'unknown')})" f"#{int(row.id)} {str(row.name or f'User#{int(row.id)}')}(角色:{str(row.role or 'unknown')})"
for row in members[:10] for row in members[:10]
) )
metadata = self._normalize_source_registry_metadata(
source_type="db",
metadata={
"table": "users",
"count": len(members),
"project_id": scoped_project_id,
"project_name": scoped_project_name,
},
explicit_updated_at=getattr(members[0], "updated_at", None) if members else None,
)
rows.append( rows.append(
RetrievedItem( RetrievedItem(
source="db", source="db",
@ -96,12 +106,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
), ),
score=0.95, score=0.95,
source_label="User database", source_label="User database",
metadata={ metadata=metadata,
"table": "users",
"count": len(members),
"project_id": scoped_project_id,
"project_name": scoped_project_name,
},
) )
) )
@ -114,13 +119,18 @@ class AdaptiveRAGRetrievalRouterDBMixin:
summary = ";".join( summary = ";".join(
f"#{p.id} {p.title or p.name}(状态:{p.status})" for p in projects f"#{p.id} {p.title or p.name}(状态:{p.status})" for p in projects
) )
metadata = self._normalize_source_registry_metadata(
source_type="db",
metadata={"table": "projects", "count": len(projects)},
explicit_updated_at=getattr(projects[0], "updated_at", None) if projects else None,
)
rows.append( rows.append(
RetrievedItem( RetrievedItem(
source="db", source="db",
content=f"项目列表:{summary}", content=f"项目列表:{summary}",
score=0.93, score=0.93,
source_label="Project database", source_label="Project database",
metadata={"table": "projects", "count": len(projects)}, metadata=metadata,
) )
) )
@ -138,13 +148,22 @@ class AdaptiveRAGRetrievalRouterDBMixin:
).scalars().all() ).scalars().all()
if pending: if pending:
preview = ";".join((s.summary or s.content or "")[:80] for s in pending[:5]) preview = ";".join((s.summary or s.content or "")[:80] for s in pending[:5])
metadata = self._normalize_source_registry_metadata(
source_type="db",
metadata={
"table": "signals",
"count": len(pending),
"project_id": int(project_id) if project_id is not None else None,
},
explicit_updated_at=getattr(pending[0], "created_at", None) if pending else None,
)
rows.append( rows.append(
RetrievedItem( RetrievedItem(
source="db", source="db",
content=f"最近信号({len(pending)}条):{preview}", content=f"最近信号({len(pending)}条):{preview}",
score=0.9, score=0.9,
source_label="Project database", source_label="Project database",
metadata={"table": "signals", "count": len(pending)}, metadata=metadata,
) )
) )
@ -156,6 +175,15 @@ class AdaptiveRAGRetrievalRouterDBMixin:
if tasks: if tasks:
open_tasks = [t for t in tasks if str(t.status or "").lower() not in {"done", "completed", "已完成"}] open_tasks = [t for t in tasks if str(t.status or "").lower() not in {"done", "completed", "已完成"}]
owners = [str(t.assignee_id) for t in open_tasks if t.assignee_id is not None] owners = [str(t.assignee_id) for t in open_tasks if t.assignee_id is not None]
metadata = self._normalize_source_registry_metadata(
source_type="db",
metadata={
"table": "tasks",
"open_count": len(open_tasks),
"project_id": int(project_id) if project_id is not None else None,
},
explicit_updated_at=getattr(tasks[0], "updated_at", None) if tasks else None,
)
rows.append( rows.append(
RetrievedItem( RetrievedItem(
source="db", source="db",
@ -165,7 +193,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
), ),
score=0.91, score=0.91,
source_label="Project database", source_label="Project database",
metadata={"table": "tasks", "open_count": len(open_tasks)}, metadata=metadata,
) )
) )
@ -175,6 +203,15 @@ class AdaptiveRAGRetrievalRouterDBMixin:
charter_stmt = charter_stmt.where(TruthNode.project_id == project_id) charter_stmt = charter_stmt.where(TruthNode.project_id == project_id)
charters = (await self.db.execute(charter_stmt.order_by(TruthNode.updated_at.desc()).limit(6))).scalars().all() charters = (await self.db.execute(charter_stmt.order_by(TruthNode.updated_at.desc()).limit(6))).scalars().all()
if charters: if charters:
metadata = self._normalize_source_registry_metadata(
source_type="db",
metadata={
"table": "truth_nodes",
"count": len(charters),
"project_id": int(project_id) if project_id is not None else None,
},
explicit_updated_at=getattr(charters[0], "updated_at", None) if charters else None,
)
rows.append( rows.append(
RetrievedItem( RetrievedItem(
source="db", source="db",
@ -183,7 +220,7 @@ class AdaptiveRAGRetrievalRouterDBMixin:
), ),
score=0.92, score=0.92,
source_label="Project database", source_label="Project database",
metadata={"table": "truth_nodes", "count": len(charters)}, metadata=metadata,
) )
) )
@ -196,15 +233,19 @@ class AdaptiveRAGRetrievalRouterDBMixin:
f"最大 {float(w.max_available_hours_per_week or 0):.1f}h,负载比 {float(w.workload_ratio or 0):.2f}" f"最大 {float(w.max_available_hours_per_week or 0):.1f}h,负载比 {float(w.workload_ratio or 0):.2f}"
for w in workloads for w in workloads
) )
metadata = self._normalize_source_registry_metadata(
source_type="db",
metadata={"table": "user_workloads", "count": len(workloads)},
explicit_updated_at=getattr(workloads[0], "updated_at", None) if workloads else None,
)
rows.append( rows.append(
RetrievedItem( RetrievedItem(
source="db", source="db",
content=f"团队工作量:{content}", content=f"团队工作量:{content}",
score=0.88, score=0.88,
source_label="Project database", source_label="Project database",
metadata={"table": "user_workloads", "count": len(workloads)}, metadata=metadata,
) )
) )
return rows[:DEFAULT_TOP_K] return rows[:DEFAULT_TOP_K]

View File

@ -26,24 +26,16 @@ class AdaptiveRAGRetrievalVectorMixin:
if not source_types: if not source_types:
return [] return []
limit = max(1, min(int(top_k or DEFAULT_TOP_K), 40)) limit = max(1, min(int(top_k or DEFAULT_TOP_K), 40))
if self._is_postgresql_session(): if not self._is_postgresql_session():
pg_rows = await self._retrieve_vector_context_pg( return []
question_embedding=question_embedding, rows = await self._retrieve_vector_context_pg(
source_types=source_types,
project_id=project_id,
source_chat_id=source_chat_id,
limit=limit,
)
if pg_rows is not None:
return pg_rows
return await self._retrieve_vector_context_legacy(
question_embedding=question_embedding, question_embedding=question_embedding,
source_types=source_types, source_types=source_types,
project_id=project_id, project_id=project_id,
source_chat_id=source_chat_id, source_chat_id=source_chat_id,
limit=limit, limit=limit,
) )
return rows if isinstance(rows, list) else []
def _is_postgresql_session(self) -> bool: def _is_postgresql_session(self) -> bool:
try: try:
@ -71,7 +63,7 @@ class AdaptiveRAGRetrievalVectorMixin:
project_id: Optional[int], project_id: Optional[int],
source_chat_id: Optional[str], source_chat_id: Optional[str],
limit: int, limit: int,
) -> Optional[list[RetrievedItem]]: ) -> list[RetrievedItem]:
query_vec = self._vector_literal(question_embedding) query_vec = self._vector_literal(question_embedding)
scoped_project_id = int(project_id) if project_id is not None else 0 scoped_project_id = int(project_id) if project_id is not None else 0
scoped_source_chat_id = str(source_chat_id or "").strip() scoped_source_chat_id = str(source_chat_id or "").strip()
@ -79,9 +71,11 @@ class AdaptiveRAGRetrievalVectorMixin:
""" """
SELECT SELECT
id, id,
doc_id,
source_type, source_type,
content, content,
project_id, project_id,
chunk_index,
"metadata" AS chunk_metadata, "metadata" AS chunk_metadata,
1 - (embedding <=> CAST(:query_vec AS vector)) AS similarity 1 - (embedding <=> CAST(:query_vec AS vector)) AS similarity
FROM knowledge_base_chunks FROM knowledge_base_chunks
@ -119,7 +113,7 @@ class AdaptiveRAGRetrievalVectorMixin:
) )
).mappings().all() ).mappings().all()
except Exception: except Exception:
return None return []
scored: list[RetrievedItem] = [] scored: list[RetrievedItem] = []
for row in rows: for row in rows:
@ -127,19 +121,13 @@ class AdaptiveRAGRetrievalVectorMixin:
meta = self._safe_dict(row.get("chunk_metadata")).copy() meta = self._safe_dict(row.get("chunk_metadata")).copy()
chunk_project_id_raw = row.get("project_id") chunk_project_id_raw = row.get("project_id")
chunk_project_id = int(chunk_project_id_raw) if chunk_project_id_raw is not None else None chunk_project_id = int(chunk_project_id_raw) if chunk_project_id_raw is not None else None
if chunk_project_id is not None: meta = self._normalize_source_registry_metadata(
meta.setdefault("project_id", chunk_project_id) source_type=source_type,
metadata=meta,
if source_type == "pmflow": explicit_source_id=row.get("doc_id") if source_type in {"doc", "url"} else None,
target_url = str(meta.get("target_url") or "").strip() explicit_chunk_id=row.get("chunk_index"),
if not target_url: explicit_project_id=chunk_project_id,
target_url = self._build_pmflow_target_url( )
record_type=str(meta.get("record_type") or ""),
record_id=meta.get("record_id"),
project_id=meta.get("project_id"),
)
if target_url:
meta["target_url"] = target_url
score = self._to_float(row.get("similarity"), 0.0) score = self._to_float(row.get("similarity"), 0.0)
if score <= 0: if score <= 0:
@ -154,68 +142,11 @@ class AdaptiveRAGRetrievalVectorMixin:
metadata=meta, metadata=meta,
) )
) )
scored.sort(key=lambda x: x.score, reverse=True) scored.sort(
return scored[:limit] key=lambda item: (
item.score,
async def _retrieve_vector_context_legacy( self._source_priority_sort_value(item.metadata),
self, ),
*, reverse=True,
question_embedding: list[float],
source_types: list[str],
project_id: Optional[int],
source_chat_id: Optional[str],
limit: int,
) -> list[RetrievedItem]:
stmt = select(KnowledgeBaseChunk).where(
and_(
KnowledgeBaseChunk.tenant_id == self.tenant_id,
KnowledgeBaseChunk.source_type.in_(source_types),
)
) )
scan_limit = max(120, min(1200, limit * 20))
chunks = (await self.db.execute(stmt.limit(scan_limit))).scalars().all()
scored: list[RetrievedItem] = []
for chunk in chunks:
meta = self._safe_dict(chunk.chunk_metadata).copy()
chunk_project_id = int(chunk.project_id) if chunk.project_id is not None else None
if chunk_project_id is not None:
meta.setdefault("project_id", chunk_project_id)
if project_id is not None:
if chunk.source_type in {"feishu", "pmflow"}:
if chunk_project_id != int(project_id):
continue
elif chunk_project_id is not None and chunk_project_id != int(project_id):
continue
if chunk.source_type == "feishu" and source_chat_id:
chat_id = str(meta.get("chat_id") or "").strip()
if chat_id and chat_id != str(source_chat_id):
continue
if chunk.source_type == "pmflow":
target_url = str(meta.get("target_url") or "").strip()
if not target_url:
target_url = self._build_pmflow_target_url(
record_type=str(meta.get("record_type") or ""),
record_id=meta.get("record_id"),
project_id=meta.get("project_id"),
)
if target_url:
meta["target_url"] = target_url
score = self._cosine_similarity(question_embedding, self._embedding_to_list(chunk.embedding))
if score <= 0:
continue
label = self._source_label(chunk.source_type, meta)
scored.append(
RetrievedItem(
source=str(chunk.source_type or "doc"),
content=str(chunk.content or "")[:1200],
score=score,
source_label=label,
metadata=meta,
)
)
scored.sort(key=lambda x: x.score, reverse=True)
return scored[:limit] return scored[:limit]

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from .... import ai_pmo_kernel_common_core as _common from .... import ai_pmo_kernel_common_core as _common
from ....workflow_semantic_planner import WorkflowSemanticPlanner
for _name, _value in vars(_common).items(): for _name, _value in vars(_common).items():
if not _name.startswith("__"): if not _name.startswith("__"):
@ -10,6 +11,55 @@ for _name, _value in vars(_common).items():
class KernelIntentResolveMixin: class KernelIntentResolveMixin:
@staticmethod
def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool:
if not isinstance(last_active_at, datetime):
return False
delta = _utc_now_naive() - last_active_at
return 0 <= delta.total_seconds() <= float(window_seconds)
@classmethod
def _should_inherit_recent_feishu_session_context(
cls,
*,
message_text: str,
session: Optional[ConversationSession],
) -> bool:
if session is None:
return False
sender = str(getattr(session, "sender", "") or "").strip().lower()
if not sender.startswith("feishu:"):
return False
if not cls._within_recent_window(
getattr(session, "last_active_at", None),
window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS,
):
return False
if not str(getattr(session, "last_intent", "") or "").strip():
return False
if WorkflowSemanticPlanner._derive_query_scene_metadata(message_text):
return True
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
if not normalized:
return False
followup_tokens = (
"需求",
"mvp",
"方案",
"对应",
"这个",
"它",
"给我",
"补充",
"展开",
"细化",
"重答",
"重新回答",
"高级模型",
"更强模型",
)
return any(token in normalized for token in followup_tokens)
async def _resolve_intent( async def _resolve_intent(
self, self,
*, *,
@ -24,6 +74,10 @@ class KernelIntentResolveMixin:
isinstance(thread_context, dict) isinstance(thread_context, dict)
and str(thread_context.get("reply_to_message_id") or "").strip() and str(thread_context.get("reply_to_message_id") or "").strip()
) )
inherit_session_context = reply_anchor_present or self._should_inherit_recent_feishu_session_context(
message_text=message_text,
session=session,
)
if self._is_short_confirmation_message( if self._is_short_confirmation_message(
message_text=message_text, message_text=message_text,
session=session, session=session,
@ -57,10 +111,10 @@ class KernelIntentResolveMixin:
world_model=world_model, world_model=world_model,
rule_intent=None, rule_intent=None,
session_last_intent_override=( session_last_intent_override=(
str(getattr(session, "last_intent", "") or "").strip() if reply_anchor_present else "" str(getattr(session, "last_intent", "") or "").strip() if inherit_session_context else ""
), ),
session_project_id_override=( session_project_id_override=(
_safe_int(getattr(session, "current_project_id", None)) if reply_anchor_present else None _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None
), ),
) )
if llm_intent is None: if llm_intent is None:

View File

@ -262,12 +262,19 @@ class KernelIngressFlowEntryMixin:
**(intent.metadata or {}), **(intent.metadata or {}),
"rag_request_context": dict(rag_request_context), "rag_request_context": dict(rag_request_context),
} }
source_context_metadata = {
"source_sender_open_id": str(source_sender_open_id or "").strip() or None,
"source_chat_id": str(channel_ref or "").strip() or None,
"channel_ref": str(channel_ref or "").strip() or None,
"chat_type": str(chat_type or "").strip() or None,
"session_key": str(ingress_prepare.get("session_key") or "").strip() or None,
}
if thread_context: if thread_context:
intent.metadata = { source_context_metadata["thread_context"] = dict(thread_context)
**(intent.metadata or {}), intent.metadata = {
"thread_context": dict(thread_context), **(intent.metadata or {}),
"session_key": str(ingress_prepare.get("session_key") or "").strip() or None, **{key: value for key, value in source_context_metadata.items() if value not in (None, "")},
} }
workflow_route = self.route_resolver.from_canonical( workflow_route = self.route_resolver.from_canonical(
canonical_intent=intent, canonical_intent=intent,
message_text=clean_text, message_text=clean_text,

View File

@ -25,6 +25,8 @@ class ExecuteQueryProjectMixin:
) -> dict[str, Any]: ) -> dict[str, Any]:
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
metadata = intent.metadata if isinstance(intent.metadata, dict) else {} metadata = intent.metadata if isinstance(intent.metadata, dict) else {}
semantic_bundle = metadata.get("semantic_bundle") if isinstance(metadata.get("semantic_bundle"), dict) else {}
semantic_diagnostics = semantic_bundle.get("diagnostics") if isinstance(semantic_bundle.get("diagnostics"), dict) else {}
rag_request_context = ( rag_request_context = (
metadata.get("rag_request_context") metadata.get("rag_request_context")
if isinstance(metadata.get("rag_request_context"), dict) if isinstance(metadata.get("rag_request_context"), dict)
@ -55,10 +57,10 @@ class ExecuteQueryProjectMixin:
asked_by = _safe_int(operator_user_id) asked_by = _safe_int(operator_user_id)
asked_via = source_system asked_via = source_system
requested_project_id = None requested_project_id = None
source_chat_id = None source_chat_id = str(metadata.get("source_chat_id") or metadata.get("channel_ref") or "").strip() or None
conversation_key = ( conversation_key = (
build_feishu_conversation_scope_key( build_feishu_conversation_scope_key(
source_chat_id=None, source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id, source_sender_open_id=source_sender_open_id,
sender=None, sender=None,
) )
@ -67,15 +69,48 @@ class ExecuteQueryProjectMixin:
) )
session_id = None session_id = None
force_new_session = False force_new_session = False
thinking_mode = None thinking_mode = str(semantic_diagnostics.get("force_thinking_mode") or "").strip() or None
action_mode = "read" action_mode = "read"
sync_requirement = False sync_requirement = False
message_intent = None message_intent = str(
semantic_diagnostics.get("message_intent") or metadata.get("message_intent") or ""
).strip() or None
mixed_signal_part = None mixed_signal_part = None
consultant_mode_preference = None consultant_mode_preference = str(
semantic_diagnostics.get("consultant_mode_preference") or metadata.get("consultant_mode_preference") or ""
).strip().lower() or None
if isinstance(rag_request_context, dict): if isinstance(rag_request_context, dict):
source_sender_open_id = str(rag_request_context.get("source_sender_open_id") or "").strip() or None source_sender_open_id = str(rag_request_context.get("source_sender_open_id") or "").strip() or None
thread_context = rag_request_context.get("thread_context") if isinstance(rag_request_context.get("thread_context"), dict) else None thread_context = rag_request_context.get("thread_context") if isinstance(rag_request_context.get("thread_context"), dict) else None
if not source_chat_id:
source_chat_id = str(
rag_request_context.get("source_chat_id")
or metadata.get("source_chat_id")
or metadata.get("channel_ref")
or ""
).strip() or None
if not message_intent:
message_intent = str(
semantic_diagnostics.get("message_intent") or metadata.get("message_intent") or ""
).strip() or None
if not consultant_mode_preference:
consultant_mode_preference = str(
semantic_diagnostics.get("consultant_mode_preference") or metadata.get("consultant_mode_preference") or ""
).strip().lower() or None
if not thinking_mode:
thinking_mode = str(
rag_request_context.get("thinking_mode")
or semantic_diagnostics.get("force_thinking_mode")
or ""
).strip() or None
if str(source_system or "").strip().lower() == "feishu" and not conversation_key:
conversation_key = build_feishu_conversation_scope_key(
source_chat_id=source_chat_id,
source_sender_open_id=source_sender_open_id,
sender=None,
)
if str(message_intent or "").strip().lower() == "correction_escalation" and not thinking_mode:
thinking_mode = "deep"
rag_kwargs: dict[str, Any] = { rag_kwargs: dict[str, Any] = {
"question": message_text, "question": message_text,
"asked_by": asked_by, "asked_by": asked_by,

View File

@ -42,6 +42,9 @@ WORKFLOW_PROJECT_SIGNAL = "project_signal_flow"
WORKFLOW_SIGNAL_SOURCE = "signal_source_flow" WORKFLOW_SIGNAL_SOURCE = "signal_source_flow"
WORKFLOW_FOLLOWUP = "followup_flow" WORKFLOW_FOLLOWUP = "followup_flow"
QUERY_SCENE_BUSINESS_REVIEW = "business_review"
QUERY_SCENE_CORRECTION_ESCALATION = "correction_escalation"
MESSAGE_GATE_AUTO = "auto_execute" MESSAGE_GATE_AUTO = "auto_execute"
MESSAGE_GATE_PENDING = "pending_confirmation" MESSAGE_GATE_PENDING = "pending_confirmation"
MESSAGE_GATE_FAILED_SAFE = "failed_safe" MESSAGE_GATE_FAILED_SAFE = "failed_safe"
@ -113,6 +116,115 @@ CONTEXTUAL_WRITE_ACTION_HINTS = (
"落地", "落地",
) )
BUSINESS_REVIEW_ANALYSIS_HINTS = (
"分析",
"分析下",
"怎么做",
"如何做",
"方案",
"规划",
"梳理",
"评审",
"mvp",
"首期交付",
"需求",
"要做一个",
"想做一个",
"给我需求对应的mvp",
"我需要对应的需求mvp",
"需求mvp",
"对应的mvp",
)
BUSINESS_REVIEW_TARGET_HINTS = (
"平台",
"系统",
"产品",
"业务",
"api",
"llm",
"模型",
"聚合",
"需求",
"mvp",
"方案",
)
CORRECTION_ESCALATION_HINTS = (
"重新回答",
"重答",
"调用高级模型",
"高级模型",
"更强模型",
"用更强模型",
"高配模型",
)
NON_REQUIREMENT_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"okay",
"yes",
"no",
"1",
"2",
"3",
"thanks",
"thx",
"赞",
"辛苦",
"你是谁",
"你是誰",
"你是谁?",
"你是誰?",
"说话",
"在吗",
"在不在",
"今天什么日期",
"今天几号",
"今天星期几",
"现在几点",
"当前日期",
}
NON_REQUIREMENT_CONTAINS = (
"你是谁",
"什么身份",
"身份确认",
"今天什么日期",
"当前日期",
"日期疑问",
"询问日期",
"现在几点",
"几点了",
"今天周几",
"今天星期几",
"说话",
"在吗",
"在不在",
)
REQUIREMENT_HINTS = (
"需求",
"优化",
"改一下",
"调整",
"修复",
"bug",
"故障",
"报错",
"项目",
"页面",
"接口",
"功能",
"排版",
"样式",
"任务",
"立项",
"上线",
"配置",
)
LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建")
GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile( GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile(
r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$",
re.IGNORECASE, re.IGNORECASE,
@ -221,6 +333,33 @@ SMALLTALK_EXACT = {
} }
SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok") SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok")
SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#")
FRONT_NOISE_HINTS = SMALLTALK_SHORT_HINTS
FRONT_NOISE_STRUCTURED_HINTS = SMALLTALK_STRUCTURED_HINTS
PROJECT_INTENT_RE = re.compile(r"(立项|新项目|我们要做|需要开发|启动|规划)", re.IGNORECASE)
SIGNAL_FACT_RE = re.compile(r"(竞品|上线|发布|反馈|风险|bug|故障|告警|投诉)", re.IGNORECASE)
TIMELINE_RE = re.compile(r"(\d+\s*(天|周|月)|本周|本月|月底|下周|下月|Q[1-4]|\d{1,2}月\d{1,2}日)")
OWNER_RE = re.compile(r"(由|负责人|owner|owner:|@)(\S+)")
INDEPENDENT_REQUIREMENT_RE = re.compile(
r"(独立需求|单独需求|单开|另起|新增需求|新建项目|新增项目|单独立项|开个新|做个新)",
re.IGNORECASE,
)
SIMPLE_SIGNAL_REQUEST_RE = re.compile(
r"(新增|增加|加一个|加个|补充|支持|实现|接入|优化|修复|改进).{0,18}(功能|能力|接口|页面|按钮|流程|识别|提醒|校验|报表|字段|规则|模块)",
re.IGNORECASE,
)
PROJECT_COMPLEXITY_RE = re.compile(
r"(立项|新项目|项目章程|里程碑|预算|排期|roadmap|跨团队|跨部门|季度目标|年度目标|okr)",
re.IGNORECASE,
)
QUESTION_REQUIREMENT_VERB_RE = re.compile(
r"(新增|增加|加一个|加个|支持|接入|实现|优化|修复|改进|调整|补充|完善|同步|对齐|落库|入库)",
re.IGNORECASE,
)
QUESTION_REQUIREMENT_OBJECT_RE = re.compile(
r"(功能|能力|接口|页面|按钮|流程|校验|报表|字段|规则|模块|任务|项目|需求|通知|提醒|配置|归属|队列|信号)",
re.IGNORECASE,
)
SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪")
TASK_CREATE_HINTS = ( TASK_CREATE_HINTS = (
@ -311,6 +450,10 @@ LEGACY_COMMAND_OBJECT_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE) CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE)
CORRECTION_CUE_RE = re.compile(
r"(不对|错了|理解错|不是这个|不是这个意思|我说的是|我要的是|不是啊|不是新增 md|不是写 markdown)",
re.IGNORECASE,
)
EXTERNAL_INTEGRATION_RE = re.compile( EXTERNAL_INTEGRATION_RE = re.compile(
r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)", r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)",
re.IGNORECASE, re.IGNORECASE,

View File

@ -5,9 +5,12 @@ import re
from typing import Any, Iterable, Optional from typing import Any, Iterable, Optional
from .constants import ( from .constants import (
BUSINESS_REVIEW_ANALYSIS_HINTS,
BUSINESS_REVIEW_TARGET_HINTS,
CONTEXTUAL_CONFIRM_HINTS, CONTEXTUAL_CONFIRM_HINTS,
CONTEXTUAL_DEICTIC_HINTS, CONTEXTUAL_DEICTIC_HINTS,
CONTEXTUAL_WRITE_ACTION_HINTS, CONTEXTUAL_WRITE_ACTION_HINTS,
CORRECTION_ESCALATION_HINTS,
CREATE_ACTION_RE, CREATE_ACTION_RE,
EXTERNAL_INTEGRATION_RE, EXTERNAL_INTEGRATION_RE,
FOLLOWUP_CANCEL_HINTS, FOLLOWUP_CANCEL_HINTS,
@ -46,6 +49,8 @@ from .constants import (
PROJECT_ID_QUERY_RE, PROJECT_ID_QUERY_RE,
PROJECT_LIST_QUERY_RE, PROJECT_LIST_QUERY_RE,
PROJECT_SIGNAL_DISCOVER_HINTS, PROJECT_SIGNAL_DISCOVER_HINTS,
QUERY_SCENE_BUSINESS_REVIEW,
QUERY_SCENE_CORRECTION_ESCALATION,
QUESTION_HINTS, QUESTION_HINTS,
REPLY_MODE_CARD, REPLY_MODE_CARD,
REPLY_MODE_TEXT, REPLY_MODE_TEXT,
@ -121,6 +126,61 @@ class WorkflowSemanticPlanner:
return False return False
return "?" in normalized or "?" in normalized or any(token in normalized for token in QUESTION_HINTS) return "?" in normalized or "?" in normalized or any(token in normalized for token in QUESTION_HINTS)
@classmethod
def _derive_query_scene_metadata(cls, text: str) -> dict[str, Any]:
normalized = cls._normalize_text(text)
if not normalized:
return {}
has_correction_cue = any(
token in normalized
for token in (
"不对",
"错了",
"答错",
"纠正",
"更正",
"重新回答",
"重答",
)
)
has_escalation_hint = any(token in normalized for token in CORRECTION_ESCALATION_HINTS)
if has_escalation_hint and (has_correction_cue or "高级模型" in normalized or "更强模型" in normalized):
return {
"message_intent": QUERY_SCENE_CORRECTION_ESCALATION,
"consultant_mode_preference": "challenge",
"force_thinking_mode": "deep",
}
has_review_analysis_hint = any(token in normalized for token in BUSINESS_REVIEW_ANALYSIS_HINTS)
has_review_target_hint = any(token in normalized for token in BUSINESS_REVIEW_TARGET_HINTS)
if has_review_analysis_hint and has_review_target_hint:
return {
"message_intent": QUERY_SCENE_BUSINESS_REVIEW,
"review_framework": "five_lenses",
"consultant_mode_preference": "clarify",
}
return {}
@classmethod
def _build_query_bundle_diagnostics(
cls,
*,
intent_name: Optional[str] = None,
workflow_route: Optional[str] = None,
scene_metadata: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
diagnostics: dict[str, Any] = {}
if str(intent_name or "").strip():
diagnostics["intent_name"] = str(intent_name).strip()
if str(workflow_route or "").strip():
diagnostics["workflow_route"] = str(workflow_route).strip()
for key in ("message_intent", "review_framework", "consultant_mode_preference", "force_thinking_mode"):
value = None if not isinstance(scene_metadata, dict) else scene_metadata.get(key)
if value not in (None, ""):
diagnostics[key] = value
return diagnostics
@classmethod @classmethod
def _is_smalltalk_message(cls, text: str) -> bool: def _is_smalltalk_message(cls, text: str) -> bool:
compact = re.sub(r"[\s\??!!,,。~~、]+", "", str(text or "")).strip().lower() compact = re.sub(r"[\s\??!!,,。~~、]+", "", str(text or "")).strip().lower()
@ -380,6 +440,7 @@ class WorkflowSemanticPlanner:
) -> WorkflowBundle: ) -> WorkflowBundle:
payload = dict(metadata or {}) payload = dict(metadata or {})
normalized_route = str(workflow_route or payload.get("workflow_route") or "").strip().lower() normalized_route = str(workflow_route or payload.get("workflow_route") or "").strip().lower()
query_scene_metadata = cls._derive_query_scene_metadata(message_text)
workflows: list[WorkflowPlan] = [] workflows: list[WorkflowPlan] = []
slots = [slot for slot in [workflow_slot("project_id", project_id)] if slot is not None] slots = [slot for slot in [workflow_slot("project_id", project_id)] if slot is not None]
if intent_name == INTENT_QUERY: if intent_name == INTENT_QUERY:
@ -392,6 +453,7 @@ class WorkflowSemanticPlanner:
requires_confirmation=False, requires_confirmation=False,
confidence=0.88, confidence=0.88,
slots=slots, slots=slots,
metadata=dict(query_scene_metadata),
) )
) )
elif intent_name == INTENT_PROJECT_CREATE: elif intent_name == INTENT_PROJECT_CREATE:
@ -492,7 +554,11 @@ class WorkflowSemanticPlanner:
workflows=workflows, workflows=workflows,
message_gate=cls._gate_for_workflows(workflows), message_gate=cls._gate_for_workflows(workflows),
reply_contract=cls._reply_contract(source_system=source_system, primary_workflow=primary_workflow), reply_contract=cls._reply_contract(source_system=source_system, primary_workflow=primary_workflow),
diagnostics={"intent_name": intent_name, "workflow_route": normalized_route or None}, diagnostics=cls._build_query_bundle_diagnostics(
intent_name=intent_name,
workflow_route=normalized_route or None,
scene_metadata=query_scene_metadata,
),
) )
@classmethod @classmethod
@ -506,6 +572,7 @@ class WorkflowSemanticPlanner:
text = str(message_text or "").strip() text = str(message_text or "").strip()
payload = dict(metadata or {}) payload = dict(metadata or {})
normalized = cls._normalize_text(text) normalized = cls._normalize_text(text)
query_scene_metadata = cls._derive_query_scene_metadata(text)
workflows: list[WorkflowPlan] = [] workflows: list[WorkflowPlan] = []
if not text or cls._is_smalltalk_message(text): if not text or cls._is_smalltalk_message(text):
workflows.append( workflows.append(
@ -517,6 +584,7 @@ class WorkflowSemanticPlanner:
requires_confirmation=False, requires_confirmation=False,
confidence=0.55, confidence=0.55,
clarification_needed=False, clarification_needed=False,
metadata=dict(query_scene_metadata),
) )
) )
elif cls._looks_like_followup(text, metadata=payload): elif cls._looks_like_followup(text, metadata=payload):
@ -607,6 +675,7 @@ class WorkflowSemanticPlanner:
operation_codes=[ROUTE_QUERY_ANSWER], operation_codes=[ROUTE_QUERY_ANSWER],
requires_confirmation=False, requires_confirmation=False,
confidence=0.88 if question_like else 0.6, confidence=0.88 if question_like else 0.6,
metadata=dict(query_scene_metadata),
) )
) )
primary_workflow = workflows[0].workflow_type primary_workflow = workflows[0].workflow_type
@ -618,7 +687,7 @@ class WorkflowSemanticPlanner:
workflows=workflows, workflows=workflows,
message_gate=cls._gate_for_workflows(workflows), message_gate=cls._gate_for_workflows(workflows),
reply_contract=cls._reply_contract(source_system=source_system, primary_workflow=primary_workflow), reply_contract=cls._reply_contract(source_system=source_system, primary_workflow=primary_workflow),
diagnostics={}, diagnostics=cls._build_query_bundle_diagnostics(scene_metadata=query_scene_metadata),
) )
@classmethod @classmethod

View File

@ -77,6 +77,7 @@ class AIServiceBusinessTasksMixin:
) -> dict: ) -> dict:
"""从市场/模型信号生成需求宪章草案""" """从市场/模型信号生成需求宪章草案"""
from ...config.ai_prompts import PROMPT_GENERATE_CHARTER from ...config.ai_prompts import PROMPT_GENERATE_CHARTER
from ...services.llm_service import llm_service
tid = _t2s(tenant_id) tid = _t2s(tenant_id)
try: try:
@ -88,14 +89,17 @@ class AIServiceBusinessTasksMixin:
project_context=str(project_context or "").strip() or "无", project_context=str(project_context or "").strip() or "无",
active_projects_json=str(active_projects_json or "[]").strip() or "[]", active_projects_json=str(active_projects_json or "[]").strip() or "[]",
) )
text = await self._invoke_with_retry( structured = await llm_service.generate_structured_object(
prompt, prompt,
db=db, db=db,
tenant_id=tid, tenant_id=int(tid or 1),
function_name="generate_charter_from_signal", function_name="generate_charter_from_signal",
project_id=resolved_project_id, project_id=resolved_project_id,
use_prompt_profile=False,
) )
return self._parse_json(text) if not structured.payload:
raise ValueError("generate_charter_from_signal returned empty payload")
return structured.payload
async def decompose_tasks_from_charter( async def decompose_tasks_from_charter(
self, self,

View File

@ -10,7 +10,6 @@ class FeishuCardActionMixin:
_ACTION_CONTINUE = "continue" _ACTION_CONTINUE = "continue"
_TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm" _TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm"
_TEMPLATE_PENDING_MANUAL = "pending_manual" _TEMPLATE_PENDING_MANUAL = "pending_manual"
_TEMPLATE_QUEUE_PENDING_CONFIRM = "queue_pending_confirm"
_ACTION_ALIASES: dict[str, str] = { _ACTION_ALIASES: dict[str, str] = {
"confirm": _ACTION_CONFIRM, "confirm": _ACTION_CONFIRM,
"draft_confirm": _ACTION_CONFIRM, "draft_confirm": _ACTION_CONFIRM,
@ -61,21 +60,6 @@ class FeishuCardActionMixin:
return int(row_work_item_id) return int(row_work_item_id)
return None return None
@staticmethod
def _coerce_capture_queue_item_id(capture_result: Dict[str, Any]) -> Optional[int]:
queue_item_id = FeishuCardActionMixin._safe_int(
capture_result.get("queue_item_id")
or capture_result.get("item_id")
)
if queue_item_id is not None and queue_item_id > 0:
return int(queue_item_id)
rows = capture_result.get("results") if isinstance(capture_result.get("results"), list) else []
if rows and isinstance(rows[0], dict):
row_item_id = FeishuCardActionMixin._safe_int(rows[0].get("item_id"))
if row_item_id is not None and row_item_id > 0:
return int(row_item_id)
return None
@classmethod @classmethod
def _build_action_card_from_capture_result( def _build_action_card_from_capture_result(
cls, cls,
@ -88,8 +72,7 @@ class FeishuCardActionMixin:
return None return None
template_key = str(capture_result.get("reply_template_key") or "").strip().lower() template_key = str(capture_result.get("reply_template_key") or "").strip().lower()
work_item_id = cls._coerce_capture_work_item_id(capture_result) work_item_id = cls._coerce_capture_work_item_id(capture_result)
queue_item_id = cls._coerce_capture_queue_item_id(capture_result) if work_item_id is None:
if template_key != cls._TEMPLATE_QUEUE_PENDING_CONFIRM and work_item_id is None:
return None return None
reply_text = str(capture_result.get("direct_reply_text") or "").strip() reply_text = str(capture_result.get("direct_reply_text") or "").strip()
safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。" safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。"
@ -177,35 +160,6 @@ class FeishuCardActionMixin:
"elements": [*base_elements, {"tag": "action", "actions": actions}], "elements": [*base_elements, {"tag": "action", "actions": actions}],
} }
if template_key == cls._TEMPLATE_QUEUE_PENDING_CONFIRM and queue_item_id is not None:
actions = [
{
"tag": "button",
"type": "primary",
"text": {"tag": "plain_text", "content": "继续执行"},
"value": {
"action": cls._ACTION_CONTINUE,
"queue_item_id": int(queue_item_id),
},
},
{
"tag": "button",
"type": "danger",
"text": {"tag": "plain_text", "content": "取消"},
"value": {
"action": cls._ACTION_CANCEL,
"queue_item_id": int(queue_item_id),
},
},
]
return {
"config": {"wide_screen_mode": True, "enable_forward": True},
"header": {
"title": {"tag": "plain_text", "content": "待确认动作"},
},
"elements": [*base_elements, {"tag": "action", "actions": actions}],
}
return None return None
@classmethod @classmethod
@ -241,11 +195,6 @@ class FeishuCardActionMixin:
or value.get("target_work_item_id") or value.get("target_work_item_id")
or value.get("workItemId") or value.get("workItemId")
) )
queue_item_id = cls._safe_int(
value.get("queue_item_id")
or value.get("item_id")
or value.get("queueItemId")
)
draft_version = cls._safe_int(value.get("draft_version") or value.get("version")) draft_version = cls._safe_int(value.get("draft_version") or value.get("version"))
revision_note = ( revision_note = (
str(value.get("revision_note") or value.get("note") or value.get("comment") or "") str(value.get("revision_note") or value.get("note") or value.get("comment") or "")
@ -276,7 +225,6 @@ class FeishuCardActionMixin:
return { return {
"action": normalized_action, "action": normalized_action,
"work_item_id": work_item_id, "work_item_id": work_item_id,
"queue_item_id": queue_item_id,
"draft_version": draft_version, "draft_version": draft_version,
"revision_note": revision_note, "revision_note": revision_note,
"source_message_id": source_message_id, "source_message_id": source_message_id,
@ -292,69 +240,20 @@ class FeishuCardActionMixin:
parsed = self._extract_card_action_payload(event) parsed = self._extract_card_action_payload(event)
action = str(parsed.get("action") or "").strip().lower() action = str(parsed.get("action") or "").strip().lower()
work_item_id = self._safe_int(parsed.get("work_item_id")) work_item_id = self._safe_int(parsed.get("work_item_id"))
queue_item_id = self._safe_int(parsed.get("queue_item_id"))
if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE}: if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE}:
return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "unsupported_action"}} return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "unsupported_action"}}
if (work_item_id is None or work_item_id <= 0) and (queue_item_id is None or queue_item_id <= 0): if work_item_id is None or work_item_id <= 0:
return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "missing_work_item_id"}} return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "missing_work_item_id"}}
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime import AIPMOKernelService from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime import AIPMOKernelService
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
from app.services.integrations.feishu_signal_pipeline import FeishuSignalPipelineService
operator_sender = str(parsed.get("sender_name") or "飞书用户").strip()[:80] or "飞书用户" operator_sender = str(parsed.get("sender_name") or "飞书用户").strip()[:80] or "飞书用户"
source_message_id = str(parsed.get("source_message_id") or "").strip()[:128] or None source_message_id = str(parsed.get("source_message_id") or "").strip()[:128] or None
chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None
sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None
try: try:
if queue_item_id is not None and queue_item_id > 0: if action == self._ACTION_CONTINUE:
pipeline = FeishuSignalPipelineService(self.db, tenant_id=self.tenant_id)
operator_user = await pipeline._resolve_command_operator_user(sender=operator_sender)
if operator_user is None:
raise ValueError("operator_not_found")
if action == self._ACTION_CANCEL:
queue_result = await pipeline.dismiss_item(
item_id=int(queue_item_id),
reason="feishu_card_cancelled",
)
normalized = {
"handled": True,
"status": "cancelled",
"followup_reply": f"已取消待处理项#{int(queue_item_id)}。",
"queue_item_id": int(queue_item_id),
"queue_status": str(queue_result.get("status") or "").strip() or None,
}
else:
queue_result = await pipeline.confirm_item(
item_id=int(queue_item_id),
user_id=int(operator_user.id),
)
followup_reply = f"已继续处理待处理项#{int(queue_item_id)}。"
if queue_result.get("source_registry_id") and queue_result.get("project_id"):
followup_reply = (
f"已继续处理:信号源注册#{queue_result['source_registry_id']}"
f" 已接入项目#{queue_result['project_id']}。"
)
elif queue_result.get("task_id") and queue_result.get("project_id"):
followup_reply = (
f"已继续处理:任务#{queue_result['task_id']}"
f" 已在项目#{queue_result['project_id']} 中创建。"
)
elif queue_result.get("project_id"):
followup_reply = f"已继续处理,结果已落到项目#{queue_result['project_id']}。"
normalized = {
"handled": True,
"status": "succeeded",
"followup_reply": followup_reply,
"queue_item_id": int(queue_item_id),
"project_id": self._safe_int(queue_result.get("project_id")),
"task_id": self._safe_int(queue_result.get("task_id")),
"signal_id": self._safe_int(queue_result.get("signal_id")),
"source_registry_id": self._safe_int(queue_result.get("source_registry_id")),
}
if hasattr(self.db, "commit") and callable(getattr(self.db, "commit")):
await self.db.commit()
elif action == self._ACTION_CONTINUE:
kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id) kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id)
result = await kernel.process_ingress( result = await kernel.process_ingress(
source_system="ai_pmo_ui", source_system="ai_pmo_ui",
@ -411,22 +310,15 @@ class FeishuCardActionMixin:
or normalized.get("work_item_id") or normalized.get("work_item_id")
or work_item_id or work_item_id
) )
target_queue_item_id = self._safe_int(
normalized.get("queue_item_id")
or queue_item_id
)
template_key = "" template_key = ""
if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM: if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM:
template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM
elif status in {"pending_manual", "pending_confirmation"}: elif status in {"pending_manual", "pending_confirmation"}:
template_key = self._TEMPLATE_PENDING_MANUAL template_key = self._TEMPLATE_PENDING_MANUAL
elif status in {"queued_for_decision", "pending_queue", "pending_confirmation_queue"}:
template_key = self._TEMPLATE_QUEUE_PENDING_CONFIRM
action_card = self._build_action_card_from_capture_result( action_card = self._build_action_card_from_capture_result(
capture_result={ capture_result={
"reply_template_key": template_key, "reply_template_key": template_key,
"work_item_id": target_work_item_id, "work_item_id": target_work_item_id,
"queue_item_id": target_queue_item_id,
"draft_version": self._safe_int(normalized.get("draft_version")), "draft_version": self._safe_int(normalized.get("draft_version")),
"direct_reply_text": followup_reply, "direct_reply_text": followup_reply,
}, },
@ -437,7 +329,7 @@ class FeishuCardActionMixin:
await self._safe_reply_card( await self._safe_reply_card(
chat_id=chat_id, chat_id=chat_id,
card_content=action_card, card_content=action_card,
trace_id=f"feishu-card-action-{int(target_work_item_id or target_queue_item_id or 0)}", trace_id=f"feishu-card-action-{int(target_work_item_id or 0)}",
source_message_id=source_message_id, source_message_id=source_message_id,
perf_t0=None, perf_t0=None,
) )
@ -447,7 +339,7 @@ class FeishuCardActionMixin:
mention_open_id=sender_open_id, mention_open_id=sender_open_id,
mention_name=operator_sender, mention_name=operator_sender,
text=followup_reply, text=followup_reply,
trace_id=f"feishu-card-action-{int(target_work_item_id or target_queue_item_id or 0)}", trace_id=f"feishu-card-action-{int(target_work_item_id or 0)}",
source_message_id=source_message_id, source_message_id=source_message_id,
perf_t0=None, perf_t0=None,
) )

View File

@ -121,23 +121,55 @@ class FeishuPipelineProcessMessageCoreMixin:
} }
inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) inferred_project_id = _safe_int(chat_context_resolution.get("project_id"))
followup_capture = await self._maybe_handle_queue_followup( wants_followup_continue = bool(FOLLOWUP_CONTINUE_RE.search(message))
message=message, wants_followup_status = bool(FOLLOWUP_STATUS_RE.search(message))
sender=sender, if wants_followup_continue or wants_followup_status:
source_chat_id=source_chat_id, duration_ms = int((time.perf_counter() - process_started_at) * 1000)
source_sender_open_id=source_sender_open_id, routing_decision = "legacy_queue_followup_retired"
feishu_message_id=feishu_message_id, classification = "command" if wants_followup_continue else "question"
process_started_at=process_started_at, reply_text = (
thread_context=thread_context, "历史飞书待处理队列追问已下线。"
) "请改为通过新的 AI PMO 工作项卡片继续处理,或使用新的 work_item 主链查询状态。"
if followup_capture is not None: )
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="followup",
routing_decision=routing_decision,
routing_reason="旧 feishu_items 队列 followup 已退场,历史文本追问入口不再继续执行旧队列。",
reasoning_bullets=[
"命中 continue/status followup 语义。",
"旧 feishu_items 队列 followup 已停用。",
"系统明确返回迁移提示,不再自动确认或查询旧队列。",
],
processing_status="success",
error_message=None,
duration_ms=duration_ms,
)
await self.db.commit() await self.db.commit()
return self._decorate_capture_result( return self._decorate_capture_result(
followup_capture, {
"status": "answered",
"reason": routing_decision,
"classification": classification,
"project_scope": classification,
"linked_project_ids": [],
"signal_id": None,
"created_project_id": None,
"auto_tasks": [],
"ai_trace_id": f"feishu-followup-retired-{(feishu_message_id or 'noid')[:32]}",
"results": [],
"direct_reply_required": True,
"direct_reply_text": reply_text,
},
chat_type=normalized_chat_type, chat_type=normalized_chat_type,
normalized_text=message, normalized_text=message,
route=f"{normalized_chat_type}.followup", route=f"{normalized_chat_type}.legacy_queue_followup_retired",
db_mutations=self._summarize_db_mutations(followup_capture), db_mutations=[],
) )
lookup_capture = await self._maybe_answer_project_lookup_question( lookup_capture = await self._maybe_answer_project_lookup_question(

View File

@ -17,8 +17,6 @@ from .mixin_process_message_core import FeishuPipelineProcessMessageCoreMixin
from .mixin_process_message_default import FeishuPipelineProcessMessageDefaultMixin from .mixin_process_message_default import FeishuPipelineProcessMessageDefaultMixin
from .mixin_process_message_helpers_a import FeishuPipelineProcessMessageHelpersAMixin from .mixin_process_message_helpers_a import FeishuPipelineProcessMessageHelpersAMixin
from .mixin_process_message_helpers_b import FeishuPipelineProcessMessageHelpersBMixin from .mixin_process_message_helpers_b import FeishuPipelineProcessMessageHelpersBMixin
from .mixin_followup_a import FeishuPipelineFollowupAMixin
from .mixin_followup_b import FeishuPipelineFollowupBMixin
from .mixin_lookup_and_block import FeishuPipelineLookupAndBlockMixin from .mixin_lookup_and_block import FeishuPipelineLookupAndBlockMixin
from .mixin_queue_read_write import FeishuPipelineQueueReadWriteMixin from .mixin_queue_read_write import FeishuPipelineQueueReadWriteMixin
from .mixin_queue_confirm_undo import FeishuPipelineQueueConfirmUndoMixin from .mixin_queue_confirm_undo import FeishuPipelineQueueConfirmUndoMixin
@ -49,8 +47,6 @@ class FeishuSignalPipelineService(
FeishuPipelineProcessMessageDefaultMixin, FeishuPipelineProcessMessageDefaultMixin,
FeishuPipelineProcessMessageHelpersAMixin, FeishuPipelineProcessMessageHelpersAMixin,
FeishuPipelineProcessMessageHelpersBMixin, FeishuPipelineProcessMessageHelpersBMixin,
FeishuPipelineFollowupAMixin,
FeishuPipelineFollowupBMixin,
FeishuPipelineLookupAndBlockMixin, FeishuPipelineLookupAndBlockMixin,
FeishuPipelineQueueReadWriteMixin, FeishuPipelineQueueReadWriteMixin,
FeishuPipelineQueueConfirmUndoMixin, FeishuPipelineQueueConfirmUndoMixin,

View File

@ -41,6 +41,33 @@ from ...ai_pmo.ai_pmo_kernel import (
gate_execution as kernel_gate_execution, gate_execution as kernel_gate_execution,
resolve_correction as kernel_resolve_correction, resolve_correction as kernel_resolve_correction,
) )
from ...ai_pmo.workflow_semantic_planner.constants import (
CORRECTION_CUE_RE,
CREATE_ACTION_RE,
EXTERNAL_INTEGRATION_RE,
FOLLOWUP_CONTINUE_RE,
FOLLOWUP_CREATE_CONTINUE_RE,
FOLLOWUP_REFERENCE_RE,
FOLLOWUP_STATUS_RE,
HEALTHCHECK_RE,
INDEPENDENT_REQUIREMENT_RE,
LEGACY_COMMAND_OBJECT_RE,
LEGACY_COMMAND_PROJECT_CREATE_RE,
LEGACY_COMMAND_VERB_RE,
OBVIOUS_QUESTION_RE,
OWNER_RE,
PROJECT_COMPLEXITY_RE,
PROJECT_ID_QUERY_RE,
PROJECT_INTENT_RE,
PROJECT_LIST_QUERY_RE,
QUERY_INTENT_RE,
QUESTION_REQUIREMENT_OBJECT_RE,
QUESTION_REQUIREMENT_VERB_RE,
SHORT_CONFIRM_RE,
SIGNAL_FACT_RE,
SIMPLE_SIGNAL_REQUEST_RE,
TIMELINE_RE,
)
from ...context_cache_service import context_cache_service from ...context_cache_service import context_cache_service
from ...notification_service import NotificationService from ...notification_service import NotificationService
from ...operation_log_service import log_operation from ...operation_log_service import log_operation
@ -68,76 +95,10 @@ PROJECT_PRIORITY_ORDER = {
} }
SIGNAL_TYPE_OPTIONS = {"竞品动态", "用户反馈", "项目风险", "新需求"} SIGNAL_TYPE_OPTIONS = {"竞品动态", "用户反馈", "项目风险", "新需求"}
PROJECT_INTENT_RE = re.compile(r"(立项|新项目|我们要做|需要开发|启动|规划)", re.IGNORECASE)
SIGNAL_FACT_RE = re.compile(r"(竞品|上线|发布|反馈|风险|bug|故障|告警|投诉)", re.IGNORECASE)
TIMELINE_RE = re.compile(r"(\d+\s*(天|周|月)|本周|本月|月底|下周|下月|Q[1-4]|\d{1,2}月\d{1,2}日)")
OWNER_RE = re.compile(r"(由|负责人|owner|owner:|@)(\S+)")
INDEPENDENT_REQUIREMENT_RE = re.compile(
r"(独立需求|单独需求|单开|另起|新增需求|新建项目|新增项目|单独立项|开个新|做个新)",
re.IGNORECASE,
)
SIMPLE_SIGNAL_REQUEST_RE = re.compile(
r"(新增|增加|加一个|加个|补充|支持|实现|接入|优化|修复|改进).{0,18}(功能|能力|接口|页面|按钮|流程|识别|提醒|校验|报表|字段|规则|模块)",
re.IGNORECASE,
)
PROJECT_COMPLEXITY_RE = re.compile(
r"(立项|新项目|项目章程|里程碑|预算|排期|roadmap|跨团队|跨部门|季度目标|年度目标|okr)",
re.IGNORECASE,
)
QUESTION_REQUIREMENT_VERB_RE = re.compile(
r"(新增|增加|加一个|加个|支持|接入|实现|优化|修复|改进|调整|补充|完善|同步|对齐|落库|入库)",
re.IGNORECASE,
)
QUESTION_REQUIREMENT_OBJECT_RE = re.compile(
r"(功能|能力|接口|页面|按钮|流程|校验|报表|字段|规则|模块|任务|项目|需求|通知|提醒|配置|归属|队列|信号)",
re.IGNORECASE,
)
PROJECT_LIST_QUERY_RE = re.compile(
r"(现在|当前|目前|系统里|系统中|项目里|项目中)?.{0,4}(有哪些项目|项目列表|项目清单|哪(些|几个)项目)",
re.IGNORECASE,
)
PROJECT_ID_QUERY_RE = re.compile(
r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)",
re.IGNORECASE,
)
LEGACY_COMMAND_PROJECT_CREATE_RE = re.compile(
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目",
re.IGNORECASE,
)
LEGACY_COMMAND_VERB_RE = re.compile(
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)",
re.IGNORECASE,
)
LEGACY_COMMAND_OBJECT_RE = re.compile(
r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)",
re.IGNORECASE,
)
FOLLOWUP_CONTINUE_RE = re.compile(r"(继续处理|继续推进|继续执行|接着处理|继续跑)", re.IGNORECASE)
FOLLOWUP_CREATE_CONTINUE_RE = re.compile(r"(继续创建|接着创建|继续建|接着建)", re.IGNORECASE)
FOLLOWUP_STATUS_RE = re.compile(
r"(进度|状态|处理到哪|处理了吗|现在怎么样|怎么样了|结果呢|是否已经创建|已经创建了吗|创建了吗|创建成功了吗|建好了吗|建成了吗)",
re.IGNORECASE,
)
FOLLOWUP_REFERENCE_RE = re.compile(
r"(刚才|刚刚|前面|之前|上次|上一条|我刚才要求|我刚才说|我说的|那个)",
re.IGNORECASE,
)
OBVIOUS_QUESTION_RE = re.compile(
r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|什么|哪些|有哪些|在哪|哪一个|哪几个|特点|能力|介绍|说明|风险)",
re.IGNORECASE,
)
QUERY_INTENT_RE = re.compile(
r"(哪些|有哪些|特点|技术特点|能力|介绍|说明|怎么|如何|是什么|什么|能做什么|可以做什么|项目有哪些风险)",
re.IGNORECASE,
)
FEISHU_BOT_MENTION_RE = re.compile(r"(?:<at[^>]*>[^<]*</at>|@pmflow\b|@_user_1\b)", re.IGNORECASE) FEISHU_BOT_MENTION_RE = re.compile(r"(?:<at[^>]*>[^<]*</at>|@pmflow\b|@_user_1\b)", re.IGNORECASE)
FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*") FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*")
FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$") FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$")
FEISHU_NOISE_TOKEN_RE = re.compile(r"\[(?:附带图片|图片|表情|文件|语音|消息卡片)\]", re.IGNORECASE) FEISHU_NOISE_TOKEN_RE = re.compile(r"\[(?:附带图片|图片|表情|文件|语音|消息卡片)\]", re.IGNORECASE)
EXTERNAL_INTEGRATION_RE = re.compile(
r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)",
re.IGNORECASE,
)
CROSS_SYSTEM_RE = re.compile(r"(跨系统|跨平台|跨团队|外部系统|第三方系统)", re.IGNORECASE) CROSS_SYSTEM_RE = re.compile(r"(跨系统|跨平台|跨团队|外部系统|第三方系统)", re.IGNORECASE)
TASK_CREATE_RE = re.compile( TASK_CREATE_RE = re.compile(
r"(新增|新建|创建|加一个|加个|补一个|补个).{0,8}任务", r"(新增|新建|创建|加一个|加个|补一个|补个).{0,8}任务",
@ -147,19 +108,6 @@ PROJECT_CREATE_GATE_RE = re.compile(
r"(新增|新建|创建|建立|立项|放到|放进|加入|录入|进).{0,18}(pmflow|项目列表|项目库|项目台账|项目主数据|一个项目|项目)", r"(新增|新建|创建|建立|立项|放到|放进|加入|录入|进).{0,18}(pmflow|项目列表|项目库|项目台账|项目主数据|一个项目|项目)",
re.IGNORECASE, re.IGNORECASE,
) )
HEALTHCHECK_RE = re.compile(
r"^(?:ping|pong|health(?:check)?|在线吗|活着吗|存活吗|还在吗|服务正常吗)\??$",
re.IGNORECASE,
)
SHORT_CONFIRM_RE = re.compile(
r"^(?:收到|好的|好|ok|okay|明白|了解|知道了|收到啦|嗯嗯|行|辛苦了|thanks?|thx)[!!.。]?$",
re.IGNORECASE,
)
CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE)
CORRECTION_CUE_RE = re.compile(
r"(不对|错了|理解错|不是这个|不是这个意思|我说的是|我要的是|不是啊|不是新增 md|不是写 markdown)",
re.IGNORECASE,
)
ROUTING_AUTO_CREATE_PROJECT = "auto_create_project" ROUTING_AUTO_CREATE_PROJECT = "auto_create_project"
ROUTING_AUTO_ENTER_SIGNAL = "auto_enter_signal" ROUTING_AUTO_ENTER_SIGNAL = "auto_enter_signal"

View File

@ -41,12 +41,12 @@ class PKGCycleRunMixin:
stage_results["structured_delta"] = {"error": stage_errors["structured_delta"]} stage_results["structured_delta"] = {"error": stage_errors["structured_delta"]}
try: try:
stage_results["feishu_enqueue"] = await self.enqueue_feishu_delta_events(limit=min(delta_limit, 1000)) stage_results["work_item_enqueue"] = await self.enqueue_work_item_delta_events(limit=min(delta_limit, 1000))
success_count += 1 success_count += 1
except Exception as exc: except Exception as exc:
await self.db.rollback() await self.db.rollback()
stage_errors["feishu_enqueue"] = str(exc)[:500] stage_errors["work_item_enqueue"] = str(exc)[:500]
stage_results["feishu_enqueue"] = {"error": stage_errors["feishu_enqueue"]} stage_results["work_item_enqueue"] = {"error": stage_errors["work_item_enqueue"]}
try: try:
stage_results["event_processing"] = await self.process_pending_events(max_batch=event_batch) stage_results["event_processing"] = await self.process_pending_events(max_batch=event_batch)
@ -147,4 +147,3 @@ class PKGCycleRunMixin:
started_at_hint=run_started_at, started_at_hint=run_started_at,
) )
return payload return payload

View File

@ -2,37 +2,55 @@ from __future__ import annotations
from .shared import * from .shared import *
_WORK_ITEM_DELTA_CHANNEL = "work_item_delta"
_WORK_ITEM_DELTA_OFFSET_KEY = "last_work_item_updated_at"
_LEGACY_FEISHU_DELTA_CHANNEL = "feishu_delta"
_LEGACY_FEISHU_DELTA_OFFSET_KEY = "last_feishu_item_updated_at"
class PKGEventQueueMixin: class PKGEventQueueMixin:
async def enqueue_feishu_delta_events(self, *, limit: int = 500) -> dict[str, Any]: async def enqueue_work_item_delta_events(self, *, limit: int = 500) -> dict[str, Any]:
max_rows = max(1, min(int(limit), 2000)) max_rows = max(1, min(int(limit), 2000))
last_raw = await self.get_ingest_offset(channel="feishu_delta", offset_key="last_feishu_item_updated_at") last_raw = await self.get_ingest_offset(
channel=_WORK_ITEM_DELTA_CHANNEL,
offset_key=_WORK_ITEM_DELTA_OFFSET_KEY,
)
if not last_raw:
last_raw = await self.get_ingest_offset(
channel=_LEGACY_FEISHU_DELTA_CHANNEL,
offset_key=_LEGACY_FEISHU_DELTA_OFFSET_KEY,
)
last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1)) last_dt = self._parse_iso_dt(last_raw) or (datetime(1970, 1, 1))
rows = ( rows = (
await self.db.execute( await self.db.execute(
select(FeishuItem) select(WorkItem)
.where( .where(
and_( and_(
FeishuItem.tenant_id == self.tenant_id, WorkItem.tenant_id == self.tenant_id,
FeishuItem.updated_at >= last_dt, WorkItem.source_system == "feishu",
WorkItem.updated_at >= last_dt,
) )
) )
.order_by(FeishuItem.updated_at.asc(), FeishuItem.id.asc()) .order_by(WorkItem.updated_at.asc(), WorkItem.id.asc())
.limit(max_rows) .limit(max_rows)
) )
).scalars().all() ).scalars().all()
enqueued = 0 enqueued = 0
newest_seen = last_dt newest_seen = last_dt
for item in rows: for item in rows:
event_key = str(item.feishu_message_id or f"feishu_item:{int(item.id)}") event_key = str(item.source_message_id or f"work_item:{int(item.id)}")
await self.enqueue_ingest_event( await self.enqueue_ingest_event(
channel="feishu_delta", channel=_WORK_ITEM_DELTA_CHANNEL,
event_key=event_key[:128], event_key=event_key[:128],
payload={ payload={
"feishu_item_id": int(item.id), "work_item_id": int(item.id),
"matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, "project_id": int(item.project_id) if item.project_id is not None else None,
"level": str(item.level or ""), "signal_id": int(item.signal_id) if item.signal_id is not None else None,
"route_status": str(item.route_status or ""), "state": str(item.state or ""),
"intent": str(item.intent or ""),
"source_system": str(item.source_system or ""),
"source_message_id": str(item.source_message_id or "")[:128] or None,
}, },
status="pending", status="pending",
) )
@ -41,8 +59,8 @@ class PKGEventQueueMixin:
newest_seen = item.updated_at newest_seen = item.updated_at
await self.set_ingest_offset( await self.set_ingest_offset(
channel="feishu_delta", channel=_WORK_ITEM_DELTA_CHANNEL,
offset_key="last_feishu_item_updated_at", offset_key=_WORK_ITEM_DELTA_OFFSET_KEY,
offset_value=newest_seen.isoformat(), offset_value=newest_seen.isoformat(),
payload={"batch_size": len(rows)}, payload={"batch_size": len(rows)},
) )
@ -54,6 +72,9 @@ class PKGEventQueueMixin:
"candidate_count": len(rows), "candidate_count": len(rows),
} }
async def enqueue_feishu_delta_events(self, *, limit: int = 500) -> dict[str, Any]:
return await self.enqueue_work_item_delta_events(limit=limit)
async def process_pending_events(self, *, max_batch: int = 200, max_retry: int = 3) -> dict[str, Any]: async def process_pending_events(self, *, max_batch: int = 200, max_retry: int = 3) -> dict[str, Any]:
batch = max(1, min(int(max_batch), 1000)) batch = max(1, min(int(max_batch), 1000))
retries = max(0, min(int(max_retry), 10)) retries = max(0, min(int(max_retry), 10))
@ -76,8 +97,8 @@ class PKGEventQueueMixin:
for ev in rows: for ev in rows:
stats["processed"] += 1 stats["processed"] += 1
try: try:
if str(ev.channel) == "feishu_delta": if str(ev.channel) in {_WORK_ITEM_DELTA_CHANNEL, _LEGACY_FEISHU_DELTA_CHANNEL}:
await self._process_feishu_event(ev) await self._process_delta_event(ev)
ev.status = "done" ev.status = "done"
ev.error_message = None ev.error_message = None
ev.processed_at = _utc_now_naive() ev.processed_at = _utc_now_naive()
@ -96,8 +117,13 @@ class PKGEventQueueMixin:
await self.db.commit() await self.db.commit()
return stats return stats
async def _process_feishu_event(self, event: IngestEvent) -> None: async def _process_delta_event(self, event: IngestEvent) -> None:
payload = event.payload if isinstance(event.payload, dict) else {} payload = event.payload if isinstance(event.payload, dict) else {}
work_item_id = payload.get("work_item_id")
if work_item_id is not None:
await self._process_work_item_event(event, work_item_id=int(work_item_id))
return
item_id = payload.get("feishu_item_id") item_id = payload.get("feishu_item_id")
if item_id is None: if item_id is None:
return return
@ -133,8 +159,8 @@ class PKGEventQueueMixin:
entity_id=int(signal_entity.id), entity_id=int(signal_entity.id),
source_type="feishu", source_type="feishu",
source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255], source_ref=str(item.feishu_message_id or f"item:{int(item.id)}")[:255],
metadata={"feishu_item_id": int(item.id)}, metadata={"feishu_item_id": int(item.id)},
) )
if item.matched_project_id is not None: if item.matched_project_id is not None:
project_entity = await self.upsert_entity( project_entity = await self.upsert_entity(
@ -161,3 +187,91 @@ class PKGEventQueueMixin:
metadata={"feishu_item_id": int(item.id)}, metadata={"feishu_item_id": int(item.id)},
) )
async def _process_work_item_event(self, event: IngestEvent, *, work_item_id: int) -> None:
work_item = (
await self.db.execute(
select(WorkItem).where(
and_(
WorkItem.tenant_id == self.tenant_id,
WorkItem.id == int(work_item_id),
)
)
)
).scalar_one_or_none()
if work_item is None:
return
run = (
await self.db.execute(
select(AIOrchestrationRun)
.where(
and_(
AIOrchestrationRun.tenant_id == self.tenant_id,
AIOrchestrationRun.work_item_id == int(work_item_id),
)
)
.order_by(AIOrchestrationRun.id.desc())
.limit(1)
)
).scalar_one_or_none()
source_ref = (
str((run.source_message_id if run is not None else None) or work_item.source_message_id or f"work_item:{int(work_item.id)}")[:255]
)
message_text = str((run.message_text if run is not None else "") or "").strip()
work_item_entity = await self.upsert_entity(
entity_type="work_item",
entity_key=f"work_item:{int(work_item.id)}",
display_name=(message_text or f"WorkItem#{int(work_item.id)}")[:255],
payload={
"work_item_id": int(work_item.id),
"state": str(work_item.state or ""),
"intent": str(work_item.intent or ""),
"project_id": int(work_item.project_id) if work_item.project_id is not None else None,
"signal_id": int(work_item.signal_id) if work_item.signal_id is not None else None,
"source_system": str(work_item.source_system or ""),
"source_message_id": str(work_item.source_message_id or "")[:128] or None,
"ai_orchestration_run_id": int(run.id) if run is not None else None,
},
source_type=str(work_item.source_system or "ai_pmo"),
source_ref=source_ref,
)
await self.add_evidence(
evidence_type="work_item_delta",
content_excerpt=message_text or f"work_item#{int(work_item.id)}",
entity_id=int(work_item_entity.id),
source_type=str(work_item.source_system or "ai_pmo"),
source_ref=source_ref,
metadata={
"work_item_id": int(work_item.id),
"ai_orchestration_run_id": int(run.id) if run is not None else None,
},
)
if work_item.project_id is not None:
project_entity = await self.upsert_entity(
entity_type="project",
entity_key=f"project:{int(work_item.project_id)}",
display_name=f"Project#{int(work_item.project_id)}",
payload={"project_id": int(work_item.project_id)},
source_type="db",
source_ref=f"projects:{int(work_item.project_id)}",
)
rel = await self.upsert_relation(
relation_type="belongs_to",
subject_entity_id=int(work_item_entity.id),
object_entity_id=int(project_entity.id),
source_type=str(work_item.source_system or "ai_pmo"),
source_ref=source_ref,
)
await self.add_evidence(
evidence_type="work_item_delta",
content_excerpt=(
message_text
or f"work_item#{int(work_item.id)} belongs_to project#{int(work_item.project_id)}"
)[:1000],
relation_id=int(rel.id),
source_type=str(work_item.source_system or "ai_pmo"),
source_ref=source_ref,
metadata={"work_item_id": int(work_item.id)},
)

View File

@ -148,12 +148,13 @@ class PKGSyncRunsMixin:
) )
).scalar_one() ).scalar_one()
async def _freshness_minutes(channel: str, offset_key: str) -> Optional[int]: async def _freshness_minutes(entries: list[tuple[str, str]]) -> Optional[int]:
raw = await self.get_ingest_offset(channel=channel, offset_key=offset_key) for channel, offset_key in entries:
dt = self._parse_iso_dt(raw) raw = await self.get_ingest_offset(channel=channel, offset_key=offset_key)
if dt is None: dt = self._parse_iso_dt(raw)
return None if dt is not None:
return max(int((now - dt).total_seconds() // 60), 0) return max(int((now - dt).total_seconds() // 60), 0)
return None
return { return {
"tenant_id": self.tenant_id, "tenant_id": self.tenant_id,
@ -170,11 +171,15 @@ class PKGSyncRunsMixin:
"dead_letter_events": int(dead_letter or 0), "dead_letter_events": int(dead_letter or 0),
"pending_events": int(pending_events or 0), "pending_events": int(pending_events or 0),
"freshness_minutes": { "freshness_minutes": {
"db_structured": await _freshness_minutes("db_delta", "last_structured_sync_at"), "db_structured": await _freshness_minutes([("db_delta", "last_structured_sync_at")]),
"feishu_delta": await _freshness_minutes("feishu_delta", "last_feishu_item_updated_at"), "work_item_delta": await _freshness_minutes(
"code_scan": await _freshness_minutes("code_scan", "last_code_scan_at"), [
"symbol_scan": await _freshness_minutes("symbol_scan", "last_symbol_scan_at"), ("work_item_delta", "last_work_item_updated_at"),
"feature_catalog": await _freshness_minutes("feature_catalog", "last_feature_catalog_sync_at"), ("feishu_delta", "last_feishu_item_updated_at"),
]
),
"code_scan": await _freshness_minutes([("code_scan", "last_code_scan_at")]),
"symbol_scan": await _freshness_minutes([("symbol_scan", "last_symbol_scan_at")]),
"feature_catalog": await _freshness_minutes([("feature_catalog", "last_feature_catalog_sync_at")]),
}, },
} }

View File

@ -20,6 +20,7 @@ from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ...models import ( from ...models import (
AIOrchestrationRun,
IngestEvent, IngestEvent,
IngestOffset, IngestOffset,
KGEvidence, KGEvidence,
@ -33,6 +34,7 @@ from ...models import (
SymbolReference, SymbolReference,
Task, Task,
User, User,
WorkItem,
) )
from ..context_cache_service import context_cache_service from ..context_cache_service import context_cache_service
from ..signal.symbol_index import SymbolIndexService from ..signal.symbol_index import SymbolIndexService

View File

@ -254,18 +254,6 @@ class SignalCaptureEntryMixin:
"direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。", "direct_reply_text": "系统处理中出现异常,本次未执行入库。请稍后重试,或补充更明确的目标/对象/动作。",
} }
return await self._capture_from_feishu_legacy_flow(
trace_id=trace_id,
group_chat_id=group_chat_id,
message_content=message_content,
cleaned_message=cleaned_message,
proposer_name=proposer_name,
proposer_open_id=proposer_open_id,
mentioned_bot_names=mentioned_bot_names,
tenant_id=tenant_id,
captured_dt=captured_dt,
)
async def _process_feishu_with_kernel_sync( async def _process_feishu_with_kernel_sync(
self, self,
*, *,

View File

@ -41,6 +41,11 @@ from ...llm_service import LLMService
from ...notification_service import NotificationService from ...notification_service import NotificationService
from ... import workload as workload_service from ... import workload as workload_service
from ...adaptive_rag_service import AdaptiveRAGService from ...adaptive_rag_service import AdaptiveRAGService
from ...ai_pmo.workflow_semantic_planner.constants import (
NON_REQUIREMENT_CONTAINS,
NON_REQUIREMENT_EXACT,
REQUIREMENT_HINTS,
)
from ...ai_orchestration_run import AIOrchestrationRunService from ...ai_orchestration_run import AIOrchestrationRunService
from ...context_cache_service import context_cache_service from ...context_cache_service import context_cache_service
from ..signal_source_orchestration import SignalSourceOrchestrationService from ..signal_source_orchestration import SignalSourceOrchestrationService
@ -59,71 +64,6 @@ EMOJI_UNICODE_RE = re.compile(
ONLY_PUNCT_OR_SPACE_RE = re.compile(r"^[\s\W_]+$", re.UNICODE) ONLY_PUNCT_OR_SPACE_RE = re.compile(r"^[\s\W_]+$", re.UNICODE)
MENTION_TAG_RE = re.compile(r"</?at[^>]*>", re.IGNORECASE) MENTION_TAG_RE = re.compile(r"</?at[^>]*>", re.IGNORECASE)
CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+") CONTROL_CHAR_RE = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+")
NON_REQUIREMENT_EXACT = {
"收到",
"收到啦",
"好的",
"ok",
"okk",
"okay",
"yes",
"no",
"1",
"2",
"3",
"thanks",
"thx",
"赞",
"辛苦",
"你是谁",
"你是誰",
"你是谁?",
"你是誰?",
"说话",
"在吗",
"在不在",
"今天什么日期",
"今天几号",
"今天星期几",
"现在几点",
"当前日期",
}
NON_REQUIREMENT_CONTAINS = [
"你是谁",
"什么身份",
"身份确认",
"今天什么日期",
"当前日期",
"日期疑问",
"询问日期",
"现在几点",
"几点了",
"今天周几",
"今天星期几",
"说话",
"在吗",
"在不在",
]
REQUIREMENT_HINTS = [
"需求",
"优化",
"改一下",
"调整",
"修复",
"bug",
"故障",
"报错",
"项目",
"页面",
"接口",
"功能",
"排版",
"样式",
"任务",
"立项",
"上线",
"配置",
]
logger = get_logger(__name__) logger = get_logger(__name__)
AI_THINK_STAGE_ZH = { AI_THINK_STAGE_ZH = {

View File

@ -159,6 +159,7 @@ class SignalSourceRegistryExecuteMixin:
}, },
"analysis": analysis if isinstance(analysis, dict) else None, "analysis": analysis if isinstance(analysis, dict) else None,
"priority": item["priority"], "priority": item["priority"],
"parent_signal_id": int(signal_id) if signal_id is not None else None,
}, },
} }
normalized_source = self._normalize_source_payload( normalized_source = self._normalize_source_payload(
@ -183,11 +184,12 @@ class SignalSourceRegistryExecuteMixin:
}, },
"analysis": analysis if isinstance(analysis, dict) else None, "analysis": analysis if isinstance(analysis, dict) else None,
"item_index": index, "item_index": index,
"parent_signal_id": int(signal_id) if signal_id is not None else None,
} }
registry_result = await self._create_or_reuse_registry( registry_result = await self._create_or_reuse_registry(
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,
signal_id=signal_id, signal_id=None,
operator_user_id=operator_user_id, operator_user_id=operator_user_id,
requested_via=requested_via, requested_via=requested_via,
source_payload=normalized_source, source_payload=normalized_source,
@ -210,6 +212,142 @@ class SignalSourceRegistryExecuteMixin:
) )
return results return results
async def _ensure_project_signal_link(
self,
*,
tenant_id: int,
project_id: int,
signal_id: int,
) -> None:
existing = (
await self.db.execute(
select(ProjectSignalLink.id).where(
and_(
ProjectSignalLink.tenant_id == int(tenant_id),
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == int(signal_id),
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
self.db.add(
ProjectSignalLink(
tenant_id=int(tenant_id),
project_id=int(project_id),
signal_id=int(signal_id),
link_type="related",
relevance_score=1.0,
)
)
@staticmethod
def _build_registry_signal_source_value(*, source_identifier: Any, source_kind: Any) -> str:
candidate = str(source_identifier or "").strip() or str(source_kind or "").strip() or "signal_source"
return candidate[:50]
def _build_registry_signal_priority(
self,
*,
row: SignalSourceRegistry,
) -> str:
config = row.config if isinstance(row.config, dict) else {}
raw_context = row.raw_context if isinstance(row.raw_context, dict) else {}
requirement_item = raw_context.get("requirement_item") if isinstance(raw_context.get("requirement_item"), dict) else {}
priority = (
config.get("priority")
or requirement_item.get("priority")
or "medium"
)
return self._normalize_requirement_priority(priority)
@staticmethod
def _build_registry_signal_content(
*,
row: SignalSourceRegistry,
) -> str:
lines = [f"信号源:{str(row.source_name or '未命名信号源').strip()}"]
description = str(row.description or "").strip()
if description:
lines.append(description)
source_kind = str(row.source_kind or "").strip()
if source_kind:
lines.append(f"来源类型:{source_kind}")
source_identifier = str(row.source_identifier or "").strip()
if source_identifier:
lines.append(f"来源标识:{source_identifier}")
return "\n".join(line for line in lines if line).strip()[:2000]
@staticmethod
def _build_registry_signal_analysis(
*,
row: SignalSourceRegistry,
project_id: int,
requested_via: str,
) -> str:
payload = {
"source_context": {
"registry_id": int(row.id),
"source_kind": str(row.source_kind or "").strip(),
"source_name": str(row.source_name or "").strip(),
"source_identifier": str(row.source_identifier or "").strip(),
"requested_via": str(requested_via or row.requested_via or "").strip(),
},
"ai_analysis": {
"requirement_project_scope": "existing_project",
"reason": "signal_source_registry_materialized",
"target_project_hints": [int(project_id)],
},
}
return json.dumps(payload, ensure_ascii=False)
async def _materialize_registry_signal(
self,
*,
tenant_id: int,
project_id: int,
row: SignalSourceRegistry,
requested_via: str,
) -> Signal:
summary = str(row.description or row.source_name or "信号源").strip()
signal = Signal(
tenant_id=int(tenant_id),
source=self._build_registry_signal_source_value(
source_identifier=row.source_identifier,
source_kind=row.source_kind,
),
content=self._build_registry_signal_content(row=row),
impact_score=0.6,
priority=self._build_registry_signal_priority(row=row),
processed=False,
strategic_analysis=self._build_registry_signal_analysis(
row=row,
project_id=project_id,
requested_via=requested_via,
),
tasks_generated=json.dumps([], ensure_ascii=False),
matched_project_id=int(project_id),
title=(str(row.source_name or "未命名信号源").strip() or "未命名信号源")[:80],
signal_type="新需求",
summary=summary[:300] or "信号源物化记录",
reasoning=json.dumps(["由 signal_source_registry 直接物化为正式 Signal。"], ensure_ascii=False),
confidence=0.85,
recommended_action="enter_only",
status="待处理",
)
self.db.add(signal)
await self.db.flush()
await self._ensure_project_signal_link(
tenant_id=tenant_id,
project_id=project_id,
signal_id=int(signal.id),
)
row.signal_id = int(signal.id)
row.last_signal_at = _utc_now_naive()
await self.db.flush()
return signal
async def _create_or_reuse_registry( async def _create_or_reuse_registry(
self, self,
*, *,
@ -265,9 +403,23 @@ class SignalSourceRegistryExecuteMixin:
self.db.add(row) self.db.add(row)
await self.db.flush() await self.db.flush()
await self.db.refresh(row) await self.db.refresh(row)
if row.signal_id is None:
await self._materialize_registry_signal(
tenant_id=tenant_id,
project_id=project_id,
row=row,
requested_via=requested_via,
)
else:
await self._ensure_project_signal_link(
tenant_id=tenant_id,
project_id=project_id,
signal_id=int(row.signal_id),
)
return { return {
"status": "created", "status": "created",
"registry_id": int(row.id), "registry_id": int(row.id),
"signal_id": int(row.signal_id) if row.signal_id is not None else None,
"source_kind": str(row.source_kind or ""), "source_kind": str(row.source_kind or ""),
"source_name": str(row.source_name or ""), "source_name": str(row.source_name or ""),
"source_identifier": str(row.source_identifier or ""), "source_identifier": str(row.source_identifier or ""),
@ -290,7 +442,8 @@ class SignalSourceRegistryExecuteMixin:
).scalar_one_or_none() ).scalar_one_or_none()
if existing is None: if existing is None:
raise raise
existing.signal_id = signal_id if existing.signal_id is None and signal_id is not None:
existing.signal_id = int(signal_id)
if str(normalized_source.get("source_name") or "").strip(): if str(normalized_source.get("source_name") or "").strip():
existing.source_name = str(normalized_source.get("source_name") or "").strip()[:MAX_SOURCE_NAME_LEN] existing.source_name = str(normalized_source.get("source_name") or "").strip()[:MAX_SOURCE_NAME_LEN]
existing.description = str(normalized_source.get("description") or existing.description or "").strip() or None existing.description = str(normalized_source.get("description") or existing.description or "").strip() or None
@ -306,11 +459,28 @@ class SignalSourceRegistryExecuteMixin:
if not isinstance(existing.config, dict) or not existing.config: if not isinstance(existing.config, dict) or not existing.config:
existing.status = "pending" existing.status = "pending"
existing.onboarding_status = "pending" existing.onboarding_status = "pending"
if existing.signal_id is None:
if signal_id is not None:
existing.signal_id = int(signal_id)
else:
await self._materialize_registry_signal(
tenant_id=tenant_id,
project_id=project_id,
row=existing,
requested_via=requested_via,
)
if existing.signal_id is not None:
await self._ensure_project_signal_link(
tenant_id=tenant_id,
project_id=project_id,
signal_id=int(existing.signal_id),
)
existing.last_signal_at = _utc_now_naive() existing.last_signal_at = _utc_now_naive()
await self.db.flush() await self.db.flush()
return { return {
"status": "reused", "status": "reused",
"registry_id": int(existing.id), "registry_id": int(existing.id),
"signal_id": int(existing.signal_id) if existing.signal_id is not None else None,
"source_kind": str(existing.source_kind or ""), "source_kind": str(existing.source_kind or ""),
"source_name": str(existing.source_name or ""), "source_name": str(existing.source_name or ""),
"source_identifier": str(existing.source_identifier or ""), "source_identifier": str(existing.source_identifier or ""),

View File

@ -23,6 +23,7 @@ from ....config.ai_prompts import (
from ....models import ( from ....models import (
AIWorkerExecution, AIWorkerExecution,
Project, Project,
ProjectSignalLink,
Signal, Signal,
SignalSourceRegistry, SignalSourceRegistry,
User, User,

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from .shared import Optional, SignalSourceRegistry, User, and_, select from .shared import Optional, ProjectSignalLink, Signal, SignalSourceRegistry, User, and_, select
class SignalSourceRepairDataAccessMixin: class SignalSourceRepairDataAccessMixin:
@ -37,7 +37,9 @@ class SignalSourceRepairDataAccessMixin:
row: SignalSourceRegistry, row: SignalSourceRegistry,
) -> Optional[int]: ) -> Optional[int]:
target_key = self._normalize_identifier_key(row.source_identifier) target_key = self._normalize_identifier_key(row.source_identifier)
if not target_key: target_name = self._normalize_identifier_key(row.source_name)
target_description = self._normalize_identifier_key(row.description)
if not any([target_key, target_name, target_description]):
return None return None
candidates = ( candidates = (
await self.db.execute( await self.db.execute(
@ -54,17 +56,93 @@ class SignalSourceRepairDataAccessMixin:
.limit(50) .limit(50)
) )
).scalars().all() ).scalars().all()
for candidate in candidates:
current_key = self._normalize_identifier_key(candidate.source_identifier) if target_key:
if current_key and current_key == target_key: for candidate in candidates:
return self._safe_int(candidate.signal_id) current_key = self._normalize_identifier_key(candidate.source_identifier)
for candidate in candidates: if current_key and current_key == target_key:
current_key = self._normalize_identifier_key(candidate.source_identifier) return self._safe_int(candidate.signal_id)
if current_key and ( for candidate in candidates:
current_key.endswith(target_key) current_key = self._normalize_identifier_key(candidate.source_identifier)
or target_key.endswith(current_key) if current_key and (
): current_key.endswith(target_key)
return self._safe_int(candidate.signal_id) or target_key.endswith(current_key)
):
return self._safe_int(candidate.signal_id)
linked_signals = (
await self.db.execute(
select(Signal)
.join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id)
.where(
and_(
ProjectSignalLink.tenant_id == int(tenant_id),
ProjectSignalLink.project_id == int(project_id),
Signal.tenant_id == int(tenant_id),
)
)
.order_by(Signal.created_at.desc(), Signal.id.desc())
.limit(100)
)
).scalars().all()
fallback_signals = (
await self.db.execute(
select(Signal)
.where(
and_(
Signal.tenant_id == int(tenant_id),
Signal.matched_project_id == int(project_id),
)
)
.order_by(Signal.created_at.desc(), Signal.id.desc())
.limit(100)
)
).scalars().all()
signal_candidates: list[Signal] = []
seen_signal_ids: set[int] = set()
for candidate in [*linked_signals, *fallback_signals]:
signal_id = self._safe_int(getattr(candidate, "id", None))
if signal_id is None or signal_id in seen_signal_ids:
continue
seen_signal_ids.add(signal_id)
signal_candidates.append(candidate)
if target_key:
for candidate in signal_candidates:
source_key = self._normalize_identifier_key(candidate.source)
if source_key and (
source_key == target_key
or source_key.endswith(target_key)
or target_key.endswith(source_key)
):
return self._safe_int(candidate.id)
if target_name:
for candidate in signal_candidates:
title_key = self._normalize_identifier_key(candidate.title)
summary_key = self._normalize_identifier_key(candidate.summary)
for current_key in (title_key, summary_key):
if current_key and (
current_key == target_name
or current_key.endswith(target_name)
or target_name.endswith(current_key)
or target_name in current_key
):
return self._safe_int(candidate.id)
if target_description:
for candidate in signal_candidates:
summary_key = self._normalize_identifier_key(candidate.summary)
content_key = self._normalize_identifier_key(candidate.content)
for current_key in (summary_key, content_key):
if current_key and (
current_key == target_description
or current_key.endswith(target_description)
or target_description.endswith(current_key)
or target_description in current_key
):
return self._safe_int(candidate.id)
return None return None
async def _active_user_ids(self, *, tenant_id: int) -> list[int]: async def _active_user_ids(self, *, tenant_id: int) -> list[int]:

View File

@ -4,16 +4,139 @@ from __future__ import annotations
from .shared import ( from .shared import (
NotificationService, NotificationService,
ProjectSignalLink,
Signal,
SignalSourceRegistry, SignalSourceRegistry,
and_,
get_audit_context, get_audit_context,
json,
log_operation, log_operation,
now_cn_naive, now_cn_naive,
select,
) )
class SignalSourceRepairPersistMixin: class SignalSourceRepairPersistMixin:
"""Persist repaired links and emit visibility records.""" """Persist repaired links and emit visibility records."""
@staticmethod
def _build_signal_source_value(*, row: SignalSourceRegistry) -> str:
candidate = str(row.source_identifier or "").strip() or str(row.source_kind or "").strip() or "signal_source"
return candidate[:50]
@staticmethod
def _build_signal_priority(*, row: SignalSourceRegistry) -> str:
config = row.config if isinstance(row.config, dict) else {}
raw_context = row.raw_context if isinstance(row.raw_context, dict) else {}
requirement_item = raw_context.get("requirement_item") if isinstance(raw_context.get("requirement_item"), dict) else {}
raw_priority = str(config.get("priority") or requirement_item.get("priority") or "medium").strip().lower()
if raw_priority in {"p0", "critical", "high"}:
return "high"
if raw_priority in {"p2", "low"}:
return "low"
return "medium"
@staticmethod
def _build_signal_content(*, row: SignalSourceRegistry) -> str:
lines = [f"信号源:{str(row.source_name or '未命名信号源').strip()}"]
description = str(row.description or "").strip()
if description:
lines.append(description)
source_kind = str(row.source_kind or "").strip()
if source_kind:
lines.append(f"来源类型:{source_kind}")
source_identifier = str(row.source_identifier or "").strip()
if source_identifier:
lines.append(f"来源标识:{source_identifier}")
return "\n".join(line for line in lines if line).strip()[:2000]
@staticmethod
def _build_signal_analysis(
*,
project_id: int,
row: SignalSourceRegistry,
) -> str:
payload = {
"source_context": {
"registry_id": int(row.id),
"source_kind": str(row.source_kind or "").strip(),
"source_name": str(row.source_name or "").strip(),
"source_identifier": str(row.source_identifier or "").strip(),
"requested_via": str(row.requested_via or "").strip(),
},
"ai_analysis": {
"requirement_project_scope": "existing_project",
"reason": "signal_source_registry_repair_materialized",
"target_project_hints": [int(project_id)],
},
}
return json.dumps(payload, ensure_ascii=False)
async def _ensure_project_signal_link(
self,
*,
tenant_id: int,
project_id: int,
signal_id: int,
) -> None:
existing = (
await self.db.execute(
select(ProjectSignalLink.id).where(
and_(
ProjectSignalLink.tenant_id == int(tenant_id),
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == int(signal_id),
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
self.db.add(
ProjectSignalLink(
tenant_id=int(tenant_id),
project_id=int(project_id),
signal_id=int(signal_id),
link_type="related",
relevance_score=1.0,
)
)
async def _materialize_registry_signal(
self,
*,
tenant_id: int,
project_id: int,
row: SignalSourceRegistry,
) -> Signal:
summary = str(row.description or row.source_name or "信号源").strip()
signal = Signal(
tenant_id=int(tenant_id),
source=self._build_signal_source_value(row=row),
content=self._build_signal_content(row=row),
impact_score=0.6,
priority=self._build_signal_priority(row=row),
processed=False,
strategic_analysis=self._build_signal_analysis(project_id=project_id, row=row),
tasks_generated=json.dumps([], ensure_ascii=False),
matched_project_id=int(project_id),
title=(str(row.source_name or "未命名信号源").strip() or "未命名信号源")[:80],
signal_type="新需求",
summary=summary[:300] or "信号源物化记录",
reasoning=json.dumps(["由 signal_source_registry 修复流程直接物化为正式 Signal。"], ensure_ascii=False),
confidence=0.85,
recommended_action="enter_only",
status="待处理",
)
self.db.add(signal)
await self.db.flush()
await self._ensure_project_signal_link(
tenant_id=tenant_id,
project_id=project_id,
signal_id=int(signal.id),
)
return signal
async def _emit_visibility_records( async def _emit_visibility_records(
self, self,
*, *,
@ -78,6 +201,23 @@ class SignalSourceRepairPersistMixin:
) -> None: ) -> None:
row.signal_id = int(signal_id) row.signal_id = int(signal_id)
row.last_signal_at = now_cn_naive() row.last_signal_at = now_cn_naive()
signal = (
await self.db.execute(
select(Signal).where(
and_(
Signal.tenant_id == int(tenant_id),
Signal.id == int(signal_id),
)
).limit(1)
)
).scalar_one_or_none()
if signal is not None and getattr(signal, "matched_project_id", None) is None:
signal.matched_project_id = int(project_id)
await self._ensure_project_signal_link(
tenant_id=tenant_id,
project_id=project_id,
signal_id=int(signal_id),
)
await self._emit_visibility_records( await self._emit_visibility_records(
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,

View File

@ -2,9 +2,6 @@
from __future__ import annotations from __future__ import annotations
from .errors import SignalSourceRepairMissingSignalError
from .shared import ProactiveSignalCaptureService
class SignalSourceRepairFlowMixin: class SignalSourceRepairFlowMixin:
"""Runs single/batch repair orchestration.""" """Runs single/batch repair orchestration."""
@ -12,12 +9,10 @@ class SignalSourceRepairFlowMixin:
async def _repair_single_registry( async def _repair_single_registry(
self, self,
*, *,
capture_service: ProactiveSignalCaptureService,
tenant_id: int, tenant_id: int,
project_id: int, project_id: int,
registry_id: int, registry_id: int,
sender: str, sender: str,
timeout_seconds: int,
) -> dict[str, object]: ) -> dict[str, object]:
row = await self._load_registry( row = await self._load_registry(
tenant_id=tenant_id, tenant_id=tenant_id,
@ -56,42 +51,14 @@ class SignalSourceRepairFlowMixin:
route_status="", route_status="",
) )
capture_result = await self._capture_registry( _ = sender
capture_service=capture_service, signal = await self._materialize_registry_signal(
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,
sender=sender,
row=row, row=row,
stage="base",
timeout_seconds=timeout_seconds,
) )
signal_id = self._extract_signal_id(capture_result) signal_id = int(signal.id)
if signal_id is None: capture_status = "materialized"
capture_result = await self._capture_registry(
capture_service=capture_service,
tenant_id=tenant_id,
project_id=project_id,
sender=sender,
row=row,
stage="explicit",
timeout_seconds=timeout_seconds,
)
signal_id = self._extract_signal_id(capture_result)
if signal_id is None:
signal_id = await self._find_reusable_signal_id(
tenant_id=tenant_id,
project_id=project_id,
row=row,
)
if signal_id is None:
raise SignalSourceRepairMissingSignalError(
registry_id=int(row.id),
project_id=int(project_id),
status=str(capture_result.get("status") or ""),
)
capture_status = str(capture_result.get("status") or "captured")
await self._persist_registry_link( await self._persist_registry_link(
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,
@ -103,8 +70,8 @@ class SignalSourceRepairFlowMixin:
row=row, row=row,
signal_id=int(signal_id), signal_id=int(signal_id),
capture_status=capture_status, capture_status=capture_status,
work_item_id=self._safe_int(capture_result.get("work_item_id")), work_item_id=None,
route_status=self._extract_route_status(capture_result), route_status="",
) )
async def repair_registries( async def repair_registries(
@ -115,18 +82,14 @@ class SignalSourceRepairFlowMixin:
registry_ids: list[int], registry_ids: list[int],
sender: str, sender: str,
) -> list[dict[str, object]]: ) -> list[dict[str, object]]:
capture_service = ProactiveSignalCaptureService(self.db)
timeout_seconds = self._resolve_timeout_seconds()
results: list[dict[str, object]] = [] results: list[dict[str, object]] = []
try: try:
for registry_id in registry_ids: for registry_id in registry_ids:
item = await self._repair_single_registry( item = await self._repair_single_registry(
capture_service=capture_service,
tenant_id=tenant_id, tenant_id=tenant_id,
project_id=project_id, project_id=project_id,
registry_id=int(registry_id), registry_id=int(registry_id),
sender=sender, sender=sender,
timeout_seconds=timeout_seconds,
) )
results.append(item) results.append(item)
await self.db.commit() await self.db.commit()

View File

@ -17,7 +17,7 @@ class SignalSourceRepairService(
SignalSourceRepairPersistMixin, SignalSourceRepairPersistMixin,
SignalSourceRepairFlowMixin, SignalSourceRepairFlowMixin,
): ):
"""通过 capture-feishu 回填历史 registry.signal_id。""" """修复历史 registry.signal_id:优先回填既有 Signal,不足时再物化正式 Signal。"""
def __init__(self, db: AsyncSession) -> None: def __init__(self, db: AsyncSession) -> None:
self.db = db self.db = db

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
import os import os
import re import re
from typing import Any, Optional from typing import Any, Optional
@ -10,7 +11,7 @@ from typing import Any, Optional
from sqlalchemy import and_, select from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from ....models import SignalSourceRegistry, User from ....models import ProjectSignalLink, Signal, SignalSourceRegistry, User
from ....utils.audited_write import get_audit_context from ....utils.audited_write import get_audit_context
from ....utils.timezone import now_cn_naive from ....utils.timezone import now_cn_naive
from ...notification_service import NotificationService from ...notification_service import NotificationService
@ -22,12 +23,15 @@ __all__ = [
"AsyncSession", "AsyncSession",
"NotificationService", "NotificationService",
"Optional", "Optional",
"ProjectSignalLink",
"ProactiveSignalCaptureService", "ProactiveSignalCaptureService",
"Signal",
"SignalSourceRegistry", "SignalSourceRegistry",
"User", "User",
"and_", "and_",
"asyncio", "asyncio",
"get_audit_context", "get_audit_context",
"json",
"log_operation", "log_operation",
"now_cn_naive", "now_cn_naive",
"os", "os",

View File

@ -71,6 +71,9 @@ def _build_charter_context_payload(charter: TruthNode) -> dict[str, Any]:
modules = content.get("modules") modules = content.get("modules")
if not isinstance(modules, dict): if not isinstance(modules, dict):
modules = {} modules = {}
derived_artifacts = content.get("derived_artifacts")
if not isinstance(derived_artifacts, dict):
derived_artifacts = {}
module_1 = modules.get("module_1") if isinstance(modules.get("module_1"), dict) else {} module_1 = modules.get("module_1") if isinstance(modules.get("module_1"), dict) else {}
module_2 = modules.get("module_2") if isinstance(modules.get("module_2"), dict) else {} module_2 = modules.get("module_2") if isinstance(modules.get("module_2"), dict) else {}
module_4 = modules.get("module_4") if isinstance(modules.get("module_4"), dict) else {} module_4 = modules.get("module_4") if isinstance(modules.get("module_4"), dict) else {}
@ -158,6 +161,28 @@ def _build_charter_context_payload(charter: TruthNode) -> dict[str, Any]:
max_items=5, max_items=5,
max_len=100, max_len=100,
), ),
"task_seeds": [
{
"title": _compact_task_breakdown_text(item.get("title"), max_len=100),
"description": _compact_task_breakdown_text(item.get("description"), max_len=160),
"evidence_refs": _compact_task_breakdown_items(item.get("evidence_refs") or [], max_items=5, max_len=80),
}
for item in (derived_artifacts.get("task_seeds") or [])
if isinstance(item, dict) and _compact_task_breakdown_text(item.get("title"), max_len=100)
],
"verification_metrics": [
_compact_task_breakdown_text(item.get("name") or item.get("title"), max_len=100)
for item in (derived_artifacts.get("verification_metrics") or [])
if isinstance(item, dict) and _compact_task_breakdown_text(item.get("name") or item.get("title"), max_len=100)
],
"risk_gates": [
{
"name": _compact_task_breakdown_text(item.get("name"), max_len=100),
"action": _compact_task_breakdown_text(item.get("action"), max_len=140),
}
for item in (derived_artifacts.get("risk_gates") or [])
if isinstance(item, dict) and _compact_task_breakdown_text(item.get("name"), max_len=100)
],
"signal_ids": normalized_signal_ids, "signal_ids": normalized_signal_ids,
} }

View File

@ -2,9 +2,180 @@ from __future__ import annotations
from .shared import * from .shared import *
from .charter_seed_utils import _build_signal_context_text, _to_bullet_text, _to_string_list from .charter_seed_utils import _build_signal_context_text, _safe_threshold, _to_bullet_text, _to_string_list
from .charter_prefill_utils import _default_ai_validations, _default_escalation_trigger, _generate_charter_seed from .charter_prefill_utils import _default_ai_validations, _default_escalation_trigger, _generate_charter_seed
_MODULE_FIELD_KEYS: dict[str, tuple[str, ...]] = {
"module_1": ("problem_statement", "goal_statement", "kpis"),
"module_2": ("in_scope", "out_of_scope", "alignment_qa"),
"module_3": ("primary_model", "fallback_model", "capability_table", "dependency_map", "business_view", "engineering_view"),
"module_4": ("milestones", "role_effort_estimates", "capability_owner_assignment", "buffer_policy"),
"module_5": ("risk_register", "trigger_rules", "execution_rule"),
"module_6": ("attachment_metadata", "attachment_summary", "search_tags", "governance_rule"),
}
_STATUS_REASON_MAP = {
"evidence_insufficient": "缺少可验证证据",
"generation_failed": "AI 生成失败",
"generic_text": "文本存在但证据不足",
"conflict_blocked": "存在未决冲突",
}
def _field_required(module_key: str, field_key: str) -> bool:
return field_key in _CHARTER_REQUIRED_FIELDS.get(module_key, ())
def _field_title(module_key: str, field_key: str) -> str:
return f"{module_key}.{field_key}"
def _stringify_field_value(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, (list, tuple)):
items = _to_string_list(value)
if not items:
return ""
return _to_bullet_text(items)
return str(value).strip()
def _normalize_evidence_items(value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
normalized: list[dict[str, Any]] = []
for item in value:
if isinstance(item, dict):
try:
signal_id = int(item.get("signal_id")) if item.get("signal_id") is not None else None
except (TypeError, ValueError):
signal_id = None
normalized.append(
{
"signal_id": signal_id,
"summary": str(item.get("summary") or item.get("excerpt") or "").strip(),
"quote": str(item.get("quote") or item.get("snippet") or "").strip(),
"reason": str(item.get("reason") or "").strip(),
"confidence": _safe_threshold(item.get("confidence"), 0.0),
}
)
continue
text = str(item or "").strip()
if text:
normalized.append(
{
"signal_id": None,
"summary": text,
"quote": "",
"reason": "",
"confidence": 0.0,
}
)
return normalized
def _legacy_seed_modules(seed: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {
"module_1": {
"problem_statement": seed.get("summary"),
"goal_statement": _to_bullet_text(_to_string_list(seed.get("objectives"))),
"kpis": _to_bullet_text(
_to_string_list(seed.get("success_metrics") or seed.get("success_criteria"))
),
},
"module_2": {
"in_scope": _to_bullet_text(_to_string_list(seed.get("in_scope"))),
"out_of_scope": _to_bullet_text(_to_string_list(seed.get("out_of_scope"))),
"alignment_qa": str(seed.get("alignment_qa") or "").strip(),
},
"module_3": {
"primary_model": seed.get("primary_model"),
"fallback_model": seed.get("fallback_model"),
"capability_table": seed.get("capability_table"),
"dependency_map": _to_bullet_text(_to_string_list(seed.get("dependencies"))),
"business_view": seed.get("business_view"),
"engineering_view": seed.get("engineering_view"),
},
"module_4": {
"milestones": _to_bullet_text(_to_string_list(seed.get("milestones") or seed.get("mvp_items"))),
"role_effort_estimates": _to_bullet_text(_to_string_list(seed.get("team_setup"))),
"capability_owner_assignment": seed.get("owner_suggestion"),
"buffer_policy": seed.get("buffer_policy"),
},
"module_5": {
"risk_register": _to_bullet_text(_to_string_list(seed.get("risks"))),
"trigger_rules": _to_bullet_text(_to_string_list(seed.get("trigger_rules"))),
"execution_rule": _to_bullet_text(_to_string_list(seed.get("execution_rule"))),
},
"module_6": {
"attachment_metadata": _to_bullet_text(_to_string_list(seed.get("attachment_metadata"))),
"attachment_summary": seed.get("attachment_summary"),
"search_tags": _to_bullet_text(_to_string_list(seed.get("search_tags"))),
"governance_rule": seed.get("governance_rule"),
},
}
def _derive_status(
*,
value: str,
evidence: list[dict[str, Any]],
required: bool,
explicit_status: str,
generation_failed: bool,
) -> str:
if explicit_status:
return explicit_status
if value and evidence:
return "valid"
if value:
return "generic_text"
if generation_failed:
return "generation_failed"
if required:
return "evidence_insufficient"
return "generation_failed"
def _readiness_detail_for_status(module_key: str, field_key: str, status: str) -> str:
reason = _STATUS_REASON_MAP.get(status, "未通过校验")
return f"{_field_title(module_key, field_key)} {reason}"
def _build_verification_metrics(modules: dict[str, dict[str, str]], field_status: dict[str, dict[str, str]]) -> list[dict[str, Any]]:
if field_status.get("module_1", {}).get("kpis") != "valid":
return []
items = _to_string_list(modules.get("module_1", {}).get("kpis"))
return [
{
"name": item,
"expected": item,
"source_field": "module_1.kpis",
}
for item in items
]
def _build_risk_gates(modules: dict[str, dict[str, str]], field_status: dict[str, dict[str, str]]) -> list[dict[str, Any]]:
trigger_status = field_status.get("module_5", {}).get("trigger_rules")
execution_status = field_status.get("module_5", {}).get("execution_rule")
if trigger_status != "valid" or execution_status != "valid":
return []
triggers = _to_string_list(modules.get("module_5", {}).get("trigger_rules"))
action = modules.get("module_5", {}).get("execution_rule", "").strip()
return [
{
"name": trigger,
"action": action,
"source_fields": ["module_5.trigger_rules", "module_5.execution_rule"],
}
for trigger in triggers
]
async def _build_ai_first_charter_content( async def _build_ai_first_charter_content(
*, *,
db: AsyncSession, db: AsyncSession,
@ -21,39 +192,55 @@ async def _build_ai_first_charter_content(
project_id=project_id, project_id=project_id,
signal_context=signal_context, signal_context=signal_context,
) )
generation_error = str(seed.get("generation_error") or "").strip()
seed_modules = seed.get("modules")
if not isinstance(seed_modules, dict):
seed_modules = _legacy_seed_modules(seed)
summary = str(seed.get("summary") or "").strip() modules: dict[str, dict[str, str]] = {}
background = str(seed.get("background") or "").strip() field_status: dict[str, dict[str, str]] = {}
objectives = _to_string_list(seed.get("objectives")) field_evidence: dict[str, dict[str, list[dict[str, Any]]]] = {}
scope = str(seed.get("scope") or "").strip() blocking_fields: list[str] = []
in_scope_items = _to_string_list(seed.get("in_scope")) blocking_reasons: list[str] = []
out_of_scope_items = _to_string_list(seed.get("out_of_scope"))
success_metrics = _to_string_list(seed.get("success_metrics"))
risks = _to_string_list(seed.get("risks"))
dependencies = _to_string_list(seed.get("dependencies"))
primary_model = str(seed.get("primary_model") or "").strip()
fallback_model = str(seed.get("fallback_model") or "").strip()
if not summary: for module_key, field_keys in _MODULE_FIELD_KEYS.items():
summary = "目标用户在当前流程中存在效率损耗,需要通过 AI 驱动的流程优化提升交付质量与速度。" modules[module_key] = {}
if not background: field_status[module_key] = {}
background = signal_context[:500] or "基于已绑定信号源自动生成。" field_evidence[module_key] = {}
if not objectives: module_seed = seed_modules.get(module_key)
objectives = ["缩短端到端交付周期", "提升关键路径质量稳定性", "降低跨角色协同成本"] if not isinstance(module_seed, dict):
if not success_metrics: module_seed = {}
success_metrics = ["需求到上线周期缩短 >= 30%", "关键任务按期交付率 >= 90%", "线上回滚率 <= 3%"]
if not in_scope_items: for field_key in field_keys:
in_scope_items = ["需求澄清与宪章生成自动化", "任务拆解与负责人推荐", "对齐状态自动追踪与留痕"] raw_field = module_seed.get(field_key)
if not out_of_scope_items: explicit_status = ""
out_of_scope_items = ["替代团队组织架构决策", "绕过人工否决机制自动上线", "非项目相关流程改造"] if isinstance(raw_field, dict):
if not risks: explicit_status = str(raw_field.get("status") or "").strip()
risks = ["需求波动导致范围蔓延", "模型能力边界与输入复杂度不匹配", "关键成员负载过高导致延期"] raw_value = raw_field.get("value")
if not dependencies: if raw_value is None:
dependencies = ["FastAPI", "React", "LLM 推理服务", "项目知识库/日志系统"] raw_value = raw_field.get("text")
if not primary_model: else:
primary_model = "待 AI 评估后绑定" raw_value = raw_field
if not fallback_model: value = _stringify_field_value(raw_value)
fallback_model = "同供应商低成本模型" evidence = _normalize_evidence_items(raw_field.get("evidence")) if isinstance(raw_field, dict) else []
status = _derive_status(
value=value,
evidence=evidence,
required=_field_required(module_key, field_key),
explicit_status=explicit_status,
generation_failed=bool(generation_error),
)
modules[module_key][field_key] = value
field_status[module_key][field_key] = status
field_evidence[module_key][field_key] = evidence
if _field_required(module_key, field_key) and status != "valid":
blocking_fields.append(_field_title(module_key, field_key))
if isinstance(raw_field, dict):
reason = str(raw_field.get("blocking_reason") or "").strip()
else:
reason = ""
blocking_reasons.append(reason or _readiness_detail_for_status(module_key, field_key, status))
source_refs = [f"signal:{sid}" for sid in signal_ids] source_refs = [f"signal:{sid}" for sid in signal_ids]
now_iso = now_cn_aware().isoformat() now_iso = now_cn_aware().isoformat()
@ -64,113 +251,74 @@ async def _build_ai_first_charter_content(
validations_m5 = _default_ai_validations("模块5:风险与兜底") validations_m5 = _default_ai_validations("模块5:风险与兜底")
validations_m6 = _default_ai_validations("模块6:附件与治理") validations_m6 = _default_ai_validations("模块6:附件与治理")
escalation = _default_escalation_trigger() escalation = _default_escalation_trigger()
for module_key, validations in (
("module_1", validations_m1),
("module_2", validations_m2),
("module_3", validations_m3),
("module_4", validations_m4),
("module_5", validations_m5),
("module_6", validations_m6),
):
modules[module_key]["ai_validations"] = validations
modules[module_key]["escalation_trigger"] = escalation
alignment_qa = _to_bullet_text( explicit_readiness = seed.get("readiness") if isinstance(seed.get("readiness"), dict) else {}
[ for item in explicit_readiness.get("blocking_fields") or []:
f"核心用户与痛点:{summary}", field = str(item or "").strip()
"上线时间与硬约束:由里程碑计划自动回填并持续校验", if field and field not in blocking_fields:
f"明确不做项:{'; '.join(out_of_scope_items[:3])}", blocking_fields.append(field)
f"关键依赖:{'; '.join(dependencies[:4])}", for item in explicit_readiness.get("blocking_reasons") or []:
f"核心风险与缓解:{'; '.join(risks[:3])}", reason = str(item or "").strip()
] if reason and reason not in blocking_reasons:
) blocking_reasons.append(reason)
modules = { required_total = sum(len(fields) for fields in _CHARTER_REQUIRED_FIELDS.values())
"module_1": { valid_required = 0
"problem_statement": summary, for module_key, fields in _CHARTER_REQUIRED_FIELDS.items():
"goal_statement": _to_bullet_text(objectives), for field_key in fields:
"kpis": _to_bullet_text(success_metrics), if field_status.get(module_key, {}).get(field_key) == "valid":
"ai_validations": validations_m1, valid_required += 1
"escalation_trigger": escalation, readiness = {
}, "ready_for_confirm": not blocking_fields and explicit_readiness.get("ready_for_confirm") is not False,
"module_2": { "blocking_fields": blocking_fields,
"in_scope": _to_bullet_text(in_scope_items), "blocking_reasons": blocking_reasons,
"out_of_scope": _to_bullet_text(out_of_scope_items),
"alignment_qa": alignment_qa,
"ai_validations": validations_m2,
"escalation_trigger": escalation,
},
"module_3": {
"primary_model": primary_model,
"fallback_model": fallback_model,
"capability_table": _to_bullet_text(
[
"上下文窗口/并发/延迟由模型能力中心实时读取",
"按 P50/P90 工作负载自动估算 token 与成本",
"超边界输入自动触发替代模型建议",
]
),
"dependency_map": _to_bullet_text(dependencies),
"business_view": "以业务术语展示能力上限、成本区间与上线风险。",
"engineering_view": "以工程参数展示 context window、QPS、成本与错误预算。",
"ai_validations": validations_m3,
"escalation_trigger": escalation,
},
"module_4": {
"milestones": _to_bullet_text(
[
"需求冻结 -> 技术评审 -> 开发完成 -> QA 验收 -> 发布上线",
"关键依赖阻塞超过阈值自动重排计划",
]
),
"role_effort_estimates": _to_bullet_text(
[
"产品/PM:需求澄清与验收口径",
"前后端工程:核心功能开发与联调",
"AI 工程:模型评估、提示词与推理链路优化",
]
),
"capability_owner_assignment": "负责人由能力图谱自动推荐并可人工否决。",
"buffer_policy": "默认 15% 进度缓冲,关键路径超阈值触发自动预警。",
"ai_validations": validations_m4,
"escalation_trigger": escalation,
},
"module_5": {
"risk_register": _to_bullet_text(risks),
"trigger_rules": _to_bullet_text(
[
"进度偏差 > 15%",
"关键接口错误率连续超过阈值",
"模型调用成本超预算预警线",
]
),
"execution_rule": _to_bullet_text(
[
"触发后优先执行预设 fallback",
"自动通知责任人并写入审计日志",
"连续失败时升级到人工决策",
]
),
"ai_validations": validations_m5,
"escalation_trigger": escalation,
},
"module_6": {
"attachment_metadata": _to_bullet_text(
[
"原始信号记录",
"需求讨论记录",
"设计与评审文档",
"发布与复盘材料",
]
),
"attachment_summary": "AI 自动提炼附件决策结论并关联到对应模块字段。",
"search_tags": _to_bullet_text(["需求宪章", "AI First", "SSTC", "对齐记录"]),
"ai_validations": validations_m6,
"governance_rule": "附件仅作证据引用,核心事实字段以模块1-5为准。",
},
} }
derived_artifacts = seed.get("derived_artifacts") if isinstance(seed.get("derived_artifacts"), dict) else {}
if not isinstance(derived_artifacts.get("task_seeds"), list):
derived_artifacts["task_seeds"] = []
if not isinstance(derived_artifacts.get("verification_metrics"), list) or not derived_artifacts.get("verification_metrics"):
derived_artifacts["verification_metrics"] = _build_verification_metrics(modules, field_status)
if not isinstance(derived_artifacts.get("risk_gates"), list) or not derived_artifacts.get("risk_gates"):
derived_artifacts["risk_gates"] = _build_risk_gates(modules, field_status)
summary = modules["module_1"]["problem_statement"]
background = str(seed.get("background") or "").strip()
objectives = _to_string_list(modules["module_1"]["goal_statement"])
scope = str(seed.get("scope") or "").strip()
in_scope_items = _to_string_list(modules["module_2"]["in_scope"])
out_of_scope_items = _to_string_list(modules["module_2"]["out_of_scope"])
success_metrics = _to_string_list(modules["module_1"]["kpis"])
risks = _to_string_list(modules["module_5"]["risk_register"])
return { return {
"schema_version": "charter_ai_first_v1", "schema_version": "charter_ai_first_v1",
"auto_generated_by_ai": True, "auto_generated_by_ai": True,
"metadata": { "metadata": {
"status": "draft", "status": "draft",
"confidence_score": 0.82 if seed else 0.72, "confidence_score": _safe_threshold(
seed.get("confidence_score") or (valid_required / required_total if required_total else 0.0),
0.0,
),
"last_validated_at": now_iso, "last_validated_at": now_iso,
"evidence_refs": source_refs, "evidence_refs": source_refs,
"escalation_reason": None, "escalation_reason": blocking_reasons[0] if blocking_reasons else generation_error or None,
}, },
"modules": modules, "modules": modules,
"field_status": field_status,
"field_evidence": field_evidence,
"readiness": readiness,
"derived_artifacts": derived_artifacts,
"signal_ids": signal_ids, "signal_ids": signal_ids,
"summary": summary, "summary": summary,
"background": background, "background": background,
@ -185,4 +333,3 @@ async def _build_ai_first_charter_content(
"risks": risks, "risks": risks,
"raw_source_text": raw_text.strip() or None, "raw_source_text": raw_text.strip() or None,
} }

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from .shared import * from .shared import *
from .charter_seed_utils import _localize_seed_to_zh, _needs_seed_localize_zh, _parse_json_object_text, _to_bullet_text from .charter_seed_utils import _localize_seed_to_zh, _needs_seed_localize_zh, _to_bullet_text
def _has_non_empty_module_data(content: dict[str, Any]) -> bool: def _has_non_empty_module_data(content: dict[str, Any]) -> bool:
modules = content.get("modules") modules = content.get("modules")
@ -18,15 +18,23 @@ def _has_non_empty_module_data(content: dict[str, Any]) -> bool:
def _has_complete_module_data(content: dict[str, Any]) -> bool: def _has_complete_module_data(content: dict[str, Any]) -> bool:
modules = content.get("modules") modules = content.get("modules")
field_status = content.get("field_status")
if not isinstance(modules, dict): if not isinstance(modules, dict):
return False return False
if not isinstance(field_status, dict):
return False
for module_key, required_fields in _CHARTER_REQUIRED_FIELDS.items(): for module_key, required_fields in _CHARTER_REQUIRED_FIELDS.items():
module_payload = modules.get(module_key) module_payload = modules.get(module_key)
status_payload = field_status.get(module_key)
if not isinstance(module_payload, dict): if not isinstance(module_payload, dict):
return False return False
if not isinstance(status_payload, dict):
return False
for field_key in required_fields: for field_key in required_fields:
if not str(module_payload.get(field_key) or "").strip(): if not str(module_payload.get(field_key) or "").strip():
return False return False
if str(status_payload.get(field_key) or "").strip() != "valid":
return False
return True return True
def _merge_prefill_content( def _merge_prefill_content(
@ -204,47 +212,6 @@ async def _generate_charter_seed(
) )
active_projects_json = json.dumps(active_projects, ensure_ascii=False) active_projects_json = json.dumps(active_projects, ensure_ascii=False)
prompt = (
"你是 AI 项目经理,请基于输入信号生成可落地的宪章草稿。"
"所有自然语言字段必须使用简体中文,禁止英文句子;模型名称或技术名词可保留英文。"
"必须先结合当前项目上下文与活跃项目列表判断是否重复建设或应当合并。"
"只输出 JSON,不要 markdown。\n"
"字段:\n"
"{\n"
' "summary": "一句话问题摘要",\n'
' "background": "背景与动机",\n'
' "objectives": ["目标1","目标2"],\n'
' "scope": "范围说明",\n'
' "in_scope": ["范围内事项1","范围内事项2"],\n'
' "out_of_scope": ["范围外事项1","范围外事项2"],\n'
' "success_metrics": ["指标1","指标2"],\n'
' "risks": ["风险1","风险2"],\n'
' "dependencies": ["依赖1","依赖2"],\n'
' "primary_model": "模型名",\n'
' "fallback_model": "备用模型名"\n'
"}\n\n"
f"当前项目上下文:\n{project_context_text or '无'}\n\n"
f"现有活跃项目(JSON):\n{active_projects_json or '[]'}\n\n"
f"信号输入:\n{signal_context[:6000]}"
)
try:
raw = await llm_service.generate(
prompt,
function_name="sstc_charter_prefill",
db=db,
tenant_id=tenant_id,
project_id=project_id,
)
parsed = _parse_json_object_text(raw)
if parsed:
if _needs_seed_localize_zh(parsed):
return await _localize_seed_to_zh(
db=db, tenant_id=tenant_id, project_id=project_id, seed=parsed
)
return parsed
except Exception:
pass
try: try:
ai_seed = await AIService().generate_charter_from_signal( ai_seed = await AIService().generate_charter_from_signal(
signal_context, signal_context,
@ -260,6 +227,6 @@ async def _generate_charter_seed(
db=db, tenant_id=tenant_id, project_id=project_id, seed=ai_seed db=db, tenant_id=tenant_id, project_id=project_id, seed=ai_seed
) )
return ai_seed return ai_seed
return {} return {"generation_error": "structured_charter_empty"}
except Exception: except Exception as exc:
return {} return {"generation_error": str(exc)}

View File

@ -33,15 +33,15 @@ def build_feishu_thread_key(
if anchor: if anchor:
return f"feishu:{chat}:{anchor}" if chat else f"feishu:{anchor}" return f"feishu:{chat}:{anchor}" if chat else f"feishu:{anchor}"
message_id = _normalize_token(source_message_id)
if message_id:
return f"feishu:{chat}:{message_id}" if chat else f"feishu:{message_id}"
actor = _normalize_token(source_sender_open_id) actor = _normalize_token(source_sender_open_id)
if not actor: if not actor:
actor = _normalize_token(sender).lower() actor = _normalize_token(sender).lower()
if actor: if actor:
return f"feishu:{chat}:{actor}" if chat else f"feishu:{actor}" return f"feishu:{chat}:{actor}" if chat else f"feishu:{actor}"
message_id = _normalize_token(source_message_id)
if message_id:
return f"feishu:{chat}:{message_id}" if chat else f"feishu:{message_id}"
return "" return ""

View File

@ -1,12 +1,15 @@
import pytest import pytest
from types import SimpleNamespace from types import SimpleNamespace
from app.config.ai_prompts import PROMPT_AI_PMO_QA
from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnswerEntryMixin from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnswerEntryMixin
from app.services.adaptive_rag_service.mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin 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_answer import AdaptiveRAGDeepGuardAnswerMixin
from app.services.adaptive_rag_service.mixin_deep_guard_rules import AdaptiveRAGDeepGuardRulesMixin
from app.services.adaptive_rag_service.mixin_deterministic_query_core import ( from app.services.adaptive_rag_service.mixin_deterministic_query_core import (
AdaptiveRAGDeterministicQueryCoreMixin, AdaptiveRAGDeterministicQueryCoreMixin,
) )
from app.services.adaptive_rag_service.mixin_retrieval_vector import AdaptiveRAGRetrievalVectorMixin
from app.services.adaptive_rag_service.shared_part_02 import DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K from app.services.adaptive_rag_service.shared_part_02 import DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K
@ -244,6 +247,84 @@ def test_query_type_classifies_colloquial_list_request_as_list() -> None:
assert service._classify_query_type("我感觉很多重复的项目在系统,你能列一下吗?") == "list" assert service._classify_query_type("我感觉很多重复的项目在系统,你能列一下吗?") == "list"
@pytest.mark.asyncio
async def test_vector_retrieval_skips_legacy_fallback_for_non_postgresql_session() -> None:
class _FakeLLM:
async def get_embedding_async(self, question: str, **kwargs):
_ = (question, kwargs)
return [0.1, 0.2, 0.3]
class _FakeService(AdaptiveRAGRetrievalVectorMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
self.llm = _FakeLLM()
self.legacy_called = False
def _map_domains_to_source_types(self, domains: list[str]) -> list[str]:
return list(domains)
def _is_postgresql_session(self) -> bool:
return False
async def _retrieve_vector_context_legacy(self, **kwargs):
_ = kwargs
self.legacy_called = True
return ["legacy-hit"]
service = _FakeService()
result = await service._retrieve_vector_context(
"查询项目文档",
domains=["doc"],
project_id=None,
source_chat_id=None,
)
assert result == []
assert service.legacy_called is False
@pytest.mark.asyncio
async def test_vector_retrieval_skips_legacy_fallback_when_pg_query_errors() -> None:
class _FakeLLM:
async def get_embedding_async(self, question: str, **kwargs):
_ = (question, kwargs)
return [0.1, 0.2, 0.3]
class _FakeService(AdaptiveRAGRetrievalVectorMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
self.llm = _FakeLLM()
self.legacy_called = False
def _map_domains_to_source_types(self, domains: list[str]) -> list[str]:
return list(domains)
def _is_postgresql_session(self) -> bool:
return True
async def _retrieve_vector_context_pg(self, **kwargs):
_ = kwargs
return None
async def _retrieve_vector_context_legacy(self, **kwargs):
_ = kwargs
self.legacy_called = True
return ["legacy-hit"]
service = _FakeService()
result = await service._retrieve_vector_context(
"查询项目文档",
domains=["doc"],
project_id=None,
source_chat_id=None,
)
assert result == []
assert service.legacy_called is False
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_deep_guard_answer_raises_on_empty_llm_response() -> None: async def test_deep_guard_answer_raises_on_empty_llm_response() -> None:
class _FakeLLM: class _FakeLLM:
@ -381,3 +462,113 @@ async def test_select_scene_prompt_applies_consultant_mode_preference_wrapper()
assert scene_key == "default_consultant_advise" assert scene_key == "default_consultant_advise"
assert "PROMPT::PROMPT_RAG_ANSWER" in prompt assert "PROMPT::PROMPT_RAG_ANSWER" in prompt
assert "稳妥建议" in prompt assert "稳妥建议" in prompt
def test_ai_pmo_qa_prompt_includes_five_lenses_and_runtime_state_guard() -> None:
assert "事实源在哪" in PROMPT_AI_PMO_QA
assert "输出模板在哪" in PROMPT_AI_PMO_QA
assert "检索范围在哪" in PROMPT_AI_PMO_QA
assert "执行器在哪" in PROMPT_AI_PMO_QA
assert "反馈回流在哪" in PROMPT_AI_PMO_QA
assert "静态资料" in PROMPT_AI_PMO_QA
assert "运行态配置" in PROMPT_AI_PMO_QA
@pytest.mark.asyncio
async def test_select_scene_prompt_routes_business_review_to_ai_pmo_prompt() -> None:
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 1
async def _render_scene_prompt(self, *, prompt_key: str, fallback_template: str, **kwargs):
_ = fallback_template
return (
f"PROMPT::{prompt_key}|"
f"business_review={kwargs.get('business_review_mode')}|"
f"framework={kwargs.get('review_framework')}"
)
service = _FakeService()
scene_key, prompt = await service._select_scene_prompt(
question="我要做一个大模型 API 聚合平台,帮我分析下要怎么做",
project_id=None,
asked_via="feishu",
message_intent="business_review",
mixed_signal_part=None,
conversation_text="用户上一轮提到要做 API 聚合平台",
context_blocks_text="无",
system_snapshot={},
user_snapshot={},
system_snapshot_text="{}",
user_snapshot_text="{}",
relevance_max=0.51,
query_type="explain",
consultant_mode_preference="clarify",
)
assert scene_key == "ai_pmo_business_review_consultant_clarify"
assert "PROMPT::PROMPT_AI_PMO_QA" in prompt
assert "business_review=true" in prompt
assert "framework=five_lenses" in prompt
def test_runtime_state_query_guard_blocks_static_catalog_evidence() -> None:
class _FakeService(AdaptiveRAGDeepGuardRulesMixin):
pass
service = _FakeService()
blocked = service._should_block_answer_with_guard(
question="高级模型路由是否启用?",
effective_question="高级模型路由是否启用?",
sources=[
{"source": "feature_catalog", "label": "Feature Catalog", "metadata": {}},
{"source": "graph", "label": "Knowledge Graph", "metadata": {"path": "1->uses->2"}},
],
relevance_max=0.96,
)
assert blocked is True
assert "无法仅凭静态资料确认" in service._build_evidence_guard_answer("高级模型路由是否启用?")
def test_runtime_state_query_guard_allows_routing_config_evidence() -> None:
class _FakeService(AdaptiveRAGDeepGuardRulesMixin):
pass
service = _FakeService()
blocked = service._should_block_answer_with_guard(
question="高级模型路由是否启用?",
effective_question="高级模型路由是否启用?",
sources=[
{
"source": "doc",
"label": "Routing Config",
"metadata": {"source_id": "backend/.openclaw/pmflow-routing-rules.json"},
}
],
relevance_max=0.74,
)
assert blocked is False
def test_correction_escalation_query_forces_deep_reasoning_in_feishu_channel() -> None:
class _FakeService(AdaptiveRAGDeepGuardRulesMixin):
pass
service = _FakeService()
reason = service._forced_deep_reason(
question="不对,调用高级模型来回答我的问题",
effective_question="不对,调用高级模型来回答我的问题",
conversation=[{"question": "我要做一个大模型 API 聚合平台", "answer": "上一轮回答"}],
)
assert reason == "correction_escalation"
assert service._should_force_deep_for_channel(
asked_via="feishu",
question="不对,调用高级模型来回答我的问题",
effective_question="不对,调用高级模型来回答我的问题",
reason=reason,
) is True

View File

@ -0,0 +1,80 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
import passlib.context
def _load_migration_module():
path = (
Path(__file__).resolve().parents[1]
/ "alembic"
/ "versions"
/ "add_users_table_rbac.py"
)
spec = importlib.util.spec_from_file_location("add_users_table_rbac_migration", path)
module = importlib.util.module_from_spec(spec)
assert spec is not None
assert spec.loader is not None
spec.loader.exec_module(module)
return module
def test_add_users_table_migration_reuses_precreated_postgres_enums(monkeypatch) -> None:
migration = _load_migration_module()
created_types: list[tuple[str, bool]] = []
class _FakeDialect:
name = "postgresql"
class _FakeBind:
dialect = _FakeDialect()
class _FakeOp:
def __init__(self) -> None:
self.created_table: tuple[str, tuple[object, ...]] | None = None
self.executed_sql: list[str] = []
def get_bind(self):
return _FakeBind()
def create_table(self, table_name: str, *columns, **kwargs):
_ = kwargs
self.created_table = (table_name, columns)
def execute(self, sql) -> None: # noqa: ANN001
self.executed_sql.append(str(sql))
class _FakeCryptContext:
def __init__(self, **kwargs) -> None:
_ = kwargs
def hash(self, raw: str) -> str:
return f"hashed::{raw}"
def _fake_enum_create(self, bind, checkfirst: bool = False) -> None: # noqa: ANN001
_ = bind
created_types.append((str(getattr(self, "name", "")), bool(checkfirst)))
fake_op = _FakeOp()
monkeypatch.setattr(migration, "op", fake_op)
monkeypatch.setattr(migration.ENUM, "create", _fake_enum_create)
monkeypatch.setattr(passlib.context, "CryptContext", _FakeCryptContext)
migration.upgrade()
assert created_types == [("userrole", True), ("userstatus", True)]
assert fake_op.created_table is not None
table_name, columns = fake_op.created_table
assert table_name == "users"
column_map = {column.name: column for column in columns if getattr(column, "name", None)}
role_type = column_map["role"].type
status_type = column_map["status"].type
assert isinstance(role_type, migration.ENUM)
assert isinstance(status_type, migration.ENUM)
assert role_type.create_type is False
assert status_type.create_type is False

View File

@ -7,6 +7,9 @@ from fastapi import HTTPException
from app.api.ai_pmo_api import routes_core from app.api.ai_pmo_api import routes_core
from app.api.ai_pmo_api.schemas import PMOProcessTextRequest from app.api.ai_pmo_api.schemas import PMOProcessTextRequest
from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract
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_rules import ( from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import (
KernelIntentRulesMixin, KernelIntentRulesMixin,
) )
@ -277,6 +280,63 @@ def test_short_confirmation_requires_reply_anchor() -> None:
) )
def test_build_feishu_thread_key_prefers_actor_scope_without_anchor() -> None:
assert build_feishu_thread_key(
source_chat_id="chat-1",
source_sender_open_id="ou_dm_1",
sender="产品经理A",
source_message_id="om_current_message",
thread_context=None,
) == "feishu:chat-1:ou_dm_1"
@pytest.mark.asyncio
async def test_kernel_intent_resolve_inherits_recent_feishu_session_context_without_reply_anchor() -> None:
captured: dict[str, object] = {}
class FakeResolver(KernelIntentResolveMixin):
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"] == "query.read_only"
assert captured["session_project_id_override"] == 21
def test_reply_renderer_formats_project_delete_pending_and_success() -> None: def test_reply_renderer_formats_project_delete_pending_and_success() -> None:
renderer = ReplyRenderer( renderer = ReplyRenderer(
state_waiting_manual="waiting_manual", state_waiting_manual="waiting_manual",
@ -581,6 +641,65 @@ async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch)
) )
@pytest.mark.asyncio
async def test_execute_query_intent_uses_semantic_bundle_rag_context_when_no_explicit_rag_request(monkeypatch) -> None:
captured: dict[str, object] = {}
class FakeAdaptiveRAGService:
def __init__(self, db, tenant_id: int):
_ = db
captured["tenant_id"] = tenant_id
async def answer_question(self, **kwargs):
captured["answer_kwargs"] = kwargs
return {"answer": "corrected-answer", "session_id": 9}
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
def __init__(self) -> None:
self.db = object()
self.tenant_id = 3
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
executor = FakeExecutor()
intent = SimpleNamespace(
project_id=None,
metadata={
"source_chat_id": "chat-dm-9",
"source_sender_open_id": "ou-feishu-9",
"semantic_bundle": {
"diagnostics": {
"message_intent": "correction_escalation",
"consultant_mode_preference": "challenge",
"force_thinking_mode": "deep",
}
},
},
)
result = await executor._execute_query_intent(
intent=intent,
message_text="不对,调用高级模型来回答我的问题",
operator_user_id=999,
source_system="feishu",
source_message_id="msg-feishu-9",
)
assert result["status"] == "succeeded"
answer_kwargs = captured["answer_kwargs"]
assert isinstance(answer_kwargs, dict)
assert answer_kwargs["asked_by"] is None
assert answer_kwargs["asked_via"] == "feishu"
assert answer_kwargs["source_chat_id"] == "chat-dm-9"
assert answer_kwargs["source_sender_open_id"] == "ou-feishu-9"
assert answer_kwargs["message_intent"] == "correction_escalation"
assert answer_kwargs["consultant_mode_preference"] == "challenge"
assert answer_kwargs["thinking_mode"] == "deep"
assert answer_kwargs["conversation_key"] == build_feishu_conversation_scope_key(
source_chat_id="chat-dm-9",
source_sender_open_id="ou-feishu-9",
sender=None,
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatch) -> None: async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatch) -> None:
class FakeAdaptiveRAGService: class FakeAdaptiveRAGService:

View File

@ -7,6 +7,7 @@ import pytest
from app.config.ai_prompts import build_default_role_prompt_bundle from app.config.ai_prompts import build_default_role_prompt_bundle
from app.services.ai_service_core import AIService from app.services.ai_service_core import AIService
from app.services.llm_service import StructuredExtractionResult
from app.services.sstc import charter_prefill_utils from app.services.sstc import charter_prefill_utils
from app.services.signal.draft_generator_service import DraftGeneratorService from app.services.signal.draft_generator_service import DraftGeneratorService
@ -225,27 +226,32 @@ async def test_generate_charter_seed_uses_project_context(monkeypatch) -> None:
} }
] ]
async def fake_generate(prompt, **kwargs): async def fake_generate_structured_object(prompt, **kwargs):
captured["prompt"] = prompt captured["prompt"] = prompt
return json.dumps( payload = {
{ "title": "内部知识库问答升级",
"title": "内部知识库问答升级", "summary": "围绕内部知识库问答能力升级",
"summary": "围绕内部知识库问答能力升级", "background": "背景说明",
"background": "背景说明", "objectives": ["提升检索质量"],
"objectives": ["提升检索质量"], "scope": "范围说明",
"scope": "范围说明", "success_metrics": ["命中率提升"],
"success_metrics": ["命中率提升"], "risks": ["重复建设风险"],
"risks": ["重复建设风险"], "dependencies": ["知识库索引"],
"dependencies": ["知识库索引"], "primary_model": "gpt",
"primary_model": "gpt", "fallback_model": "gpt-mini",
"fallback_model": "gpt-mini", }
}, return StructuredExtractionResult(
ensure_ascii=False, payload=payload,
source_field="message.content",
provider="glm",
model="qwen-plus",
raw_text=json.dumps(payload, ensure_ascii=False),
validation="ok",
) )
monkeypatch.setattr(charter_prefill_utils, "_load_charter_project_context", fake_load_project_context) monkeypatch.setattr(charter_prefill_utils, "_load_charter_project_context", fake_load_project_context)
monkeypatch.setattr(charter_prefill_utils, "_load_active_projects_context", fake_load_active_projects_context) monkeypatch.setattr(charter_prefill_utils, "_load_active_projects_context", fake_load_active_projects_context)
monkeypatch.setattr(charter_prefill_utils.llm_service, "generate", fake_generate) monkeypatch.setattr(charter_prefill_utils.llm_service, "generate_structured_object", fake_generate_structured_object)
result = await charter_prefill_utils._generate_charter_seed( result = await charter_prefill_utils._generate_charter_seed(
db=object(), db=object(),
@ -267,27 +273,31 @@ async def test_generate_charter_from_signal_includes_project_context(monkeypatch
service = AIService() service = AIService()
captured: dict[str, object] = {} captured: dict[str, object] = {}
async def fake_invoke(self, prompt, **kwargs): async def fake_generate_structured_object(prompt, **kwargs):
_ = self
captured["prompt"] = prompt captured["prompt"] = prompt
captured["kwargs"] = kwargs captured["kwargs"] = kwargs
return json.dumps( payload = {
{ "title": "内部知识库问答升级",
"title": "内部知识库问答升级", "summary": "围绕内部知识库问答能力升级",
"summary": "围绕内部知识库问答能力升级", "background": "背景说明",
"background": "背景说明", "objectives": ["提升检索质量"],
"objectives": ["提升检索质量"], "scope": "范围说明",
"scope": "范围说明", "success_metrics": ["命中率提升"],
"success_metrics": ["命中率提升"], "risks": ["重复建设风险"],
"risks": ["重复建设风险"], "dependencies": ["知识库索引"],
"dependencies": ["知识库索引"], "primary_model": "gpt",
"primary_model": "gpt", "fallback_model": "gpt-mini",
"fallback_model": "gpt-mini", }
}, return StructuredExtractionResult(
ensure_ascii=False, payload=payload,
source_field="message.content",
provider="glm",
model="qwen-plus",
raw_text=json.dumps(payload, ensure_ascii=False),
validation="ok",
) )
monkeypatch.setattr(AIService, "_invoke_with_retry", fake_invoke) monkeypatch.setattr(charter_prefill_utils.llm_service, "generate_structured_object", fake_generate_structured_object)
result = await service.generate_charter_from_signal( result = await service.generate_charter_from_signal(
"AI 劝退诉求:请升级内部知识库问答能力。", "AI 劝退诉求:请升级内部知识库问答能力。",
@ -303,4 +313,116 @@ async def test_generate_charter_from_signal_includes_project_context(monkeypatch
assert "当前项目上下文:企业知识库问答平台" in prompt assert "当前项目上下文:企业知识库问答平台" in prompt
assert "现有活跃项目(JSON)" in prompt assert "现有活跃项目(JSON)" in prompt
assert kwargs.get("project_id") == 7 assert kwargs.get("project_id") == 7
assert kwargs.get("use_prompt_profile") is False
assert result["summary"] == "围绕内部知识库问答能力升级" assert result["summary"] == "围绕内部知识库问答能力升级"
@pytest.mark.asyncio
async def test_build_ai_first_charter_content_marks_missing_evidence_without_fallback(monkeypatch) -> None:
from app.services.sstc import charter_prefill_builder
async def fake_generate_charter_seed(**kwargs):
_ = kwargs
return {
"modules": {
"module_1": {
"problem_statement": {
"value": "知识检索命中率偏低",
"evidence": [
{
"signal_id": 11,
"summary": "客服重复追问较多",
"quote": "用户多次重复提问相同内容",
"reason": "直接体现当前问题",
"confidence": 0.93,
}
],
},
"goal_statement": {
"value": "提升检索命中率并降低重复提问",
"evidence": [
{
"signal_id": 11,
"summary": "客服重复追问较多",
"quote": "用户多次重复提问相同内容",
"reason": "体现目标方向",
"confidence": 0.9,
}
],
},
"kpis": {
"value": "",
"evidence": [],
"status": "evidence_insufficient",
},
},
"module_2": {
"in_scope": {
"value": "问答检索增强\n知识补录流程",
"evidence": [
{
"signal_id": 11,
"summary": "客服重复追问较多",
"quote": "用户多次重复提问相同内容",
"reason": "范围来自当前信号",
"confidence": 0.88,
}
],
},
"out_of_scope": {
"value": "权限体系重构",
"evidence": [
{
"signal_id": 12,
"summary": "当前版本不涉及权限系统",
"quote": "本期不动权限模块",
"reason": "明确排除项",
"confidence": 0.84,
}
],
},
"alignment_qa": {
"value": "",
"evidence": [],
"status": "evidence_insufficient",
},
},
},
"readiness": {
"ready_for_confirm": False,
"blocking_fields": ["module_1.kpis", "module_2.alignment_qa"],
"blocking_reasons": ["关键 KPI 缺少证据", "固定对齐问答未生成"],
},
"derived_artifacts": {
"task_seeds": [
{
"title": "梳理检索指标与验收口径",
"description": "根据模块1目标与 KPI 补齐任务种子",
"evidence_refs": ["module_1.goal_statement"],
}
],
"verification_metrics": [],
"risk_gates": [],
},
}
monkeypatch.setattr(charter_prefill_builder, "_generate_charter_seed", fake_generate_charter_seed)
result = await charter_prefill_builder._build_ai_first_charter_content(
db=object(),
tenant_id=1,
project_id=7,
signal_rows=[],
signal_ids=[11, 12],
raw_text="请升级内部知识库问答能力。",
)
assert result["modules"]["module_1"]["kpis"] == ""
assert result["modules"]["module_2"]["alignment_qa"] == ""
assert "需求到上线周期缩短" not in json.dumps(result, ensure_ascii=False)
assert result["field_status"]["module_1"]["kpis"] == "evidence_insufficient"
assert result["field_status"]["module_2"]["alignment_qa"] == "evidence_insufficient"
assert result["field_evidence"]["module_1"]["problem_statement"][0]["signal_id"] == 11
assert result["readiness"]["ready_for_confirm"] is False
assert "module_1.kpis" in result["readiness"]["blocking_fields"]
assert result["derived_artifacts"]["task_seeds"][0]["title"] == "梳理检索指标与验收口径"

View File

@ -99,7 +99,7 @@ def test_build_action_card_for_pending_manual_continue() -> None:
assert (actions[0].get("value") or {}).get("work_item_id") == 99 assert (actions[0].get("value") or {}).get("work_item_id") == 99
def test_build_action_card_for_queue_pending_confirm() -> None: def test_build_action_card_for_queue_pending_confirm_returns_none() -> None:
card = FeishuCardActionMixin._build_action_card_from_capture_result( card = FeishuCardActionMixin._build_action_card_from_capture_result(
capture_result={ capture_result={
"reply_template_key": "queue_pending_confirm", "reply_template_key": "queue_pending_confirm",
@ -109,14 +109,7 @@ def test_build_action_card_for_queue_pending_confirm() -> None:
mention_open_id=None, mention_open_id=None,
mention_name=None, mention_name=None,
) )
assert isinstance(card, dict) assert card is None
assert card["header"]["title"]["content"] == "待确认动作"
action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {})
actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else []
action_names = {((btn.get("value") or {}).get("action")) for btn in actions if isinstance(btn, dict)}
assert {"continue", "cancel"} <= action_names
queue_ids = {((btn.get("value") or {}).get("queue_item_id")) for btn in actions if isinstance(btn, dict)}
assert queue_ids == {201}
@pytest.mark.asyncio @pytest.mark.asyncio
@ -177,7 +170,7 @@ async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_handle_card_action_trigger_queue_continue_dispatches_pipeline(monkeypatch) -> None: async def test_handle_card_action_trigger_queue_continue_is_ignored(monkeypatch) -> None:
called: dict[str, object] = {} called: dict[str, object] = {}
class DummyDB: class DummyDB:
@ -192,30 +185,9 @@ async def test_handle_card_action_trigger_queue_continue_dispatches_pipeline(mon
async def _fake_reply_text(**kwargs): async def _fake_reply_text(**kwargs):
called["reply_text"] = kwargs.get("text") called["reply_text"] = kwargs.get("text")
called["reply_chat"] = kwargs.get("chat_id")
monkeypatch.setattr(service, "_safe_reply_text", _fake_reply_text) monkeypatch.setattr(service, "_safe_reply_text", _fake_reply_text)
class _FakeOperator:
id = 77
class _FakePipeline:
def __init__(self, db, tenant_id):
called["pipeline_tenant_id"] = tenant_id
async def _resolve_command_operator_user(self, *, sender):
called["operator_sender"] = sender
return _FakeOperator()
async def confirm_item(self, *, item_id, user_id):
called["confirm_item"] = {"item_id": item_id, "user_id": user_id}
return {"project_id": "18", "source_registry_id": "501"}
monkeypatch.setattr(
"app.services.integrations.feishu_signal_pipeline.FeishuSignalPipelineService",
_FakePipeline,
)
result = await service._handle_card_action_trigger( result = await service._handle_card_action_trigger(
{ {
"open_message_id": "om_queue_1", "open_message_id": "om_queue_1",
@ -226,9 +198,7 @@ async def test_handle_card_action_trigger_queue_continue_dispatches_pipeline(mon
) )
assert result["code"] == 0 assert result["code"] == 0
assert result["msg"] == "card_action_ignored"
data = result.get("data") if isinstance(result.get("data"), dict) else {} data = result.get("data") if isinstance(result.get("data"), dict) else {}
assert data.get("handled") is True assert data.get("reason") == "missing_work_item_id"
assert data.get("queue_item_id") == 201 assert "reply_text" not in called
assert called["confirm_item"] == {"item_id": 201, "user_id": 77}
assert called["reply_text"] == "已继续处理:信号源注册#501 已接入项目#18。"
assert called["reply_chat"] == "oc_queue_1"

View File

@ -133,6 +133,50 @@ async def test_feishu_card_action_callback_maps_ignored_to_refresh_message(monke
assert response.json()["toast"]["content"] == "这张卡片已失效,请刷新后再试" assert response.json()["toast"]["content"] == "这张卡片已失效,请刷新后再试"
@pytest.mark.asyncio
async def test_feishu_card_action_callback_drops_legacy_queue_item_id(monkeypatch) -> None:
called: dict[str, object] = {}
async def _fake_handle_card_action_trigger(self, event): # noqa: ANN001
called["event"] = event
return {
"code": 0,
"msg": "card_action_ignored",
"data": {
"reason": "missing_work_item_id",
},
}
monkeypatch.setattr(
FeishuIntegrationService,
"_handle_card_action_trigger",
_fake_handle_card_action_trigger,
)
app = _build_app(_FakeDB())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/feishu/card-action",
json={
"type": "card.action.trigger",
"open_message_id": "om_queue_legacy_1",
"open_chat_id": "oc_queue_legacy_1",
"operator": {"open_id": "ou_abc", "name": "测试用户"},
"action": {
"value": {
"action": "continue",
"queue_item_id": 201,
}
},
},
)
assert response.status_code == 200
assert response.json()["toast"]["content"] == "这张卡片已失效,请刷新后再试"
assert "queue_item_id" not in called["event"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_feishu_card_action_callback_falls_back_when_signature_mismatch(monkeypatch) -> None: async def test_feishu_card_action_callback_falls_back_when_signature_mismatch(monkeypatch) -> None:
called: dict[str, object] = {} called: dict[str, object] = {}

View File

@ -149,5 +149,26 @@ async def test_auto_create_project_from_new_signal_persists_signal_sources(db_se
) )
).scalars().all() ).scalars().all()
assert len(registries) == 2 assert len(registries) == 2
assert all(registry.signal_id is not None for registry in registries)
assert registries[0].raw_context["origin"] == "feishu_new_project" assert registries[0].raw_context["origin"] == "feishu_new_project"
assert registries[0].source_kind == "feishu" assert registries[0].source_kind == "feishu"
generated_signals = (
await db_session.execute(
select(Signal).where(
Signal.id != int(signal.id),
Signal.tenant_id == 1,
)
)
).scalars().all()
assert len(generated_signals) == 2
links = (
await db_session.execute(
select(ProjectSignalLink).where(
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.tenant_id == 1,
)
)
).scalars().all()
assert len(links) == 3

View File

@ -1,3 +1,4 @@
import inspect
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@ -42,6 +43,8 @@ from app.services.integrations.feishu_signal_pipeline.mixin_process_message_help
from app.services.integrations.feishu_signal_pipeline.mixin_queue_confirm_undo import ( from app.services.integrations.feishu_signal_pipeline.mixin_queue_confirm_undo import (
FeishuPipelineQueueConfirmUndoMixin, FeishuPipelineQueueConfirmUndoMixin,
) )
from app.services.integrations.feishu_signal_pipeline.service import FeishuSignalPipelineService
from app.services.integrations.feishu_signal_pipeline import mixin_process_message_core
from app.services.signal.signal_capture_service import mixin_capture_entry from app.services.signal.signal_capture_service import mixin_capture_entry
from app.services.signal.signal_capture_service.service import ProactiveSignalCaptureService from app.services.signal.signal_capture_service.service import ProactiveSignalCaptureService
from app.services.signal.signal_capture_service.mixin_task_orchestration_main import ( from app.services.signal.signal_capture_service.mixin_task_orchestration_main import (
@ -198,6 +201,19 @@ async def test_followup_queue_handler_ignores_unmatched_thread_anchor() -> None:
assert result is None assert result is None
def test_process_message_no_longer_calls_legacy_queue_followup() -> None:
source = inspect.getsource(mixin_process_message_core.FeishuPipelineProcessMessageCoreMixin.process_message)
assert "_maybe_handle_queue_followup" not in source
def test_feishu_signal_pipeline_service_runtime_excludes_legacy_followup_mixins() -> None:
source = inspect.getsource(FeishuSignalPipelineService)
assert "FeishuPipelineFollowupAMixin" not in source
assert "FeishuPipelineFollowupBMixin" not in source
def test_decorate_capture_result_adds_queue_action_card_metadata() -> None: def test_decorate_capture_result_adds_queue_action_card_metadata() -> None:
result = FeishuPipelineProcessMessageHelpersBMixin._decorate_capture_result( result = FeishuPipelineProcessMessageHelpersBMixin._decorate_capture_result(
{ {
@ -391,6 +407,12 @@ async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeyp
assert calls["cache"] == 1 assert calls["cache"] == 1
def test_capture_from_feishu_no_longer_references_legacy_fallback() -> None:
source = inspect.getsource(mixin_capture_entry.SignalCaptureEntryMixin.capture_from_feishu)
assert "_capture_from_feishu_legacy_flow" not in source
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None: async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None:
called = {"fallback": False} called = {"fallback": False}

View File

@ -0,0 +1,115 @@
from __future__ import annotations
from types import SimpleNamespace
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.api import knowledge_graph
from app.core.database import get_db
from app.dependencies.auth import get_current_user
from app.dependencies.tenant import get_tenant_id
class _FakeDB:
async def commit(self) -> None:
return None
async def rollback(self) -> None:
return None
def _build_app(*, db_session) -> FastAPI:
app = FastAPI()
app.include_router(knowledge_graph.router)
async def _fake_get_db():
yield db_session
app.dependency_overrides[get_db] = _fake_get_db
app.dependency_overrides[get_tenant_id] = lambda: 1
app.dependency_overrides[get_current_user] = lambda: SimpleNamespace(
id=9001,
tenant_id=1,
name="管理员",
username="admin",
role="ADMIN",
)
return app
@pytest.mark.asyncio
async def test_sync_work_items_route_uses_work_item_delta_mainline(monkeypatch) -> None:
called: dict[str, int] = {}
async def _fake_enqueue_work_item_delta_events(self, *, limit: int = 500): # noqa: ANN001
called["limit"] = int(limit)
return {"enqueued": int(limit), "channel": "work_item_delta"}
async def _should_not_use_legacy_alias(self, *, limit: int = 500): # noqa: ANN001
raise AssertionError(f"legacy alias should not be used: {limit}")
monkeypatch.setattr(
knowledge_graph.ProjectKnowledgeGraphService,
"enqueue_work_item_delta_events",
_fake_enqueue_work_item_delta_events,
)
monkeypatch.setattr(
knowledge_graph.ProjectKnowledgeGraphService,
"enqueue_feishu_delta_events",
_should_not_use_legacy_alias,
)
app = _build_app(db_session=_FakeDB())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/ai-steward/knowledge-graph/sync/work-items",
json={"limit": 321},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert payload["message"] == "success"
assert payload["data"] == {"enqueued": 321, "channel": "work_item_delta"}
assert called["limit"] == 321
@pytest.mark.asyncio
async def test_sync_feishu_route_is_compat_alias_for_work_item_mainline(monkeypatch) -> None:
called: dict[str, int] = {}
async def _fake_enqueue_work_item_delta_events(self, *, limit: int = 500): # noqa: ANN001
called["limit"] = int(limit)
return {"enqueued": int(limit), "channel": "work_item_delta"}
async def _should_not_use_legacy_alias(self, *, limit: int = 500): # noqa: ANN001
raise AssertionError(f"legacy alias should not be used: {limit}")
monkeypatch.setattr(
knowledge_graph.ProjectKnowledgeGraphService,
"enqueue_work_item_delta_events",
_fake_enqueue_work_item_delta_events,
)
monkeypatch.setattr(
knowledge_graph.ProjectKnowledgeGraphService,
"enqueue_feishu_delta_events",
_should_not_use_legacy_alias,
)
app = _build_app(db_session=_FakeDB())
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.post(
"/ai-steward/knowledge-graph/sync/feishu",
json={"limit": 123},
)
assert response.status_code == 200
payload = response.json()
assert payload["code"] == 0
assert payload["message"] == "success"
assert payload["data"] == {"enqueued": 123, "channel": "work_item_delta"}
assert called["limit"] == 123

View File

@ -0,0 +1,308 @@
from __future__ import annotations
from datetime import datetime, timedelta
import pytest
import pytest_asyncio
from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.models.ai_orchestration_run import AIOrchestrationRun
from app.models.base import Base
from app.models.feishu_item import FeishuItem
from app.models.ingest_event import IngestEvent
from app.models.ingest_offset import IngestOffset
from app.models.kg_entity import KGEntity
from app.models.kg_evidence import KGEvidence
from app.models.kg_relation import KGRelation
from app.models.kg_sync_run import KGSyncRun
from app.models.project import Project
from app.models.signal import Signal
from app.models.user import User
from app.models.work_item import WorkItem
from app.services.project_knowledge_graph_service import ProjectKnowledgeGraphService
def _use_sqlite_json_types(*tables) -> None:
for table in tables:
for column in table.columns:
if column.type.__class__.__name__ == "JSONB":
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
tables = [
User.__table__,
Project.__table__,
FeishuItem.__table__,
Signal.__table__,
IngestEvent.__table__,
IngestOffset.__table__,
WorkItem.__table__,
AIOrchestrationRun.__table__,
KGEntity.__table__,
KGRelation.__table__,
KGEvidence.__table__,
KGSyncRun.__table__,
]
_use_sqlite_json_types(*tables)
async with engine.begin() as conn:
await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables))
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_enqueue_work_item_delta_events_reads_feishu_work_items_into_kg(db_session) -> None:
user = User(
tenant_id=1,
username="kg_owner",
email="kg_owner@example.com",
password_hash="hash",
name="图谱负责人",
role="ADMIN",
status="active",
is_active=True,
)
project = Project(
tenant_id=1,
name="alpha",
title="Alpha 项目",
status="active",
owner_id=None,
assigned_role="backend",
)
seed_ingest_event = IngestEvent(
tenant_id=1,
channel="seed",
event_key="seed-1",
status="done",
payload={},
)
ignored_ingest_event = IngestEvent(
tenant_id=1,
channel="seed",
event_key="seed-2",
status="done",
payload={},
)
db_session.add_all([user, project, seed_ingest_event, ignored_ingest_event])
await db_session.flush()
target_work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(seed_ingest_event.id),
source_system="feishu",
source_message_id="om_work_item_1",
state="waiting_manual",
intent="project.create",
project_id=int(project.id),
execution_result={"status": "pending_manual"},
review_summary={"verified": False, "issues": ["pending_manual"]},
)
ignored_work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(ignored_ingest_event.id),
source_system="ai_pmo_ui",
source_message_id="om_work_item_ignored",
state="succeeded",
intent="followup.confirm",
execution_result={"status": "succeeded"},
review_summary={"verified": True, "issues": []},
)
db_session.add_all([target_work_item, ignored_work_item])
await db_session.flush()
run = AIOrchestrationRun(
tenant_id=1,
trace_id="trace-work-item-1",
run_type="ingress",
status="completed",
source_system="feishu",
source_message_id="om_work_item_1",
sender="测试用户",
channel_ref="chat-alpha",
message_text="请创建项目 Alpha",
work_item_id=int(target_work_item.id),
project_id=int(project.id),
)
db_session.add(run)
await db_session.commit()
service = ProjectKnowledgeGraphService(db_session, tenant_id=1)
enqueue_result = await service.enqueue_work_item_delta_events(limit=20)
assert enqueue_result["enqueued"] == 1
queued_event = (
await db_session.execute(
select(IngestEvent).where(
IngestEvent.tenant_id == 1,
IngestEvent.channel == "work_item_delta",
IngestEvent.event_key == "om_work_item_1",
)
)
).scalar_one()
assert queued_event.payload["work_item_id"] == int(target_work_item.id)
assert queued_event.payload["project_id"] == int(project.id)
assert queued_event.payload["source_system"] == "feishu"
assert "feishu_item_id" not in queued_event.payload
process_result = await service.process_pending_events(max_batch=20)
assert process_result["done"] == 1
work_item_entity = (
await db_session.execute(
select(KGEntity).where(
KGEntity.tenant_id == 1,
KGEntity.entity_type == "work_item",
KGEntity.entity_key == f"work_item:{int(target_work_item.id)}",
)
)
).scalar_one()
assert work_item_entity.payload["work_item_id"] == int(target_work_item.id)
assert work_item_entity.payload["project_id"] == int(project.id)
assert work_item_entity.payload["source_system"] == "feishu"
evidence = (
await db_session.execute(
select(KGEvidence).where(
KGEvidence.tenant_id == 1,
KGEvidence.evidence_type == "work_item_delta",
)
)
).scalars().all()
assert len(evidence) >= 1
assert any("请创建项目 Alpha" in row.content_excerpt for row in evidence)
relation = (
await db_session.execute(
select(KGRelation).where(
KGRelation.tenant_id == 1,
KGRelation.relation_type == "belongs_to",
)
)
).scalar_one_or_none()
assert relation is not None
@pytest.mark.asyncio
async def test_enqueue_feishu_delta_events_is_compat_alias_for_work_item_delta(db_session) -> None:
project = Project(
tenant_id=1,
name="alpha",
title="Alpha 项目",
status="active",
owner_id=None,
assigned_role="backend",
)
seed_ingest_event = IngestEvent(
tenant_id=1,
channel="seed",
event_key="seed-compat",
status="done",
payload={},
)
db_session.add_all([project, seed_ingest_event])
await db_session.flush()
target_work_item = WorkItem(
tenant_id=1,
ingest_event_id=int(seed_ingest_event.id),
source_system="feishu",
source_message_id="om_work_item_compat",
state="waiting_manual",
intent="project.create",
project_id=int(project.id),
execution_result={"status": "pending_manual"},
review_summary={"verified": False, "issues": ["pending_manual"]},
)
db_session.add(target_work_item)
await db_session.commit()
service = ProjectKnowledgeGraphService(db_session, tenant_id=1)
enqueue_result = await service.enqueue_feishu_delta_events(limit=20)
assert enqueue_result["enqueued"] == 1
queued_event = (
await db_session.execute(
select(IngestEvent).where(
IngestEvent.tenant_id == 1,
IngestEvent.channel == "work_item_delta",
IngestEvent.event_key == "om_work_item_compat",
)
)
).scalar_one()
assert queued_event.payload["work_item_id"] == int(target_work_item.id)
offset = (
await db_session.execute(
select(IngestOffset).where(
IngestOffset.tenant_id == 1,
IngestOffset.channel == "work_item_delta",
IngestOffset.offset_key == "last_work_item_updated_at",
)
)
).scalar_one()
assert offset.offset_value
@pytest.mark.asyncio
async def test_sync_cycle_and_slo_expose_work_item_delta_naming(db_session, monkeypatch) -> None:
service = ProjectKnowledgeGraphService(db_session, tenant_id=1)
async def _structured(*, limit: int = 1000) -> dict[str, int]:
return {"synced": int(limit)}
async def _work_item_enqueue(*, limit: int = 500) -> dict[str, int]:
return {"enqueued": int(limit)}
async def _process(*, max_batch: int = 200, max_retry: int = 3) -> dict[str, int]:
return {"processed": int(max_batch), "done": int(max_batch), "retry": 0, "dead_letter": 0}
async def _code_scan(*, base_dir=None, interval_hours: int = 6) -> dict[str, int | str | None]:
return {"interval_hours": int(interval_hours), "base_dir": base_dir}
async def _symbol_index(*, base_dir=None, interval_hours: int = 6) -> dict[str, int | str | None]:
return {"interval_hours": int(interval_hours), "base_dir": base_dir}
async def _feature_catalog(*, interval_hours: int = 6) -> dict[str, int]:
return {"interval_hours": int(interval_hours)}
async def _guardrails(*, interval_hours: int = 6) -> dict[str, int]:
return {"interval_hours": int(interval_hours)}
monkeypatch.setattr(service, "sync_structured_delta", _structured)
monkeypatch.setattr(service, "enqueue_work_item_delta_events", _work_item_enqueue, raising=False)
monkeypatch.setattr(service, "process_pending_events", _process)
monkeypatch.setattr(service, "sync_codebase_if_due", _code_scan)
monkeypatch.setattr(service, "sync_symbol_index_if_due", _symbol_index)
monkeypatch.setattr(service, "sync_feature_catalog_if_due", _feature_catalog)
monkeypatch.setattr(service, "sync_model_guardrails_if_due", _guardrails)
cycle_result = await service.run_incremental_cycle(delta_limit=12, event_batch=5)
assert cycle_result["run_status"] == "success"
assert cycle_result["work_item_enqueue"] == {"enqueued": 12}
assert "feishu_enqueue" not in cycle_result
stale_at = (datetime.now() - timedelta(minutes=7)).replace(microsecond=0)
await service.set_ingest_offset(
channel="feishu_delta",
offset_key="last_feishu_item_updated_at",
offset_value=stale_at.isoformat(),
payload={"compat": True},
)
await db_session.commit()
slo_result = await service.get_sync_slo(lookback_hours=24)
assert "work_item_delta" in slo_result["freshness_minutes"]
assert slo_result["freshness_minutes"]["work_item_delta"] is not None
assert "feishu_delta" not in slo_result["freshness_minutes"]

View File

@ -9,6 +9,7 @@ from app.api.router import api_router
from app.api.ai_steward_api import router as ai_steward_router from app.api.ai_steward_api import router as ai_steward_router
from app.api.rag_api import routes_query from app.api.rag_api import routes_query
from app.schemas.rag import RAGAskRequest from app.schemas.rag import RAGAskRequest
from app.services.adaptive_rag_service.mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin
def test_rag_ask_request_normalizes_contract_fields() -> None: def test_rag_ask_request_normalizes_contract_fields() -> None:
@ -262,6 +263,96 @@ def test_build_rag_response_payload_preserves_rag_fields() -> None:
assert payload["evidence_pack"]["tool_trace"] == [{"tool": "vector_search"}] assert payload["evidence_pack"]["tool_trace"] == [{"tool": "vector_search"}]
def test_answer_payload_normalizes_source_registry_fields_into_citations() -> None:
class _FakeService(AdaptiveRAGAnswerPayloadMixin):
@staticmethod
def _to_float(value, default: float) -> float:
try:
return float(value)
except Exception:
return float(default)
service = _FakeService()
citations = service._build_citations_from_sources(
[
{
"source": "pmflow",
"label": "PMFlow[task#88]",
"score": 0.91,
"metadata": {
"record_type": "task",
"record_id": 88,
"project_id": 17,
"target_url": "/projects/17/tasks/88",
"updated_at": "2026-04-01T09:30:00+08:00",
},
},
{
"source": "doc",
"label": "Document[架构说明]",
"score": 0.73,
"metadata": {
"doc_id": 3,
"chunk_index": 2,
"file_url": "https://docs.example.com/spec",
},
},
]
)
assert citations == [
{
"source_type": "pmflow",
"source_label": "PMFlow[task#88]",
"source_id": "task:88",
"chunk_id": None,
"target_url": "/projects/17/tasks/88",
"project_id": "17",
"updated_at": "2026-04-01T09:30:00+08:00",
"permission_scope": "project",
"source_priority": 100,
"confidence": 0.91,
},
{
"source_type": "doc",
"source_label": "Document[架构说明]",
"source_id": "3",
"chunk_id": "2",
"target_url": "https://docs.example.com/spec",
"project_id": None,
"updated_at": None,
"permission_scope": "tenant",
"source_priority": 70,
"confidence": 0.73,
},
]
assert service._build_sources_used_from_citations(citations) == [
{
"source_type": "pmflow",
"source_label": "PMFlow[task#88]",
"source_id": "task:88",
"chunk_id": None,
"target_url": "/projects/17/tasks/88",
"project_id": "17",
"updated_at": "2026-04-01T09:30:00+08:00",
"permission_scope": "project",
"source_priority": 100,
},
{
"source_type": "doc",
"source_label": "Document[架构说明]",
"source_id": "3",
"chunk_id": "2",
"target_url": "https://docs.example.com/spec",
"project_id": None,
"updated_at": None,
"permission_scope": "tenant",
"source_priority": 70,
},
]
def test_build_rag_response_payload_adds_grounding_contract_defaults() -> None: def test_build_rag_response_payload_adds_grounding_contract_defaults() -> None:
payload = rag_ask_contract.build_rag_response_payload( payload = rag_ask_contract.build_rag_response_payload(
ingress_result={ ingress_result={

View File

@ -45,3 +45,24 @@ def test_semantic_lint_allows_workflow_semantic_planner_directory(tmp_path) -> N
) )
assert problems == [] assert problems == []
def test_semantic_lint_repo_has_no_compat_baseline_exceptions() -> None:
repo_root = Path(__file__).resolve().parents[2]
baseline_path = repo_root / "scripts" / "semantic_lint_baseline.txt"
baseline_rows = [
line.strip()
for line in baseline_path.read_text(encoding="utf-8").splitlines()
if line.strip() and not line.strip().startswith("#")
]
assert baseline_rows == []
module = _load_semantic_lint_module()
problems = module.collect_problems(
root=repo_root,
scan_roots=module.DEFAULT_SCAN_ROOTS,
baseline_path=Path("scripts/semantic_lint_baseline.txt"),
)
assert problems == []

View File

@ -0,0 +1,234 @@
from __future__ import annotations
import os
import pytest
import pytest_asyncio
from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow"
from app.models.base import Base
from app.models.project import Project
from app.models.project_signal_link import ProjectSignalLink
from app.models.signal import Signal
from app.models.signal_source_registry import SignalSourceRegistry
from app.models.user import User
from app.services.signal.signal_source_repair import SignalSourceRepairService
def _use_sqlite_json_types() -> None:
for table in (User.__table__, SignalSourceRegistry.__table__):
for column in table.columns:
if column.name in {"assigned_domains", "config", "raw_context"}:
column.type = JSON()
@pytest_asyncio.fixture
async def db_session():
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
_use_sqlite_json_types()
async with engine.begin() as conn:
await conn.run_sync(
lambda sync_conn: Base.metadata.create_all(
sync_conn,
tables=[
User.__table__,
Project.__table__,
Signal.__table__,
ProjectSignalLink.__table__,
SignalSourceRegistry.__table__,
],
)
)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as session:
yield session
await engine.dispose()
@pytest.mark.asyncio
async def test_repair_registries_materializes_formal_signal_without_capture(db_session, monkeypatch) -> None:
owner = User(
tenant_id=1,
username="owner",
email="owner@example.com",
password_hash="hash",
name="负责人",
role="ADMIN",
status="active",
is_active=True,
)
project = Project(
tenant_id=1,
name="cashflow",
title="现金流项目",
status="active",
assigned_role="backend",
)
db_session.add_all([owner, project])
await db_session.flush()
registry = SignalSourceRegistry(
tenant_id=1,
project_id=int(project.id),
created_by_user_id=int(owner.id),
source_kind="github",
source_name="现金流日报仓库",
source_identifier="daily_cashflow_analysis",
description="每日现金流分析脚本仓库",
requested_via="ai_steward",
status="pending",
onboarding_status="pending",
config={},
raw_context={},
)
db_session.add(registry)
await db_session.commit()
service = SignalSourceRepairService(db_session)
async def _fail_if_capture_called(**kwargs): # noqa: ANN001
_ = kwargs
raise AssertionError("repair should materialize Signal directly instead of calling capture flow")
async def _noop_emit_visibility_records(**kwargs): # noqa: ANN001
_ = kwargs
monkeypatch.setattr(service, "_capture_registry", _fail_if_capture_called)
monkeypatch.setattr(service, "_emit_visibility_records", _noop_emit_visibility_records)
result = await service.repair_registries(
tenant_id=1,
project_id=int(project.id),
registry_ids=[int(registry.id)],
sender="PMFlow修复器",
)
assert len(result) == 1
assert result[0]["capture_status"] == "materialized"
assert result[0]["signal_id"] is not None
refreshed_registry = (
await db_session.execute(select(SignalSourceRegistry).where(SignalSourceRegistry.id == int(registry.id)))
).scalar_one()
assert refreshed_registry.signal_id == int(result[0]["signal_id"])
signal = (
await db_session.execute(select(Signal).where(Signal.id == int(result[0]["signal_id"])))
).scalar_one()
assert signal.title == "现金流日报仓库"
assert signal.matched_project_id == int(project.id)
assert signal.source == "daily_cashflow_analysis"
link = (
await db_session.execute(
select(ProjectSignalLink).where(
ProjectSignalLink.project_id == int(project.id),
ProjectSignalLink.signal_id == int(signal.id),
)
)
).scalar_one_or_none()
assert link is not None
@pytest.mark.asyncio
async def test_repair_registries_relinks_existing_project_signal_before_materializing(db_session, monkeypatch) -> None:
owner = User(
tenant_id=1,
username="owner2",
email="owner2@example.com",
password_hash="hash",
name="负责人2",
role="ADMIN",
status="active",
is_active=True,
)
project = Project(
tenant_id=1,
name="cashflow-existing",
title="现金流项目-已有信号",
status="active",
assigned_role="backend",
)
db_session.add_all([owner, project])
await db_session.flush()
signal = Signal(
tenant_id=1,
source="cashflow_alert_subscription",
content="已有现金流预警信号",
impact_score=0.7,
priority="high",
processed=False,
strategic_analysis="{}",
tasks_generated="[]",
matched_project_id=None,
title="现金流预警系统需求",
signal_type="新需求",
summary="现金流健康评分与预警系统",
reasoning="[]",
confidence=0.9,
recommended_action="enter_only",
status="待处理",
)
db_session.add(signal)
await db_session.flush()
db_session.add(
ProjectSignalLink(
tenant_id=1,
project_id=int(project.id),
signal_id=int(signal.id),
link_type="related",
relevance_score=1.0,
)
)
registry = SignalSourceRegistry(
tenant_id=1,
project_id=int(project.id),
created_by_user_id=int(owner.id),
source_kind="webhook",
source_name="现金流预警订阅",
source_identifier="cashflow_alert_subscription",
description="企业订阅现金流预警,当健康评分低于阈值时推送通知",
requested_via="ai_steward",
status="pending",
onboarding_status="pending",
config={},
raw_context={},
)
db_session.add(registry)
await db_session.commit()
service = SignalSourceRepairService(db_session)
async def _noop_emit_visibility_records(**kwargs): # noqa: ANN001
_ = kwargs
monkeypatch.setattr(service, "_emit_visibility_records", _noop_emit_visibility_records)
result = await service.repair_registries(
tenant_id=1,
project_id=int(project.id),
registry_ids=[int(registry.id)],
sender="PMFlow修复器",
)
assert len(result) == 1
assert result[0]["capture_status"] == "relinked"
assert result[0]["signal_id"] == int(signal.id)
refreshed_registry = (
await db_session.execute(select(SignalSourceRegistry).where(SignalSourceRegistry.id == int(registry.id)))
).scalar_one()
assert refreshed_registry.signal_id == int(signal.id)
refreshed_signal = (
await db_session.execute(select(Signal).where(Signal.id == int(signal.id)))
).scalar_one()
assert refreshed_signal.matched_project_id == int(project.id)
signals = (await db_session.execute(select(Signal).order_by(Signal.id.asc()))).scalars().all()
assert len(signals) == 1

View File

@ -7,20 +7,23 @@ import pytest_asyncio
from sqlalchemy import JSON, select from sqlalchemy import JSON, select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" os.environ["DATABASE_URL"] = "postgresql+asyncpg://pmflow:pmflow_pass@127.0.0.1:5435/pmflow"
from app.models.base import Base from app.models.base import Base
from app.models.project import Project from app.models.project import Project
from app.models.project_signal_link import ProjectSignalLink
from app.models.signal import Signal from app.models.signal import Signal
from app.models.signal_source_registry import SignalSourceRegistry from app.models.signal_source_registry import SignalSourceRegistry
from app.models.truth_node import TruthNode
from app.models.user import User from app.models.user import User
from app.api.sstc_api.signals_list import list_signals
from app.services.signal.signal_source_orchestration import SignalSourceOrchestrationService from app.services.signal.signal_source_orchestration import SignalSourceOrchestrationService
def _use_sqlite_json_types() -> None: def _use_sqlite_json_types() -> None:
for table in (User.__table__, SignalSourceRegistry.__table__): for table in (User.__table__, SignalSourceRegistry.__table__, TruthNode.__table__):
for column in table.columns: for column in table.columns:
if column.name in {"assigned_domains", "config", "raw_context"}: if column.name in {"assigned_domains", "config", "raw_context", "content"}:
column.type = JSON() column.type = JSON()
@ -36,7 +39,9 @@ async def db_session():
User.__table__, User.__table__,
Project.__table__, Project.__table__,
Signal.__table__, Signal.__table__,
ProjectSignalLink.__table__,
SignalSourceRegistry.__table__, SignalSourceRegistry.__table__,
TruthNode.__table__,
], ],
) )
) )
@ -111,6 +116,117 @@ async def test_materialize_requirement_sources_dedupes_duplicate_items(db_sessio
rows = (await db_session.execute(select(SignalSourceRegistry).order_by(SignalSourceRegistry.id))).scalars().all() rows = (await db_session.execute(select(SignalSourceRegistry).order_by(SignalSourceRegistry.id))).scalars().all()
assert len(rows) == 2 assert len(rows) == 2
assert all(row.signal_id is not None for row in rows)
assert len({int(row.signal_id) for row in rows if row.signal_id is not None}) == 2
assert rows[0].raw_context["origin"] == "project_analysis" assert rows[0].raw_context["origin"] == "project_analysis"
assert rows[0].raw_context["requirement_item"]["title"] == "内容本地化" assert rows[0].raw_context["requirement_item"]["title"] == "内容本地化"
assert rows[1].raw_context["requirement_item"]["section"] == "MVP 功能" assert rows[1].raw_context["requirement_item"]["section"] == "MVP 功能"
generated_signals = (
await db_session.execute(
select(Signal).where(Signal.id != int(signal.id)).order_by(Signal.id.asc())
)
).scalars().all()
assert len(generated_signals) == 2
assert generated_signals[0].title == "核心痛点 - 内容本地化"
assert generated_signals[1].title == "MVP 功能 - 社媒运营"
links = (
await db_session.execute(
select(ProjectSignalLink).where(ProjectSignalLink.project_id == int(project.id)).order_by(ProjectSignalLink.id.asc())
)
).scalars().all()
assert len(links) == 2
assert {int(link.signal_id) for link in links} == {int(item.id) for item in generated_signals}
@pytest.mark.asyncio
async def test_list_signals_returns_linked_signal_when_charter_exists(db_session) -> None:
owner = User(
tenant_id=1,
username="sstc-owner",
email="sstc-owner@example.com",
password_hash="hash",
name="负责人",
role="ADMIN",
status="active",
is_active=True,
)
project = Project(
tenant_id=1,
name="sstc-project",
title="SSTC Project",
status="active",
assigned_role="backend",
owner_id=1,
)
signal = Signal(
tenant_id=1,
source="bank_statement_upload",
content="银行流水导入模块开发",
impact_score=0.8,
priority="high",
processed=False,
strategic_analysis="{}",
tasks_generated="[]",
matched_project_id=int(1),
title="📊 银行流水导入功能需求",
signal_type="新需求",
summary="银行流水导入模块开发",
reasoning="[]",
confidence=0.9,
recommended_action="enter_only",
status="待处理",
)
db_session.add_all([owner, project, signal])
await db_session.flush()
project.owner_id = int(owner.id)
signal.matched_project_id = int(project.id)
db_session.add(
ProjectSignalLink(
tenant_id=1,
project_id=int(project.id),
signal_id=int(signal.id),
link_type="related",
relevance_score=1.0,
)
)
db_session.add(
TruthNode(
tenant_id=1,
node_type="charter",
project_id=int(project.id),
signal_id=int(signal.id),
created_by=int(owner.id),
title="银行流水导入宪章",
content={"signal_ids": [int(signal.id)]},
version=1,
status="draft",
)
)
await db_session.commit()
response = await list_signals(
project_id=str(project.id),
status="linked",
keyword=None,
source=None,
priority=None,
created_from=None,
created_to=None,
limit=10,
offset=0,
db=db_session,
current_user=owner,
)
payload = response["data"]
assert payload["total"] == 1
assert len(payload["items"]) == 1
assert payload["items"][0]["id"] == str(signal.id)
assert payload["items"][0]["status_code"] == "linked"
assert payload["items"][0]["status"] == "已关联宪章"
assert payload["items"][0]["can_create_charter"] is False
assert payload["items"][0]["charter_name"] == "银行流水导入宪章"

View File

@ -0,0 +1,170 @@
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from app.api.sstc_api import charters_ai_alignment as charters_ai_alignment_api
from app.api.sstc_api import releases_status_generate as releases_status_generate_api
@pytest.mark.asyncio
async def test_confirm_alignment_blocks_when_charter_readiness_is_not_ready(monkeypatch) -> None:
async def _fake_ensure_project(service, project_id):
_ = (service, project_id)
return SimpleNamespace(id=6)
async def _fake_ensure_charter(db, *, tenant_id: int, project_id: int, charter_id: int):
_ = (db, tenant_id, project_id, charter_id)
return SimpleNamespace(
id=55,
title="内部知识库升级宪章",
status="aligning",
content={
"readiness": {
"ready_for_confirm": False,
"blocking_fields": ["module_1.kpis", "module_2.alignment_qa"],
"blocking_reasons": ["关键 KPI 缺少证据", "固定对齐问答未完成"],
}
},
)
class _FakeResult:
def scalar_one_or_none(self):
return None
def scalars(self):
return self
def all(self):
return []
class _FakeDB:
async def execute(self, stmt):
_ = stmt
return _FakeResult()
def add(self, value):
_ = value
return None
async def commit(self):
raise AssertionError("readiness blocked charters must not commit confirmation")
monkeypatch.setattr(charters_ai_alignment_api, "_ensure_project", _fake_ensure_project)
monkeypatch.setattr(charters_ai_alignment_api, "_ensure_charter", _fake_ensure_charter)
with pytest.raises(HTTPException) as exc_info:
await charters_ai_alignment_api.confirm_alignment(
project_id="6",
charter_id="55",
body=SimpleNamespace(score=90, confirmed=True, comments="已确认对齐"),
db=_FakeDB(),
current_user=SimpleNamespace(tenant_id=1, id=7),
permission_user=SimpleNamespace(),
)
assert exc_info.value.status_code == 409
assert "关键 KPI 缺少证据" in str(exc_info.value.detail)
assert "module_1.kpis" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_generate_release_verification_uses_charter_verification_metrics(monkeypatch) -> None:
async def _fake_ensure_project(service, project_id):
_ = (service, project_id)
return SimpleNamespace(id=6)
async def _fake_ensure_release(db, *, tenant_id: int, project_id: int, release_node_id: int):
_ = (db, tenant_id, project_id, release_node_id)
return SimpleNamespace(
id=88,
status="pending",
content={"charter_id": 55},
)
async def _fake_ensure_charter(db, *, tenant_id: int, project_id: int, charter_id: int):
_ = (db, tenant_id, project_id, charter_id)
return SimpleNamespace(
id=55,
content={
"derived_artifacts": {
"verification_metrics": [
{"name": "首响低于 3 秒", "expected": "P95 <= 3s"},
{"name": "重复提问率下降", "expected": "下降 20%+"},
]
},
"success_metrics": ["不应回退到这里"],
},
)
class _FakeDB:
def __init__(self):
self.committed = False
async def commit(self):
self.committed = True
monkeypatch.setattr(releases_status_generate_api, "_ensure_project", _fake_ensure_project)
monkeypatch.setattr(releases_status_generate_api, "_ensure_release", _fake_ensure_release)
monkeypatch.setattr(releases_status_generate_api, "_ensure_charter", _fake_ensure_charter)
db = _FakeDB()
response = await releases_status_generate_api.generate_release_verification(
project_id="6",
release_node_id="88",
db=db,
current_user=SimpleNamespace(tenant_id=1, id=7, role="ADMIN"),
permission_user=SimpleNamespace(),
)
payload = response["data"]
assert db.committed is True
assert payload["verification_items"][0]["title"] == "首响低于 3 秒"
assert payload["verification_items"][0]["expected"] == "P95 <= 3s"
assert payload["verification_items"][0]["source"] == "charter_verification_metric"
@pytest.mark.asyncio
async def test_generate_release_verification_fails_without_charter_metrics(monkeypatch) -> None:
async def _fake_ensure_project(service, project_id):
_ = (service, project_id)
return SimpleNamespace(id=6)
async def _fake_ensure_release(db, *, tenant_id: int, project_id: int, release_node_id: int):
_ = (db, tenant_id, project_id, release_node_id)
return SimpleNamespace(
id=88,
status="pending",
content={"charter_id": 55},
)
async def _fake_ensure_charter(db, *, tenant_id: int, project_id: int, charter_id: int):
_ = (db, tenant_id, project_id, charter_id)
return SimpleNamespace(
id=55,
content={
"derived_artifacts": {"verification_metrics": []},
"success_metrics": [],
"core_metrics": [],
},
)
class _FakeDB:
async def commit(self):
raise AssertionError("missing metrics must not commit verification items")
monkeypatch.setattr(releases_status_generate_api, "_ensure_project", _fake_ensure_project)
monkeypatch.setattr(releases_status_generate_api, "_ensure_release", _fake_ensure_release)
monkeypatch.setattr(releases_status_generate_api, "_ensure_charter", _fake_ensure_charter)
with pytest.raises(HTTPException) as exc_info:
await releases_status_generate_api.generate_release_verification(
project_id="6",
release_node_id="88",
db=_FakeDB(),
current_user=SimpleNamespace(tenant_id=1, id=7, role="ADMIN"),
permission_user=SimpleNamespace(),
)
assert exc_info.value.status_code == 409
assert "verification metrics" in str(exc_info.value.detail).lower()

View File

@ -68,6 +68,58 @@ def test_task_breakdown_context_flattens_charter_modules() -> None:
assert "附件仅作证据引用" in context["charter"]["constraints"][0] assert "附件仅作证据引用" in context["charter"]["constraints"][0]
def test_task_breakdown_context_prefers_derived_task_seeds() -> None:
project = SimpleNamespace(
id=6,
title="内部知识库升级",
name="内部知识库升级",
description="围绕内部知识库问答能力升级",
status="active",
assigned_role="backend",
)
charter = SimpleNamespace(
id=55,
title="内部知识库升级宪章",
version=2,
status="aligned",
content={
"modules": {
"module_1": {
"problem_statement": "减少重复检索",
"goal_statement": "提升命中率 / 降低重复提问",
"kpis": "首响 < 3s",
},
"module_4": {
"milestones": "需求冻结\n开发完成",
},
},
"derived_artifacts": {
"task_seeds": [
{
"title": "先补检索评估基线",
"description": "补齐离线评测与验收口径",
"evidence_refs": ["module_1.kpis"],
},
{
"title": "再实现导出链路",
"description": "基于基线结果实现最小闭环",
"evidence_refs": ["module_4.milestones"],
},
]
},
},
)
context = _build_task_breakdown_context(
description="支持导出知识库内容",
project=project,
charter=charter,
)
assert context["charter"]["task_seeds"][0]["title"] == "先补检索评估基线"
assert context["charter"]["task_seeds"][1]["evidence_refs"] == ["module_4.milestones"]
def test_task_breakdown_prompt_embeds_charter_context() -> None: def test_task_breakdown_prompt_embeds_charter_context() -> None:
prompt = _build_task_breakdown_prompt( prompt = _build_task_breakdown_prompt(
project_id=6, project_id=6,

View File

@ -42,3 +42,31 @@ def test_workflow_semantic_planner_helpers_cover_legacy_entry_semantics() -> Non
def test_workflow_semantic_planner_contextual_write_confirmation_is_stateful() -> None: def test_workflow_semantic_planner_contextual_write_confirmation_is_stateful() -> None:
assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("那就按这个继续执行") is True assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("那就按这个继续执行") is True
assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("这个项目现在怎么样?") is False assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("这个项目现在怎么样?") is False
def test_workflow_semantic_planner_routes_new_business_review_with_five_lenses() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="我要做一个大模型 API 聚合平台,帮我分析下要怎么做",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert [item.workflow_type for item in bundle.workflows] == ["query_flow"]
assert bundle.message_gate == "auto_execute"
assert bundle.workflows[0].metadata["message_intent"] == "business_review"
assert bundle.workflows[0].metadata["review_framework"] == "five_lenses"
assert bundle.diagnostics["message_intent"] == "business_review"
assert bundle.diagnostics["consultant_mode_preference"] == "clarify"
def test_workflow_semantic_planner_marks_correction_escalation_queries() -> None:
bundle = WorkflowSemanticPlanner.plan_message(
message_text="不对,调用高级模型重新回答我的问题",
source_system="feishu",
)
assert bundle.primary_workflow == "query_flow"
assert bundle.workflows[0].metadata["message_intent"] == "correction_escalation"
assert bundle.workflows[0].metadata["force_thinking_mode"] == "deep"
assert bundle.diagnostics["message_intent"] == "correction_escalation"
assert bundle.diagnostics["consultant_mode_preference"] == "challenge"

View File

@ -6,9 +6,17 @@ import type {
SSTCTask, SSTCTask,
} from "@/api/sstc"; } from "@/api/sstc";
import type { User } from "@/api/users"; import type { User } from "@/api/users";
import { CHARTER_MODULE_SCHEMAS, type CharterModuleKey } from "@/components/sstc/charterModules"; import {
CHARTER_MODULE_SCHEMAS,
type CharterFieldEvidence,
type CharterFieldStatus,
type CharterModuleKey,
type CharterReadiness,
} from "@/components/sstc/charterModules";
import { import {
FIXED_ALIGNMENT_QA_QUESTIONS, FIXED_ALIGNMENT_QA_QUESTIONS,
charterFieldStatusBadgeClass,
charterFieldStatusLabel,
statusBadgeClass, statusBadgeClass,
} from "@/components/sstc/sstcUtils"; } from "@/components/sstc/sstcUtils";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -96,7 +104,7 @@ interface SSTCCharterStagePanelProps {
} }
>; >;
}; };
allModulesConfirmed: boolean; charterReadiness: CharterReadiness;
shouldShowCharterGenerating: boolean; shouldShowCharterGenerating: boolean;
serializedCharterContent: Record<string, unknown>; serializedCharterContent: Record<string, unknown>;
sourceSignalOpen: boolean; sourceSignalOpen: boolean;
@ -115,9 +123,10 @@ interface SSTCCharterStagePanelProps {
onSelectCharter: (charterId: string) => void; onSelectCharter: (charterId: string) => void;
updateModuleField: (moduleKey: CharterModuleKey, fieldKey: string, value: string) => void; updateModuleField: (moduleKey: CharterModuleKey, fieldKey: string, value: string) => void;
updateAlignmentQaAnswer: (answerIndex: number, value: string) => void; updateAlignmentQaAnswer: (answerIndex: number, value: string) => void;
confirmModule: (moduleKey: CharterModuleKey) => void;
onStartAlignment: () => void; onStartAlignment: () => void;
onConfirmAlignment: () => void; onConfirmAlignment: () => void;
getFieldStatus: (moduleKey: CharterModuleKey, fieldKey: string) => CharterFieldStatus;
getFieldEvidence: (moduleKey: CharterModuleKey, fieldKey: string) => CharterFieldEvidence[];
getFieldEditState: (moduleKey: CharterModuleKey, fieldKey: string) => ModuleFieldEditState; getFieldEditState: (moduleKey: CharterModuleKey, fieldKey: string) => ModuleFieldEditState;
getModuleStepStatus: (moduleKey: CharterModuleKey) => string; getModuleStepStatus: (moduleKey: CharterModuleKey) => string;
updateCharterMutation: UpdateCharterMutationLike; updateCharterMutation: UpdateCharterMutationLike;
@ -138,7 +147,7 @@ export function SSTCCharterStagePanel({
activeModule, activeModule,
activeModuleIndex, activeModuleIndex,
charterProgress, charterProgress,
allModulesConfirmed, charterReadiness,
shouldShowCharterGenerating, shouldShowCharterGenerating,
serializedCharterContent, serializedCharterContent,
sourceSignalOpen, sourceSignalOpen,
@ -157,9 +166,10 @@ export function SSTCCharterStagePanel({
onSelectCharter, onSelectCharter,
updateModuleField, updateModuleField,
updateAlignmentQaAnswer, updateAlignmentQaAnswer,
confirmModule,
onStartAlignment, onStartAlignment,
onConfirmAlignment, onConfirmAlignment,
getFieldStatus,
getFieldEvidence,
getFieldEditState, getFieldEditState,
getModuleStepStatus, getModuleStepStatus,
updateCharterMutation, updateCharterMutation,
@ -186,14 +196,13 @@ export function SSTCCharterStagePanel({
: null; : null;
const hasMultipleCharters = charters.length > 1; const hasMultipleCharters = charters.length > 1;
const confirmedModules = CHARTER_MODULE_SCHEMAS.filter( const confirmedModules = CHARTER_MODULE_SCHEMAS.filter(
(schema) => getModuleStepStatus(schema.key) === "已完成" (schema) => charterProgress.moduleProgress[schema.key].done
).length; ).length;
const filledModulePercent = Math.round( const filledModulePercent = Math.round(
(confirmedModules / Math.max(CHARTER_MODULE_SCHEMAS.length, 1)) * 100 (confirmedModules / Math.max(CHARTER_MODULE_SCHEMAS.length, 1)) * 100
); );
const charterDisplayStatus = allModulesConfirmed ? "已对齐" : toZhStatus(selectedCharter?.status); const charterDisplayStatus = toZhStatus(selectedCharter?.status);
const activeModuleStatus = getModuleStepStatus(activeModule.key); const activeModuleStatus = getModuleStepStatus(activeModule.key);
const activeModuleProgress = charterProgress.moduleProgress[activeModule.key];
const aiReferenceText = const aiReferenceText =
sourceSignals.length > 0 sourceSignals.length > 0
? `参考了 ${sourceSignals ? `参考了 ${sourceSignals
@ -204,6 +213,8 @@ export function SSTCCharterStagePanel({
const alignmentUserNameMap = new Map( const alignmentUserNameMap = new Map(
projectUsers.map((member) => [String(member.id), member.name || member.username]) projectUsers.map((member) => [String(member.id), member.name || member.username])
); );
const sourceSignalMap = new Map(sourceSignals.map((signal) => [Number(signal.id), signal]));
const readinessBlockingFieldSet = new Set(charterReadiness.blocking_fields);
const alignmentRecords = (charterDetail?.alignment_records || []).filter((record) => { const alignmentRecords = (charterDetail?.alignment_records || []).filter((record) => {
if (projectUsers.length === 0) return false; if (projectUsers.length === 0) return false;
return ( return (
@ -322,7 +333,11 @@ export function SSTCCharterStagePanel({
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700" className="h-7 px-2 text-xs text-slate-600 hover:bg-blue-50 hover:text-blue-700"
disabled={!selectedCharter || confirmAlignmentMutation.isPending} disabled={
!selectedCharter ||
confirmAlignmentMutation.isPending ||
!charterReadiness.ready_for_confirm
}
onClick={onConfirmAlignment} onClick={onConfirmAlignment}
> >
{confirmAlignmentMutation.isPending ? "提交中..." : "确认对齐"} {confirmAlignmentMutation.isPending ? "提交中..." : "确认对齐"}
@ -331,9 +346,19 @@ export function SSTCCharterStagePanel({
</div> </div>
</div> </div>
{allModulesConfirmed && ( {charterReadiness.ready_for_confirm && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-2 text-xs text-emerald-700"> <div className="rounded-xl border border-emerald-200 bg-emerald-50 p-2 text-xs text-emerald-700">
6 个模块已逐项确认,可提交“确认对齐”完成整体对齐。 宪章已通过确认前校验,可执行一次总确认并解锁任务阶段。
</div>
)}
{!charterReadiness.ready_for_confirm && charterReadiness.blocking_reasons.length > 0 && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
<p className="font-medium">总确认已阻断,需先补齐以下问题:</p>
<div className="mt-2 space-y-1">
{charterReadiness.blocking_reasons.map((reason, idx) => (
<p key={`blocking-${idx}`}>{reason}</p>
))}
</div>
</div> </div>
)} )}
@ -423,22 +448,9 @@ export function SSTCCharterStagePanel({
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
{activeModuleIndex + 1}/{CHARTER_MODULE_SCHEMAS.length} {activeModuleIndex + 1}/{CHARTER_MODULE_SCHEMAS.length}
</p> </p>
{!mobileReadOnly && ( <Badge className="border border-slate-200 bg-white text-slate-600">
<Button {activeModuleStatus}
size="sm" </Badge>
variant={activeModuleStatus === "已完成" ? "outline" : "default"}
className={activeModuleStatus === "已完成" ? "h-7 border-emerald-200 text-emerald-700" : "h-7 bg-blue-600 text-white hover:bg-blue-700"}
disabled={
!selectedCharter ||
updateCharterMutation.isPending ||
activeModuleStatus === "已完成" ||
!activeModuleProgress.done
}
onClick={() => confirmModule(activeModule.key)}
>
{activeModuleStatus === "已完成" ? "已确认" : "确认本模块"}
</Button>
)}
</div> </div>
</div> </div>
@ -451,6 +463,10 @@ export function SSTCCharterStagePanel({
) )
.map((field) => { .map((field) => {
const state = getFieldEditState(activeModule.key, field.key); const state = getFieldEditState(activeModule.key, field.key);
const fieldStatus = getFieldStatus(activeModule.key, field.key);
const fieldEvidence = getFieldEvidence(activeModule.key, field.key);
const fieldRef = `${activeModule.key}.${field.key}`;
const isBlockingField = readinessBlockingFieldSet.has(fieldRef);
const fieldTone = const fieldTone =
state.aiGenerated && !state.edited ? "bg-[#F4F7FF]" : "bg-white"; state.aiGenerated && !state.edited ? "bg-[#F4F7FF]" : "bg-white";
return ( return (
@ -485,12 +501,18 @@ export function SSTCCharterStagePanel({
)} )}
<div className="mt-2 flex items-center justify-between gap-2 text-[11px]"> <div className="mt-2 flex items-center justify-between gap-2 text-[11px]">
<div className="space-y-1"> <div className="space-y-1">
<Badge className={`border ${charterFieldStatusBadgeClass(fieldStatus)}`}>
{charterFieldStatusLabel(fieldStatus)}
</Badge>
<p className="text-slate-500"> <p className="text-slate-500">
{state.aiGenerated ? (state.edited ? "AI 草稿后已编辑" : "AI 已生成") : "人工输入"} {state.aiGenerated ? (state.edited ? "AI 草稿后已编辑" : "AI 已生成") : "人工输入"}
</p> </p>
{state.aiGenerated && !state.edited && ( {state.aiGenerated && !state.edited && (
<p className="text-slate-400">{aiReferenceText}</p> <p className="text-slate-400">{aiReferenceText}</p>
)} )}
{isBlockingField && (
<p className="text-red-600">该字段会阻止整份宪章确认</p>
)}
</div> </div>
{!mobileReadOnly && ( {!mobileReadOnly && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@ -521,13 +543,46 @@ export function SSTCCharterStagePanel({
</div> </div>
)} )}
</div> </div>
<div className="mt-3 rounded-lg bg-slate-50 p-2">
<p className="text-[11px] font-medium text-slate-600">字段级证据</p>
{fieldEvidence.length > 0 ? (
<div className="mt-2 space-y-2">
{fieldEvidence.map((item, index) => {
const signal = item.signal_id ? sourceSignalMap.get(Number(item.signal_id)) : null;
return (
<div key={`${fieldRef}-evidence-${index}`} className="rounded-md border border-slate-200 bg-white p-2">
<p className="text-[11px] text-slate-700">
信号 #{item.signal_id || "-"} · {signal?.title || "未命名信号"}
</p>
{item.summary && (
<p className="mt-1 text-[11px] text-slate-500">摘要:{item.summary}</p>
)}
{item.quote && (
<p className="mt-1 text-[11px] text-slate-500">摘录:{item.quote}</p>
)}
{item.reason && (
<p className="mt-1 text-[11px] text-slate-500">引用原因:{item.reason}</p>
)}
</div>
);
})}
</div>
) : (
<p className="mt-2 text-[11px] text-slate-500">暂无字段级证据,当前字段不会被视为完成。</p>
)}
</div>
</div> </div>
); );
})} })}
{activeModule.key === "module_2" && ( {activeModule.key === "module_2" && (
<div className="rounded-xl bg-[#F4F7FF] p-3"> <div className="rounded-xl bg-[#F4F7FF] p-3">
<p className="mb-2 text-xs text-slate-500">5个固定对齐问答</p> <div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs text-slate-500">5个固定对齐问答</p>
<Badge className={`border ${charterFieldStatusBadgeClass(getFieldStatus("module_2", "alignment_qa"))}`}>
{charterFieldStatusLabel(getFieldStatus("module_2", "alignment_qa"))}
</Badge>
</div>
<div className="space-y-2"> <div className="space-y-2">
{FIXED_ALIGNMENT_QA_QUESTIONS.slice(0, showAllAlignmentQa ? 5 : 2).map( {FIXED_ALIGNMENT_QA_QUESTIONS.slice(0, showAllAlignmentQa ? 5 : 2).map(
(question, idx) => { (question, idx) => {
@ -547,7 +602,7 @@ export function SSTCCharterStagePanel({
/> />
) : ( ) : (
<p className="mt-2 whitespace-pre-wrap text-sm text-slate-700"> <p className="mt-2 whitespace-pre-wrap text-sm text-slate-700">
{alignmentQaAnswers[idx] || "AI 待补全"} {alignmentQaAnswers[idx] || "待补证据"}
</p> </p>
)} )}
{!mobileReadOnly && ( {!mobileReadOnly && (
@ -574,6 +629,28 @@ export function SSTCCharterStagePanel({
> >
{showAllAlignmentQa ? "▾ 收起剩余 3 个问答" : "▸ 展开剩余 3 个问答"} {showAllAlignmentQa ? "▾ 收起剩余 3 个问答" : "▸ 展开剩余 3 个问答"}
</button> </button>
<div className="mt-3 rounded-lg bg-white p-2">
<p className="text-[11px] font-medium text-slate-600">问答字段证据</p>
{getFieldEvidence("module_2", "alignment_qa").length > 0 ? (
<div className="mt-2 space-y-2">
{getFieldEvidence("module_2", "alignment_qa").map((item, index) => {
const signal = item.signal_id ? sourceSignalMap.get(Number(item.signal_id)) : null;
return (
<div key={`module_2-alignment_qa-${index}`} className="rounded-md border border-slate-200 bg-slate-50 p-2">
<p className="text-[11px] text-slate-700">
信号 #{item.signal_id || "-"} · {signal?.title || "未命名信号"}
</p>
{item.summary && <p className="mt-1 text-[11px] text-slate-500">摘要:{item.summary}</p>}
{item.quote && <p className="mt-1 text-[11px] text-slate-500">摘录:{item.quote}</p>}
{item.reason && <p className="mt-1 text-[11px] text-slate-500">引用原因:{item.reason}</p>}
</div>
);
})}
</div>
) : (
<p className="mt-2 text-[11px] text-slate-500">该字段暂无证据,存在 blocker 时将无法总确认。</p>
)}
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -52,7 +52,7 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) {
activeModule: orchestration.activeModule, activeModule: orchestration.activeModule,
activeModuleIndex: orchestration.activeModuleIndex, activeModuleIndex: orchestration.activeModuleIndex,
charterProgress: orchestration.charterProgress, charterProgress: orchestration.charterProgress,
allModulesConfirmed: orchestration.allModulesConfirmed, charterReadiness: orchestration.charterReadiness,
shouldShowCharterGenerating: orchestration.shouldShowCharterGenerating, shouldShowCharterGenerating: orchestration.shouldShowCharterGenerating,
serializedCharterContent: orchestration.serializedCharterContent, serializedCharterContent: orchestration.serializedCharterContent,
sourceSignalOpen: orchestration.sourceSignalOpen, sourceSignalOpen: orchestration.sourceSignalOpen,
@ -73,9 +73,10 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) {
onSelectCharter: props.onSelectCharter, onSelectCharter: props.onSelectCharter,
updateModuleField: orchestration.updateModuleField, updateModuleField: orchestration.updateModuleField,
updateAlignmentQaAnswer: orchestration.updateAlignmentQaAnswer, updateAlignmentQaAnswer: orchestration.updateAlignmentQaAnswer,
confirmModule: orchestration.confirmModule,
onStartAlignment: orchestration.handleStartAlignment, onStartAlignment: orchestration.handleStartAlignment,
onConfirmAlignment: orchestration.handleConfirmAlignment, onConfirmAlignment: orchestration.handleConfirmAlignment,
getFieldStatus: orchestration.getFieldStatus,
getFieldEvidence: orchestration.getFieldEvidence,
getFieldEditState: orchestration.getFieldEditState, getFieldEditState: orchestration.getFieldEditState,
getModuleStepStatus: orchestration.getModuleStepStatus, getModuleStepStatus: orchestration.getModuleStepStatus,
updateCharterMutation: orchestration.updateCharterMutation, updateCharterMutation: orchestration.updateCharterMutation,

View File

@ -131,7 +131,7 @@ export function SignalContentHoverCard({
</button> </button>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-h-[85vh] w-[min(680px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto rounded-2xl border-0 bg-white shadow-[0_20px_60px_rgba(15,23,42,0.2)]"> <DialogContent className="max-h-[85vh] w-[min(680px,calc(100vw-1rem))] max-w-[calc(100vw-1rem)] overflow-y-auto overscroll-contain rounded-2xl border-0 bg-white shadow-[0_20px_60px_rgba(15,23,42,0.2)] sm:max-h-[85vh] sm:overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl text-slate-900">{detailTitle}</DialogTitle> <DialogTitle className="text-xl text-slate-900">{detailTitle}</DialogTitle>
<DialogDescription className="text-slate-500"> <DialogDescription className="text-slate-500">

View File

@ -23,6 +23,28 @@ export interface CharterModuleSchema {
fields: CharterModuleFieldSchema[]; fields: CharterModuleFieldSchema[];
} }
export type CharterFieldStatus =
| "valid"
| "evidence_insufficient"
| "generation_failed"
| "generic_text"
| "conflict_blocked"
| "";
export interface CharterFieldEvidence {
signal_id?: number | null;
summary?: string;
quote?: string;
reason?: string;
confidence?: number | null;
}
export interface CharterReadiness {
ready_for_confirm: boolean;
blocking_fields: string[];
blocking_reasons: string[];
}
export interface CharterModuleDraft { export interface CharterModuleDraft {
metadata: { metadata: {
status: string; status: string;
@ -31,6 +53,9 @@ export interface CharterModuleDraft {
evidence_refs: string; evidence_refs: string;
escalation_reason: string; escalation_reason: string;
}; };
fieldStatus: Record<CharterModuleKey, Record<string, CharterFieldStatus>>;
fieldEvidence: Record<CharterModuleKey, Record<string, CharterFieldEvidence[]>>;
readiness: CharterReadiness;
modules: Record<CharterModuleKey, Record<string, string>>; modules: Record<CharterModuleKey, Record<string, string>>;
} }
@ -40,6 +65,7 @@ export interface ModuleProgress {
percent: number; percent: number;
done: boolean; done: boolean;
missing: string[]; missing: string[];
blocked: string[];
} }
export interface CharterProgress { export interface CharterProgress {
@ -50,6 +76,37 @@ export interface CharterProgress {
moduleProgress: Record<CharterModuleKey, ModuleProgress>; moduleProgress: Record<CharterModuleKey, ModuleProgress>;
} }
function calculateDraftReadiness(draft: CharterModuleDraft): CharterReadiness {
const blocking_fields: string[] = [];
const blocking_reasons: string[] = [];
for (const schema of CHARTER_MODULE_SCHEMAS) {
for (const field of schema.fields.filter((item) => !!item.required)) {
const value = draft.modules[schema.key][field.key] || "";
const status = draft.fieldStatus[schema.key][field.key];
const evidence = draft.fieldEvidence[schema.key][field.key] || [];
if (value.trim().length > 0 && status === "valid" && evidence.length > 0) continue;
const ref = `${schema.key}.${field.key}`;
blocking_fields.push(ref);
if (status === "conflict_blocked") {
blocking_reasons.push(`${ref} 存在未决冲突`);
} else if (status === "generation_failed") {
blocking_reasons.push(`${ref} 生成失败`);
} else if (value.trim().length > 0 && evidence.length === 0) {
blocking_reasons.push(`${ref} 缺少字段级证据`);
} else {
blocking_reasons.push(`${ref} 尚未完成`);
}
}
}
return {
ready_for_confirm: blocking_fields.length === 0,
blocking_fields,
blocking_reasons,
};
}
export const CHARTER_MODULE_SCHEMAS: CharterModuleSchema[] = [ export const CHARTER_MODULE_SCHEMAS: CharterModuleSchema[] = [
{ {
key: "module_1", key: "module_1",
@ -474,15 +531,49 @@ function ensureModuleMap(): Record<CharterModuleKey, Record<string, string>> {
function ensureModuleProgressMap(): Record<CharterModuleKey, ModuleProgress> { function ensureModuleProgressMap(): Record<CharterModuleKey, ModuleProgress> {
return { return {
module_1: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, module_1: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] },
module_2: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, module_2: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] },
module_3: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, module_3: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] },
module_4: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, module_4: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] },
module_5: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, module_5: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] },
module_6: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, module_6: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] },
}; };
} }
function ensureFieldStatusMap(): Record<CharterModuleKey, Record<string, CharterFieldStatus>> {
const fieldStatus: Record<CharterModuleKey, Record<string, CharterFieldStatus>> = {
module_1: {},
module_2: {},
module_3: {},
module_4: {},
module_5: {},
module_6: {},
};
for (const schema of CHARTER_MODULE_SCHEMAS) {
for (const field of schema.fields) {
fieldStatus[schema.key][field.key] = "";
}
}
return fieldStatus;
}
function ensureFieldEvidenceMap(): Record<CharterModuleKey, Record<string, CharterFieldEvidence[]>> {
const fieldEvidence: Record<CharterModuleKey, Record<string, CharterFieldEvidence[]>> = {
module_1: {},
module_2: {},
module_3: {},
module_4: {},
module_5: {},
module_6: {},
};
for (const schema of CHARTER_MODULE_SCHEMAS) {
for (const field of schema.fields) {
fieldEvidence[schema.key][field.key] = [];
}
}
return fieldEvidence;
}
function setIfEmpty(target: Record<string, string>, key: string, value: unknown): void { function setIfEmpty(target: Record<string, string>, key: string, value: unknown): void {
if ((target[key] || "").trim().length > 0) return; if ((target[key] || "").trim().length > 0) return;
const text = toText(value).trim(); const text = toText(value).trim();
@ -536,6 +627,13 @@ export function createEmptyCharterModuleDraft(): CharterModuleDraft {
evidence_refs: "", evidence_refs: "",
escalation_reason: "", escalation_reason: "",
}, },
fieldStatus: ensureFieldStatusMap(),
fieldEvidence: ensureFieldEvidenceMap(),
readiness: {
ready_for_confirm: false,
blocking_fields: [],
blocking_reasons: [],
},
modules, modules,
}; };
} }
@ -544,6 +642,9 @@ export function parseCharterModuleDraft(content: Record<string, unknown> | null
const root = toRecord(content); const root = toRecord(content);
const metadata = toRecord(root.metadata); const metadata = toRecord(root.metadata);
const modulesRoot = toRecord(root.modules); const modulesRoot = toRecord(root.modules);
const fieldStatusRoot = toRecord(root.field_status);
const fieldEvidenceRoot = toRecord(root.field_evidence);
const readinessRoot = toRecord(root.readiness);
const draft = createEmptyCharterModuleDraft(); const draft = createEmptyCharterModuleDraft();
draft.metadata.status = toText(metadata.status ?? root.status) || "draft"; draft.metadata.status = toText(metadata.status ?? root.status) || "draft";
@ -551,12 +652,36 @@ export function parseCharterModuleDraft(content: Record<string, unknown> | null
draft.metadata.last_validated_at = toText(metadata.last_validated_at ?? root.last_validated_at); draft.metadata.last_validated_at = toText(metadata.last_validated_at ?? root.last_validated_at);
draft.metadata.evidence_refs = toText(metadata.evidence_refs ?? root.evidence_refs); draft.metadata.evidence_refs = toText(metadata.evidence_refs ?? root.evidence_refs);
draft.metadata.escalation_reason = toText(metadata.escalation_reason ?? root.escalation_reason); draft.metadata.escalation_reason = toText(metadata.escalation_reason ?? root.escalation_reason);
draft.readiness.ready_for_confirm = Boolean(readinessRoot.ready_for_confirm);
draft.readiness.blocking_fields = Array.isArray(readinessRoot.blocking_fields)
? readinessRoot.blocking_fields.map((item) => String(item || "").trim()).filter((item) => item.length > 0)
: [];
draft.readiness.blocking_reasons = Array.isArray(readinessRoot.blocking_reasons)
? readinessRoot.blocking_reasons.map((item) => String(item || "").trim()).filter((item) => item.length > 0)
: [];
for (const schema of CHARTER_MODULE_SCHEMAS) { for (const schema of CHARTER_MODULE_SCHEMAS) {
const moduleRaw = toRecord(modulesRoot[schema.key]); const moduleRaw = toRecord(modulesRoot[schema.key]);
const directModuleRaw = toRecord(root[schema.key]); const directModuleRaw = toRecord(root[schema.key]);
const fieldStatusModule = toRecord(fieldStatusRoot[schema.key]);
const fieldEvidenceModule = toRecord(fieldEvidenceRoot[schema.key]);
for (const field of schema.fields) { for (const field of schema.fields) {
draft.modules[schema.key][field.key] = toText(moduleRaw[field.key] ?? directModuleRaw[field.key]); draft.modules[schema.key][field.key] = toText(moduleRaw[field.key] ?? directModuleRaw[field.key]);
draft.fieldStatus[schema.key][field.key] = String(fieldStatusModule[field.key] || "").trim() as CharterFieldStatus;
const evidenceRaw = fieldEvidenceModule[field.key];
draft.fieldEvidence[schema.key][field.key] = Array.isArray(evidenceRaw)
? evidenceRaw.filter((item) => typeof item === "object" && item !== null).map((item) => ({
signal_id: Number.isFinite(Number((item as Record<string, unknown>).signal_id))
? Number((item as Record<string, unknown>).signal_id)
: null,
summary: toText((item as Record<string, unknown>).summary),
quote: toText((item as Record<string, unknown>).quote),
reason: toText((item as Record<string, unknown>).reason),
confidence: Number.isFinite(Number((item as Record<string, unknown>).confidence))
? Number((item as Record<string, unknown>).confidence)
: null,
}))
: [];
} }
} }
@ -593,6 +718,12 @@ export function buildCharterContentPayload(
} }
} }
payload.modules = modules; payload.modules = modules;
payload.field_status = draft.fieldStatus;
payload.field_evidence = draft.fieldEvidence;
payload.readiness = calculateDraftReadiness(draft);
if ("derived_artifacts" in root) {
payload.derived_artifacts = root.derived_artifacts;
}
const rawSignalIds = Array.isArray(root.signal_ids) ? root.signal_ids : []; const rawSignalIds = Array.isArray(root.signal_ids) ? root.signal_ids : [];
payload.signal_ids = rawSignalIds.filter((sid) => Number.isFinite(Number(sid))).map((sid) => Number(sid)); payload.signal_ids = rawSignalIds.filter((sid) => Number.isFinite(Number(sid))).map((sid) => Number(sid));
@ -617,14 +748,20 @@ export function calculateCharterProgress(draft: CharterModuleDraft): CharterProg
for (const schema of CHARTER_MODULE_SCHEMAS) { for (const schema of CHARTER_MODULE_SCHEMAS) {
const requiredFields = schema.fields.filter((field) => !!field.required); const requiredFields = schema.fields.filter((field) => !!field.required);
const missing: string[] = []; const missing: string[] = [];
const blocked: string[] = [];
let done = 0; let done = 0;
for (const field of requiredFields) { for (const field of requiredFields) {
const value = draft.modules[schema.key][field.key] || ""; const value = draft.modules[schema.key][field.key] || "";
if (value.trim().length > 0) { const status = draft.fieldStatus[schema.key][field.key];
const evidence = draft.fieldEvidence[schema.key][field.key] || [];
if (value.trim().length > 0 && status === "valid" && evidence.length > 0) {
done += 1; done += 1;
} else { } else {
missing.push(field.label.replace("(必填)", "")); missing.push(field.label.replace("(必填)", ""));
if (value.trim().length > 0) {
blocked.push(field.label.replace("(必填)", ""));
}
} }
} }
@ -638,6 +775,7 @@ export function calculateCharterProgress(draft: CharterModuleDraft): CharterProg
percent, percent,
done: isDone, done: isDone,
missing, missing,
blocked,
}; };
requiredTotal += total; requiredTotal += total;
@ -653,3 +791,7 @@ export function calculateCharterProgress(draft: CharterModuleDraft): CharterProg
moduleProgress, moduleProgress,
}; };
} }
export function calculateCharterReadiness(draft: CharterModuleDraft): CharterReadiness {
return calculateDraftReadiness(draft);
}

View File

@ -1,5 +1,5 @@
import { type SSTCSignal } from "@/api/sstc"; import { type SSTCSignal } from "@/api/sstc";
import { type CharterModuleDraft } from "@/components/sstc/charterModules"; import { type CharterFieldStatus } from "@/components/sstc/charterModules";
import { apiBaseURL } from "@/lib/api"; import { apiBaseURL } from "@/lib/api";
export interface SignalFilterState { export interface SignalFilterState {
@ -203,12 +203,22 @@ export function buildAlignmentQaText(answers: string[]): string {
}).join("\n\n"); }).join("\n\n");
} }
export function buildFallbackAlignmentAnswers(draft: CharterModuleDraft): string[] { export function charterFieldStatusLabel(status: CharterFieldStatus): string {
return [ if (status === "valid") return "证据完备";
firstLine(draft.modules.module_1.problem_statement), if (status === "evidence_insufficient") return "证据不足";
firstLine(draft.modules.module_4.milestones), if (status === "generation_failed") return "生成失败";
firstLine(draft.modules.module_2.out_of_scope), if (status === "generic_text") return "泛化文本";
firstLine(draft.modules.module_1.kpis), if (status === "conflict_blocked") return "冲突阻断";
firstLine(draft.modules.module_5.risk_register), return "待校验";
]; }
export function charterFieldStatusBadgeClass(status: CharterFieldStatus): string {
if (status === "valid") return "bg-emerald-100 text-emerald-700 border-emerald-200";
if (status === "conflict_blocked" || status === "generation_failed") {
return "bg-red-100 text-red-700 border-red-200";
}
if (status === "generic_text" || status === "evidence_insufficient") {
return "bg-amber-100 text-amber-700 border-amber-200";
}
return "bg-slate-100 text-slate-600 border-slate-200";
} }

View File

@ -18,6 +18,7 @@ import { getApiErrorMessage } from "@/lib/apiError";
import { import {
CHARTER_MODULE_SCHEMAS, CHARTER_MODULE_SCHEMAS,
buildCharterContentPayload, buildCharterContentPayload,
calculateCharterReadiness,
calculateCharterProgress, calculateCharterProgress,
createEmptyCharterModuleDraft, createEmptyCharterModuleDraft,
parseCharterModuleDraft, parseCharterModuleDraft,
@ -25,7 +26,6 @@ import {
} from "@/components/sstc/charterModules"; } from "@/components/sstc/charterModules";
import { import {
buildAlignmentQaText, buildAlignmentQaText,
buildFallbackAlignmentAnswers,
normalizeLane, normalizeLane,
normalizeReleaseStatus, normalizeReleaseStatus,
parseAlignmentQaAnswers, parseAlignmentQaAnswers,
@ -192,6 +192,7 @@ export function useSSTCWorkspaceOrchestration({
); );
const charterProgress = useMemo(() => calculateCharterProgress(charterModuleDraft), [charterModuleDraft]); const charterProgress = useMemo(() => calculateCharterProgress(charterModuleDraft), [charterModuleDraft]);
const charterReadiness = useMemo(() => calculateCharterReadiness(charterModuleDraft), [charterModuleDraft]);
const serializedCharterContent = useMemo(() => { const serializedCharterContent = useMemo(() => {
const payload = buildCharterContentPayload( const payload = buildCharterContentPayload(
@ -215,24 +216,15 @@ export function useSSTCWorkspaceOrchestration({
[charterModuleDraft] [charterModuleDraft]
); );
const requiredModuleKeys = useMemo(
() => CHARTER_MODULE_SCHEMAS.filter((schema) => schema.fields.some((field) => !!field.required)).map((schema) => schema.key),
[]
);
const allModulesConfirmed = useMemo( const allModulesConfirmed = useMemo(
() => requiredModuleKeys.every((moduleKey) => !!moduleConfirmations[moduleKey]), () => charterReadiness.ready_for_confirm,
[moduleConfirmations, requiredModuleKeys] [charterReadiness.ready_for_confirm]
); );
useEffect(() => { useEffect(() => {
setCharterDraftTitle(selectedCharter?.title || ""); setCharterDraftTitle(selectedCharter?.title || "");
const currentContent = (selectedCharter?.content as Record<string, unknown>) || {}; const currentContent = (selectedCharter?.content as Record<string, unknown>) || {};
const parsed = parseCharterModuleDraft(currentContent); const parsed = parseCharterModuleDraft(currentContent);
if (!parsed.modules.module_2.alignment_qa.trim()) {
const fallback = buildFallbackAlignmentAnswers(parsed);
parsed.modules.module_2.alignment_qa = buildAlignmentQaText(fallback);
}
setCharterModuleDraft(parsed); setCharterModuleDraft(parsed);
setCharterBaselineDraft(parsed); setCharterBaselineDraft(parsed);
setModuleConfirmations(parseModuleConfirmationState(currentContent.module_confirmations)); setModuleConfirmations(parseModuleConfirmationState(currentContent.module_confirmations));
@ -626,40 +618,11 @@ export function useSSTCWorkspaceOrchestration({
}; };
const confirmModule = (moduleKey: CharterModuleKey) => { const confirmModule = (moduleKey: CharterModuleKey) => {
if (!selectedCharter) return; const schema = CHARTER_MODULE_SCHEMAS.find((item) => item.key === moduleKey);
const progress = charterProgress.moduleProgress[moduleKey]; toast({
if (!progress?.done) { title: `${schema?.shortTitle || "模块"}已切换为整份宪章总确认,请查看字段证据与阻断项后直接确认对齐`,
toast({ title: "请先补全当前模块的必填项,再执行确认", variant: "error" }); variant: "success",
return; });
}
if (moduleConfirmations[moduleKey]) return;
const previous = moduleConfirmations;
const next = { ...previous, [moduleKey]: true };
setModuleConfirmations(next);
updateCharterMutation.mutate(
{
charterId: selectedCharter.id,
title: charterDraftTitle || selectedCharter.title,
content: {
...serializedCharterContent,
module_confirmations: next,
},
},
{
onSuccess: () => {
const schema = CHARTER_MODULE_SCHEMAS.find((item) => item.key === moduleKey);
toast({ title: `${schema?.shortTitle || "模块"}已确认`, variant: "success" });
},
onError: (error) => {
setModuleConfirmations(previous);
toast({
title: getApiErrorMessage(error, "模块确认失败"),
variant: "error",
});
},
}
);
}; };
const getFieldEditState = (moduleKey: CharterModuleKey, fieldKey: string) => { const getFieldEditState = (moduleKey: CharterModuleKey, fieldKey: string) => {
@ -671,13 +634,13 @@ export function useSSTCWorkspaceOrchestration({
}; };
const getModuleStepStatus = (moduleKey: CharterModuleKey): string => { const getModuleStepStatus = (moduleKey: CharterModuleKey): string => {
if (moduleConfirmations[moduleKey]) return "已完成";
if (moduleKey === activeModule.key) return "进行中";
const progress = charterProgress.moduleProgress[moduleKey]; const progress = charterProgress.moduleProgress[moduleKey];
const moduleContent = charterModuleDraft.modules[moduleKey]; const moduleContent = charterModuleDraft.modules[moduleKey];
const hasContent = Object.values(moduleContent).some((value) => String(value || "").trim().length > 0); const hasContent = Object.values(moduleContent).some((value) => String(value || "").trim().length > 0);
if (!hasContent) return moduleKey === "module_6" ? "选填" : "未开始"; if (!hasContent) return moduleKey === "module_6" ? "选填" : "未开始";
if (progress.done) return "进行中"; if (progress.done) return "已就绪";
if (moduleKey === activeModule.key) return progress.blocked.length > 0 ? "待补证据" : "进行中";
if (progress.blocked.length > 0) return "待补证据";
return "进行中"; return "进行中";
}; };
@ -688,13 +651,20 @@ export function useSSTCWorkspaceOrchestration({
const handleConfirmAlignment = () => { const handleConfirmAlignment = () => {
if (!selectedCharter) return; if (!selectedCharter) return;
if (!allModulesConfirmed) { if (!charterReadiness.ready_for_confirm) {
toast({ title: "请先逐个确认 6 个模块,再提交整体对齐", variant: "error" }); const reason = charterReadiness.blocking_reasons[0] || "宪章仍存在阻断项,请先补齐证据";
toast({ title: reason, variant: "error" });
return; return;
} }
confirmAlignmentMutation.mutate(selectedCharter.id); confirmAlignmentMutation.mutate(selectedCharter.id);
}; };
const getFieldStatus = (moduleKey: CharterModuleKey, fieldKey: string) =>
charterModuleDraft.fieldStatus[moduleKey][fieldKey] || "";
const getFieldEvidence = (moduleKey: CharterModuleKey, fieldKey: string) =>
charterModuleDraft.fieldEvidence[moduleKey][fieldKey] || [];
return { return {
toast, toast,
stageLocked, stageLocked,
@ -736,6 +706,7 @@ export function useSSTCWorkspaceOrchestration({
charterProgress, charterProgress,
serializedCharterContent, serializedCharterContent,
allModulesConfirmed, allModulesConfirmed,
charterReadiness,
shouldShowCharterGenerating, shouldShowCharterGenerating,
rankedSignals, rankedSignals,
highValueSignalCount, highValueSignalCount,
@ -786,6 +757,8 @@ export function useSSTCWorkspaceOrchestration({
alignmentQaAnswers, alignmentQaAnswers,
updateAlignmentQaAnswer, updateAlignmentQaAnswer,
confirmModule, confirmModule,
getFieldStatus,
getFieldEvidence,
getFieldEditState, getFieldEditState,
getModuleStepStatus, getModuleStepStatus,
}; };

View File

@ -1,15 +1,2 @@
# Approved compatibility exceptions for semantic-lint. # Approved compatibility exceptions for semantic-lint.
# Format: relative/path.py:line:rule_id # Format: relative/path.py:line:rule_id
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:71:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:79:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:95:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:99:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:103:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:107:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:111:semantic_regex_constant
backend/app/services/integrations/feishu_signal_pipeline/shared_core.py:129:semantic_regex_constant
backend/app/services/signal/signal_capture_service/shared.py:107:semantic_hint_constant
backend/app/api/ai_pmo_api/shared.py:42:semantic_hint_constant
backend/app/api/ai_pmo_api/shared.py:43:semantic_hint_constant
backend/app/api/ai_pmo_api/shared.py:44:semantic_regex_constant
backend/app/api/ai_pmo_api/shared.py:48:semantic_hint_constant