Implement structured AI task breakdown and auto scheduling
This commit is contained in:
parent
2e3de00c04
commit
4268d791ed
26
.env.example
26
.env.example
|
|
@ -26,32 +26,10 @@ LLM_ROUTING_DEFAULT_TIER=active
|
||||||
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
LLM_LOW_TO_HIGH_ESCALATION_ENABLED=true
|
||||||
LLM_REQUEST_TIMEOUT=90
|
LLM_REQUEST_TIMEOUT=90
|
||||||
|
|
||||||
# 模型名统一使用裸名,不带供应商前缀
|
# 模型默认值统一由 backend/.openclaw/pmflow-routing-rules.json 管理
|
||||||
# GLM-4.7 配 GLM_API_KEY
|
DASHSCOPE_API_KEY=
|
||||||
# GLM-5(经 OpenAI 兼容网关)配 OPENAI_API_KEY
|
|
||||||
GLM_API_KEY=
|
|
||||||
GLM_BASE_URL=https://api.edgefn.net/v1
|
|
||||||
GLM_MODEL=GLM-4.7
|
|
||||||
OPENAI_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_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 深入思考模式
|
# Adaptive RAG 深入思考模式
|
||||||
RAG_DEEP_RETRIEVAL_STEPS=5
|
RAG_DEEP_RETRIEVAL_STEPS=5
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
65
CLAUDE.md
65
CLAUDE.md
|
|
@ -45,7 +45,7 @@ backend/
|
||||||
│ ├── schemas/ # Pydantic 模型
|
│ ├── schemas/ # Pydantic 模型
|
||||||
│ ├── services/ # 业务服务(AI/SSTC/集成)
|
│ ├── services/ # 业务服务(AI/SSTC/集成)
|
||||||
│ ├── config/ # 配置与 prompt
|
│ ├── config/ # 配置与 prompt
|
||||||
│ └── utils/ # 工具函数
|
│ └── utils/ # 工具函数(timezone / audited_write / thread_context)
|
||||||
├── alembic/ # 数据库迁移
|
├── alembic/ # 数据库迁移
|
||||||
│ └── versions/
|
│ └── versions/
|
||||||
│ ├── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段
|
│ ├── 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_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2)
|
||||||
│ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表
|
│ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表
|
||||||
│ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展
|
│ ├── 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/ # 后端运维/数据修复脚本
|
├── scripts/ # 后端运维/数据修复脚本
|
||||||
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
|
│ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links
|
||||||
│ ├── dev/ # 开发辅助脚本(check_users/seed_all)
|
│ ├── dev/ # 开发辅助脚本(check_users/seed_all)
|
||||||
|
|
@ -73,15 +74,22 @@ backend/
|
||||||
│ ├── test_adaptive_rag_mainline_contract.py
|
│ ├── test_adaptive_rag_mainline_contract.py
|
||||||
│ ├── test_ai_pmo_execution_ledger_contract.py
|
│ ├── test_ai_pmo_execution_ledger_contract.py
|
||||||
│ ├── test_feishu_card_action_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 依赖
|
├── requirements.txt # Python 依赖
|
||||||
└── Dockerfile
|
└── Dockerfile
|
||||||
```
|
```
|
||||||
- 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口)
|
- 新增:`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/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别)
|
||||||
- 新增:`backend/app/core/startup/ai_pmo_ingress_async.py`(AI PMO 入站异步任务轮询启动循环)
|
- 新增:`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/timezone.py`(统一北京时间工具:`now_cn_aware/now_cn_naive`)
|
||||||
- 新增:`backend/app/utils/audited_write.py`(写接口审计上下文、caller 归一、北京时间格式化)
|
- 新增:`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/middleware/audit_context.py`(请求级 trace/interface/caller 注入)
|
||||||
- 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`)
|
- 新增:`backend/app/api/audit.py`(审计查询接口:`GET /audit/operations`)
|
||||||
- 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化)
|
- 新增:`backend/app/services/audit_query_service/service.py`(operation_logs 过滤与序列化)
|
||||||
|
|
@ -93,6 +101,15 @@ backend/
|
||||||
- 新增:`backend/app/services/signal/draft_generator_service.py`(写操作草稿生成服务:草稿结构化产出 + 24h 过期)
|
- 新增:`backend/app/services/signal/draft_generator_service.py`(写操作草稿生成服务:草稿结构化产出 + 24h 过期)
|
||||||
- 新增:`backend/app/services/ai_pmo/draft_confirm_handler.py`(草稿确认处理器:确认/修改/取消 + 幂等保护)
|
- 新增:`backend/app/services/ai_pmo/draft_confirm_handler.py`(草稿确认处理器:确认/修改/取消 + 幂等保护)
|
||||||
- 新增:`backend/app/core/startup/draft_expiry_worker.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_ai_service_core_runtime.py`(AIService 运行时回归:统一收敛 llm_service、缓存命中与流式兼容输出)
|
||||||
- 新增:`backend/tests/test_rag_route_contract.py`(RAG 双入口契约回归:`asked_via/action_mode/message_intent/mixed_signal_part` 统一透传)
|
- 新增:`backend/tests/test_rag_route_contract.py`(RAG 双入口契约回归:`asked_via/action_mode/message_intent/mixed_signal_part` 统一透传)
|
||||||
- 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派)
|
- 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派)
|
||||||
|
|
@ -103,6 +120,7 @@ backend/
|
||||||
- 新增:`backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`(AdaptiveRAG 基础问答主链编排:运行时准备 + 只读主路径执行)
|
- 新增:`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_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_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/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_auto.py`(自动执行与草稿待确认路径接入统一执行账本,日志 `operation_code` 对齐 `action_contract`)
|
||||||
- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py`(`waiting_manual` 路径接入统一执行账本与动作参数来源约束)
|
- 更新:`backend/app/services/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_prepare.py`(飞书消息入口预处理:上下文抽取、空文本分支、附件标准化)
|
||||||
- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py`(飞书消息入口执行流:capture 持久化、异常回执、结果收尾)
|
- 新增:`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_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_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/signal_planner_service.py`(Signal Planner 异步分析服务)
|
||||||
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
- 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务)
|
||||||
- 新增:`backend/app/services/task_brief_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_api/shared.py`(SSTC 路由层公共依赖导入)
|
||||||
- 更新:`backend/app/api/sstc.py`(降级为兼容入口,仅导出聚合 `router`)
|
- 更新:`backend/app/api/sstc.py`(降级为兼容入口,仅导出聚合 `router`)
|
||||||
- 新增:`backend/app/services/sstc/`(SSTC 业务辅助能力拆分:signal/charter/governance/entity_lookup/review)
|
- 新增:`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_service/`(LLM 服务按职责拆分的 mixin 包,所有子文件 <= 300 行)
|
||||||
- 更新:`backend/app/services/llm.py`(降级为兼容入口,导出 `LLMService` 与 `llm_service`)
|
- 更新:`backend/app/services/llm.py`(降级为兼容入口,导出 `LLMService` 与 `llm_service`)
|
||||||
- 新增:`backend/app/services/signal/signal_capture_service/`(主动信号捕获服务按入口/编排/分析/新项目拆分)
|
- 新增:`backend/app/services/signal/signal_capture_service/`(主动信号捕获服务按入口/编排/分析/新项目拆分)
|
||||||
|
|
@ -197,7 +221,7 @@ backend/
|
||||||
- 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分)
|
- 新增:`backend/app/services/context_cache_service/`(上下文缓存服务按缓存核心/画像/快照/失效钩子拆分)
|
||||||
- 更新:`backend/app/services/context_cache.py`(降级为兼容入口)
|
- 更新:`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_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/signals_api/`(signals 路由拆分为 list/create、ingest/fetch、feedback 子模块)
|
||||||
- 新增:`backend/app/api/wecom_api/`(wecom 路由拆分为 config/webhook/messaging 子模块)
|
- 新增:`backend/app/api/wecom_api/`(wecom 路由拆分为 config/webhook/messaging 子模块)
|
||||||
- 新增:`backend/app/api/endpoints/skill_api/`(人员能力图谱路由拆分为 tags/bindings/workload/recommend 子模块)
|
- 新增:`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`
|
- 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 上下文
|
- 双模型上下文注入仅允许通过 `ModelContextBundleService` 统一组包:固定三段 `knowledge_graph/task_template/contract_sync`,禁止在业务服务散落拼接 prompt 上下文
|
||||||
- AI PMO `signal.create` 在用户显式要求“先分析再入库 / 自行分析”时,若批量信号结构化分析失败,禁止回退规则标题直接入库,必须返回失败安全态
|
- 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` 回退,避免思考过程外漏
|
- 用户可见问答链路(`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 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`,禁止把需求段落并入问答段落
|
- mixed 场景回复必须强制两段格式输出:`[回答]` 与 `[已收到需求] ...,已入库处理`,禁止把需求段落并入问答段落
|
||||||
- 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot/glm_semantic 快路径,统一落到场景化 Prompt 生成,避免旧确定性清单回复覆盖场景格式
|
- 飞书与 AI PMO 入口(含 `message_intent=mixed`)问答链路必须绕过 deterministic/snapshot/glm_semantic 快路径,统一落到场景化 Prompt 生成,避免旧确定性清单回复覆盖场景格式
|
||||||
- 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启;未开启时运行期与连通测试都不可用
|
- 本地 `ollama` 仅允许知识库问答链路使用,且必须通过 `llm_engine.settings.enable_local_kb_qa` 显式开启;未开启时运行期与连通测试都不可用
|
||||||
- AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿
|
- AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿
|
||||||
- Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要
|
- Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要
|
||||||
- AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径
|
- AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径
|
||||||
- AI PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库
|
- AI PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库;`project.create` 的 Feishu 文本入口先转待确认卡片,点击后才允许执行
|
||||||
- 草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理
|
- 草稿确认只认显式 `reply_to_message_id` + 明确草稿动作(`确认/修改/取消`)或显式 `work_item_id`;草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理
|
||||||
- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账
|
- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账
|
||||||
|
- AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定
|
||||||
|
|
||||||
## 9. 新增代码的参考模式
|
## 9. 新增代码的参考模式
|
||||||
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
- 新增后端接口:参考 `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`,仅管理员可修改本地知识库问答开关)
|
- 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` 便于链路自检
|
- 飞书捕获模拟接口:`POST /ai-steward/signal/capture-feishu` 在 `direct_reply_required=true` 时会同步写入 `feishu_reply_outbox`,并返回 `reply_outbox_enqueued/reply_outbox_id` 便于链路自检
|
||||||
- 配置读取类接口:参考 `backend/app/api/llm_providers.py`(`GET /ai-steward/llm/providers`)
|
- 配置读取类接口:参考 `backend/app/api/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`
|
- AI Steward 指挥规划接口已移除:`POST /ai-steward/command/dispatch`
|
||||||
- 项目删除接口(`DELETE /projects/{project_id}`):参考 `backend/app/api/projects.py`,先清理强依赖外键、再置空弱依赖外键、最后删除主记录(单事务提交)
|
- 项目删除接口(`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`
|
- 项目成员分配接口(`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(不要幻构行为)
|
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||||
- 飞书回调字段可能缺失文件名/大小,仅保证 `attachment_id/key/type` 可用
|
- 飞书回调字段可能缺失文件名/大小,仅保证 `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 主链按 `OPENCLAW_ROUTING_ENABLED` 走 OpenClaw 路由规则(`backend/.openclaw/pmflow-routing-rules.json`);请求超时统一以 `LLM_REQUEST_TIMEOUT` 环境变量为准
|
||||||
- LLM 非结构化响应仅在非用户可见链路且显式开启 `LLM_REASONING_CONTENT_FALLBACK` 时,才允许从 `reasoning_content` 回退;`answer/reply/qa` 链路一律禁用该回退
|
- LLM 非结构化响应仅在非用户可见链路且显式开启 `LLM_REASONING_CONTENT_FALLBACK` 时,才允许从 `reasoning_content` 回退;`answer/reply/qa` 链路一律禁用该回退
|
||||||
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
- World Model 静态层已接入 Redis 缓存(key: `wm:static:{tenant_id}:{project_id}`,TTL 600s)
|
||||||
- 写操作后主动 invalidate,动态层不缓存
|
- 写操作后主动 invalidate,动态层不缓存
|
||||||
- AI PMO 意图识别已切换 `model-only`:`_resolve_intent` 仅接受 LLM 结构化结果,不再走规则/正则兜底链路
|
- 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 函数路由支持 `integrations(type='model_routing')` 覆盖配置;配置缺失时默认直连 GLM
|
||||||
- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)`
|
- Signal Planner 结构化调用采用 `25s` 硬超时:超时或结构化不合法时标记失败并通知人工重试,不再写入降级 `ai_planner(fallback_mode=true)`
|
||||||
- AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准
|
- AI Steward 用户侧已移除 OpenClaw 指挥规划入口;后续项目分析主链以 GLM 严格失败直出为准
|
||||||
- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行
|
- 飞书业务命令命中旧 command-first 路由时,仅返回“已下线”阻断提示,不再触发实际执行
|
||||||
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名
|
- 模型命名统一使用裸模型名(如 `GLM-4.7`、`MiniMax-M2.5`),不使用供应商前缀模型名
|
||||||
- 双 Agent 路由治理:低/高层函数均可由 OpenClaw 规则命中到指定 provider/model(含 `classify_intent_ai_pmo_kernel`)
|
- 双 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)用于离线回放与分桶评分
|
- 写操作链路统一产出 `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` 强制进入人工确认,不直接自动执行
|
- AI PMO 意图仲裁默认开启(`AI_PMO_INTENT_ARBITRATION_ENABLED=true`):规则与 LLM 写操作冲突时按 `AI_PMO_INTENT_CONFLICT_POLICY=pending_manual` 强制进入人工确认,不直接自动执行
|
||||||
- 飞书 `qa_llm` 回复已增加事实熔断:命中“数据库迁移/DDL/Alembic/让我直接处理/模型失败了所以没分析”等漂移语句时,自动回退固定模板,禁止对外宣称未证实执行动作
|
- 飞书 `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`),避免历史脚本直接查旧表名失败
|
- 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败
|
||||||
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`
|
- `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual`
|
||||||
- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试
|
- 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试
|
||||||
- 会话命中 `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` 更新
|
- 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新
|
||||||
- 飞书回复 outbox 已支持 `text + interactive card` 双通道:执行场景命中 `draft_pending_confirm/pending_manual` 时优先入队按钮卡片;卡片消息仍经 outbox 异步派发(不走业务路径同步直发)
|
- 飞书回复 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 持久化与回执收尾
|
- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾
|
||||||
- `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入
|
- `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 与两段输出格式化
|
- `/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] 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] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `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` 已升级为一等 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] 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] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定
|
||||||
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
|
- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,54 @@
|
||||||
{
|
{
|
||||||
"version": "v2",
|
"version": "v3",
|
||||||
"updated_at": "2026-03-11T09:10:00Z",
|
"updated_at": "2026-03-25T00:00:00Z",
|
||||||
"source_of_truth": "openclaw",
|
"source_of_truth": "openclaw",
|
||||||
"description": "PMFlow 三引擎统一路由:所有主链路统一经 OpenClaw OpenAI-Compatible 网关。",
|
"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": [
|
"rules": [
|
||||||
{
|
{
|
||||||
"name": "engine_low_project_draft_glm47_fallback",
|
"name": "engine_low_project_draft_glm47_fallback",
|
||||||
|
|
@ -10,8 +56,9 @@
|
||||||
"priority": 340,
|
"priority": 340,
|
||||||
"tier": "low",
|
"tier": "low",
|
||||||
"provider": "glm",
|
"provider": "glm",
|
||||||
"model": "GLM-4.7",
|
"model": "qwen-plus",
|
||||||
"api_env": "GLM_API_KEY",
|
"api_env": "DASHSCOPE_API_KEY",
|
||||||
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
"match_any": [
|
"match_any": [
|
||||||
"project_draft_analysis_glm47_fallback",
|
"project_draft_analysis_glm47_fallback",
|
||||||
"project_draft_signal_glm47_fallback",
|
"project_draft_signal_glm47_fallback",
|
||||||
|
|
@ -35,8 +82,19 @@
|
||||||
"priority": 310,
|
"priority": 310,
|
||||||
"tier": "low",
|
"tier": "low",
|
||||||
"provider": "glm",
|
"provider": "glm",
|
||||||
"model": "GLM-4.7",
|
"model": "qwen-plus",
|
||||||
"api_env": "GLM_API_KEY",
|
"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"
|
"function_exact": "classify_intent_ai_pmo_kernel"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -45,8 +103,9 @@
|
||||||
"priority": 300,
|
"priority": 300,
|
||||||
"tier": "low",
|
"tier": "low",
|
||||||
"provider": "glm",
|
"provider": "glm",
|
||||||
"model": "GLM-4.7",
|
"model": "qwen-plus",
|
||||||
"api_env": "GLM_API_KEY",
|
"api_env": "DASHSCOPE_API_KEY",
|
||||||
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
"match_any": [
|
"match_any": [
|
||||||
"feishu_level_classification",
|
"feishu_level_classification",
|
||||||
"feishu_signal_extraction",
|
"feishu_signal_extraction",
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
@ -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")
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
@ -10,40 +9,39 @@ from fastapi import APIRouter, Depends
|
||||||
from ..core.response import success_response
|
from ..core.response import success_response
|
||||||
from ..dependencies.auth import get_current_user
|
from ..dependencies.auth import get_current_user
|
||||||
from ..models import 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"])
|
router = APIRouter(prefix="/ai-steward/llm", tags=["AI Steward"])
|
||||||
|
|
||||||
|
|
||||||
def _build_provider_payload() -> dict[str, Any]:
|
def _build_provider_payload() -> dict[str, Any]:
|
||||||
low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", "GLM-4.7") or "GLM-4.7").strip()
|
provider_defaults = get_provider_defaults()
|
||||||
high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", "MiniMax-M2.5") or "MiniMax-M2.5").strip()
|
tier_defaults = get_tier_defaults()
|
||||||
base_url = str(os.getenv("OPENAI_BASE_URL", "https://api.edgefn.net/v1") or "https://api.edgefn.net/v1").strip()
|
low_tier = dict(tier_defaults.get("low") or {})
|
||||||
ollama_base_url = str(
|
high_tier = dict(tier_defaults.get("high") or {})
|
||||||
os.getenv("OLLAMA_BASE_URL", os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")) or "http://127.0.0.1:11434"
|
|
||||||
).strip()
|
|
||||||
return {
|
return {
|
||||||
"low": low_model,
|
"low": str(low_tier.get("model") or provider_defaults.get("glm", {}).get("model") or "qwen-plus").strip(),
|
||||||
"high": high_model,
|
"high": str(high_tier.get("model") or "GLM-5").strip(),
|
||||||
"base_url": base_url,
|
"base_url": str(high_tier.get("base_url") or provider_defaults.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1").strip(),
|
||||||
"providers": {
|
"providers": {
|
||||||
"glm": {
|
"glm": {
|
||||||
"model": str(os.getenv("GLM_MODEL", low_model) or low_model).strip(),
|
"model": str(provider_defaults.get("glm", {}).get("model") or "qwen-plus").strip(),
|
||||||
"base_url": str(os.getenv("GLM_BASE_URL", base_url) or base_url).strip(),
|
"base_url": str(provider_defaults.get("glm", {}).get("base_url") or "https://dashscope.aliyuncs.com/compatible-mode/v1").strip(),
|
||||||
"api_env": "GLM_API_KEY",
|
"api_env": "DASHSCOPE_API_KEY",
|
||||||
},
|
},
|
||||||
"openai": {
|
"openai": {
|
||||||
"model": str(os.getenv("OPENAI_MODEL", high_model) or high_model).strip(),
|
"model": str(provider_defaults.get("openai", {}).get("model") or "MiniMax-M2.5").strip(),
|
||||||
"base_url": base_url,
|
"base_url": str(provider_defaults.get("openai", {}).get("base_url") or "https://api.edgefn.net/v1").strip(),
|
||||||
"api_env": "OPENAI_API_KEY",
|
"api_env": "OPENAI_API_KEY",
|
||||||
},
|
},
|
||||||
"deepseek": {
|
"deepseek": {
|
||||||
"model": str(os.getenv("DEEPSEEK_MODEL", "deepseek-chat") or "deepseek-chat").strip(),
|
"model": str(provider_defaults.get("deepseek", {}).get("model") or "deepseek-chat").strip(),
|
||||||
"base_url": str(os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") or "").strip(),
|
"base_url": str(provider_defaults.get("deepseek", {}).get("base_url") or "https://api.deepseek.com/v1").strip(),
|
||||||
"api_env": "DEEPSEEK_API_KEY",
|
"api_env": "DEEPSEEK_API_KEY",
|
||||||
},
|
},
|
||||||
"ollama": {
|
"ollama": {
|
||||||
"model": str(os.getenv("OLLAMA_MODEL", "llama3:latest") or "llama3:latest").strip(),
|
"model": str(provider_defaults.get("ollama", {}).get("model") or "llama3:latest").strip(),
|
||||||
"base_url": ollama_base_url,
|
"base_url": str(provider_defaults.get("ollama", {}).get("base_url") or "http://127.0.0.1:11434").strip(),
|
||||||
"api_env": "",
|
"api_env": "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
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
|
from .schemas import ProjectCreateRequest, ProjectMembersAssignRequest, ProjectUpdateRequest
|
||||||
|
|
||||||
@router.post("/")
|
@router.post("/")
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,14 @@ import re
|
||||||
|
|
||||||
from .shared import *
|
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.entity_lookup import _ensure_charter, _ensure_project
|
||||||
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
|
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"])
|
router = APIRouter(tags=["sstc"])
|
||||||
|
|
||||||
|
|
@ -17,6 +22,7 @@ def _task_title_key(value: str) -> str:
|
||||||
text = str(value or "").strip().lower()
|
text = str(value or "").strip().lower()
|
||||||
return _TITLE_NORMALIZE_RE.sub("", text)
|
return _TITLE_NORMALIZE_RE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown")
|
@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown")
|
||||||
async def ai_breakdown_tasks(
|
async def ai_breakdown_tasks(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|
@ -30,25 +36,32 @@ async def ai_breakdown_tasks(
|
||||||
pid = _parse_project_id(project_id)
|
pid = _parse_project_id(project_id)
|
||||||
cid = _parse_node_id(charter_id, "charter_id")
|
cid = _parse_node_id(charter_id, "charter_id")
|
||||||
tenant_id = int(current_user.tenant_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)
|
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
|
||||||
_assert_charter_aligned(charter)
|
_assert_charter_aligned(charter)
|
||||||
|
|
||||||
prompt = f"""请基于以下需求宪章拆解 5-8 条任务,返回 JSON 数组:
|
charter_context = _build_task_breakdown_context(
|
||||||
[
|
description=str(charter.title or project.title or ""),
|
||||||
{{"task_name":"", "type":"backend/frontend/ai/devops", "estimated_hours": 4.0, "priority":"P0/P1/P2"}}
|
project=project,
|
||||||
]
|
charter=charter,
|
||||||
|
)
|
||||||
宪章标题:{charter.title}
|
available_skills = await _load_available_skill_names(db, tenant_id=tenant_id)
|
||||||
宪章内容:{_as_dict(charter.content)}
|
prompt = _build_task_breakdown_prompt(
|
||||||
"""
|
project_id=pid,
|
||||||
|
charter_context=charter_context,
|
||||||
|
available_skills=available_skills,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
rows = await llm_service.generate_task_breakdown_async(
|
raw_text = await llm_service.generate(
|
||||||
prompt,
|
prompt,
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
project_id=pid,
|
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:
|
except RuntimeError as exc:
|
||||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -73,7 +86,7 @@ async def ai_breakdown_tasks(
|
||||||
created = []
|
created = []
|
||||||
skipped_duplicates = 0
|
skipped_duplicates = 0
|
||||||
prompt_tokens = max(len(prompt) // 4, 1)
|
prompt_tokens = max(len(prompt) // 4, 1)
|
||||||
completion_tokens = 0
|
completion_tokens = max(len(raw_text) // 4, 0)
|
||||||
for item in rows or []:
|
for item in rows or []:
|
||||||
title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32)
|
title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32)
|
||||||
if not title:
|
if not title:
|
||||||
|
|
@ -108,7 +121,6 @@ async def ai_breakdown_tasks(
|
||||||
refresh_workload=False,
|
refresh_workload=False,
|
||||||
)
|
)
|
||||||
created.append(task)
|
created.append(task)
|
||||||
completion_tokens += max(len(title) // 2, 1)
|
|
||||||
|
|
||||||
db.add(
|
db.add(
|
||||||
AICallLog(
|
AICallLog(
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,11 @@ from ..schemas import TaskBreakdownRequest, TaskBreakdownResponse
|
||||||
from ..services.llm_service import llm_service
|
from ..services.llm_service import llm_service
|
||||||
from ..services.integrations.integration_core.plane_service import plane_service
|
from ..services.integrations.integration_core.plane_service import plane_service
|
||||||
from ..services.skill import recommend_personnel
|
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"])
|
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||||||
|
|
||||||
|
|
@ -23,16 +28,38 @@ async def breakdown_tasks(
|
||||||
):
|
):
|
||||||
"""AI 任务拆解"""
|
"""AI 任务拆解"""
|
||||||
tenant_id = int(current_user.tenant_id)
|
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:
|
try:
|
||||||
tasks = await llm_service.generate_task_breakdown_async(
|
raw_text = await llm_service.generate(
|
||||||
request.description,
|
prompt,
|
||||||
db=db,
|
db=db,
|
||||||
tenant_id=tenant_id,
|
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:
|
except RuntimeError:
|
||||||
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
|
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
|
||||||
if not tasks:
|
except Exception as exc:
|
||||||
raise HTTPException(status_code=500, detail="Failed to generate task breakdown")
|
raise HTTPException(status_code=500, detail=f"Failed to generate task breakdown: {exc}")
|
||||||
|
|
||||||
sync_result = None
|
sync_result = None
|
||||||
if request.sync_to_plane:
|
if request.sync_to_plane:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -7,7 +7,60 @@ AI Prompt 集中管理
|
||||||
使用 .format() 或 f-string 时需保证占位符与调用处一致。
|
使用 .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:
|
def build_default_role_prompt_bundle() -> dict:
|
||||||
"""返回默认角色 Prompt 配置(每次调用返回新对象)。"""
|
"""返回默认角色 Prompt 配置(每次调用返回新对象)。"""
|
||||||
|
|
@ -17,25 +70,28 @@ def build_default_role_prompt_bundle() -> dict:
|
||||||
"roles": [
|
"roles": [
|
||||||
{
|
{
|
||||||
"role_key": "pm_dispatcher",
|
"role_key": "pm_dispatcher",
|
||||||
"role_name": "产品经理(任务分派)",
|
"role_name": "产品经理(立项评审)",
|
||||||
"role_description": "负责澄清需求并给出可执行任务分派建议",
|
"role_description": "负责查重、评审、起草立项建议并给出确认前的产品方案",
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"system_prompt": """
|
"system_prompt": """
|
||||||
你是 PMFlow 的任务分派产品经理。
|
你是 PMFlow 的产品经理助手。
|
||||||
|
|
||||||
职责定位:
|
职责定位:
|
||||||
1. 先理解需求背景,再输出执行计划。
|
1. 先判断是否已有同名或相似项目,再给出是否复用、合并或新建的建议。
|
||||||
2. 重点给出可落地的任务分派建议,避免空泛讨论。
|
2. 先输出产品评审结论,再展开项目定位、MVP、前景、需求清单、风险与负责人建议。
|
||||||
3. 回复风格要专业、直接、有人味,优先给结论。
|
3. 回复风格要专业、清晰、像真实产品经理评审,允许给出取舍与备选方案。
|
||||||
|
4. 只有在用户明确确认后,才进入写入或继续执行流程。
|
||||||
|
|
||||||
输出要求:
|
输出要求:
|
||||||
1. 先用 1-2 句话复述需求并确认目标。
|
1. 先用 1-2 句话复述需求并判断立项方向。
|
||||||
2. 再给 3 条以内“下一步动作”,每条都要可执行。
|
2. 再给出是否有同名/相似项目的判断。
|
||||||
3. 若信息不足,只提关键澄清问题(最多 2 个),不要臆测。
|
3. 再展开项目定位、MVP、前景、需求清单、风险与待补充信息。
|
||||||
4. 如果涉及风险、依赖或排期冲突,要明确提醒。
|
4. 如果信息不足,只提关键澄清问题(最多 3 个),不要臆测。
|
||||||
|
5. 如果涉及风险、依赖、排期冲突或重复建设,要明确提醒。
|
||||||
|
|
||||||
禁止项:
|
禁止项:
|
||||||
- 不要输出与项目无关的泛化建议。
|
- 不要输出与项目无关的泛化建议。
|
||||||
|
- 不要只给机械化模板,不要把评审写成简单入库文案。
|
||||||
- 不要使用命令式生硬语气。
|
- 不要使用命令式生硬语气。
|
||||||
- 不要编造不存在的数据或结论。
|
- 不要编造不存在的数据或结论。
|
||||||
""".strip(),
|
""".strip(),
|
||||||
|
|
@ -101,6 +157,7 @@ __all__ = [
|
||||||
"PROMPT_DRAFT_GENERATOR",
|
"PROMPT_DRAFT_GENERATOR",
|
||||||
"PROMPT_RAG_INTENT_CLASSIFICATION",
|
"PROMPT_RAG_INTENT_CLASSIFICATION",
|
||||||
"PROMPT_RAG_ANSWER",
|
"PROMPT_RAG_ANSWER",
|
||||||
|
"PROMPT_DECISION_QA",
|
||||||
"PROMPT_RAG_DB_PROJECT_QA",
|
"PROMPT_RAG_DB_PROJECT_QA",
|
||||||
"PROMPT_GENERATE_CHARTER",
|
"PROMPT_GENERATE_CHARTER",
|
||||||
"PROMPT_DECOMPOSE_TASKS",
|
"PROMPT_DECOMPOSE_TASKS",
|
||||||
|
|
@ -123,5 +180,11 @@ __all__ = [
|
||||||
"PROMPT_MEMBER_WORKLOAD_QA",
|
"PROMPT_MEMBER_WORKLOAD_QA",
|
||||||
"PROMPT_AI_PMO_QA",
|
"PROMPT_AI_PMO_QA",
|
||||||
"PROMPT_EXTERNAL_TREND_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",
|
"build_default_role_prompt_bundle",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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_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_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_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_01 import PROMPT_FEISHU_DIRECT_QA
|
||||||
from .part_scenes_02 import PROMPT_FEISHU_MIXED_QA
|
from .part_scenes_02 import PROMPT_FEISHU_MIXED_QA
|
||||||
from .part_scenes_03 import PROMPT_PROJECT_STATUS_QA
|
from .part_scenes_03 import PROMPT_PROJECT_STATUS_QA
|
||||||
from .part_scenes_04 import PROMPT_MEMBER_WORKLOAD_QA
|
from .part_scenes_04 import PROMPT_MEMBER_WORKLOAD_QA
|
||||||
from .part_scenes_05 import PROMPT_AI_PMO_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__ = [
|
__all__ = [
|
||||||
"PROMPT_IMPACT_ANALYSIS",
|
"PROMPT_IMPACT_ANALYSIS",
|
||||||
|
|
@ -33,6 +33,7 @@ __all__ = [
|
||||||
"PROMPT_DRAFT_GENERATOR",
|
"PROMPT_DRAFT_GENERATOR",
|
||||||
"PROMPT_RAG_INTENT_CLASSIFICATION",
|
"PROMPT_RAG_INTENT_CLASSIFICATION",
|
||||||
"PROMPT_RAG_ANSWER",
|
"PROMPT_RAG_ANSWER",
|
||||||
|
"PROMPT_DECISION_QA",
|
||||||
"PROMPT_RAG_DB_PROJECT_QA",
|
"PROMPT_RAG_DB_PROJECT_QA",
|
||||||
"PROMPT_GENERATE_CHARTER",
|
"PROMPT_GENERATE_CHARTER",
|
||||||
"PROMPT_DECOMPOSE_TASKS",
|
"PROMPT_DECOMPOSE_TASKS",
|
||||||
|
|
@ -55,4 +56,5 @@ __all__ = [
|
||||||
"PROMPT_MEMBER_WORKLOAD_QA",
|
"PROMPT_MEMBER_WORKLOAD_QA",
|
||||||
"PROMPT_AI_PMO_QA",
|
"PROMPT_AI_PMO_QA",
|
||||||
"PROMPT_EXTERNAL_TREND_QA",
|
"PROMPT_EXTERNAL_TREND_QA",
|
||||||
|
"PROMPT_INDUSTRY_TREND_QA",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,18 @@ PROMPT_FEISHU_PROJECT_PREFILL = """
|
||||||
{{
|
{{
|
||||||
"suggested_name": "项目名",
|
"suggested_name": "项目名",
|
||||||
"suggested_description": "1-2句描述",
|
"suggested_description": "1-2句描述",
|
||||||
"suggested_owner": "负责人名或角色",
|
"suggested_owner": "负责人名或角色;不明确时返回 null",
|
||||||
"suggested_timeline": "截止日期或周期",
|
"suggested_timeline": "截止日期或周期;不明确时返回 null",
|
||||||
"confidence": 0.0-1.0,
|
"confidence": 0.0-1.0,
|
||||||
"reasoning": ["依据1","依据2","依据3"]
|
"reasoning": ["依据1","依据2","依据3"]
|
||||||
}}
|
}}
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
1. reasoning 必须是 3 条简短 bullet。
|
1. reasoning 必须是 3 条简短 bullet。
|
||||||
2. 没有明确字段时可合理推断并在 reasoning 说明。
|
2. 只有在消息里明确出现负责人、角色、截止时间、周期时才填写对应字段。
|
||||||
3. 只输出 JSON。
|
3. 不要为了“填满字段”去猜负责人或时间线;不明确时返回 null。
|
||||||
|
4. confidence 低于 0.6 时,suggested_owner 和 suggested_timeline 优先返回 null。
|
||||||
|
5. 只输出 JSON。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
||||||
|
|
@ -53,6 +55,7 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
- project_json: {project_json}
|
- project_json: {project_json}
|
||||||
|
- charter_json: {charter_json}
|
||||||
- signal_json: {signal_json}
|
- signal_json: {signal_json}
|
||||||
- members_json: {members_json}
|
- members_json: {members_json}
|
||||||
|
|
||||||
|
|
@ -74,11 +77,13 @@ PROMPT_FEISHU_TASK_ORCHESTRATION = """
|
||||||
}}
|
}}
|
||||||
|
|
||||||
约束:
|
约束:
|
||||||
1. 任务数量 2-8 条,不要空数组。
|
1. 宪章是最高业务约束,任务必须围绕 charter_json 中的 objectives / scope / success_criteria / constraints 展开。
|
||||||
2. 优先覆盖“立即可执行”的任务,避免泛化管理话术。
|
2. 如果 charter_json 为空,先依据 project_json 与 signal_json 做最小可执行拆解,不要自行编造宪章。
|
||||||
3. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。
|
3. 任务数量 2-8 条,不要空数组。
|
||||||
4. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。
|
4. 优先覆盖“立即可执行”的任务,避免泛化管理话术。
|
||||||
5. 只输出 JSON,不要 Markdown,不要解释。
|
5. `recommended_assignee_id` 必须来自 members_json.user_id;无法确认时填 null。
|
||||||
|
6. 推荐负责人时同时考虑技能匹配与 workload_ratio,避免把任务继续压给高负载成员。
|
||||||
|
7. 只输出 JSON,不要 Markdown,不要解释。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
PROMPT_SIGNAL_SOURCE_ONBOARDING = """
|
||||||
|
|
@ -166,13 +171,41 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||||
|
|
||||||
只输出一个 JSON 对象(不要解释、不要 Markdown):
|
只输出一个 JSON 对象(不要解释、不要 Markdown):
|
||||||
{{
|
{{
|
||||||
"family": "query|project_create|task|project_signal|signal_source",
|
"family": "query|project_create|project|task|personnel|knowledge|notification|project_signal|signal_source|followup",
|
||||||
"action": "answer|create|discover|onboard",
|
"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|task.create|signal.create|signal_source.onboard|followup.continue",
|
"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,
|
"confidence": 0.0,
|
||||||
"project_id": 0,
|
"project_id": 0,
|
||||||
"requested_count": 1,
|
"requested_count": 1,
|
||||||
"requires_confirmation": false,
|
"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": "一句话简述判定原因"
|
"reasoning": "一句话简述判定原因"
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
|
@ -180,22 +213,28 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """
|
||||||
1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。
|
1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。
|
||||||
2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。
|
2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。
|
||||||
3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。
|
3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。
|
||||||
4. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。
|
4. project.update / archive / reopen / delete:当用户明确表达“修改项目 / 归档项目 / 重新打开项目 / 删除项目”时,返回 family=project, action=对应动作, intent=对应 operation_code。
|
||||||
5. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。
|
5. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。
|
||||||
6. signal.create few-shot:
|
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
|
- “给XX项目加个需求” -> signal.create
|
||||||
- “新增一个功能需求” -> signal.create
|
- “新增一个功能需求” -> signal.create
|
||||||
- “帮我记录这个想法” -> signal.create
|
- “帮我记录这个想法” -> signal.create
|
||||||
- “提个需求:支持导出” -> signal.create
|
- “提个需求:支持导出” -> signal.create
|
||||||
7. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。
|
12. 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。
|
13. 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(用于草稿确认执行)。
|
14. 仅当消息明确 reply_to 一条挂起草稿(reply_to_message_id 命中待确认草稿)且含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。
|
||||||
10. 若 session_last_intent=draft_pending_confirm 且消息含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。
|
15. 仅当消息明确 reply_to 一条挂起草稿且含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。
|
||||||
11. 若 session_last_intent=draft_pending_confirm 且消息含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。
|
16. 仅当消息明确 reply_to 一条挂起草稿且含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。
|
||||||
12. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。
|
17. 若没有 reply_to 挂起草稿锚点,不要因为确认词直接进入 followup.continue;无把握时返回 query.read_only。
|
||||||
13. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。
|
18. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。
|
||||||
14. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。
|
19. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。
|
||||||
15. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。
|
20. 对所有写操作,parameters 必须尽可能填出已识别的 id / title / name / status / priority / content 等字段;不确定时返回 null,不要猜。
|
||||||
|
21. 若请求 DDL(建表/改表/删表),必须返回 query.read_only,reasoning 含 ddl_api_required。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
||||||
|
|
@ -231,10 +270,12 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """
|
||||||
PROMPT_DRAFT_GENERATOR = """
|
PROMPT_DRAFT_GENERATOR = """
|
||||||
首字符必须是 {{,末字符必须是 }}。
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
你是 PMFlow 的资深产品经理。
|
你是 PMFlow 的资深产品经理。
|
||||||
收到一条新的业务需求,请起草分析报告。
|
收到一条新的业务需求,请先按产品经理视角做立项评审,再起草详细方案。
|
||||||
|
|
||||||
输入:
|
输入:
|
||||||
- 项目信息:{project_title} - {project_description}
|
- 当前上下文项目:{project_title} - {project_description}
|
||||||
|
- 现有活跃项目(JSON):{active_projects_json}
|
||||||
|
- 候选相似项目(JSON):{similar_project_candidates_json}
|
||||||
- 团队成员(按负载排序):{members_json}
|
- 团队成员(按负载排序):{members_json}
|
||||||
- 最近同类需求:{recent_signals}
|
- 最近同类需求:{recent_signals}
|
||||||
- 用户需求:{user_message}
|
- 用户需求:{user_message}
|
||||||
|
|
@ -243,7 +284,12 @@ PROMPT_DRAFT_GENERATOR = """
|
||||||
|
|
||||||
请输出 JSON,包含:
|
请输出 JSON,包含:
|
||||||
- title: 语义化标题(10字以内,必须概括目标,不得复述原话)
|
- 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: 合理|待讨论|不合理
|
||||||
- feasibility_reason: 判断理由(30字以内)
|
- feasibility_reason: 判断理由(30字以内)
|
||||||
- recommended_assignee_id: 推荐成员ID
|
- recommended_assignee_id: 推荐成员ID
|
||||||
|
|
@ -257,10 +303,12 @@ PROMPT_DRAFT_GENERATOR = """
|
||||||
- 明确的功能需求且技术可实现 => 合理
|
- 明确的功能需求且技术可实现 => 合理
|
||||||
- 需求模糊、边界冲突或依赖不清 => 待讨论
|
- 需求模糊、边界冲突或依赖不清 => 待讨论
|
||||||
- 明显不可行或超出系统范围 => 不合理
|
- 明显不可行或超出系统范围 => 不合理
|
||||||
4. 推荐负责人优先选择负载低于 80% 的成员。
|
4. 如果存在同名或高度相似项目,必须写出差异与复用/合并建议。
|
||||||
5. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。
|
5. MVP 建议优先写“最小可验证闭环”,不要写成泛化愿景。
|
||||||
6. followup_plan 最少 1 项,最多 3 项。
|
6. 推荐负责人优先选择负载低于 80% 的成员。
|
||||||
7. 若无法确定推荐人,recommended_assignee_id 返回 null。
|
7. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。
|
||||||
|
8. followup_plan 最少 1 项,最多 3 项。
|
||||||
|
9. 若无法确定推荐人,recommended_assignee_id 返回 null。
|
||||||
""".strip()
|
""".strip()
|
||||||
|
|
||||||
PROMPT_RAG_INTENT_CLASSIFICATION = """
|
PROMPT_RAG_INTENT_CLASSIFICATION = """
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,19 @@ User Snapshot(当前用户):
|
||||||
{context_blocks}
|
{context_blocks}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
PROMPT_DECISION_QA = """
|
||||||
|
你是 PMFlow 的产品顾问,负责处理产品取舍、优先级、二选一和方案比较类问题。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 直接给出明确选择,不要回避,不要反问。
|
||||||
|
2. 如果用户给出两个或多个选项,必须明确选一个。
|
||||||
|
3. 不依赖数据库、历史记录或外部实时数据,基于常识与办公协作场景给出最稳妥建议即可。
|
||||||
|
4. 先给结论,再给 1-2 条理由,回答要简洁。
|
||||||
|
5. 不输出思考过程,不要使用“让我/我来/我先/我需要”开头。
|
||||||
|
|
||||||
|
问题:{question}
|
||||||
|
"""
|
||||||
|
|
||||||
PROMPT_RAG_DB_PROJECT_QA = """
|
PROMPT_RAG_DB_PROJECT_QA = """
|
||||||
你是 PMFlow 的项目数据语义检索助手。
|
你是 PMFlow 的项目数据语义检索助手。
|
||||||
你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。
|
你会收到“用户问题”和“项目索引(JSON)”,请基于索引做语义匹配后回答。
|
||||||
|
|
@ -61,10 +74,20 @@ PROMPT_RAG_DB_PROJECT_QA = """
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_GENERATE_CHARTER = """
|
PROMPT_GENERATE_CHARTER = """
|
||||||
你是一位资深产品经理。根据以下市场/技术信号,生成一份结构化的需求宪章草案。
|
首字符必须是 {{,末字符必须是 }}。
|
||||||
|
你是一位 PMFlow 的资深产品经理。根据当前项目上下文与输入信号,生成一份结构化的需求宪章草案。
|
||||||
|
|
||||||
信号内容:
|
输入:
|
||||||
{signal_content}
|
- 当前项目上下文:{project_context}
|
||||||
|
- 现有活跃项目(JSON):{active_projects_json}
|
||||||
|
- 信号内容:{signal_content}
|
||||||
|
|
||||||
|
请先判断这条需求更适合作为:
|
||||||
|
1. 当前项目的延展;
|
||||||
|
2. 现有活跃项目的复用/合并;
|
||||||
|
3. 必须新建项目。
|
||||||
|
|
||||||
|
如果存在同名或高度相似项目,必须在背景、范围和风险里明确写出差异点、复用点与合并建议。
|
||||||
|
|
||||||
请输出 JSON 格式,包含以下字段:
|
请输出 JSON 格式,包含以下字段:
|
||||||
{{
|
{{
|
||||||
|
|
@ -81,7 +104,7 @@ PROMPT_GENERATE_CHARTER = """
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PROMPT_DECOMPOSE_TASKS = """
|
PROMPT_DECOMPOSE_TASKS = """
|
||||||
你是一位 AI 技术项目经理。根据以下需求宪章内容,拆解为可执行的任务列表。
|
你是一位 AI 技术项目经理。根据以下需求宪章与项目上下文,拆解为可执行的任务列表。
|
||||||
项目 ID:{project_id}。
|
项目 ID:{project_id}。
|
||||||
|
|
||||||
宪章内容:
|
宪章内容:
|
||||||
|
|
@ -89,6 +112,12 @@ PROMPT_DECOMPOSE_TASKS = """
|
||||||
|
|
||||||
请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。
|
请识别每个任务所需的 AI 开发技能标签(仅从以下系统技能中匹配:{available_skills})。
|
||||||
|
|
||||||
|
规则:
|
||||||
|
1. 如果宪章内容是结构化 JSON,请优先读取其中的 `request` / `project` / `charter` / `modules` 字段。
|
||||||
|
2. 优先围绕 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 拆解任务。
|
||||||
|
3. 任务要尽量贴近可执行产出,不要输出空泛的管理话术。
|
||||||
|
4. 如果上下文同时给出项目与宪章信息,以宪章约束为主,项目信息用于补充边界。
|
||||||
|
|
||||||
输出 JSON 格式:
|
输出 JSON 格式:
|
||||||
{{
|
{{
|
||||||
"tasks": [
|
"tasks": [
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ PROMPT_SIGNAL_PLANNER = """
|
||||||
- 项目ID:{project_id}
|
- 项目ID:{project_id}
|
||||||
- 项目标题:{project_title}
|
- 项目标题:{project_title}
|
||||||
- 项目归属判定:{project_scope}
|
- 项目归属判定:{project_scope}
|
||||||
|
- 宪章内容(JSON):{charter_json}
|
||||||
- 最近相关信号(JSON):{recent_signals}
|
- 最近相关信号(JSON):{recent_signals}
|
||||||
- 项目成员能力画像(JSON):{member_profiles}
|
- 项目成员能力画像(JSON):{member_profiles}
|
||||||
- 当前时间(北京时间):{now_cn}
|
- 当前时间(北京时间):{now_cn}
|
||||||
|
|
@ -186,10 +187,12 @@ PROMPT_SIGNAL_PLANNER = """
|
||||||
}}
|
}}
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 若无法确定负责人,assignee_user_id 返回 null,且在 assignee_reason 中说明原因。
|
1. 宪章是最高业务约束。若 `charter_json` 非空,优先依据其中的 `objectives`、`scope`、`success_criteria`、`constraints`、`milestones`、`risks` 做规划。
|
||||||
2. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。
|
2. 若 `charter_json` 为空,则继续基于项目、近期信号与成员画像做最小可执行规划,不要编造不存在的宪章内容。
|
||||||
3. confidence 范围 0-1,不能省略。
|
3. 若无法确定负责人,assignee_user_id 返回 null,且在 assignee_reason 中说明原因。
|
||||||
4. 只输出 JSON,不要输出 Markdown 或解释文本。
|
4. execution_plan 必须是 2-5 条可执行动作,不要空泛描述。
|
||||||
|
5. confidence 范围 0-1,不能省略。
|
||||||
|
6. 只输出 JSON,不要输出 Markdown 或解释文本。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,29 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_FEISHU_DIRECT_QA = """
|
FEISHU_QA_RESPONSE_STYLE = """
|
||||||
你是 PMFlow 的项目管理助手。
|
回复风格要求:
|
||||||
用户通过飞书直接提问。
|
1. 先给结论,再给关键依据,最后再给下一步。
|
||||||
|
2. 用正式、克制、面向协作的中文,像资深项目经理而不是聊天机器人。
|
||||||
|
3. 如果能用数字、项目名、负责人、时间点表达,请直接写明。
|
||||||
|
4. 若信息不足,必须明确说明缺少什么,最多列 3 项。
|
||||||
|
5. 禁止自我介绍、寒暄、思考过程、数据库/模型/接口实现细节。
|
||||||
|
""".strip()
|
||||||
|
|
||||||
规则:
|
PROMPT_FEISHU_DIRECT_QA = "\n\n".join(
|
||||||
1. 只基于给定上下文回答
|
[
|
||||||
2. 证据不足直接说"信息不足,缺少:XXX"
|
"""
|
||||||
3. 不输出思考过程,直接给答案
|
你是 PMFlow 的飞书项目管理顾问,负责直接问答。
|
||||||
4. 回答简洁,适合飞书消息阅读(不超过200字)
|
""".strip(),
|
||||||
5. 涉及项目数据优先用数字说话
|
FEISHU_QA_RESPONSE_STYLE,
|
||||||
|
"""
|
||||||
|
回答要求:
|
||||||
|
1. 仅基于给定上下文回答,优先使用结构化数据。
|
||||||
|
2. 如果问题涉及项目状态、进展、成员或任务,请优先给出可验证的事实和数字。
|
||||||
|
3. 如果问题涉及选择、建议或判断,直接给出明确结论和 1-2 条理由。
|
||||||
|
4. 信息不足时,直接写“信息不足,缺少:...”并列出关键缺失项。
|
||||||
|
5. 控制在 220 字以内,适合飞书消息阅读。
|
||||||
|
6. 不输出 Markdown、JSON 或思考过程。
|
||||||
|
7. 禁止以“让我先/我来看看/我需要先”等句式开头。
|
||||||
|
|
||||||
当前项目ID:{project_id}
|
当前项目ID:{project_id}
|
||||||
问题:{question}
|
问题:{question}
|
||||||
|
|
@ -17,6 +31,8 @@ System Snapshot:{system_snapshot}
|
||||||
User Snapshot:{user_snapshot}
|
User Snapshot:{user_snapshot}
|
||||||
最近会话:{conversation}
|
最近会话:{conversation}
|
||||||
检索上下文:{context_blocks}
|
检索上下文:{context_blocks}
|
||||||
""".strip()
|
""".strip(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["PROMPT_FEISHU_DIRECT_QA"]
|
__all__ = ["FEISHU_QA_RESPONSE_STYLE", "PROMPT_FEISHU_DIRECT_QA"]
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_FEISHU_MIXED_QA = """
|
from .part_scenes_01 import FEISHU_QA_RESPONSE_STYLE
|
||||||
你是 PMFlow 的项目管理助手。
|
|
||||||
用户消息同时包含问题和需求。
|
|
||||||
|
|
||||||
|
PROMPT_FEISHU_MIXED_QA = "\n\n".join(
|
||||||
|
[
|
||||||
|
"""
|
||||||
|
你是 PMFlow 的飞书项目管理顾问,负责处理“问题 + 需求”混合消息。
|
||||||
|
""".strip(),
|
||||||
|
FEISHU_QA_RESPONSE_STYLE,
|
||||||
|
"""
|
||||||
处理规则:
|
处理规则:
|
||||||
1. 先回答问题部分(简洁,不超过100字)
|
1. 先回答问题部分,使用 1 句到 2 句的专业结论,最多 100 字。
|
||||||
2. 确认已收到需求部分
|
2. 再单独确认需求部分,格式必须是:
|
||||||
3. 格式:
|
[回答] ...
|
||||||
[回答] xxx
|
[已收到需求] ...,已入库处理
|
||||||
[已收到需求] xxx,已入库处理
|
3. [已收到需求] 里的内容要像项目经理确认记录,简洁、明确、可执行,不要空话。
|
||||||
|
4. 若问题部分信息不足,先写缺失项,再给最稳妥的结论。
|
||||||
禁止:
|
5. 不要把需求部分当问题回答,也不要混成一段。
|
||||||
- 输出思考过程
|
6. 不输出 Markdown、JSON 或思考过程。
|
||||||
- 把需求部分当问题回答
|
|
||||||
- 合并两部分混着说
|
|
||||||
|
|
||||||
当前项目ID:{project_id}
|
当前项目ID:{project_id}
|
||||||
消息:{message}
|
消息:{message}
|
||||||
|
|
@ -22,6 +25,8 @@ PROMPT_FEISHU_MIXED_QA = """
|
||||||
需求部分:{signal_part}
|
需求部分:{signal_part}
|
||||||
System Snapshot:{system_snapshot}
|
System Snapshot:{system_snapshot}
|
||||||
检索上下文:{context_blocks}
|
检索上下文:{context_blocks}
|
||||||
""".strip()
|
""".strip(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["PROMPT_FEISHU_MIXED_QA"]
|
__all__ = ["PROMPT_FEISHU_MIXED_QA"]
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,20 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
PROMPT_PROJECT_STATUS_QA = """
|
PROMPT_PROJECT_STATUS_QA = """
|
||||||
你是 PMFlow 的项目状态语义检索助手。
|
你是 PMFlow 的项目状态分析助手。
|
||||||
用户关注项目进度、状态、完成率。
|
用户关注项目进度、状态、完成率、阶段和卡点。
|
||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 只基于给定上下文回答
|
1. 只基于给定上下文回答,不要编造项目现状。
|
||||||
2. 禁止输出思考过程
|
2. 先给当前结论,再给量化进展,再给卡点或下一步。
|
||||||
3. 用数字量化回答:
|
3. 优先用数字回答:
|
||||||
完成率X%,已完成X个任务,进行中X个,待办X个
|
完成率X%,已完成X个任务,进行中X个,待办X个。
|
||||||
4. 涉及多个项目时用列表格式
|
4. 如果能识别出当前阶段,请明确写出“项目阶段”。
|
||||||
5. 证据不足直接说:
|
5. 如果上下文里有负责人或关键角色,请补充“当前负责人”。
|
||||||
|
6. 涉及多个项目时,用列表分别说明。
|
||||||
|
7. 证据不足时直接写:
|
||||||
信息不足,缺少:XXX
|
信息不足,缺少:XXX
|
||||||
|
8. 禁止输出思考过程、系统实现细节或空泛套话。
|
||||||
|
|
||||||
当前项目ID:{project_id}
|
当前项目ID:{project_id}
|
||||||
问题:{question}
|
问题:{question}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@ PROMPT_MEMBER_WORKLOAD_QA = """
|
||||||
1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据
|
1. 优先使用 System Snapshot.team_members 和 User Snapshot 数据
|
||||||
2. 禁止默认返回项目列表
|
2. 禁止默认返回项目列表
|
||||||
3. 涉及负载用百分比表示
|
3. 涉及负载用百分比表示
|
||||||
4. 推荐负责人时说明推荐理由
|
4. 忽略与成员负载无关的项目列表和历史项目详情
|
||||||
5. 禁止输出思考过程
|
5. 推荐负责人时说明推荐理由
|
||||||
6. 不超过150字
|
6. 禁止输出思考过程
|
||||||
|
7. 不超过150字
|
||||||
|
|
||||||
问题:{question}
|
问题:{question}
|
||||||
System Snapshot:{system_snapshot}
|
System Snapshot:{system_snapshot}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ PROMPT_EXTERNAL_TREND_QA = """
|
||||||
规则:
|
规则:
|
||||||
1. 若有外部搜索结果优先使用
|
1. 若有外部搜索结果优先使用
|
||||||
2. 若无外部数据明确说:
|
2. 若无外部数据明确说:
|
||||||
"当前未接入实时数据,以下基于训练数据(截止XXXX年)"
|
"当前未接入实时数据,以下基于通用行业认知。"
|
||||||
3. 结合项目实际给出应用建议:
|
3. 结合项目实际给出应用建议:
|
||||||
"对你们[项目名]的影响是..."
|
"对你们[项目名]的影响是..."
|
||||||
4. 禁止输出思考过程
|
4. 禁止输出思考过程
|
||||||
|
|
@ -22,4 +22,19 @@ PROMPT_EXTERNAL_TREND_QA = """
|
||||||
外部搜索结果:{external_results}
|
外部搜索结果:{external_results}
|
||||||
""".strip()
|
""".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"]
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from ..logging import get_logger
|
from ..logging import get_logger
|
||||||
|
from ...core.database import AsyncSessionLocal
|
||||||
|
from ...services.model.model_config import build_tier_runtime
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
@ -18,11 +20,12 @@ def startup_log(message: str, **fields) -> None:
|
||||||
async def probe_model_connectivity_on_startup() -> None:
|
async def probe_model_connectivity_on_startup() -> None:
|
||||||
from ...services.llm_service import llm_service
|
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_runtime = build_tier_runtime("low")
|
||||||
low_model = str(os.getenv("LLM_ROUTING_LOW_MODEL", os.getenv("GLM_MODEL", "GLM-4.7")) or "").strip() or "GLM-4.7"
|
high_runtime = build_tier_runtime("high")
|
||||||
|
low_provider = str(low_runtime.get("provider") or "glm").strip() or "glm"
|
||||||
high_provider = str(os.getenv("LLM_ROUTING_HIGH_PROVIDER", os.getenv("LLM_PROVIDER", "openai")) or "").strip() or "openai"
|
low_model = str(low_runtime.get("model") or "qwen-plus").strip() or "qwen-plus"
|
||||||
high_model = str(os.getenv("LLM_ROUTING_HIGH_MODEL", os.getenv("OPENAI_MODEL", "GLM-5")) or "").strip() or "GLM-5"
|
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]] = [
|
probe_targets: list[tuple[str, str, str]] = [
|
||||||
("LOW", low_provider, low_model),
|
("LOW", low_provider, low_model),
|
||||||
|
|
@ -44,8 +47,33 @@ async def probe_model_connectivity_on_startup() -> None:
|
||||||
|
|
||||||
for level, provider, model in deduped_targets:
|
for level, provider, model in deduped_targets:
|
||||||
try:
|
try:
|
||||||
|
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)
|
runtime = llm_service._env_provider_runtime(provider)
|
||||||
runtime["model"] = model
|
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:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"startup_model_probe_runtime_invalid",
|
"startup_model_probe_runtime_invalid",
|
||||||
|
|
@ -60,6 +88,8 @@ async def probe_model_connectivity_on_startup() -> None:
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
ok = False
|
ok = False
|
||||||
error_text = ""
|
error_text = ""
|
||||||
|
circuit_cls = type(llm_service)
|
||||||
|
saved_circuit_state = deepcopy(getattr(circuit_cls, "_provider_circuit_state", {}))
|
||||||
try:
|
try:
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
llm_service._chat_completion(
|
llm_service._chat_completion(
|
||||||
|
|
@ -88,6 +118,8 @@ async def probe_model_connectivity_on_startup() -> None:
|
||||||
model=model,
|
model=model,
|
||||||
error=error_text,
|
error=error_text,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
circuit_cls._provider_circuit_state = saved_circuit_state
|
||||||
|
|
||||||
elapsed = time.perf_counter() - started_at
|
elapsed = time.perf_counter() - started_at
|
||||||
if ok:
|
if ok:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class FeishuReplyOutbox(Base):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=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)
|
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)
|
chat_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
|
||||||
message_text: Mapped[str] = mapped_column(TEXT, nullable=False)
|
message_text: Mapped[str] = mapped_column(TEXT, nullable=False)
|
||||||
reply_hash: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True)
|
reply_hash: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ class TaskBreakdownRequest(BaseModel):
|
||||||
"""任务拆解请求"""
|
"""任务拆解请求"""
|
||||||
|
|
||||||
description: str
|
description: str
|
||||||
|
project_id: Optional[int] = None
|
||||||
|
charter_id: Optional[int] = None
|
||||||
sync_to_plane: bool = False
|
sync_to_plane: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,15 @@ class RecentSignalItem(TypedDict):
|
||||||
signal_type: str
|
signal_type: str
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveProjectSnapshot(TypedDict):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
status: str
|
||||||
|
owner_id: Optional[int]
|
||||||
|
|
||||||
|
|
||||||
class DecisionContextSnapshot(TypedDict):
|
class DecisionContextSnapshot(TypedDict):
|
||||||
captured_at: str
|
captured_at: str
|
||||||
project_id: Optional[int]
|
project_id: Optional[int]
|
||||||
|
|
@ -116,6 +125,7 @@ class WorldModel(TypedDict):
|
||||||
dependencies: DependencySummary
|
dependencies: DependencySummary
|
||||||
recent_signals: list[RecentSignalItem]
|
recent_signals: list[RecentSignalItem]
|
||||||
decision_context_snapshot: DecisionContextSnapshot
|
decision_context_snapshot: DecisionContextSnapshot
|
||||||
|
active_projects: NotRequired[list[ActiveProjectSnapshot]]
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -131,6 +141,7 @@ __all__ = [
|
||||||
"RiskSummary",
|
"RiskSummary",
|
||||||
"DependencySummary",
|
"DependencySummary",
|
||||||
"RecentSignalItem",
|
"RecentSignalItem",
|
||||||
|
"ActiveProjectSnapshot",
|
||||||
"DecisionContextSnapshot",
|
"DecisionContextSnapshot",
|
||||||
"WorldModel",
|
"WorldModel",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
asked_via=asked_via,
|
asked_via=asked_via,
|
||||||
message_intent=message_intent,
|
message_intent=message_intent,
|
||||||
mixed_signal_part=mixed_signal_part,
|
mixed_signal_part=mixed_signal_part,
|
||||||
|
query_type=query_type,
|
||||||
)
|
)
|
||||||
used_sources = self._dedupe_sources(retrieved)
|
used_sources = self._dedupe_sources(retrieved)
|
||||||
if not used_sources:
|
if not used_sources:
|
||||||
|
|
@ -142,6 +143,7 @@ class AdaptiveRAGAnswerRetrievalMixin:
|
||||||
asked_via=asked_via,
|
asked_via=asked_via,
|
||||||
message_intent=message_intent,
|
message_intent=message_intent,
|
||||||
mixed_signal_part=mixed_signal_part,
|
mixed_signal_part=mixed_signal_part,
|
||||||
|
query_type=query_type,
|
||||||
)
|
)
|
||||||
deep_sources = self._dedupe_sources(deep_retrieved)
|
deep_sources = self._dedupe_sources(deep_retrieved)
|
||||||
if not deep_sources:
|
if not deep_sources:
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,52 @@
|
||||||
"""Adaptive RAG mixin module."""
|
"""Adaptive RAG mixin module."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ...config.ai_prompts import PROMPT_PROJECT_STATUS_QA
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGDeepGuardAnswerMixin:
|
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
|
@staticmethod
|
||||||
def _is_ai_pmo_process_entry(asked_via: Optional[str]) -> bool:
|
def _is_ai_pmo_process_entry(asked_via: Optional[str]) -> bool:
|
||||||
channel = str(asked_via or "").strip().lower()
|
channel = str(asked_via or "").strip().lower()
|
||||||
|
|
@ -20,6 +62,53 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
def _contains_any_keyword(text: str, keywords: tuple[str, ...]) -> bool:
|
def _contains_any_keyword(text: str, keywords: tuple[str, ...]) -> bool:
|
||||||
return any(token in text for token in keywords)
|
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
|
@staticmethod
|
||||||
def _format_mixed_two_block_output(
|
def _format_mixed_two_block_output(
|
||||||
*,
|
*,
|
||||||
|
|
@ -66,15 +155,46 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
system_snapshot_text: str,
|
system_snapshot_text: str,
|
||||||
user_snapshot_text: str,
|
user_snapshot_text: str,
|
||||||
relevance_max: float,
|
relevance_max: float,
|
||||||
|
query_type: Optional[str] = None,
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
normalized_question = re.sub(r"\s+", "", str(question or "").lower())
|
normalized_question = re.sub(r"\s+", "", str(question or "").lower())
|
||||||
member_keywords = ("谁", "成员", "负责人", "负载")
|
member_keywords = ("谁", "成员", "负责人", "负载")
|
||||||
trend_keywords = ("最新", "行业", "市场", "前景", "趋势", "动态")
|
trend_keywords = ("最新", "行业", "市场", "前景", "趋势", "动态")
|
||||||
|
|
||||||
if self._contains_any_keyword(normalized_question, member_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 (
|
return (
|
||||||
"member_workload",
|
"member_workload",
|
||||||
PROMPT_MEMBER_WORKLOAD_QA.format(
|
PROMPT_MEMBER_WORKLOAD_QA.format(
|
||||||
|
question=question,
|
||||||
|
system_snapshot=system_snapshot_text or "{}",
|
||||||
|
user_snapshot=compact_user_snapshot_text or "{}",
|
||||||
|
context_blocks=filtered_context_blocks,
|
||||||
|
).strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if str(message_intent or "").strip().lower() == "mixed":
|
||||||
|
safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充"
|
||||||
|
merged_message = f"{question}\n{safe_signal_part}".strip()
|
||||||
|
return (
|
||||||
|
"mixed",
|
||||||
|
PROMPT_FEISHU_MIXED_QA.format(
|
||||||
|
project_id=project_id if project_id is not None else "unknown",
|
||||||
|
message=merged_message,
|
||||||
|
question_part=question,
|
||||||
|
signal_part=safe_signal_part,
|
||||||
|
system_snapshot=system_snapshot_text or "{}",
|
||||||
|
context_blocks=context_blocks_text or "无",
|
||||||
|
).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,
|
question=question,
|
||||||
system_snapshot=system_snapshot_text or "{}",
|
system_snapshot=system_snapshot_text or "{}",
|
||||||
user_snapshot=user_snapshot_text or "{}",
|
user_snapshot=user_snapshot_text or "{}",
|
||||||
|
|
@ -83,6 +203,24 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
)
|
)
|
||||||
|
|
||||||
if self._contains_any_keyword(normalized_question, trend_keywords):
|
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_context_text = json.dumps(
|
||||||
{
|
{
|
||||||
"project_id": project_id if project_id is not None else "unknown",
|
"project_id": project_id if project_id is not None else "unknown",
|
||||||
|
|
@ -99,19 +237,18 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
external_results=context_blocks_text or "无",
|
external_results=context_blocks_text or "无",
|
||||||
).strip(),
|
).strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if str(message_intent or "").strip().lower() == "mixed":
|
|
||||||
safe_signal_part = str(mixed_signal_part or "").strip() or "需求信息待补充"
|
|
||||||
merged_message = f"{question}\n{safe_signal_part}".strip()
|
|
||||||
return (
|
return (
|
||||||
"mixed",
|
"industry_trend",
|
||||||
PROMPT_FEISHU_MIXED_QA.format(
|
PROMPT_INDUSTRY_TREND_QA.format(
|
||||||
project_id=project_id if project_id is not None else "unknown",
|
question=question,
|
||||||
message=merged_message,
|
).strip(),
|
||||||
question_part=question,
|
)
|
||||||
signal_part=safe_signal_part,
|
|
||||||
system_snapshot=system_snapshot_text or "{}",
|
if self._is_decision_choice_question(question):
|
||||||
context_blocks=context_blocks_text or "无",
|
return (
|
||||||
|
"decision_choice",
|
||||||
|
PROMPT_DECISION_QA.format(
|
||||||
|
question=question,
|
||||||
).strip(),
|
).strip(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -264,6 +401,89 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
lines.append("来源: System Snapshot / User Snapshot")
|
lines.append("来源: System Snapshot / User Snapshot")
|
||||||
return "\n".join(lines)[:3200]
|
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(
|
async def _generate_answer(
|
||||||
self,
|
self,
|
||||||
question: str,
|
question: str,
|
||||||
|
|
@ -280,9 +500,13 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
asked_via: str = "pmflow",
|
asked_via: str = "pmflow",
|
||||||
message_intent: Optional[str] = None,
|
message_intent: Optional[str] = None,
|
||||||
mixed_signal_part: Optional[str] = None,
|
mixed_signal_part: Optional[str] = None,
|
||||||
|
query_type: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
active_thinking_mode = self._normalize_thinking_mode(thinking_mode)
|
||||||
deep_thinking = active_thinking_mode == THINKING_MODE_DEEP
|
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] = []
|
context_blocks: list[str] = []
|
||||||
used_tokens = 0
|
used_tokens = 0
|
||||||
max_blocks = max(4, min(int(context_block_limit or DEFAULT_TOP_K * 2), 32))
|
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,
|
system_snapshot_text=system_snapshot_text,
|
||||||
user_snapshot_text=user_snapshot_text,
|
user_snapshot_text=user_snapshot_text,
|
||||||
relevance_max=relevance_max,
|
relevance_max=relevance_max,
|
||||||
|
query_type=query_type,
|
||||||
)
|
)
|
||||||
if deep_thinking:
|
if deep_thinking:
|
||||||
prompt = (
|
prompt = (
|
||||||
|
|
@ -354,7 +579,7 @@ class AdaptiveRAGDeepGuardAnswerMixin:
|
||||||
)
|
)
|
||||||
text = str(answer or "").strip()
|
text = str(answer or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
return "我未能从当前上下文生成有效答案,请补充问题细节后重试。"
|
raise ValueError("adaptive_rag_answer_empty_response")
|
||||||
if scene_key == "mixed":
|
if scene_key == "mixed":
|
||||||
return self._format_mixed_two_block_output(
|
return self._format_mixed_two_block_output(
|
||||||
answer_text=text,
|
answer_text=text,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,12 @@ class AdaptiveRAGDeterministicQueryCoreMixin:
|
||||||
if q_type == QUERY_TYPE_LIST:
|
if q_type == QUERY_TYPE_LIST:
|
||||||
return await self._run_list_query(question=question, project_id=project_id)
|
return await self._run_list_query(question=question, project_id=project_id)
|
||||||
if q_type == QUERY_TYPE_STATUS:
|
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)
|
progress = await self._try_fast_project_progress_answer(clean_q=question, project_id=project_id)
|
||||||
if progress:
|
if progress:
|
||||||
progress["query_type"] = QUERY_TYPE_STATUS
|
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)}")
|
best_name = str(row.title or row.name or f"项目#{int(row.id)}")
|
||||||
|
|
||||||
return (best_id, best_name)
|
return (best_id, best_name)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ from .shared import *
|
||||||
|
|
||||||
|
|
||||||
class AdaptiveRAGFastAnswerProjectMainMixin:
|
class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
|
PROJECT_CREATED_AT_RE = re.compile(
|
||||||
|
r"(什么时候.*创建|何时.*创建|创建时间|立项时间|成立时间|创建于|何时立项|什么时候立项)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
async def _try_fast_project_progress_answer(
|
async def _try_fast_project_progress_answer(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -138,6 +143,116 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
"response_time_ms": db_query_ms,
|
"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(
|
async def _try_fast_project_existence_answer(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -224,4 +339,3 @@ class AdaptiveRAGFastAnswerProjectMainMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return (await self.db.execute(stmt.limit(max(1, int(limit))))).scalars().all()
|
return (await self.db.execute(stmt.limit(max(1, int(limit))))).scalars().all()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ...config.ai_prompts import (
|
from ...config.ai_prompts import (
|
||||||
PROMPT_AI_PMO_QA,
|
PROMPT_AI_PMO_QA,
|
||||||
|
PROMPT_DECISION_QA,
|
||||||
PROMPT_EXTERNAL_TREND_QA,
|
PROMPT_EXTERNAL_TREND_QA,
|
||||||
|
PROMPT_INDUSTRY_TREND_QA,
|
||||||
PROMPT_FEISHU_DIRECT_QA,
|
PROMPT_FEISHU_DIRECT_QA,
|
||||||
PROMPT_FEISHU_MIXED_QA,
|
PROMPT_FEISHU_MIXED_QA,
|
||||||
PROMPT_MEMBER_WORKLOAD_QA,
|
PROMPT_MEMBER_WORKLOAD_QA,
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,7 @@ FOLLOWUP_ENUMERATION_KEYWORDS = (
|
||||||
"都有哪些",
|
"都有哪些",
|
||||||
"具体有哪些",
|
"具体有哪些",
|
||||||
"列出来",
|
"列出来",
|
||||||
|
"列一下",
|
||||||
"清单",
|
"清单",
|
||||||
"名单",
|
"名单",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,7 @@ WRITE_EXPRESSIONS = (
|
||||||
"麻烦",
|
"麻烦",
|
||||||
)
|
)
|
||||||
COUNT_HINTS = ("多少", "几个", "数量", "总数", "count")
|
COUNT_HINTS = ("多少", "几个", "数量", "总数", "count")
|
||||||
LIST_HINTS = ("哪些", "列表", "清单", "列出", "分别", "名单", "都有哪些")
|
LIST_HINTS = ("哪些", "列表", "清单", "列出", "列一下", "分别", "名单", "都有哪些")
|
||||||
STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况")
|
STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况")
|
||||||
ENTITY_ALIAS_MAP: dict[str, str] = {
|
ENTITY_ALIAS_MAP: dict[str, str] = {
|
||||||
"能力": "skill",
|
"能力": "skill",
|
||||||
|
|
|
||||||
|
|
@ -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 {}
|
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")
|
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
|
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:
|
try:
|
||||||
capture_service = ProactiveSignalCaptureService(db)
|
capture_service = ProactiveSignalCaptureService(db)
|
||||||
|
|
@ -183,6 +191,7 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None:
|
||||||
force_sync=True,
|
force_sync=True,
|
||||||
message_sent_at=message_sent_at_value,
|
message_sent_at=message_sent_at_value,
|
||||||
session_snapshot=session_snapshot_value,
|
session_snapshot=session_snapshot_value,
|
||||||
|
thread_context=thread_context_value,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
stop_heartbeat.set()
|
stop_heartbeat.set()
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,7 @@ class AIOrchestrationRunService:
|
||||||
chat_type: Optional[str],
|
chat_type: Optional[str],
|
||||||
captured_at: Optional[datetime],
|
captured_at: Optional[datetime],
|
||||||
project_hint_id: Optional[int],
|
project_hint_id: Optional[int],
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
max_retries: int = 3,
|
max_retries: int = 3,
|
||||||
) -> tuple[AIOrchestrationRun, bool]:
|
) -> tuple[AIOrchestrationRun, bool]:
|
||||||
trace_id = self.build_ingress_trace_id(
|
trace_id = self.build_ingress_trace_id(
|
||||||
|
|
@ -136,6 +137,9 @@ class AIOrchestrationRunService:
|
||||||
"source_system": str(source_system or "").strip()[:32],
|
"source_system": str(source_system or "").strip()[:32],
|
||||||
"source_message_id": str(source_message_id or "").strip()[:128],
|
"source_message_id": str(source_message_id or "").strip()[:128],
|
||||||
"queued_at": _utc_now_naive().isoformat(),
|
"queued_at": _utc_now_naive().isoformat(),
|
||||||
|
"task_payload": {
|
||||||
|
"thread_context": thread_context if isinstance(thread_context, dict) else None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
row = AIOrchestrationRun(
|
row = AIOrchestrationRun(
|
||||||
tenant_id=int(tenant_id),
|
tenant_id=int(tenant_id),
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,27 @@ import os
|
||||||
from .shared import (
|
from .shared import (
|
||||||
CanonicalIntent,
|
CanonicalIntent,
|
||||||
INTENT_QUERY,
|
INTENT_QUERY,
|
||||||
|
INTENT_PROJECT_CREATE,
|
||||||
|
INTENT_PROJECT_UPDATE,
|
||||||
|
INTENT_PROJECT_ARCHIVE,
|
||||||
|
INTENT_PROJECT_REOPEN,
|
||||||
|
INTENT_PROJECT_DELETE,
|
||||||
INTENT_TASK_CREATE,
|
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_CREATE,
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||||
re,
|
re,
|
||||||
|
|
@ -35,6 +55,22 @@ class ExecutionPolicyBrain:
|
||||||
projected_capacity_ratio = float(simulation.get("projected_capacity_peak_ratio") or 0.0)
|
projected_capacity_ratio = float(simulation.get("projected_capacity_peak_ratio") or 0.0)
|
||||||
capacity_warning: dict[str, object] | None = None
|
capacity_warning: dict[str, object] | None = None
|
||||||
normalized = re.sub(r"\s+", "", str(message_text or "")).lower()
|
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:
|
for gate_code, tokens in self.policy_gate_rules:
|
||||||
if any(token.lower().replace(" ", "") in normalized for token in tokens):
|
if any(token.lower().replace(" ", "") in normalized for token in tokens):
|
||||||
return {
|
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 {
|
return {
|
||||||
"auto_execute": True,
|
"auto_execute": True,
|
||||||
"policy_gate_hit": False,
|
"policy_gate_hit": False,
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,56 @@ from ..ai_pmo_intent import (
|
||||||
INTENT_FOLLOWUP_CONFIRM,
|
INTENT_FOLLOWUP_CONFIRM,
|
||||||
INTENT_FOLLOWUP_CONTINUE,
|
INTENT_FOLLOWUP_CONTINUE,
|
||||||
INTENT_PROJECT_CREATE,
|
INTENT_PROJECT_CREATE,
|
||||||
|
INTENT_PROJECT_UPDATE,
|
||||||
|
INTENT_PROJECT_ARCHIVE,
|
||||||
|
INTENT_PROJECT_REOPEN,
|
||||||
|
INTENT_PROJECT_DELETE,
|
||||||
INTENT_QUERY,
|
INTENT_QUERY,
|
||||||
INTENT_TASK_CREATE,
|
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_CREATE,
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||||
|
ROUTE_FAMILY_KNOWLEDGE,
|
||||||
|
ROUTE_FAMILY_NOTIFICATION,
|
||||||
|
ROUTE_FAMILY_PERSONNEL,
|
||||||
|
ROUTE_FAMILY_PROJECT,
|
||||||
ROUTE_PROJECT_SIGNAL_CANCEL,
|
ROUTE_PROJECT_SIGNAL_CANCEL,
|
||||||
ROUTE_PROJECT_SIGNAL_COMMIT,
|
ROUTE_PROJECT_SIGNAL_COMMIT,
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
||||||
ROUTE_PROJECT_SIGNAL_REFRESH,
|
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,
|
CanonicalIntent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -46,14 +88,56 @@ __all__ = [
|
||||||
"INTENT_FOLLOWUP_CONFIRM",
|
"INTENT_FOLLOWUP_CONFIRM",
|
||||||
"INTENT_FOLLOWUP_CONTINUE",
|
"INTENT_FOLLOWUP_CONTINUE",
|
||||||
"INTENT_PROJECT_CREATE",
|
"INTENT_PROJECT_CREATE",
|
||||||
|
"INTENT_PROJECT_UPDATE",
|
||||||
|
"INTENT_PROJECT_ARCHIVE",
|
||||||
|
"INTENT_PROJECT_REOPEN",
|
||||||
|
"INTENT_PROJECT_DELETE",
|
||||||
"INTENT_QUERY",
|
"INTENT_QUERY",
|
||||||
"INTENT_TASK_CREATE",
|
"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_CREATE",
|
||||||
"INTENT_SIGNAL_SOURCE_ONBOARD",
|
"INTENT_SIGNAL_SOURCE_ONBOARD",
|
||||||
|
"ROUTE_FAMILY_KNOWLEDGE",
|
||||||
|
"ROUTE_FAMILY_NOTIFICATION",
|
||||||
|
"ROUTE_FAMILY_PERSONNEL",
|
||||||
|
"ROUTE_FAMILY_PROJECT",
|
||||||
"ROUTE_PROJECT_SIGNAL_CANCEL",
|
"ROUTE_PROJECT_SIGNAL_CANCEL",
|
||||||
"ROUTE_PROJECT_SIGNAL_COMMIT",
|
"ROUTE_PROJECT_SIGNAL_COMMIT",
|
||||||
"ROUTE_PROJECT_SIGNAL_DISCOVER",
|
"ROUTE_PROJECT_SIGNAL_DISCOVER",
|
||||||
"ROUTE_PROJECT_SIGNAL_REFRESH",
|
"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",
|
"normalize_workload_ratio",
|
||||||
"re",
|
"re",
|
||||||
"safe_int",
|
"safe_int",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,26 @@ class SimulationEngine:
|
||||||
BUDGET_COST_BY_OPERATION = {
|
BUDGET_COST_BY_OPERATION = {
|
||||||
"query.answer": 1.0,
|
"query.answer": 1.0,
|
||||||
"project.create": 120.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.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.enter": 8.0,
|
||||||
"signal_source.onboard": 40.0,
|
"signal_source.onboard": 40.0,
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0,
|
ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0,
|
||||||
|
|
@ -30,7 +49,26 @@ class SimulationEngine:
|
||||||
HOURS_BY_OPERATION = {
|
HOURS_BY_OPERATION = {
|
||||||
"query.answer": 0.2,
|
"query.answer": 0.2,
|
||||||
"project.create": 12.0,
|
"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.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.enter": 1.5,
|
||||||
"signal_source.onboard": 6.0,
|
"signal_source.onboard": 6.0,
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8,
|
ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8,
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,26 @@ from __future__ import annotations
|
||||||
from .shared import (
|
from .shared import (
|
||||||
CanonicalIntent,
|
CanonicalIntent,
|
||||||
INTENT_PROJECT_CREATE,
|
INTENT_PROJECT_CREATE,
|
||||||
|
INTENT_PROJECT_UPDATE,
|
||||||
|
INTENT_PROJECT_ARCHIVE,
|
||||||
|
INTENT_PROJECT_REOPEN,
|
||||||
|
INTENT_PROJECT_DELETE,
|
||||||
INTENT_TASK_CREATE,
|
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,
|
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||||
Any,
|
Any,
|
||||||
normalize_workload_ratio,
|
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)
|
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:
|
if intent.name == INTENT_PROJECT_CREATE:
|
||||||
risk_score = max(risk_score, 0.62)
|
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:
|
if intent.name == INTENT_TASK_CREATE:
|
||||||
risk_score = max(risk_score, 0.55)
|
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:
|
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||||
risk_score = max(risk_score, 0.58)
|
risk_score = max(risk_score, 0.58)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,52 @@ from .shared import (
|
||||||
INTENT_FOLLOWUP_CONFIRM,
|
INTENT_FOLLOWUP_CONFIRM,
|
||||||
INTENT_FOLLOWUP_CONTINUE,
|
INTENT_FOLLOWUP_CONTINUE,
|
||||||
INTENT_PROJECT_CREATE,
|
INTENT_PROJECT_CREATE,
|
||||||
|
INTENT_PROJECT_UPDATE,
|
||||||
|
INTENT_PROJECT_ARCHIVE,
|
||||||
|
INTENT_PROJECT_REOPEN,
|
||||||
|
INTENT_PROJECT_DELETE,
|
||||||
INTENT_QUERY,
|
INTENT_QUERY,
|
||||||
INTENT_TASK_CREATE,
|
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_CREATE,
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||||
|
ROUTE_PROJECT_ARCHIVE,
|
||||||
ROUTE_PROJECT_SIGNAL_CANCEL,
|
ROUTE_PROJECT_SIGNAL_CANCEL,
|
||||||
ROUTE_PROJECT_SIGNAL_COMMIT,
|
ROUTE_PROJECT_SIGNAL_COMMIT,
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
||||||
ROUTE_PROJECT_SIGNAL_REFRESH,
|
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,
|
Any,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -43,8 +81,46 @@ class TacticalBrain:
|
||||||
execution_actions.append({"operation_code": "query.answer", "mode": "read_only"})
|
execution_actions.append({"operation_code": "query.answer", "mode": "read_only"})
|
||||||
elif intent.name == INTENT_PROJECT_CREATE:
|
elif intent.name == INTENT_PROJECT_CREATE:
|
||||||
execution_actions.append({"operation_code": "project.create", "mode": "write"})
|
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:
|
elif intent.name == INTENT_TASK_CREATE:
|
||||||
execution_actions.append({"operation_code": "task.create", "mode": "write"})
|
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:
|
elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||||
execution_actions.append({"operation_code": "signal.enter", "mode": "write"})
|
execution_actions.append({"operation_code": "signal.enter", "mode": "write"})
|
||||||
execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"})
|
execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"})
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ class RouteResolver:
|
||||||
mapped = ROUTE_CANONICAL_INTENT_MAP.get(normalized)
|
mapped = ROUTE_CANONICAL_INTENT_MAP.get(normalized)
|
||||||
if mapped:
|
if mapped:
|
||||||
return mapped
|
return mapped
|
||||||
|
if normalized in INTENT_CANDIDATES:
|
||||||
|
return normalized
|
||||||
family, _ = cls._split_route_key(normalized)
|
family, _ = cls._split_route_key(normalized)
|
||||||
if family == ROUTE_FAMILY_PROJECT_SIGNAL:
|
if family == ROUTE_FAMILY_PROJECT_SIGNAL:
|
||||||
return INTENT_SIGNAL_CREATE
|
return INTENT_SIGNAL_CREATE
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,26 @@ from ...signal.signal_source_orchestration import SignalSourceOrchestrationServi
|
||||||
|
|
||||||
INTENT_QUERY = "query.read_only"
|
INTENT_QUERY = "query.read_only"
|
||||||
INTENT_PROJECT_CREATE = "project.create"
|
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_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_CREATE = "signal.create"
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard"
|
INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard"
|
||||||
INTENT_FOLLOWUP_CONFIRM = "followup.confirm"
|
INTENT_FOLLOWUP_CONFIRM = "followup.confirm"
|
||||||
|
|
@ -26,13 +45,33 @@ FOLLOWUP_TARGET_INTENTS = {
|
||||||
|
|
||||||
ROUTE_FAMILY_QUERY = "query"
|
ROUTE_FAMILY_QUERY = "query"
|
||||||
ROUTE_FAMILY_PROJECT_CREATE = "project_create"
|
ROUTE_FAMILY_PROJECT_CREATE = "project_create"
|
||||||
|
ROUTE_FAMILY_PROJECT = "project"
|
||||||
ROUTE_FAMILY_TASK = "task"
|
ROUTE_FAMILY_TASK = "task"
|
||||||
|
ROUTE_FAMILY_PERSONNEL = "personnel"
|
||||||
|
ROUTE_FAMILY_KNOWLEDGE = "knowledge"
|
||||||
|
ROUTE_FAMILY_NOTIFICATION = "notification"
|
||||||
ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal"
|
ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal"
|
||||||
ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source"
|
ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source"
|
||||||
ROUTE_FAMILY_FOLLOWUP = "followup"
|
ROUTE_FAMILY_FOLLOWUP = "followup"
|
||||||
|
|
||||||
ROUTE_ACTION_ANSWER = "answer"
|
ROUTE_ACTION_ANSWER = "answer"
|
||||||
ROUTE_ACTION_CREATE = "create"
|
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_DISCOVER = "discover"
|
||||||
ROUTE_ACTION_COMMIT = "commit"
|
ROUTE_ACTION_COMMIT = "commit"
|
||||||
ROUTE_ACTION_CANCEL = "cancel"
|
ROUTE_ACTION_CANCEL = "cancel"
|
||||||
|
|
@ -43,7 +82,26 @@ ROUTE_ACTION_CONFIRM = "confirm"
|
||||||
|
|
||||||
ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}"
|
ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}"
|
||||||
ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}"
|
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_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_CREATE = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_CREATE}"
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}"
|
ROUTE_PROJECT_SIGNAL_DISCOVER = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_DISCOVER}"
|
||||||
ROUTE_PROJECT_SIGNAL_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}"
|
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 = {
|
WORKFLOW_ROUTE_KEYS = {
|
||||||
ROUTE_QUERY_ANSWER,
|
ROUTE_QUERY_ANSWER,
|
||||||
ROUTE_PROJECT_CREATE,
|
ROUTE_PROJECT_CREATE,
|
||||||
|
ROUTE_PROJECT_UPDATE,
|
||||||
|
ROUTE_PROJECT_ARCHIVE,
|
||||||
|
ROUTE_PROJECT_REOPEN,
|
||||||
|
ROUTE_PROJECT_DELETE,
|
||||||
ROUTE_TASK_CREATE,
|
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_CREATE,
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
||||||
ROUTE_PROJECT_SIGNAL_COMMIT,
|
ROUTE_PROJECT_SIGNAL_COMMIT,
|
||||||
|
|
@ -70,7 +147,26 @@ WORKFLOW_ROUTE_KEYS = {
|
||||||
ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
|
ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = {
|
||||||
ROUTE_QUERY_ANSWER: INTENT_QUERY,
|
ROUTE_QUERY_ANSWER: INTENT_QUERY,
|
||||||
ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE,
|
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_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_CREATE: INTENT_SIGNAL_CREATE,
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE,
|
ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE,
|
||||||
ROUTE_PROJECT_SIGNAL_COMMIT: 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] = {
|
LEGACY_INTENT_ROUTE_MAP: dict[str, str] = {
|
||||||
INTENT_QUERY: ROUTE_QUERY_ANSWER,
|
INTENT_QUERY: ROUTE_QUERY_ANSWER,
|
||||||
INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE,
|
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_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_CREATE: ROUTE_PROJECT_SIGNAL_CREATE,
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD,
|
INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD,
|
||||||
INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM,
|
INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM,
|
||||||
|
|
@ -214,16 +329,54 @@ INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel"
|
||||||
INTENT_CANDIDATES = (
|
INTENT_CANDIDATES = (
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||||
INTENT_TASK_CREATE,
|
INTENT_TASK_CREATE,
|
||||||
|
INTENT_TASK_UPDATE,
|
||||||
|
INTENT_TASK_ASSIGN,
|
||||||
|
INTENT_TASK_PRIORITIZE,
|
||||||
|
INTENT_TASK_STATUS_TRANSITION,
|
||||||
|
INTENT_TASK_DELETE,
|
||||||
INTENT_SIGNAL_CREATE,
|
INTENT_SIGNAL_CREATE,
|
||||||
INTENT_QUERY,
|
|
||||||
INTENT_PROJECT_CREATE,
|
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_CONFIRM,
|
||||||
INTENT_FOLLOWUP_CONTINUE,
|
INTENT_FOLLOWUP_CONTINUE,
|
||||||
INTENT_IGNORE,
|
INTENT_IGNORE,
|
||||||
)
|
)
|
||||||
WRITE_INTENTS = {
|
WRITE_INTENTS = {
|
||||||
INTENT_PROJECT_CREATE,
|
INTENT_PROJECT_CREATE,
|
||||||
|
INTENT_PROJECT_UPDATE,
|
||||||
|
INTENT_PROJECT_ARCHIVE,
|
||||||
|
INTENT_PROJECT_REOPEN,
|
||||||
|
INTENT_PROJECT_DELETE,
|
||||||
INTENT_TASK_CREATE,
|
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_CREATE,
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||||
INTENT_FOLLOWUP_CONFIRM,
|
INTENT_FOLLOWUP_CONFIRM,
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable, Sequence
|
from typing import Iterable, Sequence
|
||||||
|
|
||||||
BOOL_TRUE_VALUES = {"1", "true", "yes", "y", "on", "是"}
|
|
||||||
INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel"
|
INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel"
|
||||||
INTENT_FUNCTION_COMPLEX = "classify_intent_complex"
|
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
|
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:
|
def _normalize_text(text: str) -> str:
|
||||||
return re.sub(r"\s+", "", str(text or "")).lower()
|
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_code: str = "manual_review_required",
|
||||||
force_manual_reason: str = "",
|
force_manual_reason: str = "",
|
||||||
) -> GateDecision:
|
) -> 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:
|
if force_manual:
|
||||||
return GateDecision(
|
return GateDecision(
|
||||||
|
|
@ -190,34 +176,10 @@ def gate_execution(
|
||||||
auto_execute=False,
|
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(
|
return GateDecision(
|
||||||
action="auto_execute",
|
action="auto_execute",
|
||||||
gate_code="auto_execute_allowed",
|
gate_code="auto_execute_allowed",
|
||||||
reason="置信度达标,允许自动执行。",
|
reason="基础门禁通过,允许自动执行。",
|
||||||
auto_execute=True,
|
auto_execute=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,338 @@ class ReplyRenderer:
|
||||||
self.receipt_phase_pending = receipt_phase_pending
|
self.receipt_phase_pending = receipt_phase_pending
|
||||||
self.receipt_phase_error = receipt_phase_error
|
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(
|
def render(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
@ -73,6 +405,7 @@ class ReplyRenderer:
|
||||||
decision_record: dict[str, Any],
|
decision_record: dict[str, Any],
|
||||||
execution_result: dict[str, Any],
|
execution_result: dict[str, Any],
|
||||||
) -> 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 work_state == self.state_waiting_manual:
|
||||||
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}:
|
||||||
followup_text = str(execution_result.get("followup_reply") or "").strip()
|
followup_text = str(execution_result.get("followup_reply") or "").strip()
|
||||||
|
|
@ -96,6 +429,14 @@ class ReplyRenderer:
|
||||||
},
|
},
|
||||||
"text": pending_text,
|
"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()
|
gate_message = str(((decision_record.get("approval_requirement") or {}).get("message")) or "").strip()
|
||||||
if not gate_message:
|
if not gate_message:
|
||||||
gate_message = "已进入待人工确认队列。"
|
gate_message = "已进入待人工确认队列。"
|
||||||
|
|
@ -174,6 +515,13 @@ class ReplyRenderer:
|
||||||
},
|
},
|
||||||
"text": text,
|
"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:
|
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||||
signal_id = _safe_int(execution_result.get("signal_id"))
|
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 {}
|
workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {}
|
||||||
|
|
|
||||||
|
|
@ -27,17 +27,71 @@ from ....utils.timezone import now_cn_naive
|
||||||
from ...adaptive_rag_service import AdaptiveRAGService
|
from ...adaptive_rag_service import AdaptiveRAGService
|
||||||
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow
|
||||||
from ..ai_pmo_intent import (
|
from ..ai_pmo_intent import (
|
||||||
|
INTENT_PROJECT_UPDATE,
|
||||||
|
INTENT_PROJECT_ARCHIVE,
|
||||||
|
INTENT_PROJECT_REOPEN,
|
||||||
|
INTENT_PROJECT_DELETE,
|
||||||
INTENT_PROJECT_CREATE,
|
INTENT_PROJECT_CREATE,
|
||||||
INTENT_QUERY,
|
INTENT_QUERY,
|
||||||
INTENT_TASK_CREATE,
|
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_CREATE,
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||||
LEGACY_INTENT_ROUTE_MAP,
|
LEGACY_INTENT_ROUTE_MAP,
|
||||||
ROUTE_ACTION_CREATE,
|
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_ACTION_DISCOVER,
|
||||||
ROUTE_PROJECT_SIGNAL_CREATE,
|
ROUTE_PROJECT_SIGNAL_CREATE,
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
||||||
|
ROUTE_PROJECT_UPDATE,
|
||||||
|
ROUTE_PROJECT_ARCHIVE,
|
||||||
|
ROUTE_PROJECT_REOPEN,
|
||||||
|
ROUTE_PROJECT_DELETE,
|
||||||
ROUTE_TASK_CREATE,
|
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,
|
CanonicalIntent,
|
||||||
IntentResolver,
|
IntentResolver,
|
||||||
RouteResolver,
|
RouteResolver,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ from ..ai_pmo_intent import (
|
||||||
INTENT_FOLLOWUP_CONTINUE,
|
INTENT_FOLLOWUP_CONTINUE,
|
||||||
ROUTE_ACTION_DISCOVER,
|
ROUTE_ACTION_DISCOVER,
|
||||||
ROUTE_FAMILY_FOLLOWUP,
|
ROUTE_FAMILY_FOLLOWUP,
|
||||||
|
ROUTE_FAMILY_KNOWLEDGE,
|
||||||
|
ROUTE_FAMILY_NOTIFICATION,
|
||||||
|
ROUTE_FAMILY_PERSONNEL,
|
||||||
|
ROUTE_FAMILY_PROJECT,
|
||||||
ROUTE_FAMILY_PROJECT_CREATE,
|
ROUTE_FAMILY_PROJECT_CREATE,
|
||||||
ROUTE_FAMILY_PROJECT_SIGNAL,
|
ROUTE_FAMILY_PROJECT_SIGNAL,
|
||||||
ROUTE_FAMILY_QUERY,
|
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):
|
class SignalSourceWorkflowHandler(WorkflowHandler):
|
||||||
async def start(
|
async def start(
|
||||||
self,
|
self,
|
||||||
|
|
@ -256,6 +285,10 @@ class WorkflowRegistry:
|
||||||
ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel),
|
ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel),
|
||||||
ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel),
|
ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel),
|
||||||
ROUTE_FAMILY_TASK: TaskCreateWorkflowHandler(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_PROJECT_SIGNAL: ProjectSignalWorkflowHandler(kernel),
|
||||||
ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel),
|
ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel),
|
||||||
ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel),
|
ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .... import ai_pmo_kernel_common_core as _common
|
from .... import ai_pmo_kernel_common_core as _common
|
||||||
|
from app.utils.thread_context import build_feishu_thread_key
|
||||||
|
|
||||||
for _name, _value in vars(_common).items():
|
for _name, _value in vars(_common).items():
|
||||||
if not _name.startswith("__"):
|
if not _name.startswith("__"):
|
||||||
|
|
@ -95,16 +96,19 @@ class KernelClosurePayloadMixin:
|
||||||
owner_user_id: Optional[int] = None,
|
owner_user_id: Optional[int] = None,
|
||||||
project_id: Optional[int] = None,
|
project_id: Optional[int] = None,
|
||||||
captured_at: Optional[Any] = None,
|
captured_at: Optional[Any] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
normalized_message = self._normalize_dialogue_text(message_text, max_len=600)
|
normalized_message = self._normalize_dialogue_text(message_text, max_len=600)
|
||||||
turns: list[dict[str, str]] = []
|
turns: list[dict[str, str]] = []
|
||||||
normalized_source_system = str(source_system or "").strip().lower()
|
normalized_source_system = str(source_system or "").strip().lower()
|
||||||
normalized_channel = str(channel_ref or "").strip()
|
normalized_channel = str(channel_ref or "").strip()
|
||||||
if normalized_source_system == "feishu" and normalized_channel:
|
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_chat_id=normalized_channel,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key)
|
session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key)
|
||||||
if session_id is not None:
|
if session_id is not None:
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .... import ai_pmo_kernel_common_core as _common
|
from .... import ai_pmo_kernel_common_core as _common
|
||||||
|
from app.utils.thread_context import build_feishu_thread_key
|
||||||
|
|
||||||
for _name, _value in vars(_common).items():
|
for _name, _value in vars(_common).items():
|
||||||
if not _name.startswith("__"):
|
if not _name.startswith("__"):
|
||||||
|
|
@ -36,8 +37,64 @@ class KernelFollowupVerificationMixin:
|
||||||
return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []}
|
return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []}
|
||||||
if intent.name == INTENT_PROJECT_CREATE:
|
if intent.name == INTENT_PROJECT_CREATE:
|
||||||
return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []}
|
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:
|
if intent.name == INTENT_TASK_CREATE:
|
||||||
return {"verified": _safe_int(execution_result.get("task_id")) is not None, "issues": []}
|
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}:
|
if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
|
||||||
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
|
signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids"))
|
||||||
signal_id = _safe_int(execution_result.get("signal_id"))
|
signal_id = _safe_int(execution_result.get("signal_id"))
|
||||||
|
|
@ -113,13 +170,13 @@ class KernelFollowupVerificationMixin:
|
||||||
source_chat_id: Optional[str],
|
source_chat_id: Optional[str],
|
||||||
source_sender_open_id: Optional[str],
|
source_sender_open_id: Optional[str],
|
||||||
sender: Optional[str],
|
sender: Optional[str],
|
||||||
|
source_message_id: Optional[str] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
chat = str(source_chat_id or "").strip()
|
return build_feishu_thread_key(
|
||||||
if not chat:
|
source_chat_id=source_chat_id,
|
||||||
return ""
|
source_sender_open_id=source_sender_open_id,
|
||||||
actor = str(source_sender_open_id or "").strip()
|
sender=sender,
|
||||||
if not actor:
|
source_message_id=source_message_id,
|
||||||
actor = str(sender or "").strip().lower()
|
thread_context=thread_context,
|
||||||
actor = re.sub(r"\s+", "", actor)
|
)
|
||||||
actor = actor[:96] if actor else "unknown"
|
|
||||||
return f"feishu:{chat}:{actor}"
|
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,8 @@ class KernelIngressRenderMixin:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _primary_operation_code(intent_name: str) -> Optional[str]:
|
def _primary_operation_code(intent_name: str) -> Optional[str]:
|
||||||
normalized_route = RouteResolver.normalize_route_key(intent_name)
|
normalized_route = RouteResolver.normalize_route_key(intent_name)
|
||||||
|
if normalized_route in INTENT_CANDIDATES:
|
||||||
|
return normalized_route
|
||||||
if normalized_route == ROUTE_QUERY_ANSWER:
|
if normalized_route == ROUTE_QUERY_ANSWER:
|
||||||
return "query.answer"
|
return "query.answer"
|
||||||
if normalized_route == ROUTE_PROJECT_CREATE:
|
if normalized_route == ROUTE_PROJECT_CREATE:
|
||||||
|
|
@ -130,8 +132,36 @@ class KernelIngressRenderMixin:
|
||||||
return "query.answer"
|
return "query.answer"
|
||||||
if intent_name == INTENT_PROJECT_CREATE:
|
if intent_name == INTENT_PROJECT_CREATE:
|
||||||
return "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:
|
if intent_name == INTENT_TASK_CREATE:
|
||||||
return "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:
|
if intent_name == INTENT_SIGNAL_CREATE:
|
||||||
return "signal.enter"
|
return "signal.enter"
|
||||||
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||||
|
|
@ -144,7 +174,34 @@ class KernelIngressRenderMixin:
|
||||||
def _risk_level_for_intent(intent_name: str) -> str:
|
def _risk_level_for_intent(intent_name: str) -> str:
|
||||||
if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
|
if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}:
|
||||||
return "high"
|
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 "medium"
|
||||||
return "low"
|
return "low"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,52 @@ class KernelIngressStorageMixin:
|
||||||
if op == "project.create":
|
if op == "project.create":
|
||||||
entity_id = _safe_int(execution_result.get("project_id"))
|
entity_id = _safe_int(execution_result.get("project_id"))
|
||||||
return ("project", str(entity_id)) if entity_id is not None else ("project", None)
|
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":
|
if op == "task.create":
|
||||||
entity_id = _safe_int(execution_result.get("task_id"))
|
entity_id = _safe_int(execution_result.get("task_id"))
|
||||||
return ("task", str(entity_id)) if entity_id is not None else ("task", None)
|
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}:
|
if op in {"signal.enter", ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_CREATE}:
|
||||||
entity_id = _safe_int(execution_result.get("signal_id"))
|
entity_id = _safe_int(execution_result.get("signal_id"))
|
||||||
return ("signal", str(entity_id)) if entity_id is not None else ("signal", None)
|
return ("signal", str(entity_id)) if entity_id is not None else ("signal", None)
|
||||||
|
|
|
||||||
|
|
@ -19,24 +19,27 @@ class KernelIntentLLMMixin:
|
||||||
session: Optional[ConversationSession],
|
session: Optional[ConversationSession],
|
||||||
world_model: dict[str, Any],
|
world_model: dict[str, Any],
|
||||||
rule_intent: Optional[CanonicalIntent] = None,
|
rule_intent: Optional[CanonicalIntent] = None,
|
||||||
|
session_last_intent_override: Optional[str] = None,
|
||||||
|
session_project_id_override: Optional[int] = None,
|
||||||
) -> Optional[CanonicalIntent]:
|
) -> 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(
|
prompt = PROMPT_AI_PMO_INTENT_CLASSIFICATION.format(
|
||||||
message_text=str(message_text or "")[:1200],
|
message_text=str(message_text or "")[:1200],
|
||||||
session_last_intent=str(getattr(session, "last_intent", "") or "").strip() if session is not None else "",
|
session_last_intent=session_last_intent,
|
||||||
session_project_id=_safe_int(getattr(session, "current_project_id", None)) if session is not None else None,
|
session_project_id=session_project_id,
|
||||||
world_model_project_id=_safe_int(world_model.get("resolved_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_intents_json=json.dumps(list(INTENT_CANDIDATES), ensure_ascii=False),
|
||||||
candidate_routes_json=json.dumps(
|
candidate_routes_json=json.dumps(sorted(WORKFLOW_ROUTE_KEYS), ensure_ascii=False),
|
||||||
[
|
|
||||||
ROUTE_QUERY_ANSWER,
|
|
||||||
ROUTE_PROJECT_CREATE,
|
|
||||||
ROUTE_TASK_CREATE,
|
|
||||||
ROUTE_PROJECT_SIGNAL_CREATE,
|
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
|
||||||
ROUTE_SIGNAL_SOURCE_ONBOARD,
|
|
||||||
],
|
|
||||||
ensure_ascii=False,
|
|
||||||
),
|
|
||||||
).strip()
|
).strip()
|
||||||
total_budget_seconds = _intent_llm_total_budget_seconds()
|
total_budget_seconds = _intent_llm_total_budget_seconds()
|
||||||
stage_started_at = time.perf_counter()
|
stage_started_at = time.perf_counter()
|
||||||
|
|
@ -167,6 +170,8 @@ class KernelIntentLLMMixin:
|
||||||
"context_source": "llm",
|
"context_source": "llm",
|
||||||
"stage": stage_label,
|
"stage": stage_label,
|
||||||
"reasoning": reasoning[:300],
|
"reasoning": reasoning[:300],
|
||||||
|
"llm_payload": payload,
|
||||||
|
"llm_parameters": payload.get("parameters") if isinstance(payload.get("parameters"), dict) else {},
|
||||||
"llm_function": function_name,
|
"llm_function": function_name,
|
||||||
"route_tier": route_tier,
|
"route_tier": route_tier,
|
||||||
"route_complex": route_tier == "high",
|
"route_complex": route_tier == "high",
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,18 @@ class KernelIntentResolveMixin:
|
||||||
world_model: dict[str, Any],
|
world_model: dict[str, Any],
|
||||||
message_sent_at: Optional[Any] = None,
|
message_sent_at: Optional[Any] = None,
|
||||||
session_snapshot: Optional[dict[str, Any]] = None,
|
session_snapshot: Optional[dict[str, Any]] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> tuple[CanonicalIntent, str]:
|
) -> 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(
|
if self._is_short_confirmation_message(
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
session=session,
|
session=session,
|
||||||
message_sent_at=message_sent_at,
|
message_sent_at=message_sent_at,
|
||||||
session_snapshot=session_snapshot,
|
session_snapshot=session_snapshot,
|
||||||
|
reply_anchor_present=reply_anchor_present,
|
||||||
) and session is not None:
|
) and session is not None:
|
||||||
inherited = self._build_short_confirm_intent(
|
inherited = self._build_short_confirm_intent(
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
|
|
@ -50,6 +56,12 @@ class KernelIntentResolveMixin:
|
||||||
session=session,
|
session=session,
|
||||||
world_model=world_model,
|
world_model=world_model,
|
||||||
rule_intent=None,
|
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:
|
if llm_intent is None:
|
||||||
raise RuntimeError("intent_model_only_no_valid_llm_intent")
|
raise RuntimeError("intent_model_only_no_valid_llm_intent")
|
||||||
|
|
|
||||||
|
|
@ -276,7 +276,10 @@ class KernelIntentRulesMixin:
|
||||||
session: Optional[ConversationSession],
|
session: Optional[ConversationSession],
|
||||||
message_sent_at: Optional[Any] = None,
|
message_sent_at: Optional[Any] = None,
|
||||||
session_snapshot: Optional[dict[str, Any]] = None,
|
session_snapshot: Optional[dict[str, Any]] = None,
|
||||||
|
reply_anchor_present: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
if not reply_anchor_present:
|
||||||
|
return False
|
||||||
snapshot = session_snapshot if isinstance(session_snapshot, dict) else {}
|
snapshot = session_snapshot if isinstance(session_snapshot, dict) else {}
|
||||||
if session is None and not snapshot:
|
if session is None and not snapshot:
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .... import ai_pmo_kernel_common_core as _common
|
from .... import ai_pmo_kernel_common_core as _common
|
||||||
|
from app.utils.thread_context import build_feishu_thread_key
|
||||||
|
|
||||||
for _name, _value in vars(_common).items():
|
for _name, _value in vars(_common).items():
|
||||||
if not _name.startswith("__"):
|
if not _name.startswith("__"):
|
||||||
|
|
@ -31,8 +32,29 @@ class KernelWorldModelSessionMixin:
|
||||||
return None
|
return None
|
||||||
return session_project_id
|
return session_project_id
|
||||||
|
|
||||||
async def _get_or_create_session(self, *, tenant_id: int, sender: str) -> ConversationSession:
|
async def _get_or_create_session(
|
||||||
sender_key = str(sender or "").strip()[:128] or "unknown"
|
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 = (
|
existing = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(ConversationSession).where(
|
select(ConversationSession).where(
|
||||||
|
|
@ -81,11 +103,25 @@ class KernelWorldModelSessionMixin:
|
||||||
*,
|
*,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
sender: str,
|
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],
|
current_project_id: Optional[int],
|
||||||
last_intent: Optional[str],
|
last_intent: Optional[str],
|
||||||
last_message_text: Optional[str],
|
last_message_text: Optional[str],
|
||||||
) -> None:
|
) -> 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:
|
if current_project_id is not None:
|
||||||
row.current_project_id = int(current_project_id)
|
row.current_project_id = int(current_project_id)
|
||||||
row.last_intent = (str(last_intent or "").strip()[:64] or None)
|
row.last_intent = (str(last_intent or "").strip()[:64] or None)
|
||||||
|
|
|
||||||
|
|
@ -82,21 +82,60 @@ from ..ai_pmo_intent import (
|
||||||
INTENT_IGNORE,
|
INTENT_IGNORE,
|
||||||
INTENT_LLM_FUNCTION_NAME,
|
INTENT_LLM_FUNCTION_NAME,
|
||||||
INTENT_PROJECT_CREATE,
|
INTENT_PROJECT_CREATE,
|
||||||
|
INTENT_PROJECT_UPDATE,
|
||||||
|
INTENT_PROJECT_ARCHIVE,
|
||||||
|
INTENT_PROJECT_REOPEN,
|
||||||
|
INTENT_PROJECT_DELETE,
|
||||||
INTENT_QUERY,
|
INTENT_QUERY,
|
||||||
INTENT_TASK_CREATE,
|
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_CREATE,
|
||||||
INTENT_SIGNAL_SOURCE_ONBOARD,
|
INTENT_SIGNAL_SOURCE_ONBOARD,
|
||||||
LEGACY_INTENT_ROUTE_MAP,
|
LEGACY_INTENT_ROUTE_MAP,
|
||||||
ROUTE_ACTION_ANSWER,
|
ROUTE_ACTION_ANSWER,
|
||||||
|
ROUTE_ACTION_ACK,
|
||||||
|
ROUTE_ACTION_ARCHIVE,
|
||||||
ROUTE_ACTION_CANCEL,
|
ROUTE_ACTION_CANCEL,
|
||||||
|
ROUTE_ACTION_CLOSE,
|
||||||
ROUTE_ACTION_COMMIT,
|
ROUTE_ACTION_COMMIT,
|
||||||
ROUTE_ACTION_CONFIRM,
|
ROUTE_ACTION_CONFIRM,
|
||||||
ROUTE_ACTION_CONTINUE,
|
ROUTE_ACTION_CONTINUE,
|
||||||
ROUTE_ACTION_CREATE,
|
ROUTE_ACTION_CREATE,
|
||||||
ROUTE_ACTION_DISCOVER,
|
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_ONBOARD,
|
||||||
ROUTE_ACTION_REFRESH,
|
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_FOLLOWUP,
|
||||||
|
ROUTE_FAMILY_KNOWLEDGE,
|
||||||
|
ROUTE_FAMILY_NOTIFICATION,
|
||||||
|
ROUTE_FAMILY_PERSONNEL,
|
||||||
|
ROUTE_FAMILY_PROJECT,
|
||||||
ROUTE_FAMILY_PROJECT_CREATE,
|
ROUTE_FAMILY_PROJECT_CREATE,
|
||||||
ROUTE_FAMILY_PROJECT_SIGNAL,
|
ROUTE_FAMILY_PROJECT_SIGNAL,
|
||||||
ROUTE_FAMILY_QUERY,
|
ROUTE_FAMILY_QUERY,
|
||||||
|
|
@ -105,12 +144,31 @@ from ..ai_pmo_intent import (
|
||||||
ROUTE_FOLLOWUP_CONFIRM,
|
ROUTE_FOLLOWUP_CONFIRM,
|
||||||
ROUTE_FOLLOWUP_CONTINUE,
|
ROUTE_FOLLOWUP_CONTINUE,
|
||||||
ROUTE_PROJECT_CREATE,
|
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_CANCEL,
|
||||||
ROUTE_PROJECT_SIGNAL_COMMIT,
|
ROUTE_PROJECT_SIGNAL_COMMIT,
|
||||||
ROUTE_PROJECT_SIGNAL_CREATE,
|
ROUTE_PROJECT_SIGNAL_CREATE,
|
||||||
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
ROUTE_PROJECT_SIGNAL_DISCOVER,
|
||||||
ROUTE_PROJECT_SIGNAL_REFRESH,
|
ROUTE_PROJECT_SIGNAL_REFRESH,
|
||||||
ROUTE_QUERY_ANSWER,
|
ROUTE_QUERY_ANSWER,
|
||||||
|
ROUTE_TASK_ASSIGN,
|
||||||
|
ROUTE_TASK_DELETE,
|
||||||
|
ROUTE_TASK_PRIORITIZE,
|
||||||
|
ROUTE_TASK_STATUS_TRANSITION,
|
||||||
|
ROUTE_TASK_UPDATE,
|
||||||
ROUTE_SIGNAL_SOURCE_ONBOARD,
|
ROUTE_SIGNAL_SOURCE_ONBOARD,
|
||||||
ROUTE_TASK_CREATE,
|
ROUTE_TASK_CREATE,
|
||||||
SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
|
SESSION_SHORT_CONFIRM_WINDOW_SECONDS,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ class KernelIngressFlowAutoMixin:
|
||||||
started: float,
|
started: float,
|
||||||
fallback_intent: CanonicalIntent,
|
fallback_intent: CanonicalIntent,
|
||||||
resolved_project_for_session: Optional[int],
|
resolved_project_for_session: Optional[int],
|
||||||
|
session: ConversationSession,
|
||||||
log_perf_fn: Any,
|
log_perf_fn: Any,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
work_item.state = STATE_PLANNED
|
work_item.state = STATE_PLANNED
|
||||||
|
|
@ -149,6 +150,7 @@ class KernelIngressFlowAutoMixin:
|
||||||
await self._update_session(
|
await self._update_session(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
session=session,
|
||||||
current_project_id=final_project_id,
|
current_project_id=final_project_id,
|
||||||
last_intent=STATE_DRAFT_PENDING_CONFIRM,
|
last_intent=STATE_DRAFT_PENDING_CONFIRM,
|
||||||
last_message_text=message_text[:200],
|
last_message_text=message_text[:200],
|
||||||
|
|
@ -331,6 +333,7 @@ class KernelIngressFlowAutoMixin:
|
||||||
await self._update_session(
|
await self._update_session(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
session=session,
|
||||||
current_project_id=final_project_id,
|
current_project_id=final_project_id,
|
||||||
last_intent=final_intent_name,
|
last_intent=final_intent_name,
|
||||||
last_message_text=message_text[:200],
|
last_message_text=message_text[:200],
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
question=question_part,
|
question=question_part,
|
||||||
asked_by=None,
|
asked_by=None,
|
||||||
asked_via=source_system,
|
asked_via=source_system,
|
||||||
project_id=project_hint_id,
|
project_id=None,
|
||||||
source_chat_id=channel_ref,
|
source_chat_id=channel_ref,
|
||||||
conversation_key=source_message_id,
|
conversation_key=source_message_id,
|
||||||
message_intent="mixed",
|
message_intent="mixed",
|
||||||
|
|
@ -98,6 +98,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
chat_type: Optional[str] = None,
|
chat_type: Optional[str] = None,
|
||||||
message_sent_at: Optional[Any] = None,
|
message_sent_at: Optional[Any] = None,
|
||||||
session_snapshot: Optional[dict[str, Any]] = None,
|
session_snapshot: Optional[dict[str, Any]] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
return await self.process_ingress(
|
return await self.process_ingress(
|
||||||
source_system="feishu",
|
source_system="feishu",
|
||||||
|
|
@ -110,6 +111,11 @@ class KernelIngressFlowEntryMixin:
|
||||||
chat_type=chat_type,
|
chat_type=chat_type,
|
||||||
message_sent_at=message_sent_at,
|
message_sent_at=message_sent_at,
|
||||||
session_snapshot=session_snapshot,
|
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(
|
async def process_ingress(
|
||||||
|
|
@ -131,6 +137,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
perf_work_item_id: Optional[int] = None
|
perf_work_item_id: Optional[int] = None
|
||||||
perf_intent_name = ""
|
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:
|
def _log_perf(stage_name: str, stage_started_at: float) -> None:
|
||||||
elapsed_seconds = time.perf_counter() - stage_started_at
|
elapsed_seconds = time.perf_counter() - stage_started_at
|
||||||
perf_line = (
|
perf_line = (
|
||||||
|
|
@ -150,6 +157,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
captured_at=captured_at,
|
captured_at=captured_at,
|
||||||
chat_type=chat_type,
|
chat_type=chat_type,
|
||||||
project_hint_id=project_hint_id,
|
project_hint_id=project_hint_id,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
if ingress_prepare.get("early_result") is not None:
|
if ingress_prepare.get("early_result") is not None:
|
||||||
return ingress_prepare["early_result"]
|
return ingress_prepare["early_result"]
|
||||||
|
|
@ -160,7 +168,15 @@ class KernelIngressFlowEntryMixin:
|
||||||
operator = ingress_prepare["operator"]
|
operator = ingress_prepare["operator"]
|
||||||
work_item = ingress_prepare["work_item"]
|
work_item = ingress_prepare["work_item"]
|
||||||
session = ingress_prepare["session"]
|
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(
|
draft_capture = await self._maybe_handle_draft_confirmation_ingress(
|
||||||
work_item=work_item,
|
work_item=work_item,
|
||||||
ingest_event=ingest_event,
|
ingest_event=ingest_event,
|
||||||
|
|
@ -172,6 +188,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
channel_ref=channel_ref,
|
channel_ref=channel_ref,
|
||||||
message_text=clean_text,
|
message_text=clean_text,
|
||||||
started=started,
|
started=started,
|
||||||
|
ingress_options=ingress_options,
|
||||||
)
|
)
|
||||||
if draft_capture is not None:
|
if draft_capture is not None:
|
||||||
return draft_capture
|
return draft_capture
|
||||||
|
|
@ -208,6 +225,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
world_model=world_model,
|
world_model=world_model,
|
||||||
message_sent_at=message_sent_at,
|
message_sent_at=message_sent_at,
|
||||||
session_snapshot=session_snapshot,
|
session_snapshot=session_snapshot,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
intent = self._enrich_signal_source_intent_from_closure(
|
intent = self._enrich_signal_source_intent_from_closure(
|
||||||
intent=intent,
|
intent=intent,
|
||||||
|
|
@ -229,6 +247,12 @@ class KernelIngressFlowEntryMixin:
|
||||||
**(intent.metadata or {}),
|
**(intent.metadata or {}),
|
||||||
"rag_request_context": dict(rag_request_context),
|
"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(
|
workflow_route = self.route_resolver.from_canonical(
|
||||||
canonical_intent=intent,
|
canonical_intent=intent,
|
||||||
message_text=clean_text,
|
message_text=clean_text,
|
||||||
|
|
@ -258,6 +282,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
return await self._finalize_ignored_ingress(
|
return await self._finalize_ignored_ingress(
|
||||||
work_item=work_item,
|
work_item=work_item,
|
||||||
ingest_event=ingest_event,
|
ingest_event=ingest_event,
|
||||||
|
session=session,
|
||||||
synthetic_message_id=synthetic_message_id,
|
synthetic_message_id=synthetic_message_id,
|
||||||
clean_text=clean_text,
|
clean_text=clean_text,
|
||||||
intent=intent,
|
intent=intent,
|
||||||
|
|
@ -348,6 +373,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
stage_started_at = time.perf_counter()
|
stage_started_at = time.perf_counter()
|
||||||
capture_result = await self._finalize_waiting_manual_ingress(
|
capture_result = await self._finalize_waiting_manual_ingress(
|
||||||
work_item=work_item,
|
work_item=work_item,
|
||||||
|
session=session,
|
||||||
intent=intent,
|
intent=intent,
|
||||||
world_model=world_model,
|
world_model=world_model,
|
||||||
decision_payload=decision_payload,
|
decision_payload=decision_payload,
|
||||||
|
|
@ -382,6 +408,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
started=started,
|
started=started,
|
||||||
fallback_intent=fallback_intent,
|
fallback_intent=fallback_intent,
|
||||||
resolved_project_for_session=resolved_project_for_session,
|
resolved_project_for_session=resolved_project_for_session,
|
||||||
|
session=session,
|
||||||
log_perf_fn=_log_perf,
|
log_perf_fn=_log_perf,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -414,6 +441,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
channel_ref: Optional[str],
|
channel_ref: Optional[str],
|
||||||
message_text: str,
|
message_text: str,
|
||||||
started: float,
|
started: float,
|
||||||
|
ingress_options: Optional[dict[str, Any]],
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
from ..draft_confirm_handler import DraftConfirmHandler
|
from ..draft_confirm_handler import DraftConfirmHandler
|
||||||
|
|
||||||
|
|
@ -426,6 +454,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
channel_ref=channel_ref,
|
channel_ref=channel_ref,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
expected_version=None,
|
expected_version=None,
|
||||||
|
thread_context=self._extract_thread_context_from_ingress_options(ingress_options),
|
||||||
)
|
)
|
||||||
if not bool(decision.get("handled")):
|
if not bool(decision.get("handled")):
|
||||||
return None
|
return None
|
||||||
|
|
@ -513,6 +542,7 @@ class KernelIngressFlowEntryMixin:
|
||||||
await self._update_session(
|
await self._update_session(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
session=session,
|
||||||
current_project_id=_safe_int(decision.get("project_id")),
|
current_project_id=_safe_int(decision.get("project_id")),
|
||||||
last_intent=next_session_intent,
|
last_intent=next_session_intent,
|
||||||
last_message_text=message_text[:200],
|
last_message_text=message_text[:200],
|
||||||
|
|
@ -548,6 +578,78 @@ class KernelIngressFlowEntryMixin:
|
||||||
classification="command",
|
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(
|
async def _persist_kernel_failure_with_fresh_session(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from app.utils.thread_context import build_feishu_thread_key
|
||||||
|
|
||||||
class KernelIngressFlowPrepareManualMixin:
|
class KernelIngressFlowPrepareManualMixin:
|
||||||
async def _prepare_ingress_envelope(
|
async def _prepare_ingress_envelope(
|
||||||
|
|
@ -15,6 +16,7 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
captured_at: Optional[datetime],
|
captured_at: Optional[datetime],
|
||||||
chat_type: Optional[str],
|
chat_type: Optional[str],
|
||||||
project_hint_id: Optional[int],
|
project_hint_id: Optional[int],
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
clean_text = self._normalize_text(message_text)
|
clean_text = self._normalize_text(message_text)
|
||||||
if not clean_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(
|
synthetic_message_id = self._build_source_message_id(
|
||||||
source_system=source_system,
|
source_system=source_system,
|
||||||
source_message_id=source_message_id,
|
source_message_id=source_message_id,
|
||||||
|
|
@ -66,6 +88,7 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
source_message_id=synthetic_message_id,
|
source_message_id=synthetic_message_id,
|
||||||
captured_at=captured_at,
|
captured_at=captured_at,
|
||||||
project_id=project_hint_id,
|
project_id=project_hint_id,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
ingest_event, created = await self._claim_ingress_event(
|
ingest_event, created = await self._claim_ingress_event(
|
||||||
|
|
@ -103,7 +126,15 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
source_message_id=synthetic_message_id,
|
source_message_id=synthetic_message_id,
|
||||||
owner_user_id=_safe_int(getattr(operator, "id", None)),
|
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 {
|
return {
|
||||||
"early_result": None,
|
"early_result": None,
|
||||||
"clean_text": clean_text,
|
"clean_text": clean_text,
|
||||||
|
|
@ -113,6 +144,7 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
"operator": operator,
|
"operator": operator,
|
||||||
"work_item": work_item,
|
"work_item": work_item,
|
||||||
"session": session,
|
"session": session,
|
||||||
|
"session_key": session_key or None,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _finalize_ignored_ingress(
|
async def _finalize_ignored_ingress(
|
||||||
|
|
@ -120,6 +152,7 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
*,
|
*,
|
||||||
work_item: Any,
|
work_item: Any,
|
||||||
ingest_event: Any,
|
ingest_event: Any,
|
||||||
|
session: ConversationSession,
|
||||||
synthetic_message_id: str,
|
synthetic_message_id: str,
|
||||||
clean_text: str,
|
clean_text: str,
|
||||||
intent: CanonicalIntent,
|
intent: CanonicalIntent,
|
||||||
|
|
@ -144,6 +177,7 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
await self._update_session(
|
await self._update_session(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
session=session,
|
||||||
current_project_id=resolved_project_for_session,
|
current_project_id=resolved_project_for_session,
|
||||||
last_intent=intent.name,
|
last_intent=intent.name,
|
||||||
last_message_text=clean_text[:200],
|
last_message_text=clean_text[:200],
|
||||||
|
|
@ -162,6 +196,7 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
work_item: Any,
|
work_item: Any,
|
||||||
|
session: ConversationSession,
|
||||||
intent: CanonicalIntent,
|
intent: CanonicalIntent,
|
||||||
world_model: dict[str, Any],
|
world_model: dict[str, Any],
|
||||||
decision_payload: dict[str, Any],
|
decision_payload: dict[str, Any],
|
||||||
|
|
@ -209,7 +244,7 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
work_state=STATE_WAITING_MANUAL,
|
work_state=STATE_WAITING_MANUAL,
|
||||||
intent=intent,
|
intent=intent,
|
||||||
decision_record={"approval_requirement": policy.get("approval_requirement")},
|
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"])
|
work_item.reply_template_key = str(render["template_key"])
|
||||||
await self._project_waiting_manual(
|
await self._project_waiting_manual(
|
||||||
|
|
@ -271,6 +306,7 @@ class KernelIngressFlowPrepareManualMixin:
|
||||||
await self._update_session(
|
await self._update_session(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
session=session,
|
||||||
current_project_id=_safe_int(work_item.project_id),
|
current_project_id=_safe_int(work_item.project_id),
|
||||||
last_intent=intent.name,
|
last_intent=intent.name,
|
||||||
last_message_text=message_text[:200],
|
last_message_text=message_text[:200],
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,31 @@ class KernelWorldModelCoreBuilderMixin:
|
||||||
risk_summary = await self._build_risk_summary(project_id=resolved_project_id, query_tables=queried_tables)
|
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)
|
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)
|
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 = {
|
world_model: WorldModel = {
|
||||||
"sender": sender,
|
"sender": sender,
|
||||||
|
|
@ -98,6 +123,7 @@ class KernelWorldModelCoreBuilderMixin:
|
||||||
"risk_summary": risk_summary,
|
"risk_summary": risk_summary,
|
||||||
"dependencies": dependencies,
|
"dependencies": dependencies,
|
||||||
"recent_signals": recent_signals,
|
"recent_signals": recent_signals,
|
||||||
|
"active_projects": active_projects,
|
||||||
"decision_context_snapshot": {
|
"decision_context_snapshot": {
|
||||||
"captured_at": _utc_now_naive().isoformat(),
|
"captured_at": _utc_now_naive().isoformat(),
|
||||||
"project_id": resolved_project_id,
|
"project_id": resolved_project_id,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,7 @@ from ...adaptive_rag_service import AdaptiveRAGService
|
||||||
from ...context_cache_service import context_cache_service
|
from ...context_cache_service import context_cache_service
|
||||||
from ...sstc_service_core import ALIGNED_STATUSES
|
from ...sstc_service_core import ALIGNED_STATUSES
|
||||||
from ...task_persistence_core import build_task_create_draft, persist_task
|
from ...task_persistence_core import build_task_create_draft, persist_task
|
||||||
|
from app.utils.thread_context import build_feishu_thread_key
|
||||||
|
|
||||||
|
|
||||||
class ExecuteQueryProjectMixin:
|
class ExecuteQueryProjectMixin:
|
||||||
|
|
@ -42,12 +43,28 @@ class ExecuteQueryProjectMixin:
|
||||||
sync_requirement = bool(rag_request_context.get("sync_requirement"))
|
sync_requirement = bool(rag_request_context.get("sync_requirement"))
|
||||||
message_intent = str(rag_request_context.get("message_intent") or "").strip() or None
|
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
|
mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None
|
||||||
|
else:
|
||||||
|
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:
|
else:
|
||||||
asked_by = _safe_int(operator_user_id)
|
asked_by = _safe_int(operator_user_id)
|
||||||
asked_via = source_system
|
asked_via = source_system
|
||||||
requested_project_id = None
|
requested_project_id = None
|
||||||
source_chat_id = source_message_id
|
source_chat_id = None
|
||||||
conversation_key = source_message_id
|
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
|
session_id = None
|
||||||
force_new_session = False
|
force_new_session = False
|
||||||
thinking_mode = None
|
thinking_mode = None
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from ....models import OperationExecutionLedger
|
||||||
from .shared import * # noqa: F401, F403
|
from .shared import * # noqa: F401, F403
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,6 +21,65 @@ class ExecuteSignalSourceMixin:
|
||||||
source_message_id: str,
|
source_message_id: str,
|
||||||
channel_ref,
|
channel_ref,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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(
|
semantic_payload = self._build_signal_source_semantic_payload(
|
||||||
intent=intent,
|
intent=intent,
|
||||||
message_text=message_text,
|
message_text=message_text,
|
||||||
|
|
@ -49,7 +111,7 @@ class ExecuteSignalSourceMixin:
|
||||||
title=initial_signal_title,
|
title=initial_signal_title,
|
||||||
summary=signal_summary,
|
summary=signal_summary,
|
||||||
content=signal_content,
|
content=signal_content,
|
||||||
project_id=intent.project_id,
|
project_id=int(intent.project_id),
|
||||||
source="PMFlow",
|
source="PMFlow",
|
||||||
signal_type="新需求",
|
signal_type="新需求",
|
||||||
recommended_action="enter_only",
|
recommended_action="enter_only",
|
||||||
|
|
@ -84,7 +146,7 @@ class ExecuteSignalSourceMixin:
|
||||||
if self._is_acceptable_title(ai_source_title):
|
if self._is_acceptable_title(ai_source_title):
|
||||||
signal.title = ai_source_title
|
signal.title = ai_source_title
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return {
|
result = {
|
||||||
"status": (
|
"status": (
|
||||||
"succeeded"
|
"succeeded"
|
||||||
if str(domain_workflow.get("status") or "").lower() == "completed"
|
if str(domain_workflow.get("status") or "").lower() == "completed"
|
||||||
|
|
@ -104,3 +166,129 @@ class ExecuteSignalSourceMixin:
|
||||||
else orchestration.get("openclaw")
|
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,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .mixin_execute_mutations import ExecuteMutationsMixin
|
||||||
from .mixin_execute_query_project import ExecuteQueryProjectMixin
|
from .mixin_execute_query_project import ExecuteQueryProjectMixin
|
||||||
from .mixin_execute_signal_create import ExecuteSignalCreateMixin
|
from .mixin_execute_signal_create import ExecuteSignalCreateMixin
|
||||||
from .mixin_execute_signal_source import ExecuteSignalSourceMixin
|
from .mixin_execute_signal_source import ExecuteSignalSourceMixin
|
||||||
|
|
@ -11,6 +12,7 @@ from .shared import * # noqa: F401, F403
|
||||||
|
|
||||||
class TypedExecutorExecuteMixin(
|
class TypedExecutorExecuteMixin(
|
||||||
ExecuteHelpersMixin,
|
ExecuteHelpersMixin,
|
||||||
|
ExecuteMutationsMixin,
|
||||||
ExecuteQueryProjectMixin,
|
ExecuteQueryProjectMixin,
|
||||||
ExecuteSignalSourceMixin,
|
ExecuteSignalSourceMixin,
|
||||||
ExecuteSignalCreateMixin,
|
ExecuteSignalCreateMixin,
|
||||||
|
|
@ -49,6 +51,33 @@ class TypedExecutorExecuteMixin(
|
||||||
operator_user_id=operator_user_id,
|
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:
|
if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD:
|
||||||
return await self._execute_signal_source_onboard_intent(
|
return await self._execute_signal_source_onboard_intent(
|
||||||
intent=intent,
|
intent=intent,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ for _name, _value in vars(_common).items():
|
||||||
globals()[_name] = _value
|
globals()[_name] = _value
|
||||||
|
|
||||||
from ..signal.draft_generator_service import DraftGeneratorService
|
from ..signal.draft_generator_service import DraftGeneratorService
|
||||||
|
from ...models.feishu_reply_outbox import FeishuReplyOutbox
|
||||||
|
|
||||||
|
|
||||||
class DraftConfirmHandler:
|
class DraftConfirmHandler:
|
||||||
|
|
@ -56,6 +57,7 @@ class DraftConfirmHandler:
|
||||||
channel_ref: Optional[str],
|
channel_ref: Optional[str],
|
||||||
source_sender_open_id: Optional[str],
|
source_sender_open_id: Optional[str],
|
||||||
expected_version: Optional[int] = None,
|
expected_version: Optional[int] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
action, revision_note = self.parse_action_from_text(message_text=message_text)
|
action, revision_note = self.parse_action_from_text(message_text=message_text)
|
||||||
explicit_work_item_id = self._extract_work_item_id(message_text)
|
explicit_work_item_id = self._extract_work_item_id(message_text)
|
||||||
|
|
@ -66,12 +68,25 @@ class DraftConfirmHandler:
|
||||||
channel_ref=channel_ref,
|
channel_ref=channel_ref,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
explicit_work_item_id=explicit_work_item_id,
|
explicit_work_item_id=explicit_work_item_id,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
if target is None:
|
if target is None:
|
||||||
|
if explicit_work_item_id is not None:
|
||||||
return {
|
return {
|
||||||
"handled": True,
|
"handled": False,
|
||||||
"status": "ignored",
|
"status": "ignored",
|
||||||
"followup_reply": "当前没有待确认草稿,请先提交写操作需求。",
|
"followup_reply": "",
|
||||||
|
"target_work_item_id": None,
|
||||||
|
"target_state": None,
|
||||||
|
"target_intent": None,
|
||||||
|
"project_id": None,
|
||||||
|
"signal_id": None,
|
||||||
|
"workflow_route": None,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"handled": False,
|
||||||
|
"status": "ignored",
|
||||||
|
"followup_reply": "",
|
||||||
"target_work_item_id": None,
|
"target_work_item_id": None,
|
||||||
"target_state": None,
|
"target_state": None,
|
||||||
"target_intent": None,
|
"target_intent": None,
|
||||||
|
|
@ -580,6 +595,7 @@ class DraftConfirmHandler:
|
||||||
channel_ref: Optional[str],
|
channel_ref: Optional[str],
|
||||||
source_sender_open_id: Optional[str],
|
source_sender_open_id: Optional[str],
|
||||||
explicit_work_item_id: Optional[int],
|
explicit_work_item_id: Optional[int],
|
||||||
|
thread_context: Optional[dict[str, Any]],
|
||||||
) -> Optional[tuple[WorkItem, IngestEvent]]:
|
) -> Optional[tuple[WorkItem, IngestEvent]]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(WorkItem, IngestEvent)
|
select(WorkItem, IngestEvent)
|
||||||
|
|
@ -603,6 +619,41 @@ class DraftConfirmHandler:
|
||||||
normalized_sender = str(sender or "").strip()
|
normalized_sender = str(sender or "").strip()
|
||||||
normalized_channel = str(channel_ref or "").strip()
|
normalized_channel = str(channel_ref or "").strip()
|
||||||
normalized_open_id = str(source_sender_open_id 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]] = []
|
candidates: list[tuple[WorkItem, IngestEvent]] = []
|
||||||
for work_item, ingest in rows:
|
for work_item, ingest in rows:
|
||||||
|
|
@ -616,12 +667,57 @@ class DraftConfirmHandler:
|
||||||
else:
|
else:
|
||||||
if not (same_sender or same_channel):
|
if not (same_sender or same_channel):
|
||||||
continue
|
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))
|
candidates.append((work_item, ingest))
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
return candidates[0]
|
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(
|
async def _sync_decision_outcome(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|
|
||||||
|
|
@ -71,14 +71,29 @@ class AIServiceBusinessTasksMixin:
|
||||||
signal_content: str,
|
signal_content: str,
|
||||||
tenant_id: Union[str, int] = "default",
|
tenant_id: Union[str, int] = "default",
|
||||||
db=None,
|
db=None,
|
||||||
|
project_id: Optional[Union[str, int]] = None,
|
||||||
|
project_context: Optional[str] = None,
|
||||||
|
active_projects_json: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""从市场/模型信号生成需求宪章草案"""
|
"""从市场/模型信号生成需求宪章草案"""
|
||||||
from ...config.ai_prompts import PROMPT_GENERATE_CHARTER
|
from ...config.ai_prompts import PROMPT_GENERATE_CHARTER
|
||||||
|
|
||||||
tid = _t2s(tenant_id)
|
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(
|
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)
|
return self._parse_json(text)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,47 +42,68 @@ class AIServiceRuntimeCoreMixin:
|
||||||
routing_enabled = self._is_env_true("OPENCLAW_ROUTING_ENABLED", True)
|
routing_enabled = self._is_env_true("OPENCLAW_ROUTING_ENABLED", True)
|
||||||
direct_emergency_enabled = self._is_env_true("LLM_DIRECT_EMERGENCY_ENABLED", False)
|
direct_emergency_enabled = self._is_env_true("LLM_DIRECT_EMERGENCY_ENABLED", False)
|
||||||
if routing_enabled and not direct_emergency_enabled:
|
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 {
|
return {
|
||||||
"api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(),
|
"api_key": str(
|
||||||
"model": str(
|
api_key
|
||||||
model
|
or os.getenv(str(provider_defaults.get("api_env") or "OPENAI_API_KEY"), "")
|
||||||
or os.getenv("LLM_ROUTING_HIGH_MODEL")
|
or os.getenv("OPENAI_API_KEY", "")
|
||||||
or os.getenv("OPENAI_MODEL")
|
|
||||||
or DEFAULT_OPENAI_MODEL
|
|
||||||
or ""
|
or ""
|
||||||
).strip(),
|
).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()
|
provider = str(os.getenv("LLM_PROVIDER", "openai") or "openai").strip().lower()
|
||||||
if provider == "glm":
|
if provider == "glm":
|
||||||
|
defaults = dict(PROVIDER_DEFAULTS.get("glm", {}))
|
||||||
return {
|
return {
|
||||||
"api_key": str(api_key or os.getenv("GLM_API_KEY", "") or "").strip(),
|
"api_key": str(
|
||||||
"model": str(model or os.getenv("GLM_MODEL", DEFAULT_GLM_MODEL) or "").strip(),
|
api_key
|
||||||
"base_url": str(base_url or os.getenv("GLM_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(),
|
or os.getenv("DASHSCOPE_API_KEY")
|
||||||
}
|
or os.getenv("GLM_API_KEY", "")
|
||||||
if provider == "deepseek":
|
or ""
|
||||||
return {
|
).strip(),
|
||||||
"api_key": str(api_key or os.getenv("DEEPSEEK_API_KEY", "") or "").strip(),
|
"model": str(
|
||||||
"model": str(model or os.getenv("DEEPSEEK_MODEL", "deepseek-chat") or "").strip(),
|
model
|
||||||
"base_url": str(base_url or os.getenv("DEEPSEEK_BASE_URL", "https://api.deepseek.com/v1") or "").strip(),
|
or defaults.get("model")
|
||||||
}
|
or DEFAULT_GLM_MODEL
|
||||||
if provider == "ollama":
|
or ""
|
||||||
return {
|
).strip(),
|
||||||
"api_key": str(api_key or "").strip(),
|
|
||||||
"model": str(model or os.getenv("OLLAMA_MODEL", "llama3:latest") or "").strip(),
|
|
||||||
"base_url": str(
|
"base_url": str(
|
||||||
base_url
|
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 os.getenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||||
or ""
|
or ""
|
||||||
).strip(),
|
).strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defaults = dict(PROVIDER_DEFAULTS.get("openai", {}))
|
||||||
return {
|
return {
|
||||||
"api_key": str(api_key or os.getenv("OPENAI_API_KEY", "") or "").strip(),
|
"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(),
|
"model": str(model or defaults.get("model") or DEFAULT_OPENAI_MODEL or "").strip(),
|
||||||
"base_url": str(base_url or os.getenv("OPENAI_BASE_URL", DEFAULT_OPENAI_BASE_URL) or "").strip(),
|
"base_url": str(base_url or defaults.get("base_url") or DEFAULT_OPENAI_BASE_URL or "").strip(),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _get_llm(self) -> BaseChatModel:
|
def _get_llm(self) -> BaseChatModel:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from sqlalchemy import and_, select
|
||||||
|
|
||||||
# LangChain & OpenAI
|
# LangChain & OpenAI
|
||||||
from ...utils.timezone import now_cn_naive
|
from ...utils.timezone import now_cn_naive
|
||||||
|
from ..model.model_config import get_provider_defaults, get_tier_defaults
|
||||||
try:
|
try:
|
||||||
from langchain_openai import ChatOpenAI
|
from langchain_openai import ChatOpenAI
|
||||||
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
|
||||||
|
|
@ -41,9 +42,15 @@ INPUT_PRICE_PER_1K = 0.00015
|
||||||
OUTPUT_PRICE_PER_1K = 0.0006
|
OUTPUT_PRICE_PER_1K = 0.0006
|
||||||
CACHE_TTL_SECONDS = 3600 # 1 小时
|
CACHE_TTL_SECONDS = 3600 # 1 小时
|
||||||
PROMPT_PROFILE_CACHE_TTL_SECONDS = 60
|
PROMPT_PROFILE_CACHE_TTL_SECONDS = 60
|
||||||
DEFAULT_OPENAI_BASE_URL = "https://api.edgefn.net/v1"
|
_PROVIDER_DEFAULTS = get_provider_defaults()
|
||||||
DEFAULT_OPENAI_MODEL = "MiniMax-M2.5"
|
_TIER_DEFAULTS = get_tier_defaults()
|
||||||
DEFAULT_GLM_MODEL = "GLM-4.7"
|
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 ...config.ai_prompts import build_default_role_prompt_bundle
|
||||||
from ...core.database import AsyncSessionLocal
|
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("__")]
|
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,19 @@ class ContextCacheInvalidator:
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _system_key(tenant_id: int) -> str:
|
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
|
@staticmethod
|
||||||
def _project_list_key(tenant_id: int) -> str:
|
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
|
@staticmethod
|
||||||
def _signal_pending_key(tenant_id: int) -> str:
|
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
|
@staticmethod
|
||||||
def _user_key(tenant_id: int, user_id: int) -> str:
|
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(
|
def invalidate(
|
||||||
self,
|
self,
|
||||||
|
|
@ -169,4 +169,3 @@ def register_context_cache_invalidation_hooks() -> None:
|
||||||
session.info.pop("_pmflow_cache_invalidation", None)
|
session.info.pop("_pmflow_cache_invalidation", None)
|
||||||
|
|
||||||
setattr(register_context_cache_invalidation_hooks, "_registered", True)
|
setattr(register_context_cache_invalidation_hooks, "_registered", True)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ class FeishuCardActionMixin:
|
||||||
_ACTION_CONTINUE = "continue"
|
_ACTION_CONTINUE = "continue"
|
||||||
_TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm"
|
_TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm"
|
||||||
_TEMPLATE_PENDING_MANUAL = "pending_manual"
|
_TEMPLATE_PENDING_MANUAL = "pending_manual"
|
||||||
|
_TEMPLATE_QUEUE_PENDING_CONFIRM = "queue_pending_confirm"
|
||||||
_ACTION_ALIASES: dict[str, str] = {
|
_ACTION_ALIASES: dict[str, str] = {
|
||||||
"confirm": _ACTION_CONFIRM,
|
"confirm": _ACTION_CONFIRM,
|
||||||
"draft_confirm": _ACTION_CONFIRM,
|
"draft_confirm": _ACTION_CONFIRM,
|
||||||
|
|
@ -60,6 +61,21 @@ class FeishuCardActionMixin:
|
||||||
return int(row_work_item_id)
|
return int(row_work_item_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_capture_queue_item_id(capture_result: Dict[str, Any]) -> Optional[int]:
|
||||||
|
queue_item_id = FeishuCardActionMixin._safe_int(
|
||||||
|
capture_result.get("queue_item_id")
|
||||||
|
or capture_result.get("item_id")
|
||||||
|
)
|
||||||
|
if queue_item_id is not None and queue_item_id > 0:
|
||||||
|
return int(queue_item_id)
|
||||||
|
rows = capture_result.get("results") if isinstance(capture_result.get("results"), list) else []
|
||||||
|
if rows and isinstance(rows[0], dict):
|
||||||
|
row_item_id = FeishuCardActionMixin._safe_int(rows[0].get("item_id"))
|
||||||
|
if row_item_id is not None and row_item_id > 0:
|
||||||
|
return int(row_item_id)
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _build_action_card_from_capture_result(
|
def _build_action_card_from_capture_result(
|
||||||
cls,
|
cls,
|
||||||
|
|
@ -72,7 +88,8 @@ class FeishuCardActionMixin:
|
||||||
return None
|
return None
|
||||||
template_key = str(capture_result.get("reply_template_key") or "").strip().lower()
|
template_key = str(capture_result.get("reply_template_key") or "").strip().lower()
|
||||||
work_item_id = cls._coerce_capture_work_item_id(capture_result)
|
work_item_id = cls._coerce_capture_work_item_id(capture_result)
|
||||||
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
|
return None
|
||||||
reply_text = str(capture_result.get("direct_reply_text") or "").strip()
|
reply_text = str(capture_result.get("direct_reply_text") or "").strip()
|
||||||
safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。"
|
safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。"
|
||||||
|
|
@ -160,6 +177,35 @@ class FeishuCardActionMixin:
|
||||||
"elements": [*base_elements, {"tag": "action", "actions": actions}],
|
"elements": [*base_elements, {"tag": "action", "actions": actions}],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if template_key == cls._TEMPLATE_QUEUE_PENDING_CONFIRM and queue_item_id is not None:
|
||||||
|
actions = [
|
||||||
|
{
|
||||||
|
"tag": "button",
|
||||||
|
"type": "primary",
|
||||||
|
"text": {"tag": "plain_text", "content": "继续执行"},
|
||||||
|
"value": {
|
||||||
|
"action": cls._ACTION_CONTINUE,
|
||||||
|
"queue_item_id": int(queue_item_id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tag": "button",
|
||||||
|
"type": "danger",
|
||||||
|
"text": {"tag": "plain_text", "content": "取消"},
|
||||||
|
"value": {
|
||||||
|
"action": cls._ACTION_CANCEL,
|
||||||
|
"queue_item_id": int(queue_item_id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"config": {"wide_screen_mode": True, "enable_forward": True},
|
||||||
|
"header": {
|
||||||
|
"title": {"tag": "plain_text", "content": "待确认动作"},
|
||||||
|
},
|
||||||
|
"elements": [*base_elements, {"tag": "action", "actions": actions}],
|
||||||
|
}
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
@ -195,6 +241,11 @@ class FeishuCardActionMixin:
|
||||||
or value.get("target_work_item_id")
|
or value.get("target_work_item_id")
|
||||||
or value.get("workItemId")
|
or value.get("workItemId")
|
||||||
)
|
)
|
||||||
|
queue_item_id = cls._safe_int(
|
||||||
|
value.get("queue_item_id")
|
||||||
|
or value.get("item_id")
|
||||||
|
or value.get("queueItemId")
|
||||||
|
)
|
||||||
draft_version = cls._safe_int(value.get("draft_version") or value.get("version"))
|
draft_version = cls._safe_int(value.get("draft_version") or value.get("version"))
|
||||||
revision_note = (
|
revision_note = (
|
||||||
str(value.get("revision_note") or value.get("note") or value.get("comment") or "")
|
str(value.get("revision_note") or value.get("note") or value.get("comment") or "")
|
||||||
|
|
@ -225,6 +276,7 @@ class FeishuCardActionMixin:
|
||||||
return {
|
return {
|
||||||
"action": normalized_action,
|
"action": normalized_action,
|
||||||
"work_item_id": work_item_id,
|
"work_item_id": work_item_id,
|
||||||
|
"queue_item_id": queue_item_id,
|
||||||
"draft_version": draft_version,
|
"draft_version": draft_version,
|
||||||
"revision_note": revision_note,
|
"revision_note": revision_note,
|
||||||
"source_message_id": source_message_id,
|
"source_message_id": source_message_id,
|
||||||
|
|
@ -240,20 +292,69 @@ class FeishuCardActionMixin:
|
||||||
parsed = self._extract_card_action_payload(event)
|
parsed = self._extract_card_action_payload(event)
|
||||||
action = str(parsed.get("action") or "").strip().lower()
|
action = str(parsed.get("action") or "").strip().lower()
|
||||||
work_item_id = self._safe_int(parsed.get("work_item_id"))
|
work_item_id = self._safe_int(parsed.get("work_item_id"))
|
||||||
|
queue_item_id = self._safe_int(parsed.get("queue_item_id"))
|
||||||
if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE}:
|
if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE}:
|
||||||
return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "unsupported_action"}}
|
return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "unsupported_action"}}
|
||||||
if work_item_id is None or work_item_id <= 0:
|
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"}}
|
return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "missing_work_item_id"}}
|
||||||
|
|
||||||
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime import AIPMOKernelService
|
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime import AIPMOKernelService
|
||||||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||||||
|
from app.services.integrations.feishu_signal_pipeline import FeishuSignalPipelineService
|
||||||
|
|
||||||
operator_sender = str(parsed.get("sender_name") or "飞书用户").strip()[:80] or "飞书用户"
|
operator_sender = str(parsed.get("sender_name") or "飞书用户").strip()[:80] or "飞书用户"
|
||||||
source_message_id = str(parsed.get("source_message_id") or "").strip()[:128] or None
|
source_message_id = str(parsed.get("source_message_id") or "").strip()[:128] or None
|
||||||
chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None
|
chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None
|
||||||
sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None
|
sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None
|
||||||
try:
|
try:
|
||||||
if 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)
|
kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id)
|
||||||
result = await kernel.process_ingress(
|
result = await kernel.process_ingress(
|
||||||
source_system="ai_pmo_ui",
|
source_system="ai_pmo_ui",
|
||||||
|
|
@ -310,15 +411,22 @@ class FeishuCardActionMixin:
|
||||||
or normalized.get("work_item_id")
|
or normalized.get("work_item_id")
|
||||||
or work_item_id
|
or work_item_id
|
||||||
)
|
)
|
||||||
|
target_queue_item_id = self._safe_int(
|
||||||
|
normalized.get("queue_item_id")
|
||||||
|
or queue_item_id
|
||||||
|
)
|
||||||
template_key = ""
|
template_key = ""
|
||||||
if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM:
|
if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM:
|
||||||
template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM
|
template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM
|
||||||
elif status in {"pending_manual", "pending_confirmation"}:
|
elif status in {"pending_manual", "pending_confirmation"}:
|
||||||
template_key = self._TEMPLATE_PENDING_MANUAL
|
template_key = self._TEMPLATE_PENDING_MANUAL
|
||||||
|
elif status in {"queued_for_decision", "pending_queue", "pending_confirmation_queue"}:
|
||||||
|
template_key = self._TEMPLATE_QUEUE_PENDING_CONFIRM
|
||||||
action_card = self._build_action_card_from_capture_result(
|
action_card = self._build_action_card_from_capture_result(
|
||||||
capture_result={
|
capture_result={
|
||||||
"reply_template_key": template_key,
|
"reply_template_key": template_key,
|
||||||
"work_item_id": target_work_item_id,
|
"work_item_id": target_work_item_id,
|
||||||
|
"queue_item_id": target_queue_item_id,
|
||||||
"draft_version": self._safe_int(normalized.get("draft_version")),
|
"draft_version": self._safe_int(normalized.get("draft_version")),
|
||||||
"direct_reply_text": followup_reply,
|
"direct_reply_text": followup_reply,
|
||||||
},
|
},
|
||||||
|
|
@ -329,7 +437,7 @@ class FeishuCardActionMixin:
|
||||||
await self._safe_reply_card(
|
await self._safe_reply_card(
|
||||||
chat_id=chat_id,
|
chat_id=chat_id,
|
||||||
card_content=action_card,
|
card_content=action_card,
|
||||||
trace_id=f"feishu-card-action-{int(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,
|
source_message_id=source_message_id,
|
||||||
perf_t0=None,
|
perf_t0=None,
|
||||||
)
|
)
|
||||||
|
|
@ -339,7 +447,7 @@ class FeishuCardActionMixin:
|
||||||
mention_open_id=sender_open_id,
|
mention_open_id=sender_open_id,
|
||||||
mention_name=operator_sender,
|
mention_name=operator_sender,
|
||||||
text=followup_reply,
|
text=followup_reply,
|
||||||
trace_id=f"feishu-card-action-{int(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,
|
source_message_id=source_message_id,
|
||||||
perf_t0=None,
|
perf_t0=None,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,8 @@ class FeishuConfigMixin:
|
||||||
config = integration.config or {}
|
config = integration.config or {}
|
||||||
self.app_id = config.get('app_id')
|
self.app_id = config.get('app_id')
|
||||||
self.app_secret = config.get('app_secret')
|
self.app_secret = config.get('app_secret')
|
||||||
self.encrypt_key = None
|
self.encrypt_key = str(config.get('encrypt_key') or "").strip() or None
|
||||||
self.verification_token = None
|
self.verification_token = str(config.get('verification_token') or "").strip() or None
|
||||||
self.event_mode = self._normalize_event_mode(config.get('event_mode'))
|
self.event_mode = self._normalize_event_mode(config.get('event_mode'))
|
||||||
else:
|
else:
|
||||||
self.app_id = None
|
self.app_id = None
|
||||||
|
|
@ -86,13 +86,6 @@ class FeishuConfigMixin:
|
||||||
):
|
):
|
||||||
"""保存飞书配置到数据库"""
|
"""保存飞书配置到数据库"""
|
||||||
mode = self._normalize_event_mode(event_mode)
|
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(
|
result = await self.db.execute(
|
||||||
|
|
@ -102,6 +95,25 @@ class FeishuConfigMixin:
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
integration = result.scalar_one_or_none()
|
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:
|
if integration:
|
||||||
integration.config = config
|
integration.config = config
|
||||||
|
|
@ -120,8 +132,8 @@ class FeishuConfigMixin:
|
||||||
# 更新内存配置
|
# 更新内存配置
|
||||||
self.app_id = app_id
|
self.app_id = app_id
|
||||||
self.app_secret = app_secret
|
self.app_secret = app_secret
|
||||||
self.encrypt_key = None
|
self.encrypt_key = resolved_encrypt_key or None
|
||||||
self.verification_token = None
|
self.verification_token = resolved_verification_token or None
|
||||||
self.event_mode = mode
|
self.event_mode = mode
|
||||||
self.tenant_id = tenant_id
|
self.tenant_id = tenant_id
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,11 @@ class FeishuReceiveMixin:
|
||||||
message_id=(message.get("message_id") or "").strip() or None,
|
message_id=(message.get("message_id") or "").strip() or None,
|
||||||
chat_type=str(context.get("chat_type") or "group"),
|
chat_type=str(context.get("chat_type") or "group"),
|
||||||
message_attachments=message_attachments,
|
message_attachments=message_attachments,
|
||||||
|
thread_context=(
|
||||||
|
context.get("thread_context")
|
||||||
|
if isinstance(context.get("thread_context"), dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return await self._handle_capture_exception_from_ingress(
|
return await self._handle_capture_exception_from_ingress(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ class FeishuReceiveCaptureFlowMixin:
|
||||||
message_id: str,
|
message_id: str,
|
||||||
chat_type: str,
|
chat_type: str,
|
||||||
message_attachments: List[Dict[str, Any]],
|
message_attachments: List[Dict[str, Any]],
|
||||||
|
thread_context: Optional[Dict[str, Any]],
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
capture_service = ProactiveSignalCaptureService(self.db)
|
capture_service = ProactiveSignalCaptureService(self.db)
|
||||||
result = await capture_service.capture_from_feishu(
|
result = await capture_service.capture_from_feishu(
|
||||||
|
|
@ -28,6 +29,7 @@ class FeishuReceiveCaptureFlowMixin:
|
||||||
mentioned_bot_names=mentioned_bot_names,
|
mentioned_bot_names=mentioned_bot_names,
|
||||||
feishu_message_id=message_id,
|
feishu_message_id=message_id,
|
||||||
chat_type=chat_type,
|
chat_type=chat_type,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
await self._persist_signal_attachments_from_ingress(
|
await self._persist_signal_attachments_from_ingress(
|
||||||
signal_id=result.get("signal_id"),
|
signal_id=result.get("signal_id"),
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,11 @@ class FeishuReceivePrepareMixin:
|
||||||
chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group"
|
chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group"
|
||||||
captured_at = self._extract_message_datetime(message)
|
captured_at = self._extract_message_datetime(message)
|
||||||
occurred_at_iso = captured_at.isoformat() if isinstance(captured_at, datetime) else None
|
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(
|
self._emit_state_transition(
|
||||||
trace_id=state_trace_id,
|
trace_id=state_trace_id,
|
||||||
|
|
@ -71,6 +76,127 @@ class FeishuReceivePrepareMixin:
|
||||||
"chat_type": chat_type,
|
"chat_type": chat_type,
|
||||||
"captured_at": captured_at,
|
"captured_at": captured_at,
|
||||||
"occurred_at_iso": occurred_at_iso,
|
"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(
|
async def _reply_for_empty_text_ingress(
|
||||||
|
|
|
||||||
|
|
@ -99,11 +99,19 @@ class FeishuReplyLLMCoreMixin:
|
||||||
reply_text = self._strip_leading_thinking_paragraphs(reply_text)
|
reply_text = self._strip_leading_thinking_paragraphs(reply_text)
|
||||||
if not self._is_capability_question(message_content):
|
if not self._is_capability_question(message_content):
|
||||||
reply_text = self._strip_identity_lead(reply_text)
|
reply_text = self._strip_identity_lead(reply_text)
|
||||||
if (not reply_text) or self._looks_like_provider_error(reply_text):
|
if not reply_text:
|
||||||
reply_text = self._fallback_reply_text(capture_result)
|
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:
|
except Exception as exc:
|
||||||
print(f"[Feishu] generate mention reply failed: {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, _ = self._guard_qa_reply_text_with_reason(
|
||||||
reply_text,
|
reply_text,
|
||||||
trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"),
|
trace_id=str(trace_id or f"feishu-sm-{uuid4().hex[:12]}"),
|
||||||
|
|
@ -257,15 +265,29 @@ class FeishuReplyLLMCoreMixin:
|
||||||
|
|
||||||
return False
|
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
|
@staticmethod
|
||||||
def _fallback_reply_text(capture_result: Dict[str, Any]) -> str:
|
def _fallback_reply_text(capture_result: Dict[str, Any]) -> str:
|
||||||
status = str(capture_result.get("status") or "")
|
return FeishuReplyLLMCoreMixin._build_llm_failure_reply_text(capture_result)
|
||||||
classification = str(capture_result.get("classification") or "")
|
|
||||||
if status == "captured":
|
|
||||||
if classification in {"project", "new_project"}:
|
|
||||||
return "我已收到需求并完成初步识别,已按项目级信息入队处理。你可以继续补充目标、范围和时间,我会进一步细化分派。"
|
|
||||||
return "我已收到需求并完成初步识别,已进入信号处理流程。若你希望我直接分派任务,请补充目标、优先级和期望时间。"
|
|
||||||
return "我已收到消息并开始处理。若你希望我直接分派任务,请补充目标、优先级和时间要求。"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_time_truth_reply(message_content: Optional[str]) -> Optional[str]:
|
def _build_time_truth_reply(message_content: Optional[str]) -> Optional[str]:
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,7 @@ class FeishuReplyOutboxMixin:
|
||||||
return
|
return
|
||||||
tenant_id = int(claimed.get("tenant_id") or 1)
|
tenant_id = int(claimed.get("tenant_id") or 1)
|
||||||
reply_hash = str(claimed.get("reply_hash") or "").strip()
|
reply_hash = str(claimed.get("reply_hash") or "").strip()
|
||||||
|
sent_message_id: Optional[str] = None
|
||||||
if reply_hash:
|
if reply_hash:
|
||||||
async with AsyncSessionLocal() as db_check:
|
async with AsyncSessionLocal() as db_check:
|
||||||
sent_duplicate = (
|
sent_duplicate = (
|
||||||
|
|
@ -296,15 +297,16 @@ class FeishuReplyOutboxMixin:
|
||||||
card_content = cls._decode_outbox_card_message(raw_message)
|
card_content = cls._decode_outbox_card_message(raw_message)
|
||||||
if not isinstance(card_content, dict):
|
if not isinstance(card_content, dict):
|
||||||
raise ValueError("invalid_outbox_card_payload")
|
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 ""),
|
chat_id=str(claimed.get("chat_id") or ""),
|
||||||
card_content=card_content,
|
card_content=card_content,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await service.send_message_to_group(
|
send_result = await service.send_message_to_group(
|
||||||
chat_id=str(claimed.get("chat_id") or ""),
|
chat_id=str(claimed.get("chat_id") or ""),
|
||||||
message=raw_message,
|
message=raw_message,
|
||||||
)
|
)
|
||||||
|
sent_message_id = cls._extract_sent_message_id(send_result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
send_error = str(exc)
|
send_error = str(exc)
|
||||||
|
|
||||||
|
|
@ -324,6 +326,8 @@ class FeishuReplyOutboxMixin:
|
||||||
row.sent_at = now_cn_naive()
|
row.sent_at = now_cn_naive()
|
||||||
row.next_retry_at = None
|
row.next_retry_at = None
|
||||||
row.last_error = None
|
row.last_error = None
|
||||||
|
if sent_message_id:
|
||||||
|
row.sent_message_id = sent_message_id[:128]
|
||||||
await db_update.commit()
|
await db_update.commit()
|
||||||
perf_t2 = time.perf_counter()
|
perf_t2 = time.perf_counter()
|
||||||
perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None)
|
perf_t0 = _OUTBOX_PERF_T0_BY_ID.pop(outbox_id, None)
|
||||||
|
|
@ -366,3 +370,21 @@ class FeishuReplyOutboxMixin:
|
||||||
return None
|
return None
|
||||||
raw = f"{source}{body}".encode("utf-8")
|
raw = f"{source}{body}".encode("utf-8")
|
||||||
return hashlib.md5(raw, usedforsecurity=False).hexdigest()
|
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
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from .shared import (
|
||||||
Integration,
|
Integration,
|
||||||
Optional,
|
Optional,
|
||||||
asyncio,
|
asyncio,
|
||||||
|
ensure_feishu_tls_trust_bundle,
|
||||||
json,
|
json,
|
||||||
logger,
|
logger,
|
||||||
now_cn_aware,
|
now_cn_aware,
|
||||||
|
|
@ -63,6 +64,8 @@ class LongConnectionLifecycleConnectionMixin:
|
||||||
self._tenant_id = int((config or {}).get("tenant_id") or 1)
|
self._tenant_id = int((config or {}).get("tenant_id") or 1)
|
||||||
app_id = str((config or {}).get("app_id") or "").strip()
|
app_id = str((config or {}).get("app_id") or "").strip()
|
||||||
app_secret = str((config or {}).get("app_secret") 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)
|
self._configured = bool(app_id and app_secret)
|
||||||
|
|
||||||
if not self._configured:
|
if not self._configured:
|
||||||
|
|
@ -76,6 +79,16 @@ class LongConnectionLifecycleConnectionMixin:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
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)
|
await self._preflight_auth(tenant_id=self._tenant_id)
|
||||||
self._last_auth_at = now_cn_aware().isoformat()
|
self._last_auth_at = now_cn_aware().isoformat()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|
@ -98,7 +111,12 @@ class LongConnectionLifecycleConnectionMixin:
|
||||||
self._last_disconnect_at = None
|
self._last_disconnect_at = None
|
||||||
self._restart_attempts = 0
|
self._restart_attempts = 0
|
||||||
self._next_retry_at = 0.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
|
return True
|
||||||
|
|
||||||
async def _preflight_auth(self, *, tenant_id: int) -> None:
|
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))
|
backoff = self._retry_base_seconds * (2 ** max(0, self._restart_attempts - 1))
|
||||||
self._next_retry_at = time.time() + backoff
|
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(
|
self._thread = threading.Thread(
|
||||||
target=self._run_ws_client,
|
target=self._run_ws_client,
|
||||||
args=(app_id, app_secret),
|
args=(app_id, app_secret, encrypt_key, verification_token),
|
||||||
name="feishu-long-connection",
|
name="feishu-long-connection",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ class LongConnectionLifecycleControlMixin:
|
||||||
self._last_error: Optional[str] = None
|
self._last_error: Optional[str] = None
|
||||||
self._last_error_code: Optional[str] = None
|
self._last_error_code: Optional[str] = None
|
||||||
self._last_error_retryable = False
|
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_event_at: Optional[str] = None
|
||||||
self._last_auth_at: Optional[str] = None
|
self._last_auth_at: Optional[str] = None
|
||||||
self._last_connect_at: Optional[str] = None
|
self._last_connect_at: Optional[str] = None
|
||||||
|
|
@ -134,6 +136,8 @@ class LongConnectionLifecycleControlMixin:
|
||||||
"last_error": self._last_error,
|
"last_error": self._last_error,
|
||||||
"last_error_code": self._last_error_code,
|
"last_error_code": self._last_error_code,
|
||||||
"last_error_retryable": self._last_error_retryable,
|
"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_event_at": self._last_event_at,
|
||||||
"last_auth_at": self._last_auth_at,
|
"last_auth_at": self._last_auth_at,
|
||||||
"last_connect_at": self._last_connect_at,
|
"last_connect_at": self._last_connect_at,
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,34 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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:
|
class LongConnectionRuntimeMixin:
|
||||||
"""Owns SDK callback/runtime event processing."""
|
"""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."""
|
"""Run blocking SDK websocket client in dedicated thread."""
|
||||||
try:
|
try:
|
||||||
|
ensure_feishu_tls_trust_bundle()
|
||||||
import lark_oapi as lark # type: ignore
|
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)
|
register_receive = getattr(builder, "register_p2_im_message_receive_v1", None)
|
||||||
if not callable(register_receive):
|
if not callable(register_receive):
|
||||||
raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1")
|
raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1")
|
||||||
|
|
@ -80,6 +96,9 @@ class LongConnectionRuntimeMixin:
|
||||||
payload = self._to_dict(data)
|
payload = self._to_dict(data)
|
||||||
message_id = ""
|
message_id = ""
|
||||||
event_type = ""
|
event_type = ""
|
||||||
|
event_keys: list[str] = []
|
||||||
|
message_keys: list[str] = []
|
||||||
|
thread_debug: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
event = payload.get("event", {}) if isinstance(payload, dict) else {}
|
event = payload.get("event", {}) if isinstance(payload, dict) else {}
|
||||||
header = payload.get("header", {}) 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 payload.get("type")
|
||||||
or ""
|
or ""
|
||||||
).strip()
|
).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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if isinstance(payload, dict):
|
if isinstance(payload, dict):
|
||||||
|
|
@ -100,6 +144,27 @@ class LongConnectionRuntimeMixin:
|
||||||
f"message_id={message_id or 'unknown'} "
|
f"message_id={message_id or 'unknown'} "
|
||||||
f"event_type={event_type 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(
|
logger.info(
|
||||||
"feishu_long_connection_event_received: "
|
"feishu_long_connection_event_received: "
|
||||||
f"event_type={event_type or 'unknown'}, message_id={message_id or 'unknown'}"
|
f"event_type={event_type or 'unknown'}, message_id={message_id or 'unknown'}"
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import certifi
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.future import select
|
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_MAX_ATTEMPTS = 3
|
||||||
DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS = 1.0
|
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__ = [
|
__all__ = [
|
||||||
"Any",
|
"Any",
|
||||||
"Optional",
|
"Optional",
|
||||||
|
|
@ -41,6 +79,7 @@ __all__ = [
|
||||||
"FeishuIntegrationService",
|
"FeishuIntegrationService",
|
||||||
"CN_TZ",
|
"CN_TZ",
|
||||||
"now_cn_aware",
|
"now_cn_aware",
|
||||||
|
"ensure_feishu_tls_trust_bundle",
|
||||||
"logger",
|
"logger",
|
||||||
"DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS",
|
"DEFAULT_FEISHU_LONG_CONNECTION_RETRY_MAX_ATTEMPTS",
|
||||||
"DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS",
|
"DEFAULT_FEISHU_LONG_CONNECTION_RETRY_BASE_SECONDS",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ class FeishuPipelineCommandCommitProjectMixin:
|
||||||
operator_user: User,
|
operator_user: User,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
trace_id: str,
|
trace_id: str,
|
||||||
|
idempotency_key: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
operator_user_id = _safe_int(getattr(operator_user, "id", None))
|
operator_user_id = _safe_int(getattr(operator_user, "id", None))
|
||||||
if operator_user_id is None:
|
if operator_user_id is None:
|
||||||
|
|
@ -20,6 +21,29 @@ class FeishuPipelineCommandCommitProjectMixin:
|
||||||
if not title:
|
if not title:
|
||||||
raise ValueError("project title required")
|
raise ValueError("project title required")
|
||||||
description = str(payload.get("description") or "").strip()[:4000] or None
|
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 = (
|
existing = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
|
|
@ -33,7 +57,7 @@ class FeishuPipelineCommandCommitProjectMixin:
|
||||||
)
|
)
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
return {
|
result = {
|
||||||
"status": "skipped",
|
"status": "skipped",
|
||||||
"message": "project_already_exists",
|
"message": "project_already_exists",
|
||||||
"entity_type": "project",
|
"entity_type": "project",
|
||||||
|
|
@ -44,8 +68,19 @@ class FeishuPipelineCommandCommitProjectMixin:
|
||||||
"status": str(existing.status or "").strip(),
|
"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)
|
await ensure_project_id_sequence(self.db)
|
||||||
|
project: Optional[Project] = None
|
||||||
|
try:
|
||||||
|
async with self.db.begin_nested():
|
||||||
project = Project(
|
project = Project(
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
name=title,
|
name=title,
|
||||||
|
|
@ -82,9 +117,34 @@ class FeishuPipelineCommandCommitProjectMixin:
|
||||||
},
|
},
|
||||||
remark=trace_id[:1000],
|
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()
|
await self.db.commit()
|
||||||
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
|
return result
|
||||||
return {
|
|
||||||
|
if project is None:
|
||||||
|
raise ValueError("project_create_failed")
|
||||||
|
|
||||||
|
result = {
|
||||||
"status": "applied",
|
"status": "applied",
|
||||||
"message": f"project created #{int(project.id)}",
|
"message": f"project created #{int(project.id)}",
|
||||||
"entity_type": "project",
|
"entity_type": "project",
|
||||||
|
|
@ -95,4 +155,100 @@ class FeishuPipelineCommandCommitProjectMixin:
|
||||||
"status": str(project.status or "").strip(),
|
"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()
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,16 @@ class FeishuPipelineCommandCommitTaskMixin:
|
||||||
operator_user: User,
|
operator_user: User,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
trace_id: str,
|
trace_id: str,
|
||||||
|
idempotency_key: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
operator_user_id = _safe_int(getattr(operator_user, "id", None))
|
operator_user_id = _safe_int(getattr(operator_user, "id", None))
|
||||||
if operator_user_id is None:
|
if operator_user_id is None:
|
||||||
raise ValueError("operator_not_found")
|
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"))
|
project_id = _safe_int(payload.get("project_id"))
|
||||||
if project_id is None:
|
if project_id is None:
|
||||||
raise ValueError("project_context_missing")
|
raise ValueError("project_context_missing")
|
||||||
|
|
@ -46,6 +52,26 @@ class FeishuPipelineCommandCommitTaskMixin:
|
||||||
if charter is None:
|
if charter is None:
|
||||||
raise ValueError("aligned charter required before task.create")
|
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(
|
task = await persist_task(
|
||||||
self.db,
|
self.db,
|
||||||
tenant_id=self.tenant_id,
|
tenant_id=self.tenant_id,
|
||||||
|
|
@ -59,20 +85,105 @@ class FeishuPipelineCommandCommitTaskMixin:
|
||||||
actor_user_id=operator_user_id,
|
actor_user_id=operator_user_id,
|
||||||
action="task.create",
|
action="task.create",
|
||||||
remark=trace_id[:1000],
|
remark=trace_id[:1000],
|
||||||
commit=True,
|
commit=False,
|
||||||
refresh_workload=False,
|
refresh_workload=False,
|
||||||
)
|
)
|
||||||
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
|
task_id = int(task.id)
|
||||||
self._sync_task_rag(task=task)
|
task_title = str(task.title or "").strip()
|
||||||
return {
|
task_project_id = int(task.project_id)
|
||||||
|
result = {
|
||||||
"status": "applied",
|
"status": "applied",
|
||||||
"message": f"task created #{int(task.id)}",
|
"message": f"task created #{task_id}",
|
||||||
"entity_type": "task",
|
"entity_type": "task",
|
||||||
"entity_id": str(task.id),
|
"entity_id": str(task_id),
|
||||||
"result": {
|
"result": {
|
||||||
"task_id": int(task.id),
|
"task_id": task_id,
|
||||||
"title": str(task.title or "").strip(),
|
"title": task_title,
|
||||||
"project_id": int(project_id),
|
"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()
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ class FeishuPipelineCommandExecCoreMixin:
|
||||||
operation_code: str,
|
operation_code: str,
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
trace_id: str,
|
trace_id: str,
|
||||||
|
idempotency_key: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
logger.info(
|
logger.info(
|
||||||
"operation_started",
|
"operation_started",
|
||||||
|
|
@ -84,12 +85,14 @@ class FeishuPipelineCommandExecCoreMixin:
|
||||||
operator_user=operator_user,
|
operator_user=operator_user,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
)
|
)
|
||||||
elif operation_code == "task.create":
|
elif operation_code == "task.create":
|
||||||
result = await self._commit_task_create(
|
result = await self._commit_task_create(
|
||||||
operator_user=operator_user,
|
operator_user=operator_user,
|
||||||
payload=payload,
|
payload=payload,
|
||||||
trace_id=trace_id,
|
trace_id=trace_id,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
raise ValueError("operation_not_supported")
|
raise ValueError("operation_not_supported")
|
||||||
|
|
@ -144,4 +147,3 @@ class FeishuPipelineCommandExecCoreMixin:
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,8 @@ class FeishuPipelineContextPrefillAMixin:
|
||||||
source_chat_id: str,
|
source_chat_id: str,
|
||||||
source_sender_open_id: Optional[str],
|
source_sender_open_id: Optional[str],
|
||||||
sender: str,
|
sender: str,
|
||||||
|
source_message_id: Optional[str] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
limit: int = 3,
|
limit: int = 3,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
if self.db is None or not source_chat_id:
|
if self.db is None or not source_chat_id:
|
||||||
|
|
@ -171,6 +173,8 @@ class FeishuPipelineContextPrefillAMixin:
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key)
|
external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key)
|
||||||
if external_session_id is None:
|
if external_session_id is None:
|
||||||
|
|
@ -276,4 +280,3 @@ class FeishuPipelineContextPrefillAMixin:
|
||||||
"missing_fields": missing_fields,
|
"missing_fields": missing_fields,
|
||||||
"ready": not missing_fields,
|
"ready": not missing_fields,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
source_sender_open_id: Optional[str],
|
source_sender_open_id: Optional[str],
|
||||||
sender: str,
|
sender: str,
|
||||||
fields: dict[str, Any],
|
fields: dict[str, Any],
|
||||||
|
source_message_id: Optional[str] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
if fields.get("ready"):
|
if fields.get("ready"):
|
||||||
return fields
|
return fields
|
||||||
|
|
@ -23,6 +25,8 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
thread_context=thread_context,
|
||||||
limit=3,
|
limit=3,
|
||||||
)
|
)
|
||||||
context_blocks: list[str] = []
|
context_blocks: list[str] = []
|
||||||
|
|
@ -39,12 +43,6 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
context_blocks.append(f"最近系统回答:{recent_answer}")
|
context_blocks.append(f"最近系统回答:{recent_answer}")
|
||||||
answer_hint = 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:
|
if not context_blocks:
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
@ -230,4 +228,3 @@ class FeishuPipelineContextPrefillBMixin:
|
||||||
if str(blocking_reason or "").strip():
|
if str(blocking_reason or "").strip():
|
||||||
return str(blocking_reason).strip()
|
return str(blocking_reason).strip()
|
||||||
return _compact_text(fallback_text, max_len=140)
|
return _compact_text(fallback_text, max_len=140)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -257,9 +257,11 @@ class FeishuPipelineExtractionMixin:
|
||||||
return {
|
return {
|
||||||
"suggested_name": suggested_name,
|
"suggested_name": suggested_name,
|
||||||
"suggested_description": suggested_description,
|
"suggested_description": suggested_description,
|
||||||
"suggested_owner": "待指定",
|
"suggested_owner": None,
|
||||||
"suggested_timeline": "待评估",
|
"suggested_timeline": None,
|
||||||
"confidence": 0.62,
|
"confidence": 0.35,
|
||||||
"reasoning": ["LLM 返回异常,已按规则生成项目预填草案。"],
|
"reasoning": [
|
||||||
|
"LLM 返回异常,已按规则生成项目预填草案。",
|
||||||
|
"负责人和时间线仅在消息明确给出时才填写。",
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from app.utils.thread_context import extract_thread_anchor
|
||||||
|
|
||||||
|
|
||||||
class FeishuPipelineFollowupAMixin:
|
class FeishuPipelineFollowupAMixin:
|
||||||
|
|
@ -11,8 +12,10 @@ class FeishuPipelineFollowupAMixin:
|
||||||
message: str,
|
message: str,
|
||||||
sender: str,
|
sender: str,
|
||||||
source_chat_id: str,
|
source_chat_id: str,
|
||||||
|
source_sender_open_id: Optional[str],
|
||||||
feishu_message_id: Optional[str],
|
feishu_message_id: Optional[str],
|
||||||
process_started_at: float,
|
process_started_at: float,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
if not source_chat_id:
|
if not source_chat_id:
|
||||||
return None
|
return None
|
||||||
|
|
@ -21,7 +24,26 @@ class FeishuPipelineFollowupAMixin:
|
||||||
if not wants_continue and not wants_status:
|
if not wants_continue and not wants_status:
|
||||||
return None
|
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:
|
if not recent_items:
|
||||||
reply_text = "当前群里还没有可追踪的飞书处理项。"
|
reply_text = "当前群里还没有可追踪的飞书处理项。"
|
||||||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||||||
|
|
@ -31,6 +53,9 @@ class FeishuPipelineFollowupAMixin:
|
||||||
mutations: list[dict[str, Any]] = []
|
mutations: list[dict[str, Any]] = []
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
elif wants_continue:
|
elif wants_continue:
|
||||||
|
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]
|
pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED]
|
||||||
if len(pending_items) != 1:
|
if len(pending_items) != 1:
|
||||||
reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3])
|
reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3])
|
||||||
|
|
@ -144,10 +169,23 @@ class FeishuPipelineFollowupAMixin:
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
target_item = self._select_followup_status_target(message=message, recent_items=recent_items)
|
target_item = anchor_item or self._select_followup_status_target(
|
||||||
reply_text = await self._build_followup_status_reply(target_item)
|
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
|
decision = ROUTING_QUEUE_FOLLOWUP_QUERY
|
||||||
reason = "命中进度/状态追问,已直接基于真实 DB 返回最近处理项状态。"
|
reason = "命中进度/状态追问,已直接基于真实 DB 返回唯一处理项状态。"
|
||||||
classification = "question"
|
classification = "question"
|
||||||
status = "answered"
|
status = "answered"
|
||||||
mutations = []
|
mutations = []
|
||||||
|
|
@ -172,7 +210,7 @@ class FeishuPipelineFollowupAMixin:
|
||||||
routing_decision=decision,
|
routing_decision=decision,
|
||||||
routing_reason=reason,
|
routing_reason=reason,
|
||||||
reasoning_bullets=[
|
reasoning_bullets=[
|
||||||
"follow-up 查询/指令命中最近飞书上下文。",
|
"follow-up 查询/指令命中飞书线程锚点或唯一目标。",
|
||||||
"回复内容只基于真实 DB 状态,不使用自由生成。",
|
"回复内容只基于真实 DB 状态,不使用自由生成。",
|
||||||
f"recent_items={len(recent_items)}",
|
f"recent_items={len(recent_items)}",
|
||||||
],
|
],
|
||||||
|
|
@ -203,16 +241,26 @@ class FeishuPipelineFollowupAMixin:
|
||||||
"db_mutations": mutations,
|
"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 = (
|
rows = (
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
select(FeishuItem)
|
select(FeishuItem)
|
||||||
.where(
|
.where(and_(*filters))
|
||||||
and_(
|
|
||||||
FeishuItem.tenant_id == self.tenant_id,
|
|
||||||
FeishuItem.source_chat_id == (source_chat_id or "")[:128],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(FeishuItem.id.desc())
|
.order_by(FeishuItem.id.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
@ -243,6 +291,15 @@ class FeishuPipelineFollowupAMixin:
|
||||||
if level == "command":
|
if level == "command":
|
||||||
project_id = confirm_result.get("project_id")
|
project_id = confirm_result.get("project_id")
|
||||||
task_id = confirm_result.get("task_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:
|
if task_id and project_id:
|
||||||
return f"已继续处理:任务#{task_id} 已在项目#{project_id} 中创建。"
|
return f"已继续处理:任务#{task_id} 已在项目#{project_id} 中创建。"
|
||||||
if project_id:
|
if project_id:
|
||||||
|
|
@ -253,4 +310,3 @@ class FeishuPipelineFollowupAMixin:
|
||||||
if signal_id and project_id:
|
if signal_id and project_id:
|
||||||
return f"已继续处理:信号#{signal_id} 已确认并关联项目#{project_id}。"
|
return f"已继续处理:信号#{signal_id} 已确认并关联项目#{project_id}。"
|
||||||
return f"已继续处理:队列项#{int(target_item.id)} 已确认。"
|
return f"已继续处理:队列项#{int(target_item.id)} 已确认。"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,47 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from app.utils.thread_context import extract_thread_anchor
|
||||||
|
|
||||||
|
|
||||||
class FeishuPipelineFollowupBMixin:
|
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
|
@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:
|
if not recent_items:
|
||||||
raise ValueError("recent_items_empty")
|
return None
|
||||||
normalized = re.sub(r"\s+", "", str(message or "")).lower()
|
normalized = re.sub(r"\s+", "", str(message or "")).lower()
|
||||||
wants_project_create = any(token in normalized for token in ("创建", "建好", "建成", "项目"))
|
wants_project_create = any(token in normalized for token in ("创建", "建好", "建成", "项目"))
|
||||||
wants_source_status = any(token in normalized for token in ("信号源", "web搜索", "webhook", "source"))
|
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:
|
if wants_source_status:
|
||||||
for item in recent_items:
|
for item in recent_items:
|
||||||
operation_code = cls._extract_item_operation_code(item)
|
operation_code = cls._extract_item_operation_code(item)
|
||||||
if operation_code == "source.connect" or "信号源" in str(item.raw_message or ""):
|
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:
|
if wants_project_create:
|
||||||
for item in recent_items:
|
for item in recent_items:
|
||||||
|
|
@ -29,16 +54,42 @@ class FeishuPipelineFollowupBMixin:
|
||||||
or operation_code == "project.create"
|
or operation_code == "project.create"
|
||||||
or card_type in {"project_create", "new_project", "new_project_candidate"}
|
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)
|
route_status = self._normalize_route_status(item.route_status)
|
||||||
level = str(item.level or "").strip().lower()
|
level = str(item.level or "").strip().lower()
|
||||||
if level == "project":
|
if level == "project":
|
||||||
suggestion = await self._get_project_suggestion(int(item.id))
|
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_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:
|
if route_status == ROUTE_STATUS_AUTO_DONE and project_id:
|
||||||
return f"最近一条项目候选已确认,项目#{int(project_id)} 已落库。"
|
return f"最近一条项目候选已确认,项目#{int(project_id)} 已落库。"
|
||||||
if route_status == ROUTE_STATUS_UNDONE:
|
if route_status == ROUTE_STATUS_UNDONE:
|
||||||
|
|
@ -50,7 +101,17 @@ class FeishuPipelineFollowupBMixin:
|
||||||
operation_code = str(payload.get("operation_code") or "").strip()
|
operation_code = str(payload.get("operation_code") or "").strip()
|
||||||
project_id = _safe_int(payload.get("project_id") or item.matched_project_id)
|
project_id = _safe_int(payload.get("project_id") or item.matched_project_id)
|
||||||
summary = str(payload.get("result_summary") or "").strip()
|
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 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 (
|
return summary or (
|
||||||
f"最近一条命令已执行完成,归属项目#{int(project_id)}。"
|
f"最近一条命令已执行完成,归属项目#{int(project_id)}。"
|
||||||
if project_id is not None
|
if project_id is not None
|
||||||
|
|
@ -59,6 +120,8 @@ class FeishuPipelineFollowupBMixin:
|
||||||
if route_status == ROUTE_STATUS_UNDONE:
|
if route_status == ROUTE_STATUS_UNDONE:
|
||||||
return "最近一条命令结果已撤销。"
|
return "最近一条命令结果已撤销。"
|
||||||
if route_status == ROUTE_STATUS_QUEUED:
|
if route_status == ROUTE_STATUS_QUEUED:
|
||||||
|
if operation_code == "source.connect":
|
||||||
|
return "最近一条信号源接入请求已入队待确认。"
|
||||||
return (
|
return (
|
||||||
f"最近一条命令 {operation_code or '待处理动作'} 已入队待确认。"
|
f"最近一条命令 {operation_code or '待处理动作'} 已入队待确认。"
|
||||||
if operation_code
|
if operation_code
|
||||||
|
|
@ -296,4 +359,3 @@ class FeishuPipelineFollowupBMixin:
|
||||||
if operation_code == "notification.create":
|
if operation_code == "notification.create":
|
||||||
return f"通知#{row.get('entity_id') or ''}已创建".strip()
|
return f"通知#{row.get('entity_id') or ''}已创建".strip()
|
||||||
return message or operation_code
|
return message or operation_code
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,7 @@ class FeishuPipelineGateDispatchMixin:
|
||||||
projects: list[dict[str, Any]],
|
projects: list[dict[str, Any]],
|
||||||
process_started_at: float,
|
process_started_at: float,
|
||||||
normalized_chat_type: str,
|
normalized_chat_type: str,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
gate_intent = str(intent_gate.get("intent") or INTENT_SIGNAL_INTAKE)
|
gate_intent = str(intent_gate.get("intent") or INTENT_SIGNAL_INTAKE)
|
||||||
if gate_intent == INTENT_SMALLTALK:
|
if gate_intent == INTENT_SMALLTALK:
|
||||||
|
|
@ -155,6 +156,8 @@ class FeishuPipelineGateDispatchMixin:
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
source_message_id=feishu_message_id,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
intent_result: dict[str, Any] = {}
|
intent_result: dict[str, Any] = {}
|
||||||
try:
|
try:
|
||||||
|
|
@ -171,14 +174,14 @@ class FeishuPipelineGateDispatchMixin:
|
||||||
question=rag_question,
|
question=rag_question,
|
||||||
asked_by=None,
|
asked_by=None,
|
||||||
asked_via="feishu",
|
asked_via="feishu",
|
||||||
project_id=inferred_project_id,
|
project_id=None,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
)
|
)
|
||||||
qa_capture = await self._build_direct_qa_capture(
|
qa_capture = await self._build_direct_qa_capture(
|
||||||
message=message,
|
message=message,
|
||||||
rag_result=rag_result,
|
rag_result=rag_result,
|
||||||
inferred_project_id=inferred_project_id,
|
inferred_project_id=None,
|
||||||
feishu_message_id=feishu_message_id,
|
feishu_message_id=feishu_message_id,
|
||||||
process_started_at=process_started_at,
|
process_started_at=process_started_at,
|
||||||
route_reason="intent gate 判定为 query,直接走 RAG 直答通道,不进入决策队列。",
|
route_reason="intent gate 判定为 query,直接走 RAG 直答通道,不进入决策队列。",
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ class FeishuPipelineGateProjectMixin:
|
||||||
"clarification_message": clarification_message,
|
"clarification_message": clarification_message,
|
||||||
"direct_reply_required": True,
|
"direct_reply_required": True,
|
||||||
"direct_reply_text": clarification_message,
|
"direct_reply_text": clarification_message,
|
||||||
|
"reply_template_key": "queue_pending_confirm",
|
||||||
}
|
}
|
||||||
|
|
||||||
if not self._can_execute_high_value_action(operator_user):
|
if not self._can_execute_high_value_action(operator_user):
|
||||||
|
|
@ -130,79 +131,62 @@ class FeishuPipelineGateProjectMixin:
|
||||||
"description": payload_description,
|
"description": payload_description,
|
||||||
}
|
}
|
||||||
trace_id = f"intent-project-{self._stable_positive_int(message, sender, feishu_message_id)}"
|
trace_id = f"intent-project-{self._stable_positive_int(message, sender, feishu_message_id)}"
|
||||||
execution = await self._execute_controlled_operation(
|
queued = await self._queue_project_intent_item(
|
||||||
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(
|
|
||||||
message=message,
|
message=message,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
feishu_message_id=feishu_message_id,
|
feishu_message_id=feishu_message_id,
|
||||||
captured_at=captured_at,
|
occurred_at=occurred_at,
|
||||||
intent=INTENT_CREATE_PROJECT,
|
intent_gate=intent_gate,
|
||||||
operation_code="project.create",
|
blocking_reason=clarification_message or "建项目已进入待确认队列,请点击继续执行。",
|
||||||
execution_result=first,
|
decision_reason="high_value_action_guard",
|
||||||
matched_project_id=project_id,
|
)
|
||||||
|
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(
|
await self._write_processing_log(
|
||||||
feishu_message_id=feishu_message_id,
|
feishu_message_id=feishu_message_id,
|
||||||
feishu_item_id=item_id,
|
feishu_item_id=item_id,
|
||||||
raw_message=message,
|
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,
|
project_score=1.0,
|
||||||
signal_confidence=0.0,
|
signal_confidence=0.0,
|
||||||
level="project",
|
level="project",
|
||||||
routing_decision=ROUTING_OPERATION_COMMITTED,
|
routing_decision=ROUTING_INTENT_GATE_CLARIFICATION,
|
||||||
routing_reason="建项目已通过 deterministic operation executor 提交,并以真实 DB 结果回执。",
|
routing_reason="建项目已转为待确认卡片,等待人工点击继续执行。",
|
||||||
reasoning_bullets=[
|
reasoning_bullets=[
|
||||||
"intent gate 命中 create_project。",
|
"intent gate 命中 create_project。",
|
||||||
"执行器仅走 project.create。",
|
"执行器不再直接 commit,统一进入待确认卡片。",
|
||||||
"回复绑定真实 commit 结果。",
|
"点击后再走受控 project.create。",
|
||||||
],
|
],
|
||||||
processing_status="success",
|
processing_status="success",
|
||||||
error_message=None,
|
error_message=None,
|
||||||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||||||
normalized_text=message,
|
normalized_text=message,
|
||||||
route=ROUTING_OPERATION_COMMITTED,
|
route=ROUTING_INTENT_GATE_CLARIFICATION,
|
||||||
intent=INTENT_CREATE_PROJECT,
|
intent=INTENT_CREATE_PROJECT,
|
||||||
executor="explicit_service",
|
executor="explicit_service",
|
||||||
operation_code="project.create",
|
operation_code="project.create",
|
||||||
operation_result=first if isinstance(first, dict) else None,
|
operation_result={"status": "queued", "queue_item_id": item_id},
|
||||||
reply_source="db_commit",
|
reply_source="deterministic_reply",
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"status": "captured",
|
"status": "captured",
|
||||||
"classification": "project",
|
"classification": "project",
|
||||||
"project_scope": "project",
|
"project_scope": "project",
|
||||||
"linked_project_ids": [int(project_id)] if project_id is not None else [],
|
"linked_project_ids": [],
|
||||||
"signal_id": None,
|
"signal_id": None,
|
||||||
"created_project_id": int(project_id) if project_id is not None else None,
|
"created_project_id": None,
|
||||||
"auto_tasks": [],
|
"auto_tasks": [],
|
||||||
"ai_trace_id": trace_id,
|
"ai_trace_id": f"feishu-project-pending-{item_id}",
|
||||||
"results": [
|
"results": [queued],
|
||||||
{
|
|
||||||
"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,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"intent": INTENT_CREATE_PROJECT,
|
"intent": INTENT_CREATE_PROJECT,
|
||||||
"direct_reply_required": True,
|
"direct_reply_required": True,
|
||||||
"direct_reply_text": reply_text,
|
"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},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ class FeishuPipelineIntentGateCoreMixin:
|
||||||
source_chat_id: str = "",
|
source_chat_id: str = "",
|
||||||
source_sender_open_id: Optional[str] = None,
|
source_sender_open_id: Optional[str] = None,
|
||||||
sender: str = "",
|
sender: str = "",
|
||||||
|
source_message_id: Optional[str] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
text = str(message or "").strip()
|
text = str(message or "").strip()
|
||||||
if not text:
|
if not text:
|
||||||
|
|
@ -121,6 +123,8 @@ class FeishuPipelineIntentGateCoreMixin:
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
source_message_id=source_message_id,
|
||||||
|
thread_context=thread_context,
|
||||||
fields=fields,
|
fields=fields,
|
||||||
)
|
)
|
||||||
if fields["ready"]:
|
if fields["ready"]:
|
||||||
|
|
@ -280,4 +284,3 @@ class FeishuPipelineIntentGateCoreMixin:
|
||||||
def _strip_feishu_mention_prefix(text: str) -> str:
|
def _strip_feishu_mention_prefix(text: str) -> str:
|
||||||
cleaned = re.sub(r"^<at[^>]*>[^<]*</at>\s*", "", str(text or ""), flags=re.IGNORECASE).strip()
|
cleaned = re.sub(r"^<at[^>]*>[^<]*</at>\s*", "", str(text or ""), flags=re.IGNORECASE).strip()
|
||||||
return re.sub(r"\s+", " ", cleaned).strip()
|
return re.sub(r"\s+", " ", cleaned).strip()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,7 @@ class FeishuPipelineItemFinalizeRoutingMixin:
|
||||||
correction_keywords=[],
|
correction_keywords=[],
|
||||||
complexity_reasons=[],
|
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.card_type = "signal"
|
||||||
item.route_status = ROUTE_STATUS_QUEUED
|
item.route_status = ROUTE_STATUS_QUEUED
|
||||||
item.queue_visible = True
|
item.queue_visible = True
|
||||||
|
|
@ -181,14 +181,10 @@ class FeishuPipelineItemFinalizeRoutingMixin:
|
||||||
item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。"
|
item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。"
|
||||||
item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。"
|
item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。"
|
||||||
item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。"
|
item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。"
|
||||||
elif gate.action == "reject":
|
|
||||||
item.blocking_reason = gate.reason
|
|
||||||
item.next_step_text = "请补充更明确的执行对象后重试,或人工确认继续处理。"
|
|
||||||
item.decision_reason = "共享决策内核判定置信度不足,自动执行被拒绝。"
|
|
||||||
else:
|
else:
|
||||||
item.blocking_reason = gate.reason or "当前置信度不足以自动入库,需要你拍板后再继续。"
|
item.blocking_reason = gate.reason or "当前信息不足以自动入库,需要你确认后再继续。"
|
||||||
item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。"
|
item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。"
|
||||||
item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。"
|
item.decision_reason = "信号仍需人工确认后再进入自动入库。"
|
||||||
item.result_payload = {
|
item.result_payload = {
|
||||||
"result_summary": "信号已进入待确认队列。",
|
"result_summary": "信号已进入待确认队列。",
|
||||||
"result_count": 0,
|
"result_count": 0,
|
||||||
|
|
@ -222,4 +218,3 @@ class FeishuPipelineItemFinalizeRoutingMixin:
|
||||||
signal=signal,
|
signal=signal,
|
||||||
)
|
)
|
||||||
return int(matched_project_id)
|
return int(matched_project_id)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,8 +221,12 @@ class FeishuPipelineItemProcessingMixin:
|
||||||
feishu_item_id=int(item_id),
|
feishu_item_id=int(item_id),
|
||||||
suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255],
|
suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255],
|
||||||
suggested_description=(project_info.get("suggested_description") or content[:400])[:4000],
|
suggested_description=(project_info.get("suggested_description") or content[:400])[:4000],
|
||||||
suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120],
|
suggested_owner=(
|
||||||
suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120],
|
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),
|
confidence=_safe_float(project_info.get("confidence"), fallback_confidence),
|
||||||
reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False),
|
reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False),
|
||||||
status="pending",
|
status="pending",
|
||||||
|
|
@ -291,4 +295,3 @@ class FeishuPipelineItemProcessingMixin:
|
||||||
"signal_id": None,
|
"signal_id": None,
|
||||||
"route_status": item.route_status,
|
"route_status": item.route_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from .shared import *
|
from .shared import *
|
||||||
|
from app.utils.thread_context import build_feishu_thread_key
|
||||||
|
|
||||||
|
|
||||||
class FeishuPipelineMessageUtilsAMixin:
|
class FeishuPipelineMessageUtilsAMixin:
|
||||||
|
|
@ -75,16 +76,16 @@ class FeishuPipelineMessageUtilsAMixin:
|
||||||
source_chat_id: Optional[str],
|
source_chat_id: Optional[str],
|
||||||
source_sender_open_id: Optional[str],
|
source_sender_open_id: Optional[str],
|
||||||
sender: Optional[str],
|
sender: Optional[str],
|
||||||
|
source_message_id: Optional[str] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
chat = str(source_chat_id or "").strip()
|
return build_feishu_thread_key(
|
||||||
if not chat:
|
source_chat_id=source_chat_id,
|
||||||
return ""
|
source_sender_open_id=source_sender_open_id,
|
||||||
actor = str(source_sender_open_id or "").strip()
|
sender=sender,
|
||||||
if not actor:
|
source_message_id=source_message_id,
|
||||||
actor = str(sender or "").strip().lower()
|
thread_context=thread_context,
|
||||||
actor = re.sub(r"\s+", "", actor)
|
)
|
||||||
actor = actor[:96] if actor else "unknown"
|
|
||||||
return f"feishu:{chat}:{actor}"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_requirement_like_question(message: str) -> bool:
|
def _is_requirement_like_question(message: str) -> bool:
|
||||||
|
|
@ -137,4 +138,3 @@ class FeishuPipelineMessageUtilsAMixin:
|
||||||
if converted:
|
if converted:
|
||||||
return converted
|
return converted
|
||||||
return self._normalize_incoming_text(fallback_content)
|
return self._normalize_incoming_text(fallback_content)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ class FeishuPipelineMessageUtilsBMixin:
|
||||||
matched_project_id: Optional[int],
|
matched_project_id: Optional[int],
|
||||||
signal_auto_enter_threshold: float,
|
signal_auto_enter_threshold: float,
|
||||||
) -> tuple[str, str, list[str]]:
|
) -> tuple[str, str, list[str]]:
|
||||||
|
_ = signal_auto_enter_threshold
|
||||||
if cold_start_enabled:
|
if cold_start_enabled:
|
||||||
return (
|
return (
|
||||||
ROUTING_PENDING_COLD_START,
|
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:
|
if not matched_project_id:
|
||||||
return (
|
return (
|
||||||
ROUTING_PENDING_UNCERTAIN_PROJECT,
|
ROUTING_PENDING_UNCERTAIN_PROJECT,
|
||||||
"置信度达标但项目归属不确定,转人工确认。",
|
"项目归属不确定,转人工确认。",
|
||||||
[
|
[
|
||||||
f"signal_confidence={confidence:.2f},达到自动判定线。",
|
f"signal_confidence={confidence:.2f},仅用于排序参考。",
|
||||||
"未识别到明确项目归属。",
|
"未识别到明确项目归属。",
|
||||||
"已进入待处理队列等待项目确认。",
|
"已进入待处理队列等待项目确认。",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
ROUTING_AUTO_ENTER_SIGNAL,
|
ROUTING_AUTO_ENTER_SIGNAL,
|
||||||
"信号置信度达标且项目归属明确,系统已自动入库。",
|
"项目归属明确,系统已自动入库。",
|
||||||
[
|
[
|
||||||
f"signal_confidence={confidence:.2f},高于 {signal_auto_enter_threshold:.2f} 阈值。",
|
f"signal_confidence={confidence:.2f},仅用于排序参考。",
|
||||||
f"已匹配项目 #{int(matched_project_id)}。",
|
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 action_tokens)
|
||||||
and any(token in text for token in object_tokens)
|
and any(token in text for token in object_tokens)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ class FeishuPipelineProcessMessageCoreMixin:
|
||||||
feishu_message_id: Optional[str] = None,
|
feishu_message_id: Optional[str] = None,
|
||||||
captured_at: Optional[datetime] = None,
|
captured_at: Optional[datetime] = None,
|
||||||
chat_type: Optional[str] = None,
|
chat_type: Optional[str] = None,
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
process_started_at = time.perf_counter()
|
process_started_at = time.perf_counter()
|
||||||
normalized_chat_type = self._normalize_chat_type(chat_type=chat_type, source_chat_id=source_chat_id)
|
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,
|
message=message,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
|
source_sender_open_id=source_sender_open_id,
|
||||||
feishu_message_id=feishu_message_id,
|
feishu_message_id=feishu_message_id,
|
||||||
process_started_at=process_started_at,
|
process_started_at=process_started_at,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
if followup_capture is not None:
|
if followup_capture is not None:
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
|
|
@ -159,6 +162,8 @@ class FeishuPipelineProcessMessageCoreMixin:
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
source_message_id=feishu_message_id,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"intent_resolved",
|
"intent_resolved",
|
||||||
|
|
@ -186,6 +191,7 @@ class FeishuPipelineProcessMessageCoreMixin:
|
||||||
projects=projects,
|
projects=projects,
|
||||||
process_started_at=process_started_at,
|
process_started_at=process_started_at,
|
||||||
normalized_chat_type=normalized_chat_type,
|
normalized_chat_type=normalized_chat_type,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
if gate_capture is not None:
|
if gate_capture is not None:
|
||||||
return gate_capture
|
return gate_capture
|
||||||
|
|
@ -203,5 +209,5 @@ class FeishuPipelineProcessMessageCoreMixin:
|
||||||
process_started_at=process_started_at,
|
process_started_at=process_started_at,
|
||||||
normalized_chat_type=normalized_chat_type,
|
normalized_chat_type=normalized_chat_type,
|
||||||
chat_context_resolution=chat_context_resolution,
|
chat_context_resolution=chat_context_resolution,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,15 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
process_started_at: float,
|
process_started_at: float,
|
||||||
normalized_chat_type: str,
|
normalized_chat_type: str,
|
||||||
chat_context_resolution: dict[str, Any],
|
chat_context_resolution: dict[str, Any],
|
||||||
|
thread_context: Optional[dict[str, Any]] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||||||
conversation_key = self._build_rag_conversation_key(
|
conversation_key = self._build_rag_conversation_key(
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
source_sender_open_id=source_sender_open_id,
|
source_sender_open_id=source_sender_open_id,
|
||||||
sender=sender,
|
sender=sender,
|
||||||
|
source_message_id=feishu_message_id,
|
||||||
|
thread_context=thread_context,
|
||||||
)
|
)
|
||||||
|
|
||||||
sidecar_rag_result: Optional[dict[str, Any]] = None
|
sidecar_rag_result: Optional[dict[str, Any]] = None
|
||||||
|
|
@ -54,7 +57,7 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
question=rag_question_part,
|
question=rag_question_part,
|
||||||
asked_by=None,
|
asked_by=None,
|
||||||
asked_via="feishu",
|
asked_via="feishu",
|
||||||
project_id=inferred_project_id,
|
project_id=None,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
message_intent="mixed",
|
message_intent="mixed",
|
||||||
|
|
@ -69,14 +72,14 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
question=rag_question_part,
|
question=rag_question_part,
|
||||||
asked_by=None,
|
asked_by=None,
|
||||||
asked_via="feishu",
|
asked_via="feishu",
|
||||||
project_id=inferred_project_id,
|
project_id=None,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
)
|
)
|
||||||
return await self._finalize_direct_qa_capture_result(
|
return await self._finalize_direct_qa_capture_result(
|
||||||
message=message,
|
message=message,
|
||||||
rag_result=rag_result,
|
rag_result=rag_result,
|
||||||
inferred_project_id=inferred_project_id,
|
inferred_project_id=None,
|
||||||
feishu_message_id=feishu_message_id,
|
feishu_message_id=feishu_message_id,
|
||||||
process_started_at=process_started_at,
|
process_started_at=process_started_at,
|
||||||
route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。",
|
route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。",
|
||||||
|
|
@ -103,7 +106,7 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
question=rag_question_part,
|
question=rag_question_part,
|
||||||
asked_by=None,
|
asked_by=None,
|
||||||
asked_via="feishu",
|
asked_via="feishu",
|
||||||
project_id=inferred_project_id,
|
project_id=None,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
message_intent="mixed",
|
message_intent="mixed",
|
||||||
|
|
@ -118,14 +121,14 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
question=rag_question_part,
|
question=rag_question_part,
|
||||||
asked_by=None,
|
asked_by=None,
|
||||||
asked_via="feishu",
|
asked_via="feishu",
|
||||||
project_id=inferred_project_id,
|
project_id=None,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
)
|
)
|
||||||
return await self._finalize_direct_qa_capture_result(
|
return await self._finalize_direct_qa_capture_result(
|
||||||
message=message,
|
message=message,
|
||||||
rag_result=rag_result,
|
rag_result=rag_result,
|
||||||
inferred_project_id=inferred_project_id,
|
inferred_project_id=None,
|
||||||
feishu_message_id=feishu_message_id,
|
feishu_message_id=feishu_message_id,
|
||||||
process_started_at=process_started_at,
|
process_started_at=process_started_at,
|
||||||
route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。",
|
route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。",
|
||||||
|
|
@ -145,7 +148,7 @@ class FeishuPipelineProcessMessageDefaultMixin:
|
||||||
question=rag_question_part,
|
question=rag_question_part,
|
||||||
asked_by=None,
|
asked_by=None,
|
||||||
asked_via="feishu",
|
asked_via="feishu",
|
||||||
project_id=inferred_project_id,
|
project_id=None,
|
||||||
source_chat_id=source_chat_id,
|
source_chat_id=source_chat_id,
|
||||||
conversation_key=conversation_key,
|
conversation_key=conversation_key,
|
||||||
message_intent="mixed",
|
message_intent="mixed",
|
||||||
|
|
|
||||||
|
|
@ -17,18 +17,28 @@ class FeishuPipelineProcessMessageHelpersBMixin:
|
||||||
payload = dict(result or {})
|
payload = dict(result or {})
|
||||||
direct_reply_text = str(payload.get("direct_reply_text") or "").strip()
|
direct_reply_text = str(payload.get("direct_reply_text") or "").strip()
|
||||||
if direct_reply_text:
|
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()
|
clarification_message = str(payload.get("clarification_message") or "").strip()
|
||||||
if clarification_message:
|
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()
|
followup_reply = str(payload.get("followup_reply") or "").strip()
|
||||||
if followup_reply:
|
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["chat_type"] = chat_type
|
||||||
payload["normalized_text"] = normalized_text
|
payload["normalized_text"] = normalized_text
|
||||||
payload["route"] = route
|
payload["route"] = route
|
||||||
payload["db_mutations"] = db_mutations
|
payload["db_mutations"] = db_mutations
|
||||||
payload.setdefault("results", [])
|
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
|
return payload
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -186,4 +196,3 @@ class FeishuPipelineProcessMessageHelpersBMixin:
|
||||||
sources=rag_result.get("sources") or [],
|
sources=rag_result.get("sources") or [],
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,21 @@ from . import shared as _pipeline
|
||||||
|
|
||||||
|
|
||||||
class FeishuPipelineQueueConfirmUndoMixin:
|
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]:
|
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)
|
item = await self._get_item(item_id)
|
||||||
if not item:
|
if not item:
|
||||||
raise ValueError("item_not_found")
|
raise ValueError("item_not_found")
|
||||||
|
|
||||||
level = (item.level or "").lower()
|
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}
|
result: dict[str, Any] = {"id": str(item.id), "level": level}
|
||||||
if level == "project":
|
if level == "project":
|
||||||
suggestion = await self._get_project_suggestion(item.id)
|
suggestion = await self._get_project_suggestion(item.id)
|
||||||
|
|
@ -34,6 +43,7 @@ class FeishuPipelineQueueConfirmUndoMixin:
|
||||||
"description": candidate_fields["description"],
|
"description": candidate_fields["description"],
|
||||||
},
|
},
|
||||||
trace_id=f"confirm-project-{int(item.id)}-{int(user_id)}",
|
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 {}
|
first = execution["results"][0] if execution.get("results") else {}
|
||||||
status = str(first.get("status") or "").strip().lower()
|
status = str(first.get("status") or "").strip().lower()
|
||||||
|
|
@ -61,13 +71,22 @@ class FeishuPipelineQueueConfirmUndoMixin:
|
||||||
payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||||||
operation_code = str(payload.get("operation_code") or "").strip()
|
operation_code = str(payload.get("operation_code") or "").strip()
|
||||||
operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {}
|
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":
|
||||||
raise ValueError("command_operation_not_supported")
|
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(
|
execution = await self._execute_controlled_operation(
|
||||||
operator_user=operator_user,
|
operator_user=operator_user,
|
||||||
operation_code=operation_code,
|
operation_code=operation_code,
|
||||||
payload=operation_payload,
|
payload=operation_payload,
|
||||||
trace_id=f"confirm-command-{int(item.id)}-{int(user_id)}",
|
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 {}
|
first = execution["results"][0] if execution.get("results") else {}
|
||||||
status = str(first.get("status") or "").strip().lower()
|
status = str(first.get("status") or "").strip().lower()
|
||||||
|
|
@ -86,6 +105,8 @@ class FeishuPipelineQueueConfirmUndoMixin:
|
||||||
}
|
}
|
||||||
result["project_id"] = str(project_id) if project_id is not None else None
|
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
|
result["task_id"] = str(task_id) if task_id is not None else None
|
||||||
|
else:
|
||||||
|
raise ValueError("command_operation_not_supported")
|
||||||
else:
|
else:
|
||||||
signal = await self._get_signal_by_item(item.id)
|
signal = await self._get_signal_by_item(item.id)
|
||||||
if not signal:
|
if not signal:
|
||||||
|
|
@ -138,6 +159,121 @@ class FeishuPipelineQueueConfirmUndoMixin:
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
return result
|
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]:
|
async def undo_auto_processed(self, *, item_id: int) -> dict[str, Any]:
|
||||||
item = await self._get_item(item_id)
|
item = await self._get_item(item_id)
|
||||||
if not item:
|
if not item:
|
||||||
|
|
@ -191,4 +327,3 @@ class FeishuPipelineQueueConfirmUndoMixin:
|
||||||
}
|
}
|
||||||
await self.db.commit()
|
await self.db.commit()
|
||||||
return {"id": str(item.id), "status": item.route_status}
|
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
Loading…
Reference in New Issue