diff --git a/.env.example b/.env.example index ee48bbf5..3bc71c66 100644 --- a/.env.example +++ b/.env.example @@ -26,32 +26,10 @@ LLM_ROUTING_DEFAULT_TIER=active LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true LLM_REQUEST_TIMEOUT=90 -# 模型名统一使用裸名,不带供应商前缀 -# GLM-4.7 配 GLM_API_KEY -# GLM-5(经 OpenAI 兼容网关)配 OPENAI_API_KEY -GLM_API_KEY= -GLM_BASE_URL=https://api.edgefn.net/v1 -GLM_MODEL=GLM-4.7 +# 模型默认值统一由 backend/.openclaw/pmflow-routing-rules.json 管理 +DASHSCOPE_API_KEY= OPENAI_API_KEY= -OPENAI_BASE_URL=https://api.edgefn.net/v1 -OPENAI_MODEL=GLM-5 - -LLM_ROUTING_LOW_PROVIDER=glm -LLM_ROUTING_LOW_MODEL=GLM-4.7 -LLM_ROUTING_HIGH_PROVIDER=openai -LLM_ROUTING_HIGH_MODEL=GLM-5 -LLM_ROUTING_SAFETY_PROVIDER=glm -LLM_ROUTING_SAFETY_MODEL=GLM-4.7 - DEEPSEEK_API_KEY= -DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 -DEEPSEEK_MODEL=deepseek-chat - -OLLAMA_HOST=http://host.docker.internal:11434 -OLLAMA_BASE_URL=http://host.docker.internal:11434 -OLLAMA_MODEL=llama3:latest -OLLAMA_CHAT_MODEL=llama3:8b -OLLAMA_EMBEDDING_MODEL=nomic-embed-text # Adaptive RAG 深入思考模式 RAG_DEEP_RETRIEVAL_STEPS=5 diff --git a/.tmp-feishu-escape.png b/.tmp-feishu-escape.png new file mode 100644 index 00000000..d8023776 Binary files /dev/null and b/.tmp-feishu-escape.png differ diff --git a/.tmp-feishu-menu.png b/.tmp-feishu-menu.png new file mode 100644 index 00000000..71eec338 Binary files /dev/null and b/.tmp-feishu-menu.png differ diff --git a/.tmp-feishu-newtab.png b/.tmp-feishu-newtab.png new file mode 100644 index 00000000..a293ccae Binary files /dev/null and b/.tmp-feishu-newtab.png differ diff --git a/.tmp-feishu-replymode.png b/.tmp-feishu-replymode.png new file mode 100644 index 00000000..3522a002 Binary files /dev/null and b/.tmp-feishu-replymode.png differ diff --git a/CLAUDE.md b/CLAUDE.md index 2ca5b438..cff49a11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ backend/ │ ├── schemas/ # Pydantic 模型 │ ├── services/ # 业务服务(AI/SSTC/集成) │ ├── config/ # 配置与 prompt -│ └── utils/ # 工具函数 +│ └── utils/ # 工具函数(timezone / audited_write / thread_context) ├── alembic/ # 数据库迁移 │ └── versions/ │ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段 @@ -56,7 +56,8 @@ backend/ │ ├── 20260311_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2) │ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表 │ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展 -│ └── 20260323_add_work_item_draft_confirmation_fields.py # work_items 草稿确认字段与索引 +│ ├── 20260323_add_work_item_draft_confirmation_fields.py # work_items 草稿确认字段与索引 +│ └── 20260324_add_project_create_idempotency_indexes.py # 项目创建幂等索引:活动项目标题大小写无关唯一 ├── scripts/ # 后端运维/数据修复脚本 │ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ ├── dev/ # 开发辅助脚本(check_users/seed_all) @@ -73,15 +74,22 @@ backend/ │ ├── test_adaptive_rag_mainline_contract.py │ ├── test_ai_pmo_execution_ledger_contract.py │ ├── test_feishu_card_action_contract.py -│ └── test_feishu_receive_orchestration_contract.py +│ ├── test_feishu_pipeline_queue_contract.py +│ ├── test_feishu_long_connection_tls_contract.py +│ ├── test_feishu_receive_orchestration_contract.py +│ ├── test_signal_planner_contract.py +│ ├── test_ai_prompt_registry_contract.py +│ └── test_sstc_tasks_ai_breakdown_contract.py ├── requirements.txt # Python 依赖 └── Dockerfile ``` - 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口) +- 新增:`backend/app/services/model/model_config.py`(模型 provider / tier 默认值的唯一配置读取器,读取 `backend/.openclaw/pmflow-routing-rules.json`) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) - 新增:`backend/app/core/startup/ai_pmo_ingress_async.py`(AI PMO 入站异步任务轮询启动循环) - 新增:`backend/app/utils/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`) - 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化) +- 新增:`backend/app/utils/thread_context.py`(飞书线程/消息锚点归一:优先 root/thread/reply_to,其次 source_message_id) - 新增:`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 过滤与序列化) @@ -93,6 +101,15 @@ backend/ - 新增:`backend/app/services/signal/draft_generator_service.py`(写操作草稿生成服务:草稿结构化产出 + 24h 过期) - 新增:`backend/app/services/ai_pmo/draft_confirm_handler.py`(草稿确认处理器:确认/修改/取消 + 幂等保护) - 新增:`backend/app/core/startup/draft_expiry_worker.py`(草稿超时取消后台扫描任务) +- 新增:`backend/app/core/startup/model_probe.py`(启动期模型连通性探针:优先走 DB 里的实际引擎配置,失败不污染 provider circuit) +- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环) +- 新增:`backend/app/config/ai_prompt_registry.py`(prompt registry / manifest,统一登记 prompt 场景、调用入口与契约) +- 更新:`backend/app/config/ai_prompts.py`(默认角色 Prompt 调整为“产品经理(立项评审)”,强调查重、MVP、前景与需求清单) +- 更新:`backend/app/config/ai_prompts_catalog/part_02.py`(`PROMPT_DRAFT_GENERATOR` 增加 active_projects / similar_projects / MVP / prospect / requirements 评审字段) +- 更新:`backend/app/api/tasks.py`(`/tasks/breakdown` 支持可选 `project_id/charter_id`,与 SSTC 宪章上下文和技能词表统一) +- 更新:`backend/app/schemas/common.py`(`TaskBreakdownRequest` 增加可选 `project_id/charter_id`) +- 更新:`backend/app/services/sstc/charter_data.py`(新增任务拆解上下文/技能词表/ prompt 拼装共享 helper) +- 更新:`backend/app/config/ai_prompts_catalog/part_03.py`(`PROMPT_DECOMPOSE_TASKS` 统一读取 `request/project/charter/modules` 结构化上下文) - 新增:`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 契约回归:意图识别、路由归一与执行器分派) @@ -103,6 +120,7 @@ backend/ - 新增:`backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`(AdaptiveRAG 基础问答主链编排:运行时准备 + 只读主路径执行) - 新增:`backend/tests/test_adaptive_rag_mainline_contract.py`(AdaptiveRAG 入口分层契约回归:read 路径绕过写确认 gate、write 路径命中确认 gate、feishu write 仍走主链) - 新增:`backend/tests/test_ai_pmo_execution_ledger_contract.py`(执行账本/动作参数来源契约回归:`action_contract` 归一、账本幂等 key、状态映射) +- 新增:`backend/tests/test_sstc_tasks_ai_breakdown_contract.py`(SSTC 手动 AI 拆任务契约回归:宪章上下文压缩、prompt 注入、技能词表透传) - 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(新增统一 `action_contract` 组装、`operation_execution_ledger` upsert、账本状态归一) - 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(自动执行与草稿待确认路径接入统一执行账本,日志 `operation_code` 对齐 `action_contract`) - 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py`(`waiting_manual` 路径接入统一执行账本与动作参数来源约束) @@ -110,7 +128,12 @@ backend/ - 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py`(飞书消息入口预处理:上下文抽取、空文本分支、附件标准化) - 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py`(飞书消息入口执行流:capture 持久化、异常回执、结果收尾) - 新增:`backend/tests/test_feishu_card_action_contract.py`(飞书卡片回调契约回归:动作解析与事件分发) +- 新增:`backend/tests/test_feishu_pipeline_queue_contract.py`(飞书待确认队列契约回归:action card 元数据与 follow-up 唯一目标约束) - 新增:`backend/tests/test_feishu_receive_orchestration_contract.py`(飞书入口编排契约回归:bot 过滤、空文本分支、capture/finalize 编排) +- 新增:`backend/tests/test_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/alembic/versions/20260324_add_project_create_idempotency_indexes.py`(活动项目标题大小写无关唯一索引,收口项目创建重复写入) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) @@ -188,6 +211,7 @@ backend/ - 新增:`backend/app/api/sstc_api/shared.py`(SSTC 路由层公共依赖导入) - 更新:`backend/app/api/sstc.py`(降级为兼容入口,仅导出聚合 `router`) - 新增:`backend/app/services/sstc/`(SSTC 业务辅助能力拆分:signal/charter/governance/entity_lookup/review) +- 新增:`backend/app/services/sstc/charter_payload.py`(轻量宪章上下文提取模块:供 Signal Planner / SSTC 共享,避免导入环) - 新增:`backend/app/services/llm_service/`(LLM 服务按职责拆分的 mixin 包,所有子文件 <= 300 行) - 更新:`backend/app/services/llm.py`(降级为兼容入口,导出 `LLMService` 与 `llm_service`) - 新增:`backend/app/services/signal/signal_capture_service/`(主动信号捕获服务按入口/编排/分析/新项目拆分) @@ -197,7 +221,7 @@ backend/ - 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分) - 更新:`backend/app/services/context_cache.py`(降级为兼容入口) - 新增:`backend/app/config/ai_prompts_catalog/`(Prompt 常量分片目录,按 `part_01..part_04` + `part_scenes_01..part_scenes_06` 管理) -- 更新:`backend/app/config/ai_prompts.py`(兼容聚合入口,保留 `build_default_role_prompt_bundle`) +- 更新:`backend/app/config/ai_prompts.py`(兼容聚合入口,保留 `build_default_role_prompt_bundle` 并显式导出 prompt registry helpers) - 新增:`backend/app/api/signals_api/`(signals 路由拆分为 list/create、ingest/fetch、feedback 子模块) - 新增:`backend/app/api/wecom_api/`(wecom 路由拆分为 config/webhook/messaging 子模块) - 新增:`backend/app/api/endpoints/skill_api/`(人员能力图谱路由拆分为 tags/bindings/workload/recommend 子模块) @@ -422,19 +446,22 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - low/high 双模型(`classify_intent_ai_pmo_kernel`、`classify_intent_complex`)与 `openclaw_command_dispatch` 必须遵循 DDL 接口化约束:禁止直连 SQL DDL,命中时强制输出 `ddl_api_required` - 双模型上下文注入仅允许通过 `ModelContextBundleService` 统一组包:固定三段 `knowledge_graph/task_template/contract_sync`,禁止在业务服务散落拼接 prompt 上下文 - AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态 -- 所有 LLM 调用禁止模型级兜底:禁止高阶升级、Safety 引擎、应急直连、stub 假成功;模型异常、超时或结构化输出不合法时必须显式报错 +- 所有 LLM 调用禁止静默兜底与 stub 假成功;低阶模型失败时必须优先强制升级到高阶 GLM-5 重试并保留低/高两段日志,高阶仍失败时才显式报错,禁止用固定兜底文案冒充成功结果 - 禁止模型兜底回复文案:模型失败、超时、空响应、结构化解析失败时,必须返回真实错误原因;禁止输出“系统处理中/请稍后重试”等固定文案冒充成功结果 +- AdaptiveRAG `adaptive_rag_answer_normal` 空响应必须显式抛错并交由高阶 GLM-5 failover 处理;禁止在生成层直接回落为“补充问题细节后重试”之类的兜底回复 - 用户可见问答链路(`function_name` 含 `answer/reply/qa`)禁止使用 `reasoning_content` 回退,避免思考过程外漏 -- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 外部趋势 > mixed > AI PMO 入口 > 默认 RAG +- AdaptiveRAG 场景化 Prompt 选择器必须在 `mixin_deep_guard_answer.py` 组装 Prompt 前执行,并遵循固定优先级:成员负载 > 趋势(项目关联 external_trend / 通用 industry_trend) > mixed > AI PMO 入口 > 默认 RAG +- 通用行业趋势问答(如“宠物行业最新动态”)不得注入数据库、项目历史、用户历史或外部实时搜索结果;只有显式带项目上下文时才允许走 `external_trend`,对“不要提项目/别提项目”这类否定语句不能误判为项目上下文 - mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`,禁止把需求段落并入问答段落 - 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot/glm_semantic 快路径,统一落到场景化 Prompt 生成,避免旧确定性清单回复覆盖场景格式 - 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启;未开启时运行期与连通测试都不可用 - AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿 - Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要 - AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径 -- AI PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库 -- 草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理 +- 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` 处理 - AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账 +- AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定 ## 9. 新增代码的参考模式 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 @@ -446,6 +473,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - LLM 引擎设置接口:参考 `backend/app/api/llm_engines.py`(`PUT /ai-steward/llm-engines/settings`,仅管理员可修改本地知识库问答开关) - 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检 - 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`) +- 模型配置唯一来源:参考 `backend/app/services/model/model_config.py`(统一读取 `backend/.openclaw/pmflow-routing-rules.json` 内的 `provider_defaults/tier_defaults/rules`) +- 模型配置热更新后,LLM provider circuit 会在下一次调用时自动清空旧熔断状态,避免旧配置失败污染新配置的连通性判断 +- 启动期模型探针只做连通性观测,不应把探针失败写回 provider circuit;否则会误伤真实业务请求 - AI Steward 指挥规划接口已移除:`POST /ai-steward/command/dispatch` - 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交) - 项目成员分配接口(`PUT /projects/{project_id}/members`):参考 `backend/app/api/projects.py` + `backend/app/services/project_member/service.py`,要求显式传入 `member_user_ids` 并写入 `user_projects` @@ -479,19 +509,23 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 ## 11. 已知 BOUNDARY(不要幻构行为) - 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用 +- 飞书长连接 websocket 在本地/CI 环境默认使用 `certifi` CA bundle 作为信任链;如需自定义 CA,可通过 `FEISHU_LONG_CONNECTION_SSL_CERT_FILE` 或 `SSL_CERT_FILE` 覆盖 +- 飞书长连接状态分为两层:`last_received_at/last_received_message_id` 表示 SDK 回调已收到事件,`last_event_at` 表示 worker 完成处理;排查“没吃进去”时必须优先看 `last_received_*` +- 飞书长连接会话应复用 `Integration.config` 中已有的 `encrypt_key/verification_token`;配置保存时若未显式修改这两个字段,必须保留现有值,避免 SDK 因缺失事件密钥而拒收消息 - LLM 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则(`backend/.openclaw/pmflow-routing-rules.json`);请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准 - LLM 非结构化响应仅在非用户可见链路且显式开启 `LLM_REASONING_CONTENT_FALLBACK` 时,才允许从 `reasoning_content` 回退;`answer/reply/qa` 链路一律禁用该回退 - World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s) - 写操作后主动 invalidate,动态层不缓存 - AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路 -- 环境变量以根目录 `.env` / `.env.example` 为唯一配置源;`backend/.env` 已移除且不再读取 +- AI PMO / Feishu 执行门已取消置信度阈值拦截与高风险人工复核:`confidence` 仅保留在 `decision_trace` / processing log 中作观测,不再作为自动执行的 reject 条件;当前门禁仅保留 `force_manual` 与 `correction_action` +- 模型默认值与 provider/tier 映射以 `backend/.openclaw/pmflow-routing-rules.json` 为唯一配置源;`.env` / `.env.example` 仅保留 API key 与运行开关,`OPENCLAW_ROUTING_RULES_PATH` 只用于定位这份文件 - Signal Planner 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时默认直连 GLM - Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)` - AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准 - 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行 - 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名 - 双 Agent 路由治理:低/高层函数均可由 OpenClaw 规则命中到指定 provider/model(含 `classify_intent_ai_pmo_kernel`) -- 双模型 DDL 治理图谱的模型来源优先读取显式模型配置;缺失时回退 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL` +- 双模型 DDL 治理图谱的模型来源统一读取 `backend/.openclaw/pmflow-routing-rules.json` 中的 `provider_defaults/tier_defaults`;不再依赖 `LLM_ROUTING_LOW_MODEL/LLM_ROUTING_HIGH_MODEL` 作为主配置 - 写操作链路统一产出 `decision_trace`(intent/tier/confidence/gate_code/correction_action)用于离线回放与分桶评分 - AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行 - 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作 @@ -503,9 +537,16 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败 - `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual` - 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试 -- 会话命中 `session.last_intent=draft_pending_confirm` 时,入站优先进入草稿确认处理器,不再走常规意图识别 +- 飞书会话与问答上下文键已改为线程/消息锚点优先:优先 `reply_to_message_id/root_message_id/thread_id/source_message_id`,避免 sender 级最近会话复用串线;草稿确认还需要回查 `feishu_reply_outbox.sent_message_id`,以便把“回复草稿消息”的线程锚点映射回对应 work_item +- 飞书回复线程锚点抽取同时兼容 event 顶层与 message/content 内字段,并对 event/message 的嵌套结构做递归兜底;若 Feishu SDK 返回的 reply 关系字段在更深层对象里,也必须纳入线程锚点,不可只读 `message.content` +- 飞书异步入站队列在 `ai_orchestration_runs.result_payload.task_payload` 中必须保留 `thread_context`,worker 重放时不得仅依赖 `source_message_id`,否则 reply-thread 会在异步链路丢锚点 +- 飞书纯问答直答不再注入群聊级 `project_id` 兜底上下文;只有显式 reply-thread / follow-up 线程锚点命中时,才允许继承上一轮语义上下文,禁止把群里最近项目当成普通新问题的默认场景 +- 成员/负载类问答(如“杨宇现在负载情况如何”)会优先走 `users + user_workloads` 的确定性直查;只有姓名/账号能唯一匹配时才直接返回数据库负载答案,不再把项目列表或历史上下文混入负载结论 +- 会话命中 `session.last_intent=draft_pending_confirm` 不再自动劫持普通消息;只有明确 `reply_to_message_id` 且消息本身是草稿动作,或显式 `work_item_id` 命中挂起草稿时,才进入草稿确认处理器 +- 飞书 follow-up 状态追问不再猜测“最近一条处理项”;只有显式 reply 锚点或唯一业务目标匹配时才允许继续查询,避免多个话题并行时串线 - 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新 - 飞书回复 outbox 已支持 `text + interactive card` 双通道:执行场景命中 `draft_pending_confirm/pending_manual` 时优先入队按钮卡片;卡片消息仍经 outbox 异步派发(不走业务路径同步直发) +- AI PMO 回执渲染器 `ReplyRenderer` 已按 `operation_code` 收口 CRUD 写回执:`project/task/personnel/knowledge/notification` 的成功态与待确认态都必须从 `execution_result.action_contract` / intent 参数生成可读文案,禁止再回落成泛化“已收到并入库”提示 - 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾 - `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入 - `/ai-pmo/process-text` 问答链路在 AdaptiveRAG 内通过 `asked_via=pmflow_manual` 识别为 AI PMO 入口;mixed 场景通过 `message_intent=mixed` 透传触发专用 Prompt 与两段输出格式化 @@ -519,6 +560,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - [x] AI 入口主链已统一:飞书长连接(`capture_from_feishu`)、`/ai-pmo/process-text` 与 `/rag/ask` 已统一收敛到 `AIPMOKernelService.process_ingress` 主链;`/rag/ask` 通过 `rag_ask_contract.build_kernel_ingress_kwargs` 透传 RAG 上下文(`session_id/new_session/thinking_mode/action_mode/...`),query 执行后由 `build_rag_response_payload` 保持 HTTP 响应兼容,回归由 `test_rag_route_contract.py`、`test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py` 锁定 - [x] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `mixin_receive_prepare.py` / capture 执行 `mixin_receive_capture_flow.py` / 编排 `mixin_receive.py`);`mixin_receive.py` 不再混合传输解析、附件标准化、capture 异常兜底与收尾回执逻辑,分层边界通过 `test_feishu_receive_orchestration_contract.py` 锁定 - [x] `task.create` 已升级为一等 typed intent:`IntentResolver/RouteResolver` 新增 `task.create` 常量与 `task.create` 路由族;TypedExecutor 已接入任务落库执行(含 `project_id`/任务标题槽位守卫、对齐 Charter 校验、`persist_task` 写入);`WorkflowRegistry`、`ReplyRenderer`、`followup` 校验与草稿确认闭环已纳入 `task.create`;并补 `test_ai_pmo_task_intent_contract.py` 回归锁定 +- [x] `task.create` 已接入消息级幂等账本:Feishu `source_message_id/feishu_message_id` 作为 `task.create` 账本 key,重复确认只回放同一结果,不再重复落库;回归由 `test_feishu_pipeline_queue_contract.py` 锁定 +- [x] `signal_source.onboard` 已接入消息级幂等账本:TypedExecutor 以 `source_system + source_message_id` 作为 onboarding ledger key,重复提交只回放同一结果,不再重复跑信号源接入编排;回归由 `test_ai_pmo_signal_source_onboard_contract.py` 锁定 - [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链) - [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` 已锁定回归 diff --git a/backend/.openclaw/pmflow-routing-rules.json b/backend/.openclaw/pmflow-routing-rules.json index cd365b48..bfd7fd56 100644 --- a/backend/.openclaw/pmflow-routing-rules.json +++ b/backend/.openclaw/pmflow-routing-rules.json @@ -1,8 +1,54 @@ { - "version": "v2", - "updated_at": "2026-03-11T09:10:00Z", + "version": "v3", + "updated_at": "2026-03-25T00:00:00Z", "source_of_truth": "openclaw", "description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。", + "provider_defaults": { + "deepseek": { + "name": "DeepSeek", + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat", + "api_env": "DEEPSEEK_API_KEY" + }, + "glm": { + "name": "Qwen (DashScope)", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "model": "qwen-plus", + "api_env": "DASHSCOPE_API_KEY" + }, + "openai": { + "name": "OpenAI", + "base_url": "https://api.edgefn.net/v1", + "model": "MiniMax-M2.5", + "api_env": "OPENAI_API_KEY" + }, + "ollama": { + "name": "Ollama", + "base_url": "http://127.0.0.1:11434", + "model": "llama3:latest", + "api_env": "" + } + }, + "tier_defaults": { + "low": { + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_env": "DASHSCOPE_API_KEY" + }, + "high": { + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_env": "OPENAI_API_KEY" + }, + "safety": { + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_env": "DASHSCOPE_API_KEY" + } + }, "rules": [ { "name": "engine_low_project_draft_glm47_fallback", @@ -10,8 +56,9 @@ "priority": 340, "tier": "low", "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", + "model": "qwen-plus", + "api_env": "DASHSCOPE_API_KEY", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "match_any": [ "project_draft_analysis_glm47_fallback", "project_draft_signal_glm47_fallback", @@ -35,8 +82,19 @@ "priority": 310, "tier": "low", "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", + "model": "qwen-plus", + "api_env": "DASHSCOPE_API_KEY", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "function_exact": "classify_intent_ai_pmo_kernel" + }, + { + "name": "engine_high_intent_kernel_exact", + "enabled": true, + "priority": 305, + "tier": "high", + "provider": "openai", + "model": "GLM-5", + "api_env": "OPENAI_API_KEY", "function_exact": "classify_intent_ai_pmo_kernel" }, { @@ -45,8 +103,9 @@ "priority": 300, "tier": "low", "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", + "model": "qwen-plus", + "api_env": "DASHSCOPE_API_KEY", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", "match_any": [ "feishu_level_classification", "feishu_signal_extraction", diff --git a/backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py b/backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py new file mode 100644 index 00000000..bf43e2d5 --- /dev/null +++ b/backend/alembic/versions/20260324_add_project_create_idempotency_indexes.py @@ -0,0 +1,83 @@ +"""add project create idempotency indexes + +Revision ID: add_project_create_idempotency_indexes +Revises: add_work_item_draft_confirmation_fields +Create Date: 2026-03-24 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_project_create_idempotency_indexes" +down_revision = "add_work_item_draft_confirmation_fields" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _duplicate_active_titles(bind) -> list[tuple[int, str, int]]: + rows = bind.execute( + sa.text( + """ + SELECT + tenant_id, + lower(coalesce(nullif(title, ''), nullif(name, ''))) AS normalized_title, + count(*) AS duplicate_count + FROM projects + WHERE status <> 'inactive' + AND coalesce(nullif(title, ''), nullif(name, '')) IS NOT NULL + GROUP BY tenant_id, lower(coalesce(nullif(title, ''), nullif(name, ''))) + HAVING count(*) > 1 + ORDER BY duplicate_count DESC, tenant_id ASC + LIMIT 10 + """ + ) + ).fetchall() + return [ + (int(row[0]), str(row[1] or ""), int(row[2])) + for row in rows + ] + + +def upgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "projects"): + return + + duplicates = _duplicate_active_titles(bind) + if duplicates: + sample = ", ".join( + f"tenant={tenant_id}, title={title}, count={count}" + for tenant_id, title, count in duplicates[:3] + ) + raise RuntimeError( + "cannot create uq_projects_tenant_active_title_ci because duplicate active project titles exist: " + + sample + ) + + op.execute( + sa.text( + """ + CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_tenant_active_title_ci + ON projects ( + tenant_id, + lower(coalesce(nullif(title, ''), nullif(name, ''))) + ) + WHERE status <> 'inactive' + AND coalesce(nullif(title, ''), nullif(name, '')) IS NOT NULL + """ + ) + ) + + +def downgrade() -> None: + bind = op.get_bind() + if not _table_exists(bind, "projects"): + return + op.execute(sa.text("DROP INDEX IF EXISTS uq_projects_tenant_active_title_ci")) diff --git a/backend/alembic/versions/20260325_add_feishu_reply_outbox_sent_message_id.py b/backend/alembic/versions/20260325_add_feishu_reply_outbox_sent_message_id.py new file mode 100644 index 00000000..1b3f10d4 --- /dev/null +++ b/backend/alembic/versions/20260325_add_feishu_reply_outbox_sent_message_id.py @@ -0,0 +1,40 @@ +"""add feishu reply outbox sent_message_id + +Revision ID: 20260325_add_feishu_reply_outbox_sent_message_id +Revises: f38d00f6fee4 +Create Date: 2026-03-25 10:00:00.000000 + +""" +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260325_add_feishu_reply_outbox_sent_message_id" +down_revision: Union[str, Sequence[str], None] = "f38d00f6fee4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "feishu_reply_outbox", + sa.Column("sent_message_id", sa.VARCHAR(length=128), nullable=True), + ) + op.create_index( + "ix_feishu_reply_outbox_sent_message_id", + "feishu_reply_outbox", + ["sent_message_id"], + unique=False, + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index("ix_feishu_reply_outbox_sent_message_id", table_name="feishu_reply_outbox") + op.drop_column("feishu_reply_outbox", "sent_message_id") diff --git a/backend/app/api/llm_providers.py b/backend/app/api/llm_providers.py index 9022510c..c6222c5f 100644 --- a/backend/app/api/llm_providers.py +++ b/backend/app/api/llm_providers.py @@ -2,7 +2,6 @@ from __future__ import annotations -import os from typing import Any from fastapi import APIRouter, Depends @@ -10,40 +9,39 @@ from fastapi import APIRouter, Depends from ..core.response import success_response from ..dependencies.auth import get_current_user from ..models import User +from ..services.model.model_config import get_provider_defaults, get_tier_defaults router = APIRouter(prefix="/ai-steward/llm", tags=["AI Steward"]) def _build_provider_payload() -> dict[str, Any]: - low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip() - high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip() - base_url = str(os.getenv("OPENAI_BASE_URL", "https://api.edgefn.net/v1") or "https://api.edgefn.net/v1").strip() - ollama_base_url = str( - os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")) or "http://127.0.0.1:11434" - ).strip() + provider_defaults = get_provider_defaults() + tier_defaults = get_tier_defaults() + low_tier = dict(tier_defaults.get("low") or {}) + high_tier = dict(tier_defaults.get("high") or {}) return { - "low": low_model, - "high": high_model, - "base_url": base_url, + "low": str(low_tier.get("model") or provider_defaults.get("glm", {}).get("model") or "qwen-plus").strip(), + "high": str(high_tier.get("model") or "GLM-5").strip(), + "base_url": str(high_tier.get("base_url") or provider_defaults.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1").strip(), "providers": { "glm": { - "model": str(os.getenv("GLM_MODEL", low_model) or low_model).strip(), - "base_url": str(os.getenv("GLM_BASE_URL", base_url) or base_url).strip(), - "api_env": "GLM_API_KEY", + "model": str(provider_defaults.get("glm", {}).get("model") or "qwen-plus").strip(), + "base_url": str(provider_defaults.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(), + "api_env": "DASHSCOPE_API_KEY", }, "openai": { - "model": str(os.getenv("OPENAI_MODEL", high_model) or high_model).strip(), - "base_url": base_url, + "model": str(provider_defaults.get("openai", {}).get("model") or "MiniMax-M2.5").strip(), + "base_url": str(provider_defaults.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1").strip(), "api_env": "OPENAI_API_KEY", }, "deepseek": { - "model": str(os.getenv("DEEPSEEK_MODEL", "deepseek-chat") or "deepseek-chat").strip(), - "base_url": str(os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") or "").strip(), + "model": str(provider_defaults.get("deepseek", {}).get("model") or "deepseek-chat").strip(), + "base_url": str(provider_defaults.get("deepseek", {}).get("base_url") or "https://api.deepseek.com/v1").strip(), "api_env": "DEEPSEEK_API_KEY", }, "ollama": { - "model": str(os.getenv("OLLAMA_MODEL", "llama3:latest") or "llama3:latest").strip(), - "base_url": ollama_base_url, + "model": str(provider_defaults.get("ollama", {}).get("model") or "llama3:latest").strip(), + "base_url": str(provider_defaults.get("ollama", {}).get("base_url") or "http://127.0.0.1:11434").strip(), "api_env": "", }, }, diff --git a/backend/app/api/projects_api/routes_mutation.py b/backend/app/api/projects_api/routes_mutation.py index 304d0a7a..064a6533 100644 --- a/backend/app/api/projects_api/routes_mutation.py +++ b/backend/app/api/projects_api/routes_mutation.py @@ -1,7 +1,7 @@ from __future__ import annotations from .shared import * -from .helpers import _cleanup_project_dependencies, _fetch_project_member_user_ids, _get_project_or_404 +from .helpers import _cleanup_project_dependencies, _fetch_project_member_user_ids, _get_project_or_404, _serialize_project_row from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectUpdateRequest @router.post("/") diff --git a/backend/app/api/sstc_api/tasks_ai_breakdown.py b/backend/app/api/sstc_api/tasks_ai_breakdown.py index 592485a1..7be1b9f8 100644 --- a/backend/app/api/sstc_api/tasks_ai_breakdown.py +++ b/backend/app/api/sstc_api/tasks_ai_breakdown.py @@ -4,9 +4,14 @@ import re from .shared import * +from ...services.sstc.charter_data import ( + _build_task_breakdown_context, + _build_task_breakdown_prompt, + _load_available_skill_names, +) from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project from ...services.sstc.governance import _assert_charter_aligned, _require_manager -from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async +from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async router = APIRouter(tags=["sstc"]) @@ -17,6 +22,7 @@ def _task_title_key(value: str) -> str: text = str(value or "").strip().lower() return _TITLE_NORMALIZE_RE.sub("", text) + @router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown") async def ai_breakdown_tasks( project_id: str, @@ -30,25 +36,32 @@ async def ai_breakdown_tasks( pid = _parse_project_id(project_id) cid = _parse_node_id(charter_id, "charter_id") tenant_id = int(current_user.tenant_id) - await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) + project = await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid) charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid) _assert_charter_aligned(charter) - prompt = f"""请基于以下需求宪章拆解 5-8 条任务,返回 JSON 数组: -[ - {{"task_name":"", "type":"backend/frontend/ai/devops", "estimated_hours": 4.0, "priority":"P0/P1/P2"}} -] - -宪章标题:{charter.title} -宪章内容:{_as_dict(charter.content)} -""" + charter_context = _build_task_breakdown_context( + description=str(charter.title or project.title or ""), + project=project, + charter=charter, + ) + available_skills = await _load_available_skill_names(db, tenant_id=tenant_id) + prompt = _build_task_breakdown_prompt( + project_id=pid, + charter_context=charter_context, + available_skills=available_skills, + ) try: - rows = await llm_service.generate_task_breakdown_async( + raw_text = await llm_service.generate( prompt, db=db, tenant_id=tenant_id, project_id=pid, + function_name="sstc_ai_breakdown", ) + rows = llm_service._parse_json_array(raw_text) + if not rows: + raise ValueError("AI 拆解返回空结果或无法解析为 JSON 数组") except RuntimeError as exc: raise HTTPException(status_code=403, detail=str(exc)) from exc except Exception as exc: @@ -73,7 +86,7 @@ async def ai_breakdown_tasks( created = [] skipped_duplicates = 0 prompt_tokens = max(len(prompt) // 4, 1) - completion_tokens = 0 + completion_tokens = max(len(raw_text) // 4, 0) for item in rows or []: title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32) if not title: @@ -108,7 +121,6 @@ async def ai_breakdown_tasks( refresh_workload=False, ) created.append(task) - completion_tokens += max(len(title) // 2, 1) db.add( AICallLog( diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 5ea31d76..0b69e944 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -11,6 +11,11 @@ from ..schemas import TaskBreakdownRequest, TaskBreakdownResponse from ..services.llm_service import llm_service from ..services.integrations.integration_core.plane_service import plane_service from ..services.skill import recommend_personnel +from ..services.sstc.charter_data import ( + _build_task_breakdown_prompt, + _load_available_skill_names, + _load_task_breakdown_context, +) router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -23,16 +28,38 @@ async def breakdown_tasks( ): """AI 任务拆解""" tenant_id = int(current_user.tenant_id) + context = await _load_task_breakdown_context( + db, + tenant_id=tenant_id, + description=request.description, + project_id=request.project_id, + charter_id=request.charter_id, + ) + if request.project_id is not None and not context.get("project"): + raise HTTPException(status_code=404, detail="Project not found") + if request.charter_id is not None and not context.get("charter"): + raise HTTPException(status_code=404, detail="Charter not found") + + available_skills = await _load_available_skill_names(db, tenant_id=tenant_id) + prompt = _build_task_breakdown_prompt( + project_id=request.project_id, + context=context, + available_skills=available_skills, + ) try: - tasks = await llm_service.generate_task_breakdown_async( - request.description, + raw_text = await llm_service.generate( + prompt, db=db, tenant_id=tenant_id, + function_name="generate_task_breakdown", ) + tasks = llm_service._parse_json_array(raw_text) + if not tasks: + raise ValueError("AI 拆解返回空结果或无法解析为 JSON 数组") except RuntimeError: raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)") - if not tasks: - raise HTTPException(status_code=500, detail="Failed to generate task breakdown") + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Failed to generate task breakdown: {exc}") sync_result = None if request.sync_to_plane: diff --git a/backend/app/config/ai_prompt_registry.py b/backend/app/config/ai_prompt_registry.py new file mode 100644 index 00000000..76202a37 --- /dev/null +++ b/backend/app/config/ai_prompt_registry.py @@ -0,0 +1,472 @@ +from __future__ import annotations + +from typing import Any + +from .ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS + + +def _entry( + prompt_key: str, + *, + module: str, + business_scenario: str, + input_contract: str, + output_contract: str, + call_sites: tuple[str, ...] = (), +) -> dict[str, Any]: + return { + "prompt_key": prompt_key, + "module": module, + "business_scenario": business_scenario, + "call_sites": tuple(str(site) for site in call_sites if str(site or "").strip()), + "input_contract": input_contract, + "output_contract": output_contract, + } + + +JSON_INPUT = "输入为结构化 JSON 上下文。" +JSON_OUTPUT = "输出严格 JSON 对象,不要 Markdown。" +JSON_LIST_OUTPUT = "输出严格 JSON 数组,不要 Markdown。" +QA_INPUT = "输入为问题文本 + 业务上下文。" +QA_OUTPUT = "输出自然语言答复。" +PLAN_INPUT = "输入为信号 / 项目 / 宪章 / 成员上下文。" +PLAN_OUTPUT = "输出可执行规划 JSON。" +SCORE_INPUT = "输入为对比对象 + 评分上下文。" +SCORE_OUTPUT = "输出评分或建议 JSON。" +FRAGMENT_INPUT = "输入为 prompt 片段占位内容。" +FRAGMENT_OUTPUT = "输出为可拼接的 prompt 片段。" + +DEEP_GUARD_CALL_SITES = ("backend/app/services/adaptive_rag_service/mixin_deep_guard_answer.py",) +AI_BUSINESS_TASKS_CALL_SITES = ("backend/app/services/ai_service_core/mixin_business_tasks.py",) +AI_BUSINESS_ANALYSIS_CALL_SITES = ("backend/app/services/ai_service_core/mixin_business_analysis.py",) +FEISHU_EXTRACT_CALL_SITES = ("backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py",) +FEISHU_REPLY_CALL_SITES = ("backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py",) +FEISHU_PIPELINE_CALL_SITES = ("backend/app/services/integrations/feishu_signal_pipeline/mixin_task_orchestration_main.py",) +FEISHU_SIGNAL_CLASSIFY_CALL_SITES = ("backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py",) +SIGNAL_SOURCE_CALL_SITES = ("backend/app/services/signal/signal_source_orchestration/mixin_registry_execute.py",) +DRAFT_GENERATOR_CALL_SITES = ("backend/app/services/signal/draft_generator_service.py",) +RAG_INTENT_CALL_SITES = ("backend/app/services/adaptive_rag_service/mixin_core_intent.py",) +RAG_DB_CALL_SITES = ("backend/app/services/adaptive_rag_service/mixin_fast_answer_semantic_p1.py",) +SIGNAL_PLANNER_CALL_SITES = ("backend/app/services/signal/signal_planner/mixin_prompt.py", "backend/app/services/signal/signal_planner/mixin_analyze.py") +SKILL_SCORE_CALL_SITES = ("backend/app/services/skill/skill_score_service.py",) +WORKLOAD_CALL_SITES = ("backend/app/services/workload/ai_optimize.py",) +RECIPE_CALL_SITES = ("backend/app/services/recipe/service.py",) +PMO_INTENT_CALL_SITES = ("backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py",) +PMO_BATCH_CALL_SITES = ("backend/app/services/ai_pmo/ai_pmo_typed_executor_discovery/mixin_analysis.py",) + + +def _part_01_entries() -> tuple[dict[str, Any], ...]: + return ( + _entry( + "PROMPT_IMPACT_ANALYSIS", + module="part_01", + business_scenario="需求变更影响分析", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含影响结论、风险、建议。", + ), + _entry( + "PROMPT_TASK_BREAKDOWN", + module="part_01", + business_scenario="任务拆解基础片段", + input_contract=FRAGMENT_INPUT, + output_contract=FRAGMENT_OUTPUT, + ), + _entry( + "PROMPT_STRATEGIC_ANALYSIS_HEADER", + module="part_01", + business_scenario="战略分析上下文片段", + input_contract=FRAGMENT_INPUT, + output_contract=FRAGMENT_OUTPUT, + ), + _entry( + "PROMPT_RAG_CONTEXT_HEADER", + module="part_01", + business_scenario="RAG 上下文 header 片段", + input_contract=FRAGMENT_INPUT, + output_contract=FRAGMENT_OUTPUT, + ), + _entry( + "PROMPT_RAG_CONTEXT_ITEM", + module="part_01", + business_scenario="RAG 上下文 item 片段", + input_contract=FRAGMENT_INPUT, + output_contract=FRAGMENT_OUTPUT, + ), + _entry( + "PROMPT_CODE_AUDIT", + module="part_01", + business_scenario="代码审计", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含问题、证据、风险与修复建议。", + ), + _entry( + "PROMPT_CONTEXT_INSIGHTS", + module="part_01", + business_scenario="上下文洞察", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含关键洞察与下一步建议。", + ), + _entry( + "PROMPT_FEISHU_REQUIREMENT_ANALYSIS", + module="part_01", + business_scenario="飞书需求解析", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含需求抽取、风险与建议。", + call_sites=FEISHU_EXTRACT_CALL_SITES, + ), + _entry( + "PROMPT_FEISHU_HUMANIZED_REPLY", + module="part_01", + business_scenario="飞书回复润色", + input_contract=QA_INPUT, + output_contract=QA_OUTPUT, + call_sites=FEISHU_REPLY_CALL_SITES, + ), + _entry( + "PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION", + module="part_01", + business_scenario="项目信号分类", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含分类、优先级与推荐动作。", + call_sites=FEISHU_SIGNAL_CLASSIFY_CALL_SITES, + ), + _entry( + "PROMPT_FEISHU_LEVEL_CLASSIFICATION", + module="part_01", + business_scenario="飞书消息层级分类", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含层级与判断原因。", + call_sites=FEISHU_EXTRACT_CALL_SITES, + ), + ) + + +def _part_02_entries() -> tuple[dict[str, Any], ...]: + return ( + _entry( + "PROMPT_FEISHU_SIGNAL_EXTRACTION", + module="part_02", + business_scenario="飞书信号结构化抽取", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含标题、摘要、优先级、匹配项目和建议动作。", + call_sites=FEISHU_EXTRACT_CALL_SITES, + ), + _entry( + "PROMPT_FEISHU_PROJECT_PREFILL", + module="part_02", + business_scenario="飞书项目预填", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含项目名、描述、负责人、时间线与置信度。", + call_sites=FEISHU_EXTRACT_CALL_SITES, + ), + _entry( + "PROMPT_FEISHU_TASK_ORCHESTRATION", + module="part_02", + business_scenario="飞书任务编排与负责人建议", + input_contract="输入 project_json / charter_json / signal_json / members_json。", + output_contract="输出 JSON 对象,包含任务列表、负责人建议与置信度。", + call_sites=FEISHU_PIPELINE_CALL_SITES, + ), + _entry( + "PROMPT_SIGNAL_SOURCE_ONBOARDING", + module="part_02", + business_scenario="信号源接入规划", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含是否接入、信号源信息、角色分工与执行指令。", + call_sites=SIGNAL_SOURCE_CALL_SITES, + ), + _entry( + "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", + module="part_02", + business_scenario="信号源接入 worker 执行", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含交付物、风险与下一步。", + call_sites=SIGNAL_SOURCE_CALL_SITES, + ), + _entry( + "PROMPT_AI_PMO_INTENT_CLASSIFICATION", + module="part_02", + business_scenario="AI PMO 意图识别", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含 family / action / intent / confidence。", + call_sites=PMO_INTENT_CALL_SITES, + ), + _entry( + "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", + module="part_02", + business_scenario="AI PMO 批量信号分析", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含 signals 数组。", + call_sites=PMO_BATCH_CALL_SITES, + ), + _entry( + "PROMPT_DRAFT_GENERATOR", + module="part_02", + business_scenario="立项草案生成", + input_contract="输入 request / project / active_projects / similar_projects / charter / members / recent_signals。", + output_contract="输出 JSON 对象,包含项目草案、重复建设判断与风险。", + call_sites=DRAFT_GENERATOR_CALL_SITES, + ), + _entry( + "PROMPT_RAG_INTENT_CLASSIFICATION", + module="part_02", + business_scenario="RAG 意图识别", + input_contract=QA_INPUT, + output_contract="输出 JSON 对象,包含 intent / confidence / reason。", + call_sites=RAG_INTENT_CALL_SITES, + ), + ) + + +def _part_03_entries() -> tuple[dict[str, Any], ...]: + return ( + _entry( + "PROMPT_RAG_ANSWER", + module="part_03", + business_scenario="RAG 问答生成", + input_contract=QA_INPUT, + output_contract=QA_OUTPUT, + call_sites=DEEP_GUARD_CALL_SITES, + ), + _entry( + "PROMPT_DECISION_QA", + module="part_03", + business_scenario="决策类问答", + input_contract=QA_INPUT, + output_contract="输出自然语言结论,必要时包含取舍理由。", + call_sites=DEEP_GUARD_CALL_SITES, + ), + _entry( + "PROMPT_RAG_DB_PROJECT_QA", + module="part_03", + business_scenario="项目知识库问答", + input_contract=JSON_INPUT, + output_contract=QA_OUTPUT, + call_sites=RAG_DB_CALL_SITES, + ), + _entry( + "PROMPT_GENERATE_CHARTER", + module="part_03", + business_scenario="宪章草案生成", + input_contract="输入 request / project / active_projects / similar_projects / signal_context。", + output_contract="输出 JSON 对象,包含项目宪章草案与风险。", + call_sites=("backend/app/services/ai_service_core/mixin_business_tasks.py", "backend/app/services/sstc/charter_prefill_utils.py"), + ), + _entry( + "PROMPT_DECOMPOSE_TASKS", + module="part_03", + business_scenario="任务拆解", + input_contract="输入 request / project / charter / modules 的结构化上下文。", + output_contract="输出 JSON 数组,包含任务名称、描述、工时、优先级和技能标签。", + call_sites=("backend/app/services/ai_service_core/mixin_business_tasks.py", "backend/app/api/tasks.py", "backend/app/api/sstc_api/tasks_ai_breakdown.py"), + ), + _entry( + "PROMPT_RECOMMEND_ASSIGNEES", + module="part_03", + business_scenario="负责人推荐", + input_contract=JSON_INPUT, + output_contract="输出 JSON 数组,包含候选人、匹配理由和负载信息。", + call_sites=AI_BUSINESS_TASKS_CALL_SITES, + ), + _entry( + "PROMPT_ALIGNMENT_SCORE", + module="part_03", + business_scenario="对齐度评分", + input_contract=SCORE_INPUT, + output_contract=SCORE_OUTPUT, + call_sites=AI_BUSINESS_ANALYSIS_CALL_SITES, + ), + _entry( + "PROMPT_COGNITIVE_CONFLICTS", + module="part_03", + business_scenario="认知冲突识别", + input_contract=SCORE_INPUT, + output_contract=SCORE_OUTPUT, + call_sites=AI_BUSINESS_ANALYSIS_CALL_SITES, + ), + _entry( + "PROMPT_EVALUATE_MODEL_REQUIREMENT", + module="part_03", + business_scenario="模型需求评估", + input_contract=SCORE_INPUT, + output_contract=SCORE_OUTPUT, + call_sites=AI_BUSINESS_ANALYSIS_CALL_SITES, + ), + _entry( + "PROMPT_ANALYZE_CAPABILITY_GAP", + module="part_03", + business_scenario="能力缺口分析", + input_contract=SCORE_INPUT, + output_contract=SCORE_OUTPUT, + call_sites=AI_BUSINESS_ANALYSIS_CALL_SITES, + ), + _entry( + "PROMPT_ANALYZE_COST_ROI", + module="part_03", + business_scenario="成本收益分析", + input_contract=SCORE_INPUT, + output_contract=SCORE_OUTPUT, + call_sites=AI_BUSINESS_ANALYSIS_CALL_SITES, + ), + ) + + +def _part_04_entries() -> tuple[dict[str, Any], ...]: + return ( + _entry( + "PROMPT_ANALYZE_COST_OPTIMIZATION", + module="part_04", + business_scenario="成本优化分析", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含降级、缓存、批处理与节省测算。", + call_sites=AI_BUSINESS_ANALYSIS_CALL_SITES, + ), + _entry( + "PROMPT_ASSESS_MODEL_UPDATE_IMPACT", + module="part_04", + business_scenario="模型升级影响评估", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含成本变化、兼容风险与建议。", + call_sites=("backend/app/services/model/model_capability/service.py",), + ), + _entry( + "PROMPT_EXPERIMENT_SYNC_DECISION", + module="part_04", + business_scenario="实验结果同步决策", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含置信度、是否自动同步与原因。", + call_sites=("backend/app/services/ai_pmo/ai_pmo_cadence/mixin_builders.py",), + ), + _entry( + "PROMPT_SKILL_SCORE_UPDATE", + module="part_04", + business_scenario="技能评分更新", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含技能增量列表。", + call_sites=SKILL_SCORE_CALL_SITES, + ), + _entry( + "PROMPT_WORKLOAD_OPTIMIZATION", + module="part_04", + business_scenario="任务负载优化", + input_contract=JSON_INPUT, + output_contract="输出 JSON 对象,包含重分配建议。", + call_sites=WORKLOAD_CALL_SITES, + ), + _entry( + "PROMPT_ANALYZE_FOOD_CALORIES", + module="part_04", + business_scenario="食物热量估算", + input_contract=QA_INPUT, + output_contract="输出 JSON 对象,包含食物名、热量、营养和不确定因素。", + call_sites=RECIPE_CALL_SITES, + ), + _entry( + "PROMPT_SIGNAL_PLANNER", + module="part_04", + business_scenario="信号规划 / 任务编排建议", + input_contract="输入 signal / project / charter_json / recent_signals / member_profiles。", + output_contract="输出 JSON 对象,包含 summary、priority、负责人建议、执行计划和风险。", + call_sites=SIGNAL_PLANNER_CALL_SITES, + ), + ) + + +def _scene_entries() -> tuple[dict[str, Any], ...]: + return ( + _entry( + "PROMPT_FEISHU_DIRECT_QA", + module="part_scenes_01", + business_scenario="飞书直接问答", + input_contract="输入 question / system_snapshot / user_snapshot / context_blocks。", + output_contract=QA_OUTPUT, + call_sites=DEEP_GUARD_CALL_SITES, + ), + _entry( + "PROMPT_FEISHU_MIXED_QA", + module="part_scenes_02", + business_scenario="飞书混合问答", + input_contract="输入 project_id / message / question_part / signal_part / system_snapshot / context_blocks。", + output_contract="输出先结论后入库说明的自然语言答复。", + call_sites=DEEP_GUARD_CALL_SITES, + ), + _entry( + "PROMPT_PROJECT_STATUS_QA", + module="part_scenes_03", + business_scenario="项目状态问答", + input_contract="输入 question / project_id / system_snapshot / user_snapshot / context_blocks。", + output_contract="输出项目状态结论、进度与下一步建议。", + call_sites=DEEP_GUARD_CALL_SITES, + ), + _entry( + "PROMPT_MEMBER_WORKLOAD_QA", + module="part_scenes_04", + business_scenario="成员负载问答", + input_contract="输入 question / system_snapshot / user_snapshot / context_blocks。", + output_contract="输出成员负载与推荐建议。", + call_sites=DEEP_GUARD_CALL_SITES, + ), + _entry( + "PROMPT_AI_PMO_QA", + module="part_scenes_05", + business_scenario="AI PMO 问答", + input_contract="输入 question / project_id / system_snapshot / user_snapshot / context_blocks。", + output_contract="输出 AI PMO 视角的结论与建议。", + call_sites=DEEP_GUARD_CALL_SITES, + ), + _entry( + "PROMPT_EXTERNAL_TREND_QA", + module="part_scenes_06", + business_scenario="外部趋势问答", + input_contract="输入 question / project_id / system_snapshot / user_snapshot / context_blocks。", + output_contract="输出行业/市场趋势判断与建议。", + call_sites=DEEP_GUARD_CALL_SITES, + ), + _entry( + "PROMPT_INDUSTRY_TREND_QA", + module="part_scenes_06", + business_scenario="行业趋势问答", + input_contract="输入 question / project_id / system_snapshot / user_snapshot / context_blocks。", + output_contract="输出行业趋势判断与建议。", + call_sites=DEEP_GUARD_CALL_SITES, + ), + ) + + +PROMPT_REGISTRY: tuple[dict[str, Any], ...] = ( + *_part_01_entries(), + *_part_02_entries(), + *_part_03_entries(), + *_part_04_entries(), + *_scene_entries(), +) + +PROMPT_REGISTRY_MAP: dict[str, dict[str, Any]] = { + entry["prompt_key"]: entry for entry in PROMPT_REGISTRY +} + + +def get_prompt_registry() -> list[dict[str, Any]]: + return [dict(entry) for entry in PROMPT_REGISTRY] + + +def get_prompt_registry_entry(prompt_key: str) -> dict[str, Any] | None: + key = str(prompt_key or "").strip() + if not key: + return None + entry = PROMPT_REGISTRY_MAP.get(key) + return dict(entry) if entry is not None else None + + +def iter_prompt_registry_keys() -> tuple[str, ...]: + return tuple(entry["prompt_key"] for entry in PROMPT_REGISTRY) + + +__all__ = [ + "PROMPT_CATALOG_EXPORTS", + "PROMPT_REGISTRY", + "PROMPT_REGISTRY_MAP", + "get_prompt_registry", + "get_prompt_registry_entry", + "iter_prompt_registry_keys", +] diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 686b037a..a67226c1 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -7,7 +7,60 @@ AI Prompt 集中管理 使用 .format() 或 f-string 时需保证占位符与调用处一致。 """ -from .ai_prompts_catalog import * +from .ai_prompts_catalog import ( + PROMPT_IMPACT_ANALYSIS, + PROMPT_TASK_BREAKDOWN, + PROMPT_STRATEGIC_ANALYSIS_HEADER, + PROMPT_RAG_CONTEXT_HEADER, + PROMPT_RAG_CONTEXT_ITEM, + PROMPT_CODE_AUDIT, + PROMPT_CONTEXT_INSIGHTS, + PROMPT_FEISHU_REQUIREMENT_ANALYSIS, + PROMPT_FEISHU_HUMANIZED_REPLY, + PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, + PROMPT_FEISHU_LEVEL_CLASSIFICATION, + PROMPT_FEISHU_SIGNAL_EXTRACTION, + PROMPT_FEISHU_PROJECT_PREFILL, + PROMPT_FEISHU_TASK_ORCHESTRATION, + PROMPT_SIGNAL_SOURCE_ONBOARDING, + PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, + PROMPT_AI_PMO_INTENT_CLASSIFICATION, + PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, + PROMPT_DRAFT_GENERATOR, + PROMPT_RAG_INTENT_CLASSIFICATION, + PROMPT_RAG_ANSWER, + PROMPT_DECISION_QA, + PROMPT_RAG_DB_PROJECT_QA, + PROMPT_GENERATE_CHARTER, + PROMPT_DECOMPOSE_TASKS, + PROMPT_RECOMMEND_ASSIGNEES, + PROMPT_ALIGNMENT_SCORE, + PROMPT_COGNITIVE_CONFLICTS, + PROMPT_EVALUATE_MODEL_REQUIREMENT, + PROMPT_ANALYZE_CAPABILITY_GAP, + PROMPT_ANALYZE_COST_ROI, + PROMPT_ANALYZE_COST_OPTIMIZATION, + PROMPT_ASSESS_MODEL_UPDATE_IMPACT, + PROMPT_EXPERIMENT_SYNC_DECISION, + PROMPT_SKILL_SCORE_UPDATE, + PROMPT_WORKLOAD_OPTIMIZATION, + PROMPT_ANALYZE_FOOD_CALORIES, + PROMPT_SIGNAL_PLANNER, + PROMPT_FEISHU_DIRECT_QA, + PROMPT_FEISHU_MIXED_QA, + PROMPT_PROJECT_STATUS_QA, + PROMPT_MEMBER_WORKLOAD_QA, + PROMPT_AI_PMO_QA, + PROMPT_EXTERNAL_TREND_QA, + PROMPT_INDUSTRY_TREND_QA, +) +from .ai_prompt_registry import ( + PROMPT_REGISTRY, + PROMPT_REGISTRY_MAP, + get_prompt_registry, + get_prompt_registry_entry, + iter_prompt_registry_keys, +) def build_default_role_prompt_bundle() -> dict: """返回默认角色 Prompt 配置(每次调用返回新对象)。""" @@ -17,25 +70,28 @@ def build_default_role_prompt_bundle() -> dict: "roles": [ { "role_key": "pm_dispatcher", - "role_name": "产品经理(任务分派)", - "role_description": "负责澄清需求并给出可执行任务分派建议", + "role_name": "产品经理(立项评审)", + "role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案", "enabled": True, "system_prompt": """ -你是 PMFlow 的任务分派产品经理。 +你是 PMFlow 的产品经理助手。 职责定位: -1. 先理解需求背景,再输出执行计划。 -2. 重点给出可落地的任务分派建议,避免空泛讨论。 -3. 回复风格要专业、直接、有人味,优先给结论。 +1. 先判断是否已有同名或相似项目,再给出是否复用、合并或新建的建议。 +2. 先输出产品评审结论,再展开项目定位、MVP、前景、需求清单、风险与负责人建议。 +3. 回复风格要专业、清晰、像真实产品经理评审,允许给出取舍与备选方案。 +4. 只有在用户明确确认后,才进入写入或继续执行流程。 输出要求: -1. 先用 1-2 句话复述需求并确认目标。 -2. 再给 3 条以内“下一步动作”,每条都要可执行。 -3. 若信息不足,只提关键澄清问题(最多 2 个),不要臆测。 -4. 如果涉及风险、依赖或排期冲突,要明确提醒。 +1. 先用 1-2 句话复述需求并判断立项方向。 +2. 再给出是否有同名/相似项目的判断。 +3. 再展开项目定位、MVP、前景、需求清单、风险与待补充信息。 +4. 如果信息不足,只提关键澄清问题(最多 3 个),不要臆测。 +5. 如果涉及风险、依赖、排期冲突或重复建设,要明确提醒。 禁止项: - 不要输出与项目无关的泛化建议。 +- 不要只给机械化模板,不要把评审写成简单入库文案。 - 不要使用命令式生硬语气。 - 不要编造不存在的数据或结论。 """.strip(), @@ -101,6 +157,7 @@ __all__ = [ "PROMPT_DRAFT_GENERATOR", "PROMPT_RAG_INTENT_CLASSIFICATION", "PROMPT_RAG_ANSWER", + "PROMPT_DECISION_QA", "PROMPT_RAG_DB_PROJECT_QA", "PROMPT_GENERATE_CHARTER", "PROMPT_DECOMPOSE_TASKS", @@ -123,5 +180,11 @@ __all__ = [ "PROMPT_MEMBER_WORKLOAD_QA", "PROMPT_AI_PMO_QA", "PROMPT_EXTERNAL_TREND_QA", + "PROMPT_INDUSTRY_TREND_QA", + "PROMPT_REGISTRY", + "PROMPT_REGISTRY_MAP", + "get_prompt_registry", + "get_prompt_registry_entry", + "iter_prompt_registry_keys", "build_default_role_prompt_bundle", ] diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 6daddc2b..3686b8a6 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -2,14 +2,14 @@ from __future__ import annotations from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_DRAFT_GENERATOR, PROMPT_RAG_INTENT_CLASSIFICATION -from .part_03 import PROMPT_RAG_ANSWER, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI +from .part_03 import PROMPT_RAG_ANSWER, PROMPT_DECISION_QA, PROMPT_RAG_DB_PROJECT_QA, PROMPT_GENERATE_CHARTER, PROMPT_DECOMPOSE_TASKS, PROMPT_RECOMMEND_ASSIGNEES, PROMPT_ALIGNMENT_SCORE, PROMPT_COGNITIVE_CONFLICTS, PROMPT_EVALUATE_MODEL_REQUIREMENT, PROMPT_ANALYZE_CAPABILITY_GAP, PROMPT_ANALYZE_COST_ROI from .part_04 import PROMPT_ANALYZE_COST_OPTIMIZATION, PROMPT_ASSESS_MODEL_UPDATE_IMPACT, PROMPT_EXPERIMENT_SYNC_DECISION, PROMPT_SKILL_SCORE_UPDATE, PROMPT_WORKLOAD_OPTIMIZATION, PROMPT_ANALYZE_FOOD_CALORIES, PROMPT_SIGNAL_PLANNER from .part_scenes_01 import PROMPT_FEISHU_DIRECT_QA from .part_scenes_02 import PROMPT_FEISHU_MIXED_QA from .part_scenes_03 import PROMPT_PROJECT_STATUS_QA from .part_scenes_04 import PROMPT_MEMBER_WORKLOAD_QA from .part_scenes_05 import PROMPT_AI_PMO_QA -from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA +from .part_scenes_06 import PROMPT_EXTERNAL_TREND_QA, PROMPT_INDUSTRY_TREND_QA __all__ = [ "PROMPT_IMPACT_ANALYSIS", @@ -33,6 +33,7 @@ __all__ = [ "PROMPT_DRAFT_GENERATOR", "PROMPT_RAG_INTENT_CLASSIFICATION", "PROMPT_RAG_ANSWER", + "PROMPT_DECISION_QA", "PROMPT_RAG_DB_PROJECT_QA", "PROMPT_GENERATE_CHARTER", "PROMPT_DECOMPOSE_TASKS", @@ -55,4 +56,5 @@ __all__ = [ "PROMPT_MEMBER_WORKLOAD_QA", "PROMPT_AI_PMO_QA", "PROMPT_EXTERNAL_TREND_QA", + "PROMPT_INDUSTRY_TREND_QA", ] diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py index 8efecd84..3796668d 100644 --- a/backend/app/config/ai_prompts_catalog/part_02.py +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -35,16 +35,18 @@ PROMPT_FEISHU_PROJECT_PREFILL = """ {{ "suggested_name": "项目名", "suggested_description": "1-2句描述", - "suggested_owner": "负责人名或角色", - "suggested_timeline": "截止日期或周期", + "suggested_owner": "负责人名或角色;不明确时返回 null", + "suggested_timeline": "截止日期或周期;不明确时返回 null", "confidence": 0.0-1.0, "reasoning": ["依据1","依据2","依据3"] }} 约束: 1. reasoning 必须是 3 条简短 bullet。 -2. 没有明确字段时可合理推断并在 reasoning 说明。 -3. 只输出 JSON。 +2. 只有在消息里明确出现负责人、角色、截止时间、周期时才填写对应字段。 +3. 不要为了“填满字段”去猜负责人或时间线;不明确时返回 null。 +4. confidence 低于 0.6 时,suggested_owner 和 suggested_timeline 优先返回 null。 +5. 只输出 JSON。 """ PROMPT_FEISHU_TASK_ORCHESTRATION = """ @@ -53,6 +55,7 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """ 输入: - project_json: {project_json} +- charter_json: {charter_json} - signal_json: {signal_json} - members_json: {members_json} @@ -74,11 +77,13 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """ }} 约束: -1. 任务数量 2-8 条,不要空数组。 -2. 优先覆盖“立即可执行”的任务,避免泛化管理话术。 -3. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。 -4. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。 -5. 只输出 JSON,不要 Markdown,不要解释。 +1. 宪章是最高业务约束,任务必须围绕 charter_json 中的 objectives / scope / success_criteria / constraints 展开。 +2. 如果 charter_json 为空,先依据 project_json 与 signal_json 做最小可执行拆解,不要自行编造宪章。 +3. 任务数量 2-8 条,不要空数组。 +4. 优先覆盖“立即可执行”的任务,避免泛化管理话术。 +5. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。 +6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。 +7. 只输出 JSON,不要 Markdown,不要解释。 """ PROMPT_SIGNAL_SOURCE_ONBOARDING = """ @@ -166,13 +171,41 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ 只输出一个 JSON 对象(不要解释、不要 Markdown): {{ - "family": "query|project_create|task|project_signal|signal_source", - "action": "answer|create|discover|onboard", - "intent": "query.read_only|project.create|task.create|signal.create|signal_source.onboard|followup.continue", + "family": "query|project_create|project|task|personnel|knowledge|notification|project_signal|signal_source|followup", + "action": "answer|create|update|archive|reopen|delete|assign|prioritize|status_transition|role_update|workload_update|availability_update|doc_upsert|chunk_upsert|link_upsert|reindex|ack|close|discover|onboard|continue|confirm", + "intent": "query.read_only|project.create|project.update|project.archive|project.reopen|project.delete|task.create|task.update|task.assign|task.prioritize|task.status_transition|task.delete|personnel.role_update|personnel.workload_update|personnel.availability_update|knowledge.doc_upsert|knowledge.chunk_upsert|knowledge.link_upsert|knowledge.reindex|notification.create|notification.ack|notification.close|signal.create|signal_source.onboard|followup.continue", "confidence": 0.0, "project_id": 0, "requested_count": 1, "requires_confirmation": false, + "parameters": { + "project_id": 0, + "project_name": "可选", + "task_id": 0, + "task_title": "可选", + "user_id": 0, + "user_name": "可选", + "notification_id": 0, + "doc_id": 0, + "doc_title": "可选", + "chunk_id": 0, + "scope": "tenant|project|document", + "title": "可选", + "description": "可选", + "status": "可选", + "priority": "可选", + "assignee_id": 0, + "role": "可选", + "max_weekly_hours": 0, + "content": "可选", + "chunk_text": "可选", + "source_type": "可选", + "source_id": 0, + "target_type": "可选", + "target_id": 0, + "url": "可选", + "path": "可选" + }, "reasoning": "一句话简述判定原因" }} @@ -180,22 +213,28 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ 1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。 2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。 3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。 -4. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。 -5. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。 -6. signal.create few-shot: +4. project.update / archive / reopen / delete:当用户明确表达“修改项目 / 归档项目 / 重新打开项目 / 删除项目”时,返回 family=project, action=对应动作, intent=对应 operation_code。 +5. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。 +6. task.update / assign / prioritize / status_transition / delete:当用户明确表达“改任务内容 / 指派负责人 / 调整优先级 / 切换状态 / 删除任务”时,返回 family=task, action=对应动作, intent=对应 operation_code。 +7. personnel.*:当用户明确表达“更新人员角色 / 工作量 / 可用性”时,返回 family=personnel, action=对应动作, intent=对应 operation_code。 +8. knowledge.*:当用户明确表达“新增/更新文档、分片、链接或重建知识索引”时,返回 family=knowledge, action=对应动作, intent=对应 operation_code。 +9. notification.*:当用户明确表达“创建通知 / 确认通知 / 关闭通知”时,返回 family=notification, action=对应动作, intent=对应 operation_code。 +10. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。 +11. signal.create few-shot: - “给XX项目加个需求” -> signal.create - “新增一个功能需求” -> signal.create - “帮我记录这个想法” -> signal.create - “提个需求:支持导出” -> signal.create -7. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 -8. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。 -9. 若 session_last_intent=draft_pending_confirm 且消息含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。 -10. 若 session_last_intent=draft_pending_confirm 且消息含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。 -11. 若 session_last_intent=draft_pending_confirm 且消息含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。 -12. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。 -13. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 -14. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 -15. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。 +12. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 +13. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。 +14. 仅当消息明确 reply_to 一条挂起草稿(reply_to_message_id 命中待确认草稿)且含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。 +15. 仅当消息明确 reply_to 一条挂起草稿且含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。 +16. 仅当消息明确 reply_to 一条挂起草稿且含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。 +17. 若没有 reply_to 挂起草稿锚点,不要因为确认词直接进入 followup.continue;无把握时返回 query.read_only。 +18. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 +19. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 +20. 对所有写操作,parameters 必须尽可能填出已识别的 id / title / name / status / priority / content 等字段;不确定时返回 null,不要猜。 +21. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 含 ddl_api_required。 """ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ @@ -231,10 +270,12 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ PROMPT_DRAFT_GENERATOR = """ 首字符必须是 {{,末字符必须是 }}。 你是 PMFlow 的资深产品经理。 -收到一条新的业务需求,请起草分析报告。 +收到一条新的业务需求,请先按产品经理视角做立项评审,再起草详细方案。 输入: -- 项目信息:{project_title} - {project_description} +- 当前上下文项目:{project_title} - {project_description} +- 现有活跃项目(JSON):{active_projects_json} +- 候选相似项目(JSON):{similar_project_candidates_json} - 团队成员(按负载排序):{members_json} - 最近同类需求:{recent_signals} - 用户需求:{user_message} @@ -243,7 +284,12 @@ PROMPT_DRAFT_GENERATOR = """ 请输出 JSON,包含: - title: 语义化标题(10字以内,必须概括目标,不得复述原话) -- summary: 需求摘要(50字以内) +- summary: 项目定位与需求摘要(80字以内) +- similar_projects: 同名/相似项目列表,元素包含 project_id、title、difference、risk、reuse_suggestion +- mvp_items: MVP 建议数组,3-5 条 +- prospect_assessment: 前景判断(1-3 句) +- requirement_items: 需求清单数组,3-8 条 +- missing_info: 还缺的信息数组,0-5 条 - feasibility: 合理|待讨论|不合理 - feasibility_reason: 判断理由(30字以内) - recommended_assignee_id: 推荐成员ID @@ -257,10 +303,12 @@ PROMPT_DRAFT_GENERATOR = """ - 明确的功能需求且技术可实现 => 合理 - 需求模糊、边界冲突或依赖不清 => 待讨论 - 明显不可行或超出系统范围 => 不合理 -4. 推荐负责人优先选择负载低于 80% 的成员。 -5. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。 -6. followup_plan 最少 1 项,最多 3 项。 -7. 若无法确定推荐人,recommended_assignee_id 返回 null。 +4. 如果存在同名或高度相似项目,必须写出差异与复用/合并建议。 +5. MVP 建议优先写“最小可验证闭环”,不要写成泛化愿景。 +6. 推荐负责人优先选择负载低于 80% 的成员。 +7. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。 +8. followup_plan 最少 1 项,最多 3 项。 +9. 若无法确定推荐人,recommended_assignee_id 返回 null。 """.strip() PROMPT_RAG_INTENT_CLASSIFICATION = """ diff --git a/backend/app/config/ai_prompts_catalog/part_03.py b/backend/app/config/ai_prompts_catalog/part_03.py index 6ae50fb4..97173e32 100644 --- a/backend/app/config/ai_prompts_catalog/part_03.py +++ b/backend/app/config/ai_prompts_catalog/part_03.py @@ -34,6 +34,19 @@ User Snapshot(当前用户): {context_blocks} """ +PROMPT_DECISION_QA = """ +你是 PMFlow 的产品顾问,负责处理产品取舍、优先级、二选一和方案比较类问题。 + +规则: +1. 直接给出明确选择,不要回避,不要反问。 +2. 如果用户给出两个或多个选项,必须明确选一个。 +3. 不依赖数据库、历史记录或外部实时数据,基于常识与办公协作场景给出最稳妥建议即可。 +4. 先给结论,再给 1-2 条理由,回答要简洁。 +5. 不输出思考过程,不要使用“让我/我来/我先/我需要”开头。 + +问题:{question} +""" + PROMPT_RAG_DB_PROJECT_QA = """ 你是 PMFlow 的项目数据语义检索助手。 你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。 @@ -61,10 +74,20 @@ PROMPT_RAG_DB_PROJECT_QA = """ """ PROMPT_GENERATE_CHARTER = """ -你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。 +首字符必须是 {{,末字符必须是 }}。 +你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。 -信号内容: -{signal_content} +输入: +- 当前项目上下文:{project_context} +- 现有活跃项目(JSON):{active_projects_json} +- 信号内容:{signal_content} + +请先判断这条需求更适合作为: +1. 当前项目的延展; +2. 现有活跃项目的复用/合并; +3. 必须新建项目。 + +如果存在同名或高度相似项目,必须在背景、范围和风险里明确写出差异点、复用点与合并建议。 请输出 JSON 格式,包含以下字段: {{ @@ -81,7 +104,7 @@ PROMPT_GENERATE_CHARTER = """ """ PROMPT_DECOMPOSE_TASKS = """ -你是一位 AI 技术项目经理。根据以下需求宪章内容,拆解为可执行的任务列表。 +你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。 项目 ID:{project_id}。 宪章内容: @@ -89,6 +112,12 @@ PROMPT_DECOMPOSE_TASKS = """ 请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。 +规则: +1. 如果宪章内容是结构化 JSON,请优先读取其中的 `request` / `project` / `charter` / `modules` 字段。 +2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。 +3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。 +4. 如果上下文同时给出项目与宪章信息,以宪章约束为主,项目信息用于补充边界。 + 输出 JSON 格式: {{ "tasks": [ diff --git a/backend/app/config/ai_prompts_catalog/part_04.py b/backend/app/config/ai_prompts_catalog/part_04.py index e951e42e..b89c62a4 100644 --- a/backend/app/config/ai_prompts_catalog/part_04.py +++ b/backend/app/config/ai_prompts_catalog/part_04.py @@ -164,6 +164,7 @@ PROMPT_SIGNAL_PLANNER = """ - 项目ID:{project_id} - 项目标题:{project_title} - 项目归属判定:{project_scope} +- 宪章内容(JSON):{charter_json} - 最近相关信号(JSON):{recent_signals} - 项目成员能力画像(JSON):{member_profiles} - 当前时间(北京时间):{now_cn} @@ -186,10 +187,12 @@ PROMPT_SIGNAL_PLANNER = """ }} 规则: -1. 若无法确定负责人,assignee_user_id 返回 null,且在 assignee_reason 中说明原因。 -2. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。 -3. confidence 范围 0-1,不能省略。 -4. 只输出 JSON,不要输出 Markdown 或解释文本。 +1. 宪章是最高业务约束。若 `charter_json` 非空,优先依据其中的 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 做规划。 +2. 若 `charter_json` 为空,则继续基于项目、近期信号与成员画像做最小可执行规划,不要编造不存在的宪章内容。 +3. 若无法确定负责人,assignee_user_id 返回 null,且在 assignee_reason 中说明原因。 +4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。 +5. confidence 范围 0-1,不能省略。 +6. 只输出 JSON,不要输出 Markdown 或解释文本。 """ __all__ = [ diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_01.py b/backend/app/config/ai_prompts_catalog/part_scenes_01.py index e4b3022a..940d9d04 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_01.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_01.py @@ -1,15 +1,29 @@ from __future__ import annotations -PROMPT_FEISHU_DIRECT_QA = """ -你是 PMFlow 的项目管理助手。 -用户通过飞书直接提问。 +FEISHU_QA_RESPONSE_STYLE = """ +回复风格要求: +1. 先给结论,再给关键依据,最后再给下一步。 +2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。 +3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。 +4. 若信息不足,必须明确说明缺少什么,最多列 3 项。 +5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。 +""".strip() -规则: -1. 只基于给定上下文回答 -2. 证据不足直接说"信息不足,缺少:XXX" -3. 不输出思考过程,直接给答案 -4. 回答简洁,适合飞书消息阅读(不超过200字) -5. 涉及项目数据优先用数字说话 +PROMPT_FEISHU_DIRECT_QA = "\n\n".join( + [ + """ +你是 PMFlow 的飞书项目管理顾问,负责直接问答。 +""".strip(), + FEISHU_QA_RESPONSE_STYLE, + """ +回答要求: +1. 仅基于给定上下文回答,优先使用结构化数据。 +2. 如果问题涉及项目状态、进展、成员或任务,请优先给出可验证的事实和数字。 +3. 如果问题涉及选择、建议或判断,直接给出明确结论和 1-2 条理由。 +4. 信息不足时,直接写“信息不足,缺少:...”并列出关键缺失项。 +5. 控制在 220 字以内,适合飞书消息阅读。 +6. 不输出 Markdown、JSON 或思考过程。 +7. 禁止以“让我先/我来看看/我需要先”等句式开头。 当前项目ID:{project_id} 问题:{question} @@ -17,6 +31,8 @@ System Snapshot:{system_snapshot} User Snapshot:{user_snapshot} 最近会话:{conversation} 检索上下文:{context_blocks} -""".strip() +""".strip(), + ] +) -__all__ = ["PROMPT_FEISHU_DIRECT_QA"] +__all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_02.py b/backend/app/config/ai_prompts_catalog/part_scenes_02.py index 75a83cbd..698f6ce9 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_02.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_02.py @@ -1,20 +1,23 @@ from __future__ import annotations -PROMPT_FEISHU_MIXED_QA = """ -你是 PMFlow 的项目管理助手。 -用户消息同时包含问题和需求。 +from .part_scenes_01 import FEISHU_QA_RESPONSE_STYLE +PROMPT_FEISHU_MIXED_QA = "\n\n".join( + [ + """ +你是 PMFlow 的飞书项目管理顾问,负责处理“问题 + 需求”混合消息。 +""".strip(), + FEISHU_QA_RESPONSE_STYLE, + """ 处理规则: -1. 先回答问题部分(简洁,不超过100字) -2. 确认已收到需求部分 -3. 格式: - [回答] xxx - [已收到需求] xxx,已入库处理 - -禁止: -- 输出思考过程 -- 把需求部分当问题回答 -- 合并两部分混着说 +1. 先回答问题部分,使用 1 句到 2 句的专业结论,最多 100 字。 +2. 再单独确认需求部分,格式必须是: + [回答] ... + [已收到需求] ...,已入库处理 +3. [已收到需求] 里的内容要像项目经理确认记录,简洁、明确、可执行,不要空话。 +4. 若问题部分信息不足,先写缺失项,再给最稳妥的结论。 +5. 不要把需求部分当问题回答,也不要混成一段。 +6. 不输出 Markdown、JSON 或思考过程。 当前项目ID:{project_id} 消息:{message} @@ -22,6 +25,8 @@ PROMPT_FEISHU_MIXED_QA = """ 需求部分:{signal_part} System Snapshot:{system_snapshot} 检索上下文:{context_blocks} -""".strip() +""".strip(), + ] +) __all__ = ["PROMPT_FEISHU_MIXED_QA"] diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_03.py b/backend/app/config/ai_prompts_catalog/part_scenes_03.py index 78977a30..e8fdcc33 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_03.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_03.py @@ -1,17 +1,20 @@ from __future__ import annotations PROMPT_PROJECT_STATUS_QA = """ -你是 PMFlow 的项目状态语义检索助手。 -用户关注项目进度、状态、完成率。 +你是 PMFlow 的项目状态分析助手。 +用户关注项目进度、状态、完成率、阶段和卡点。 规则: -1. 只基于给定上下文回答 -2. 禁止输出思考过程 -3. 用数字量化回答: - 完成率X%,已完成X个任务,进行中X个,待办X个 -4. 涉及多个项目时用列表格式 -5. 证据不足直接说: +1. 只基于给定上下文回答,不要编造项目现状。 +2. 先给当前结论,再给量化进展,再给卡点或下一步。 +3. 优先用数字回答: + 完成率X%,已完成X个任务,进行中X个,待办X个。 +4. 如果能识别出当前阶段,请明确写出“项目阶段”。 +5. 如果上下文里有负责人或关键角色,请补充“当前负责人”。 +6. 涉及多个项目时,用列表分别说明。 +7. 证据不足时直接写: 信息不足,缺少:XXX +8. 禁止输出思考过程、系统实现细节或空泛套话。 当前项目ID:{project_id} 问题:{question} diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_04.py b/backend/app/config/ai_prompts_catalog/part_scenes_04.py index 43f22ad5..f7eede9d 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_04.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_04.py @@ -8,9 +8,10 @@ PROMPT_MEMBER_WORKLOAD_QA = """ 1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据 2. 禁止默认返回项目列表 3. 涉及负载用百分比表示 -4. 推荐负责人时说明推荐理由 -5. 禁止输出思考过程 -6. 不超过150字 +4. 忽略与成员负载无关的项目列表和历史项目详情 +5. 推荐负责人时说明推荐理由 +6. 禁止输出思考过程 +7. 不超过150字 问题:{question} System Snapshot:{system_snapshot} diff --git a/backend/app/config/ai_prompts_catalog/part_scenes_06.py b/backend/app/config/ai_prompts_catalog/part_scenes_06.py index 8b7a1d07..108d6e68 100644 --- a/backend/app/config/ai_prompts_catalog/part_scenes_06.py +++ b/backend/app/config/ai_prompts_catalog/part_scenes_06.py @@ -7,7 +7,7 @@ PROMPT_EXTERNAL_TREND_QA = """ 规则: 1. 若有外部搜索结果优先使用 2. 若无外部数据明确说: - "当前未接入实时数据,以下基于训练数据(截止XXXX年)" + "当前未接入实时数据,以下基于通用行业认知。" 3. 结合项目实际给出应用建议: "对你们[项目名]的影响是..." 4. 禁止输出思考过程 @@ -22,4 +22,19 @@ PROMPT_EXTERNAL_TREND_QA = """ 外部搜索结果:{external_results} """.strip() -__all__ = ["PROMPT_EXTERNAL_TREND_QA"] +PROMPT_INDUSTRY_TREND_QA = """ +你是 PMFlow 的行业趋势助手。 +用户在询问某个行业、市场或产品方向的通用趋势判断。 + +规则: +1. 不要使用数据库、项目历史、用户历史或外部实时搜索结果。 +2. 如果用户问“最新”,必须明确说明当前未接入实时数据,只能基于通用认知给出趋势判断。 +3. 不要把回答强行绑定到某个项目。 +4. 先给结论,再给 2 条以内理由与建议。 +5. 控制总长度不超过 500 字,回答要适合飞书消息阅读。 +6. 不要输出思考过程,不要使用“让我/我来/我先/我需要”开头。 + +问题:{question} +""".strip() + +__all__ = ["PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA"] diff --git a/backend/app/core/startup/model_probe.py b/backend/app/core/startup/model_probe.py index a7a9c482..277507a0 100644 --- a/backend/app/core/startup/model_probe.py +++ b/backend/app/core/startup/model_probe.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio -import os import time +from copy import deepcopy from ..logging import get_logger +from ...core.database import AsyncSessionLocal +from ...services.model.model_config import build_tier_runtime logger = get_logger(__name__) @@ -18,11 +20,12 @@ def startup_log(message: str, **fields) -> None: async def probe_model_connectivity_on_startup() -> None: from ...services.llm_service import llm_service - low_provider = str(os.getenv("LLM_ROUTING_LOW_PROVIDER", os.getenv("LLM_PROVIDER", "glm")) or "").strip() or "glm" - low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7" - - high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai" - high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5" + low_runtime = build_tier_runtime("low") + high_runtime = build_tier_runtime("high") + low_provider = str(low_runtime.get("provider") or "glm").strip() or "glm" + low_model = str(low_runtime.get("model") or "qwen-plus").strip() or "qwen-plus" + high_provider = str(high_runtime.get("provider") or "openai").strip() or "openai" + high_model = str(high_runtime.get("model") or "GLM-5").strip() or "GLM-5" probe_targets: list[tuple[str, str, str]] = [ ("LOW", low_provider, low_model), @@ -44,8 +47,33 @@ async def probe_model_connectivity_on_startup() -> None: for level, provider, model in deduped_targets: try: - runtime = llm_service._env_provider_runtime(provider) - runtime["model"] = model + runtime = None + async with AsyncSessionLocal() as db: + try: + resolved_function_name = ( + "classify_intent_ai_pmo_kernel" if level == "LOW" else "classify_intent_complex" + ) + runtime = await llm_service._resolve_engine_for_call( + db, + tenant_id=1, + function_name=resolved_function_name, + ) + except Exception as resolve_exc: + logger.warning( + "startup_model_probe_runtime_resolve_failed", + level=level, + provider=provider, + model=model, + error=str(resolve_exc), + ) + if not isinstance(runtime, dict) or not runtime: + runtime = llm_service._env_provider_runtime(provider) + if level == "LOW": + runtime["model"] = low_model + runtime["base_url"] = str(low_runtime.get("base_url") or runtime.get("base_url") or "") + elif level == "HIGH": + runtime["model"] = high_model + runtime["base_url"] = str(high_runtime.get("base_url") or runtime.get("base_url") or "") except Exception as exc: logger.error( "startup_model_probe_runtime_invalid", @@ -60,6 +88,8 @@ async def probe_model_connectivity_on_startup() -> None: started_at = time.perf_counter() ok = False error_text = "" + circuit_cls = type(llm_service) + saved_circuit_state = deepcopy(getattr(circuit_cls, "_provider_circuit_state", {})) try: result = await asyncio.wait_for( llm_service._chat_completion( @@ -85,9 +115,11 @@ async def probe_model_connectivity_on_startup() -> None: "startup_model_probe_failed", level=level, provider=provider, - model=model, - error=error_text, - ) + model=model, + error=error_text, + ) + finally: + circuit_cls._provider_circuit_state = saved_circuit_state elapsed = time.perf_counter() - started_at if ok: diff --git a/backend/app/models/feishu_reply_outbox.py b/backend/app/models/feishu_reply_outbox.py index 064b5ec1..3473e7ac 100644 --- a/backend/app/models/feishu_reply_outbox.py +++ b/backend/app/models/feishu_reply_outbox.py @@ -16,6 +16,7 @@ class FeishuReplyOutbox(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) source_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) + sent_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) chat_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) message_text: Mapped[str] = mapped_column(TEXT, nullable=False) reply_hash: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True) diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py index 86f8f438..294d588d 100644 --- a/backend/app/schemas/common.py +++ b/backend/app/schemas/common.py @@ -16,6 +16,8 @@ class TaskBreakdownRequest(BaseModel): """任务拆解请求""" description: str + project_id: Optional[int] = None + charter_id: Optional[int] = None sync_to_plane: bool = False diff --git a/backend/app/schemas/world_model.py b/backend/app/schemas/world_model.py index f002aa23..09893898 100644 --- a/backend/app/schemas/world_model.py +++ b/backend/app/schemas/world_model.py @@ -91,6 +91,15 @@ class RecentSignalItem(TypedDict): signal_type: str +class ActiveProjectSnapshot(TypedDict): + id: int + name: str + title: str + description: str + status: str + owner_id: Optional[int] + + class DecisionContextSnapshot(TypedDict): captured_at: str project_id: Optional[int] @@ -116,6 +125,7 @@ class WorldModel(TypedDict): dependencies: DependencySummary recent_signals: list[RecentSignalItem] decision_context_snapshot: DecisionContextSnapshot + active_projects: NotRequired[list[ActiveProjectSnapshot]] __all__ = [ @@ -131,6 +141,7 @@ __all__ = [ "RiskSummary", "DependencySummary", "RecentSignalItem", + "ActiveProjectSnapshot", "DecisionContextSnapshot", "WorldModel", ] diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py index c6c361f4..c07a197d 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py @@ -81,6 +81,7 @@ class AdaptiveRAGAnswerRetrievalMixin: asked_via=asked_via, message_intent=message_intent, mixed_signal_part=mixed_signal_part, + query_type=query_type, ) used_sources = self._dedupe_sources(retrieved) if not used_sources: @@ -142,6 +143,7 @@ class AdaptiveRAGAnswerRetrievalMixin: asked_via=asked_via, message_intent=message_intent, mixed_signal_part=mixed_signal_part, + query_type=query_type, ) deep_sources = self._dedupe_sources(deep_retrieved) if not deep_sources: 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 19d1c41f..b11e0e6c 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 @@ -1,10 +1,52 @@ """Adaptive RAG mixin module.""" from __future__ import annotations +from ...config.ai_prompts import PROMPT_PROJECT_STATUS_QA from .shared import * class AdaptiveRAGDeepGuardAnswerMixin: + @staticmethod + def _compact_member_workload_user_snapshot(user_snapshot: Optional[dict[str, Any]]) -> dict[str, Any]: + if not isinstance(user_snapshot, dict): + return {} + allowed_keys = ( + "team_members", + "members", + "workloads", + "current_user", + "user_profile", + "load_summary", + "workload_summary", + ) + compact = {key: user_snapshot.get(key) for key in allowed_keys if key in user_snapshot} + return compact if compact else {} + + @staticmethod + def _filter_member_workload_context_blocks(context_blocks_text: str) -> str: + raw = str(context_blocks_text or "").strip() + if not raw or raw == "无": + return "无" + blocks = re.split(r"\n(?=\[(?:\d+|M)\])", raw) + keywords = ( + "team_members", + "workload", + "负载", + "成员", + "负责人", + "用户", + "工时", + "可用工时", + ) + kept: list[str] = [] + for block in blocks: + text = str(block or "").strip() + if not text: + continue + if any(keyword in text for keyword in keywords): + kept.append(text) + return "\n".join(kept) if kept else "无" + @staticmethod def _is_ai_pmo_process_entry(asked_via: Optional[str]) -> bool: channel = str(asked_via or "").strip().lower() @@ -20,6 +62,53 @@ class AdaptiveRAGDeepGuardAnswerMixin: def _contains_any_keyword(text: str, keywords: tuple[str, ...]) -> bool: return any(token in text for token in keywords) + @staticmethod + def _is_decision_choice_question(question: str) -> bool: + text = re.sub(r"\s+", "", str(question or "").lower()) + if not text: + return False + decision_markers = ( + "二选一", + "三选一", + "只允许一种", + "只能选一种", + "如果", + "你会", + "你选", + "优先", + "更应该", + "更适合", + "还是", + ) + if not any(marker in text for marker in decision_markers): + return False + return any(marker in text for marker in ("还是", "二选一", "三选一", "只允许一种", "只能选一种", "优先", "更应该", "更适合", "你会", "你选")) + + @staticmethod + def _is_project_status_question(question: str, query_type: Optional[str]) -> bool: + text = re.sub(r"\s+", "", str(question or "").lower()) + if not text: + return False + if str(query_type or "").strip().lower() == QUERY_TYPE_STATUS: + return True + return bool( + PROJECT_PROGRESS_RE.search(text) + or any( + token in text + for token in ( + "进展", + "进度", + "阶段", + "完成率", + "完成度", + "推进", + "里程碑", + "状态", + "情况", + ) + ) + ) + @staticmethod def _format_mixed_two_block_output( *, @@ -66,37 +155,23 @@ class AdaptiveRAGDeepGuardAnswerMixin: system_snapshot_text: str, user_snapshot_text: str, relevance_max: float, + query_type: Optional[str] = None, ) -> tuple[str, str]: normalized_question = re.sub(r"\s+", "", str(question or "").lower()) member_keywords = ("谁", "成员", "负责人", "负载") trend_keywords = ("最新", "行业", "市场", "前景", "趋势", "动态") if self._contains_any_keyword(normalized_question, member_keywords): + filtered_context_blocks = self._filter_member_workload_context_blocks(context_blocks_text) + compact_user_snapshot = self._compact_member_workload_user_snapshot(user_snapshot) + compact_user_snapshot_text = json.dumps(compact_user_snapshot or {}, ensure_ascii=False)[:6000] return ( "member_workload", PROMPT_MEMBER_WORKLOAD_QA.format( question=question, system_snapshot=system_snapshot_text or "{}", - user_snapshot=user_snapshot_text or "{}", - context_blocks=context_blocks_text or "无", - ).strip(), - ) - - if self._contains_any_keyword(normalized_question, trend_keywords): - project_context_text = json.dumps( - { - "project_id": project_id if project_id is not None else "unknown", - "system_snapshot": system_snapshot or {}, - "user_snapshot": user_snapshot or {}, - }, - ensure_ascii=False, - )[:16000] - return ( - "external_trend", - PROMPT_EXTERNAL_TREND_QA.format( - question=question, - project_context=project_context_text or "{}", - external_results=context_blocks_text or "无", + user_snapshot=compact_user_snapshot_text or "{}", + context_blocks=filtered_context_blocks, ).strip(), ) @@ -115,6 +190,68 @@ class AdaptiveRAGDeepGuardAnswerMixin: ).strip(), ) + if self._is_project_status_question(question, query_type): + return ( + "project_status", + PROMPT_PROJECT_STATUS_QA.format( + project_id=project_id if project_id is not None else "unknown", + question=question, + system_snapshot=system_snapshot_text or "{}", + user_snapshot=user_snapshot_text or "{}", + context_blocks=context_blocks_text or "无", + ).strip(), + ) + + if self._contains_any_keyword(normalized_question, trend_keywords): + explicit_project_context_markers = ( + token in normalized_question + for token in ("当前项目", "这个项目", "该项目", "项目里", "项目中", "你们项目", "我们项目") + ) + negated_project_context_markers = ( + "不要提项目", + "别提项目", + "不提项目", + "无项目", + "不涉及项目", + "不要绑定项目", + "不要关联项目", + "无需项目", + ) + has_project_context = any(explicit_project_context_markers) and not any( + token in normalized_question for token in negated_project_context_markers + ) + if has_project_context: + project_context_text = json.dumps( + { + "project_id": project_id if project_id is not None else "unknown", + "system_snapshot": system_snapshot or {}, + "user_snapshot": user_snapshot or {}, + }, + ensure_ascii=False, + )[:16000] + return ( + "external_trend", + PROMPT_EXTERNAL_TREND_QA.format( + question=question, + project_context=project_context_text or "{}", + external_results=context_blocks_text or "无", + ).strip(), + ) + return ( + "industry_trend", + PROMPT_INDUSTRY_TREND_QA.format( + question=question, + ).strip(), + ) + + if self._is_decision_choice_question(question): + return ( + "decision_choice", + PROMPT_DECISION_QA.format( + question=question, + ).strip(), + ) + if self._is_ai_pmo_process_entry(asked_via): world_model_text = json.dumps( { @@ -264,6 +401,89 @@ class AdaptiveRAGDeepGuardAnswerMixin: lines.append("来源: System Snapshot / User Snapshot") return "\n".join(lines)[:3200] + async def _build_member_workload_direct_answer(self, question: str) -> Optional[str]: + text = str(question or "").strip() + if not text or not self._contains_any_keyword(text, ("负载", "工作量", "工时")): + return None + user_rows = ( + await self.db.execute( + select(User.id, User.name, User.username) + .where( + and_( + User.tenant_id == self.tenant_id, + User.is_active.is_(True), + ) + ) + .order_by(User.id.asc()) + .limit(500) + ) + ).all() + if not user_rows: + return None + matched_rows: list[tuple[int, str, str, int]] = [] + for row in user_rows: + name = str(row.name or "").strip() + username = str(row.username or "").strip() + score = 0 + if name and name in text: + score = max(score, len(name)) + if username and username in text: + score = max(score, len(username)) + if score > 0: + matched_rows.append((int(row.id), name, username, score)) + if not matched_rows: + return None + matched_rows.sort(key=lambda item: (-item[3], item[0])) + user_id = int(matched_rows[0][0]) + user_name = str(matched_rows[0][1] or matched_rows[0][2] or "该成员") + workload_row = ( + await self.db.execute( + select(UserWorkload.workload_ratio, UserWorkload.current_assigned_hours, UserWorkload.max_available_hours_per_week).where( + and_( + UserWorkload.tenant_id == self.tenant_id, + UserWorkload.user_id == user_id, + ) + ) + ) + ).first() + if workload_row is None: + user_row = ( + await self.db.execute( + select(User.workload).where( + and_( + User.tenant_id == self.tenant_id, + User.id == user_id, + ) + ) + ) + ).scalar_one_or_none() + workload_ratio = round(float(user_row or 0) / 100.0, 2) + assigned_hours = None + max_hours = None + else: + workload_ratio = float(workload_row[0] or 0.0) + assigned_hours = float(workload_row[1] or 0.0) if workload_row[1] is not None else None + max_hours = float(workload_row[2] or 0.0) if workload_row[2] is not None else None + + percent = workload_ratio + if percent > 10: + percent = round(percent, 2) + else: + percent = round(percent * 100.0, 2) + + if percent >= 100: + status_text = "严重超载,今日无法再承接新任务" + elif percent >= 80: + status_text = "负载偏高,建议优先控制新增任务" + else: + status_text = "负载正常" + + detail_bits = [f"{user_name}当前负载{percent:.2f}%"] + if assigned_hours is not None and max_hours is not None: + detail_bits.append(f"(已分配{assigned_hours:.2f}h / 最大{max_hours:.2f}h)") + detail_bits.append(f",{status_text}。") + return "".join(detail_bits) + async def _generate_answer( self, question: str, @@ -280,9 +500,13 @@ class AdaptiveRAGDeepGuardAnswerMixin: asked_via: str = "pmflow", message_intent: Optional[str] = None, mixed_signal_part: Optional[str] = None, + query_type: Optional[str] = None, ) -> str: active_thinking_mode = self._normalize_thinking_mode(thinking_mode) deep_thinking = active_thinking_mode == THINKING_MODE_DEEP + member_workload_direct_answer = await self._build_member_workload_direct_answer(question) + if member_workload_direct_answer: + return member_workload_direct_answer[:3200] context_blocks: list[str] = [] used_tokens = 0 max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32)) @@ -324,6 +548,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: system_snapshot_text=system_snapshot_text, user_snapshot_text=user_snapshot_text, relevance_max=relevance_max, + query_type=query_type, ) if deep_thinking: prompt = ( @@ -354,7 +579,7 @@ class AdaptiveRAGDeepGuardAnswerMixin: ) text = str(answer or "").strip() if not text: - return "我未能从当前上下文生成有效答案,请补充问题细节后重试。" + raise ValueError("adaptive_rag_answer_empty_response") if scene_key == "mixed": return self._format_mixed_two_block_output( answer_text=text, diff --git a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py index 528d5c82..d3ac037d 100644 --- a/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py +++ b/backend/app/services/adaptive_rag_service/mixin_deterministic_query_core.py @@ -33,6 +33,12 @@ class AdaptiveRAGDeterministicQueryCoreMixin: if q_type == QUERY_TYPE_LIST: return await self._run_list_query(question=question, project_id=project_id) if q_type == QUERY_TYPE_STATUS: + created_at = await self._try_fast_project_created_at_answer(clean_q=question, project_id=project_id) + if created_at: + created_at["query_type"] = QUERY_TYPE_STATUS + created_at.setdefault("confidence", 0.97) + created_at.setdefault("missing_fields", []) + return created_at progress = await self._try_fast_project_progress_answer(clean_q=question, project_id=project_id) if progress: progress["query_type"] = QUERY_TYPE_STATUS @@ -140,4 +146,3 @@ class AdaptiveRAGDeterministicQueryCoreMixin: best_name = str(row.title or row.name or f"项目#{int(row.id)}") return (best_id, best_name) - diff --git a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py index 673df46c..1f57c79e 100644 --- a/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py +++ b/backend/app/services/adaptive_rag_service/mixin_fast_answer_project_main.py @@ -5,6 +5,11 @@ from .shared import * class AdaptiveRAGFastAnswerProjectMainMixin: + PROJECT_CREATED_AT_RE = re.compile( + r"(什么时候.*创建|何时.*创建|创建时间|立项时间|成立时间|创建于|何时立项|什么时候立项)", + re.IGNORECASE, + ) + async def _try_fast_project_progress_answer( self, *, @@ -138,6 +143,116 @@ class AdaptiveRAGFastAnswerProjectMainMixin: "response_time_ms": db_query_ms, } + async def _try_fast_project_created_at_answer( + self, + *, + clean_q: str, + project_id: Optional[int], + ) -> Optional[dict[str, Any]]: + question = str(clean_q or "").strip() + if not question or not self.PROJECT_CREATED_AT_RE.search(question): + return None + if "项目" not in question and "系统" not in question: + return None + + primary_keyword = self._extract_project_lookup_keyword(question) + query_terms = self._extract_query_terms(question, preferred=primary_keyword) + started = time.perf_counter() + + target_project_id, target_name, used_keyword = await self._resolve_project_scope_for_question( + question=question, + project_id=project_id, + ) + if target_project_id is None and query_terms: + for term in query_terms: + rows = await self._search_projects_by_keyword(keyword=term, project_id=project_id, limit=3) + if rows: + target_project_id = int(rows[0].id) + target_name = str(rows[0].title or rows[0].name or f"项目#{int(rows[0].id)}") + used_keyword = term + break + + if target_project_id is None: + db_query_ms = int((time.perf_counter() - started) * 1000) + return { + "answer": f"未检索到可确认创建时间的项目,无法判断“{question}”对应对象。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "projects", + "query_keyword": used_keyword, + "match_count": 0, + }, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + + project_row = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(target_project_id), + ) + ) + ) + ).scalar_one_or_none() + db_query_ms = int((time.perf_counter() - started) * 1000) + if project_row is None: + return { + "answer": f"未检索到项目#{int(target_project_id)} 的创建时间。", + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "projects", + "project_id": int(target_project_id), + "query_keyword": used_keyword, + "match_count": 1, + }, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + + created_at = project_row.created_at + if created_at is None: + answer = f"项目「{target_name or str(project_row.title or project_row.name or f'项目#{int(project_row.id)}')}」已找到,但未记录创建时间。" + else: + if created_at.tzinfo is not None: + created_at = created_at.astimezone(CN_TZ) + answer = ( + f"项目「{target_name or str(project_row.title or project_row.name or f'项目#{int(project_row.id)}')}」" + f"创建于 {created_at.strftime('%Y-%m-%d %H:%M')}。" + ) + + return { + "answer": answer, + "sources": [ + { + "source": "db", + "label": "Project database", + "score": 0.99, + "metadata": { + "table": "projects", + "project_id": int(project_row.id), + "query_keyword": used_keyword, + "created_at": project_row.created_at.isoformat() if project_row.created_at else None, + }, + } + ], + "db_query_ms": db_query_ms, + "response_time_ms": db_query_ms, + } + async def _try_fast_project_existence_answer( self, *, @@ -224,4 +339,3 @@ class AdaptiveRAGFastAnswerProjectMainMixin: ) ) return (await self.db.execute(stmt.limit(max(1, int(limit))))).scalars().all() - diff --git a/backend/app/services/adaptive_rag_service/shared_imports.py b/backend/app/services/adaptive_rag_service/shared_imports.py index 5f173957..db0b00b9 100644 --- a/backend/app/services/adaptive_rag_service/shared_imports.py +++ b/backend/app/services/adaptive_rag_service/shared_imports.py @@ -29,7 +29,9 @@ from sqlalchemy.ext.asyncio import AsyncSession from ...config.ai_prompts import ( PROMPT_AI_PMO_QA, + PROMPT_DECISION_QA, PROMPT_EXTERNAL_TREND_QA, + PROMPT_INDUSTRY_TREND_QA, PROMPT_FEISHU_DIRECT_QA, PROMPT_FEISHU_MIXED_QA, PROMPT_MEMBER_WORKLOAD_QA, diff --git a/backend/app/services/adaptive_rag_service/shared_part_01.py b/backend/app/services/adaptive_rag_service/shared_part_01.py index e3d628a3..8c933be9 100644 --- a/backend/app/services/adaptive_rag_service/shared_part_01.py +++ b/backend/app/services/adaptive_rag_service/shared_part_01.py @@ -190,6 +190,7 @@ FOLLOWUP_ENUMERATION_KEYWORDS = ( "都有哪些", "具体有哪些", "列出来", + "列一下", "清单", "名单", ) diff --git a/backend/app/services/adaptive_rag_service/shared_part_02.py b/backend/app/services/adaptive_rag_service/shared_part_02.py index 3773ab4f..f9179382 100644 --- a/backend/app/services/adaptive_rag_service/shared_part_02.py +++ b/backend/app/services/adaptive_rag_service/shared_part_02.py @@ -94,7 +94,7 @@ WRITE_EXPRESSIONS = ( "麻烦", ) COUNT_HINTS = ("多少", "几个", "数量", "总数", "count") -LIST_HINTS = ("哪些", "列表", "清单", "列出", "分别", "名单", "都有哪些") +LIST_HINTS = ("哪些", "列表", "清单", "列出", "列一下", "分别", "名单", "都有哪些") STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况") ENTITY_ALIAS_MAP: dict[str, str] = { "能力": "skill", diff --git a/backend/app/services/ai_orchestration_run/ingress_worker.py b/backend/app/services/ai_orchestration_run/ingress_worker.py index 262a866f..9e9ab352 100644 --- a/backend/app/services/ai_orchestration_run/ingress_worker.py +++ b/backend/app/services/ai_orchestration_run/ingress_worker.py @@ -145,6 +145,14 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None: task_payload_value = result_payload_value.get("task_payload") if isinstance(result_payload_value.get("task_payload"), dict) else {} message_sent_at_value = task_payload_value.get("message_sent_at") session_snapshot_value = task_payload_value.get("session_snapshot") if isinstance(task_payload_value.get("session_snapshot"), dict) else None + thread_context_value = task_payload_value.get("thread_context") if isinstance(task_payload_value.get("thread_context"), dict) else None + logger.info( + "ingress_worker_thread_context_snapshot", + run_id=run_id_value, + trace_id=trace_id_value, + source_message_id=source_message_id_value, + thread_context={k: str(v)[:128] for k, v in (thread_context_value or {}).items()} if isinstance(thread_context_value, dict) else None, + ) try: capture_service = ProactiveSignalCaptureService(db) @@ -183,6 +191,7 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None: force_sync=True, message_sent_at=message_sent_at_value, session_snapshot=session_snapshot_value, + thread_context=thread_context_value, ) finally: stop_heartbeat.set() diff --git a/backend/app/services/ai_orchestration_run/service.py b/backend/app/services/ai_orchestration_run/service.py index e2e350de..a4ccb9a8 100644 --- a/backend/app/services/ai_orchestration_run/service.py +++ b/backend/app/services/ai_orchestration_run/service.py @@ -121,6 +121,7 @@ class AIOrchestrationRunService: chat_type: Optional[str], captured_at: Optional[datetime], project_hint_id: Optional[int], + thread_context: Optional[dict[str, Any]] = None, max_retries: int = 3, ) -> tuple[AIOrchestrationRun, bool]: trace_id = self.build_ingress_trace_id( @@ -136,6 +137,9 @@ class AIOrchestrationRunService: "source_system": str(source_system or "").strip()[:32], "source_message_id": str(source_message_id or "").strip()[:128], "queued_at": _utc_now_naive().isoformat(), + "task_payload": { + "thread_context": thread_context if isinstance(thread_context, dict) else None, + }, } row = AIOrchestrationRun( tenant_id=int(tenant_id), diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/policy.py b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py index a3b8c6e1..4e42c3d6 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/policy.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py @@ -7,7 +7,27 @@ import os from .shared import ( CanonicalIntent, INTENT_QUERY, + INTENT_PROJECT_CREATE, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, re, @@ -35,6 +55,22 @@ class ExecutionPolicyBrain: projected_capacity_ratio = float(simulation.get("projected_capacity_peak_ratio") or 0.0) capacity_warning: dict[str, object] | None = None normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + destructive_intents = { + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_DELETE, + INTENT_TASK_DELETE, + } + if intent.name in destructive_intents: + return { + "auto_execute": False, + "policy_gate_hit": True, + "policy_gate_code": "destructive_intent", + "approval_requirement": { + "required": True, + "reason": "destructive_intent", + "message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。", + }, + } for gate_code, tokens in self.policy_gate_rules: if any(token.lower().replace(" ", "") in normalized for token in tokens): return { @@ -60,7 +96,28 @@ class ExecutionPolicyBrain: }, } - if intent.name in {INTENT_QUERY, INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE}: + if intent.name in { + INTENT_QUERY, + INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_SIGNAL_CREATE, + INTENT_PROJECT_CREATE, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_REOPEN, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, + }: return { "auto_execute": True, "policy_gate_hit": False, diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/shared.py b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py index caf99027..5c60fe89 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py @@ -9,14 +9,56 @@ from ..ai_pmo_intent import ( INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE, INTENT_PROJECT_CREATE, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, INTENT_QUERY, INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, + ROUTE_FAMILY_KNOWLEDGE, + ROUTE_FAMILY_NOTIFICATION, + ROUTE_FAMILY_PERSONNEL, + ROUTE_FAMILY_PROJECT, ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_REFRESH, + ROUTE_PROJECT_UPDATE, + ROUTE_PROJECT_ARCHIVE, + ROUTE_PROJECT_REOPEN, + ROUTE_PROJECT_DELETE, + ROUTE_TASK_UPDATE, + ROUTE_TASK_ASSIGN, + ROUTE_TASK_PRIORITIZE, + ROUTE_TASK_STATUS_TRANSITION, + ROUTE_TASK_DELETE, + ROUTE_PERSONNEL_ROLE_UPDATE, + ROUTE_PERSONNEL_WORKLOAD_UPDATE, + ROUTE_PERSONNEL_AVAILABILITY_UPDATE, + ROUTE_KNOWLEDGE_DOC_UPSERT, + ROUTE_KNOWLEDGE_CHUNK_UPSERT, + ROUTE_KNOWLEDGE_LINK_UPSERT, + ROUTE_KNOWLEDGE_REINDEX, + ROUTE_NOTIFICATION_CREATE, + ROUTE_NOTIFICATION_ACK, + ROUTE_NOTIFICATION_CLOSE, CanonicalIntent, ) @@ -46,14 +88,56 @@ __all__ = [ "INTENT_FOLLOWUP_CONFIRM", "INTENT_FOLLOWUP_CONTINUE", "INTENT_PROJECT_CREATE", + "INTENT_PROJECT_UPDATE", + "INTENT_PROJECT_ARCHIVE", + "INTENT_PROJECT_REOPEN", + "INTENT_PROJECT_DELETE", "INTENT_QUERY", "INTENT_TASK_CREATE", + "INTENT_TASK_UPDATE", + "INTENT_TASK_ASSIGN", + "INTENT_TASK_PRIORITIZE", + "INTENT_TASK_STATUS_TRANSITION", + "INTENT_TASK_DELETE", + "INTENT_PERSONNEL_ROLE_UPDATE", + "INTENT_PERSONNEL_WORKLOAD_UPDATE", + "INTENT_PERSONNEL_AVAILABILITY_UPDATE", + "INTENT_KNOWLEDGE_DOC_UPSERT", + "INTENT_KNOWLEDGE_CHUNK_UPSERT", + "INTENT_KNOWLEDGE_LINK_UPSERT", + "INTENT_KNOWLEDGE_REINDEX", + "INTENT_NOTIFICATION_CREATE", + "INTENT_NOTIFICATION_ACK", + "INTENT_NOTIFICATION_CLOSE", "INTENT_SIGNAL_CREATE", "INTENT_SIGNAL_SOURCE_ONBOARD", + "ROUTE_FAMILY_KNOWLEDGE", + "ROUTE_FAMILY_NOTIFICATION", + "ROUTE_FAMILY_PERSONNEL", + "ROUTE_FAMILY_PROJECT", "ROUTE_PROJECT_SIGNAL_CANCEL", "ROUTE_PROJECT_SIGNAL_COMMIT", "ROUTE_PROJECT_SIGNAL_DISCOVER", "ROUTE_PROJECT_SIGNAL_REFRESH", + "ROUTE_PROJECT_UPDATE", + "ROUTE_PROJECT_ARCHIVE", + "ROUTE_PROJECT_REOPEN", + "ROUTE_PROJECT_DELETE", + "ROUTE_TASK_UPDATE", + "ROUTE_TASK_ASSIGN", + "ROUTE_TASK_PRIORITIZE", + "ROUTE_TASK_STATUS_TRANSITION", + "ROUTE_TASK_DELETE", + "ROUTE_PERSONNEL_ROLE_UPDATE", + "ROUTE_PERSONNEL_WORKLOAD_UPDATE", + "ROUTE_PERSONNEL_AVAILABILITY_UPDATE", + "ROUTE_KNOWLEDGE_DOC_UPSERT", + "ROUTE_KNOWLEDGE_CHUNK_UPSERT", + "ROUTE_KNOWLEDGE_LINK_UPSERT", + "ROUTE_KNOWLEDGE_REINDEX", + "ROUTE_NOTIFICATION_CREATE", + "ROUTE_NOTIFICATION_ACK", + "ROUTE_NOTIFICATION_CLOSE", "normalize_workload_ratio", "re", "safe_int", diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py index ffc9f7cb..2ec1d40e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py @@ -19,7 +19,26 @@ class SimulationEngine: BUDGET_COST_BY_OPERATION = { "query.answer": 1.0, "project.create": 120.0, + "project.update": 12.0, + "project.archive": 6.0, + "project.reopen": 6.0, + "project.delete": 8.0, "task.create": 14.0, + "task.update": 4.0, + "task.assign": 3.0, + "task.prioritize": 2.0, + "task.status_transition": 2.0, + "task.delete": 3.0, + "personnel.role_update": 4.0, + "personnel.workload_update": 2.0, + "personnel.availability_update": 2.0, + "knowledge.doc_upsert": 12.0, + "knowledge.chunk_upsert": 6.0, + "knowledge.link_upsert": 4.0, + "knowledge.reindex": 20.0, + "notification.create": 1.0, + "notification.ack": 0.3, + "notification.close": 0.5, "signal.enter": 8.0, "signal_source.onboard": 40.0, ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0, @@ -30,7 +49,26 @@ class SimulationEngine: HOURS_BY_OPERATION = { "query.answer": 0.2, "project.create": 12.0, + "project.update": 1.2, + "project.archive": 0.6, + "project.reopen": 0.6, + "project.delete": 0.8, "task.create": 2.5, + "task.update": 0.8, + "task.assign": 0.5, + "task.prioritize": 0.4, + "task.status_transition": 0.4, + "task.delete": 0.5, + "personnel.role_update": 0.5, + "personnel.workload_update": 0.3, + "personnel.availability_update": 0.3, + "knowledge.doc_upsert": 1.8, + "knowledge.chunk_upsert": 0.8, + "knowledge.link_upsert": 0.5, + "knowledge.reindex": 3.0, + "notification.create": 0.2, + "notification.ack": 0.1, + "notification.close": 0.1, "signal.enter": 1.5, "signal_source.onboard": 6.0, ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8, diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py index 5cb031e7..e201d521 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py @@ -5,7 +5,26 @@ from __future__ import annotations from .shared import ( CanonicalIntent, INTENT_PROJECT_CREATE, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, INTENT_SIGNAL_SOURCE_ONBOARD, Any, normalize_workload_ratio, @@ -29,8 +48,31 @@ class StrategicBrain: risk_score = min(1.0, 0.18 + risk_count * 0.08 + budget_ratio * 0.25 + len(overloaded_people) * 0.1) if intent.name == INTENT_PROJECT_CREATE: risk_score = max(risk_score, 0.62) + if intent.name in {INTENT_PROJECT_UPDATE, INTENT_PROJECT_ARCHIVE, INTENT_PROJECT_REOPEN}: + risk_score = max(risk_score, 0.48) + if intent.name == INTENT_PROJECT_DELETE: + risk_score = max(risk_score, 0.82) if intent.name == INTENT_TASK_CREATE: risk_score = max(risk_score, 0.55) + if intent.name in {INTENT_TASK_UPDATE, INTENT_TASK_ASSIGN, INTENT_TASK_PRIORITIZE, INTENT_TASK_STATUS_TRANSITION}: + risk_score = max(risk_score, 0.42) + if intent.name == INTENT_TASK_DELETE: + risk_score = max(risk_score, 0.7) + if intent.name in { + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + }: + risk_score = max(risk_score, 0.46) + if intent.name in { + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + }: + risk_score = max(risk_score, 0.38) + if intent.name in {INTENT_NOTIFICATION_CREATE, INTENT_NOTIFICATION_ACK, INTENT_NOTIFICATION_CLOSE}: + risk_score = max(risk_score, 0.28) if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: risk_score = max(risk_score, 0.58) diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py index f1d402ca..76ac09c5 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py @@ -7,14 +7,52 @@ from .shared import ( INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE, INTENT_PROJECT_CREATE, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, INTENT_QUERY, INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, + ROUTE_PROJECT_ARCHIVE, ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_REFRESH, + ROUTE_PROJECT_DELETE, + ROUTE_PROJECT_REOPEN, + ROUTE_PROJECT_UPDATE, + ROUTE_TASK_ASSIGN, + ROUTE_TASK_DELETE, + ROUTE_TASK_PRIORITIZE, + ROUTE_TASK_STATUS_TRANSITION, + ROUTE_TASK_UPDATE, + ROUTE_PERSONNEL_ROLE_UPDATE, + ROUTE_PERSONNEL_WORKLOAD_UPDATE, + ROUTE_PERSONNEL_AVAILABILITY_UPDATE, + ROUTE_KNOWLEDGE_DOC_UPSERT, + ROUTE_KNOWLEDGE_CHUNK_UPSERT, + ROUTE_KNOWLEDGE_LINK_UPSERT, + ROUTE_KNOWLEDGE_REINDEX, + ROUTE_NOTIFICATION_CREATE, + ROUTE_NOTIFICATION_ACK, + ROUTE_NOTIFICATION_CLOSE, Any, ) @@ -43,8 +81,46 @@ class TacticalBrain: execution_actions.append({"operation_code": "query.answer", "mode": "read_only"}) elif intent.name == INTENT_PROJECT_CREATE: execution_actions.append({"operation_code": "project.create", "mode": "write"}) + elif intent.name == INTENT_PROJECT_UPDATE: + execution_actions.append({"operation_code": ROUTE_PROJECT_UPDATE, "mode": "write"}) + elif intent.name == INTENT_PROJECT_ARCHIVE: + execution_actions.append({"operation_code": ROUTE_PROJECT_ARCHIVE, "mode": "write"}) + elif intent.name == INTENT_PROJECT_REOPEN: + execution_actions.append({"operation_code": ROUTE_PROJECT_REOPEN, "mode": "write"}) + elif intent.name == INTENT_PROJECT_DELETE: + execution_actions.append({"operation_code": ROUTE_PROJECT_DELETE, "mode": "write"}) elif intent.name == INTENT_TASK_CREATE: execution_actions.append({"operation_code": "task.create", "mode": "write"}) + elif intent.name == INTENT_TASK_UPDATE: + execution_actions.append({"operation_code": ROUTE_TASK_UPDATE, "mode": "write"}) + elif intent.name == INTENT_TASK_ASSIGN: + execution_actions.append({"operation_code": ROUTE_TASK_ASSIGN, "mode": "write"}) + elif intent.name == INTENT_TASK_PRIORITIZE: + execution_actions.append({"operation_code": ROUTE_TASK_PRIORITIZE, "mode": "write"}) + elif intent.name == INTENT_TASK_STATUS_TRANSITION: + execution_actions.append({"operation_code": ROUTE_TASK_STATUS_TRANSITION, "mode": "write"}) + elif intent.name == INTENT_TASK_DELETE: + execution_actions.append({"operation_code": ROUTE_TASK_DELETE, "mode": "write"}) + elif intent.name == INTENT_PERSONNEL_ROLE_UPDATE: + execution_actions.append({"operation_code": ROUTE_PERSONNEL_ROLE_UPDATE, "mode": "write"}) + elif intent.name == INTENT_PERSONNEL_WORKLOAD_UPDATE: + execution_actions.append({"operation_code": ROUTE_PERSONNEL_WORKLOAD_UPDATE, "mode": "write"}) + elif intent.name == INTENT_PERSONNEL_AVAILABILITY_UPDATE: + execution_actions.append({"operation_code": ROUTE_PERSONNEL_AVAILABILITY_UPDATE, "mode": "write"}) + elif intent.name == INTENT_KNOWLEDGE_DOC_UPSERT: + execution_actions.append({"operation_code": ROUTE_KNOWLEDGE_DOC_UPSERT, "mode": "write"}) + elif intent.name == INTENT_KNOWLEDGE_CHUNK_UPSERT: + execution_actions.append({"operation_code": ROUTE_KNOWLEDGE_CHUNK_UPSERT, "mode": "write"}) + elif intent.name == INTENT_KNOWLEDGE_LINK_UPSERT: + execution_actions.append({"operation_code": ROUTE_KNOWLEDGE_LINK_UPSERT, "mode": "write"}) + elif intent.name == INTENT_KNOWLEDGE_REINDEX: + execution_actions.append({"operation_code": ROUTE_KNOWLEDGE_REINDEX, "mode": "write"}) + elif intent.name == INTENT_NOTIFICATION_CREATE: + execution_actions.append({"operation_code": ROUTE_NOTIFICATION_CREATE, "mode": "write"}) + elif intent.name == INTENT_NOTIFICATION_ACK: + execution_actions.append({"operation_code": ROUTE_NOTIFICATION_ACK, "mode": "write"}) + elif intent.name == INTENT_NOTIFICATION_CLOSE: + execution_actions.append({"operation_code": ROUTE_NOTIFICATION_CLOSE, "mode": "write"}) elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"}) diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py index 1e77d231..609d19f0 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py @@ -34,6 +34,8 @@ class RouteResolver: mapped = ROUTE_CANONICAL_INTENT_MAP.get(normalized) if mapped: return mapped + if normalized in INTENT_CANDIDATES: + return normalized family, _ = cls._split_route_key(normalized) if family == ROUTE_FAMILY_PROJECT_SIGNAL: return INTENT_SIGNAL_CREATE diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py index e48a7350..758636cc 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py @@ -11,7 +11,26 @@ from ...signal.signal_source_orchestration import SignalSourceOrchestrationServi INTENT_QUERY = "query.read_only" INTENT_PROJECT_CREATE = "project.create" +INTENT_PROJECT_UPDATE = "project.update" +INTENT_PROJECT_ARCHIVE = "project.archive" +INTENT_PROJECT_REOPEN = "project.reopen" +INTENT_PROJECT_DELETE = "project.delete" INTENT_TASK_CREATE = "task.create" +INTENT_TASK_UPDATE = "task.update" +INTENT_TASK_ASSIGN = "task.assign" +INTENT_TASK_PRIORITIZE = "task.prioritize" +INTENT_TASK_STATUS_TRANSITION = "task.status_transition" +INTENT_TASK_DELETE = "task.delete" +INTENT_PERSONNEL_ROLE_UPDATE = "personnel.role_update" +INTENT_PERSONNEL_WORKLOAD_UPDATE = "personnel.workload_update" +INTENT_PERSONNEL_AVAILABILITY_UPDATE = "personnel.availability_update" +INTENT_KNOWLEDGE_DOC_UPSERT = "knowledge.doc_upsert" +INTENT_KNOWLEDGE_CHUNK_UPSERT = "knowledge.chunk_upsert" +INTENT_KNOWLEDGE_LINK_UPSERT = "knowledge.link_upsert" +INTENT_KNOWLEDGE_REINDEX = "knowledge.reindex" +INTENT_NOTIFICATION_CREATE = "notification.create" +INTENT_NOTIFICATION_ACK = "notification.ack" +INTENT_NOTIFICATION_CLOSE = "notification.close" INTENT_SIGNAL_CREATE = "signal.create" INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" INTENT_FOLLOWUP_CONFIRM = "followup.confirm" @@ -26,13 +45,33 @@ FOLLOWUP_TARGET_INTENTS = { ROUTE_FAMILY_QUERY = "query" ROUTE_FAMILY_PROJECT_CREATE = "project_create" +ROUTE_FAMILY_PROJECT = "project" ROUTE_FAMILY_TASK = "task" +ROUTE_FAMILY_PERSONNEL = "personnel" +ROUTE_FAMILY_KNOWLEDGE = "knowledge" +ROUTE_FAMILY_NOTIFICATION = "notification" ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal" ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source" ROUTE_FAMILY_FOLLOWUP = "followup" ROUTE_ACTION_ANSWER = "answer" ROUTE_ACTION_CREATE = "create" +ROUTE_ACTION_UPDATE = "update" +ROUTE_ACTION_ARCHIVE = "archive" +ROUTE_ACTION_REOPEN = "reopen" +ROUTE_ACTION_DELETE = "delete" +ROUTE_ACTION_ASSIGN = "assign" +ROUTE_ACTION_PRIORITIZE = "prioritize" +ROUTE_ACTION_STATUS_TRANSITION = "status_transition" +ROUTE_ACTION_ROLE_UPDATE = "role_update" +ROUTE_ACTION_WORKLOAD_UPDATE = "workload_update" +ROUTE_ACTION_AVAILABILITY_UPDATE = "availability_update" +ROUTE_ACTION_DOC_UPSERT = "doc_upsert" +ROUTE_ACTION_CHUNK_UPSERT = "chunk_upsert" +ROUTE_ACTION_LINK_UPSERT = "link_upsert" +ROUTE_ACTION_REINDEX = "reindex" +ROUTE_ACTION_ACK = "ack" +ROUTE_ACTION_CLOSE = "close" ROUTE_ACTION_DISCOVER = "discover" ROUTE_ACTION_COMMIT = "commit" ROUTE_ACTION_CANCEL = "cancel" @@ -43,7 +82,26 @@ ROUTE_ACTION_CONFIRM = "confirm" ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}" ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}" +ROUTE_PROJECT_UPDATE = f"{ROUTE_FAMILY_PROJECT}.{ROUTE_ACTION_UPDATE}" +ROUTE_PROJECT_ARCHIVE = f"{ROUTE_FAMILY_PROJECT}.{ROUTE_ACTION_ARCHIVE}" +ROUTE_PROJECT_REOPEN = f"{ROUTE_FAMILY_PROJECT}.{ROUTE_ACTION_REOPEN}" +ROUTE_PROJECT_DELETE = f"{ROUTE_FAMILY_PROJECT}.{ROUTE_ACTION_DELETE}" ROUTE_TASK_CREATE = f"{ROUTE_FAMILY_TASK}.{ROUTE_ACTION_CREATE}" +ROUTE_TASK_UPDATE = f"{ROUTE_FAMILY_TASK}.{ROUTE_ACTION_UPDATE}" +ROUTE_TASK_ASSIGN = f"{ROUTE_FAMILY_TASK}.{ROUTE_ACTION_ASSIGN}" +ROUTE_TASK_PRIORITIZE = f"{ROUTE_FAMILY_TASK}.{ROUTE_ACTION_PRIORITIZE}" +ROUTE_TASK_STATUS_TRANSITION = f"{ROUTE_FAMILY_TASK}.{ROUTE_ACTION_STATUS_TRANSITION}" +ROUTE_TASK_DELETE = f"{ROUTE_FAMILY_TASK}.{ROUTE_ACTION_DELETE}" +ROUTE_PERSONNEL_ROLE_UPDATE = f"{ROUTE_FAMILY_PERSONNEL}.{ROUTE_ACTION_ROLE_UPDATE}" +ROUTE_PERSONNEL_WORKLOAD_UPDATE = f"{ROUTE_FAMILY_PERSONNEL}.{ROUTE_ACTION_WORKLOAD_UPDATE}" +ROUTE_PERSONNEL_AVAILABILITY_UPDATE = f"{ROUTE_FAMILY_PERSONNEL}.{ROUTE_ACTION_AVAILABILITY_UPDATE}" +ROUTE_KNOWLEDGE_DOC_UPSERT = f"{ROUTE_FAMILY_KNOWLEDGE}.{ROUTE_ACTION_DOC_UPSERT}" +ROUTE_KNOWLEDGE_CHUNK_UPSERT = f"{ROUTE_FAMILY_KNOWLEDGE}.{ROUTE_ACTION_CHUNK_UPSERT}" +ROUTE_KNOWLEDGE_LINK_UPSERT = f"{ROUTE_FAMILY_KNOWLEDGE}.{ROUTE_ACTION_LINK_UPSERT}" +ROUTE_KNOWLEDGE_REINDEX = f"{ROUTE_FAMILY_KNOWLEDGE}.{ROUTE_ACTION_REINDEX}" +ROUTE_NOTIFICATION_CREATE = f"{ROUTE_FAMILY_NOTIFICATION}.{ROUTE_ACTION_CREATE}" +ROUTE_NOTIFICATION_ACK = f"{ROUTE_FAMILY_NOTIFICATION}.{ROUTE_ACTION_ACK}" +ROUTE_NOTIFICATION_CLOSE = f"{ROUTE_FAMILY_NOTIFICATION}.{ROUTE_ACTION_CLOSE}" ROUTE_PROJECT_SIGNAL_CREATE = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CREATE}" ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}" ROUTE_PROJECT_SIGNAL_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}" @@ -56,7 +114,26 @@ ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}" WORKFLOW_ROUTE_KEYS = { ROUTE_QUERY_ANSWER, ROUTE_PROJECT_CREATE, + ROUTE_PROJECT_UPDATE, + ROUTE_PROJECT_ARCHIVE, + ROUTE_PROJECT_REOPEN, + ROUTE_PROJECT_DELETE, ROUTE_TASK_CREATE, + ROUTE_TASK_UPDATE, + ROUTE_TASK_ASSIGN, + ROUTE_TASK_PRIORITIZE, + ROUTE_TASK_STATUS_TRANSITION, + ROUTE_TASK_DELETE, + ROUTE_PERSONNEL_ROLE_UPDATE, + ROUTE_PERSONNEL_WORKLOAD_UPDATE, + ROUTE_PERSONNEL_AVAILABILITY_UPDATE, + ROUTE_KNOWLEDGE_DOC_UPSERT, + ROUTE_KNOWLEDGE_CHUNK_UPSERT, + ROUTE_KNOWLEDGE_LINK_UPSERT, + ROUTE_KNOWLEDGE_REINDEX, + ROUTE_NOTIFICATION_CREATE, + ROUTE_NOTIFICATION_ACK, + ROUTE_NOTIFICATION_CLOSE, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT, @@ -70,7 +147,26 @@ WORKFLOW_ROUTE_KEYS = { ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { ROUTE_QUERY_ANSWER: INTENT_QUERY, ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE, + ROUTE_PROJECT_UPDATE: INTENT_PROJECT_UPDATE, + ROUTE_PROJECT_ARCHIVE: INTENT_PROJECT_ARCHIVE, + ROUTE_PROJECT_REOPEN: INTENT_PROJECT_REOPEN, + ROUTE_PROJECT_DELETE: INTENT_PROJECT_DELETE, ROUTE_TASK_CREATE: INTENT_TASK_CREATE, + ROUTE_TASK_UPDATE: INTENT_TASK_UPDATE, + ROUTE_TASK_ASSIGN: INTENT_TASK_ASSIGN, + ROUTE_TASK_PRIORITIZE: INTENT_TASK_PRIORITIZE, + ROUTE_TASK_STATUS_TRANSITION: INTENT_TASK_STATUS_TRANSITION, + ROUTE_TASK_DELETE: INTENT_TASK_DELETE, + ROUTE_PERSONNEL_ROLE_UPDATE: INTENT_PERSONNEL_ROLE_UPDATE, + ROUTE_PERSONNEL_WORKLOAD_UPDATE: INTENT_PERSONNEL_WORKLOAD_UPDATE, + ROUTE_PERSONNEL_AVAILABILITY_UPDATE: INTENT_PERSONNEL_AVAILABILITY_UPDATE, + ROUTE_KNOWLEDGE_DOC_UPSERT: INTENT_KNOWLEDGE_DOC_UPSERT, + ROUTE_KNOWLEDGE_CHUNK_UPSERT: INTENT_KNOWLEDGE_CHUNK_UPSERT, + ROUTE_KNOWLEDGE_LINK_UPSERT: INTENT_KNOWLEDGE_LINK_UPSERT, + ROUTE_KNOWLEDGE_REINDEX: INTENT_KNOWLEDGE_REINDEX, + ROUTE_NOTIFICATION_CREATE: INTENT_NOTIFICATION_CREATE, + ROUTE_NOTIFICATION_ACK: INTENT_NOTIFICATION_ACK, + ROUTE_NOTIFICATION_CLOSE: INTENT_NOTIFICATION_CLOSE, ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_COMMIT: INTENT_SIGNAL_CREATE, @@ -84,7 +180,26 @@ ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { INTENT_QUERY: ROUTE_QUERY_ANSWER, INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE, + INTENT_PROJECT_UPDATE: ROUTE_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE: ROUTE_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN: ROUTE_PROJECT_REOPEN, + INTENT_PROJECT_DELETE: ROUTE_PROJECT_DELETE, INTENT_TASK_CREATE: ROUTE_TASK_CREATE, + INTENT_TASK_UPDATE: ROUTE_TASK_UPDATE, + INTENT_TASK_ASSIGN: ROUTE_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE: ROUTE_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION: ROUTE_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE: ROUTE_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE: ROUTE_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE: ROUTE_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE: ROUTE_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT: ROUTE_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT: ROUTE_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT: ROUTE_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX: ROUTE_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE: ROUTE_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK: ROUTE_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE: ROUTE_NOTIFICATION_CLOSE, INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD, INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM, @@ -214,16 +329,54 @@ INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" INTENT_CANDIDATES = ( INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, INTENT_SIGNAL_CREATE, - INTENT_QUERY, INTENT_PROJECT_CREATE, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, + INTENT_QUERY, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE, INTENT_IGNORE, ) WRITE_INTENTS = { INTENT_PROJECT_CREATE, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_FOLLOWUP_CONFIRM, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py index 3e1f5d99..22a9eeaa 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py @@ -7,7 +7,6 @@ import re from dataclasses import dataclass from typing import Iterable, Sequence -BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"} INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel" INTENT_FUNCTION_COMPLEX = "classify_intent_complex" @@ -39,13 +38,6 @@ def _env_float(name: str, default: float) -> float: return float(default) if value < 0 else value -def _env_bool(name: str, default: bool = False) -> bool: - raw = os.getenv(name) - if raw is None: - return default - return str(raw).strip().lower() in BOOL_TRUE_VALUES - - def _normalize_text(text: str) -> str: return re.sub(r"\s+", "", str(text or "")).lower() @@ -167,12 +159,6 @@ def gate_execution( force_manual_code: str = "manual_review_required", force_manual_reason: str = "", ) -> GateDecision: - auto_threshold = _env_float("AGENT_GATE_AUTO_THRESHOLD", 0.82) - review_threshold = _env_float("AGENT_GATE_REVIEW_THRESHOLD", 0.60) - strict_high_risk = _env_bool("AGENT_GATE_STRICT_HIGH_RISK", True) - require_review_below_auto = _env_bool("AGENT_GATE_REVIEW_BELOW_AUTO", False) - normalized_risk = str(risk_level or "medium").strip().lower() - safe_confidence = max(0.0, min(1.0, float(confidence or 0.0))) if force_manual: return GateDecision( @@ -190,34 +176,10 @@ def gate_execution( auto_execute=False, ) - if safe_confidence < review_threshold: - return GateDecision( - action="reject", - gate_code="confidence_too_low", - reason="置信度低于安全阈值,已阻断自动执行。", - auto_execute=False, - ) - - if normalized_risk in {"high", "critical"} and strict_high_risk and safe_confidence < max(auto_threshold, 0.9): - return GateDecision( - action="pending_manual", - gate_code="high_risk_manual_review", - reason="高风险写操作需要更高置信度,当前进入待人工确认。", - auto_execute=False, - ) - - if safe_confidence < auto_threshold and require_review_below_auto: - return GateDecision( - action="pending_manual", - gate_code="confidence_review_required", - reason="置信度未达自动执行阈值,进入待人工确认。", - auto_execute=False, - ) - return GateDecision( action="auto_execute", gate_code="auto_execute_allowed", - reason="置信度达标,允许自动执行。", + reason="基础门禁通过,允许自动执行。", auto_execute=True, ) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py index cb5afa1b..9cee2679 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py @@ -65,6 +65,338 @@ class ReplyRenderer: self.receipt_phase_pending = receipt_phase_pending self.receipt_phase_error = receipt_phase_error + @staticmethod + def _operation_code(*, intent: CanonicalIntent, execution_result: dict[str, Any]) -> str: + action_contract = execution_result.get("action_contract") if isinstance(execution_result, dict) else {} + if isinstance(action_contract, dict): + operation_code = str(action_contract.get("operation_code") or "").strip().lower() + if operation_code: + return operation_code + operation_code = str(execution_result.get("operation_code") or "").strip().lower() + if operation_code: + return operation_code + return str(intent.name or "").strip().lower() + + @staticmethod + def _intent_parameters(*, intent: CanonicalIntent, execution_result: dict[str, Any]) -> dict[str, Any]: + action_contract = execution_result.get("action_contract") if isinstance(execution_result, dict) else {} + if isinstance(action_contract, dict): + params = action_contract.get("params") + if isinstance(params, dict) and params: + return params + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + for key in ("llm_parameters", "parameters"): + params = metadata.get(key) + if isinstance(params, dict) and params: + return params + payload = metadata.get("llm_payload") + if isinstance(payload, dict): + params = payload.get("parameters") + if isinstance(params, dict) and params: + return params + return {} + + def _mutation_target_label(self, *, operation_code: str, intent: CanonicalIntent, execution_result: dict[str, Any]) -> str: + params = self._intent_parameters(intent=intent, execution_result=execution_result) + op = str(operation_code or "").strip().lower() + if op.startswith("project."): + project = execution_result.get("project") if isinstance(execution_result.get("project"), dict) else {} + project_id = _safe_int(execution_result.get("project_id")) or _safe_int(project.get("id")) or _safe_int(params.get("project_id")) + title = str( + project.get("title") + or project.get("name") + or params.get("project_title") + or params.get("project_name") + or params.get("title") + or "" + ).strip() + if project_id is not None and title: + return f"项目#{project_id}《{title}》" + if project_id is not None: + return f"项目#{project_id}" + if title: + return f"项目《{title}》" + return "项目" + if op.startswith("task."): + task = execution_result.get("task") if isinstance(execution_result.get("task"), dict) else {} + task_id = _safe_int(execution_result.get("task_id")) or _safe_int(task.get("id")) or _safe_int(params.get("task_id")) + title = str( + task.get("title") + or params.get("task_title") + or params.get("title") + or "" + ).strip() + if task_id is not None and title: + return f"任务#{task_id}《{title}》" + if task_id is not None: + return f"任务#{task_id}" + if title: + return f"任务《{title}》" + return "任务" + if op.startswith("personnel."): + user = execution_result.get("user") if isinstance(execution_result.get("user"), dict) else {} + user_id = _safe_int(execution_result.get("user_id")) or _safe_int(user.get("id")) or _safe_int(params.get("user_id")) + name = str( + user.get("name") + or user.get("username") + or params.get("user_name") + or params.get("name") + or params.get("username") + or "" + ).strip() + if user_id is not None and name: + return f"用户#{user_id}({name})" + if user_id is not None: + return f"用户#{user_id}" + if name: + return f"用户({name})" + return "用户" + if op.startswith("knowledge."): + doc = execution_result.get("doc") if isinstance(execution_result.get("doc"), dict) else {} + doc_id = _safe_int(execution_result.get("doc_id")) or _safe_int(doc.get("id")) or _safe_int(params.get("doc_id")) + title = str(doc.get("title") or params.get("doc_title") or params.get("title") or "").strip() + if doc_id is not None and title: + return f"知识文档#{doc_id}《{title}》" + if doc_id is not None: + return f"知识文档#{doc_id}" + if title: + return f"知识文档《{title}》" + relation_id = _safe_int(execution_result.get("relation_id")) + if relation_id is not None: + return f"知识关联#{relation_id}" + chunk_id = _safe_int(execution_result.get("chunk_id")) + if chunk_id is not None: + return f"知识分片#{chunk_id}" + return "知识对象" + if op.startswith("notification."): + notification = execution_result.get("notification") if isinstance(execution_result.get("notification"), dict) else {} + notification_id = _safe_int(execution_result.get("notification_id")) or _safe_int(notification.get("id")) or _safe_int(params.get("notification_id")) + title = str(notification.get("title") or params.get("title") or "").strip() + if notification_id is not None and title: + return f"通知#{notification_id}《{title}》" + if notification_id is not None: + return f"通知#{notification_id}" + if title: + return f"通知《{title}》" + return "通知" + return "" + + def _render_mutation_pending( + self, + *, + operation_code: str, + intent: CanonicalIntent, + decision_record: dict[str, Any], + execution_result: dict[str, Any], + ) -> Optional[dict[str, Any]]: + op = str(operation_code or "").strip().lower() + params = self._intent_parameters(intent=intent, execution_result=execution_result) + target = self._mutation_target_label(operation_code=op, intent=intent, execution_result=execution_result) + gate_message = str(((decision_record.get("approval_requirement") or {}).get("message")) or "").strip() + candidate_project_ids = execution_result.get("candidate_project_ids") if isinstance(execution_result.get("candidate_project_ids"), list) else [] + candidate_task_ids = execution_result.get("candidate_task_ids") if isinstance(execution_result.get("candidate_task_ids"), list) else [] + candidate_user_ids = execution_result.get("candidate_user_ids") if isinstance(execution_result.get("candidate_user_ids"), list) else [] + + if op.startswith("project."): + if candidate_project_ids: + ids = "、".join(f"#{_safe_int(item)}" for item in candidate_project_ids[:5] if _safe_int(item) is not None) + return { + "receipt_phase": self.receipt_phase_pending, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "operation_code": op, "candidate_project_ids": candidate_project_ids[:5]}, + "text": f"项目名称对应多个结果:{ids}。请补充 project_id 后再继续。", + } + if target: + prefix = "删除" if op == "project.delete" else "归档" if op == "project.archive" else "重新打开" if op == "project.reopen" else "更新" + text = f"已收到{prefix}{target}的请求,当前需要人工确认后执行。" + if gate_message: + text = f"{text} {gate_message}" + return { + "receipt_phase": self.receipt_phase_pending, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "operation_code": op, "target": target}, + "text": text, + } + if op.startswith("task."): + if candidate_task_ids: + ids = "、".join(f"#{_safe_int(item)}" for item in candidate_task_ids[:5] if _safe_int(item) is not None) + return { + "receipt_phase": self.receipt_phase_pending, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "operation_code": op, "candidate_task_ids": candidate_task_ids[:5]}, + "text": f"任务标题对应多个结果:{ids}。请补充 task_id 后再继续。", + } + if target: + prefix = "删除" if op == "task.delete" else "调整" + text = f"已收到{prefix}{target}的请求,当前需要人工确认后执行。" + if gate_message: + text = f"{text} {gate_message}" + return { + "receipt_phase": self.receipt_phase_pending, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "operation_code": op, "target": target}, + "text": text, + } + if op.startswith("personnel.") and candidate_user_ids: + ids = "、".join(f"#{_safe_int(item)}" for item in candidate_user_ids[:5] if _safe_int(item) is not None) + return { + "receipt_phase": self.receipt_phase_pending, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "operation_code": op, "candidate_user_ids": candidate_user_ids[:5]}, + "text": f"用户名称对应多个结果:{ids}。请补充 user_id 后再继续。", + } + if op.startswith("knowledge.") and target: + text = f"已收到对{target}的处理请求,当前需要人工确认后执行。" + if gate_message: + text = f"{text} {gate_message}" + return { + "receipt_phase": self.receipt_phase_pending, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "operation_code": op, "target": target}, + "text": text, + } + if op.startswith("notification.") and target: + text = f"已收到{target}的处理请求,当前需要人工确认后执行。" + if gate_message: + text = f"{text} {gate_message}" + return { + "receipt_phase": self.receipt_phase_pending, + "template_key": "pending_manual", + "payload": {"intent": intent.name, "operation_code": op, "target": target}, + "text": text, + } + return None + + def _render_mutation_success( + self, + *, + operation_code: str, + intent: CanonicalIntent, + execution_result: dict[str, Any], + ) -> Optional[dict[str, Any]]: + params = self._intent_parameters(intent=intent, execution_result=execution_result) + target = self._mutation_target_label(operation_code=operation_code, intent=intent, execution_result=execution_result) + op = str(operation_code or "").strip().lower() + if op == "project.update": + text = f"已更新{target}。" if target else "已更新项目。" + elif op == "project.archive": + text = f"已归档{target}。" if target else "已归档项目。" + elif op == "project.reopen": + text = f"已重新打开{target}。" if target else "已重新打开项目。" + elif op == "project.delete": + text = f"已删除{target}。" if target else "已删除项目。" + elif op == "task.update": + text = f"已更新{target}。" if target else "已更新任务。" + elif op == "task.assign": + assignee = execution_result.get("assignee") if isinstance(execution_result.get("assignee"), dict) else {} + assignee_name = str(assignee.get("name") or assignee.get("username") or params.get("assignee_name") or params.get("user_name") or params.get("name") or "").strip() + if target and assignee_name: + text = f"已将{target}分配给{assignee_name}。" + elif target: + text = f"已完成{target}分配。" + else: + text = "已完成任务分配。" + elif op == "task.prioritize": + priority = str( + params.get("priority") + or (execution_result.get("task") if isinstance(execution_result.get("task"), dict) else {}).get("priority") + or "" + ).strip() + if target and priority: + text = f"已将{target}的优先级调整为{priority}。" + elif target: + text = f"已调整{target}的优先级。" + else: + text = "已调整任务优先级。" + elif op == "task.status_transition": + status = str( + params.get("status") + or (execution_result.get("task") if isinstance(execution_result.get("task"), dict) else {}).get("status") + or "" + ).strip() + if target and status: + text = f"已将{target}状态更新为{status}。" + elif target: + text = f"已更新{target}状态。" + else: + text = "已更新任务状态。" + elif op == "task.delete": + text = f"已删除{target}。" if target else "已删除任务。" + elif op == "personnel.role_update": + role = str(params.get("role") or (execution_result.get("user") if isinstance(execution_result.get("user"), dict) else {}).get("role") or "").strip() + if target and role: + text = f"已将{target}的角色更新为{role}。" + elif target: + text = f"已更新{target}的角色。" + else: + text = "已更新人员角色。" + elif op == "personnel.workload_update": + workload = execution_result.get("workload") if isinstance(execution_result.get("workload"), dict) else {} + max_hours = workload.get("max_weekly_hours") or params.get("max_weekly_hours") + if target and max_hours is not None: + text = f"已将{target}的每周可用工时更新为{max_hours}。" + elif target: + text = f"已更新{target}的工时设置。" + else: + text = "已更新人员工时。" + elif op == "personnel.availability_update": + status = str(params.get("status") or (execution_result.get("user") if isinstance(execution_result.get("user"), dict) else {}).get("status") or "").strip() + if target and status: + text = f"已将{target}的可用性更新为{status}。" + elif target: + text = f"已更新{target}的可用性。" + else: + text = "已更新人员可用性。" + elif op == "knowledge.doc_upsert": + text = f"已更新{target}。" if target else "已更新知识文档。" + elif op == "knowledge.chunk_upsert": + chunk_id = _safe_int(execution_result.get("chunk_id")) + doc_id = _safe_int(execution_result.get("doc_id")) + if chunk_id is not None and doc_id is not None: + text = f"已更新知识分片#{chunk_id},归属文档#{doc_id}。" + elif chunk_id is not None: + text = f"已更新知识分片#{chunk_id}。" + else: + text = "已更新知识分片。" + elif op == "knowledge.link_upsert": + relation_id = _safe_int(execution_result.get("relation_id")) + if relation_id is not None: + text = f"已建立知识关联#{relation_id}。" + else: + text = "已建立知识关联。" + elif op == "knowledge.reindex": + scope = str(execution_result.get("scope") or params.get("scope") or "tenant").strip().lower() + if scope == "document": + text = "已完成知识文档索引重建。" + elif scope == "project": + text = "已完成项目知识索引重建。" + else: + text = "已完成知识索引重建。" + elif op == "notification.create": + notification_id = _safe_int(execution_result.get("notification_id")) + if target and notification_id is not None: + text = f"已创建{target}并发送。" + elif notification_id is not None: + text = f"已创建通知#{notification_id}并发送。" + else: + text = "已创建通知并发送。" + elif op == "notification.ack": + text = f"已确认{target}。" if target else "已确认通知。" + elif op == "notification.close": + text = f"已关闭{target}。" if target else "已关闭通知。" + else: + return None + return { + "receipt_phase": self.receipt_phase_action, + "template_key": "action_succeeded", + "payload": { + "intent": intent.name, + "operation_code": op, + "target": target or None, + }, + "text": text, + } + def render( self, *, @@ -73,6 +405,7 @@ class ReplyRenderer: decision_record: dict[str, Any], execution_result: dict[str, Any], ) -> dict[str, Any]: + operation_code = self._operation_code(intent=intent, execution_result=execution_result) if work_state == self.state_waiting_manual: if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: followup_text = str(execution_result.get("followup_reply") or "").strip() @@ -96,6 +429,14 @@ class ReplyRenderer: }, "text": pending_text, } + mutation_pending = self._render_mutation_pending( + operation_code=operation_code, + intent=intent, + decision_record=decision_record, + execution_result=execution_result, + ) + if mutation_pending is not None: + return mutation_pending gate_message = str(((decision_record.get("approval_requirement") or {}).get("message")) or "").strip() if not gate_message: gate_message = "已进入待人工确认队列。" @@ -174,6 +515,13 @@ class ReplyRenderer: }, "text": text, } + mutation_success = self._render_mutation_success( + operation_code=operation_code, + intent=intent, + execution_result=execution_result, + ) + if mutation_success is not None: + return mutation_success if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: signal_id = _safe_int(execution_result.get("signal_id")) workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py index 71c93d16..e6594f4f 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py @@ -27,17 +27,71 @@ from ....utils.timezone import now_cn_naive from ...adaptive_rag_service import AdaptiveRAGService from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow from ..ai_pmo_intent import ( + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, INTENT_PROJECT_CREATE, INTENT_QUERY, INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, LEGACY_INTENT_ROUTE_MAP, ROUTE_ACTION_CREATE, + ROUTE_ACTION_UPDATE, + ROUTE_ACTION_ARCHIVE, + ROUTE_ACTION_REOPEN, + ROUTE_ACTION_DELETE, + ROUTE_ACTION_ASSIGN, + ROUTE_ACTION_PRIORITIZE, + ROUTE_ACTION_STATUS_TRANSITION, + ROUTE_ACTION_ROLE_UPDATE, + ROUTE_ACTION_WORKLOAD_UPDATE, + ROUTE_ACTION_AVAILABILITY_UPDATE, + ROUTE_ACTION_DOC_UPSERT, + ROUTE_ACTION_CHUNK_UPSERT, + ROUTE_ACTION_LINK_UPSERT, + ROUTE_ACTION_REINDEX, + ROUTE_ACTION_ACK, + ROUTE_ACTION_CLOSE, ROUTE_ACTION_DISCOVER, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_PROJECT_UPDATE, + ROUTE_PROJECT_ARCHIVE, + ROUTE_PROJECT_REOPEN, + ROUTE_PROJECT_DELETE, ROUTE_TASK_CREATE, + ROUTE_TASK_UPDATE, + ROUTE_TASK_ASSIGN, + ROUTE_TASK_PRIORITIZE, + ROUTE_TASK_STATUS_TRANSITION, + ROUTE_TASK_DELETE, + ROUTE_PERSONNEL_ROLE_UPDATE, + ROUTE_PERSONNEL_WORKLOAD_UPDATE, + ROUTE_PERSONNEL_AVAILABILITY_UPDATE, + ROUTE_KNOWLEDGE_DOC_UPSERT, + ROUTE_KNOWLEDGE_CHUNK_UPSERT, + ROUTE_KNOWLEDGE_LINK_UPSERT, + ROUTE_KNOWLEDGE_REINDEX, + ROUTE_NOTIFICATION_CREATE, + ROUTE_NOTIFICATION_ACK, + ROUTE_NOTIFICATION_CLOSE, CanonicalIntent, IntentResolver, RouteResolver, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py index ce35ad55..557815c0 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py @@ -10,6 +10,10 @@ from ..ai_pmo_intent import ( INTENT_FOLLOWUP_CONTINUE, ROUTE_ACTION_DISCOVER, ROUTE_FAMILY_FOLLOWUP, + ROUTE_FAMILY_KNOWLEDGE, + ROUTE_FAMILY_NOTIFICATION, + ROUTE_FAMILY_PERSONNEL, + ROUTE_FAMILY_PROJECT, ROUTE_FAMILY_PROJECT_CREATE, ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_QUERY, @@ -188,6 +192,31 @@ class TaskCreateWorkflowHandler(WorkflowHandler): ) +class MutationWorkflowHandler(WorkflowHandler): + async def start( + self, + *, + route: WorkflowRoute, + intent: CanonicalIntent, + message_text: str, + sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + ) -> dict[str, object]: + _ = route + return await self.kernel.executor.execute( + intent=intent, + message_text=message_text, + sender=sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + + class SignalSourceWorkflowHandler(WorkflowHandler): async def start( self, @@ -256,6 +285,10 @@ class WorkflowRegistry: ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel), ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel), ROUTE_FAMILY_TASK: TaskCreateWorkflowHandler(kernel), + ROUTE_FAMILY_PROJECT: MutationWorkflowHandler(kernel), + ROUTE_FAMILY_PERSONNEL: MutationWorkflowHandler(kernel), + ROUTE_FAMILY_KNOWLEDGE: MutationWorkflowHandler(kernel), + ROUTE_FAMILY_NOTIFICATION: MutationWorkflowHandler(kernel), ROUTE_FAMILY_PROJECT_SIGNAL: ProjectSignalWorkflowHandler(kernel), ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel), ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel), diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py index 413697a4..3d448c96 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py @@ -3,6 +3,7 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common +from app.utils.thread_context import build_feishu_thread_key for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -95,16 +96,19 @@ class KernelClosurePayloadMixin: owner_user_id: Optional[int] = None, project_id: Optional[int] = None, captured_at: Optional[Any] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: normalized_message = self._normalize_dialogue_text(message_text, max_len=600) turns: list[dict[str, str]] = [] normalized_source_system = str(source_system or "").strip().lower() normalized_channel = str(channel_ref or "").strip() if normalized_source_system == "feishu" and normalized_channel: - conversation_key = self._build_feishu_rag_conversation_key( + conversation_key = build_feishu_thread_key( source_chat_id=normalized_channel, source_sender_open_id=source_sender_open_id, sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, ) session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) if session_id is not None: diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py index 869f1faa..d056e799 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py @@ -3,6 +3,7 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common +from app.utils.thread_context import build_feishu_thread_key for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -36,8 +37,64 @@ class KernelFollowupVerificationMixin: return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []} if intent.name == INTENT_PROJECT_CREATE: return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []} + if intent.name in { + "project.update", + "project.archive", + "project.reopen", + "project.delete", + }: + return { + "verified": _safe_int(execution_result.get("project_id")) is not None + or bool(execution_result.get("deleted")), + "issues": [], + } if intent.name == INTENT_TASK_CREATE: return {"verified": _safe_int(execution_result.get("task_id")) is not None, "issues": []} + if intent.name in { + "task.update", + "task.assign", + "task.prioritize", + "task.status_transition", + "task.delete", + }: + return { + "verified": _safe_int(execution_result.get("task_id")) is not None + or bool(execution_result.get("deleted")), + "issues": [], + } + if intent.name in { + "personnel.role_update", + "personnel.workload_update", + "personnel.availability_update", + }: + return { + "verified": _safe_int(execution_result.get("user_id")) is not None, + "issues": [], + } + if intent.name in { + "knowledge.doc_upsert", + "knowledge.chunk_upsert", + "knowledge.link_upsert", + "knowledge.reindex", + }: + return { + "verified": _safe_int(execution_result.get("doc_id")) + is not None + or _safe_int(execution_result.get("chunk_id")) is not None + or _safe_int(execution_result.get("relation_id")) is not None + or bool(execution_result.get("reindexed")), + "issues": [], + } + if intent.name in { + "notification.create", + "notification.ack", + "notification.close", + }: + return { + "verified": _safe_int(execution_result.get("notification_id")) is not None + or bool(execution_result.get("notification_ids")), + "issues": [], + } if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) signal_id = _safe_int(execution_result.get("signal_id")) @@ -113,13 +170,13 @@ class KernelFollowupVerificationMixin: source_chat_id: Optional[str], source_sender_open_id: Optional[str], sender: Optional[str], + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> str: - chat = str(source_chat_id or "").strip() - if not chat: - return "" - actor = str(source_sender_open_id or "").strip() - if not actor: - actor = str(sender or "").strip().lower() - actor = re.sub(r"\s+", "", actor) - actor = actor[:96] if actor else "unknown" - return f"feishu:{chat}:{actor}" + return build_feishu_thread_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, + ) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py index aba7f514..bcd25372 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py @@ -106,6 +106,8 @@ class KernelIngressRenderMixin: @staticmethod def _primary_operation_code(intent_name: str) -> Optional[str]: normalized_route = RouteResolver.normalize_route_key(intent_name) + if normalized_route in INTENT_CANDIDATES: + return normalized_route if normalized_route == ROUTE_QUERY_ANSWER: return "query.answer" if normalized_route == ROUTE_PROJECT_CREATE: @@ -130,8 +132,36 @@ class KernelIngressRenderMixin: return "query.answer" if intent_name == INTENT_PROJECT_CREATE: return "project.create" + if intent_name in { + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, + }: + return intent_name if intent_name == INTENT_TASK_CREATE: return "task.create" + if intent_name in { + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + }: + return intent_name + if intent_name in { + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, + }: + return intent_name if intent_name == INTENT_SIGNAL_CREATE: return "signal.enter" if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: @@ -144,7 +174,34 @@ class KernelIngressRenderMixin: def _risk_level_for_intent(intent_name: str) -> str: if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: return "high" - if intent_name in {INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + if intent_name in { + INTENT_PROJECT_DELETE, + INTENT_PROJECT_ARCHIVE, + INTENT_TASK_DELETE, + }: + return "high" + if intent_name in { + INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_REOPEN, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, + INTENT_SIGNAL_CREATE, + INTENT_FOLLOWUP_CONFIRM, + INTENT_FOLLOWUP_CONTINUE, + }: return "medium" return "low" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py index c8580c4d..a064201e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py @@ -60,9 +60,52 @@ class KernelIngressStorageMixin: if op == "project.create": entity_id = _safe_int(execution_result.get("project_id")) return ("project", str(entity_id)) if entity_id is not None else ("project", None) + if op in { + "project.update", + "project.archive", + "project.reopen", + "project.delete", + }: + entity_id = _safe_int(execution_result.get("project_id")) + return ("project", str(entity_id)) if entity_id is not None else ("project", None) if op == "task.create": entity_id = _safe_int(execution_result.get("task_id")) return ("task", str(entity_id)) if entity_id is not None else ("task", None) + if op in { + "task.update", + "task.assign", + "task.prioritize", + "task.status_transition", + "task.delete", + }: + entity_id = _safe_int(execution_result.get("task_id")) + return ("task", str(entity_id)) if entity_id is not None else ("task", None) + if op in { + "personnel.role_update", + "personnel.workload_update", + "personnel.availability_update", + }: + entity_id = _safe_int(execution_result.get("user_id")) + return ("user", str(entity_id)) if entity_id is not None else ("user", None) + if op in { + "knowledge.doc_upsert", + "knowledge.chunk_upsert", + "knowledge.link_upsert", + "knowledge.reindex", + }: + entity_id = _safe_int(execution_result.get("doc_id")) + if entity_id is None: + entity_id = _safe_int(execution_result.get("chunk_id")) + if entity_id is None: + entity_id = _safe_int(execution_result.get("relation_id")) + return ("knowledge", str(entity_id)) if entity_id is not None else ("knowledge", None) + if op in { + "notification.create", + "notification.ack", + "notification.close", + }: + entity_id = _safe_int(execution_result.get("notification_id")) + return ("notification", str(entity_id)) if entity_id is not None else ("notification", None) if op in {"signal.enter", ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_CREATE}: entity_id = _safe_int(execution_result.get("signal_id")) return ("signal", str(entity_id)) if entity_id is not None else ("signal", None) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py index dbdd1e29..e022d1b5 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py @@ -19,24 +19,27 @@ class KernelIntentLLMMixin: session: Optional[ConversationSession], world_model: dict[str, Any], rule_intent: Optional[CanonicalIntent] = None, + session_last_intent_override: Optional[str] = None, + session_project_id_override: Optional[int] = None, ) -> Optional[CanonicalIntent]: + session_last_intent = ( + str(session_last_intent_override or "").strip() + if session_last_intent_override is not None + else str(getattr(session, "last_intent", "") or "").strip() if session is not None else "" + ) + if session_project_id_override is not None: + session_project_id = _safe_int(session_project_id_override) + elif session is not None: + session_project_id = _safe_int(getattr(session, "current_project_id", None)) + else: + session_project_id = None prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format( message_text=str(message_text or "")[:1200], - session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "", - session_project_id=_safe_int(getattr(session, "current_project_id", None)) if session is not None else None, + session_last_intent=session_last_intent, + session_project_id=session_project_id, world_model_project_id=_safe_int(world_model.get("resolved_project_id")), candidate_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False), - candidate_routes_json=json.dumps( - [ - ROUTE_QUERY_ANSWER, - ROUTE_PROJECT_CREATE, - ROUTE_TASK_CREATE, - ROUTE_PROJECT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_DISCOVER, - ROUTE_SIGNAL_SOURCE_ONBOARD, - ], - ensure_ascii=False, - ), + candidate_routes_json=json.dumps(sorted(WORKFLOW_ROUTE_KEYS), ensure_ascii=False), ).strip() total_budget_seconds = _intent_llm_total_budget_seconds() stage_started_at = time.perf_counter() @@ -164,12 +167,14 @@ class KernelIntentLLMMixin: project_id=project_id, confidence=confidence, metadata={ - "context_source": "llm", - "stage": stage_label, - "reasoning": reasoning[:300], - "llm_function": function_name, - "route_tier": route_tier, - "route_complex": route_tier == "high", + "context_source": "llm", + "stage": stage_label, + "reasoning": reasoning[:300], + "llm_payload": payload, + "llm_parameters": payload.get("parameters") if isinstance(payload.get("parameters"), dict) else {}, + "llm_function": function_name, + "route_tier": route_tier, + "route_complex": route_tier == "high", "route_reasons": route_reasons, "timeout_seconds": llm_timeout_seconds, "total_budget_seconds": total_budget_seconds, 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 404c2903..022affd5 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 @@ -18,12 +18,18 @@ class KernelIntentResolveMixin: world_model: dict[str, Any], message_sent_at: Optional[Any] = None, session_snapshot: Optional[dict[str, Any]] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> tuple[CanonicalIntent, str]: + reply_anchor_present = bool( + isinstance(thread_context, dict) + and str(thread_context.get("reply_to_message_id") or "").strip() + ) if self._is_short_confirmation_message( message_text=message_text, session=session, message_sent_at=message_sent_at, session_snapshot=session_snapshot, + reply_anchor_present=reply_anchor_present, ) and session is not None: inherited = self._build_short_confirm_intent( message_text=message_text, @@ -50,6 +56,12 @@ class KernelIntentResolveMixin: session=session, world_model=world_model, rule_intent=None, + session_last_intent_override=( + str(getattr(session, "last_intent", "") or "").strip() if reply_anchor_present else "" + ), + session_project_id_override=( + _safe_int(getattr(session, "current_project_id", None)) if reply_anchor_present else None + ), ) if llm_intent is None: raise RuntimeError("intent_model_only_no_valid_llm_intent") diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py index e202035a..28673432 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py @@ -276,7 +276,10 @@ class KernelIntentRulesMixin: session: Optional[ConversationSession], message_sent_at: Optional[Any] = None, session_snapshot: Optional[dict[str, Any]] = None, + reply_anchor_present: bool = False, ) -> bool: + if not reply_anchor_present: + return False snapshot = session_snapshot if isinstance(session_snapshot, dict) else {} if session is None and not snapshot: return False diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py index cbe121b4..16ce8e50 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py @@ -3,6 +3,7 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common +from app.utils.thread_context import build_feishu_thread_key for _name, _value in vars(_common).items(): if not _name.startswith("__"): @@ -31,8 +32,29 @@ class KernelWorldModelSessionMixin: return None return session_project_id - async def _get_or_create_session(self, *, tenant_id: int, sender: str) -> ConversationSession: - sender_key = str(sender or "").strip()[:128] or "unknown" + async def _get_or_create_session( + self, + *, + tenant_id: int, + sender: str, + session_key: Optional[str] = None, + source_chat_id: Optional[str] = None, + source_sender_open_id: Optional[str] = None, + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, + ) -> ConversationSession: + sender_key = ( + str(session_key or "").strip() + or build_feishu_thread_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, + ) + or str(sender or "").strip() + or "unknown" + )[:128] existing = ( await self.db.execute( select(ConversationSession).where( @@ -81,11 +103,25 @@ class KernelWorldModelSessionMixin: *, tenant_id: int, sender: str, + session: Optional[ConversationSession] = None, + session_key: Optional[str] = None, + source_chat_id: Optional[str] = None, + source_sender_open_id: Optional[str] = None, + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, current_project_id: Optional[int], last_intent: Optional[str], last_message_text: Optional[str], ) -> None: - row = await self._get_or_create_session(tenant_id=tenant_id, sender=sender) + row = session or await self._get_or_create_session( + tenant_id=tenant_id, + sender=sender, + session_key=session_key, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + source_message_id=source_message_id, + thread_context=thread_context, + ) if current_project_id is not None: row.current_project_id = int(current_project_id) row.last_intent = (str(last_intent or "").strip()[:64] or None) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py index 4550cb8a..ec3d7345 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py @@ -82,21 +82,60 @@ from ..ai_pmo_intent import ( INTENT_IGNORE, INTENT_LLM_FUNCTION_NAME, INTENT_PROJECT_CREATE, + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, INTENT_QUERY, INTENT_TASK_CREATE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, LEGACY_INTENT_ROUTE_MAP, ROUTE_ACTION_ANSWER, + ROUTE_ACTION_ACK, + ROUTE_ACTION_ARCHIVE, ROUTE_ACTION_CANCEL, + ROUTE_ACTION_CLOSE, ROUTE_ACTION_COMMIT, ROUTE_ACTION_CONFIRM, ROUTE_ACTION_CONTINUE, ROUTE_ACTION_CREATE, ROUTE_ACTION_DISCOVER, + ROUTE_ACTION_DELETE, + ROUTE_ACTION_ASSIGN, + ROUTE_ACTION_PRIORITIZE, + ROUTE_ACTION_REINDEX, + ROUTE_ACTION_REOPEN, + ROUTE_ACTION_ROLE_UPDATE, + ROUTE_ACTION_STATUS_TRANSITION, + ROUTE_ACTION_UPDATE, ROUTE_ACTION_ONBOARD, ROUTE_ACTION_REFRESH, + ROUTE_ACTION_WORKLOAD_UPDATE, + ROUTE_ACTION_AVAILABILITY_UPDATE, + ROUTE_ACTION_DOC_UPSERT, + ROUTE_ACTION_CHUNK_UPSERT, + ROUTE_ACTION_LINK_UPSERT, ROUTE_FAMILY_FOLLOWUP, + ROUTE_FAMILY_KNOWLEDGE, + ROUTE_FAMILY_NOTIFICATION, + ROUTE_FAMILY_PERSONNEL, + ROUTE_FAMILY_PROJECT, ROUTE_FAMILY_PROJECT_CREATE, ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_QUERY, @@ -105,12 +144,31 @@ from ..ai_pmo_intent import ( ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE, ROUTE_PROJECT_CREATE, + ROUTE_PROJECT_ARCHIVE, + ROUTE_PROJECT_DELETE, + ROUTE_PROJECT_REOPEN, + ROUTE_PROJECT_UPDATE, + ROUTE_NOTIFICATION_ACK, + ROUTE_NOTIFICATION_CLOSE, + ROUTE_NOTIFICATION_CREATE, + ROUTE_PERSONNEL_AVAILABILITY_UPDATE, + ROUTE_PERSONNEL_ROLE_UPDATE, + ROUTE_PERSONNEL_WORKLOAD_UPDATE, + ROUTE_KNOWLEDGE_CHUNK_UPSERT, + ROUTE_KNOWLEDGE_DOC_UPSERT, + ROUTE_KNOWLEDGE_LINK_UPSERT, + ROUTE_KNOWLEDGE_REINDEX, ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_QUERY_ANSWER, + ROUTE_TASK_ASSIGN, + ROUTE_TASK_DELETE, + ROUTE_TASK_PRIORITIZE, + ROUTE_TASK_STATUS_TRANSITION, + ROUTE_TASK_UPDATE, ROUTE_SIGNAL_SOURCE_ONBOARD, ROUTE_TASK_CREATE, SESSION_SHORT_CONFIRM_WINDOW_SECONDS, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py index 32261c4e..54a38e72 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py @@ -25,6 +25,7 @@ class KernelIngressFlowAutoMixin: started: float, fallback_intent: CanonicalIntent, resolved_project_for_session: Optional[int], + session: ConversationSession, log_perf_fn: Any, ) -> dict[str, Any]: work_item.state = STATE_PLANNED @@ -149,6 +150,7 @@ class KernelIngressFlowAutoMixin: await self._update_session( tenant_id=self.tenant_id, sender=sender, + session=session, current_project_id=final_project_id, last_intent=STATE_DRAFT_PENDING_CONFIRM, last_message_text=message_text[:200], @@ -331,6 +333,7 @@ class KernelIngressFlowAutoMixin: await self._update_session( tenant_id=self.tenant_id, sender=sender, + session=session, current_project_id=final_project_id, last_intent=final_intent_name, last_message_text=message_text[:200], 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 5151df2e..653fe0cb 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 @@ -62,7 +62,7 @@ class KernelIngressFlowEntryMixin: question=question_part, asked_by=None, asked_via=source_system, - project_id=project_hint_id, + project_id=None, source_chat_id=channel_ref, conversation_key=source_message_id, message_intent="mixed", @@ -98,6 +98,7 @@ class KernelIngressFlowEntryMixin: chat_type: Optional[str] = None, message_sent_at: Optional[Any] = None, session_snapshot: Optional[dict[str, Any]] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: return await self.process_ingress( source_system="feishu", @@ -110,6 +111,11 @@ class KernelIngressFlowEntryMixin: chat_type=chat_type, message_sent_at=message_sent_at, session_snapshot=session_snapshot, + ingress_options=( + {"thread_context": dict(thread_context)} + if isinstance(thread_context, dict) and thread_context + else None + ), ) async def process_ingress( @@ -131,6 +137,7 @@ class KernelIngressFlowEntryMixin: started = time.perf_counter() perf_work_item_id: Optional[int] = None perf_intent_name = "" + thread_context = self._extract_thread_context_from_ingress_options(ingress_options) def _log_perf(stage_name: str, stage_started_at: float) -> None: elapsed_seconds = time.perf_counter() - stage_started_at perf_line = ( @@ -150,6 +157,7 @@ class KernelIngressFlowEntryMixin: captured_at=captured_at, chat_type=chat_type, project_hint_id=project_hint_id, + thread_context=thread_context, ) if ingress_prepare.get("early_result") is not None: return ingress_prepare["early_result"] @@ -160,7 +168,15 @@ class KernelIngressFlowEntryMixin: operator = ingress_prepare["operator"] work_item = ingress_prepare["work_item"] session = ingress_prepare["session"] - if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: + self._merge_ingress_thread_context( + ingest_event=ingest_event, + ingress_options=ingress_options, + ) + if self._should_route_to_draft_confirmation( + session=session, + message_text=clean_text, + ingress_options=ingress_options, + ): draft_capture = await self._maybe_handle_draft_confirmation_ingress( work_item=work_item, ingest_event=ingest_event, @@ -172,6 +188,7 @@ class KernelIngressFlowEntryMixin: channel_ref=channel_ref, message_text=clean_text, started=started, + ingress_options=ingress_options, ) if draft_capture is not None: return draft_capture @@ -208,6 +225,7 @@ class KernelIngressFlowEntryMixin: world_model=world_model, message_sent_at=message_sent_at, session_snapshot=session_snapshot, + thread_context=thread_context, ) intent = self._enrich_signal_source_intent_from_closure( intent=intent, @@ -229,6 +247,12 @@ class KernelIngressFlowEntryMixin: **(intent.metadata or {}), "rag_request_context": dict(rag_request_context), } + 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, + } workflow_route = self.route_resolver.from_canonical( canonical_intent=intent, message_text=clean_text, @@ -258,6 +282,7 @@ class KernelIngressFlowEntryMixin: return await self._finalize_ignored_ingress( work_item=work_item, ingest_event=ingest_event, + session=session, synthetic_message_id=synthetic_message_id, clean_text=clean_text, intent=intent, @@ -348,6 +373,7 @@ class KernelIngressFlowEntryMixin: stage_started_at = time.perf_counter() capture_result = await self._finalize_waiting_manual_ingress( work_item=work_item, + session=session, intent=intent, world_model=world_model, decision_payload=decision_payload, @@ -377,13 +403,14 @@ class KernelIngressFlowEntryMixin: message_text=clean_text, sender=sender, channel_ref=channel_ref, - source_sender_open_id=source_sender_open_id, - ingest_event=ingest_event, - started=started, - fallback_intent=fallback_intent, - resolved_project_for_session=resolved_project_for_session, - log_perf_fn=_log_perf, - ) + source_sender_open_id=source_sender_open_id, + ingest_event=ingest_event, + started=started, + fallback_intent=fallback_intent, + resolved_project_for_session=resolved_project_for_session, + session=session, + log_perf_fn=_log_perf, + ) except Exception as exc: await self._persist_kernel_failure_with_fresh_session( work_item_id=_safe_int(getattr(work_item, "id", None)), @@ -414,6 +441,7 @@ class KernelIngressFlowEntryMixin: channel_ref: Optional[str], message_text: str, started: float, + ingress_options: Optional[dict[str, Any]], ) -> Optional[dict[str, Any]]: from ..draft_confirm_handler import DraftConfirmHandler @@ -426,6 +454,7 @@ class KernelIngressFlowEntryMixin: channel_ref=channel_ref, source_sender_open_id=source_sender_open_id, expected_version=None, + thread_context=self._extract_thread_context_from_ingress_options(ingress_options), ) if not bool(decision.get("handled")): return None @@ -513,6 +542,7 @@ class KernelIngressFlowEntryMixin: await self._update_session( tenant_id=self.tenant_id, sender=sender, + session=session, current_project_id=_safe_int(decision.get("project_id")), last_intent=next_session_intent, last_message_text=message_text[:200], @@ -548,6 +578,78 @@ class KernelIngressFlowEntryMixin: classification="command", ) + @staticmethod + def _extract_thread_context_from_ingress_options( + ingress_options: Optional[dict[str, Any]], + ) -> dict[str, Optional[str]]: + if not isinstance(ingress_options, dict): + return {} + raw_context = ingress_options.get("thread_context") + if not isinstance(raw_context, dict): + return {} + normalized: dict[str, Optional[str]] = {} + for key in ("thread_id", "root_message_id", "reply_to_message_id"): + value = str(raw_context.get(key) or "").strip() + if value: + normalized[key] = value[:128] + return normalized + + @classmethod + def _has_explicit_draft_reply_anchor( + cls, + *, + message_text: str, + ingress_options: Optional[dict[str, Any]], + ) -> bool: + from ..draft_confirm_handler import DraftConfirmHandler + + if DraftConfirmHandler._extract_work_item_id(message_text) is not None: + return True + thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) + return bool(str(thread_context.get("reply_to_message_id") or "").strip()) + + @classmethod + def _should_route_to_draft_confirmation( + cls, + *, + session: ConversationSession, + message_text: str, + ingress_options: Optional[dict[str, Any]], + ) -> bool: + _ = session + from ..draft_confirm_handler import DraftConfirmHandler + + if DraftConfirmHandler._extract_work_item_id(message_text) is not None: + return True + + thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) + if not str(thread_context.get("reply_to_message_id") or "").strip(): + return False + + action, _ = DraftConfirmHandler.parse_action_from_text(message_text=message_text) + return action in { + DraftConfirmHandler._ACTION_CONFIRM, + DraftConfirmHandler._ACTION_REVISE, + DraftConfirmHandler._ACTION_CANCEL, + } + + @classmethod + def _merge_ingress_thread_context( + cls, + *, + ingest_event: IngestEvent, + ingress_options: Optional[dict[str, Any]], + ) -> None: + thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) + if not thread_context: + return + raw_payload = getattr(ingest_event, "payload", None) + payload = raw_payload if isinstance(raw_payload, dict) else {} + ingest_event.payload = { + **payload, + **thread_context, + } + async def _persist_kernel_failure_with_fresh_session( self, *, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py index c13065ed..a3a613f8 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from app.utils.thread_context import build_feishu_thread_key class KernelIngressFlowPrepareManualMixin: async def _prepare_ingress_envelope( @@ -15,6 +16,7 @@ class KernelIngressFlowPrepareManualMixin: captured_at: Optional[datetime], chat_type: Optional[str], project_hint_id: Optional[int], + thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: clean_text = self._normalize_text(message_text) if not clean_text: @@ -50,6 +52,26 @@ class KernelIngressFlowPrepareManualMixin: } } + session_key = ( + build_feishu_thread_key( + source_chat_id=channel_ref, + source_sender_open_id=source_sender_open_id, + sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, + ) + if str(source_system or "").strip().lower() == "feishu" + else "" + ) + logger.info( + "ai_pmo_ingress_session_key_snapshot", + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + session_key=session_key or None, + thread_context={k: str(v)[:128] for k, v in (thread_context or {}).items()} if isinstance(thread_context, dict) else None, + ) + synthetic_message_id = self._build_source_message_id( source_system=source_system, source_message_id=source_message_id, @@ -66,6 +88,7 @@ class KernelIngressFlowPrepareManualMixin: source_message_id=synthetic_message_id, captured_at=captured_at, project_id=project_hint_id, + thread_context=thread_context, ) ingest_event, created = await self._claim_ingress_event( @@ -103,7 +126,15 @@ class KernelIngressFlowPrepareManualMixin: source_message_id=synthetic_message_id, owner_user_id=_safe_int(getattr(operator, "id", None)), ) - session = await self._get_or_create_session(tenant_id=self.tenant_id, sender=sender) + session = await self._get_or_create_session( + tenant_id=self.tenant_id, + sender=sender, + session_key=session_key, + source_chat_id=channel_ref, + source_sender_open_id=source_sender_open_id, + source_message_id=synthetic_message_id, + thread_context=thread_context, + ) return { "early_result": None, "clean_text": clean_text, @@ -113,6 +144,7 @@ class KernelIngressFlowPrepareManualMixin: "operator": operator, "work_item": work_item, "session": session, + "session_key": session_key or None, } async def _finalize_ignored_ingress( @@ -120,6 +152,7 @@ class KernelIngressFlowPrepareManualMixin: *, work_item: Any, ingest_event: Any, + session: ConversationSession, synthetic_message_id: str, clean_text: str, intent: CanonicalIntent, @@ -144,6 +177,7 @@ class KernelIngressFlowPrepareManualMixin: await self._update_session( tenant_id=self.tenant_id, sender=sender, + session=session, current_project_id=resolved_project_for_session, last_intent=intent.name, last_message_text=clean_text[:200], @@ -162,6 +196,7 @@ class KernelIngressFlowPrepareManualMixin: self, *, work_item: Any, + session: ConversationSession, intent: CanonicalIntent, world_model: dict[str, Any], decision_payload: dict[str, Any], @@ -209,7 +244,7 @@ class KernelIngressFlowPrepareManualMixin: work_state=STATE_WAITING_MANUAL, intent=intent, decision_record={"approval_requirement": policy.get("approval_requirement")}, - execution_result={}, + execution_result=work_item.execution_result if isinstance(work_item.execution_result, dict) else {}, ) work_item.reply_template_key = str(render["template_key"]) await self._project_waiting_manual( @@ -271,6 +306,7 @@ class KernelIngressFlowPrepareManualMixin: await self._update_session( tenant_id=self.tenant_id, sender=sender, + session=session, current_project_id=_safe_int(work_item.project_id), last_intent=intent.name, last_message_text=message_text[:200], diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py index 6e17aa4f..a04e3fd5 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_builder.py @@ -82,6 +82,31 @@ class KernelWorldModelCoreBuilderMixin: risk_summary = await self._build_risk_summary(project_id=resolved_project_id, query_tables=queried_tables) dependencies = self._build_dependency_summary(charter=charter, task_summary=task_summary) recent_signals = await self._build_recent_signal_summary(project_id=resolved_project_id, query_tables=queried_tables) + self._record_world_model_query_table(queried_tables, "projects") + active_projects_rows = ( + await self.db.execute( + select(Project.id, Project.name, Project.title, Project.description, Project.status, Project.owner_id) + .where( + and_( + Project.tenant_id == self.tenant_id, + Project.status != "inactive", + ) + ) + .order_by(Project.id.desc()) + .limit(20) + ) + ).all() + active_projects = [ + { + "id": int(row.id), + "name": str(row.name or ""), + "title": str(row.title or row.name or ""), + "description": str(row.description or ""), + "status": str(row.status or ""), + "owner_id": _safe_int(row.owner_id), + } + for row in active_projects_rows + ] world_model: WorldModel = { "sender": sender, @@ -98,6 +123,7 @@ class KernelWorldModelCoreBuilderMixin: "risk_summary": risk_summary, "dependencies": dependencies, "recent_signals": recent_signals, + "active_projects": active_projects, "decision_context_snapshot": { "captured_at": _utc_now_naive().isoformat(), "project_id": resolved_project_id, diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_mutations.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_mutations.py new file mode 100644 index 00000000..a4741f6c --- /dev/null +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_mutations.py @@ -0,0 +1,1857 @@ +"""Mutation intent execution for typed executor.""" + +from __future__ import annotations + +from typing import Iterable, Optional + +from sqlalchemy import and_, delete, func, or_, select, update + +from ....models import ( + AIOrchestrationRun, + AIWorkerExecution, + Budget, + ConversationSession, + CostTracking, + Experiment, + KnowledgeBaseChunk, + KnowledgeBaseDoc, + KGEntity, + KGRelation, + Notification, + OperationExecutionLedger, + OperationLog, + Project, + ProjectSignalLink, + SignalSourceRegistry, + Task, + TruthNode, + User, + UserProject, + UserSkillBinding, + UserWorkload, + WorkItem, +) +from ....models.enums import UserStatus +from ....services.adaptive_rag_service import AdaptiveRAGService +from ....services.context_cache_service import context_cache_service +from ....services.notification_service import NotificationService +from ....services.operation_log_service import log_operation +from ....services.project_knowledge_graph_service import ProjectKnowledgeGraphService +from ....services.task_persistence_core import normalize_due_date, normalize_estimated_hours, normalize_int_list, normalize_task_priority, normalize_task_status +from ....services.workload import ensure_user_workload, recalculate_user_workload_ratio +from ....services.workload.shared import LEVEL_HOURS_MULTIPLIER +from ....utils.title import compact_title +from ....utils.timezone import now_cn_naive +from .shared import * # noqa: F401, F403 + + +WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static" + + +class ExecuteMutationsMixin: + """Handle non-create write intents with typed DB mutations.""" + + @staticmethod + def _intent_parameters(intent) -> dict[str, Any]: + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + for key in ("llm_parameters", "parameters"): + params = metadata.get(key) + if isinstance(params, dict) and params: + return params + payload = metadata.get("llm_payload") + if isinstance(payload, dict): + params = payload.get("parameters") + if isinstance(params, dict) and params: + return params + return {} + + @staticmethod + def _clean_text(value: Any, *, max_len: int = 5000) -> Optional[str]: + text = str(value or "").strip() + if not text: + return None + return text[:max_len] + + @staticmethod + def _clean_title(value: Any, *, max_len: int = 80) -> Optional[str]: + title = compact_title(str(value or "").strip(), max_len=max_len) + return title or None + + @staticmethod + def _normalize_project_status(value: Any, *, default: str = "active") -> str: + raw = str(value or "").strip().lower() + if raw in {"active", "archived", "done", "inactive"}: + return raw + if raw in {"已归档", "归档", "archive"}: + return "archived" + if raw in {"完成", "completed", "closed"}: + return "done" + if raw in {"停用", "inactive"}: + return "inactive" + return default + + @staticmethod + def _normalize_user_status(value: Any, *, default: str = "active") -> str: + raw = str(value or "").strip().lower() + if raw in {"active", "away", "offline"}: + return raw + if raw in {"离开", "away"}: + return "away" + if raw in {"离线", "停用", "offline"}: + return "offline" + return default + + @staticmethod + def _serialize_project(project: Project) -> dict[str, Any]: + return { + "id": int(project.id), + "title": str(project.title or project.name or "").strip(), + "name": str(project.name or project.title or "").strip(), + "description": project.description, + "status": project.status, + "domain_id": int(project.domain_id) if project.domain_id is not None else None, + "assigned_role": project.assigned_role, + "owner_id": int(project.owner_id) if project.owner_id is not None else None, + "signal_count": int(project.signal_count or 0), + "created_at": project.created_at.isoformat() if project.created_at else None, + "updated_at": project.updated_at.isoformat() if project.updated_at else None, + } + + @staticmethod + def _serialize_task(task: Task) -> dict[str, Any]: + return { + "id": int(task.id), + "truth_node_id": int(task.truth_node_id), + "project_id": int(task.project_id), + "assignee_id": int(task.assignee_id) if task.assignee_id is not None else None, + "title": str(task.title or "").strip(), + "description": task.description, + "required_skill_tag_ids": list(task.required_skill_tag_ids or []), + "status": str(task.status or "").strip(), + "priority": str(task.priority or "").strip(), + "estimated_hours": float(task.estimated_hours) if task.estimated_hours is not None else None, + "due_date": task.due_date.isoformat() if task.due_date else None, + "completed_at": task.completed_at.isoformat() if task.completed_at else None, + "updated_at": task.updated_at.isoformat() if task.updated_at else None, + } + + @staticmethod + def _serialize_user_row(user: User) -> dict[str, Any]: + return { + "id": int(user.id), + "username": str(user.username or "").strip(), + "name": str(user.name or "").strip(), + "email": str(user.email or "").strip(), + "role": str(user.role or "").strip(), + "assigned_domains": user.assigned_domains or [], + "workload": int(user.workload or 0), + "status": str(user.status or "").strip(), + "is_active": bool(user.is_active), + "created_at": user.created_at.isoformat() if user.created_at else None, + "updated_at": user.updated_at.isoformat() if user.updated_at else None, + } + + @staticmethod + def _serialize_notification(row: Notification) -> dict[str, Any]: + return { + "id": int(row.id), + "user_id": int(row.user_id), + "title": str(row.title or "").strip(), + "content": row.content, + "category": row.category, + "source": row.source, + "entity_type": row.entity_type, + "entity_id": int(row.entity_id) if row.entity_id is not None else None, + "target_url": row.target_url, + "kind": row.kind, + "priority": row.priority, + "status": row.status, + "blocking_reason": row.blocking_reason, + "recommended_action": row.recommended_action, + "dedupe_key": row.dedupe_key, + "occurrence_count": int(row.occurrence_count or 1), + "payload": row.payload if isinstance(row.payload, dict) else None, + "is_read": bool(row.is_read), + "read_at": row.read_at.isoformat() if row.read_at else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } + + @staticmethod + def _serialize_doc(doc: KnowledgeBaseDoc) -> dict[str, Any]: + return { + "id": int(doc.id), + "title": str(doc.title or "").strip(), + "file_type": str(doc.file_type or "").strip(), + "source_type": str(doc.source_type or "").strip(), + "file_url": doc.file_url, + "uploaded_by": int(doc.uploaded_by) if doc.uploaded_by is not None else None, + "status": str(doc.status or "").strip(), + "last_fetched_at": doc.last_fetched_at.isoformat() if doc.last_fetched_at else None, + "error_message": doc.error_message, + "created_at": doc.created_at.isoformat() if doc.created_at else None, + "updated_at": doc.updated_at.isoformat() if doc.updated_at else None, + } + + @staticmethod + def _mutation_failed(operation_code: str, message: str, **payload: Any) -> dict[str, Any]: + return { + "status": "failed_safe", + "operation_code": operation_code, + "error_message": message, + **payload, + } + + @staticmethod + def _mutation_pending(operation_code: str, message: str, **payload: Any) -> dict[str, Any]: + return { + "status": "pending_manual", + "operation_code": operation_code, + "error_message": message, + **payload, + } + + async def _lookup_project_candidates( + self, + *, + project_id: Optional[int], + project_title: Optional[str], + project_name: Optional[str], + ) -> list[Project]: + if project_id is not None: + row = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == int(self.tenant_id), + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + return [row] if row is not None else [] + + raw = self._clean_title(project_title or project_name, max_len=80) + if not raw: + return [] + lowered = raw.lower() + rows = ( + await self.db.execute( + select(Project) + .where(Project.tenant_id == int(self.tenant_id)) + .where( + or_( + func.lower(Project.title) == lowered, + func.lower(Project.name) == lowered, + ) + ) + .order_by(Project.updated_at.desc(), Project.id.desc()) + ) + ).scalars().all() + if rows: + return list(rows) + return [] + + async def _resolve_project( + self, + *, + project_id: Optional[int], + project_title: Optional[str], + project_name: Optional[str], + ) -> tuple[Optional[Project], Optional[list[Project]]]: + candidates = await self._lookup_project_candidates( + project_id=project_id, + project_title=project_title, + project_name=project_name, + ) + if not candidates: + return None, None + if len(candidates) > 1 and project_id is None: + return None, candidates + return candidates[0], None + + async def _lookup_task_candidates( + self, + *, + task_id: Optional[int], + task_title: Optional[str], + project_id: Optional[int], + ) -> list[Task]: + if task_id is not None: + row = ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == int(self.tenant_id), + Task.id == int(task_id), + ) + ) + ) + ).scalar_one_or_none() + return [row] if row is not None else [] + + raw = self._clean_title(task_title, max_len=120) + if not raw: + return [] + lowered = raw.lower() + stmt = select(Task).where(Task.tenant_id == int(self.tenant_id)) + if project_id is not None: + stmt = stmt.where(Task.project_id == int(project_id)) + rows = ( + await self.db.execute( + stmt.where(func.lower(Task.title) == lowered).order_by(Task.updated_at.desc(), Task.id.desc()) + ) + ).scalars().all() + return list(rows) + + async def _resolve_task( + self, + *, + task_id: Optional[int], + task_title: Optional[str], + project_id: Optional[int], + ) -> tuple[Optional[Task], Optional[list[Task]]]: + candidates = await self._lookup_task_candidates( + task_id=task_id, + task_title=task_title, + project_id=project_id, + ) + if not candidates: + return None, None + if len(candidates) > 1 and task_id is None: + return None, candidates + return candidates[0], None + + async def _resolve_user( + self, + *, + user_id: Optional[int], + user_name: Optional[str], + username: Optional[str] = None, + ) -> tuple[Optional[User], Optional[list[User]]]: + if user_id is not None: + row = ( + await self.db.execute( + select(User).where( + and_( + User.tenant_id == int(self.tenant_id), + User.id == int(user_id), + ) + ) + ) + ).scalar_one_or_none() + return row, None + + raw = self._clean_title(user_name or username, max_len=100) + if not raw: + return None, None + lowered = raw.lower() + rows = ( + await self.db.execute( + select(User) + .where(User.tenant_id == int(self.tenant_id)) + .where( + or_( + func.lower(User.name) == lowered, + func.lower(User.username) == lowered, + ) + ) + .order_by(User.updated_at.desc(), User.id.desc()) + ) + ).scalars().all() + if rows: + if len(rows) > 1: + return None, list(rows) + return rows[0], None + return None, None + + async def _resolve_notification( + self, + *, + notification_id: Optional[int], + user_id: Optional[int], + ) -> Notification | None: + if notification_id is None: + return None + stmt = select(Notification).where( + and_( + Notification.tenant_id == int(self.tenant_id), + Notification.id == int(notification_id), + ) + ) + if user_id is not None: + stmt = stmt.where(Notification.user_id == int(user_id)) + return (await self.db.execute(stmt)).scalar_one_or_none() + + @staticmethod + def _world_model_cache_key(tenant_id: int, project_id: int) -> str: + return f"{WORLD_MODEL_STATIC_CACHE_KEY_PREFIX}:{int(tenant_id)}:{int(project_id)}" + + async def _invalidate_project_cache( + self, + *, + project_id: Optional[int], + user_ids: Optional[Iterable[int]] = None, + ) -> None: + await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) + if project_id is not None: + await context_cache_service.delete_keys( + self._world_model_cache_key(int(self.tenant_id), int(project_id)) + ) + if user_ids: + for user_id in user_ids: + if _safe_int(user_id, 0) and _safe_int(user_id, 0) > 0: + await context_cache_service.invalidate_user_context( + tenant_id=self.tenant_id, + user_id=int(user_id), + ) + + async def _invalidate_user_project_caches(self, *, user_id: Optional[int]) -> None: + if user_id is None: + return + project_ids = ( + await self.db.execute( + select(UserProject.project_id).where( + and_( + UserProject.tenant_id == int(self.tenant_id), + UserProject.user_id == int(user_id), + ) + ) + ) + ).scalars().all() + for project_id in {int(pid) for pid in project_ids if pid is not None}: + await context_cache_service.delete_keys( + self._world_model_cache_key(int(self.tenant_id), int(project_id)) + ) + + async def _refresh_user_workload_without_commit(self, *, user_id: int) -> Optional[UserWorkload]: + workload = await ensure_user_workload(self.db, int(self.tenant_id), int(user_id)) + task_rows = ( + await self.db.execute( + select(Task).where( + and_( + Task.tenant_id == int(self.tenant_id), + Task.assignee_id == int(user_id), + func.lower(Task.status).notin_(["done", "completed", "closed"]), + ) + ) + ) + ).scalars().all() + skill_rows = ( + await self.db.execute( + select( + UserSkillBinding.skill_tag_id, + UserSkillBinding.level, + UserSkillBinding.proficiency_level, + UserSkillBinding.proficiency, + ).where( + and_( + UserSkillBinding.tenant_id == int(self.tenant_id), + UserSkillBinding.user_id == int(user_id), + ) + ) + ) + ).all() + + def normalize_level( + level: Optional[str], + proficiency_level: Optional[str], + proficiency: Optional[int], + ) -> str: + raw = (level or proficiency_level or "").strip().lower() + if raw in LEVEL_HOURS_MULTIPLIER: + return raw + score = int(proficiency or 0) + if score >= 85: + return "expert" + if score >= 70: + return "advanced" + if score >= 50: + return "intermediate" + return "beginner" + + level_map: dict[int, str] = { + int(skill_tag_id): normalize_level(level, proficiency_level, proficiency) + for skill_tag_id, level, proficiency_level, proficiency in skill_rows + } + + def effective_estimated_hours(task: Task) -> float: + base = float(task.estimated_hours or 0.0) + if base <= 0: + return 0.0 + required_ids = normalize_int_list(task.required_skill_tag_ids) + if not required_ids: + return base + multipliers = [ + LEVEL_HOURS_MULTIPLIER.get(level_map[sid], 1.0) + for sid in required_ids + if sid in level_map + ] + if not multipliers: + return base + factor = sum(multipliers) / len(multipliers) + return round(base * factor, 2) + + workload.current_assigned_hours = round( + sum(effective_estimated_hours(task) for task in task_rows), + 2, + ) + await recalculate_user_workload_ratio(self.db, int(self.tenant_id), int(user_id), commit=False) + return workload + + async def _log_operation( + self, + *, + actor_user_id: Optional[int], + project_id: Optional[int], + entity_type: str, + entity_id: str, + action: str, + before_data: Optional[dict[str, Any]] = None, + after_data: Optional[dict[str, Any]] = None, + ) -> None: + if actor_user_id is None: + return + await log_operation( + self.db, + tenant_id=int(self.tenant_id), + user_id=int(actor_user_id), + project_id=int(project_id) if project_id is not None else None, + entity_type=entity_type, + entity_id=str(entity_id), + action=action, + before_data=before_data, + after_data=after_data, + caller="ai", + remark="ai_pmo_typed_intent", + ) + + async def _cleanup_project_dependencies(self, *, project_id: int) -> None: + task_ids_subquery = ( + select(Task.id) + .where( + and_( + Task.tenant_id == int(self.tenant_id), + Task.project_id == int(project_id), + ) + ) + .scalar_subquery() + ) + + await self.db.execute( + update(CostTracking) + .where( + and_( + CostTracking.tenant_id == int(self.tenant_id), + CostTracking.task_id.in_(task_ids_subquery), + ) + ) + .values(task_id=None) + ) + await self.db.execute( + delete(AIWorkerExecution).where( + and_( + AIWorkerExecution.tenant_id == int(self.tenant_id), + AIWorkerExecution.project_id == int(project_id), + ) + ) + ) + await self.db.execute( + delete(AIOrchestrationRun).where( + and_( + AIOrchestrationRun.tenant_id == int(self.tenant_id), + AIOrchestrationRun.project_id == int(project_id), + ) + ) + ) + await self.db.execute( + delete(SignalSourceRegistry).where( + and_( + SignalSourceRegistry.tenant_id == int(self.tenant_id), + SignalSourceRegistry.project_id == int(project_id), + ) + ) + ) + await self.db.execute( + delete(ProjectSignalLink).where( + and_( + ProjectSignalLink.tenant_id == int(self.tenant_id), + ProjectSignalLink.project_id == int(project_id), + ) + ) + ) + await self.db.execute( + delete(Task).where( + and_( + Task.tenant_id == int(self.tenant_id), + Task.project_id == int(project_id), + ) + ) + ) + await self.db.execute( + delete(UserProject).where( + and_( + UserProject.tenant_id == int(self.tenant_id), + UserProject.project_id == int(project_id), + ) + ) + ) + await self.db.execute( + update(WorkItem) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.project_id == int(project_id), + ) + ) + .values(project_id=None) + ) + await self.db.execute( + update(OperationLog) + .where( + and_( + OperationLog.tenant_id == int(self.tenant_id), + OperationLog.project_id == int(project_id), + ) + ) + .values(project_id=None) + ) + await self.db.execute( + update(OperationExecutionLedger) + .where( + and_( + OperationExecutionLedger.tenant_id == int(self.tenant_id), + OperationExecutionLedger.project_id == int(project_id), + ) + ) + .values(project_id=None) + ) + await self.db.execute( + update(Experiment) + .where( + and_( + Experiment.tenant_id == int(self.tenant_id), + Experiment.project_id == int(project_id), + ) + ) + .values(project_id=None) + ) + await self.db.execute( + update(Budget) + .where( + and_( + Budget.tenant_id == int(self.tenant_id), + Budget.project_id == int(project_id), + ) + ) + .values(project_id=None) + ) + await self.db.execute( + update(ConversationSession) + .where( + and_( + ConversationSession.tenant_id == int(self.tenant_id), + ConversationSession.current_project_id == int(project_id), + ) + ) + .values(current_project_id=None) + ) + + async def _execute_mutation_intent( + self, + *, + intent, + message_text: str, + operator_user_id: Optional[int], + ) -> dict[str, Any]: + operation_code = str(intent.name or "").strip().lower() + params = self._intent_parameters(intent) + if operation_code == INTENT_PROJECT_UPDATE: + return await self._execute_project_update_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_PROJECT_ARCHIVE: + return await self._execute_project_archive_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_PROJECT_REOPEN: + return await self._execute_project_reopen_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_PROJECT_DELETE: + return await self._execute_project_delete_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_TASK_UPDATE: + return await self._execute_task_update_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_TASK_ASSIGN: + return await self._execute_task_assign_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_TASK_PRIORITIZE: + return await self._execute_task_prioritize_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_TASK_STATUS_TRANSITION: + return await self._execute_task_status_transition_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_TASK_DELETE: + return await self._execute_task_delete_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_PERSONNEL_ROLE_UPDATE: + return await self._execute_personnel_role_update_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_PERSONNEL_WORKLOAD_UPDATE: + return await self._execute_personnel_workload_update_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_PERSONNEL_AVAILABILITY_UPDATE: + return await self._execute_personnel_availability_update_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_KNOWLEDGE_DOC_UPSERT: + return await self._execute_knowledge_doc_upsert_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_KNOWLEDGE_CHUNK_UPSERT: + return await self._execute_knowledge_chunk_upsert_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_KNOWLEDGE_LINK_UPSERT: + return await self._execute_knowledge_link_upsert_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_KNOWLEDGE_REINDEX: + return await self._execute_knowledge_reindex_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_NOTIFICATION_CREATE: + return await self._execute_notification_create_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_NOTIFICATION_ACK: + return await self._execute_notification_ack_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + if operation_code == INTENT_NOTIFICATION_CLOSE: + return await self._execute_notification_close_intent( + intent=intent, + params=params, + operator_user_id=operator_user_id, + ) + return self._mutation_failed( + operation_code, + "当前写意图尚未迁移到 AI PMO typed executor。", + intent=operation_code, + ) + + async def _execute_project_update_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project, candidates = await self._resolve_project( + project_id=_safe_int(params.get("project_id")) or _safe_int(intent.project_id), + project_title=params.get("project_title") or params.get("title"), + project_name=params.get("project_name"), + ) + if project is None: + if candidates: + candidate_ids = [int(row.id) for row in candidates[:5]] + return self._mutation_pending( + intent.name, + "当前项目名称对应多个项目,请补充 project_id 后再执行。", + candidate_project_ids=candidate_ids, + ) + return self._mutation_failed(intent.name, "未找到可更新的项目。", project_id=None) + + before = self._serialize_project(project) + if (title := self._clean_title(params.get("title"), max_len=80)) is not None: + project.title = title + project.name = title + if (description := self._clean_text(params.get("description"), max_len=4000)) is not None: + project.description = description + if params.get("status") is not None: + project.status = self._normalize_project_status(params.get("status"), default=project.status) + if params.get("domain_id") is not None: + try: + project.domain_id = int(params.get("domain_id")) + except (TypeError, ValueError): + return self._mutation_failed(intent.name, "domain_id 非法。", project_id=int(project.id)) + if params.get("assigned_role") is not None: + project.assigned_role = self._clean_text(params.get("assigned_role"), max_len=50) + + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action=intent.name, + before_data=before, + after_data=self._serialize_project(project), + ) + await self.db.commit() + await self.db.refresh(project) + await self._invalidate_project_cache(project_id=int(project.id)) + return { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(project.id), + "project": self._serialize_project(project), + } + + async def _execute_project_archive_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project, candidates = await self._resolve_project( + project_id=_safe_int(params.get("project_id")) or _safe_int(intent.project_id), + project_title=params.get("project_title") or params.get("title"), + project_name=params.get("project_name"), + ) + if project is None: + if candidates: + return self._mutation_pending(intent.name, "项目名称重复,请补充 project_id。") + return self._mutation_failed(intent.name, "未找到可归档的项目。", project_id=None) + before = self._serialize_project(project) + project.status = "archived" + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action=intent.name, + before_data=before, + after_data=self._serialize_project(project), + ) + await self.db.commit() + await self.db.refresh(project) + await self._invalidate_project_cache(project_id=int(project.id)) + return { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(project.id), + "project": self._serialize_project(project), + } + + async def _execute_project_reopen_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project, candidates = await self._resolve_project( + project_id=_safe_int(params.get("project_id")) or _safe_int(intent.project_id), + project_title=params.get("project_title") or params.get("title"), + project_name=params.get("project_name"), + ) + if project is None: + if candidates: + return self._mutation_pending(intent.name, "项目名称重复,请补充 project_id。") + return self._mutation_failed(intent.name, "未找到可重新打开的项目。", project_id=None) + before = self._serialize_project(project) + project.status = "active" + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action=intent.name, + before_data=before, + after_data=self._serialize_project(project), + ) + await self.db.commit() + await self.db.refresh(project) + await self._invalidate_project_cache(project_id=int(project.id)) + return { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(project.id), + "project": self._serialize_project(project), + } + + async def _execute_project_delete_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project, candidates = await self._resolve_project( + project_id=_safe_int(params.get("project_id")) or _safe_int(intent.project_id), + project_title=params.get("project_title") or params.get("title"), + project_name=params.get("project_name"), + ) + if project is None: + if candidates: + return self._mutation_pending(intent.name, "项目名称重复,请补充 project_id。") + return self._mutation_failed(intent.name, "未找到可删除的项目。", project_id=None) + before = self._serialize_project(project) + task_assignee_ids = [ + int(uid) + for uid in ( + await self.db.execute( + select(Task.assignee_id).where( + and_( + Task.tenant_id == int(self.tenant_id), + Task.project_id == int(project.id), + ) + ) + ) + ).scalars().all() + if uid is not None + ] + member_user_ids = [ + int(uid) + for uid in ( + await self.db.execute( + select(UserProject.user_id).where( + and_( + UserProject.tenant_id == int(self.tenant_id), + UserProject.project_id == int(project.id), + ) + ) + ) + ).scalars().all() + if uid is not None + ] + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action=intent.name, + before_data=before, + after_data={"deleted": True, "project_id": int(project.id), "project_title": before["title"]}, + ) + await self._cleanup_project_dependencies(project_id=int(project.id)) + await self.db.execute( + delete(Project).where( + and_( + Project.tenant_id == int(self.tenant_id), + Project.id == int(project.id), + ) + ) + ) + for assignee_id in sorted(set(task_assignee_ids)): + await self._refresh_user_workload_without_commit(user_id=int(assignee_id)) + await self.db.commit() + await self._invalidate_project_cache(project_id=int(project.id), user_ids=member_user_ids) + return { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(project.id), + "deleted": True, + } + + async def _execute_task_update_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + task, candidates = await self._resolve_task( + task_id=_safe_int(params.get("task_id")), + task_title=params.get("task_title") or params.get("title"), + project_id=project_id, + ) + if task is None: + if candidates: + return self._mutation_pending(intent.name, "任务标题对应多个结果,请补充 task_id。") + return self._mutation_failed(intent.name, "未找到可更新的任务。", task_id=None) + before = self._serialize_task(task) + old_assignee_id = int(task.assignee_id) if task.assignee_id is not None else None + if (title := self._clean_title(params.get("title"), max_len=120)) is not None: + task.title = title + if (description := self._clean_text(params.get("description"), max_len=5000)) is not None: + task.description = description + if params.get("assignee_id") is not None: + try: + task.assignee_id = int(params.get("assignee_id")) + except (TypeError, ValueError): + return self._mutation_failed(intent.name, "assignee_id 非法。", task_id=int(task.id)) + if params.get("required_skill_tag_ids") is not None: + task.required_skill_tag_ids = normalize_int_list(params.get("required_skill_tag_ids")) + if params.get("priority") is not None: + task.priority = normalize_task_priority(params.get("priority")) + if params.get("estimated_hours") is not None: + try: + task.estimated_hours = normalize_estimated_hours(params.get("estimated_hours")) + except ValueError as exc: + return self._mutation_failed(intent.name, str(exc), task_id=int(task.id)) + if params.get("due_date") is not None: + try: + task.due_date = normalize_due_date(params.get("due_date")) + except ValueError as exc: + return self._mutation_failed(intent.name, str(exc), task_id=int(task.id)) + + touched_user_ids = {uid for uid in (old_assignee_id, int(task.assignee_id) if task.assignee_id else None) if uid is not None} + for uid in touched_user_ids: + await self._refresh_user_workload_without_commit(user_id=int(uid)) + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(task.project_id), + entity_type="task", + entity_id=str(task.id), + action=intent.name, + before_data=before, + after_data=self._serialize_task(task), + ) + await self.db.commit() + await self.db.refresh(task) + await self._invalidate_project_cache(project_id=int(task.project_id), user_ids=touched_user_ids) + return { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(task.project_id), + "task_id": int(task.id), + "task": self._serialize_task(task), + } + + async def _execute_task_assign_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + task, candidates = await self._resolve_task( + task_id=_safe_int(params.get("task_id")), + task_title=params.get("task_title") or params.get("title"), + project_id=project_id, + ) + if task is None: + if candidates: + return self._mutation_pending(intent.name, "任务标题对应多个结果,请补充 task_id。") + return self._mutation_failed(intent.name, "未找到可分配的任务。", task_id=None) + assignee, assignee_candidates = await self._resolve_user( + user_id=_safe_int(params.get("assignee_id")), + user_name=params.get("assignee_name") or params.get("user_name"), + username=params.get("username"), + ) + if assignee is None: + if assignee_candidates: + return self._mutation_pending(intent.name, "负责人名称对应多个用户,请补充 assignee_id。") + return self._mutation_failed(intent.name, "未找到可分配的负责人。", task_id=int(task.id)) + before = self._serialize_task(task) + old_assignee_id = int(task.assignee_id) if task.assignee_id is not None else None + task.assignee_id = int(assignee.id) + for uid in {uid for uid in (old_assignee_id, int(assignee.id)) if uid is not None}: + await self._refresh_user_workload_without_commit(user_id=int(uid)) + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(task.project_id), + entity_type="task", + entity_id=str(task.id), + action=intent.name, + before_data=before, + after_data=self._serialize_task(task), + ) + await self.db.commit() + await self.db.refresh(task) + await self._invalidate_project_cache(project_id=int(task.project_id), user_ids={old_assignee_id, int(assignee.id)}) + return { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(task.project_id), + "task_id": int(task.id), + "assignee_id": int(assignee.id), + "task": self._serialize_task(task), + "assignee": self._serialize_user_row(assignee), + } + + async def _execute_task_prioritize_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + task, candidates = await self._resolve_task( + task_id=_safe_int(params.get("task_id")), + task_title=params.get("task_title") or params.get("title"), + project_id=project_id, + ) + if task is None: + if candidates: + return self._mutation_pending(intent.name, "任务标题对应多个结果,请补充 task_id。") + return self._mutation_failed(intent.name, "未找到可调整优先级的任务。", task_id=None) + before = self._serialize_task(task) + if params.get("priority") is None: + return self._mutation_failed(intent.name, "缺少 priority。", task_id=int(task.id)) + task.priority = normalize_task_priority(params.get("priority")) + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(task.project_id), + entity_type="task", + entity_id=str(task.id), + action=intent.name, + before_data=before, + after_data=self._serialize_task(task), + ) + await self.db.commit() + await self.db.refresh(task) + await self._invalidate_project_cache(project_id=int(task.project_id), user_ids={int(task.assignee_id)} if task.assignee_id else None) + return { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(task.project_id), + "task_id": int(task.id), + "task": self._serialize_task(task), + } + + async def _execute_task_status_transition_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + task, candidates = await self._resolve_task( + task_id=_safe_int(params.get("task_id")), + task_title=params.get("task_title") or params.get("title"), + project_id=project_id, + ) + if task is None: + if candidates: + return self._mutation_pending(intent.name, "任务标题对应多个结果,请补充 task_id。") + return self._mutation_failed(intent.name, "未找到可变更状态的任务。", task_id=None) + if params.get("status") is None: + return self._mutation_failed(intent.name, "缺少 status。", task_id=int(task.id)) + before = self._serialize_task(task) + new_status = normalize_task_status(params.get("status")) + previous_status = str(task.status or "").strip().lower() + task.status = new_status + task.completed_at = now_cn_naive() if new_status in {"done", "completed", "closed"} else None + if task.assignee_id is not None: + await self._refresh_user_workload_without_commit(user_id=int(task.assignee_id)) + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(task.project_id), + entity_type="task", + entity_id=str(task.id), + action=intent.name, + before_data=before, + after_data=self._serialize_task(task), + ) + await self.db.commit() + await self.db.refresh(task) + await self._invalidate_project_cache(project_id=int(task.project_id), user_ids={int(task.assignee_id)} if task.assignee_id else None) + result = { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(task.project_id), + "task_id": int(task.id), + "task": self._serialize_task(task), + } + if previous_status not in {"done", "completed", "closed"} and new_status in {"done", "completed", "closed"}: + result["completed"] = True + return result + + async def _execute_task_delete_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + task, candidates = await self._resolve_task( + task_id=_safe_int(params.get("task_id")), + task_title=params.get("task_title") or params.get("title"), + project_id=project_id, + ) + if task is None: + if candidates: + return self._mutation_pending(intent.name, "任务标题对应多个结果,请补充 task_id。") + return self._mutation_failed(intent.name, "未找到可删除的任务。", task_id=None) + before = self._serialize_task(task) + assignee_id = int(task.assignee_id) if task.assignee_id is not None else None + await self._log_operation( + actor_user_id=operator_user_id, + project_id=int(task.project_id), + entity_type="task", + entity_id=str(task.id), + action=intent.name, + before_data=before, + after_data={"deleted": True, "task_id": int(task.id), "task_title": before["title"]}, + ) + await self.db.execute( + delete(Task).where( + and_( + Task.tenant_id == int(self.tenant_id), + Task.id == int(task.id), + Task.project_id == int(task.project_id), + ) + ) + ) + if assignee_id is not None: + await self._refresh_user_workload_without_commit(user_id=assignee_id) + await self.db.commit() + await self._invalidate_project_cache(project_id=int(task.project_id), user_ids={assignee_id} if assignee_id else None) + return { + "status": "succeeded", + "operation_code": intent.name, + "project_id": int(task.project_id), + "task_id": int(task.id), + "deleted": True, + } + + async def _execute_personnel_role_update_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + user, candidates = await self._resolve_user( + user_id=_safe_int(params.get("user_id")), + user_name=params.get("user_name") or params.get("name"), + username=params.get("username"), + ) + if user is None: + if candidates: + return self._mutation_pending(intent.name, "用户名称对应多个结果,请补充 user_id。") + return self._mutation_failed(intent.name, "未找到可更新角色的用户。", user_id=None) + if params.get("role") is None: + return self._mutation_failed(intent.name, "缺少 role。", user_id=int(user.id)) + before = self._serialize_user_row(user) + user.role = self._clean_text(params.get("role"), max_len=50) or user.role + await self._log_operation( + actor_user_id=operator_user_id, + project_id=None, + entity_type="user", + entity_id=str(user.id), + action=intent.name, + before_data=before, + after_data=self._serialize_user_row(user), + ) + await self.db.commit() + await self.db.refresh(user) + await self._invalidate_project_cache(project_id=None, user_ids={int(user.id)}) + return { + "status": "succeeded", + "operation_code": intent.name, + "user_id": int(user.id), + "user": self._serialize_user_row(user), + } + + async def _execute_personnel_workload_update_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + user, candidates = await self._resolve_user( + user_id=_safe_int(params.get("user_id")), + user_name=params.get("user_name") or params.get("name"), + username=params.get("username"), + ) + if user is None: + if candidates: + return self._mutation_pending(intent.name, "用户名称对应多个结果,请补充 user_id。") + return self._mutation_failed(intent.name, "未找到可更新工时的用户。", user_id=None) + if params.get("max_weekly_hours") is None: + return self._mutation_failed(intent.name, "缺少 max_weekly_hours。", user_id=int(user.id)) + try: + max_weekly_hours = max(1.0, float(params.get("max_weekly_hours"))) + except (TypeError, ValueError): + return self._mutation_failed(intent.name, "max_weekly_hours 非法。", user_id=int(user.id)) + workload = await ensure_user_workload(self.db, int(self.tenant_id), int(user.id)) + before = { + "user_id": int(user.id), + "max_weekly_hours": float(workload.max_available_hours_per_week or 40.0), + "workload_ratio": float(workload.workload_ratio or 0.0), + } + workload.max_available_hours_per_week = max_weekly_hours + await recalculate_user_workload_ratio(self.db, int(self.tenant_id), int(user.id), commit=False) + await self._log_operation( + actor_user_id=operator_user_id, + project_id=None, + entity_type="user", + entity_id=str(user.id), + action=intent.name, + before_data=before, + after_data={ + "user_id": int(user.id), + "max_weekly_hours": float(max_weekly_hours), + "workload_ratio": float(workload.workload_ratio or 0.0), + }, + ) + await self.db.commit() + await self._invalidate_project_cache(project_id=None, user_ids={int(user.id)}) + return { + "status": "succeeded", + "operation_code": intent.name, + "user_id": int(user.id), + "workload": { + "user_id": int(user.id), + "max_weekly_hours": float(workload.max_available_hours_per_week or 0.0), + "workload_ratio": float(workload.workload_ratio or 0.0), + }, + } + + async def _execute_personnel_availability_update_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + user, candidates = await self._resolve_user( + user_id=_safe_int(params.get("user_id")), + user_name=params.get("user_name") or params.get("name"), + username=params.get("username"), + ) + if user is None: + if candidates: + return self._mutation_pending(intent.name, "用户名称对应多个结果,请补充 user_id。") + return self._mutation_failed(intent.name, "未找到可更新可用性的用户。", user_id=None) + if params.get("status") is None: + return self._mutation_failed(intent.name, "缺少 status。", user_id=int(user.id)) + before = self._serialize_user_row(user) + status = self._normalize_user_status(params.get("status")) + user.status = status + user.is_active = status != "offline" + if status == "offline": + user.workload = 0 + await self.db.execute( + update(Task) + .where( + and_( + Task.tenant_id == int(self.tenant_id), + Task.assignee_id == int(user.id), + ) + ) + .values(assignee_id=None, updated_at=now_cn_naive()) + ) + workload = await ensure_user_workload(self.db, int(self.tenant_id), int(user.id)) + workload.current_assigned_hours = 0.0 + workload.workload_ratio = 0.0 + workload.max_available_hours_per_week = max(1.0, float(workload.max_available_hours_per_week or 40.0)) + else: + await self._refresh_user_workload_without_commit(user_id=int(user.id)) + await self._log_operation( + actor_user_id=operator_user_id, + project_id=None, + entity_type="user", + entity_id=str(user.id), + action=intent.name, + before_data=before, + after_data=self._serialize_user_row(user), + ) + await self.db.commit() + await self.db.refresh(user) + await self._invalidate_user_project_caches(user_id=int(user.id)) + await self._invalidate_project_cache(project_id=None, user_ids={int(user.id)}) + return { + "status": "succeeded", + "operation_code": intent.name, + "user_id": int(user.id), + "user": self._serialize_user_row(user), + } + + async def _execute_knowledge_doc_upsert_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + doc_id = _safe_int(params.get("doc_id")) + title = self._clean_title(params.get("title"), max_len=255) + content = self._clean_text( + params.get("content") + or params.get("raw_content") + or params.get("body") + or params.get("text"), + max_len=200000, + ) + if not title: + return self._mutation_failed(intent.name, "缺少 title。", doc_id=doc_id) + if not content: + return self._mutation_failed(intent.name, "缺少 content。", doc_id=doc_id) + source_type = self._clean_text(params.get("source_type") or "file", max_len=32) or "file" + file_type = self._clean_text(params.get("file_type") or "txt", max_len=32) or "txt" + file_url = self._clean_text(params.get("file_url"), max_len=1024) + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + rag_service = AdaptiveRAGService(self.db, tenant_id=int(self.tenant_id)) + if doc_id is None: + doc = KnowledgeBaseDoc( + tenant_id=int(self.tenant_id), + title=title, + file_type=file_type, + source_type=source_type, + file_url=file_url, + uploaded_by=_safe_int(operator_user_id), + status="processing", + last_fetched_at=now_cn_naive() if source_type == "url" else None, + ) + self.db.add(doc) + await self.db.flush() + else: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.tenant_id == int(self.tenant_id), + KnowledgeBaseDoc.id == int(doc_id), + ) + ) + ) + ).scalar_one_or_none() + if doc is None: + return self._mutation_failed(intent.name, "知识文档不存在。", doc_id=doc_id) + doc.title = title + doc.file_type = file_type + doc.source_type = source_type + doc.file_url = file_url + if operator_user_id is not None: + doc.uploaded_by = int(operator_user_id) + doc.status = "processing" + doc.last_fetched_at = now_cn_naive() if source_type == "url" else doc.last_fetched_at + await rag_service._index_doc_content( + doc=doc, + raw_content=content, + project_id=project_id, + source_type=source_type, + ) + await self.db.refresh(doc) + await self._invalidate_project_cache(project_id=project_id) + return { + "status": "succeeded", + "operation_code": intent.name, + "doc_id": int(doc.id), + "doc": rag_service._serialize_doc(doc), + } + + async def _execute_knowledge_chunk_upsert_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + doc_id = _safe_int(params.get("doc_id")) + if doc_id is None: + return self._mutation_failed(intent.name, "缺少 doc_id。") + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.tenant_id == int(self.tenant_id), + KnowledgeBaseDoc.id == int(doc_id), + ) + ) + ) + ).scalar_one_or_none() + if doc is None: + return self._mutation_failed(intent.name, "知识文档不存在。", doc_id=doc_id) + chunk_text = self._clean_text( + params.get("chunk_text") or params.get("content") or params.get("text"), + max_len=10000, + ) + if not chunk_text: + return self._mutation_failed(intent.name, "缺少 chunk_text。", doc_id=doc_id) + chunk_id = _safe_int(params.get("chunk_id")) + source_type = self._clean_text(params.get("source_type") or doc.source_type or "doc", max_len=32) or "doc" + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + metadata = params.get("metadata") if isinstance(params.get("metadata"), dict) else {} + embedding = await llm_service.get_embedding_async(chunk_text, db=self.db, tenant_id=int(self.tenant_id)) + if chunk_id is None: + max_index = ( + await self.db.execute( + select(func.max(KnowledgeBaseChunk.chunk_index)).where( + and_( + KnowledgeBaseChunk.tenant_id == int(self.tenant_id), + KnowledgeBaseChunk.doc_id == int(doc.id), + ) + ) + ) + ).scalar_one() + chunk = KnowledgeBaseChunk( + tenant_id=int(self.tenant_id), + doc_id=int(doc.id), + source_type=source_type, + project_id=project_id, + content=chunk_text, + embedding=embedding if embedding else None, + chunk_index=int(max_index or 0) + 1, + chunk_metadata={ + "title": doc.title, + "file_type": doc.file_type, + "source_type": source_type, + "project_id": project_id, + **metadata, + }, + ) + self.db.add(chunk) + await self.db.flush() + else: + chunk = ( + await self.db.execute( + select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == int(self.tenant_id), + KnowledgeBaseChunk.id == int(chunk_id), + ) + ) + ) + ).scalar_one_or_none() + if chunk is None: + return self._mutation_failed(intent.name, "知识分片不存在。", doc_id=doc_id, chunk_id=chunk_id) + chunk.doc_id = int(doc.id) + chunk.source_type = source_type + chunk.project_id = project_id + chunk.content = chunk_text + chunk.embedding = embedding if embedding else None + chunk.chunk_metadata = { + "title": doc.title, + "file_type": doc.file_type, + "source_type": source_type, + "project_id": project_id, + **metadata, + } + await self.db.commit() + await self._invalidate_project_cache(project_id=project_id) + return { + "status": "succeeded", + "operation_code": intent.name, + "doc_id": int(doc.id), + "chunk_id": int(chunk.id), + "chunk": { + "id": int(chunk.id), + "doc_id": int(chunk.doc_id) if chunk.doc_id is not None else None, + "project_id": int(chunk.project_id) if chunk.project_id is not None else None, + "source_type": str(chunk.source_type or "").strip(), + "chunk_index": int(chunk.chunk_index or 0), + }, + } + + async def _execute_knowledge_link_upsert_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + source_type = self._clean_text(params.get("source_type"), max_len=40) + target_type = self._clean_text(params.get("target_type"), max_len=40) + source_id = _safe_int(params.get("source_id")) + target_id = _safe_int(params.get("target_id")) + if not source_type or not target_type or source_id is None or target_id is None: + return self._mutation_failed(intent.name, "link_upsert 缺少 source/target 信息。") + relation_type = self._clean_text( + params.get("relation_type") or f"{source_type}_to_{target_type}", + max_len=40, + ) or "knowledge_link" + weight = float(params.get("weight") or 1.0) + payload = params.get("payload") if isinstance(params.get("payload"), dict) else {} + kg_service = ProjectKnowledgeGraphService(self.db, tenant_id=int(self.tenant_id)) + source_entity = await kg_service.upsert_entity( + entity_type=source_type, + entity_key=f"{source_type}:{int(source_id)}", + display_name=str(params.get("source_name") or f"{source_type}#{int(source_id)}").strip()[:255], + payload={ + "source_type": source_type, + "source_id": int(source_id), + **(params.get("source_payload") if isinstance(params.get("source_payload"), dict) else {}), + }, + source_type="ai_pmo", + source_ref=f"{source_type}:{int(source_id)}", + ) + target_entity = await kg_service.upsert_entity( + entity_type=target_type, + entity_key=f"{target_type}:{int(target_id)}", + display_name=str(params.get("target_name") or f"{target_type}#{int(target_id)}").strip()[:255], + payload={ + "target_type": target_type, + "target_id": int(target_id), + **(params.get("target_payload") if isinstance(params.get("target_payload"), dict) else {}), + }, + source_type="ai_pmo", + source_ref=f"{target_type}:{int(target_id)}", + ) + relation = await kg_service.upsert_relation( + relation_type=relation_type, + subject_entity_id=int(source_entity.id), + object_entity_id=int(target_entity.id), + weight=weight, + payload={ + "source_type": source_type, + "source_id": int(source_id), + "target_type": target_type, + "target_id": int(target_id), + **payload, + }, + source_type="ai_pmo", + source_ref=f"{relation_type}:{source_type}:{int(source_id)}->{target_type}:{int(target_id)}", + ) + await kg_service.add_evidence( + evidence_type="knowledge_link_upsert", + content_excerpt=f"{source_type}#{int(source_id)} {relation_type} {target_type}#{int(target_id)}", + relation_id=int(relation.id), + source_type="ai_pmo", + source_ref=f"{relation_type}:{source_type}:{int(source_id)}->{target_type}:{int(target_id)}", + metadata={ + "operation_code": intent.name, + "payload": payload, + }, + ) + await self.db.commit() + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + await self._invalidate_project_cache(project_id=project_id) + return { + "status": "succeeded", + "operation_code": intent.name, + "relation_id": int(relation.id), + "link_id": int(relation.id), + "source_entity_id": int(source_entity.id), + "target_entity_id": int(target_entity.id), + } + + async def _execute_knowledge_reindex_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + scope = self._clean_text(params.get("scope") or "tenant", max_len=20) or "tenant" + project_id = _safe_int(params.get("project_id")) or _safe_int(intent.project_id) + doc_id = _safe_int(params.get("doc_id")) + kg_service = ProjectKnowledgeGraphService(self.db, tenant_id=int(self.tenant_id)) + if scope == "project" and project_id is not None: + counters = await kg_service.sync_structured_entities(limit=1000) + await self._invalidate_project_cache(project_id=project_id) + return { + "status": "succeeded", + "operation_code": intent.name, + "reindexed": True, + "scope": scope, + "project_id": int(project_id), + "doc_id": doc_id, + "counters": counters, + } + if scope == "document" and doc_id is not None: + doc = ( + await self.db.execute( + select(KnowledgeBaseDoc).where( + and_( + KnowledgeBaseDoc.tenant_id == int(self.tenant_id), + KnowledgeBaseDoc.id == int(doc_id), + ) + ) + ) + ).scalar_one_or_none() + if doc is None: + return self._mutation_failed(intent.name, "知识文档不存在。", doc_id=doc_id) + chunks = ( + await self.db.execute( + select(KnowledgeBaseChunk).where( + and_( + KnowledgeBaseChunk.tenant_id == int(self.tenant_id), + KnowledgeBaseChunk.doc_id == int(doc.id), + ) + ) + ) + ).scalars().all() + if doc.source_type == "url" and str(doc.file_url or "").strip(): + rag_service = AdaptiveRAGService(self.db, tenant_id=int(self.tenant_id)) + await rag_service.refresh_url_doc(doc_id=int(doc.id), project_id=project_id) + elif chunks: + await self.db.execute( + update(KnowledgeBaseChunk) + .where( + and_( + KnowledgeBaseChunk.tenant_id == int(self.tenant_id), + KnowledgeBaseChunk.doc_id == int(doc.id), + ) + ) + .values( + chunk_metadata={ + "reindexed_at": now_cn_naive().isoformat(), + "project_id": project_id, + } + ) + ) + await self.db.commit() + await self._invalidate_project_cache(project_id=project_id) + return { + "status": "succeeded", + "operation_code": intent.name, + "reindexed": True, + "scope": scope, + "project_id": project_id, + "doc_id": int(doc.id), + "chunk_count": len(chunks), + } + counters = await kg_service.sync_structured_entities(limit=1000) + await self._invalidate_project_cache(project_id=project_id) + return { + "status": "succeeded", + "operation_code": intent.name, + "reindexed": True, + "scope": scope, + "project_id": project_id, + "doc_id": doc_id, + "counters": counters, + } + + async def _execute_notification_create_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + user, candidates = await self._resolve_user( + user_id=_safe_int(params.get("user_id")), + user_name=params.get("user_name") or params.get("name"), + username=params.get("username"), + ) + if user is None: + if candidates: + return self._mutation_pending(intent.name, "用户名称对应多个结果,请补充 user_id。") + return self._mutation_failed(intent.name, "未找到可创建通知的用户。", user_id=None) + title = self._clean_title(params.get("title"), max_len=150) + content = self._clean_text(params.get("content") or params.get("message"), max_len=2000) + if not title or not content: + return self._mutation_failed(intent.name, "缺少通知 title 或 content。", user_id=int(user.id)) + notification_service = NotificationService(self.db) + row = await notification_service.create_notification( + tenant_id=int(self.tenant_id), + user_id=int(user.id), + title=title, + content=content, + category=self._clean_text(params.get("category") or "system", max_len=50) or "system", + source=self._clean_text(params.get("source") or "ai_pmo", max_len=50) or "ai_pmo", + entity_type=self._clean_text(params.get("entity_type"), max_len=50), + entity_id=_safe_int(params.get("entity_id")), + target_url=self._clean_text(params.get("target_url"), max_len=500), + kind=self._clean_text(params.get("kind"), max_len=30), + priority=self._clean_text(params.get("priority"), max_len=20), + status=self._clean_text(params.get("status") or "notified", max_len=30) or "notified", + blocking_reason=self._clean_text(params.get("blocking_reason"), max_len=2000), + recommended_action=self._clean_text(params.get("recommended_action"), max_len=80), + dedupe_key=self._clean_text(params.get("dedupe_key"), max_len=255), + occurrence_count=int(params.get("occurrence_count") or 1), + payload=params.get("payload") if isinstance(params.get("payload"), dict) else None, + commit=False, + ) + await self.db.commit() + await self._invalidate_project_cache(project_id=_safe_int(params.get("project_id")) or _safe_int(intent.project_id), user_ids={int(user.id)}) + return { + "status": "succeeded", + "operation_code": intent.name, + "notification_id": int(row.id), + "notification": self._serialize_notification(row), + } + + async def _execute_notification_ack_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + if operator_user_id is None: + return self._mutation_failed(intent.name, "缺少当前操作者,无法确认通知。") + notification = await self._resolve_notification( + notification_id=_safe_int(params.get("notification_id")), + user_id=_safe_int(operator_user_id), + ) + if notification is None: + return self._mutation_failed(intent.name, "未找到可确认的通知。", notification_id=_safe_int(params.get("notification_id"))) + notification.is_read = True + notification.read_at = now_cn_naive() + if self._clean_text(params.get("status"), max_len=30) is not None: + notification.status = self._clean_text(params.get("status"), max_len=30) + await self.db.commit() + await self._invalidate_project_cache(project_id=_safe_int(params.get("project_id")) or _safe_int(intent.project_id), user_ids={int(notification.user_id)}) + return { + "status": "succeeded", + "operation_code": intent.name, + "notification_id": int(notification.id), + "notification": self._serialize_notification(notification), + } + + async def _execute_notification_close_intent( + self, + *, + intent, + params: dict[str, Any], + operator_user_id: Optional[int], + ) -> dict[str, Any]: + if operator_user_id is None: + return self._mutation_failed(intent.name, "缺少当前操作者,无法关闭通知。") + notification = await self._resolve_notification( + notification_id=_safe_int(params.get("notification_id")), + user_id=_safe_int(operator_user_id), + ) + if notification is None: + return self._mutation_failed(intent.name, "未找到可关闭的通知。", notification_id=_safe_int(params.get("notification_id"))) + notification.is_read = True + notification.read_at = now_cn_naive() + notification.status = "closed" + await self.db.commit() + await self._invalidate_project_cache(project_id=_safe_int(params.get("project_id")) or _safe_int(intent.project_id), user_ids={int(notification.user_id)}) + return { + "status": "succeeded", + "operation_code": intent.name, + "notification_id": int(notification.id), + "notification": self._serialize_notification(notification), + } 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 2cd19dbb..749fd880 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 @@ -8,6 +8,7 @@ from ...adaptive_rag_service import AdaptiveRAGService from ...context_cache_service import context_cache_service from ...sstc_service_core import ALIGNED_STATUSES from ...task_persistence_core import build_task_create_draft, persist_task +from app.utils.thread_context import build_feishu_thread_key class ExecuteQueryProjectMixin: @@ -43,11 +44,27 @@ class ExecuteQueryProjectMixin: message_intent = str(rag_request_context.get("message_intent") or "").strip() or None mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None else: - asked_by = _safe_int(operator_user_id) - asked_via = source_system + thread_context = metadata.get("thread_context") if isinstance(metadata.get("thread_context"), dict) else None + is_feishu_source = str(source_system or "").strip().lower() == "feishu" + if is_feishu_source: + asked_by = None + asked_via = "feishu" + else: + asked_by = _safe_int(operator_user_id) + asked_via = source_system requested_project_id = None - source_chat_id = source_message_id - conversation_key = source_message_id + source_chat_id = None + conversation_key = ( + build_feishu_thread_key( + source_chat_id=None, + source_sender_open_id=None, + sender=None, + source_message_id=source_message_id, + thread_context=thread_context, + ) + if is_feishu_source + else source_message_id + ) session_id = None force_new_session = False thinking_mode = None diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py index 3675f87c..2c3c3ff7 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_signal_source.py @@ -2,6 +2,9 @@ from __future__ import annotations +from sqlalchemy.exc import IntegrityError + +from ....models import OperationExecutionLedger from .shared import * # noqa: F401, F403 @@ -18,89 +21,274 @@ class ExecuteSignalSourceMixin: source_message_id: str, channel_ref, ) -> dict[str, Any]: - semantic_payload = self._build_signal_source_semantic_payload( - intent=intent, + normalized_idempotency_key = self._build_signal_source_onboard_idempotency_key( + source_system=source_system, + source_message_id=source_message_id, + ) + existing_ledger = await self._load_signal_source_onboard_ledger( + idempotency_key=normalized_idempotency_key, + ) + if existing_ledger is not None: + existing_status = str(existing_ledger.status or "").strip().lower() + stored_result = ( + existing_ledger.result_payload if isinstance(existing_ledger.result_payload, dict) else {} + ) + if existing_status in {"applied", "skipped"} and stored_result: + return dict(stored_result) + if stored_result: + return dict(stored_result) + return { + "status": "pending_manual" if existing_status == "processing" else "failed_safe", + "project_id": intent.project_id, + "signal_id": None, + "signal_source_workflow": { + "status": existing_status or "processing", + "message": "signal_source.onboard already in progress", + "idempotency_key": normalized_idempotency_key, + }, + "signal_source_orchestration": {}, + "auto_tasks": [], + } + + ledger, created = await self._claim_signal_source_onboard_ledger( + idempotency_key=normalized_idempotency_key, + source_system=source_system, + source_message_id=source_message_id, + project_id=_safe_int(intent.project_id), message_text=message_text, ) - signal_summary = str(semantic_payload.get("summary") or intent.signal_summary or message_text) - signal_content = str(semantic_payload.get("content") or message_text) - initial_title_seed = self._normalize_title_candidate( - str(semantic_payload.get("title") or intent.signal_title or "") - ) - initial_signal_title = ( - initial_title_seed - if self._is_acceptable_title(initial_title_seed) - else "信号源接入请求" - ) - strategic_payload = ( - semantic_payload.get("strategic_analysis") - if isinstance(semantic_payload.get("strategic_analysis"), dict) - else {} - ) - source_context = ( - strategic_payload.get("source_context") - if isinstance(strategic_payload.get("source_context"), dict) - else {} - ) - source_context["source_message_id"] = source_message_id - source_context["channel_ref"] = channel_ref - strategic_payload["source_context"] = source_context - signal = await self._create_signal( - title=initial_signal_title, - summary=signal_summary, - content=signal_content, - project_id=intent.project_id, - source="PMFlow", - signal_type="新需求", - recommended_action="enter_only", - reasoning_bullets=( - semantic_payload.get("reasoning_bullets") - if isinstance(semantic_payload.get("reasoning_bullets"), list) - else None - ), - strategic_analysis=( - strategic_payload if isinstance(strategic_payload, dict) else None - ), - ) - domain_workflow = await self.signal_source_workflow.run( - tenant_id=self.tenant_id, - project_id=int(intent.project_id or 0), - signal_id=int(signal.id), - initiating_text=message_text, - requested_via=source_system, - operator_user_id=operator_user_id, - ) - orchestration = ( - domain_workflow.get("raw_result") - if isinstance(domain_workflow.get("raw_result"), dict) - else {} - ) - signal_source_payload = ( - orchestration.get("signal_source") - if isinstance(orchestration.get("signal_source"), dict) - else {} - ) - ai_source_title = self._normalize_title_candidate(signal_source_payload.get("source_name")) - if self._is_acceptable_title(ai_source_title): - signal.title = ai_source_title - await self.db.flush() - return { - "status": ( - "succeeded" - if str(domain_workflow.get("status") or "").lower() == "completed" - else ( - "pending_manual" - if str(domain_workflow.get("status") or "").lower() == "pending_manual" - else "failed_safe" + if ledger is not None and not created: + ledger_status = str(ledger.status or "").strip().lower() + stored_result = ledger.result_payload if isinstance(ledger.result_payload, dict) else {} + if ledger_status in {"applied", "skipped"} and stored_result: + return dict(stored_result) + if stored_result: + return dict(stored_result) + return { + "status": "pending_manual" if ledger_status == "processing" else "failed_safe", + "project_id": intent.project_id, + "signal_id": None, + "signal_source_workflow": { + "status": ledger_status or "processing", + "message": "signal_source.onboard already in progress", + "idempotency_key": normalized_idempotency_key, + }, + "signal_source_orchestration": {}, + "auto_tasks": [], + } + + await self.db.commit() + + try: + semantic_payload = self._build_signal_source_semantic_payload( + intent=intent, + message_text=message_text, + ) + signal_summary = str(semantic_payload.get("summary") or intent.signal_summary or message_text) + signal_content = str(semantic_payload.get("content") or message_text) + initial_title_seed = self._normalize_title_candidate( + str(semantic_payload.get("title") or intent.signal_title or "") + ) + initial_signal_title = ( + initial_title_seed + if self._is_acceptable_title(initial_title_seed) + else "信号源接入请求" + ) + strategic_payload = ( + semantic_payload.get("strategic_analysis") + if isinstance(semantic_payload.get("strategic_analysis"), dict) + else {} + ) + source_context = ( + strategic_payload.get("source_context") + if isinstance(strategic_payload.get("source_context"), dict) + else {} + ) + source_context["source_message_id"] = source_message_id + source_context["channel_ref"] = channel_ref + strategic_payload["source_context"] = source_context + signal = await self._create_signal( + title=initial_signal_title, + summary=signal_summary, + content=signal_content, + project_id=int(intent.project_id), + source="PMFlow", + signal_type="新需求", + recommended_action="enter_only", + reasoning_bullets=( + semantic_payload.get("reasoning_bullets") + if isinstance(semantic_payload.get("reasoning_bullets"), list) + else None + ), + strategic_analysis=( + strategic_payload if isinstance(strategic_payload, dict) else None + ), + ) + domain_workflow = await self.signal_source_workflow.run( + tenant_id=self.tenant_id, + project_id=int(intent.project_id or 0), + signal_id=int(signal.id), + initiating_text=message_text, + requested_via=source_system, + operator_user_id=operator_user_id, + ) + orchestration = ( + domain_workflow.get("raw_result") + if isinstance(domain_workflow.get("raw_result"), dict) + else {} + ) + signal_source_payload = ( + orchestration.get("signal_source") + if isinstance(orchestration.get("signal_source"), dict) + else {} + ) + ai_source_title = self._normalize_title_candidate(signal_source_payload.get("source_name")) + if self._is_acceptable_title(ai_source_title): + signal.title = ai_source_title + await self.db.flush() + result = { + "status": ( + "succeeded" + if str(domain_workflow.get("status") or "").lower() == "completed" + else ( + "pending_manual" + if str(domain_workflow.get("status") or "").lower() == "pending_manual" + else "failed_safe" + ) + ), + "signal_id": int(signal.id), + "project_id": intent.project_id, + "signal_source_workflow": domain_workflow, + "signal_source_orchestration": orchestration, + "auto_tasks": self._extract_auto_tasks( + orchestration.get("execution") + if isinstance(orchestration.get("execution"), dict) + else orchestration.get("openclaw") + ), + } + await self._finalize_signal_source_onboard_ledger( + ledger=ledger, + status=result["status"], + result=result, + signal_source_payload=signal_source_payload, + signal_id=int(signal.id), + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) + return result + except Exception as exc: + await self._finalize_signal_source_onboard_ledger( + ledger=ledger, + status="failed", + result={ + "status": "failed_safe", + "project_id": intent.project_id, + "signal_id": None, + "signal_source_workflow": {}, + "signal_source_orchestration": {}, + "auto_tasks": [], + "error_message": str(exc)[:1000], + }, + signal_source_payload=None, + signal_id=None, + error_message=str(exc), + ) + await self.db.commit() + raise + + @staticmethod + def _build_signal_source_onboard_idempotency_key( + *, + source_system: str, + source_message_id: str, + ) -> str: + message_id = str(source_message_id or "").strip() + if message_id: + source = str(source_system or "").strip().lower() + key = f"{source}:{message_id}" if source else message_id + return key[:128] + return "" + + async def _load_signal_source_onboard_ledger( + self, + *, + idempotency_key: str, + ) -> Optional[OperationExecutionLedger]: + if not idempotency_key: + return None + return ( + await self.db.execute( + select(OperationExecutionLedger).where( + and_( + OperationExecutionLedger.tenant_id == self.tenant_id, + OperationExecutionLedger.operation_code == "signal_source.onboard", + OperationExecutionLedger.idempotency_key == idempotency_key, + ) + ).limit(1) + ) + ).scalar_one_or_none() + + async def _claim_signal_source_onboard_ledger( + self, + *, + idempotency_key: str, + source_system: str, + source_message_id: str, + project_id: Optional[int], + message_text: str, + ) -> tuple[Optional[OperationExecutionLedger], bool]: + if not idempotency_key: + return None, False + ledger = await self._load_signal_source_onboard_ledger(idempotency_key=idempotency_key) + if ledger is not None: + return ledger, False + try: + async with self.db.begin_nested(): + ledger = OperationExecutionLedger( + tenant_id=self.tenant_id, + operation_code="signal_source.onboard", + project_id=_safe_int(project_id), + idempotency_key=idempotency_key, + trace_id=str(source_message_id or "").strip()[:64] or None, + status="processing", + payload={ + "source_system": str(source_system or "").strip() or None, + "source_message_id": str(source_message_id or "").strip() or None, + "message_text": str(message_text or "")[:4000], + "project_id": _safe_int(project_id), + }, + result_payload=None, + entity_type="signal_source", + entity_id=None, + error_message=None, ) - ), - "signal_id": int(signal.id), - "project_id": intent.project_id, - "signal_source_workflow": domain_workflow, - "signal_source_orchestration": orchestration, - "auto_tasks": self._extract_auto_tasks( - orchestration.get("execution") - if isinstance(orchestration.get("execution"), dict) - else orchestration.get("openclaw") - ), - } + self.db.add(ledger) + await self.db.flush() + return ledger, True + except IntegrityError: + return await self._load_signal_source_onboard_ledger(idempotency_key=idempotency_key), False + + async def _finalize_signal_source_onboard_ledger( + self, + *, + ledger: Optional[OperationExecutionLedger], + status: str, + result: dict[str, Any], + signal_source_payload: Optional[dict[str, Any]], + signal_id: Optional[int], + error_message: Optional[str] = None, + ) -> None: + if ledger is None: + return + normalized_status = str(status or "").strip().lower() or "processing" + ledger.status = normalized_status + ledger.entity_type = "signal_source" + ledger.entity_id = ( + str(signal_source_payload.get("registry_id")) + if isinstance(signal_source_payload, dict) and signal_source_payload.get("registry_id") is not None + else str(signal_id) if signal_id is not None else None + ) + ledger.result_payload = dict(result) + ledger.error_message = (str(error_message or "").strip() or None) if normalized_status == "failed" else None + ledger.project_id = _safe_int(result.get("project_id")) + await self.db.flush() diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py index 5d3258fb..2a41e316 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py @@ -2,6 +2,7 @@ from __future__ import annotations +from .mixin_execute_mutations import ExecuteMutationsMixin from .mixin_execute_query_project import ExecuteQueryProjectMixin from .mixin_execute_signal_create import ExecuteSignalCreateMixin from .mixin_execute_signal_source import ExecuteSignalSourceMixin @@ -11,6 +12,7 @@ from .shared import * # noqa: F401, F403 class TypedExecutorExecuteMixin( ExecuteHelpersMixin, + ExecuteMutationsMixin, ExecuteQueryProjectMixin, ExecuteSignalSourceMixin, ExecuteSignalCreateMixin, @@ -49,6 +51,33 @@ class TypedExecutorExecuteMixin( operator_user_id=operator_user_id, ) + if intent.name in { + INTENT_PROJECT_UPDATE, + INTENT_PROJECT_ARCHIVE, + INTENT_PROJECT_REOPEN, + INTENT_PROJECT_DELETE, + INTENT_TASK_UPDATE, + INTENT_TASK_ASSIGN, + INTENT_TASK_PRIORITIZE, + INTENT_TASK_STATUS_TRANSITION, + INTENT_TASK_DELETE, + INTENT_PERSONNEL_ROLE_UPDATE, + INTENT_PERSONNEL_WORKLOAD_UPDATE, + INTENT_PERSONNEL_AVAILABILITY_UPDATE, + INTENT_KNOWLEDGE_DOC_UPSERT, + INTENT_KNOWLEDGE_CHUNK_UPSERT, + INTENT_KNOWLEDGE_LINK_UPSERT, + INTENT_KNOWLEDGE_REINDEX, + INTENT_NOTIFICATION_CREATE, + INTENT_NOTIFICATION_ACK, + INTENT_NOTIFICATION_CLOSE, + }: + return await self._execute_mutation_intent( + intent=intent, + message_text=message_text, + operator_user_id=operator_user_id, + ) + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: return await self._execute_signal_source_onboard_intent( intent=intent, diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index b2b377fd..6850ae30 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -12,6 +12,7 @@ for _name, _value in vars(_common).items(): globals()[_name] = _value from ..signal.draft_generator_service import DraftGeneratorService +from ...models.feishu_reply_outbox import FeishuReplyOutbox class DraftConfirmHandler: @@ -56,6 +57,7 @@ class DraftConfirmHandler: channel_ref: Optional[str], source_sender_open_id: Optional[str], expected_version: Optional[int] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: action, revision_note = self.parse_action_from_text(message_text=message_text) explicit_work_item_id = self._extract_work_item_id(message_text) @@ -66,12 +68,25 @@ class DraftConfirmHandler: channel_ref=channel_ref, source_sender_open_id=source_sender_open_id, explicit_work_item_id=explicit_work_item_id, + thread_context=thread_context, ) if target is None: + if explicit_work_item_id is not None: + return { + "handled": False, + "status": "ignored", + "followup_reply": "", + "target_work_item_id": None, + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } return { - "handled": True, + "handled": False, "status": "ignored", - "followup_reply": "当前没有待确认草稿,请先提交写操作需求。", + "followup_reply": "", "target_work_item_id": None, "target_state": None, "target_intent": None, @@ -580,6 +595,7 @@ class DraftConfirmHandler: channel_ref: Optional[str], source_sender_open_id: Optional[str], explicit_work_item_id: Optional[int], + thread_context: Optional[dict[str, Any]], ) -> Optional[tuple[WorkItem, IngestEvent]]: stmt = ( select(WorkItem, IngestEvent) @@ -603,6 +619,41 @@ class DraftConfirmHandler: normalized_sender = str(sender or "").strip() normalized_channel = str(channel_ref or "").strip() normalized_open_id = str(source_sender_open_id or "").strip() + anchor_context = self._normalize_thread_context(thread_context) + if not anchor_context: + return None + + source_message_ids = sorted( + { + str(getattr(work_item, "source_message_id", "") or "").strip() + for work_item, _ in rows + if str(getattr(work_item, "source_message_id", "") or "").strip() + } + ) + sent_message_id_map: dict[str, str] = {} + if source_message_ids: + outbox_rows = ( + await self.db.execute( + select( + FeishuReplyOutbox.source_message_id, + FeishuReplyOutbox.sent_message_id, + ) + .where( + and_( + FeishuReplyOutbox.tenant_id == int(self.tenant_id), + FeishuReplyOutbox.source_message_id.in_(source_message_ids), + FeishuReplyOutbox.status == "sent", + FeishuReplyOutbox.sent_message_id.is_not(None), + ) + ) + .order_by(FeishuReplyOutbox.id.desc()) + ) + ).all() + for source_message_id, sent_message_id in outbox_rows: + source_key = str(source_message_id or "").strip() + sent_key = str(sent_message_id or "").strip() + if source_key and sent_key and source_key not in sent_message_id_map: + sent_message_id_map[source_key] = sent_key candidates: list[tuple[WorkItem, IngestEvent]] = [] for work_item, ingest in rows: @@ -616,12 +667,57 @@ class DraftConfirmHandler: else: if not (same_sender or same_channel): continue + if not self._matches_thread_anchor( + work_item=work_item, + ingest=ingest, + anchor_context=anchor_context, + draft_reply_message_id=sent_message_id_map.get(str(getattr(work_item, "source_message_id", "") or "").strip()), + ): + continue candidates.append((work_item, ingest)) if not candidates: return None return candidates[0] + @staticmethod + def _normalize_thread_context( + thread_context: Optional[dict[str, Any]], + ) -> dict[str, str]: + if not isinstance(thread_context, dict): + return {} + normalized: dict[str, str] = {} + for key in ("reply_to_message_id", "root_message_id", "thread_id"): + value = str(thread_context.get(key) or "").strip() + if value: + normalized[key] = value[:128] + return normalized + + @classmethod + def _matches_thread_anchor( + cls, + *, + work_item: WorkItem, + ingest: IngestEvent, + anchor_context: dict[str, str], + draft_reply_message_id: Optional[str] = None, + ) -> bool: + if not anchor_context: + return False + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + candidate_values = { + str(payload.get("reply_to_message_id") or "").strip(), + str(payload.get("root_message_id") or "").strip(), + str(getattr(ingest, "source_message_id", "") or "").strip(), + str(getattr(work_item, "source_message_id", "") or "").strip(), + str(draft_reply_message_id or "").strip(), + } + candidate_values.discard("") + return any( + str(value or "").strip() in candidate_values + for value in anchor_context.values() + ) + async def _sync_decision_outcome( self, *, 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 767b1c28..4f7c1d8b 100644 --- a/backend/app/services/ai_service_core/mixin_business_tasks.py +++ b/backend/app/services/ai_service_core/mixin_business_tasks.py @@ -71,14 +71,29 @@ class AIServiceBusinessTasksMixin: signal_content: str, tenant_id: Union[str, int] = "default", db=None, + project_id: Optional[Union[str, int]] = None, + project_context: Optional[str] = None, + active_projects_json: Optional[str] = None, ) -> dict: """从市场/模型信号生成需求宪章草案""" from ...config.ai_prompts import PROMPT_GENERATE_CHARTER tid = _t2s(tenant_id) - prompt = PROMPT_GENERATE_CHARTER.format(signal_content=signal_content) + try: + resolved_project_id = int(project_id) if project_id is not None and str(project_id).strip() else None + except (TypeError, ValueError): + resolved_project_id = None + prompt = PROMPT_GENERATE_CHARTER.format( + signal_content=signal_content, + project_context=str(project_context or "").strip() or "无", + active_projects_json=str(active_projects_json or "[]").strip() or "[]", + ) text = await self._invoke_with_retry( - prompt, db=db, tenant_id=tid, function_name="generate_charter_from_signal", + prompt, + db=db, + tenant_id=tid, + function_name="generate_charter_from_signal", + project_id=resolved_project_id, ) return self._parse_json(text) diff --git a/backend/app/services/ai_service_core/mixin_runtime_core.py b/backend/app/services/ai_service_core/mixin_runtime_core.py index 6f57f784..38651e53 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_core.py +++ b/backend/app/services/ai_service_core/mixin_runtime_core.py @@ -42,47 +42,68 @@ class AIServiceRuntimeCoreMixin: routing_enabled = self._is_env_true("OPENCLAW_ROUTING_ENABLED", True) direct_emergency_enabled = self._is_env_true("LLM_DIRECT_EMERGENCY_ENABLED", False) if routing_enabled and not direct_emergency_enabled: + high_defaults = dict(TIER_DEFAULTS.get("high", {})) + high_provider = str(high_defaults.get("provider") or "openai").strip().lower() + provider_defaults = dict(PROVIDER_DEFAULTS.get(high_provider, PROVIDER_DEFAULTS["openai"])) return { - "api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(), - "model": str( - model - or os.getenv("LLM_ROUTING_HIGH_MODEL") - or os.getenv("OPENAI_MODEL") - or DEFAULT_OPENAI_MODEL + "api_key": str( + api_key + or os.getenv(str(provider_defaults.get("api_env") or "OPENAI_API_KEY"), "") + or os.getenv("OPENAI_API_KEY", "") or "" ).strip(), - "base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), + "model": str(model or high_defaults.get("model") or DEFAULT_OPENAI_MODEL or "").strip(), + "base_url": str(base_url or high_defaults.get("base_url") or DEFAULT_OPENAI_BASE_URL or "").strip(), } provider = str(os.getenv("LLM_PROVIDER", "openai") or "openai").strip().lower() if provider == "glm": + defaults = dict(PROVIDER_DEFAULTS.get("glm", {})) return { - "api_key": str(api_key or os.getenv("GLM_API_KEY", "") or "").strip(), - "model": str(model or os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL) or "").strip(), - "base_url": str(base_url or os.getenv("GLM_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), - } - if provider == "deepseek": - return { - "api_key": str(api_key or os.getenv("DEEPSEEK_API_KEY", "") or "").strip(), - "model": str(model or os.getenv("DEEPSEEK_MODEL", "deepseek-chat") or "").strip(), - "base_url": str(base_url or os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") or "").strip(), - } - if provider == "ollama": - return { - "api_key": str(api_key or "").strip(), - "model": str(model or os.getenv("OLLAMA_MODEL", "llama3:latest") or "").strip(), + "api_key": str( + api_key + or os.getenv("DASHSCOPE_API_KEY") + or os.getenv("GLM_API_KEY", "") + or "" + ).strip(), + "model": str( + model + or defaults.get("model") + or DEFAULT_GLM_MODEL + or "" + ).strip(), "base_url": str( base_url - or os.getenv("OLLAMA_BASE_URL") + or defaults.get("base_url") + or DEFAULT_DASHSCOPE_BASE_URL + or "" + ).strip(), + } + if provider == "deepseek": + defaults = dict(PROVIDER_DEFAULTS.get("deepseek", {})) + return { + "api_key": str(api_key or os.getenv("DEEPSEEK_API_KEY", "") or "").strip(), + "model": str(model or defaults.get("model") or "deepseek-chat" or "").strip(), + "base_url": str(base_url or defaults.get("base_url") or "https://api.deepseek.com/v1" or "").strip(), + } + if provider == "ollama": + defaults = dict(PROVIDER_DEFAULTS.get("ollama", {})) + return { + "api_key": str(api_key or "").strip(), + "model": str(model or defaults.get("model") or "llama3:latest" or "").strip(), + "base_url": str( + base_url + or defaults.get("base_url") or os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434") or "" ).strip(), } + defaults = dict(PROVIDER_DEFAULTS.get("openai", {})) return { "api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(), - "model": str(model or os.getenv("OPENAI_MODEL", DEFAULT_OPENAI_MODEL) or "").strip(), - "base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(), + "model": str(model or defaults.get("model") or DEFAULT_OPENAI_MODEL or "").strip(), + "base_url": str(base_url or defaults.get("base_url") or DEFAULT_OPENAI_BASE_URL or "").strip(), } def _get_llm(self) -> BaseChatModel: diff --git a/backend/app/services/ai_service_core/shared.py b/backend/app/services/ai_service_core/shared.py index f6aace8b..a1feb557 100644 --- a/backend/app/services/ai_service_core/shared.py +++ b/backend/app/services/ai_service_core/shared.py @@ -20,6 +20,7 @@ from sqlalchemy import and_, select # LangChain & OpenAI from ...utils.timezone import now_cn_naive +from ..model.model_config import get_provider_defaults, get_tier_defaults try: from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage, AIMessage, SystemMessage @@ -41,9 +42,15 @@ INPUT_PRICE_PER_1K = 0.00015 OUTPUT_PRICE_PER_1K = 0.0006 CACHE_TTL_SECONDS = 3600 # 1 小时 PROMPT_PROFILE_CACHE_TTL_SECONDS = 60 -DEFAULT_OPENAI_BASE_URL = "https://api.edgefn.net/v1" -DEFAULT_OPENAI_MODEL = "MiniMax-M2.5" -DEFAULT_GLM_MODEL = "GLM-4.7" +_PROVIDER_DEFAULTS = get_provider_defaults() +_TIER_DEFAULTS = get_tier_defaults() +PROVIDER_DEFAULTS = _PROVIDER_DEFAULTS +TIER_DEFAULTS = _TIER_DEFAULTS +DEFAULT_OPENAI_BASE_URL = str(_PROVIDER_DEFAULTS.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1") +DEFAULT_OPENAI_MODEL = str(_PROVIDER_DEFAULTS.get("openai", {}).get("model") or "MiniMax-M2.5") +DEFAULT_DASHSCOPE_BASE_URL = str(_PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") +DEFAULT_DASHSCOPE_MODEL = str(_PROVIDER_DEFAULTS.get("glm", {}).get("model") or "qwen-plus") +DEFAULT_GLM_MODEL = DEFAULT_DASHSCOPE_MODEL from ...config.ai_prompts import build_default_role_prompt_bundle from ...core.database import AsyncSessionLocal @@ -73,4 +80,3 @@ def _tenant_to_int(tenant_id: Union[str, int]) -> int: __all__ = [name for name in globals() if not name.startswith("__")] - diff --git a/backend/app/services/context_cache_service/invalidation.py b/backend/app/services/context_cache_service/invalidation.py index 956dd1c1..3cd8d81c 100644 --- a/backend/app/services/context_cache_service/invalidation.py +++ b/backend/app/services/context_cache_service/invalidation.py @@ -21,19 +21,19 @@ class ContextCacheInvalidator: @staticmethod def _system_key(tenant_id: int) -> str: - return ContextCacheService._system_key(_safe_int(tenant_id, 1)) + return f"pmflow:context:system:{_safe_int(tenant_id, 1)}" @staticmethod def _project_list_key(tenant_id: int) -> str: - return ContextCacheService._project_list_key(_safe_int(tenant_id, 1)) + return f"pmflow:project:list:{_safe_int(tenant_id, 1)}" @staticmethod def _signal_pending_key(tenant_id: int) -> str: - return ContextCacheService._signal_pending_key(_safe_int(tenant_id, 1)) + return f"pmflow:signal:pending_count:{_safe_int(tenant_id, 1)}" @staticmethod def _user_key(tenant_id: int, user_id: int) -> str: - return ContextCacheService._user_key(_safe_int(tenant_id, 1), _safe_int(user_id, 0)) + return f"pmflow:context:user:{_safe_int(tenant_id, 1)}:{_safe_int(user_id, 0)}" def invalidate( self, @@ -169,4 +169,3 @@ def register_context_cache_invalidation_hooks() -> None: session.info.pop("_pmflow_cache_invalidation", None) setattr(register_context_cache_invalidation_hooks, "_registered", True) - 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 f9b03091..01dee247 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,6 +10,7 @@ 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, @@ -60,6 +61,21 @@ 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, @@ -72,7 +88,8 @@ 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) - if work_item_id is None: + 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: return None reply_text = str(capture_result.get("direct_reply_text") or "").strip() safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。" @@ -160,6 +177,35 @@ 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 @@ -195,6 +241,11 @@ 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 "") @@ -225,6 +276,7 @@ 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, @@ -240,20 +292,69 @@ 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: + if (work_item_id is None or work_item_id <= 0) and (queue_item_id is None or queue_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 action == self._ACTION_CONTINUE: + 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: kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id) result = await kernel.process_ingress( source_system="ai_pmo_ui", @@ -310,15 +411,22 @@ 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, }, @@ -329,7 +437,7 @@ class FeishuCardActionMixin: await self._safe_reply_card( chat_id=chat_id, card_content=action_card, - trace_id=f"feishu-card-action-{int(work_item_id)}", + trace_id=f"feishu-card-action-{int(target_work_item_id or target_queue_item_id or 0)}", source_message_id=source_message_id, perf_t0=None, ) @@ -339,7 +447,7 @@ class FeishuCardActionMixin: mention_open_id=sender_open_id, mention_name=operator_sender, text=followup_reply, - trace_id=f"feishu-card-action-{int(work_item_id)}", + trace_id=f"feishu-card-action-{int(target_work_item_id or target_queue_item_id or 0)}", source_message_id=source_message_id, perf_t0=None, ) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_config.py b/backend/app/services/integrations/feishu_integration_service/mixin_config.py index e0549242..8c58edb8 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_config.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_config.py @@ -61,8 +61,8 @@ class FeishuConfigMixin: config = integration.config or {} self.app_id = config.get('app_id') self.app_secret = config.get('app_secret') - self.encrypt_key = None - self.verification_token = None + self.encrypt_key = str(config.get('encrypt_key') or "").strip() or None + self.verification_token = str(config.get('verification_token') or "").strip() or None self.event_mode = self._normalize_event_mode(config.get('event_mode')) else: self.app_id = None @@ -86,13 +86,6 @@ class FeishuConfigMixin: ): """保存飞书配置到数据库""" mode = self._normalize_event_mode(event_mode) - config = { - 'app_id': app_id, - 'app_secret': app_secret, - 'encrypt_key': "", - 'verification_token': "", - 'event_mode': mode, - } # 查找或创建集成记录 result = await self.db.execute( @@ -102,6 +95,25 @@ class FeishuConfigMixin: ) ) integration = result.scalar_one_or_none() + existing_config: dict[str, Any] = {} + if integration: + if isinstance(integration.config, str): + try: + existing_config = json.loads(integration.config) if integration.config else {} + except Exception: + existing_config = {} + else: + existing_config = integration.config or {} + + resolved_encrypt_key = str(encrypt_key or "").strip() or str(existing_config.get('encrypt_key') or "").strip() + resolved_verification_token = str(verification_token or "").strip() or str(existing_config.get('verification_token') or "").strip() + config = { + 'app_id': app_id, + 'app_secret': app_secret, + 'encrypt_key': resolved_encrypt_key, + 'verification_token': resolved_verification_token, + 'event_mode': mode, + } if integration: integration.config = config @@ -120,8 +132,8 @@ class FeishuConfigMixin: # 更新内存配置 self.app_id = app_id self.app_secret = app_secret - self.encrypt_key = None - self.verification_token = None + self.encrypt_key = resolved_encrypt_key or None + self.verification_token = resolved_verification_token or None self.event_mode = mode self.tenant_id = tenant_id diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive.py index 47455672..5892f122 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive.py @@ -68,6 +68,11 @@ class FeishuReceiveMixin: message_id=(message.get("message_id") or "").strip() or None, chat_type=str(context.get("chat_type") or "group"), message_attachments=message_attachments, + thread_context=( + context.get("thread_context") + if isinstance(context.get("thread_context"), dict) + else None + ), ) except Exception as exc: return await self._handle_capture_exception_from_ingress( diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py index 931e4762..9ae8a2d0 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py @@ -16,6 +16,7 @@ class FeishuReceiveCaptureFlowMixin: message_id: str, chat_type: str, message_attachments: List[Dict[str, Any]], + thread_context: Optional[Dict[str, Any]], ) -> Dict[str, Any]: capture_service = ProactiveSignalCaptureService(self.db) result = await capture_service.capture_from_feishu( @@ -28,6 +29,7 @@ class FeishuReceiveCaptureFlowMixin: mentioned_bot_names=mentioned_bot_names, feishu_message_id=message_id, chat_type=chat_type, + thread_context=thread_context, ) await self._persist_signal_attachments_from_ingress( signal_id=result.get("signal_id"), diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py index ae3241d4..393e083f 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py @@ -41,6 +41,11 @@ class FeishuReceivePrepareMixin: chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group" captured_at = self._extract_message_datetime(message) occurred_at_iso = captured_at.isoformat() if isinstance(captured_at, datetime) else None + thread_context = self._extract_thread_context( + event=event, + message=message, + message_payload=message_payload, + ) self._emit_state_transition( trace_id=state_trace_id, @@ -71,6 +76,127 @@ class FeishuReceivePrepareMixin: "chat_type": chat_type, "captured_at": captured_at, "occurred_at_iso": occurred_at_iso, + "thread_context": thread_context, + } + + @staticmethod + def _extract_thread_context( + *, + event: Dict[str, Any], + message: Dict[str, Any], + message_payload: Dict[str, Any], + ) -> Dict[str, Optional[str]]: + def _pick_text(*values: Any) -> Optional[str]: + for value in values: + text = str(value or "").strip() + if text: + return text[:128] + return None + + def _find_nested_text( + value: Any, + *, + candidate_keys: tuple[str, ...], + depth: int = 0, + max_depth: int = 4, + ) -> Optional[str]: + if depth > max_depth: + return None + if isinstance(value, dict): + for key in candidate_keys: + if key in value: + text = _pick_text(value.get(key)) + if text: + return text + for nested_value in value.values(): + nested_text = _find_nested_text( + nested_value, + candidate_keys=candidate_keys, + depth=depth + 1, + max_depth=max_depth, + ) + if nested_text: + return nested_text + elif isinstance(value, list): + for nested_value in value: + nested_text = _find_nested_text( + nested_value, + candidate_keys=candidate_keys, + depth=depth + 1, + max_depth=max_depth, + ) + if nested_text: + return nested_text + return None + + top_level = event if isinstance(event, dict) else {} + content = message.get("content") if isinstance(message.get("content"), dict) else {} + thread_id = _pick_text( + top_level.get("thread_id"), + top_level.get("message_thread_id"), + top_level.get("chat_thread_id"), + message.get("thread_id"), + message.get("message_thread_id"), + message.get("chat_thread_id"), + content.get("thread_id") if isinstance(content, dict) else None, + ) or _find_nested_text( + top_level, + candidate_keys=("thread_id", "message_thread_id", "chat_thread_id"), + ) or _find_nested_text( + message, + candidate_keys=("thread_id", "message_thread_id", "chat_thread_id"), + ) + root_message_id = _pick_text( + top_level.get("root_id"), + top_level.get("root_message_id"), + top_level.get("reply_root_id"), + message.get("root_id"), + message.get("root_message_id"), + message.get("reply_root_id"), + content.get("root_id") if isinstance(content, dict) else None, + ) or _find_nested_text( + top_level, + candidate_keys=("root_id", "root_message_id", "reply_root_id", "parent_message_id"), + ) or _find_nested_text( + message, + candidate_keys=("root_id", "root_message_id", "reply_root_id", "parent_message_id"), + ) + reply_to_message_id = _pick_text( + top_level.get("reply_to_message_id"), + top_level.get("reply_to_id"), + top_level.get("parent_id"), + top_level.get("reply_in_thread_message_id"), + message.get("reply_to_message_id"), + message.get("reply_to_id"), + message.get("parent_id"), + message.get("reply_in_thread_message_id"), + content.get("reply_to_message_id") if isinstance(content, dict) else None, + message_payload.get("reply_to_message_id") if isinstance(message_payload, dict) else None, + ) or _find_nested_text( + top_level, + candidate_keys=( + "reply_to_message_id", + "reply_to_id", + "parent_id", + "reply_in_thread_message_id", + "parent_message_id", + "reply_message_id", + ), + ) or _find_nested_text( + message, + candidate_keys=( + "reply_to_message_id", + "reply_to_id", + "parent_id", + "reply_in_thread_message_id", + "parent_message_id", + "reply_message_id", + ), + ) + return { + "thread_id": thread_id, + "root_message_id": root_message_id, + "reply_to_message_id": reply_to_message_id, } async def _reply_for_empty_text_ingress( diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py index 13583294..e7532b19 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py @@ -99,11 +99,19 @@ class FeishuReplyLLMCoreMixin: reply_text = self._strip_leading_thinking_paragraphs(reply_text) if not self._is_capability_question(message_content): reply_text = self._strip_identity_lead(reply_text) - if (not reply_text) or self._looks_like_provider_error(reply_text): - reply_text = self._fallback_reply_text(capture_result) + if not reply_text: + reply_text = self._build_llm_failure_reply_text( + capture_result, + reason="模型返回空内容", + ) + elif self._looks_like_provider_error(reply_text): + reply_text = self._build_llm_failure_reply_text( + capture_result, + reason=reply_text, + ) except Exception as exc: print(f"[Feishu] generate mention reply failed: {exc}") - reply_text = self._fallback_reply_text(capture_result) + reply_text = self._build_llm_failure_reply_text(capture_result, reason=str(exc)) reply_text, _ = self._guard_qa_reply_text_with_reason( reply_text, trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"), @@ -257,15 +265,29 @@ class FeishuReplyLLMCoreMixin: return False + @staticmethod + def _build_llm_failure_reply_text( + capture_result: Dict[str, Any], + *, + reason: Optional[str] = None, + ) -> str: + classification = str(capture_result.get("classification") or "").strip().lower() + scope_text = "项目级需求" if classification in {"project", "new_project"} else "信号" + reason_text = re.sub(r"\s+", " ", str(reason or "").strip()) + if reason_text: + return ( + f"当前回复生成失败,原因:{reason_text[:120]}。" + f"系统已保留这条{scope_text}处理结果,请稍后重试," + "或补充目标、范围和时间要求。" + ) + return ( + f"当前回复暂未生成到位,系统已保留这条{scope_text}处理结果。" + "请稍后重试,或补充目标、范围和时间要求。" + ) + @staticmethod def _fallback_reply_text(capture_result: Dict[str, Any]) -> str: - status = str(capture_result.get("status") or "") - classification = str(capture_result.get("classification") or "") - if status == "captured": - if classification in {"project", "new_project"}: - return "我已收到需求并完成初步识别,已按项目级信息入队处理。你可以继续补充目标、范围和时间,我会进一步细化分派。" - return "我已收到需求并完成初步识别,已进入信号处理流程。若你希望我直接分派任务,请补充目标、优先级和期望时间。" - return "我已收到消息并开始处理。若你希望我直接分派任务,请补充目标、优先级和时间要求。" + return FeishuReplyLLMCoreMixin._build_llm_failure_reply_text(capture_result) @staticmethod def _build_time_truth_reply(message_content: Optional[str]) -> Optional[str]: diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py index 4208ab85..a3a3517c 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py @@ -256,6 +256,7 @@ class FeishuReplyOutboxMixin: return tenant_id = int(claimed.get("tenant_id") or 1) reply_hash = str(claimed.get("reply_hash") or "").strip() + sent_message_id: Optional[str] = None if reply_hash: async with AsyncSessionLocal() as db_check: sent_duplicate = ( @@ -296,15 +297,16 @@ class FeishuReplyOutboxMixin: card_content = cls._decode_outbox_card_message(raw_message) if not isinstance(card_content, dict): raise ValueError("invalid_outbox_card_payload") - await service.send_interactive_card( + send_result = await service.send_interactive_card( chat_id=str(claimed.get("chat_id") or ""), card_content=card_content, ) else: - await service.send_message_to_group( + send_result = await service.send_message_to_group( chat_id=str(claimed.get("chat_id") or ""), message=raw_message, ) + sent_message_id = cls._extract_sent_message_id(send_result) except Exception as exc: send_error = str(exc) @@ -324,6 +326,8 @@ class FeishuReplyOutboxMixin: row.sent_at = now_cn_naive() row.next_retry_at = None row.last_error = None + if sent_message_id: + row.sent_message_id = sent_message_id[:128] await db_update.commit() perf_t2 = time.perf_counter() perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None) @@ -366,3 +370,21 @@ class FeishuReplyOutboxMixin: return None raw = f"{source}{body}".encode("utf-8") return hashlib.md5(raw, usedforsecurity=False).hexdigest() + + @staticmethod + def _extract_sent_message_id(response: Any) -> Optional[str]: + if not isinstance(response, dict): + return None + data = response.get("data") + data_dict = data if isinstance(data, dict) else {} + candidates = ( + response.get("message_id"), + response.get("msg_id"), + data_dict.get("message_id"), + data_dict.get("msg_id"), + ) + for value in candidates: + text = str(value or "").strip() + if text: + return text[:128] + return None diff --git a/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_connection.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_connection.py index c10e4dea..bec717d1 100644 --- a/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_connection.py +++ b/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_connection.py @@ -8,6 +8,7 @@ from .shared import ( Integration, Optional, asyncio, + ensure_feishu_tls_trust_bundle, json, logger, now_cn_aware, @@ -63,6 +64,8 @@ class LongConnectionLifecycleConnectionMixin: self._tenant_id = int((config or {}).get("tenant_id") or 1) app_id = str((config or {}).get("app_id") or "").strip() app_secret = str((config or {}).get("app_secret") or "").strip() + encrypt_key = str((config or {}).get("encrypt_key") or "").strip() + verification_token = str((config or {}).get("verification_token") or "").strip() self._configured = bool(app_id and app_secret) if not self._configured: @@ -76,6 +79,16 @@ class LongConnectionLifecycleConnectionMixin: return False try: + tls_bundle = ensure_feishu_tls_trust_bundle() + logger.info( + "feishu_long_connection_tls_bundle_ready", + tenant_id=self._tenant_id, + bundle_path=tls_bundle["bundle_path"], + source=tls_bundle["source"], + applied_keys=tls_bundle["applied_keys"], + has_encrypt_key=bool(encrypt_key), + has_verification_token=bool(verification_token), + ) await self._preflight_auth(tenant_id=self._tenant_id) self._last_auth_at = now_cn_aware().isoformat() except Exception as exc: @@ -98,7 +111,12 @@ class LongConnectionLifecycleConnectionMixin: self._last_disconnect_at = None self._restart_attempts = 0 self._next_retry_at = 0.0 - self._start_ws_thread(app_id, app_secret) + self._start_ws_thread( + app_id, + app_secret, + encrypt_key=encrypt_key, + verification_token=verification_token, + ) return True async def _preflight_auth(self, *, tenant_id: int) -> None: @@ -169,10 +187,17 @@ class LongConnectionLifecycleConnectionMixin: backoff = self._retry_base_seconds * (2 ** max(0, self._restart_attempts - 1)) self._next_retry_at = time.time() + backoff - def _start_ws_thread(self, app_id: str, app_secret: str) -> None: + def _start_ws_thread( + self, + app_id: str, + app_secret: str, + *, + encrypt_key: str = "", + verification_token: str = "", + ) -> None: self._thread = threading.Thread( target=self._run_ws_client, - args=(app_id, app_secret), + args=(app_id, app_secret, encrypt_key, verification_token), name="feishu-long-connection", daemon=True, ) diff --git a/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_control.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_control.py index 11ac8009..70f91899 100644 --- a/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_control.py +++ b/backend/app/services/integrations/feishu_long_connection_core/mixin_lifecycle_control.py @@ -40,6 +40,8 @@ class LongConnectionLifecycleControlMixin: self._last_error: Optional[str] = None self._last_error_code: Optional[str] = None self._last_error_retryable = False + self._last_received_at: Optional[str] = None + self._last_received_message_id: Optional[str] = None self._last_event_at: Optional[str] = None self._last_auth_at: Optional[str] = None self._last_connect_at: Optional[str] = None @@ -134,6 +136,8 @@ class LongConnectionLifecycleControlMixin: "last_error": self._last_error, "last_error_code": self._last_error_code, "last_error_retryable": self._last_error_retryable, + "last_received_at": self._last_received_at, + "last_received_message_id": self._last_received_message_id, "last_event_at": self._last_event_at, "last_auth_at": self._last_auth_at, "last_connect_at": self._last_connect_at, diff --git a/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py index 792b85ef..6f989497 100644 --- a/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py +++ b/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py @@ -2,18 +2,34 @@ from __future__ import annotations -from .shared import Any, FeishuIntegrationService, Optional, asyncio, logger, now_cn_aware, time +from .shared import ( + Any, + FeishuIntegrationService, + Optional, + asyncio, + ensure_feishu_tls_trust_bundle, + logger, + now_cn_aware, + time, +) class LongConnectionRuntimeMixin: """Owns SDK callback/runtime event processing.""" - def _run_ws_client(self, app_id: str, app_secret: str) -> None: + def _run_ws_client( + self, + app_id: str, + app_secret: str, + encrypt_key: str = "", + verification_token: str = "", + ) -> None: """Run blocking SDK websocket client in dedicated thread.""" try: + ensure_feishu_tls_trust_bundle() import lark_oapi as lark # type: ignore - builder = lark.EventDispatcherHandler.builder("", "") + builder = lark.EventDispatcherHandler.builder(encrypt_key or "", verification_token or "") register_receive = getattr(builder, "register_p2_im_message_receive_v1", None) if not callable(register_receive): raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1") @@ -80,6 +96,9 @@ class LongConnectionRuntimeMixin: payload = self._to_dict(data) message_id = "" event_type = "" + event_keys: list[str] = [] + message_keys: list[str] = [] + thread_debug: dict[str, Any] = {} try: event = payload.get("event", {}) if isinstance(payload, dict) else {} header = payload.get("header", {}) if isinstance(payload, dict) else {} @@ -91,6 +110,31 @@ class LongConnectionRuntimeMixin: or payload.get("type") or "" ).strip() + if isinstance(event, dict): + event_keys = [str(k) for k in sorted(event.keys())[:40]] + if isinstance(message, dict): + message_keys = [str(k) for k in sorted(message.keys())[:40]] + if isinstance(event, dict) and isinstance(message, dict): + candidate_keys = ( + "thread_id", + "message_thread_id", + "chat_thread_id", + "root_id", + "root_message_id", + "reply_root_id", + "reply_to_message_id", + "reply_to_id", + "parent_id", + "reply_in_thread_message_id", + ) + thread_debug = { + key: message.get(key) + for key in candidate_keys + if str(message.get(key) or "").strip() + } + for key in candidate_keys: + if key not in thread_debug and str(event.get(key) or "").strip(): + thread_debug[key] = event.get(key) except Exception: pass if isinstance(payload, dict): @@ -100,6 +144,27 @@ class LongConnectionRuntimeMixin: f"message_id={message_id or 'unknown'} " f"event_type={event_type or 'unknown'}" ) + self._last_received_at = now_cn_aware().isoformat() + self._last_received_message_id = message_id or None + if thread_debug: + logger.info( + "feishu_long_connection_thread_context_snapshot", + tenant_id=self._tenant_id, + message_id=message_id or "unknown", + event_type=event_type or "unknown", + event_keys=event_keys, + message_keys=message_keys, + thread_debug={k: str(v)[:128] for k, v in thread_debug.items()}, + ) + else: + logger.info( + "feishu_long_connection_event_keys_snapshot", + tenant_id=self._tenant_id, + message_id=message_id or "unknown", + event_type=event_type or "unknown", + event_keys=event_keys, + message_keys=message_keys, + ) logger.info( "feishu_long_connection_event_received: " f"event_type={event_type or 'unknown'}, message_id={message_id or 'unknown'}" diff --git a/backend/app/services/integrations/feishu_long_connection_core/shared.py b/backend/app/services/integrations/feishu_long_connection_core/shared.py index 47428364..04789247 100644 --- a/backend/app/services/integrations/feishu_long_connection_core/shared.py +++ b/backend/app/services/integrations/feishu_long_connection_core/shared.py @@ -11,6 +11,7 @@ import threading import time from typing import Any, Optional +import certifi from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from sqlalchemy.future import select @@ -24,6 +25,43 @@ logger = get_logger(__name__) DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS = 3 DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS = 1.0 + +def ensure_feishu_tls_trust_bundle() -> dict[str, Any]: + """Ensure Feishu websocket/HTTP clients have a trustworthy CA bundle. + + The local development machine may not have the full system trust store + available for Feishu's certificate chain. We default to certifi and let + explicit environment overrides win. + """ + + explicit_bundle = str(os.getenv("FEISHU_LONG_CONNECTION_SSL_CERT_FILE") or "").strip() + current_ssl_bundle = str(os.getenv("SSL_CERT_FILE") or "").strip() + bundle_path = explicit_bundle or current_ssl_bundle or certifi.where() + source = ( + "FEISHU_LONG_CONNECTION_SSL_CERT_FILE" + if explicit_bundle + else "SSL_CERT_FILE" + if current_ssl_bundle + else "certifi" + ) + + applied_keys: list[str] = [] + if explicit_bundle: + for key in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): + os.environ[key] = bundle_path + applied_keys.append(key) + else: + for key in ("SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"): + if not os.getenv(key): + os.environ[key] = bundle_path + applied_keys.append(key) + + return { + "bundle_path": bundle_path, + "source": source, + "applied_keys": applied_keys, + } + __all__ = [ "Any", "Optional", @@ -41,6 +79,7 @@ __all__ = [ "FeishuIntegrationService", "CN_TZ", "now_cn_aware", + "ensure_feishu_tls_trust_bundle", "logger", "DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS", "DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS", diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_project.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_project.py index 071be92d..e0c2f9c2 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_project.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_project.py @@ -11,6 +11,7 @@ class FeishuPipelineCommandCommitProjectMixin: operator_user: User, payload: dict[str, Any], trace_id: str, + idempotency_key: Optional[str] = None, ) -> dict[str, Any]: operator_user_id = _safe_int(getattr(operator_user, "id", None)) if operator_user_id is None: @@ -20,6 +21,29 @@ class FeishuPipelineCommandCommitProjectMixin: if not title: raise ValueError("project title required") description = str(payload.get("description") or "").strip()[:4000] or None + normalized_idempotency_key = self._build_project_create_idempotency_key( + idempotency_key=idempotency_key, + trace_id=trace_id, + ) + + existing_ledger = await self._load_project_create_ledger( + idempotency_key=normalized_idempotency_key, + ) + if existing_ledger is not None and str(existing_ledger.status or "").strip().lower() in {"applied", "skipped"}: + stored_result = existing_ledger.result_payload if isinstance(existing_ledger.result_payload, dict) else {} + if stored_result: + return dict(stored_result) + + ledger = await self._claim_project_create_ledger( + idempotency_key=normalized_idempotency_key, + trace_id=trace_id, + payload={ + "title": title, + "description": description, + }, + ) + if ledger is not None and isinstance(ledger.result_payload, dict) and str(ledger.status or "").strip().lower() in {"applied", "skipped"}: + return dict(ledger.result_payload) existing = ( await self.db.execute( @@ -33,7 +57,7 @@ class FeishuPipelineCommandCommitProjectMixin: ) ).scalar_one_or_none() if existing is not None: - return { + result = { "status": "skipped", "message": "project_already_exists", "entity_type": "project", @@ -44,47 +68,83 @@ class FeishuPipelineCommandCommitProjectMixin: "status": str(existing.status or "").strip(), }, } + await self._finalize_project_create_ledger( + ledger=ledger, + status="skipped", + result=result, + project_id=int(existing.id), + ) + await self.db.commit() + return result await ensure_project_id_sequence(self.db) - project = Project( - tenant_id=self.tenant_id, - name=title, - title=title, - description=description, - owner_id=operator_user_id, - status="active", - domain_id=None, - assigned_role="ai_engineer", - signal_count=0, - ) - self.db.add(project) - await self.db.flush() - await ensure_project_default_members( - self.db, - tenant_id=self.tenant_id, - project_id=int(project.id), - owner_id=int(project.owner_id) if project.owner_id is not None else None, - assigned_role=project.assigned_role, - ) - await log_operation( - self.db, - tenant_id=self.tenant_id, - user_id=operator_user_id, - project_id=int(project.id), - entity_type="project", - entity_id=str(project.id), - action="project.create", - before_data=None, - after_data={ - "title": project.title, - "status": project.status, - "owner_id": int(project.owner_id) if project.owner_id is not None else None, - }, - remark=trace_id[:1000], - ) - await self.db.commit() - await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) - return { + project: Optional[Project] = None + try: + async with self.db.begin_nested(): + project = Project( + tenant_id=self.tenant_id, + name=title, + title=title, + description=description, + owner_id=operator_user_id, + status="active", + domain_id=None, + assigned_role="ai_engineer", + signal_count=0, + ) + self.db.add(project) + await self.db.flush() + await ensure_project_default_members( + self.db, + tenant_id=self.tenant_id, + project_id=int(project.id), + owner_id=int(project.owner_id) if project.owner_id is not None else None, + assigned_role=project.assigned_role, + ) + await log_operation( + self.db, + tenant_id=self.tenant_id, + user_id=operator_user_id, + project_id=int(project.id), + entity_type="project", + entity_id=str(project.id), + action="project.create", + before_data=None, + after_data={ + "title": project.title, + "status": project.status, + "owner_id": int(project.owner_id) if project.owner_id is not None else None, + }, + remark=trace_id[:1000], + ) + except IntegrityError: + duplicate = await self._load_existing_project_by_title(title=title) + if duplicate is None: + raise + result = { + "status": "skipped", + "message": "project_already_exists", + "entity_type": "project", + "entity_id": str(duplicate.id), + "result": { + "project_id": int(duplicate.id), + "title": str(duplicate.title or duplicate.name or "").strip(), + "status": str(duplicate.status or "").strip(), + }, + } + await self._finalize_project_create_ledger( + ledger=ledger, + status="skipped", + result=result, + project_id=int(duplicate.id), + ) + await self.db.commit() + return result + + if project is None: + raise ValueError("project_create_failed") + + result = { "status": "applied", "message": f"project created #{int(project.id)}", "entity_type": "project", @@ -95,4 +155,100 @@ class FeishuPipelineCommandCommitProjectMixin: "status": str(project.status or "").strip(), }, } + await self._finalize_project_create_ledger( + ledger=ledger, + status="applied", + result=result, + project_id=int(project.id), + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) + return result + @staticmethod + def _build_project_create_idempotency_key( + *, + idempotency_key: Optional[str], + trace_id: str, + ) -> str: + raw = str(idempotency_key or "").strip() or str(trace_id or "").strip() + return raw[:128] + + async def _load_project_create_ledger( + self, + *, + idempotency_key: str, + ) -> Optional[OperationExecutionLedger]: + return ( + await self.db.execute( + select(OperationExecutionLedger).where( + and_( + OperationExecutionLedger.tenant_id == self.tenant_id, + OperationExecutionLedger.operation_code == "project.create", + OperationExecutionLedger.idempotency_key == idempotency_key, + ) + ).limit(1) + ) + ).scalar_one_or_none() + + async def _claim_project_create_ledger( + self, + *, + idempotency_key: str, + trace_id: str, + payload: dict[str, Any], + ) -> Optional[OperationExecutionLedger]: + ledger = await self._load_project_create_ledger(idempotency_key=idempotency_key) + if ledger is not None: + return ledger + try: + async with self.db.begin_nested(): + ledger = OperationExecutionLedger( + tenant_id=self.tenant_id, + operation_code="project.create", + project_id=None, + idempotency_key=idempotency_key, + trace_id=trace_id[:64] or None, + status="processing", + payload=payload, + result_payload=None, + entity_type="project", + entity_id=None, + error_message=None, + ) + self.db.add(ledger) + await self.db.flush() + return ledger + except IntegrityError: + return await self._load_project_create_ledger(idempotency_key=idempotency_key) + + async def _load_existing_project_by_title(self, *, title: str) -> Optional[Project]: + return ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + or_(func.lower(Project.title) == title.lower(), func.lower(Project.name) == title.lower()), + Project.status != "inactive", + ) + ).limit(1) + ) + ).scalar_one_or_none() + + async def _finalize_project_create_ledger( + self, + *, + ledger: Optional[OperationExecutionLedger], + status: str, + result: dict[str, Any], + project_id: Optional[int], + ) -> None: + if ledger is None: + return + ledger.status = str(status or "").strip().lower() or "processing" + ledger.project_id = _safe_int(project_id) + ledger.result_payload = dict(result) + ledger.entity_type = "project" + ledger.entity_id = str(project_id) if project_id is not None else None + ledger.error_message = None + await self.db.flush() diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_task.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_task.py index 674d72d1..6b8d8b15 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_task.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_commit_task.py @@ -11,10 +11,16 @@ class FeishuPipelineCommandCommitTaskMixin: operator_user: User, payload: dict[str, Any], trace_id: str, + idempotency_key: Optional[str] = None, ) -> dict[str, Any]: operator_user_id = _safe_int(getattr(operator_user, "id", None)) if operator_user_id is None: raise ValueError("operator_not_found") + + normalized_idempotency_key = self._build_task_create_idempotency_key( + idempotency_key=idempotency_key, + trace_id=trace_id, + ) project_id = _safe_int(payload.get("project_id")) if project_id is None: raise ValueError("project_context_missing") @@ -46,6 +52,26 @@ class FeishuPipelineCommandCommitTaskMixin: if charter is None: raise ValueError("aligned charter required before task.create") + existing_ledger = await self._load_task_create_ledger( + idempotency_key=normalized_idempotency_key, + ) + if existing_ledger is not None and str(existing_ledger.status or "").strip().lower() in {"applied", "skipped"}: + stored_result = existing_ledger.result_payload if isinstance(existing_ledger.result_payload, dict) else {} + if stored_result: + return dict(stored_result) + + ledger = await self._claim_task_create_ledger( + idempotency_key=normalized_idempotency_key, + trace_id=trace_id, + payload={ + "title": str(payload.get("title") or "").strip(), + "description": str(payload.get("description") or "").strip() or None, + "project_id": int(project_id), + }, + ) + if ledger is not None and isinstance(ledger.result_payload, dict) and str(ledger.status or "").strip().lower() in {"applied", "skipped"}: + return dict(ledger.result_payload) + task = await persist_task( self.db, tenant_id=self.tenant_id, @@ -59,20 +85,105 @@ class FeishuPipelineCommandCommitTaskMixin: actor_user_id=operator_user_id, action="task.create", remark=trace_id[:1000], - commit=True, + commit=False, refresh_workload=False, ) - await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) - self._sync_task_rag(task=task) - return { + task_id = int(task.id) + task_title = str(task.title or "").strip() + task_project_id = int(task.project_id) + result = { "status": "applied", - "message": f"task created #{int(task.id)}", + "message": f"task created #{task_id}", "entity_type": "task", - "entity_id": str(task.id), + "entity_id": str(task_id), "result": { - "task_id": int(task.id), - "title": str(task.title or "").strip(), - "project_id": int(project_id), + "task_id": task_id, + "title": task_title, + "project_id": task_project_id, }, } + await self._finalize_task_create_ledger( + ledger=ledger, + status="applied", + result=result, + task_id=task_id, + ) + await self.db.commit() + await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id) + self._sync_task_rag(task=task) + return result + @staticmethod + def _build_task_create_idempotency_key( + *, + idempotency_key: Optional[str], + trace_id: str, + ) -> str: + raw = str(idempotency_key or "").strip() or str(trace_id or "").strip() + return raw[:128] + + async def _load_task_create_ledger( + self, + *, + idempotency_key: str, + ) -> Optional[OperationExecutionLedger]: + return ( + await self.db.execute( + select(OperationExecutionLedger).where( + and_( + OperationExecutionLedger.tenant_id == self.tenant_id, + OperationExecutionLedger.operation_code == "task.create", + OperationExecutionLedger.idempotency_key == idempotency_key, + ) + ).limit(1) + ) + ).scalar_one_or_none() + + async def _claim_task_create_ledger( + self, + *, + idempotency_key: str, + trace_id: str, + payload: dict[str, Any], + ) -> Optional[OperationExecutionLedger]: + ledger = await self._load_task_create_ledger(idempotency_key=idempotency_key) + if ledger is not None: + return ledger + try: + async with self.db.begin_nested(): + ledger = OperationExecutionLedger( + tenant_id=self.tenant_id, + operation_code="task.create", + project_id=_safe_int(payload.get("project_id")), + idempotency_key=idempotency_key, + trace_id=trace_id[:64] or None, + status="processing", + payload=payload, + result_payload=None, + entity_type="task", + entity_id=None, + error_message=None, + ) + self.db.add(ledger) + await self.db.flush() + return ledger + except IntegrityError: + return await self._load_task_create_ledger(idempotency_key=idempotency_key) + + async def _finalize_task_create_ledger( + self, + *, + ledger: Optional[OperationExecutionLedger], + status: str, + result: dict[str, Any], + task_id: Optional[int], + ) -> None: + if ledger is None: + return + ledger.status = str(status or "").strip().lower() or "processing" + ledger.project_id = _safe_int(result.get("result", {}).get("project_id") if isinstance(result.get("result"), dict) else None) + ledger.entity_type = "task" + ledger.entity_id = str(task_id) if task_id is not None else None + ledger.result_payload = dict(result) + ledger.error_message = None + await self.db.flush() diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_exec_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_exec_core.py index a06e08ec..6bc549ac 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_exec_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_command_exec_core.py @@ -70,6 +70,7 @@ class FeishuPipelineCommandExecCoreMixin: operation_code: str, payload: dict[str, Any], trace_id: str, + idempotency_key: Optional[str] = None, ) -> dict[str, Any]: logger.info( "operation_started", @@ -84,12 +85,14 @@ class FeishuPipelineCommandExecCoreMixin: operator_user=operator_user, payload=payload, trace_id=trace_id, + idempotency_key=idempotency_key, ) elif operation_code == "task.create": result = await self._commit_task_create( operator_user=operator_user, payload=payload, trace_id=trace_id, + idempotency_key=idempotency_key, ) else: raise ValueError("operation_not_supported") @@ -144,4 +147,3 @@ class FeishuPipelineCommandExecCoreMixin: ) except Exception: return - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py index f50331f1..dc8e20e0 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py @@ -163,6 +163,8 @@ class FeishuPipelineContextPrefillAMixin: source_chat_id: str, source_sender_open_id: Optional[str], sender: str, + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, limit: int = 3, ) -> list[dict[str, str]]: if self.db is None or not source_chat_id: @@ -171,6 +173,8 @@ class FeishuPipelineContextPrefillAMixin: source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, ) external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) if external_session_id is None: @@ -276,4 +280,3 @@ class FeishuPipelineContextPrefillAMixin: "missing_fields": missing_fields, "ready": not missing_fields, } - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py index f1acd9cd..02edf566 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py @@ -13,6 +13,8 @@ class FeishuPipelineContextPrefillBMixin: source_sender_open_id: Optional[str], sender: str, fields: dict[str, Any], + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: if fields.get("ready"): return fields @@ -23,6 +25,8 @@ class FeishuPipelineContextPrefillBMixin: source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, limit=3, ) context_blocks: list[str] = [] @@ -39,12 +43,6 @@ class FeishuPipelineContextPrefillBMixin: context_blocks.append(f"最近系统回答:{recent_answer}") answer_hint = recent_answer - if not context_blocks: - reply_fallback = await self._load_recent_chat_reply_fallback_text(source_chat_id=source_chat_id) - if reply_fallback: - context_blocks.append(f"最近系统回答:{reply_fallback}") - answer_hint = reply_fallback - if not context_blocks: return fields @@ -230,4 +228,3 @@ class FeishuPipelineContextPrefillBMixin: if str(blocking_reason or "").strip(): return str(blocking_reason).strip() return _compact_text(fallback_text, max_len=140) - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py index 32e88e2a..0147a030 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_extraction.py @@ -257,9 +257,11 @@ class FeishuPipelineExtractionMixin: return { "suggested_name": suggested_name, "suggested_description": suggested_description, - "suggested_owner": "待指定", - "suggested_timeline": "待评估", - "confidence": 0.62, - "reasoning": ["LLM 返回异常,已按规则生成项目预填草案。"], + "suggested_owner": None, + "suggested_timeline": None, + "confidence": 0.35, + "reasoning": [ + "LLM 返回异常,已按规则生成项目预填草案。", + "负责人和时间线仅在消息明确给出时才填写。", + ], } - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py index 3271201d..65cb07f0 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_a.py @@ -2,6 +2,7 @@ from __future__ import annotations from .shared import * +from app.utils.thread_context import extract_thread_anchor class FeishuPipelineFollowupAMixin: @@ -11,8 +12,10 @@ class FeishuPipelineFollowupAMixin: message: str, sender: str, source_chat_id: str, + source_sender_open_id: Optional[str], feishu_message_id: Optional[str], process_started_at: float, + thread_context: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: if not source_chat_id: return None @@ -21,7 +24,26 @@ class FeishuPipelineFollowupAMixin: if not wants_continue and not wants_status: return None - recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id) + recent_items = await self._load_recent_chat_items( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + ) + thread_anchor = extract_thread_anchor(thread_context) + anchor_item = ( + next( + ( + item + for item in recent_items + if thread_anchor and str(getattr(item, "feishu_message_id", "") or "").strip() == thread_anchor + ), + None, + ) + if thread_anchor + else None + ) + if thread_anchor and anchor_item is None: + return None if not recent_items: reply_text = "当前群里还没有可追踪的飞书处理项。" decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED @@ -31,7 +53,10 @@ class FeishuPipelineFollowupAMixin: mutations: list[dict[str, Any]] = [] results: list[dict[str, Any]] = [] elif wants_continue: - pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED] + if anchor_item is not None and self._normalize_route_status(anchor_item.route_status) == ROUTE_STATUS_QUEUED: + pending_items = [anchor_item] + else: + pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED] if len(pending_items) != 1: reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3]) decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED @@ -144,14 +169,27 @@ class FeishuPipelineFollowupAMixin: } ] else: - target_item = self._select_followup_status_target(message=message, recent_items=recent_items) - reply_text = await self._build_followup_status_reply(target_item) - decision = ROUTING_QUEUE_FOLLOWUP_QUERY - reason = "命中进度/状态追问,已直接基于真实 DB 返回最近处理项状态。" - classification = "question" - status = "answered" - mutations = [] - results = [] + target_item = anchor_item or self._select_followup_status_target( + message=message, + recent_items=recent_items, + thread_context=thread_context, + ) + if target_item is None: + reply_text = self._build_followup_pending_clarification(recent_items[:3]) + decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED + reason = "状态追问缺少唯一锚点,系统拒绝猜测最近一条处理项。" + classification = "question" + status = "answered" + mutations = [] + results = [] + else: + reply_text = await self._build_followup_status_reply(target_item, message=message) + decision = ROUTING_QUEUE_FOLLOWUP_QUERY + reason = "命中进度/状态追问,已直接基于真实 DB 返回唯一处理项状态。" + classification = "question" + status = "answered" + mutations = [] + results = [] await self._write_processing_log( feishu_message_id=feishu_message_id, @@ -172,7 +210,7 @@ class FeishuPipelineFollowupAMixin: routing_decision=decision, routing_reason=reason, reasoning_bullets=[ - "follow-up 查询/指令命中最近飞书上下文。", + "follow-up 查询/指令命中飞书线程锚点或唯一目标。", "回复内容只基于真实 DB 状态,不使用自由生成。", f"recent_items={len(recent_items)}", ], @@ -203,16 +241,26 @@ class FeishuPipelineFollowupAMixin: "db_mutations": mutations, } - async def _load_recent_chat_items(self, *, source_chat_id: str, limit: int = 5) -> list[FeishuItem]: + async def _load_recent_chat_items( + self, + *, + source_chat_id: str, + source_sender_open_id: Optional[str] = None, + sender: Optional[str] = None, + limit: int = 5, + ) -> list[FeishuItem]: + filters = [ + FeishuItem.tenant_id == self.tenant_id, + FeishuItem.source_chat_id == (source_chat_id or "")[:128], + ] + normalized_open_id = str(source_sender_open_id or "").strip() + if normalized_open_id: + filters.append(FeishuItem.source_sender_open_id == normalized_open_id[:128]) + _ = sender rows = ( await self.db.execute( select(FeishuItem) - .where( - and_( - FeishuItem.tenant_id == self.tenant_id, - FeishuItem.source_chat_id == (source_chat_id or "")[:128], - ) - ) + .where(and_(*filters)) .order_by(FeishuItem.id.desc()) .limit(limit) ) @@ -243,6 +291,15 @@ class FeishuPipelineFollowupAMixin: if level == "command": project_id = confirm_result.get("project_id") task_id = confirm_result.get("task_id") + source_registry_id = confirm_result.get("source_registry_id") + payload = target_item.result_payload if isinstance(target_item.result_payload, dict) else {} + operation_code = str(payload.get("operation_code") or "").strip().lower() + if operation_code == "source.connect": + if source_registry_id and project_id: + return f"已继续处理:信号源注册#{source_registry_id} 已接入项目#{project_id}。" + if project_id: + return f"已继续处理:信号源接入请求已绑定项目#{project_id} 并开始执行。" + return f"已继续处理:命令队列项#{int(target_item.id)} 已确认。" if task_id and project_id: return f"已继续处理:任务#{task_id} 已在项目#{project_id} 中创建。" if project_id: @@ -253,4 +310,3 @@ class FeishuPipelineFollowupAMixin: if signal_id and project_id: return f"已继续处理:信号#{signal_id} 已确认并关联项目#{project_id}。" return f"已继续处理:队列项#{int(target_item.id)} 已确认。" - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py index 3b7ae1f2..3e2377d6 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_followup_b.py @@ -2,22 +2,47 @@ from __future__ import annotations from .shared import * +from app.utils.thread_context import extract_thread_anchor class FeishuPipelineFollowupBMixin: + @staticmethod + def _extract_item_operation_code(item: FeishuItem) -> str: + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + return str(payload.get("operation_code") or "").strip().lower() + @classmethod - def _select_followup_status_target(cls, *, message: str, recent_items: list[FeishuItem]) -> FeishuItem: + def _select_followup_status_target( + cls, + *, + message: str, + recent_items: list[FeishuItem], + thread_context: Optional[dict[str, Any]] = None, + ) -> Optional[FeishuItem]: if not recent_items: - raise ValueError("recent_items_empty") + return None normalized = re.sub(r"\s+", "", str(message or "")).lower() wants_project_create = any(token in normalized for token in ("创建", "建好", "建成", "项目")) wants_source_status = any(token in normalized for token in ("信号源", "web搜索", "webhook", "source")) + matches: list[FeishuItem] = [] + thread_anchor = extract_thread_anchor(thread_context) + if thread_anchor: + anchor_item = next( + ( + item + for item in recent_items + if str(getattr(item, "feishu_message_id", "") or "").strip() == thread_anchor + ), + None, + ) + return anchor_item if wants_source_status: for item in recent_items: operation_code = cls._extract_item_operation_code(item) if operation_code == "source.connect" or "信号源" in str(item.raw_message or ""): - return item + matches.append(item) + return matches[0] if len(matches) == 1 else None if wants_project_create: for item in recent_items: @@ -29,16 +54,42 @@ class FeishuPipelineFollowupBMixin: or operation_code == "project.create" or card_type in {"project_create", "new_project", "new_project_candidate"} ): - return item + matches.append(item) + return matches[0] if len(matches) == 1 else None - return recent_items[0] + return None - async def _build_followup_status_reply(self, item: FeishuItem) -> str: + async def _build_followup_status_reply(self, item: FeishuItem, *, message: str = "") -> str: route_status = self._normalize_route_status(item.route_status) level = str(item.level or "").strip().lower() if level == "project": suggestion = await self._get_project_suggestion(int(item.id)) project_id = suggestion.created_project_id if suggestion and suggestion.created_project_id else item.matched_project_id + project_row = None + if project_id is not None: + project_row = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == self.tenant_id, + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + wants_created_at = bool( + re.search(r"(什么时候.*创建|何时.*创建|创建时间|立项时间|成立时间|创建于|何时立项|什么时候立项)", str(message or ""), re.IGNORECASE) + ) + if wants_created_at and project_row is not None: + created_at = project_row.created_at + if created_at is not None: + if created_at.tzinfo is not None: + created_at = created_at.astimezone(CN_TZ) + return ( + f"项目「{str(project_row.title or project_row.name or f'项目#{int(project_row.id)}')}」" + f"创建于 {created_at.strftime('%Y-%m-%d %H:%M')}。" + ) + return f"项目「{str(project_row.title or project_row.name or f'项目#{int(project_row.id)}')}」已找到,但未记录创建时间。" if route_status == ROUTE_STATUS_AUTO_DONE and project_id: return f"最近一条项目候选已确认,项目#{int(project_id)} 已落库。" if route_status == ROUTE_STATUS_UNDONE: @@ -50,7 +101,17 @@ class FeishuPipelineFollowupBMixin: operation_code = str(payload.get("operation_code") or "").strip() project_id = _safe_int(payload.get("project_id") or item.matched_project_id) summary = str(payload.get("result_summary") or "").strip() + signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {} + source_registry_id = _safe_int( + payload.get("source_registry_id") + or signal_source.get("registry_id") + ) if route_status == ROUTE_STATUS_AUTO_DONE: + if operation_code == "source.connect" and source_registry_id is not None: + return ( + f"最近一条信号源接入已完成,注册#{int(source_registry_id)}" + + (f",归属项目#{int(project_id)}。" if project_id is not None else "。") + ) return summary or ( f"最近一条命令已执行完成,归属项目#{int(project_id)}。" if project_id is not None @@ -59,6 +120,8 @@ class FeishuPipelineFollowupBMixin: if route_status == ROUTE_STATUS_UNDONE: return "最近一条命令结果已撤销。" if route_status == ROUTE_STATUS_QUEUED: + if operation_code == "source.connect": + return "最近一条信号源接入请求已入队待确认。" return ( f"最近一条命令 {operation_code or '待处理动作'} 已入队待确认。" if operation_code @@ -296,4 +359,3 @@ class FeishuPipelineFollowupBMixin: if operation_code == "notification.create": return f"通知#{row.get('entity_id') or ''}已创建".strip() return message or operation_code - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py index b5327f73..22fa3a50 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_dispatch.py @@ -91,6 +91,7 @@ class FeishuPipelineGateDispatchMixin: projects: list[dict[str, Any]], process_started_at: float, normalized_chat_type: str, + thread_context: Optional[dict[str, Any]] = None, ) -> Optional[dict[str, Any]]: gate_intent = str(intent_gate.get("intent") or INTENT_SIGNAL_INTAKE) if gate_intent == INTENT_SMALLTALK: @@ -155,6 +156,8 @@ class FeishuPipelineGateDispatchMixin: source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, sender=sender, + source_message_id=feishu_message_id, + thread_context=thread_context, ) intent_result: dict[str, Any] = {} try: @@ -171,14 +174,14 @@ class FeishuPipelineGateDispatchMixin: question=rag_question, asked_by=None, asked_via="feishu", - project_id=inferred_project_id, + project_id=None, source_chat_id=source_chat_id, conversation_key=conversation_key, ) qa_capture = await self._build_direct_qa_capture( message=message, rag_result=rag_result, - inferred_project_id=inferred_project_id, + inferred_project_id=None, feishu_message_id=feishu_message_id, process_started_at=process_started_at, route_reason="intent gate 判定为 query,直接走 RAG 直答通道,不进入决策队列。", diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_project.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_project.py index 7433a131..fe826fb4 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_project.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_gate_project.py @@ -66,6 +66,7 @@ class FeishuPipelineGateProjectMixin: "clarification_message": clarification_message, "direct_reply_required": True, "direct_reply_text": clarification_message, + "reply_template_key": "queue_pending_confirm", } if not self._can_execute_high_value_action(operator_user): @@ -130,79 +131,62 @@ class FeishuPipelineGateProjectMixin: "description": payload_description, } trace_id = f"intent-project-{self._stable_positive_int(message, sender, feishu_message_id)}" - execution = await self._execute_controlled_operation( - operator_user=operator_user, - operation_code="project.create", - payload=payload, - trace_id=trace_id, - ) - first = execution["results"][0] if execution.get("results") else {} - status = str(first.get("status") or "").strip().lower() - if status not in {"applied", "skipped"}: - raise ValueError(str(first.get("message") or "project.create failed")) - - project_result = first.get("result") if isinstance(first.get("result"), dict) else {} - project_id = _safe_int(project_result.get("project_id") or first.get("entity_id")) - item_id = await self._record_committed_operation_item( + queued = await self._queue_project_intent_item( message=message, sender=sender, source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, feishu_message_id=feishu_message_id, - captured_at=captured_at, - intent=INTENT_CREATE_PROJECT, - operation_code="project.create", - execution_result=first, - matched_project_id=project_id, + occurred_at=occurred_at, + intent_gate=intent_gate, + blocking_reason=clarification_message or "建项目已进入待确认队列,请点击继续执行。", + decision_reason="high_value_action_guard", + ) + item_id = _safe_int(queued.get("item_id")) + reply_text = ( + str(queued.get("clarification_message") or "").strip() + or "建项目已进入待确认队列,请点击继续执行。" ) - reply_text = self._build_operation_commit_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}}) await self._write_processing_log( feishu_message_id=feishu_message_id, feishu_item_id=item_id, raw_message=message, - ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000], + ai_raw_output=json.dumps({"intent_gate": intent_gate, "queued": queued}, ensure_ascii=False)[:120000], project_score=1.0, signal_confidence=0.0, level="project", - routing_decision=ROUTING_OPERATION_COMMITTED, - routing_reason="建项目已通过 deterministic operation executor 提交,并以真实 DB 结果回执。", + routing_decision=ROUTING_INTENT_GATE_CLARIFICATION, + routing_reason="建项目已转为待确认卡片,等待人工点击继续执行。", reasoning_bullets=[ "intent gate 命中 create_project。", - "执行器仅走 project.create。", - "回复绑定真实 commit 结果。", + "执行器不再直接 commit,统一进入待确认卡片。", + "点击后再走受控 project.create。", ], processing_status="success", error_message=None, duration_ms=int((time.perf_counter() - process_started_at) * 1000), normalized_text=message, - route=ROUTING_OPERATION_COMMITTED, + route=ROUTING_INTENT_GATE_CLARIFICATION, intent=INTENT_CREATE_PROJECT, executor="explicit_service", operation_code="project.create", - operation_result=first if isinstance(first, dict) else None, - reply_source="db_commit", + operation_result={"status": "queued", "queue_item_id": item_id}, + reply_source="deterministic_reply", ) return { "status": "captured", "classification": "project", "project_scope": "project", - "linked_project_ids": [int(project_id)] if project_id is not None else [], + "linked_project_ids": [], "signal_id": None, - "created_project_id": int(project_id) if project_id is not None else None, + "created_project_id": None, "auto_tasks": [], - "ai_trace_id": trace_id, - "results": [ - { - "item_id": item_id, - "level": "command", - "project_id": int(project_id) if project_id is not None else None, - "signal_id": None, - "route_status": ROUTE_STATUS_AUTO_DONE, - } - ], + "ai_trace_id": f"feishu-project-pending-{item_id}", + "results": [queued], "intent": INTENT_CREATE_PROJECT, "direct_reply_required": True, "direct_reply_text": reply_text, - "command_result": {"execution": execution}, + "reply_template_key": "queue_pending_confirm", + "queue_item_id": item_id, + "command_result": {"queue": queued, "trace_id": trace_id}, } - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py index c4afed3e..f61fb9cd 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py @@ -13,6 +13,8 @@ class FeishuPipelineIntentGateCoreMixin: source_chat_id: str = "", source_sender_open_id: Optional[str] = None, sender: str = "", + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: text = str(message or "").strip() if not text: @@ -121,6 +123,8 @@ class FeishuPipelineIntentGateCoreMixin: source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, fields=fields, ) if fields["ready"]: @@ -280,4 +284,3 @@ class FeishuPipelineIntentGateCoreMixin: def _strip_feishu_mention_prefix(text: str) -> str: cleaned = re.sub(r"^]*>[^<]*\s*", "", str(text or ""), flags=re.IGNORECASE).strip() return re.sub(r"\s+", " ", cleaned).strip() - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py index 5d2fee55..caf5c798 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_finalize_routing.py @@ -166,7 +166,7 @@ class FeishuPipelineItemFinalizeRoutingMixin: correction_keywords=[], complexity_reasons=[], ) - if cold_start_enabled or gate.action != "auto_execute" or confidence < signal_auto_enter_threshold or not matched_project_id: + if cold_start_enabled or gate.action != "auto_execute" or not matched_project_id: item.card_type = "signal" item.route_status = ROUTE_STATUS_QUEUED item.queue_visible = True @@ -181,14 +181,10 @@ class FeishuPipelineItemFinalizeRoutingMixin: item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。" item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。" item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。" - elif gate.action == "reject": - item.blocking_reason = gate.reason - item.next_step_text = "请补充更明确的执行对象后重试,或人工确认继续处理。" - item.decision_reason = "共享决策内核判定置信度不足,自动执行被拒绝。" else: - item.blocking_reason = gate.reason or "当前置信度不足以自动入库,需要你拍板后再继续。" + item.blocking_reason = gate.reason or "当前信息不足以自动入库,需要你确认后再继续。" item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。" - item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。" + item.decision_reason = "信号仍需人工确认后再进入自动入库。" item.result_payload = { "result_summary": "信号已进入待确认队列。", "result_count": 0, @@ -222,4 +218,3 @@ class FeishuPipelineItemFinalizeRoutingMixin: signal=signal, ) return int(matched_project_id) - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py index e3594194..3e5bcd92 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_item_processing.py @@ -221,8 +221,12 @@ class FeishuPipelineItemProcessingMixin: feishu_item_id=int(item_id), suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255], suggested_description=(project_info.get("suggested_description") or content[:400])[:4000], - suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120], - suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120], + suggested_owner=( + str(project_info.get("suggested_owner") or "").strip()[:120] or None + ), + suggested_timeline=( + str(project_info.get("suggested_timeline") or "").strip()[:120] or None + ), confidence=_safe_float(project_info.get("confidence"), fallback_confidence), reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False), status="pending", @@ -291,4 +295,3 @@ class FeishuPipelineItemProcessingMixin: "signal_id": None, "route_status": item.route_status, } - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py index d70ed30e..667c200b 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_a.py @@ -2,6 +2,7 @@ from __future__ import annotations from .shared import * +from app.utils.thread_context import build_feishu_thread_key class FeishuPipelineMessageUtilsAMixin: @@ -75,16 +76,16 @@ class FeishuPipelineMessageUtilsAMixin: source_chat_id: Optional[str], source_sender_open_id: Optional[str], sender: Optional[str], + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> str: - chat = str(source_chat_id or "").strip() - if not chat: - return "" - actor = str(source_sender_open_id or "").strip() - if not actor: - actor = str(sender or "").strip().lower() - actor = re.sub(r"\s+", "", actor) - actor = actor[:96] if actor else "unknown" - return f"feishu:{chat}:{actor}" + return build_feishu_thread_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, + ) @staticmethod def _is_requirement_like_question(message: str) -> bool: @@ -137,4 +138,3 @@ class FeishuPipelineMessageUtilsAMixin: if converted: return converted return self._normalize_incoming_text(fallback_content) - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py index e6b2fb2c..092b9131 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_message_utils_b.py @@ -164,6 +164,7 @@ class FeishuPipelineMessageUtilsBMixin: matched_project_id: Optional[int], signal_auto_enter_threshold: float, ) -> tuple[str, str, list[str]]: + _ = signal_auto_enter_threshold if cold_start_enabled: return ( ROUTING_PENDING_COLD_START, @@ -174,31 +175,21 @@ class FeishuPipelineMessageUtilsBMixin: "等待人工审核后再入库。", ], ) - if confidence < signal_auto_enter_threshold: - return ( - ROUTING_PENDING_LOW_CONFIDENCE, - "信号置信度低于自动入库阈值,已进入待处理队列。", - [ - f"signal_confidence={confidence:.2f},低于 {signal_auto_enter_threshold:.2f} 阈值。", - "当前信息不足以支持自动决策。", - "需要人工确认后再执行后续动作。", - ], - ) if not matched_project_id: return ( ROUTING_PENDING_UNCERTAIN_PROJECT, - "置信度达标但项目归属不确定,转人工确认。", + "项目归属不确定,转人工确认。", [ - f"signal_confidence={confidence:.2f},达到自动判定线。", + f"signal_confidence={confidence:.2f},仅用于排序参考。", "未识别到明确项目归属。", "已进入待处理队列等待项目确认。", ], ) return ( ROUTING_AUTO_ENTER_SIGNAL, - "信号置信度达标且项目归属明确,系统已自动入库。", + "项目归属明确,系统已自动入库。", [ - f"signal_confidence={confidence:.2f},高于 {signal_auto_enter_threshold:.2f} 阈值。", + f"signal_confidence={confidence:.2f},仅用于排序参考。", f"已匹配项目 #{int(matched_project_id)}。", "已自动建立信号关联并发送通知。", ], @@ -261,4 +252,3 @@ class FeishuPipelineMessageUtilsBMixin: and any(token in text for token in action_tokens) and any(token in text for token in object_tokens) ) - 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 40fa9279..9ef19db5 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 @@ -15,6 +15,7 @@ class FeishuPipelineProcessMessageCoreMixin: feishu_message_id: Optional[str] = None, captured_at: Optional[datetime] = None, chat_type: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: process_started_at = time.perf_counter() normalized_chat_type = self._normalize_chat_type(chat_type=chat_type, source_chat_id=source_chat_id) @@ -124,8 +125,10 @@ class FeishuPipelineProcessMessageCoreMixin: 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: await self.db.commit() @@ -159,6 +162,8 @@ class FeishuPipelineProcessMessageCoreMixin: source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, sender=sender, + source_message_id=feishu_message_id, + thread_context=thread_context, ) logger.info( "intent_resolved", @@ -186,6 +191,7 @@ class FeishuPipelineProcessMessageCoreMixin: projects=projects, process_started_at=process_started_at, normalized_chat_type=normalized_chat_type, + thread_context=thread_context, ) if gate_capture is not None: return gate_capture @@ -203,5 +209,5 @@ class FeishuPipelineProcessMessageCoreMixin: process_started_at=process_started_at, normalized_chat_type=normalized_chat_type, chat_context_resolution=chat_context_resolution, + thread_context=thread_context, ) - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py index 723f04a8..ce933a49 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py @@ -20,12 +20,15 @@ class FeishuPipelineProcessMessageDefaultMixin: process_started_at: float, normalized_chat_type: str, chat_context_resolution: dict[str, Any], + thread_context: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) conversation_key = self._build_rag_conversation_key( source_chat_id=source_chat_id, source_sender_open_id=source_sender_open_id, sender=sender, + source_message_id=feishu_message_id, + thread_context=thread_context, ) sidecar_rag_result: Optional[dict[str, Any]] = None @@ -54,7 +57,7 @@ class FeishuPipelineProcessMessageDefaultMixin: question=rag_question_part, asked_by=None, asked_via="feishu", - project_id=inferred_project_id, + project_id=None, source_chat_id=source_chat_id, conversation_key=conversation_key, message_intent="mixed", @@ -69,14 +72,14 @@ class FeishuPipelineProcessMessageDefaultMixin: question=rag_question_part, asked_by=None, asked_via="feishu", - project_id=inferred_project_id, + project_id=None, source_chat_id=source_chat_id, conversation_key=conversation_key, ) return await self._finalize_direct_qa_capture_result( message=message, rag_result=rag_result, - inferred_project_id=inferred_project_id, + inferred_project_id=None, feishu_message_id=feishu_message_id, process_started_at=process_started_at, route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。", @@ -103,7 +106,7 @@ class FeishuPipelineProcessMessageDefaultMixin: question=rag_question_part, asked_by=None, asked_via="feishu", - project_id=inferred_project_id, + project_id=None, source_chat_id=source_chat_id, conversation_key=conversation_key, message_intent="mixed", @@ -118,14 +121,14 @@ class FeishuPipelineProcessMessageDefaultMixin: question=rag_question_part, asked_by=None, asked_via="feishu", - project_id=inferred_project_id, + project_id=None, source_chat_id=source_chat_id, conversation_key=conversation_key, ) return await self._finalize_direct_qa_capture_result( message=message, rag_result=rag_result, - inferred_project_id=inferred_project_id, + inferred_project_id=None, feishu_message_id=feishu_message_id, process_started_at=process_started_at, route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。", @@ -145,7 +148,7 @@ class FeishuPipelineProcessMessageDefaultMixin: question=rag_question_part, asked_by=None, asked_via="feishu", - project_id=inferred_project_id, + project_id=None, source_chat_id=source_chat_id, conversation_key=conversation_key, message_intent="mixed", diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py index c1498566..714b2ca9 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_b.py @@ -17,18 +17,28 @@ class FeishuPipelineProcessMessageHelpersBMixin: payload = dict(result or {}) direct_reply_text = str(payload.get("direct_reply_text") or "").strip() if direct_reply_text: - payload["direct_reply_text"] = FeishuSignalPipelineService._normalize_direct_reply_text(direct_reply_text) + payload["direct_reply_text"] = FeishuPipelineProcessMessageHelpersBMixin._normalize_direct_reply_text(direct_reply_text) clarification_message = str(payload.get("clarification_message") or "").strip() if clarification_message: - payload["clarification_message"] = FeishuSignalPipelineService._normalize_direct_reply_text(clarification_message) + payload["clarification_message"] = FeishuPipelineProcessMessageHelpersBMixin._normalize_direct_reply_text(clarification_message) followup_reply = str(payload.get("followup_reply") or "").strip() if followup_reply: - payload["followup_reply"] = FeishuSignalPipelineService._normalize_direct_reply_text(followup_reply) + payload["followup_reply"] = FeishuPipelineProcessMessageHelpersBMixin._normalize_direct_reply_text(followup_reply) payload["chat_type"] = chat_type payload["normalized_text"] = normalized_text payload["route"] = route payload["db_mutations"] = db_mutations payload.setdefault("results", []) + first_result = payload["results"][0] if payload["results"] and isinstance(payload["results"][0], dict) else {} + route_status = str(first_result.get("route_status") or "").strip().lower() + try: + raw_queue_item_id = first_result.get("item_id") or payload.get("queue_item_id") + queue_item_id = int(raw_queue_item_id) if raw_queue_item_id not in (None, "") else None + except (TypeError, ValueError): + queue_item_id = None + if route_status == ROUTE_STATUS_QUEUED and queue_item_id is not None: + payload.setdefault("reply_template_key", "queue_pending_confirm") + payload["queue_item_id"] = int(queue_item_id) return payload @staticmethod @@ -186,4 +196,3 @@ class FeishuPipelineProcessMessageHelpersBMixin: sources=rag_result.get("sources") or [], ), } - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_confirm_undo.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_confirm_undo.py index fc6f8512..b9eafb9e 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_confirm_undo.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_queue_confirm_undo.py @@ -6,12 +6,21 @@ from . import shared as _pipeline class FeishuPipelineQueueConfirmUndoMixin: + @staticmethod + def _build_operation_idempotency_key(*, item: FeishuItem) -> str: + source_message_id = str(getattr(item, "feishu_message_id", "") or "").strip() + if source_message_id: + return f"feishu:{source_message_id}"[:128] + return f"queue-item:{int(item.id)}" + async def confirm_item(self, *, item_id: int, user_id: int, action: Optional[str] = None) -> dict[str, Any]: item = await self._get_item(item_id) if not item: raise ValueError("item_not_found") level = (item.level or "").lower() + if self._normalize_route_status(item.route_status) == _pipeline.ROUTE_STATUS_AUTO_DONE: + return await self._build_existing_confirmation_result(item) result: dict[str, Any] = {"id": str(item.id), "level": level} if level == "project": suggestion = await self._get_project_suggestion(item.id) @@ -34,6 +43,7 @@ class FeishuPipelineQueueConfirmUndoMixin: "description": candidate_fields["description"], }, trace_id=f"confirm-project-{int(item.id)}-{int(user_id)}", + idempotency_key=self._build_operation_idempotency_key(item=item), ) first = execution["results"][0] if execution.get("results") else {} status = str(first.get("status") or "").strip().lower() @@ -61,31 +71,42 @@ class FeishuPipelineQueueConfirmUndoMixin: payload = item.result_payload if isinstance(item.result_payload, dict) else {} operation_code = str(payload.get("operation_code") or "").strip() operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {} - if operation_code not in {"task.create"}: + if operation_code == "source.connect": + result.update( + await self._confirm_source_connect_item( + item=item, + operator_user=operator_user, + payload=payload, + operation_payload=operation_payload, + ) + ) + elif operation_code == "task.create": + execution = await self._execute_controlled_operation( + operator_user=operator_user, + operation_code=operation_code, + payload=operation_payload, + trace_id=f"confirm-command-{int(item.id)}-{int(user_id)}", + idempotency_key=self._build_operation_idempotency_key(item=item), + ) + first = execution["results"][0] if execution.get("results") else {} + status = str(first.get("status") or "").strip().lower() + if status not in {"applied", "skipped"}: + raise ValueError(str(first.get("message") or "command_execution_failed")) + project_id = _pipeline._safe_int(operation_payload.get("project_id") or payload.get("project_id") or item.matched_project_id) + task_id = _pipeline._safe_int(first.get("entity_id")) + item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id + item.result_payload = { + **payload, + "executor": "explicit_service", + "result_summary": f"已创建任务 #{task_id}" if task_id is not None else "已创建任务", + "operation_result": first.get("result") if isinstance(first.get("result"), dict) else {}, + "target_url": f"/projects/{project_id}/board" if project_id else None, + "project_id": project_id, + } + result["project_id"] = str(project_id) if project_id is not None else None + result["task_id"] = str(task_id) if task_id is not None else None + else: raise ValueError("command_operation_not_supported") - execution = await self._execute_controlled_operation( - operator_user=operator_user, - operation_code=operation_code, - payload=operation_payload, - trace_id=f"confirm-command-{int(item.id)}-{int(user_id)}", - ) - first = execution["results"][0] if execution.get("results") else {} - status = str(first.get("status") or "").strip().lower() - if status not in {"applied", "skipped"}: - raise ValueError(str(first.get("message") or "command_execution_failed")) - project_id = _pipeline._safe_int(operation_payload.get("project_id") or payload.get("project_id") or item.matched_project_id) - task_id = _pipeline._safe_int(first.get("entity_id")) - item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id - item.result_payload = { - **payload, - "executor": "explicit_service", - "result_summary": f"已创建任务 #{task_id}" if task_id is not None else "已创建任务", - "operation_result": first.get("result") if isinstance(first.get("result"), dict) else {}, - "target_url": f"/projects/{project_id}/board" if project_id else None, - "project_id": project_id, - } - result["project_id"] = str(project_id) if project_id is not None else None - result["task_id"] = str(task_id) if task_id is not None else None else: signal = await self._get_signal_by_item(item.id) if not signal: @@ -138,6 +159,121 @@ class FeishuPipelineQueueConfirmUndoMixin: await self.db.commit() return result + async def _build_existing_confirmation_result(self, item: FeishuItem) -> dict[str, Any]: + result = { + "id": str(item.id), + "level": str(item.level or "").lower(), + } + payload = item.result_payload if isinstance(item.result_payload, dict) else {} + if result["level"] == "project": + project_id = _pipeline._safe_int(payload.get("project_id") or item.matched_project_id) + result["project_id"] = str(project_id) if project_id is not None else None + return result + if result["level"] == "command": + project_id = _pipeline._safe_int(payload.get("project_id") or item.matched_project_id) + task_id = _pipeline._safe_int(payload.get("task_id") or (payload.get("operation_result") or {}).get("task_id")) + signal_source = payload.get("signal_source") if isinstance(payload.get("signal_source"), dict) else {} + source_registry_id = _pipeline._safe_int( + payload.get("source_registry_id") + or signal_source.get("registry_id") + ) + signal_id = _pipeline._safe_int(payload.get("signal_id")) + result.update( + { + "project_id": str(project_id) if project_id is not None else None, + "task_id": str(task_id) if task_id is not None else None, + "source_registry_id": str(source_registry_id) if source_registry_id is not None else None, + "signal_id": str(signal_id) if signal_id is not None else None, + } + ) + return result + signal = await self._get_signal_by_item(item.id) + if signal is not None: + result["signal_id"] = str(signal.id) + matched_project_id = signal.matched_project_id or item.matched_project_id + result["project_id"] = str(matched_project_id) if matched_project_id is not None else None + return result + + async def _confirm_source_connect_item( + self, + *, + item: FeishuItem, + operator_user: User, + payload: dict[str, Any], + operation_payload: dict[str, Any], + ) -> dict[str, Any]: + from ...signal.signal_source_orchestration import SignalSourceOrchestrationService + + project_id = _pipeline._safe_int( + operation_payload.get("project_id") + or payload.get("project_id") + or item.matched_project_id + ) + if project_id is None: + raise ValueError("source_connect_missing_project") + + signal = await self._get_signal_by_item(item.id) + if signal is None: + signal = Signal( + tenant_id=self.tenant_id, + source="Feishu", + content=str(item.raw_message or "")[:2000] or None, + impact_score=0.6, + priority="medium", + processed=False, + strategic_analysis=None, + tasks_generated=json.dumps([], ensure_ascii=False), + feishu_item_id=int(item.id), + matched_project_id=int(project_id), + title="信号源接入请求", + signal_type="新需求", + summary=str(item.raw_message or "")[:300] or "信号源接入请求", + reasoning=json.dumps(["Feishu 待确认卡片继续执行触发。"], ensure_ascii=False), + confidence=0.8, + recommended_action="enter_only", + status="待处理", + ) + self.db.add(signal) + await self.db.flush() + await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(project_id)) + + orchestration = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( + tenant_id=self.tenant_id, + project_id=int(project_id), + signal_id=int(signal.id), + initiating_text=str(item.raw_message or ""), + requested_via="feishu", + operator_user_id=int(operator_user.id), + dry_run=False, + ) + orchestration_status = str(orchestration.get("status") or "").strip().lower() + if orchestration_status not in {"completed", "partial", "planned", "pending_manual"}: + raise ValueError(str(orchestration.get("reason") or "source_connect_failed")) + + signal_source = orchestration.get("signal_source") if isinstance(orchestration.get("signal_source"), dict) else {} + registry_id = _pipeline._safe_int(signal_source.get("registry_id")) + result_summary = "信号源接入已开始执行。" + if registry_id is not None: + result_summary = f"信号源注册#{int(registry_id)} 已创建并开始执行。" + item.matched_project_id = int(project_id) + item.result_payload = { + **payload, + "executor": "explicit_service", + "operation_code": "source.connect", + "operation_result": orchestration, + "signal_source": signal_source, + "source_registry_id": registry_id, + "signal_id": int(signal.id), + "result_summary": result_summary, + "target_url": f"/projects/{int(project_id)}/board", + "project_id": int(project_id), + } + return { + "project_id": str(project_id), + "signal_id": str(signal.id), + "source_registry_id": str(registry_id) if registry_id is not None else None, + } + async def undo_auto_processed(self, *, item_id: int) -> dict[str, Any]: item = await self._get_item(item_id) if not item: @@ -191,4 +327,3 @@ class FeishuPipelineQueueConfirmUndoMixin: } await self.db.commit() return {"id": str(item.id), "status": item.route_status} - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py index 23302dc3..e7d152a4 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py @@ -77,6 +77,7 @@ class FeishuPipelineSerializationMixin: source_chat_id=str(item.source_chat_id or ""), source_sender_open_id=str(item.source_sender_open_id or "") or None, sender=str(item.source_sender or ""), + source_message_id=str(item.feishu_message_id or "") or None, fields=project_fields, ) if suggestion: @@ -201,4 +202,3 @@ class FeishuPipelineSerializationMixin: for history in history_rows ] return row - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/shared.py b/backend/app/services/integrations/feishu_signal_pipeline/shared.py index 63ddf1c5..b317671a 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/shared.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/shared.py @@ -3,3 +3,23 @@ from __future__ import annotations from .shared_core import * from .shared_schema_utils import * +from .shared_schema_utils import ( + _LevelClassificationSchema, + _LevelSplitItemSchema, + _ProjectPrefillSchema, + _SignalExtractionSchema, + _build_undo_deadline, + _compact_text, + _first_non_empty, + _normalize_level_value, + _normalize_reasoning, + _safe_bool, + _safe_dict, + _safe_float, + _safe_int, + _safe_json_object, + _utc_now, + _validate_schema, +) + +__all__ = [name for name in globals() if not name.startswith("__")] 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 32ced794..758941b7 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py @@ -25,6 +25,7 @@ from ....models import ( AIProcessingLog, FeishuReplyOutbox, Integration, + OperationExecutionLedger, Project, ProjectSignalLink, ProjectSuggestion, @@ -213,4 +214,3 @@ AUTO_DONE_ITEM_ROUTE_STATUSES = { DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80 DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85 DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60 - diff --git a/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py b/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py index 2657bd61..cbdb8eb8 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/shared_schema_utils.py @@ -1,3 +1,14 @@ +from __future__ import annotations + +import json +from datetime import datetime, timedelta +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field, ValidationError + +from ....utils.timezone import now_cn_aware + + class _LevelSplitItemSchema(BaseModel): level: Literal["project", "signal"] content: str = Field(min_length=1, max_length=4000) @@ -30,8 +41,8 @@ class _SignalExtractionSchema(BaseModel): class _ProjectPrefillSchema(BaseModel): suggested_name: str = Field(min_length=1, max_length=255) suggested_description: str = Field(min_length=1, max_length=4000) - suggested_owner: str = Field(min_length=1, max_length=120) - suggested_timeline: str = Field(min_length=1, max_length=120) + suggested_owner: Optional[str] = Field(default=None, max_length=120) + suggested_timeline: Optional[str] = Field(default=None, max_length=120) confidence: float = Field(ge=0.0, le=1.0) reasoning: list[str] = Field(min_length=1, max_length=3) @@ -167,5 +178,3 @@ def _build_undo_deadline(value: Optional[datetime], *, minutes: int = 10) -> Opt if value.tzinfo is not None: value = value.astimezone(CN_TZ).replace(tzinfo=None) return value + timedelta(minutes=minutes) - - diff --git a/backend/app/services/llm_service/mixin_cache_circuit.py b/backend/app/services/llm_service/mixin_cache_circuit.py index 3eeca44b..3673e8ec 100644 --- a/backend/app/services/llm_service/mixin_cache_circuit.py +++ b/backend/app/services/llm_service/mixin_cache_circuit.py @@ -4,6 +4,7 @@ from .shared import * class LLMCacheCircuitMixin: _provider_circuit_state: dict[str, dict[str, Any]] = {} + _provider_circuit_config_signature: str = "" def __init__(self) -> None: self.request_timeout = float(os.getenv("LLM_REQUEST_TIMEOUT", "45")) @@ -11,6 +12,7 @@ class LLMCacheCircuitMixin: self._redis: Optional[Any] = None self._prompt_profile_cache: dict[int, tuple[float, dict[str, str]]] = {} self._openclaw_routing_cache: tuple[float, dict[str, Any]] | None = None + type(self)._provider_circuit_config_signature = type(self)._current_model_config_signature() async def _get_redis(self): if not REDIS_AVAILABLE or not self.redis_url: @@ -58,6 +60,30 @@ class LLMCacheCircuitMixin: def _provider_circuit_key(*, provider: str, base_url: str, model: str) -> str: return f"{str(provider or '').strip().lower()}|{str(base_url or '').strip().rstrip('/')}|{str(model or '').strip()}" + @classmethod + def _current_model_config_signature(cls) -> str: + bundle = load_model_config() + config_path = str(bundle.get("config_path") or "").strip() + if not config_path: + return "" + try: + mtime = Path(config_path).expanduser().stat().st_mtime + except Exception: + mtime = -1.0 + version = str(bundle.get("version") or "").strip() + updated_at = str(bundle.get("updated_at") or "").strip() + return f"{config_path}|{mtime}|{version}|{updated_at}" + + @classmethod + def _refresh_provider_circuit_state_if_needed(cls) -> None: + current_signature = cls._current_model_config_signature() + if not current_signature: + return + previous_signature = str(cls._provider_circuit_config_signature or "").strip() + if previous_signature and previous_signature != current_signature: + cls._provider_circuit_state.clear() + cls._provider_circuit_config_signature = current_signature + @staticmethod def _http_status_from_exception(exc: Exception) -> Optional[int]: if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None: @@ -126,6 +152,7 @@ class LLMCacheCircuitMixin: @classmethod def _is_provider_circuit_open(cls, circuit_key: str) -> bool: + cls._refresh_provider_circuit_state_if_needed() state = cls._provider_circuit_state.get(str(circuit_key)) if not state: return False @@ -142,10 +169,12 @@ class LLMCacheCircuitMixin: @classmethod def _record_provider_success(cls, circuit_key: str) -> None: + cls._refresh_provider_circuit_state_if_needed() cls._provider_circuit_state.pop(str(circuit_key), None) @classmethod def _record_provider_failure(cls, circuit_key: str, exc: Exception) -> None: + cls._refresh_provider_circuit_state_if_needed() if cls._is_provider_circuit_open_exception(exc): return if not cls._is_circuit_breaker_worthy_exception(exc): diff --git a/backend/app/services/llm_service/mixin_chat_openai.py b/backend/app/services/llm_service/mixin_chat_openai.py index 86b36495..c9ea5af2 100644 --- a/backend/app/services/llm_service/mixin_chat_openai.py +++ b/backend/app/services/llm_service/mixin_chat_openai.py @@ -54,6 +54,8 @@ class LLMChatOpenAIMixin: current_max_tokens = max(int(max_tokens or 0), 1) http_status_retry_budget = self._http_status_retry_budget_for_function(function_label) http_status_retry_attempt = 0 + network_retry_budget = 1 + network_retry_attempt = 0 use_json_response_format = bool(structured_output) direct_answer_retry_used = False while True: @@ -71,8 +73,39 @@ class LLMChatOpenAIMixin: "timeoutSeconds": int(openclaw_agents_timeout_seconds), } } - async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: - resp = await client.post(endpoint, headers=headers, json=payload) + try: + async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: + resp = await client.post(endpoint, headers=headers, json=payload) + except httpx.RequestError as exc: + if network_retry_budget > 0: + retry_delay = min(0.8 * (2 ** network_retry_attempt), 3.0) + _runtime_log( + "LLM", + ( + "网络异常重试:" + f"function={function_label}, provider={provider_name}, model={model}, " + f"retry_in={retry_delay:.2f}s, left={network_retry_budget - 1}, " + f"error={type(exc).__name__}" + ), + ) + logger.warning( + "llm_network_retry trace_id=%s function_name=%s provider=%s model=%s base_url=%s retry_attempt=%s retry_delay_seconds=%.2f error_type=%s error=%s", + request_trace_id, + function_label, + provider_name, + model, + str(base_url or "")[:120], + network_retry_attempt, + retry_delay, + type(exc).__name__, + str(exc)[:260], + ) + network_retry_budget -= 1 + network_retry_attempt += 1 + await asyncio.sleep(retry_delay) + continue + raise + else: if is_openclaw_gateway_request: elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( diff --git a/backend/app/services/llm_service/mixin_openclaw_routing.py b/backend/app/services/llm_service/mixin_openclaw_routing.py index 99232de4..d8059920 100644 --- a/backend/app/services/llm_service/mixin_openclaw_routing.py +++ b/backend/app/services/llm_service/mixin_openclaw_routing.py @@ -18,18 +18,7 @@ class LLMOpenclawRoutingMixin: return runtime def _openclaw_rules_path(self) -> str: - custom = str( - os.getenv( - "OPENCLAW_ROUTING_RULES_PATH", - OPENCLAW_DEFAULT_RULES_RELATIVE_PATH, - ) - or "" - ).strip() - raw_path = custom or OPENCLAW_DEFAULT_RULES_RELATIVE_PATH - candidate = Path(raw_path) - if not candidate.is_absolute(): - candidate = (Path(__file__).resolve().parents[2] / candidate).resolve() - return str(candidate) + return str(resolve_model_config_path()) def _load_openclaw_routing_bundle(self) -> dict[str, Any]: now = time.time() @@ -37,15 +26,7 @@ class LLMOpenclawRoutingMixin: return dict(self._openclaw_routing_cache[1]) path = Path(self._openclaw_rules_path()) - bundle: dict[str, Any] = {} - if path.exists(): - try: - parsed = json.loads(path.read_text(encoding="utf-8")) - if isinstance(parsed, dict): - bundle = parsed - except Exception as exc: - _runtime_log("OpenClaw路由", f"规则文件解析失败:path={path} error={exc}") - bundle = {} + bundle: dict[str, Any] = load_model_config(path=path) self._openclaw_routing_cache = (now + OPENCLAW_ROUTING_CACHE_TTL_SECONDS, dict(bundle)) return dict(bundle) @@ -186,7 +167,9 @@ class LLMOpenclawRoutingMixin: if base_url_override: routed["base_url"] = base_url_override if api_env_override: - routed["api_key"] = str(os.getenv(api_env_override, "") or "").strip() + env_api_key = str(os.getenv(api_env_override, "") or "").strip() + if env_api_key: + routed["api_key"] = env_api_key routed["api_env"] = api_env_override return routed diff --git a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py index 34c74a0b..688570ba 100644 --- a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py +++ b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py @@ -105,21 +105,18 @@ class LLMPromptProfileRuntimeMixin: api_env = str(defaults.get("api_env") or "").strip() api_key = os.getenv(api_env, "") if api_env else "" if provider_name == "glm": - api_key = os.getenv("GLM_API_KEY", os.getenv("ZHIPUAI_API_KEY", api_key)) - - base_url = os.getenv(f"{provider_name.upper()}_BASE_URL", defaults["base_url"]) - model = os.getenv(f"{provider_name.upper()}_MODEL", defaults["model"]) - if provider_name == "ollama": - base_url = os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", defaults["base_url"])) - model = os.getenv("OLLAMA_MODEL", os.getenv("OLLAMA_CHAT_MODEL", defaults["model"])) + api_key = os.getenv( + "DASHSCOPE_API_KEY", + os.getenv("GLM_API_KEY", os.getenv("ZHIPUAI_API_KEY", api_key)), + ) return { "id": f"env-{provider_name}", "name": f"{defaults['name']} (ENV)", "provider": provider_name, "api_key": str(api_key or "").strip(), - "base_url": str(base_url or defaults["base_url"]).strip().rstrip("/"), - "model": str(model or defaults["model"]).strip(), + "base_url": str(defaults["base_url"]).strip().rstrip("/"), + "model": str(defaults["model"]).strip(), } def _env_engine_runtime(self) -> dict[str, Any]: diff --git a/backend/app/services/llm_service/mixin_tier_engine_selection.py b/backend/app/services/llm_service/mixin_tier_engine_selection.py index 5107e8df..387b660b 100644 --- a/backend/app/services/llm_service/mixin_tier_engine_selection.py +++ b/backend/app/services/llm_service/mixin_tier_engine_selection.py @@ -166,33 +166,10 @@ class LLMTierEngineSelectionMixin: active_engine: dict[str, Any], ) -> Optional[dict[str, Any]]: tier = str(tier or "").strip().lower() - upper = tier.upper() - default_provider_by_tier = { - "low": "glm", - "high": "openai", - "safety": "glm", - } - provider_env = str( - os.getenv( - f"LLM_ROUTING_{upper}_PROVIDER", - default_provider_by_tier.get( - tier, - os.getenv("LLM_PROVIDER", "glm"), - ), - ) - or "" - ).strip() - engine_id = str(os.getenv(f"LLM_ROUTING_{upper}_ENGINE_ID", "") or "").strip() - model_override = str(os.getenv(f"LLM_ROUTING_{upper}_MODEL", "") or "").strip() - base_url_override = str(os.getenv(f"LLM_ROUTING_{upper}_BASE_URL", "") or "").strip().rstrip("/") + tier_defaults = dict(TIER_DEFAULTS.get(tier) or {}) + provider_env = str(tier_defaults.get("provider") or "glm").strip() routed: Optional[dict[str, Any]] = None - if engine_id: - for item in engines: - if str(item.get("id") or "") == engine_id: - routed = self._normalize_engine_runtime(item) - break - if routed is None and provider_env: try: provider = _normalize_provider(provider_env) @@ -213,17 +190,10 @@ class LLMTierEngineSelectionMixin: return None provider = str(routed.get("provider") or "").strip().lower() - if model_override: - routed["model"] = model_override - elif self._openclaw_routing_enabled() and provider == "openai": - default_model = DEFAULT_TIER_MODEL_OVERRIDES.get(tier) - if default_model: - routed["model"] = default_model - if base_url_override: - routed["base_url"] = base_url_override - elif self._openclaw_routing_enabled() and provider == "openai": - configured_openai_base = str(os.getenv("OPENAI_BASE_URL", "") or "").strip().rstrip("/") - routed["base_url"] = configured_openai_base or "https://api.openai.com/v1" + if tier_defaults.get("model"): + routed["model"] = str(tier_defaults.get("model") or routed.get("model") or "").strip() + if tier_defaults.get("base_url"): + routed["base_url"] = str(tier_defaults.get("base_url") or routed.get("base_url") or "").strip().rstrip("/") if str(routed.get("id") or "") != str(active_engine.get("id") or ""): _runtime_log( @@ -262,4 +232,3 @@ class LLMTierEngineSelectionMixin: engine_label="目标引擎", ) raise ValueError("engine 不存在") - diff --git a/backend/app/services/llm_service/mixin_tier_escalation.py b/backend/app/services/llm_service/mixin_tier_escalation.py index 2660aca6..1bf0ebf4 100644 --- a/backend/app/services/llm_service/mixin_tier_escalation.py +++ b/backend/app/services/llm_service/mixin_tier_escalation.py @@ -6,6 +6,15 @@ class LLMTierEscalationMixin: def _low_to_high_escalation_enabled(self) -> bool: return _env_bool("LLM_LOW_TO_HIGH_ESCALATION_ENABLED", True) + @staticmethod + def _same_runtime_engine(a: dict[str, Any], b: dict[str, Any]) -> bool: + return ( + str(a.get("provider") or "").strip().lower() == str(b.get("provider") or "").strip().lower() + and str(a.get("model") or "").strip().lower() == str(b.get("model") or "").strip().lower() + and str(a.get("base_url") or "").strip().rstrip("/").lower() + == str(b.get("base_url") or "").strip().rstrip("/").lower() + ) + def _is_low_tier_function(self, function_name: str) -> bool: rule = self._match_openclaw_rule(function_name) if rule is not None: @@ -36,7 +45,16 @@ class LLMTierEscalationMixin: normalized = re.sub(r"[\s_]+", "", str(model_name or "")).lower() if not normalized: return False - return "glm" in normalized and "4.7" in normalized + if normalized.startswith("qwen"): + return True + return "glm" in normalized and ("4.7" in normalized or "47" in normalized) + + @staticmethod + def _is_glm_5_model(model_name: str) -> bool: + normalized = re.sub(r"[\s_]+", "", str(model_name or "")).lower() + if not normalized: + return False + return normalized in {"glm-5", "glm5"} or normalized.startswith("glm-5") or normalized.startswith("glm5") def _should_retry_glm47_with_high_tier( self, @@ -46,9 +64,6 @@ class LLMTierEscalationMixin: ) -> bool: if not self._low_to_high_escalation_enabled(): return False - fn = str(function_name or "").strip().lower() - if fn in {"classify_intent_ai_pmo_kernel", "classify_intent_complex"}: - return False failed_model = str(failed_engine.get("model") or "").strip() if self._is_glm_47_model(failed_model): return True @@ -58,6 +73,144 @@ class LLMTierEscalationMixin: return "glm" in lowered and "4." in lowered return False + def _prefer_glm5_high_tier_retry_engine( + self, + *, + bundle: dict[str, Any], + engines: list[dict[str, Any]], + function_name: str, + failed_engine: dict[str, Any], + current_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + if self._is_glm_5_model(str(current_engine.get("model") or "")): + return current_engine + settings = self._normalize_engine_settings(bundle.get("settings")) + for item in engines: + candidate = dict(item) + if not self._is_glm_5_model(str(candidate.get("model") or "")): + continue + if self._same_runtime_engine(candidate, failed_engine): + continue + try: + candidate = self._ensure_engine_allowed_for_function( + engine=candidate, + function_name=function_name, + settings=settings, + engine_label="forced_glm5_failover", + ) + except Exception as exc: + _runtime_log( + "LLM路由", + ( + "GLM-5强制升级候选被拦截:" + f"function={function_name}, error={type(exc).__name__}:{str(exc)[:180]}" + ), + ) + continue + _runtime_log( + "LLM路由", + ( + "GLM-5强制高阶升级命中:" + f"function={function_name}, provider={candidate.get('provider')}, model={candidate.get('model')}" + ), + ) + return candidate + return None + + def _match_openclaw_high_tier_rule_for_escalation(self, function_name: str) -> Optional[dict[str, Any]]: + bundle = self._load_openclaw_routing_bundle() + rules = bundle.get("rules") if isinstance(bundle.get("rules"), list) else [] + indexed: list[tuple[int, int, dict[str, Any]]] = [] + for idx, row in enumerate(rules): + if not isinstance(row, dict): + continue + if not bool(row.get("enabled", True)): + continue + tier_name = str(row.get("tier") or "").strip().lower() + if tier_name != "high": + continue + if not self._rule_matches_function(row, function_name): + continue + priority = int(row.get("priority") or 0) + indexed.append((priority, -idx, row)) + if not indexed: + _runtime_log("LLM路由", f"OpenClaw高阶规则未命中:function={function_name}") + return None + indexed.sort(reverse=True) + rule = dict(indexed[0][2]) + _runtime_log( + "LLM路由", + ( + "OpenClaw高阶规则候选:" + f"function={function_name}, rule={rule.get('name') or 'unnamed'}, " + f"priority={rule.get('priority')}, provider={rule.get('provider')}, model={rule.get('model')}" + ), + ) + return rule + + async def _resolve_openclaw_high_tier_engine_for_escalation( + self, + *, + bundle: dict[str, Any], + engines: list[dict[str, Any]], + active_engine: dict[str, Any], + function_name: str, + failed_engine: dict[str, Any], + ) -> Optional[dict[str, Any]]: + rule = self._match_openclaw_high_tier_rule_for_escalation(function_name) + if not isinstance(rule, dict): + return None + routed = self._resolve_engine_from_rule( + rule=rule, + engines=engines, + active_engine=active_engine, + ) + if not isinstance(routed, dict): + _runtime_log( + "LLM路由", + ( + "OpenClaw高阶规则命中但未解析到可用引擎:" + f"function={function_name}, rule={rule.get('name') or 'unnamed'}" + ), + ) + return None + routed = self._ensure_openclaw_control_plane_engine(routed) + if self._same_runtime_engine(routed, failed_engine): + _runtime_log( + "LLM路由", + ( + "OpenClaw高阶升级命中但与失败引擎同源,跳过:" + f"function={function_name}, provider={routed.get('provider')}, model={routed.get('model')}" + ), + ) + return None + try: + routed = self._ensure_engine_allowed_for_function( + engine=routed, + function_name=function_name, + settings=self._normalize_engine_settings(bundle.get("settings")), + engine_label="openclaw_high_tier_failover", + ) + except Exception as exc: + _runtime_log( + "LLM路由", + ( + "OpenClaw高阶升级被拦截:" + f"function={function_name}, rule={rule.get('name') or 'unnamed'}, " + f"error={type(exc).__name__}:{str(exc)[:180]}" + ), + ) + return None + _runtime_log( + "LLM路由", + ( + "OpenClaw高阶升级命中:" + f"function={function_name}, rule={rule.get('name') or 'unnamed'}, " + f"provider={routed.get('provider')}, model={routed.get('model')}" + ), + ) + return routed + async def _retry_with_high_tier_after_low_model_failure( self, *, @@ -78,11 +231,34 @@ class LLMTierEscalationMixin: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)] active_engine = self._resolve_active_engine_from_bundle(bundle) - high_engine = self._resolve_tier_engine( - tier="high", + _runtime_log( + "LLM路由", + ( + "低阶模型失败准备升级高阶:" + f"function={function_name}, from={str(failed_engine.get('provider') or '').strip().lower()}:" + f"{str(failed_engine.get('model') or '').strip()}, reason={str(error)[:120]}" + ), + ) + high_engine = await self._resolve_openclaw_high_tier_engine_for_escalation( + bundle=bundle, engines=engines, active_engine=active_engine, + function_name=function_name, + failed_engine=failed_engine, ) + if not isinstance(high_engine, dict): + _runtime_log( + "LLM路由", + ( + "OpenClaw高阶升级未命中,回退通用 high tier:" + f"function={function_name}, reason={str(error)[:120]}" + ), + ) + high_engine = self._resolve_tier_engine( + tier="high", + engines=engines, + active_engine=active_engine, + ) if not isinstance(high_engine, dict): return None, None high_engine = self._ensure_engine_allowed_for_function( @@ -91,11 +267,27 @@ class LLMTierEscalationMixin: settings=self._normalize_engine_settings(bundle.get("settings")), engine_label="high_tier_failover", ) + forced_glm5_engine = self._prefer_glm5_high_tier_retry_engine( + bundle=bundle, + engines=engines, + function_name=function_name, + failed_engine=failed_engine, + current_engine=high_engine, + ) + if isinstance(forced_glm5_engine, dict): + high_engine = forced_glm5_engine failed_provider = str(failed_engine.get("provider") or "").strip().lower() failed_model = str(failed_engine.get("model") or "").strip() high_provider = str(high_engine.get("provider") or "").strip().lower() high_model = str(high_engine.get("model") or "").strip() if failed_provider == high_provider and failed_model == high_model: + _runtime_log( + "LLM路由", + ( + "高阶升级引擎与失败引擎相同,跳过:" + f"function={function_name}, provider={high_provider}, model={high_model}" + ), + ) return None, None _runtime_log( "LLM路由", @@ -121,6 +313,13 @@ class LLMTierEscalationMixin: raise ValueError("high_tier_empty_response") if self._is_invalid_structured_output(function_name=function_name, response_text=text): raise ValueError("high_tier_invalid_structured_output") + _runtime_log( + "LLM路由", + ( + "高阶升级成功:" + f"function={function_name}, provider={high_provider}, model={high_model}, text_len={len(text)}" + ), + ) return text, high_engine except Exception as retry_exc: _runtime_log( @@ -157,11 +356,36 @@ class LLMTierEscalationMixin: _, bundle = await self._get_or_init_bundle(db, tenant_id=tenant_id) engines = [item for item in bundle.get("engines", []) if isinstance(item, dict)] active_engine = self._resolve_active_engine_from_bundle(bundle) - high_engine = self._resolve_tier_engine( - tier="high", + _runtime_log( + "LLM路由", + ( + "低阶结构化失败准备升级高阶:" + f"function={function_name}, from={str(failed_engine.get('provider') or '').strip().lower()}:" + f"{str(failed_engine.get('model') or '').strip()}, reason={str(error)[:120]}, " + f"force_intent_retry={force_intent_retry}" + ), + ) + high_engine = await self._resolve_openclaw_high_tier_engine_for_escalation( + bundle=bundle, engines=engines, active_engine=active_engine, + function_name=function_name, + failed_engine=failed_engine, ) + if not isinstance(high_engine, dict): + _runtime_log( + "LLM路由", + ( + "OpenClaw高阶结构化升级未命中,回退通用 high tier:" + f"function={function_name}, reason={str(error)[:120]}, " + f"force_intent_retry={force_intent_retry}" + ), + ) + high_engine = self._resolve_tier_engine( + tier="high", + engines=engines, + active_engine=active_engine, + ) if not isinstance(high_engine, dict): return None, None high_engine = self._ensure_engine_allowed_for_function( @@ -170,12 +394,28 @@ class LLMTierEscalationMixin: settings=self._normalize_engine_settings(bundle.get("settings")), engine_label="high_tier_structured_failover", ) + forced_glm5_engine = self._prefer_glm5_high_tier_retry_engine( + bundle=bundle, + engines=engines, + function_name=function_name, + failed_engine=failed_engine, + current_engine=high_engine, + ) + if isinstance(forced_glm5_engine, dict): + high_engine = forced_glm5_engine failed_provider = str(failed_engine.get("provider") or "").strip().lower() failed_model = str(failed_engine.get("model") or "").strip() high_provider = str(high_engine.get("provider") or "").strip().lower() high_model = str(high_engine.get("model") or "").strip() if failed_provider == high_provider and failed_model == high_model: if not force_intent_retry: + _runtime_log( + "LLM路由", + ( + "高阶结构化升级引擎与失败引擎相同,跳过:" + f"function={function_name}, provider={high_provider}, model={high_model}" + ), + ) return None, None fallback_engine: Optional[dict[str, Any]] = None for fallback_tier in ("safety", "low"): @@ -244,6 +484,14 @@ class LLMTierEscalationMixin: f"validation={extraction.validation}, fallback=true" ), ) + _runtime_log( + "LLM路由", + ( + "高阶结构化升级成功:" + f"function={function_name}, provider={high_provider}, model={high_model}, " + f"structured_source={extraction.source_field or 'none'}" + ), + ) return extraction, high_engine except Exception as retry_exc: _runtime_log( diff --git a/backend/app/services/llm_service/shared_part_01.py b/backend/app/services/llm_service/shared_part_01.py index 837369ab..67090313 100644 --- a/backend/app/services/llm_service/shared_part_01.py +++ b/backend/app/services/llm_service/shared_part_01.py @@ -27,6 +27,14 @@ from sqlalchemy import and_, select from sqlalchemy.exc import OperationalError from sqlalchemy.ext.asyncio import AsyncSession +from ..model.model_config import ( + build_provider_runtime, + build_tier_runtime, + get_provider_defaults, + get_tier_defaults, + load_model_config, + resolve_model_config_path, +) from ...config.ai_prompts import build_default_role_prompt_bundle from ...core.database import AsyncSessionLocal from ...models import AICallLog, Integration @@ -44,35 +52,13 @@ except Exception: # pragma: no cover logger = logging.getLogger(__name__) SUPPORTED_PROVIDERS = {"deepseek", "glm", "openai", "ollama"} -OPENCLAW_DEFAULT_LOW_MODEL = "GLM-4.7" -OPENCLAW_DEFAULT_HIGH_MODEL = "MiniMax-M2.5" -OPENCLAW_DEFAULT_SAFETY_MODEL = "deepseek-chat" -PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { - "deepseek": { - "name": "DeepSeek", - "base_url": "https://api.deepseek.com/v1", - "model": "deepseek-chat", - "api_env": "DEEPSEEK_API_KEY", - }, - "glm": { - "name": "GLM", - "base_url": "https://open.bigmodel.cn/api/paas/v4", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", - }, - "openai": { - "name": "OpenAI", - "base_url": "https://api.openai.com/v1", - "model": "gpt-4o-mini", - "api_env": "OPENAI_API_KEY", - }, - "ollama": { - "name": "Ollama", - "base_url": "http://127.0.0.1:11434", - "model": "llama3:latest", - "api_env": "", - }, -} +MODEL_CONFIG_BUNDLE = load_model_config() +PROVIDER_DEFAULTS: dict[str, dict[str, str]] = get_provider_defaults() +TIER_DEFAULTS: dict[str, dict[str, str]] = get_tier_defaults() +OPENCLAW_DEFAULT_LOW_MODEL = str(TIER_DEFAULTS.get("low", {}).get("model") or "qwen-plus") +OPENCLAW_DEFAULT_HIGH_MODEL = str(TIER_DEFAULTS.get("high", {}).get("model") or "GLM-5") +OPENCLAW_DEFAULT_SAFETY_MODEL = str(TIER_DEFAULTS.get("safety", {}).get("model") or OPENCLAW_DEFAULT_LOW_MODEL) +OPENCLAW_DASHSCOPE_BASE_URL = str(PROVIDER_DEFAULTS.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1") PROVIDER_ALIASES = { "zhipu": "glm", "chatglm": "glm", @@ -86,9 +72,8 @@ OPENCLAW_DEFAULT_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json" OPENCLAW_AGENTS_DEFAULT_TIMEOUT_SECONDS = 120 OPENCLAW_AGENTS_TIMEOUT_SECONDS_MAX = 120 DEFAULT_TIER_MODEL_OVERRIDES = { - "low": OPENCLAW_DEFAULT_LOW_MODEL, - "high": OPENCLAW_DEFAULT_HIGH_MODEL, - "safety": OPENCLAW_DEFAULT_SAFETY_MODEL, + tier: str(spec.get("model") or "") + for tier, spec in TIER_DEFAULTS.items() } DEFAULT_HIGH_TIER_HINTS = ( "classify_intent_complex", diff --git a/backend/app/services/model/model_config.py b/backend/app/services/model/model_config.py new file mode 100644 index 00000000..bbb51ed6 --- /dev/null +++ b/backend/app/services/model/model_config.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import json +import os +from copy import deepcopy +from pathlib import Path +from typing import Any + +DEFAULT_ROUTING_RULES_RELATIVE_PATH = "../.openclaw/pmflow-routing-rules.json" +_APP_ROOT = Path(__file__).resolve().parents[2] +_DEFAULT_ROUTING_RULES_PATH = (_APP_ROOT / DEFAULT_ROUTING_RULES_RELATIVE_PATH).resolve() + +_PROVIDER_ALIASES = { + "zhipu": "glm", + "chatglm": "glm", + "deep-seek": "deepseek", + "local": "ollama", +} + +_DEFAULT_PROVIDER_DEFAULTS: dict[str, dict[str, str]] = { + "deepseek": { + "name": "DeepSeek", + "base_url": "https://api.deepseek.com/v1", + "model": "deepseek-chat", + "api_env": "DEEPSEEK_API_KEY", + }, + "glm": { + "name": "Qwen (DashScope)", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "model": "qwen-plus", + "api_env": "DASHSCOPE_API_KEY", + }, + "openai": { + "name": "OpenAI", + "base_url": "https://api.edgefn.net/v1", + "model": "MiniMax-M2.5", + "api_env": "OPENAI_API_KEY", + }, + "ollama": { + "name": "Ollama", + "base_url": "http://127.0.0.1:11434", + "model": "llama3:latest", + "api_env": "", + }, +} + +_DEFAULT_TIER_DEFAULTS: dict[str, dict[str, str]] = { + "low": { + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_env": "DASHSCOPE_API_KEY", + }, + "high": { + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_env": "OPENAI_API_KEY", + }, + "safety": { + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_env": "DASHSCOPE_API_KEY", + }, +} + +_CONFIG_CACHE: dict[str, Any] = {"path": "", "mtime": -1.0, "bundle": None} + + +def _normalize_provider(provider: str) -> str: + value = str(provider or "").strip().lower() + value = _PROVIDER_ALIASES.get(value, value) + if value not in _DEFAULT_PROVIDER_DEFAULTS: + raise ValueError("provider 仅支持 deepseek/glm/openai/ollama") + return value + + +def _normalize_tier(tier: str) -> str: + value = str(tier or "").strip().lower() + if value not in _DEFAULT_TIER_DEFAULTS: + raise ValueError("tier 仅支持 low/high/safety") + return value + + +def resolve_model_config_path(path: str | Path | None = None) -> Path: + raw = path if path is not None else os.getenv("OPENCLAW_ROUTING_RULES_PATH", "") + raw_text = str(raw or "").strip() + if not raw_text: + return _DEFAULT_ROUTING_RULES_PATH + candidate = Path(raw_text).expanduser() + if candidate.is_absolute(): + return candidate + return (_APP_ROOT / candidate).resolve() + + +def _normalize_provider_defaults(raw: Any) -> dict[str, dict[str, str]]: + payload = raw if isinstance(raw, dict) else {} + result: dict[str, dict[str, str]] = {} + for provider, defaults in _DEFAULT_PROVIDER_DEFAULTS.items(): + item = payload.get(provider) if isinstance(payload.get(provider), dict) else {} + result[provider] = { + "name": str(item.get("name") or defaults["name"]).strip(), + "base_url": str(item.get("base_url") or defaults["base_url"]).strip().rstrip("/"), + "model": str(item.get("model") or defaults["model"]).strip(), + "api_env": str(item.get("api_env") or defaults["api_env"]).strip(), + } + return result + + +def _normalize_tier_defaults(raw: Any) -> dict[str, dict[str, str]]: + payload = raw if isinstance(raw, dict) else {} + result: dict[str, dict[str, str]] = {} + for tier, defaults in _DEFAULT_TIER_DEFAULTS.items(): + item = payload.get(tier) if isinstance(payload.get(tier), dict) else {} + provider_raw = str(item.get("provider") or defaults["provider"]).strip() + provider = _normalize_provider(provider_raw) + result[tier] = { + "provider": provider, + "model": str(item.get("model") or defaults["model"]).strip(), + "base_url": str(item.get("base_url") or defaults["base_url"]).strip().rstrip("/"), + "api_env": str(item.get("api_env") or defaults["api_env"]).strip(), + } + return result + + +def _normalize_bundle(raw: Any, *, config_path: Path) -> dict[str, Any]: + payload = raw if isinstance(raw, dict) else {} + bundle = { + "version": str(payload.get("version") or "v3").strip(), + "updated_at": str(payload.get("updated_at") or "").strip(), + "source_of_truth": str(payload.get("source_of_truth") or "openclaw").strip(), + "description": str(payload.get("description") or "").strip(), + "config_path": str(config_path), + "provider_defaults": _normalize_provider_defaults(payload.get("provider_defaults")), + "tier_defaults": _normalize_tier_defaults(payload.get("tier_defaults")), + "rules": [deepcopy(item) for item in payload.get("rules", []) if isinstance(item, dict)], + } + return bundle + + +def _load_raw_payload(config_path: Path) -> dict[str, Any]: + if not config_path.exists(): + return {} + try: + parsed = json.loads(config_path.read_text(encoding="utf-8")) + return parsed if isinstance(parsed, dict) else {} + except Exception: + return {} + + +def load_model_config(*, refresh: bool = False, path: str | Path | None = None) -> dict[str, Any]: + config_path = resolve_model_config_path(path) + mtime = config_path.stat().st_mtime if config_path.exists() else -1.0 + cached = _CONFIG_CACHE + if ( + not refresh + and cached.get("bundle") is not None + and cached.get("path") == str(config_path) + and cached.get("mtime") == mtime + ): + return deepcopy(cached["bundle"]) + + raw = _load_raw_payload(config_path) + bundle = _normalize_bundle(raw, config_path=config_path) + _CONFIG_CACHE.update({"path": str(config_path), "mtime": mtime, "bundle": deepcopy(bundle)}) + return deepcopy(bundle) + + +def get_provider_defaults(provider: str | None = None, *, refresh: bool = False) -> dict[str, Any]: + bundle = load_model_config(refresh=refresh) + defaults = bundle.get("provider_defaults") if isinstance(bundle.get("provider_defaults"), dict) else {} + if provider is None: + return deepcopy(defaults) + provider_name = _normalize_provider(provider) + fallback = _DEFAULT_PROVIDER_DEFAULTS[provider_name] + return deepcopy(defaults.get(provider_name, fallback)) + + +def get_tier_defaults(tier: str | None = None, *, refresh: bool = False) -> dict[str, Any]: + bundle = load_model_config(refresh=refresh) + defaults = bundle.get("tier_defaults") if isinstance(bundle.get("tier_defaults"), dict) else {} + if tier is None: + return deepcopy(defaults) + tier_name = _normalize_tier(tier) + fallback = _DEFAULT_TIER_DEFAULTS[tier_name] + return deepcopy(defaults.get(tier_name, fallback)) + + +def build_provider_runtime( + provider: str, + *, + api_key: str = "", + model: str | None = None, + base_url: str | None = None, +) -> dict[str, Any]: + provider_name = _normalize_provider(provider) + defaults = get_provider_defaults(provider_name) + return { + "id": f"env-{provider_name}", + "name": str(defaults.get("name") or _DEFAULT_PROVIDER_DEFAULTS[provider_name]["name"]).strip(), + "provider": provider_name, + "api_key": str(api_key or "").strip(), + "base_url": str(base_url or defaults.get("base_url") or "").strip().rstrip("/"), + "model": str(model or defaults.get("model") or "").strip(), + "api_env": str(defaults.get("api_env") or _DEFAULT_PROVIDER_DEFAULTS[provider_name]["api_env"]).strip(), + } + + +def build_tier_runtime( + tier: str, + *, + api_key: str = "", +) -> dict[str, Any]: + tier_name = _normalize_tier(tier) + tier_defaults = get_tier_defaults(tier_name) + provider_name = _normalize_provider(str(tier_defaults.get("provider") or "glm")) + provider_defaults = get_provider_defaults(provider_name) + return { + "id": f"tier-{tier_name}", + "name": str(provider_defaults.get("name") or "").strip(), + "provider": provider_name, + "api_key": str(api_key or "").strip(), + "base_url": str(tier_defaults.get("base_url") or provider_defaults.get("base_url") or "").strip().rstrip("/"), + "model": str(tier_defaults.get("model") or provider_defaults.get("model") or "").strip(), + "api_env": str(tier_defaults.get("api_env") or provider_defaults.get("api_env") or "").strip(), + "tier": tier_name, + } + + +__all__ = [ + "build_provider_runtime", + "build_tier_runtime", + "get_provider_defaults", + "get_tier_defaults", + "load_model_config", + "resolve_model_config_path", +] diff --git a/backend/app/services/model/model_routing/service.py b/backend/app/services/model/model_routing/service.py index f92d55ee..c2fb2fde 100644 --- a/backend/app/services/model/model_routing/service.py +++ b/backend/app/services/model/model_routing/service.py @@ -9,6 +9,7 @@ from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ....models import Integration +from ..model_config import get_tier_defaults from ....utils.timezone import now_cn_naive ALLOWED_PROVIDERS = {"openai", "glm", "deepseek", "ollama"} @@ -123,13 +124,14 @@ class ModelRoutingService: @classmethod def _effective_from_rule(cls, *, function_name: str, rule: dict[str, Any] | None) -> dict[str, Any]: if rule is None: + low_defaults = get_tier_defaults("low") return { "function_name": cls._normalize_function_name(function_name), - "provider": "glm", - "model": "GLM-4.7", - "api_env": "GLM_API_KEY", + "provider": str(low_defaults.get("provider") or "glm").strip().lower(), + "model": str(low_defaults.get("model") or "qwen-plus").strip(), + "api_env": str(low_defaults.get("api_env") or "DASHSCOPE_API_KEY").strip(), "tier": "high", - "base_url": "", + "base_url": str(low_defaults.get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(), "source": "default", "updated_at": "", "updated_by": None, diff --git a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py index 8484c98b..1ffca44b 100644 --- a/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py +++ b/backend/app/services/project_knowledge_graph_service/mixin_guardrails.py @@ -31,59 +31,24 @@ class PKGGuardrailsMixin: @classmethod def _resolve_guardrail_target_models(cls) -> dict[str, dict[str, str]]: - specs: dict[str, dict[str, str]] = { - "low": { - "tier": "low", - "provider": str(os.getenv("LLM_ROUTING_LOW_PROVIDER", "glm") or "glm").strip(), - "model": str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip(), - "api_env": str(os.getenv("LLM_ROUTING_LOW_API_ENV", "GLM_API_KEY") or "GLM_API_KEY").strip(), - "source": "env_default", - "source_path": "", + bundle = load_model_config() + provider_defaults = bundle.get("provider_defaults") if isinstance(bundle.get("provider_defaults"), dict) else {} + tier_defaults = bundle.get("tier_defaults") if isinstance(bundle.get("tier_defaults"), dict) else {} + config_path = str(bundle.get("config_path") or "") + specs: dict[str, dict[str, str]] = {} + for tier in ("low", "high"): + tier_spec = tier_defaults.get(tier) if isinstance(tier_defaults.get(tier), dict) else {} + provider = str(tier_spec.get("provider") or "glm").strip() + provider_spec = provider_defaults.get(provider) if isinstance(provider_defaults.get(provider), dict) else {} + specs[tier] = { + "tier": tier, + "provider": provider, + "model": str(tier_spec.get("model") or provider_spec.get("model") or "").strip(), + "api_env": str(tier_spec.get("api_env") or provider_spec.get("api_env") or "").strip(), + "source": "routing_file", + "source_path": config_path, "rule_name": "", - }, - "high": { - "tier": "high", - "provider": str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", "openai") or "openai").strip(), - "model": str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip(), - "api_env": str(os.getenv("LLM_ROUTING_HIGH_API_ENV", "OPENAI_API_KEY") or "OPENAI_API_KEY").strip(), - "source": "env_default", - "source_path": "", - "rule_name": "", - }, - } - - for path in cls._routing_rule_candidate_paths(): - try: - content = path.read_text(encoding="utf-8") - payload = json.loads(content) - except Exception: - continue - rows = payload.get("rules") if isinstance(payload, dict) and isinstance(payload.get("rules"), list) else [] - if not rows: - continue - enabled_rows = [row for row in rows if isinstance(row, dict) and bool(row.get("enabled", True))] - enabled_rows.sort(key=lambda item: int(item.get("priority") or 0), reverse=True) - resolved_any = False - for tier in ("low", "high"): - for row in enabled_rows: - if str(row.get("tier") or "").strip().lower() != tier: - continue - model = str(row.get("model") or "").strip() - if not model: - continue - specs[tier] = { - "tier": tier, - "provider": str(row.get("provider") or specs[tier]["provider"]).strip(), - "model": model, - "api_env": str(row.get("api_env") or specs[tier]["api_env"]).strip(), - "source": "routing_rule", - "source_path": str(path), - "rule_name": str(row.get("name") or "").strip(), - } - resolved_any = True - break - if resolved_any and all(str(specs[tier].get("source") or "") == "routing_rule" for tier in ("low", "high")): - break + } return specs async def _sync_model_guardrail_entities(self) -> dict[str, Any]: @@ -165,7 +130,7 @@ class PKGGuardrailsMixin: for tier in ("low", "high"): spec = model_specs.get(tier) if isinstance(model_specs.get(tier), dict) else {} - model_name = str(spec.get("model") or "").strip() or ("GLM-4.7" if tier == "low" else "MiniMax-M2.5") + model_name = str(spec.get("model") or "").strip() or ("qwen-plus" if tier == "low" else "MiniMax-M2.5") provider = str(spec.get("provider") or "").strip() api_env = str(spec.get("api_env") or "").strip() source = str(spec.get("source") or "env_default").strip() @@ -280,4 +245,3 @@ class PKGGuardrailsMixin: ) await self.db.commit() return payload - diff --git a/backend/app/services/project_knowledge_graph_service/shared.py b/backend/app/services/project_knowledge_graph_service/shared.py index 2b72b460..79aa1734 100644 --- a/backend/app/services/project_knowledge_graph_service/shared.py +++ b/backend/app/services/project_knowledge_graph_service/shared.py @@ -36,6 +36,7 @@ from ...models import ( ) from ..context_cache_service import context_cache_service from ..signal.symbol_index import SymbolIndexService +from ..model.model_config import load_model_config from ...utils.timezone import now_cn_naive diff --git a/backend/app/services/signal/draft_generator_service.py b/backend/app/services/signal/draft_generator_service.py index 15f574ce..9a0aee29 100644 --- a/backend/app/services/signal/draft_generator_service.py +++ b/backend/app/services/signal/draft_generator_service.py @@ -4,6 +4,8 @@ from __future__ import annotations import asyncio import json +import re +from difflib import SequenceMatcher from datetime import timedelta from typing import Any, Optional @@ -43,12 +45,21 @@ class DraftGeneratorService: project = world_model.get("project") if isinstance(world_model.get("project"), dict) else {} project_title = str(project.get("title") or project.get("name") or "未指定项目").strip() or "未指定项目" project_description = str(project.get("description") or "").strip() or "暂无项目描述" + active_projects = self._normalize_active_projects(world_model.get("active_projects")) + similar_project_candidates = self._build_similar_project_candidates( + project_title=project_title, + project_description=project_description, + message_text=str(message_text or ""), + active_projects=active_projects, + ) members = await self._load_members(world_model=world_model) recent_signals = self._normalize_recent_signals(world_model.get("recent_signals")) prompt = PROMPT_DRAFT_GENERATOR.format( project_title=project_title, project_description=project_description, + active_projects_json=json.dumps(active_projects, ensure_ascii=False), + similar_project_candidates_json=json.dumps(similar_project_candidates, ensure_ascii=False), members_json=json.dumps(members, ensure_ascii=False), recent_signals=json.dumps(recent_signals, ensure_ascii=False), user_message=str(message_text or "")[:2000], @@ -60,6 +71,8 @@ class DraftGeneratorService: draft = self._normalize_draft_payload( payload=payload, members=members, + active_projects=active_projects, + similar_project_candidates=similar_project_candidates, original_text=message_text, version=next_version, ) @@ -205,6 +218,8 @@ class DraftGeneratorService: *, payload: dict[str, Any], members: list[dict[str, Any]], + active_projects: list[dict[str, Any]], + similar_project_candidates: list[dict[str, Any]], original_text: str, version: int, ) -> dict[str, Any]: @@ -231,6 +246,42 @@ class DraftGeneratorService: if not assignee_reason: assignee_reason = "基于当前负载与任务匹配度给出的建议。" + similar_projects = self._normalize_similar_projects( + payload.get("similar_projects"), + fallback_candidates=similar_project_candidates, + active_projects=active_projects, + original_text=original_text, + title=title, + ) + mvp_items = self._normalize_string_list( + payload.get("mvp_items"), + fallback=[ + "明确项目目标与边界", + "完成同名/相似项目查重", + "交付最小可验证闭环", + ], + max_items=5, + max_length=80, + ) + prospect_assessment = str(payload.get("prospect_assessment") or "").strip()[:240] + if not prospect_assessment: + prospect_assessment = "该项目具备落地价值,但建议先确认范围边界和现有项目复用情况,再决定是否推进。" + requirement_items = self._normalize_string_list( + payload.get("requirement_items"), + fallback=[ + "项目定位与目标", + "MVP 功能范围", + "需求边界与验收标准", + ], + max_items=8, + max_length=100, + ) + missing_info = self._normalize_string_list( + payload.get("missing_info"), + fallback=[], + max_items=5, + max_length=80, + ) followup_plan = self._normalize_followup_plan(payload.get("followup_plan")) if not followup_plan: followup_plan = [ @@ -245,6 +296,11 @@ class DraftGeneratorService: return { "title": title, "summary": summary, + "similar_projects": similar_projects, + "mvp_items": mvp_items, + "prospect_assessment": prospect_assessment, + "requirement_items": requirement_items, + "missing_info": missing_info, "feasibility": feasibility, "feasibility_reason": feasibility_reason, "recommended_assignee_id": assignee_id, @@ -260,6 +316,11 @@ class DraftGeneratorService: def render_draft_message(draft: dict[str, Any]) -> str: title = str(draft.get("title") or "未命名需求").strip() or "未命名需求" summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要" + similar_projects = draft.get("similar_projects") if isinstance(draft.get("similar_projects"), list) else [] + mvp_items = draft.get("mvp_items") if isinstance(draft.get("mvp_items"), list) else [] + prospect_assessment = str(draft.get("prospect_assessment") or "").strip() + requirement_items = draft.get("requirement_items") if isinstance(draft.get("requirement_items"), list) else [] + missing_info = draft.get("missing_info") if isinstance(draft.get("missing_info"), list) else [] feasibility = str(draft.get("feasibility") or "待讨论").strip() or "待讨论" feasibility_reason = str(draft.get("feasibility_reason") or "").strip() assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" @@ -280,13 +341,55 @@ class DraftGeneratorService: workload_text = f"{assignee_workload:.1f}" if assignee_workload % 1 else f"{int(assignee_workload)}" feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。" assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。" + similar_lines = DraftGeneratorService._render_bullet_items( + similar_projects, + default_line="· 未发现同名或高相似项目,可作为新立项继续评审。", + formatter=DraftGeneratorService._render_similar_project_line, + max_items=3, + ) + mvp_lines = DraftGeneratorService._render_bullet_items( + mvp_items, + default_line="· 待定:先明确目标后补齐 MVP 范围", + formatter=DraftGeneratorService._render_text_item_line, + max_items=5, + ) + requirement_lines = DraftGeneratorService._render_bullet_items( + requirement_items, + default_line="· 待定:补充需求清单", + formatter=DraftGeneratorService._render_text_item_line, + max_items=8, + ) + missing_info_lines = DraftGeneratorService._render_bullet_items( + missing_info, + default_line="· 暂无", + formatter=DraftGeneratorService._render_text_item_line, + max_items=5, + ) + prospect_lines = DraftGeneratorService._split_paragraph_lines( + prospect_assessment or "该项目前景尚可,但建议先确认范围边界。" + ) return "\n".join( [ - "📋 已为你起草需求,请确认:", + "📋 我先按产品经理视角起草一版评审建议,请确认:", "", f"📌 {title}", summary, "", + "🔎 同名 / 相似项目:", + *similar_lines, + "", + "🚀 MVP 建议:", + *mvp_lines, + "", + "📈 前景判断:", + *prospect_lines, + "", + "🧩 需求清单:", + *requirement_lines, + "", + "❓ 还缺的信息:", + *missing_info_lines, + "", f"✅ 可行性:{feasibility}", feasibility_reason_line, "", @@ -335,6 +438,252 @@ class DraftGeneratorService: normalized.append({"when": when, "action": action}) return normalized + @staticmethod + def _normalize_active_projects(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, Any]] = [] + for row in value[:20]: + if not isinstance(row, dict): + continue + project_id = DraftGeneratorService._safe_int(row.get("id")) + title = str(row.get("title") or row.get("name") or "").strip()[:120] + if project_id is None or not title: + continue + normalized.append( + { + "project_id": project_id, + "title": title, + "name": str(row.get("name") or title).strip()[:120], + "description": str(row.get("description") or "").strip()[:300], + "status": str(row.get("status") or "").strip()[:20], + } + ) + return normalized + + def _build_similar_project_candidates( + self, + *, + project_title: str, + project_description: str, + message_text: str, + active_projects: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + query = " ".join( + part + for part in [str(project_title or "").strip(), str(project_description or "").strip(), str(message_text or "").strip()] + if part + ) + if not query: + return [] + candidates: list[dict[str, Any]] = [] + for row in active_projects: + project_id = self._safe_int(row.get("project_id")) + title = str(row.get("title") or row.get("name") or "").strip() + if project_id is None or not title: + continue + description = str(row.get("description") or "").strip() + candidate_text = f"{title} {description}".strip() + score = self._score_similarity(query, candidate_text) + if score < 0.22: + continue + reuse_suggestion = "建议先评估是否复用现有项目,避免重复建设。" + if score >= 0.55: + reuse_suggestion = "名称或目标高度接近,优先确认是否复用或合并。" + difference = "与当前需求主题接近,建议重点确认范围边界。" + if description and len(description) > 20: + difference = "现有项目已有一定描述,建议对比当前需求的目标、范围和交付边界。" + risk = "若范围重叠,可能造成重复建设或维护分散。" + candidates.append( + { + "project_id": project_id, + "title": title, + "difference": difference, + "risk": risk, + "reuse_suggestion": reuse_suggestion, + "match_score": round(min(score, 1.0), 3), + } + ) + candidates.sort(key=lambda item: (-float(item.get("match_score") or 0.0), int(item.get("project_id") or 0))) + return candidates[:3] + + @staticmethod + def _score_similarity(left: str, right: str) -> float: + left_norm = re.sub(r"\s+", "", str(left or "").lower()) + right_norm = re.sub(r"\s+", "", str(right or "").lower()) + if not left_norm or not right_norm: + return 0.0 + base = SequenceMatcher(None, left_norm, right_norm).ratio() + if left_norm in right_norm or right_norm in left_norm: + base = max(base, 0.75) + shared = set(re.findall(r"[\u4e00-\u9fff]{2,}", left_norm)) & set(re.findall(r"[\u4e00-\u9fff]{2,}", right_norm)) + if shared: + base = min(1.0, base + min(len(shared) * 0.08, 0.2)) + return base + + @staticmethod + def _normalize_similar_projects( + value: Any, + *, + fallback_candidates: list[dict[str, Any]], + active_projects: list[dict[str, Any]], + original_text: str, + title: str, + ) -> list[dict[str, Any]]: + normalized: list[dict[str, Any]] = [] + if isinstance(value, list): + for row in value[:5]: + if isinstance(row, dict): + project_id = DraftGeneratorService._safe_int(row.get("project_id") or row.get("id")) + project_title = str(row.get("title") or row.get("name") or "").strip()[:120] + if project_id is None or not project_title: + continue + normalized.append( + { + "project_id": project_id, + "title": project_title, + "difference": str(row.get("difference") or "").strip()[:160] or "需确认范围差异。", + "risk": str(row.get("risk") or "").strip()[:160] or "可能存在重复建设风险。", + "reuse_suggestion": str(row.get("reuse_suggestion") or row.get("suggestion") or "").strip()[:160] + or "建议先评估是否复用或合并。", + } + ) + elif isinstance(row, str) and row.strip(): + normalized.append( + { + "project_id": None, + "title": row.strip()[:120], + "difference": "模型未提供详细差异,建议人工确认。", + "risk": "存在重复建设的可能。", + "reuse_suggestion": "建议先核对项目是否同名或高度相似。", + } + ) + if normalized: + return normalized[:3] + + if fallback_candidates: + return [ + { + "project_id": int(row.get("project_id")), + "title": str(row.get("title") or "").strip()[:120], + "difference": str(row.get("difference") or "").strip()[:160] or "需确认范围差异。", + "risk": str(row.get("risk") or "").strip()[:160] or "可能存在重复建设风险。", + "reuse_suggestion": str(row.get("reuse_suggestion") or "").strip()[:160] or "建议先评估是否复用或合并。", + } + for row in fallback_candidates[:3] + if DraftGeneratorService._safe_int(row.get("project_id")) is not None + ] + + query = " ".join(part for part in [title, original_text] if str(part or "").strip()) + for row in active_projects[:3]: + project_title = str(row.get("title") or row.get("name") or "").strip() + if not project_title: + continue + if query and (project_title in query or query in project_title): + normalized.append( + { + "project_id": int(row.get("project_id") or 0), + "title": project_title, + "difference": "名称高度接近,建议确认是否为同一项目范围。", + "risk": "若直接新建,容易造成重复建设。", + "reuse_suggestion": "建议复用现有项目或先合并范围。", + } + ) + return normalized[:3] + + @staticmethod + def _normalize_string_list( + value: Any, + *, + fallback: list[str], + max_items: int, + max_length: int, + ) -> list[str]: + items: list[str] = [] + if isinstance(value, list): + for row in value: + if isinstance(row, str): + text = " ".join(row.split()).strip()[:max_length] + elif isinstance(row, dict): + text = " ".join( + str(row.get(key) or "").strip() + for key in ("text", "title", "content", "summary", "name") + if str(row.get(key) or "").strip() + ).strip()[:max_length] + if not text: + text = " ".join(str(row.get("value") or "").split()).strip()[:max_length] + else: + text = "" + if text: + items.append(text) + if len(items) >= max_items: + break + if items: + return items[:max_items] + return [str(item).strip()[:max_length] for item in fallback if str(item).strip()][:max_items] + + @staticmethod + def _render_bullet_items( + items: list[Any], + *, + default_line: str, + formatter: Any, + max_items: int, + ) -> list[str]: + lines: list[str] = [] + if isinstance(items, list): + for item in items[:max_items]: + line = formatter(item) + if line: + lines.append(line) + if not lines: + lines = [default_line] + return lines + + @staticmethod + def _render_text_item_line(item: Any) -> str: + if isinstance(item, str): + text = " ".join(item.split()).strip() + elif isinstance(item, dict): + text = " ".join( + str(item.get(key) or "").strip() + for key in ("text", "title", "content", "summary", "name") + if str(item.get(key) or "").strip() + ).strip() + if not text: + text = " ".join(str(item.get("value") or "").split()).strip() + else: + text = "" + if not text: + return "" + return f"· {text[:120]}" + + @staticmethod + def _render_similar_project_line(item: Any) -> str: + if not isinstance(item, dict): + return DraftGeneratorService._render_text_item_line(item) + project_id = DraftGeneratorService._safe_int(item.get("project_id")) + title = str(item.get("title") or "").strip()[:60] + difference = str(item.get("difference") or "").strip()[:120] + risk = str(item.get("risk") or "").strip()[:80] + reuse = str(item.get("reuse_suggestion") or "").strip()[:100] + parts = [] + if project_id is not None: + parts.append(f"#{project_id}") + if title: + parts.append(title) + prefix = " ".join(parts) or "相似项目" + detail_parts = [part for part in [difference, risk, reuse] if part] + detail = ";".join(detail_parts) if detail_parts else "建议先确认范围边界。" + return f"· {prefix}:{detail}" + + @staticmethod + def _split_paragraph_lines(text: str) -> list[str]: + normalized = " ".join(str(text or "").split()).strip() + if not normalized: + return ["· 暂无"] + return [f"· {chunk.strip()}" for chunk in re.split(r"[。;;]\s*", normalized) if chunk.strip()][:3] + @staticmethod def _safe_int(value: Any) -> Optional[int]: try: 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 7f8ce81f..2f3cf05c 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 @@ -21,6 +21,7 @@ class SignalCaptureEntryMixin: force_sync: bool = False, message_sent_at: Optional[Any] = None, session_snapshot: Optional[Dict[str, Any]] = None, + thread_context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ 从飞书群聊捕获信号 @@ -68,6 +69,10 @@ class SignalCaptureEntryMixin: enqueue_session = await kernel._get_or_create_session( tenant_id=int(tenant_id), sender=proposer_name, + source_chat_id=group_chat_id, + source_sender_open_id=proposer_open_id, + source_message_id=source_message_id, + thread_context=thread_context, ) should_sync_process = kernel._is_short_confirmation_message( message_text=cleaned_message, @@ -106,6 +111,7 @@ class SignalCaptureEntryMixin: chat_type=chat_type, message_sent_at=effective_message_sent_at, session_snapshot=effective_session_snapshot, + thread_context=thread_context, ) if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message): @@ -120,6 +126,10 @@ class SignalCaptureEntryMixin: enqueue_session = await kernel._get_or_create_session( tenant_id=int(tenant_id), sender=proposer_name, + source_chat_id=group_chat_id, + source_sender_open_id=proposer_open_id, + source_message_id=source_message_id, + thread_context=thread_context, ) enqueue_message_sent_at = self._resolve_enqueue_message_sent_at( captured_dt=captured_dt, @@ -145,13 +155,16 @@ class SignalCaptureEntryMixin: chat_type=chat_type, captured_at=captured_dt, project_hint_id=None, + thread_context=thread_context, max_retries=3, ) if created: base_payload = run_row.result_payload if isinstance(run_row.result_payload, dict) else {} + task_payload = base_payload.get("task_payload") if isinstance(base_payload.get("task_payload"), dict) else {} run_row.result_payload = { **base_payload, "task_payload": { + **task_payload, "message_sent_at": enqueue_message_sent_at.isoformat(), "session_snapshot": enqueue_session_snapshot, }, @@ -184,6 +197,8 @@ class SignalCaptureEntryMixin: group_chat_id=group_chat_id, sender=proposer_name, sender_open_id=proposer_open_id, + source_message_id=source_message_id, + thread_context=thread_context, message=cleaned_message, pipeline_result=pipeline_result, ) @@ -238,6 +253,7 @@ class SignalCaptureEntryMixin: chat_type: Optional[str], message_sent_at: Optional[Any] = None, session_snapshot: Optional[Dict[str, Any]] = None, + thread_context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: pipeline_result = await kernel.process_feishu_message( group_chat_id=group_chat_id, @@ -249,6 +265,7 @@ class SignalCaptureEntryMixin: chat_type=chat_type, message_sent_at=message_sent_at, session_snapshot=session_snapshot, + thread_context=thread_context, ) if not bool(pipeline_result.get("handled")): self._emit_ai_think( @@ -280,6 +297,8 @@ class SignalCaptureEntryMixin: group_chat_id=group_chat_id, sender=proposer_name, sender_open_id=proposer_open_id, + source_message_id=feishu_message_id, + thread_context=thread_context, message=cleaned_message, pipeline_result=pipeline_result, ) diff --git a/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py index 1570cbff..7a4b4cef 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py +++ b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py @@ -10,6 +10,8 @@ class SignalCapturePipelineCacheMixin: group_chat_id: str, sender: str, sender_open_id: Optional[str], + source_message_id: Optional[str], + thread_context: Optional[Dict[str, Any]], message: str, pipeline_result: Dict[str, Any], ) -> None: @@ -20,6 +22,8 @@ class SignalCapturePipelineCacheMixin: source_chat_id=group_chat_id, source_sender_open_id=sender_open_id, sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, ) external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key) if external_session_id is None: @@ -257,4 +261,3 @@ class SignalCapturePipelineCacheMixin: } ) return items - diff --git a/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_main.py b/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_main.py index 1bc00669..acfc8b3f 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_main.py +++ b/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_main.py @@ -3,6 +3,191 @@ from __future__ import annotations from .shared import * class SignalCaptureTaskOrchestrationMainMixin: + @staticmethod + def _compact_orchestration_text(value: Any, *, max_len: int = 120) -> str: + text = str(value or "").strip() + if not text: + return "" + text = re.sub(r"\s+", " ", text) + return text[:max_len] + + @classmethod + def _compact_orchestration_items( + cls, + value: Any, + *, + max_items: int = 6, + max_len: int = 120, + ) -> List[str]: + if isinstance(value, list): + raw_items = value + elif isinstance(value, str): + raw_items = re.split(r"[\n\r,,;;、/|]+", value) + else: + raw_items = [] + + items: List[str] = [] + seen: set[str] = set() + for raw in raw_items: + text = cls._compact_orchestration_text(raw, max_len=max_len) + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + items.append(text) + if len(items) >= max_items: + break + return items + + @classmethod + def _build_task_orchestration_charter_context( + cls, + charter: Optional[TruthNode], + ) -> Dict[str, Any]: + if charter is None: + return {} + + content = charter.content + if isinstance(content, str): + try: + content = json.loads(content) + except Exception: + content = {} + if not isinstance(content, dict): + content = {} + + modules = content.get("modules") + if not isinstance(modules, dict): + modules = {} + + 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 {} + module_5 = modules.get("module_5") if isinstance(modules.get("module_5"), dict) else {} + module_6 = modules.get("module_6") if isinstance(modules.get("module_6"), dict) else {} + + summary = cls._compact_orchestration_text( + content.get("summary") + or content.get("rationale") + or content.get("background") + or module_1.get("problem_statement") + or "", + max_len=180, + ) + objectives = cls._compact_orchestration_items( + content.get("objectives") + or module_1.get("goal_statement") + or [], + max_items=5, + max_len=100, + ) + in_scope = cls._compact_orchestration_items( + content.get("in_scope") + or module_2.get("in_scope") + or [], + max_items=5, + max_len=100, + ) + out_of_scope = cls._compact_orchestration_items( + content.get("out_of_scope") + or module_2.get("out_of_scope") + or [], + max_items=5, + max_len=100, + ) + success_criteria = cls._compact_orchestration_items( + content.get("success_criteria") + or content.get("success_metrics") + or content.get("core_metrics") + or module_1.get("kpis") + or [], + max_items=6, + max_len=100, + ) + constraints = cls._compact_orchestration_items( + content.get("constraints") + or module_5.get("execution_rule") + or module_6.get("governance_rule") + or module_2.get("alignment_qa") + or [], + max_items=6, + max_len=100, + ) + milestones = cls._compact_orchestration_items( + content.get("milestones") + or module_4.get("milestones") + or [], + max_items=5, + max_len=100, + ) + risks = cls._compact_orchestration_items( + content.get("risks") + or module_5.get("risk_register") + or [], + max_items=5, + max_len=100, + ) + scope = cls._compact_orchestration_text( + content.get("scope") + or module_2.get("alignment_qa") + or "", + max_len=240, + ) + + return { + "id": int(charter.id), + "title": cls._compact_orchestration_text(charter.title, max_len=120) or "未命名宪章", + "version": int(charter.version or 1), + "status": cls._compact_orchestration_text(charter.status, max_len=32) or "unknown", + "summary": summary, + "objectives": objectives, + "scope": scope, + "in_scope": in_scope, + "out_of_scope": out_of_scope, + "success_criteria": success_criteria, + "constraints": constraints, + "milestones": milestones, + "risks": risks, + } + + async def _load_task_orchestration_charter_context( + self, + *, + tenant_id: int, + project_id: int, + charter_id: int, + ) -> Dict[str, Any]: + charter = ( + await self.db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == tenant_id, + TruthNode.project_id == project_id, + TruthNode.id == charter_id, + TruthNode.node_type == "charter", + ) + ) + ) + ).scalar_one_or_none() + return self._build_task_orchestration_charter_context(charter) + + @staticmethod + def _build_task_orchestration_prompt( + *, + project_payload: Dict[str, Any], + charter_payload: Dict[str, Any], + signal_payload: Dict[str, Any], + team_members: List[Dict[str, Any]], + ) -> str: + return PROMPT_FEISHU_TASK_ORCHESTRATION.format( + project_json=json.dumps(project_payload, ensure_ascii=False), + charter_json=json.dumps(charter_payload, ensure_ascii=False), + signal_json=json.dumps(signal_payload, ensure_ascii=False), + members_json=json.dumps(team_members, ensure_ascii=False), + ) + async def _auto_orchestrate_project_tasks( self, *, @@ -58,11 +243,17 @@ class SignalCaptureTaskOrchestrationMainMixin: "tasks_created": 0, } + charter_context = await self._load_task_orchestration_charter_context( + tenant_id=tenant_id, + project_id=project_id, + charter_id=int(charter_id), + ) project_payload = { "id": int(project.id), "title": (project.title or project.name or "").strip(), "description": (project.description or "").strip()[:600], "assigned_role": (project.assigned_role or "").strip(), + "status": (project.status or "").strip(), } signal_payload = { "signal_id": signal_id, @@ -71,10 +262,11 @@ class SignalCaptureTaskOrchestrationMainMixin: "proposer": proposer_name, "captured_at": captured_at.isoformat(), } - prompt = PROMPT_FEISHU_TASK_ORCHESTRATION.format( - project_json=json.dumps(project_payload, ensure_ascii=False), - signal_json=json.dumps(signal_payload, ensure_ascii=False), - members_json=json.dumps(team_members, ensure_ascii=False), + prompt = self._build_task_orchestration_prompt( + project_payload=project_payload, + charter_payload=charter_context, + signal_payload=signal_payload, + team_members=team_members, ) llm_status = "success" @@ -109,6 +301,7 @@ class SignalCaptureTaskOrchestrationMainMixin: tasks_payload = self._build_task_orchestration_fallback( cleaned_message=cleaned_message, classification=classification, + charter_context=charter_context, ) created_items: List[Dict[str, Any]] = [] @@ -226,4 +419,3 @@ class SignalCaptureTaskOrchestrationMainMixin: "llm_error": llm_error[:300] if llm_error else "", } return created_items, execution_meta - diff --git a/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_skill.py b/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_skill.py index 9a87e1a9..658a3738 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_skill.py +++ b/backend/app/services/signal/signal_capture_service/mixin_task_orchestration_skill.py @@ -107,15 +107,29 @@ class SignalCaptureTaskOrchestrationSkillMixin: *, cleaned_message: str, classification: Dict[str, Any], + charter_context: Optional[Dict[str, Any]] = None, ) -> List[Dict[str, Any]]: summary = self._normalize_single_line_text( str(classification.get("summary") or cleaned_message), max_len=120, ) + charter_title = "" + charter_summary = "" + if isinstance(charter_context, dict): + charter_title = self._normalize_single_line_text( + str(charter_context.get("title") or ""), + max_len=80, + ) + charter_summary = self._normalize_single_line_text( + str(charter_context.get("summary") or charter_context.get("scope") or ""), + max_len=120, + ) + charter_anchor = charter_title or charter_summary or "当前项目宪章" + charter_prefix = f"基于宪章「{charter_anchor}」" return [ { "title": "需求澄清与范围确认", - "description": f"基于飞书信号补全需求边界、验收口径与优先级。上下文:{summary}", + "description": f"{charter_prefix},补全需求边界、验收口径与优先级。上下文:{summary}", "priority": "high", "estimated_hours": 4, "required_skill_names": ["产品管理", "项目管理"], @@ -125,7 +139,7 @@ class SignalCaptureTaskOrchestrationSkillMixin: }, { "title": "核心方案落地", - "description": f"围绕需求信号推进可交付实现,拆分后端/前端/AI 子任务并排期。上下文:{summary}", + "description": f"{charter_prefix},围绕关键约束拆分后端/前端/AI 子任务并排期。上下文:{summary}", "priority": "medium", "estimated_hours": 12, "required_skill_names": ["FastAPI", "React", "LangChain"], @@ -172,4 +186,3 @@ class SignalCaptureTaskOrchestrationSkillMixin: return int(value) except Exception: return None - diff --git a/backend/app/services/signal/signal_capture_service/shared.py b/backend/app/services/signal/signal_capture_service/shared.py index e0247dc0..1fc10d97 100644 --- a/backend/app/services/signal/signal_capture_service/shared.py +++ b/backend/app/services/signal/signal_capture_service/shared.py @@ -47,6 +47,7 @@ from ..signal_source_orchestration import SignalSourceOrchestrationService from ...ai_pmo.ai_pmo_kernel import AIPMOKernelService from ....utils.project_members import ensure_project_default_members from ....utils.sequence import ensure_project_id_sequence, ensure_signal_id_sequence +from app.utils.thread_context import build_feishu_thread_key from ....utils.timezone import CN_TZ, now_cn_aware @@ -160,16 +161,16 @@ def _build_feishu_rag_conversation_key( source_chat_id: Optional[str], source_sender_open_id: Optional[str], sender: Optional[str], + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, ) -> str: - chat = str(source_chat_id or "").strip() - if not chat: - return "" - actor = str(source_sender_open_id or "").strip() - if not actor: - actor = str(sender or "").strip().lower() - actor = re.sub(r"\s+", "", actor) - actor = actor[:96] if actor else "unknown" - return f"feishu:{chat}:{actor}" + return build_feishu_thread_key( + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + sender=sender, + source_message_id=source_message_id, + thread_context=thread_context, + ) __all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/signal/signal_planner/mixin_analyze.py b/backend/app/services/signal/signal_planner/mixin_analyze.py index 65d91826..38485c1e 100644 --- a/backend/app/services/signal/signal_planner/mixin_analyze.py +++ b/backend/app/services/signal/signal_planner/mixin_analyze.py @@ -49,6 +49,11 @@ class SignalPlannerAnalyzeMixin: tenant_id=int(tenant_id), project_id=project_id, ) + charter_json = await cls._load_latest_charter_payload( + db, + tenant_id=int(tenant_id), + project_id=project_id, + ) recent_signals = await cls._load_recent_signal_summaries( db, tenant_id=int(tenant_id), @@ -63,6 +68,7 @@ class SignalPlannerAnalyzeMixin: prompt = cls._build_prompt( signal=signal, project_title=project_title, + charter_json=charter_json, recent_signals=recent_signals, members=members, ) diff --git a/backend/app/services/signal/signal_planner/mixin_loaders.py b/backend/app/services/signal/signal_planner/mixin_loaders.py index 43a1f37f..2acae7ca 100644 --- a/backend/app/services/signal/signal_planner/mixin_loaders.py +++ b/backend/app/services/signal/signal_planner/mixin_loaders.py @@ -9,6 +9,7 @@ from .shared import ( Project, Signal, SkillTag, + TruthNode, User, UserProject, UserSkillBinding, @@ -16,6 +17,7 @@ from .shared import ( and_, select, ) +from ...sstc.charter_payload import _build_charter_context_payload class SignalPlannerLoadersMixin: @@ -62,10 +64,38 @@ class SignalPlannerLoadersMixin: ) ) .limit(1) - ) + ) ).scalar_one_or_none() return str(row or "") + @classmethod + async def _load_latest_charter_payload( + cls, + db: AsyncSession, + *, + tenant_id: int, + project_id: Optional[int], + ) -> dict[str, Any]: + if project_id is None: + return {} + charter = ( + await db.execute( + select(TruthNode) + .where( + and_( + TruthNode.tenant_id == int(tenant_id), + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + ) + ) + .order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if charter is None: + return {} + return _build_charter_context_payload(charter) + @classmethod async def _load_recent_signal_summaries( cls, diff --git a/backend/app/services/signal/signal_planner/mixin_prompt.py b/backend/app/services/signal/signal_planner/mixin_prompt.py index bcccb24d..542dd9d0 100644 --- a/backend/app/services/signal/signal_planner/mixin_prompt.py +++ b/backend/app/services/signal/signal_planner/mixin_prompt.py @@ -14,6 +14,7 @@ class SignalPlannerPromptMixin: *, signal: Signal, project_title: str, + charter_json: dict[str, Any], recent_signals: list[dict[str, Any]], members: list[dict[str, Any]], ) -> str: @@ -35,6 +36,7 @@ class SignalPlannerPromptMixin: ), project_title=str(project_title or ""), project_scope=str(ai_analysis.get("requirement_project_scope") or ""), + charter_json=json.dumps(charter_json or {}, ensure_ascii=False), recent_signals=json.dumps(recent_signals, ensure_ascii=False), member_profiles=json.dumps(members, ensure_ascii=False), now_cn=now_cn_naive().isoformat(), diff --git a/backend/app/services/signal/signal_planner/shared.py b/backend/app/services/signal/signal_planner/shared.py index 627f139b..64241a63 100644 --- a/backend/app/services/signal/signal_planner/shared.py +++ b/backend/app/services/signal/signal_planner/shared.py @@ -17,6 +17,7 @@ from ....models import ( ProjectSignalLink, Signal, SkillTag, + TruthNode, User, UserProject, UserSkillBinding, @@ -45,6 +46,7 @@ __all__ = [ "Signal", "SignalStrategicAnalysisService", "SkillTag", + "TruthNode", "User", "UserProject", "UserSkillBinding", diff --git a/backend/app/services/sstc/charter_data.py b/backend/app/services/sstc/charter_data.py index 12579e3c..e414343a 100644 --- a/backend/app/services/sstc/charter_data.py +++ b/backend/app/services/sstc/charter_data.py @@ -2,9 +2,21 @@ from __future__ import annotations from .shared import * +from ...config.ai_prompts import PROMPT_DECOMPOSE_TASKS from .signal_core import _as_dict, _extract_signal_attachments, _normalize_planner_status, _signal_display_type, _signal_project_scope, _signal_source_context from .signal_filters import _signal_proposer_name, _signal_title_text +_DEFAULT_TASK_BREAKDOWN_SKILLS = ( + "Python", + "TypeScript", + "FastAPI", + "React", + "SQLAlchemy", + "LangChain", + "Docker", + "LLM调优", +) + async def _fetch_valid_project_signal_ids( db: AsyncSession, *, @@ -53,6 +65,102 @@ def _build_charter_summary_payload(charter: TruthNode) -> dict[str, Any]: "version": charter.version, } + +def _build_charter_context_payload(charter: TruthNode) -> dict[str, Any]: + content = _as_dict(charter.content) + modules = content.get("modules") + if not isinstance(modules, dict): + modules = {} + 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 {} + module_5 = modules.get("module_5") if isinstance(modules.get("module_5"), dict) else {} + module_6 = modules.get("module_6") if isinstance(modules.get("module_6"), dict) else {} + + signal_ids = content.get("signal_ids") + normalized_signal_ids: list[int] = [] + if isinstance(signal_ids, list): + for sid in signal_ids: + try: + normalized_signal_ids.append(int(sid)) + except Exception: + continue + + return { + "id": int(charter.id), + "title": _compact_task_breakdown_text(charter.title or "", max_len=120) or "未命名宪章", + "version": int(charter.version or 1), + "status": _compact_task_breakdown_text(charter.status or "", max_len=32) or "unknown", + "summary": _compact_task_breakdown_text( + content.get("summary") + or content.get("rationale") + or content.get("background") + or module_1.get("problem_statement") + or "", + max_len=180, + ), + "objectives": _compact_task_breakdown_items( + content.get("objectives") + or module_1.get("goal_statement") + or [], + max_items=5, + max_len=100, + ), + "scope": _compact_task_breakdown_text( + content.get("scope") + or module_2.get("alignment_qa") + or "", + max_len=240, + ), + "in_scope": _compact_task_breakdown_items( + content.get("in_scope") + or module_2.get("in_scope") + or [], + max_items=5, + max_len=100, + ), + "out_of_scope": _compact_task_breakdown_items( + content.get("out_of_scope") + or module_2.get("out_of_scope") + or [], + max_items=5, + max_len=100, + ), + "success_criteria": _compact_task_breakdown_items( + content.get("success_criteria") + or content.get("success_metrics") + or content.get("core_metrics") + or module_1.get("kpis") + or [], + max_items=6, + max_len=100, + ), + "constraints": _compact_task_breakdown_items( + content.get("constraints") + or module_5.get("execution_rule") + or module_6.get("governance_rule") + or module_2.get("alignment_qa") + or [], + max_items=6, + max_len=100, + ), + "milestones": _compact_task_breakdown_items( + content.get("milestones") + or module_4.get("milestones") + or [], + max_items=5, + max_len=100, + ), + "risks": _compact_task_breakdown_items( + content.get("risks") + or module_5.get("risk_register") + or [], + max_items=5, + max_len=100, + ), + "signal_ids": normalized_signal_ids, + } + def _serialize_charter_signal(signal: Signal) -> dict[str, Any]: return { "id": str(signal.id), @@ -135,3 +243,163 @@ def _serialize_alignment_record( "comments": record.comments, "confirmed_at": confirmed_at, } + + +def _compact_task_breakdown_text(value: object, *, max_len: int = 120) -> str: + text = str(value or "").strip() + if not text: + return "" + text = re.sub(r"\s+", " ", text) + return text[:max_len] + + +def _compact_task_breakdown_items( + value: object, + *, + max_items: int = 6, + max_len: int = 120, +) -> list[str]: + if isinstance(value, list): + raw_items = value + elif isinstance(value, str): + raw_items = re.split(r"[\n\r,,;;、/|]+", value) + else: + raw_items = [] + + items: list[str] = [] + seen: set[str] = set() + for raw in raw_items: + text = _compact_task_breakdown_text(raw, max_len=max_len) + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + items.append(text) + if len(items) >= max_items: + break + return items + + +def _build_task_breakdown_context( + *, + description: str, + project: Optional[Project] = None, + charter: Optional[TruthNode] = None, +) -> dict[str, Any]: + project_payload: dict[str, Any] = {} + if project is not None: + project_payload = { + "id": int(project.id), + "title": _compact_task_breakdown_text(project.title or project.name or "", max_len=120) or "未命名项目", + "description": _compact_task_breakdown_text(project.description or "", max_len=240), + "status": _compact_task_breakdown_text(project.status or "", max_len=32), + "assigned_role": _compact_task_breakdown_text(project.assigned_role or "", max_len=32), + } + + charter_payload: dict[str, Any] = {} + if charter is not None: + charter_payload = _build_charter_context_payload(charter) + + return { + "request": { + "description": _compact_task_breakdown_text(description, max_len=1200), + }, + "project": project_payload, + "charter": charter_payload, + } + + +async def _load_available_skill_names( + db: AsyncSession, + *, + tenant_id: int, +) -> str: + rows = ( + await db.execute( + select(SkillTag.name) + .where(SkillTag.tenant_id == tenant_id) + .order_by(SkillTag.sort_order.asc(), SkillTag.name.asc()) + ) + ).scalars().all() + names: list[str] = [] + seen: set[str] = set() + for raw in rows: + name = str(raw or "").strip() + if not name: + continue + key = name.lower() + if key in seen: + continue + seen.add(key) + names.append(name) + if not names: + names = list(_DEFAULT_TASK_BREAKDOWN_SKILLS) + return ", ".join(names) + + +async def _load_task_breakdown_context( + db: AsyncSession, + *, + tenant_id: int, + description: str, + project_id: Optional[int] = None, + charter_id: Optional[int] = None, +) -> dict[str, Any]: + project = None + if project_id is not None: + project = ( + await db.execute( + select(Project).where( + and_( + Project.tenant_id == int(tenant_id), + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + + charter = None + if charter_id is not None: + charter_query = select(TruthNode).where( + and_( + TruthNode.tenant_id == int(tenant_id), + TruthNode.id == int(charter_id), + TruthNode.node_type == "charter", + ) + ) + if project_id is not None: + charter_query = charter_query.where(TruthNode.project_id == int(project_id)) + charter = (await db.execute(charter_query)).scalar_one_or_none() + elif project_id is not None: + charter = ( + await db.execute( + select(TruthNode).where( + and_( + TruthNode.tenant_id == int(tenant_id), + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + ) + ).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + + return _build_task_breakdown_context( + description=description, + project=project, + charter=charter, + ) + + +def _build_task_breakdown_prompt( + *, + project_id: Optional[int], + context: dict[str, Any], + available_skills: str, +) -> str: + return PROMPT_DECOMPOSE_TASKS.format( + project_id=str(project_id or "未指定"), + charter_content=json.dumps(context, ensure_ascii=False), + available_skills=available_skills, + ) diff --git a/backend/app/services/sstc/charter_payload.py b/backend/app/services/sstc/charter_payload.py new file mode 100644 index 00000000..6eb88899 --- /dev/null +++ b/backend/app/services/sstc/charter_payload.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +from ...models import TruthNode + + +def _as_dict(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + except (TypeError, ValueError): + return {"text": value} + return parsed if isinstance(parsed, dict) else {"text": value} + return {} + + +def _compact_text(value: object, *, max_len: int = 120) -> str: + text = str(value or "").strip() + if not text: + return "" + text = re.sub(r"\s+", " ", text) + return text[:max_len] + + +def _compact_items( + value: object, + *, + max_items: int = 6, + max_len: int = 120, +) -> list[str]: + if isinstance(value, list): + raw_items = value + elif isinstance(value, str): + raw_items = re.split(r"[\n\r,,;;、/|]+", value) + else: + raw_items = [] + + items: list[str] = [] + seen: set[str] = set() + for raw in raw_items: + text = _compact_text(raw, max_len=max_len) + if not text: + continue + key = text.lower() + if key in seen: + continue + seen.add(key) + items.append(text) + if len(items) >= max_items: + break + return items + + +def _build_charter_context_payload(charter: TruthNode) -> dict[str, Any]: + content = _as_dict(charter.content) + modules = content.get("modules") + if not isinstance(modules, dict): + modules = {} + 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 {} + module_5 = modules.get("module_5") if isinstance(modules.get("module_5"), dict) else {} + module_6 = modules.get("module_6") if isinstance(modules.get("module_6"), dict) else {} + + signal_ids = content.get("signal_ids") + normalized_signal_ids: list[int] = [] + if isinstance(signal_ids, list): + for sid in signal_ids: + try: + normalized_signal_ids.append(int(sid)) + except Exception: + continue + + return { + "id": int(charter.id), + "title": _compact_text(charter.title or "", max_len=120) or "未命名宪章", + "version": int(charter.version or 1), + "status": _compact_text(charter.status or "", max_len=32) or "unknown", + "summary": _compact_text( + content.get("summary") + or content.get("rationale") + or content.get("background") + or module_1.get("problem_statement") + or "", + max_len=180, + ), + "objectives": _compact_items( + content.get("objectives") + or module_1.get("goal_statement") + or [], + max_items=5, + max_len=100, + ), + "scope": _compact_text( + content.get("scope") + or module_2.get("alignment_qa") + or "", + max_len=240, + ), + "in_scope": _compact_items( + content.get("in_scope") + or module_2.get("in_scope") + or [], + max_items=5, + max_len=100, + ), + "out_of_scope": _compact_items( + content.get("out_of_scope") + or module_2.get("out_of_scope") + or [], + max_items=5, + max_len=100, + ), + "success_criteria": _compact_items( + content.get("success_criteria") + or content.get("success_metrics") + or content.get("core_metrics") + or module_1.get("kpis") + or [], + max_items=6, + max_len=100, + ), + "constraints": _compact_items( + content.get("constraints") + or module_5.get("execution_rule") + or module_6.get("governance_rule") + or module_2.get("alignment_qa") + or [], + max_items=6, + max_len=100, + ), + "milestones": _compact_items( + content.get("milestones") + or module_4.get("milestones") + or [], + max_items=5, + max_len=100, + ), + "risks": _compact_items( + content.get("risks") + or module_5.get("risk_register") + or [], + max_items=5, + max_len=100, + ), + "signal_ids": normalized_signal_ids, + } + + +__all__ = ["_build_charter_context_payload"] diff --git a/backend/app/services/sstc/charter_prefill_utils.py b/backend/app/services/sstc/charter_prefill_utils.py index 8504e550..3dfdbbc0 100644 --- a/backend/app/services/sstc/charter_prefill_utils.py +++ b/backend/app/services/sstc/charter_prefill_utils.py @@ -69,6 +69,115 @@ def _default_escalation_trigger() -> str: ] ) +async def _load_charter_project_context( + *, + db: AsyncSession, + tenant_id: int, + project_id: int, +) -> dict[str, Any]: + from ...models import Project + + row = ( + await db.execute( + select(Project).where( + and_( + Project.tenant_id == int(tenant_id), + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if row is None: + return {} + return { + "id": int(row.id), + "name": str(getattr(row, "name", "") or "").strip(), + "title": str(getattr(row, "title", "") or getattr(row, "name", "") or "").strip(), + "description": str(getattr(row, "description", "") or "").strip(), + "status": str(getattr(row, "status", "") or "").strip(), + "owner_id": getattr(row, "owner_id", None), + } + +async def _load_active_projects_context( + *, + db: AsyncSession, + tenant_id: int, + project_id: int, + limit: int = 10, +) -> list[dict[str, Any]]: + from ...models import Project + + rows = ( + await db.execute( + select( + Project.id, + Project.name, + Project.title, + Project.description, + Project.status, + Project.owner_id, + ) + .where( + and_( + Project.tenant_id == int(tenant_id), + Project.status != "inactive", + Project.id != int(project_id), + ) + ) + .order_by(Project.id.desc()) + .limit(max(1, min(20, int(limit)))) + ) + ).all() + active_projects: list[dict[str, Any]] = [] + for row in rows: + active_projects.append( + { + "id": int(row.id), + "name": str(row.name or "").strip(), + "title": str(row.title or row.name or "").strip(), + "description": str(row.description or "").strip(), + "status": str(row.status or "").strip(), + "owner_id": getattr(row, "owner_id", None), + } + ) + return active_projects + +def _build_charter_project_context_text( + project_context: dict[str, Any], + active_projects: list[dict[str, Any]], +) -> str: + blocks: list[str] = [] + if isinstance(project_context, dict) and project_context: + blocks.append( + "\n".join( + [ + "[当前项目]", + f"ID:{project_context.get('id')}", + f"名称:{project_context.get('title') or project_context.get('name') or '未命名项目'}", + f"描述:{str(project_context.get('description') or '暂无描述').strip()}", + f"状态:{project_context.get('status') or 'unknown'}", + f"负责人ID:{project_context.get('owner_id') or '未指定'}", + ] + ).strip() + ) + + if active_projects: + lines = ["[活跃项目(用于查重/复用)]"] + for idx, project in enumerate(active_projects[:10], start=1): + title = str(project.get("title") or project.get("name") or "未命名项目").strip() + description = str(project.get("description") or "").strip() + status = str(project.get("status") or "").strip() or "unknown" + owner_id = project.get("owner_id") + row_parts = [f"{idx}. {title}", f"状态:{status}"] + if description: + row_parts.append(f"描述:{description[:80]}") + if owner_id is not None: + row_parts.append(f"负责人ID:{owner_id}") + lines.append(" | ".join(row_parts)) + blocks.append("\n".join(lines)) + + return "\n\n".join(blocks).strip() + async def _generate_charter_seed( *, db: AsyncSession, @@ -79,9 +188,26 @@ async def _generate_charter_seed( if not signal_context.strip(): return {} + project_context = await _load_charter_project_context( + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + active_projects = await _load_active_projects_context( + db=db, + tenant_id=tenant_id, + project_id=project_id, + ) + project_context_text = _build_charter_project_context_text( + project_context=project_context, + active_projects=active_projects, + ) + active_projects_json = json.dumps(active_projects, ensure_ascii=False) + prompt = ( "你是 AI 项目经理,请基于输入信号生成可落地的宪章草稿。" "所有自然语言字段必须使用简体中文,禁止英文句子;模型名称或技术名词可保留英文。" + "必须先结合当前项目上下文与活跃项目列表判断是否重复建设或应当合并。" "只输出 JSON,不要 markdown。\n" "字段:\n" "{\n" @@ -97,6 +223,8 @@ async def _generate_charter_seed( ' "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: @@ -122,6 +250,9 @@ async def _generate_charter_seed( signal_context, tenant_id=tenant_id, db=db, + project_id=project_id, + project_context=project_context_text, + active_projects_json=active_projects_json, ) if isinstance(ai_seed, dict): if _needs_seed_localize_zh(ai_seed): @@ -132,4 +263,3 @@ async def _generate_charter_seed( return {} except Exception: return {} - diff --git a/backend/app/utils/thread_context.py b/backend/app/utils/thread_context.py new file mode 100644 index 00000000..e8ab8d32 --- /dev/null +++ b/backend/app/utils/thread_context.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Any, Optional + +import re + + +def _normalize_token(value: Any) -> str: + text = re.sub(r"\s+", "", str(value or "").strip()) + return text[:128] + + +def extract_thread_anchor(thread_context: Optional[dict[str, Any]] = None) -> str: + if not isinstance(thread_context, dict): + return "" + for key in ("root_message_id", "thread_id", "reply_to_message_id"): + value = _normalize_token(thread_context.get(key)) + if value: + return value + return "" + + +def build_feishu_thread_key( + *, + source_chat_id: Optional[str], + source_sender_open_id: Optional[str], + sender: Optional[str], + source_message_id: Optional[str] = None, + thread_context: Optional[dict[str, Any]] = None, +) -> str: + chat = _normalize_token(source_chat_id) + anchor = extract_thread_anchor(thread_context) + 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}" + return "" + diff --git a/backend/check_drafts.py b/backend/check_drafts.py new file mode 100644 index 00000000..312d865e --- /dev/null +++ b/backend/check_drafts.py @@ -0,0 +1,52 @@ +"""Check and handle draft confirmation.""" +import asyncio + +async def main(): + from app.core.database import AsyncSessionLocal + from sqlalchemy import select, and_ + from app.models import WorkItem, IngestEvent + + tenant_id = 1 + + async with AsyncSessionLocal() as db: + # Check for pending drafts + stmt = ( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.state == "draft_pending_confirm", + ) + ) + .order_by(WorkItem.id.desc()) + .limit(5) + ) + result = await db.execute(stmt) + rows = result.all() + + if not rows: + print("✅ 当前没有待确认的草稿") + print("\n最近处理的工作项:") + # Show recent work items + recent_stmt = ( + select(WorkItem) + .where(WorkItem.tenant_id == tenant_id) + .order_by(WorkItem.id.desc()) + .limit(3) + ) + recent_result = await db.execute(recent_stmt) + recent_items = recent_result.scalars().all() + for item in recent_items: + print(f" - ID: {item.id}, State: {item.state}, Intent: {item.intent}") + else: + print(f"找到 {len(rows)} 个待确认草稿:\n") + for work_item, ingest_event in rows: + print(f"Work Item ID: {work_item.id}") + print(f"Intent: {work_item.intent}") + print(f"Draft Content: {work_item.draft_content}") + print(f"Created: {work_item.created_at}") + print("-" * 50) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/handle_confirm.py b/backend/handle_confirm.py new file mode 100644 index 00000000..627d71c3 --- /dev/null +++ b/backend/handle_confirm.py @@ -0,0 +1,66 @@ +"""Handle draft confirmation directly.""" +import asyncio +import os + +async def main(): + from app.core.database import AsyncSessionLocal + from app.services.ai_pmo.ai_pmo_kernel import AIPMOKernelService + from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler + from sqlalchemy import select, and_ + from app.models import WorkItem, IngestEvent + + tenant_id = 1 + + async with AsyncSessionLocal() as db: + # Find the pending draft work item + stmt = ( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.state == "draft_pending_confirm", + ) + ) + .order_by(WorkItem.id.desc()) + .limit(1) + ) + result = await db.execute(stmt) + row = result.first() + + if not row: + print("No pending draft found") + return + + work_item, ingest_event = row + print(f"Found pending draft: work_item_id={work_item.id}, intent={work_item.intent}") + + # Handle confirmation + kernel = AIPMOKernelService(db, tenant_id=tenant_id) + handler = DraftConfirmHandler(kernel) + + result = await handler.handle_text_reply( + message_text="确认", + sender="林宇航", + source_system="feishu", + source_message_id="om_x100b53013d259114c4d68abc6d1af6a", + channel_ref="oc_c487d87563e48c2f9566ccd5a758212c", + source_sender_open_id="ou_b6d2d3aa8c02d18256def525e85d558f", + expected_version=None, + thread_context={ + "reply_to_message_id": "om_x100b53013e7a4cb0c34d331fdf31ca9" + } + ) + + print(f"\nResult: {result}") + await db.commit() + + if result.get("handled"): + print(f"\n✅ Success: {result.get('followup_reply')}") + print(f"Signal ID: {result.get('signal_id')}") + print(f"Project ID: {result.get('project_id')}") + else: + print(f"\n❌ Failed: {result}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/handle_confirm2.py b/backend/handle_confirm2.py new file mode 100644 index 00000000..591f0351 --- /dev/null +++ b/backend/handle_confirm2.py @@ -0,0 +1,65 @@ +"""Handle draft confirmation directly.""" +import asyncio + +async def main(): + from app.core.database import AsyncSessionLocal + from app.services.ai_pmo.ai_pmo_kernel import AIPMOKernelService + from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler + from sqlalchemy import select, and_ + from app.models import WorkItem, IngestEvent + + tenant_id = 1 + + async with AsyncSessionLocal() as db: + # Find the latest pending draft work item + stmt = ( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.state == "draft_pending_confirm", + ) + ) + .order_by(WorkItem.id.desc()) + .limit(1) + ) + result = await db.execute(stmt) + row = result.first() + + if not row: + print("No pending draft found") + return + + work_item, ingest_event = row + print(f"Found pending draft: work_item_id={work_item.id}, intent={work_item.intent}") + + # Handle confirmation + kernel = AIPMOKernelService(db, tenant_id=tenant_id) + handler = DraftConfirmHandler(kernel) + + result = await handler.handle_text_reply( + message_text="确认,按这版详细方案入库,并保留里程碑、风险和验收标准。", + sender="林宇航", + source_system="feishu", + source_message_id="om_x100b530cb976dc80c2a9f8c5bc69309", + channel_ref="oc_c487d87563e48c2f9566ccd5a758212c", + source_sender_open_id="ou_b6d2d3aa8c02d18256def525e85d558f", + expected_version=None, + thread_context={ + "reply_to_message_id": "om_x100b530c8cefdca4c3b0892f8e9a726" + } + ) + + print(f"\nResult: {result}") + await db.commit() + + if result.get("handled"): + print(f"\n✅ Success: {result.get('followup_reply')}") + print(f"Signal ID: {result.get('signal_id')}") + print(f"Project ID: {result.get('project_id')}") + else: + print(f"\n❌ Failed: {result}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/handle_confirm3.py b/backend/handle_confirm3.py new file mode 100644 index 00000000..b81e64ca --- /dev/null +++ b/backend/handle_confirm3.py @@ -0,0 +1,67 @@ +"""Handle draft confirmation with better matching.""" +import asyncio + +async def main(): + from app.core.database import AsyncSessionLocal + from app.services.ai_pmo.ai_pmo_kernel import AIPMOKernelService + from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler + from sqlalchemy import select, and_ + from app.models import WorkItem, IngestEvent + + tenant_id = 1 + + async with AsyncSessionLocal() as db: + # Find the most recent project_create draft (not project_signal) + stmt = ( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.state == "draft_pending_confirm", + WorkItem.intent.like("project_create%"), + ) + ) + .order_by(WorkItem.id.desc()) + .limit(1) + ) + result = await db.execute(stmt) + row = result.first() + + if not row: + print("No pending project_create draft found") + return + + work_item, ingest_event = row + print(f"Found pending draft: work_item_id={work_item.id}, intent={work_item.intent}") + print(f"Summary: {work_item.draft_content.get('summary', 'N/A')}") + + # Handle confirmation + kernel = AIPMOKernelService(db, tenant_id=tenant_id) + handler = DraftConfirmHandler(kernel) + + result = await handler.handle_text_reply( + message_text="确认,按这版详细方案入库,并保留里程碑、风险和验收标准。", + sender="林宇航", + source_system="feishu", + source_message_id="om_x100b530d1d6020acc14e0a90435704b", + channel_ref="oc_c487d87563e48c2f9566ccd5a758212c", + source_sender_open_id="ou_b6d2d3aa8c02d18256def525e85d558f", + expected_version=None, + thread_context={ + "reply_to_message_id": "om_x100b530d7cc744a0c39a8052f539e52" + } + ) + + print(f"\nResult: {result}") + await db.commit() + + if result.get("handled"): + print(f"\n✅ Success: {result.get('followup_reply')}") + print(f"Signal ID: {result.get('signal_id')}") + print(f"Project ID: {result.get('project_id')}") + else: + print(f"\n❌ Failed: {result}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/handle_draft_confirm.py b/backend/handle_draft_confirm.py new file mode 100644 index 00000000..c457cc34 --- /dev/null +++ b/backend/handle_draft_confirm.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Handle draft confirmation directly.""" +import asyncio +import os +import sys + +# Add backend to path +sys.path.insert(0, '/Users/yuuuu/Documents/AIProjectManger/backend') + +async def main(): + from dotenv import load_dotenv + load_dotenv('/Users/yuuuu/Documents/AIProjectManger/.env') + + from app.core.database import AsyncSessionLocal + from app.services.ai_pmo.ai_pmo_kernel import AIPMOKernelService + from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler + from sqlalchemy import select, and_ + from app.models import WorkItem, IngestEvent + + tenant_id = 1 + + async with AsyncSessionLocal() as db: + # Find the pending draft work item + stmt = ( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.state == "draft_pending_confirm", + ) + ) + .order_by(WorkItem.id.desc()) + .limit(1) + ) + result = await db.execute(stmt) + row = result.first() + + if not row: + print("No pending draft found") + return + + work_item, ingest_event = row + print(f"Found pending draft: work_item_id={work_item.id}, intent={work_item.intent}") + print(f"Draft content: {work_item.draft_content}") + + # Handle confirmation + kernel = AIPMOKernelService(db, tenant_id=tenant_id) + handler = DraftConfirmHandler(kernel) + + result = await handler.handle_text_reply( + message_text="确认", + sender="林宇航", + source_system="feishu", + source_message_id="om_x100b53013d259114c4d68abc6d1af6a", + channel_ref="oc_c487d87563e48c2f9566ccd5a758212c", + source_sender_open_id="ou_b6d2d3aa8c02d18256def525e85d558f", + expected_version=None, + thread_context={ + "reply_to_message_id": "om_x100b53013e7a4cb0c34d331fdf31ca9" + } + ) + + print(f"\nResult: {result}") + await db.commit() + + if result.get("handled"): + print(f"\n✅ Success: {result.get('followup_reply')}") + print(f"Signal ID: {result.get('signal_id')}") + print(f"Project ID: {result.get('project_id')}") + else: + print(f"\n❌ Failed: {result}") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/requirements.txt b/backend/requirements.txt index fdbac24e..d797a21c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -8,6 +8,7 @@ pgvector>=0.3.5 python-dotenv>=1.0.1 redis>=5.0.8 httpx>=0.27.0 +certifi>=2024.7.4 pydantic>=2.8.2 python-multipart>=0.0.9 structlog>=24.2.0 diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index 6540dc0a..1e3aa9e1 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -1,7 +1,12 @@ import pytest +from types import SimpleNamespace 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_deterministic_query_core import ( + AdaptiveRAGDeterministicQueryCoreMixin, +) class _FakeAdaptiveRAGService( @@ -95,3 +100,149 @@ async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None assert service.base_calls == 1 assert int(service.base_kwargs["deep_retrieval_steps"]) <= 3 assert int(service.base_kwargs["deep_vector_top_k"]) <= 8 + + +@pytest.mark.asyncio +async def test_deterministic_query_prefers_project_created_at_answer() -> None: + calls: list[str] = [] + + class _FakeDeterministicService(AdaptiveRAGDeterministicQueryCoreMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _try_fast_project_created_at_answer(self, *, clean_q: str, project_id): + calls.append("created_at") + _ = (clean_q, project_id) + return {"answer": "创建于 2026-03-01 09:30。", "sources": [], "missing_fields": []} + + async def _try_fast_project_progress_answer(self, *, clean_q: str, project_id): + calls.append("progress") + _ = (clean_q, project_id) + return {"answer": "进度回答", "sources": []} + + async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id): + calls.append("existence") + _ = (clean_q, project_id) + return {"answer": "存在性回答", "sources": []} + + service = _FakeDeterministicService() + result = await service._run_deterministic_query( + question="他是什么时候创建的?", + project_id=7, + query_type="status", + ) + + assert result is not None + assert "创建于 2026-03-01 09:30" in str(result["answer"]) + assert calls[0] == "created_at" + + +def test_query_type_classifies_colloquial_list_request_as_list() -> None: + service = AdaptiveRAGDeterministicQueryCoreMixin() + + assert service._classify_query_type("我感觉很多重复的项目在系统,你能列一下吗?") == "list" + + +@pytest.mark.asyncio +async def test_deep_guard_answer_raises_on_empty_llm_response() -> None: + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + _ = (prompt, kwargs) + return "" + + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "normal" if str(thinking_mode or "").strip().lower() != "deep" else "deep" + + async def get_project_manifest(self, project_id): + _ = project_id + return {} + + def _select_scene_prompt(self, **kwargs): + _ = kwargs + return "external_trend", "请回答问题" + + def _enforce_feature_evidence_in_answer(self, *, question: str, answer_text: str, retrieved, system_snapshot): + _ = (question, retrieved, system_snapshot) + return answer_text + + service = _FakeService() + + with pytest.raises(ValueError, match="adaptive_rag_answer_empty_response"): + await service._generate_answer( + question="最近AI行业有什么动态", + retrieved=[], + conversation=[], + project_id=None, + asked_via="feishu", + ) + + +@pytest.mark.asyncio +async def test_deep_guard_answer_prefers_member_workload_direct_answer() -> None: + class _FakeResult: + def __init__(self, rows=None, scalar=None): + self._rows = rows or [] + self._scalar = scalar + + def all(self): + return self._rows + + def first(self): + return self._rows[0] if self._rows else None + + def scalar_one_or_none(self): + return self._scalar + + class _FakeDB: + def __init__(self) -> None: + self.calls = 0 + + async def execute(self, stmt, params=None): # noqa: ANN001 + self.calls += 1 + if self.calls == 1: + return _FakeResult(rows=[SimpleNamespace(id=24, name="杨宇", username="yangyu")]) + return _FakeResult(rows=[(265.56, 95.6, 60.0)]) + + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): # pragma: no cover - should not be called + raise AssertionError("LLM should not be called for direct member workload answer") + + class _FakeService(AdaptiveRAGDeepGuardAnswerMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.llm = _FakeLLM() + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "normal" + + async def get_project_manifest(self, project_id): + _ = project_id + return {} + + def _select_scene_prompt(self, **kwargs): + _ = kwargs + return "member_workload", "member workload prompt" + + def _enforce_feature_evidence_in_answer(self, *, question: str, answer_text: str, retrieved, system_snapshot): + _ = (question, retrieved, system_snapshot) + return answer_text + + service = _FakeService() + result = await service._generate_answer( + question="杨宇现在负载情况如何?", + retrieved=[], + conversation=[], + project_id=None, + asked_via="feishu", + ) + + assert "杨宇当前负载265.56%" in result + assert "严重超载" in result diff --git a/backend/tests/test_ai_orchestration_run_service.py b/backend/tests/test_ai_orchestration_run_service.py new file mode 100644 index 00000000..4c0597d1 --- /dev/null +++ b/backend/tests/test_ai_orchestration_run_service.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.services.ai_orchestration_run.service import AIOrchestrationRunService + + +class _FakeDB: + def __init__(self) -> None: + self.added = None + + def add(self, row) -> None: # noqa: ANN001 + self.added = row + + async def flush(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_enqueue_ingress_run_persists_thread_context_in_task_payload(monkeypatch) -> None: + db = _FakeDB() + service = AIOrchestrationRunService(db) + + async def _fake_get_run(*, tenant_id, trace_id): # noqa: ARG001 + return None + + monkeypatch.setattr(service, "get_run", _fake_get_run) + + row, created = await service.enqueue_ingress_run( + tenant_id=1, + source_system="feishu", + source_message_id="msg-1", + sender="林宇航", + message_text="它是什么时候创建的?", + channel_ref="chat-1", + source_sender_open_id="ou-1", + chat_type="group", + captured_at=None, + project_hint_id=None, + thread_context={ + "thread_id": "th-1", + "root_message_id": "root-1", + "reply_to_message_id": "reply-1", + }, + ) + + assert created is True + assert db.added is row + assert isinstance(row.result_payload, dict) + assert row.result_payload["task_payload"]["thread_context"] == { + "thread_id": "th-1", + "root_message_id": "root-1", + "reply_to_message_id": "reply-1", + } diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index 6676951d..873e91cf 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -1,3 +1,4 @@ +from datetime import datetime, timedelta from types import SimpleNamespace import pytest @@ -5,8 +6,19 @@ 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.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import ( + KernelIntentRulesMixin, +) +from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution +from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry +from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project +from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent +from app.services.llm_service import ChatCompletionResult, StructuredExtractionResult +from app.services.llm_service import mixin_tier_escalation +from app.services.llm_service.mixin_tier_escalation import LLMTierEscalationMixin +from app.utils.thread_context import build_feishu_thread_key @pytest.mark.asyncio @@ -128,6 +140,7 @@ async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_via"] == "pmflow_manual" + assert answer_kwargs["project_id"] is None assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["conversation_key"] == "manual-msg-2" @@ -181,6 +194,7 @@ async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", + ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}}, ) assert kernel.draft_called is True @@ -188,6 +202,264 @@ async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> assert "草稿" in result["direct_reply_text"] +def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None: + session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="确认", + ingress_options=None, + ) is False + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="确认", + ingress_options={"thread_context": {"thread_id": "th_123"}}, + ) is False + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="确认", + ingress_options={"thread_context": {"root_message_id": "root_123"}}, + ) is False + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="创建时间是多少?", + ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, + ) is False + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="确认", + ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, + ) is True + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="工作项ID: 88 创建时间是多少?", + ingress_options=None, + ) is True + + +def test_short_confirmation_requires_reply_anchor() -> None: + session = SimpleNamespace( + last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM, + last_active_at=datetime.now(), + ) + sent_at = datetime.now() + timedelta(seconds=1) + + assert ( + KernelIntentRulesMixin._is_short_confirmation_message( + message_text="确认", + session=session, + message_sent_at=sent_at, + session_snapshot=None, + reply_anchor_present=False, + ) + is False + ) + assert ( + KernelIntentRulesMixin._is_short_confirmation_message( + message_text="确认", + session=session, + message_sent_at=sent_at, + session_snapshot=None, + reply_anchor_present=True, + ) + is True + ) + + +def test_reply_renderer_formats_project_delete_pending_and_success() -> None: + renderer = ReplyRenderer( + state_waiting_manual="waiting_manual", + state_failed="failed", + receipt_phase_action="action", + receipt_phase_pending="pending", + receipt_phase_error="error", + ) + intent = CanonicalIntent( + name="project.delete", + confidence=0.96, + project_id=None, + signal_title="", + signal_summary="", + metadata={"llm_parameters": {"project_id": 12, "project_title": "重复项目"}}, + ) + + pending = renderer.render( + work_state="waiting_manual", + intent=intent, + decision_record={"approval_requirement": {"message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。"}}, + execution_result={ + "status": "waiting_manual", + "action_contract": { + "operation_code": "project.delete", + "params": {"project_id": 12, "project_title": "重复项目"}, + }, + }, + ) + assert pending["template_key"] == "pending_manual" + assert "重复项目" in pending["text"] + assert "人工确认" in pending["text"] + + success = renderer.render( + work_state="succeeded", + intent=intent, + decision_record={"approval_requirement": {}}, + execution_result={"status": "succeeded", "operation_code": "project.delete", "project_id": 12}, + ) + assert success["template_key"] == "action_succeeded" + assert "已删除项目#12《重复项目》" in success["text"] + + +@pytest.mark.asyncio +async def test_finalize_waiting_manual_passes_execution_result_to_renderer(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeReviewService: + async def review_work_item(self, **kwargs): + _ = kwargs + return {"verified": False, "issues": ["waiting_manual"], "next_step": "请确认"} + + class FakeRenderer: + def render(self, **kwargs): + captured["render_kwargs"] = kwargs + return { + "receipt_phase": "pending", + "template_key": "pending_manual", + "payload": {"intent": kwargs["intent"].name}, + "text": "ok", + } + + class FakeKernel(mixin_prepare_manual.KernelIngressFlowPrepareManualMixin): + def __init__(self) -> None: + class _FakeDB: + async def commit(self): + return None + + self.db = _FakeDB() + self.tenant_id = 7 + self.review_service = FakeReviewService() + self.reply_renderer = FakeRenderer() + + async def _maybe_emit_risk_cadence_snapshot(self, **kwargs): + _ = kwargs + + async def _claim_reply_receipt(self, **kwargs): + _ = kwargs + return True + + async def _write_processing_log(self, **kwargs): + _ = kwargs + + async def _update_session(self, **kwargs): + _ = kwargs + + async def _upsert_operation_execution_ledger(self, **kwargs): + _ = kwargs + return None + + def _extract_execution_operation_code(self, execution_result): + if not isinstance(execution_result, dict): + return None + action_contract = execution_result.get("action_contract") if isinstance(execution_result.get("action_contract"), dict) else {} + if isinstance(action_contract, dict): + operation_code = str(action_contract.get("operation_code") or "").strip().lower() + if operation_code: + return operation_code + operation_code = str(execution_result.get("operation_code") or "").strip().lower() + return operation_code or None + + def _normalize_execution_ledger_status(self, *, work_item_state, execution_status): + _ = execution_status + return "pending_manual" if str(work_item_state or "").strip().lower() == "waiting_manual" else "processing" + + async def _project_waiting_manual(self, **kwargs): + captured["project_waiting_manual"] = kwargs + + def _apply_execution_action_contract(self, **kwargs): + execution_result = dict(kwargs["execution_result"]) + execution_result["action_contract"] = { + "operation_code": "project.delete", + "params": {"project_id": 12, "project_title": "重复项目"}, + } + return execution_result + + def _build_capture_result(self, *, work_item, intent, execution_result, render, direct_reply_required, status, classification): + _ = (work_item, intent, execution_result, render, direct_reply_required, classification) + return {"status": status, "render": render} + + kernel = FakeKernel() + work_item = SimpleNamespace( + state=None, + action_bundle=None, + execution_result=None, + review_summary=None, + reply_template_key=None, + id=99, + project_id=12, + signal_id=None, + intent="project.delete", + source_message_id="msg-1", + draft_version=None, + draft_expires_at=None, + ) + session = SimpleNamespace() + ingest_event = SimpleNamespace() + decision_payload = { + "policy": {"approval_requirement": {"message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。"}}, + "execution_plan": {"actions": []}, + "simulation": {}, + } + workflow_route = SimpleNamespace(key="project.delete", action="delete") + intent = CanonicalIntent( + name="project.delete", + confidence=0.94, + project_id=12, + signal_title="", + signal_summary="", + metadata={"llm_parameters": {"project_id": 12, "project_title": "重复项目"}}, + ) + + result = await kernel._finalize_waiting_manual_ingress( + work_item=work_item, + session=session, + intent=intent, + world_model={}, + decision_payload=decision_payload, + workflow_route=workflow_route, + decision_trace={"reason": "test"}, + source_system="feishu", + source_message_id="msg-1", + message_text="删除重复项目", + sender="tester", + ingest_event=ingest_event, + started=0.0, + ) + + assert result["status"] == "captured" + render_kwargs = captured["render_kwargs"] + assert isinstance(render_kwargs, dict) + assert render_kwargs["execution_result"]["action_contract"]["operation_code"] == "project.delete" + assert render_kwargs["execution_result"]["action_contract"]["params"]["project_title"] == "重复项目" + + +def test_gate_execution_ignores_low_confidence_but_keeps_risk_review() -> None: + low_confidence = gate_execution(confidence=0.02, risk_level="medium") + assert low_confidence.action == "auto_execute" + assert low_confidence.auto_execute is True + assert low_confidence.gate_code == "auto_execute_allowed" + assert "置信度" not in low_confidence.reason + + high_risk = gate_execution(confidence=0.02, risk_level="high") + assert high_risk.action == "auto_execute" + assert high_risk.auto_execute is True + assert high_risk.gate_code == "auto_execute_allowed" + assert "人工确认" not in high_risk.reason + + @pytest.mark.asyncio async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> None: captured: dict[str, object] = {} @@ -249,3 +521,501 @@ async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> N assert answer_kwargs["sync_requirement"] is True assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" + + +@pytest.mark.asyncio +async def test_execute_query_intent_feishu_fallback_uses_thread_key(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": "fallback-answer", "session_id": 7} + + 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={"thread_context": {"root_message_id": "root-1"}}) + result = await executor._execute_query_intent( + intent=intent, + message_text="最近AI行业有什么动态", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-feishu-1", + ) + + 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"] is None + assert answer_kwargs["conversation_key"] == build_feishu_thread_key( + source_chat_id=None, + source_sender_open_id=None, + sender=None, + source_message_id="msg-feishu-1", + thread_context={"root_message_id": "root-1"}, + ) + + +@pytest.mark.asyncio +async def test_low_tier_rag_failure_prefers_glm5_openclaw_failover(monkeypatch) -> None: + captured: dict[str, object] = {"logs": []} + + monkeypatch.setattr( + mixin_tier_escalation, + "_runtime_log", + lambda scope, message: captured["logs"].append((scope, message)), + ) + + low_engine = { + "id": "low-1", + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "api_key": "low-key", + } + high_engine = { + "id": "high-1", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.openai.com/v1", + "api_key": "high-key", + } + bundle = { + "settings": {}, + "active_engine_id": "low-1", + "engines": [low_engine, high_engine], + } + openclaw_bundle = { + "rules": [ + { + "name": "engine_high_reasoning_glm5", + "enabled": True, + "priority": 220, + "tier": "high", + "provider": "openai", + "model": "GLM-5", + "api_env": "OPENAI_API_KEY", + "match_any": ["adaptive_rag_answer"], + } + ] + } + + class FakeLLM(LLMTierEscalationMixin): + def _low_to_high_escalation_enabled(self) -> bool: + return True + + def _is_low_tier_function(self, function_name: str) -> bool: + return function_name == "adaptive_rag_answer_normal" + + @staticmethod + def _rule_matches_function(rule, function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + if bool(rule.get("match_all", False)): + return True + exact = str(rule.get("function_exact") or "").strip().lower() + if exact and fn == exact: + return True + prefix = str(rule.get("function_prefix") or "").strip().lower() + if prefix and fn.startswith(prefix): + return True + match_any = rule.get("match_any") + if isinstance(match_any, list): + for hint in match_any: + token = str(hint or "").strip().lower() + if token and token in fn: + return True + return False + + async def _get_or_init_bundle(self, db, *, tenant_id: int): + _ = (db, tenant_id) + return SimpleNamespace(), bundle + + def _resolve_active_engine_from_bundle(self, bundle_value): + _ = bundle_value + return dict(low_engine) + + def _load_openclaw_routing_bundle(self): + return dict(openclaw_bundle) + + def _resolve_engine_from_rule(self, *, rule, engines, active_engine): + _ = active_engine + for item in engines: + if ( + str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() + and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() + ): + return dict(item) + return None + + def _ensure_openclaw_control_plane_engine(self, engine): + return dict(engine) + + def _normalize_engine_settings(self, raw): + _ = raw + return {} + + def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): + _ = (function_name, settings, engine_label) + return dict(engine) + + def _is_invalid_structured_output(self, *, function_name, response_text): + _ = (function_name, response_text) + return False + + async def _chat_completion(self, **kwargs): + captured["chat_kwargs"] = kwargs + return "GLM-5 answer" + + llm = FakeLLM() + text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( + db=object(), + tenant_id=1, + function_name="adaptive_rag_answer_normal", + failed_engine=dict(low_engine), + prompt="请回答一个问题", + system_prompt="system prompt", + temperature=0.2, + max_tokens=512, + response_mode="complete", + error=ValueError("LLM 返回空内容"), + ) + + assert text == "GLM-5 answer" + assert isinstance(resolved_engine, dict) + assert resolved_engine["provider"] == "openai" + assert resolved_engine["model"] == "GLM-5" + assert captured["chat_kwargs"]["model"] == "GLM-5" + log_text = "\n".join(message for _, message in captured["logs"]) + assert "OpenClaw高阶升级命中" in log_text + assert "高阶升级成功" in log_text + + +@pytest.mark.asyncio +async def test_low_tier_intent_failure_forces_glm5_retry_even_when_generic_high_tier_differs(monkeypatch) -> None: + captured: dict[str, object] = {"logs": []} + + monkeypatch.setattr( + mixin_tier_escalation, + "_runtime_log", + lambda scope, message: captured["logs"].append((scope, message)), + ) + + low_engine = { + "id": "low-1", + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "api_key": "low-key", + } + mini_engine = { + "id": "high-mini-1", + "provider": "openai", + "model": "MiniMax-M2.5", + "base_url": "https://api.openai.com/v1", + "api_key": "mini-key", + } + glm5_engine = { + "id": "high-glm5-1", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.openai.com/v1", + "api_key": "glm5-key", + } + bundle = { + "settings": {}, + "active_engine_id": "low-1", + "engines": [low_engine, mini_engine, glm5_engine], + } + openclaw_bundle = { + "rules": [ + { + "name": "engine_low_intent_kernel_exact", + "enabled": True, + "priority": 310, + "tier": "low", + "provider": "glm", + "model": "qwen-plus", + "api_env": "DASHSCOPE_API_KEY", + "function_exact": "classify_intent_ai_pmo_kernel", + } + ] + } + + class FakeLLM(LLMTierEscalationMixin): + def _low_to_high_escalation_enabled(self) -> bool: + return True + + def _is_low_tier_function(self, function_name: str) -> bool: + return function_name == "classify_intent_ai_pmo_kernel" + + @staticmethod + def _rule_matches_function(rule, function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + if bool(rule.get("match_all", False)): + return True + exact = str(rule.get("function_exact") or "").strip().lower() + if exact and fn == exact: + return True + prefix = str(rule.get("function_prefix") or "").strip().lower() + if prefix and fn.startswith(prefix): + return True + match_any = rule.get("match_any") + if isinstance(match_any, list): + for hint in match_any: + token = str(hint or "").strip().lower() + if token and token in fn: + return True + return False + + async def _get_or_init_bundle(self, db, *, tenant_id: int): + _ = (db, tenant_id) + return SimpleNamespace(), bundle + + def _resolve_active_engine_from_bundle(self, bundle_value): + _ = bundle_value + return dict(low_engine) + + def _load_openclaw_routing_bundle(self): + return dict(openclaw_bundle) + + def _resolve_engine_from_rule(self, *, rule, engines, active_engine): + _ = active_engine + for item in engines: + if ( + str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() + and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() + ): + return dict(item) + return None + + def _resolve_tier_engine(self, *, tier, engines, active_engine): + _ = (engines, active_engine) + if str(tier).strip().lower() == "high": + return dict(mini_engine) + return dict(low_engine) + + def _ensure_openclaw_control_plane_engine(self, engine): + return dict(engine) + + def _normalize_engine_settings(self, raw): + _ = raw + return {} + + def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): + _ = (function_name, settings, engine_label) + return dict(engine) + + def _is_invalid_structured_output(self, *, function_name, response_text): + _ = (function_name, response_text) + return False + + async def _chat_completion(self, **kwargs): + captured["chat_kwargs"] = kwargs + return "GLM-5 answer" + + llm = FakeLLM() + text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( + db=object(), + tenant_id=1, + function_name="classify_intent_ai_pmo_kernel", + failed_engine=dict(low_engine), + prompt="请返回结构化意图", + system_prompt="system prompt", + temperature=0.2, + max_tokens=512, + response_mode="complete", + error=ValueError("LLM 返回空内容"), + ) + + assert text == "GLM-5 answer" + assert isinstance(resolved_engine, dict) + assert resolved_engine["model"] == "GLM-5" + assert captured["chat_kwargs"]["model"] == "GLM-5" + log_text = "\n".join(message for _, message in captured["logs"]) + assert "GLM-5强制高阶升级命中" in log_text or "OpenClaw高阶升级命中" in log_text + assert "高阶升级成功" in log_text + + +@pytest.mark.asyncio +async def test_low_tier_structured_failure_forces_glm5_retry(monkeypatch) -> None: + captured: dict[str, object] = {"logs": []} + + monkeypatch.setattr( + mixin_tier_escalation, + "_runtime_log", + lambda scope, message: captured["logs"].append((scope, message)), + ) + + low_engine = { + "id": "low-1", + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://open.bigmodel.cn/api/paas/v4", + "api_key": "low-key", + } + mini_engine = { + "id": "high-mini-1", + "provider": "openai", + "model": "MiniMax-M2.5", + "base_url": "https://api.openai.com/v1", + "api_key": "mini-key", + } + glm5_engine = { + "id": "high-glm5-1", + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.openai.com/v1", + "api_key": "glm5-key", + } + bundle = { + "settings": {}, + "active_engine_id": "low-1", + "engines": [low_engine, mini_engine, glm5_engine], + } + openclaw_bundle = { + "rules": [ + { + "name": "engine_low_intent_kernel_exact", + "enabled": True, + "priority": 310, + "tier": "low", + "provider": "glm", + "model": "qwen-plus", + "api_env": "DASHSCOPE_API_KEY", + "function_exact": "classify_intent_ai_pmo_kernel", + } + ] + } + + class FakeLLM(LLMTierEscalationMixin): + def _low_to_high_escalation_enabled(self) -> bool: + return True + + def _is_low_tier_function(self, function_name: str) -> bool: + return function_name == "classify_intent_ai_pmo_kernel" + + @staticmethod + def _rule_matches_function(rule, function_name: str) -> bool: + fn = str(function_name or "").strip().lower() + if not fn: + return False + if bool(rule.get("match_all", False)): + return True + exact = str(rule.get("function_exact") or "").strip().lower() + if exact and fn == exact: + return True + prefix = str(rule.get("function_prefix") or "").strip().lower() + if prefix and fn.startswith(prefix): + return True + match_any = rule.get("match_any") + if isinstance(match_any, list): + for hint in match_any: + token = str(hint or "").strip().lower() + if token and token in fn: + return True + return False + + async def _get_or_init_bundle(self, db, *, tenant_id: int): + _ = (db, tenant_id) + return SimpleNamespace(), bundle + + def _resolve_active_engine_from_bundle(self, bundle_value): + _ = bundle_value + return dict(low_engine) + + def _load_openclaw_routing_bundle(self): + return dict(openclaw_bundle) + + def _resolve_engine_from_rule(self, *, rule, engines, active_engine): + _ = active_engine + for item in engines: + if ( + str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() + and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() + ): + return dict(item) + return None + + def _resolve_tier_engine(self, *, tier, engines, active_engine): + _ = (engines, active_engine) + if str(tier).strip().lower() == "high": + return dict(mini_engine) + return dict(low_engine) + + def _ensure_openclaw_control_plane_engine(self, engine): + return dict(engine) + + def _normalize_engine_settings(self, raw): + _ = raw + return {} + + def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): + _ = (function_name, settings, engine_label) + return dict(engine) + + def _is_invalid_structured_output(self, *, function_name, response_text): + _ = (function_name, response_text) + return False + + def _extract_structured_object_from_response( + self, + *, + function_name, + response, + fallback_text, + provider, + model, + ): + _ = (function_name, response, fallback_text) + return StructuredExtractionResult( + payload={"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}, + source_field="generated_text", + provider=provider, + model=model, + raw_text='{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}', + validation="ok", + ) + + async def _chat_completion_raw(self, **kwargs): + captured["chat_kwargs"] = kwargs + raw_text = '{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}' + return ChatCompletionResult(text=raw_text, raw_response=raw_text) + + llm = FakeLLM() + result, resolved_engine = await llm._retry_structured_with_high_tier_after_low_model_failure( + db=object(), + tenant_id=1, + function_name="classify_intent_ai_pmo_kernel", + failed_engine=dict(low_engine), + prompt="请返回结构化意图", + system_prompt="system prompt", + temperature=0.2, + max_tokens=512, + response_mode="complete", + error=ValueError("LLM 返回空内容"), + ) + + assert getattr(result, "payload", None) is not None + assert result.payload["confidence"] == pytest.approx(0.91) + assert result.model == "GLM-5" + assert isinstance(resolved_engine, dict) + assert resolved_engine["model"] == "GLM-5" + assert captured["chat_kwargs"]["model"] == "GLM-5" + log_text = "\n".join(message for _, message in captured["logs"]) + assert "GLM-5强制高阶升级命中" in log_text or "OpenClaw高阶升级命中" in log_text + assert "高阶结构化升级成功" in log_text diff --git a/backend/tests/test_ai_pmo_signal_source_onboard_contract.py b/backend/tests/test_ai_pmo_signal_source_onboard_contract.py new file mode 100644 index 00000000..ebf40b18 --- /dev/null +++ b/backend/tests/test_ai_pmo_signal_source_onboard_contract.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import pytest + +from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_signal_source import ( + ExecuteSignalSourceMixin, +) + + +def test_signal_source_onboard_idempotency_key_prefers_message_key() -> None: + key_one = ExecuteSignalSourceMixin._build_signal_source_onboard_idempotency_key( + source_system="feishu", + source_message_id="msg-123", + ) + key_two = ExecuteSignalSourceMixin._build_signal_source_onboard_idempotency_key( + source_system="feishu", + source_message_id="msg-123", + ) + bare_key = ExecuteSignalSourceMixin._build_signal_source_onboard_idempotency_key( + source_system="", + source_message_id="msg-123", + ) + + assert key_one == "feishu:msg-123" + assert key_two == "feishu:msg-123" + assert bare_key == "msg-123" + + +@pytest.mark.asyncio +async def test_signal_source_onboard_replays_existing_ledger_without_reexecution() -> None: + class _FakeLedger: + def __init__(self) -> None: + self.status = "applied" + self.result_payload = { + "status": "succeeded", + "signal_id": 88, + "project_id": 11, + "signal_source_workflow": { + "status": "completed", + "run_status": "completed", + }, + "signal_source_orchestration": { + "signal_source": { + "registry_id": 501, + "source_name": "Webhook A", + } + }, + "auto_tasks": [], + } + + class _FakeWorkflow: + def __init__(self) -> None: + self.called = False + + async def run(self, **kwargs): + self.called = True + raise AssertionError("signal_source_workflow.run should not be called on replay") + + class _FakeDB: + def __init__(self) -> None: + self.committed = False + + async def commit(self): + self.committed = True + + class _FakeExecutor(ExecuteSignalSourceMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.signal_source_workflow = _FakeWorkflow() + self.loaded_key = None + + async def _load_signal_source_onboard_ledger(self, *, idempotency_key): + self.loaded_key = idempotency_key + return _FakeLedger() + + async def _claim_signal_source_onboard_ledger(self, **kwargs): + raise AssertionError("ledger claim should not be reached when replaying an applied result") + + def _build_signal_source_semantic_payload(self, **kwargs): + raise AssertionError("semantic payload should not be rebuilt on replay") + + async def _create_signal(self, **kwargs): + raise AssertionError("signal creation should not be reached on replay") + + @staticmethod + def _normalize_title_candidate(value): + return str(value or "").strip() + + @staticmethod + def _is_acceptable_title(text): + return bool(text) + + @staticmethod + def _extract_auto_tasks(openclaw_payload): + return [] + + kernel = _FakeExecutor() + result = await kernel._execute_signal_source_onboard_intent( + intent=type("Intent", (), {"project_id": 11, "signal_summary": "摘要", "signal_title": "标题"})(), + message_text="请接入这个信号源", + operator_user_id=9, + source_system="feishu", + source_message_id="msg-abc", + channel_ref="oc-1", + ) + + assert result["status"] == "succeeded" + assert result["signal_id"] == 88 + assert kernel.loaded_key == "feishu:msg-abc" + assert kernel.db.committed is False + assert kernel.signal_source_workflow.called is False diff --git a/backend/tests/test_ai_prompt_registry_contract.py b/backend/tests/test_ai_prompt_registry_contract.py new file mode 100644 index 00000000..e143c7ef --- /dev/null +++ b/backend/tests/test_ai_prompt_registry_contract.py @@ -0,0 +1,58 @@ +from app.config.ai_prompt_registry import ( + PROMPT_REGISTRY_MAP, + get_prompt_registry, + get_prompt_registry_entry, + iter_prompt_registry_keys, +) +from app.config.ai_prompts_catalog import __all__ as PROMPT_CATALOG_EXPORTS + + +def test_prompt_registry_covers_all_catalog_exports() -> None: + catalog_keys = set(PROMPT_CATALOG_EXPORTS) + registry_keys = set(iter_prompt_registry_keys()) + + assert registry_keys == catalog_keys + assert set(PROMPT_REGISTRY_MAP) == catalog_keys + + for key in catalog_keys: + entry = get_prompt_registry_entry(key) + assert entry is not None + assert entry["prompt_key"] == key + + +def test_prompt_registry_returns_detached_copies() -> None: + registry = get_prompt_registry() + original_key = registry[0]["prompt_key"] + + registry[0]["prompt_key"] = "BROKEN_KEY" + + assert PROMPT_REGISTRY_MAP[original_key]["prompt_key"] == original_key + + +def test_high_frequency_registry_entries_have_complete_metadata() -> None: + required_keys = ( + "PROMPT_SIGNAL_PLANNER", + "PROMPT_GENERATE_CHARTER", + "PROMPT_DECOMPOSE_TASKS", + "PROMPT_PROJECT_STATUS_QA", + "PROMPT_MEMBER_WORKLOAD_QA", + "PROMPT_AI_PMO_QA", + "PROMPT_EXTERNAL_TREND_QA", + "PROMPT_INDUSTRY_TREND_QA", + ) + + for prompt_key in required_keys: + entry = get_prompt_registry_entry(prompt_key) + assert entry is not None + assert entry["module"] + assert entry["business_scenario"] + assert entry["input_contract"] + assert entry["output_contract"] + assert isinstance(entry["call_sites"], tuple) + assert entry["call_sites"], prompt_key + + signal_planner_entry = get_prompt_registry_entry("PROMPT_SIGNAL_PLANNER") + assert signal_planner_entry is not None + assert "charter_json" in signal_planner_entry["input_contract"] + assert any("mixin_prompt.py" in site for site in signal_planner_entry["call_sites"]) + assert any("mixin_analyze.py" in site for site in signal_planner_entry["call_sites"]) diff --git a/backend/tests/test_ai_service_core_runtime.py b/backend/tests/test_ai_service_core_runtime.py index ab969cd2..50cc39b1 100644 --- a/backend/tests/test_ai_service_core_runtime.py +++ b/backend/tests/test_ai_service_core_runtime.py @@ -1,7 +1,16 @@ import pytest +from pathlib import Path +import time +import httpx + +from app.api.llm_providers import _build_provider_payload +from app.core.startup import model_probe +from app.services.llm_service import mixin_chat_openai from app.services.ai_service_core import AIService +from app.services.llm_service import mixin_cache_circuit from app.services.llm_service import llm_service +from app.services.model.model_config import load_model_config @pytest.mark.asyncio @@ -67,3 +76,232 @@ async def test_stream_chat_yields_single_chunk_from_invoke(monkeypatch) -> None: chunks.append(chunk) assert chunks == ["stream-result"] + + +def test_model_config_bundle_centralizes_provider_and_tier_defaults() -> None: + bundle = load_model_config() + provider_defaults = bundle.get("provider_defaults") + tier_defaults = bundle.get("tier_defaults") + + assert provider_defaults["glm"]["model"] == "qwen-plus" + assert provider_defaults["openai"]["model"] == "MiniMax-M2.5" + assert tier_defaults["low"]["model"] == "qwen-plus" + assert tier_defaults["high"]["model"] == "GLM-5" + + +def test_provider_payload_reads_canonical_model_config(monkeypatch) -> None: + monkeypatch.setenv("DASHSCOPE_MODEL", "wrong-value") + monkeypatch.setenv("OPENAI_MODEL", "wrong-high-value") + + payload = _build_provider_payload() + + assert payload["low"] == "qwen-plus" + assert payload["high"] == "GLM-5" + assert payload["base_url"] == "https://api.edgefn.net/v1" + assert payload["providers"]["glm"]["model"] == "qwen-plus" + assert payload["providers"]["glm"]["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" + assert payload["providers"]["openai"]["model"] == "MiniMax-M2.5" + + +@pytest.mark.asyncio +async def test_ai_service_runtime_prefers_canonical_config_over_env(monkeypatch) -> None: + service = AIService() + monkeypatch.setenv("OPENCLAW_ROUTING_ENABLED", "false") + monkeypatch.setenv("LLM_DIRECT_EMERGENCY_ENABLED", "true") + monkeypatch.setenv("LLM_PROVIDER", "glm") + monkeypatch.setenv("DASHSCOPE_MODEL", "wrong-value") + monkeypatch.setenv("DASHSCOPE_BASE_URL", "https://example.invalid") + + runtime = service._resolve_llm_runtime(api_key=None, model=None, base_url=None) + + assert runtime["model"] == "qwen-plus" + assert runtime["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" + + +def test_openclaw_rule_keeps_existing_api_key_when_env_missing(monkeypatch) -> None: + monkeypatch.delenv("DASHSCOPE_API_KEY", raising=False) + + rule = { + "tier": "low", + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_env": "DASHSCOPE_API_KEY", + } + engines = [ + { + "id": "low-1", + "name": "Qwen 低阶引擎", + "provider": "glm", + "model": "GLM-4.7", + "api_key": "db-key", + "base_url": "https://api.edgefn.net/v1", + } + ] + active_engine = { + "id": "high-1", + "name": "OpenAI 高阶引擎", + "provider": "openai", + "model": "GLM-5", + "api_key": "high-key", + "base_url": "https://api.edgefn.net/v1", + } + + routed = llm_service._resolve_engine_from_rule( + rule=rule, + engines=engines, + active_engine=active_engine, + ) + + assert routed is not None + assert routed["provider"] == "glm" + assert routed["model"] == "qwen-plus" + assert routed["base_url"] == "https://dashscope.aliyuncs.com/compatible-mode/v1" + assert routed["api_key"] == "db-key" + + +def test_provider_circuit_state_clears_when_model_config_changes(monkeypatch, tmp_path) -> None: + circuit_key = "glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" + original_state = dict(llm_service._provider_circuit_state) + original_signature = llm_service._provider_circuit_config_signature + try: + llm_service._provider_circuit_state[circuit_key] = { + "consecutive_failures": 2, + "open_until": time.monotonic() + 300.0, + "last_error": "stale", + "updated_at_monotonic": time.monotonic(), + } + llm_service._provider_circuit_config_signature = "stale-signature" + + config_path = tmp_path / "pmflow-routing-rules.json" + config_path.write_text("{}", encoding="utf-8") + monkeypatch.setattr( + mixin_cache_circuit, + "load_model_config", + lambda refresh=False: { + "config_path": str(config_path), + "version": "v9", + "updated_at": "2026-03-25T12:06:00+08:00", + }, + ) + + assert llm_service._is_provider_circuit_open(circuit_key) is False + assert circuit_key not in llm_service._provider_circuit_state + finally: + llm_service._provider_circuit_state.clear() + llm_service._provider_circuit_state.update(original_state) + llm_service._provider_circuit_config_signature = original_signature + + +@pytest.mark.asyncio +async def test_startup_model_probe_does_not_poison_provider_circuit(monkeypatch) -> None: + class DummySession: + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + original_state = dict(llm_service._provider_circuit_state) + original_signature = llm_service._provider_circuit_config_signature + try: + llm_service._provider_circuit_state["glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus"] = { + "consecutive_failures": 2, + "open_until": time.monotonic() + 300.0, + "last_error": "stale", + "updated_at_monotonic": time.monotonic(), + } + + async def fake_resolve_engine_for_call(db, *, tenant_id: int, function_name: str): + _ = (db, tenant_id) + if function_name == "classify_intent_ai_pmo_kernel": + return { + "provider": "glm", + "model": "qwen-plus", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "api_key": "db-key", + } + return { + "provider": "openai", + "model": "GLM-5", + "base_url": "https://api.edgefn.net/v1", + "api_key": "high-key", + } + + async def fake_chat_completion(*args, **kwargs): + raise ValueError("boom") + + monkeypatch.setattr(model_probe, "AsyncSessionLocal", lambda: DummySession()) + monkeypatch.setattr(llm_service, "_resolve_engine_for_call", fake_resolve_engine_for_call) + monkeypatch.setattr(llm_service, "_chat_completion", fake_chat_completion) + + await model_probe.probe_model_connectivity_on_startup() + + assert "glm|https://dashscope.aliyuncs.com/compatible-mode/v1|qwen-plus" in llm_service._provider_circuit_state + finally: + llm_service._provider_circuit_state.clear() + llm_service._provider_circuit_state.update(original_state) + llm_service._provider_circuit_config_signature = original_signature + + +@pytest.mark.asyncio +async def test_openai_compatible_chat_retries_once_on_connect_error(monkeypatch) -> None: + calls = {"count": 0} + + class FakeResponse: + status_code = 200 + + def json(self): + return { + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "ok", "reasoning_content": ""}, + "finish_reason": "stop", + } + ] + } + + class FakeClient: + def __init__(self, *args, **kwargs): + _ = (args, kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + async def post(self, *args, **kwargs): + _ = (args, kwargs) + calls["count"] += 1 + if calls["count"] == 1: + raise httpx.ConnectError("boom", request=httpx.Request("POST", "https://example.com")) + return FakeResponse() + + async def fake_sleep(_seconds: float): + return None + + monkeypatch.setattr(mixin_chat_openai.httpx, "AsyncClient", FakeClient) + monkeypatch.setattr(mixin_chat_openai.asyncio, "sleep", fake_sleep) + + result = await llm_service._chat_completion_via_openai_compatible( + key="test-key", + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", + model="qwen-plus", + temperature=0.0, + max_tokens=16, + messages=[{"role": "user", "content": "ping"}], + request_timeout=1.0, + structured_output=False, + function_label="unit_test_connect_retry", + provider_name="glm", + is_openclaw_gateway_request=False, + openclaw_agents_timeout_seconds=120, + request_started_at_perf=time.perf_counter(), + request_trace_id="trace-1", + openclaw_agent_timeout=None, + ) + + assert calls["count"] == 2 + assert result.text == "ok" diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index 24609902..26eaab1e 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -1,6 +1,17 @@ +import pytest + +from app.config.ai_prompts import ( + PROMPT_EXTERNAL_TREND_QA, + PROMPT_FEISHU_DIRECT_QA, + PROMPT_FEISHU_MIXED_QA, + PROMPT_PROJECT_STATUS_QA, +) from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin +from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import ( + FeishuReplyLLMCoreMixin, +) def test_parse_action_confirm() -> None: @@ -27,6 +38,96 @@ def test_parse_action_yes_as_confirm() -> None: assert note == "" +def test_draft_confirm_handler_thread_anchor_match() -> None: + ingest = type( + "Ingest", + (), + { + "payload": { + "thread_id": "th_1", + "root_message_id": "root_1", + "reply_to_message_id": "reply_1", + }, + "source_message_id": "src_1", + }, + )() + work_item = type("WorkItem", (), {"source_message_id": "wi_1"})() + + assert DraftConfirmHandler._matches_thread_anchor( + work_item=work_item, + ingest=ingest, + anchor_context={"reply_to_message_id": "reply_1"}, + ) is True + assert DraftConfirmHandler._matches_thread_anchor( + work_item=work_item, + ingest=ingest, + anchor_context={"reply_to_message_id": "missing"}, + ) is False + assert DraftConfirmHandler._normalize_thread_context( + {"thread_id": "th_1", "root_message_id": "root_1", "reply_to_message_id": "reply_1"} + ) == { + "thread_id": "th_1", + "root_message_id": "root_1", + "reply_to_message_id": "reply_1", + } + + +def test_draft_confirm_handler_thread_anchor_matches_draft_reply_message_id() -> None: + ingest = type( + "Ingest", + (), + { + "payload": { + "thread_id": "th_1", + "root_message_id": "draft_msg_1", + "reply_to_message_id": "draft_msg_1", + }, + "source_message_id": "original_msg_1", + }, + )() + work_item = type("WorkItem", (), {"source_message_id": "original_msg_1"})() + + assert DraftConfirmHandler._matches_thread_anchor( + work_item=work_item, + ingest=ingest, + anchor_context={"root_message_id": "draft_msg_1"}, + draft_reply_message_id="draft_msg_1", + ) is True + + +def test_reply_outbox_extract_sent_message_id() -> None: + from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin + + assert FeishuReplyOutboxMixin._extract_sent_message_id({"data": {"message_id": "msg_123"}}) == "msg_123" + assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456" + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return None + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + + result = await handler.handle_text_reply( + message_text="创建时间是多少?", + sender="林宇航", + source_system="feishu", + source_message_id="msg-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context={"reply_to_message_id": "reply-1"}, + ) + + assert result["handled"] is False + assert result["status"] == "ignored" + assert result["followup_reply"] == "" + + def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: assert callable(mixin_auto._safe_int) assert mixin_auto._safe_int("123") == 123 @@ -88,3 +189,159 @@ def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None: ) assert scene_key == "ai_pmo" assert "帮我总结这个项目" in prompt + + +def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="如果 AI 助手第一次上线只允许一种提醒,你会把它做成桌面通知还是飞书消息?为什么?", + project_id=None, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + assert scene_key == "decision_choice" + assert "明确选择" in prompt + assert "飞书消息" in prompt + + +def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="帮我看看宠物行业最新动态", + project_id=None, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={"assistant_profile": {"name": "PMFlow"}}, + user_snapshot={"projects": [{"id": 1}]}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + assert scene_key == "industry_trend" + assert "当前未接入实时数据" in prompt + assert "project_context" not in prompt + + +def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="杨宇现在负载情况如何?", + project_id=None, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text=( + "[1] 来源: project\n相关度: 0.991\n内容: Enterprise Knowledge Base 项目列表\n" + "[2] 来源: workload\n相关度: 0.884\n内容: 杨宇 当前负载 265.56% 超载\n" + ), + system_snapshot={"team_members": []}, + user_snapshot={"projects": [{"id": 1}]}, + system_snapshot_text="{\"team_members\": []}", + user_snapshot_text="{\"projects\": [{\"id\": 1}]}", + relevance_max=0.0, + ) + assert scene_key == "member_workload" + assert "Enterprise Knowledge Base 项目列表" not in prompt + assert "杨宇 当前负载 265.56% 超载" in prompt + assert '"projects"' not in prompt + + +def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_trend() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="帮我看看宠物行业最新动态,重点只说消费侧和产品侧的变化,不要提项目。", + project_id=7, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={"assistant_profile": {"name": "PMFlow"}}, + user_snapshot={"projects": [{"id": 1}]}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + assert scene_key == "industry_trend" + assert "当前未接入实时数据" in prompt + assert "project_context" not in prompt + + +def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_context_explicit() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="这个项目所在行业最新动态怎么样?", + project_id=7, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={"assistant_profile": {"name": "PMFlow"}}, + user_snapshot={"projects": [{"id": 1}]}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + assert scene_key == "external_trend" + assert "项目上下文" in prompt + assert "外部搜索结果" in prompt + + +def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="这个项目最新进展怎么样?", + project_id=7, + asked_via="feishu", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={"assistant_profile": {"name": "PMFlow"}}, + user_snapshot={"projects": [{"id": 1}]}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + query_type="status", + ) + assert scene_key == "project_status" + assert "项目状态分析助手" in prompt + assert "完成率" in prompt + assert "当前阶段" in prompt + + +def test_feishu_qa_prompts_prefer_professional_answer_style() -> None: + assert "先给结论" in PROMPT_FEISHU_DIRECT_QA + assert "信息不足,缺少" in PROMPT_FEISHU_DIRECT_QA + assert "适合飞书消息阅读" in PROMPT_FEISHU_DIRECT_QA + assert "先回答问题部分" in PROMPT_FEISHU_MIXED_QA + assert "[已收到需求]" in PROMPT_FEISHU_MIXED_QA + assert "专业" in PROMPT_FEISHU_MIXED_QA + assert "XXXX年" not in PROMPT_EXTERNAL_TREND_QA + assert "通用行业认知" in PROMPT_EXTERNAL_TREND_QA + assert "项目状态分析助手" in PROMPT_PROJECT_STATUS_QA + + +def test_feishu_reply_llm_fallback_is_failure_oriented() -> None: + reply_text = FeishuReplyLLMCoreMixin._fallback_reply_text( + { + "status": "captured", + "classification": "project", + } + ) + assert "当前回复" in reply_text + assert "已收到需求并完成初步识别" not in reply_text + assert "已入库处理" not in reply_text diff --git a/backend/tests/test_draft_generator_service.py b/backend/tests/test_draft_generator_service.py new file mode 100644 index 00000000..36847ebf --- /dev/null +++ b/backend/tests/test_draft_generator_service.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from app.config.ai_prompts import build_default_role_prompt_bundle +from app.services.ai_service_core import AIService +from app.services.sstc import charter_prefill_utils +from app.services.signal.draft_generator_service import DraftGeneratorService + + +def test_default_role_prompt_bundle_is_pm_reviewer() -> None: + bundle = build_default_role_prompt_bundle() + assert bundle["active_role_key"] == "pm_dispatcher" + role = next((item for item in bundle["roles"] if item.get("role_key") == "pm_dispatcher"), {}) + assert "立项评审" in str(role.get("role_name") or "") + assert "同名或相似项目" in str(role.get("system_prompt") or "") + assert "MVP" in str(role.get("system_prompt") or "") + + +@pytest.mark.asyncio +async def test_generate_and_render_draft_includes_pm_sections(monkeypatch) -> None: + service = DraftGeneratorService(db=object(), tenant_id=1) + captured: dict[str, str] = {} + + async def fake_load_members(*, world_model): + _ = world_model + return [ + {"id": 7, "name": "Alice", "workload_percent": 25.0}, + {"id": 8, "name": "Bob", "workload_percent": 60.0}, + ] + + async def fake_generate_draft_payload(*, prompt): + captured["prompt"] = prompt + return {} + + monkeypatch.setattr(service, "_load_members", fake_load_members) + monkeypatch.setattr(service, "_generate_draft_payload", fake_generate_draft_payload) + + work_item = SimpleNamespace( + draft_version=0, + draft_content={}, + draft_expires_at=None, + confirm_message_id=None, + execution_result={}, + ) + world_model = { + "project": {"title": "企业知识库问答平台", "description": "面向内部知识库的问答能力"}, + "active_projects": [ + { + "id": 51, + "name": "AI劝退助手", + "title": "AI劝退助手", + "description": "项目进展与风险提醒", + "status": "active", + "owner_id": 10, + }, + { + "id": 52, + "name": "企业知识库问答平台 V2", + "title": "企业知识库问答平台 V2", + "description": "旧版知识库问答平台", + "status": "active", + "owner_id": 11, + }, + ], + "recent_signals": [ + {"id": 91, "title": "文档检索性能优化", "signal_type": "新需求", "status": "open"}, + ], + "capacity": [], + } + + generated = await service.generate_and_persist( + work_item=work_item, + intent_name="project.create", + message_text="我想做一个企业知识库问答项目,先帮我评估一下。", + world_model=world_model, + ) + + prompt = captured["prompt"] + assert "现有活跃项目(JSON)" in prompt + assert "候选相似项目(JSON)" in prompt + assert "同名/相似项目列表" in prompt + assert generated["draft"]["similar_projects"] + assert "🔎 同名 / 相似项目:" in generated["draft_text"] + assert "🚀 MVP 建议:" in generated["draft_text"] + assert "📈 前景判断:" in generated["draft_text"] + assert "🧩 需求清单:" in generated["draft_text"] + assert "❓ 还缺的信息:" in generated["draft_text"] + assert generated["draft"]["recommended_assignee_name"] == "Alice" + assert generated["draft"]["recommended_assignee_workload"] == 25.0 + + +@pytest.mark.asyncio +async def test_generate_charter_seed_uses_project_context(monkeypatch) -> None: + captured: dict[str, str] = {} + + async def fake_load_project_context(*, db, tenant_id, project_id): + _ = db, tenant_id, project_id + return { + "id": 7, + "name": "kb-v2", + "title": "企业知识库问答平台", + "description": "内部知识库问答升级", + "status": "active", + "owner_id": 10, + } + + async def fake_load_active_projects_context(*, db, tenant_id, project_id, limit=10): + _ = db, tenant_id, project_id, limit + return [ + { + "id": 51, + "name": "ai-warn", + "title": "AI劝退助手", + "description": "项目进展与风险提醒", + "status": "active", + "owner_id": 11, + } + ] + + async def fake_generate(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, + ) + + 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) + + result = await charter_prefill_utils._generate_charter_seed( + db=object(), + tenant_id=1, + project_id=7, + signal_context="AI 劝退诉求:请升级内部知识库问答能力。", + ) + + prompt = captured["prompt"] + assert "当前项目上下文" in prompt + assert "企业知识库问答平台" in prompt + assert "现有活跃项目(JSON)" in prompt + assert "AI劝退助手" in prompt + assert result["summary"] == "围绕内部知识库问答能力升级" + + +@pytest.mark.asyncio +async def test_generate_charter_from_signal_includes_project_context(monkeypatch) -> None: + service = AIService() + captured: dict[str, object] = {} + + async def fake_invoke(self, prompt, **kwargs): + _ = self + 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, + ) + + monkeypatch.setattr(AIService, "_invoke_with_retry", fake_invoke) + + result = await service.generate_charter_from_signal( + "AI 劝退诉求:请升级内部知识库问答能力。", + tenant_id=1, + db=object(), + project_id=7, + project_context="当前项目上下文:企业知识库问答平台", + active_projects_json='[{"id":51,"title":"AI劝退助手"}]', + ) + + prompt = str(captured["prompt"] or "") + kwargs = captured["kwargs"] if isinstance(captured.get("kwargs"), dict) else {} + assert "当前项目上下文:企业知识库问答平台" in prompt + assert "现有活跃项目(JSON)" in prompt + assert kwargs.get("project_id") == 7 + assert result["summary"] == "围绕内部知识库问答能力升级" diff --git a/backend/tests/test_feishu_card_action_contract.py b/backend/tests/test_feishu_card_action_contract.py index 24a16166..f6c16495 100644 --- a/backend/tests/test_feishu_card_action_contract.py +++ b/backend/tests/test_feishu_card_action_contract.py @@ -99,6 +99,26 @@ def test_build_action_card_for_pending_manual_continue() -> None: assert (actions[0].get("value") or {}).get("work_item_id") == 99 +def test_build_action_card_for_queue_pending_confirm() -> None: + card = FeishuCardActionMixin._build_action_card_from_capture_result( + capture_result={ + "reply_template_key": "queue_pending_confirm", + "results": [{"item_id": 201, "route_status": "queued_for_decision"}], + "direct_reply_text": "需要你确认后继续执行。", + }, + 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} + + @pytest.mark.asyncio async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch) -> None: called: dict[str, object] = {} @@ -154,3 +174,61 @@ async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch assert ingress_kwargs["source_system"] == "ai_pmo_ui" assert called["reply_text"] == "已继续执行。" assert called["reply_chat"] == "oc_continue_1" + + +@pytest.mark.asyncio +async def test_handle_card_action_trigger_queue_continue_dispatches_pipeline(monkeypatch) -> None: + called: dict[str, object] = {} + + class DummyDB: + async def commit(self) -> None: + called["committed"] = True + + async def rollback(self) -> None: + called["rolled_back"] = True + + service = FeishuIntegrationService(db=DummyDB()) + service.tenant_id = 7 + + async def _fake_reply_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", + "open_chat_id": "oc_queue_1", + "operator": {"open_id": "ou_queue", "name": "队列用户"}, + "action": {"value": {"action": "continue", "queue_item_id": 201}}, + } + ) + + assert result["code"] == 0 + 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" diff --git a/backend/tests/test_feishu_long_connection_tls_contract.py b/backend/tests/test_feishu_long_connection_tls_contract.py new file mode 100644 index 00000000..bb1939cb --- /dev/null +++ b/backend/tests/test_feishu_long_connection_tls_contract.py @@ -0,0 +1,264 @@ +import asyncio +import os + +import certifi +import pytest + +from app.services.integrations.feishu_integration_service.mixin_config import FeishuConfigMixin +from app.services.integrations.feishu_long_connection_core.mixin_lifecycle_connection import ( + LongConnectionLifecycleConnectionMixin, +) +from app.services.integrations.feishu_long_connection_core.mixin_lifecycle_control import ( + LongConnectionLifecycleControlMixin, +) +from app.services.integrations.feishu_long_connection_core.mixin_runtime import ( + LongConnectionRuntimeMixin, +) +from app.services.integrations.feishu_long_connection_core.shared import ( + ensure_feishu_tls_trust_bundle, +) + + +@pytest.fixture(autouse=True) +def _clear_tls_env(monkeypatch: pytest.MonkeyPatch) -> None: + for key in ( + "FEISHU_LONG_CONNECTION_SSL_CERT_FILE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + ): + monkeypatch.delenv(key, raising=False) + + +def test_ensure_feishu_tls_trust_bundle_defaults_to_certifi(monkeypatch: pytest.MonkeyPatch) -> None: + bundle = ensure_feishu_tls_trust_bundle() + + assert bundle["bundle_path"] == certifi.where() + assert bundle["source"] == "certifi" + assert os.environ["SSL_CERT_FILE"] == certifi.where() + assert os.environ["REQUESTS_CA_BUNDLE"] == certifi.where() + assert os.environ["CURL_CA_BUNDLE"] == certifi.where() + + +def test_ensure_feishu_tls_trust_bundle_honors_explicit_override(monkeypatch: pytest.MonkeyPatch) -> None: + override_path = "/tmp/custom-feishu-ca.pem" + monkeypatch.setenv("FEISHU_LONG_CONNECTION_SSL_CERT_FILE", override_path) + + bundle = ensure_feishu_tls_trust_bundle() + + assert bundle["bundle_path"] == override_path + assert bundle["source"] == "FEISHU_LONG_CONNECTION_SSL_CERT_FILE" + assert os.environ["SSL_CERT_FILE"] == override_path + assert os.environ["REQUESTS_CA_BUNDLE"] == override_path + assert os.environ["CURL_CA_BUNDLE"] == override_path + + +class _DummyLongConnectionManager(LongConnectionLifecycleConnectionMixin): + def __init__(self) -> None: + self._boot_lock = asyncio.Lock() + self._queue = asyncio.Queue() + self._running = True + self._thread = None + self._client = None + self._sdk_available = True + self._configured = False + self._event_mode = "long_connection" + self._tenant_id = 1 + self._last_error = None + self._last_error_code = None + self._last_error_retryable = False + self._last_received_at = None + self._last_received_message_id = None + self._last_event_at = None + self._last_auth_at = None + self._last_connect_at = None + self._last_disconnect_at = None + self._restart_attempts = 0 + self._next_retry_at = 0.0 + self._stopping = False + self._max_retry_attempts = 3 + self._retry_base_seconds = 1.0 + + async def _load_active_config(self) -> dict[str, object] | None: + return { + "tenant_id": 1, + "app_id": "app-id", + "app_secret": "app-secret", + "encrypt_key": "encrypt-key", + "verification_token": "verification-token", + } + + async def _preflight_auth(self, *, tenant_id: int) -> None: # noqa: ARG002 + return None + + def _start_ws_thread( + self, + app_id: str, + app_secret: str, + *, + encrypt_key: str = "", + verification_token: str = "", + ) -> None: + self.started_with = (app_id, app_secret, encrypt_key, verification_token) + + +class _FakeResult: + def __init__(self, value) -> None: + self._value = value + + def scalar_one_or_none(self): + return self._value + + +class _FakeDB: + def __init__(self, integration) -> None: + self._integration = integration + self.committed = False + + async def execute(self, query): # noqa: ARG002 + return _FakeResult(self._integration) + + async def commit(self) -> None: + self.committed = True + + +@pytest.mark.asyncio +async def test_feishu_config_load_preserves_event_credentials() -> None: + integration = type( + "IntegrationRow", + (), + { + "config": { + "app_id": "app-id", + "app_secret": "app-secret", + "event_mode": "long_connection", + "encrypt_key": "encrypt-key", + "verification_token": "verification-token", + }, + "tenant_id": 1, + }, + )() + + service = FeishuConfigMixin(_FakeDB(integration)) + + await service.load_config(tenant_id=1) + + assert service.app_id == "app-id" + assert service.app_secret == "app-secret" + assert service.encrypt_key == "encrypt-key" + assert service.verification_token == "verification-token" + assert service.event_mode == "long_connection" + + +@pytest.mark.asyncio +async def test_feishu_config_save_preserves_existing_event_credentials() -> None: + integration = type( + "IntegrationRow", + (), + { + "config": { + "app_id": "old-app", + "app_secret": "old-secret", + "event_mode": "long_connection", + "encrypt_key": "existing-encrypt-key", + "verification_token": "existing-verification-token", + }, + "tenant_id": 1, + "is_active": True, + }, + )() + + service = FeishuConfigMixin(_FakeDB(integration)) + + await service.save_config( + app_id="new-app", + app_secret="new-secret", + encrypt_key="", + verification_token="", + event_mode="long_connection", + tenant_id=1, + ) + + assert integration.config["app_id"] == "new-app" + assert integration.config["app_secret"] == "new-secret" + assert integration.config["encrypt_key"] == "existing-encrypt-key" + assert integration.config["verification_token"] == "existing-verification-token" + assert service.encrypt_key == "existing-encrypt-key" + assert service.verification_token == "existing-verification-token" + assert service.app_id == "new-app" + assert service.app_secret == "new-secret" + assert service.event_mode == "long_connection" + assert service.db.committed is True + + +@pytest.mark.asyncio +async def test_ensure_connection_bootstraps_tls_bundle_before_ws_start() -> None: + manager = _DummyLongConnectionManager() + + started = await manager._ensure_connection(reason="startup") + + assert started is True + assert manager.started_with == ( + "app-id", + "app-secret", + "encrypt-key", + "verification-token", + ) + assert os.environ["SSL_CERT_FILE"] == certifi.where() + assert os.environ["REQUESTS_CA_BUNDLE"] == certifi.where() + assert os.environ["CURL_CA_BUNDLE"] == certifi.where() + + +@pytest.mark.asyncio +async def test_long_connection_status_tracks_received_and_processed_times() -> None: + class _RuntimeDummy(LongConnectionRuntimeMixin): + def __init__(self) -> None: + self._running = True + self._loop = asyncio.get_running_loop() + self._queue = asyncio.Queue() + self._tenant_id = 1 + self._last_received_at = None + self._last_received_message_id = None + self._last_event_at = None + + manager = _RuntimeDummy() + manager._loop = asyncio.get_running_loop() + + manager._on_message_event( + { + "header": {"event_type": "im.message.receive_v1"}, + "event": {"message": {"message_id": "msg-123"}}, + } + ) + await asyncio.sleep(0) + + assert manager._last_received_message_id == "msg-123" + assert manager._last_received_at is not None + assert manager._queue.qsize() == 1 + + lifecycle_manager = type("StatusOnly", (), {})() + lifecycle_manager._running = True + lifecycle_manager._tenant_id = 1 + lifecycle_manager._event_mode = "long_connection" + lifecycle_manager._sdk_available = True + lifecycle_manager._configured = True + lifecycle_manager._worker_task = None + lifecycle_manager._supervisor_task = None + lifecycle_manager._thread = None + lifecycle_manager._last_error = None + lifecycle_manager._last_error_code = None + lifecycle_manager._last_error_retryable = False + lifecycle_manager._last_received_at = manager._last_received_at + lifecycle_manager._last_received_message_id = manager._last_received_message_id + lifecycle_manager._last_event_at = None + lifecycle_manager._last_auth_at = None + lifecycle_manager._last_connect_at = None + lifecycle_manager._last_disconnect_at = None + lifecycle_manager._restart_attempts = 0 + lifecycle_manager._next_retry_at = 0.0 + lifecycle_manager._max_retry_attempts = 3 + lifecycle_manager._retry_base_seconds = 1.0 + status = LongConnectionLifecycleControlMixin.status(lifecycle_manager) + assert status["last_received_message_id"] == "msg-123" + assert status["last_received_at"] == manager._last_received_at + assert status["last_event_at"] is None diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py new file mode 100644 index 00000000..829d2fc6 --- /dev/null +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -0,0 +1,736 @@ +from types import SimpleNamespace + +import pytest + +from app.services.integrations.feishu_signal_pipeline.mixin_gate_project import ( + FeishuPipelineGateProjectMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_command_commit_project import ( + FeishuPipelineCommandCommitProjectMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_command_commit_task import ( + FeishuPipelineCommandCommitTaskMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_context_prefill_b import ( + FeishuPipelineContextPrefillBMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_extraction import ( + FeishuPipelineExtractionMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_item_processing import ( + FeishuPipelineItemProcessingMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_followup_a import ( + FeishuPipelineFollowupAMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_followup_b import FeishuPipelineFollowupBMixin +from app.services.integrations.feishu_signal_pipeline.mixin_gate_dispatch import ( + FeishuPipelineGateDispatchMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_item_finalize_routing import ( + FeishuPipelineItemFinalizeRoutingMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_message_utils_b import ( + FeishuPipelineMessageUtilsBMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_process_message_helpers_b import ( + FeishuPipelineProcessMessageHelpersBMixin, +) +from app.services.integrations.feishu_signal_pipeline.mixin_queue_confirm_undo import ( + FeishuPipelineQueueConfirmUndoMixin, +) +from app.services.signal.signal_capture_service.mixin_task_orchestration_main import ( + SignalCaptureTaskOrchestrationMainMixin, +) +from app.services.signal.signal_capture_service.mixin_task_orchestration_skill import ( + SignalCaptureTaskOrchestrationSkillMixin, +) + + +class _Item: + def __init__(self, *, item_id: int, level: str, operation_code: str, raw_message: str = "") -> None: + self.id = item_id + self.level = level + self.card_type = operation_code.replace(".", "_") + self.raw_message = raw_message + self.result_payload = {"operation_code": operation_code} + self.feishu_message_id = f"msg-{item_id}" + + +def test_followup_status_target_requires_unique_match() -> None: + items = [ + _Item(item_id=1, level="command", operation_code="project.create"), + _Item(item_id=2, level="command", operation_code="project.create"), + ] + + target = FeishuPipelineFollowupBMixin._select_followup_status_target( + message="刚才创建项目现在怎么样了", + recent_items=items, + ) + + assert target is None + + +def test_followup_status_target_rejects_single_item_without_anchor() -> None: + items = [ + _Item(item_id=1, level="project", operation_code="project.create"), + ] + + target = FeishuPipelineFollowupBMixin._select_followup_status_target( + message="它现在怎么样?", + recent_items=items, + ) + + assert target is None + + +def test_followup_status_target_prefers_thread_anchor() -> None: + items = [ + _Item(item_id=1, level="project", operation_code="project.create"), + _Item(item_id=2, level="project", operation_code="project.create"), + ] + items[0].feishu_message_id = "root-1" + items[1].feishu_message_id = "root-2" + + target = FeishuPipelineFollowupBMixin._select_followup_status_target( + message="他是什么时候创建的?", + recent_items=items, + thread_context={"root_message_id": "root-1"}, + ) + + assert target is items[0] + + +def test_followup_status_target_rejects_unmatched_thread_anchor() -> None: + items = [ + _Item(item_id=1, level="project", operation_code="project.create"), + _Item(item_id=2, level="project", operation_code="project.create"), + ] + items[0].feishu_message_id = "root-1" + items[1].feishu_message_id = "root-2" + + target = FeishuPipelineFollowupBMixin._select_followup_status_target( + message="他是什么时候创建的?", + recent_items=items, + thread_context={"reply_to_message_id": "reply-miss"}, + ) + + assert target is None + + +@pytest.mark.asyncio +async def test_followup_status_reply_reports_project_created_at(monkeypatch) -> None: + from datetime import datetime + + class _FakeDBResult: + def __init__(self, value) -> None: + self._value = value + + def scalar_one_or_none(self): + return self._value + + class _FakeDB: + async def execute(self, query): + _ = query + return _FakeDBResult(type("ProjectRow", (), {"id": 7, "tenant_id": 1, "title": "自动开票系统", "name": "自动开票系统", "created_at": datetime(2026, 3, 1, 9, 30)})()) + + class _FakeFollowup(FeishuPipelineFollowupBMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + async def _get_project_suggestion(self, item_id: int): + _ = item_id + return type("Suggestion", (), {"created_project_id": 7})() + + @staticmethod + def _normalize_route_status(value): + return str(value) + + item = _Item(item_id=1, level="project", operation_code="project.create") + item.route_status = "auto_done" + item.matched_project_id = 7 + + reply = await _FakeFollowup()._build_followup_status_reply(item, message="他是什么时候创建的?") + + assert "创建于 2026-03-01 09:30" in reply + + +@pytest.mark.asyncio +async def test_followup_queue_handler_ignores_unmatched_thread_anchor() -> None: + class _FakeItem: + def __init__(self) -> None: + self.id = 1 + self.route_status = "queued" + self.level = "project" + self.feishu_message_id = "queue-root-1" + self.blocking_reason = "请确认" + + class _FakeFollowup(FeishuPipelineFollowupAMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + @staticmethod + def _looks_like_continue_followup(message: str) -> bool: + _ = message + return False + + async def _load_recent_chat_items(self, *, source_chat_id, source_sender_open_id, sender, limit=5): + _ = (source_chat_id, source_sender_open_id, sender, limit) + return [_FakeItem()] + + result = await _FakeFollowup()._maybe_handle_queue_followup( + message="他是什么时候创建的?", + sender="测试用户", + source_chat_id="chat-1", + source_sender_open_id="ou-1", + feishu_message_id="msg-1", + process_started_at=0.0, + thread_context={"reply_to_message_id": "reply-miss"}, + ) + + assert result is None + + +def test_decorate_capture_result_adds_queue_action_card_metadata() -> None: + result = FeishuPipelineProcessMessageHelpersBMixin._decorate_capture_result( + { + "status": "captured", + "results": [{"item_id": 301, "route_status": "queued_for_decision"}], + "direct_reply_text": "已转待确认。", + }, + chat_type="group", + normalized_text="请创建项目", + route="group.intent_gate.project_create", + db_mutations=[], + ) + + assert result["reply_template_key"] == "queue_pending_confirm" + assert result["queue_item_id"] == 301 + + +@pytest.mark.asyncio +async def test_intent_gate_query_does_not_inherit_project_context(monkeypatch) -> None: + captured: dict[str, object] = {} + + class _FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + captured["tenant_id"] = tenant_id + _ = db + + async def classify_intent(self, text: str): + captured["classified_text"] = text + return {"intent": "question", "question_part": text} + + async def answer_question(self, **kwargs): + captured["answer_kwargs"] = kwargs + return {"answer": "AI 行业动态正常", "sources": []} + + class _FakeGate(FeishuPipelineGateDispatchMixin, FeishuPipelineProcessMessageHelpersBMixin): + def __init__(self) -> None: + class _FakeDB: + async def commit(self) -> None: + return None + + self.db = _FakeDB() + self.tenant_id = 1 + + async def _write_processing_log(self, **kwargs): + captured["write_log"] = kwargs + + def _build_rag_conversation_key(self, **kwargs): + _ = kwargs + return "feishu:chat-1" + + def _build_rag_reply_text(self, *, answer: str, sources: list[dict[str, object]]) -> str: + _ = sources + return answer + + monkeypatch.setattr( + "app.services.integrations.feishu_signal_pipeline.mixin_gate_dispatch.AdaptiveRAGService", + _FakeAdaptiveRAGService, + ) + + result = await _FakeGate()._maybe_handle_intent_gate_capture( + intent_gate={"intent": "query"}, + message="最近AI行业有什么动态", + sender="测试用户", + source_chat_id="chat-1", + source_sender_open_id="ou-1", + feishu_message_id="msg-1", + captured_at=None, + inferred_project_id=6, + projects=[], + process_started_at=0.0, + normalized_chat_type="group", + thread_context={"reply_to_message_id": "msg-root-1"}, + ) + + assert isinstance(result, dict) + assert result["linked_project_ids"] == [] + assert captured["answer_kwargs"]["project_id"] is None + + +@pytest.mark.asyncio +async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None: + called = {"fallback": False} + + class _FakePrefill(FeishuPipelineContextPrefillBMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + @staticmethod + def _looks_like_contextual_project_request(message: str) -> bool: + return True + + async def _load_recent_chat_turns(self, **kwargs): + _ = kwargs + return [] + + async def _load_recent_chat_reply_fallback_text(self, *, source_chat_id: str, limit: int = 5) -> str: + _ = (source_chat_id, limit) + called["fallback"] = True + raise AssertionError("reply fallback should not be used when no recent turns are available") + + fields = { + "title": "", + "description": "", + "project_scope": "", + "missing_fields": ["title", "description", "project_scope"], + "ready": False, + } + result = await _FakePrefill()._maybe_prefill_project_fields_from_context( + message="根据这个创建一个项目", + source_chat_id="chat-1", + source_sender_open_id="ou-1", + sender="测试用户", + fields=fields, + source_message_id="msg-1", + thread_context={"reply_to_message_id": "msg-root-1"}, + ) + + assert result == fields + assert called["fallback"] is False + + +@pytest.mark.asyncio +async def test_project_prefill_fallback_leaves_owner_and_timeline_empty() -> None: + class _FakeLLM: + async def generate(self, *args, **kwargs): + _ = args, kwargs + raise RuntimeError("boom") + + class _FakeExtraction(FeishuPipelineExtractionMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.llm = _FakeLLM() + + result = await _FakeExtraction()._extract_project_prefill( + "我们想做一个内部知识库升级项目,先把项目名称和范围整理出来。" + ) + + assert result["suggested_owner"] is None + assert result["suggested_timeline"] is None + assert float(result["confidence"]) <= 0.35 + assert len(result["reasoning"]) >= 1 + + +@pytest.mark.asyncio +async def test_create_project_suggestion_preserves_uncertain_owner_and_timeline() -> None: + class _FakeDB: + def __init__(self) -> None: + self.added = [] + + def add(self, obj): + self.added.append(obj) + + async def flush(self): + return None + + class _FakeProcessing(FeishuPipelineItemProcessingMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + async def _extract_project_prefill(self, message: str): + _ = message + return { + "suggested_name": "内部知识库升级", + "suggested_description": "围绕内部知识库问答能力升级", + "suggested_owner": None, + "suggested_timeline": None, + "confidence": 0.31, + "reasoning": ["需求里没有明确负责人", "需求里没有明确时间线"], + } + + project_info, suggestion = await _FakeProcessing()._create_project_suggestion( + item_id=12, + content="我们想做一个内部知识库升级项目,先把项目名称和范围整理出来。", + sender="测试用户", + fallback_confidence=0.31, + ) + + assert project_info["suggested_owner"] is None + assert project_info["suggested_timeline"] is None + assert suggestion.suggested_owner is None + assert suggestion.suggested_timeline is None + + +def test_project_create_idempotency_key_prefers_message_key() -> None: + key_one = FeishuPipelineCommandCommitProjectMixin._build_project_create_idempotency_key( + idempotency_key="feishu:sm-123", + trace_id="trace-1", + ) + key_two = FeishuPipelineCommandCommitProjectMixin._build_project_create_idempotency_key( + idempotency_key="feishu:sm-123", + trace_id="trace-2", + ) + fallback_key = FeishuPipelineCommandCommitProjectMixin._build_project_create_idempotency_key( + idempotency_key=None, + trace_id="trace-3", + ) + + assert key_one == "feishu:sm-123" + assert key_two == "feishu:sm-123" + assert fallback_key == "trace-3" + + +def test_task_orchestration_charter_context_flattens_modules() -> None: + charter = SimpleNamespace( + id=55, + title="内部知识库升级宪章", + version=2, + status="aligning", + content={ + "modules": { + "module_1": { + "problem_statement": "减少重复检索", + "goal_statement": "提升命中率 / 降低重复提问", + "kpis": "首响 < 3s", + }, + "module_2": { + "in_scope": "问答检索,知识补充", + "out_of_scope": "权限模型重构", + }, + "module_4": { + "milestones": "需求冻结\n开发完成", + }, + "module_5": { + "risk_register": "需求膨胀; 依赖不清", + }, + "module_6": { + "governance_rule": "附件仅作证据引用", + }, + } + }, + ) + + context = SignalCaptureTaskOrchestrationMainMixin._build_task_orchestration_charter_context(charter) + + assert context["title"] == "内部知识库升级宪章" + assert context["summary"] == "减少重复检索" + assert context["objectives"] == ["提升命中率", "降低重复提问"] + assert context["in_scope"] == ["问答检索", "知识补充"] + assert context["out_of_scope"] == ["权限模型重构"] + assert context["milestones"] == ["需求冻结", "开发完成"] + assert "附件仅作证据引用" in context["constraints"][0] + + +def test_task_orchestration_prompt_embeds_charter_context() -> None: + prompt = SignalCaptureTaskOrchestrationMainMixin._build_task_orchestration_prompt( + project_payload={ + "id": 6, + "title": "内部知识库升级", + "description": "围绕内部知识库问答能力升级", + "assigned_role": "backend", + "status": "active", + }, + charter_payload={ + "id": 55, + "title": "内部知识库升级宪章", + "version": 2, + "status": "aligning", + "summary": "减少重复检索", + "objectives": ["提升命中率", "降低重复提问"], + "scope": "知识库检索与问答", + "in_scope": ["问答检索", "知识补充"], + "out_of_scope": ["权限模型重构"], + "success_criteria": ["首响 < 3s"], + "constraints": ["附件仅作证据引用"], + "milestones": ["需求冻结", "开发完成"], + "risks": ["需求膨胀"], + }, + signal_payload={ + "signal_id": 8, + "message": "支持导出知识库内容", + "classification": {"classification": "new_project"}, + "proposer": "测试用户", + "captured_at": "2026-03-25T10:00:00+08:00", + }, + team_members=[ + {"user_id": 1, "name": "Alice", "workload_ratio": 42.0, "skill_tag_ids": [11, 12]}, + ], + ) + + assert "charter_json" in prompt + assert "内部知识库升级宪章" in prompt + assert "success_criteria" in prompt + assert "附件仅作证据引用" in prompt + + +def test_task_orchestration_fallback_uses_charter_context() -> None: + class _FakeSkill(SignalCaptureTaskOrchestrationSkillMixin): + @staticmethod + def _normalize_single_line_text(text: str, max_len: int = 64) -> str: + return " ".join(str(text or "").split())[:max_len] + + tasks = _FakeSkill()._build_task_orchestration_fallback( + cleaned_message="新增内部知识库导出需求", + classification={"summary": "补充内部知识库导出能力"}, + charter_context={ + "title": "内部知识库升级宪章", + "summary": "围绕内部知识库问答体验升级", + }, + ) + + assert len(tasks) == 2 + assert all("内部知识库升级宪章" in task["description"] for task in tasks) + + +def test_queue_confirm_undo_prefers_feishu_message_id_for_idempotency() -> None: + item = _Item(item_id=42, level="command", operation_code="project.create") + key = FeishuPipelineQueueConfirmUndoMixin._build_operation_idempotency_key(item=item) + assert key == "feishu:msg-42" + + +def test_task_create_idempotency_key_prefers_message_key() -> None: + key_one = FeishuPipelineCommandCommitTaskMixin._build_task_create_idempotency_key( + idempotency_key="feishu:sm-456", + trace_id="trace-1", + ) + key_two = FeishuPipelineCommandCommitTaskMixin._build_task_create_idempotency_key( + idempotency_key="feishu:sm-456", + trace_id="trace-2", + ) + fallback_key = FeishuPipelineCommandCommitTaskMixin._build_task_create_idempotency_key( + idempotency_key=None, + trace_id="trace-3", + ) + + assert key_one == "feishu:sm-456" + assert key_two == "feishu:sm-456" + assert fallback_key == "trace-3" + + +@pytest.mark.asyncio +async def test_task_create_replays_existing_ledger_without_persisting_task(monkeypatch) -> None: + from app.services.integrations.feishu_signal_pipeline import mixin_command_commit_task as task_commit_module + + class _FakeResult: + def __init__(self, value) -> None: + self._value = value + + def scalar_one_or_none(self): + return self._value + + def scalars(self): + return self + + def first(self): + return self._value + + class _FakeDB: + def __init__(self) -> None: + self.calls = 0 + self.committed = False + + async def execute(self, query): + self.calls += 1 + if self.calls == 1: + return _FakeResult(type("ProjectRow", (), {"id": 11, "tenant_id": 1})()) + if self.calls == 2: + return _FakeResult(type("TruthNodeRow", (), {"id": 22, "tenant_id": 1})()) + raise AssertionError("unexpected execute") + + async def commit(self): + self.committed = True + + class _FakeLedger: + def __init__(self) -> None: + self.status = "applied" + self.result_payload = { + "status": "applied", + "message": "task created #777", + "entity_type": "task", + "entity_id": "777", + "result": { + "task_id": 777, + "title": "补充埋点", + "project_id": 11, + }, + } + + class _FakeCommit(FeishuPipelineCommandCommitTaskMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.sync_called = False + + async def _load_task_create_ledger(self, *, idempotency_key): + self.loaded_key = idempotency_key + return _FakeLedger() + + async def _claim_task_create_ledger(self, *, idempotency_key, trace_id, payload): + raise AssertionError("ledger claim should not be reached when replaying an applied result") + + def _sync_task_rag(self, *, task): + self.sync_called = True + + async def _fail_persist_task(*args, **kwargs): + raise AssertionError("persist_task should not be called when replaying an existing ledger") + + monkeypatch.setattr(task_commit_module, "persist_task", _fail_persist_task) + + kernel = _FakeCommit() + result = await kernel._commit_task_create( + operator_user=type("User", (), {"id": 9})(), + payload={"project_id": 11, "title": "补充埋点", "description": "补充行为埋点"}, + trace_id="trace-task-1", + idempotency_key="feishu:msg-777", + ) + + assert result["status"] == "applied" + assert result["result"]["task_id"] == 777 + assert kernel.loaded_key == "feishu:msg-777" + assert kernel.db.committed is False + assert kernel.sync_called is False + + +@pytest.mark.asyncio +async def test_project_gate_always_queues_ready_create(monkeypatch) -> None: + called: dict[str, object] = {} + + class _FakeGate(FeishuPipelineGateProjectMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + @staticmethod + def _build_project_clarification_message(missing_fields): + return "请补充项目信息。" + + @staticmethod + def _stable_positive_int(*parts): + return 42 + + async def _resolve_command_operator_user(self, *, sender): + called["operator_sender"] = sender + return object() + + def _can_execute_high_value_action(self, operator_user): + called["can_execute"] = operator_user + return True + + async def _queue_project_intent_item(self, **kwargs): + called["queue_kwargs"] = kwargs + return { + "item_id": 42, + "level": "project", + "project_id": None, + "signal_id": None, + "route_status": "queued", + "clarification_message": "建项目已进入待确认队列,请点击继续执行。", + } + + async def _write_processing_log(self, **kwargs): + called["write_processing_log"] = kwargs + + async def _execute_controlled_operation(self, **kwargs): + raise AssertionError("project.create should not execute before confirmation") + + kernel = _FakeGate() + result = await kernel._handle_create_project_gate( + message="请创建一个新项目《AI PMO闭环重构实战-0314》:目标是完成工作流路由重构并上线验收。", + sender="林宇航", + source_chat_id="chat-1", + source_sender_open_id="ou-1", + feishu_message_id="msg-1", + captured_at=None, + intent_gate={ + "intent": "project.create", + "fields": { + "ready": True, + "title": "AI PMO闭环重构实战-0314", + "description": "目标是完成工作流路由重构并上线验收。", + "project_scope": "workflow routing", + }, + }, + process_started_at=0.0, + ) + + assert result["direct_reply_required"] is True + assert result["created_project_id"] is None + assert result["results"][0]["item_id"] == 42 + assert result["reply_template_key"] == "queue_pending_confirm" + assert result["queue_item_id"] == 42 + assert called["queue_kwargs"]["message"].startswith("请创建一个新项目") + + +@pytest.mark.asyncio +async def test_signal_route_item_ignores_low_confidence_threshold_when_project_is_matched() -> None: + class _FakeDBResult: + def scalar_one_or_none(self): + return None + + class _FakeDB: + def __init__(self) -> None: + self.added = [] + + async def execute(self, query): + _ = query + return _FakeDBResult() + + def add(self, obj): + self.added.append(obj) + + async def flush(self): + return None + + class _FakeFinalize(FeishuPipelineItemFinalizeRoutingMixin, FeishuPipelineMessageUtilsBMixin): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + self.log_kwargs: dict[str, object] = {} + self.notified: tuple[int, int] | None = None + + async def _ensure_signal_link(self, *, signal_id: int, project_id: int) -> None: + _ = (signal_id, project_id) + + async def _write_processing_log(self, **kwargs): + self.log_kwargs = kwargs + + async def _notify_signal_auto_entered(self, *, project_id: int, signal) -> None: + self.notified = (project_id, int(signal.id)) + + item = SimpleNamespace(id=1) + signal = SimpleNamespace(id=88, title="直播礼物量分析") + + kernel = _FakeFinalize() + result = await kernel._route_signal_item( + item=item, + signal=signal, + matched_project_id=62, + confidence=0.03, + cold_start_enabled=False, + signal_auto_enter_threshold=0.80, + ) + + assert result == 62 + assert item.safe_to_auto_execute is True + assert item.auto_execute_state == "done" + assert item.blocking_reason is None + assert item.route_status == "auto_done" + assert kernel.notified == (62, 88) diff --git a/backend/tests/test_feishu_receive_orchestration_contract.py b/backend/tests/test_feishu_receive_orchestration_contract.py index 6c474555..1a4b1374 100644 --- a/backend/tests/test_feishu_receive_orchestration_contract.py +++ b/backend/tests/test_feishu_receive_orchestration_contract.py @@ -1,5 +1,8 @@ import pytest +from app.services.integrations.feishu_integration_service.mixin_receive_prepare import ( + FeishuReceivePrepareMixin, +) from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService @@ -116,3 +119,53 @@ async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypa assert called["capture"]["chat_id"] == "chat-1" assert called["capture"]["sender_name"] == "解析后用户名" assert called["finalize"]["chat_type"] == "group" + + +def test_extract_thread_context_reads_top_level_reply_fields() -> None: + event = { + "thread_id": "th-1", + "root_id": "root-1", + "parent_id": "reply-1", + "message": { + "message_id": "msg-1", + "content": "{\"text\":\"hello\"}", + }, + } + context = FeishuReceivePrepareMixin._extract_thread_context( + event=event, + message=event["message"], + message_payload={"text": "hello", "attachments": [], "message_type": "text"}, + ) + + assert context == { + "thread_id": "th-1", + "root_message_id": "root-1", + "reply_to_message_id": "reply-1", + } + + +def test_extract_thread_context_reads_nested_reply_fields() -> None: + event = { + "message": { + "message_id": "msg-1", + "meta": { + "conversation": { + "thread_id": "th-nested", + "root_message_id": "root-nested", + "reply_to_message_id": "reply-nested", + } + }, + "content": "{\"text\":\"hello\"}", + } + } + context = FeishuReceivePrepareMixin._extract_thread_context( + event=event, + message=event["message"], + message_payload={"text": "hello", "attachments": [], "message_type": "text"}, + ) + + assert context == { + "thread_id": "th-nested", + "root_message_id": "root-nested", + "reply_to_message_id": "reply-nested", + } diff --git a/backend/tests/test_signal_planner_contract.py b/backend/tests/test_signal_planner_contract.py new file mode 100644 index 00000000..9a3a1868 --- /dev/null +++ b/backend/tests/test_signal_planner_contract.py @@ -0,0 +1,206 @@ +from types import SimpleNamespace + +import pytest + +from app.services.signal.signal_planner.mixin_loaders import SignalPlannerLoadersMixin +from app.services.signal.signal_planner.service import SignalPlannerService + + +class _FakeScalarResult: + def __init__(self, row): + self._row = row + + def scalar_one_or_none(self): + return self._row + + +class _FakeDB: + def __init__(self, rows): + self._rows = list(rows) + self.statements = [] + + async def execute(self, statement): + self.statements.append(statement) + if self._rows: + return _FakeScalarResult(self._rows.pop(0)) + return _FakeScalarResult(None) + + +@pytest.mark.asyncio +async def test_signal_planner_latest_charter_payload_flattens_modules_and_orders_latest() -> None: + charter = SimpleNamespace( + id=77, + title="支付优化宪章", + version=3, + status="aligned", + content={ + "modules": { + "module_1": { + "problem_statement": "减少支付失败", + "goal_statement": "提升支付成功率", + "kpis": "成功率 >= 99%", + }, + "module_2": { + "in_scope": "支付链路优化", + "out_of_scope": "结算清分重构", + }, + "module_4": { + "milestones": "需求冻结\n开发上线", + }, + "module_5": { + "risk_register": "第三方通道不稳定", + }, + "module_6": { + "governance_rule": "对外变更需复核", + }, + } + }, + ) + db = _FakeDB([charter]) + + payload = await SignalPlannerLoadersMixin._load_latest_charter_payload( + db, + tenant_id=1, + project_id=42, + ) + + assert payload["title"] == "支付优化宪章" + assert payload["summary"] == "减少支付失败" + assert payload["objectives"] == ["提升支付成功率"] + assert payload["in_scope"] == ["支付链路优化"] + assert payload["out_of_scope"] == ["结算清分重构"] + assert payload["milestones"] == ["需求冻结", "开发上线"] + assert "对外变更需复核" in payload["constraints"][0] + + sql_text = str(db.statements[0]).lower() + assert "order by" in sql_text + assert "updated_at" in sql_text + assert "id desc" in sql_text + + +def test_signal_planner_prompt_embeds_charter_context() -> None: + signal = SimpleNamespace( + id=12, + title="继续优化支付失败重试", + summary="提升支付成功率", + content="需要优化支付失败重试策略", + matched_project_id=42, + strategic_analysis={ + "ai_analysis": { + "summary": "支付链路存在失败重试问题", + "requirement_project_scope": "支付优化", + } + }, + ) + prompt = SignalPlannerService._build_prompt( + signal=signal, + project_title="支付优化", + charter_json={ + "id": 77, + "title": "支付优化宪章", + "objectives": ["提升支付成功率"], + "scope": "支付链路优化", + "success_criteria": ["成功率 >= 99%"], + "constraints": ["对外变更需复核"], + "milestones": ["需求冻结", "开发上线"], + "risks": ["第三方通道不稳定"], + }, + recent_signals=[{"id": 1, "title": "支付失败告警"}], + members=[{"user_id": 9, "name": "Alice", "workload_ratio": 42.5}], + ) + + assert "charter_json" in prompt + assert "支付优化宪章" in prompt + assert "success_criteria" in prompt + assert "对外变更需复核" in prompt + + +@pytest.mark.asyncio +async def test_signal_planner_analyze_degrades_without_charter(monkeypatch) -> None: + signal = SimpleNamespace( + id=12, + title="继续优化支付失败重试", + summary="提升支付成功率", + content="需要优化支付失败重试策略", + matched_project_id=42, + strategic_analysis={ + "ai_analysis": { + "summary": "支付链路存在失败重试问题", + "requirement_project_scope": "支付优化", + } + }, + ) + captured: dict[str, object] = {} + + async def _fake_load_signal(db, *, tenant_id: int, signal_id: int): + _ = (db, tenant_id, signal_id) + return signal + + async def _fake_load_project_title(db, *, tenant_id: int, project_id): + _ = (db, tenant_id, project_id) + return "支付优化" + + async def _fake_load_charter_payload(db, *, tenant_id: int, project_id): + _ = (db, tenant_id, project_id) + return {} + + async def _fake_load_recent_signals(db, *, tenant_id: int, project_id, exclude_signal_id: int): + _ = (db, tenant_id, project_id, exclude_signal_id) + return [] + + async def _fake_load_members(db, *, tenant_id: int, project_id): + _ = (db, tenant_id, project_id) + return [{"user_id": 9, "name": "Alice", "workload_ratio": 42.5}] + + async def _fake_generate_structured_object(prompt: str, **kwargs): + captured["prompt"] = prompt + captured["kwargs"] = kwargs + return SimpleNamespace( + payload={ + "summary": "聚焦支付失败重试", + "priority": "high", + "assignee_user_id": 9, + "assignee_reason": "技能匹配且负载可接受", + "execution_plan": ["梳理失败场景", "拆解重试策略"], + "risks": ["第三方通道波动"], + "confidence": 0.91, + } + ) + + async def _fake_get_effective_routing(db, *, tenant_id: int, function_name: str): + _ = (db, tenant_id, function_name) + return {"source": "override", "provider": "glm", "model": "qwen-plus"} + + async def _fake_merge_and_save(db, *, signal_id: int, planner_result: dict, tenant_id: int): + _ = (db, signal_id, tenant_id) + captured["planner_result"] = planner_result + return signal + + class _FakeDB: + async def commit(self): + return None + + async def rollback(self): + return None + + monkeypatch.setattr(SignalPlannerService, "_load_signal", _fake_load_signal) + monkeypatch.setattr(SignalPlannerService, "_load_project_title", _fake_load_project_title) + monkeypatch.setattr(SignalPlannerService, "_load_latest_charter_payload", _fake_load_charter_payload) + monkeypatch.setattr(SignalPlannerService, "_load_recent_signal_summaries", _fake_load_recent_signals) + monkeypatch.setattr(SignalPlannerService, "_load_member_profiles", _fake_load_members) + monkeypatch.setattr("app.services.signal.signal_planner.mixin_analyze.llm_service.generate_structured_object", _fake_generate_structured_object) + monkeypatch.setattr("app.services.signal.signal_planner.mixin_analyze.ModelRoutingService.get_effective_routing", _fake_get_effective_routing) + monkeypatch.setattr("app.services.signal.signal_planner.mixin_analyze.SignalStrategicAnalysisService.merge_and_save", _fake_merge_and_save) + + await SignalPlannerService._analyze_with_session( + db=_FakeDB(), + signal_id=12, + tenant_id=1, + source_message_id="msg-1", + source_chat_id="chat-1", + dedupe_seed="seed-1", + ) + + assert "charter_json" in str(captured["prompt"]) + assert "{}" in str(captured["prompt"]) + assert captured["planner_result"]["summary"] == "聚焦支付失败重试" diff --git a/backend/tests/test_sstc_tasks_ai_breakdown_contract.py b/backend/tests/test_sstc_tasks_ai_breakdown_contract.py new file mode 100644 index 00000000..a584c93a --- /dev/null +++ b/backend/tests/test_sstc_tasks_ai_breakdown_contract.py @@ -0,0 +1,197 @@ +import json +from types import SimpleNamespace + +import pytest + +from app.api import tasks as tasks_api +from app.services.sstc.charter_data import ( + _build_task_breakdown_context, + _build_task_breakdown_prompt, +) +from app.schemas import TaskBreakdownRequest + + +def test_task_breakdown_context_flattens_charter_modules() -> None: + project = SimpleNamespace( + id=6, + title="内部知识库升级", + name="内部知识库升级", + description="围绕内部知识库问答能力升级", + status="active", + assigned_role="backend", + ) + charter = SimpleNamespace( + id=55, + title="内部知识库升级宪章", + version=2, + status="aligning", + content={ + "modules": { + "module_1": { + "problem_statement": "减少重复检索", + "goal_statement": "提升命中率 / 降低重复提问", + "kpis": "首响 < 3s", + }, + "module_2": { + "in_scope": "问答检索,知识补充", + "out_of_scope": "权限模型重构", + }, + "module_4": { + "milestones": "需求冻结\n开发完成", + }, + "module_5": { + "risk_register": "需求膨胀; 依赖不清", + }, + "module_6": { + "governance_rule": "附件仅作证据引用", + }, + } + }, + ) + + context = _build_task_breakdown_context( + description="支持导出知识库内容", + project=project, + charter=charter, + ) + + assert context["project"]["title"] == "内部知识库升级" + assert context["project"]["status"] == "active" + assert context["charter"]["summary"] == "减少重复检索" + assert context["charter"]["objectives"] == ["提升命中率", "降低重复提问"] + assert context["charter"]["in_scope"] == ["问答检索", "知识补充"] + assert context["charter"]["out_of_scope"] == ["权限模型重构"] + assert context["charter"]["milestones"] == ["需求冻结", "开发完成"] + assert "附件仅作证据引用" in context["charter"]["constraints"][0] + + +def test_task_breakdown_prompt_embeds_charter_context() -> None: + prompt = _build_task_breakdown_prompt( + project_id=6, + context={ + "project": { + "id": 6, + "title": "内部知识库升级", + "description": "围绕内部知识库问答能力升级", + "status": "active", + "assigned_role": "backend", + }, + "charter": { + "id": 55, + "title": "内部知识库升级宪章", + "version": 2, + "status": "aligning", + "summary": "减少重复检索", + "objectives": ["提升命中率", "降低重复提问"], + "scope": "知识库检索与问答", + "in_scope": ["问答检索", "知识补充"], + "out_of_scope": ["权限模型重构"], + "success_criteria": ["首响 < 3s"], + "constraints": ["附件仅作证据引用"], + "milestones": ["需求冻结", "开发完成"], + "risks": ["需求膨胀"], + "signal_ids": [12], + }, + }, + available_skills="FastAPI, React, LangChain", + ) + + assert "需求宪章与项目上下文" in prompt + assert "内部知识库升级宪章" in prompt + assert "FastAPI, React, LangChain" in prompt + assert "objectives" in prompt + assert "constraints" in prompt + + +@pytest.mark.asyncio +async def test_general_task_breakdown_route_uses_project_and_charter_context(monkeypatch) -> None: + captured: dict[str, object] = {} + + async def _fake_load_context(db, *, tenant_id: int, description: str, project_id=None, charter_id=None): + _ = (db, tenant_id, project_id, charter_id) + captured["description"] = description + return { + "request": {"description": description}, + "project": { + "id": 6, + "title": "内部知识库升级", + "description": "围绕内部知识库问答能力升级", + "status": "active", + "assigned_role": "backend", + }, + "charter": { + "id": 55, + "title": "内部知识库升级宪章", + "version": 2, + "status": "aligning", + "summary": "减少重复检索", + "objectives": ["提升命中率", "降低重复提问"], + "scope": "知识库检索与问答", + "in_scope": ["问答检索", "知识补充"], + "out_of_scope": ["权限模型重构"], + "success_criteria": ["首响 < 3s"], + "constraints": ["附件仅作证据引用"], + "milestones": ["需求冻结", "开发完成"], + "risks": ["需求膨胀"], + "signal_ids": [12], + }, + } + + async def _fake_load_skills(db, *, tenant_id: int): + _ = (db, tenant_id) + return "FastAPI, React, LangChain" + + class _FakeLLM: + async def generate(self, prompt: str, **kwargs): + captured["prompt"] = prompt + captured["llm_kwargs"] = kwargs + return json.dumps( + [ + { + "task_name": "需求澄清与范围确认", + "type": "ai", + "estimated_hours": 4, + "priority": "high", + "required_skill_names": ["FastAPI"], + }, + { + "task_name": "核心方案落地", + "type": "backend", + "estimated_hours": 12, + "priority": "medium", + "required_skill_names": ["React", "LangChain"], + }, + ], + ensure_ascii=False, + ) + + @staticmethod + def _parse_json_array(text: str): + parsed = json.loads(text) + return [item for item in parsed if isinstance(item, dict)] + + class _FakePlane: + async def sync_tasks(self, tasks, is_urgent: bool = False): + _ = is_urgent + return {"synced_count": len(tasks)} + + monkeypatch.setattr(tasks_api, "_load_task_breakdown_context", _fake_load_context) + monkeypatch.setattr(tasks_api, "_load_available_skill_names", _fake_load_skills) + monkeypatch.setattr(tasks_api, "llm_service", _FakeLLM()) + monkeypatch.setattr(tasks_api, "plane_service", _FakePlane()) + + result = await tasks_api.breakdown_tasks( + TaskBreakdownRequest( + description="支持导出知识库内容", + project_id=6, + charter_id=55, + sync_to_plane=True, + ), + db=SimpleNamespace(), + current_user=SimpleNamespace(tenant_id=1, id=1), + ) + + assert "内部知识库升级宪章" in str(captured["prompt"]) + assert "FastAPI, React, LangChain" in str(captured["prompt"]) + assert result["sync_result"]["synced_count"] == 2 + assert result["tasks"][0]["task_name"] == "需求澄清与范围确认" diff --git a/frontend/src/pages/ai-steward/constants.ts b/frontend/src/pages/ai-steward/constants.ts index 4d9f54a8..299c6d09 100644 --- a/frontend/src/pages/ai-steward/constants.ts +++ b/frontend/src/pages/ai-steward/constants.ts @@ -3,7 +3,7 @@ import type { EngineProviderPresets } from "./types"; export const PROVIDER_ZH: Record = { deepseek: "DeepSeek", - glm: "GLM", + glm: "Qwen(DashScope)", openai: "OpenAI", ollama: "Ollama(本地)", }; @@ -19,8 +19,8 @@ export const ENGINE_PROVIDER_FALLBACK_PRESETS: EngineProviderPresets = { base_url: "https://api.deepseek.com/v1", }, glm: { - model: "GLM-4.7", - base_url: "https://api.edgefn.net/v1", + model: "qwen-plus", + base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", }, openai: { model: "MiniMax-M2.5", diff --git a/frontend/src/pages/ai-steward/sections/EngineTab.tsx b/frontend/src/pages/ai-steward/sections/EngineTab.tsx index 594a8092..d14ed3e9 100644 --- a/frontend/src/pages/ai-steward/sections/EngineTab.tsx +++ b/frontend/src/pages/ai-steward/sections/EngineTab.tsx @@ -229,7 +229,7 @@ export default function EngineTab({ setEngineForm((prev) => ({ ...prev, model: e.target.value }))} - placeholder="如:deepseek-chat / GLM-4.7 / MiniMax-M2.5 / llama3:latest" + placeholder="如:deepseek-chat / qwen-plus / MiniMax-M2.5 / llama3:latest" />
@@ -237,9 +237,9 @@ export default function EngineTab({ setEngineForm((prev) => ({ ...prev, base_url: e.target.value }))} - placeholder="如:https://api.edgefn.net/v1" + placeholder="如:https://dashscope.aliyuncs.com/compatible-mode/v1" /> -
当前推荐:GLM 与 MiniMax 都走 `https://api.edgefn.net/v1`
+
当前推荐:Qwen 走 `https://dashscope.aliyuncs.com/compatible-mode/v1`,MiniMax 继续走 OpenAI 兼容网关
diff --git a/package-lock.json b/package-lock.json index 417cd38a..a6ecbbd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2,5 +2,171 @@ "name": "AIProjectManger", "lockfileVersion": 3, "requires": true, - "packages": {} + "packages": { + "": { + "devDependencies": { + "pg": "^8.20.0" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } } diff --git a/package.json b/package.json new file mode 100644 index 00000000..9dd9a84c --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "devDependencies": { + "pg": "^8.20.0" + } +}