diff --git a/CLAUDE.md b/CLAUDE.md index e225ed50..2ca5b438 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ backend/ │ ├── __init__.py # app 包入口(根目录轻量化后保留) │ ├── api/ # 路由层(含 endpoints) │ ├── core/ # 配置、日志、核心能力 -│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/probe/warmup) +│ │ └── startup/ # 启动阶段拆分模块(db guard/outbox/ingress async/draft expiry/probe/warmup) │ ├── deps.py # 共享依赖导出(get_db/AsyncSessionLocal) │ ├── dependencies/ # 鉴权/租户等依赖注入 │ ├── middleware/ # 请求中间件(审计上下文注入等) @@ -55,14 +55,27 @@ backend/ │ ├── 20260311_add_ai_pmo_work_items_view.py # ai_pmo_work_items 兼容视图(映射 work_items) │ ├── 20260311_expand_user_workload_ratio_precision.py # user_workloads.workload_ratio 精度扩容(10,2) │ ├── 20260311_add_conversation_sessions_table.py # 飞书多轮会话上下文持久化表 -│ └── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展 +│ ├── 20260316_add_ai_orchestration_ingress_async_fields.py # ai_orchestration_runs 入站异步重试字段扩展 +│ └── 20260323_add_work_item_draft_confirmation_fields.py # work_items 草稿确认字段与索引 ├── scripts/ # 后端运维/数据修复脚本 │ ├── backfill_orphan_signal_links.py # 回填孤立 signal 的 project_signal_links │ ├── dev/ # 开发辅助脚本(check_users/seed_all) │ └── legacy/ # 历史脚本(仅保留兼容参考,不作为主流程) +├── tests/ # 后端回归测试 +│ ├── test_draft_confirm_handler.py +│ ├── test_ai_service_core_runtime.py +│ ├── test_rag_route_contract.py +│ ├── test_ai_pmo_task_intent_contract.py +│ ├── test_ai_pmo_ingress_mainline.py +│ ├── test_ai_pmo_qa_http_mainline_e2e.py +│ ├── test_context_cache_profile_catalog.py +│ ├── test_ai_pmo_tactical_brain_contract.py +│ ├── test_adaptive_rag_mainline_contract.py +│ ├── test_ai_pmo_execution_ledger_contract.py +│ ├── test_feishu_card_action_contract.py +│ └── test_feishu_receive_orchestration_contract.py ├── requirements.txt # Python 依赖 -├── Dockerfile -└── (tests 已移除) +└── Dockerfile ``` - 新增:`backend/app/api/llm_providers.py`(AI 管家 LLM provider preset 配置读取接口) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py`(双 Agent 共享决策内核:复杂度路由/执行门控/修正识别) @@ -77,6 +90,27 @@ backend/ - 新增:`backend/alembic/versions/20260312_add_signal_planner_fields.py`(signals 增加 planner 状态字段与索引) - 新增:`backend/app/services/signal/signal_planner/signal_strategic_analysis_service.py`(Signal Planner 写回收口) - 新增:`backend/app/services/integrations/feishu_outbox/service.py`(复用 reply_hash 的 outbox 入队收口) +- 新增:`backend/app/services/signal/draft_generator_service.py`(写操作草稿生成服务:草稿结构化产出 + 24h 过期) +- 新增:`backend/app/services/ai_pmo/draft_confirm_handler.py`(草稿确认处理器:确认/修改/取消 + 幂等保护) +- 新增:`backend/app/core/startup/draft_expiry_worker.py`(草稿超时取消后台扫描任务) +- 新增:`backend/tests/test_ai_service_core_runtime.py`(AIService 运行时回归:统一收敛 llm_service、缓存命中与流式兼容输出) +- 新增:`backend/tests/test_rag_route_contract.py`(RAG 双入口契约回归:`asked_via/action_mode/message_intent/mixed_signal_part` 统一透传) +- 新增:`backend/tests/test_ai_pmo_task_intent_contract.py`(AI PMO `task.create` 一等 typed intent 契约回归:意图识别、路由归一与执行器分派) +- 新增:`backend/tests/test_ai_pmo_ingress_mainline.py`(AI PMO 主链入口回归:`/ai-pmo/process-text` 手工问答入口、mixed sidecar 透传、`draft_pending_confirm` 优先分支) +- 新增:`backend/tests/test_ai_pmo_qa_http_mainline_e2e.py`(AI PMO/QA HTTP 主链集成回归:`/rag/ask` 只读问答、mixed 两段输出、`/ai-pmo/process-text` 草稿待确认) +- 新增:`backend/tests/test_context_cache_profile_catalog.py`(Context Cache 功能目录契约回归:AI Steward 统一指向 `/rag/ask`) +- 新增:`backend/tests/test_ai_pmo_tactical_brain_contract.py`(TacticalBrain 执行动作契约回归:`query.answer`/`work_item.resume`/`project_signal.*` 映射) +- 新增:`backend/app/services/adaptive_rag_service/mixin_answer_mainline.py`(AdaptiveRAG 基础问答主链编排:运行时准备 + 只读主路径执行) +- 新增:`backend/tests/test_adaptive_rag_mainline_contract.py`(AdaptiveRAG 入口分层契约回归:read 路径绕过写确认 gate、write 路径命中确认 gate、feishu write 仍走主链) +- 新增:`backend/tests/test_ai_pmo_execution_ledger_contract.py`(执行账本/动作参数来源契约回归:`action_contract` 归一、账本幂等 key、状态映射) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(新增统一 `action_contract` 组装、`operation_execution_ledger` upsert、账本状态归一) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(自动执行与草稿待确认路径接入统一执行账本,日志 `operation_code` 对齐 `action_contract`) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py`(`waiting_manual` 路径接入统一执行账本与动作参数来源约束) +- 新增:`backend/app/services/integrations/feishu_integration_service/mixin_card_action.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/tests/test_feishu_card_action_contract.py`(飞书卡片回调契约回归:动作解析与事件分发) +- 新增:`backend/tests/test_feishu_receive_orchestration_contract.py`(飞书入口编排契约回归:bot 过滤、空文本分支、capture/finalize 编排) - 新增:`backend/app/services/signal_planner_service.py`(Signal Planner 异步分析服务) - 新增:`backend/app/services/model/model_routing/service.py`(函数级模型路由配置服务) - 新增:`backend/app/services/task_brief_service.py`(增量任务包中心服务) @@ -107,6 +141,9 @@ backend/ - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/`(Kernel 入站主流程编排) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py`(Kernel 入站事件/工作项写入) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py`(Kernel 入站结果组装与输出辅助) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py`(写操作改为“先起草后确认”,状态进入 `draft_pending_confirm`) +- 更新:`backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py`(会话命中 `draft_pending_confirm` 时优先路由草稿确认处理) +- 更新:`backend/app/models/work_item.py`(新增 `draft_content/draft_expires_at/draft_version/confirm_message_id`) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/compositions/world_model.py`(Kernel world-model 组合入口) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/`(Kernel world-model 核心构建与缓存) - 新增:`backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py`(Kernel 会话继承与会话态维护) @@ -263,6 +300,9 @@ frontend/ - 新增:`frontend/src/pages/inbox/helpers.ts`(Inbox 页面 tab 过滤、排序、project-scope 判断 helper) - 新增:`frontend/src/pages/inbox/components/InboxFiltersPanel.tsx`(Inbox 页面顶部筛选/搜索/tab 面板拆分) - 新增:`frontend/src/pages/inbox/InboxPage.tsx`(Inbox 页面编排层,主文件聚焦状态与动作流转) +- 更新:`frontend/src/api/aiPmo.ts`(新增草稿决策 API:`decideDraftWorkItem`,并扩展工作项 draft 字段) +- 更新:`frontend/src/lib/inbox.ts`(支持 `draft_pending_confirm` 卡片映射与三动作按钮) +- 更新:`frontend/src/types/inbox.ts`(新增草稿动作类型 `revise_draft/cancel_draft`) - 更新:`frontend/src/pages/RecipesPage.tsx`(降级为轻量入口,转发到 `pages/recipes/RecipesPage`) - 新增:`frontend/src/pages/recipes/helpers.ts`(Recipes 页面默认表单与分类常量) - 新增:`frontend/src/pages/recipes/components/CreateRecipeDialog.tsx`、`AnalyzeRecipeDialog.tsx`(Recipes 页面手动创建/AI 识别弹窗拆分) @@ -392,6 +432,9 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI Steward 零配置创建任一步 LLM 失败、超时或 JSON 非法时必须直接报错,禁止回退默认 analysis/signal/charter/tasks 草稿 - Signal Source Onboarding 的 planner/worker 任一步失败必须显式报错或标记 failed,禁止 heuristic/fallback 兜底计划与“最小闭环”摘要 - AI Steward 不再暴露 OpenClaw 指挥规划入口,`/ai-steward/command/dispatch` 视为下线路径 +- AI PMO 写操作(`project.create/signal.create/signal_source.onboard`)默认先生成草稿并进入 `draft_pending_confirm`,未确认前禁止自动执行入库 +- 草稿确认必须校验 `draft_version`,并通过 `confirm_message_id` 防重放;草稿超时取消统一由 `draft_expiry_worker` + `feishu_reply_outbox` 处理 +- AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账 ## 9. 新增代码的参考模式 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 @@ -410,7 +453,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI PMO 审批接口(`POST /ai-pmo/work-items/{work_item_id}/decision`):显式 `approve/reject`,`reject` 必须落 `ignored`(失效)并同步 `decision_records.final_outcome` - AI PMO 审批接口补充:`approve + waiting_manual + project_id` 走“快速返回 + 后台继续执行”,避免前端长时间阻塞 - AI PMO 恢复接口(`POST /ai-pmo/work-items/{work_item_id}/resume`):支持 `action=continue|commit|refresh|cancel`,复用同一闭环恢复入口 +- AI PMO 草稿决策接口(`POST /ai-pmo/work-items/{work_item_id}/draft-decision`):支持 `action=confirm|revise|cancel`,与飞书“确认/修改/取消”行为一致 +- RAG 问答主入口契约(`POST /rag/ask`):HTTP 入口已收敛到 `AIPMOKernelService.process_ingress` 主链;请求侧统一透传 `asked_via/action_mode/message_intent/mixed_signal_part`,由 `ingress_options.rag_request_context` 下传到 query executor 并映射到 `AdaptiveRAGService.answer_question`,返回侧从 ingress `rag_result`/`direct_reply_text` 组装兼容响应 - AI PMO 消息台列表接口(`GET /ai-pmo/work-items`):返回 `latest_policy_gate_code` 与规范化 `approval_requirement{required,reason,message}`(来源于最新 `decision_records` 投影) +- AI PMO 消息台列表接口补充:返回草稿字段 `draft_content/draft_expires_at/draft_version/confirm_message_id`,用于草稿确认卡片渲染 - AI PMO 消息台列表接口补充:返回源消息上下文字段 `source_message_text/source_captured_at/source_sender/source_channel_ref`,用于详情页展示“原始输入”与事件时间 - AI PMO 消息台列表接口补充:返回闭环语义字段 `source_conversation_closure/closure_summary/implementation_modules/source_closure_turns`,用于详情页展示“消息闭环 + 实现模块” - AI PMO 消息台历史回填补充:当 `ingest_events.payload` 闭环字段缺失或低质量时,优先回放 `ai_processing_logs`(同 `feishu_message_id`)并补充 `qa_records` 最近问答,避免 Redis 会话过期后仅剩单句摘要 @@ -457,9 +503,25 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - 兼容 SQL 查询入口:`ai_pmo_work_items` 为视图别名(底层映射 `work_items`),避免历史脚本直接查旧表名失败 - `signal_source.onboard` 已加入工作流门禁旁路:进入执行阶段后按闭环结果返回 `succeeded/pending_manual/failed_safe`,不再被前置仿真门禁直接拦截为 `waiting_manual` - 飞书入站已拆分“短确认同步快路径 + 非短确认异步队列”:短确认命中会话窗口时直接继承上轮意图并同步执行;其余消息先回“已收到,正在处理”后由 `ai_orchestration_runs` 异步 worker 执行与重试 +- 会话命中 `session.last_intent=draft_pending_confirm` 时,入站优先进入草稿确认处理器,不再走常规意图识别 +- 草稿确认成功后执行路径复用 `followup` 恢复链路,目标 work_item 在同事务内完成状态迁移与 `decision_records.final_outcome` 更新 +- 飞书回复 outbox 已支持 `text + interactive card` 双通道:执行场景命中 `draft_pending_confirm/pending_manual` 时优先入队按钮卡片;卡片消息仍经 outbox 异步派发(不走业务路径同步直发) +- 飞书入口已按职责拆分为“预处理/捕获执行/结果回执”三层:`mixin_receive.py` 仅保留编排,`mixin_receive_prepare.py` 负责传输解析与上下文抽取,`mixin_receive_capture_flow.py` 负责 capture 持久化与回执收尾 - `ai_orchestration_runs.project_id` 允许为空:用于承载尚未完成项目归属判定的入站异步任务;既有 `signal_source_onboarding` 运行仍按显式 `project_id` 写入 - `/ai-pmo/process-text` 问答链路在 AdaptiveRAG 内通过 `asked_via=pmflow_manual` 识别为 AI PMO 入口;mixed 场景通过 `message_intent=mixed` 透传触发专用 Prompt 与两段输出格式化 +- `/rag/ask` 已改为经 AI PMO kernel ingress 主链执行(与飞书长连接、`/ai-pmo/process-text` 同链路);query 执行阶段通过 `rag_request_context` 保留 `session_id/new_session/thinking_mode/action_mode/sync_requirement/message_intent/mixed_signal_part` +- AdaptiveRAG `answer_question` 入口已拆为“双阶段”:`_prepare_answer_runtime`(会话/上下文/动作模式解析)+ `_execute_base_answer_mainline`(基础问答主路径);写确认 `ack` gate 仅在 `sync_requirement=true && action_mode in write/read_write && 非 feishu` 时触发 +- AI PMO ingress 执行路径(`waiting_manual/draft_pending_confirm/auto execute`)已统一写入 `operation_execution_ledger`,`operation_code` 与 `execution_result.action_contract` 一致;账本状态统一映射为 `applied/pending_manual/pending_confirmation/failed` - `PROMPT_DECOMPOSE_TASKS` 技能标签来源已改为运行时动态注入:优先读取 `skill_tags`(按 `sort_order/name`),为空时才回退默认技能集合 ## 12. 技术债务清单 -- [ ] `mixin_auto.py` `_safe_int` NameError:之前被 `simulation_capacity_limit` 门禁遮住未暴露,需单独任务修复 +- [x] `mixin_auto.py` `_safe_int` NameError:已通过显式导入 `_safe_int` + 回归测试锁定,避免后续共享导出调整导致运行时未定义 +- [x] AI 入口主链已统一:飞书长连接(`capture_from_feishu`)、`/ai-pmo/process-text` 与 `/rag/ask` 已统一收敛到 `AIPMOKernelService.process_ingress` 主链;`/rag/ask` 通过 `rag_ask_contract.build_kernel_ingress_kwargs` 透传 RAG 上下文(`session_id/new_session/thinking_mode/action_mode/...`),query 执行后由 `build_rag_response_payload` 保持 HTTP 响应兼容,回归由 `test_rag_route_contract.py`、`test_ai_pmo_qa_http_mainline_e2e.py`、`test_ai_pmo_ingress_mainline.py` 锁定 +- [x] 飞书入口层耦合已收敛:飞书真实入口已拆为三段职责链(预处理 `mixin_receive_prepare.py` / capture 执行 `mixin_receive_capture_flow.py` / 编排 `mixin_receive.py`);`mixin_receive.py` 不再混合传输解析、附件标准化、capture 异常兜底与收尾回执逻辑,分层边界通过 `test_feishu_receive_orchestration_contract.py` 锁定 +- [x] `task.create` 已升级为一等 typed intent:`IntentResolver/RouteResolver` 新增 `task.create` 常量与 `task.create` 路由族;TypedExecutor 已接入任务落库执行(含 `project_id`/任务标题槽位守卫、对齐 Charter 校验、`persist_task` 写入);`WorkflowRegistry`、`ReplyRenderer`、`followup` 校验与草稿确认闭环已纳入 `task.create`;并补 `test_ai_pmo_task_intent_contract.py` 回归锁定 +- [x] AdaptiveRAG 职责已完成首轮收敛:`answer_question` 入口已分层为 `prepare runtime` 与 `base mainline` 两段,写确认逻辑通过 `_should_trigger_write_ack_gate` 独立门控;基础问答主路径不再与写确认判断混写在同一函数体。分层契约由 `test_adaptive_rag_mainline_contract.py` 锁定(read 绕过 ack、write 命中 ack、feishu write 维持主链) +- [x] 执行层已形成统一执行账本与参数来源约束:ingress 执行路径统一通过 `KernelIngressStorageMixin` 生成 `execution_result.action_contract`(固定 `param_source=decision.execution_plan.actions+workflow_route+execution_result`)并写入 `operation_execution_ledger`(`applied/pending_manual/pending_confirmation/failed`);`mixin_auto.py`、`mixin_prepare_manual.py` 的 `operation_code` 日志来源已对齐 `action_contract`,回归由 `test_ai_pmo_execution_ledger_contract.py` 锁定 +- [x] QA/RAG 路由契约双轨已收敛:`/ai-steward/rag/ask` 兼容入口已移除,问答统一收敛到 `POST /rag/ask`;参数构造与 tenant 解析收口在 `backend/app/api/rag_ask_contract.py`,前端 `FloatingQAChat` 与 Context Cache API 目录均已对齐,`backend/tests/test_rag_route_contract.py` 与 `backend/tests/test_context_cache_profile_catalog.py` 已锁定回归 +- [x] LLM 运行时仍未完全收敛:`ai_service_core._invoke_with_retry` 与 `stream_chat` 已统一走 `llm_service`;旧 `_get_llm` 路径已显式禁用,并补回归测试锁定 +- [x] QA 主链自动化测试覆盖已补齐:`backend/tests/` 已覆盖问答/RAG 主路径与关键场景;新增 `test_ai_pmo_qa_http_mainline_e2e.py` 通过 HTTP 层锁定 `/rag/ask` 的 `qa_readonly/mixed` 契约以及 `/ai-pmo/process-text` 的 `draft_pending_confirm` 主链返回,配合 `test_ai_pmo_ingress_mainline.py`、`test_ai_pmo_task_intent_contract.py`、`test_ai_pmo_tactical_brain_contract.py` 形成稳定回归基线 +- [x] 飞书交互闭环已补齐:执行场景回执在 `draft_pending_confirm/pending_manual` 下优先生成按钮卡片并经 outbox 异步发送;`card.action.trigger` 已覆盖 `confirm/revise/cancel/continue` 回调分发,分别复用 `DraftConfirmHandler.handle_api_decision` 与 AI PMO `process_ingress` 主链继续执行;`backend/tests/test_feishu_card_action_contract.py` 已补卡片构造与 `continue` 回归契约 diff --git a/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py b/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py new file mode 100644 index 00000000..36ecb9b6 --- /dev/null +++ b/backend/alembic/versions/20260323_add_work_item_draft_confirmation_fields.py @@ -0,0 +1,92 @@ +"""add work item draft confirmation fields + +Revision ID: add_work_item_draft_confirmation_fields +Revises: add_ai_orchestration_ingress_async_fields +Create Date: 2026-03-23 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "add_work_item_draft_confirmation_fields" +down_revision = "add_ai_orchestration_ingress_async_fields" +branch_labels = None +depends_on = None + + +def _table_exists(bind, table_name: str) -> bool: + return sa.inspect(bind).has_table(table_name) + + +def _column_exists(bind, table_name: str, column_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == column_name for item in inspector.get_columns(table_name)) + + +def _index_exists(bind, table_name: str, index_name: str) -> bool: + inspector = sa.inspect(bind) + if not inspector.has_table(table_name): + return False + return any(str(item.get("name") or "") == index_name for item in inspector.get_indexes(table_name)) + + +def upgrade() -> None: + bind = op.get_bind() + table_name = "work_items" + if not _table_exists(bind, table_name): + return + + with op.batch_alter_table(table_name) as batch_op: + if not _column_exists(bind, table_name, "draft_content"): + batch_op.add_column(sa.Column("draft_content", sa.JSON(), nullable=True)) + if not _column_exists(bind, table_name, "draft_expires_at"): + batch_op.add_column(sa.Column("draft_expires_at", sa.TIMESTAMP(), nullable=True)) + if not _column_exists(bind, table_name, "draft_version"): + batch_op.add_column(sa.Column("draft_version", sa.Integer(), nullable=False, server_default="0")) + if not _column_exists(bind, table_name, "confirm_message_id"): + batch_op.add_column(sa.Column("confirm_message_id", sa.String(length=128), nullable=True)) + + if not _index_exists(bind, table_name, "ix_work_items_draft_expires"): + op.create_index( + "ix_work_items_draft_expires", + table_name, + ["draft_expires_at"], + postgresql_where=sa.text("state = 'draft_pending_confirm'"), + ) + + if not _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"): + op.create_index( + "uq_work_items_tenant_confirm_message", + table_name, + ["tenant_id", "confirm_message_id"], + unique=True, + postgresql_where=sa.text("confirm_message_id IS NOT NULL"), + ) + + +def downgrade() -> None: + bind = op.get_bind() + table_name = "work_items" + if not _table_exists(bind, table_name): + return + + if _index_exists(bind, table_name, "uq_work_items_tenant_confirm_message"): + op.drop_index("uq_work_items_tenant_confirm_message", table_name=table_name) + + if _index_exists(bind, table_name, "ix_work_items_draft_expires"): + op.drop_index("ix_work_items_draft_expires", table_name=table_name) + + with op.batch_alter_table(table_name) as batch_op: + if _column_exists(bind, table_name, "confirm_message_id"): + batch_op.drop_column("confirm_message_id") + if _column_exists(bind, table_name, "draft_version"): + batch_op.drop_column("draft_version") + if _column_exists(bind, table_name, "draft_expires_at"): + batch_op.drop_column("draft_expires_at") + if _column_exists(bind, table_name, "draft_content"): + batch_op.drop_column("draft_content") diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_filters.py b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py index 651e3c75..97135f74 100644 --- a/backend/app/api/ai_pmo_api/helpers_inbox_filters.py +++ b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py @@ -7,6 +7,10 @@ def _normalize_noise_text(text: str) -> str: def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> bool: intent_name = str(row.intent or "").strip().lower() + if intent_name == "followup.confirm": + execution_result = row.execution_result if isinstance(row.execution_result, dict) else {} + if _safe_int(execution_result.get("target_work_item_id")) is not None: + return True if intent_name not in FRONT_NOISE_INTENTS: return False payload = ingest.payload if isinstance(ingest.payload, dict) else {} @@ -107,10 +111,12 @@ def _stage_text(state: str) -> str: "decided": "已决策", "planned": "已规划", "executing": "执行中", + "draft_pending_confirm": "草稿待确认", "waiting_manual": "待人工确认", "succeeded": "执行完成", "failed": "执行失败", "ignored": "已忽略", + "cancelled": "已取消", } return mapping.get(str(state or "").strip(), str(state or "").strip() or "-") @@ -180,4 +186,3 @@ async def _build_latest_decision_projection( "approval_requirement": _normalize_approval_requirement(row.approval_requirement), } return projection - diff --git a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py index 8d2589e2..43071d78 100644 --- a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py +++ b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py @@ -78,6 +78,9 @@ def _serialize_work_item_summary( source_captured_at = str(payload.get("captured_at") or "").strip() or None source_sender = str(payload.get("sender") or "").strip() or None source_channel_ref = str(payload.get("channel_ref") or "").strip() or None + draft_content = row.draft_content if isinstance(row.draft_content, dict) else {} + draft_title = _compact_text(str(draft_content.get("title") or "").strip(), 64) + draft_summary = _compact_text(str(draft_content.get("summary") or "").strip(), 180) summary_seed = closure_summary or message_text or _next_step_text(row) or row.intent or "AI PMO 工作项" summary = _compact_text(summary_seed, 180) compact_message = re.sub(r"\s+", "", message_text).lower() if message_text else "" @@ -88,6 +91,11 @@ def _serialize_work_item_summary( else (message_text or closure_summary or f"AI PMO · {row.intent or 'work_item'}") ) title = _compact_text(title_seed, 64) + if str(row.state or "").strip() == "draft_pending_confirm": + if draft_title: + title = draft_title + if draft_summary: + summary = draft_summary priority, priority_rank = _priority_from_review(row.review_summary) project_name = None if project is not None: @@ -134,7 +142,10 @@ def _serialize_work_item_summary( "target_url": f"/projects/{project_id}/board" if project_id is not None else None, "review_summary": row.review_summary, "execution_result": row.execution_result, + "draft_content": draft_content or None, + "draft_expires_at": row.draft_expires_at.isoformat() if row.draft_expires_at else None, + "draft_version": int(row.draft_version or 0), + "confirm_message_id": str(row.confirm_message_id or "").strip() or None, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } - diff --git a/backend/app/api/ai_pmo_api/routes_core.py b/backend/app/api/ai_pmo_api/routes_core.py index ba364d3d..24a80e5a 100644 --- a/backend/app/api/ai_pmo_api/routes_core.py +++ b/backend/app/api/ai_pmo_api/routes_core.py @@ -138,6 +138,10 @@ async def get_work_item( "execution_result": row.execution_result, "review_summary": row.review_summary, "reply_template_key": row.reply_template_key, + "draft_content": row.draft_content if isinstance(row.draft_content, dict) else None, + "draft_expires_at": row.draft_expires_at.isoformat() if row.draft_expires_at else None, + "draft_version": int(row.draft_version or 0), + "confirm_message_id": str(row.confirm_message_id or "").strip() or None, "decision_records": [ { "id": int(item.id), @@ -178,4 +182,3 @@ async def get_work_item( ], } return success_response(payload) - diff --git a/backend/app/api/ai_pmo_api/routes_resume.py b/backend/app/api/ai_pmo_api/routes_resume.py index 1e45db6e..c17178dc 100644 --- a/backend/app/api/ai_pmo_api/routes_resume.py +++ b/backend/app/api/ai_pmo_api/routes_resume.py @@ -2,7 +2,8 @@ from __future__ import annotations from .shared import * from .helpers_resume import _queue_resume_work_item, _reject_work_item -from .schemas import PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest +from .schemas import PMODraftDecisionRequest, PMOResumeWorkItemRequest, PMOWorkItemDecisionRequest +from ...services.ai_pmo.draft_confirm_handler import DraftConfirmHandler @router.post("/work-items/{work_item_id}/resume") async def resume_work_item( @@ -149,3 +150,38 @@ async def decide_work_item( result = _normalize_resume_result(raw=raw_result, target_work_item=target) return success_response(result) + +@router.post("/work-items/{work_item_id}/draft-decision") +async def decide_draft_work_item( + work_item_id: int, + request: PMODraftDecisionRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), + tenant_id: int = Depends(get_tenant_id), +): + target = ( + await db.execute( + select(WorkItem).where( + and_( + WorkItem.tenant_id == tenant_id, + WorkItem.id == int(work_item_id), + ) + ).limit(1) + ) + ).scalar_one_or_none() + if target is None: + raise HTTPException(status_code=404, detail="work_item_not_found") + + handler = DraftConfirmHandler(AIPMOKernelService(db, tenant_id=tenant_id)) + result = await handler.handle_api_decision( + work_item_id=int(work_item_id), + action=str(request.action or "").strip().lower(), + operator_sender=str(current_user.name or current_user.username or "PMFlow用户"), + source_message_id=request.source_message_id, + revision_note=request.revision_note, + draft_version=request.draft_version, + ) + await db.commit() + if not bool(result.get("handled")): + raise HTTPException(status_code=400, detail=str(result.get("followup_reply") or "draft_decision_failed")) + return success_response(result) diff --git a/backend/app/api/ai_pmo_api/schemas.py b/backend/app/api/ai_pmo_api/schemas.py index f271a8d0..708d3068 100644 --- a/backend/app/api/ai_pmo_api/schemas.py +++ b/backend/app/api/ai_pmo_api/schemas.py @@ -23,3 +23,9 @@ class PMOWorkItemDecisionRequest(BaseModel): source_message_id: Optional[str] = Field(default=None, max_length=128) reason: Optional[str] = Field(default=None, max_length=500) + +class PMODraftDecisionRequest(BaseModel): + action: Literal["confirm", "revise", "cancel"] = Field(..., description="草稿动作:confirm/revise/cancel") + revision_note: Optional[str] = Field(default=None, max_length=500) + source_message_id: Optional[str] = Field(default=None, max_length=128) + draft_version: Optional[int] = Field(default=None, ge=1) diff --git a/backend/app/api/ai_steward_api/routes_rag_sources.py b/backend/app/api/ai_steward_api/routes_rag_sources.py index f22a194e..f897b3a2 100644 --- a/backend/app/api/ai_steward_api/routes_rag_sources.py +++ b/backend/app/api/ai_steward_api/routes_rag_sources.py @@ -4,29 +4,6 @@ from .shared import * from .helpers import _ensure_project_signal_link from .schemas import SignalSourceListResponse, SignalSourceOnboardRequest, SignalSourceRepairRequest -@router.post("/rag/ask") -async def ask_ai_steward_rag( - request: RAGAskRequest, - current_user: User = Depends(get_current_user), - db: AsyncSession = Depends(get_db), - tenant_id: int = Depends(get_tenant_id), -): - effective_tenant_id = int(getattr(current_user, "tenant_id", tenant_id) or tenant_id) - service = AdaptiveRAGService(db, tenant_id=effective_tenant_id) - result = await service.answer_question( - question=request.question, - asked_by=int(current_user.id), - asked_via="pmflow", - project_id=request.project_id, - source_chat_id=request.source_chat_id, - session_id=request.session_id, - force_new_session=bool(request.new_session), - thinking_mode=request.thinking_mode, - action_mode=request.action_mode, - sync_requirement=bool(request.sync_requirement), - ) - return success_response(result) - @router.post("/signal-sources/onboard") @audited_write(caller="user") async def onboard_signal_source( @@ -148,4 +125,3 @@ async def repair_dirty_signal_sources( "items": rows, } ) - diff --git a/backend/app/api/ai_steward_api/shared.py b/backend/app/api/ai_steward_api/shared.py index 65df3d53..aeb4d426 100644 --- a/backend/app/api/ai_steward_api/shared.py +++ b/backend/app/api/ai_steward_api/shared.py @@ -15,8 +15,6 @@ from ...dependencies.auth import get_current_user from ...dependencies.permissions import require_manager_user from ...dependencies.tenant import get_tenant_id from ...models import ProjectSignalLink, Signal, User -from ...schemas.rag import RAGAskRequest -from ...services.adaptive_rag_service import AdaptiveRAGService from ...services.ai_orchestration_run import AIOrchestrationRunService from ...services.ai_steward_service import AIStewardService, get_ai_steward_service from ...services.blocker_detection_core import get_blocker_detection_service diff --git a/backend/app/api/rag_api/routes_query.py b/backend/app/api/rag_api/routes_query.py index ea3175bc..ce88fade 100644 --- a/backend/app/api/rag_api/routes_query.py +++ b/backend/app/api/rag_api/routes_query.py @@ -2,6 +2,8 @@ from __future__ import annotations from .shared import * from .helpers import _resolve_tenant_id +from .. import rag_ask_contract +from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService @router.post("/ask") async def rag_ask( @@ -10,20 +12,24 @@ async def rag_ask( db: AsyncSession = Depends(get_db), tenant_id: int = Depends(get_tenant_id), ): - service = AdaptiveRAGService(db, tenant_id=_resolve_tenant_id(current_user, tenant_id)) - result = await service.answer_question( - question=request.question, - asked_by=int(current_user.id), - asked_via=request.asked_via, - project_id=request.project_id, - source_chat_id=request.source_chat_id, - session_id=request.session_id, - force_new_session=bool(request.new_session), - thinking_mode=request.thinking_mode, - action_mode=request.action_mode, - sync_requirement=bool(request.sync_requirement), + service = AIPMOKernelService( + db, + tenant_id=rag_ask_contract.resolve_effective_tenant_id(current_user, tenant_id), + ) + ingress_result = await service.process_ingress( + **rag_ask_contract.build_kernel_ingress_kwargs( + request=request, + current_user=current_user, + ) + ) + if not bool(ingress_result.get("handled")): + raise HTTPException( + status_code=400, + detail=str(ingress_result.get("reason") or "ai_pmo_kernel_not_handled"), + ) + return success_response( + rag_ask_contract.build_rag_response_payload(ingress_result=ingress_result), ) - return success_response(result) @router.get("/manifest") async def rag_manifest( @@ -133,4 +139,3 @@ async def rag_symbol_detail( except ValueError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc return success_response(data) - diff --git a/backend/app/api/rag_ask_contract.py b/backend/app/api/rag_ask_contract.py new file mode 100644 index 00000000..41f9f2ae --- /dev/null +++ b/backend/app/api/rag_ask_contract.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from typing import Any + +from ..models import User +from ..schemas.rag import RAGAskRequest + + +def resolve_effective_tenant_id(current_user: User, fallback_tenant_id: int) -> int: + try: + return int(getattr(current_user, "tenant_id") or fallback_tenant_id or 1) + except Exception: + return int(fallback_tenant_id or 1) + + +def build_answer_question_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]: + asked_by = int(getattr(current_user, "id")) + return { + "question": request.question, + "asked_by": asked_by, + "asked_via": request.asked_via, + "project_id": request.project_id, + "source_chat_id": request.source_chat_id, + "session_id": request.session_id, + "force_new_session": bool(request.new_session), + "thinking_mode": request.thinking_mode, + "action_mode": request.action_mode, + "sync_requirement": bool(request.sync_requirement), + "message_intent": request.message_intent, + "mixed_signal_part": request.mixed_signal_part, + } + + +def _safe_int(value: Any) -> int | None: + try: + if value is None: + return None + return int(value) + except Exception: + return None + + +def _resolve_sender_name(current_user: User) -> str: + sender = str( + getattr(current_user, "name", None) + or getattr(current_user, "username", None) + or "PMFlow用户" + ).strip() + return sender or "PMFlow用户" + + +def build_kernel_ingress_kwargs(request: RAGAskRequest, current_user: User) -> dict[str, Any]: + rag_request_context = build_answer_question_kwargs(request=request, current_user=current_user) + return { + "source_system": str(request.asked_via or "pmflow").strip() or "pmflow", + "source_message_id": None, + "message_text": request.question, + "sender": _resolve_sender_name(current_user), + "channel_ref": request.source_chat_id, + "source_sender_open_id": None, + "captured_at": None, + "chat_type": "manual", + "project_hint_id": request.project_id, + "ingress_options": { + "rag_request_context": rag_request_context, + }, + } + + +def build_rag_response_payload(*, ingress_result: dict[str, Any]) -> dict[str, Any]: + result = ingress_result if isinstance(ingress_result, dict) else {} + rag_result = result.get("rag_result") if isinstance(result.get("rag_result"), dict) else {} + payload = dict(rag_result) + + answer = str(payload.get("answer") or result.get("direct_reply_text") or "").strip() + payload["answer"] = answer or "当前暂无可用回答。" + + if "reply_required" not in payload: + payload["reply_required"] = bool(result.get("direct_reply_required")) + if "reply_text" not in payload: + payload["reply_text"] = str(result.get("direct_reply_text") or "").strip() or None + if "trace_id" not in payload: + payload["trace_id"] = str(result.get("ai_trace_id") or "").strip() or None + if "processing_stage" not in payload: + payload["processing_stage"] = "qa" + + payload["work_item_id"] = _safe_int(result.get("work_item_id")) + payload["ingress_status"] = str(result.get("status") or "").strip() or None + payload["intent"] = str(result.get("intent") or "").strip() or None + return payload diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index 4c9f0005..686b037a 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -98,6 +98,7 @@ __all__ = [ "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_RAG_DB_PROJECT_QA", diff --git a/backend/app/config/ai_prompts_catalog/__init__.py b/backend/app/config/ai_prompts_catalog/__init__.py index 3419ac31..6daddc2b 100644 --- a/backend/app/config/ai_prompts_catalog/__init__.py +++ b/backend/app/config/ai_prompts_catalog/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from .part_01 import PROMPT_IMPACT_ANALYSIS, PROMPT_TASK_BREAKDOWN, PROMPT_STRATEGIC_ANALYSIS_HEADER, PROMPT_RAG_CONTEXT_HEADER, PROMPT_RAG_CONTEXT_ITEM, PROMPT_CODE_AUDIT, PROMPT_CONTEXT_INSIGHTS, PROMPT_FEISHU_REQUIREMENT_ANALYSIS, PROMPT_FEISHU_HUMANIZED_REPLY, PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION, PROMPT_FEISHU_LEVEL_CLASSIFICATION -from .part_02 import PROMPT_FEISHU_SIGNAL_EXTRACTION, PROMPT_FEISHU_PROJECT_PREFILL, PROMPT_FEISHU_TASK_ORCHESTRATION, PROMPT_SIGNAL_SOURCE_ONBOARDING, PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION, PROMPT_AI_PMO_INTENT_CLASSIFICATION, PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS, PROMPT_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_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 @@ -30,6 +30,7 @@ __all__ = [ "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_RAG_DB_PROJECT_QA", diff --git a/backend/app/config/ai_prompts_catalog/part_02.py b/backend/app/config/ai_prompts_catalog/part_02.py index 0f747348..8efecd84 100644 --- a/backend/app/config/ai_prompts_catalog/part_02.py +++ b/backend/app/config/ai_prompts_catalog/part_02.py @@ -166,9 +166,9 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ 只输出一个 JSON 对象(不要解释、不要 Markdown): {{ - "family": "query|project_create|project_signal|signal_source", + "family": "query|project_create|task|project_signal|signal_source", "action": "answer|create|discover|onboard", - "intent": "query.read_only|project.create|signal.create|signal_source.onboard|followup.continue", + "intent": "query.read_only|project.create|task.create|signal.create|signal_source.onboard|followup.continue", "confidence": 0.0, "project_id": 0, "requested_count": 1, @@ -176,16 +176,26 @@ PROMPT_AI_PMO_INTENT_CLASSIFICATION = """ "reasoning": "一句话简述判定原因" }} -快速规则(按优先级): -1. 含“分析/梳理/方案/设计思路/帮我分析/先分析” => family=query, action=answer, intent=query.read_only。 -2. 含“创建/新建/新增 + 项目”,且不含第1条分析诉求 => family=project_create, action=create, intent=project.create。 -3. 含“新增信号/录入信号/创建信号” => family=project_signal, action=create, intent=signal.create。 -4. 含“先分析候选/拆分候选并确认” => family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 -5. 含“信号源/来源/webhook/rss/github 接入” => family=signal_source, action=onboard, intent=signal_source.onboard。 -6. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。 -7. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 -8. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 -9. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。 +判定规则(按优先级): +1. query.read_only 定义:仅当用户是在“查询 / 了解 / 分析已有内容”时,才判定为 query.read_only。 +2. 若消息同时包含“新增 / 创建 / 记录 / 提交”等写操作意图,优先判定为对应写操作意图,不要误判为 query.read_only。 +3. project.create:当用户明确表达“创建一个新项目”时,返回 family=project_create, action=create, intent=project.create。 +4. task.create:当用户明确表达“创建/新增/安排任务”时,返回 family=task, action=create, intent=task.create。 +5. signal.create 定义:当用户想创建 / 新增 / 录入 / 提交一条新的需求、功能、信号、想法或问题时,返回 family=project_signal, action=create, intent=signal.create。核心特征是“要产生一条新记录”,不管用户用什么词表达。 +6. signal.create few-shot: + - “给XX项目加个需求” -> signal.create + - “新增一个功能需求” -> signal.create + - “帮我记录这个想法” -> signal.create + - “提个需求:支持导出” -> signal.create +7. project_signal discover:当用户表达“先分析候选 / 拆分候选并确认”时,返回 family=project_signal, action=discover, intent=signal.create, requires_confirmation=true。 +8. signal_source.onboard:当用户表达“信号源 / 来源 / webhook / rss / github 接入”时,返回 family=signal_source, action=onboard, intent=signal_source.onboard。 +9. 若 session_last_intent=draft_pending_confirm 且消息含“确认/好的/就这样/行/ok”,优先返回 followup.continue(用于草稿确认执行)。 +10. 若 session_last_intent=draft_pending_confirm 且消息含“修改/改成/换成”,返回 followup.continue(用于草稿重起草)。 +11. 若 session_last_intent=draft_pending_confirm 且消息含“取消/不要/算了”,返回 followup.continue(用于草稿取消)。 +12. 若消息为短确认语气,可结合 session_last_intent;无把握时返回 query.answer。 +13. project_id 不确定返回 null;requested_count 默认 1(仅 project_signal 有效,范围 1-5)。 +14. family/action/intent 必须严格使用上面 JSON 示例给定的枚举值,不能自造。 +15. 若请求 DDL(建表/改表/删表),必须返回 query.answer + query.read_only,reasoning 含 ddl_api_required。 """ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ @@ -218,6 +228,41 @@ PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS = """ 4. 不要输出 Markdown,不要解释,不要代码块。 """.strip() +PROMPT_DRAFT_GENERATOR = """ +首字符必须是 {{,末字符必须是 }}。 +你是 PMFlow 的资深产品经理。 +收到一条新的业务需求,请起草分析报告。 + +输入: +- 项目信息:{project_title} - {project_description} +- 团队成员(按负载排序):{members_json} +- 最近同类需求:{recent_signals} +- 用户需求:{user_message} +- 意图:{intent_name} +- 修改意见(若为空表示首次起草):{revision_note} + +请输出 JSON,包含: +- title: 语义化标题(10字以内,必须概括目标,不得复述原话) +- summary: 需求摘要(50字以内) +- feasibility: 合理|待讨论|不合理 +- feasibility_reason: 判断理由(30字以内) +- recommended_assignee_id: 推荐成员ID +- recommended_assignee_reason: 推荐理由 +- followup_plan: 数组,每项含 when 和 action + +约束: +1. 只输出 JSON,不要解释,不要思考过程,不要 Markdown。 +2. title 必须是语义化摘要,不能包含原始消息中的原话或长片段复述。示例:可写“劝退后次日跟进机制”,不要写“给AI劝退助手加个需求...”。 +3. feasibility 判定规则: + - 明确的功能需求且技术可实现 => 合理 + - 需求模糊、边界冲突或依赖不清 => 待讨论 + - 明显不可行或超出系统范围 => 不合理 +4. 推荐负责人优先选择负载低于 80% 的成员。 +5. 若所有成员负载都 >= 80%,推荐负载最低者,并在 recommended_assignee_reason 明确写出负载风险。 +6. followup_plan 最少 1 项,最多 3 项。 +7. 若无法确定推荐人,recommended_assignee_id 返回 null。 +""".strip() + PROMPT_RAG_INTENT_CLASSIFICATION = """ 你是 PMFlow 的意图分类器。请判断输入文本属于:question / statement / mixed。 @@ -243,5 +288,6 @@ __all__ = [ "PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION", "PROMPT_AI_PMO_INTENT_CLASSIFICATION", "PROMPT_AI_PMO_SIGNAL_BATCH_ANALYSIS", + "PROMPT_DRAFT_GENERATOR", "PROMPT_RAG_INTENT_CLASSIFICATION", ] diff --git a/backend/app/core/startup/draft_expiry_worker.py b/backend/app/core/startup/draft_expiry_worker.py new file mode 100644 index 00000000..56023e84 --- /dev/null +++ b/backend/app/core/startup/draft_expiry_worker.py @@ -0,0 +1,114 @@ +"""Background worker for expiring stale draft confirmations.""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from sqlalchemy import and_, select + +from ..database import AsyncSessionLocal +from ..logging import get_logger +from ...models import DecisionRecord, IngestEvent, WorkItem +from ...services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_CANCELLED, STATE_DRAFT_PENDING_CONFIRM +from ...services.integrations.feishu_outbox import FeishuOutboxService +from ...utils.timezone import now_cn_naive + + +logger = get_logger(__name__) +_DRAFT_EXPIRY_SCAN_INTERVAL_SECONDS = 60 * 60 +_DRAFT_EXPIRY_BATCH_SIZE = 200 + + +async def _cancel_expired_drafts_once(limit: int = _DRAFT_EXPIRY_BATCH_SIZE) -> int: + async with AsyncSessionLocal() as db: + now_at = now_cn_naive() + rows = ( + await db.execute( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.state == STATE_DRAFT_PENDING_CONFIRM, + WorkItem.draft_expires_at.is_not(None), + WorkItem.draft_expires_at <= now_at, + ) + ) + .order_by(WorkItem.id.asc()) + .limit(max(1, int(limit))) + ) + ).all() + + cancelled_count = 0 + for work_item, ingest in rows: + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + draft_payload = work_item.draft_content if isinstance(work_item.draft_content, dict) else {} + original_text = str(draft_payload.get("original_text") or payload.get("message_text") or "").strip() + + work_item.state = STATE_CANCELLED + work_item.draft_expires_at = None + work_item.reply_template_key = "draft_timeout_cancelled" + work_item.execution_result = { + **(work_item.execution_result if isinstance(work_item.execution_result, dict) else {}), + "status": "cancelled", + "cancel_reason": "draft_expired", + "cancelled_at": now_at.isoformat(), + } + + decision = ( + await db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == int(work_item.tenant_id), + DecisionRecord.work_item_id == int(work_item.id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if decision is not None: + decision.execution_result = work_item.execution_result + decision.final_outcome = STATE_CANCELLED + + source_system = str(work_item.source_system or "").strip().lower() + source_channel = str(payload.get("channel_ref") or "").strip() + if source_system == "feishu" and source_channel: + message_preview = original_text[:20] + if message_preview: + timeout_text = ( + "⏰ 草稿已超时取消\n\n" + f"需求\"{message_preview}...\"\n" + "已自动取消,如需提交请重新发送。" + ) + else: + timeout_text = "⏰ 草稿已超时取消,已自动取消,如需提交请重新发送。" + await FeishuOutboxService.enqueue( + db, + tenant_id=int(work_item.tenant_id), + chat_id=source_channel, + text=timeout_text, + source_message_id=str(work_item.source_message_id or "").strip() or None, + dedupe_seed=f"draft-expired:{int(work_item.id)}", + ) + cancelled_count += 1 + + if cancelled_count > 0: + await db.commit() + logger.info("draft_expiry_worker_cancelled", count=cancelled_count) + else: + await db.rollback() + return cancelled_count + + +async def draft_expiry_drain_loop() -> None: + while True: + try: + await _cancel_expired_drafts_once() + await asyncio.sleep(_DRAFT_EXPIRY_SCAN_INTERVAL_SECONDS) + except asyncio.CancelledError: + break + except Exception as exc: + logger.warning("draft_expiry_worker_failed", error=str(exc)[:500]) + await asyncio.sleep(30) diff --git a/backend/app/main.py b/backend/app/main.py index 5216931e..a8745cf3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -16,6 +16,7 @@ from .core.response import success_response from .core.database import AsyncSessionLocal, engine from .core.startup.db_guard import check_database_dialect_guard from .core.startup.ai_pmo_ingress_async import ai_pmo_ingress_drain_loop +from .core.startup.draft_expiry_worker import draft_expiry_drain_loop from .core.startup.feishu_outbox import feishu_outbox_drain_loop from .core.startup.model_probe import probe_model_connectivity_on_startup, startup_log from .core.startup.world_model_warmup import warmup_world_models @@ -87,6 +88,7 @@ async def lifespan(_: FastAPI): outbox_task = asyncio.create_task(feishu_outbox_drain_loop(), name="feishu-outbox-drain-loop") ingress_task = asyncio.create_task(ai_pmo_ingress_drain_loop(), name="ai-pmo-ingress-drain-loop") + draft_expiry_task = asyncio.create_task(draft_expiry_drain_loop(), name="draft-expiry-drain-loop") wm_warmup_task = asyncio.create_task(warmup_world_models(), name="wm-world-model-warmup") startup_log("后端运行中", url=f"http://127.0.0.1:{backend_port}") @@ -105,6 +107,12 @@ async def lifespan(_: FastAPI): except asyncio.CancelledError: pass + draft_expiry_task.cancel() + try: + await draft_expiry_task + except asyncio.CancelledError: + pass + if not wm_warmup_task.done(): wm_warmup_task.cancel() with suppress(asyncio.CancelledError): diff --git a/backend/app/models/work_item.py b/backend/app/models/work_item.py index 8fe87525..4d688563 100644 --- a/backend/app/models/work_item.py +++ b/backend/app/models/work_item.py @@ -2,9 +2,10 @@ from __future__ import annotations +from datetime import datetime from typing import Any, Optional -from sqlalchemy import BigInteger, ForeignKey, Integer, UniqueConstraint, VARCHAR +from sqlalchemy import BigInteger, ForeignKey, Integer, TIMESTAMP, UniqueConstraint, VARCHAR from sqlalchemy.orm import Mapped, mapped_column from .base import Base @@ -54,4 +55,7 @@ class WorkItem(Base): execution_result: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) review_summary: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) reply_template_key: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True) - + draft_content: Mapped[Optional[Any]] = mapped_column(JSONB, nullable=True) + draft_expires_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True) + draft_version: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + confirm_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True) diff --git a/backend/app/schemas/rag.py b/backend/app/schemas/rag.py index 3eef117e..cf665740 100644 --- a/backend/app/schemas/rag.py +++ b/backend/app/schemas/rag.py @@ -3,21 +3,66 @@ from __future__ import annotations from datetime import datetime -from typing import Optional +from typing import ClassVar, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class RAGAskRequest(BaseModel): + _ALLOWED_ASKED_VIA: ClassVar[set[str]] = { + "pmflow", + "feishu", + "lark", + "feishu_group", + "feishu_dm", + "pmflow_manual", + "/ai-pmo/process-text", + "ai-pmo-process-text", + "ai_pmo", + "ai_pmo_ui", + } + _ASKED_VIA_ALIAS: ClassVar[dict[str, str]] = { + "ai-pmo-process-text": "/ai-pmo/process-text", + } question: str = Field(..., min_length=1, max_length=4000) project_id: Optional[int] = Field(default=None) - asked_via: str = Field(default="pmflow", pattern="^(pmflow|feishu)$") + asked_via: str = Field(default="pmflow", min_length=1, max_length=32) source_chat_id: Optional[str] = Field(default=None, max_length=128) session_id: Optional[int] = Field(default=None, ge=1) new_session: bool = Field(default=False) thinking_mode: str = Field(default="normal", pattern="^(normal|deep)$") action_mode: str = Field(default="auto", pattern="^(auto|read|write|read_write)$") sync_requirement: bool = Field(default=True) + message_intent: Optional[str] = Field(default=None, max_length=32) + mixed_signal_part: Optional[str] = Field(default=None, max_length=2000) + + @field_validator("asked_via", mode="before") + @classmethod + def _normalize_asked_via(cls, value: object) -> str: + text = str(value or "pmflow").strip().lower() + text = cls._ASKED_VIA_ALIAS.get(text, text) + if text not in cls._ALLOWED_ASKED_VIA: + allowed = ", ".join(sorted(cls._ALLOWED_ASKED_VIA)) + raise ValueError(f"asked_via must be one of: {allowed}") + return text + + @field_validator("message_intent", mode="before") + @classmethod + def _normalize_message_intent(cls, value: object) -> Optional[str]: + if value is None: + return None + text = str(value).strip().lower() + if not text: + return None + return text + + @field_validator("mixed_signal_part", mode="before") + @classmethod + def _normalize_mixed_signal_part(cls, value: object) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + return text or None class KnowledgeDocCreateRequest(BaseModel): diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py index 9b4162f5..86795c58 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_entry.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_entry.py @@ -27,6 +27,19 @@ class AdaptiveRAGAnswerEntryMixin: "ai_pmo_ui", } + @staticmethod + def _should_trigger_write_ack_gate( + *, + sync_requirement: bool, + asked_via: str, + effective_action_mode: str, + ) -> bool: + return ( + bool(sync_requirement) + and str(asked_via or "").strip().lower() != "feishu" + and str(effective_action_mode or "").strip().lower() in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE} + ) + async def answer_question( self, *, @@ -46,11 +59,6 @@ class AdaptiveRAGAnswerEntryMixin: ) -> dict[str, Any]: started = time.perf_counter() clean_q = str(question or "").strip() - external_conversation_key = self._build_external_conversation_key( - asked_via=asked_via, - source_chat_id=source_chat_id, - conversation_key=conversation_key, - ) active_thinking_mode = self._normalize_thinking_mode(thinking_mode) deep_thinking = active_thinking_mode == THINKING_MODE_DEEP asked_via_lower = str(asked_via or "").strip().lower() @@ -84,46 +92,31 @@ class AdaptiveRAGAnswerEntryMixin: thinking_mode=active_thinking_mode, ) - session_id, turn_index = await self._touch_session_for_new_record( - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - question=clean_q, - preferred_session_id=session_id, - force_new_session=bool(force_new_session), - ) - context_session_id: Optional[int] - if session_id is not None: - context_session_id = int(session_id) - elif asked_by is None: - context_session_id = self._external_session_id_from_key(external_conversation_key) - else: - context_session_id = None - conversation = await self._load_recent_conversation( + runtime = await self._prepare_answer_runtime( + clean_q=clean_q, asked_by=asked_by, asked_via=asked_via, project_id=project_id, + source_chat_id=source_chat_id, session_id=session_id, - external_session_id=context_session_id, - limit=10, + conversation_key=conversation_key, + force_new_session=force_new_session, + action_mode=action_mode, ) - state_row = await self._ensure_session_state(session_id=session_id) - effective_q, rewrite_meta = self._rewrite_question_with_state( - question=clean_q, - conversation=conversation, - session_state=state_row, - ) - query_type = self._classify_query_type(effective_q) - requested_action_mode = self._normalize_action_mode(action_mode) - effective_action_mode = self._resolve_action_mode( - requested_mode=requested_action_mode, - question=clean_q, - effective_question=effective_q, - ) - if ( - bool(sync_requirement) - and asked_via_lower != "feishu" - and effective_action_mode in {ACTION_MODE_WRITE, ACTION_MODE_READ_WRITE} + session_id = runtime["session_id"] + turn_index = int(runtime["turn_index"] or 1) + context_session_id = runtime["context_session_id"] + conversation = runtime["conversation"] + state_row = runtime["state_row"] + effective_q = str(runtime["effective_q"] or "").strip() + rewrite_meta = runtime["rewrite_meta"] if isinstance(runtime["rewrite_meta"], dict) else {} + query_type = str(runtime["query_type"] or QUERY_TYPE_UNKNOWN) + effective_action_mode = str(runtime["effective_action_mode"] or ACTION_MODE_AUTO).strip().lower() + + if self._should_trigger_write_ack_gate( + sync_requirement=sync_requirement, + asked_via=asked_via, + effective_action_mode=effective_action_mode, ): ack_result = await self._handle_ack_first_requirement( question=clean_q, @@ -142,132 +135,8 @@ class AdaptiveRAGAnswerEntryMixin: ) if ack_result is not None: return ack_result - forced_deep_reason = "" - if not deep_thinking: - forced_deep_reason = self._forced_deep_reason( - question=clean_q, - effective_question=effective_q, - conversation=conversation, - ) - if forced_deep_reason and self._should_force_deep_for_channel( - asked_via=asked_via, - question=clean_q, - effective_question=effective_q, - reason=forced_deep_reason, - ): - deep_thinking = True - active_thinking_mode = THINKING_MODE_DEEP - else: - forced_deep_reason = "" - - clarification_answer = self._build_clarification_question( - original_question=clean_q, - effective_question=effective_q, - session_state=state_row, - ) - clarification_result = await self._maybe_return_clarification_response( - clarification_answer=clarification_answer, - started=started, + return await self._execute_base_answer_mainline( clean_q=clean_q, - effective_q=effective_q, - query_type=query_type, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - session_id=session_id, - turn_index=turn_index, - state_row=state_row, - rewrite_meta=rewrite_meta, - context_session_id=context_session_id, - active_thinking_mode=active_thinking_mode, - ) - if clarification_result is not None: - return clarification_result - - system_snapshot = await self.context_cache.get_system_snapshot( - db=self.db, - tenant_id=self.tenant_id, - ) - user_snapshot = await self.context_cache.get_user_snapshot( - db=self.db, - tenant_id=self.tenant_id, - user_id=asked_by, - ) - - if not deep_thinking and not enforce_scene_prompt_only: - deterministic_result = await self._run_deterministic_query( - question=effective_q, - project_id=project_id, - query_type=query_type, - ) - if deterministic_result is not None: - return await self._persist_fast_answer( - question=clean_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - fast_result=deterministic_result, - session_id=session_id, - external_session_id=context_session_id, - turn_index=turn_index, - query_type=str(deterministic_result.get("query_type") or query_type), - missing_fields=list(deterministic_result.get("missing_fields") or []), - state_row=state_row, - effective_question=effective_q, - rewrite_meta=rewrite_meta, - thinking_mode=active_thinking_mode, - ) - - direct_answer = None - if not deep_thinking and not enforce_scene_prompt_only: - direct_answer = self._build_snapshot_direct_answer( - effective_q, - system_snapshot=system_snapshot, - user_snapshot=user_snapshot, - ) - direct_result = await self._maybe_return_snapshot_direct_response( - direct_answer=direct_answer, - started=started, - clean_q=clean_q, - effective_q=effective_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - session_id=session_id, - turn_index=turn_index, - state_row=state_row, - rewrite_meta=rewrite_meta, - context_session_id=context_session_id, - active_thinking_mode=active_thinking_mode, - user_snapshot=user_snapshot, - ) - if direct_result is not None: - return direct_result - - is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) - if not deep_thinking and not enforce_scene_prompt_only and not is_snapshot_direct_question: - glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id) - if glm_semantic_result is not None: - return await self._persist_fast_answer( - question=clean_q, - asked_by=asked_by, - asked_via=asked_via, - project_id=project_id, - fast_result=glm_semantic_result, - session_id=session_id, - external_session_id=context_session_id, - turn_index=turn_index, - query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, - missing_fields=[], - state_row=state_row, - effective_question=effective_q, - rewrite_meta=rewrite_meta, - thinking_mode=active_thinking_mode, - ) - - return await self._build_retrieval_response( - clean_q=clean_q, - effective_q=effective_q, asked_by=asked_by, asked_via=asked_via, project_id=project_id, @@ -277,16 +146,15 @@ class AdaptiveRAGAnswerEntryMixin: context_session_id=context_session_id, conversation=conversation, state_row=state_row, + effective_q=effective_q, rewrite_meta=rewrite_meta, query_type=query_type, thinking_mode=thinking_mode, active_thinking_mode=active_thinking_mode, deep_thinking=deep_thinking, - forced_deep_reason=forced_deep_reason, + enforce_scene_prompt_only=enforce_scene_prompt_only, deep_retrieval_steps=deep_retrieval_steps, deep_vector_top_k=deep_vector_top_k, - system_snapshot=system_snapshot, - user_snapshot=user_snapshot, started=started, message_intent=message_intent, mixed_signal_part=mixed_signal_part, diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py new file mode 100644 index 00000000..5132d588 --- /dev/null +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -0,0 +1,248 @@ +"""Adaptive RAG answer mainline orchestration helpers.""" +from __future__ import annotations + +from .shared import * + + +class AdaptiveRAGAnswerMainlineMixin: + async def _prepare_answer_runtime( + self, + *, + clean_q: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + source_chat_id: Optional[str], + session_id: Optional[int], + conversation_key: Optional[str], + force_new_session: bool, + action_mode: str, + ) -> dict[str, Any]: + external_conversation_key = self._build_external_conversation_key( + asked_via=asked_via, + source_chat_id=source_chat_id, + conversation_key=conversation_key, + ) + session_id, turn_index = await self._touch_session_for_new_record( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + question=clean_q, + preferred_session_id=session_id, + force_new_session=bool(force_new_session), + ) + context_session_id: Optional[int] + if session_id is not None: + context_session_id = int(session_id) + elif asked_by is None: + context_session_id = self._external_session_id_from_key(external_conversation_key) + else: + context_session_id = None + conversation = await self._load_recent_conversation( + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + external_session_id=context_session_id, + limit=10, + ) + state_row = await self._ensure_session_state(session_id=session_id) + effective_q, rewrite_meta = self._rewrite_question_with_state( + question=clean_q, + conversation=conversation, + session_state=state_row, + ) + query_type = self._classify_query_type(effective_q) + requested_action_mode = self._normalize_action_mode(action_mode) + effective_action_mode = self._resolve_action_mode( + requested_mode=requested_action_mode, + question=clean_q, + effective_question=effective_q, + ) + return { + "session_id": session_id, + "turn_index": turn_index, + "context_session_id": context_session_id, + "conversation": conversation, + "state_row": state_row, + "effective_q": effective_q, + "rewrite_meta": rewrite_meta, + "query_type": query_type, + "effective_action_mode": effective_action_mode, + } + + async def _execute_base_answer_mainline( + self, + *, + clean_q: str, + asked_by: Optional[int], + asked_via: str, + project_id: Optional[int], + source_chat_id: Optional[str], + session_id: Optional[int], + turn_index: int, + context_session_id: Optional[int], + conversation: list[dict[str, str]], + state_row: Optional[QASessionState], + effective_q: str, + rewrite_meta: dict[str, Any], + query_type: str, + thinking_mode: str, + active_thinking_mode: str, + deep_thinking: bool, + enforce_scene_prompt_only: bool, + deep_retrieval_steps: int, + deep_vector_top_k: int, + started: float, + message_intent: Optional[str], + mixed_signal_part: Optional[str], + ) -> dict[str, Any]: + forced_deep_reason = "" + if not deep_thinking: + forced_deep_reason = self._forced_deep_reason( + question=clean_q, + effective_question=effective_q, + conversation=conversation, + ) + if forced_deep_reason and self._should_force_deep_for_channel( + asked_via=asked_via, + question=clean_q, + effective_question=effective_q, + reason=forced_deep_reason, + ): + deep_thinking = True + active_thinking_mode = THINKING_MODE_DEEP + else: + forced_deep_reason = "" + + clarification_answer = self._build_clarification_question( + original_question=clean_q, + effective_question=effective_q, + session_state=state_row, + ) + clarification_result = await self._maybe_return_clarification_response( + clarification_answer=clarification_answer, + started=started, + clean_q=clean_q, + effective_q=effective_q, + query_type=query_type, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + state_row=state_row, + rewrite_meta=rewrite_meta, + context_session_id=context_session_id, + active_thinking_mode=active_thinking_mode, + ) + if clarification_result is not None: + return clarification_result + + system_snapshot = await self.context_cache.get_system_snapshot( + db=self.db, + tenant_id=self.tenant_id, + ) + user_snapshot = await self.context_cache.get_user_snapshot( + db=self.db, + tenant_id=self.tenant_id, + user_id=asked_by, + ) + + if not deep_thinking and not enforce_scene_prompt_only: + deterministic_result = await self._run_deterministic_query( + question=effective_q, + project_id=project_id, + query_type=query_type, + ) + if deterministic_result is not None: + return await self._persist_fast_answer( + question=clean_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + fast_result=deterministic_result, + session_id=session_id, + external_session_id=context_session_id, + turn_index=turn_index, + query_type=str(deterministic_result.get("query_type") or query_type), + missing_fields=list(deterministic_result.get("missing_fields") or []), + state_row=state_row, + effective_question=effective_q, + rewrite_meta=rewrite_meta, + thinking_mode=active_thinking_mode, + ) + + direct_answer = None + if not deep_thinking and not enforce_scene_prompt_only: + direct_answer = self._build_snapshot_direct_answer( + effective_q, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + ) + direct_result = await self._maybe_return_snapshot_direct_response( + direct_answer=direct_answer, + started=started, + clean_q=clean_q, + effective_q=effective_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + session_id=session_id, + turn_index=turn_index, + state_row=state_row, + rewrite_meta=rewrite_meta, + context_session_id=context_session_id, + active_thinking_mode=active_thinking_mode, + user_snapshot=user_snapshot, + ) + if direct_result is not None: + return direct_result + + is_snapshot_direct_question = self._is_snapshot_direct_question(effective_q) + if not deep_thinking and not enforce_scene_prompt_only and not is_snapshot_direct_question: + glm_semantic_result = await self._try_glm_project_semantic_answer(clean_q=effective_q, project_id=project_id) + if glm_semantic_result is not None: + return await self._persist_fast_answer( + question=clean_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + fast_result=glm_semantic_result, + session_id=session_id, + external_session_id=context_session_id, + turn_index=turn_index, + query_type=query_type if query_type != QUERY_TYPE_UNKNOWN else QUERY_TYPE_EXPLAIN, + missing_fields=[], + state_row=state_row, + effective_question=effective_q, + rewrite_meta=rewrite_meta, + thinking_mode=active_thinking_mode, + ) + + return await self._build_retrieval_response( + clean_q=clean_q, + effective_q=effective_q, + asked_by=asked_by, + asked_via=asked_via, + project_id=project_id, + source_chat_id=source_chat_id, + session_id=session_id, + turn_index=turn_index, + context_session_id=context_session_id, + conversation=conversation, + state_row=state_row, + rewrite_meta=rewrite_meta, + query_type=query_type, + thinking_mode=thinking_mode, + active_thinking_mode=active_thinking_mode, + deep_thinking=deep_thinking, + forced_deep_reason=forced_deep_reason, + deep_retrieval_steps=deep_retrieval_steps, + deep_vector_top_k=deep_vector_top_k, + system_snapshot=system_snapshot, + user_snapshot=user_snapshot, + started=started, + message_intent=message_intent, + mixed_signal_part=mixed_signal_part, + ) diff --git a/backend/app/services/adaptive_rag_service/service.py b/backend/app/services/adaptive_rag_service/service.py index 3092a2b5..05bec306 100644 --- a/backend/app/services/adaptive_rag_service/service.py +++ b/backend/app/services/adaptive_rag_service/service.py @@ -3,6 +3,7 @@ from __future__ import annotations from .mixin_core_intent import AdaptiveRAGCoreIntentMixin from .mixin_answer_entry import AdaptiveRAGAnswerEntryMixin +from .mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin from .mixin_answer_precheck import AdaptiveRAGAnswerPrecheckMixin from .mixin_answer_retrieval import AdaptiveRAGAnswerRetrievalMixin from .mixin_answer_payload import AdaptiveRAGAnswerPayloadMixin @@ -43,6 +44,7 @@ from .mixin_conversation_utils_b import AdaptiveRAGConversationUtilsBMixin class AdaptiveRAGService( AdaptiveRAGCoreIntentMixin, AdaptiveRAGAnswerEntryMixin, + AdaptiveRAGAnswerMainlineMixin, AdaptiveRAGAnswerPrecheckMixin, AdaptiveRAGAnswerRetrievalMixin, AdaptiveRAGAnswerPayloadMixin, diff --git a/backend/app/services/ai_orchestration_run/ingress_worker.py b/backend/app/services/ai_orchestration_run/ingress_worker.py index e098e91e..262a866f 100644 --- a/backend/app/services/ai_orchestration_run/ingress_worker.py +++ b/backend/app/services/ai_orchestration_run/ingress_worker.py @@ -141,6 +141,10 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None: captured_at_value = row.captured_at source_message_id_value = str(row.source_message_id or "").strip() or None chat_type_value = str(row.chat_type or "").strip() or None + result_payload_value = row.result_payload if isinstance(row.result_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") + session_snapshot_value = task_payload_value.get("session_snapshot") if isinstance(task_payload_value.get("session_snapshot"), dict) else None try: capture_service = ProactiveSignalCaptureService(db) @@ -177,6 +181,8 @@ async def _dispatch_claimed_ingress_run(*, run_id: int) -> None: feishu_message_id=source_message_id_value, chat_type=chat_type_value, force_sync=True, + message_sent_at=message_sent_at_value, + session_snapshot=session_snapshot_value, ) finally: stop_heartbeat.set() diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/policy.py b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py index c60effb0..a3b8c6e1 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/policy.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/policy.py @@ -7,6 +7,7 @@ import os from .shared import ( CanonicalIntent, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, re, @@ -59,7 +60,7 @@ class ExecutionPolicyBrain: }, } - if intent.name in {INTENT_QUERY, INTENT_SIGNAL_CREATE}: + if intent.name in {INTENT_QUERY, INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE}: return { "auto_execute": True, "policy_gate_hit": False, diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/shared.py b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py index 66212919..caf99027 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/shared.py @@ -10,6 +10,7 @@ from ..ai_pmo_intent import ( INTENT_FOLLOWUP_CONTINUE, INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, ROUTE_PROJECT_SIGNAL_CANCEL, @@ -46,6 +47,7 @@ __all__ = [ "INTENT_FOLLOWUP_CONTINUE", "INTENT_PROJECT_CREATE", "INTENT_QUERY", + "INTENT_TASK_CREATE", "INTENT_SIGNAL_CREATE", "INTENT_SIGNAL_SOURCE_ONBOARD", "ROUTE_PROJECT_SIGNAL_CANCEL", diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py index 6d9c3299..ffc9f7cb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/simulation.py @@ -19,6 +19,7 @@ class SimulationEngine: BUDGET_COST_BY_OPERATION = { "query.answer": 1.0, "project.create": 120.0, + "task.create": 14.0, "signal.enter": 8.0, "signal_source.onboard": 40.0, ROUTE_PROJECT_SIGNAL_DISCOVER: 3.0, @@ -29,6 +30,7 @@ class SimulationEngine: HOURS_BY_OPERATION = { "query.answer": 0.2, "project.create": 12.0, + "task.create": 2.5, "signal.enter": 1.5, "signal_source.onboard": 6.0, ROUTE_PROJECT_SIGNAL_DISCOVER: 0.8, diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py index 45e1bfed..5cb031e7 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/strategic.py @@ -5,6 +5,7 @@ from __future__ import annotations from .shared import ( CanonicalIntent, INTENT_PROJECT_CREATE, + INTENT_TASK_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, Any, normalize_workload_ratio, @@ -28,6 +29,8 @@ class StrategicBrain: risk_score = min(1.0, 0.18 + risk_count * 0.08 + budget_ratio * 0.25 + len(overloaded_people) * 0.1) if intent.name == INTENT_PROJECT_CREATE: risk_score = max(risk_score, 0.62) + if intent.name == INTENT_TASK_CREATE: + risk_score = max(risk_score, 0.55) if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: risk_score = max(risk_score, 0.58) diff --git a/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py index 2c9bc5b7..f1d402ca 100644 --- a/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py +++ b/backend/app/services/ai_pmo/ai_pmo_decision/tactical.py @@ -8,6 +8,7 @@ from .shared import ( INTENT_FOLLOWUP_CONTINUE, INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, ROUTE_PROJECT_SIGNAL_CANCEL, @@ -42,6 +43,8 @@ class TacticalBrain: execution_actions.append({"operation_code": "query.answer", "mode": "read_only"}) elif intent.name == INTENT_PROJECT_CREATE: execution_actions.append({"operation_code": "project.create", "mode": "write"}) + elif intent.name == INTENT_TASK_CREATE: + execution_actions.append({"operation_code": "task.create", "mode": "write"}) elif intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: execution_actions.append({"operation_code": "signal.enter", "mode": "write"}) execution_actions.append({"operation_code": "signal_source.onboard", "mode": "write"}) diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py index ef1c7c0e..a4876d4f 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py @@ -8,6 +8,7 @@ from .intent_resolver import IntentResolver __all__ = [ "INTENT_QUERY", "INTENT_PROJECT_CREATE", + "INTENT_TASK_CREATE", "INTENT_SIGNAL_CREATE", "INTENT_SIGNAL_SOURCE_ONBOARD", "INTENT_FOLLOWUP_CONFIRM", @@ -16,6 +17,7 @@ __all__ = [ "FOLLOWUP_TARGET_INTENTS", "ROUTE_FAMILY_QUERY", "ROUTE_FAMILY_PROJECT_CREATE", + "ROUTE_FAMILY_TASK", "ROUTE_FAMILY_PROJECT_SIGNAL", "ROUTE_FAMILY_SIGNAL_SOURCE", "ROUTE_FAMILY_FOLLOWUP", @@ -30,6 +32,7 @@ __all__ = [ "ROUTE_ACTION_CONFIRM", "ROUTE_QUERY_ANSWER", "ROUTE_PROJECT_CREATE", + "ROUTE_TASK_CREATE", "ROUTE_PROJECT_SIGNAL_CREATE", "ROUTE_PROJECT_SIGNAL_DISCOVER", "ROUTE_PROJECT_SIGNAL_COMMIT", @@ -53,6 +56,7 @@ __all__ = [ "FOLLOWUP_CONTINUE_HINTS", "FOLLOWUP_WORK_ITEM_ID_RE", "SIGNAL_CREATE_HINTS", + "TASK_CREATE_HINTS", "PROJECT_CREATE_HINTS", "SESSION_SHORT_CONFIRM_WINDOW_SECONDS", "SHORT_CONFIRM_MAX_MESSAGE_LENGTH", diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py index 26a6a408..6c88445d 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/intent_resolver.py @@ -103,6 +103,16 @@ class IntentResolver: pretty[:300], {}, ) + if IntentResolver._looks_like_task_create_request(text=pretty): + extracted_title = IntentResolver._extract_task_title(pretty) + return CanonicalIntent( + INTENT_TASK_CREATE, + 0.82, + project_id, + extracted_title or IntentResolver._build_signal_title(pretty, default="新任务"), + pretty[:300], + {"task_title": extracted_title} if extracted_title else {}, + ) if IntentResolver._looks_like_signal_create_request(text=pretty): return CanonicalIntent( INTENT_SIGNAL_CREATE, @@ -148,19 +158,71 @@ class IntentResolver: return True if any(token in normalized for token in SIGNAL_CREATE_HINTS): return True - signal_object_hints = ("信号", "需求", "任务", "事项", "问题", "改造", "优化") + signal_object_hints = ("信号", "需求", "事项", "问题", "改造", "优化") has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) has_signal_object = any(token in normalized for token in signal_object_hints) if has_operation and has_signal_object: return True return bool( re.search( - r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|任务|事项)", + r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|事项)", normalized, re.IGNORECASE, ) ) + @staticmethod + def _looks_like_task_create_request(*, text: str) -> bool: + normalized = re.sub(r"\s+", "", str(text or "")).lower() + if not normalized: + return False + if IntentResolver._looks_like_negated_write_statement(text=normalized): + return False + if any(token in normalized for token in TASK_CREATE_HINTS): + return True + task_object_hints = ("任务", "工单", "待办", "todo") + has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS) + has_task_object = any(token in normalized for token in task_object_hints) + if has_operation and has_task_object: + return True + return bool( + re.search( + r"(?:给|帮|请).{0,24}(?:新增|增加|添加|创建|新建|加).{0,16}(?:任务|工单|待办)", + normalized, + re.IGNORECASE, + ) + ) + + @staticmethod + def _extract_task_title(text: str) -> str: + cleaned = re.sub(r"\s+", " ", str(text or "")).strip() + if not cleaned: + return "" + quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned) + if quoted_match: + return quoted_match.group(1).strip()[:120] + for pattern in ( + r"(?:新增|新建|创建|增加|添加|安排|分配|加一个|加个).{0,8}任务(?:叫|名为|标题是|是|:|:)?\s*(.+)", + r"任务(?:叫|名为|标题是|是|:|:)\s*(.+)", + ): + match = re.search(pattern, cleaned, re.IGNORECASE) + if not match: + continue + candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::") + candidate = re.sub(r"^(?:到|进|放到)\s*项目.*$", "", candidate, flags=re.IGNORECASE).strip(" ,,::") + if candidate: + return candidate[:120] + fallback = re.sub( + r"^(?:请|帮我|帮忙)?(?:给|在)?(?:项目\s*#?\d+\s*)?(?:里|中)?(?:新增|新建|创建|增加|添加|加(?:一个|个)?|安排|分配)\s*(?:一条|一个|个)?任务", + "", + cleaned, + flags=re.IGNORECASE, + ).strip(" ,,::") + fallback = re.sub(r"[。;;!!??].*$", "", fallback).strip(" ,,::") + if not fallback: + return "" + return fallback[:120] + @staticmethod def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: compact = re.sub(r"[\\s\\??!!,,。~~、]+", "", str(pretty or "")).strip().lower() @@ -181,6 +243,8 @@ class IntentResolver: return False if IntentResolver._looks_like_signal_create_request(text=normalized): return False + if IntentResolver._looks_like_task_create_request(text=normalized): + return False if any(token in normalized for token in QUESTION_HINTS): return True has_question_structure = ( diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py index 43e146f1..1e77d231 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/route_resolver.py @@ -41,6 +41,8 @@ class RouteResolver: return INTENT_SIGNAL_SOURCE_ONBOARD if family == ROUTE_FAMILY_PROJECT_CREATE: return INTENT_PROJECT_CREATE + if family == ROUTE_FAMILY_TASK: + return INTENT_TASK_CREATE if family == ROUTE_FAMILY_FOLLOWUP: return INTENT_FOLLOWUP_CONTINUE return INTENT_QUERY @@ -61,8 +63,14 @@ class RouteResolver: return explicit_action == ROUTE_ACTION_DISCOVER @staticmethod - def parse_followup_action(*, metadata: dict[str, Any], canonical_intent: str) -> str: - explicit_action = str(metadata.get("followup_action") or metadata.get("action") or "").strip().lower() + def parse_followup_action( + *, + metadata: Optional[dict[str, Any]] = None, + canonical_intent: str, + message_text: str = "", + ) -> str: + payload = metadata if isinstance(metadata, dict) else {} + explicit_action = str(payload.get("followup_action") or payload.get("action") or "").strip().lower() if explicit_action in { ROUTE_ACTION_CONTINUE, ROUTE_ACTION_CONFIRM, @@ -71,6 +79,13 @@ class RouteResolver: ROUTE_ACTION_REFRESH, }: return explicit_action + normalized_text = re.sub(r"\s+", "", str(message_text or "")).lower() + if any(token in normalized_text for token in FOLLOWUP_REFRESH_HINTS): + return ROUTE_ACTION_REFRESH + if any(token in normalized_text for token in FOLLOWUP_CANCEL_HINTS): + return ROUTE_ACTION_CANCEL + if any(token in normalized_text for token in FOLLOWUP_COMMIT_HINTS): + return ROUTE_ACTION_COMMIT if canonical_intent in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: return ROUTE_ACTION_COMMIT return ROUTE_ACTION_CONTINUE @@ -92,8 +107,15 @@ class RouteResolver: route_key = ROUTE_PROJECT_SIGNAL_DISCOVER elif route_key not in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER}: route_key = ROUTE_PROJECT_SIGNAL_CREATE + elif canonical_intent.name == INTENT_TASK_CREATE: + if route_key != ROUTE_TASK_CREATE: + route_key = ROUTE_TASK_CREATE elif canonical_intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: - action = cls.parse_followup_action(metadata=metadata, canonical_intent=canonical_intent.name) + action = cls.parse_followup_action( + metadata=metadata, + canonical_intent=canonical_intent.name, + message_text=message_text, + ) if action == ROUTE_ACTION_CONFIRM: route_key = ROUTE_FOLLOWUP_CONFIRM else: diff --git a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py index 6df8a788..e48a7350 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py @@ -11,6 +11,7 @@ from ...signal.signal_source_orchestration import SignalSourceOrchestrationServi INTENT_QUERY = "query.read_only" INTENT_PROJECT_CREATE = "project.create" +INTENT_TASK_CREATE = "task.create" INTENT_SIGNAL_CREATE = "signal.create" INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" INTENT_FOLLOWUP_CONFIRM = "followup.confirm" @@ -18,12 +19,14 @@ INTENT_FOLLOWUP_CONTINUE = "followup.continue" INTENT_IGNORE = "ignore" FOLLOWUP_TARGET_INTENTS = { INTENT_PROJECT_CREATE, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, } ROUTE_FAMILY_QUERY = "query" ROUTE_FAMILY_PROJECT_CREATE = "project_create" +ROUTE_FAMILY_TASK = "task" ROUTE_FAMILY_PROJECT_SIGNAL = "project_signal" ROUTE_FAMILY_SIGNAL_SOURCE = "signal_source" ROUTE_FAMILY_FOLLOWUP = "followup" @@ -40,6 +43,7 @@ ROUTE_ACTION_CONFIRM = "confirm" ROUTE_QUERY_ANSWER = f"{ROUTE_FAMILY_QUERY}.{ROUTE_ACTION_ANSWER}" ROUTE_PROJECT_CREATE = f"{ROUTE_FAMILY_PROJECT_CREATE}.{ROUTE_ACTION_CREATE}" +ROUTE_TASK_CREATE = f"{ROUTE_FAMILY_TASK}.{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_COMMIT = f"{ROUTE_FAMILY_PROJECT_SIGNAL}.{ROUTE_ACTION_COMMIT}" @@ -52,6 +56,7 @@ ROUTE_FOLLOWUP_CONTINUE = f"{ROUTE_FAMILY_FOLLOWUP}.{ROUTE_ACTION_CONTINUE}" WORKFLOW_ROUTE_KEYS = { ROUTE_QUERY_ANSWER, ROUTE_PROJECT_CREATE, + ROUTE_TASK_CREATE, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT, @@ -65,6 +70,7 @@ WORKFLOW_ROUTE_KEYS = { ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { ROUTE_QUERY_ANSWER: INTENT_QUERY, ROUTE_PROJECT_CREATE: INTENT_PROJECT_CREATE, + ROUTE_TASK_CREATE: INTENT_TASK_CREATE, ROUTE_PROJECT_SIGNAL_CREATE: INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER: INTENT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_COMMIT: INTENT_SIGNAL_CREATE, @@ -78,12 +84,14 @@ ROUTE_CANONICAL_INTENT_MAP: dict[str, str] = { LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { INTENT_QUERY: ROUTE_QUERY_ANSWER, INTENT_PROJECT_CREATE: ROUTE_PROJECT_CREATE, + INTENT_TASK_CREATE: ROUTE_TASK_CREATE, INTENT_SIGNAL_CREATE: ROUTE_PROJECT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD: ROUTE_SIGNAL_SOURCE_ONBOARD, INTENT_FOLLOWUP_CONFIRM: ROUTE_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE: ROUTE_FOLLOWUP_CONTINUE, "query.answer": ROUTE_QUERY_ANSWER, "project_create.create": ROUTE_PROJECT_CREATE, + "task.create": ROUTE_TASK_CREATE, "project_signal.create": ROUTE_PROJECT_SIGNAL_CREATE, "project_signal.discover": ROUTE_PROJECT_SIGNAL_DISCOVER, "project_signal.commit": ROUTE_PROJECT_SIGNAL_COMMIT, @@ -154,6 +162,18 @@ FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", " FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") +TASK_CREATE_HINTS = ( + "创建任务", + "新建任务", + "新增任务", + "增加任务", + "添加任务", + "加个任务", + "加一个任务", + "安排任务", + "分配任务", + "派个任务", +) PROJECT_CREATE_HINTS = ( "创建项目", "新建项目", @@ -193,6 +213,7 @@ SHORT_CONFIRM_HINTS = ( INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" INTENT_CANDIDATES = ( INTENT_SIGNAL_SOURCE_ONBOARD, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_QUERY, INTENT_PROJECT_CREATE, @@ -202,10 +223,10 @@ INTENT_CANDIDATES = ( ) WRITE_INTENTS = { INTENT_PROJECT_CREATE, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE, } INTENT_CONFLICT_POLICIES = {"pending_manual", "prefer_rule", "prefer_llm"} - diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py index 43410595..cb5afa1b 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_reply_renderer.py @@ -9,6 +9,7 @@ from ..ai_pmo_intent import ( INTENT_FOLLOWUP_CONTINUE, INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, ROUTE_PROJECT_SIGNAL_CANCEL, @@ -151,6 +152,28 @@ class ReplyRenderer: "payload": {"intent": intent.name, "project_id": project_id, "project_title": project_title}, "text": f"已立项:项目#{project_id}《{project_title}》已创建完成。" if project_id else f"已立项:《{project_title}》已创建完成。", } + if intent.name == INTENT_TASK_CREATE: + 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")) + project_id = _safe_int(execution_result.get("project_id")) + task_title = str(task.get("title") or "").strip() or "新任务" + if task_id is not None and project_id is not None: + text = f"已创建任务:项目#{project_id} 任务#{task_id}《{task_title}》。" + elif task_id is not None: + text = f"已创建任务:任务#{task_id}《{task_title}》。" + else: + text = f"已创建任务:《{task_title}》。" + return { + "receipt_phase": self.receipt_phase_action, + "template_key": "action_succeeded", + "payload": { + "intent": intent.name, + "project_id": project_id, + "task_id": task_id, + "task_title": task_title, + }, + "text": text, + } if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: signal_id = _safe_int(execution_result.get("signal_id")) workflow = execution_result.get("signal_source_workflow") if isinstance(execution_result.get("signal_source_workflow"), dict) else {} diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py index 4ecd2740..71c93d16 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_common.py @@ -29,6 +29,7 @@ from .ai_pmo_domain_workflow import SignalSourceDomainWorkflow from ..ai_pmo_intent import ( INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, LEGACY_INTENT_ROUTE_MAP, @@ -36,6 +37,7 @@ from ..ai_pmo_intent import ( ROUTE_ACTION_DISCOVER, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_TASK_CREATE, CanonicalIntent, IntentResolver, RouteResolver, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py index e423845b..ce35ad55 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_workflow_runtime.py @@ -14,6 +14,7 @@ from ..ai_pmo_intent import ( ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_QUERY, ROUTE_FAMILY_SIGNAL_SOURCE, + ROUTE_FAMILY_TASK, CanonicalIntent, WorkflowRoute, _safe_int, @@ -162,6 +163,31 @@ class ProjectCreateWorkflowHandler(WorkflowHandler): ) +class TaskCreateWorkflowHandler(WorkflowHandler): + async def start( + self, + *, + route: WorkflowRoute, + intent: CanonicalIntent, + message_text: str, + sender: str, + operator_user_id: Optional[int], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + ) -> dict[str, object]: + _ = route + return await self.kernel.executor.execute( + intent=intent, + message_text=message_text, + sender=sender, + operator_user_id=operator_user_id, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + + class SignalSourceWorkflowHandler(WorkflowHandler): async def start( self, @@ -229,6 +255,7 @@ class WorkflowRegistry: self._handlers: dict[str, WorkflowHandler] = { ROUTE_FAMILY_QUERY: QueryWorkflowHandler(kernel), ROUTE_FAMILY_PROJECT_CREATE: ProjectCreateWorkflowHandler(kernel), + ROUTE_FAMILY_TASK: TaskCreateWorkflowHandler(kernel), ROUTE_FAMILY_PROJECT_SIGNAL: ProjectSignalWorkflowHandler(kernel), ROUTE_FAMILY_SIGNAL_SOURCE: SignalSourceWorkflowHandler(kernel), ROUTE_FAMILY_FOLLOWUP: WorkflowHandler(kernel), diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py index e50196ed..d94a5175 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_project_signal_resume.py @@ -57,6 +57,7 @@ class KernelFollowupProjectSignalResumeMixin: ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE, + ROUTE_FAMILY_TASK, }: reply_text = f"工作项#{int(target_work_item.id)} 当前类型为 {target_intent.name or 'unknown'},暂不支持继续处理。" return { diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py index a9ee459f..a035e7ab 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_routing.py @@ -10,6 +10,44 @@ for _name, _value in vars(_common).items(): class KernelFollowupRoutingMixin: + async def _project_draft_pending_confirm( + self, + *, + work_item: WorkItem, + message_text: str, + draft_text: str, + intent: CanonicalIntent, + ) -> None: + await NotificationService(self.db).create_notifications( + tenant_id=self.tenant_id, + user_ids=await NotificationService(self.db)._resolve_recipients(tenant_id=self.tenant_id), + title="AI PMO 草稿待确认", + content=str(draft_text or message_text)[:4000], + category="decision", + source="ai_pmo", + entity_type="work_item", + entity_id=int(work_item.id), + target_url=f"/inbox?workItemId={int(work_item.id)}", + kind="action_required", + priority="high", + status="pending_manual", + recommended_action="view_detail", + dedupe_key=f"ai-pmo-draft:{int(work_item.id)}", + payload={ + "work_item_id": int(work_item.id), + "intent": intent.name, + "project_id": intent.project_id, + "message_text": message_text[:500], + "draft_version": int(work_item.draft_version or 0), + "draft_expires_at": ( + work_item.draft_expires_at.isoformat() + if isinstance(work_item.draft_expires_at, datetime) + else None + ), + }, + commit=False, + ) + async def _project_waiting_manual( self, *, @@ -168,10 +206,15 @@ class KernelFollowupRoutingMixin: "target_work_item_id": int(target_work_item.id), }, ) - elif target_family in {ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE}: + elif target_family in {ROUTE_FAMILY_SIGNAL_SOURCE, ROUTE_FAMILY_PROJECT_CREATE, ROUTE_FAMILY_TASK}: resolved_target_route = WorkflowRoute( family=target_family, - action=target_action or (ROUTE_ACTION_ONBOARD if target_family == ROUTE_FAMILY_SIGNAL_SOURCE else ROUTE_ACTION_CREATE), + action=target_action + or ( + ROUTE_ACTION_ONBOARD + if target_family == ROUTE_FAMILY_SIGNAL_SOURCE + else ROUTE_ACTION_CREATE + ), project_id=_safe_int(target_work_item.project_id), requested_count=1, requires_confirmation=True, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py index a78fa435..869f1faa 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/followup/ai_pmo_kernel_mixins_followup_verification.py @@ -36,6 +36,8 @@ class KernelFollowupVerificationMixin: return {"verified": bool(str(execution_result.get("answer") or "").strip()), "issues": []} if intent.name == INTENT_PROJECT_CREATE: return {"verified": _safe_int(execution_result.get("project_id")) is not None, "issues": []} + if intent.name == INTENT_TASK_CREATE: + return {"verified": _safe_int(execution_result.get("task_id")) is not None, "issues": []} if intent.name in {INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) signal_id = _safe_int(execution_result.get("signal_id")) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py index 5dbc9782..aba7f514 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_render.py @@ -57,6 +57,7 @@ class KernelIngressRenderMixin: "signal_id": primary_signal_id, "signal_ids": signal_ids, "created_project_id": _safe_int(execution_result.get("project_id")) if intent.name == INTENT_PROJECT_CREATE else None, + "created_task_id": _safe_int(execution_result.get("task_id")) if intent.name == INTENT_TASK_CREATE else None, "auto_tasks": execution_result.get("auto_tasks") if isinstance(execution_result.get("auto_tasks"), list) else execution_result.get("tasks", []), "ai_trace_id": f"ai-pmo-{int(work_item.id)}", "results": [ @@ -64,7 +65,11 @@ class KernelIngressRenderMixin: "work_item_id": int(work_item.id), "state": work_item.state, "intent": intent.name, - "route_status": "queued_for_decision" if work_item.state == STATE_WAITING_MANUAL else "auto_done", + "route_status": ( + "queued_for_decision" + if work_item.state in {STATE_WAITING_MANUAL, STATE_DRAFT_PENDING_CONFIRM} + else "auto_done" + ), } ], "intent": intent.name, @@ -81,6 +86,12 @@ class KernelIngressRenderMixin: else ((intent.metadata or {}).get("decision_trace") if isinstance(intent.metadata, dict) else None) ), } + if str(render.get("template_key") or "").strip().lower() == "draft_pending_confirm": + payload = render.get("payload") if isinstance(render.get("payload"), dict) else {} + result["draft_version"] = _safe_int(payload.get("draft_version")) or _safe_int(getattr(work_item, "draft_version", None)) + draft_expires_at = payload.get("draft_expires_at") + if isinstance(draft_expires_at, str) and draft_expires_at.strip(): + result["draft_expires_at"] = draft_expires_at.strip() if intent.name == INTENT_QUERY and isinstance(execution_result.get("rag_result"), dict): result["rag_result"] = execution_result["rag_result"] if intent.name == INTENT_PROJECT_CREATE: @@ -99,6 +110,8 @@ class KernelIngressRenderMixin: return "query.answer" if normalized_route == ROUTE_PROJECT_CREATE: return "project.create" + if normalized_route == ROUTE_TASK_CREATE: + return "task.create" if normalized_route in {ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_COMMIT}: if normalized_route == ROUTE_PROJECT_SIGNAL_DISCOVER: return ROUTE_PROJECT_SIGNAL_DISCOVER @@ -117,6 +130,8 @@ class KernelIngressRenderMixin: return "query.answer" if intent_name == INTENT_PROJECT_CREATE: return "project.create" + if intent_name == INTENT_TASK_CREATE: + return "task.create" if intent_name == INTENT_SIGNAL_CREATE: return "signal.enter" if intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: @@ -129,7 +144,7 @@ class KernelIngressRenderMixin: def _risk_level_for_intent(intent_name: str) -> str: if intent_name in {INTENT_PROJECT_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: return "high" - if intent_name in {INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: + if intent_name in {INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: return "medium" return "low" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py index 2e2cc63d..c8580c4d 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/ingress/ai_pmo_kernel_mixins_ingress_storage.py @@ -10,6 +10,254 @@ for _name, _value in vars(_common).items(): class KernelIngressStorageMixin: + @staticmethod + def _normalize_execution_ledger_status( + *, + work_item_state: Optional[str], + execution_status: Optional[str], + ) -> str: + state = str(work_item_state or "").strip().lower() + status = str(execution_status or "").strip().lower() + if state == STATE_SUCCEEDED: + return "applied" + if state == STATE_DRAFT_PENDING_CONFIRM: + return "pending_confirmation" + if state == STATE_WAITING_MANUAL: + return "pending_manual" + if state in {STATE_FAILED, STATE_IGNORED}: + return "failed" + if status in {"succeeded", "applied", "answered"}: + return "applied" + if status in {"pending_manual", "pending_confirmation"}: + return status + if status in {"failed", "failed_safe", "error"}: + return "failed" + return "processing" + + @staticmethod + def _build_execution_ledger_idempotency_key( + *, + tenant_id: int, + work_item_id: int, + operation_code: str, + source_message_id: str, + ) -> str: + raw = ( + f"{int(tenant_id)}:" + f"{int(work_item_id)}:" + f"{str(operation_code or '').strip().lower()}:" + f"{str(source_message_id or '').strip().lower()}" + ) + return hashlib.sha1(raw.encode("utf-8"), usedforsecurity=False).hexdigest()[:64] + + @staticmethod + def _resolve_execution_ledger_entity( + *, + operation_code: str, + execution_result: dict[str, Any], + ) -> tuple[Optional[str], Optional[str]]: + op = str(operation_code or "").strip().lower() + if op == "project.create": + entity_id = _safe_int(execution_result.get("project_id")) + return ("project", str(entity_id)) if entity_id is not None else ("project", None) + if op == "task.create": + entity_id = _safe_int(execution_result.get("task_id")) + return ("task", str(entity_id)) if entity_id is not None else ("task", None) + if op in {"signal.enter", ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_CREATE}: + entity_id = _safe_int(execution_result.get("signal_id")) + return ("signal", str(entity_id)) if entity_id is not None else ("signal", None) + if op in {"signal_source.onboard", ROUTE_SIGNAL_SOURCE_ONBOARD}: + entity_id = _safe_int(execution_result.get("signal_id")) + return ("signal_source", str(entity_id)) if entity_id is not None else ("signal_source", None) + if op == "work_item.resume": + entity_id = _safe_int(execution_result.get("target_work_item_id")) + return ("work_item", str(entity_id)) if entity_id is not None else ("work_item", None) + return (None, None) + + def _build_execution_action_contract( + self, + *, + intent: CanonicalIntent, + workflow_route: WorkflowRoute, + decision_payload: dict[str, Any], + execution_result: dict[str, Any], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + ) -> dict[str, Any]: + execution_plan = decision_payload.get("execution_plan") if isinstance(decision_payload.get("execution_plan"), dict) else {} + actions = execution_plan.get("actions") if isinstance(execution_plan.get("actions"), list) else [] + route_key = str(getattr(workflow_route, "key", "") or "").strip().lower() + operation_code = str( + self._primary_operation_code(route_key or str(intent.name or "")) + or "" + ).strip().lower() + selected_action: dict[str, Any] = {} + for action in actions: + if not isinstance(action, dict): + continue + current_code = str(action.get("operation_code") or "").strip().lower() + if current_code and current_code == operation_code: + selected_action = action + break + if not selected_action: + selected_action = actions[0] if actions and isinstance(actions[0], dict) else {} + if not operation_code: + operation_code = str(selected_action.get("operation_code") or "").strip().lower() + mode = str(selected_action.get("mode") or "").strip().lower() + if not mode: + mode = "read_only" if str(intent.name or "") == INTENT_QUERY else "write" + + signal_ids = _normalize_signal_id_list(execution_result.get("signal_ids")) + if not signal_ids and _safe_int(execution_result.get("signal_id")) is not None: + signal_ids = [_safe_int(execution_result.get("signal_id"))] + params: dict[str, Any] = { + "project_id": ( + _safe_int(execution_result.get("project_id")) + or _safe_int(getattr(workflow_route, "project_id", None)) + or _safe_int(execution_plan.get("project_id")) + or _safe_int(intent.project_id) + ), + "requested_count": ( + _safe_int(execution_result.get("requested_count")) + or _safe_int(getattr(workflow_route, "requested_count", None)) + ), + "signal_id": _safe_int(execution_result.get("signal_id")), + "signal_ids": signal_ids, + "task_id": _safe_int(execution_result.get("task_id")), + "target_work_item_id": _safe_int(execution_result.get("target_work_item_id")), + "source_system": str(source_system or "").strip() or None, + "source_message_id": str(source_message_id or "").strip() or None, + "channel_ref": str(channel_ref or "").strip() or None, + } + normalized_params = { + key: value + for key, value in params.items() + if value not in (None, "", []) + } + return { + "operation_code": operation_code or None, + "mode": mode, + "workflow_route": route_key or None, + "workflow_action": str(getattr(workflow_route, "action", "") or "").strip().lower() or None, + "param_source": "decision.execution_plan.actions+workflow_route+execution_result", + "params": normalized_params, + } + + def _apply_execution_action_contract( + self, + *, + intent: CanonicalIntent, + workflow_route: WorkflowRoute, + decision_payload: dict[str, Any], + execution_result: dict[str, Any], + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + ) -> dict[str, Any]: + normalized_result = dict(execution_result or {}) + action_contract = self._build_execution_action_contract( + intent=intent, + workflow_route=workflow_route, + decision_payload=decision_payload, + execution_result=normalized_result, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + normalized_result["action_contract"] = action_contract + normalized_result["action_param_source"] = action_contract["param_source"] + operation_code = str(action_contract.get("operation_code") or "").strip().lower() + if operation_code and not str(normalized_result.get("operation_code") or "").strip(): + normalized_result["operation_code"] = operation_code + return normalized_result + + async def _upsert_operation_execution_ledger( + self, + *, + work_item_id: int, + operation_code: Optional[str], + source_message_id: str, + project_id: Optional[int], + status: str, + payload: dict[str, Any], + execution_result: dict[str, Any], + error_message: Optional[str] = None, + ) -> Optional[OperationExecutionLedger]: + normalized_code = str(operation_code or "").strip().lower() + if not normalized_code: + return None + idempotency_key = self._build_execution_ledger_idempotency_key( + tenant_id=int(self.tenant_id), + work_item_id=int(work_item_id), + operation_code=normalized_code, + source_message_id=source_message_id, + ) + ledger = ( + await self.db.execute( + select(OperationExecutionLedger).where( + and_( + OperationExecutionLedger.tenant_id == int(self.tenant_id), + OperationExecutionLedger.operation_code == normalized_code, + OperationExecutionLedger.idempotency_key == idempotency_key, + ) + ).limit(1) + ) + ).scalar_one_or_none() + entity_type, entity_id = self._resolve_execution_ledger_entity( + operation_code=normalized_code, + execution_result=execution_result if isinstance(execution_result, dict) else {}, + ) + normalized_payload = payload if isinstance(payload, dict) else {"payload": payload} + normalized_result = ( + execution_result + if isinstance(execution_result, dict) + else {"result": execution_result} + ) + normalized_status = self._normalize_execution_ledger_status( + work_item_state=None, + execution_status=status, + ) + if ledger is None: + ledger = OperationExecutionLedger( + tenant_id=int(self.tenant_id), + operation_code=normalized_code, + project_id=_safe_int(project_id), + idempotency_key=idempotency_key, + trace_id=f"work_item:{int(work_item_id)}", + status=normalized_status, + payload=normalized_payload, + result_payload=normalized_result, + entity_type=entity_type, + entity_id=entity_id, + error_message=(str(error_message or "").strip() or None), + ) + self.db.add(ledger) + else: + ledger.project_id = _safe_int(project_id) + ledger.status = normalized_status + ledger.payload = normalized_payload + ledger.result_payload = normalized_result + ledger.entity_type = entity_type + ledger.entity_id = entity_id + ledger.error_message = (str(error_message or "").strip() or None) + await self.db.flush() + return ledger + + @staticmethod + def _extract_execution_operation_code(execution_result: dict[str, Any]) -> Optional[str]: + action_contract = ( + execution_result.get("action_contract") + if isinstance(execution_result.get("action_contract"), dict) + else {} + ) + operation_code = str( + action_contract.get("operation_code") + or execution_result.get("operation_code") + or "" + ).strip().lower() + return operation_code or None + async def _claim_ingress_event( self, *, @@ -204,4 +452,3 @@ class KernelIngressStorageMixin: ) ) await self.db.flush() - diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py index 583fe361..dbdd1e29 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_llm.py @@ -30,6 +30,7 @@ class KernelIntentLLMMixin: [ ROUTE_QUERY_ANSWER, ROUTE_PROJECT_CREATE, + ROUTE_TASK_CREATE, ROUTE_PROJECT_SIGNAL_CREATE, ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_SIGNAL_SOURCE_ONBOARD, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py index 0fd48b78..404c2903 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_resolve.py @@ -16,15 +16,20 @@ class KernelIntentResolveMixin: message_text: str, session: Optional[ConversationSession], world_model: dict[str, Any], + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[dict[str, Any]] = None, ) -> tuple[CanonicalIntent, str]: if self._is_short_confirmation_message( message_text=message_text, session=session, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) and session is not None: inherited = self._build_short_confirm_intent( message_text=message_text, session=session, world_model=world_model, + session_snapshot=session_snapshot, ) logger.info( "ai_pmo_short_confirm_inherited", diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py index 36d430db..e202035a 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py @@ -169,15 +169,30 @@ class KernelIntentRulesMixin: @staticmethod def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]: - if intent.name != INTENT_PROJECT_CREATE: - return True, {} - extracted_title = AIStewardService._extract_project_title_from_instruction(message_text) - has_title = bool(str(extracted_title or "").strip()) - return has_title, { - "slot_check": "project_title", - "slot_ok": has_title, - "extracted_project_title": extracted_title[:50] if extracted_title else "", - } + if intent.name == INTENT_PROJECT_CREATE: + extracted_title = AIStewardService._extract_project_title_from_instruction(message_text) + has_title = bool(str(extracted_title or "").strip()) + return has_title, { + "slot_check": "project_title", + "slot_ok": has_title, + "extracted_project_title": extracted_title[:50] if extracted_title else "", + } + if intent.name == INTENT_TASK_CREATE: + extracted_task_title = IntentResolver._extract_task_title(message_text) + has_task_title = bool(str(extracted_task_title or "").strip()) + has_project_scope = _safe_int(intent.project_id) is not None + missing_fields: list[str] = [] + if not has_project_scope: + missing_fields.append("project_id") + if not has_task_title: + missing_fields.append("task_title") + return not missing_fields, { + "slot_check": "task_create_required_fields", + "slot_ok": not missing_fields, + "missing_fields": missing_fields, + "extracted_task_title": extracted_task_title[:80] if extracted_task_title else "", + } + return True, {} def _apply_slot_guard(self, *, intent: CanonicalIntent, message_text: str) -> CanonicalIntent: slot_ok, slot_meta = self._has_required_slots(intent=intent, message_text=message_text) @@ -259,15 +274,22 @@ class KernelIntentRulesMixin: *, message_text: str, session: Optional[ConversationSession], + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[dict[str, Any]] = None, ) -> bool: - if session is None: + snapshot = session_snapshot if isinstance(session_snapshot, dict) else {} + if session is None and not snapshot: return False - if not cls._within_recent_window( - getattr(session, "last_active_at", None), - window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS, - ): + last_active_at = cls._coerce_naive_datetime(snapshot.get("last_active_at")) + if last_active_at is None and session is not None: + last_active_at = cls._coerce_naive_datetime(getattr(session, "last_active_at", None)) + sent_at = cls._coerce_naive_datetime(message_sent_at) or _utc_now_naive() + if last_active_at is None: return False - last_intent = str(getattr(session, "last_intent", "") or "").strip() + delta = sent_at - last_active_at + if not (0 <= delta.total_seconds() <= float(SESSION_SHORT_CONFIRM_WINDOW_SECONDS)): + return False + last_intent = str(snapshot.get("last_intent") or (getattr(session, "last_intent", "") if session is not None else "") or "").strip() if not last_intent: return False normalized = re.sub(r"\s+", "", str(message_text or "")).lower() @@ -277,6 +299,23 @@ class KernelIntentRulesMixin: return False return any(token in normalized for token in SHORT_CONFIRM_HINTS) + @staticmethod + def _coerce_naive_datetime(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + if value.tzinfo is None: + return value + return value.astimezone().replace(tzinfo=None) + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except Exception: + return None + if parsed.tzinfo is None: + return parsed + return parsed.astimezone().replace(tzinfo=None) + @staticmethod def _build_canonical_intent( *, @@ -317,6 +356,19 @@ class KernelIntentRulesMixin: pretty[:300], extras, ) + if intent_name == INTENT_TASK_CREATE: + task_title = str(extras.get("task_title") or IntentResolver._extract_task_title(pretty) or "").strip() + if not task_title: + task_title = IntentResolver._build_signal_title(pretty, default="新任务") + extras.setdefault("task_title", task_title[:120]) + return CanonicalIntent( + INTENT_TASK_CREATE, + safe_confidence, + project_id, + task_title[:120], + pretty[:300], + extras, + ) if intent_name == INTENT_QUERY: return CanonicalIntent(INTENT_QUERY, safe_confidence, project_id, "", pretty[:300], extras) if intent_name == INTENT_FOLLOWUP_CONFIRM: @@ -331,14 +383,25 @@ class KernelIntentRulesMixin: message_text: str, session: ConversationSession, world_model: dict[str, Any], + session_snapshot: Optional[dict[str, Any]] = None, ) -> CanonicalIntent: - inherited_name = str(getattr(session, "last_intent", "") or "").strip() + snapshot = session_snapshot if isinstance(session_snapshot, dict) else {} + inherited_name = str(snapshot.get("last_intent") or getattr(session, "last_intent", "") or "").strip() if inherited_name not in INTENT_CANDIDATES: inherited_name = INTENT_QUERY - inherited_project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id")) + snapshot_project_id = _safe_int(snapshot.get("current_project_id")) + inherited_project_id = ( + snapshot_project_id + or _safe_int(getattr(session, "current_project_id", None)) + or _safe_int(world_model.get("resolved_project_id")) + ) if inherited_project_id is not None and int(inherited_project_id) <= 0: inherited_project_id = None - inherited_text = str(getattr(session, "last_message_text", "") or "").strip() or message_text + inherited_text = ( + str(snapshot.get("last_message_text") or "").strip() + or str(getattr(session, "last_message_text", "") or "").strip() + or message_text + ) return self._build_canonical_intent( intent_name=inherited_name, message_text=inherited_text, @@ -346,6 +409,6 @@ class KernelIntentRulesMixin: confidence=0.86, metadata={ "context_source": "session_inherited", - "inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(), + "inherited_from_intent": str(snapshot.get("last_intent") or getattr(session, "last_intent", "") or "").strip(), }, ) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py index b891b50e..73158e7c 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/constants.py @@ -10,9 +10,11 @@ STATE_DECIDED = "decided" STATE_PLANNED = "planned" STATE_EXECUTING = "executing" STATE_WAITING_MANUAL = "waiting_manual" +STATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm" STATE_SUCCEEDED = "succeeded" STATE_FAILED = "failed" STATE_IGNORED = "ignored" +STATE_CANCELLED = "cancelled" RECEIPT_PHASE_ACTION = "action_applied" RECEIPT_PHASE_PENDING = "pending_manual" diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py index fa2bc2cf..4550cb8a 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/imports.py @@ -83,6 +83,7 @@ from ..ai_pmo_intent import ( INTENT_LLM_FUNCTION_NAME, INTENT_PROJECT_CREATE, INTENT_QUERY, + INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD, LEGACY_INTENT_ROUTE_MAP, @@ -100,6 +101,7 @@ from ..ai_pmo_intent import ( ROUTE_FAMILY_PROJECT_SIGNAL, ROUTE_FAMILY_QUERY, ROUTE_FAMILY_SIGNAL_SOURCE, + ROUTE_FAMILY_TASK, ROUTE_FOLLOWUP_CONFIRM, ROUTE_FOLLOWUP_CONTINUE, ROUTE_PROJECT_CREATE, @@ -110,6 +112,7 @@ from ..ai_pmo_intent import ( ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_QUERY_ANSWER, ROUTE_SIGNAL_SOURCE_ONBOARD, + ROUTE_TASK_CREATE, SESSION_SHORT_CONFIRM_WINDOW_SECONDS, SHORT_CONFIRM_HINTS, SHORT_CONFIRM_MAX_MESSAGE_LENGTH, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py index 4a1c5380..32261c4e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_auto.py @@ -1,6 +1,7 @@ from __future__ import annotations from .shared import * +from ..ai_pmo_kernel_common_core.helpers import _safe_int as _safe_int class KernelIngressFlowAutoMixin: async def _execute_auto_ingress_flow( @@ -29,6 +30,153 @@ class KernelIngressFlowAutoMixin: work_item.state = STATE_PLANNED work_item.action_bundle = decision_payload["execution_plan"] work_item.state = STATE_EXECUTING + if self._should_enter_draft_confirmation(intent=intent, workflow_route=workflow_route): + from ...signal.draft_generator_service import DraftGeneratorService + + stage_started_at = time.perf_counter() + generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id) + generated = await generator.generate_and_persist( + work_item=work_item, + intent_name=intent.name, + message_text=message_text, + world_model=world_model, + ) + log_perf_fn("DraftGenerator.generate", stage_started_at) + + execution_result = work_item.execution_result if isinstance(work_item.execution_result, dict) else {} + execution_result.setdefault("decision_trace", decision_trace) + execution_result.setdefault("workflow_route", workflow_route.key) + execution_result.setdefault("workflow_action", workflow_route.action) + execution_result = self._apply_execution_action_contract( + intent=intent, + workflow_route=workflow_route, + decision_payload=decision_payload, + execution_result=execution_result, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) + work_item.execution_result = execution_result + work_item.state = STATE_DRAFT_PENDING_CONFIRM + work_item.reply_template_key = "draft_pending_confirm" + + verification = {"verified": False, "issues": [STATE_DRAFT_PENDING_CONFIRM]} + work_item.review_summary = await self.review_service.review_work_item( + work_item=work_item, + intent_name=intent.name, + execution_result=execution_result, + world_model=world_model, + verification=verification, + simulation=decision_payload["simulation"], + ) + await self._maybe_emit_risk_cadence_snapshot( + project_id=_safe_int(work_item.project_id), + review_summary=work_item.review_summary, + ) + + decision_row.execution_result = execution_result + decision_row.final_outcome = STATE_DRAFT_PENDING_CONFIRM + operation_code_for_log = ( + self._extract_execution_operation_code(execution_result) + or self._primary_operation_code(str(work_item.intent or intent.name)) + ) + await self._upsert_operation_execution_ledger( + work_item_id=int(work_item.id), + operation_code=operation_code_for_log, + source_message_id=source_message_id, + project_id=_safe_int(work_item.project_id), + status=self._normalize_execution_ledger_status( + work_item_state=STATE_DRAFT_PENDING_CONFIRM, + execution_status=str(execution_result.get("status") or ""), + ), + payload={ + "work_item_id": int(work_item.id), + "workflow_route": workflow_route.key, + "workflow_action": workflow_route.action, + "source_system": source_system, + "source_message_id": source_message_id, + "action_contract": execution_result.get("action_contract"), + }, + execution_result={ + **execution_result, + "work_item_state": STATE_DRAFT_PENDING_CONFIRM, + }, + ) + + draft_text = str(generated.get("draft_text") or "").strip() + render = { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "draft_pending_confirm", + "payload": { + "intent": intent.name, + "draft_version": int(generated.get("draft_version") or 0), + "draft_expires_at": ( + generated["draft_expires_at"].isoformat() + if isinstance(generated.get("draft_expires_at"), datetime) + else None + ), + }, + "text": draft_text or "已生成草稿,请确认后入库。", + } + await self._project_draft_pending_confirm( + work_item=work_item, + message_text=message_text, + draft_text=str(render["text"]), + intent=intent, + ) + + direct_reply_required = await self._claim_reply_receipt( + work_item_id=int(work_item.id), + source_system=source_system, + source_message_id=source_message_id, + receipt_phase=str(render["receipt_phase"]), + template_key=str(render["template_key"]), + payload=render["payload"], + ) + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=source_message_id, + work_item=work_item, + message_text=message_text, + route=f"ai_pmo.{STATE_DRAFT_PENDING_CONFIRM}", + operation_code=operation_code_for_log, + operation_result=execution_result, + processing_status="success", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + final_project_id = _safe_int(work_item.project_id) or resolved_project_for_session + await self._update_session( + tenant_id=self.tenant_id, + sender=sender, + current_project_id=final_project_id, + last_intent=STATE_DRAFT_PENDING_CONFIRM, + last_message_text=message_text[:200], + ) + try: + await self.db.commit() + except Exception as exc: + await self._persist_kernel_failure_with_fresh_session( + work_item_id=_safe_int(getattr(work_item, "id", None)), + ingest_event_id=_safe_int(getattr(ingest_event, "id", None)), + error_message=str(exc), + source_system=source_system, + source_message_id=source_message_id, + ) + raise + stage_started_at = time.perf_counter() + capture_result = self._build_capture_result( + work_item=work_item, + intent=intent, + execution_result=execution_result, + render=render, + direct_reply_required=direct_reply_required, + status="captured", + classification="command", + ) + log_perf_fn("_build_capture_result", stage_started_at) + return capture_result + if intent.name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: stage_started_at = time.perf_counter() execution_result, verification = await self._execute_followup_intent( @@ -67,6 +215,15 @@ class KernelIngressFlowAutoMixin: resolved_route = str(execution_result.get("resolved_route") or "").strip().lower() if resolved_route: work_item.intent = self.route_resolver.normalize_route_key(resolved_route) + execution_result = self._apply_execution_action_contract( + intent=intent, + workflow_route=workflow_route, + decision_payload=decision_payload, + execution_result=execution_result, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + ) work_item.execution_result = execution_result if _safe_int(execution_result.get("project_id")) is not None: work_item.project_id = _safe_int(execution_result.get("project_id")) @@ -97,6 +254,37 @@ class KernelIngressFlowAutoMixin: else: work_item.state = STATE_FAILED decision_row.final_outcome = work_item.state + operation_code_for_log = ( + self._extract_execution_operation_code(execution_result) + or self._primary_operation_code(str(work_item.intent or intent.name)) + ) + await self._upsert_operation_execution_ledger( + work_item_id=int(work_item.id), + operation_code=operation_code_for_log, + source_message_id=source_message_id, + project_id=_safe_int(work_item.project_id), + status=self._normalize_execution_ledger_status( + work_item_state=str(work_item.state or ""), + execution_status=str(execution_result.get("status") or ""), + ), + payload={ + "work_item_id": int(work_item.id), + "workflow_route": workflow_route.key, + "workflow_action": workflow_route.action, + "source_system": source_system, + "source_message_id": source_message_id, + "action_contract": execution_result.get("action_contract"), + }, + execution_result={ + **execution_result, + "work_item_state": str(work_item.state or ""), + }, + error_message=( + str(execution_result.get("error_message") or "").strip() + if str(work_item.state or "") == STATE_FAILED + else None + ), + ) render = self.reply_renderer.render( work_state=work_item.state, @@ -133,7 +321,7 @@ class KernelIngressFlowAutoMixin: work_item=work_item, message_text=message_text, route=f"ai_pmo.{work_item.intent or intent.name}", - operation_code=self._primary_operation_code(str(work_item.intent or intent.name)), + operation_code=operation_code_for_log, operation_result=execution_result, processing_status="success" if work_item.state in {STATE_SUCCEEDED, STATE_WAITING_MANUAL} else "failed", duration_ms=int((time.perf_counter() - started) * 1000), @@ -206,6 +394,14 @@ class KernelIngressFlowAutoMixin: log_perf_fn("_build_capture_result", stage_started_at) return capture_result + @staticmethod + def _should_enter_draft_confirmation(*, intent: CanonicalIntent, workflow_route: Any) -> bool: + if intent.name not in {INTENT_PROJECT_CREATE, INTENT_TASK_CREATE, INTENT_SIGNAL_CREATE, INTENT_SIGNAL_SOURCE_ONBOARD}: + return False + if str(getattr(workflow_route, "family", "") or "") == ROUTE_FAMILY_PROJECT_SIGNAL: + return str(getattr(workflow_route, "action", "") or "") == ROUTE_ACTION_CREATE + return True + @staticmethod def _build_capacity_warning_text( *, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py index 6977bef0..5151df2e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_entry.py @@ -96,6 +96,8 @@ class KernelIngressFlowEntryMixin: source_sender_open_id: Optional[str] = None, captured_at: Optional[datetime] = None, chat_type: Optional[str] = None, + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: return await self.process_ingress( source_system="feishu", @@ -106,6 +108,8 @@ class KernelIngressFlowEntryMixin: source_sender_open_id=source_sender_open_id, captured_at=captured_at, chat_type=chat_type, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) async def process_ingress( @@ -120,6 +124,9 @@ class KernelIngressFlowEntryMixin: captured_at: Optional[datetime] = None, chat_type: Optional[str] = None, project_hint_id: Optional[int] = None, + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[dict[str, Any]] = None, + ingress_options: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: started = time.perf_counter() perf_work_item_id: Optional[int] = None @@ -153,10 +160,27 @@ class KernelIngressFlowEntryMixin: operator = ingress_prepare["operator"] work_item = ingress_prepare["work_item"] session = ingress_prepare["session"] + if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: + draft_capture = await self._maybe_handle_draft_confirmation_ingress( + work_item=work_item, + ingest_event=ingest_event, + session=session, + synthetic_message_id=synthetic_message_id, + source_system=source_system, + source_sender_open_id=source_sender_open_id, + sender=sender, + channel_ref=channel_ref, + message_text=clean_text, + started=started, + ) + if draft_capture is not None: + return draft_capture mixed_meta: dict[str, Any] = {} if not self._is_short_confirmation_message( message_text=clean_text, session=session, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ): clean_text, mixed_meta = await self._prepare_mixed_sidecar_context( source_system=source_system, @@ -182,6 +206,8 @@ class KernelIngressFlowEntryMixin: message_text=clean_text, session=session, world_model=world_model, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) intent = self._enrich_signal_source_intent_from_closure( intent=intent, @@ -193,6 +219,16 @@ class KernelIngressFlowEntryMixin: **(intent.metadata or {}), **mixed_meta, } + rag_request_context = ( + ingress_options.get("rag_request_context") + if isinstance(ingress_options, dict) + else None + ) + if isinstance(rag_request_context, dict): + intent.metadata = { + **(intent.metadata or {}), + "rag_request_context": dict(rag_request_context), + } workflow_route = self.route_resolver.from_canonical( canonical_intent=intent, message_text=clean_text, @@ -365,6 +401,153 @@ class KernelIngressFlowEntryMixin: ) return {"handled": False, "reason": "kernel_exception", "error": str(exc)} + async def _maybe_handle_draft_confirmation_ingress( + self, + *, + work_item: WorkItem, + ingest_event: IngestEvent, + session: ConversationSession, + synthetic_message_id: str, + source_system: str, + source_sender_open_id: Optional[str], + sender: str, + channel_ref: Optional[str], + message_text: str, + started: float, + ) -> Optional[dict[str, Any]]: + from ..draft_confirm_handler import DraftConfirmHandler + + handler = DraftConfirmHandler(self) + decision = await handler.handle_text_reply( + message_text=message_text, + sender=sender, + source_system=source_system, + source_message_id=synthetic_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + expected_version=None, + ) + if not bool(decision.get("handled")): + return None + + result_status = str(decision.get("status") or "").strip().lower() + if result_status in {"succeeded", "draft_pending_confirm", "pending_manual", "cancelled", "ignored"}: + work_item.state = STATE_SUCCEEDED + else: + work_item.state = STATE_FAILED + work_item.intent = ROUTE_FOLLOWUP_CONFIRM + work_item.project_id = _safe_int(decision.get("project_id")) + work_item.signal_id = _safe_int(decision.get("signal_id")) + execution_result = { + "status": result_status or "failed_safe", + "followup_reply": str(decision.get("followup_reply") or "").strip(), + "target_work_item_id": _safe_int(decision.get("target_work_item_id")), + "target_state": str(decision.get("target_state") or "").strip() or None, + "target_intent": str(decision.get("target_intent") or "").strip() or None, + "project_id": _safe_int(decision.get("project_id")), + "signal_id": _safe_int(decision.get("signal_id")), + "workflow_route": str(decision.get("workflow_route") or ROUTE_FOLLOWUP_CONFIRM).strip(), + } + work_item.execution_result = execution_result + work_item.review_summary = { + "verified": work_item.state == STATE_SUCCEEDED, + "issues": [] if work_item.state == STATE_SUCCEEDED else ["draft_confirm_failed"], + "next_step": "已处理草稿确认请求" if work_item.state == STATE_SUCCEEDED else "请检查草稿确认状态", + } + followup_text = str(decision.get("followup_reply") or "").strip() or "草稿状态已更新。" + if result_status == "draft_pending_confirm": + render = { + "receipt_phase": RECEIPT_PHASE_PENDING, + "template_key": "draft_pending_confirm", + "payload": {"status": result_status}, + "text": followup_text, + } + elif result_status == "cancelled": + render = { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_rejected", + "payload": {"status": result_status}, + "text": followup_text, + } + elif work_item.state == STATE_SUCCEEDED: + render = { + "receipt_phase": RECEIPT_PHASE_ACTION, + "template_key": "action_succeeded", + "payload": {"status": result_status}, + "text": followup_text, + } + else: + render = { + "receipt_phase": RECEIPT_PHASE_ERROR, + "template_key": "safe_error", + "payload": {"status": result_status}, + "text": followup_text, + } + work_item.reply_template_key = str(render["template_key"]) + direct_reply_required = await self._claim_reply_receipt( + work_item_id=int(work_item.id), + source_system=source_system, + source_message_id=synthetic_message_id, + receipt_phase=str(render["receipt_phase"]), + template_key=str(render["template_key"]), + payload=render["payload"], + ) + ingest_event.status = "done" + ingest_event.processed_at = _utc_now_naive() + await self._write_processing_log( + source_message_id=synthetic_message_id, + work_item=work_item, + message_text=message_text, + route="ai_pmo.draft_confirm", + operation_code="draft.confirm", + operation_result=execution_result, + processing_status="success" if work_item.state == STATE_SUCCEEDED else "failed", + duration_ms=int((time.perf_counter() - started) * 1000), + ) + + next_session_intent = ( + STATE_DRAFT_PENDING_CONFIRM + if result_status in {"draft_pending_confirm", "pending_manual"} + else str(decision.get("target_intent") or INTENT_FOLLOWUP_CONFIRM).strip() + ) + await self._update_session( + tenant_id=self.tenant_id, + sender=sender, + current_project_id=_safe_int(decision.get("project_id")), + last_intent=next_session_intent, + last_message_text=message_text[:200], + ) + + try: + await self.db.commit() + except Exception as exc: + await self._persist_kernel_failure_with_fresh_session( + work_item_id=_safe_int(getattr(work_item, "id", None)), + ingest_event_id=_safe_int(getattr(ingest_event, "id", None)), + error_message=str(exc), + source_system=source_system, + source_message_id=synthetic_message_id, + ) + raise + + capture_intent = CanonicalIntent( + name=INTENT_FOLLOWUP_CONFIRM, + confidence=0.95, + project_id=_safe_int(decision.get("project_id")), + signal_title="", + signal_summary="", + metadata={"workflow_route": ROUTE_FOLLOWUP_CONFIRM}, + ) + return self._build_capture_result( + work_item=work_item, + intent=capture_intent, + execution_result=execution_result, + render=render, + direct_reply_required=direct_reply_required, + status="answered" if work_item.state == STATE_SUCCEEDED else "captured", + classification="command", + ) + async def _persist_kernel_failure_with_fresh_session( self, *, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py index 9346b05f..c13065ed 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_ingress_flow/mixin_prepare_manual.py @@ -184,6 +184,15 @@ class KernelIngressFlowPrepareManualMixin: "workflow_action": workflow_route.action, "decision_trace": decision_trace, } + work_item.execution_result = self._apply_execution_action_contract( + intent=intent, + workflow_route=workflow_route, + decision_payload=decision_payload, + execution_result=work_item.execution_result, + source_system=source_system, + source_message_id=source_message_id, + channel_ref=None, + ) manual_issues = ["waiting_manual"] policy_gate_code = str(policy.get("policy_gate_code") or "").strip() if policy_gate_code: @@ -213,6 +222,32 @@ class KernelIngressFlowPrepareManualMixin: project_id=_safe_int(work_item.project_id), review_summary=work_item.review_summary, ) + operation_code_for_log = ( + self._extract_execution_operation_code(work_item.execution_result) + or self._primary_operation_code(str(work_item.intent or intent.name)) + ) + await self._upsert_operation_execution_ledger( + work_item_id=int(work_item.id), + operation_code=operation_code_for_log, + source_message_id=source_message_id, + project_id=_safe_int(work_item.project_id), + status=self._normalize_execution_ledger_status( + work_item_state=STATE_WAITING_MANUAL, + execution_status=str((work_item.execution_result or {}).get("status") or ""), + ), + payload={ + "work_item_id": int(work_item.id), + "workflow_route": workflow_route.key, + "workflow_action": workflow_route.action, + "source_system": source_system, + "source_message_id": source_message_id, + "action_contract": (work_item.execution_result or {}).get("action_contract"), + }, + execution_result={ + **(work_item.execution_result or {}), + "work_item_state": STATE_WAITING_MANUAL, + }, + ) direct_reply_required = await self._claim_reply_receipt( work_item_id=int(work_item.id), source_system=source_system, @@ -228,7 +263,7 @@ class KernelIngressFlowPrepareManualMixin: work_item=work_item, message_text=message_text, route="ai_pmo.waiting_manual", - operation_code=None, + operation_code=operation_code_for_log, operation_result=work_item.execution_result, processing_status="success", duration_ms=int((time.perf_counter() - started) * 1000), diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py index c9db5e67..2cd19dbb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/mixin_execute_query_project.py @@ -3,11 +3,15 @@ from __future__ import annotations from .shared import * # noqa: F401, F403 +from .shared import _safe_int from ...adaptive_rag_service import AdaptiveRAGService +from ...context_cache_service import context_cache_service +from ...sstc_service_core import ALIGNED_STATUSES +from ...task_persistence_core import build_task_create_draft, persist_task class ExecuteQueryProjectMixin: - """Handle query and project-create intents.""" + """Handle query/project/task create intents.""" async def _execute_query_intent( self, @@ -19,17 +23,58 @@ class ExecuteQueryProjectMixin: source_message_id: str, ) -> dict[str, Any]: rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + rag_request_context = ( + metadata.get("rag_request_context") + if isinstance(metadata.get("rag_request_context"), dict) + else None + ) + if isinstance(rag_request_context, dict): + asked_by = _safe_int(rag_request_context.get("asked_by")) + asked_via = str(rag_request_context.get("asked_via") or source_system).strip() or source_system + requested_project_id = _safe_int(rag_request_context.get("project_id")) + source_chat_id = str(rag_request_context.get("source_chat_id") or "").strip() or None + conversation_key = str(rag_request_context.get("conversation_key") or "").strip() or None + session_id = _safe_int(rag_request_context.get("session_id")) + force_new_session = bool(rag_request_context.get("force_new_session")) + thinking_mode = str(rag_request_context.get("thinking_mode") or "").strip() or None + action_mode = str(rag_request_context.get("action_mode") or "read").strip() or "read" + sync_requirement = bool(rag_request_context.get("sync_requirement")) + message_intent = str(rag_request_context.get("message_intent") or "").strip() or None + mixed_signal_part = str(rag_request_context.get("mixed_signal_part") or "").strip() or None + else: + asked_by = _safe_int(operator_user_id) + asked_via = source_system + requested_project_id = None + source_chat_id = source_message_id + conversation_key = source_message_id + session_id = None + force_new_session = False + thinking_mode = None + action_mode = "read" + sync_requirement = False + message_intent = None + mixed_signal_part = None + rag_kwargs: dict[str, Any] = { + "question": message_text, + "asked_by": asked_by, + "asked_via": asked_via, + "project_id": requested_project_id if requested_project_id is not None else intent.project_id, + "source_chat_id": source_chat_id, + "conversation_key": conversation_key, + "action_mode": action_mode, + "sync_requirement": sync_requirement, + "message_intent": message_intent, + "mixed_signal_part": mixed_signal_part, + } + if session_id is not None: + rag_kwargs["session_id"] = int(session_id) + if force_new_session: + rag_kwargs["force_new_session"] = True + if thinking_mode: + rag_kwargs["thinking_mode"] = thinking_mode try: - rag_result = await rag_service.answer_question( - question=message_text, - asked_by=operator_user_id, - asked_via=source_system, - project_id=intent.project_id, - source_chat_id=source_message_id, - conversation_key=source_message_id, - action_mode="read", - sync_requirement=False, - ) + rag_result = await rag_service.answer_question(**rag_kwargs) except Exception as exc: return { "status": "failed_safe", @@ -71,3 +116,143 @@ class ExecuteQueryProjectMixin: "tasks": payload.get("tasks") if isinstance(payload.get("tasks"), list) else [], "raw_payload": payload, } + + @staticmethod + def _extract_task_priority(message_text: str) -> str: + normalized = re.sub(r"\s+", "", str(message_text or "")).lower() + if re.search(r"\bp0\b", normalized, re.IGNORECASE) or any( + token in normalized for token in ("最高优先", "紧急", "critical", "urgent") + ): + return "high" + if re.search(r"\bp1\b", normalized, re.IGNORECASE) or any( + token in normalized for token in ("高优先", "高优先级", "highpriority") + ): + return "high" + if re.search(r"\bp3\b", normalized, re.IGNORECASE) or any( + token in normalized for token in ("低优先", "低优先级", "lowpriority") + ): + return "low" + return "medium" + + @staticmethod + def _extract_task_description(*, message_text: str, title: str) -> Optional[str]: + cleaned = re.sub(r"\s+", " ", str(message_text or "")).strip() + if not cleaned: + return None + description = cleaned.replace(str(title or "").strip(), "", 1).strip(" ,,::") + description = re.sub( + r"^(?:请|帮我|帮忙)?(?:给|在)?(?:项目\s*#?\d+\s*)?(?:里|中)?(?:新增|新建|创建|增加|添加|加(?:一个|个)?|安排|分配)\s*(?:一条|一个|个)?任务", + "", + description, + flags=re.IGNORECASE, + ).strip(" ,,::") + return description[:5000] or None + + async def _execute_task_create_intent( + self, + *, + intent, + message_text: str, + operator_user_id, + ) -> dict[str, Any]: + metadata = intent.metadata if isinstance(intent.metadata, dict) else {} + project_id = _safe_int(intent.project_id) or _safe_int(metadata.get("project_id")) + if project_id is None: + return { + "status": "pending_manual", + "error_message": "未执行:当前建任务请求缺少项目归属,请补充项目 ID 后再确认。", + "project_id": None, + } + + project = ( + await self.db.execute( + select(Project).where( + and_( + Project.tenant_id == int(self.tenant_id), + Project.id == int(project_id), + ) + ) + ) + ).scalar_one_or_none() + if project is None: + return { + "status": "failed_safe", + "error_message": f"未执行:项目#{int(project_id)} 不存在或不可访问。", + "project_id": int(project_id), + } + + provided_title = str(metadata.get("task_title") or "").strip() + extracted_title = ( + provided_title + or IntentResolver._extract_task_title(message_text) + or IntentResolver._extract_task_title(str(getattr(intent, "signal_title", "") or "")) + ) + task_title = str(extracted_title or "").strip() + if not task_title: + return { + "status": "pending_manual", + "error_message": "未执行:当前建任务请求缺少任务标题,请补充后再确认。", + "project_id": int(project_id), + } + + charter = ( + await self.db.execute( + select(TruthNode) + .where( + and_( + TruthNode.tenant_id == int(self.tenant_id), + TruthNode.project_id == int(project_id), + TruthNode.node_type == "charter", + TruthNode.status.in_(tuple(ALIGNED_STATUSES)), + ) + ) + .order_by(TruthNode.updated_at.desc(), TruthNode.id.desc()) + ) + ).scalars().first() + if charter is None: + return { + "status": "pending_manual", + "error_message": "未执行:项目尚无已对齐的 Charter,当前不能直接创建任务。", + "project_id": int(project_id), + } + + description = self._extract_task_description(message_text=message_text, title=task_title) + try: + draft = build_task_create_draft( + title=task_title, + description=description, + priority=self._extract_task_priority(message_text), + ) + except Exception as exc: + return { + "status": "failed_safe", + "error_message": f"任务字段校验失败:{type(exc).__name__}", + "project_id": int(project_id), + } + + task = await persist_task( + self.db, + tenant_id=int(self.tenant_id), + truth_node_id=int(charter.id), + project_id=int(project_id), + draft=draft, + actor_user_id=_safe_int(operator_user_id), + action=INTENT_TASK_CREATE, + remark="ai_pmo_typed_intent", + commit=False, + refresh_workload=False, + ) + await context_cache_service.invalidate_system_context(tenant_id=int(self.tenant_id)) + return { + "status": "succeeded", + "project_id": int(project_id), + "task_id": int(task.id), + "task": { + "id": int(task.id), + "title": str(task.title or "").strip(), + "description": str(task.description or "").strip(), + "priority": str(task.priority or "").strip(), + "status": str(task.status or "").strip(), + }, + "charter_id": int(charter.id), + } diff --git a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py index 1d4f6354..5d3258fb 100644 --- a/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py +++ b/backend/app/services/ai_pmo/ai_pmo_typed_executor_execute/service.py @@ -27,9 +27,10 @@ class TypedExecutorExecuteMixin( channel_ref: Optional[str] = None, ) -> dict[str, Any]: if intent.name == INTENT_QUERY: + query_text = str(getattr(intent, "description", "") or "").strip() or message_text return await self._execute_query_intent( intent=intent, - message_text=message_text, + message_text=query_text, operator_user_id=operator_user_id, source_system=source_system, source_message_id=source_message_id, @@ -41,6 +42,13 @@ class TypedExecutorExecuteMixin( operator_user_id=operator_user_id, ) + if intent.name == INTENT_TASK_CREATE: + return await self._execute_task_create_intent( + intent=intent, + message_text=message_text, + operator_user_id=operator_user_id, + ) + if intent.name == INTENT_SIGNAL_SOURCE_ONBOARD: return await self._execute_signal_source_onboard_intent( intent=intent, diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py new file mode 100644 index 00000000..b2b377fd --- /dev/null +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -0,0 +1,816 @@ +"""Draft confirmation/revision/cancel handler for AI PMO write intents.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any, Optional + +from . import ai_pmo_kernel_common_core as _common + +for _name, _value in vars(_common).items(): + if not _name.startswith("__"): + globals()[_name] = _value + +from ..signal.draft_generator_service import DraftGeneratorService + + +class DraftConfirmHandler: + """Handle draft decision from Feishu text or inbox API.""" + + _ACTION_CONFIRM = "confirm" + _ACTION_REVISE = "revise" + _ACTION_CANCEL = "cancel" + _ACTION_UNKNOWN = "unknown" + + _CONFIRM_HINTS = ("确认", "好的", "就这样", "行", "ok", "okay", "同意", "没问题") + _REVISE_HINTS = ("修改", "改成", "改为", "改", "换成") + _CANCEL_HINTS = ("取消", "不要", "算了", "放弃") + + _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) + + def __init__(self, kernel: Any) -> None: + self.kernel = kernel + self.db = kernel.db + self.tenant_id = int(kernel.tenant_id) + self.draft_generator = DraftGeneratorService(self.db, tenant_id=self.tenant_id) + + @classmethod + def parse_action_from_text(cls, *, message_text: str) -> tuple[str, str]: + text = str(message_text or "").strip() + lowered = text.lower() + if any(hint in text for hint in cls._REVISE_HINTS): + return cls._ACTION_REVISE, cls._extract_revision_note(text) + if any(hint in text for hint in cls._CANCEL_HINTS): + return cls._ACTION_CANCEL, "" + if any(hint in text for hint in cls._CONFIRM_HINTS) or lowered in {"y", "yes"}: + return cls._ACTION_CONFIRM, "" + return cls._ACTION_UNKNOWN, "" + + async def handle_text_reply( + self, + *, + message_text: str, + sender: str, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + expected_version: Optional[int] = None, + ) -> dict[str, Any]: + action, revision_note = self.parse_action_from_text(message_text=message_text) + explicit_work_item_id = self._extract_work_item_id(message_text) + + target = await self._resolve_pending_draft( + sender=sender, + source_system=source_system, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + explicit_work_item_id=explicit_work_item_id, + ) + if target is None: + return { + "handled": True, + "status": "ignored", + "followup_reply": "当前没有待确认草稿,请先提交写操作需求。", + "target_work_item_id": None, + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + target_work_item, target_ingest = target + if action == self._ACTION_CONFIRM: + return await self.confirm_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + sender=sender, + source_system=source_system, + source_message_id=source_message_id, + message_text=message_text, + expected_version=expected_version, + ) + if action == self._ACTION_REVISE: + return await self.revise_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + revision_note=revision_note, + expected_version=expected_version, + ) + if action == self._ACTION_CANCEL: + return await self.cancel_draft( + target_work_item=target_work_item, + reason="用户取消草稿", + ) + return { + "handled": True, + "status": "pending_manual", + "followup_reply": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。", + "target_work_item_id": int(target_work_item.id), + "target_state": str(target_work_item.state or "").strip() or None, + "target_intent": str(target_work_item.intent or "").strip() or None, + "project_id": _safe_int(target_work_item.project_id), + "signal_id": _safe_int(target_work_item.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), + } + + async def handle_api_decision( + self, + *, + work_item_id: int, + action: str, + operator_sender: str, + source_message_id: Optional[str], + revision_note: Optional[str], + draft_version: Optional[int], + ) -> dict[str, Any]: + row = ( + await self.db.execute( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.id == int(work_item_id), + ) + ) + .limit(1) + ) + ).first() + if row is None: + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "草稿不存在或已被删除。", + "target_work_item_id": int(work_item_id), + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + target_work_item, target_ingest = row + normalized_action = str(action or "").strip().lower() + normalized_source_message_id = ( + str(source_message_id or "").strip() + or f"draft-api-{normalized_action}-{int(work_item_id)}-{int(datetime.now().timestamp() * 1000)}" + )[:128] + + if normalized_action == self._ACTION_CONFIRM: + return await self.confirm_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + sender=operator_sender, + source_system="ai_pmo_ui", + source_message_id=normalized_source_message_id, + message_text="确认", + expected_version=draft_version, + ) + if normalized_action == self._ACTION_REVISE: + return await self.revise_draft( + target_work_item=target_work_item, + target_ingest=target_ingest, + revision_note=str(revision_note or "").strip(), + expected_version=draft_version, + ) + if normalized_action == self._ACTION_CANCEL: + return await self.cancel_draft( + target_work_item=target_work_item, + reason="用户在消息台取消草稿", + ) + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "不支持的草稿操作。", + "target_work_item_id": int(work_item_id), + "target_state": str(target_work_item.state or "").strip() or None, + "target_intent": str(target_work_item.intent or "").strip() or None, + "project_id": _safe_int(target_work_item.project_id), + "signal_id": _safe_int(target_work_item.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), + } + + async def confirm_draft( + self, + *, + target_work_item: WorkItem, + target_ingest: IngestEvent, + sender: str, + source_system: str, + source_message_id: str, + message_text: str, + expected_version: Optional[int], + ) -> dict[str, Any]: + locked = ( + await self.db.execute( + select(WorkItem) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.id == int(target_work_item.id), + ) + ) + .with_for_update() + .limit(1) + ) + ).scalar_one_or_none() + if locked is None: + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "草稿不存在或已被删除。", + "target_work_item_id": int(target_work_item.id), + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + state = str(locked.state or "").strip() + if state != STATE_DRAFT_PENDING_CONFIRM: + return { + "handled": True, + "status": "succeeded" if state == STATE_SUCCEEDED else "failed_safe", + "followup_reply": "该草稿已处理,无需重复确认。", + "target_work_item_id": int(locked.id), + "target_state": state or None, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + current_version = int(locked.draft_version or 0) + if expected_version is not None and int(expected_version) != current_version: + return { + "handled": True, + "status": "pending_manual", + "followup_reply": f"草稿版本已更新,请基于最新版本 v{current_version} 再确认。", + "target_work_item_id": int(locked.id), + "target_state": state, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + now_at = _utc_now_naive() + if isinstance(locked.draft_expires_at, datetime) and locked.draft_expires_at <= now_at: + locked.state = STATE_CANCELLED + locked.execution_result = { + **(locked.execution_result if isinstance(locked.execution_result, dict) else {}), + "status": "cancelled", + "cancel_reason": "draft_expired", + "cancelled_at": now_at.isoformat(), + } + await self._sync_decision_outcome( + work_item_id=int(locked.id), + execution_result=locked.execution_result, + final_outcome=STATE_CANCELLED, + ) + return { + "handled": True, + "status": "cancelled", + "followup_reply": "⏰ 草稿已超时取消,请重新发送需求。", + "target_work_item_id": int(locked.id), + "target_state": STATE_CANCELLED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + existing_confirm_id = str(locked.confirm_message_id or "").strip() + if existing_confirm_id and existing_confirm_id != str(source_message_id or "").strip(): + return { + "handled": True, + "status": "failed_safe", + "followup_reply": "该草稿已被其他确认消息处理,请刷新状态后查看结果。", + "target_work_item_id": int(locked.id), + "target_state": str(locked.state or "").strip() or None, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + locked.confirm_message_id = str(source_message_id or "").strip()[:128] or None + locked.state = STATE_EXECUTING + locked.reply_template_key = "action_processing" + await self.db.flush() + + route_key = RouteResolver.normalize_route_key(str(locked.intent or "")) + _, route_action = RouteResolver._split_route_key(route_key) + draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {} + existing_execution = locked.execution_result if isinstance(locked.execution_result, dict) else {} + + try: + signal = await self._persist_signal_from_draft( + work_item=locked, + draft=draft_payload, + source_system=source_system, + source_message_id=source_message_id, + sender=sender, + message_text=message_text, + ) + except Exception as exc: + locked.state = STATE_FAILED + locked.draft_expires_at = None + locked.reply_template_key = "safe_error" + error_message = f"草稿确认入库失败:{str(exc)[:240]}" + locked.execution_result = { + **existing_execution, + "status": "failed_safe", + "workflow_route": route_key, + "workflow_action": route_action, + "error_message": error_message, + "committed_from_draft": False, + } + await self._sync_decision_outcome( + work_item_id=int(locked.id), + execution_result=locked.execution_result, + final_outcome=STATE_FAILED, + ) + await self.db.flush() + return { + "handled": True, + "status": "failed_safe", + "followup_reply": error_message, + "target_work_item_id": int(locked.id), + "target_state": STATE_FAILED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": route_key, + } + + signal_id = _safe_int(getattr(signal, "id", None)) + if signal_id is not None: + locked.signal_id = int(signal_id) + locked.state = STATE_SUCCEEDED + locked.draft_expires_at = None + locked.reply_template_key = "action_succeeded" + + reply_text = self._build_confirm_success_text( + draft=draft_payload, + signal_id=_safe_int(locked.signal_id), + ) + locked.execution_result = { + **existing_execution, + "status": "succeeded", + "workflow_route": route_key, + "workflow_action": route_action, + "signal_id": _safe_int(locked.signal_id), + "project_id": _safe_int(locked.project_id), + "draft_version": current_version, + "followup_reply": reply_text, + "committed_from_draft": True, + } + await self._sync_decision_outcome( + work_item_id=int(locked.id), + execution_result=locked.execution_result, + final_outcome=STATE_SUCCEEDED, + ) + await self.db.flush() + + return { + "handled": True, + "status": "succeeded", + "followup_reply": reply_text, + "target_work_item_id": int(locked.id), + "target_state": STATE_SUCCEEDED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": route_key, + } + + async def revise_draft( + self, + *, + target_work_item: WorkItem, + target_ingest: IngestEvent, + revision_note: str, + expected_version: Optional[int], + ) -> dict[str, Any]: + locked = ( + await self.db.execute( + select(WorkItem) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.id == int(target_work_item.id), + ) + ) + .with_for_update() + .limit(1) + ) + ).scalar_one_or_none() + if locked is None: + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "草稿不存在或已被删除。", + "target_work_item_id": int(target_work_item.id), + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + state = str(locked.state or "").strip() + if state != STATE_DRAFT_PENDING_CONFIRM: + return { + "handled": True, + "status": "failed_safe", + "followup_reply": "当前草稿已不在待确认状态,无法再修改。", + "target_work_item_id": int(locked.id), + "target_state": state or None, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + current_version = int(locked.draft_version or 0) + if expected_version is not None and int(expected_version) != current_version: + return { + "handled": True, + "status": "pending_manual", + "followup_reply": f"草稿版本已更新,请刷新后基于 v{current_version} 再修改。", + "target_work_item_id": int(locked.id), + "target_state": state, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + ingest_payload = target_ingest.payload if isinstance(target_ingest.payload, dict) else {} + original_message = str(ingest_payload.get("message_text") or "").strip() + if not original_message: + original_message = str((locked.draft_content or {}).get("original_text") or "").strip() + + intent_name = RouteResolver.canonical_intent_for_route(RouteResolver.normalize_route_key(str(locked.intent or ""))) + generated = await self.draft_generator.generate_and_persist( + work_item=locked, + intent_name=intent_name, + message_text=original_message, + world_model=locked.context_snapshot if isinstance(locked.context_snapshot, dict) else {}, + revision_note=revision_note, + ) + locked.state = STATE_DRAFT_PENDING_CONFIRM + locked.reply_template_key = "draft_pending_confirm" + await self.db.flush() + + draft_text = str(generated.get("draft_text") or "").strip() + if not draft_text: + draft_text = DraftGeneratorService.render_draft_message(locked.draft_content if isinstance(locked.draft_content, dict) else {}) + followup_reply = "🔄 正在重新起草,请稍候...\n\n" + draft_text + + return { + "handled": True, + "status": "draft_pending_confirm", + "followup_reply": followup_reply, + "target_work_item_id": int(locked.id), + "target_state": STATE_DRAFT_PENDING_CONFIRM, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + "draft_version": int(locked.draft_version or 0), + "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None, + } + + async def cancel_draft( + self, + *, + target_work_item: WorkItem, + reason: str, + ) -> dict[str, Any]: + locked = ( + await self.db.execute( + select(WorkItem) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.id == int(target_work_item.id), + ) + ) + .with_for_update() + .limit(1) + ) + ).scalar_one_or_none() + if locked is None: + return { + "handled": False, + "status": "failed_safe", + "followup_reply": "草稿不存在或已被删除。", + "target_work_item_id": int(target_work_item.id), + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } + + if str(locked.state or "").strip() == STATE_CANCELLED: + return { + "handled": True, + "status": "cancelled", + "followup_reply": "已取消,如需重新提交请再次发送。", + "target_work_item_id": int(locked.id), + "target_state": STATE_CANCELLED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + if str(locked.state or "").strip() != STATE_DRAFT_PENDING_CONFIRM: + return { + "handled": True, + "status": "failed_safe", + "followup_reply": "当前草稿已不在待确认状态,无法取消。", + "target_work_item_id": int(locked.id), + "target_state": str(locked.state or "").strip() or None, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + locked.state = STATE_CANCELLED + locked.draft_expires_at = None + locked.reply_template_key = "draft_cancelled" + locked.execution_result = { + **(locked.execution_result if isinstance(locked.execution_result, dict) else {}), + "status": "cancelled", + "cancel_reason": str(reason or "用户取消草稿")[:120], + "cancelled_at": _utc_now_naive().isoformat(), + } + await self._sync_decision_outcome( + work_item_id=int(locked.id), + execution_result=locked.execution_result, + final_outcome=STATE_CANCELLED, + ) + await self.db.flush() + + return { + "handled": True, + "status": "cancelled", + "followup_reply": "已取消,如需重新提交请再次发送。", + "target_work_item_id": int(locked.id), + "target_state": STATE_CANCELLED, + "target_intent": str(locked.intent or "").strip() or None, + "project_id": _safe_int(locked.project_id), + "signal_id": _safe_int(locked.signal_id), + "workflow_route": RouteResolver.normalize_route_key(str(locked.intent or "")), + } + + async def _resolve_pending_draft( + self, + *, + sender: str, + source_system: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + explicit_work_item_id: Optional[int], + ) -> Optional[tuple[WorkItem, IngestEvent]]: + stmt = ( + select(WorkItem, IngestEvent) + .join(IngestEvent, IngestEvent.id == WorkItem.ingest_event_id) + .where( + and_( + WorkItem.tenant_id == int(self.tenant_id), + WorkItem.state == STATE_DRAFT_PENDING_CONFIRM, + ) + ) + .order_by(WorkItem.id.desc()) + .limit(30) + ) + rows = (await self.db.execute(stmt)).all() + if explicit_work_item_id is not None: + for work_item, ingest in rows: + if int(work_item.id) == int(explicit_work_item_id): + return work_item, ingest + return None + + normalized_sender = str(sender or "").strip() + normalized_channel = str(channel_ref or "").strip() + normalized_open_id = str(source_sender_open_id or "").strip() + + candidates: list[tuple[WorkItem, IngestEvent]] = [] + for work_item, ingest in rows: + payload = ingest.payload if isinstance(ingest.payload, dict) else {} + same_sender = normalized_sender and str(payload.get("sender") or "").strip() == normalized_sender + same_channel = normalized_channel and str(payload.get("channel_ref") or "").strip() == normalized_channel + same_open_id = normalized_open_id and str(payload.get("source_sender_open_id") or "").strip() == normalized_open_id + if str(source_system or "").strip().lower() == "feishu": + if not (same_open_id or same_channel or same_sender): + continue + else: + if not (same_sender or same_channel): + continue + candidates.append((work_item, ingest)) + + if not candidates: + return None + return candidates[0] + + async def _sync_decision_outcome( + self, + *, + work_item_id: int, + execution_result: dict[str, Any], + final_outcome: str, + ) -> None: + decision = ( + await self.db.execute( + select(DecisionRecord) + .where( + and_( + DecisionRecord.tenant_id == int(self.tenant_id), + DecisionRecord.work_item_id == int(work_item_id), + ) + ) + .order_by(DecisionRecord.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if decision is None: + return + decision.execution_result = execution_result + decision.final_outcome = str(final_outcome or "")[:32] or None + await self.db.flush() + + @classmethod + def _extract_work_item_id(cls, text: str) -> Optional[int]: + matched = cls._WORK_ITEM_ID_RE.search(str(text or "")) + if matched is None: + return None + return _safe_int(matched.group(1)) + + @classmethod + def _extract_revision_note(cls, text: str) -> str: + normalized = str(text or "").strip() + for hint in cls._REVISE_HINTS: + if normalized.startswith(hint): + note = normalized[len(hint):].strip(" ::") + return note[:500] + return "" + + @staticmethod + def _safe_float(value: Any) -> Optional[float]: + try: + if value is None or value == "": + return None + return float(value) + except (TypeError, ValueError): + return None + + @staticmethod + def _build_confirm_success_text(*, draft: dict[str, Any], signal_id: Optional[int]) -> str: + title = str(draft.get("title") or "需求").strip() or "需求" + assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" + lines = ["✅ 已入库", "", f"📌 {title}"] + if signal_id is not None: + lines.append(f"信号 #{int(signal_id)} 已创建") + else: + lines.append("已完成入库并开始执行") + lines.append(f"负责人:{assignee_name}") + return "\n".join(lines) + + async def _persist_signal_from_draft( + self, + *, + work_item: WorkItem, + draft: dict[str, Any], + source_system: str, + source_message_id: str, + sender: str, + message_text: str, + ) -> Signal: + title = str(draft.get("title") or "").strip()[:200] + if not title: + title = str(draft.get("summary") or "").strip()[:50] + if not title: + title = "新需求" + summary = str(draft.get("summary") or "").strip()[:300] or title + content = str(draft.get("original_text") or "").strip()[:2000] or summary + + assignee_id = _safe_int(draft.get("recommended_assignee_id")) + assignee_name = str(draft.get("recommended_assignee_name") or "").strip()[:40] + workload = self._safe_float(draft.get("recommended_assignee_workload")) + feasibility = str(draft.get("feasibility") or "").strip()[:20] + feasibility_reason = str(draft.get("feasibility_reason") or "").strip()[:120] + followup_plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else [] + + project_id = _safe_int(work_item.project_id) + strategy_payload = { + "source_context": { + "source_message_id": str(source_message_id or "").strip()[:128], + "source_system": str(source_system or "").strip()[:32], + "from_work_item_id": int(work_item.id), + "followup_sender": str(sender or "").strip()[:80], + }, + "ai_analysis": { + "summary": summary, + "intent": RouteResolver.canonical_intent_for_route( + RouteResolver.normalize_route_key(str(work_item.intent or "")) + ), + "draft_confirmed_direct_commit": True, + "feasibility": feasibility, + "feasibility_reason": feasibility_reason, + "followup_plan": followup_plan[:3], + }, + "assignee": { + "user_id": assignee_id, + "name": assignee_name, + "workload_percent": workload, + }, + } + reasoning_items = [ + "来源:草稿确认后直接入库(不重新分析)。", + f"可行性:{feasibility or '待讨论'}", + f"原因:{feasibility_reason or '无'}", + ] + + signal = Signal( + tenant_id=int(self.tenant_id), + source="PMFlow", + title=title, + summary=summary, + content=content, + signal_type="新需求", + reasoning=json.dumps(reasoning_items, ensure_ascii=False), + strategic_analysis=json.dumps(strategy_payload, ensure_ascii=False), + confidence=0.78, + recommended_action="enter_only", + matched_project_id=project_id, + impact_score=0.7, + priority="medium", + processed=False, + tasks_generated=json.dumps([], ensure_ascii=False), + status="待处理", + planner_assignee_user_id=assignee_id, + ) + self.db.add(signal) + await self.db.flush() + + if project_id is not None: + link_exists = ( + await self.db.execute( + select(ProjectSignalLink.id) + .where( + and_( + ProjectSignalLink.tenant_id == int(self.tenant_id), + ProjectSignalLink.project_id == int(project_id), + ProjectSignalLink.signal_id == int(signal.id), + ) + ) + .limit(1) + ) + ).scalar_one_or_none() + if link_exists is None: + self.db.add( + ProjectSignalLink( + tenant_id=int(self.tenant_id), + project_id=int(project_id), + signal_id=int(signal.id), + link_type="related", + relevance_score=1.0, + ) + ) + await self.db.flush() + + if not str(message_text or "").strip(): + message_text = summary + return signal + + @staticmethod + def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute: + normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or "")) + family, action = RouteResolver._split_route_key(normalized_key) + execution_result = work_item.execution_result if isinstance(work_item.execution_result, dict) else {} + requested_count = _safe_int(execution_result.get("requested_count")) or 1 + if family != ROUTE_FAMILY_PROJECT_SIGNAL: + requested_count = 1 + return WorkflowRoute( + family=family, + action=action, + project_id=_safe_int(work_item.project_id), + requested_count=requested_count, + requires_confirmation=True, + metadata={ + "workflow_route": normalized_key, + "workflow_family": family, + "workflow_action": action, + "resumed_from": "draft_pending_confirm", + "target_work_item_id": int(work_item.id), + }, + ) diff --git a/backend/app/services/ai_service_core/mixin_business_analysis.py b/backend/app/services/ai_service_core/mixin_business_analysis.py index d2e7af1b..bd0525c1 100644 --- a/backend/app/services/ai_service_core/mixin_business_analysis.py +++ b/backend/app/services/ai_service_core/mixin_business_analysis.py @@ -168,9 +168,12 @@ class AIServiceBusinessAnalysisMixin: return self._parse_json(text) async def stream_chat(self, prompt: str) -> AsyncGenerator[str, None]: - """流式生成""" - llm = self._get_llm() - async for chunk in llm.astream([HumanMessage(content=prompt)]): - if hasattr(chunk, "content") and chunk.content: - yield chunk.content - + """兼容流式接口:统一走 llm_service,并以单块返回。""" + text = await self._invoke_with_retry( + prompt, + function_name="ai_service_stream_chat", + use_cache=False, + ) + normalized = str(text or "").strip() + if normalized: + yield normalized diff --git a/backend/app/services/ai_service_core/mixin_runtime_core.py b/backend/app/services/ai_service_core/mixin_runtime_core.py index 13dacbc2..6f57f784 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_core.py +++ b/backend/app/services/ai_service_core/mixin_runtime_core.py @@ -86,18 +86,7 @@ class AIServiceRuntimeCoreMixin: } def _get_llm(self) -> BaseChatModel: - if not LANGCHAIN_AVAILABLE: - raise RuntimeError("请安装 langchain-openai: pip install langchain-openai langchain-core") - if not self.api_key: - raise RuntimeError("请设置统一路由所需 API Key 环境变量") - if self._llm is None: - self._llm = ChatOpenAI( - model=self.model, - api_key=self.api_key, - base_url=self.base_url, - temperature=0.3, - ) - return self._llm + raise RuntimeError("AIService 已统一收敛到 llm_service;请使用 _invoke_with_retry。") async def _get_redis(self): if not REDIS_AVAILABLE or not self.redis_url: @@ -132,4 +121,3 @@ class AIServiceRuntimeCoreMixin: if text.endswith("```"): text = text[:-3] return json.loads(text.strip()) - diff --git a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py index 34a947a6..8e66324c 100644 --- a/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py +++ b/backend/app/services/ai_service_core/mixin_runtime_prompt_retry.py @@ -125,7 +125,9 @@ class AIServiceRuntimePromptRetryMixin: cache_key: Optional[str] = None, project_id: Optional[int] = None, ) -> str: - """带缓存、重试、日志的 LLM 调用""" + """带缓存、重试、预算治理的 LLM 调用(统一走 llm_service)。""" + from ..llm_service import llm_service + input_hash = cache_key or _hash_input(function_name, prompt) if use_cache and db is None: @@ -158,35 +160,27 @@ class AIServiceRuntimePromptRetryMixin: # 预算系统异常不阻断主流程,避免影响现有可用性 pass - llm = self._get_llm() - _, system_prompt = await self._resolve_active_system_prompt( - tenant_id=tenant_id, - db=db, - ) last_err = None for attempt in range(self.max_retries): try: - t0 = time.perf_counter() - messages = [HumanMessage(content=prompt)] - if system_prompt: - messages = [SystemMessage(content=system_prompt), *messages] - resp = await llm.ainvoke(messages) - latency_ms = int((time.perf_counter() - t0) * 1000) - text = resp.content if hasattr(resp, "content") else str(resp) - pt = getattr(resp, "response_metadata", None) or {} - usage = (pt or {}).get("usage", {}) or (getattr(resp, "response_metadata", None) or {}).get("usage", {}) - prompt_tokens = usage.get("input_tokens", 0) or usage.get("prompt_tokens", 0) - completion_tokens = usage.get("output_tokens", 0) or usage.get("completion_tokens", 0) + text = await llm_service.generate( + prompt, + db=db, + tenant_id=_tenant_to_int(tenant_id), + function_name=function_name, + use_prompt_profile=True, + ) + text = str(text or "") + if not text.strip(): + raise RuntimeError("llm_empty_response") - if db: - await self._log_call( - db, tenant_id, function_name, input_hash, "success", - prompt_tokens, completion_tokens, latency_ms, - ) - # 按实际 token 成本累计预算 + # 预算使用累计(按文本长度估算,不阻断主链路) + if db is not None: try: from ..budget_enforcement import BudgetEnforcementService + prompt_tokens = max(len(prompt) // 4, 1) + completion_tokens = max(len(text) // 4, 1) actual_cost = _estimate_cost(prompt_tokens, completion_tokens) await BudgetEnforcementService(db).update_budget_usage_after_ai_call( tenant_id=_tenant_to_int(tenant_id), @@ -200,13 +194,10 @@ class AIServiceRuntimePromptRetryMixin: return text except Exception as e: last_err = e - wait = 2 ** attempt - time.sleep(wait) - - if db: - await self._log_call( - db, tenant_id, function_name, input_hash, "failed", - error_message=str(last_err), - ) - raise last_err + if attempt < self.max_retries - 1: + wait_seconds = 2 ** attempt + await asyncio.sleep(wait_seconds) + if last_err is not None: + raise last_err + raise RuntimeError("llm_invoke_failed") diff --git a/backend/app/services/ai_service_core/shared.py b/backend/app/services/ai_service_core/shared.py index 8d3bd95f..f6aace8b 100644 --- a/backend/app/services/ai_service_core/shared.py +++ b/backend/app/services/ai_service_core/shared.py @@ -13,6 +13,7 @@ import hashlib import json import os import time +import asyncio from datetime import datetime from typing import Any, AsyncGenerator, Optional, Union from sqlalchemy import and_, select @@ -71,3 +72,5 @@ def _tenant_to_int(tenant_id: Union[str, int]) -> int: return 1 +__all__ = [name for name in globals() if not name.startswith("__")] + diff --git a/backend/app/services/context_cache_service/mixin_profile_basics.py b/backend/app/services/context_cache_service/mixin_profile_basics.py index 6a693811..0b514358 100644 --- a/backend/app/services/context_cache_service/mixin_profile_basics.py +++ b/backend/app/services/context_cache_service/mixin_profile_basics.py @@ -87,7 +87,7 @@ class ContextCacheProfileBasicsMixin: "summary": "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", "page_component": "AIStewardPage", "page_file": "frontend/src/pages/AIStewardPage.tsx", - "apis": ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + "apis": ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], "aliases": ["ai steward", "ai管家", "问答", "rag", "智能问答"], }, { @@ -132,4 +132,3 @@ class ContextCacheProfileBasicsMixin: "aliases": ["users", "成员管理", "用户", "人员管理"], }, ] - diff --git a/backend/app/services/context_cache_service/mixin_profile_route_parser.py b/backend/app/services/context_cache_service/mixin_profile_route_parser.py index 3006b5c6..04193a50 100644 --- a/backend/app/services/context_cache_service/mixin_profile_route_parser.py +++ b/backend/app/services/context_cache_service/mixin_profile_route_parser.py @@ -62,7 +62,7 @@ class ContextCacheProfileRouteParserMixin: ), "/ai-steward": ( "AI 任务协同与治理中心,包含简报、冲突检测、问答历史。", - ["/ai-steward/", "/ai-steward/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], + ["/ai-steward/", "/rag/ask", "/rag/qa-sessions", "/rag/qa-records"], ["ai steward", "ai管家", "问答", "rag", "智能问答"], ), "/ai-config": ( @@ -251,4 +251,3 @@ class ContextCacheProfileRouteParserMixin: "scanned_at": now_cn_aware().isoformat(), }, } - diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py new file mode 100644 index 00000000..f9b03091 --- /dev/null +++ b/backend/app/services/integrations/feishu_integration_service/mixin_card_action.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from .shared import * + + +class FeishuCardActionMixin: + _ACTION_CONFIRM = "confirm" + _ACTION_REVISE = "revise" + _ACTION_CANCEL = "cancel" + _ACTION_CONTINUE = "continue" + _TEMPLATE_DRAFT_PENDING_CONFIRM = "draft_pending_confirm" + _TEMPLATE_PENDING_MANUAL = "pending_manual" + _ACTION_ALIASES: dict[str, str] = { + "confirm": _ACTION_CONFIRM, + "draft_confirm": _ACTION_CONFIRM, + "confirm_draft": _ACTION_CONFIRM, + "确认": _ACTION_CONFIRM, + "ok": _ACTION_CONFIRM, + "revise": _ACTION_REVISE, + "draft_revise": _ACTION_REVISE, + "revise_draft": _ACTION_REVISE, + "修改": _ACTION_REVISE, + "edit": _ACTION_REVISE, + "cancel": _ACTION_CANCEL, + "draft_cancel": _ACTION_CANCEL, + "cancel_draft": _ACTION_CANCEL, + "取消": _ACTION_CANCEL, + "abort": _ACTION_CANCEL, + "continue": _ACTION_CONTINUE, + "resume": _ACTION_CONTINUE, + "继续": _ACTION_CONTINUE, + "继续执行": _ACTION_CONTINUE, + } + + @staticmethod + def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + @classmethod + def _normalize_draft_action(cls, raw_action: Any) -> Optional[str]: + normalized = str(raw_action or "").strip().lower() + if not normalized: + return None + return cls._ACTION_ALIASES.get(normalized) + + @staticmethod + def _coerce_capture_work_item_id(capture_result: Dict[str, Any]) -> Optional[int]: + work_item_id = FeishuCardActionMixin._safe_int(capture_result.get("work_item_id")) + if work_item_id is not None and work_item_id > 0: + return int(work_item_id) + rows = capture_result.get("results") if isinstance(capture_result.get("results"), list) else [] + if rows and isinstance(rows[0], dict): + row_work_item_id = FeishuCardActionMixin._safe_int(rows[0].get("work_item_id")) + if row_work_item_id is not None and row_work_item_id > 0: + return int(row_work_item_id) + return None + + @classmethod + def _build_action_card_from_capture_result( + cls, + *, + capture_result: Dict[str, Any], + mention_open_id: Optional[str], + mention_name: Optional[str], + ) -> Optional[dict[str, Any]]: + if not isinstance(capture_result, dict): + return None + template_key = str(capture_result.get("reply_template_key") or "").strip().lower() + work_item_id = cls._coerce_capture_work_item_id(capture_result) + if work_item_id is None: + return None + reply_text = str(capture_result.get("direct_reply_text") or "").strip() + safe_reply_text = reply_text[:3000] if reply_text else "已生成动作卡片,请点击按钮继续。" + + mention_line = "" + safe_open_id = str(mention_open_id or "").strip()[:128] + safe_name = re.sub(r"\s+", " ", str(mention_name or "").strip())[:40] + if safe_open_id: + mention_line = f"{(' ' + safe_name) if safe_name else ''}" + + base_elements: list[dict[str, Any]] = [] + if mention_line: + base_elements.append( + { + "tag": "div", + "text": {"tag": "lark_md", "content": mention_line}, + } + ) + base_elements.append( + { + "tag": "div", + "text": {"tag": "lark_md", "content": safe_reply_text}, + } + ) + + if template_key == cls._TEMPLATE_DRAFT_PENDING_CONFIRM: + draft_version = cls._safe_int(capture_result.get("draft_version")) + actions = [ + { + "tag": "button", + "type": "primary", + "text": {"tag": "plain_text", "content": "确认"}, + "value": { + "action": cls._ACTION_CONFIRM, + "work_item_id": int(work_item_id), + "draft_version": draft_version, + }, + }, + { + "tag": "button", + "type": "default", + "text": {"tag": "plain_text", "content": "修改"}, + "value": { + "action": cls._ACTION_REVISE, + "work_item_id": int(work_item_id), + "draft_version": draft_version, + }, + }, + { + "tag": "button", + "type": "danger", + "text": {"tag": "plain_text", "content": "取消"}, + "value": { + "action": cls._ACTION_CANCEL, + "work_item_id": int(work_item_id), + "draft_version": draft_version, + }, + }, + ] + return { + "config": {"wide_screen_mode": True, "enable_forward": True}, + "header": { + "title": {"tag": "plain_text", "content": "AI PMO 草稿待确认"}, + }, + "elements": [*base_elements, {"tag": "action", "actions": actions}], + } + + if template_key == cls._TEMPLATE_PENDING_MANUAL: + actions = [ + { + "tag": "button", + "type": "primary", + "text": {"tag": "plain_text", "content": "继续执行"}, + "value": { + "action": cls._ACTION_CONTINUE, + "work_item_id": int(work_item_id), + }, + } + ] + return { + "config": {"wide_screen_mode": True, "enable_forward": True}, + "header": { + "title": {"tag": "plain_text", "content": "AI PMO 待继续执行"}, + }, + "elements": [*base_elements, {"tag": "action", "actions": actions}], + } + + return None + + @classmethod + def _extract_card_action_payload(cls, event: Dict[str, Any]) -> dict[str, Any]: + payload = event if isinstance(event, dict) else {} + operator = payload.get("operator") if isinstance(payload.get("operator"), dict) else {} + action = payload.get("action") if isinstance(payload.get("action"), dict) else {} + raw_value = action.get("value") + value: dict[str, Any] = {} + if isinstance(raw_value, dict): + value = raw_value + elif isinstance(raw_value, str): + raw_text = raw_value.strip() + if raw_text: + try: + parsed = json.loads(raw_text) + if isinstance(parsed, dict): + value = parsed + except Exception: + value = {} + + normalized_action = cls._normalize_draft_action( + value.get("action") + or value.get("draft_action") + or value.get("followup_action") + or value.get("resume_action") + or value.get("op") + or action.get("name") + or action.get("tag") + ) + work_item_id = cls._safe_int( + value.get("work_item_id") + or value.get("target_work_item_id") + or value.get("workItemId") + ) + draft_version = cls._safe_int(value.get("draft_version") or value.get("version")) + revision_note = ( + str(value.get("revision_note") or value.get("note") or value.get("comment") or "") + .strip()[:500] + or None + ) + source_message_id = ( + str( + payload.get("open_message_id") + or payload.get("message_id") + or payload.get("token") + or "" + ).strip()[:128] + or None + ) + chat_id = ( + str(payload.get("open_chat_id") or payload.get("chat_id") or "").strip()[:128] + or None + ) + sender_open_id = ( + str(operator.get("open_id") or operator.get("user_id") or "").strip()[:128] + or None + ) + sender_name = ( + str(operator.get("name") or operator.get("display_name") or "").strip()[:80] + or None + ) + return { + "action": normalized_action, + "work_item_id": work_item_id, + "draft_version": draft_version, + "revision_note": revision_note, + "source_message_id": source_message_id, + "chat_id": chat_id, + "sender_open_id": sender_open_id, + "sender_name": sender_name, + } + + async def _handle_card_action_trigger( + self, + event: Dict[str, Any], + ) -> Dict[str, Any]: + parsed = self._extract_card_action_payload(event) + action = str(parsed.get("action") or "").strip().lower() + work_item_id = self._safe_int(parsed.get("work_item_id")) + if action not in {self._ACTION_CONFIRM, self._ACTION_REVISE, self._ACTION_CANCEL, self._ACTION_CONTINUE}: + return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "unsupported_action"}} + if work_item_id is None or work_item_id <= 0: + return {"code": 0, "msg": "card_action_ignored", "data": {"reason": "missing_work_item_id"}} + + from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime import AIPMOKernelService + from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler + + operator_sender = str(parsed.get("sender_name") or "飞书用户").strip()[:80] or "飞书用户" + source_message_id = str(parsed.get("source_message_id") or "").strip()[:128] or None + chat_id = str(parsed.get("chat_id") or "").strip()[:128] or None + sender_open_id = str(parsed.get("sender_open_id") or "").strip()[:128] or None + try: + if action == self._ACTION_CONTINUE: + kernel = AIPMOKernelService(self.db, tenant_id=self.tenant_id) + result = await kernel.process_ingress( + source_system="ai_pmo_ui", + source_message_id=( + source_message_id + or f"feishu-card-continue-{int(work_item_id)}-{int(time.time() * 1000)}" + ), + message_text=f"继续处理 work_item #{int(work_item_id)}", + sender=f"{operator_sender}(card)", + channel_ref="ai_pmo_ui", + source_sender_open_id=sender_open_id, + captured_at=None, + chat_type="manual", + project_hint_id=None, + ) + if hasattr(self.db, "commit") and callable(getattr(self.db, "commit")): + await self.db.commit() + followup_reply = str(result.get("direct_reply_text") or "").strip() + if not followup_reply: + if bool(result.get("handled")): + followup_reply = "已收到继续执行指令,正在处理。" + else: + followup_reply = str(result.get("error") or result.get("reason") or "继续执行失败").strip() + normalized = { + "handled": bool(result.get("handled")), + "status": str(result.get("status") or "").strip() or ("captured" if bool(result.get("handled")) else "failed_safe"), + "followup_reply": followup_reply, + "target_work_item_id": int(work_item_id), + } + else: + draft_version = self._safe_int(parsed.get("draft_version")) + revision_note = str(parsed.get("revision_note") or "").strip() or None + handler = DraftConfirmHandler(AIPMOKernelService(self.db, tenant_id=self.tenant_id)) + normalized = await handler.handle_api_decision( + work_item_id=int(work_item_id), + action=action, + operator_sender=operator_sender, + source_message_id=source_message_id, + revision_note=revision_note, + draft_version=draft_version, + ) + if hasattr(self.db, "commit") and callable(getattr(self.db, "commit")): + await self.db.commit() + except Exception as exc: + if hasattr(self.db, "rollback") and callable(getattr(self.db, "rollback")): + await self.db.rollback() + return {"code": 1, "msg": "card_action_failed", "data": {"error": str(exc)}} + + followup_reply = str(normalized.get("followup_reply") or "").strip() + if followup_reply and chat_id: + status = str(normalized.get("status") or "").strip().lower() + target_work_item_id = self._safe_int( + normalized.get("target_work_item_id") + or normalized.get("work_item_id") + or work_item_id + ) + template_key = "" + if status == self._TEMPLATE_DRAFT_PENDING_CONFIRM: + template_key = self._TEMPLATE_DRAFT_PENDING_CONFIRM + elif status in {"pending_manual", "pending_confirmation"}: + template_key = self._TEMPLATE_PENDING_MANUAL + action_card = self._build_action_card_from_capture_result( + capture_result={ + "reply_template_key": template_key, + "work_item_id": target_work_item_id, + "draft_version": self._safe_int(normalized.get("draft_version")), + "direct_reply_text": followup_reply, + }, + mention_open_id=sender_open_id, + mention_name=operator_sender, + ) + if isinstance(action_card, dict): + await self._safe_reply_card( + chat_id=chat_id, + card_content=action_card, + trace_id=f"feishu-card-action-{int(work_item_id)}", + source_message_id=source_message_id, + perf_t0=None, + ) + else: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=operator_sender, + text=followup_reply, + trace_id=f"feishu-card-action-{int(work_item_id)}", + source_message_id=source_message_id, + perf_t0=None, + ) + return {"code": 0, "msg": "success", "data": normalized} diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_config.py b/backend/app/services/integrations/feishu_integration_service/mixin_config.py index 0f080c31..e0549242 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_config.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_config.py @@ -135,6 +135,7 @@ class FeishuConfigMixin: 支持的事件类型: - message.receive_v1: 收到群聊消息 - im.message.receive_v1: 收到群聊消息(schema 2.0) + - card.action.trigger: 卡片按钮回调 """ try: event_type = (event_data.get('type') or '').strip() @@ -142,6 +143,8 @@ class FeishuConfigMixin: # 飞书 schema 2.0 会返回 im.message.receive_v1 if event_type in {'message.receive_v1', 'im.message.receive_v1'}: return await self._handle_message_receive(event_data) + if event_type in {'card.action.trigger', 'im.card.action.trigger'}: + return await self._handle_card_action_trigger(event_data) return { 'code': 0, diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive.py index 27d1f788..47455672 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive.py @@ -2,216 +2,96 @@ from __future__ import annotations from .shared import * + class FeishuReceiveMixin: async def _handle_message_receive( self, event: Dict[str, Any] ) -> Dict[str, Any]: - """处理收到的消息事件""" + """处理收到的消息事件(编排层)。""" state_trace_id = f"feishu-sm-{uuid4().hex[:12]}" - ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu" - message = event.get('message', {}) if isinstance(event.get('message', {}), dict) else {} - chat = event.get('chat', {}) if isinstance(event.get('chat', {}), dict) else {} - sender = event.get('sender', {}) if isinstance(event.get('sender', {}), dict) else {} - message_id = (message.get("message_id") or "").strip() or "unknown" - perf_t0_raw = event.get("_perf_t0") - perf_t0: Optional[float] = None - if isinstance(perf_t0_raw, (int, float)): - perf_t0 = float(perf_t0_raw) - else: - try: - perf_t0 = float(str(perf_t0_raw).strip()) - except Exception: - perf_t0 = None - self._emit_state_transition( - trace_id=state_trace_id, - state="RECEIVED", - message_id=message_id, - message_type=str(message.get("message_type") or "unknown"), - ) - sender_type = str(sender.get("sender_type") or "").strip().lower() + context = self._build_receive_context(event=event, state_trace_id=state_trace_id) + message = context["message"] + sender = context["sender"] + message_id = str(context["message_id"]) + sender_type = str(context["sender_type"]) if sender_type in {"app", "bot"}: - return {'code': 0, 'msg': 'Ignore bot/app message'} - - mentioned_bot_names = self._extract_mentioned_bot_names(message) - - # 解析消息内容与附件元数据 - message_payload = self._extract_message_payload(event) - raw_message_content = str(message_payload.get("text") or "").strip() or None - message_content = ( - self._strip_bot_mention_tokens(raw_message_content) - if raw_message_content - else "" - ).strip() or None - message_attachments = ( - message_payload.get("attachments") - if isinstance(message_payload.get("attachments"), list) - else [] - ) + return {"code": 0, "msg": "Ignore bot/app message"} await self._record_ingress_trace( - ingress_source=ingress_source, + ingress_source=str(context["ingress_source"]), trace_id=state_trace_id, message_id=message_id, event=event, - message_content=raw_message_content or "", + message_content=str(context.get("raw_message_content") or ""), ) + message_content = str(context.get("message_content") or "").strip() if not message_content: - print( - f"[Feishu] message.receive_v1 ignored: no text content, " - f"message_type={message.get('message_type')}, keys={list(message.keys())}" + await self._reply_for_empty_text_ingress( + state_trace_id=state_trace_id, + message=message, + sender=sender, + message_id=message_id, + chat_id=str(context.get("chat_id") or ""), + mentioned_bot_names=( + context.get("mentioned_bot_names") + if isinstance(context.get("mentioned_bot_names"), list) + else [] + ), + sender_open_id=context.get("sender_open_id"), + perf_t0=context.get("perf_t0"), ) - chat_id = chat.get('chat_id') or message.get('chat_id') or '' - if mentioned_bot_names and chat_id: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=(sender.get('sender_id', {}) or {}).get('open_id'), - mention_name=(sender.get('sender_name') or sender.get('name') or '你'), - text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。", - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route="empty_text_prompt", - ) - return {'code': 0, 'msg': 'No text content'} + return {"code": 0, "msg": "No text content"} - # 获取发送者信息 - sender_id = sender.get('sender_id', {}) if isinstance(sender.get('sender_id', {}), dict) else {} - sender_open_id = (sender_id.get('open_id') or "").strip() or None sender_name = await self._resolve_sender_display_name( sender=sender, - sender_id=sender_id, + sender_id=( + context.get("sender_id") + if isinstance(context.get("sender_id"), dict) + else {} + ), ) - - # 获取群聊信息 - chat_id = chat.get('chat_id') or message.get('chat_id') or '' - chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group" - captured_at = self._extract_message_datetime(message) - occurred_at_iso = ( - captured_at.isoformat() if isinstance(captured_at, datetime) else None - ) - message_attachments = self._normalize_ingress_attachment_records( - message_attachments, - occurred_at=occurred_at_iso, - source_message_id=(message.get("message_id") or "").strip() or None, - source_chat_id=chat_id or None, - ) - - # 使用信号捕获服务处理 - capture_service = ProactiveSignalCaptureService(self.db) - + chat_id = str(context.get("chat_id") or "") + sender_open_id = context.get("sender_open_id") + message_attachments = self._normalize_message_attachments_from_context(context=context) try: - result = await capture_service.capture_from_feishu( - group_chat_id=chat_id, + result = await self._capture_and_persist_from_ingress( + chat_id=chat_id, message_content=message_content, - sender=sender_name, - tenant_id=self.tenant_id, - proposer_open_id=sender_open_id, - captured_at=captured_at, - mentioned_bot_names=mentioned_bot_names, - feishu_message_id=(message.get("message_id") or "").strip() or None, - chat_type=chat_type, - ) - await self._persist_signal_attachments_from_ingress( - signal_id=result.get("signal_id"), - attachments=message_attachments, + sender_name=sender_name, + sender_open_id=sender_open_id, + captured_at=context.get("captured_at"), + mentioned_bot_names=( + context.get("mentioned_bot_names") + if isinstance(context.get("mentioned_bot_names"), list) + else [] + ), + message_id=(message.get("message_id") or "").strip() or None, + chat_type=str(context.get("chat_type") or "group"), + message_attachments=message_attachments, ) except Exception as exc: - self._emit_state_transition( - trace_id=state_trace_id, - state="ERROR_REPLY", - reason="capture_exception", - error=str(exc), + return await self._handle_capture_exception_from_ingress( + state_trace_id=state_trace_id, + error=exc, + chat_id=chat_id, + sender_open_id=sender_open_id, + sender_name=sender_name, + message_id=message_id, + perf_t0=context.get("perf_t0"), ) - if chat_id: - await self._safe_reply_text( - chat_id=chat_id, - mention_open_id=sender_open_id, - mention_name=sender_name, - text=self._build_error_reply_text(), - trace_id=state_trace_id, - source_message_id=message_id, - perf_t0=perf_t0, - ) - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route="error_template", - ) - return { - "code": 0, - "msg": "error_handled", - "data": { - "status": "error", - "classification": "error", - "ai_trace_id": state_trace_id, - }, - } - - print( - "[Feishu] Message processed: " - f"status={result.get('status')}, " - f"classification={result.get('classification')}, " - f"linked_projects={result.get('linked_project_ids', [])}, " - f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, " - f"ai_trace_id={result.get('ai_trace_id')}" - ) - scene = self._classify_message_scene(capture_result=result) - self._emit_state_transition( - trace_id=state_trace_id, - state="CLASSIFIED", - scene=scene, - status=str(result.get("status") or "unknown"), - classification=str(result.get("classification") or "unknown"), - ) - if str(result.get("status") or "").lower() != "error": - self._emit_state_transition( - trace_id=state_trace_id, - state="COMMITTED", - ai_trace_id=str(result.get("ai_trace_id") or ""), - ) - - reply_route = await self._route_scene_reply( - scene=scene, + return await self._finalize_receive_result( result=result, + state_trace_id=state_trace_id, + message_id=message_id, message_content=message_content, chat_id=chat_id, + chat_type=str(context.get("chat_type") or "group"), sender_open_id=sender_open_id, sender_name=sender_name, - mentioned_bot_names=mentioned_bot_names, - trace_id=state_trace_id, - message_id=message_id, - perf_t0=perf_t0, + mentioned_bot_names=( + context.get("mentioned_bot_names") + if isinstance(context.get("mentioned_bot_names"), list) + else [] + ), + perf_t0=context.get("perf_t0"), ) - - if reply_route != "none": - self._emit_state_transition( - trace_id=state_trace_id, - state="REPLIED", - route=reply_route, - ) - await self._record_message_trace( - trace_id=state_trace_id, - message_id=message_id, - chat_type=chat_type, - message_content=message_content, - capture_result=result, - reply_source=reply_route, - ) - - return { - 'code': 0, - 'msg': 'success', - 'data': { - 'status': result.get('status'), - 'classification': result.get('classification'), - 'linked_project_ids': result.get('linked_project_ids', []), - 'auto_tasks_count': len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else []), - 'ai_trace_id': result.get('ai_trace_id'), - 'state_trace_id': state_trace_id, - 'scene': scene, - }, - } diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py new file mode 100644 index 00000000..931e4762 --- /dev/null +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_capture_flow.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +from .shared import * + + +class FeishuReceiveCaptureFlowMixin: + async def _capture_and_persist_from_ingress( + self, + *, + chat_id: str, + message_content: str, + sender_name: str, + sender_open_id: Optional[str], + captured_at: Optional[datetime], + mentioned_bot_names: List[str], + message_id: str, + chat_type: str, + message_attachments: List[Dict[str, Any]], + ) -> Dict[str, Any]: + capture_service = ProactiveSignalCaptureService(self.db) + result = await capture_service.capture_from_feishu( + group_chat_id=chat_id, + message_content=message_content, + sender=sender_name, + tenant_id=self.tenant_id, + proposer_open_id=sender_open_id, + captured_at=captured_at, + mentioned_bot_names=mentioned_bot_names, + feishu_message_id=message_id, + chat_type=chat_type, + ) + await self._persist_signal_attachments_from_ingress( + signal_id=result.get("signal_id"), + attachments=message_attachments, + ) + return result + + async def _handle_capture_exception_from_ingress( + self, + *, + state_trace_id: str, + error: Exception, + chat_id: str, + sender_open_id: Optional[str], + sender_name: str, + message_id: str, + perf_t0: Optional[float], + ) -> Dict[str, Any]: + self._emit_state_transition( + trace_id=state_trace_id, + state="ERROR_REPLY", + reason="capture_exception", + error=str(error), + ) + if chat_id: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=sender_name, + text=self._build_error_reply_text(), + trace_id=state_trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="error_template", + ) + return { + "code": 0, + "msg": "error_handled", + "data": { + "status": "error", + "classification": "error", + "ai_trace_id": state_trace_id, + }, + } + + async def _finalize_receive_result( + self, + *, + result: Dict[str, Any], + state_trace_id: str, + message_id: str, + message_content: str, + chat_id: str, + chat_type: str, + sender_open_id: Optional[str], + sender_name: str, + mentioned_bot_names: List[str], + perf_t0: Optional[float], + ) -> Dict[str, Any]: + print( + "[Feishu] Message processed: " + f"status={result.get('status')}, " + f"classification={result.get('classification')}, " + f"linked_projects={result.get('linked_project_ids', [])}, " + f"auto_tasks={len(result.get('auto_tasks', []) if isinstance(result.get('auto_tasks', []), list) else [])}, " + f"ai_trace_id={result.get('ai_trace_id')}" + ) + scene = self._classify_message_scene(capture_result=result) + self._emit_state_transition( + trace_id=state_trace_id, + state="CLASSIFIED", + scene=scene, + status=str(result.get("status") or "unknown"), + classification=str(result.get("classification") or "unknown"), + ) + if str(result.get("status") or "").lower() != "error": + self._emit_state_transition( + trace_id=state_trace_id, + state="COMMITTED", + ai_trace_id=str(result.get("ai_trace_id") or ""), + ) + + reply_route = await self._route_scene_reply( + scene=scene, + result=result, + message_content=message_content, + chat_id=chat_id, + sender_open_id=sender_open_id, + sender_name=sender_name, + mentioned_bot_names=mentioned_bot_names, + trace_id=state_trace_id, + message_id=message_id, + perf_t0=perf_t0, + ) + if reply_route != "none": + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route=reply_route, + ) + + await self._record_message_trace( + trace_id=state_trace_id, + message_id=message_id, + chat_type=chat_type, + message_content=message_content, + capture_result=result, + reply_source=reply_route, + ) + return { + "code": 0, + "msg": "success", + "data": { + "status": result.get("status"), + "classification": result.get("classification"), + "linked_project_ids": result.get("linked_project_ids", []), + "auto_tasks_count": len( + result.get("auto_tasks", []) + if isinstance(result.get("auto_tasks", []), list) + else [] + ), + "ai_trace_id": result.get("ai_trace_id"), + "state_trace_id": state_trace_id, + "scene": scene, + }, + } diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py new file mode 100644 index 00000000..ae3241d4 --- /dev/null +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_prepare.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from .shared import * + + +class FeishuReceivePrepareMixin: + @staticmethod + def _extract_perf_t0(event: Dict[str, Any]) -> Optional[float]: + perf_t0_raw = event.get("_perf_t0") + if isinstance(perf_t0_raw, (int, float)): + return float(perf_t0_raw) + try: + return float(str(perf_t0_raw).strip()) + except Exception: + return None + + def _build_receive_context( + self, + *, + event: Dict[str, Any], + state_trace_id: str, + ) -> Dict[str, Any]: + ingress_source = str(event.get("_ingress_source") or "feishu").strip() or "feishu" + message = event.get("message", {}) if isinstance(event.get("message", {}), dict) else {} + chat = event.get("chat", {}) if isinstance(event.get("chat", {}), dict) else {} + sender = event.get("sender", {}) if isinstance(event.get("sender", {}), dict) else {} + message_id = (message.get("message_id") or "").strip() or "unknown" + perf_t0 = self._extract_perf_t0(event) + sender_type = str(sender.get("sender_type") or "").strip().lower() + mentioned_bot_names = self._extract_mentioned_bot_names(message) + message_payload = self._extract_message_payload(event) + raw_message_content = str(message_payload.get("text") or "").strip() or None + message_content = ( + self._strip_bot_mention_tokens(raw_message_content) + if raw_message_content + else "" + ).strip() or None + sender_id = sender.get("sender_id", {}) if isinstance(sender.get("sender_id", {}), dict) else {} + sender_open_id = (sender_id.get("open_id") or "").strip() or None + chat_id = chat.get("chat_id") or message.get("chat_id") or "" + chat_type = str(chat.get("chat_type") or message.get("chat_type") or "").strip().lower() or "group" + captured_at = self._extract_message_datetime(message) + occurred_at_iso = captured_at.isoformat() if isinstance(captured_at, datetime) else None + + self._emit_state_transition( + trace_id=state_trace_id, + state="RECEIVED", + message_id=message_id, + message_type=str(message.get("message_type") or "unknown"), + ) + + return { + "ingress_source": ingress_source, + "message": message, + "chat": chat, + "sender": sender, + "message_id": message_id, + "perf_t0": perf_t0, + "sender_type": sender_type, + "mentioned_bot_names": mentioned_bot_names, + "raw_message_content": raw_message_content, + "message_content": message_content, + "message_attachments": ( + message_payload.get("attachments") + if isinstance(message_payload.get("attachments"), list) + else [] + ), + "sender_id": sender_id, + "sender_open_id": sender_open_id, + "chat_id": chat_id, + "chat_type": chat_type, + "captured_at": captured_at, + "occurred_at_iso": occurred_at_iso, + } + + async def _reply_for_empty_text_ingress( + self, + *, + state_trace_id: str, + message: Dict[str, Any], + sender: Dict[str, Any], + message_id: str, + chat_id: str, + mentioned_bot_names: List[str], + sender_open_id: Optional[str], + perf_t0: Optional[float], + ) -> bool: + if chat_id and mentioned_bot_names: + await self._safe_reply_text( + chat_id=chat_id, + mention_open_id=sender_open_id, + mention_name=(sender.get("sender_name") or sender.get("name") or "你"), + text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。", + trace_id=state_trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + self._emit_state_transition( + trace_id=state_trace_id, + state="REPLIED", + route="empty_text_prompt", + ) + print( + f"[Feishu] message.receive_v1 ignored: no text content, " + f"message_type={message.get('message_type')}, keys={list(message.keys())}" + ) + return True + + def _normalize_message_attachments_from_context( + self, + *, + context: Dict[str, Any], + ) -> List[Dict[str, Any]]: + message = context.get("message") if isinstance(context.get("message"), dict) else {} + chat_id = str(context.get("chat_id") or "") + return self._normalize_ingress_attachment_records( + context.get("message_attachments") + if isinstance(context.get("message_attachments"), list) + else [], + occurred_at=context.get("occurred_at_iso"), + source_message_id=(message.get("message_id") or "").strip() or None, + source_chat_id=chat_id or None, + ) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py index 76e41e3f..4009108d 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_receive_routing.py @@ -55,6 +55,20 @@ class FeishuReceiveRoutingMixin: ) if not deterministic_reply and str(result.get("status") or "").lower() == "error": deterministic_reply = self._build_error_reply_text() + action_card = self._build_action_card_from_capture_result( + capture_result=result, + mention_open_id=sender_open_id, + mention_name=sender_name, + ) + if chat_id and isinstance(action_card, dict): + await self._safe_reply_card( + chat_id=chat_id, + card_content=action_card, + trace_id=trace_id, + source_message_id=message_id, + perf_t0=perf_t0, + ) + return "execute_action_card" if chat_id and deterministic_reply: await self._safe_reply_text( chat_id=chat_id, @@ -156,4 +170,3 @@ class FeishuReceiveRoutingMixin: safe_state = str(state or "unknown") details = ", ".join([f"{k}={v}" for k, v in payload.items()]) if payload else "" print(f"[Feishu-SM][{safe_trace}] state={safe_state}{', ' + details if details else ''}") - diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py index 739098b2..4208ab85 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_outbox.py @@ -3,6 +3,29 @@ from __future__ import annotations from .shared import * class FeishuReplyOutboxMixin: + _CARD_MESSAGE_PREFIX = "__pmflow_feishu_card__:" + + @classmethod + def _is_card_outbox_message(cls, value: Optional[str]) -> bool: + return str(value or "").startswith(cls._CARD_MESSAGE_PREFIX) + + @classmethod + def _encode_outbox_card_message(cls, *, card_content: dict[str, Any]) -> str: + return f"{cls._CARD_MESSAGE_PREFIX}{json.dumps(card_content, ensure_ascii=False, separators=(',', ':'))}" + + @classmethod + def _decode_outbox_card_message(cls, value: Optional[str]) -> Optional[dict[str, Any]]: + raw = str(value or "") + if not raw.startswith(cls._CARD_MESSAGE_PREFIX): + return None + payload_text = raw[len(cls._CARD_MESSAGE_PREFIX):].strip() + if not payload_text: + return None + parsed = json.loads(payload_text) + if isinstance(parsed, dict): + return parsed + return None + async def _safe_reply_text( self, *, @@ -47,6 +70,42 @@ class FeishuReplyOutboxMixin: # 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。 return + async def _safe_reply_card( + self, + *, + chat_id: str, + card_content: dict[str, Any], + trace_id: Optional[str] = None, + source_message_id: Optional[str] = None, + perf_t0: Optional[float] = None, + ) -> None: + try: + payload = self._encode_outbox_card_message(card_content=card_content) + outbox_id = await self._enqueue_reply_outbox( + chat_id=chat_id, + message_text=payload, + trace_id=trace_id, + source_message_id=source_message_id, + ) + perf_t1 = time.perf_counter() + if outbox_id > 0 and perf_t0 is not None: + _OUTBOX_PERF_T0_BY_ID[int(outbox_id)] = float(perf_t0) + if perf_t0 is not None: + print( + f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " + f"delta={perf_t1 - perf_t0:.2f}s " + f"outbox_id={outbox_id}" + ) + else: + print( + f"[PERF] outbox_written ts={now_cn_naive().isoformat()} T1={perf_t1:.6f} " + f"delta=N/A " + f"outbox_id={outbox_id}" + ) + except Exception as exc: + print(f"[Feishu] enqueue card outbox failed: chat_id={chat_id}, error={exc}") + return + async def _enqueue_reply_outbox( self, *, @@ -57,7 +116,10 @@ class FeishuReplyOutboxMixin: ) -> int: async with AsyncSessionLocal() as db: normalized_source_message_id = (source_message_id or "")[:128] or None - normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" + if self._is_card_outbox_message(message_text): + normalized_message_text = (message_text or "")[:12000] + else: + normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" reply_hash = self._build_reply_hash( source_message_id=normalized_source_message_id, message_text=normalized_message_text, @@ -229,10 +291,20 @@ class FeishuReplyOutboxMixin: async with AsyncSessionLocal() as db_send: service = cls(db_send) await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1)) - await service.send_message_to_group( - chat_id=str(claimed.get("chat_id") or ""), - message=str(claimed.get("message_text") or ""), - ) + raw_message = str(claimed.get("message_text") or "") + if cls._is_card_outbox_message(raw_message): + card_content = cls._decode_outbox_card_message(raw_message) + if not isinstance(card_content, dict): + raise ValueError("invalid_outbox_card_payload") + await service.send_interactive_card( + chat_id=str(claimed.get("chat_id") or ""), + card_content=card_content, + ) + else: + await service.send_message_to_group( + chat_id=str(claimed.get("chat_id") or ""), + message=raw_message, + ) except Exception as exc: send_error = str(exc) diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_transport.py b/backend/app/services/integrations/feishu_integration_service/mixin_transport.py index f10c2975..e8846f62 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_transport.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_transport.py @@ -153,17 +153,6 @@ class FeishuTransportMixin: 用于发送格式化的简报、通知等 """ - if not self.is_configured(): - raise ValueError("飞书未配置") - - access_token = await self._get_tenant_access_token() - - url = "https://open.feishu.cn/open-apis/im/v1/messages" - headers = { - 'Authorization': f'Bearer {access_token}', - 'Content-Type': 'application/json' - } - # 构造卡片内容 card_content = { 'config': {'wide_screen_mode': True}, @@ -188,10 +177,30 @@ class FeishuTransportMixin: elif item.get('type') == 'divider': card_content['elements'].append({'tag': 'hr'}) + return await self.send_interactive_card( + chat_id=chat_id, + card_content=card_content, + ) + + async def send_interactive_card( + self, + chat_id: str, + card_content: Dict[str, Any], + ) -> Dict[str, Any]: + """发送交互卡片消息(支持按钮回调 value 透传)。""" + if not self.is_configured(): + raise ValueError("飞书未配置") + + access_token = await self._get_tenant_access_token() + url = "https://open.feishu.cn/open-apis/im/v1/messages" + headers = { + 'Authorization': f'Bearer {access_token}', + 'Content-Type': 'application/json' + } message_data = { 'receive_id': chat_id, 'msg_type': 'interactive', - 'content': json.dumps(card_content) + 'content': json.dumps(card_content, ensure_ascii=False), } async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client: @@ -215,4 +224,3 @@ class FeishuTransportMixin: f"飞书发送失败: code={payload.get('code')} msg={payload.get('msg') or payload.get('message')}" ) return payload - diff --git a/backend/app/services/integrations/feishu_integration_service/service.py b/backend/app/services/integrations/feishu_integration_service/service.py index 33a03497..dd54a7d7 100644 --- a/backend/app/services/integrations/feishu_integration_service/service.py +++ b/backend/app/services/integrations/feishu_integration_service/service.py @@ -1,6 +1,9 @@ from __future__ import annotations +from .mixin_card_action import FeishuCardActionMixin from .mixin_config import FeishuConfigMixin +from .mixin_receive_capture_flow import FeishuReceiveCaptureFlowMixin +from .mixin_receive_prepare import FeishuReceivePrepareMixin from .mixin_message_payload import FeishuMessagePayloadMixin from .mixin_receive import FeishuReceiveMixin from .mixin_receive_routing import FeishuReceiveRoutingMixin @@ -15,7 +18,10 @@ from .shared import AsyncSession class FeishuIntegrationService( + FeishuCardActionMixin, FeishuConfigMixin, + FeishuReceivePrepareMixin, + FeishuReceiveCaptureFlowMixin, FeishuReceiveMixin, FeishuReceiveRoutingMixin, FeishuTraceMixin, diff --git a/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py b/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py index 44dcf67e..792b85ef 100644 --- a/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py +++ b/backend/app/services/integrations/feishu_long_connection_core/mixin_runtime.py @@ -18,6 +18,9 @@ class LongConnectionRuntimeMixin: if not callable(register_receive): raise RuntimeError("当前 lark_oapi 版本不支持 register_p2_im_message_receive_v1") builder = register_receive(self._on_message_event) + register_card_action = getattr(builder, "register_p2_card_action_trigger", None) + if callable(register_card_action): + builder = register_card_action(self._on_message_event) register_read = getattr(builder, "register_p2_im_message_message_read_v1", None) if callable(register_read): @@ -147,7 +150,12 @@ class LongConnectionRuntimeMixin: return event_type = str(event_data.get("type") or "").strip() - if event_type and event_type not in {"im.message.receive_v1", "message.receive_v1"}: + if event_type and event_type not in { + "im.message.receive_v1", + "message.receive_v1", + "card.action.trigger", + "im.card.action.trigger", + }: logger.info(f"feishu_long_connection_event_skipped: event_type={event_type}") return diff --git a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py index 7284ab41..34c74a0b 100644 --- a/backend/app/services/llm_service/mixin_prompt_profile_runtime.py +++ b/backend/app/services/llm_service/mixin_prompt_profile_runtime.py @@ -2,6 +2,11 @@ from __future__ import annotations from .shared import * +AI_CALL_LOG_RESPONSE_MAX_CHARS = 10_000 +AI_CALL_LOG_RESPONSE_TAIL_CHARS = 2_000 +AI_CALL_LOG_TRUNCATION_MARKER = "\n...[TRUNCATED]...\n" + + class LLMPromptProfileRuntimeMixin: async def _resolve_active_prompt_profile(self, db: AsyncSession, *, tenant_id: int) -> dict[str, str]: cached = self._prompt_profile_cache.get(int(tenant_id)) @@ -154,7 +159,7 @@ class LLMPromptProfileRuntimeMixin: "user_id": None, "function_name": function_name, "prompt": prompt[:8000], - "response": (response or "")[:8000], + "response": self._trim_ai_call_response(response), "tokens_used": tokens_used, "cost": cost, "status": status, @@ -174,6 +179,19 @@ class LLMPromptProfileRuntimeMixin: await session.rollback() return + @staticmethod + def _trim_ai_call_response(response: str) -> str: + text = str(response or "") + if len(text) <= AI_CALL_LOG_RESPONSE_MAX_CHARS: + return text + + tail_chars = min(AI_CALL_LOG_RESPONSE_TAIL_CHARS, AI_CALL_LOG_RESPONSE_MAX_CHARS // 3) + head_budget = AI_CALL_LOG_RESPONSE_MAX_CHARS - tail_chars - len(AI_CALL_LOG_TRUNCATION_MARKER) + if head_budget <= 0: + return text[:AI_CALL_LOG_RESPONSE_MAX_CHARS] + + return f"{text[:head_budget]}{AI_CALL_LOG_TRUNCATION_MARKER}{text[-tail_chars:]}" + @staticmethod def _estimate_cost(provider: str, model: str, tokens: int) -> float: _ = model @@ -242,4 +260,3 @@ class LLMPromptProfileRuntimeMixin: return "" cleaned = re.sub(r".*?", "", cleaned, flags=re.IGNORECASE | re.DOTALL) return cleaned.strip() - diff --git a/backend/app/services/signal/draft_generator_service.py b/backend/app/services/signal/draft_generator_service.py new file mode 100644 index 00000000..15f574ce --- /dev/null +++ b/backend/app/services/signal/draft_generator_service.py @@ -0,0 +1,352 @@ +"""Draft generation service for write-operation confirmation.""" + +from __future__ import annotations + +import asyncio +import json +from datetime import timedelta +from typing import Any, Optional + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ...config.ai_prompts import PROMPT_DRAFT_GENERATOR +from ...core.logging import get_logger +from ...models import User, UserWorkload, WorkItem +from ...utils.timezone import now_cn_naive +from ..llm_service import llm_service + +logger = get_logger(__name__) + +_DRAFT_TIMEOUT_HOURS = 24 +_FEASIBILITY_ALLOWED = {"合理", "待讨论", "不合理"} + + +class DraftGeneratorService: + """Generate and persist draft payload for write intents.""" + + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def generate_and_persist( + self, + *, + work_item: WorkItem, + intent_name: str, + message_text: str, + world_model: dict[str, Any], + revision_note: Optional[str] = None, + ) -> dict[str, Any]: + current_version = int(work_item.draft_version or 0) + next_version = current_version + 1 + project = world_model.get("project") if isinstance(world_model.get("project"), dict) else {} + project_title = str(project.get("title") or project.get("name") or "未指定项目").strip() or "未指定项目" + project_description = str(project.get("description") or "").strip() or "暂无项目描述" + + members = await self._load_members(world_model=world_model) + recent_signals = self._normalize_recent_signals(world_model.get("recent_signals")) + prompt = PROMPT_DRAFT_GENERATOR.format( + project_title=project_title, + project_description=project_description, + members_json=json.dumps(members, ensure_ascii=False), + recent_signals=json.dumps(recent_signals, ensure_ascii=False), + user_message=str(message_text or "")[:2000], + intent_name=str(intent_name or "")[:64], + revision_note=str(revision_note or "")[:500], + ).strip() + + payload = await self._generate_draft_payload(prompt=prompt) + draft = self._normalize_draft_payload( + payload=payload, + members=members, + original_text=message_text, + version=next_version, + ) + + expires_at = now_cn_naive() + timedelta(hours=_DRAFT_TIMEOUT_HOURS) + work_item.draft_content = draft + work_item.draft_version = next_version + work_item.draft_expires_at = expires_at + work_item.confirm_message_id = None + + existing_execution = work_item.execution_result if isinstance(work_item.execution_result, dict) else {} + work_item.execution_result = { + **existing_execution, + "status": "draft_pending_confirm", + "draft": draft, + "draft_version": next_version, + "draft_expires_at": expires_at.isoformat(), + "revision_note": str(revision_note or "")[:500] or None, + } + + return { + "draft": draft, + "draft_text": self.render_draft_message(draft), + "draft_expires_at": expires_at, + "draft_version": next_version, + } + + async def _generate_draft_payload(self, *, prompt: str) -> dict[str, Any]: + try: + extraction = await asyncio.wait_for( + llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=self.tenant_id, + function_name="draft_generator_v1", + use_prompt_profile=False, + temperature=0.0, + max_tokens=1400, + response_mode="fast", + ), + timeout=35.0, + ) + except Exception as exc: + logger.warning( + "draft_generator_llm_failed", + tenant_id=self.tenant_id, + error=str(exc), + ) + return {} + if extraction is None or not isinstance(extraction.payload, dict): + return {} + return extraction.payload + + async def _load_members(self, *, world_model: dict[str, Any]) -> list[dict[str, Any]]: + capacity = world_model.get("capacity") if isinstance(world_model.get("capacity"), list) else [] + workload_map: dict[int, float] = {} + for row in capacity: + if not isinstance(row, dict): + continue + user_id = self._safe_int(row.get("user_id")) + if user_id is None: + continue + ratio = self._safe_float(row.get("workload_ratio")) + workload_map[user_id] = max(0.0, min(100.0, ratio * 100.0)) + + user_ids = sorted(workload_map.keys()) + if user_ids: + rows = ( + await self.db.execute( + select(User.id, User.name, User.username) + .where( + and_( + User.tenant_id == int(self.tenant_id), + User.id.in_(user_ids), + User.is_active.is_(True), + ) + ) + ) + ).all() + members = [ + { + "id": int(row.id), + "name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40], + "workload_percent": round(float(workload_map.get(int(row.id), 0.0)), 1), + } + for row in rows + ] + members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0))) + return members[:20] + + rows = ( + await self.db.execute( + select(User.id, User.name, User.username, UserWorkload.workload_ratio) + .outerjoin( + UserWorkload, + and_( + UserWorkload.tenant_id == User.tenant_id, + UserWorkload.user_id == User.id, + ), + ) + .where( + and_( + User.tenant_id == int(self.tenant_id), + User.is_active.is_(True), + ) + ) + .order_by(User.id.asc()) + .limit(20) + ) + ).all() + members = [] + for row in rows: + members.append( + { + "id": int(row.id), + "name": str(row.name or row.username or f"成员#{int(row.id)}").strip()[:40], + "workload_percent": round(max(0.0, min(100.0, self._safe_float(row.workload_ratio) * 100.0)), 1), + } + ) + members.sort(key=lambda item: (float(item.get("workload_percent") or 0.0), int(item.get("id") or 0))) + return members + + @staticmethod + def _normalize_recent_signals(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, Any]] = [] + for row in value[:8]: + if not isinstance(row, dict): + continue + normalized.append( + { + "id": DraftGeneratorService._safe_int(row.get("id")), + "title": str(row.get("title") or "").strip()[:80], + "type": str(row.get("signal_type") or "").strip()[:30], + "status": str(row.get("status") or "").strip()[:20], + } + ) + return normalized + + def _normalize_draft_payload( + self, + *, + payload: dict[str, Any], + members: list[dict[str, Any]], + original_text: str, + version: int, + ) -> dict[str, Any]: + title = self._normalize_title(str(payload.get("title") or "")) + summary = self._normalize_summary(str(payload.get("summary") or ""), fallback_text=original_text) + + feasibility = str(payload.get("feasibility") or "").strip() + if feasibility not in _FEASIBILITY_ALLOWED: + feasibility = "待讨论" + feasibility_reason = str(payload.get("feasibility_reason") or "").strip()[:30] + if not feasibility_reason: + feasibility_reason = "信息仍需补充,建议先确认边界。" + + assignee_map = { + int(item.get("id") or 0): item + for item in members + if self._safe_int(item.get("id")) is not None + } + assignee_id = self._safe_int(payload.get("recommended_assignee_id")) + if assignee_id is None or assignee_id not in assignee_map: + assignee_id = int(members[0]["id"]) if members else None + assignee = assignee_map.get(assignee_id) if assignee_id is not None else None + assignee_reason = str(payload.get("recommended_assignee_reason") or "").strip()[:80] + if not assignee_reason: + assignee_reason = "基于当前负载与任务匹配度给出的建议。" + + followup_plan = self._normalize_followup_plan(payload.get("followup_plan")) + if not followup_plan: + followup_plan = [ + {"when": "明天", "action": "确认触发时机与验收口径"}, + {"when": "一周后", "action": "复核执行效果并决定是否扩展"}, + ] + + workload = 0.0 + if assignee is not None: + workload = float(assignee.get("workload_percent") or 0.0) + + return { + "title": title, + "summary": summary, + "feasibility": feasibility, + "feasibility_reason": feasibility_reason, + "recommended_assignee_id": assignee_id, + "recommended_assignee_name": str((assignee or {}).get("name") or "待指定").strip()[:40] or "待指定", + "recommended_assignee_workload": round(max(0.0, min(100.0, workload)), 1), + "recommended_assignee_reason": assignee_reason, + "followup_plan": followup_plan, + "original_text": str(original_text or "")[:2000], + "version": int(version), + } + + @staticmethod + def render_draft_message(draft: dict[str, Any]) -> str: + title = str(draft.get("title") or "未命名需求").strip() or "未命名需求" + summary = str(draft.get("summary") or "暂无摘要").strip() or "暂无摘要" + feasibility = str(draft.get("feasibility") or "待讨论").strip() or "待讨论" + feasibility_reason = str(draft.get("feasibility_reason") or "").strip() + assignee_name = str(draft.get("recommended_assignee_name") or "待指定").strip() or "待指定" + assignee_workload = DraftGeneratorService._safe_float(draft.get("recommended_assignee_workload")) + assignee_reason = str(draft.get("recommended_assignee_reason") or "").strip() + plan = draft.get("followup_plan") if isinstance(draft.get("followup_plan"), list) else [] + + plan_lines: list[str] = [] + for row in plan[:2]: + if not isinstance(row, dict): + continue + when = str(row.get("when") or "待定").strip()[:20] or "待定" + action = str(row.get("action") or "补充执行动作").strip()[:80] or "补充执行动作" + plan_lines.append(f"· {when}:{action}") + if not plan_lines: + plan_lines = ["· 待定:补充跟进计划"] + + workload_text = f"{assignee_workload:.1f}" if assignee_workload % 1 else f"{int(assignee_workload)}" + feasibility_reason_line = feasibility_reason if feasibility_reason else "需补充约束后再确认。" + assignee_reason_line = assignee_reason if assignee_reason else "建议结合成员技能与当前负载综合判断。" + return "\n".join( + [ + "📋 已为你起草需求,请确认:", + "", + f"📌 {title}", + summary, + "", + f"✅ 可行性:{feasibility}", + feasibility_reason_line, + "", + f"👤 推荐负责人:{assignee_name} · 当前负载{workload_text}%", + assignee_reason_line, + "", + "📅 跟进计划:", + *plan_lines, + "", + "---", + "回复 确认 → 入库", + "回复 修改 [具体修改意见] → 重新起草", + "回复 取消 → 放弃", + "⏰ 24小时内未确认自动取消", + ] + ) + + @staticmethod + def _normalize_title(raw_title: str) -> str: + title = str(raw_title or "").strip() + if not title: + return "需求草稿" + return title[:20] + + @staticmethod + def _normalize_summary(raw_summary: str, *, fallback_text: str) -> str: + summary = " ".join(str(raw_summary or "").split()).strip() + if not summary: + summary = " ".join(str(fallback_text or "").split()).strip() + if not summary: + summary = "请确认该需求是否需要立即入库。" + return summary[:80] + + @staticmethod + def _normalize_followup_plan(value: Any) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + normalized: list[dict[str, str]] = [] + for row in value[:3]: + if not isinstance(row, dict): + continue + when = str(row.get("when") or "").strip()[:20] + action = str(row.get("action") or "").strip()[:80] + if not when or not action: + continue + normalized.append({"when": when, "action": action}) + return normalized + + @staticmethod + def _safe_int(value: Any) -> Optional[int]: + try: + if value is None or value == "": + return None + return int(value) + except Exception: + return None + + @staticmethod + def _safe_float(value: Any) -> float: + try: + return float(value or 0.0) + except Exception: + return 0.0 diff --git a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py index 91bba63c..7f8ce81f 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py +++ b/backend/app/services/signal/signal_capture_service/mixin_capture_entry.py @@ -19,6 +19,8 @@ class SignalCaptureEntryMixin: feishu_message_id: Optional[str] = None, chat_type: Optional[str] = None, force_sync: bool = False, + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ 从飞书群聊捕获信号 @@ -48,20 +50,49 @@ class SignalCaptureEntryMixin: self._emit_ai_think("capture_skip_empty", trace_id=trace_id) return {"status": "ignored", "reason": "empty_or_emoji_only"} + source_message_id = str(feishu_message_id or "").strip() + if not source_message_id: + source_message_id = AIPMOKernelService._build_source_message_id( + source_system="feishu", + source_message_id=None, + channel_ref=group_chat_id, + sender=proposer_name, + message_text=cleaned_message, + ) + kernel = AIPMOKernelService(self.db, tenant_id=tenant_id) try: should_sync_process = bool(force_sync) + enqueue_session = None if not should_sync_process: - session = await kernel._get_or_create_session( + enqueue_session = await kernel._get_or_create_session( tenant_id=int(tenant_id), sender=proposer_name, ) should_sync_process = kernel._is_short_confirmation_message( message_text=cleaned_message, - session=session, + session=enqueue_session, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) if should_sync_process: + effective_message_sent_at = self._resolve_enqueue_message_sent_at( + captured_dt=captured_dt, + message_sent_at=message_sent_at, + ) + effective_session_snapshot = ( + session_snapshot + if isinstance(session_snapshot, dict) + else await self._build_short_confirm_session_snapshot( + session=enqueue_session, + tenant_id=int(tenant_id), + sender=proposer_name, + channel_ref=group_chat_id, + source_message_id=source_message_id, + fallback_snapshot=None, + ) + ) return await self._process_feishu_with_kernel_sync( trace_id=trace_id, tenant_id=tenant_id, @@ -73,6 +104,8 @@ class SignalCaptureEntryMixin: captured_dt=captured_dt, feishu_message_id=feishu_message_id, chat_type=chat_type, + message_sent_at=effective_message_sent_at, + session_snapshot=effective_session_snapshot, ) if self._is_non_requirement_message(cleaned_message) and not self._is_question_like(cleaned_message): @@ -83,15 +116,23 @@ class SignalCaptureEntryMixin: ) return {"status": "ignored", "reason": "non_requirement_noise"} - source_message_id = str(feishu_message_id or "").strip() - if not source_message_id: - source_message_id = AIPMOKernelService._build_source_message_id( - source_system="feishu", - source_message_id=None, - channel_ref=group_chat_id, + if enqueue_session is None: + enqueue_session = await kernel._get_or_create_session( + tenant_id=int(tenant_id), sender=proposer_name, - message_text=cleaned_message, ) + enqueue_message_sent_at = self._resolve_enqueue_message_sent_at( + captured_dt=captured_dt, + message_sent_at=message_sent_at, + ) + enqueue_session_snapshot = await self._build_short_confirm_session_snapshot( + session=enqueue_session, + tenant_id=int(tenant_id), + sender=proposer_name, + channel_ref=group_chat_id, + source_message_id=source_message_id, + fallback_snapshot=session_snapshot, + ) run_row, created = await AIOrchestrationRunService(self.db).enqueue_ingress_run( tenant_id=int(tenant_id), @@ -107,6 +148,14 @@ class SignalCaptureEntryMixin: max_retries=3, ) if created: + base_payload = run_row.result_payload if isinstance(run_row.result_payload, dict) else {} + run_row.result_payload = { + **base_payload, + "task_payload": { + "message_sent_at": enqueue_message_sent_at.isoformat(), + "session_snapshot": enqueue_session_snapshot, + }, + } await AIOrchestrationRunService(self.db).mark_ingress_ack_sent( tenant_id=int(tenant_id), trace_id=str(run_row.trace_id or ""), @@ -187,6 +236,8 @@ class SignalCaptureEntryMixin: captured_dt: datetime, feishu_message_id: Optional[str], chat_type: Optional[str], + message_sent_at: Optional[Any] = None, + session_snapshot: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: pipeline_result = await kernel.process_feishu_message( group_chat_id=group_chat_id, @@ -196,6 +247,8 @@ class SignalCaptureEntryMixin: source_sender_open_id=proposer_open_id, captured_at=captured_dt, chat_type=chat_type, + message_sent_at=message_sent_at, + session_snapshot=session_snapshot, ) if not bool(pipeline_result.get("handled")): self._emit_ai_think( @@ -308,3 +361,135 @@ class SignalCaptureEntryMixin: "decision_level": None, "review_summary": None, } + + @staticmethod + def _resolve_enqueue_message_sent_at( + *, + captured_dt: datetime, + message_sent_at: Optional[Any], + ) -> datetime: + if isinstance(message_sent_at, datetime): + sent_at = message_sent_at + else: + sent_at = captured_dt + if sent_at.tzinfo is None: + return sent_at.replace(tzinfo=CN_TZ) + return sent_at + + @staticmethod + def _coerce_snapshot_datetime(value: Any) -> Optional[datetime]: + if isinstance(value, datetime): + return value + text_value = str(value or "").strip() + if not text_value: + return None + try: + parsed = datetime.fromisoformat(text_value.replace("Z", "+00:00")) + except Exception: + return None + if parsed.tzinfo is None: + return parsed + return parsed.astimezone(CN_TZ).replace(tzinfo=None) + + @staticmethod + def _coerce_optional_int(value: Any) -> Optional[int]: + try: + if value is None: + return None + result = int(value) + return result if result > 0 else None + except Exception: + return None + + async def _load_latest_ingress_context( + self, + *, + tenant_id: int, + sender: str, + channel_ref: str, + source_message_id: Optional[str], + ) -> Dict[str, Any]: + row = ( + await self.db.execute( + text( + """ + SELECT + r.id, + COALESCE(r.captured_at, r.created_at) AS last_message_at, + r.message_text, + w.intent AS resolved_intent, + COALESCE(w.project_id, r.project_hint_id) AS project_id + FROM ai_orchestration_runs r + LEFT JOIN work_items w + ON w.id = r.work_item_id + WHERE r.tenant_id = :tenant_id + AND r.run_type = 'ingress_feishu_message' + AND r.source_system = 'feishu' + AND COALESCE(r.sender, '') = :sender + AND COALESCE(r.channel_ref, '') = :channel_ref + AND (:source_message_id = '' OR COALESCE(r.source_message_id, '') <> :source_message_id) + ORDER BY r.id DESC + LIMIT 1 + """ + ), + { + "tenant_id": int(tenant_id), + "sender": str(sender or "").strip(), + "channel_ref": str(channel_ref or "").strip(), + "source_message_id": str(source_message_id or "").strip(), + }, + ) + ).mappings().first() + return dict(row or {}) + + async def _build_short_confirm_session_snapshot( + self, + *, + session: Optional[Any], + tenant_id: int, + sender: str, + channel_ref: str, + source_message_id: Optional[str], + fallback_snapshot: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + fallback = fallback_snapshot if isinstance(fallback_snapshot, dict) else {} + recent_context = await self._load_latest_ingress_context( + tenant_id=int(tenant_id), + sender=sender, + channel_ref=channel_ref, + source_message_id=source_message_id, + ) + session_last_active = self._coerce_snapshot_datetime(getattr(session, "last_active_at", None)) if session is not None else None + recent_last_active = self._coerce_snapshot_datetime(recent_context.get("last_message_at")) + fallback_last_active = self._coerce_snapshot_datetime(fallback.get("last_active_at")) + + if isinstance(session_last_active, datetime) and isinstance(recent_last_active, datetime): + effective_last_active = recent_last_active if recent_last_active > session_last_active else session_last_active + else: + effective_last_active = recent_last_active or session_last_active or fallback_last_active + + session_last_intent = str(getattr(session, "last_intent", "") or "").strip() if session is not None else "" + recent_last_intent = str(recent_context.get("resolved_intent") or "").strip() + fallback_last_intent = str(fallback.get("last_intent") or "").strip() + effective_last_intent = session_last_intent or recent_last_intent or fallback_last_intent + + recent_message_text = str(recent_context.get("message_text") or "").strip() + session_message_text = str(getattr(session, "last_message_text", "") or "").strip() if session is not None else "" + fallback_message_text = str(fallback.get("last_message_text") or "").strip() + effective_message_text = recent_message_text or session_message_text or fallback_message_text + + if not effective_last_intent and effective_message_text: + effective_last_intent = "query.read_only" + + effective_project_id = ( + self._coerce_optional_int(getattr(session, "current_project_id", None) if session is not None else None) + or self._coerce_optional_int(recent_context.get("project_id")) + or self._coerce_optional_int(fallback.get("current_project_id")) + ) + + return { + "last_intent": effective_last_intent or None, + "last_active_at": effective_last_active.isoformat() if isinstance(effective_last_active, datetime) else None, + "current_project_id": effective_project_id, + "last_message_text": effective_message_text or None, + } diff --git a/backend/app/services/skill/skill_score_service.py b/backend/app/services/skill/skill_score_service.py index 8db130df..8a9f933e 100644 --- a/backend/app/services/skill/skill_score_service.py +++ b/backend/app/services/skill/skill_score_service.py @@ -211,7 +211,7 @@ async def process_task_completion( user_id=operator_user_id or int(task.assignee_id), function_name="skill_score_update", prompt=prompt, - response=(response_text or json.dumps({"deltas": deltas}, ensure_ascii=False))[:8000], + response=(response_text or json.dumps({"deltas": deltas}, ensure_ascii=False))[:10000], tokens_used=max(len(prompt) // 4, 1) + max(len(response_text) // 4, 0), cost=((max(len(prompt) // 4, 1) * 0.00015 + max(len(response_text) // 4, 0) * 0.0006) / 1000), status=status, @@ -219,4 +219,3 @@ async def process_task_completion( ) await db.commit() return {"updated_count": updated, "status": status} - diff --git a/backend/app/services/workload/ai_optimize.py b/backend/app/services/workload/ai_optimize.py index df7ac7c7..82042711 100644 --- a/backend/app/services/workload/ai_optimize.py +++ b/backend/app/services/workload/ai_optimize.py @@ -182,7 +182,7 @@ async def build_ai_optimize_preview( user_id=None, function_name="workload_optimize_prompt", prompt=llm_prompt, - response=(llm_text or "")[:8000], + response=(llm_text or "")[:10000], tokens_used=max(len(llm_prompt) // 4, 1) + max(len(llm_text) // 4, 0), cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000), status=llm_status, @@ -289,4 +289,3 @@ async def apply_ai_optimize_plan( await db.commit() return {"applied_count": applied, "affected_users": len(touched_user_ids)} - diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py new file mode 100644 index 00000000..6540dc0a --- /dev/null +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -0,0 +1,97 @@ +import pytest + +from app.services.adaptive_rag_service.mixin_answer_entry import AdaptiveRAGAnswerEntryMixin +from app.services.adaptive_rag_service.mixin_answer_mainline import AdaptiveRAGAnswerMainlineMixin + + +class _FakeAdaptiveRAGService( + AdaptiveRAGAnswerEntryMixin, + AdaptiveRAGAnswerMainlineMixin, +): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 9 + self.ack_calls = 0 + self.base_calls = 0 + self.base_kwargs: dict[str, object] = {} + + def _normalize_thinking_mode(self, thinking_mode: str) -> str: + return "deep" if str(thinking_mode or "").strip().lower() == "deep" else "normal" + + async def _prepare_answer_runtime(self, **kwargs): + clean_q = str(kwargs.get("clean_q") or "").strip() + action_mode = str(kwargs.get("action_mode") or "").strip().lower() + return { + "session_id": 101, + "turn_index": 2, + "context_session_id": 101, + "conversation": [], + "state_row": None, + "effective_q": clean_q, + "rewrite_meta": {"mode": "direct"}, + "query_type": "explain", + "effective_action_mode": action_mode, + } + + async def _handle_ack_first_requirement(self, **kwargs): + self.ack_calls += 1 + _ = kwargs + return {"answer": "ack-required", "processing_stage": "ack_only"} + + async def _execute_base_answer_mainline(self, **kwargs): + self.base_calls += 1 + self.base_kwargs = dict(kwargs) + return {"answer": "base-answer", "processing_stage": "qa"} + + +@pytest.mark.asyncio +async def test_answer_entry_read_mainline_skips_requirement_ack() -> None: + service = _FakeAdaptiveRAGService() + + result = await service.answer_question( + question="现在有哪些阻塞?", + asked_by=1, + asked_via="pmflow_manual", + action_mode="read", + sync_requirement=True, + ) + + assert result["processing_stage"] == "qa" + assert service.ack_calls == 0 + assert service.base_calls == 1 + + +@pytest.mark.asyncio +async def test_answer_entry_write_mode_uses_requirement_ack_gate() -> None: + service = _FakeAdaptiveRAGService() + + result = await service.answer_question( + question="请同步写入一条风险", + asked_by=1, + asked_via="pmflow_manual", + action_mode="write", + sync_requirement=True, + ) + + assert result["processing_stage"] == "ack_only" + assert service.ack_calls == 1 + assert service.base_calls == 0 + + +@pytest.mark.asyncio +async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None: + service = _FakeAdaptiveRAGService() + + result = await service.answer_question( + question="请在飞书里补一条风险", + asked_by=1, + asked_via="feishu", + action_mode="write", + sync_requirement=True, + ) + + assert result["processing_stage"] == "qa" + assert service.ack_calls == 0 + assert service.base_calls == 1 + assert int(service.base_kwargs["deep_retrieval_steps"]) <= 3 + assert int(service.base_kwargs["deep_vector_top_k"]) <= 8 diff --git a/backend/tests/test_ai_pmo_execution_ledger_contract.py b/backend/tests/test_ai_pmo_execution_ledger_contract.py new file mode 100644 index 00000000..466bb159 --- /dev/null +++ b/backend/tests/test_ai_pmo_execution_ledger_contract.py @@ -0,0 +1,147 @@ +from types import SimpleNamespace + +from app.services.ai_pmo.ai_pmo_intent import ( + INTENT_QUERY, + INTENT_SIGNAL_CREATE, + CanonicalIntent, +) +from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress.ai_pmo_kernel_mixins_ingress_storage import ( + KernelIngressStorageMixin, +) + + +class _FakeStorage(KernelIngressStorageMixin): + def __init__(self) -> None: + self.tenant_id = 9 + + @staticmethod + def _primary_operation_code(intent_name: str) -> str | None: + normalized = str(intent_name or "").strip().lower() + if normalized == "project_signal.discover": + return "project_signal.discover" + if normalized == INTENT_QUERY: + return "query.answer" + return None + + +def _build_intent(name: str, *, project_id: int = 11) -> CanonicalIntent: + return CanonicalIntent( + name=name, + confidence=0.9, + project_id=project_id, + signal_title="", + signal_summary="", + metadata={}, + ) + + +def test_build_execution_action_contract_uses_plan_route_and_result() -> None: + storage = _FakeStorage() + contract = storage._build_execution_action_contract( + intent=_build_intent(INTENT_SIGNAL_CREATE), + workflow_route=SimpleNamespace( + key="project_signal.discover", + action="discover", + project_id=22, + requested_count=3, + ), + decision_payload={ + "execution_plan": { + "project_id": 33, + "actions": [ + {"operation_code": "project_signal.discover", "mode": "write"}, + {"operation_code": "signal.enter", "mode": "write"}, + ], + } + }, + execution_result={"signal_id": 88, "requested_count": 5}, + source_system="feishu", + source_message_id="msg-1", + channel_ref="oc_xxx", + ) + + assert contract["operation_code"] == "project_signal.discover" + assert contract["mode"] == "write" + assert contract["workflow_route"] == "project_signal.discover" + assert contract["workflow_action"] == "discover" + assert contract["params"]["project_id"] == 22 + assert contract["params"]["requested_count"] == 5 + assert contract["params"]["signal_id"] == 88 + assert contract["params"]["source_system"] == "feishu" + assert contract["params"]["source_message_id"] == "msg-1" + assert contract["params"]["channel_ref"] == "oc_xxx" + assert contract["param_source"] == "decision.execution_plan.actions+workflow_route+execution_result" + + +def test_apply_execution_action_contract_sets_uniform_fields() -> None: + storage = _FakeStorage() + applied = storage._apply_execution_action_contract( + intent=_build_intent(INTENT_QUERY, project_id=77), + workflow_route=SimpleNamespace( + key=INTENT_QUERY, + action="answer", + project_id=77, + requested_count=None, + ), + decision_payload={"execution_plan": {"actions": []}}, + execution_result={"status": "succeeded"}, + source_system="pmflow_manual", + source_message_id="manual-1", + channel_ref=None, + ) + + assert applied["action_param_source"] == "decision.execution_plan.actions+workflow_route+execution_result" + assert applied["operation_code"] == "query.answer" + action_contract = applied["action_contract"] + assert action_contract["operation_code"] == "query.answer" + assert action_contract["mode"] == "read_only" + assert action_contract["params"]["project_id"] == 77 + + +def test_execution_ledger_idempotency_key_is_stable() -> None: + key_a = KernelIngressStorageMixin._build_execution_ledger_idempotency_key( + tenant_id=9, + work_item_id=101, + operation_code="task.create", + source_message_id="feishu-msg-abc", + ) + key_b = KernelIngressStorageMixin._build_execution_ledger_idempotency_key( + tenant_id=9, + work_item_id=101, + operation_code="task.create", + source_message_id="feishu-msg-abc", + ) + key_c = KernelIngressStorageMixin._build_execution_ledger_idempotency_key( + tenant_id=9, + work_item_id=102, + operation_code="task.create", + source_message_id="feishu-msg-abc", + ) + + assert key_a == key_b + assert key_a != key_c + assert len(key_a) == 40 + + +def test_normalize_execution_ledger_status_maps_work_item_state() -> None: + assert ( + KernelIngressStorageMixin._normalize_execution_ledger_status( + work_item_state="succeeded", + execution_status="failed_safe", + ) + == "applied" + ) + assert ( + KernelIngressStorageMixin._normalize_execution_ledger_status( + work_item_state="waiting_manual", + execution_status="succeeded", + ) + == "pending_manual" + ) + assert ( + KernelIngressStorageMixin._normalize_execution_ledger_status( + work_item_state="failed", + execution_status="succeeded", + ) + == "failed" + ) diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py new file mode 100644 index 00000000..6676951d --- /dev/null +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -0,0 +1,251 @@ +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.api.ai_pmo_api import routes_core +from app.api.ai_pmo_api.schemas import PMOProcessTextRequest +from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry +from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project + + +@pytest.mark.asyncio +async def test_process_text_route_uses_pmflow_manual_ingress(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + captured["init_db"] = db + captured["init_tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "classification": "question", + "direct_reply_required": True, + "direct_reply_text": "当前主链正常。", + } + + monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) + + request = PMOProcessTextRequest( + message_text="现在进展怎么样?", + sender="产品经理A", + source_message_id="manual-1", + project_id=11, + ) + current_user = SimpleNamespace(name="ignored", username="ignored") + + response = await routes_core.process_text( + request=request, + current_user=current_user, + db=object(), + tenant_id=5, + ) + + assert response["code"] == 0 + data = response["data"] + assert data["status"] == "answered" + kwargs = captured["ingress_kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["source_system"] == "pmflow_manual" + assert kwargs["source_message_id"] == "manual-1" + assert kwargs["message_text"] == "现在进展怎么样?" + assert kwargs["sender"] == "产品经理A" + assert kwargs["channel_ref"] == "pmflow_manual" + assert kwargs["chat_type"] == "manual" + assert kwargs["project_hint_id"] == 11 + + +@pytest.mark.asyncio +async def test_process_text_route_rejects_unhandled_result(monkeypatch) -> None: + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def process_ingress(self, **kwargs): + _ = kwargs + return {"handled": False, "reason": "kernel_not_handled"} + + monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) + request = PMOProcessTextRequest(message_text="hello") + current_user = SimpleNamespace(name="u", username="u") + + with pytest.raises(HTTPException) as exc_info: + await routes_core.process_text( + request=request, + current_user=current_user, + db=object(), + tenant_id=1, + ) + assert exc_info.value.status_code == 400 + assert str(exc_info.value.detail) == "kernel_not_handled" + + +@pytest.mark.asyncio +async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + captured["tenant_id"] = tenant_id + _ = db + + async def classify_intent(self, text: str): + captured["classified_text"] = text + return { + "intent": "mixed", + "question_part": "现在进展如何", + "statement_part": "新增任务确认流", + } + + async def answer_question(self, **kwargs): + captured["answer_kwargs"] = kwargs + return {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 9 + + monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) + kernel = FakeKernel() + clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( + source_system="pmflow_manual", + source_message_id="manual-msg-2", + channel_ref="pmflow-chat", + message_text="顺便问下现在进展如何,并且新增任务确认流", + project_hint_id=22, + ) + + assert clean_text == "新增任务确认流" + assert mixed_meta["mixed_intent"] is True + assert mixed_meta["mixed_question_part"] == "现在进展如何" + assert mixed_meta["mixed_signal_part"] == "新增任务确认流" + assert "[回答] 进展稳定" in str(mixed_meta.get("mixed_sidecar_reply") or "") + answer_kwargs = captured["answer_kwargs"] + assert isinstance(answer_kwargs, dict) + assert answer_kwargs["asked_via"] == "pmflow_manual" + assert answer_kwargs["message_intent"] == "mixed" + assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" + assert answer_kwargs["conversation_key"] == "manual-msg-2" + + +@pytest.mark.asyncio +async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.draft_called = False + + async def _prepare_ingress_envelope(self, **kwargs): + _ = kwargs + return { + "early_result": None, + "clean_text": "确认", + "synthetic_message_id": "s-msg-1", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=100), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=55), + "session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), + } + + async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): + self.draft_called = True + _ = kwargs + return { + "handled": True, + "status": "answered", + "classification": "command", + "direct_reply_required": True, + "direct_reply_text": "已确认草稿并继续执行。", + } + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + return False + + async def _prepare_mixed_sidecar_context(self, **kwargs): + _ = kwargs + raise AssertionError("draft_pending_confirm flow should bypass mixed sidecar precheck") + + kernel = FakeKernel() + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="feishu-1", + message_text="确认", + sender="tester", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + ) + + assert kernel.draft_called is True + assert result["status"] == "answered" + assert "草稿" in result["direct_reply_text"] + + +@pytest.mark.asyncio +async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def answer_question(self, **kwargs): + captured["answer_kwargs"] = kwargs + return {"answer": "来自统一 ingress 的回答", "session_id": 7} + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 3 + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + intent = SimpleNamespace( + project_id=12, + metadata={ + "rag_request_context": { + "asked_by": 101, + "asked_via": "pmflow_manual", + "project_id": 19, + "source_chat_id": "chat-abc", + "session_id": 55, + "force_new_session": True, + "thinking_mode": "deep", + "action_mode": "read_write", + "sync_requirement": True, + "message_intent": "mixed", + "mixed_signal_part": "新增任务确认流", + } + }, + ) + result = await executor._execute_query_intent( + intent=intent, + message_text="请同步更新状态", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-1", + ) + + assert result["status"] == "succeeded" + assert "统一 ingress" in result["answer"] + answer_kwargs = captured["answer_kwargs"] + assert isinstance(answer_kwargs, dict) + assert answer_kwargs["asked_by"] == 101 + assert answer_kwargs["asked_via"] == "pmflow_manual" + assert answer_kwargs["project_id"] == 19 + assert answer_kwargs["source_chat_id"] == "chat-abc" + assert answer_kwargs["session_id"] == 55 + assert answer_kwargs["force_new_session"] is True + assert answer_kwargs["thinking_mode"] == "deep" + assert answer_kwargs["action_mode"] == "read_write" + assert answer_kwargs["sync_requirement"] is True + assert answer_kwargs["message_intent"] == "mixed" + assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" diff --git a/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py new file mode 100644 index 00000000..1b3042c7 --- /dev/null +++ b/backend/tests/test_ai_pmo_qa_http_mainline_e2e.py @@ -0,0 +1,168 @@ +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient + +from app.api.ai_pmo_api import routes_core +from app.api.rag_api import routes_query +from app.core.database import get_db +from app.dependencies.auth import get_current_user +from app.dependencies.tenant import get_tenant_id + + +def _build_test_app() -> FastAPI: + app = FastAPI() + app.include_router(routes_query.router) + app.include_router(routes_core.router) + + async def _fake_get_db(): + yield object() + + app.dependency_overrides[get_db] = _fake_get_db + app.dependency_overrides[get_tenant_id] = lambda: 9 + app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( + id=101, + tenant_id=9, + name="测试用户", + username="tester", + ) + return app + + +@pytest.mark.asyncio +async def test_http_rag_ask_readonly_mainline_contract(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "intent": "query.read_only", + "direct_reply_required": True, + "direct_reply_text": "当前主链状态正常。", + "rag_result": {"answer": "当前主链状态正常。", "processing_stage": "qa"}, + } + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/rag/ask", + json={ + "question": "当前主链状态?", + "asked_via": "pmflow_manual", + "action_mode": "read", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + assert "当前主链状态正常" in str((payload.get("data") or {}).get("answer") or "") + ingress_kwargs = captured["ingress_kwargs"] + assert isinstance(ingress_kwargs, dict) + assert ingress_kwargs["source_system"] == "pmflow_manual" + rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] + assert rag_request_context["asked_via"] == "pmflow_manual" + assert rag_request_context["action_mode"] == "read" + assert rag_request_context["message_intent"] is None + + +@pytest.mark.asyncio +async def test_http_rag_ask_mixed_mainline_contract(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "intent": "query.read_only", + "direct_reply_required": True, + "direct_reply_text": "[回答] 进展稳定\n[已收到需求] 新增任务确认流", + "rag_result": {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"}, + } + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/rag/ask", + json={ + "question": "顺便问进展并新增任务确认流", + "asked_via": "pmflow_manual", + "action_mode": "read", + "message_intent": "mixed", + "mixed_signal_part": "新增任务确认流", + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + assert "[回答]" in str((payload.get("data") or {}).get("answer") or "") + ingress_kwargs = captured["ingress_kwargs"] + assert isinstance(ingress_kwargs, dict) + rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] + assert rag_request_context["message_intent"] == "mixed" + assert rag_request_context["mixed_signal_part"] == "新增任务确认流" + + +@pytest.mark.asyncio +async def test_http_ai_pmo_process_text_draft_pending_confirm_mainline(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "draft_pending_confirm", + "classification": "command", + "direct_reply_required": True, + "direct_reply_text": "草稿已生成,请确认。", + "draft_version": 2, + } + + monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) + app = _build_test_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/ai-pmo/process-text", + json={ + "message_text": "请创建任务并先给草稿", + "sender": "产品经理A", + "source_message_id": "manual-msg-100", + "project_id": 12, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["code"] == 0 + data = payload["data"] + assert data["status"] == "draft_pending_confirm" + kwargs = captured["ingress_kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["source_system"] == "pmflow_manual" + assert kwargs["message_text"] == "请创建任务并先给草稿" + assert kwargs["project_hint_id"] == 12 diff --git a/backend/tests/test_ai_pmo_tactical_brain_contract.py b/backend/tests/test_ai_pmo_tactical_brain_contract.py new file mode 100644 index 00000000..f70f076a --- /dev/null +++ b/backend/tests/test_ai_pmo_tactical_brain_contract.py @@ -0,0 +1,73 @@ +import pytest + +from app.services.ai_pmo.ai_pmo_decision.tactical import TacticalBrain +from app.services.ai_pmo.ai_pmo_intent import ( + INTENT_FOLLOWUP_CONTINUE, + INTENT_QUERY, + INTENT_SIGNAL_CREATE, + ROUTE_PROJECT_SIGNAL_CANCEL, + ROUTE_PROJECT_SIGNAL_COMMIT, + ROUTE_PROJECT_SIGNAL_DISCOVER, + ROUTE_PROJECT_SIGNAL_REFRESH, + CanonicalIntent, +) + + +def _build_intent(name: str, *, metadata: dict | None = None) -> CanonicalIntent: + return CanonicalIntent( + name=name, + confidence=0.9, + project_id=11, + signal_title="", + signal_summary="", + metadata=metadata or {}, + ) + + +def test_tactical_brain_query_intent_maps_to_read_only_answer_action() -> None: + _strategic_plan, execution_plan = TacticalBrain.plan( + intent=_build_intent(INTENT_QUERY), + world_model={}, + decision={}, + ) + assert execution_plan["intent"] == INTENT_QUERY + assert execution_plan["actions"] == [{"operation_code": "query.answer", "mode": "read_only"}] + + +def test_tactical_brain_followup_intent_maps_to_resume_action() -> None: + _strategic_plan, execution_plan = TacticalBrain.plan( + intent=_build_intent(INTENT_FOLLOWUP_CONTINUE), + world_model={}, + decision={}, + ) + assert execution_plan["actions"] == [{"operation_code": "work_item.resume", "mode": "write"}] + + +@pytest.mark.parametrize( + ("workflow_route", "expected_operation_code"), + [ + (ROUTE_PROJECT_SIGNAL_DISCOVER, ROUTE_PROJECT_SIGNAL_DISCOVER), + (ROUTE_PROJECT_SIGNAL_COMMIT, ROUTE_PROJECT_SIGNAL_COMMIT), + (ROUTE_PROJECT_SIGNAL_REFRESH, ROUTE_PROJECT_SIGNAL_REFRESH), + (ROUTE_PROJECT_SIGNAL_CANCEL, ROUTE_PROJECT_SIGNAL_CANCEL), + ], +) +def test_tactical_brain_signal_create_route_specific_action_mapping( + workflow_route: str, + expected_operation_code: str, +) -> None: + _strategic_plan, execution_plan = TacticalBrain.plan( + intent=_build_intent(INTENT_SIGNAL_CREATE, metadata={"workflow_route": workflow_route}), + world_model={}, + decision={}, + ) + assert execution_plan["actions"] == [{"operation_code": expected_operation_code, "mode": "write"}] + + +def test_tactical_brain_signal_create_defaults_to_signal_enter() -> None: + _strategic_plan, execution_plan = TacticalBrain.plan( + intent=_build_intent(INTENT_SIGNAL_CREATE, metadata={"workflow_route": "project_signal.unknown"}), + world_model={}, + decision={}, + ) + assert execution_plan["actions"] == [{"operation_code": "signal.enter", "mode": "write"}] diff --git a/backend/tests/test_ai_pmo_task_intent_contract.py b/backend/tests/test_ai_pmo_task_intent_contract.py new file mode 100644 index 00000000..bb87dc35 --- /dev/null +++ b/backend/tests/test_ai_pmo_task_intent_contract.py @@ -0,0 +1,119 @@ +import pytest + +from app.services.ai_pmo.ai_pmo_intent import ( + INTENT_FOLLOWUP_CONTINUE, + INTENT_QUERY, + INTENT_TASK_CREATE, + ROUTE_ACTION_ANSWER, + ROUTE_ACTION_CANCEL, + ROUTE_ACTION_CREATE, + ROUTE_FAMILY_QUERY, + ROUTE_FAMILY_TASK, + ROUTE_QUERY_ANSWER, + ROUTE_TASK_CREATE, + CanonicalIntent, + IntentResolver, + RouteResolver, +) +from app.services.ai_pmo.ai_pmo_typed_executor_execute.service import TypedExecutorExecuteMixin + + +def test_intent_resolver_recognizes_task_create() -> None: + intent = IntentResolver.resolve( + text="请在项目#12 新建任务:修复登录超时", + project_id=12, + ) + assert intent.name == INTENT_TASK_CREATE + assert intent.project_id == 12 + assert str((intent.metadata or {}).get("task_title") or "").startswith("修复登录超时") + + +def test_route_resolver_builds_task_create_route() -> None: + route = RouteResolver.from_canonical( + canonical_intent=CanonicalIntent( + name=INTENT_TASK_CREATE, + confidence=0.9, + project_id=3, + signal_title="修复登录超时", + signal_summary="修复线上登录超时问题", + metadata={}, + ), + message_text="新增任务:修复登录超时", + ) + assert route.family == ROUTE_FAMILY_TASK + assert route.action == ROUTE_ACTION_CREATE + assert route.key == ROUTE_TASK_CREATE + + +def test_intent_resolver_negated_write_statement_falls_back_to_query_read_only() -> None: + intent = IntentResolver.resolve( + text="先不要创建任务,告诉我当前进展和风险", + project_id=7, + ) + assert intent.name == INTENT_QUERY + assert str((intent.metadata or {}).get("reason") or "") == "negated_write_statement" + + +def test_route_resolver_builds_query_answer_route() -> None: + route = RouteResolver.from_canonical( + canonical_intent=CanonicalIntent( + name=INTENT_QUERY, + confidence=0.92, + project_id=7, + signal_title="", + signal_summary="当前进展和风险", + metadata={}, + ), + message_text="当前进展和风险是什么?", + ) + assert route.family == ROUTE_FAMILY_QUERY + assert route.action == ROUTE_ACTION_ANSWER + assert route.key == ROUTE_QUERY_ANSWER + + +def test_route_resolver_followup_action_supports_message_text_fallback() -> None: + action = RouteResolver.parse_followup_action( + canonical_intent=INTENT_FOLLOWUP_CONTINUE, + message_text="先取消这个工作项", + ) + assert action == ROUTE_ACTION_CANCEL + + +class _DummyTypedExecutor(TypedExecutorExecuteMixin): + def __init__(self) -> None: + self.called: dict[str, object] = {} + + async def _execute_task_create_intent(self, *, intent, message_text: str, operator_user_id): + self.called = { + "intent": intent.name, + "message_text": message_text, + "operator_user_id": operator_user_id, + } + return {"status": "succeeded", "task_id": 77} + + +@pytest.mark.asyncio +async def test_typed_executor_dispatches_task_create() -> None: + executor = _DummyTypedExecutor() + intent = CanonicalIntent( + name=INTENT_TASK_CREATE, + confidence=0.88, + project_id=6, + signal_title="补充埋点", + signal_summary="补充行为埋点以支持漏斗分析", + metadata={"task_title": "补充埋点"}, + ) + + result = await executor.execute( + intent=intent, + message_text="请创建任务:补充埋点", + sender="alice", + operator_user_id=101, + source_system="feishu", + source_message_id="msg-1", + ) + + assert result["status"] == "succeeded" + assert result["task_id"] == 77 + assert executor.called["intent"] == INTENT_TASK_CREATE + assert executor.called["operator_user_id"] == 101 diff --git a/backend/tests/test_ai_service_core_runtime.py b/backend/tests/test_ai_service_core_runtime.py new file mode 100644 index 00000000..ab969cd2 --- /dev/null +++ b/backend/tests/test_ai_service_core_runtime.py @@ -0,0 +1,69 @@ +import pytest + +from app.services.ai_service_core import AIService +from app.services.llm_service import llm_service + + +@pytest.mark.asyncio +async def test_invoke_with_retry_uses_llm_service(monkeypatch) -> None: + service = AIService() + captured: dict[str, object] = {} + + async def fake_generate(prompt: str, **kwargs): + captured["prompt"] = prompt + captured["kwargs"] = kwargs + return "ok-from-llm-service" + + monkeypatch.setattr(llm_service, "generate", fake_generate) + result = await service._invoke_with_retry( + "hello", + tenant_id="2", + function_name="unit_test_invoke", + use_cache=False, + ) + + assert result == "ok-from-llm-service" + assert captured["prompt"] == "hello" + kwargs = captured["kwargs"] + assert isinstance(kwargs, dict) + assert kwargs.get("tenant_id") == 2 + assert kwargs.get("function_name") == "unit_test_invoke" + assert kwargs.get("use_prompt_profile") is True + + +@pytest.mark.asyncio +async def test_invoke_with_retry_returns_cached_value_without_llm(monkeypatch) -> None: + service = AIService() + + async def fake_cache_get(key: str): + _ = key + return "cached-hit" + + async def fake_generate(prompt: str, **kwargs): + raise AssertionError("llm_service.generate should not be called when cache hit") + + monkeypatch.setattr(service, "_cache_get", fake_cache_get) + monkeypatch.setattr(llm_service, "generate", fake_generate) + result = await service._invoke_with_retry( + "cacheable", + function_name="unit_test_cache", + use_cache=True, + ) + assert result == "cached-hit" + + +@pytest.mark.asyncio +async def test_stream_chat_yields_single_chunk_from_invoke(monkeypatch) -> None: + service = AIService() + + async def fake_invoke(prompt: str, **kwargs): + _ = (prompt, kwargs) + return "stream-result" + + monkeypatch.setattr(service, "_invoke_with_retry", fake_invoke) + + chunks = [] + async for chunk in service.stream_chat("hi"): + chunks.append(chunk) + + assert chunks == ["stream-result"] diff --git a/backend/tests/test_context_cache_profile_catalog.py b/backend/tests/test_context_cache_profile_catalog.py new file mode 100644 index 00000000..77d3af91 --- /dev/null +++ b/backend/tests/test_context_cache_profile_catalog.py @@ -0,0 +1,23 @@ +from app.services.context_cache_service.mixin_profile_basics import ContextCacheProfileBasicsMixin +from app.services.context_cache_service.mixin_profile_route_parser import ContextCacheProfileRouteParserMixin + + +def _find_route(items: list[dict], route: str) -> dict: + for item in items: + if str(item.get("route") or "") == route: + return item + raise AssertionError(f"route not found in catalog: {route}") + + +def test_default_feature_catalog_uses_canonical_rag_ask() -> None: + items = ContextCacheProfileBasicsMixin._default_feature_catalog_items() + ai_steward_item = _find_route(items, "/ai-steward") + apis = list(ai_steward_item.get("apis") or []) + assert "/rag/ask" in apis + assert "/ai-steward/rag/ask" not in apis + + +def test_route_feature_summary_uses_canonical_rag_ask() -> None: + _summary, apis, _aliases = ContextCacheProfileRouteParserMixin._route_feature_summary("/ai-steward") + assert "/rag/ask" in apis + assert "/ai-steward/rag/ask" not in apis diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py new file mode 100644 index 00000000..24609902 --- /dev/null +++ b/backend/tests/test_draft_confirm_handler.py @@ -0,0 +1,90 @@ +from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler +from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto +from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin + + +def test_parse_action_confirm() -> None: + action, note = DraftConfirmHandler.parse_action_from_text(message_text="确认") + assert action == "confirm" + assert note == "" + + +def test_parse_action_revise_with_note() -> None: + action, note = DraftConfirmHandler.parse_action_from_text(message_text="修改 改成每周跟进") + assert action == "revise" + assert note == "改成每周跟进" + + +def test_parse_action_cancel() -> None: + action, note = DraftConfirmHandler.parse_action_from_text(message_text="取消") + assert action == "cancel" + assert note == "" + + +def test_parse_action_yes_as_confirm() -> None: + action, note = DraftConfirmHandler.parse_action_from_text(message_text="yes") + assert action == "confirm" + assert note == "" + + +def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: + assert callable(mixin_auto._safe_int) + assert mixin_auto._safe_int("123") == 123 + assert mixin_auto._safe_int("") is None + assert mixin_auto._safe_int(None) is None + + +def test_adaptive_rag_process_entry_and_mixed_format() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + assert mixin._is_ai_pmo_process_entry("pmflow_manual") is True + assert mixin._is_ai_pmo_process_entry("feishu") is False + + mixed = mixin._format_mixed_two_block_output( + answer_text="[回答] 接口契约已对齐\n[已收到需求] 新增任务创建确认流", + question_part="现在进展怎么样", + signal_part="补齐任务确认", + ) + assert mixed.startswith("[回答] ") + assert "\n[已收到需求] " in mixed + assert mixed.endswith(",已入库处理") + + +def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="现在进展怎么样?", + project_id=7, + asked_via="pmflow_manual", + message_intent="mixed", + mixed_signal_part="新增任务确认流", + conversation_text="无", + context_blocks_text="无", + system_snapshot={}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + assert scene_key == "mixed" + assert "现在进展怎么样" in prompt + assert "新增任务确认流" in prompt + + +def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None: + mixin = AdaptiveRAGDeepGuardAnswerMixin() + scene_key, prompt = mixin._select_scene_prompt( + question="帮我总结这个项目", + project_id=9, + asked_via="pmflow_manual", + message_intent=None, + mixed_signal_part=None, + conversation_text="无", + context_blocks_text="无", + system_snapshot={}, + user_snapshot={}, + system_snapshot_text="{}", + user_snapshot_text="{}", + relevance_max=0.0, + ) + assert scene_key == "ai_pmo" + assert "帮我总结这个项目" in prompt diff --git a/backend/tests/test_feishu_card_action_contract.py b/backend/tests/test_feishu_card_action_contract.py new file mode 100644 index 00000000..24a16166 --- /dev/null +++ b/backend/tests/test_feishu_card_action_contract.py @@ -0,0 +1,156 @@ +import pytest + +from app.services.integrations.feishu_integration_service.mixin_card_action import FeishuCardActionMixin +from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService + + +def test_extract_card_action_payload_parses_dict_value() -> None: + payload = FeishuCardActionMixin._extract_card_action_payload( + { + "open_message_id": "om_123", + "open_chat_id": "oc_123", + "operator": {"open_id": "ou_abc", "name": "测试用户"}, + "action": { + "value": { + "action": "confirm_draft", + "work_item_id": 88, + "draft_version": 3, + "revision_note": "保持现有范围", + } + }, + } + ) + assert payload["action"] == "confirm" + assert payload["work_item_id"] == 88 + assert payload["draft_version"] == 3 + assert payload["source_message_id"] == "om_123" + assert payload["chat_id"] == "oc_123" + assert payload["sender_open_id"] == "ou_abc" + + +def test_extract_card_action_payload_parses_json_string_value_and_aliases() -> None: + payload = FeishuCardActionMixin._extract_card_action_payload( + { + "message_id": "msg_1", + "chat_id": "chat_9", + "operator": {"user_id": "ou_user", "display_name": "张三"}, + "action": {"value": '{"draft_action":"修改","workItemId":"100","version":"4","note":"改成下周上线"}'}, + } + ) + assert payload["action"] == "revise" + assert payload["work_item_id"] == 100 + assert payload["draft_version"] == 4 + assert payload["revision_note"] == "改成下周上线" + assert payload["source_message_id"] == "msg_1" + assert payload["chat_id"] == "chat_9" + + +@pytest.mark.asyncio +async def test_handle_message_event_dispatches_card_action(monkeypatch) -> None: + service = FeishuIntegrationService(db=object()) + called: dict[str, object] = {} + + async def _fake_card_handler(event): + called["event_type"] = event.get("type") + return {"code": 0, "msg": "success", "data": {"handled": True}} + + monkeypatch.setattr(service, "_handle_card_action_trigger", _fake_card_handler) + + result = await service.handle_message_event({"type": "card.action.trigger", "event": {}}) + assert result["code"] == 0 + assert called["event_type"] == "card.action.trigger" + + +def test_build_action_card_for_draft_pending_confirm() -> None: + card = FeishuCardActionMixin._build_action_card_from_capture_result( + capture_result={ + "reply_template_key": "draft_pending_confirm", + "work_item_id": 321, + "draft_version": 5, + "direct_reply_text": "请确认草稿内容。", + }, + mention_open_id="ou_xxx", + mention_name="测试同学", + ) + assert isinstance(card, dict) + assert card["header"]["title"]["content"] == "AI PMO 草稿待确认" + action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {}) + actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else [] + action_names = {((btn.get("value") or {}).get("action")) for btn in actions if isinstance(btn, dict)} + assert {"confirm", "revise", "cancel"} <= action_names + + +def test_build_action_card_for_pending_manual_continue() -> None: + card = FeishuCardActionMixin._build_action_card_from_capture_result( + capture_result={ + "reply_template_key": "pending_manual", + "results": [{"work_item_id": 99}], + "direct_reply_text": "需要继续执行。", + }, + mention_open_id=None, + mention_name=None, + ) + assert isinstance(card, dict) + assert card["header"]["title"]["content"] == "AI PMO 待继续执行" + action_row = next((item for item in card["elements"] if item.get("tag") == "action"), {}) + actions = action_row.get("actions") if isinstance(action_row.get("actions"), list) else [] + assert len(actions) == 1 + assert (actions[0].get("value") or {}).get("action") == "continue" + assert (actions[0].get("value") or {}).get("work_item_id") == 99 + + +@pytest.mark.asyncio +async def test_handle_card_action_trigger_continue_dispatches_resume(monkeypatch) -> None: + called: dict[str, object] = {} + + class DummyDB: + async def commit(self) -> None: + called["committed"] = True + + async def rollback(self) -> None: + called["rolled_back"] = True + + service = FeishuIntegrationService(db=DummyDB()) + service.tenant_id = 7 + + async def _fake_reply_text(**kwargs): + called["reply_text"] = kwargs.get("text") + called["reply_chat"] = kwargs.get("chat_id") + + monkeypatch.setattr(service, "_safe_reply_text", _fake_reply_text) + + class _FakeKernel: + def __init__(self, db, tenant_id): + called["kernel_tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + called["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "captured", + "direct_reply_text": "已继续执行。", + } + + monkeypatch.setattr( + "app.services.ai_pmo.ai_pmo_kernel.ai_pmo_kernel_runtime.AIPMOKernelService", + _FakeKernel, + ) + + result = await service._handle_card_action_trigger( + { + "open_message_id": "om_continue_1", + "open_chat_id": "oc_continue_1", + "operator": {"open_id": "ou_continue", "name": "继续用户"}, + "action": {"value": {"action": "continue", "work_item_id": 55}}, + } + ) + + assert result["code"] == 0 + data = result.get("data") if isinstance(result.get("data"), dict) else {} + assert data.get("handled") is True + assert data.get("target_work_item_id") == 55 + ingress_kwargs = called["ingress_kwargs"] + assert ingress_kwargs["message_text"] == "继续处理 work_item #55" + assert ingress_kwargs["source_system"] == "ai_pmo_ui" + assert called["reply_text"] == "已继续执行。" + assert called["reply_chat"] == "oc_continue_1" diff --git a/backend/tests/test_feishu_receive_orchestration_contract.py b/backend/tests/test_feishu_receive_orchestration_contract.py new file mode 100644 index 00000000..6c474555 --- /dev/null +++ b/backend/tests/test_feishu_receive_orchestration_contract.py @@ -0,0 +1,118 @@ +import pytest + +from app.services.integrations.feishu_integration_service.service import FeishuIntegrationService + + +@pytest.mark.asyncio +async def test_handle_message_receive_ignores_bot_sender() -> None: + service = FeishuIntegrationService(db=object()) + result = await service._handle_message_receive( + { + "message": {"message_id": "m-bot"}, + "sender": {"sender_type": "bot"}, + "chat": {"chat_id": "c1"}, + } + ) + assert result["code"] == 0 + assert result["msg"] == "Ignore bot/app message" + + +@pytest.mark.asyncio +async def test_handle_message_receive_routes_empty_text_branch(monkeypatch) -> None: + service = FeishuIntegrationService(db=object()) + called: dict[str, object] = {} + + def _fake_build_receive_context(*, event, state_trace_id): # noqa: ARG001 + return { + "ingress_source": "feishu", + "message": {"message_id": "m-empty", "message_type": "post"}, + "sender": {"sender_name": "测试用户"}, + "message_id": "m-empty", + "sender_type": "user", + "raw_message_content": "", + "message_content": "", + "chat_id": "chat-empty", + "mentioned_bot_names": ["PMFlow"], + "sender_open_id": "ou-empty", + "perf_t0": None, + } + + async def _fake_record_ingress_trace(**kwargs): + called["trace"] = kwargs + + async def _fake_reply_for_empty_text_ingress(**kwargs): + called["empty_reply"] = kwargs + return True + + monkeypatch.setattr(service, "_build_receive_context", _fake_build_receive_context) + monkeypatch.setattr(service, "_record_ingress_trace", _fake_record_ingress_trace) + monkeypatch.setattr(service, "_reply_for_empty_text_ingress", _fake_reply_for_empty_text_ingress) + + result = await service._handle_message_receive({"type": "im.message.receive_v1"}) + assert result["code"] == 0 + assert result["msg"] == "No text content" + assert called["trace"]["message_id"] == "m-empty" + assert called["empty_reply"]["chat_id"] == "chat-empty" + + +@pytest.mark.asyncio +async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None: + service = FeishuIntegrationService(db=object()) + called: dict[str, object] = {} + + def _fake_build_receive_context(*, event, state_trace_id): # noqa: ARG001 + return { + "ingress_source": "long_connection", + "message": {"message_id": "m-1", "message_type": "text"}, + "sender": {"sender_name": "原始用户名"}, + "sender_id": {"open_id": "ou-1"}, + "message_id": "m-1", + "sender_type": "user", + "raw_message_content": "hello", + "message_content": "hello", + "chat_id": "chat-1", + "chat_type": "group", + "captured_at": None, + "mentioned_bot_names": [], + "sender_open_id": "ou-1", + "perf_t0": 1.2, + "message_attachments": [], + "occurred_at_iso": None, + } + + async def _fake_record_ingress_trace(**kwargs): + called["trace"] = kwargs + + async def _fake_resolve_sender_display_name(*, sender, sender_id): # noqa: ARG001 + return "解析后用户名" + + def _fake_normalize_message_attachments_from_context(*, context): # noqa: ARG001 + return [] + + async def _fake_capture_and_persist_from_ingress(**kwargs): + called["capture"] = kwargs + return { + "status": "captured", + "classification": "signal", + "linked_project_ids": [11], + "auto_tasks": [], + "ai_trace_id": "trace-1", + } + + async def _fake_finalize_receive_result(**kwargs): + called["finalize"] = kwargs + return {"code": 0, "msg": "success", "data": {"scene": "execute"}} + + monkeypatch.setattr(service, "_build_receive_context", _fake_build_receive_context) + monkeypatch.setattr(service, "_record_ingress_trace", _fake_record_ingress_trace) + monkeypatch.setattr(service, "_resolve_sender_display_name", _fake_resolve_sender_display_name) + monkeypatch.setattr(service, "_normalize_message_attachments_from_context", _fake_normalize_message_attachments_from_context) + monkeypatch.setattr(service, "_capture_and_persist_from_ingress", _fake_capture_and_persist_from_ingress) + monkeypatch.setattr(service, "_finalize_receive_result", _fake_finalize_receive_result) + + result = await service._handle_message_receive({"type": "im.message.receive_v1"}) + assert result["code"] == 0 + assert result["data"]["scene"] == "execute" + assert called["capture"]["chat_id"] == "chat-1" + assert called["capture"]["sender_name"] == "解析后用户名" + assert called["finalize"]["chat_type"] == "group" diff --git a/backend/tests/test_rag_route_contract.py b/backend/tests/test_rag_route_contract.py new file mode 100644 index 00000000..2a12af3f --- /dev/null +++ b/backend/tests/test_rag_route_contract.py @@ -0,0 +1,204 @@ +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from app.api import rag_ask_contract +from app.api.ai_steward_api import router as ai_steward_router +from app.api.rag_api import routes_query +from app.schemas.rag import RAGAskRequest + + +def test_rag_ask_request_normalizes_contract_fields() -> None: + req = RAGAskRequest( + question="状态怎么样", + asked_via="ai-pmo-process-text", + message_intent="MIXED", + mixed_signal_part="新增任务确认流", + action_mode="read", + ) + assert req.asked_via == "/ai-pmo/process-text" + assert req.message_intent == "mixed" + assert req.mixed_signal_part == "新增任务确认流" + assert req.action_mode == "read" + + +def test_rag_ask_request_rejects_unknown_asked_via() -> None: + with pytest.raises(ValidationError): + RAGAskRequest( + question="hello", + asked_via="email", + ) + + +def test_build_answer_question_kwargs_maps_fields() -> None: + request = RAGAskRequest( + question="请确认任务", + asked_via="pmflow_manual", + session_id=33, + new_session=1, + thinking_mode="deep", + action_mode="read_write", + sync_requirement=0, + message_intent="mixed", + mixed_signal_part="新增任务确认流", + ) + kwargs = rag_ask_contract.build_answer_question_kwargs( + request=request, + current_user=SimpleNamespace(id="77"), + ) + + assert kwargs == { + "question": "请确认任务", + "asked_by": 77, + "asked_via": "pmflow_manual", + "project_id": None, + "source_chat_id": None, + "session_id": 33, + "force_new_session": True, + "thinking_mode": "deep", + "action_mode": "read_write", + "sync_requirement": False, + "message_intent": "mixed", + "mixed_signal_part": "新增任务确认流", + } + + +@pytest.mark.asyncio +async def test_rag_ask_route_forwards_contract_fields(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured["tenant_id"] = tenant_id + + async def process_ingress(self, **kwargs): + captured["ingress_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "intent": "query.read_only", + "direct_reply_required": True, + "direct_reply_text": "ok", + "rag_result": {"answer": "ok", "session_id": 12, "processing_stage": "qa"}, + } + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + + request = RAGAskRequest( + question="现在进展怎么样", + asked_via="pmflow_manual", + action_mode="read", + message_intent="mixed", + mixed_signal_part="新增任务确认流", + ) + current_user = SimpleNamespace(id=101, tenant_id=9) + response = await routes_query.rag_ask( + request=request, + current_user=current_user, + db=object(), + tenant_id=9, + ) + + assert response["code"] == 0 + data = response["data"] + assert data["answer"] == "ok" + assert data["session_id"] == 12 + ingress_kwargs = captured["ingress_kwargs"] + assert isinstance(ingress_kwargs, dict) + assert ingress_kwargs["source_system"] == "pmflow_manual" + assert ingress_kwargs["message_text"] == "现在进展怎么样" + rag_request_context = ingress_kwargs["ingress_options"]["rag_request_context"] + assert rag_request_context["asked_via"] == "pmflow_manual" + assert rag_request_context["action_mode"] == "read" + assert rag_request_context["message_intent"] == "mixed" + assert rag_request_context["mixed_signal_part"] == "新增任务确认流" + + +def test_ai_steward_router_does_not_expose_legacy_rag_ask_route() -> None: + route_paths = {str(getattr(route, "path", "")).strip() for route in ai_steward_router.routes} + assert "/ai-steward/rag/ask" not in route_paths + + +@pytest.mark.asyncio +async def test_rag_ask_route_uses_shared_contract_builder(monkeypatch) -> None: + captured_kwargs: list[dict[str, object]] = [] + captured_tenants: list[int] = [] + + def fake_build_answer_kwargs(request: RAGAskRequest, current_user) -> dict[str, object]: + return { + "question": request.question, + "asked_by": int(current_user.id), + "asked_via": "shared-builder", + } + + monkeypatch.setattr(rag_ask_contract, "build_answer_question_kwargs", fake_build_answer_kwargs) + monkeypatch.setattr(rag_ask_contract, "resolve_effective_tenant_id", lambda *_args, **_kwargs: 88) + + class FakeKernel: + def __init__(self, db, tenant_id: int): + _ = db + captured_tenants.append(int(tenant_id)) + + async def process_ingress(self, **kwargs): + captured_kwargs.append(dict(kwargs)) + return {"handled": True, "status": "answered", "direct_reply_text": "ok", "rag_result": {"answer": "ok"}} + + monkeypatch.setattr(routes_query, "AIPMOKernelService", FakeKernel) + + response_rag = await routes_query.rag_ask( + request=RAGAskRequest(question="q1"), + current_user=SimpleNamespace(id=1, tenant_id=1), + db=object(), + tenant_id=1, + ) + + assert response_rag["code"] == 0 + assert captured_tenants == [88] + assert captured_kwargs == [ + { + "source_system": "pmflow", + "source_message_id": None, + "message_text": "q1", + "sender": "PMFlow用户", + "channel_ref": None, + "source_sender_open_id": None, + "captured_at": None, + "chat_type": "manual", + "project_hint_id": None, + "ingress_options": { + "rag_request_context": { + "question": "q1", + "asked_by": 1, + "asked_via": "shared-builder", + } + }, + } + ] + + +def test_build_rag_response_payload_preserves_rag_fields() -> None: + payload = rag_ask_contract.build_rag_response_payload( + ingress_result={ + "status": "answered", + "intent": "query.read_only", + "work_item_id": 321, + "ai_trace_id": "ai-pmo-321", + "direct_reply_required": True, + "direct_reply_text": "fallback", + "rag_result": { + "answer": "final answer", + "session_id": 18, + "sources": [{"source": "snapshot"}], + "processing_stage": "qa", + }, + } + ) + + assert payload["answer"] == "final answer" + assert payload["session_id"] == 18 + assert payload["work_item_id"] == 321 + assert payload["trace_id"] == "ai-pmo-321" + assert payload["reply_required"] is True + assert payload["reply_text"] == "fallback" diff --git a/backend/tmp_acceptance/draft_ai_call_logs_latest3.txt b/backend/tmp_acceptance/draft_ai_call_logs_latest3.txt new file mode 100644 index 00000000..bbf47cdf --- /dev/null +++ b/backend/tmp_acceptance/draft_ai_call_logs_latest3.txt @@ -0,0 +1,28 @@ +rows=3 +==================================================================================================== +#1 +id=1902 +function_name=project_draft_charter_glm47_fallback +status=success +created_at=2026-03-19 18:13:49.041521 +response_start + +response_end +==================================================================================================== +#2 +id=1900 +function_name=project_draft_charter_glm47_fallback +status=success +created_at=2026-03-19 18:12:49.020651 +response_start + +response_end +==================================================================================================== +#3 +id=1899 +function_name=project_draft_signal_glm47_fallback +status=success +created_at=2026-03-19 18:12:19.010876 +response_start +{"content":"竞品多侧重基础连通性测试,缺乏自动化验收流程。差异化机会在于集成CI/CD与全场景覆盖。需警惕硬件兼容性及协议复杂度带来的技术风险,建议优先构建自动化测试框架以提升效率。","impact_score":0.9,"priority":"high","strategic_analysis":{"is_strategic":true,"threat_level":"MEDIUM"}} +response_end \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_fix_verify_result.json b/backend/tmp_acceptance/draft_fix_verify_result.json new file mode 100644 index 00000000..6b5e7864 --- /dev/null +++ b/backend/tmp_acceptance/draft_fix_verify_result.json @@ -0,0 +1,130 @@ +{ + "source_message_id": "draft-fix-verify-1774243078", + "capture_response": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:draft-fix-verify-1774243078", + "results": [ + { + "route_status": "queued_for_decision", + "summary": "message_queued_async" + } + ], + "direct_reply_required": true, + "direct_reply_text": "已收到,正在处理。", + "reply_template_key": "async_processing_ack", + "work_item_id": null, + "decision_level": null, + "review_summary": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 283 + }, + "ok": true, + "run": { + "id": 112, + "status": "succeeded", + "last_error": null, + "retry_count": 0, + "max_retries": 3, + "work_item_id": 405, + "updated_at": "2026-03-23 13:18:54.271908" + }, + "work_item": { + "id": 405, + "state": "draft_pending_confirm", + "draft_version": 1, + "signal_id": null, + "confirm_message_id": null, + "draft_content": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "execution_result": { + "draft": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "status": "draft_pending_confirm", + "draft_version": 1, + "revision_note": null, + "decision_trace": { + "intent": "signal.create", + "gate_code": "auto_execute_allowed", + "confidence": 0.95, + "route_tier": "low", + "gate_action": "auto_execute", + "route_function": "classify_intent_ai_pmo_kernel", + "correction_action": "none", + "complexity_reasons": [ + "llm_only_stage1" + ], + "correction_keywords": [] + }, + "workflow_route": "project_signal.create", + "workflow_action": "create", + "draft_expires_at": "2026-03-24T13:18:54.197287" + }, + "updated_at": "2026-03-23 13:17:58.698395" + }, + "outbox": [ + { + "id": 283, + "status": "sent", + "message_text": "已收到,正在处理。", + "created_at": "2026-03-23 13:17:58.143284", + "sent_at": "2026-03-23 13:17:59.389384", + "attempts": 1, + "last_error": null + }, + { + "id": 284, + "status": "sent", + "message_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "created_at": "2026-03-23 13:18:54.271908", + "sent_at": "2026-03-23 13:18:56.583867", + "attempts": 1, + "last_error": null + } + ], + "draft_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消" +} \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_optimize_db_check.txt b/backend/tmp_acceptance/draft_optimize_db_check.txt new file mode 100644 index 00000000..0b83b317 --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_db_check.txt @@ -0,0 +1,10 @@ +source_message_id=draft-opt-verify-1774243376 +run_status=succeeded +work_item_state=succeeded +title= +feasibility= +recommended_assignee_workload=0.0 +recommended_assignee_reason= +semantic_title_ok=False +feasibility_is_合理=False +assignee_under80_or_risk_note=True diff --git a/backend/tmp_acceptance/draft_optimize_db_check_v2.dataurl b/backend/tmp_acceptance/draft_optimize_db_check_v2.dataurl new file mode 100644 index 00000000..b6ffbe06 --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_db_check_v2.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E4%BC%98%E5%8C%96%E5%90%8E%E6%95%B0%E6%8D%AE%E5%BA%93%E6%A0%A1%E9%AA%8C%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E4%BC%98%E5%8C%96%E5%90%8E%E6%95%B0%E6%8D%AE%E5%BA%93%E6%A0%A1%E9%AA%8C%3C%2Fh1%3E%3Cpre%3Esender%3D%E8%8D%89%E7%A8%BF%E4%BC%98%E5%8C%96%E9%AA%8C%E6%94%B6%E6%9C%BA%E5%99%A8%E4%BA%BA-1774243529%0Asource_message_id%3Ddraft-opt-verify2-1774243529%0Arun_status%3Dsucceeded%0Awork_item_state%3Ddraft_pending_confirm%0Atitle%3D%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%0Afeasibility%3D%E5%BE%85%E8%AE%A8%E8%AE%BA%0Arecommended_assignee_workload%3D100.0%0Arecommended_assignee_reason%3D%E5%9F%BA%E4%BA%8E%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD%E4%B8%8E%E4%BB%BB%E5%8A%A1%E5%8C%B9%E9%85%8D%E5%BA%A6%E7%BB%99%E5%87%BA%E7%9A%84%E5%BB%BA%E8%AE%AE%E3%80%82%0Asemantic_title_ok%3DFalse%0Afeasibility_is_%E5%90%88%E7%90%86%3DFalse%0Aassignee_under80_or_risk_note%3DFalse%0A%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_optimize_db_check_v2.png b/backend/tmp_acceptance/draft_optimize_db_check_v2.png new file mode 100644 index 00000000..0d57c553 Binary files /dev/null and b/backend/tmp_acceptance/draft_optimize_db_check_v2.png differ diff --git a/backend/tmp_acceptance/draft_optimize_db_check_v2.txt b/backend/tmp_acceptance/draft_optimize_db_check_v2.txt new file mode 100644 index 00000000..4851f3d9 --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_db_check_v2.txt @@ -0,0 +1,11 @@ +sender=草稿优化验收机器人-1774243529 +source_message_id=draft-opt-verify2-1774243529 +run_status=succeeded +work_item_state=draft_pending_confirm +title=给AI劝退助手加个需 +feasibility=待讨论 +recommended_assignee_workload=100.0 +recommended_assignee_reason=基于当前负载与任务匹配度给出的建议。 +semantic_title_ok=False +feasibility_is_合理=False +assignee_under80_or_risk_note=False diff --git a/backend/tmp_acceptance/draft_optimize_feishu_reply.txt b/backend/tmp_acceptance/draft_optimize_feishu_reply.txt new file mode 100644 index 00000000..e69de29b diff --git a/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.dataurl b/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.dataurl new file mode 100644 index 00000000..6ef0240f --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E4%BC%98%E5%8C%96%E5%90%8E%E8%8D%89%E7%A8%BF%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E4%BC%98%E5%8C%96%E5%90%8E%E8%8D%89%E7%A8%BF%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%3C%2Fh1%3E%3Cpre%3E%F0%9F%93%8B%20%E5%B7%B2%E4%B8%BA%E4%BD%A0%E8%B5%B7%E8%8D%89%E9%9C%80%E6%B1%82%EF%BC%8C%E8%AF%B7%E7%A1%AE%E8%AE%A4%EF%BC%9A%0A%0A%F0%9F%93%8C%20%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%0A%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%E6%B1%82%20%E5%8A%9D%E9%80%80%E5%AE%8C%E5%90%8E%E7%AC%AC%E4%BA%8C%E5%A4%A9%E9%97%AE%E4%B8%80%E4%B8%8B%E6%83%85%E5%86%B5%0A%0A%E2%9C%85%20%E5%8F%AF%E8%A1%8C%E6%80%A7%EF%BC%9A%E5%BE%85%E8%AE%A8%E8%AE%BA%0A%E4%BF%A1%E6%81%AF%E4%BB%8D%E9%9C%80%E8%A1%A5%E5%85%85%EF%BC%8C%E5%BB%BA%E8%AE%AE%E5%85%88%E7%A1%AE%E8%AE%A4%E8%BE%B9%E7%95%8C%E3%80%82%0A%0A%F0%9F%91%A4%20%E6%8E%A8%E8%8D%90%E8%B4%9F%E8%B4%A3%E4%BA%BA%EF%BC%9A%E7%B3%BB%E7%BB%9F%E7%AE%A1%E7%90%86%E5%91%98%20%C2%B7%20%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD100%25%0A%E5%9F%BA%E4%BA%8E%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD%E4%B8%8E%E4%BB%BB%E5%8A%A1%E5%8C%B9%E9%85%8D%E5%BA%A6%E7%BB%99%E5%87%BA%E7%9A%84%E5%BB%BA%E8%AE%AE%E3%80%82%0A%0A%F0%9F%93%85%20%E8%B7%9F%E8%BF%9B%E8%AE%A1%E5%88%92%EF%BC%9A%0A%C2%B7%20%E6%98%8E%E5%A4%A9%EF%BC%9A%E7%A1%AE%E8%AE%A4%E8%A7%A6%E5%8F%91%E6%97%B6%E6%9C%BA%E4%B8%8E%E9%AA%8C%E6%94%B6%E5%8F%A3%E5%BE%84%0A%C2%B7%20%E4%B8%80%E5%91%A8%E5%90%8E%EF%BC%9A%E5%A4%8D%E6%A0%B8%E6%89%A7%E8%A1%8C%E6%95%88%E6%9E%9C%E5%B9%B6%E5%86%B3%E5%AE%9A%E6%98%AF%E5%90%A6%E6%89%A9%E5%B1%95%0A%0A---%0A%E5%9B%9E%E5%A4%8D%20%E7%A1%AE%E8%AE%A4%20%E2%86%92%20%E5%85%A5%E5%BA%93%0A%E5%9B%9E%E5%A4%8D%20%E4%BF%AE%E6%94%B9%20%5B%E5%85%B7%E4%BD%93%E4%BF%AE%E6%94%B9%E6%84%8F%E8%A7%81%5D%20%E2%86%92%20%E9%87%8D%E6%96%B0%E8%B5%B7%E8%8D%89%0A%E5%9B%9E%E5%A4%8D%20%E5%8F%96%E6%B6%88%20%E2%86%92%20%E6%94%BE%E5%BC%83%0A%E2%8F%B0%2024%E5%B0%8F%E6%97%B6%E5%86%85%E6%9C%AA%E7%A1%AE%E8%AE%A4%E8%87%AA%E5%8A%A8%E5%8F%96%E6%B6%88%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.png b/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.png new file mode 100644 index 00000000..2fbaa386 Binary files /dev/null and b/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.png differ diff --git a/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.txt b/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.txt new file mode 100644 index 00000000..5ab9a546 --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_feishu_reply_v2.txt @@ -0,0 +1,20 @@ +📋 已为你起草需求,请确认: + +📌 给AI劝退助手加个需 +给AI劝退助手加个需求 劝退完后第二天问一下情况 + +✅ 可行性:待讨论 +信息仍需补充,建议先确认边界。 + +👤 推荐负责人:系统管理员 · 当前负载100% +基于当前负载与任务匹配度给出的建议。 + +📅 跟进计划: +· 明天:确认触发时机与验收口径 +· 一周后:复核执行效果并决定是否扩展 + +--- +回复 确认 → 入库 +回复 修改 [具体修改意见] → 重新起草 +回复 取消 → 放弃 +⏰ 24小时内未确认自动取消 \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_optimize_verify_result.json b/backend/tmp_acceptance/draft_optimize_verify_result.json new file mode 100644 index 00000000..2a9fffb3 --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_verify_result.json @@ -0,0 +1,81 @@ +{ + "source_message_id": "draft-opt-verify-1774243376", + "capture_response": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:draft-opt-verify-1774243376", + "results": [ + { + "route_status": "queued_for_decision", + "summary": "message_queued_async" + } + ], + "direct_reply_required": true, + "direct_reply_text": "已收到,正在处理。", + "reply_template_key": "async_processing_ack", + "work_item_id": null, + "decision_level": null, + "review_summary": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 285 + }, + "ok": false, + "run": { + "id": 113, + "status": "succeeded", + "last_error": null, + "retry_count": 0, + "max_retries": 3, + "work_item_id": 406, + "updated_at": "2026-03-23 13:22:56.674242" + }, + "work_item": { + "id": 406, + "state": "succeeded", + "draft_version": 0, + "signal_id": null, + "confirm_message_id": null, + "draft_content": null, + "execution_result": { + "status": "pending_manual", + "signal_id": null, + "project_id": 51, + "target_state": "draft_pending_confirm", + "target_intent": "project_signal.create", + "followup_reply": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。", + "workflow_route": "project_signal.create", + "target_work_item_id": 405 + }, + "updated_at": "2026-03-23 13:22:56.643351" + }, + "outbox": [ + { + "id": 285, + "status": "sent", + "message_text": "已收到,正在处理。", + "created_at": "2026-03-23 13:22:56.388208", + "sent_at": "2026-03-23 13:22:57.387736", + "attempts": 1, + "last_error": null + }, + { + "id": 286, + "status": "sent", + "message_text": "请回复“确认”、“修改 + 你的意见”或“取消”来处理当前草稿。", + "created_at": "2026-03-23 13:22:56.674242", + "sent_at": "2026-03-23 13:22:59.656752", + "attempts": 1, + "last_error": null + } + ], + "draft_text": "", + "checks": { + "semantic_title_ok": false, + "feasibility_is_合理": false, + "assignee_under80_or_risk_note": true + } +} \ No newline at end of file diff --git a/backend/tmp_acceptance/draft_optimize_verify_result_v2.json b/backend/tmp_acceptance/draft_optimize_verify_result_v2.json new file mode 100644 index 00000000..b08b8600 --- /dev/null +++ b/backend/tmp_acceptance/draft_optimize_verify_result_v2.json @@ -0,0 +1,136 @@ +{ + "sender": "草稿优化验收机器人-1774243529", + "source_message_id": "draft-opt-verify2-1774243529", + "capture_response": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:draft-opt-verify2-1774243529", + "results": [ + { + "route_status": "queued_for_decision", + "summary": "message_queued_async" + } + ], + "direct_reply_required": true, + "direct_reply_text": "已收到,正在处理。", + "reply_template_key": "async_processing_ack", + "work_item_id": null, + "decision_level": null, + "review_summary": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 287 + }, + "ok": true, + "run": { + "id": 114, + "status": "succeeded", + "last_error": null, + "retry_count": 0, + "max_retries": 3, + "work_item_id": 407, + "updated_at": "2026-03-23 13:26:16.013472" + }, + "work_item": { + "id": 407, + "state": "draft_pending_confirm", + "draft_version": 1, + "signal_id": null, + "confirm_message_id": null, + "draft_content": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "execution_result": { + "draft": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "status": "draft_pending_confirm", + "draft_version": 1, + "revision_note": null, + "decision_trace": { + "intent": "signal.create", + "gate_code": "auto_execute_allowed", + "confidence": 0.95, + "route_tier": "low", + "gate_action": "auto_execute", + "route_function": "classify_intent_ai_pmo_kernel", + "correction_action": "none", + "complexity_reasons": [ + "llm_only_stage1" + ], + "correction_keywords": [] + }, + "workflow_route": "project_signal.create", + "workflow_action": "create", + "draft_expires_at": "2026-03-24T13:26:15.966734" + }, + "updated_at": "2026-03-23 13:25:30.583098" + }, + "outbox": [ + { + "id": 287, + "status": "sent", + "message_text": "已收到,正在处理。", + "created_at": "2026-03-23 13:25:29.874847", + "sent_at": "2026-03-23 13:25:31.671652", + "attempts": 1, + "last_error": null + }, + { + "id": 288, + "status": "sent", + "message_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "created_at": "2026-03-23 13:26:16.013472", + "sent_at": "2026-03-23 13:26:18.784250", + "attempts": 1, + "last_error": null + } + ], + "draft_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "checks": { + "semantic_title_ok": false, + "feasibility_is_合理": false, + "assignee_under80_or_risk_note": false + } +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_db_check.dataurl b/backend/tmp_acceptance/scene1_db_check.dataurl new file mode 100644 index 00000000..635818e4 --- /dev/null +++ b/backend/tmp_acceptance/scene1_db_check.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E5%9C%BA%E6%99%AF1%20%E6%95%B0%E6%8D%AE%E5%BA%93%E9%AA%8C%E8%AF%81%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E5%9C%BA%E6%99%AF1%20%E6%95%B0%E6%8D%AE%E5%BA%93%E9%AA%8C%E8%AF%81%3C%2Fh1%3E%3Cpre%3Erun_status%3Dretrying%0Arun_last_error%3Dkernel_exception%0Arun_retry_count%3D3%0Awork_item_count%3D0%0Alatest_outbox_status%3Dsent%0Alatest_outbox_text%3D%E5%B7%B2%E6%94%B6%E5%88%B0%EF%BC%8C%E6%AD%A3%E5%9C%A8%E5%A4%84%E7%90%86%E3%80%82%0A%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_db_check.html b/backend/tmp_acceptance/scene1_db_check.html new file mode 100644 index 00000000..c2f3825f --- /dev/null +++ b/backend/tmp_acceptance/scene1_db_check.html @@ -0,0 +1,7 @@ +场景1 数据库验证

场景1 数据库验证

run_status=retrying
+run_last_error=kernel_exception
+run_retry_count=3
+work_item_count=0
+latest_outbox_status=sent
+latest_outbox_text=已收到,正在处理。
+
\ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_db_check.png b/backend/tmp_acceptance/scene1_db_check.png new file mode 100644 index 00000000..c28f0325 Binary files /dev/null and b/backend/tmp_acceptance/scene1_db_check.png differ diff --git a/backend/tmp_acceptance/scene1_db_check.txt b/backend/tmp_acceptance/scene1_db_check.txt new file mode 100644 index 00000000..e33098d5 --- /dev/null +++ b/backend/tmp_acceptance/scene1_db_check.txt @@ -0,0 +1 @@ +work_item_after_draft_state=None\nwork_item_after_confirm_state=None\nwork_item_signal_id=None\nsignal_exists=False\npass=False\npass_rule={"draft_pending_confirm": false, "draft_reply_has_actions": false, "semantic_title": false, "confirm_to_succeeded": false, "signal_created": false}\n \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_direct_commit_result.json b/backend/tmp_acceptance/scene1_direct_commit_result.json new file mode 100644 index 00000000..24e1aecb --- /dev/null +++ b/backend/tmp_acceptance/scene1_direct_commit_result.json @@ -0,0 +1,87 @@ +{ + "generated_at": "2026-03-23T14:43:11.708259", + "sender": "场景1复验机器人-1774247671", + "step1": { + "source_message_id": "scene1-direct-draft-1774247671", + "capture": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:scene1-direct-draft-1774247671", + "results": [ + { + "route_status": "queued_for_decision", + "summary": "message_queued_async" + } + ], + "direct_reply_required": true, + "direct_reply_text": "已收到,正在处理。", + "reply_template_key": "async_processing_ack", + "work_item_id": null, + "decision_level": null, + "review_summary": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 292 + }, + "run_status": "succeeded", + "reply_text": "已收到,正在处理。", + "work_item_id": 410, + "work_item_state": "draft_pending_confirm", + "work_item_signal_id_before_confirm": null + }, + "step2": { + "source_message_id": "scene1-direct-confirm-1774247671", + "capture": { + "handled": true, + "status": "answered", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": 342, + "signal_ids": [ + 342 + ], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-411", + "results": [ + { + "work_item_id": 411, + "state": "succeeded", + "intent": "followup.confirm", + "route_status": "auto_done" + } + ], + "intent": "followup.confirm", + "workflow_route": "followup.confirm", + "direct_reply_required": true, + "direct_reply_text": "✅ 已入库\n\n📌 给AI劝退助手加个需\n信号 #342 已创建\n负责人:系统管理员", + "reply_template_key": "action_succeeded", + "work_item_id": 411, + "decision_level": null, + "review_summary": { + "verified": true, + "issues": [], + "next_step": "已处理草稿确认请求" + }, + "decision_trace": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 294 + }, + "run_status": "missing", + "reply_text": "✅ 已入库\n\n📌 给AI劝退助手加个需\n信号 #342 已创建\n负责人:系统管理员" + }, + "db_verify": { + "target_work_item_id": 410, + "target_work_item_state": "succeeded", + "target_signal_id": 342, + "signal_exists": true, + "signal_title": "给AI劝退助手加个需", + "signal_summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "signal_assignee_id": 1 + } +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_direct_commit_summary.txt b/backend/tmp_acceptance/scene1_direct_commit_summary.txt new file mode 100644 index 00000000..57da5877 --- /dev/null +++ b/backend/tmp_acceptance/scene1_direct_commit_summary.txt @@ -0,0 +1,21 @@ +sender=场景1复验机器人-1774247671 +sid1=scene1-direct-draft-1774247671 +sid2=scene1-direct-confirm-1774247671 +step1_run_status=succeeded +step1_work_item_state=draft_pending_confirm +step1_reply= +已收到,正在处理。 + +step2_run_status=missing +step2_reply= +✅ 已入库 + +📌 给AI劝退助手加个需 +信号 #342 已创建 +负责人:系统管理员 + +db_work_item_state=succeeded +db_signal_id=342 +db_signal_exists=True +db_signal_title=给AI劝退助手加个需 +db_signal_assignee_id=1 \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_failure_snapshot.json b/backend/tmp_acceptance/scene1_failure_snapshot.json new file mode 100644 index 00000000..f0294173 --- /dev/null +++ b/backend/tmp_acceptance/scene1_failure_snapshot.json @@ -0,0 +1,55 @@ +{ + "scene": "scene1_snapshot", + "run": { + "id": 111, + "source_message_id": "draft-accept-s1-write-1774242377", + "status": "retrying", + "last_error": "kernel_exception", + "retry_count": 3, + "max_retries": 3, + "work_item_id": null, + "created_at": "2026-03-23 13:06:17.136729", + "updated_at": "2026-03-23 13:07:52.602171", + "completed_at": null + }, + "outbox": [ + { + "id": 282, + "source_message_id": "draft-accept-s1-write-1774242377", + "status": "sent", + "attempts": 1, + "message_text": "已收到,正在处理。", + "created_at": "2026-03-23 13:06:17.169520", + "sent_at": "2026-03-23 13:06:19.404236", + "last_error": null + } + ], + "work_items": [], + "latest_signals": [ + { + "id": 341, + "title": null, + "created_at": "2026-03-18 01:02:39.919530" + }, + { + "id": 340, + "title": null, + "created_at": "2026-03-18 00:57:27.998698" + }, + { + "id": 339, + "title": null, + "created_at": "2026-03-18 00:37:42.666126" + }, + { + "id": 338, + "title": null, + "created_at": "2026-03-18 00:16:27.488523" + }, + { + "id": 337, + "title": null, + "created_at": "2026-03-17 20:17:42.007996" + } + ] +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_feishu_reply.dataurl b/backend/tmp_acceptance/scene1_feishu_reply.dataurl new file mode 100644 index 00000000..5271cf5c --- /dev/null +++ b/backend/tmp_acceptance/scene1_feishu_reply.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E5%9C%BA%E6%99%AF1%20%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%E6%8A%93%E5%8F%96%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E5%9C%BA%E6%99%AF1%20%E9%A3%9E%E4%B9%A6%E5%9B%9E%E5%A4%8D%E6%8A%93%E5%8F%96%3C%2Fh1%3E%3Cpre%3E%5B1%5D%20status%3Dsent%20attempts%3D1%20created_at%3D2026-03-23%2013%3A06%3A17.169520%0A%E5%B7%B2%E6%94%B6%E5%88%B0%EF%BC%8C%E6%AD%A3%E5%9C%A8%E5%A4%84%E7%90%86%E3%80%82%0A%0A%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_feishu_reply.html b/backend/tmp_acceptance/scene1_feishu_reply.html new file mode 100644 index 00000000..2f0adb7b --- /dev/null +++ b/backend/tmp_acceptance/scene1_feishu_reply.html @@ -0,0 +1,4 @@ +场景1 飞书回复抓取

场景1 飞书回复抓取

[1] status=sent attempts=1 created_at=2026-03-23 13:06:17.169520
+已收到,正在处理。
+
+
\ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_feishu_reply.png b/backend/tmp_acceptance/scene1_feishu_reply.png new file mode 100644 index 00000000..50d2f6f9 Binary files /dev/null and b/backend/tmp_acceptance/scene1_feishu_reply.png differ diff --git a/backend/tmp_acceptance/scene1_feishu_reply.txt b/backend/tmp_acceptance/scene1_feishu_reply.txt new file mode 100644 index 00000000..8ddc5135 --- /dev/null +++ b/backend/tmp_acceptance/scene1_feishu_reply.txt @@ -0,0 +1,3 @@ +已收到,正在处理。 + +--- diff --git a/backend/tmp_acceptance/scene1_full_accept_result.json b/backend/tmp_acceptance/scene1_full_accept_result.json new file mode 100644 index 00000000..378e1580 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_accept_result.json @@ -0,0 +1,201 @@ +{ + "sender": "场景1验收机器人-1774243765", + "source_message_id_step1": "s1-full-write-1774243765", + "source_message_id_step2": "s1-full-confirm-1774243765", + "capture_step1": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:s1-full-write-1774243765", + "results": [ + { + "route_status": "queued_for_decision", + "summary": "message_queued_async" + } + ], + "direct_reply_required": true, + "direct_reply_text": "已收到,正在处理。", + "reply_template_key": "async_processing_ack", + "work_item_id": null, + "decision_level": null, + "review_summary": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 289 + }, + "capture_step2": { + "handled": true, + "status": "captured", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": null, + "signal_ids": [], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-409", + "results": [ + { + "work_item_id": 409, + "state": "failed", + "intent": "followup.confirm", + "route_status": "auto_done" + } + ], + "intent": "followup.confirm", + "workflow_route": "followup.confirm", + "direct_reply_required": true, + "direct_reply_text": "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。", + "reply_template_key": "safe_error", + "work_item_id": 409, + "decision_level": null, + "review_summary": { + "verified": false, + "issues": [ + "draft_confirm_failed" + ], + "next_step": "请检查草稿确认状态" + }, + "decision_trace": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 291 + }, + "step1_draft_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "step2_confirm_text": "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。", + "work_item_after_draft": { + "id": 408, + "source_message_id": "s1-full-write-1774243765", + "state": "draft_pending_confirm", + "draft_version": 1, + "signal_id": null, + "confirm_message_id": null, + "draft_content": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "execution_result": { + "draft": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "status": "draft_pending_confirm", + "draft_version": 1, + "revision_note": null, + "decision_trace": { + "intent": "signal.create", + "gate_code": "auto_execute_allowed", + "confidence": 0.95, + "route_tier": "low", + "gate_action": "auto_execute", + "route_function": "classify_intent_ai_pmo_kernel", + "correction_action": "none", + "complexity_reasons": [ + "llm_only_stage1" + ], + "correction_keywords": [] + }, + "workflow_route": "project_signal.create", + "workflow_action": "create", + "draft_expires_at": "2026-03-24T13:30:18.930864" + }, + "updated_at": "2026-03-23 13:29:25.362427", + "created_at": "2026-03-23 13:29:25.362427" + }, + "work_item_after_confirm": { + "id": 408, + "source_message_id": "s1-full-write-1774243765", + "state": "failed", + "draft_version": 1, + "signal_id": null, + "confirm_message_id": "s1-full-confirm-1774243765", + "draft_content": { + "title": "给AI劝退助手加个需", + "summary": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "version": 1, + "feasibility": "待讨论", + "followup_plan": [ + { + "when": "明天", + "action": "确认触发时机与验收口径" + }, + { + "when": "一周后", + "action": "复核执行效果并决定是否扩展" + } + ], + "original_text": "给AI劝退助手加个需求 劝退完后第二天问一下情况", + "feasibility_reason": "信息仍需补充,建议先确认边界。", + "recommended_assignee_id": 1, + "recommended_assignee_name": "系统管理员", + "recommended_assignee_reason": "基于当前负载与任务匹配度给出的建议。", + "recommended_assignee_workload": 100.0 + }, + "execution_result": { + "status": "failed_safe", + "project_id": 51, + "error_message": "你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。", + "workflow_route": "project_signal.create", + "workflow_action": "create", + "analyzed_signal_count": 0, + "requested_signal_count": 1 + }, + "updated_at": "2026-03-23 13:30:23.438134", + "created_at": "2026-03-23 13:29:25.362427" + }, + "signal_row": null, + "signals_before": 157, + "signals_after": 157, + "step1_checks": { + "draft_received": true, + "title_semantic_not_raw": false, + "feasibility_reasonable": false, + "assignee_workload_lt_80": false + }, + "step2_checks": { + "confirm_reply_received": true, + "confirm_reply_has_success_notice": false, + "work_item_succeeded": false, + "signal_created": false, + "signals_count_increased": false + }, + "pass": false +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_db_check.txt b/backend/tmp_acceptance/scene1_full_db_check.txt new file mode 100644 index 00000000..f0619863 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_db_check.txt @@ -0,0 +1,14 @@ +sender=场景1验收机器人-1774243765 +source_step1=s1-full-write-1774243765 +source_step2=s1-full-confirm-1774243765 +title=给AI劝退助手加个需 +feasibility=待讨论 +workload=100.0 +work_item_after_draft_state=draft_pending_confirm +work_item_after_confirm_state=failed +signal_id=None +signals_before=157 +signals_after=157 +step1_checks={"draft_received": true, "title_semantic_not_raw": false, "feasibility_reasonable": false, "assignee_workload_lt_80": false} +step2_checks={"confirm_reply_received": true, "confirm_reply_has_success_notice": false, "work_item_succeeded": false, "signal_created": false, "signals_count_increased": false} +pass=False diff --git a/backend/tmp_acceptance/scene1_full_step1_feishu.dataurl b/backend/tmp_acceptance/scene1_full_step1_feishu.dataurl new file mode 100644 index 00000000..746938cc --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_step1_feishu.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E5%9C%BA%E6%99%AF1-%E6%AD%A5%E9%AA%A41%20%E8%8D%89%E7%A8%BF%E5%9B%9E%E5%A4%8D%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E5%9C%BA%E6%99%AF1-%E6%AD%A5%E9%AA%A41%20%E8%8D%89%E7%A8%BF%E5%9B%9E%E5%A4%8D%3C%2Fh1%3E%3Cpre%3E%F0%9F%93%8B%20%E5%B7%B2%E4%B8%BA%E4%BD%A0%E8%B5%B7%E8%8D%89%E9%9C%80%E6%B1%82%EF%BC%8C%E8%AF%B7%E7%A1%AE%E8%AE%A4%EF%BC%9A%0A%0A%F0%9F%93%8C%20%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%0A%E7%BB%99AI%E5%8A%9D%E9%80%80%E5%8A%A9%E6%89%8B%E5%8A%A0%E4%B8%AA%E9%9C%80%E6%B1%82%20%E5%8A%9D%E9%80%80%E5%AE%8C%E5%90%8E%E7%AC%AC%E4%BA%8C%E5%A4%A9%E9%97%AE%E4%B8%80%E4%B8%8B%E6%83%85%E5%86%B5%0A%0A%E2%9C%85%20%E5%8F%AF%E8%A1%8C%E6%80%A7%EF%BC%9A%E5%BE%85%E8%AE%A8%E8%AE%BA%0A%E4%BF%A1%E6%81%AF%E4%BB%8D%E9%9C%80%E8%A1%A5%E5%85%85%EF%BC%8C%E5%BB%BA%E8%AE%AE%E5%85%88%E7%A1%AE%E8%AE%A4%E8%BE%B9%E7%95%8C%E3%80%82%0A%0A%F0%9F%91%A4%20%E6%8E%A8%E8%8D%90%E8%B4%9F%E8%B4%A3%E4%BA%BA%EF%BC%9A%E7%B3%BB%E7%BB%9F%E7%AE%A1%E7%90%86%E5%91%98%20%C2%B7%20%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD100%25%0A%E5%9F%BA%E4%BA%8E%E5%BD%93%E5%89%8D%E8%B4%9F%E8%BD%BD%E4%B8%8E%E4%BB%BB%E5%8A%A1%E5%8C%B9%E9%85%8D%E5%BA%A6%E7%BB%99%E5%87%BA%E7%9A%84%E5%BB%BA%E8%AE%AE%E3%80%82%0A%0A%F0%9F%93%85%20%E8%B7%9F%E8%BF%9B%E8%AE%A1%E5%88%92%EF%BC%9A%0A%C2%B7%20%E6%98%8E%E5%A4%A9%EF%BC%9A%E7%A1%AE%E8%AE%A4%E8%A7%A6%E5%8F%91%E6%97%B6%E6%9C%BA%E4%B8%8E%E9%AA%8C%E6%94%B6%E5%8F%A3%E5%BE%84%0A%C2%B7%20%E4%B8%80%E5%91%A8%E5%90%8E%EF%BC%9A%E5%A4%8D%E6%A0%B8%E6%89%A7%E8%A1%8C%E6%95%88%E6%9E%9C%E5%B9%B6%E5%86%B3%E5%AE%9A%E6%98%AF%E5%90%A6%E6%89%A9%E5%B1%95%0A%0A---%0A%E5%9B%9E%E5%A4%8D%20%E7%A1%AE%E8%AE%A4%20%E2%86%92%20%E5%85%A5%E5%BA%93%0A%E5%9B%9E%E5%A4%8D%20%E4%BF%AE%E6%94%B9%20%5B%E5%85%B7%E4%BD%93%E4%BF%AE%E6%94%B9%E6%84%8F%E8%A7%81%5D%20%E2%86%92%20%E9%87%8D%E6%96%B0%E8%B5%B7%E8%8D%89%0A%E5%9B%9E%E5%A4%8D%20%E5%8F%96%E6%B6%88%20%E2%86%92%20%E6%94%BE%E5%BC%83%0A%E2%8F%B0%2024%E5%B0%8F%E6%97%B6%E5%86%85%E6%9C%AA%E7%A1%AE%E8%AE%A4%E8%87%AA%E5%8A%A8%E5%8F%96%E6%B6%88%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_step1_feishu.png b/backend/tmp_acceptance/scene1_full_step1_feishu.png new file mode 100644 index 00000000..0d57c553 Binary files /dev/null and b/backend/tmp_acceptance/scene1_full_step1_feishu.png differ diff --git a/backend/tmp_acceptance/scene1_full_step1_feishu.txt b/backend/tmp_acceptance/scene1_full_step1_feishu.txt new file mode 100644 index 00000000..5ab9a546 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_step1_feishu.txt @@ -0,0 +1,20 @@ +📋 已为你起草需求,请确认: + +📌 给AI劝退助手加个需 +给AI劝退助手加个需求 劝退完后第二天问一下情况 + +✅ 可行性:待讨论 +信息仍需补充,建议先确认边界。 + +👤 推荐负责人:系统管理员 · 当前负载100% +基于当前负载与任务匹配度给出的建议。 + +📅 跟进计划: +· 明天:确认触发时机与验收口径 +· 一周后:复核执行效果并决定是否扩展 + +--- +回复 确认 → 入库 +回复 修改 [具体修改意见] → 重新起草 +回复 取消 → 放弃 +⏰ 24小时内未确认自动取消 \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_step1_feishu_v2.png b/backend/tmp_acceptance/scene1_full_step1_feishu_v2.png new file mode 100644 index 00000000..dd5fe59d Binary files /dev/null and b/backend/tmp_acceptance/scene1_full_step1_feishu_v2.png differ diff --git a/backend/tmp_acceptance/scene1_full_step2_feishu.dataurl b/backend/tmp_acceptance/scene1_full_step2_feishu.dataurl new file mode 100644 index 00000000..80b31d95 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_step2_feishu.dataurl @@ -0,0 +1 @@ +data:text/html;charset=utf-8,%3C%21doctype%20html%3E%3Chtml%3E%3Chead%3E%3Cmeta%20charset%3D%27utf-8%27%3E%3Ctitle%3E%E5%9C%BA%E6%99%AF1-%E6%AD%A5%E9%AA%A42%20%E7%A1%AE%E8%AE%A4%E5%9B%9E%E5%A4%8D%3C%2Ftitle%3E%3Cstyle%3Ebody%7Bfont-family%3AMenlo%2CConsolas%2Cmonospace%3Bbackground%3A%23f6f8fa%3Bpadding%3A24px%3Bcolor%3A%23111%7Dh1%7Bfont-size%3A20px%7Dpre%7Bwhite-space%3Apre-wrap%3Bbackground%3A%23fff%3Bpadding%3A16px%3Bborder-radius%3A8px%3Bborder%3A1px%20solid%20%23ddd%3Bline-height%3A1.5%7D%3C%2Fstyle%3E%3C%2Fhead%3E%3Cbody%3E%3Ch1%3E%E5%9C%BA%E6%99%AF1-%E6%AD%A5%E9%AA%A42%20%E7%A1%AE%E8%AE%A4%E5%9B%9E%E5%A4%8D%3C%2Fh1%3E%3Cpre%3E%E4%BD%A0%E8%A6%81%E6%B1%82%E5%85%88%E5%88%86%E6%9E%90%E5%86%8D%E5%85%A5%E5%BA%93%EF%BC%8C%E4%BD%86%E5%BD%93%E5%89%8D%E4%BF%A1%E5%8F%B7%E5%88%86%E6%9E%90%E7%BB%93%E6%9E%9C%E4%B8%8D%E5%8F%AF%E8%A7%A3%E6%9E%90%EF%BC%8C%E6%9C%AC%E6%AC%A1%E6%9C%AA%E8%87%AA%E5%8A%A8%E5%85%A5%E5%BA%93%E3%80%82%3C%2Fpre%3E%3C%2Fbody%3E%3C%2Fhtml%3E \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_step2_feishu.txt b/backend/tmp_acceptance/scene1_full_step2_feishu.txt new file mode 100644 index 00000000..5d27ea94 --- /dev/null +++ b/backend/tmp_acceptance/scene1_full_step2_feishu.txt @@ -0,0 +1 @@ +你要求先分析再入库,但当前信号分析结果不可解析,本次未自动入库。 \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_full_step2_feishu_v2.png b/backend/tmp_acceptance/scene1_full_step2_feishu_v2.png new file mode 100644 index 00000000..907b0340 Binary files /dev/null and b/backend/tmp_acceptance/scene1_full_step2_feishu_v2.png differ diff --git a/backend/tmp_acceptance/scene1_manual_process_text_result.json b/backend/tmp_acceptance/scene1_manual_process_text_result.json new file mode 100644 index 00000000..f2718a35 --- /dev/null +++ b/backend/tmp_acceptance/scene1_manual_process_text_result.json @@ -0,0 +1,146 @@ +{ + "generated_at": "2026-03-23T14:39:19.996808", + "sender": "场景1复验手动入口-1774247847", + "step1": { + "source_message_id": "scene1-manual-draft-1774247847", + "response": { + "code": 0, + "message": "success", + "data": { + "handled": true, + "status": "captured", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": null, + "signal_ids": [], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-412", + "results": [ + { + "work_item_id": 412, + "state": "draft_pending_confirm", + "intent": "signal.create", + "route_status": "queued_for_decision" + } + ], + "intent": "signal.create", + "workflow_route": "project_signal.create", + "direct_reply_required": true, + "direct_reply_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "reply_template_key": "draft_pending_confirm", + "work_item_id": 412, + "decision_level": "project", + "review_summary": { + "verified": false, + "issues": [ + "draft_pending_confirm" + ], + "divergences": [], + "risk_score_before": 0.0, + "risk_score_after": 1.0, + "agent_quality_score": 0.32, + "agent_quality_grade": "D", + "rule_updates": [], + "next_actions": [ + "当前工作项未形成可验证终态,进入风险例会或人工确认。", + "风险重评分偏高,建议纳入风险例会升级。" + ], + "simulation_summary": { + "intent": "signal.create", + "operation_codes": [ + "signal.enter" + ], + "estimated_budget_delta_usd": 8.0, + "projected_budget_usage_ratio": 0.0, + "estimated_capacity_delta_hours": 1.5, + "projected_capacity_peak_ratio": 3.0375, + "blocked_task_count": 0, + "overdue_task_count": 0, + "dependency_pressure": "low", + "missing_skill_tag_ids": [ + 3, + 4, + 6 + ], + "missing_skill_count": 3, + "projected_risk_score": 1.0 + }, + "workflow_summary": {} + }, + "decision_trace": { + "intent": "signal.create", + "confidence": 0.95, + "route_tier": "low", + "route_function": "classify_intent_ai_pmo_kernel", + "gate_action": "auto_execute", + "gate_code": "auto_execute_allowed", + "correction_action": "none", + "correction_keywords": [], + "complexity_reasons": [ + "llm_only_stage1" + ] + } + } + }, + "draft_reply": "", + "work_item_id": 412, + "work_item_state": "draft_pending_confirm" + }, + "step2": { + "source_message_id": "scene1-manual-confirm-1774247847", + "response": { + "code": 0, + "message": "success", + "data": { + "handled": true, + "status": "answered", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": 343, + "signal_ids": [ + 343 + ], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-413", + "results": [ + { + "work_item_id": 413, + "state": "succeeded", + "intent": "followup.confirm", + "route_status": "auto_done" + } + ], + "intent": "followup.confirm", + "workflow_route": "followup.confirm", + "direct_reply_required": true, + "direct_reply_text": "✅ 已入库\n\n📌 给AI劝退助手加个需\n信号 #343 已创建\n负责人:系统管理员", + "reply_template_key": "action_succeeded", + "work_item_id": 413, + "decision_level": null, + "review_summary": { + "verified": true, + "issues": [], + "next_step": "已处理草稿确认请求" + }, + "decision_trace": null + } + }, + "confirm_reply": "", + "confirm_work_item_id": 413, + "confirm_work_item_state": "succeeded" + }, + "db_verify": { + "target_work_item_id": 412, + "target_work_item_state": "succeeded", + "target_signal_id": 343, + "signal_exists": true, + "signal_title": "给AI劝退助手加个需", + "signal_assignee_id": 1 + } +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_manual_process_text_summary.txt b/backend/tmp_acceptance/scene1_manual_process_text_summary.txt new file mode 100644 index 00000000..f2718a35 --- /dev/null +++ b/backend/tmp_acceptance/scene1_manual_process_text_summary.txt @@ -0,0 +1,146 @@ +{ + "generated_at": "2026-03-23T14:39:19.996808", + "sender": "场景1复验手动入口-1774247847", + "step1": { + "source_message_id": "scene1-manual-draft-1774247847", + "response": { + "code": 0, + "message": "success", + "data": { + "handled": true, + "status": "captured", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": null, + "signal_ids": [], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-412", + "results": [ + { + "work_item_id": 412, + "state": "draft_pending_confirm", + "intent": "signal.create", + "route_status": "queued_for_decision" + } + ], + "intent": "signal.create", + "workflow_route": "project_signal.create", + "direct_reply_required": true, + "direct_reply_text": "📋 已为你起草需求,请确认:\n\n📌 给AI劝退助手加个需\n给AI劝退助手加个需求 劝退完后第二天问一下情况\n\n✅ 可行性:待讨论\n信息仍需补充,建议先确认边界。\n\n👤 推荐负责人:系统管理员 · 当前负载100%\n基于当前负载与任务匹配度给出的建议。\n\n📅 跟进计划:\n· 明天:确认触发时机与验收口径\n· 一周后:复核执行效果并决定是否扩展\n\n---\n回复 确认 → 入库\n回复 修改 [具体修改意见] → 重新起草\n回复 取消 → 放弃\n⏰ 24小时内未确认自动取消", + "reply_template_key": "draft_pending_confirm", + "work_item_id": 412, + "decision_level": "project", + "review_summary": { + "verified": false, + "issues": [ + "draft_pending_confirm" + ], + "divergences": [], + "risk_score_before": 0.0, + "risk_score_after": 1.0, + "agent_quality_score": 0.32, + "agent_quality_grade": "D", + "rule_updates": [], + "next_actions": [ + "当前工作项未形成可验证终态,进入风险例会或人工确认。", + "风险重评分偏高,建议纳入风险例会升级。" + ], + "simulation_summary": { + "intent": "signal.create", + "operation_codes": [ + "signal.enter" + ], + "estimated_budget_delta_usd": 8.0, + "projected_budget_usage_ratio": 0.0, + "estimated_capacity_delta_hours": 1.5, + "projected_capacity_peak_ratio": 3.0375, + "blocked_task_count": 0, + "overdue_task_count": 0, + "dependency_pressure": "low", + "missing_skill_tag_ids": [ + 3, + 4, + 6 + ], + "missing_skill_count": 3, + "projected_risk_score": 1.0 + }, + "workflow_summary": {} + }, + "decision_trace": { + "intent": "signal.create", + "confidence": 0.95, + "route_tier": "low", + "route_function": "classify_intent_ai_pmo_kernel", + "gate_action": "auto_execute", + "gate_code": "auto_execute_allowed", + "correction_action": "none", + "correction_keywords": [], + "complexity_reasons": [ + "llm_only_stage1" + ] + } + } + }, + "draft_reply": "", + "work_item_id": 412, + "work_item_state": "draft_pending_confirm" + }, + "step2": { + "source_message_id": "scene1-manual-confirm-1774247847", + "response": { + "code": 0, + "message": "success", + "data": { + "handled": true, + "status": "answered", + "classification": "command", + "linked_project_ids": [ + 51 + ], + "signal_id": 343, + "signal_ids": [ + 343 + ], + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "ai-pmo-413", + "results": [ + { + "work_item_id": 413, + "state": "succeeded", + "intent": "followup.confirm", + "route_status": "auto_done" + } + ], + "intent": "followup.confirm", + "workflow_route": "followup.confirm", + "direct_reply_required": true, + "direct_reply_text": "✅ 已入库\n\n📌 给AI劝退助手加个需\n信号 #343 已创建\n负责人:系统管理员", + "reply_template_key": "action_succeeded", + "work_item_id": 413, + "decision_level": null, + "review_summary": { + "verified": true, + "issues": [], + "next_step": "已处理草稿确认请求" + }, + "decision_trace": null + } + }, + "confirm_reply": "", + "confirm_work_item_id": 413, + "confirm_work_item_state": "succeeded" + }, + "db_verify": { + "target_work_item_id": 412, + "target_work_item_state": "succeeded", + "target_signal_id": 343, + "signal_exists": true, + "signal_title": "给AI劝退助手加个需", + "signal_assignee_id": 1 + } +} \ No newline at end of file diff --git a/backend/tmp_acceptance/scene1_result.json b/backend/tmp_acceptance/scene1_result.json new file mode 100644 index 00000000..8b863ae2 --- /dev/null +++ b/backend/tmp_acceptance/scene1_result.json @@ -0,0 +1,46 @@ +{ + "scene": "scene1_normal_confirm", + "ts": "2026-03-23T13:20:20.078503", + "source_message_id": "draft-accept-s1-write-1774242377", + "confirm_message_id": "draft-accept-s1-confirm-1774242377", + "capture_response": { + "status": "captured", + "classification": "command", + "linked_project_ids": [], + "signal_id": null, + "created_project_id": null, + "auto_tasks": [], + "ai_trace_id": "feishu:draft-accept-s1-write-1774242377", + "results": [ + { + "route_status": "queued_for_decision", + "summary": "message_queued_async" + } + ], + "direct_reply_required": true, + "direct_reply_text": "已收到,正在处理。", + "reply_template_key": "async_processing_ack", + "work_item_id": null, + "decision_level": null, + "review_summary": null, + "reply_outbox_enqueued": true, + "reply_outbox_id": 282 + }, + "work_item_after_draft": null, + "draft_reply": "已收到,正在处理。", + "draft_title_line": "", + "draft_has_actions": false, + "semantic_title_ok": false, + "confirm_capture_response": null, + "confirm_outbox": [], + "work_item_after_confirm": null, + "signal_row": null, + "pass": false, + "pass_rule": { + "draft_pending_confirm": false, + "draft_reply_has_actions": false, + "semantic_title": false, + "confirm_to_succeeded": false, + "signal_created": false + } +} \ No newline at end of file diff --git a/frontend/src/api/aiPmo.ts b/frontend/src/api/aiPmo.ts index ba336cf3..37bdc66d 100644 --- a/frontend/src/api/aiPmo.ts +++ b/frontend/src/api/aiPmo.ts @@ -39,6 +39,10 @@ export interface AIPMOWorkItem { target_url?: string | null; review_summary?: Record | null; execution_result?: Record | null; + draft_content?: Record | null; + draft_expires_at?: string | null; + draft_version?: number | null; + confirm_message_id?: string | null; latest_policy_gate_code?: string | null; approval_requirement?: AIPMOApprovalRequirement | null; created_at?: string | null; @@ -100,6 +104,10 @@ export interface AIPMOWorkItemDetail { context_snapshot?: Record | null; action_bundle?: Record | null; execution_result?: Record | null; + draft_content?: Record | null; + draft_expires_at?: string | null; + draft_version?: number | null; + confirm_message_id?: string | null; review_summary?: Record | null; reply_template_key?: string | null; decision_records: AIPMODecisionRecordDetail[]; @@ -126,6 +134,13 @@ export interface AIPMOWorkItemDecisionRequest { reason?: string; } +export interface AIPMODraftDecisionRequest { + action: "confirm" | "revise" | "cancel"; + revision_note?: string | null; + source_message_id?: string; + draft_version?: number | null; +} + export interface AIPMOCadenceSnapshot { id: number; scope_type: string; @@ -184,6 +199,15 @@ export const aiPmoApi = { return unwrap(data); }, + async decideDraftWorkItem(workItemId: number, body: AIPMODraftDecisionRequest): Promise { + const { data } = await api.post>( + `/ai-pmo/work-items/${workItemId}/draft-decision`, + body, + { timeout: 15000 } + ); + return unwrap(data); + }, + async getLatestCadence(params: { cadenceType: string; projectId?: number }): Promise { const { data } = await api.get>("/ai-pmo/cadence/latest", { params: { diff --git a/frontend/src/components/ai-steward/FloatingQAChat.tsx b/frontend/src/components/ai-steward/FloatingQAChat.tsx index d8e9a1e7..094aa7d7 100644 --- a/frontend/src/components/ai-steward/FloatingQAChat.tsx +++ b/frontend/src/components/ai-steward/FloatingQAChat.tsx @@ -190,66 +190,59 @@ async function askRAG( payloadObj.session_id = Math.trunc(sessionId); } const payload = JSON.stringify(payloadObj); - const paths = ["/rag/ask", "/ai-steward/rag/ask"]; - let lastError = "提问失败,请稍后重试。"; - - for (const path of paths) { - let res: Response; - try { - res = await fetch(`${apiBaseURL}${path}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${token}`, - }, - body: payload, - }); - } catch (error) { - throw new Error(normalizeRequestError(error, "提问失败,请稍后重试。")); - } - if (res.ok) { - const body = await res.json().catch(() => ({})); - const data = (body?.data ?? body) as { - answer?: string; - session_id?: number | null; - thinking_mode?: string; - sources?: unknown; - response_time_ms?: number | null; - action_taken?: ActionTaken | null; - signal_id?: number | null; - reply_required?: boolean; - reply_text?: string | null; - ack_latency_ms?: number | null; - trace_id?: string | null; - processing_stage?: string | null; - }; - if (!data || !String(data.answer || "").trim()) { - throw new Error("服务返回为空,未生成有效回答。"); - } - const resolvedThinkingMode: ThinkingMode = String(data.thinking_mode || thinkingMode).toLowerCase() === "deep" ? "deep" : "normal"; - return { - answer: String(data.answer), - session_id: data.session_id ?? null, - thinking_mode: resolvedThinkingMode, - sources: normalizeSources(data.sources), - response_time_ms: data.response_time_ms ?? null, - action_taken: data.action_taken ?? null, - signal_id: data.signal_id ?? null, - reply_required: Boolean(data.reply_required), - reply_text: data.reply_text ?? null, - ack_latency_ms: data.ack_latency_ms ?? null, - trace_id: data.trace_id ?? null, - processing_stage: data.processing_stage ?? null, - }; - } - - const body = await res.json().catch(() => ({})); - const detail = String(body?.detail || body?.message || "").trim(); - lastError = detail || `请求失败(${res.status})`; - if (res.status !== 404) break; + let res: Response; + try { + res = await fetch(`${apiBaseURL}/rag/ask`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: payload, + }); + } catch (error) { + throw new Error(normalizeRequestError(error, "提问失败,请稍后重试。")); } - throw new Error(lastError); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + const detail = String(body?.detail || body?.message || "").trim(); + throw new Error(detail || `请求失败(${res.status})`); + } + + const body = await res.json().catch(() => ({})); + const data = (body?.data ?? body) as { + answer?: string; + session_id?: number | null; + thinking_mode?: string; + sources?: unknown; + response_time_ms?: number | null; + action_taken?: ActionTaken | null; + signal_id?: number | null; + reply_required?: boolean; + reply_text?: string | null; + ack_latency_ms?: number | null; + trace_id?: string | null; + processing_stage?: string | null; + }; + if (!data || !String(data.answer || "").trim()) { + throw new Error("服务返回为空,未生成有效回答。"); + } + const resolvedThinkingMode: ThinkingMode = String(data.thinking_mode || thinkingMode).toLowerCase() === "deep" ? "deep" : "normal"; + return { + answer: String(data.answer), + session_id: data.session_id ?? null, + thinking_mode: resolvedThinkingMode, + sources: normalizeSources(data.sources), + response_time_ms: data.response_time_ms ?? null, + action_taken: data.action_taken ?? null, + signal_id: data.signal_id ?? null, + reply_required: Boolean(data.reply_required), + reply_text: data.reply_text ?? null, + ack_latency_ms: data.ack_latency_ms ?? null, + trace_id: data.trace_id ?? null, + processing_stage: data.processing_stage ?? null, + }; } function groupSessions(items: SessionSummary[]) { diff --git a/frontend/src/pages/inbox/InboxPage.tsx b/frontend/src/pages/inbox/InboxPage.tsx index 9bf7d2bc..1b2a710c 100644 --- a/frontend/src/pages/inbox/InboxPage.tsx +++ b/frontend/src/pages/inbox/InboxPage.tsx @@ -22,6 +22,31 @@ import { type InboxTabKey, } from "./helpers"; +function remapDraftInboxItem(item: InboxItemViewModel): InboxItemViewModel { + if (item.source_kind !== "ai_pmo_work_item" || !item.source_work_item) return item; + const state = String(item.source_work_item.state || "").toLowerCase(); + if (state !== "draft_pending_confirm") return item; + + const draftPayload = item.source_work_item.draft_content && typeof item.source_work_item.draft_content === "object" + ? item.source_work_item.draft_content as Record + : null; + const draftTitle = String(draftPayload?.title || "").trim(); + const draftSummary = String(draftPayload?.summary || "").trim(); + const draftExpiresAt = String(item.source_work_item.draft_expires_at || "").trim(); + return { + ...item, + display_kind: "action_required", + title: draftTitle || item.title, + summary: draftSummary || item.summary, + stage_text: "草稿待确认", + next_step_text: `回复确认入库、修改或取消${draftExpiresAt ? `(截止 ${draftExpiresAt})` : ""}`, + primary_action: { kind: "confirm", label: "确认入库" } satisfies InboxActionDescriptor, + secondary_action: { kind: "revise_draft", label: "修改" } satisfies InboxActionDescriptor, + weak_action: { kind: "cancel_draft", label: "取消" } satisfies InboxActionDescriptor, + priority_rank: Math.max(3, Number(item.priority_rank || 0)), + }; +} + export default function InboxPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -61,14 +86,18 @@ export default function InboxPage() { }); }, [notificationsQuery.data, workItemsQuery.data]); + const viewItems = useMemo(() => { + return inboxData.items.map((item) => remapDraftInboxItem(item)); + }, [inboxData.items]); + const projectOptions = useMemo(() => { const map = new Map(); - for (const item of inboxData.items) { + for (const item of viewItems) { if (!item.project_id) continue; map.set(String(item.project_id), item.project_name || `项目 #${item.project_id}`); } return Array.from(map.entries()).map(([value, label]) => ({ value, label })); - }, [inboxData.items]); + }, [viewItems]); const activeProjectOptions = useMemo(() => { const projects = activeProjectsQuery.data || []; @@ -79,11 +108,11 @@ export default function InboxPage() { }, [activeProjectsQuery.data]); const projectScopedItems = useMemo(() => { - return inboxData.items.filter((item) => { + return viewItems.filter((item) => { if (projectFilter === "all") return true; return String(item.project_id || "") === projectFilter; }); - }, [inboxData.items, projectFilter]); + }, [viewItems, projectFilter]); const tabCounts = useMemo(() => { return { @@ -212,6 +241,25 @@ export default function InboxPage() { } if (action.kind === "confirm" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { + const workItemState = String(item.source_work_item.state || "").toLowerCase(); + if (workItemState === "draft_pending_confirm") { + const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, { + action: "confirm", + draft_version: item.source_work_item.draft_version ?? undefined, + }); + await refreshAll(); + const followupReply = String(result.followup_reply || "").trim(); + if (result.status === "succeeded") { + toast({ title: followupReply || "草稿已确认并入库", variant: "success" }); + } else if (result.status === "pending_manual") { + toast({ title: followupReply || "草稿确认后仍需人工处理", variant: "default" }); + } else { + toast({ title: followupReply || "草稿确认失败,请查看详情", variant: "error" }); + } + setExpandedItemId(item.id); + return; + } + const missingProjectScope = isMissingProjectScopeItem(item); const selectedProjectId = missingProjectScope ? getSelectedProjectId(item) : (item.project_id ?? null); if (missingProjectScope && !selectedProjectId) { @@ -243,6 +291,38 @@ export default function InboxPage() { return; } + if (action.kind === "revise_draft" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { + const revision = window.prompt("请输入修改意见", ""); + if (revision === null) return; + const trimmed = revision.trim(); + if (!trimmed) { + toast({ title: "请先输入修改意见", variant: "default" }); + return; + } + const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, { + action: "revise", + revision_note: trimmed, + draft_version: item.source_work_item.draft_version ?? undefined, + }); + await refreshAll(); + const followupReply = String(result.followup_reply || "").trim(); + toast({ title: followupReply || "草稿已重新起草", variant: "success" }); + setExpandedItemId(item.id); + return; + } + + if (action.kind === "cancel_draft" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { + const result = await aiPmoApi.decideDraftWorkItem(item.source_work_item.id, { + action: "cancel", + draft_version: item.source_work_item.draft_version ?? undefined, + }); + await refreshAll(); + const followupReply = String(result.followup_reply || "").trim(); + toast({ title: followupReply || "草稿已取消", variant: "default" }); + setExpandedItemId(item.id); + return; + } + if (action.kind === "reject" && item.source_kind === "ai_pmo_work_item" && item.source_work_item) { const result = await aiPmoApi.decideWorkItem(item.source_work_item.id, { action: "reject", diff --git a/frontend/src/types/inbox.ts b/frontend/src/types/inbox.ts index d7d47563..874b0906 100644 --- a/frontend/src/types/inbox.ts +++ b/frontend/src/types/inbox.ts @@ -6,6 +6,8 @@ export type InboxDisplayKind = "action_required" | "auto_running" | "auto_done" export type InboxSectionKey = "all" | InboxDisplayKind; export type InboxActionKind = | "confirm" + | "revise_draft" + | "cancel_draft" | "reject" | "open_detail" | "open_target" diff --git a/scripts/ai_steward_pressure_coach.py b/scripts/ai_steward_pressure_coach.py index 65a314c9..405625de 100755 --- a/scripts/ai_steward_pressure_coach.py +++ b/scripts/ai_steward_pressure_coach.py @@ -2,7 +2,7 @@ """AI 管家并发压测 + 纠错调教脚本。 目标: -1. 对 /rag/ask 或 /ai-steward/rag/ask 做并发多轮压测。 +1. 对 /rag/ask 做并发多轮压测。 2. 覆盖“纠错追问 + AI 引擎调用链”高风险问法,检测模板化误答泄漏。 3. 可选自动提交反馈(positive / negative + correct_answer)用于线上调教数据积累。 4. 输出 JSON 报告并按阈值给出 pass/fail 退出码。 diff --git a/tmp_short_confirm_redesign_snapshot.html b/tmp_short_confirm_redesign_snapshot.html new file mode 100644 index 00000000..a66f82d2 --- /dev/null +++ b/tmp_short_confirm_redesign_snapshot.html @@ -0,0 +1,100 @@ + +short-confirm-redesign-snapshot + + +
+

短确认重设计验收结果快照

+
{
+  "messages": {
+    "msg1_id": "accept-shortconfirm-redesign-msg1-1773935717",
+    "msg2_id": "accept-shortconfirm-redesign-msg2-1773935717"
+  },
+  "checks": {
+    "msg1_intent": "query.answer",
+    "msg2_intent": "query.answer",
+    "msg2_context_source": "session_inherited",
+    "msg2_has_classify_call": false,
+    "msg2_has_any_llm_call": true
+  },
+  "runs": [
+    {
+      "id": 106,
+      "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717",
+      "status": "succeeded",
+      "retry_count": 0,
+      "work_item_id": 397,
+      "summary": "模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容: message_keys=['role', 'conten...",
+      "created_at": "2026-03-19 23:55:17.132742",
+      "updated_at": "2026-03-19 23:56:50.205344",
+      "completed_at": "2026-03-19 23:56:50.205773"
+    },
+    {
+      "id": 107,
+      "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717",
+      "status": "succeeded",
+      "retry_count": 0,
+      "work_item_id": 398,
+      "summary": "好的,已收到您的确认。如果后续有需要记录或处理的事项,请随时告诉我。",
+      "created_at": "2026-03-19 23:55:22.233825",
+      "updated_at": "2026-03-19 23:56:52.832398",
+      "completed_at": "2026-03-19 23:56:52.832584"
+    }
+  ],
+  "work_items": [
+    {
+      "id": 397,
+      "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717",
+      "state": "failed",
+      "intent": "query.answer",
+      "project_id": 51,
+      "signal_id": null,
+      "updated_at": "2026-03-19 23:55:17.185230",
+      "context_source": "llm_only",
+      "execution_signal_id": null
+    },
+    {
+      "id": 398,
+      "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717",
+      "state": "succeeded",
+      "intent": "query.answer",
+      "project_id": 51,
+      "signal_id": null,
+      "updated_at": "2026-03-19 23:56:52.816953",
+      "context_source": "session_inherited",
+      "execution_signal_id": null
+    }
+  ],
+  "ai_calls": [
+    {
+      "id": 1932,
+      "function_name": "classify_intent_ai_pmo_kernel",
+      "created_at": "2026-03-19 23:55:39.954021",
+      "hit_m1": 1,
+      "hit_m2": 0,
+      "hit_msg2_classify": 0
+    },
+    {
+      "id": 1933,
+      "function_name": "adaptive_rag_answer_normal",
+      "created_at": "2026-03-19 23:56:50.143564",
+      "hit_m1": 1,
+      "hit_m2": 0,
+      "hit_msg2_classify": 0
+    },
+    {
+      "id": 1934,
+      "function_name": "adaptive_rag_answer_normal",
+      "created_at": "2026-03-19 23:56:52.810858",
+      "hit_m1": 0,
+      "hit_m2": 1,
+      "hit_msg2_classify": 0
+    }
+  ]
+}
+
+ \ No newline at end of file diff --git a/tmp_short_confirm_redesign_snapshot.json b/tmp_short_confirm_redesign_snapshot.json new file mode 100644 index 00000000..8046bcc2 --- /dev/null +++ b/tmp_short_confirm_redesign_snapshot.json @@ -0,0 +1,87 @@ +{ + "messages": { + "msg1_id": "accept-shortconfirm-redesign-msg1-1773935717", + "msg2_id": "accept-shortconfirm-redesign-msg2-1773935717" + }, + "checks": { + "msg1_intent": "query.answer", + "msg2_intent": "query.answer", + "msg2_context_source": "session_inherited", + "msg2_has_classify_call": false, + "msg2_has_any_llm_call": true + }, + "runs": [ + { + "id": 106, + "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717", + "status": "succeeded", + "retry_count": 0, + "work_item_id": 397, + "summary": "模型调用失败:function=adaptive_rag_answer_normal, provider=glm, model=GLM-4.7, reason=LLM 返回空内容: message_keys=['role', 'conten...", + "created_at": "2026-03-19 23:55:17.132742", + "updated_at": "2026-03-19 23:56:50.205344", + "completed_at": "2026-03-19 23:56:50.205773" + }, + { + "id": 107, + "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717", + "status": "succeeded", + "retry_count": 0, + "work_item_id": 398, + "summary": "好的,已收到您的确认。如果后续有需要记录或处理的事项,请随时告诉我。", + "created_at": "2026-03-19 23:55:22.233825", + "updated_at": "2026-03-19 23:56:52.832398", + "completed_at": "2026-03-19 23:56:52.832584" + } + ], + "work_items": [ + { + "id": 397, + "source_message_id": "accept-shortconfirm-redesign-msg1-1773935717", + "state": "failed", + "intent": "query.answer", + "project_id": 51, + "signal_id": null, + "updated_at": "2026-03-19 23:55:17.185230", + "context_source": "llm_only", + "execution_signal_id": null + }, + { + "id": 398, + "source_message_id": "accept-shortconfirm-redesign-msg2-1773935717", + "state": "succeeded", + "intent": "query.answer", + "project_id": 51, + "signal_id": null, + "updated_at": "2026-03-19 23:56:52.816953", + "context_source": "session_inherited", + "execution_signal_id": null + } + ], + "ai_calls": [ + { + "id": 1932, + "function_name": "classify_intent_ai_pmo_kernel", + "created_at": "2026-03-19 23:55:39.954021", + "hit_m1": 1, + "hit_m2": 0, + "hit_msg2_classify": 0 + }, + { + "id": 1933, + "function_name": "adaptive_rag_answer_normal", + "created_at": "2026-03-19 23:56:50.143564", + "hit_m1": 1, + "hit_m2": 0, + "hit_msg2_classify": 0 + }, + { + "id": 1934, + "function_name": "adaptive_rag_answer_normal", + "created_at": "2026-03-19 23:56:52.810858", + "hit_m1": 0, + "hit_m2": 1, + "hit_msg2_classify": 0 + } + ] +} \ No newline at end of file diff --git a/tmp_short_confirm_redesign_summary.html b/tmp_short_confirm_redesign_summary.html new file mode 100644 index 00000000..48d95b69 --- /dev/null +++ b/tmp_short_confirm_redesign_summary.html @@ -0,0 +1,36 @@ + + + + + 短确认验收结果 + + + +
+

短确认重设计验收(消息1写操作场景)

+

消息1 ID: accept-shortconfirm-redesign-msg1-1773935717

+

消息2 ID: accept-shortconfirm-redesign-msg2-1773935717

+

消息1 intent: query.answer(预期 signal.create)

+

消息2 context_source: session_inherited

+

消息2 intent: query.answer(继承了消息1的查询意图)

+

消息2 classify_intent_ai_pmo_kernel 调用: 无

+

消息2 LLM 调用: 有(adaptive_rag_answer_normal)

+
ai_call_logs:
+1932 classify_intent_ai_pmo_kernel hit_msg1=1 hit_msg2=0
+1933 adaptive_rag_answer_normal    hit_msg1=1 hit_msg2=0
+1934 adaptive_rag_answer_normal    hit_msg1=0 hit_msg2=1
+
+work_items:
+397 source=msg1 state=failed    intent=query.answer context_source=llm_only
+398 source=msg2 state=succeeded intent=query.answer context_source=session_inherited
+
+ +