Implement structured AI task breakdown and auto scheduling

This commit is contained in:
Yuuuu 2026-03-26 09:40:36 +08:00
parent 2e3de00c04
commit 4268d791ed
153 changed files with 12200 additions and 857 deletions

View File

@ -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

BIN
.tmp-feishu-escape.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

BIN
.tmp-feishu-menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

BIN
.tmp-feishu-newtab.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
.tmp-feishu-replymode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

View File

@ -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` 已锁定回归

View File

@ -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",

View File

@ -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"))

View File

@ -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")

View File

@ -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": "",
},
},

View File

@ -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("/")

View File

@ -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(

View File

@ -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:

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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",
]

View File

@ -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 = """

View File

@ -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": [

View File

@ -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__ = [

View File

@ -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"]

View File

@ -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"]

View File

@ -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}

View File

@ -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}

View File

@ -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"]

View File

@ -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:

View File

@ -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)

View File

@ -16,6 +16,8 @@ class TaskBreakdownRequest(BaseModel):
"""任务拆解请求"""
description: str
project_id: Optional[int] = None
charter_id: Optional[int] = None
sync_to_plane: bool = False

View File

@ -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",
]

View File

@ -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:

View File

@ -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,

View File

@ -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)

View File

@ -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()

View File

@ -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,

View File

@ -190,6 +190,7 @@ FOLLOWUP_ENUMERATION_KEYWORDS = (
"都有哪些",
"具体有哪些",
"列出来",
"列一下",
"清单",
"名单",
)

View File

@ -94,7 +94,7 @@ WRITE_EXPRESSIONS = (
"麻烦",
)
COUNT_HINTS = ("多少", "几个", "数量", "总数", "count")
LIST_HINTS = ("哪些", "列表", "清单", "列出", "分别", "名单", "都有哪些")
LIST_HINTS = ("哪些", "列表", "清单", "列出", "列一下", "分别", "名单", "都有哪些")
STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况")
ENTITY_ALIAS_MAP: dict[str, str] = {
"能力": "skill",

View File

@ -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()

View File

@ -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),

View File

@ -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,

View File

@ -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",

View File

@ -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,

View File

@ -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)

View File

@ -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"})

View File

@ -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

View File

@ -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,

View File

@ -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,
)

View File

@ -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 {}

View File

@ -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,

View File

@ -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),

View File

@ -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:

View File

@ -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,
)

View File

@ -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"

View File

@ -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)

View File

@ -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,

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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],

View File

@ -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,
*,

View File

@ -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],

View File

@ -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,

View File

@ -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

View File

@ -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()

View File

@ -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,

View File

@ -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,
*,

View File

@ -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)

View File

@ -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:

View File

@ -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("__")]

View File

@ -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)

View File

@ -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,
)

View File

@ -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

View File

@ -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(

View File

@ -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"),

View File

@ -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(

View File

@ -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]:

View File

@ -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

View File

@ -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,
)

View File

@ -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,

View File

@ -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'}"

View File

@ -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",

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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,
}

View File

@ -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)

View File

@ -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 返回异常,已按规则生成项目预填草案。",
"负责人和时间线仅在消息明确给出时才填写。",
],
}

View File

@ -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)} 已确认。"

View File

@ -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

View File

@ -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 直答通道,不进入决策队列。",

View File

@ -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},
}

View File

@ -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"^<at[^>]*>[^<]*</at>\s*", "", str(text or ""), flags=re.IGNORECASE).strip()
return re.sub(r"\s+", " ", cleaned).strip()

View File

@ -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)

View File

@ -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,
}

View File

@ -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)

View File

@ -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)
)

View File

@ -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,
)

View File

@ -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",

View File

@ -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 [],
),
}

View File

@ -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}

Some files were not shown because too many files have changed in this diff Show More