diff --git a/AGENTS.md b/AGENTS.md index 23e25d81..72210093 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,7 @@ frontend/ # React 前端 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 前端状态管理:服务端数据用 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 抽取与结构化结果解析 ### 8.1 系统定位 @@ -127,6 +128,8 @@ frontend/ # React 前端 - 生成层可以组织信息、给出建议,但不能直接改变业务状态。 - 生成层不能替代事实层,也不能伪装成执行结果。 - 生成层的目标是可交付,不是看起来完整。 +- 核心业务生成字段必须能区分“真实生成 / 人工填写 / 模板占位 / 生成失败”,禁止只以字段非空作为完成态依据。 +- 技术层 fallback 仅允许用于重试、缓存、解析修复、传输兼容等基础设施环节,禁止上浮为用户可见业务结论或完成态。 ### 8.6 执行层约束 @@ -157,6 +160,7 @@ frontend/ # React 前端 - 禁止让知识图谱承担流程垃圾桶职责。 - 禁止让任务包替代最终任务实体。 - 禁止在无证据条件下输出确定性结论。 +- 禁止用通用模板、默认句子、占位内容把核心业务模块补满后冒充“已生成 / 已完成 / 已对齐”。 - 禁止绕过统一路由、统一审计和统一租户隔离。 ### 8.10 业务语义补充 diff --git a/CLAUDE.md b/CLAUDE.md index 6fdc2760..f780d103 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,8 @@ backend/ │ ├── test_ai_prompt_registry_contract.py │ ├── test_model_routing_service.py │ ├── test_model_routing_migration.py -│ └── test_sstc_tasks_ai_breakdown_contract.py +│ ├── test_sstc_tasks_ai_breakdown_contract.py +│ └── test_sstc_charter_release_contract.py ├── requirements.txt # Python 依赖 └── Dockerfile ``` @@ -98,7 +99,7 @@ backend/ - 新增:`backend/app/middleware/audit_context.py`(请求级 trace/interface/caller 注入) - 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`) - 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化) -- 新增:`backend/app/services/signal_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_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) @@ -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/api/tasks.py`(`/tasks/breakdown` 统一结构化任务业务对象输出,支持可选 `project_id/charter_id`,与 SSTC 宪章上下文和技能词表统一) - 更新:`backend/app/schemas/common.py`(`TaskBreakdownRequest` 增加可选 `project_id/charter_id`) -- 更新:`backend/app/services/sstc/charter_data.py`(新增任务拆解上下文/技能词表/ prompt 拼装共享 helper) +- 更新:`backend/app/services/sstc/charter_data.py`(新增任务拆解上下文/技能词表/ prompt 拼装共享 helper,并把 `derived_artifacts.task_seeds/verification_metrics/risk_gates` 暴露给下游) - 更新:`backend/app/config/ai_prompts_catalog/part_01.py`(`PROMPT_TASK_BREAKDOWN` 统一为结构化任务业务对象输出) -- 更新:`backend/app/config/ai_prompts_catalog/part_03.py`(`PROMPT_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_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 契约回归:意图识别、路由归一与执行器分派) @@ -149,6 +150,7 @@ backend/ - 新增:`backend/tests/test_draft_generator_service.py`(草稿生成服务回归:PM 评审草稿包含相似项目、MVP、前景与需求清单) - 新增:`backend/tests/test_signal_planner_contract.py`(Signal Planner 契约回归:最新宪章加载、prompt 注入、无宪章降级) - 新增:`backend/tests/test_ai_prompt_registry_contract.py`(Prompt registry 契约回归:覆盖率与元数据完整性) +- 新增:`backend/tests/test_sstc_charter_release_contract.py`(SSTC 宪章总确认/发布验证契约回归:readiness 阻断、verification metrics 来源约束) - 新增:`backend/tests/test_prompt_profile_bundle_contract.py`(默认角色 prompt bundle 契约回归:PMFlow PMO 语气、职责边界与协作口径) - 新增:`backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入) - 新增:`backend/alembic/versions/20260326_add_task_breakdown_payload_fields.py`(tasks 拆解业务对象 JSONB 字段:`ai_breakdown_payload/manual_overrides`) @@ -165,6 +167,7 @@ backend/ - 新增:`backend/tests/test_daily_briefing_service.py`(每日简报回归:北京时间 naive 时间窗、任务更新采集与空响应边界) - 新增:`backend/tests/test_ai_steward_project_from_text_contract.py`(AI Steward `project-from-text` 契约回归:`code/message/data` 包络与 `signal_sources` 字段) - 新增:`backend/tests/test_signal_source_requirement_materialization.py`(需求点 -> 信号源注册项归一/去重/原始上下文回归) +- 新增:`backend/tests/test_signal_source_repair_materialization.py`(历史 registry 修复回归:优先回填项目内既有 Signal,否则物化正式 Signal) - 新增:`backend/tests/test_feishu_new_project_signal_sources.py`(飞书 `new_project` 自动立项回归:摘要失败显式报错、信号源随项目同步落库) - 新增:`backend/tests/test_model_routing_service.py`(双层模型路由契约回归:`safety` 拒绝、runtime/query 一致、override 只存 `tier_alias`、`ai_call_logs` 仅记录 tier 级路由元信息) - 新增:`backend/tests/test_model_routing_migration.py`(`model_routing` 迁移回归:旧函数名键重命名/删除后仍保留低高层路由语义) @@ -509,7 +512,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 运行期禁止 DDL:`database.py` 钩子统一拦截 `CREATE/ALTER/DROP/...`,仅迁移路径允许执行 - 运行环境数据库仅允许 PostgreSQL(`postgresql+asyncpg`);禁止回落 SQLite - 新增接口必须遵循响应包络:`{code, message, data}`,不得裸返回 -- 项目创建时自动生成的信号源必须落到现有 `SignalSourceRegistry`,前端仅允许在现有 AI Steward 创建结果区内联展示,禁止新增独立信号源页面或独立持久化模型 +- `SignalSourceRegistry` 仅作为信号源接入/幂等内部表;用户可见的项目“信号源/信号”统一落到正式 `Signal`,SSTC 信号阶段只展示 `Signal`,禁止新增独立 registry 展示页面或第二套用户口径 - `create_project_from_natural_language` 支持透传 `requested_member_user_ids`;AI PMO 草稿确认时必须把推荐负责人并入项目创建事务,确保项目、主信号、信号源、成员关系一次性落库,禁止确认后再事后补成员 - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 @@ -528,9 +531,17 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态 - 所有 LLM 调用禁止静默兜底与 stub 假成功;low 失败时必须优先强制升级到 high 重试并保留低/高两段日志,high 仍失败时才显式报错,禁止用固定兜底文案冒充成功结果 - 禁止模型兜底回复文案:模型失败、超时、空响应、结构化解析失败时,必须返回真实错误原因;禁止输出“系统处理中/请稍后重试”等固定文案冒充成功结果 +- 核心业务内容零兜底:宪章、信号分析、任务拆解、项目建议、范围边界、风险判断、资源计划、对齐结论等用户可见核心业务内容,禁止用默认文案、模板句子、占位字段或 stub 数据冒充真实生成结果;AI 失败、证据不足或结构化失败时,必须显式失败或标记为“待确认/证据不足/生成失败” +- 核心业务完成态不得仅按字段非空判定;必须能区分“真实生成 / 人工填写 / 模板占位 / 生成失败”,技术层 fallback 仅允许用于重试、缓存、解析修复、传输兼容等基础设施环节,禁止上浮成用户可见业务结论 +- SSTC 宪章 AI 预填充必须输出并持久化 `field_status/field_evidence/readiness/derived_artifacts`;`ready_for_confirm=true` 的前提是必填字段文本存在、字段状态为 `valid` 且具备字段级证据,禁止仅凭字段非空、模板句子或 `module_confirmations` 进入完成态 +- SSTC 宪章总确认(`alignment/confirm`)必须先校验 `readiness`;存在 `generation_failed/evidence_insufficient/generic_text/conflict_blocked` 任一 blocker 时禁止确认并保持任务阶段锁定 +- SSTC 发布验证清单必须优先消费 `derived_artifacts.verification_metrics`,其次才允许读取宪章 KPI;禁止再回退 3 条通用默认验收项 - AdaptiveRAG `adaptive_rag_answer_normal` 空响应必须显式抛错并交由 high failover 处理;禁止在生成层直接回落为“补充问题细节后重试”之类的兜底回复 - 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏 - 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`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文 - mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`,禁止把需求段落并入问答段落 - 飞书与 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 PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库;`project.create` 的 Feishu 文本入口先转待确认卡片,点击后才允许执行 - 草稿确认只认显式 `reply_to_message_id` + 明确草稿动作(`确认/修改/取消`)或显式 `work_item_id`;草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理 +- 飞书历史队列协议已停用:卡片动作与文本 followup 只允许走 `work_item_id + work_item.resume/draft_confirm` 主链;禁止新增或恢复 `queue_pending_confirm`、`queue_item_id`、`_maybe_handle_queue_followup` 这类旧 `feishu_items` 队列交互入口 - AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账 - AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定 - PMFlow 业务语义治理:会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其他目录仅允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析 @@ -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_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`) -- KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`) -- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重与 `raw_context` 持久化) +- KG 上下文闭环接口:参考 `backend/app/api/knowledge_graph.py`(`GET /ai-steward/knowledge-graph/capability-summary`、`POST /ai-steward/knowledge-graph/verify-context-consumption`、`POST /ai-steward/knowledge-graph/sync/work-items`;旧 `POST /ai-steward/knowledge-graph/sync/feishu` 仅保留兼容别名) +- 需求点落信号源:参考 `backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py`(`materialize_requirement_sources` 负责归一、去重、`raw_context` 持久化,并为每条 registry 同步绑定/物化正式 `Signal`) - 审计查询接口:参考 `backend/app/api/audit.py`(`GET /audit/operations`) - 脏数据修复接口:参考 `backend/app/api/ai_steward.py`(`POST /ai-steward/signal-sources/repair-dirty`) - LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关) @@ -593,7 +605,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 通用任务拆解接口(`POST /tasks/breakdown`)统一返回结构化任务业务对象,支持 `project_id/charter_id` 上下文与 Plane 同步 - SSTC 信号列表接口(`GET /projects/{project_id}/signals`)标题生成规则:优先 `signals.title`,其次 `signals.summary`,最后回退 `signals.content` 去前缀文本 - SSTC 宪章详情接口(`GET /projects/{project_id}/charters/{charter_id}`)`alignment_records[]` 补充 `user_name/confirmed_at`,用于前端展示“对齐成员 + 对齐时间” +- SSTC 宪章 AI 预填充接口(`POST /projects/{project_id}/charters/{charter_id}/ai-prefill`)固定写回 `modules/field_status/field_evidence/readiness/derived_artifacts`,AI 失败或字段缺证据时不得再补模板文案 - SSTC AI 拆解接口(`POST /projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown`)执行任务标题去重(同次 + 历史任务),返回 `skipped_duplicates` / `auto_assigned_count`,并落 `ai_breakdown_payload/manual_overrides` +- SSTC 发布验证生成接口(`POST /projects/{project_id}/releases/{release_node_id}/verification/generate`)只允许从 `derived_artifacts.verification_metrics` 或宪章 KPI 生成校验项;两者都为空时返回 `409` - SSTC 负责人推荐接口(`POST /projects/{project_id}/tasks/{task_id}/recommend-assignee`)直接消费 `required_skill_tag_ids` 语义,不再把技能 ID 当字符串 - SSTC 任务更新接口(`PATCH /projects/{project_id}/tasks/{task_id}`)会把人工修改写入 `manual_overrides`,供后续 AI 重跑识别覆盖层 - SSTC 发布创建接口(`POST /projects/{project_id}/releases`)新增已关联任务拦截:若 `task_ids` 命中历史发布计划已绑定任务,返回 `409` + `detail.task_ids` @@ -610,7 +624,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 11. 已知 BOUNDARY(不要幻构行为) - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 -- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示,现阶段不新增独立页面路由;底层持久化统一复用现有 `SignalSourceRegistry` +- 项目创建返回的 `signal_sources` 仅作为 AI Steward 创建结果区的内联展示;项目看板与 SSTC 信号阶段统一只读正式 `Signal`,`SignalSourceRegistry` 继续只承担内部持久化与幂等,不单独上屏 - 老板晨报首版的 `external_metrics` 只返回占位区块,不接 CRM / BI / 收入系统,也不允许伪造经营指标 - 老板晨报调度默认值为 `08:30 + workdays + Asia/Shanghai`;若 `delivery_target_ref` 未配置,调度器仍生成快照,但只记录告警、不静默伪装成已推送 - `daily-briefing-v2` 与老板晨报当前只做同会话内 `snapshot/live` 同步,不引入 WebSocket 推送;导出仅复用浏览器打印能力 @@ -644,11 +658,11 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 数据库与核心写入链路统一使用北京时间(`Asia/Shanghai`):PostgreSQL 会话时区、数据库默认时区与核心 `_utc_now*` 写入入口均已切换到北京时间 - 后端与脚本层已清零 `datetime.utcnow()/timezone.utc`:统一使用 `backend/app/utils/timezone.py`(`now_cn_aware/now_cn_naive/CN_TZ`)管理时间语义 - `operation_logs.created_at_beijing` 为应用层计算字段(非 DB 列),审计查询接口返回时统一补齐 -- 历史脏信号源修复优先复用同项目已存在 `signal_id`,仅无法复用时才触发 `capture-feishu` 补录 +- 历史脏信号源修复优先回填同项目已存在正式 `Signal`(优先按 `source/source_identifier`,再按标题/摘要近似匹配);仅无法复用时才直接物化新的正式 `Signal`,不再触发 `capture-feishu` 补录 - 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败 - `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual` - 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试 -- 飞书会话与问答上下文键已改为线程/消息锚点优先:优先 `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 现在是双通道:消息事件继续走长连接,但 `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` @@ -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] 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 边界回退到旧的一锅煮 -- [ ] 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 预留接口 -- [ ] 语义治理 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 -- [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未退场:`FeishuSignalPipelineService` 已不再承担线上主入口,但离线回放、单测基座、`ai_logs/project_knowledge_graph` 历史过滤,以及 Alembic / 启动自检仍依赖 `feishu_items`。后续需先迁走历史读链路与回放基座,再为该链路补明确的 deprecated / 下线计划 -- [ ] AdaptiveRAG 向量检索仍保留 legacy fallback:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 在非 PostgreSQL 或 PG 查询失败时会回退 `_retrieve_vector_context_legacy()`,对 `knowledge_base_chunks` 做 Python 侧扫描与打分;这使 pgvector 主路径与 legacy 主路径长期双轨。后续需决定是正式声明“只支持 PostgreSQL/pgvector”,还是补齐统一 retrieval contract 与测试矩阵 -- [ ] 历史 Alembic 链幂等性缺陷仍未修复:仓库里已确认 `add_users_table_rbac.py` 在 PostgreSQL 可能重复创建 `userrole` ENUM;当前运行时仍通过直接 `uvicorn` 启动 + `AUTO_CREATE_TABLES=true` 规避全量 upgrade。后续需修补旧 migration,保证 PostgreSQL 上的 Alembic upgrade 可重复执行 +- [x] Retrieval source registry / metadata 已统一:AdaptiveRAG 检索链已建立统一 source registry / metadata normalizer,`citations[]/sources_used[]` 直接消费标准字段 `source_type/source_id/chunk_id/target_url/project_id/updated_at/permission_scope/source_priority`;`doc/url/feishu/pmflow/graph/db` 的来源元数据与排序键已收口,并由 `test_rag_route_contract.py`、`test_adaptive_rag_mainline_contract.py` 锁定 +- [x] 语义治理 baseline 已清零:散落在 `feishu_signal_pipeline`、`signal_capture_service`、`ai_pmo_api` 的 `*_HINTS/*_RE` 规则常量已迁回 `workflow_semantic_planner`,`scripts/semantic_lint_baseline.txt` 已清空,并由 `scripts/semantic_lint.py` 与 `backend/tests/test_semantic_lint.py` 做仓库级守门 +- [ ] 历史 Feishu 队列 / `feishu_items` 兼容链仍未完全退场:飞书真实入口、卡片动作、文本 queue followup,以及 `project_knowledge_graph` 的主增量/API 命名都已切到 `work_item` 主链;当前剩余债务主要是 `feishu_items` 存量读链路、历史 pending 事件兼容消费,以及 legacy `feishu_delta` 证据/回放兼容壳。后续需在历史存量清空后正式封存该兼容层 +- [x] AdaptiveRAG 向量检索 legacy fallback 已移除:`AdaptiveRAGRetrievalVectorMixin._retrieve_vector_context()` 现在只支持 PostgreSQL/pgvector 主路径;非 PostgreSQL 或 PG 查询失败时直接返回空结果,不再回退 Python 侧 legacy 扫描,边界由 `test_adaptive_rag_mainline_contract.py` 锁定 +- [x] 历史 Alembic 链幂等性缺陷已修复:`add_users_table_rbac.py` 的 PostgreSQL ENUM 创建已改为显式创建 + 列类型 `create_type=False` 复用,`userrole/userstatus` 不再在同一条 migration 内重复建型;回归由 `test_add_users_table_rbac_migration.py` 锁定 - [ ] 食谱图片上传仍是示例桩实现:`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] 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` 形成稳定回归基线 diff --git a/backend/alembic/versions/add_users_table_rbac.py b/backend/alembic/versions/add_users_table_rbac.py index c7cd0cf1..7478885f 100644 --- a/backend/alembic/versions/add_users_table_rbac.py +++ b/backend/alembic/versions/add_users_table_rbac.py @@ -20,11 +20,15 @@ depends_on = None def upgrade(): bind = op.get_bind() is_sqlite = bind.dialect.name == 'sqlite' + role_column_type = sa.String(50) + status_column_type = sa.String(50) # Create enums (only for Postgres) if not is_sqlite: 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) + 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_status_enum.create(bind, checkfirst=True) @@ -37,10 +41,10 @@ def upgrade(): sa.Column('password_hash', sa.String(255), nullable=False), sa.Column('name', sa.String(100), nullable=False), 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('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('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), ) diff --git a/backend/app/api/ai_pmo_api/shared.py b/backend/app/api/ai_pmo_api/shared.py index 5b9dd920..f88f2cea 100644 --- a/backend/app/api/ai_pmo_api/shared.py +++ b/backend/app/api/ai_pmo_api/shared.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio from datetime import datetime -import re from typing import Any, Literal, Optional 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 ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService 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"]) logger = get_logger(__name__) -FRONT_NOISE_INTENTS = {"query.read_only", "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 = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建") +FRONT_NOISE_INTENTS = {INTENT_QUERY, ROUTE_QUERY_ANSWER} diff --git a/backend/app/api/feishu_api/card_action_routes.py b/backend/app/api/feishu_api/card_action_routes.py index b09421e8..fd2b6d16 100644 --- a/backend/app/api/feishu_api/card_action_routes.py +++ b/backend/app/api/feishu_api/card_action_routes.py @@ -96,7 +96,6 @@ def _card_to_event_payload(card: Card) -> dict[str, Any]: 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") - 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") 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: 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: payload["draft_version"] = draft_version 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 ( ("草稿ID", "target_work_item_id"), ("工作项ID", "work_item_id"), - ("队列ID", "queue_item_id"), ("项目ID", "project_id"), ("任务ID", "task_id"), ("信号ID", "signal_id"), diff --git a/backend/app/api/knowledge_graph.py b/backend/app/api/knowledge_graph.py index 10a6d164..806534dc 100644 --- a/backend/app/api/knowledge_graph.py +++ b/backend/app/api/knowledge_graph.py @@ -29,7 +29,7 @@ class CodeScanRequest(BaseModel): 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) @@ -165,16 +165,29 @@ async def sync_codebase( return success_response(data) -@router.post("/sync/feishu") -async def enqueue_feishu_delta( - body: FeishuDeltaEnqueueRequest, +@router.post("/sync/work-items") +async def enqueue_work_item_delta( + 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_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) diff --git a/backend/app/api/sstc_api/charters_ai_alignment.py b/backend/app/api/sstc_api/charters_ai_alignment.py index f2714ec1..7e54915b 100644 --- a/backend/app/api/sstc_api/charters_ai_alignment.py +++ b/backend/app/api/sstc_api/charters_ai_alignment.py @@ -163,6 +163,18 @@ async def confirm_alignment( svc = SSTCService(db, tenant_id=tenant_id) await _ensure_project(svc, pid) 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( select(AlignmentRecord).where( diff --git a/backend/app/api/sstc_api/releases_status_generate.py b/backend/app/api/sstc_api/releases_status_generate.py index 4569578d..07fb68eb 100644 --- a/backend/app/api/sstc_api/releases_status_generate.py +++ b/backend/app/api/sstc_api/releases_status_generate.py @@ -80,28 +80,43 @@ async def generate_release_verification( charter_id=charter_id, ) charter_content = _as_dict(charter.content) - success_metrics = charter_content.get("success_metrics") or charter_content.get("core_metrics") or [] - if not success_metrics: - success_metrics = ["功能验收通过", "核心流程无阻塞", "关键性能指标达标"] + derived_artifacts = charter_content.get("derived_artifacts") + if not isinstance(derived_artifacts, dict): + 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 = [ { "id": f"check-{i+1}", - "title": str(metric), - "expected": "达到预期", + "title": str(metric.get("name") or metric.get("title") or "").strip(), + "expected": str(metric.get("expected") or metric.get("name") or metric.get("title") or "").strip(), "status": "pending", "required": True, - "source": "charter_success_metric", + "source": "charter_verification_metric", "verified_by": None, "verified_at": 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_progress"] = {"done": 0, "total": len(items)} content["verification_done"] = False release_node.content = content await db.commit() return success_response({"release_id": str(release_node.id), "verification_items": items}) - diff --git a/backend/app/api/sstc_api/signals_list.py b/backend/app/api/sstc_api/signals_list.py index acc6c4de..0142f64b 100644 --- a/backend/app/api/sstc_api/signals_list.py +++ b/backend/app/api/sstc_api/signals_list.py @@ -4,6 +4,11 @@ from .shared import * from ...services.sstc.entity_lookup import _ensure_project 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_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, } ) - diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py index 83ee66a9..75be9beb 100644 --- a/backend/app/config/ai_prompt_registry.py +++ b/backend/app/config/ai_prompt_registry.py @@ -362,7 +362,7 @@ def _part_03_entries() -> tuple[dict[str, Any], ...]: module="part_03", business_scenario="宪章草案生成", 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"), ), _entry( diff --git a/backend/app/config/ai_prompts_catalog/part_03.py b/backend/app/config/ai_prompts_catalog/part_03.py index a5adb2f0..1e88b101 100644 --- a/backend/app/config/ai_prompts_catalog/part_03.py +++ b/backend/app/config/ai_prompts_catalog/part_03.py @@ -112,24 +112,66 @@ PROMPT_GENERATE_CHARTER = _compose_prompt( 请输出 JSON,包含: {{ - "title": "需求宪章标题", "summary": "一句话摘要", "background": "背景与动机", - "objectives": ["目标1", "目标2"], "scope": "范围说明", - "success_criteria": ["标准1", "标准2"], - "risks": ["风险1", "风险2"], - "dependencies": ["依赖1", "依赖2"], - "mvp_items": ["MVP项1", "MVP项2"], - "owner_suggestion": "负责人建议", - "followup_questions": ["待确认问题1", "待确认问题2"] + "confidence_score": 0.0, + "modules": {{ + "module_1": {{ + "problem_statement": {{"value": "", "evidence": [{{"signal_id": 0, "summary": "", "quote": "", "reason": "", "confidence": 0.0}}], "status": "valid|evidence_insufficient|generation_failed|generic_text|conflict_blocked"}}, + "goal_statement": {{"value": "", "evidence": []}}, + "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 的项目立项、复用、合并与宪章起草目标输出。 -2. 若信息不足,followup_questions 最多 3 条。 -3. MVP 优先最小可验证闭环,不要写空泛愿景。 -4. 输出必须可直接被 json.loads 解析。 +2. 必填核心字段缺少证据时,value 可为空,但必须显式标注 status,禁止用模板文案补齐。 +3. evidence 必须优先引用 signal_id;没有证据时不要伪造引用。 +4. alignment_qa 必须是 5 个固定对齐问答的完整文本;无法生成时显式阻断 readiness。 +5. verification_metrics、task_seeds、risk_gates 必须能被下游执行或校验直接消费。 +6. 输出必须可直接被 json.loads 解析。 """, JSON_OUTPUT_GUARDRAIL, ) diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_05.py b/backend/app/config/ai_prompts_catalog/part_scenes_05.py index a299b1f5..f2de2451 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_05.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_05.py @@ -12,12 +12,27 @@ PROMPT_AI_PMO_QA = _compose_prompt( 2. 问题涉及操作时先回答再提示可用指令。 3. 只基于给定上下文回答,不要编造系统状态。 4. 禁止输出思考过程。 -5. 回答后附可操作建议: +5. 若 `business_review_mode=true`,必须按“五大口径”组织回复: + - 事实源在哪 + - 输出模板在哪 + - 检索范围在哪 + - 执行器在哪 + - 反馈回流在哪 +6. 若信息不足,不允许输出伪结论;必须指出五大口径里缺失的关键项,并最多补 3 个澄清问题。 +7. 若 `correction_mode=true`,这是“纠错升级重答”,必须复用当前对话与上下文主题,直接给出修正答案;若缺少可复用锚点,要明确说明“找不到可重答的上一轮问题”。 +8. 对“高级模型是否启用 / 模型路由是否启用 / 当前是否高配”这类运行态问题,只有拿到运行态配置、路由快照、引擎配置或真实调用记录时,才允许下启用/未启用结论。 +9. 如果证据只有页面目录、代码图谱、功能清单等静态资料,只能回答“代码里存在该能力入口,但当前启用状态无法仅凭静态资料确认”,不得冒充运行态结论。 +10. 回答后附可操作建议: "你可以继续:[操作A]/[操作B]" 问题:{question} +场景:{scene_name} +business_review_mode={business_review_mode} +review_framework={review_framework} +correction_mode={correction_mode} 当前项目:{project_id} World Model:{world_model} +近期对话:{conversation} 检索上下文:{context_blocks} """.strip(), TEXT_OUTPUT_GUARDRAIL, diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py index e06a2550..65ce6ef6 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_payload.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_payload.py @@ -5,6 +5,16 @@ from .shared import * class AdaptiveRAGAnswerPayloadMixin: + _SOURCE_PRIORITY_MAP: dict[str, int] = { + "pmflow": 100, + "db": 90, + "graph": 80, + "doc": 70, + "url": 65, + "feishu": 60, + "system": 10, + } + @staticmethod def _safe_source_metadata(raw: Any) -> dict[str, Any]: return raw if isinstance(raw, dict) else {} @@ -27,6 +37,184 @@ class AdaptiveRAGAnswerPayloadMixin: text = str(value).strip() 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 def _normalize_answer_mode(value: Optional[str]) -> str: raw = str(value or "").strip().lower() @@ -90,36 +278,17 @@ class AdaptiveRAGAnswerPayloadMixin: for item in (sources or [])[:8]: if not isinstance(item, dict): continue - metadata = self._safe_source_metadata(item.get("metadata")) - source_type = self._source_ref_value( - metadata.get("source_type") - or item.get("source") - or metadata.get("table") - or metadata.get("mode") + metadata = self._normalize_source_registry_metadata( + source_type=item.get("source"), + metadata=item.get("metadata"), ) + 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_id = self._source_ref_value( - metadata.get("source_id") - or 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") - ) - 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") - ) + source_id = self._source_ref_value(metadata.get("source_id")) + chunk_id = self._source_ref_value(metadata.get("chunk_id")) + target_url = self._source_ref_value(metadata.get("target_url")) project_id = self._source_ref_value(metadata.get("project_id")) - updated_at = self._source_ref_value( - metadata.get("updated_at") - or metadata.get("doc_updated_at") - or metadata.get("source_updated_at") - ) + updated_at = self._source_ref_value(metadata.get("updated_at")) citation = { "source_type": source_type, "source_label": source_label, @@ -128,6 +297,8 @@ class AdaptiveRAGAnswerPayloadMixin: "target_url": target_url, "project_id": project_id, "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), } key = ( @@ -157,6 +328,8 @@ class AdaptiveRAGAnswerPayloadMixin: "target_url": item.get("target_url"), "project_id": item.get("project_id"), "updated_at": item.get("updated_at"), + "permission_scope": item.get("permission_scope"), + "source_priority": item.get("source_priority"), } ) return rows diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py index 741681ae..a953e413 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py @@ -218,6 +218,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: consultant_mode_preference: Optional[str] = None, ) -> tuple[str, str]: normalized_question = re.sub(r"\s+", "", str(question or "").lower()) + normalized_message_intent = str(message_intent or "").strip().lower() member_keywords = ("谁", "成员", "负责人", "负载") trend_keywords = ("最新", "行业", "市场", "前景", "趋势", "动态") @@ -242,7 +243,38 @@ class AdaptiveRAGDeepGuardAnswerMixin: 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 "需求信息待补充" merged_message = f"{question}\n{safe_signal_part}".strip() scene_key, prompt = ( @@ -368,8 +400,13 @@ class AdaptiveRAGDeepGuardAnswerMixin: prompt_key="PROMPT_AI_PMO_QA", fallback_template=PROMPT_AI_PMO_QA, 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", world_model=world_model_text or "{}", + conversation=conversation_text or "无", context_blocks=context_blocks_text or "无", ), ) diff --git a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py index c1a3d7ca..3d2f4320 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py +++ b/backend/app/services/adaptive_rag_service/mixin_deep_guard_rules.py @@ -25,6 +25,41 @@ class AdaptiveRAGDeepGuardRulesMixin: return True return False + @classmethod + def _is_correction_escalation_query(cls, question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + escalation_hints = ( + "调用高级模型", + "高级模型", + "更强模型", + "高配模型", + "重新回答", + "重答", + ) + has_escalation_hint = any(token in text for token in escalation_hints) + if not has_escalation_hint: + return False + return cls._is_correction_query(text) or any(token in text for token in ("重新回答", "重答")) + + @classmethod + def _is_runtime_state_query(cls, question: str) -> bool: + text = str(question or "").strip().lower() + if not text: + return False + runtime_subject = ( + "高级模型", + "高配", + "模型路由", + "路由机制", + "引擎配置", + "模型配置", + "高阶模型", + ) + runtime_state = ("启用", "开启", "打开", "可用", "生效", "激活", "当前是不是", "是否") + return any(token in text for token in runtime_subject) and any(token in text for token in runtime_state) + @classmethod def _has_explicit_target_scope(cls, question: str) -> bool: text = str(question or "").strip().lower() @@ -53,6 +88,41 @@ class AdaptiveRAGDeepGuardRulesMixin: def _is_explicit_deep_request(cls, question: str) -> bool: 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 def _forced_deep_reason( cls, @@ -61,6 +131,8 @@ class AdaptiveRAGDeepGuardRulesMixin: effective_question: str, conversation: list[dict[str, str]], ) -> str: + if cls._is_correction_escalation_query(question) or cls._is_correction_escalation_query(effective_question): + return "correction_escalation" if cls._is_correction_query(question) or cls._is_correction_query(effective_question): if conversation: return "correction_turn" @@ -83,6 +155,8 @@ class AdaptiveRAGDeepGuardRulesMixin: if channel != "feishu": return True + if str(reason or "").strip() == "correction_escalation": + return True if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): return True if str(reason or "").strip() == "engine_process_query": @@ -140,6 +214,8 @@ class AdaptiveRAGDeepGuardRulesMixin: channel = str(asked_via or "").strip().lower() if channel != "feishu": return True + if str(reason or "").strip() == "correction_escalation": + return True if cls._is_explicit_deep_request(question) or cls._is_explicit_deep_request(effective_question): return True return False @@ -153,16 +229,23 @@ class AdaptiveRAGDeepGuardRulesMixin: sources: list[dict[str, Any]], relevance_max: float, ) -> bool: + if cls._is_runtime_state_query(question) or cls._is_runtime_state_query(effective_question): + return cls._is_static_runtime_sources(sources) if not (cls._is_engine_process_query(question) or cls._is_engine_process_query(effective_question)): return False if not cls._is_snapshot_only_sources(sources): return False return float(relevance_max or 0.0) < 0.68 - @staticmethod - def _build_evidence_guard_answer(question: str) -> str: + @classmethod + def _build_evidence_guard_answer(cls, question: str) -> str: raw = str(question or "").strip() target = raw[:80] if raw else "该问题" + if cls._is_runtime_state_query(raw): + return ( + f"关于“{target}”,代码里存在该能力入口,但当前启用状态无法仅凭静态资料确认。" + "请提供运行态配置、路由快照、引擎配置或真实调用记录后,我再给出可核验结论。" + ) return ( f"我不能对“{target}”直接给结论:当前只命中系统快照,缺少调用链证据。" "请补充目标对象(如 openclaw 路由规则、函数名、入口层级),我会按调用链返回可核验答案。" diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_graph.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_graph.py index 0e7a690a..9a82668f 100644 --- a/backend/app/services/adaptive_rag_service/mixin_retrieval_graph.py +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_graph.py @@ -139,14 +139,21 @@ class AdaptiveRAGRetrievalGraphMixin: ).scalars().all() evidence_by_relation: 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: excerpt = str(ev.content_excerpt or "").strip() + occurred_at = self._isoformat_or_none(getattr(ev, "occurred_at", None)) if not excerpt: - continue + excerpt = "" 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] + 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: 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] = [] for rel in relation_rows: @@ -172,21 +179,37 @@ class AdaptiveRAGRetrievalGraphMixin: ) weight = self._to_float(rel.weight, 1.0) 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( RetrievedItem( source="graph", content=content[:1200], score=score, source_label="Knowledge 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}", - }, + metadata=metadata, ) ) @@ -197,21 +220,36 @@ class AdaptiveRAGRetrievalGraphMixin: if int(payload.get("project_id")) != int(project_id): continue 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( RetrievedItem( source="graph", content=f"实体: {str(seed.display_name or seed.entity_key)}\n证据: {excerpt or '无显式证据摘录'}", score=0.58, source_label="Knowledge Graph", - metadata={ - "entity_id": int(seed.id), - "entity_type": str(seed.entity_type or ""), - "entity_key": str(seed.entity_key or ""), - }, + metadata=metadata, ) ) - 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)] except Exception: return [] - diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py index fdf5b3ae..50520cf5 100644 --- a/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_router_db.py @@ -86,6 +86,16 @@ class AdaptiveRAGRetrievalRouterDBMixin: f"#{int(row.id)} {str(row.name or f'User#{int(row.id)}')}(角色:{str(row.role or 'unknown')})" 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( RetrievedItem( source="db", @@ -96,12 +106,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ), score=0.95, source_label="User database", - metadata={ - "table": "users", - "count": len(members), - "project_id": scoped_project_id, - "project_name": scoped_project_name, - }, + metadata=metadata, ) ) @@ -114,13 +119,18 @@ class AdaptiveRAGRetrievalRouterDBMixin: summary = ";".join( 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( RetrievedItem( source="db", content=f"项目列表:{summary}", score=0.93, source_label="Project database", - metadata={"table": "projects", "count": len(projects)}, + metadata=metadata, ) ) @@ -138,13 +148,22 @@ class AdaptiveRAGRetrievalRouterDBMixin: ).scalars().all() if pending: 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( RetrievedItem( source="db", content=f"最近信号({len(pending)}条):{preview}", score=0.9, source_label="Project database", - metadata={"table": "signals", "count": len(pending)}, + metadata=metadata, ) ) @@ -156,6 +175,15 @@ class AdaptiveRAGRetrievalRouterDBMixin: if tasks: 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] + 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( RetrievedItem( source="db", @@ -165,7 +193,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ), score=0.91, 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) charters = (await self.db.execute(charter_stmt.order_by(TruthNode.updated_at.desc()).limit(6))).scalars().all() 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( RetrievedItem( source="db", @@ -183,7 +220,7 @@ class AdaptiveRAGRetrievalRouterDBMixin: ), score=0.92, 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}" 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( RetrievedItem( source="db", content=f"团队工作量:{content}", score=0.88, source_label="Project database", - metadata={"table": "user_workloads", "count": len(workloads)}, + metadata=metadata, ) ) return rows[:DEFAULT_TOP_K] - diff --git a/backend/app/services/adaptive_rag_service/mixin_retrieval_vector.py b/backend/app/services/adaptive_rag_service/mixin_retrieval_vector.py index 9533239c..98031857 100644 --- a/backend/app/services/adaptive_rag_service/mixin_retrieval_vector.py +++ b/backend/app/services/adaptive_rag_service/mixin_retrieval_vector.py @@ -26,24 +26,16 @@ class AdaptiveRAGRetrievalVectorMixin: if not source_types: return [] limit = max(1, min(int(top_k or DEFAULT_TOP_K), 40)) - if self._is_postgresql_session(): - pg_rows = await self._retrieve_vector_context_pg( - question_embedding=question_embedding, - 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( + if not self._is_postgresql_session(): + return [] + rows = await self._retrieve_vector_context_pg( question_embedding=question_embedding, source_types=source_types, project_id=project_id, source_chat_id=source_chat_id, limit=limit, ) + return rows if isinstance(rows, list) else [] def _is_postgresql_session(self) -> bool: try: @@ -71,7 +63,7 @@ class AdaptiveRAGRetrievalVectorMixin: project_id: Optional[int], source_chat_id: Optional[str], limit: int, - ) -> Optional[list[RetrievedItem]]: + ) -> list[RetrievedItem]: query_vec = self._vector_literal(question_embedding) scoped_project_id = int(project_id) if project_id is not None else 0 scoped_source_chat_id = str(source_chat_id or "").strip() @@ -79,9 +71,11 @@ class AdaptiveRAGRetrievalVectorMixin: """ SELECT id, + doc_id, source_type, content, project_id, + chunk_index, "metadata" AS chunk_metadata, 1 - (embedding <=> CAST(:query_vec AS vector)) AS similarity FROM knowledge_base_chunks @@ -119,7 +113,7 @@ class AdaptiveRAGRetrievalVectorMixin: ) ).mappings().all() except Exception: - return None + return [] scored: list[RetrievedItem] = [] for row in rows: @@ -127,19 +121,13 @@ class AdaptiveRAGRetrievalVectorMixin: meta = self._safe_dict(row.get("chunk_metadata")).copy() 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 - if chunk_project_id is not None: - meta.setdefault("project_id", chunk_project_id) - - if 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 + meta = self._normalize_source_registry_metadata( + source_type=source_type, + metadata=meta, + explicit_source_id=row.get("doc_id") if source_type in {"doc", "url"} else None, + explicit_chunk_id=row.get("chunk_index"), + explicit_project_id=chunk_project_id, + ) score = self._to_float(row.get("similarity"), 0.0) if score <= 0: @@ -154,68 +142,11 @@ class AdaptiveRAGRetrievalVectorMixin: metadata=meta, ) ) - scored.sort(key=lambda x: x.score, reverse=True) - return scored[:limit] - - async def _retrieve_vector_context_legacy( - self, - *, - 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), - ) + scored.sort( + key=lambda item: ( + item.score, + self._source_priority_sort_value(item.metadata), + ), + reverse=True, ) - - 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] - diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py index 022affd5..23bbc8e6 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py @@ -3,6 +3,7 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common +from ....workflow_semantic_planner import WorkflowSemanticPlanner for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -10,6 +11,55 @@ for _name, _value in vars(_common).items(): 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( self, *, @@ -24,6 +74,10 @@ class KernelIntentResolveMixin: isinstance(thread_context, dict) 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( message_text=message_text, session=session, @@ -57,10 +111,10 @@ class KernelIntentResolveMixin: world_model=world_model, rule_intent=None, 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=( - _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: diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index 000d4cb8..28362300 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -262,12 +262,19 @@ class KernelIngressFlowEntryMixin: **(intent.metadata or {}), "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: - intent.metadata = { - **(intent.metadata or {}), - "thread_context": dict(thread_context), - "session_key": str(ingress_prepare.get("session_key") or "").strip() or None, - } + source_context_metadata["thread_context"] = dict(thread_context) + intent.metadata = { + **(intent.metadata or {}), + **{key: value for key, value in source_context_metadata.items() if value not in (None, "")}, + } workflow_route = self.route_resolver.from_canonical( canonical_intent=intent, message_text=clean_text, diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index 06263ab9..16f431db 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -25,6 +25,8 @@ class ExecuteQueryProjectMixin: ) -> dict[str, Any]: rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) 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 = ( metadata.get("rag_request_context") if isinstance(metadata.get("rag_request_context"), dict) @@ -55,10 +57,10 @@ class ExecuteQueryProjectMixin: asked_by = _safe_int(operator_user_id) asked_via = source_system 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 = ( build_feishu_conversation_scope_key( - source_chat_id=None, + source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, sender=None, ) @@ -67,15 +69,48 @@ class ExecuteQueryProjectMixin: ) session_id = None force_new_session = False - thinking_mode = None + thinking_mode = str(semantic_diagnostics.get("force_thinking_mode") or "").strip() or None action_mode = "read" 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 - 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): 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 + 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] = { "question": message_text, "asked_by": asked_by, diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py b/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py index 846a2afe..7ab77c69 100644 --- a/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py +++ b/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py @@ -42,6 +42,9 @@ WORKFLOW_PROJECT_SIGNAL = "project_signal_flow" WORKFLOW_SIGNAL_SOURCE = "signal_source_flow" WORKFLOW_FOLLOWUP = "followup_flow" +QUERY_SCENE_BUSINESS_REVIEW = "business_review" +QUERY_SCENE_CORRECTION_ESCALATION = "correction_escalation" + MESSAGE_GATE_AUTO = "auto_execute" MESSAGE_GATE_PENDING = "pending_confirmation" 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( r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", re.IGNORECASE, @@ -221,6 +333,33 @@ SMALLTALK_EXACT = { } SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok") 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 = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") TASK_CREATE_HINTS = ( @@ -311,6 +450,10 @@ LEGACY_COMMAND_OBJECT_RE = re.compile( 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( r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)", re.IGNORECASE, diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py index 9185fb38..decae3ec 100644 --- a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py +++ b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py @@ -5,9 +5,12 @@ import re from typing import Any, Iterable, Optional from .constants import ( + BUSINESS_REVIEW_ANALYSIS_HINTS, + BUSINESS_REVIEW_TARGET_HINTS, CONTEXTUAL_CONFIRM_HINTS, CONTEXTUAL_DEICTIC_HINTS, CONTEXTUAL_WRITE_ACTION_HINTS, + CORRECTION_ESCALATION_HINTS, CREATE_ACTION_RE, EXTERNAL_INTEGRATION_RE, FOLLOWUP_CANCEL_HINTS, @@ -46,6 +49,8 @@ from .constants import ( PROJECT_ID_QUERY_RE, PROJECT_LIST_QUERY_RE, PROJECT_SIGNAL_DISCOVER_HINTS, + QUERY_SCENE_BUSINESS_REVIEW, + QUERY_SCENE_CORRECTION_ESCALATION, QUESTION_HINTS, REPLY_MODE_CARD, REPLY_MODE_TEXT, @@ -121,6 +126,61 @@ class WorkflowSemanticPlanner: return False 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 def _is_smalltalk_message(cls, text: str) -> bool: compact = re.sub(r"[\s\??!!,,。~~、]+", "", str(text or "")).strip().lower() @@ -380,6 +440,7 @@ class WorkflowSemanticPlanner: ) -> WorkflowBundle: payload = dict(metadata or {}) 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] = [] slots = [slot for slot in [workflow_slot("project_id", project_id)] if slot is not None] if intent_name == INTENT_QUERY: @@ -392,6 +453,7 @@ class WorkflowSemanticPlanner: requires_confirmation=False, confidence=0.88, slots=slots, + metadata=dict(query_scene_metadata), ) ) elif intent_name == INTENT_PROJECT_CREATE: @@ -492,7 +554,11 @@ class WorkflowSemanticPlanner: workflows=workflows, message_gate=cls._gate_for_workflows(workflows), 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 @@ -506,6 +572,7 @@ class WorkflowSemanticPlanner: text = str(message_text or "").strip() payload = dict(metadata or {}) normalized = cls._normalize_text(text) + query_scene_metadata = cls._derive_query_scene_metadata(text) workflows: list[WorkflowPlan] = [] if not text or cls._is_smalltalk_message(text): workflows.append( @@ -517,6 +584,7 @@ class WorkflowSemanticPlanner: requires_confirmation=False, confidence=0.55, clarification_needed=False, + metadata=dict(query_scene_metadata), ) ) elif cls._looks_like_followup(text, metadata=payload): @@ -607,6 +675,7 @@ class WorkflowSemanticPlanner: operation_codes=[ROUTE_QUERY_ANSWER], requires_confirmation=False, confidence=0.88 if question_like else 0.6, + metadata=dict(query_scene_metadata), ) ) primary_workflow = workflows[0].workflow_type @@ -618,7 +687,7 @@ class WorkflowSemanticPlanner: workflows=workflows, message_gate=cls._gate_for_workflows(workflows), 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 diff --git a/backend/app/services/ai_service_core/mixin_business_tasks.py b/backend/app/services/ai_service_core/mixin_business_tasks.py index 4f7c1d8b..f1daa9b6 100644 --- a/backend/app/services/ai_service_core/mixin_business_tasks.py +++ b/backend/app/services/ai_service_core/mixin_business_tasks.py @@ -77,6 +77,7 @@ class AIServiceBusinessTasksMixin: ) -> dict: """从市场/模型信号生成需求宪章草案""" from ...config.ai_prompts import PROMPT_GENERATE_CHARTER + from ...services.llm_service import llm_service tid = _t2s(tenant_id) try: @@ -88,14 +89,17 @@ class AIServiceBusinessTasksMixin: project_context=str(project_context 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, db=db, - tenant_id=tid, + tenant_id=int(tid or 1), function_name="generate_charter_from_signal", 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( self, diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py index 01dee247..9e3a12f8 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py @@ -10,7 +10,6 @@ class FeishuCardActionMixin: _ACTION_CONTINUE = "continue" _TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm" _TEMPLATE_PENDING_MANUAL = "pending_manual" - _TEMPLATE_QUEUE_PENDING_CONFIRM = "queue_pending_confirm" _ACTION_ALIASES: dict[str, str] = { "confirm": _ACTION_CONFIRM, "draft_confirm": _ACTION_CONFIRM, @@ -61,21 +60,6 @@ class FeishuCardActionMixin: return int(row_work_item_id) 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 def _build_action_card_from_capture_result( cls, @@ -88,8 +72,7 @@ class FeishuCardActionMixin: return None template_key = str(capture_result.get("reply_template_key") or "").strip().lower() work_item_id = cls._coerce_capture_work_item_id(capture_result) - queue_item_id = cls._coerce_capture_queue_item_id(capture_result) - if template_key != cls._TEMPLATE_QUEUE_PENDING_CONFIRM and work_item_id is None: + if work_item_id is None: return None reply_text = str(capture_result.get("direct_reply_text") or "").strip() safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。" @@ -177,35 +160,6 @@ class FeishuCardActionMixin: "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 @classmethod @@ -241,11 +195,6 @@ class FeishuCardActionMixin: or value.get("target_work_item_id") 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")) revision_note = ( str(value.get("revision_note") or value.get("note") or value.get("comment") or "") @@ -276,7 +225,6 @@ class FeishuCardActionMixin: return { "action": normalized_action, "work_item_id": work_item_id, - "queue_item_id": queue_item_id, "draft_version": draft_version, "revision_note": revision_note, "source_message_id": source_message_id, @@ -292,69 +240,20 @@ class FeishuCardActionMixin: parsed = self._extract_card_action_payload(event) action = str(parsed.get("action") or "").strip().lower() work_item_id = self._safe_int(parsed.get("work_item_id")) - 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}: 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"}} 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.integrations.feishu_signal_pipeline import FeishuSignalPipelineService 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 chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None try: - if queue_item_id is not None and queue_item_id > 0: - 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: + if action == self._ACTION_CONTINUE: kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id) result = await kernel.process_ingress( source_system="ai_pmo_ui", @@ -411,22 +310,15 @@ class FeishuCardActionMixin: or normalized.get("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 = "" if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM: template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM elif status in {"pending_manual", "pending_confirmation"}: template_key = self._TEMPLATE_PENDING_MANUAL - 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( capture_result={ "reply_template_key": template_key, "work_item_id": target_work_item_id, - "queue_item_id": target_queue_item_id, "draft_version": self._safe_int(normalized.get("draft_version")), "direct_reply_text": followup_reply, }, @@ -437,7 +329,7 @@ class FeishuCardActionMixin: await self._safe_reply_card( chat_id=chat_id, 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, perf_t0=None, ) @@ -447,7 +339,7 @@ class FeishuCardActionMixin: mention_open_id=sender_open_id, mention_name=operator_sender, 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, perf_t0=None, ) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py index 9ef19db5..8145f75d 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_core.py @@ -121,23 +121,55 @@ class FeishuPipelineProcessMessageCoreMixin: } inferred_project_id = _safe_int(chat_context_resolution.get("project_id")) - followup_capture = await self._maybe_handle_queue_followup( - message=message, - sender=sender, - source_chat_id=source_chat_id, - source_sender_open_id=source_sender_open_id, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - thread_context=thread_context, - ) - if followup_capture is not None: + wants_followup_continue = bool(FOLLOWUP_CONTINUE_RE.search(message)) + wants_followup_status = bool(FOLLOWUP_STATUS_RE.search(message)) + if wants_followup_continue or wants_followup_status: + duration_ms = int((time.perf_counter() - process_started_at) * 1000) + routing_decision = "legacy_queue_followup_retired" + classification = "command" if wants_followup_continue else "question" + reply_text = ( + "历史飞书待处理队列追问已下线。" + "请改为通过新的 AI PMO 工作项卡片继续处理,或使用新的 work_item 主链查询状态。" + ) + 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() 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, normalized_text=message, - route=f"{normalized_chat_type}.followup", - db_mutations=self._summarize_db_mutations(followup_capture), + route=f"{normalized_chat_type}.legacy_queue_followup_retired", + db_mutations=[], ) lookup_capture = await self._maybe_answer_project_lookup_question( diff --git a/backend/app/services/integrations/feishu_signal_pipeline/service.py b/backend/app/services/integrations/feishu_signal_pipeline/service.py index 5b786040..6feabc52 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/service.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/service.py @@ -17,8 +17,6 @@ from .mixin_process_message_core import FeishuPipelineProcessMessageCoreMixin from .mixin_process_message_default import FeishuPipelineProcessMessageDefaultMixin from .mixin_process_message_helpers_a import FeishuPipelineProcessMessageHelpersAMixin 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_queue_read_write import FeishuPipelineQueueReadWriteMixin from .mixin_queue_confirm_undo import FeishuPipelineQueueConfirmUndoMixin @@ -49,8 +47,6 @@ class FeishuSignalPipelineService( FeishuPipelineProcessMessageDefaultMixin, FeishuPipelineProcessMessageHelpersAMixin, FeishuPipelineProcessMessageHelpersBMixin, - FeishuPipelineFollowupAMixin, - FeishuPipelineFollowupBMixin, FeishuPipelineLookupAndBlockMixin, FeishuPipelineQueueReadWriteMixin, FeishuPipelineQueueConfirmUndoMixin, diff --git a/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py index 758941b7..380752dc 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py @@ -41,6 +41,33 @@ from ...ai_pmo.ai_pmo_kernel import ( gate_execution as kernel_gate_execution, 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 ...notification_service import NotificationService from ...operation_log_service import log_operation @@ -68,76 +95,10 @@ PROJECT_PRIORITY_ORDER = { } 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"(?:]*>[^<]*|@pmflow\b|@_user_1\b)", re.IGNORECASE) FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*") FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$") 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) TASK_CREATE_RE = re.compile( r"(新增|新建|创建|加一个|加个|补一个|补个).{0,8}任务", @@ -147,19 +108,6 @@ PROJECT_CREATE_GATE_RE = re.compile( r"(新增|新建|创建|建立|立项|放到|放进|加入|录入|进).{0,18}(pmflow|项目列表|项目库|项目台账|项目主数据|一个项目|项目)", 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_ENTER_SIGNAL = "auto_enter_signal" diff --git a/backend/app/services/project_knowledge_graph_service/mixin_cycle_run.py b/backend/app/services/project_knowledge_graph_service/mixin_cycle_run.py index 5041895b..b5c915bf 100644 --- a/backend/app/services/project_knowledge_graph_service/mixin_cycle_run.py +++ b/backend/app/services/project_knowledge_graph_service/mixin_cycle_run.py @@ -41,12 +41,12 @@ class PKGCycleRunMixin: stage_results["structured_delta"] = {"error": stage_errors["structured_delta"]} 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 except Exception as exc: await self.db.rollback() - stage_errors["feishu_enqueue"] = str(exc)[:500] - stage_results["feishu_enqueue"] = {"error": stage_errors["feishu_enqueue"]} + stage_errors["work_item_enqueue"] = str(exc)[:500] + stage_results["work_item_enqueue"] = {"error": stage_errors["work_item_enqueue"]} try: 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, ) return payload - diff --git a/backend/app/services/project_knowledge_graph_service/mixin_event_queue.py b/backend/app/services/project_knowledge_graph_service/mixin_event_queue.py index a8c203f2..be5ed773 100644 --- a/backend/app/services/project_knowledge_graph_service/mixin_event_queue.py +++ b/backend/app/services/project_knowledge_graph_service/mixin_event_queue.py @@ -2,37 +2,55 @@ from __future__ import annotations 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: - 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)) - 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)) rows = ( await self.db.execute( - select(FeishuItem) + select(WorkItem) .where( and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.updated_at >= last_dt, + WorkItem.tenant_id == self.tenant_id, + 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) ) ).scalars().all() enqueued = 0 newest_seen = last_dt 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( - channel="feishu_delta", + channel=_WORK_ITEM_DELTA_CHANNEL, event_key=event_key[:128], payload={ - "feishu_item_id": int(item.id), - "matched_project_id": int(item.matched_project_id) if item.matched_project_id is not None else None, - "level": str(item.level or ""), - "route_status": str(item.route_status or ""), + "work_item_id": int(item.id), + "project_id": int(item.project_id) if item.project_id is not None else None, + "signal_id": int(item.signal_id) if item.signal_id is not None else None, + "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", ) @@ -41,8 +59,8 @@ class PKGEventQueueMixin: newest_seen = item.updated_at await self.set_ingest_offset( - channel="feishu_delta", - offset_key="last_feishu_item_updated_at", + channel=_WORK_ITEM_DELTA_CHANNEL, + offset_key=_WORK_ITEM_DELTA_OFFSET_KEY, offset_value=newest_seen.isoformat(), payload={"batch_size": len(rows)}, ) @@ -54,6 +72,9 @@ class PKGEventQueueMixin: "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]: batch = max(1, min(int(max_batch), 1000)) retries = max(0, min(int(max_retry), 10)) @@ -76,8 +97,8 @@ class PKGEventQueueMixin: for ev in rows: stats["processed"] += 1 try: - if str(ev.channel) == "feishu_delta": - await self._process_feishu_event(ev) + if str(ev.channel) in {_WORK_ITEM_DELTA_CHANNEL, _LEGACY_FEISHU_DELTA_CHANNEL}: + await self._process_delta_event(ev) ev.status = "done" ev.error_message = None ev.processed_at = _utc_now_naive() @@ -96,8 +117,13 @@ class PKGEventQueueMixin: await self.db.commit() 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 {} + 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") if item_id is None: return @@ -133,8 +159,8 @@ class PKGEventQueueMixin: entity_id=int(signal_entity.id), source_type="feishu", 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: project_entity = await self.upsert_entity( @@ -161,3 +187,91 @@ class PKGEventQueueMixin: 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)}, + ) diff --git a/backend/app/services/project_knowledge_graph_service/mixin_sync_runs.py b/backend/app/services/project_knowledge_graph_service/mixin_sync_runs.py index e1980d99..ad701c47 100644 --- a/backend/app/services/project_knowledge_graph_service/mixin_sync_runs.py +++ b/backend/app/services/project_knowledge_graph_service/mixin_sync_runs.py @@ -148,12 +148,13 @@ class PKGSyncRunsMixin: ) ).scalar_one() - async def _freshness_minutes(channel: str, offset_key: str) -> Optional[int]: - raw = await self.get_ingest_offset(channel=channel, offset_key=offset_key) - dt = self._parse_iso_dt(raw) - if dt is None: - return None - return max(int((now - dt).total_seconds() // 60), 0) + async def _freshness_minutes(entries: list[tuple[str, str]]) -> Optional[int]: + for channel, offset_key in entries: + raw = await self.get_ingest_offset(channel=channel, offset_key=offset_key) + dt = self._parse_iso_dt(raw) + if dt is not None: + return max(int((now - dt).total_seconds() // 60), 0) + return None return { "tenant_id": self.tenant_id, @@ -170,11 +171,15 @@ class PKGSyncRunsMixin: "dead_letter_events": int(dead_letter or 0), "pending_events": int(pending_events or 0), "freshness_minutes": { - "db_structured": await _freshness_minutes("db_delta", "last_structured_sync_at"), - "feishu_delta": await _freshness_minutes("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"), + "db_structured": await _freshness_minutes([("db_delta", "last_structured_sync_at")]), + "work_item_delta": await _freshness_minutes( + [ + ("work_item_delta", "last_work_item_updated_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")]), }, } - diff --git a/backend/app/services/project_knowledge_graph_service/shared.py b/backend/app/services/project_knowledge_graph_service/shared.py index 79aa1734..36587b14 100644 --- a/backend/app/services/project_knowledge_graph_service/shared.py +++ b/backend/app/services/project_knowledge_graph_service/shared.py @@ -20,6 +20,7 @@ from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from ...models import ( + AIOrchestrationRun, IngestEvent, IngestOffset, KGEvidence, @@ -33,6 +34,7 @@ from ...models import ( SymbolReference, Task, User, + WorkItem, ) from ..context_cache_service import context_cache_service from ..signal.symbol_index import SymbolIndexService diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py index 00fd0fcc..ddd1b88a 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py @@ -254,18 +254,6 @@ class SignalCaptureEntryMixin: "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( self, *, diff --git a/backend/app/services/signal/signal_capture_service/shared.py b/backend/app/services/signal/signal_capture_service/shared.py index b0e3ee59..f8e8739b 100644 --- a/backend/app/services/signal/signal_capture_service/shared.py +++ b/backend/app/services/signal/signal_capture_service/shared.py @@ -41,6 +41,11 @@ from ...llm_service import LLMService from ...notification_service import NotificationService from ... import workload as workload_service 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 ...context_cache_service import context_cache_service 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) MENTION_TAG_RE = re.compile(r"]*>", re.IGNORECASE) 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__) AI_THINK_STAGE_ZH = { diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py b/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py index 6b6a157e..0c320721 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py @@ -159,6 +159,7 @@ class SignalSourceRegistryExecuteMixin: }, "analysis": analysis if isinstance(analysis, dict) else None, "priority": item["priority"], + "parent_signal_id": int(signal_id) if signal_id is not None else None, }, } normalized_source = self._normalize_source_payload( @@ -183,11 +184,12 @@ class SignalSourceRegistryExecuteMixin: }, "analysis": analysis if isinstance(analysis, dict) else None, "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( tenant_id=tenant_id, project_id=project_id, - signal_id=signal_id, + signal_id=None, operator_user_id=operator_user_id, requested_via=requested_via, source_payload=normalized_source, @@ -210,6 +212,142 @@ class SignalSourceRegistryExecuteMixin: ) 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( self, *, @@ -265,9 +403,23 @@ class SignalSourceRegistryExecuteMixin: self.db.add(row) await self.db.flush() 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 { "status": "created", "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_name": str(row.source_name or ""), "source_identifier": str(row.source_identifier or ""), @@ -290,7 +442,8 @@ class SignalSourceRegistryExecuteMixin: ).scalar_one_or_none() if existing is None: 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(): 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 @@ -306,11 +459,28 @@ class SignalSourceRegistryExecuteMixin: if not isinstance(existing.config, dict) or not existing.config: existing.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() await self.db.flush() return { "status": "reused", "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_name": str(existing.source_name or ""), "source_identifier": str(existing.source_identifier or ""), diff --git a/backend/app/services/signal/signal_source_orchestration/shared.py b/backend/app/services/signal/signal_source_orchestration/shared.py index 4af989f4..b2995bf0 100644 --- a/backend/app/services/signal/signal_source_orchestration/shared.py +++ b/backend/app/services/signal/signal_source_orchestration/shared.py @@ -23,6 +23,7 @@ from ....config.ai_prompts import ( from ....models import ( AIWorkerExecution, Project, + ProjectSignalLink, Signal, SignalSourceRegistry, User, diff --git a/backend/app/services/signal/signal_source_repair/mixin_data_access.py b/backend/app/services/signal/signal_source_repair/mixin_data_access.py index 2e0497fe..1e78f6b3 100644 --- a/backend/app/services/signal/signal_source_repair/mixin_data_access.py +++ b/backend/app/services/signal/signal_source_repair/mixin_data_access.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .shared import Optional, SignalSourceRegistry, User, and_, select +from .shared import Optional, ProjectSignalLink, Signal, SignalSourceRegistry, User, and_, select class SignalSourceRepairDataAccessMixin: @@ -37,7 +37,9 @@ class SignalSourceRepairDataAccessMixin: row: SignalSourceRegistry, ) -> Optional[int]: 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 candidates = ( await self.db.execute( @@ -54,17 +56,93 @@ class SignalSourceRepairDataAccessMixin: .limit(50) ) ).scalars().all() - for candidate in candidates: - current_key = self._normalize_identifier_key(candidate.source_identifier) - if current_key and current_key == target_key: - return self._safe_int(candidate.signal_id) - for candidate in candidates: - current_key = self._normalize_identifier_key(candidate.source_identifier) - if current_key and ( - current_key.endswith(target_key) - or target_key.endswith(current_key) - ): - return self._safe_int(candidate.signal_id) + + if target_key: + for candidate in candidates: + current_key = self._normalize_identifier_key(candidate.source_identifier) + if current_key and current_key == target_key: + return self._safe_int(candidate.signal_id) + for candidate in candidates: + current_key = self._normalize_identifier_key(candidate.source_identifier) + if current_key and ( + current_key.endswith(target_key) + 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 async def _active_user_ids(self, *, tenant_id: int) -> list[int]: diff --git a/backend/app/services/signal/signal_source_repair/mixin_persist.py b/backend/app/services/signal/signal_source_repair/mixin_persist.py index 1e2a20ee..6b1680ef 100644 --- a/backend/app/services/signal/signal_source_repair/mixin_persist.py +++ b/backend/app/services/signal/signal_source_repair/mixin_persist.py @@ -4,16 +4,139 @@ from __future__ import annotations from .shared import ( NotificationService, + ProjectSignalLink, + Signal, SignalSourceRegistry, + and_, get_audit_context, + json, log_operation, now_cn_naive, + select, ) class SignalSourceRepairPersistMixin: """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( self, *, @@ -78,6 +201,23 @@ class SignalSourceRepairPersistMixin: ) -> None: row.signal_id = int(signal_id) 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( tenant_id=tenant_id, project_id=project_id, diff --git a/backend/app/services/signal/signal_source_repair/mixin_repair.py b/backend/app/services/signal/signal_source_repair/mixin_repair.py index ab61c34e..846952b8 100644 --- a/backend/app/services/signal/signal_source_repair/mixin_repair.py +++ b/backend/app/services/signal/signal_source_repair/mixin_repair.py @@ -2,9 +2,6 @@ from __future__ import annotations -from .errors import SignalSourceRepairMissingSignalError -from .shared import ProactiveSignalCaptureService - class SignalSourceRepairFlowMixin: """Runs single/batch repair orchestration.""" @@ -12,12 +9,10 @@ class SignalSourceRepairFlowMixin: async def _repair_single_registry( self, *, - capture_service: ProactiveSignalCaptureService, tenant_id: int, project_id: int, registry_id: int, sender: str, - timeout_seconds: int, ) -> dict[str, object]: row = await self._load_registry( tenant_id=tenant_id, @@ -56,42 +51,14 @@ class SignalSourceRepairFlowMixin: route_status="", ) - capture_result = await self._capture_registry( - capture_service=capture_service, + _ = sender + signal = await self._materialize_registry_signal( tenant_id=tenant_id, project_id=project_id, - sender=sender, row=row, - stage="base", - timeout_seconds=timeout_seconds, ) - signal_id = self._extract_signal_id(capture_result) - if signal_id is None: - 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") + signal_id = int(signal.id) + capture_status = "materialized" await self._persist_registry_link( tenant_id=tenant_id, project_id=project_id, @@ -103,8 +70,8 @@ class SignalSourceRepairFlowMixin: row=row, signal_id=int(signal_id), capture_status=capture_status, - work_item_id=self._safe_int(capture_result.get("work_item_id")), - route_status=self._extract_route_status(capture_result), + work_item_id=None, + route_status="", ) async def repair_registries( @@ -115,18 +82,14 @@ class SignalSourceRepairFlowMixin: registry_ids: list[int], sender: str, ) -> list[dict[str, object]]: - capture_service = ProactiveSignalCaptureService(self.db) - timeout_seconds = self._resolve_timeout_seconds() results: list[dict[str, object]] = [] try: for registry_id in registry_ids: item = await self._repair_single_registry( - capture_service=capture_service, tenant_id=tenant_id, project_id=project_id, registry_id=int(registry_id), sender=sender, - timeout_seconds=timeout_seconds, ) results.append(item) await self.db.commit() diff --git a/backend/app/services/signal/signal_source_repair/service.py b/backend/app/services/signal/signal_source_repair/service.py index 899b92fe..4238570b 100644 --- a/backend/app/services/signal/signal_source_repair/service.py +++ b/backend/app/services/signal/signal_source_repair/service.py @@ -17,7 +17,7 @@ class SignalSourceRepairService( SignalSourceRepairPersistMixin, SignalSourceRepairFlowMixin, ): - """通过 capture-feishu 回填历史 registry.signal_id。""" + """修复历史 registry.signal_id:优先回填既有 Signal,不足时再物化正式 Signal。""" def __init__(self, db: AsyncSession) -> None: self.db = db diff --git a/backend/app/services/signal/signal_source_repair/shared.py b/backend/app/services/signal/signal_source_repair/shared.py index ad5eadc2..99a9b67f 100644 --- a/backend/app/services/signal/signal_source_repair/shared.py +++ b/backend/app/services/signal/signal_source_repair/shared.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import os import re from typing import Any, Optional @@ -10,7 +11,7 @@ from typing import Any, Optional from sqlalchemy import and_, select 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.timezone import now_cn_naive from ...notification_service import NotificationService @@ -22,12 +23,15 @@ __all__ = [ "AsyncSession", "NotificationService", "Optional", + "ProjectSignalLink", "ProactiveSignalCaptureService", + "Signal", "SignalSourceRegistry", "User", "and_", "asyncio", "get_audit_context", + "json", "log_operation", "now_cn_naive", "os", diff --git a/backend/app/services/sstc/charter_data.py b/backend/app/services/sstc/charter_data.py index e414343a..251cda79 100644 --- a/backend/app/services/sstc/charter_data.py +++ b/backend/app/services/sstc/charter_data.py @@ -71,6 +71,9 @@ def _build_charter_context_payload(charter: TruthNode) -> dict[str, Any]: modules = content.get("modules") if not isinstance(modules, dict): 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_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 {} @@ -158,6 +161,28 @@ def _build_charter_context_payload(charter: TruthNode) -> dict[str, Any]: max_items=5, 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, } diff --git a/backend/app/services/sstc/charter_prefill_builder.py b/backend/app/services/sstc/charter_prefill_builder.py index 814800d8..57d4c4c2 100644 --- a/backend/app/services/sstc/charter_prefill_builder.py +++ b/backend/app/services/sstc/charter_prefill_builder.py @@ -2,9 +2,180 @@ from __future__ import annotations 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 +_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( *, db: AsyncSession, @@ -21,39 +192,55 @@ async def _build_ai_first_charter_content( project_id=project_id, 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() - background = str(seed.get("background") or "").strip() - objectives = _to_string_list(seed.get("objectives")) - scope = str(seed.get("scope") or "").strip() - in_scope_items = _to_string_list(seed.get("in_scope")) - 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() + modules: dict[str, dict[str, str]] = {} + field_status: dict[str, dict[str, str]] = {} + field_evidence: dict[str, dict[str, list[dict[str, Any]]]] = {} + blocking_fields: list[str] = [] + blocking_reasons: list[str] = [] - if not summary: - summary = "目标用户在当前流程中存在效率损耗,需要通过 AI 驱动的流程优化提升交付质量与速度。" - if not background: - background = signal_context[:500] or "基于已绑定信号源自动生成。" - if not objectives: - objectives = ["缩短端到端交付周期", "提升关键路径质量稳定性", "降低跨角色协同成本"] - if not success_metrics: - success_metrics = ["需求到上线周期缩短 >= 30%", "关键任务按期交付率 >= 90%", "线上回滚率 <= 3%"] - if not in_scope_items: - in_scope_items = ["需求澄清与宪章生成自动化", "任务拆解与负责人推荐", "对齐状态自动追踪与留痕"] - if not out_of_scope_items: - out_of_scope_items = ["替代团队组织架构决策", "绕过人工否决机制自动上线", "非项目相关流程改造"] - if not risks: - risks = ["需求波动导致范围蔓延", "模型能力边界与输入复杂度不匹配", "关键成员负载过高导致延期"] - if not dependencies: - dependencies = ["FastAPI", "React", "LLM 推理服务", "项目知识库/日志系统"] - if not primary_model: - primary_model = "待 AI 评估后绑定" - if not fallback_model: - fallback_model = "同供应商低成本模型" + for module_key, field_keys in _MODULE_FIELD_KEYS.items(): + modules[module_key] = {} + field_status[module_key] = {} + field_evidence[module_key] = {} + module_seed = seed_modules.get(module_key) + if not isinstance(module_seed, dict): + module_seed = {} + + for field_key in field_keys: + raw_field = module_seed.get(field_key) + explicit_status = "" + if isinstance(raw_field, dict): + explicit_status = str(raw_field.get("status") or "").strip() + raw_value = raw_field.get("value") + if raw_value is None: + raw_value = raw_field.get("text") + else: + raw_value = raw_field + value = _stringify_field_value(raw_value) + 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] now_iso = now_cn_aware().isoformat() @@ -64,113 +251,74 @@ async def _build_ai_first_charter_content( validations_m5 = _default_ai_validations("模块5:风险与兜底") validations_m6 = _default_ai_validations("模块6:附件与治理") 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( - [ - f"核心用户与痛点:{summary}", - "上线时间与硬约束:由里程碑计划自动回填并持续校验", - f"明确不做项:{'; '.join(out_of_scope_items[:3])}", - f"关键依赖:{'; '.join(dependencies[:4])}", - f"核心风险与缓解:{'; '.join(risks[:3])}", - ] - ) + explicit_readiness = seed.get("readiness") if isinstance(seed.get("readiness"), dict) else {} + for item in explicit_readiness.get("blocking_fields") or []: + field = str(item or "").strip() + if field and field not in blocking_fields: + blocking_fields.append(field) + for item in explicit_readiness.get("blocking_reasons") or []: + reason = str(item or "").strip() + if reason and reason not in blocking_reasons: + blocking_reasons.append(reason) - modules = { - "module_1": { - "problem_statement": summary, - "goal_statement": _to_bullet_text(objectives), - "kpis": _to_bullet_text(success_metrics), - "ai_validations": validations_m1, - "escalation_trigger": escalation, - }, - "module_2": { - "in_scope": _to_bullet_text(in_scope_items), - "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为准。", - }, + required_total = sum(len(fields) for fields in _CHARTER_REQUIRED_FIELDS.values()) + valid_required = 0 + for module_key, fields in _CHARTER_REQUIRED_FIELDS.items(): + for field_key in fields: + if field_status.get(module_key, {}).get(field_key) == "valid": + valid_required += 1 + readiness = { + "ready_for_confirm": not blocking_fields and explicit_readiness.get("ready_for_confirm") is not False, + "blocking_fields": blocking_fields, + "blocking_reasons": blocking_reasons, } + 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 { "schema_version": "charter_ai_first_v1", "auto_generated_by_ai": True, "metadata": { "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, "evidence_refs": source_refs, - "escalation_reason": None, + "escalation_reason": blocking_reasons[0] if blocking_reasons else generation_error or None, }, "modules": modules, + "field_status": field_status, + "field_evidence": field_evidence, + "readiness": readiness, + "derived_artifacts": derived_artifacts, "signal_ids": signal_ids, "summary": summary, "background": background, @@ -185,4 +333,3 @@ async def _build_ai_first_charter_content( "risks": risks, "raw_source_text": raw_text.strip() or None, } - diff --git a/backend/app/services/sstc/charter_prefill_utils.py b/backend/app/services/sstc/charter_prefill_utils.py index 3dfdbbc0..d4451464 100644 --- a/backend/app/services/sstc/charter_prefill_utils.py +++ b/backend/app/services/sstc/charter_prefill_utils.py @@ -2,7 +2,7 @@ from __future__ import annotations 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: 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: modules = content.get("modules") + field_status = content.get("field_status") if not isinstance(modules, dict): return False + if not isinstance(field_status, dict): + return False for module_key, required_fields in _CHARTER_REQUIRED_FIELDS.items(): module_payload = modules.get(module_key) + status_payload = field_status.get(module_key) if not isinstance(module_payload, dict): return False + if not isinstance(status_payload, dict): + return False for field_key in required_fields: if not str(module_payload.get(field_key) or "").strip(): return False + if str(status_payload.get(field_key) or "").strip() != "valid": + return False return True def _merge_prefill_content( @@ -204,47 +212,6 @@ async def _generate_charter_seed( ) 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: ai_seed = await AIService().generate_charter_from_signal( signal_context, @@ -260,6 +227,6 @@ async def _generate_charter_seed( db=db, tenant_id=tenant_id, project_id=project_id, seed=ai_seed ) return ai_seed - return {} - except Exception: - return {} + return {"generation_error": "structured_charter_empty"} + except Exception as exc: + return {"generation_error": str(exc)} diff --git a/backend/app/utils/thread_context.py b/backend/app/utils/thread_context.py index 33f06035..f9d0a9d6 100644 --- a/backend/app/utils/thread_context.py +++ b/backend/app/utils/thread_context.py @@ -33,15 +33,15 @@ def build_feishu_thread_key( if 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) if not actor: actor = _normalize_token(sender).lower() if 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 "" diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 1ff08d08..86ca6c18 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -1,12 +1,15 @@ import pytest from types import SimpleNamespace +from app.config.ai_prompts import PROMPT_AI_PMO_QA from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnswerEntryMixin from app.services.adaptive_rag_service.mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin from app.services.adaptive_rag_service.mixin_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 ( 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 @@ -244,6 +247,84 @@ def test_query_type_classifies_colloquial_list_request_as_list() -> None: 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 async def test_deep_guard_answer_raises_on_empty_llm_response() -> None: class _FakeLLM: @@ -381,3 +462,113 @@ async def test_select_scene_prompt_applies_consultant_mode_preference_wrapper() assert scene_key == "default_consultant_advise" assert "PROMPT::PROMPT_RAG_ANSWER" 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 diff --git a/backend/tests/test_add_users_table_rbac_migration.py b/backend/tests/test_add_users_table_rbac_migration.py new file mode 100644 index 00000000..bc70a08b --- /dev/null +++ b/backend/tests/test_add_users_table_rbac_migration.py @@ -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 diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index 874c9c09..37a06870 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -7,6 +7,9 @@ from fastapi import HTTPException from app.api.ai_pmo_api import routes_core 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.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 ( 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: renderer = ReplyRenderer( 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 async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatch) -> None: class FakeAdaptiveRAGService: diff --git a/backend/tests/test_draft_generator_service.py b/backend/tests/test_draft_generator_service.py index 00706191..4bde9928 100644 --- a/backend/tests/test_draft_generator_service.py +++ b/backend/tests/test_draft_generator_service.py @@ -7,6 +7,7 @@ import pytest from app.config.ai_prompts import build_default_role_prompt_bundle 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.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 - return json.dumps( - { - "title": "内部知识库问答升级", - "summary": "围绕内部知识库问答能力升级", - "background": "背景说明", - "objectives": ["提升检索质量"], - "scope": "范围说明", - "success_metrics": ["命中率提升"], - "risks": ["重复建设风险"], - "dependencies": ["知识库索引"], - "primary_model": "gpt", - "fallback_model": "gpt-mini", - }, - ensure_ascii=False, + payload = { + "title": "内部知识库问答升级", + "summary": "围绕内部知识库问答能力升级", + "background": "背景说明", + "objectives": ["提升检索质量"], + "scope": "范围说明", + "success_metrics": ["命中率提升"], + "risks": ["重复建设风险"], + "dependencies": ["知识库索引"], + "primary_model": "gpt", + "fallback_model": "gpt-mini", + } + return StructuredExtractionResult( + 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_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( db=object(), @@ -267,27 +273,31 @@ async def test_generate_charter_from_signal_includes_project_context(monkeypatch service = AIService() captured: dict[str, object] = {} - async def fake_invoke(self, prompt, **kwargs): - _ = self + async def fake_generate_structured_object(prompt, **kwargs): captured["prompt"] = prompt captured["kwargs"] = kwargs - return json.dumps( - { - "title": "内部知识库问答升级", - "summary": "围绕内部知识库问答能力升级", - "background": "背景说明", - "objectives": ["提升检索质量"], - "scope": "范围说明", - "success_metrics": ["命中率提升"], - "risks": ["重复建设风险"], - "dependencies": ["知识库索引"], - "primary_model": "gpt", - "fallback_model": "gpt-mini", - }, - ensure_ascii=False, + payload = { + "title": "内部知识库问答升级", + "summary": "围绕内部知识库问答能力升级", + "background": "背景说明", + "objectives": ["提升检索质量"], + "scope": "范围说明", + "success_metrics": ["命中率提升"], + "risks": ["重复建设风险"], + "dependencies": ["知识库索引"], + "primary_model": "gpt", + "fallback_model": "gpt-mini", + } + return StructuredExtractionResult( + 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( "AI 劝退诉求:请升级内部知识库问答能力。", @@ -303,4 +313,116 @@ async def test_generate_charter_from_signal_includes_project_context(monkeypatch assert "当前项目上下文:企业知识库问答平台" in prompt assert "现有活跃项目(JSON)" in prompt assert kwargs.get("project_id") == 7 + assert kwargs.get("use_prompt_profile") is False 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"] == "梳理检索指标与验收口径" diff --git a/backend/tests/test_feishu_card_action_contract.py b/backend/tests/test_feishu_card_action_contract.py index f6c16495..5391ba12 100644 --- a/backend/tests/test_feishu_card_action_contract.py +++ b/backend/tests/test_feishu_card_action_contract.py @@ -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 -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( capture_result={ "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_name=None, ) - assert isinstance(card, dict) - 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} + assert card is None @pytest.mark.asyncio @@ -177,7 +170,7 @@ async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch @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] = {} class DummyDB: @@ -192,30 +185,9 @@ async def test_handle_card_action_trigger_queue_continue_dispatches_pipeline(mon async def _fake_reply_text(**kwargs): called["reply_text"] = kwargs.get("text") - called["reply_chat"] = kwargs.get("chat_id") 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( { "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["msg"] == "card_action_ignored" data = result.get("data") if isinstance(result.get("data"), dict) else {} - assert data.get("handled") is True - assert data.get("queue_item_id") == 201 - assert called["confirm_item"] == {"item_id": 201, "user_id": 77} - assert called["reply_text"] == "已继续处理:信号源注册#501 已接入项目#18。" - assert called["reply_chat"] == "oc_queue_1" + assert data.get("reason") == "missing_work_item_id" + assert "reply_text" not in called diff --git a/backend/tests/test_feishu_card_action_http_api.py b/backend/tests/test_feishu_card_action_http_api.py index 3a6f54d4..728b8f00 100644 --- a/backend/tests/test_feishu_card_action_http_api.py +++ b/backend/tests/test_feishu_card_action_http_api.py @@ -133,6 +133,50 @@ async def test_feishu_card_action_callback_maps_ignored_to_refresh_message(monke 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 async def test_feishu_card_action_callback_falls_back_when_signature_mismatch(monkeypatch) -> None: called: dict[str, object] = {} diff --git a/backend/tests/test_feishu_new_project_signal_sources.py b/backend/tests/test_feishu_new_project_signal_sources.py index a035209f..29476d00 100644 --- a/backend/tests/test_feishu_new_project_signal_sources.py +++ b/backend/tests/test_feishu_new_project_signal_sources.py @@ -149,5 +149,26 @@ async def test_auto_create_project_from_new_signal_persists_signal_sources(db_se ) ).scalars().all() 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].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 diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index 186f566b..fa02ac5b 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -1,3 +1,4 @@ +import inspect from types import SimpleNamespace 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 ( 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.service import ProactiveSignalCaptureService 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 +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: 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 +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 async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None: called = {"fallback": False} diff --git a/backend/tests/test_knowledge_graph_api_contract.py b/backend/tests/test_knowledge_graph_api_contract.py new file mode 100644 index 00000000..f864202d --- /dev/null +++ b/backend/tests/test_knowledge_graph_api_contract.py @@ -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 diff --git a/backend/tests/test_project_knowledge_graph_event_queue_contract.py b/backend/tests/test_project_knowledge_graph_event_queue_contract.py new file mode 100644 index 00000000..ee05f2c5 --- /dev/null +++ b/backend/tests/test_project_knowledge_graph_event_queue_contract.py @@ -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"] diff --git a/backend/tests/test_rag_route_contract.py b/backend/tests/test_rag_route_contract.py index 478d257a..03ab6274 100644 --- a/backend/tests/test_rag_route_contract.py +++ b/backend/tests/test_rag_route_contract.py @@ -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.rag_api import routes_query 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: @@ -262,6 +263,96 @@ def test_build_rag_response_payload_preserves_rag_fields() -> None: 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: payload = rag_ask_contract.build_rag_response_payload( ingress_result={ diff --git a/backend/tests/test_semantic_lint.py b/backend/tests/test_semantic_lint.py index a21e48de..a91e1d20 100644 --- a/backend/tests/test_semantic_lint.py +++ b/backend/tests/test_semantic_lint.py @@ -45,3 +45,24 @@ def test_semantic_lint_allows_workflow_semantic_planner_directory(tmp_path) -> N ) 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 == [] diff --git a/backend/tests/test_signal_source_repair_materialization.py b/backend/tests/test_signal_source_repair_materialization.py new file mode 100644 index 00000000..63e6036c --- /dev/null +++ b/backend/tests/test_signal_source_repair_materialization.py @@ -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 diff --git a/backend/tests/test_signal_source_requirement_materialization.py b/backend/tests/test_signal_source_requirement_materialization.py index 321e1eb8..dd0dc74f 100644 --- a/backend/tests/test_signal_source_requirement_materialization.py +++ b/backend/tests/test_signal_source_requirement_materialization.py @@ -7,20 +7,23 @@ import pytest_asyncio from sqlalchemy import JSON, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine -os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///:memory:" +os.environ["DATABASE_URL"] = "postgresql+asyncpg://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.truth_node import TruthNode 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 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: - if column.name in {"assigned_domains", "config", "raw_context"}: + if column.name in {"assigned_domains", "config", "raw_context", "content"}: column.type = JSON() @@ -36,7 +39,9 @@ async def db_session(): User.__table__, Project.__table__, Signal.__table__, + ProjectSignalLink.__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() 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["requirement_item"]["title"] == "内容本地化" 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"] == "银行流水导入宪章" diff --git a/backend/tests/test_sstc_charter_release_contract.py b/backend/tests/test_sstc_charter_release_contract.py new file mode 100644 index 00000000..99aa4899 --- /dev/null +++ b/backend/tests/test_sstc_charter_release_contract.py @@ -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() diff --git a/backend/tests/test_sstc_tasks_ai_breakdown_contract.py b/backend/tests/test_sstc_tasks_ai_breakdown_contract.py index 45fb7b1f..e54ac023 100644 --- a/backend/tests/test_sstc_tasks_ai_breakdown_contract.py +++ b/backend/tests/test_sstc_tasks_ai_breakdown_contract.py @@ -68,6 +68,58 @@ def test_task_breakdown_context_flattens_charter_modules() -> None: 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: prompt = _build_task_breakdown_prompt( project_id=6, diff --git a/backend/tests/test_workflow_semantic_planner.py b/backend/tests/test_workflow_semantic_planner.py index 0d7c55f9..4ed55218 100644 --- a/backend/tests/test_workflow_semantic_planner.py +++ b/backend/tests/test_workflow_semantic_planner.py @@ -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: assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("那就按这个继续执行") is True 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" diff --git a/frontend/src/components/sstc/SSTCCharterStagePanel.tsx b/frontend/src/components/sstc/SSTCCharterStagePanel.tsx index 73f2f0ee..82840b76 100644 --- a/frontend/src/components/sstc/SSTCCharterStagePanel.tsx +++ b/frontend/src/components/sstc/SSTCCharterStagePanel.tsx @@ -6,9 +6,17 @@ import type { SSTCTask, } from "@/api/sstc"; 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 { FIXED_ALIGNMENT_QA_QUESTIONS, + charterFieldStatusBadgeClass, + charterFieldStatusLabel, statusBadgeClass, } from "@/components/sstc/sstcUtils"; import { Badge } from "@/components/ui/badge"; @@ -96,7 +104,7 @@ interface SSTCCharterStagePanelProps { } >; }; - allModulesConfirmed: boolean; + charterReadiness: CharterReadiness; shouldShowCharterGenerating: boolean; serializedCharterContent: Record; sourceSignalOpen: boolean; @@ -115,9 +123,10 @@ interface SSTCCharterStagePanelProps { onSelectCharter: (charterId: string) => void; updateModuleField: (moduleKey: CharterModuleKey, fieldKey: string, value: string) => void; updateAlignmentQaAnswer: (answerIndex: number, value: string) => void; - confirmModule: (moduleKey: CharterModuleKey) => void; onStartAlignment: () => void; onConfirmAlignment: () => void; + getFieldStatus: (moduleKey: CharterModuleKey, fieldKey: string) => CharterFieldStatus; + getFieldEvidence: (moduleKey: CharterModuleKey, fieldKey: string) => CharterFieldEvidence[]; getFieldEditState: (moduleKey: CharterModuleKey, fieldKey: string) => ModuleFieldEditState; getModuleStepStatus: (moduleKey: CharterModuleKey) => string; updateCharterMutation: UpdateCharterMutationLike; @@ -138,7 +147,7 @@ export function SSTCCharterStagePanel({ activeModule, activeModuleIndex, charterProgress, - allModulesConfirmed, + charterReadiness, shouldShowCharterGenerating, serializedCharterContent, sourceSignalOpen, @@ -157,9 +166,10 @@ export function SSTCCharterStagePanel({ onSelectCharter, updateModuleField, updateAlignmentQaAnswer, - confirmModule, onStartAlignment, onConfirmAlignment, + getFieldStatus, + getFieldEvidence, getFieldEditState, getModuleStepStatus, updateCharterMutation, @@ -186,14 +196,13 @@ export function SSTCCharterStagePanel({ : null; const hasMultipleCharters = charters.length > 1; const confirmedModules = CHARTER_MODULE_SCHEMAS.filter( - (schema) => getModuleStepStatus(schema.key) === "已完成" + (schema) => charterProgress.moduleProgress[schema.key].done ).length; const filledModulePercent = Math.round( (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 activeModuleProgress = charterProgress.moduleProgress[activeModule.key]; const aiReferenceText = sourceSignals.length > 0 ? `参考了 ${sourceSignals @@ -204,6 +213,8 @@ export function SSTCCharterStagePanel({ const alignmentUserNameMap = new Map( 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) => { if (projectUsers.length === 0) return false; return ( @@ -322,7 +333,11 @@ export function SSTCCharterStagePanel({ size="sm" variant="ghost" 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} > {confirmAlignmentMutation.isPending ? "提交中..." : "确认对齐"} @@ -331,9 +346,19 @@ export function SSTCCharterStagePanel({ - {allModulesConfirmed && ( + {charterReadiness.ready_for_confirm && (
- 6 个模块已逐项确认,可提交“确认对齐”完成整体对齐。 + 宪章已通过确认前校验,可执行一次总确认并解锁任务阶段。 +
+ )} + {!charterReadiness.ready_for_confirm && charterReadiness.blocking_reasons.length > 0 && ( +
+

总确认已阻断,需先补齐以下问题:

+
+ {charterReadiness.blocking_reasons.map((reason, idx) => ( +

{reason}

+ ))} +
)} @@ -423,22 +448,9 @@ export function SSTCCharterStagePanel({

{activeModuleIndex + 1}/{CHARTER_MODULE_SCHEMAS.length}

- {!mobileReadOnly && ( - - )} + + {activeModuleStatus} + @@ -451,6 +463,10 @@ export function SSTCCharterStagePanel({ ) .map((field) => { 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 = state.aiGenerated && !state.edited ? "bg-[#F4F7FF]" : "bg-white"; return ( @@ -485,12 +501,18 @@ export function SSTCCharterStagePanel({ )}
+ + {charterFieldStatusLabel(fieldStatus)} +

{state.aiGenerated ? (state.edited ? "AI 草稿后已编辑" : "AI 已生成") : "人工输入"}

{state.aiGenerated && !state.edited && (

{aiReferenceText}

)} + {isBlockingField && ( +

该字段会阻止整份宪章确认

+ )}
{!mobileReadOnly && (
@@ -521,13 +543,46 @@ export function SSTCCharterStagePanel({
)}
+
+

字段级证据

+ {fieldEvidence.length > 0 ? ( +
+ {fieldEvidence.map((item, index) => { + const signal = item.signal_id ? sourceSignalMap.get(Number(item.signal_id)) : null; + return ( +
+

+ 信号 #{item.signal_id || "-"} · {signal?.title || "未命名信号"} +

+ {item.summary && ( +

摘要:{item.summary}

+ )} + {item.quote && ( +

摘录:{item.quote}

+ )} + {item.reason && ( +

引用原因:{item.reason}

+ )} +
+ ); + })} +
+ ) : ( +

暂无字段级证据,当前字段不会被视为完成。

+ )} +
); })} {activeModule.key === "module_2" && (
-

5个固定对齐问答

+
+

5个固定对齐问答

+ + {charterFieldStatusLabel(getFieldStatus("module_2", "alignment_qa"))} + +
{FIXED_ALIGNMENT_QA_QUESTIONS.slice(0, showAllAlignmentQa ? 5 : 2).map( (question, idx) => { @@ -547,7 +602,7 @@ export function SSTCCharterStagePanel({ /> ) : (

- {alignmentQaAnswers[idx] || "AI 待补全"} + {alignmentQaAnswers[idx] || "待补证据"}

)} {!mobileReadOnly && ( @@ -574,6 +629,28 @@ export function SSTCCharterStagePanel({ > {showAllAlignmentQa ? "▾ 收起剩余 3 个问答" : "▸ 展开剩余 3 个问答"} +
+

问答字段证据

+ {getFieldEvidence("module_2", "alignment_qa").length > 0 ? ( +
+ {getFieldEvidence("module_2", "alignment_qa").map((item, index) => { + const signal = item.signal_id ? sourceSignalMap.get(Number(item.signal_id)) : null; + return ( +
+

+ 信号 #{item.signal_id || "-"} · {signal?.title || "未命名信号"} +

+ {item.summary &&

摘要:{item.summary}

} + {item.quote &&

摘录:{item.quote}

} + {item.reason &&

引用原因:{item.reason}

} +
+ ); + })} +
+ ) : ( +

该字段暂无证据,存在 blocker 时将无法总确认。

+ )} +
)}
diff --git a/frontend/src/components/sstc/SSTCWorkspace.tsx b/frontend/src/components/sstc/SSTCWorkspace.tsx index 0fcb982d..c64591e7 100644 --- a/frontend/src/components/sstc/SSTCWorkspace.tsx +++ b/frontend/src/components/sstc/SSTCWorkspace.tsx @@ -52,7 +52,7 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) { activeModule: orchestration.activeModule, activeModuleIndex: orchestration.activeModuleIndex, charterProgress: orchestration.charterProgress, - allModulesConfirmed: orchestration.allModulesConfirmed, + charterReadiness: orchestration.charterReadiness, shouldShowCharterGenerating: orchestration.shouldShowCharterGenerating, serializedCharterContent: orchestration.serializedCharterContent, sourceSignalOpen: orchestration.sourceSignalOpen, @@ -73,9 +73,10 @@ export function SSTCWorkspace(props: SSTCWorkspaceViewProps) { onSelectCharter: props.onSelectCharter, updateModuleField: orchestration.updateModuleField, updateAlignmentQaAnswer: orchestration.updateAlignmentQaAnswer, - confirmModule: orchestration.confirmModule, onStartAlignment: orchestration.handleStartAlignment, onConfirmAlignment: orchestration.handleConfirmAlignment, + getFieldStatus: orchestration.getFieldStatus, + getFieldEvidence: orchestration.getFieldEvidence, getFieldEditState: orchestration.getFieldEditState, getModuleStepStatus: orchestration.getModuleStepStatus, updateCharterMutation: orchestration.updateCharterMutation, diff --git a/frontend/src/components/sstc/SignalContentHoverCard.tsx b/frontend/src/components/sstc/SignalContentHoverCard.tsx index 4b21c3af..c275d934 100644 --- a/frontend/src/components/sstc/SignalContentHoverCard.tsx +++ b/frontend/src/components/sstc/SignalContentHoverCard.tsx @@ -131,7 +131,7 @@ export function SignalContentHoverCard({ - + {detailTitle} diff --git a/frontend/src/components/sstc/charterModules.ts b/frontend/src/components/sstc/charterModules.ts index cda327e6..08134560 100644 --- a/frontend/src/components/sstc/charterModules.ts +++ b/frontend/src/components/sstc/charterModules.ts @@ -23,6 +23,28 @@ export interface CharterModuleSchema { 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 { metadata: { status: string; @@ -31,6 +53,9 @@ export interface CharterModuleDraft { evidence_refs: string; escalation_reason: string; }; + fieldStatus: Record>; + fieldEvidence: Record>; + readiness: CharterReadiness; modules: Record>; } @@ -40,6 +65,7 @@ export interface ModuleProgress { percent: number; done: boolean; missing: string[]; + blocked: string[]; } export interface CharterProgress { @@ -50,6 +76,37 @@ export interface CharterProgress { moduleProgress: Record; } +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[] = [ { key: "module_1", @@ -474,15 +531,49 @@ function ensureModuleMap(): Record> { function ensureModuleProgressMap(): Record { return { - module_1: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, - module_2: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, - module_3: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, - module_4: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, - module_5: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [] }, - module_6: { 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: [], blocked: [] }, + module_3: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] }, + module_4: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] }, + module_5: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] }, + module_6: { requiredTotal: 0, requiredDone: 0, percent: 0, done: false, missing: [], blocked: [] }, }; } +function ensureFieldStatusMap(): Record> { + const fieldStatus: Record> = { + 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> { + const fieldEvidence: Record> = { + 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, key: string, value: unknown): void { if ((target[key] || "").trim().length > 0) return; const text = toText(value).trim(); @@ -536,6 +627,13 @@ export function createEmptyCharterModuleDraft(): CharterModuleDraft { evidence_refs: "", escalation_reason: "", }, + fieldStatus: ensureFieldStatusMap(), + fieldEvidence: ensureFieldEvidenceMap(), + readiness: { + ready_for_confirm: false, + blocking_fields: [], + blocking_reasons: [], + }, modules, }; } @@ -544,6 +642,9 @@ export function parseCharterModuleDraft(content: Record | null const root = toRecord(content); const metadata = toRecord(root.metadata); 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(); draft.metadata.status = toText(metadata.status ?? root.status) || "draft"; @@ -551,12 +652,36 @@ export function parseCharterModuleDraft(content: Record | null 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.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) { const moduleRaw = toRecord(modulesRoot[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) { 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).signal_id)) + ? Number((item as Record).signal_id) + : null, + summary: toText((item as Record).summary), + quote: toText((item as Record).quote), + reason: toText((item as Record).reason), + confidence: Number.isFinite(Number((item as Record).confidence)) + ? Number((item as Record).confidence) + : null, + })) + : []; } } @@ -593,6 +718,12 @@ export function buildCharterContentPayload( } } 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 : []; 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) { const requiredFields = schema.fields.filter((field) => !!field.required); const missing: string[] = []; + const blocked: string[] = []; let done = 0; for (const field of requiredFields) { 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; } else { 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, done: isDone, missing, + blocked, }; requiredTotal += total; @@ -653,3 +791,7 @@ export function calculateCharterProgress(draft: CharterModuleDraft): CharterProg moduleProgress, }; } + +export function calculateCharterReadiness(draft: CharterModuleDraft): CharterReadiness { + return calculateDraftReadiness(draft); +} diff --git a/frontend/src/components/sstc/sstcUtils.ts b/frontend/src/components/sstc/sstcUtils.ts index 6043e702..9b446d6e 100644 --- a/frontend/src/components/sstc/sstcUtils.ts +++ b/frontend/src/components/sstc/sstcUtils.ts @@ -1,5 +1,5 @@ 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"; export interface SignalFilterState { @@ -203,12 +203,22 @@ export function buildAlignmentQaText(answers: string[]): string { }).join("\n\n"); } -export function buildFallbackAlignmentAnswers(draft: CharterModuleDraft): string[] { - return [ - firstLine(draft.modules.module_1.problem_statement), - firstLine(draft.modules.module_4.milestones), - firstLine(draft.modules.module_2.out_of_scope), - firstLine(draft.modules.module_1.kpis), - firstLine(draft.modules.module_5.risk_register), - ]; +export function charterFieldStatusLabel(status: CharterFieldStatus): string { + if (status === "valid") return "证据完备"; + if (status === "evidence_insufficient") return "证据不足"; + if (status === "generation_failed") return "生成失败"; + if (status === "generic_text") return "泛化文本"; + if (status === "conflict_blocked") return "冲突阻断"; + 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"; } diff --git a/frontend/src/components/sstc/useSSTCWorkspaceOrchestration.ts b/frontend/src/components/sstc/useSSTCWorkspaceOrchestration.ts index 65ffbb21..61b39e54 100644 --- a/frontend/src/components/sstc/useSSTCWorkspaceOrchestration.ts +++ b/frontend/src/components/sstc/useSSTCWorkspaceOrchestration.ts @@ -18,6 +18,7 @@ import { getApiErrorMessage } from "@/lib/apiError"; import { CHARTER_MODULE_SCHEMAS, buildCharterContentPayload, + calculateCharterReadiness, calculateCharterProgress, createEmptyCharterModuleDraft, parseCharterModuleDraft, @@ -25,7 +26,6 @@ import { } from "@/components/sstc/charterModules"; import { buildAlignmentQaText, - buildFallbackAlignmentAnswers, normalizeLane, normalizeReleaseStatus, parseAlignmentQaAnswers, @@ -192,6 +192,7 @@ export function useSSTCWorkspaceOrchestration({ ); const charterProgress = useMemo(() => calculateCharterProgress(charterModuleDraft), [charterModuleDraft]); + const charterReadiness = useMemo(() => calculateCharterReadiness(charterModuleDraft), [charterModuleDraft]); const serializedCharterContent = useMemo(() => { const payload = buildCharterContentPayload( @@ -215,24 +216,15 @@ export function useSSTCWorkspaceOrchestration({ [charterModuleDraft] ); - const requiredModuleKeys = useMemo( - () => CHARTER_MODULE_SCHEMAS.filter((schema) => schema.fields.some((field) => !!field.required)).map((schema) => schema.key), - [] - ); - const allModulesConfirmed = useMemo( - () => requiredModuleKeys.every((moduleKey) => !!moduleConfirmations[moduleKey]), - [moduleConfirmations, requiredModuleKeys] + () => charterReadiness.ready_for_confirm, + [charterReadiness.ready_for_confirm] ); useEffect(() => { setCharterDraftTitle(selectedCharter?.title || ""); const currentContent = (selectedCharter?.content as Record) || {}; 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); setCharterBaselineDraft(parsed); setModuleConfirmations(parseModuleConfirmationState(currentContent.module_confirmations)); @@ -626,40 +618,11 @@ export function useSSTCWorkspaceOrchestration({ }; const confirmModule = (moduleKey: CharterModuleKey) => { - if (!selectedCharter) return; - const progress = charterProgress.moduleProgress[moduleKey]; - if (!progress?.done) { - toast({ title: "请先补全当前模块的必填项,再执行确认", variant: "error" }); - 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 schema = CHARTER_MODULE_SCHEMAS.find((item) => item.key === moduleKey); + toast({ + title: `${schema?.shortTitle || "模块"}已切换为整份宪章总确认,请查看字段证据与阻断项后直接确认对齐`, + variant: "success", + }); }; const getFieldEditState = (moduleKey: CharterModuleKey, fieldKey: string) => { @@ -671,13 +634,13 @@ export function useSSTCWorkspaceOrchestration({ }; const getModuleStepStatus = (moduleKey: CharterModuleKey): string => { - if (moduleConfirmations[moduleKey]) return "已完成"; - if (moduleKey === activeModule.key) return "进行中"; const progress = charterProgress.moduleProgress[moduleKey]; const moduleContent = charterModuleDraft.modules[moduleKey]; const hasContent = Object.values(moduleContent).some((value) => String(value || "").trim().length > 0); 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 "进行中"; }; @@ -688,13 +651,20 @@ export function useSSTCWorkspaceOrchestration({ const handleConfirmAlignment = () => { if (!selectedCharter) return; - if (!allModulesConfirmed) { - toast({ title: "请先逐个确认 6 个模块,再提交整体对齐", variant: "error" }); + if (!charterReadiness.ready_for_confirm) { + const reason = charterReadiness.blocking_reasons[0] || "宪章仍存在阻断项,请先补齐证据"; + toast({ title: reason, variant: "error" }); return; } 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 { toast, stageLocked, @@ -736,6 +706,7 @@ export function useSSTCWorkspaceOrchestration({ charterProgress, serializedCharterContent, allModulesConfirmed, + charterReadiness, shouldShowCharterGenerating, rankedSignals, highValueSignalCount, @@ -786,6 +757,8 @@ export function useSSTCWorkspaceOrchestration({ alignmentQaAnswers, updateAlignmentQaAnswer, confirmModule, + getFieldStatus, + getFieldEvidence, getFieldEditState, getModuleStepStatus, }; diff --git a/scripts/semantic_lint_baseline.txt b/scripts/semantic_lint_baseline.txt index c6b78219..1774ec7d 100644 --- a/scripts/semantic_lint_baseline.txt +++ b/scripts/semantic_lint_baseline.txt @@ -1,15 +1,2 @@ # Approved compatibility exceptions for semantic-lint. # 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