diff --git a/AGENTS.md b/AGENTS.md index 862267ab..fd56301b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,8 @@ frontend/ # React 前端 - 禁止模型兜底回复:模型失败/超时/空响应/结构化解析失败时,必须显式报错并保留真实失败原因,禁止返回固定兜底文案冒充成功结果 - 核心业务内容零兜底:宪章、信号分析、任务拆解、项目建议、范围边界、风险判断、资源计划、对齐结论等用户可见核心业务内容,禁止使用默认文案、模板句子、占位字段或 stub 数据冒充真实生成结果;AI 失败、证据不足或结构化失败时,必须显式返回失败或标记为“待确认/证据不足/生成失败” - 业务语义治理:凡是会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS`,只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其它目录只允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析 +- 用户可见业务动作拍板必须优先走“上下文 packet -> 模型结构化决策 -> gatekeeper”链路;`reply anchor / button click / work_item_id / tenant / permission / safety / idempotency` 这类协议级判断允许保留确定性规则,但展示反馈、草稿确认/修改/取消、query vs execute、followup 动作归类不得再由 `looks_like_*` 或关键词直接最终拍板 +- 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,禁止把“改 prompt、补反例、扩关键词”当作主修法;必须优先检查 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组装、`SemanticDecision` 结构化输出与 gatekeeper 放行条件。Prompt 只允许补结构、口径和禁止事项,不得替代主链裁决 ### 8.1 系统定位 @@ -225,6 +227,7 @@ frontend/ # React 前端 ## 13. 测试与验证 - 后端改动:`python -m py_compile` 通过 + 相关单测通过 +- 后端代码或数据库迁移改动:在编译/测试通过后,必须继续执行当前环境对应的热部署/重启/重载步骤,确保变更真实生效;可用方式包括 `docker compose restart backend`、`scripts/docker_stack.sh restart|dev-restart`、`systemctl restart neural-sync`、`nginx -s reload` 等。若当前环境不是部署机、权限不足或不具备热部署条件,必须明确说明未执行原因与阻塞点,禁止默认跳过 - 前端改动:`npm run build` 通过 + `tsc --noEmit` 零错误 - 新增接口:必须在 `backend/tests/` 补充对应单测 diff --git a/CLAUDE.md b/CLAUDE.md index 4704340b..5a39624a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ - 建议:规则放 `AGENTS.md`,架构/目录说明放 `CLAUDE.md` - 当前 AI 行为的系统宪章已在 `AGENTS.md` 中升级为“事实层 / 评审层 / 生成层 / 执行层”四层分治;所有新增 AI 业务默认按该宪章接入,`CLAUDE.md` 仅保留结构与背景说明 - 新增执行约定:计划类需求一旦确认进入实现阶段,默认连续执行到“实现完成 + 验证完成 + 统一回复验收结果”;仅在明确阻塞、高风险冲突、context 限制或用户主动中断时才中途打断。 +- 新增 bugfix 执行约定:缺陷修复/回归修复允许跨多个直接相关文件端到端完成,不再按文件数硬性拆分;但必须同时满足“改动范围围绕同一根因”“不夹带无关重构”“最终附带编译/测试验证”。大功能开发或结构性重构仍优先按子任务拆分。 +- 新增发布约定:凡后端代码或数据库迁移发生改动,完成编译/测试后必须继续执行当前环境对应的热部署/重启/重载步骤,确认服务已加载新版本;若当前机器不是部署环境、缺少权限或不具备热部署条件,必须在结果中明确说明未执行原因与阻塞点,禁止默认省略。 ## 2. 项目定位 - 项目名:`PMFlow / Neural Sync` @@ -197,7 +199,7 @@ backend/ - 新增:`backend/app/utils/project_command_utils.py`(项目创建标题/描述提取与归一化辅助) - 新增:`backend/app/services/ai_pmo_intent_routing.py`(AI PMO 意图/路由常量与解析器收口,统一 `family.action` 路由映射) - 新增:`backend/app/services/ai_pmo/workflow_semantic_planner/`(统一业务语义规划层:唯一允许承载 workflow bundle 语义关键词/正则/兼容 helper 的目录) -- 新增:`backend/app/services/ai_pmo/semantic_context/`(OpenCow 风格语义上下文子模块:统一 `SemanticContextPacket/ModeCard/SemanticDecision/DecisionGateResult` 契约、`SemanticContextAssembler`、`SemanticDecisionGatekeeper` 与 `SemanticDecisionService`;当前已接入 ingress shadow 观测,并为 intent/query-scene 主链提供 direct metadata 替代语义入口) +- 新增:`backend/app/services/ai_pmo/semantic_context/`(OpenCow 风格语义上下文子模块:统一 `SemanticContextPacket/ModeCard/SemanticDecision/DecisionGateResult` 契约、`SemanticContextAssembler`、`SemanticDecisionGatekeeper` 与 `SemanticDecisionService`;当前除 ingress shadow 观测外,也开始承接 draft followup 这类用户可见动作判定,作为 intent/query-scene/followup 统一 packet 决策底座) - 新增:`backend/app/models/reply_iteration.py`(回复质量迭代模型:`QualityAsset/QualityAssetPair/JudgeRun/OptimizerCandidate/PromptRelease`) - 新增:`backend/app/models/training_pipeline.py`(训练/微调管线模型:`TrainingDataset/TrainingJob/TrainingEvaluation/TrainedModelArtifact/TrainedModelDeployment`) - 新增:`backend/app/models/mounted_ai_runtime.py`(Mounted Runtime 运行时模型:`MountedTopicWorkspace/MountedDraftWorkspace/MountedConversationRun/MountedAgentTrace/MountedSourceActivation/MountedToolCapabilityPolicy`;`MountedTopicWorkspace` 额外承载 `conversation_scope_key + plan_status`,用于同群同人 topic 隔离与 plan 状态机;`MountedDraftWorkspace` 额外承载 `active_work_item_id + latest_source_message_id + latest_reply_message_id`,用于当前 active draft 绑定与 reply anchor 锚定) @@ -583,10 +585,10 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - `ExecuteQueryProjectMixin` 对 `message_intent=requirement_plan` 的查询必须优先走 mounted runtime 直答快路径:默认只输出自然短话术,每轮最多只保留 1 个关键问题;当 `scoring_basis/target_user/output_form/cadence/first_scope` 收集完成时,必须直接整理成需求草稿,禁止继续空转追问 - `requirement_plan` 的 Mounted Runtime 状态必须显式落库到 `MountedTopicWorkspace.plan_status`:至少区分 `idle / collecting / options_ready / plan_ready / awaiting_execute_confirm / suspended`,禁止只靠 `draft.state` 或 `metadata_json` 猜当前阶段 - 新的 OpenCow 风格语义上下文链必须先组装 `SemanticContextPacket`,再由模型返回 `SemanticDecision`,最后由 gatekeeper 决定 `plan_status`;当前 shadow 结果已进入 Mounted Runtime 观测,同时 intent/query-scene 主链已切到 direct metadata,不得再把 `semantic_bundle` 当作主链真源或绕过统一执行门禁 +- `requirement_plan` 的 A/B/C 是用户可见需求收敛骨架,可以固定结构;但 A/B/C 的业务选项、AI 推荐项、推荐理由和代价必须参照 OpenCow 的 `上下文包 -> 模型结构化决策 -> gatekeeper -> 渲染` 模式生成,禁止在代码里硬编码业务候选项或旧场景模板。用户回复 `A/B/C` 时必须优先回写上一轮模型生成的选项映射,不能再落回静态选项表。 - 正在收敛中的 `requirement_plan` 必须支持普通人碎片化回复:像“技术指标吧 / 给投顾看 / 飞书日报就行 / 每天收盘后发”这种单字段短句,即使缺少新的 `semantic_bundle`,只要同线程/同群同人存在 active plan workspace,也必须自动续接当前需求收敛,不得回落到通用 query 或高阶 RAG - `requirement_plan` 的追问节奏必须像自然聊天:上一轮只确认新增信息、下一轮只问一个缺口;初始描述中的“每日/自动”不能过早推断成已确认的更新频率,避免少问关键问题或提前出草稿 -- `requirement_plan` 必须按 profile 区分场景:`scoring` 需求继续收集 `打分依据/目标用户/输出形式/更新频率/第一版范围`,`generic` 需求收集 `输入材料/目标用户/输出形式/响应时效/第一版范围`;像“会议纪要提待办”这类普通工具需求,禁止再复用“这个分数怎么打”的旧问题模板 -- `generic requirement_plan` 的 `A/B/C` 选项只能出现在 active requirement session 的 `collecting` 轮次里,并且只允许绑定当前缺口字段(如 `target_user / output_form / cadence`);普通闲聊、query.answer、非 requirement followup 必须继续保持自由文案,禁止全局套用选项模板 +- `requirement_plan` 当前用户可见主链只保留 IT 项目收敛口径;历史 `generic/scoring` 数据只允许作为兼容读取或旧测试场景,不得再作为新会话的用户可见模板扩散。普通闲聊、query.answer、非 requirement followup 必须继续保持自由文案,禁止全局套用 A/B/C 选项骨架 - 用户显式说“聊个新需求 / 另一个需求 / 跟上一个无关”时,AI PMO 必须先清掉当前会话里继承的旧 `current_project_id`,避免后续短回复继续被旧项目上下文污染 - 裸 `A/B/C/确认/你自行推荐` 这类低信息量回复,如果没有命中当前 active requirement session 或待确认草稿,必须直接提示“当前没有进行中的收敛轮次/草稿”,禁止再回落到 `query.answer` 检索图谱并生成旧项目结论 - 飞书私聊中的“答错了/不对/调用高级模型/用更强模型/重新回答”必须被识别为 `correction_escalation` 指令:优先复用上一轮上下文主题并强制深度回答,禁止把这类话术当成新的系统配置查询 @@ -607,6 +609,8 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 - AI PMO 执行层必须统一产出 `execution_result.action_contract` 与 `action_param_source`,并写入 `operation_execution_ledger`;禁止在执行链路旁路写副作用且不入账 - AI PMO 写操作幂等以消息级锚点为准:优先使用 `source_message_id` / `feishu_message_id` 作为执行账本幂等键,标题/正文仅用于业务内容,不得参与重复执行判定 - PMFlow 业务语义治理:会决定 `project.create / signal_source.onboard / followup.* / query vs execute` 的中文关键词、正则、`looks_like_*`、`*_HINTS/*_KEYWORDS` 只能定义在 `backend/app/services/ai_pmo/workflow_semantic_planner/` 或 prompt/catalog 配置层;其他目录仅允许做文本清洗、ID/URL/repo/locator 抽取与结构化结果解析 +- 用户可见业务动作拍板必须优先走 `SemanticContextPacket -> SemanticDecision -> gatekeeper` 主链;协议级锚点(`reply_to_message_id/button/work_item_id`)、租户权限、幂等等确定性规则可以保留,但展示反馈、草稿确认/修改/取消、followup 动作归类、`query vs execute` 不能再由 `looks_like_*` 或关键词直接最终拍板 +- 若问题根因属于“动作路由错误 / 上下文劫持 / 独立业务流误触发”,主修法必须优先落在 ingress 分流、active topic/draft 检测、`SemanticContextPacket` 组包、`SemanticDecision` 结构化判断与 gatekeeper 放行条件;禁止先用“补 prompt 反例 / 扩关键词 / 调语义文案”顶上去。Prompt 只负责结构、口径、禁止事项,不替代主链裁决 - `scripts/semantic_lint.py` + `scripts/semantic_lint_baseline.txt` 为语义治理守门脚本;CI 会阻止新的散落语义进入非 allowlist 目录,baseline 仅记录已知兼容残留 - 只读问答链路的 prompt 读取优先级固定为:`prompt registry manifest 校验 -> active prompt release -> 代码 seed prompt fallback`;业务服务禁止直接绕过 `PromptReleaseService` 拼接已纳管的 read-only QA prompt - 回复质量迭代与训练管线的原始反馈入口统一复用 `feedback_center_records` / `qa_records` / `ai_call_logs`;新增 `quality_assets/training_datasets` 只能作为派生层,禁止再造平行原始数据主表 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 eb5f6421..cabbe9e5 100644 --- a/backend/app/api/ai_pmo_api/helpers_inbox_filters.py +++ b/backend/app/api/ai_pmo_api/helpers_inbox_filters.py @@ -20,11 +20,18 @@ def _should_hide_work_item_from_inbox(*, row: WorkItem, ingest: IngestEvent) -> normalized = _normalize_noise_text(message_text) if not normalized: return True - if any(keyword in normalized for keyword in FRONT_NOISE_STRUCTURED_HINTS): + has_structured_context = any( + ( + _safe_int(row.project_id) is not None, + _safe_int(row.signal_id) is not None, + bool(payload.get("closure_summary")), + bool(payload.get("closure_dialogue")), + bool(payload.get("closure_modules")), + ) + ) + if has_structured_context: return False - if normalized in FRONT_NOISE_EXACT: - return True - if len(normalized) <= 8 and any(keyword in normalized for keyword in FRONT_NOISE_HINTS): + if len(normalized) <= 3: return True return False @@ -61,19 +68,15 @@ def _is_low_value_closure_payload(payload: dict[str, Any]) -> bool: has_closure = bool(summary or dialogue or modules) if not has_closure: return True - compact_summary = re.sub(r"\s+", "", summary).lower() if summary else "" - summary_is_generic = bool(compact_summary and GENERIC_SOURCE_TEXT_RE.match(compact_summary)) - weak_summary = bool(summary and any(token in summary for token in LOW_VALUE_CLOSURE_HINTS)) has_assistant_turn = any( str(item.get("role") or "").strip().lower() == "assistant" and str(item.get("text") or "").strip() for item in turns if isinstance(item, dict) ) - if weak_summary and not has_assistant_turn: + summary_len = len(re.sub(r"\s+", "", summary)) if summary else 0 + if summary_len <= 6 and not has_assistant_turn and not modules: return True - if summary_is_generic and not modules and not has_assistant_turn: - return True - if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and weak_summary: + if dialogue and "机器人:" not in dialogue and len(turns) <= 1 and summary_len <= 12 and not modules: return True return False 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 2e6ea77f..0008e78f 100644 --- a/backend/app/api/ai_pmo_api/helpers_inbox_payload.py +++ b/backend/app/api/ai_pmo_api/helpers_inbox_payload.py @@ -97,7 +97,7 @@ def _serialize_work_item_summary( 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 "" - prefer_closure_title = bool(compact_message and GENERIC_SOURCE_TEXT_RE.match(compact_message)) + prefer_closure_title = bool(compact_message and len(compact_message) <= 8 and closure_summary) title_seed = ( (closure_summary or message_text) if prefer_closure_title diff --git a/backend/app/api/ai_pmo_api/shared.py b/backend/app/api/ai_pmo_api/shared.py index f88f2cea..3b01ac3d 100644 --- a/backend/app/api/ai_pmo_api/shared.py +++ b/backend/app/api/ai_pmo_api/shared.py @@ -17,16 +17,7 @@ from ...dependencies.tenant import get_tenant_id from ...models import DecisionRecord, IngestEvent, PlanBundle, PortfolioSnapshot, Project, ReplyReceipt, User, WorkItem from ...services.ai_pmo.ai_pmo_cadence import AIPMOCadenceService from ...services.ai_pmo.ai_pmo_kernel import AIPMOKernelService -from ...services.ai_pmo.workflow_semantic_planner.constants import ( - FRONT_NOISE_HINTS, - FRONT_NOISE_STRUCTURED_HINTS, - GENERIC_SIGNAL_SOURCE_MESSAGE_RE as GENERIC_SOURCE_TEXT_RE, - INTENT_QUERY, - LOW_VALUE_CLOSURE_HINTS, - ROUTE_QUERY_ANSWER, - SMALLTALK_EXACT as FRONT_NOISE_EXACT, -) router = APIRouter(prefix="/ai-pmo", tags=["AI PMO"]) logger = get_logger(__name__) -FRONT_NOISE_INTENTS = {INTENT_QUERY, ROUTE_QUERY_ANSWER} +FRONT_NOISE_INTENTS = {"query.read_only", "query.answer"} diff --git a/backend/app/config/ai_prompts.py b/backend/app/config/ai_prompts.py index b74eb427..57ca9350 100644 --- a/backend/app/config/ai_prompts.py +++ b/backend/app/config/ai_prompts.py @@ -72,24 +72,78 @@ PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT = """ 你的输入是系统整理好的 semantic context packet。你的职责不是执行写操作,而是: 1. 判断用户这句话是否继续当前 topic。 2. 判断当前 workflow 是否继续停留在 plan。 -3. 给出 draft patch 建议。 -4. 给出要回复给用户的内容。 -5. 明确 requested_actions 与是否需要确认。 +3. 如果 packet.ingress.candidate_kind == "draft_followup",要判断这句回复是在确认草稿、修改草稿、取消草稿,还是仅仅反馈展示/沟通方式。 +4. 如果 packet.ingress.candidate_kind == "active_context_followup",要判断这句短追问应该留在当前需求、留在当前草稿,还是升级成独立 workflow。 +5. 给出 draft patch 建议。 +6. 给出要回复给用户的内容。 +7. 明确 requested_actions 与是否需要确认。 硬约束: - 只能依据 packet 中的事实、草稿、recent turns、mode card 判断。 - 不得把推断包装成事实。 - 不得请求 mode card 明确禁止的动作。 +- 如果 packet.ingress.candidate_kind == "draft_followup": + - 只有用户明确要求改内容、补内容、删内容、缩范围、改字段时,才能输出 followup_action="revise"。 + - 如果用户是在追问当前草稿里明显缺失的模块或字段,这属于补充当前草稿内容,输出 followup_action="revise",并把 revision_note 写成要补充的模块。 + - 如果用户只是反馈展示形式、消息长短、卡片样式、沟通方式,输出 followup_action="answer_only"。 + - 如果用户明确同意草稿内容,输出 followup_action="confirm"。 + - 如果用户明确不要当前草稿,输出 followup_action="cancel"。 + - 展示反馈不能包装成 revision_note。 +- 如果 packet.ingress.candidate_kind == "active_context_followup": + - 这是“当前会话已经有 active requirement / active draft 时”的上下文优先决策,不是在做通用 intent 分类。 + - 必须先基于当前已绑定的对象来判断动作,不要把这句消息先当成全局 query 或通用新意图。 + - 对于只补充当前讨论模块的短追问,默认优先判断为 current_requirement 或 current_draft,而不是独立 workflow。 + - 如果 active_context_kind == "draft_pending_confirm": + - 用户明确表示确认当前草稿时,输出 followup_scope="current_draft" 且 followup_action="confirm"。 + - 用户明确表示重写、重出一版、按这版重来、补充缺失模块时,输出 followup_scope="current_draft" 且 followup_action="revise"。 + - 用户明确表示不要当前草稿时,输出 followup_scope="current_draft" 且 followup_action="cancel"。 + - 仅当用户真正要脱离当前草稿、发起独立动作时,才允许输出 followup_scope="independent_workflow"。 + - 只有当用户同时表达了明确动作、明确对象、明确作用域时,才能输出 followup_scope="independent_workflow"。 + - 如果当前上下文是待确认草稿,且用户追问的是草稿里缺失模块,应输出 followup_scope="current_draft",同时给出 followup_action="revise" 和 revision_note。 + - 如果当前上下文是收敛中的 requirement plan,且用户只是在追问某个模块或缺口,应输出 followup_scope="current_requirement"。 + - 如果证据不足以判断归属,输出 followup_scope="clarify",并用 assistant_reply 追问,不要擅自跳到独立 workflow。 +- followup_scope 仅在 active_context_followup 场景下使用;其它场景必须输出 "none"。 +- followup_action 在 draft_followup 场景下必须使用;在 active_context_followup 场景下仅当 followup_scope="current_draft" 时允许使用,其它情况输出 "none"。 +- 如果 packet.ingress.candidate_kind == "cold_start_route": + - 这是“当前消息还没有绑定到明确对象时”的冷启动路由决策。 + - 你的职责是基于 packet.latest_user_message、packet.topic.metadata、packet.thread、packet.ingress.thread_context 决定应该进入哪些 workflow。 + - 不要使用硬编码关键词仓库思维,不要把业务词表当成主裁决器;必须根据当前消息表达的动作、对象、作用域和上下文关系做整体判断。 + - 如果消息本质上是在咨询、分析、聊需求、收敛范围、问建议,而不是立即执行写动作,应输出 query_flow。 + - 如果消息明确表达了新建项目、创建任务、接入信号源、创建项目信号等独立动作,才输出对应 workflow。 + - 允许多 workflow,但只有当消息同时表达了多个明确动作时才这样做。 + - scene_metadata 只允许包含:message_intent、review_framework、consultant_mode_preference、force_thinking_mode、advise_subtype、topic_switch。 + - workflow_plans 里的 workflow_type 只允许:query_flow、new_project_flow、project_flow、task_flow、signal_source_flow、project_signal_flow、followup_flow。 + - workflow_action 应与 workflow_type 匹配,例如 answer/create/onboard/discover/continue/confirm/cancel/refresh/commit。 + - target_scope 只允许:global、project、thread。 + - operation_codes 必须使用系统路由键,例如 query.answer、project.create、task.create、signal_source.onboard、project_signal.create、project_signal.discover、project_signal.commit、project_signal.cancel、project_signal.refresh、followup.continue、followup.confirm。 + - cold_start_route 场景下,requested_actions 只允许请求 workflow.bundle.compose。 + - 如果证据不足,不要伪造执行意图;优先保守为 query_flow,并把不确定性写入 uncertainties。 - 输出必须是一个 JSON 对象,不要输出 Markdown。 JSON schema: { "topic_relation": "continue_current_topic | new_topic | uncertain", "mode_decision": "stay_plan | ready_confirm | request_execute | answer_only", + "followup_scope": "none | current_requirement | current_draft | independent_workflow | clarify", + "followup_action": "none | confirm | revise | cancel | answer_only", + "revision_note": "string", "draft_patch": {}, + "workflow_plans": [ + { + "workflow_type": "query_flow | new_project_flow | project_flow | task_flow | signal_source_flow | project_signal_flow | followup_flow", + "workflow_action": "string", + "target_scope": "global | project | thread", + "operation_codes": ["string"], + "requires_confirmation": false, + "confidence": 0.0, + "clarification_needed": false, + "metadata": {} + } + ], + "scene_metadata": {}, "assistant_reply": "string", "requires_confirmation": true, - "requested_actions": ["reply.compose"], + "requested_actions": ["reply.compose | workflow.bundle.compose"], "confidence": 0.0, "uncertainties": [] } @@ -98,6 +152,136 @@ semantic_context_packet: {semantic_packet} """.strip() +PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT = """ +你是 PMFlow 的需求收敛 PM。 + +你的输入是系统整理好的 requirement_plan_round_packet。你的职责是:在当前需求上下文内,为“本轮缺口字段”生成 A/B/C 三个可选收敛方向,并给出一个 AI 推荐。 + +硬约束: +- A/B/C 是固定需求收敛骨架,必须保留。 +- 不要复用旧项目模板、旧业务场景或 packet 中不存在的产品名。 +- 不要把模板占位句冒充业务判断。 +- 只能基于 packet.current_context / collected_fields / collected_field_summary / latest_user_message / latest_user_reply_summary 生成选项。 +- 这是要发给飞书的一条文本消息,必须严格遵守 packet.reply_budget。 +- 最终渲染后的整条消息必须控制在 packet.reply_budget.max_chars 以内,避免消息被截断。 +- opening / focus / 每个 option 字段 / recommendation.reason / reply_instruction 都要短句化,不要写成长段说明。 +- 不要输出“当前我们正在收敛”“project_nature 字段需判断”这类内部字段说明。 +- 不要复述 packet、current_context、latest_user_message、collected_fields、reply_budget 这类内部 JSON 键名,也不要把 snake_case 英文字段名直接写给用户看。 +- 如果 latest_user_message 只是 A / B / C 这种短回复,优先参考 packet.latest_user_reply_summary 和 packet.collected_field_summary 理解用户刚刚确认的方向。 +- 如果证据不足,要把不确定性写进 recommendation.reason 或 uncertainties,不要编造成事实。 +- 输出必须是一个 JSON 对象,不要输出 Markdown。 + +JSON schema: +{ + "opening": "string", + "focus": "string", + "options": [ + { + "key": "A", + "title": "string", + "best_for": "string", + "suggested_capability": "string", + "rationale": "string", + "tradeoff": "string" + }, + { + "key": "B", + "title": "string", + "best_for": "string", + "suggested_capability": "string", + "rationale": "string", + "tradeoff": "string" + }, + { + "key": "C", + "title": "string", + "best_for": "string", + "suggested_capability": "string", + "rationale": "string", + "tradeoff": "string" + } + ], + "recommendation": { + "key": "A | B | C", + "reason": "string" + }, + "reply_instruction": "string", + "uncertainties": [] +} + +requirement_plan_round_packet: +{round_packet} +""".strip() + +PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT = """ +你是 PMFlow 的需求收敛 PM。 + +你的输入是系统整理好的 requirement_plan_draft_packet。你的职责是:基于当前已经收敛出的事实,生成一版贴合当前业务语境、可以直接给用户确认的需求草稿。 + +硬约束: +- 只能基于 packet.current_context / collected_fields / collected_field_summary / pending_items / known_constraints / revision_request 生成草稿。 +- 草稿必须贴合当前需求,不要复用旧项目模板、旧业务场景或 packet 中不存在的产品名。 +- 不要复述 packet、current_context、collected_fields、known_constraints 这类内部 JSON 键名,也不要把 snake_case 英文字段名直接写给用户看。 +- 不要输出“审批、审计、权限一次做满”“人员能力图谱”“工作流状态与审计日志”“确认门禁”这类与当前需求无关的旧系统固定话术。 +- 如果当前需求不是部署/发布/运维类项目,不要引入回滚、部署目标清单、环境凭证、平台运维团队等旧部署语境。 +- 不要把模板占位句冒充业务判断。 +- 如果证据不足,要把不确定性放进 pending_items,不要编造成事实。 +- 必须给出 3-5 个外部信号源候选,它们属于当前需求下面的子需求候选,不是新的独立 workflow。 +- external_signal_sources 只能写外部来源,例如开放平台、第三方 API、外部趋势源、外部商品源、外部回流源;不能写飞书消息、项目任务、责任人、数据库表、内部日志、内部工作流。 +- 输出必须是一个 JSON 对象,不要输出 Markdown。 + +JSON schema: +{ + "intro": "string", + "title": "string", + "one_liner": "string", + "goal": "string", + "project_nature": "string", + "target_user": "string", + "signal_inputs": "string", + "known_info": "string", + "tech_route": "string", + "first_scope": "string", + "core_flow": "string", + "delivery_mode": "string", + "success_signal": "string", + "risks": "string", + "pending_items": ["string"], + "closing": "string", + "summary": "string", + "positioning_one_liner": "string", + "target_customers": "string", + "external_signal_sources": [ + { + "name": "string", + "purpose": "string", + "phase": "string" + } + ], + "requirement_items": [ + { + "section": "string", + "title": "string", + "description": "string" + } + ], + "missing_info": ["string"], + "mvp_items": ["string"], + "team_setup": [ + { + "role": "string", + "notes": "string" + } + ], + "validation_metrics": { + "product": ["string"] + } +} + +requirement_plan_draft_packet: +{draft_packet} +""".strip() + def build_default_role_prompt_bundle() -> dict: """返回默认角色 Prompt 配置(每次调用返回新对象)。""" @@ -230,6 +414,8 @@ __all__ = [ "PROMPT_EXTERNAL_TREND_QA", "PROMPT_INDUSTRY_TREND_QA", "PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT", + "PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT", + "PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT", "PROMPT_REGISTRY", "PROMPT_REGISTRY_MAP", "get_prompt_registry", diff --git a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py index ca604655..8890d9da 100644 --- a/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py +++ b/backend/app/services/adaptive_rag_service/mixin_answer_mainline.py @@ -164,10 +164,11 @@ class AdaptiveRAGAnswerMainlineMixin: ) query_type = self._classify_query_type(effective_q) requested_action_mode = self._normalize_action_mode(action_mode) - effective_action_mode = self._resolve_action_mode( + effective_action_mode = await self._resolve_action_mode_async( requested_mode=requested_action_mode, question=resolved_question, effective_question=effective_q, + asked_via=asked_via, ) return { "session_id": session_id, diff --git a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_core.py b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_core.py index a29d8488..371d3e64 100644 --- a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_core.py +++ b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_core.py @@ -13,37 +13,41 @@ class AdaptiveRAGRequirementAckCoreMixin: return ACTION_MODE_AUTO @classmethod - def _looks_like_write_intent(cls, question: str) -> bool: - text = str(question or "").strip().lower() - if not text: - return False - has_verb = any(k in text for k in REQUIREMENT_WRITE_VERB_KEYWORDS) - has_object = any(k in text for k in REQUIREMENT_WRITE_OBJECT_KEYWORDS) - if not has_verb or not has_object: - return False - if any(prefix in text for prefix in WRITE_EXPRESSIONS): - return True - if "新增" in text or "增加" in text or "记录" in text or "入库" in text: - return True - return False - - @classmethod - def _resolve_action_mode( + async def _resolve_action_mode_async( cls, *, requested_mode: str, question: str, effective_question: str, + asked_via: str, ) -> str: mode = cls._normalize_action_mode(requested_mode) if mode != ACTION_MODE_AUTO: return mode merged = f"{question} {effective_question}".strip() - if not cls._looks_like_write_intent(merged): + if not merged: return ACTION_MODE_READ - has_question_form = ("?" in merged) or ("?" in merged) or cls._contains_any(merged, QUESTION_HINTS) - if has_question_form: + try: + from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner + + bundle = await WorkflowSemanticPlanner.aplan_message( + message_text=merged, + source_system=str(asked_via or "").strip() or "adaptive_rag_ack", + metadata={}, + ) + except Exception: + return ACTION_MODE_READ + + workflows = list(getattr(bundle, "workflows", []) or []) + if not workflows: + return ACTION_MODE_READ + + has_query_flow = any(str(getattr(item, "workflow_type", "") or "").strip() == "query_flow" for item in workflows) + has_non_query_flow = any(str(getattr(item, "workflow_type", "") or "").strip() != "query_flow" for item in workflows) + if not has_non_query_flow: + return ACTION_MODE_READ + if has_query_flow: return ACTION_MODE_READ_WRITE return ACTION_MODE_WRITE @@ -194,4 +198,3 @@ class AdaptiveRAGRequirementAckCoreMixin: reason = error_reason or "写入异常" return f"未完成入库:{reason}。我已记录本次失败,请稍后重试。" return "已收到需求,正在处理中。" - diff --git a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py index c99a7d80..4f0e2698 100644 --- a/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py +++ b/backend/app/services/adaptive_rag_service/mixin_requirement_ack_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from .shared import * -from ..ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class AdaptiveRAGRequirementAckFlowMixin: @@ -45,7 +44,6 @@ class AdaptiveRAGRequirementAckFlowMixin: linked_signal_id is not None and resolved_project_id is not None and action_taken in {"signal_created", "signal_deduped"} - and WorkflowSemanticPlanner.looks_like_signal_source_request(effective_question or question) ): try: source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( diff --git a/backend/app/services/adaptive_rag_service/shared_part_02.py b/backend/app/services/adaptive_rag_service/shared_part_02.py index f4ac6a25..087dd7ce 100644 --- a/backend/app/services/adaptive_rag_service/shared_part_02.py +++ b/backend/app/services/adaptive_rag_service/shared_part_02.py @@ -68,41 +68,6 @@ ACTION_MODE_AUTO = "auto" ACTION_MODE_READ = "read" ACTION_MODE_WRITE = "write" ACTION_MODE_READ_WRITE = "read_write" -REQUIREMENT_WRITE_VERB_KEYWORDS = ( - "新增", - "增加", - "添加", - "接入", - "支持", - "实现", - "创建", - "新建", - "记录", - "入库", - "同步", - "补充", -) -REQUIREMENT_WRITE_OBJECT_KEYWORDS = ( - "信号", - "需求", - "任务", - "项目", - "功能", - "接口", - "页面", - "按钮", - "规则", - "模块", - "卡路里", - "食谱", -) -WRITE_EXPRESSIONS = ( - "给", - "帮我", - "请帮", - "请给", - "麻烦", -) COUNT_HINTS = ("多少", "几个", "数量", "总数", "count") LIST_HINTS = ("哪些", "列表", "清单", "列出", "列一下", "分别", "名单", "都有哪些") STATUS_HINTS = ("进度", "状态", "阶段", "完成率", "完成度", "情况") 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 a4876d4f..3dfa2644 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/__init__.py @@ -44,23 +44,7 @@ __all__ = [ "WORKFLOW_ROUTE_KEYS", "ROUTE_CANONICAL_INTENT_MAP", "LEGACY_INTENT_ROUTE_MAP", - "PROJECT_SIGNAL_DISCOVER_HINTS", - "FOLLOWUP_REFRESH_HINTS", - "FOLLOWUP_CANCEL_HINTS", - "FOLLOWUP_COMMIT_HINTS", - "QUESTION_HINTS", - "SMALLTALK_EXACT", - "SMALLTALK_SHORT_HINTS", - "SMALLTALK_STRUCTURED_HINTS", - "FOLLOWUP_CONFIRM_HINTS", - "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", - "SHORT_CONFIRM_HINTS", "INTENT_LLM_FUNCTION_NAME", "INTENT_CANDIDATES", "WRITE_INTENTS", 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 22333a45..78d7f954 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 @@ -6,53 +6,9 @@ from ..workflow_semantic_planner import WorkflowSemanticPlanner class IntentResolver: """Resolve raw text to canonical intent.""" - OPERATION_VERBS = ( - "新增", - "增加", - "添加", - "加", - "加一个", - "加个", - "新建", - "创建", - "建立", - "立项", - "接入", - "修复", - "优化", - "改成", - "改为", - "更新", - "分配", - "指派", - "归档", - "执行", - ) - - @staticmethod - def _looks_like_negated_write_statement(*, text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - negation_tokens = ("别", "别再", "别乱", "不要", "不要再", "先别", "先不要", "禁止", "不能", "无需", "不用") - if not any(token in normalized for token in negation_tokens): - return False - write_tokens = ("创建", "新建", "新增", "增加", "添加", "加", "接入", "执行", "分配", "指派", "立项") - object_tokens = ("信号", "需求", "任务", "项目", "事项", "工单", "流程", "信号源") - if any(w in normalized for w in write_tokens) and any(o in normalized for o in object_tokens): - return True - return bool( - re.search( - r"(?:别|不要|先别|先不要|禁止|不能).{0,8}(?:创建|新建|新增|增加|添加|加|接入|执行|分配|指派|立项)", - normalized, - re.IGNORECASE, - ) - ) - @staticmethod def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: pretty = re.sub(r"\s+", " ", str(text or "")).strip() - normalized = re.sub(r"\s+", "", str(text or "")).lower() if not pretty: return CanonicalIntent(INTENT_IGNORE, 1.0, project_id, "", "", {"reason": "empty"}) bundle = WorkflowSemanticPlanner.plan_message( @@ -61,17 +17,9 @@ class IntentResolver: metadata={"project_id": project_id}, ) workflow_types = [item.workflow_type for item in bundle.workflows] - if IntentResolver._is_smalltalk_message(pretty=pretty, normalized=normalized): + diagnostics = bundle.diagnostics if isinstance(bundle.diagnostics, dict) else {} + if str(diagnostics.get("message_intent") or "").strip().lower() == "casual_chat": return CanonicalIntent(INTENT_IGNORE, 0.95, project_id, "", "", {"reason": "smalltalk"}) - if IntentResolver._looks_like_negated_write_statement(text=pretty): - return CanonicalIntent( - INTENT_QUERY, - 0.9, - project_id, - "", - pretty[:300], - {"reason": "negated_write_statement"}, - ) explicit_followup_target = FOLLOWUP_WORK_ITEM_ID_RE.search(pretty) if explicit_followup_target is not None: return CanonicalIntent( @@ -86,10 +34,6 @@ class IntentResolver: "semantic_bundle": bundle.to_dict(), }, ) - if any(token in pretty for token in FOLLOWUP_CONFIRM_HINTS): - return CanonicalIntent(INTENT_FOLLOWUP_CONFIRM, 0.82, project_id, "", "", {}) - if any(token in pretty for token in FOLLOWUP_CONTINUE_HINTS): - return CanonicalIntent(INTENT_FOLLOWUP_CONTINUE, 0.82, project_id, "", "", {}) if workflow_types and workflow_types[0] == "followup_flow": followup_action = bundle.workflows[0].workflow_action followup_intent = INTENT_FOLLOWUP_CONFIRM if followup_action == "confirm" else INTENT_FOLLOWUP_CONTINUE @@ -104,7 +48,7 @@ class IntentResolver: "semantic_bundle": bundle.to_dict(), }, ) - if WorkflowSemanticPlanner.looks_like_signal_source_request(pretty): + if "signal_source_flow" in workflow_types: return CanonicalIntent( INTENT_SIGNAL_SOURCE_ONBOARD, 0.92, @@ -151,7 +95,7 @@ class IntentResolver: ), }, ) - if "query_flow" in workflow_types or IntentResolver._looks_like_query(text=pretty): + if "query_flow" in workflow_types: return CanonicalIntent( INTENT_QUERY, 0.88, @@ -169,22 +113,6 @@ class IntentResolver: {"fallback": True, "reason": "default_query_fallback", "semantic_bundle": bundle.to_dict()}, ) - @staticmethod - def _looks_like_signal_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - bundle = WorkflowSemanticPlanner.plan_message( - message_text=str(text or ""), - source_system="compat_intent_resolver", - ) - return any(item.workflow_type == "project_signal_flow" for item in bundle.workflows) - - @staticmethod - def _looks_like_task_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - bundle = WorkflowSemanticPlanner.plan_message( - message_text=str(text or ""), - source_system="compat_intent_resolver", - ) - return any(item.workflow_type == "task_flow" for item in bundle.workflows) - @staticmethod def _extract_task_title(text: str) -> str: cleaned = re.sub(r"\s+", " ", str(text or "")).strip() @@ -215,27 +143,6 @@ class IntentResolver: return "" return fallback[:120] - @staticmethod - def _is_smalltalk_message(*, pretty: str, normalized: str) -> bool: - _ = normalized - return WorkflowSemanticPlanner._is_smalltalk_message(pretty) - - @staticmethod - def _looks_like_query(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - bundle = WorkflowSemanticPlanner.plan_message( - message_text=str(text or ""), - source_system="compat_intent_resolver", - ) - return len(bundle.workflows) == 1 and bundle.workflows[0].workflow_type == "query_flow" - - @staticmethod - def _looks_like_project_create_request(*, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - bundle = WorkflowSemanticPlanner.plan_message( - message_text=str(text or ""), - source_system="compat_intent_resolver", - ) - return any(item.workflow_type == "new_project_flow" for item in bundle.workflows) - @staticmethod def _build_signal_title(text: str, *, default: str) -> str: raw = str(text or "").strip() 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 86fd945a..31b81c9c 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 @@ -1,7 +1,6 @@ from __future__ import annotations from .models import * -from ..workflow_semantic_planner import WorkflowSemanticPlanner class RouteResolver: """Map canonical intent + text into workflow route.""" @@ -58,7 +57,7 @@ class RouteResolver: return 1 @staticmethod - def looks_like_project_signal_discover(*, message_text: str, metadata: dict[str, Any]) -> bool: # semantic-lint: ignore - compat route helper over planner metadata + def requests_project_signal_discover_route(*, message_text: str, metadata: dict[str, Any]) -> bool: explicit_route = str(metadata.get("workflow_route") or "").strip().lower() if isinstance(metadata, dict) else "" if explicit_route == ROUTE_PROJECT_SIGNAL_DISCOVER: return True @@ -66,17 +65,20 @@ class RouteResolver: return explicit_action == ROUTE_ACTION_DISCOVER @staticmethod - def parse_followup_action( + def resolve_followup_route_action( *, metadata: Optional[dict[str, Any]] = None, canonical_intent: str, message_text: str = "", ) -> str: - return WorkflowSemanticPlanner.parse_followup_action( - metadata=metadata, - canonical_intent=canonical_intent, - message_text=message_text, - ) + _ = message_text + 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 {"continue", "confirm", "commit", "cancel", "refresh"}: + return explicit_action + if canonical_intent == INTENT_FOLLOWUP_CONFIRM: + return ROUTE_ACTION_CONFIRM + return ROUTE_ACTION_CONTINUE @classmethod def from_canonical( @@ -91,7 +93,7 @@ class RouteResolver: if not route_key: route_key = LEGACY_INTENT_ROUTE_MAP.get(canonical_intent.name, ROUTE_QUERY_ANSWER) if canonical_intent.name == INTENT_SIGNAL_CREATE: - if cls.looks_like_project_signal_discover(message_text=message_text, metadata=metadata): + if cls.requests_project_signal_discover_route(message_text=message_text, metadata=metadata): 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 @@ -99,7 +101,7 @@ class RouteResolver: 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( + action = cls.resolve_followup_route_action( metadata={**metadata, "followup_context_present": True}, canonical_intent=canonical_intent.name, message_text=message_text, 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 710c92a6..6db12c4e 100644 --- a/backend/app/services/ai_pmo/ai_pmo_intent/shared.py +++ b/backend/app/services/ai_pmo/ai_pmo_intent/shared.py @@ -8,22 +8,10 @@ from typing import Any, Optional from ....utils.project_command_utils import extract_project_create_title from ...signal.signal_source_orchestration import SignalSourceOrchestrationService -from ..workflow_semantic_planner.constants import ( - FOLLOWUP_CANCEL_HINTS, - FOLLOWUP_COMMIT_HINTS, - FOLLOWUP_CONFIRM_HINTS, - FOLLOWUP_CONTINUE_HINTS, - FOLLOWUP_REFRESH_HINTS, - FOLLOWUP_WORK_ITEM_ID_RE, - PROJECT_CREATE_HINTS, - PROJECT_SIGNAL_DISCOVER_HINTS, - QUESTION_HINTS, - SHORT_CONFIRM_HINTS, - SIGNAL_CREATE_HINTS, - SMALLTALK_EXACT, - SMALLTALK_SHORT_HINTS, - SMALLTALK_STRUCTURED_HINTS, - TASK_CREATE_HINTS, + +FOLLOWUP_WORK_ITEM_ID_RE = re.compile( + r"(?:work[_ -]?item|工作项|队列项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", + re.IGNORECASE, ) INTENT_QUERY = "query.read_only" @@ -232,8 +220,6 @@ LEGACY_INTENT_ROUTE_MAP: dict[str, str] = { "signal_source.onboard": ROUTE_SIGNAL_SOURCE_ONBOARD, } -SESSION_SHORT_CONFIRM_WINDOW_SECONDS = 5 * 60 -SHORT_CONFIRM_MAX_MESSAGE_LENGTH = 10 INTENT_LLM_FUNCTION_NAME = "classify_intent_ai_pmo_kernel" INTENT_CANDIDATES = ( INTENT_SIGNAL_SOURCE_ONBOARD, diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py index b483f0e6..5986d877 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_agent_kernel.py @@ -7,13 +7,6 @@ import re from dataclasses import dataclass from typing import Iterable, Sequence -from ..workflow_semantic_planner.constants import ( - BACKGROUND_MARKERS, - CORRECTION_ACTION_PATTERNS, - INTENT_GROUP_KEYWORDS, - MULTI_SEGMENT_MARKERS, -) - INTENT_FUNCTION_SIMPLE = "classify_intent_ai_pmo_kernel" INTENT_FUNCTION_COMPLEX = "classify_intent_complex" @@ -33,10 +26,6 @@ def _normalize_text(text: str) -> str: return re.sub(r"\s+", "", str(text or "")).lower() -def _normalize_name(name: str) -> str: - return re.sub(r"[\s_\-]+", "", str(name or "")).lower() - - @dataclass(slots=True) class IntentRouteDecision: function_name: str @@ -59,52 +48,9 @@ class CorrectionDecision: confidence: float matched_keywords: list[str] - -def _count_intent_groups(text: str) -> int: - normalized = _normalize_text(text) - hits = 0 - for tokens in INTENT_GROUP_KEYWORDS.values(): - if any(_normalize_name(token) in normalized for token in tokens): - hits += 1 - return hits - - def is_complex_instruction(text: str, *, project_names: Sequence[str] | None = None) -> tuple[bool, list[str]]: - normalized = _normalize_text(text) - if not normalized: - return False, [] - - reasons: list[str] = [] - length_threshold = int(_env_float("AGENT_COMPLEX_LENGTH_THRESHOLD", 20)) - if len(normalized) > length_threshold: - reasons.append("length_gt_threshold") - - has_multi_segment = any(marker in text for marker in MULTI_SEGMENT_MARKERS) - if has_multi_segment: - reasons.append("multi_segment") - - has_background = any(marker in text for marker in BACKGROUND_MARKERS) - if has_background: - reasons.append("has_background") - - intent_group_hits = _count_intent_groups(text) - if intent_group_hits >= 3 or ( - intent_group_hits >= 2 - and ( - len(normalized) > max(14, length_threshold) - or has_multi_segment - or has_background - ) - ): - reasons.append("multi_intent_groups") - - for name in project_names or []: - candidate = _normalize_name(name) - if len(candidate) >= 2 and candidate in normalized: - reasons.append("contains_project_name") - break - - return bool(reasons), reasons + _ = (text, project_names) + return False, [] def select_intent_route(text: str, *, project_names: Sequence[str] | None = None) -> IntentRouteDecision: @@ -125,19 +71,7 @@ def select_intent_route(text: str, *, project_names: Sequence[str] | None = None def resolve_correction(text: str) -> CorrectionDecision: - normalized = _normalize_text(text) - if not normalized: - return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[]) - - for action, tokens in CORRECTION_ACTION_PATTERNS: - matched = [token for token in tokens if _normalize_name(token) in normalized] - if matched: - confidence = 0.9 if len(matched) >= 2 else 0.78 - return CorrectionDecision( - action=action, - confidence=float(min(1.0, confidence)), - matched_keywords=matched, - ) + _ = text return CorrectionDecision(action="none", confidence=0.0, matched_keywords=[]) 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 6536d4c3..7acf2fb5 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 @@ -97,10 +97,6 @@ from ..ai_pmo_intent import ( IntentResolver, RouteResolver, ) -from ..workflow_semantic_planner.constants import ( - GENERIC_SIGNAL_SOURCE_MESSAGE_RE, - MODULE_KEYWORD_RULES, -) from ...ai_steward_service import AIStewardService from ...llm_service import llm_service from ...model.model_context_bundle import ModelContextBundleService diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py index 12fb3258..9d06e975 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/ai_pmo_typed_executor_mixins_parse.py @@ -68,7 +68,7 @@ class TypedExecutorParseMixin: return "".join(str(value or "").split()).strip().lower() @classmethod - def _looks_like_raw_instruction(cls, *, candidate: str, source: str) -> bool: + def _candidate_echoes_source_instruction(cls, *, candidate: str, source: str) -> bool: normalized_candidate = cls._compact_text(candidate) normalized_source = cls._compact_text(source) if not normalized_candidate or not normalized_source: @@ -225,9 +225,9 @@ class TypedExecutorParseMixin: summary = cls._normalize_short_text(row.get("summary"), max_len=300) content = cls._normalize_short_text(row.get("content"), max_len=1800) - if cls._looks_like_raw_instruction(candidate=title, source=cleaned_message): + if cls._candidate_echoes_source_instruction(candidate=title, source=cleaned_message): title = "" - if cls._looks_like_raw_instruction(candidate=summary, source=cleaned_message): + if cls._candidate_echoes_source_instruction(candidate=summary, source=cleaned_message): summary = "" if not summary: diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py index c8a2adae..7ae37484 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_data.py @@ -61,18 +61,15 @@ class KernelClosureDataMixin: if not question or not answer: return -1.0 score = 0.0 - merged = f"{question} {answer}" - if "信号源" in merged: - score += 3.0 - if any(token in merged for token in ("模块", "后端", "前端", "流程", "SSTC", "任务", "执行", "接口")): - score += 2.0 if any(token in answer for token in ("暂时无法生成可靠回答", "模型服务异常", "请稍后重试")): score -= 5.0 - if normalized_message and cls._is_generic_signal_source_message(normalized_message): - if "信号源" in question: + if normalized_message: + question_norm = cls._normalize_dialogue_text(question, max_len=500) + answer_norm = cls._normalize_dialogue_text(answer, max_len=900) + if question_norm and (normalized_message in question_norm or question_norm in normalized_message): score += 1.5 - if "项目" in answer: - score += 1.0 + if answer_norm and (normalized_message in answer_norm or answer_norm in normalized_message): + score += 0.8 created_at = cls._parse_datetime_like(getattr(row, "created_at", None)) if reference_at is not None and created_at is not None: gap_seconds = abs((reference_at - created_at).total_seconds()) diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py index 3d448c96..8889c3ed 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/closure/ai_pmo_kernel_mixins_closure_payload.py @@ -13,21 +13,13 @@ for _name, _value in vars(_common).items(): class KernelClosurePayloadMixin: @staticmethod def _is_generic_signal_source_message(text: str) -> bool: - compact = "".join(str(text or "").split()).strip().lower() - if not compact: - return False - if bool(GENERIC_SIGNAL_SOURCE_MESSAGE_RE.match(compact)): - return True - return len(compact) <= 24 and "信号源" in compact and ("这个" in compact or "步骤" in compact) + _ = text + return False @classmethod def _extract_implementation_modules_from_text(cls, text: str) -> list[str]: - normalized = str(text or "").lower() - modules: list[str] = [] - for module_name, keywords in MODULE_KEYWORD_RULES: - if any(str(keyword).lower() in normalized for keyword in keywords): - modules.append(module_name) - return modules[:6] + _ = (cls, text) + return [] @staticmethod def _build_closure_dialogue_excerpt(turns: list[dict[str, str]]) -> str: @@ -72,11 +64,6 @@ class KernelClosurePayloadMixin: return f"机器人先提示条件不足,用户随后继续要求执行:{latest_user[:120]}" return "机器人提示条件不足,需补齐项目归属后继续执行信号源接入。" - if latest_user and cls._is_generic_signal_source_message(latest_user) and modules: - if len(modules) == 1 and str(modules[0]).strip() == "信号源接入": - return "用户要求将需求按步骤接入信号源,需要先补齐项目归属再继续执行。" - return f"同一消息闭环内,用户要求将需求按步骤接入信号源,重点模块:{'、'.join(modules[:4])}。" - if modules: return f"用户在本次消息闭环中推进信号源接入,涉及模块:{'、'.join(modules[:4])}。" @@ -136,7 +123,6 @@ class KernelClosurePayloadMixin: normalized_source_system == "feishu" and ( (not self._turns_have_assistant(turns)) - or (self._is_generic_signal_source_message(normalized_message) and len(turns) <= 2) ) ) if needs_semantic_turns: 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 a035e7ab..8981091c 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 @@ -121,7 +121,7 @@ class KernelFollowupRoutingMixin: workflow_route: WorkflowRoute, ) -> tuple[dict[str, Any], dict[str, Any]]: explicit_target_id = self._extract_followup_work_item_id(message_text) - followup_action = RouteResolver.parse_followup_action( + followup_action = RouteResolver.resolve_followup_route_action( message_text=message_text, canonical_intent=intent.name, ) 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 4772f6de..c5272e5d 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 @@ -70,7 +70,7 @@ class KernelIngressRenderMixin: direct_reply_text = f"{mixed_sidecar_reply}\n\n{direct_reply_text}" else: direct_reply_text = mixed_sidecar_reply - if _looks_like_upstream_timeout_message(direct_reply_text): + if _is_upstream_timeout_message(direct_reply_text): logger.warning( "ai_pmo_upstream_timeout_reply_detected", tenant_id=int(self.tenant_id), @@ -288,8 +288,6 @@ class KernelIngressRenderMixin: has_webhook = "webhook" in normalized if not (has_long_connection and has_webhook): return None - if not any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS): - return None return "已按长连接模式处理飞书接入,当前不会走 webhook;这条说明不会再被当成信号源类型变更。" @staticmethod 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 e022d1b5..2ad33c51 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 @@ -21,15 +21,17 @@ class KernelIntentLLMMixin: rule_intent: Optional[CanonicalIntent] = None, session_last_intent_override: Optional[str] = None, session_project_id_override: Optional[int] = None, + inherit_session_context: bool = True, ) -> Optional[CanonicalIntent]: - session_last_intent = ( - str(session_last_intent_override or "").strip() - if session_last_intent_override is not None - else str(getattr(session, "last_intent", "") or "").strip() if session is not None else "" - ) + if session_last_intent_override is not None: + session_last_intent = str(session_last_intent_override or "").strip() + elif inherit_session_context and session is not None: + session_last_intent = str(getattr(session, "last_intent", "") or "").strip() + else: + session_last_intent = "" if session_project_id_override is not None: session_project_id = _safe_int(session_project_id_override) - elif session is not None: + elif inherit_session_context and session is not None: session_project_id = _safe_int(getattr(session, "current_project_id", None)) else: session_project_id = None 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 79b0e491..44bf7b91 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 @@ -11,26 +11,6 @@ for _name, _value in vars(_common).items(): class KernelIntentResolveMixin: - _QUERY_FOLLOWUP_TOKENS = ( - "需求", - "mvp", - "方案", - "对应", - "这个", - "它", - "给我", - "补充", - "展开", - "细化", - "重答", - "重新回答", - "高级模型", - "更强模型", - "分析", - "评审", - "怎么做", - ) - @staticmethod def _within_recent_window(last_active_at: Optional[datetime], *, window_seconds: int) -> bool: if not isinstance(last_active_at, datetime): @@ -38,12 +18,10 @@ class KernelIntentResolveMixin: delta = _utc_now_naive() - last_active_at return 0 <= delta.total_seconds() <= float(window_seconds) - @classmethod - def _looks_like_query_scene_followup(cls, message_text: str) -> bool: - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if not normalized: - return False - return any(token in normalized for token in cls._QUERY_FOLLOWUP_TOKENS) + @staticmethod + def _is_query_context_intent(intent_name: str) -> bool: + normalized = str(intent_name or "").strip().lower() + return normalized in {INTENT_QUERY, ROUTE_QUERY_ANSWER} @classmethod def _should_inherit_recent_feishu_session_context( @@ -52,21 +30,12 @@ class KernelIntentResolveMixin: message_text: str, session: Optional[ConversationSession], ) -> bool: - if session is None: - return False - if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): - return False - sender = str(getattr(session, "sender", "") or "").strip().lower() - if not sender.startswith("feishu:"): - return False - if not cls._within_recent_window( - getattr(session, "last_active_at", None), - window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS, - ): - return False - if not str(getattr(session, "last_intent", "") or "").strip(): - return False - return cls._looks_like_query_scene_followup(message_text) + _ = (cls, message_text, session) + # OpenCow-style object binding does not allow bare recent-session query + # inheritance without an explicit bound object. Query context may still + # flow through reply anchors, but no longer through no-anchor token + # heuristics on the latest message text. + return False async def _resolve_intent( self, @@ -86,28 +55,6 @@ class KernelIntentResolveMixin: message_text=message_text, session=session, ) - if self._is_short_confirmation_message( - message_text=message_text, - session=session, - message_sent_at=message_sent_at, - session_snapshot=session_snapshot, - reply_anchor_present=reply_anchor_present, - ) and session is not None: - inherited = self._build_short_confirm_intent( - message_text=message_text, - session=session, - world_model=world_model, - session_snapshot=session_snapshot, - ) - logger.info( - "ai_pmo_short_confirm_inherited", - tenant_id=self.tenant_id, - sender=str(getattr(session, "sender", "") or ""), - inherited_from=str(getattr(session, "last_intent", "") or ""), - inherited_intent=str(inherited.name or ""), - project_id=_safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id")), - ) - return inherited, "session_inherited" # 模型唯一入口:不再使用规则/正则意图分流。 if not self._intent_llm_enabled(): @@ -124,6 +71,7 @@ class KernelIntentResolveMixin: session_project_id_override=( _safe_int(getattr(session, "current_project_id", None)) if inherit_session_context else None ), + inherit_session_context=inherit_session_context, ) if llm_intent is None: raise RuntimeError("intent_model_only_no_valid_llm_intent") diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/intent/ai_pmo_kernel_mixins_intent_rules.py index 12dca14f..37bac352 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 @@ -68,10 +68,6 @@ class KernelIntentRulesMixin: or any(token in normalized for token in ("怎么", "如何", "为何", "为什么", "是什么", "有哪些", "吗", "呢", "是否", "是不是")) ) - @classmethod - def _looks_like_contextual_write_confirmation(cls, *, message_text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.looks_like_contextual_write_confirmation(message_text) - def _recover_session_followup_write_intent( self, *, @@ -80,47 +76,13 @@ class KernelIntentRulesMixin: world_model: dict[str, Any], rule_intent: CanonicalIntent, ) -> Optional[CanonicalIntent]: - if session is None: - return None - if rule_intent.name != INTENT_QUERY: - return None - if not self._within_recent_window( - getattr(session, "last_active_at", None), - window_seconds=SESSION_SHORT_CONFIRM_WINDOW_SECONDS, - ): - return None - if not (self._is_default_rule_query(rule_intent) or float(rule_intent.confidence or 0.0) <= 0.7): - return None - if not self._looks_like_contextual_write_confirmation(message_text=message_text): - return None - - inherited_name = str(getattr(session, "last_intent", "") or "").strip() - inherited_text = str(getattr(session, "last_message_text", "") or "").strip() - if inherited_name not in WRITE_INTENTS and inherited_text: - inherited_project_id = _safe_int(getattr(session, "current_project_id", None)) - inferred = self.intent_resolver.resolve(text=inherited_text, project_id=inherited_project_id) - if inferred.name in WRITE_INTENTS: - inherited_name = inferred.name - - if inherited_name not in WRITE_INTENTS: - return None - - project_id = _safe_int(getattr(session, "current_project_id", None)) or _safe_int(world_model.get("resolved_project_id")) - if project_id is not None and int(project_id) <= 0: - project_id = None - - canonical_text = inherited_text or message_text - return self._build_canonical_intent( - intent_name=inherited_name, - message_text=canonical_text, - project_id=project_id, - confidence=0.84, - metadata={ - "context_source": "session_write_recovered", - "inherited_from_intent": str(getattr(session, "last_intent", "") or "").strip(), - "trigger_message": re.sub(r"\s+", " ", str(message_text or "")).strip()[:120], - }, - ) + _ = (message_text, session, world_model, rule_intent) + # OpenCow-style routing requires object binding before any write action. + # This compat recovery path used to elevate low-context session followups + # into write intents without a bound object, which is exactly the class of + # bug we are removing. Keep the method as an inert compat shim so callers + # don't break, but never recover a write intent from session state alone. + return None @staticmethod def _has_required_slots(*, intent: CanonicalIntent, message_text: str) -> tuple[bool, dict[str, Any]]: @@ -223,40 +185,6 @@ class KernelIntentRulesMixin: arbitration_llm_confidence=float(llm_intent.confidence or 0.0), ), source - @classmethod - def _is_short_confirmation_message( - cls, - *, - message_text: str, - session: Optional[ConversationSession], - message_sent_at: Optional[Any] = None, - session_snapshot: Optional[dict[str, Any]] = None, - reply_anchor_present: bool = False, - ) -> bool: - if not reply_anchor_present: - return False - snapshot = session_snapshot if isinstance(session_snapshot, dict) else {} - if session is None and not snapshot: - return False - 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 - 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() - if not normalized: - return False - if len(normalized) > SHORT_CONFIRM_MAX_MESSAGE_LENGTH: - 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): @@ -343,39 +271,3 @@ class KernelIntentRulesMixin: extras, ) return CanonicalIntent(INTENT_IGNORE, safe_confidence, project_id, "", "", extras or {"reason": "unsupported_intent"}) - - def _build_short_confirm_intent( - self, - *, - message_text: str, - session: ConversationSession, - world_model: dict[str, Any], - session_snapshot: Optional[dict[str, Any]] = None, - ) -> CanonicalIntent: - 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 - 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(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, - project_id=inherited_project_id, - confidence=0.86, - metadata={ - "context_source": "session_inherited", - "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/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py index 30cf355e..9d56416f 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel/mixins/world_model/ai_pmo_kernel_mixins_world_model_session.py @@ -3,6 +3,7 @@ from __future__ import annotations from .... import ai_pmo_kernel_common_core as _common +from ....workflow_semantic_planner import WorkflowSemanticPlanner from app.utils.thread_context import build_feishu_thread_key for _name, _value in vars(_common).items(): @@ -19,18 +20,14 @@ class KernelWorldModelSessionMixin: delta = now - last_active_at return 0 <= delta.total_seconds() <= float(window_seconds) - def _extract_inherited_project_id(self, *, session: Optional[ConversationSession]) -> Optional[int]: - if session is None: - return None - session_project_id = _safe_int(getattr(session, "current_project_id", None)) - if session_project_id is None or int(session_project_id) <= 0: - return None - if not self._within_recent_window( - getattr(session, "last_active_at", None), - window_seconds=SESSION_PROJECT_INHERIT_WINDOW_SECONDS, - ): - return None - return session_project_id + def _extract_inherited_project_id( + self, + *, + session: Optional[ConversationSession], + message_text: str = "", + ) -> Optional[int]: + _ = (session, message_text) + return None async def _get_or_create_session( self, 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 4eab709b..c07c022c 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 @@ -3,12 +3,6 @@ from __future__ import annotations from .imports import re -from ..workflow_semantic_planner.constants import ( - GENERIC_SIGNAL_SOURCE_MESSAGE_RE, - MODULE_KEYWORD_RULES, - POLICY_GATE_RULES, - TRANSPORT_MODE_CORRECTION_HINTS, -) STATE_RECEIVED = "received" STATE_MODELED = "modeled" @@ -37,3 +31,7 @@ INTENT_LLM_STAGE2_MIN_REMAINING_DEFAULT_SECONDS = 12.0 WORLD_MODEL_STATIC_CACHE_TTL_SECONDS = 10 * 60 WORLD_MODEL_STATIC_CACHE_KEY_PREFIX = "wm:static" + +# OpenCow-style routing no longer relies on centralized semantic token warehouses. +# Keep the policy gate input structural-only unless a caller passes explicit gates. +POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = () diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/helpers.py b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/helpers.py index 017d0dfd..84203c88 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/helpers.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_common_core/helpers.py @@ -2,7 +2,6 @@ from __future__ import annotations -from .constants import TRANSPORT_MODE_CORRECTION_HINTS from .env import ( env_bool, env_float, @@ -42,7 +41,7 @@ def _intent_llm_stage2_min_remaining_seconds() -> float: return intent_llm_stage2_min_remaining_seconds() -def _looks_like_upstream_timeout_message(text: str) -> bool: +def _is_upstream_timeout_message(text: str) -> bool: lowered = str(text or "").strip().lower() if not lowered: return False @@ -55,12 +54,8 @@ def _looks_like_upstream_timeout_message(text: str) -> bool: def _should_force_low_tier_intent_route(*, message_text: str, correction_action: str) -> bool: - if str(correction_action or "").strip().lower() != "none": - return True - normalized = re.sub(r"\s+", "", str(message_text or "")).lower() - if not normalized: - return False - return any(token in normalized for token in TRANSPORT_MODE_CORRECTION_HINTS) + _ = message_text + return str(correction_action or "").strip().lower() != "none" def _should_escalate_to_high_intent_route( 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 ee252eb9..043f07ed 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 @@ -172,9 +172,6 @@ from ..ai_pmo_intent import ( ROUTE_SIGNAL_SOURCE_ONBOARD, ROUTE_TASK_CREATE, WORKFLOW_ROUTE_KEYS, - SESSION_SHORT_CONFIRM_WINDOW_SECONDS, - SHORT_CONFIRM_HINTS, - SHORT_CONFIRM_MAX_MESSAGE_LENGTH, WRITE_INTENTS, CanonicalIntent, IntentResolver, 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 9315d67a..a8b33231 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 @@ -16,9 +16,6 @@ from app.services.ai_pmo.workflow_semantic_planner.constants import ( from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService class KernelIngressFlowEntryMixin: - _MIXED_HINT_CONNECTORS = ("顺便", "另外", "同时", "并且", "还有", "再问") - _MIXED_HINT_QUESTION = ("?", "?", "问下", "多少", "几个", "进展", "状态", "吗", "呢", "如何", "怎么", "有没有") - _MIXED_HINT_REQUIREMENT = ("创建", "新建", "新增", "安排", "执行", "处理", "接入", "录入", "添加") _RAG_QUERY_SCENE_KEYS = ( "message_intent", "review_framework", @@ -32,48 +29,6 @@ class KernelIngressFlowEntryMixin: QUERY_SCENE_CORRECTION_ESCALATION, QUERY_SCENE_REQUIREMENT_PLAN, }) - _QUERY_SCENE_TOKEN_RULES = ( - ( - QUERY_SCENE_CORRECTION_ESCALATION, - { - "consultant_mode_preference": "challenge", - "force_thinking_mode": "deep", - }, - ("不对", "错了", "答错", "纠正", "更正", "重新回答", "重答", "高级模型", "更强模型"), - ), - ( - QUERY_SCENE_BUSINESS_REVIEW, - { - "review_framework": "five_lenses", - "consultant_mode_preference": "clarify", - }, - ("分析", "拆解", "评审", "评估", "复盘", "诊断", "怎么做", "mvp", "需求"), - ), - ( - QUERY_SCENE_BUSINESS_REVIEW, - { - "consultant_mode_preference": "advise", - "advise_subtype": "assignee", - }, - ("谁来做", "谁负责", "安排谁", "推荐谁", "负责人"), - ), - ( - QUERY_SCENE_REQUIREMENT_PLAN, - { - "consultant_mode_preference": "clarify", - "topic_switch": "new", - }, - ("新需求", "新项目", "做个", "想做个", "准备做", "创建需求"), - ), - ( - QUERY_SCENE_REQUIREMENT_PLAN, - { - "consultant_mode_preference": "clarify", - }, - ("需求", "方案", "范围", "排期", "补充", "细化", "展开", "mvp"), - ), - ) - @classmethod def _extract_route_scene_metadata(cls, route_metadata: Optional[dict[str, Any]]) -> dict[str, str]: metadata = route_metadata if isinstance(route_metadata, dict) else {} @@ -86,35 +41,8 @@ class KernelIngressFlowEntryMixin: @classmethod def _derive_query_scene_metadata(cls, text: str) -> dict[str, Any]: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return {} - if any(token in normalized for token in ("最近怎么样", "在吗", "你好", "哈哈", "hi", "hello")): - return { - "message_intent": "casual_chat", - "consultant_mode_preference": "brainstorm", - } - for message_intent, extra_fields, tokens in cls._QUERY_SCENE_TOKEN_RULES: - if any(token in normalized for token in tokens): - return { - "message_intent": message_intent, - **extra_fields, - } return {} - @classmethod - def _looks_like_mixed_candidate(cls, *, text: str) -> bool: - normalized = re.sub(r"\s+", "", str(text or "")).lower() - if not normalized: - return False - has_question = any(token in normalized for token in cls._MIXED_HINT_QUESTION) - has_requirement = any(token in normalized for token in cls._MIXED_HINT_REQUIREMENT) - if not (has_question and has_requirement): - return False - if any(token in normalized for token in cls._MIXED_HINT_CONNECTORS): - return True - return "问" in normalized and "创建" in normalized - @staticmethod def _supports_mixed_sidecar(*, source_system: str) -> bool: return str(source_system or "").strip().lower() in {"feishu", "pmflow_manual", "ai_pmo_ui"} @@ -129,12 +57,8 @@ class KernelIngressFlowEntryMixin: semantic_bundle: Optional[dict[str, Any]], ) -> dict[str, Any]: context = rag_request_context if isinstance(rag_request_context, dict) else {} - derived = cls._derive_query_scene_metadata(message_text) - scene_metadata: dict[str, Any] = { - key: value - for key, value in derived.items() - if key in cls._RAG_QUERY_SCENE_KEYS and value not in (None, "") - } + _ = message_text + scene_metadata: dict[str, Any] = {} for key in cls._RAG_QUERY_SCENE_KEYS: value = context.get(key) if value in (None, ""): @@ -276,14 +200,15 @@ class KernelIngressFlowEntryMixin: message_text: str, project_hint_id: Optional[int], ) -> tuple[str, dict[str, Any]]: + normalized_text = str(message_text or "").strip() if not self._supports_mixed_sidecar(source_system=source_system): - return str(message_text or "").strip(), {} - if not self._looks_like_mixed_candidate(text=message_text): - return str(message_text or "").strip(), {} + return normalized_text, {} + if not normalized_text: + return normalized_text, {} rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id) try: - intent_result = await rag_service.classify_intent(str(message_text or "").strip()) + intent_result = await rag_service.classify_intent(normalized_text) except Exception as exc: logger.warning( "ai_pmo_mixed_precheck_failed", @@ -291,14 +216,14 @@ class KernelIngressFlowEntryMixin: source_system=source_system, error=str(exc), ) - return str(message_text or "").strip(), {} + return normalized_text, {} intent_label = str(intent_result.get("intent") or "").strip().lower() if intent_label != "mixed": - return str(message_text or "").strip(), {} + return normalized_text, {} - question_part = str(intent_result.get("question_part") or "").strip() or str(message_text or "").strip() - signal_part = str(intent_result.get("statement_part") or "").strip() or str(message_text or "").strip() + question_part = str(intent_result.get("question_part") or "").strip() or normalized_text + signal_part = str(intent_result.get("statement_part") or "").strip() or normalized_text sidecar_answer = "" try: conversation_key = source_message_id @@ -645,6 +570,19 @@ class KernelIngressFlowEntryMixin: ingest_event=ingest_event, ingress_options=ingress_options, ) + active_context_followup_result = await self._maybe_handle_active_context_followup( + source_system=source_system, + source_message_id=synthetic_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + message_text=clean_text, + thread_context=thread_context, + sender=sender, + force_bind=True, + ) + if active_context_followup_result is not None: + await _safe_finalize_mounted_runtime(result=active_context_followup_result) + return active_context_followup_result if await self._should_route_to_draft_confirmation_session( session=session, sender=sender, @@ -727,26 +665,35 @@ class KernelIngressFlowEntryMixin: source_sender_open_id=source_sender_open_id, ingress_options=ingress_options, message_text=clean_text, + session=session, + sender=sender, ) if orphan_requirement_followup_result is not None: await _safe_finalize_mounted_runtime(result=orphan_requirement_followup_result) return orphan_requirement_followup_result - mixed_meta: dict[str, Any] = {} - if not self._is_short_confirmation_message( + active_context_followup_result = await self._maybe_handle_active_context_followup( + source_system=source_system, + source_message_id=synthetic_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, 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, - source_message_id=synthetic_message_id, - channel_ref=channel_ref, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - message_text=clean_text, - project_hint_id=project_hint_id, - ) + thread_context=thread_context, + sender=sender, + force_bind=False, + ) + if active_context_followup_result is not None: + await _safe_finalize_mounted_runtime(result=active_context_followup_result) + return active_context_followup_result + mixed_meta: dict[str, Any] = {} + clean_text, mixed_meta = await self._prepare_mixed_sidecar_context( + source_system=source_system, + source_message_id=synthetic_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + message_text=clean_text, + project_hint_id=project_hint_id, + ) perf_work_item_id = int(work_item.id) try: stage_started_at = time.perf_counter() @@ -1307,6 +1254,37 @@ class KernelIngressFlowEntryMixin: thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) return bool(str(thread_context.get("reply_to_message_id") or "").strip()) + @staticmethod + async def _should_skip_no_anchor_draft_routing_by_bundle( + *, + message_text: str, + source_system: str, + ) -> bool: + text = str(message_text or "").strip() + if not text: + return False + try: + bundle = await WorkflowSemanticPlanner.aplan_message( + message_text=text, + source_system=str(source_system or "").strip() or "unknown", + metadata={}, + ) + except Exception: + return False + workflows = list(getattr(bundle, "workflows", []) or []) + if any( + str(getattr(item, "workflow_type", "") or "").strip() + in {"new_project_flow", "project_flow", "task_flow", "signal_source_flow", "project_signal_flow"} + for item in workflows + ): + return True + diagnostics = getattr(bundle, "diagnostics", None) + if not isinstance(diagnostics, dict): + return False + message_intent = str(diagnostics.get("message_intent") or "").strip().lower() + topic_switch = str(diagnostics.get("topic_switch") or "").strip().lower() + return message_intent == QUERY_SCENE_REQUIREMENT_PLAN and topic_switch == "new" + @classmethod def _should_route_to_draft_confirmation( cls, @@ -1321,31 +1299,19 @@ class KernelIngressFlowEntryMixin: if DraftConfirmHandler._extract_work_item_id(message_text) is not None: return True - thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) - action, _ = DraftConfirmHandler.parse_action_from_text(message_text=message_text) - if str(thread_context.get("reply_to_message_id") or "").strip(): - return action in { - DraftConfirmHandler._ACTION_CONFIRM, - DraftConfirmHandler._ACTION_REVISE, - DraftConfirmHandler._ACTION_CANCEL, - } or WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text) - - if WorkflowSemanticPlanner.looks_like_project_create_request(message_text): - return False if ( - WorkflowSemanticPlanner.looks_like_signal_source_request(message_text) - or WorkflowSemanticPlanner.looks_like_signal_create_request(message_text) - or WorkflowSemanticPlanner.looks_like_task_create_request(message_text) - or WorkflowSemanticPlanner.is_legacy_business_command(message_text) - or WorkflowSemanticPlanner.is_query_like_question(message_text) - or WorkflowSemanticPlanner.looks_like_project_lookup_question(message_text) + str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM + and len(str(message_text or "").strip()) <= 16 ): return False - return ( - str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM - and WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text) - ) + thread_context = cls._extract_thread_context_from_ingress_options(ingress_options) + if str(thread_context.get("reply_to_message_id") or "").strip(): + if str(getattr(session, "last_intent", "") or "").strip() != STATE_DRAFT_PENDING_CONFIRM: + return False + return not str(message_text or "").strip().endswith(("?", "?")) + + return False async def _should_route_to_draft_confirmation_session( self, @@ -1358,34 +1324,290 @@ class KernelIngressFlowEntryMixin: message_text: str, ingress_options: Optional[dict[str, Any]], ) -> bool: - if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): - return False + from ..draft_confirm_handler import DraftConfirmHandler + + handler = DraftConfirmHandler(self) + thread_context = self._extract_thread_context_from_ingress_options(ingress_options) + normalized_text = str(message_text or "").strip() + if str(thread_context.get("reply_to_message_id") or "").strip(): + if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: + return True + return await handler.has_active_draft_session( + sender=sender, + source_system=source_system, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + ) if self._should_route_to_draft_confirmation( session=session, message_text=message_text, ingress_options=ingress_options, ): return True - - from ..draft_confirm_handler import DraftConfirmHandler - - action, _ = DraftConfirmHandler.parse_action_from_text(message_text=message_text) - if action == DraftConfirmHandler._ACTION_UNKNOWN and not WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text): + if not normalized_text: return False + if await self._should_skip_no_anchor_draft_routing_by_bundle( + message_text=normalized_text, + source_system=source_system, + ): + return False + if str(getattr(session, "last_intent", "") or "").strip() == STATE_DRAFT_PENDING_CONFIRM: + return True - handler = DraftConfirmHandler(self) - return await handler.has_active_draft_session( + has_active_draft_session = await handler.has_active_draft_session( sender=sender, source_system=source_system, channel_ref=channel_ref, source_sender_open_id=source_sender_open_id, - thread_context=self._extract_thread_context_from_ingress_options(ingress_options), + thread_context=thread_context, ) + return bool(has_active_draft_session) @staticmethod def _build_orphan_requirement_followup_reply() -> str: return "当前没有进行中的 A/B/C 收敛轮次,也没有待确认草稿。你先发一句新需求,我按新的 topic 接着收。" + @staticmethod + def _derive_active_context_kind( + *, + topic: Any, + draft: Any, + ) -> str: + draft_state = str(getattr(draft, "state", "") or "").strip().lower() + plan_status = str(getattr(topic, "plan_status", "") or "").strip().lower() + if draft_state in {"ready", STATE_DRAFT_PENDING_CONFIRM} or plan_status in { + STATE_DRAFT_PENDING_CONFIRM, + "awaiting_execute_confirm", + "plan_ready", + "draft_ready", + }: + return "draft_pending_confirm" + return "requirement_plan" + + async def _bind_active_context_target( + self, + *, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + thread_context: Optional[dict[str, Any]], + sender: Optional[str], + conversation_key: str, + ) -> Optional[tuple[Any, Any, str]]: + from ..draft_confirm_handler import DraftConfirmHandler + + handler = DraftConfirmHandler(self) + try: + active_mounted = await handler._resolve_active_mounted_draft( + sender=str(sender or ""), + source_system=source_system, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + ) + except AttributeError: + if hasattr(self.db, "execute"): + raise + active_mounted = None + if active_mounted is not None: + draft, topic = active_mounted + return topic, draft, self._derive_active_context_kind(topic=topic, draft=draft) + + finder = getattr(self, "_find_active_requirement_plan_workspace", None) + if not callable(finder): + return None + topic, draft = await finder( + source_system=source_system, + source_message_id=source_message_id, + source_chat_id=channel_ref, + sender=None, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + if topic is None or draft is None: + return None + return topic, draft, self._derive_active_context_kind(topic=topic, draft=draft) + + @staticmethod + def _build_active_context_direct_reply_result(*, answer: str, reason_code: str) -> dict[str, Any]: + text = str(answer or "").strip() + return { + "handled": True, + "status": "answered", + "classification": "question", + "linked_project_ids": [], + "signal_id": None, + "created_project_id": None, + "auto_tasks": [], + "ai_trace_id": None, + "results": [], + "intent": INTENT_QUERY, + "direct_reply_required": True, + "direct_reply_text": text, + "reply_template_key": reason_code, + "work_item_id": None, + "decision_level": None, + "review_summary": None, + "workflow_route": ROUTE_QUERY_ANSWER, + } + + @staticmethod + def _extract_direct_answer_text(payload: Optional[dict[str, Any]]) -> str: + if not isinstance(payload, dict): + return "" + direct_reply = str(payload.get("direct_reply_text") or "").strip() + if direct_reply: + return direct_reply + answer = str(payload.get("answer") or "").strip() + if answer: + return answer + rag_result = payload.get("rag_result") + if isinstance(rag_result, dict): + return str(rag_result.get("answer") or "").strip() + return "" + + async def _maybe_handle_active_context_followup( + self, + *, + source_system: str, + source_message_id: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + message_text: str, + thread_context: Optional[dict[str, Any]], + sender: Optional[str], + force_bind: bool = False, + ) -> Optional[dict[str, Any]]: + if not str(message_text or "").strip(): + return None + is_feishu_source = str(source_system or "").strip().lower() == "feishu" + conversation_key = ( + build_feishu_conversation_scope_key( + source_chat_id=channel_ref, + source_sender_open_id=source_sender_open_id, + sender=None, + ) + if is_feishu_source + else source_message_id + ) + bound_context = await self._bind_active_context_target( + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + sender=sender, + ) + if bound_context is None: + return None + topic, draft, active_context_kind = bound_context + try: + packet = await SemanticContextAssembler(self.db).assemble( + tenant_id=int(self.tenant_id), + operator_user_id=0, + topic_workspace_id=int(getattr(topic, "id")), + latest_user_message=message_text, + ingress_context={ + "candidate_kind": "active_context_followup", + "source_system": source_system, + "reply_anchor_present": bool(thread_context), + "active_context_kind": active_context_kind, + "allowed_context_scopes": [ + "current_requirement", + "current_draft", + "independent_workflow", + "clarify", + ], + "thread_context": thread_context or {}, + }, + ) + decision = await SemanticDecisionService(llm_service).decide(packet=packet) + gate_result = SemanticDecisionGatekeeper.evaluate( + mode_card=packet.mode_card, + decision=decision, + ) + except Exception as exc: + logger.warning( + "active_context_followup_decision_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=source_message_id, + error=str(exc), + ) + return None + + if not bool(getattr(gate_result, "accepted", False)): + return None + + allowed_actions = { + str(item).strip() + for item in (getattr(gate_result, "allowed_actions", None) or []) + if str(item or "").strip() + } + followup_scope = str(getattr(decision, "followup_scope", "") or "").strip().lower() + assistant_reply = str(getattr(decision, "assistant_reply", "") or "").strip() + + if followup_scope == "current_requirement": + if "context.followup.current_requirement" not in allowed_actions: + return None + builder = getattr(self, "_build_requirement_planning_direct_result", None) + if callable(builder): + direct_result = await builder( + message_text=message_text, + source_system=source_system, + source_message_id=source_message_id, + source_chat_id=channel_ref, + sender=None, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + topic_switch=None, + ) + answer = self._extract_direct_answer_text(direct_result) + if answer: + return self._build_active_context_direct_reply_result( + answer=answer, + reason_code="requirement_plan_followup", + ) + if assistant_reply: + return self._build_active_context_direct_reply_result( + answer=assistant_reply, + reason_code="requirement_plan_followup", + ) + return None + + if followup_scope == "current_draft": + if "context.followup.current_draft" not in allowed_actions: + return None + from ..draft_confirm_handler import DraftConfirmHandler + + handler = DraftConfirmHandler(self) + return await handler.handle_text_reply( + message_text=message_text, + sender=str(sender or ""), + source_system=source_system, + source_message_id=source_message_id, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + semantic_followup_override={ + "action": str(getattr(decision, "followup_action", "") or "").strip().lower(), + "revision_note": str(getattr(decision, "revision_note", "") or "").strip(), + "assistant_reply": assistant_reply, + }, + ) + + if followup_scope == "clarify" and "context.followup.clarify" in allowed_actions and assistant_reply: + return self._build_active_context_direct_reply_result( + answer=assistant_reply, + reason_code="active_context_followup_clarify", + ) + return None + async def _maybe_reset_session_project_context_for_new_requirement( self, *, @@ -1399,24 +1621,17 @@ class KernelIngressFlowEntryMixin: ) -> bool: if session is None: return False - if not WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): - return False if _safe_int(getattr(session, "current_project_id", None)) is None: return False - await self._update_session( - tenant_id=self.tenant_id, - sender=sender, - session=session, - source_chat_id=channel_ref, - source_sender_open_id=source_sender_open_id, - source_message_id=source_message_id, - thread_context=thread_context, - current_project_id=None, - clear_current_project_id=True, - last_intent=str(getattr(session, "last_intent", "") or "").strip() or None, - last_message_text=str(getattr(session, "last_message_text", "") or "").strip() or None, + _ = ( + sender, + channel_ref, + source_sender_open_id, + source_message_id, + thread_context, + message_text, ) - return True + return False async def _maybe_handle_orphan_requirement_followup( self, @@ -1427,8 +1642,10 @@ class KernelIngressFlowEntryMixin: source_sender_open_id: Optional[str], ingress_options: Optional[dict[str, Any]], message_text: str, + session: Optional[ConversationSession] = None, + sender: Optional[str] = None, ) -> Optional[dict[str, Any]]: - if not WorkflowSemanticPlanner.looks_like_low_context_requirement_followup(message_text): + if str(getattr(session, "last_intent", "") or "").strip() != QUERY_SCENE_REQUIREMENT_PLAN: return None checker = getattr(self, "_has_active_requirement_plan_workspace", None) if not callable(checker): @@ -1456,6 +1673,20 @@ class KernelIngressFlowEntryMixin: ) if has_active_requirement_plan_workspace: return None + if session is not None and _safe_int(getattr(session, "current_project_id", None)) is not None: + await self._update_session( + tenant_id=self.tenant_id, + sender=str(sender or getattr(session, "sender", "") or ""), + session=session, + source_chat_id=channel_ref, + source_sender_open_id=source_sender_open_id, + source_message_id=source_message_id, + thread_context=thread_context, + current_project_id=None, + clear_current_project_id=True, + last_intent=str(getattr(session, "last_intent", "") or "").strip() or None, + last_message_text=str(getattr(session, "last_message_text", "") or "").strip() or None, + ) return { "handled": True, "status": "answered", diff --git a/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_project.py b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_project.py index 8c92be80..fb4f21a7 100644 --- a/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_project.py +++ b/backend/app/services/ai_pmo/ai_pmo_kernel_world_model_core/mixin_project.py @@ -3,7 +3,6 @@ from __future__ import annotations from .shared import * # noqa: F401, F403 -from ..workflow_semantic_planner import WorkflowSemanticPlanner class KernelWorldModelCoreProjectMixin: @@ -39,8 +38,6 @@ class KernelWorldModelCoreProjectMixin: cleaned = re.sub(r"\s+", " ", str(message_text or "")).strip() if not cleaned: return False - if not WorkflowSemanticPlanner.looks_like_project_create_request(cleaned): - return False return bool(cls._extract_explicit_project_title_anchor(message_text=cleaned)) async def _resolve_project( @@ -97,7 +94,10 @@ class KernelWorldModelCoreProjectMixin: normalized_candidate = re.sub(r"\s+", "", str(candidate or "")).lower() if normalized_candidate and normalized_candidate in normalized_message: return project - inherited_project_id = self._extract_inherited_project_id(session=session) + inherited_project_id = self._extract_inherited_project_id( + session=session, + message_text=message_text, + ) if inherited_project_id is not None: self._record_world_model_query_table(query_tables, "projects") return ( 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 74124d3d..d716b3a2 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 @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio + from .shared import * # noqa: F401, F403 from .shared import _safe_int from ...adaptive_rag_service import AdaptiveRAGService @@ -10,6 +12,7 @@ from ...mounted_ai_runtime_write_service import MountedAIRuntimeWriteService from ...sstc_service_core import ALIGNED_STATUSES from ...task_persistence_core import build_task_create_draft, persist_task from ..workflow_semantic_planner import WorkflowSemanticPlanner +from app.config.ai_prompts import PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT, PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT from app.models import MountedDraftWorkspace, MountedTopicWorkspace from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key @@ -24,9 +27,24 @@ class ExecuteQueryProjectMixin: rag_service: Any, message_text: str, project_id: Optional[int], + message_intent: Optional[str] = None, ) -> Optional[dict[str, Any]]: - if not WorkflowSemanticPlanner.looks_like_project_lookup_question(message_text): - return None + normalized_intent = str(message_intent or "").strip().lower() + keyword_extractor = getattr(rag_service, "_extract_project_lookup_keyword", None) + extracted_keyword = "" + if callable(keyword_extractor): + try: + extracted_keyword = str(keyword_extractor(message_text) or "").strip() + except Exception: + extracted_keyword = "" + if normalized_intent not in {"project_lookup", "project_lookup_question"}: + has_project_subject = bool(project_id is not None or extracted_keyword) + has_lookup_shape = bool( + re.search(r"项目\s*#?\d+", str(message_text or ""), re.IGNORECASE) + or re.search(r"(项目列表|项目清单|当前项目)", str(message_text or ""), re.IGNORECASE) + ) + if not has_project_subject or not has_lookup_shape: + return None existence_handler = getattr(rag_service, "_try_fast_project_existence_answer", None) if not callable(existence_handler): return None @@ -54,10 +72,8 @@ class ExecuteQueryProjectMixin: message_text: str, message_intent: Optional[str], ) -> bool: - normalized_intent = str(message_intent or "").strip().lower() - if normalized_intent: - return normalized_intent == "casual_chat" - return WorkflowSemanticPlanner._looks_like_casual_chat_message(message_text) + _ = message_text + return str(message_intent or "").strip().lower() == "casual_chat" @classmethod def _should_use_requirement_clarify_answer( @@ -67,11 +83,10 @@ class ExecuteQueryProjectMixin: message_intent: Optional[str], consultant_mode_preference: Optional[str], ) -> bool: + _ = message_text normalized_intent = str(message_intent or "").strip().lower() normalized_mode = str(consultant_mode_preference or "").strip().lower() - if normalized_intent or normalized_mode: - return normalized_intent == "business_review" and normalized_mode == "clarify" - return WorkflowSemanticPlanner._looks_like_requirement_followup_clarify(message_text) + return normalized_intent == "business_review" and normalized_mode == "clarify" @classmethod def _should_use_requirement_planning_answer( @@ -103,11 +118,8 @@ class ExecuteQueryProjectMixin: advise_subtype=advise_subtype, ): return False - if normalized_intent == "requirement_plan": - return True - if normalized_intent or str(consultant_mode_preference or "").strip(): - return False - return WorkflowSemanticPlanner._should_keep_requirement_discussion_in_query(message_text) + _ = message_text + return normalized_intent == "requirement_plan" @classmethod def _should_use_requirement_advise_answer( @@ -118,16 +130,15 @@ class ExecuteQueryProjectMixin: consultant_mode_preference: Optional[str], advise_subtype: Optional[str], ) -> bool: + _ = message_text normalized_intent = str(message_intent or "").strip().lower() normalized_mode = str(consultant_mode_preference or "").strip().lower() normalized_subtype = str(advise_subtype or "").strip().lower() - if normalized_intent or normalized_mode or normalized_subtype: - return ( - normalized_intent == "business_review" - and normalized_mode == "advise" - and normalized_subtype != "assignee" - ) - return WorkflowSemanticPlanner._looks_like_requirement_scope_advise(message_text) + return ( + normalized_intent == "business_review" + and normalized_mode == "advise" + and normalized_subtype != "assignee" + ) @classmethod def _should_use_assignee_recommendation_answer( @@ -138,16 +149,15 @@ class ExecuteQueryProjectMixin: consultant_mode_preference: Optional[str], advise_subtype: Optional[str], ) -> bool: + _ = message_text normalized_intent = str(message_intent or "").strip().lower() normalized_mode = str(consultant_mode_preference or "").strip().lower() normalized_subtype = str(advise_subtype or "").strip().lower() - if normalized_intent or normalized_mode or normalized_subtype: - return ( - normalized_intent == "business_review" - and normalized_mode == "advise" - and normalized_subtype == "assignee" - ) - return WorkflowSemanticPlanner._looks_like_assignee_recommendation(message_text) + return ( + normalized_intent == "business_review" + and normalized_mode == "advise" + and normalized_subtype == "assignee" + ) @staticmethod def _build_lightweight_chat_answer(*, message_text: str) -> str: @@ -181,72 +191,7 @@ class ExecuteQueryProjectMixin: @classmethod def _detect_requirement_plan_profile(cls, *, message_text: str, card: dict[str, Any]) -> str: - existing = str(card.get("plan_profile") or "").strip().lower() - if existing in {"scoring", "generic", "it_project"}: - return existing - normalized = WorkflowSemanticPlanner._normalize_text( - " ".join( - part - for part in ( - message_text, - str(card.get("title") or ""), - str(card.get("goal") or ""), - str(card.get("source_material") or ""), - str(card.get("project_nature") or ""), - str(card.get("tech_route") or ""), - str(card.get("delivery_mode") or ""), - ) - if str(part or "").strip() - ) - ) - if any(token in normalized for token in ("打分", "评分", "分数", "趋势分")): - return "scoring" - if cls._looks_like_it_project_requirement(normalized): - return "it_project" - return "generic" - - @staticmethod - def _looks_like_it_project_requirement(normalized: str) -> bool: - text = str(normalized or "").strip().lower() - if not text: - return False - strong_tokens = ( - "it项目", - "技术项目", - "openclaw", - "自动化部署", - "批量部署", - "发布治理", - "部署治理", - "发布平台", - "部署平台", - "任务编排", - "环境接入", - "回滚", - "审批", - "平台运维", - "运维团队", - "fastapi", - "react", - "redis", - "celery", - "postgres", - "postgresql", - "k8s", - "kubernetes", - "helm", - "argocd", - "terraform", - "ansible", - "ci/cd", - "cicd", - "jenkins", - ) - if any(token in text for token in strong_tokens): - return True - has_delivery_shape = any(token in text for token in ("部署", "发布", "上线", "交付")) - has_platform_shape = any(token in text for token in ("自动化", "批量", "治理", "回滚", "审批", "环境")) - return has_delivery_shape and has_platform_shape + return "it_project" @staticmethod def _extract_requirement_plan_choice(message_text: str) -> Optional[str]: @@ -262,6 +207,24 @@ class ExecuteQueryProjectMixin: return str(message_text or "").strip() return re.sub(r"^\s*[ABCabc](?:[\.\,,。、::)\]】\s]+)?", "", str(message_text or "")).strip() + @classmethod + def _requirement_plan_model_choice_value( + cls, + *, + card: dict[str, Any], + field: str, + choice: Optional[str], + ) -> str: + if not choice: + return "" + option_maps = card.get("round_option_maps") if isinstance(card.get("round_option_maps"), dict) else {} + field_map = option_maps.get(str(field or "").strip()) if isinstance(option_maps, dict) else None + if not isinstance(field_map, dict): + return "" + value = str(field_map.get(str(choice or "").strip().upper()) or "").strip() + value = re.sub(r"^(?:先做|直接做|先按|先走|走)", "", value).strip() + return cls._compact_requirement_plan_label(value) + @staticmethod def _it_project_choice_value(*, field: str, choice: str) -> Optional[str]: mapping = { @@ -295,34 +258,6 @@ class ExecuteQueryProjectMixin: normalized_choice = str(choice or "").strip().upper() return mapping.get(normalized_field, {}).get(normalized_choice) - @staticmethod - def _generic_choice_value(*, field: str, choice: str) -> Optional[str]: - mapping = { - "source_material": { - "A": "用户当前需求描述", - "B": "会议纪要/文档", - "C": "录音/转写/回放", - }, - "target_user": { - "A": "核心执行人/内部 PM", - "B": "负责人/管理者", - "C": "整个团队/终端用户", - }, - "output_form": { - "A": "飞书总结", - "B": "飞书消息", - "C": "单独页面", - }, - "cadence": { - "A": "触发后半小时内", - "B": "当天内", - "C": "第二天", - }, - } - normalized_field = str(field or "").strip() - normalized_choice = str(choice or "").strip().upper() - return mapping.get(normalized_field, {}).get(normalized_choice) - @staticmethod def _compact_requirement_plan_label(value: Any) -> str: text = str(value or "").strip() @@ -536,21 +471,12 @@ class ExecuteQueryProjectMixin: if existing: return existing normalized = WorkflowSemanticPlanner._normalize_text(message_text) - profile = cls._detect_requirement_plan_profile(message_text=message_text, card=card) - if profile == "generic": - if any(token in normalized for token in ("会议纪要", "会议记录", "纪要")) and any( - token in normalized for token in ("待办", "todo") - ): - return "会议纪要待办提取工具" - if any(token in normalized for token in ("会议纪要", "会议记录", "纪要")): - return "会议纪要整理工具" - if profile == "it_project": - if "openclaw" in normalized and any(token in normalized for token in ("部署", "发布")): - return "OpenClaw 批量自动化部署平台" - if any(token in normalized for token in ("自动化部署", "批量部署")): - return "批量自动化部署平台" - if any(token in normalized for token in ("发布治理", "部署治理")): - return "发布治理平台" + if "openclaw" in normalized and any(token in normalized for token in ("部署", "发布")): + return "OpenClaw 批量自动化部署平台" + if any(token in normalized for token in ("自动化部署", "批量部署")): + return "批量自动化部署平台" + if any(token in normalized for token in ("发布治理", "部署治理")): + return "发布治理平台" if any(token in normalized for token in ("大a", "a股", "沪深")) and any( token in normalized for token in ("打分", "评分", "分数") ): @@ -560,6 +486,9 @@ class ExecuteQueryProjectMixin: match = re.search(r"做一个([^,。;;]{2,30})", str(message_text or "")) if match: return match.group(1).strip(" ,。;;") + about_match = re.search(r"关于([^,。;;]{2,30})", str(message_text or "")) + if about_match: + return about_match.group(1).strip(" ,。;;") return "需求收敛草稿" @classmethod @@ -568,22 +497,13 @@ class ExecuteQueryProjectMixin: if existing: return existing normalized = WorkflowSemanticPlanner._normalize_text(message_text) - profile = cls._detect_requirement_plan_profile(message_text=message_text, card=card) - if profile == "generic": - if any(token in normalized for token in ("会议纪要", "会议记录", "纪要")) and any( - token in normalized for token in ("待办", "todo") - ): - return "会后自动整理待办,减少信息散落,让负责人能快速跟进。" - return "先把第一版核心能力收清楚,再决定是否正式创建。" - if profile == "it_project": - if "openclaw" in normalized and any(token in normalized for token in ("部署", "发布")): - return "先把 OpenClaw 批量部署、执行追踪和回滚治理收成一条可复用流程,再决定是否正式创建。" - return "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。" + if "openclaw" in normalized and any(token in normalized for token in ("部署", "发布")): + return "先把 OpenClaw 批量部署、执行追踪和回滚治理收成一条可复用流程,再决定是否正式创建。" if any(token in normalized for token in ("大a", "a股", "沪深")) and any( token in normalized for token in ("打分", "评分", "分数") ): return "每天产出一个可解释的 A 股趋势分数,辅助判断和记录。" - return "先把第一版核心能力收清楚,再决定是否正式创建。" + return "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。" @classmethod def _extract_requirement_plan_updates(cls, *, message_text: str, card: dict[str, Any]) -> dict[str, Any]: @@ -602,105 +522,32 @@ class ExecuteQueryProjectMixin: if not card.get("goal"): updates["goal"] = cls._derive_requirement_plan_goal(message_text=raw, card=card) - if profile == "generic" and not card.get("source_material"): - if any(token in normalized for token in ("会议纪要", "会议记录", "纪要")): - updates["source_material"] = "会议纪要" - elif any(token in normalized for token in ("录音", "录音转写", "转写")): - updates["source_material"] = "会议录音/转写" - elif any(token in normalized for token in ("直播录屏", "回放")): - updates["source_material"] = "直播录屏/回放" - elif WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(raw): - topic_tail = "" - if "关于" in normalized: - topic_tail = normalized.partition("关于")[2] - topic_tail = re.sub(r"[@_a-z0-9]+", "", topic_tail) - if len(topic_tail) >= 4: - updates["source_material"] = "用户当前需求描述" - elif choice and missing_fields and missing_fields[0] == "source_material": - mapped = cls._generic_choice_value(field="source_material", choice=choice) + if not card.get("project_nature"): + if any(token in normalized for token in ("内部提效工具", "脚本工具", "提效工具")): + updates["project_nature"] = "内部提效工具" + elif any(token in normalized for token in ("发布治理平台", "部署治理平台", "治理平台")): + updates["project_nature"] = "发布治理平台" + elif any(token in normalized for token in ("云原生交付底座", "交付底座")): + updates["project_nature"] = "云原生交付底座" + elif choice and missing_fields and missing_fields[0] == "project_nature": + mapped = cls._requirement_plan_model_choice_value( + card=card, + field="project_nature", + choice=choice, + ) or cls._it_project_choice_value(field="project_nature", choice=choice) if mapped: - updates["source_material"] = mapped + updates["project_nature"] = mapped - if profile == "scoring" and not card.get("scoring_basis"): - if any(token in normalized for token in ("技术指标", "均线", "成交量", "涨跌比", "量价", "macd", "rsi")): - updates["scoring_basis"] = "技术指标(均线、成交量、涨跌比等)" - elif any(token in normalized for token in ("情绪", "涨停家数", "北向", "北向资金", "舆情", "热度")): - updates["scoring_basis"] = "情绪/资金数据(涨停家数、北向资金、舆情热度等)" - elif any(token in normalized for token in ("规则", "已有逻辑", "现成逻辑", "自动化")): - updates["scoring_basis"] = "沿用现有打分规则并自动化" - - if profile == "it_project": - if not card.get("project_nature"): - if any(token in normalized for token in ("内部提效工具", "脚本工具", "提效工具")): - updates["project_nature"] = "内部提效工具" - elif any(token in normalized for token in ("发布治理平台", "部署治理平台", "治理平台")): - updates["project_nature"] = "发布治理平台" - elif any(token in normalized for token in ("云原生交付底座", "交付底座")): - updates["project_nature"] = "云原生交付底座" - elif choice and missing_fields and missing_fields[0] == "project_nature": - mapped = cls._it_project_choice_value(field="project_nature", choice=choice) - if mapped: - updates["project_nature"] = mapped - - if not card.get("target_user"): - target_match = re.search(r"(?:主要)?给([^,。;;]{1,24}?)(?:看|用)", cleaned_raw) - if target_match: - updates["target_user"] = target_match.group(1).strip() - elif any(token in cleaned_raw for token in ("平台运维", "运维团队")): - updates["target_user"] = "平台运维团队" - elif any(token in cleaned_raw for token in ("开发团队", "研发团队", "开发")): - updates["target_user"] = "开发团队" - elif any(token in cleaned_raw for token in ("项目交付", "交付团队")): - updates["target_user"] = "项目交付团队" - elif choice and missing_fields and missing_fields[0] == "target_user": - mapped = cls._it_project_choice_value(field="target_user", choice=choice) - if mapped: - updates["target_user"] = mapped - - if not card.get("tech_route"): - if all(token in normalized for token in ("fastapi", "react")) and any( - token in normalized for token in ("celery", "redis", "postgres", "postgresql") - ): - updates["tech_route"] = "任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" - elif any(token in normalized for token in ("shell", "ansible")) or "脚本增强" in normalized: - updates["tech_route"] = "脚本增强版(FastAPI + Shell/Ansible + PostgreSQL)" - elif any(token in normalized for token in ("k8s", "kubernetes", "helm", "argocd", "terraform")): - updates["tech_route"] = "云原生交付版(Kubernetes + Helm + ArgoCD/Terraform + FastAPI)" - elif "任务编排平台版" in cleaned_raw: - updates["tech_route"] = "任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" - elif choice and missing_fields and missing_fields[0] == "tech_route": - mapped = cls._it_project_choice_value(field="tech_route", choice=choice) - if mapped: - updates["tech_route"] = mapped - - if not card.get("first_scope"): - if any(token in cleaned_raw for token in ("批量执行闭环", "任务创建", "执行日志", "失败重试")): - updates["first_scope"] = cleaned_raw - elif any(token in cleaned_raw for token in ("环境接入闭环", "目标机器", "环境注册", "凭证管理")): - updates["first_scope"] = cleaned_raw - elif any(token in cleaned_raw for token in ("治理闭环", "审批", "回滚", "审计")): - updates["first_scope"] = cleaned_raw - elif choice and missing_fields and missing_fields[0] == "first_scope": - mapped = cls._it_project_choice_value(field="first_scope", choice=choice) - if mapped: - updates["first_scope"] = mapped - - if not card.get("delivery_mode"): - if any(token in normalized for token in ("poc", "验证版", "内部验证", "内部poc")): - updates["delivery_mode"] = "内部 PoC" - elif any(token in cleaned_raw for token in ("项目级试点", "试点")): - updates["delivery_mode"] = "项目级试点(带基础审批和回滚)" - elif any(token in cleaned_raw for token in ("平台化上线", "正式上线", "多团队接入")): - updates["delivery_mode"] = "平台化上线" - elif choice and missing_fields and missing_fields[0] == "delivery_mode": - mapped = cls._it_project_choice_value(field="delivery_mode", choice=choice) - if mapped: - updates["delivery_mode"] = mapped - - if profile != "it_project" and not card.get("target_user"): - target_match = re.search(r"(?:主要)?给([^,。;;]{1,24}?)(?:看|用)", raw) + if not card.get("target_user"): + target_match = re.search(r"(?:主要)?给([^,。;;]{1,24}?)(?:看|用)", cleaned_raw) if target_match: updates["target_user"] = target_match.group(1).strip() + elif any(token in cleaned_raw for token in ("平台运维", "运维团队")): + updates["target_user"] = "平台运维团队" + elif any(token in cleaned_raw for token in ("开发团队", "研发团队", "开发")): + updates["target_user"] = "开发团队" + elif any(token in cleaned_raw for token in ("项目交付", "交付团队")): + updates["target_user"] = "项目交付团队" elif "投顾" in raw: updates["target_user"] = "投顾" elif any(token in raw for token in ("研究员", "研究团队")): @@ -711,309 +558,1221 @@ class ExecuteQueryProjectMixin: updates["target_user"] = raw.strip("。;;") elif "自己" in raw: updates["target_user"] = "自己" - elif profile == "generic" and choice and missing_fields and missing_fields[0] == "target_user": - mapped = cls._generic_choice_value(field="target_user", choice=choice) + elif choice and missing_fields and missing_fields[0] == "target_user": + mapped = cls._requirement_plan_model_choice_value( + card=card, + field="target_user", + choice=choice, + ) or cls._it_project_choice_value(field="target_user", choice=choice) if mapped: updates["target_user"] = mapped - if profile != "it_project" and not card.get("output_form"): - if "飞书总结" in raw: - updates["output_form"] = "飞书总结" - elif "飞书日报" in raw: - updates["output_form"] = "飞书日报" - elif any(token in raw for token in ("飞书消息", "飞书通知")): - updates["output_form"] = "飞书消息" - elif any(token in raw for token in ("页面看板", "看板", "仪表盘")): - updates["output_form"] = "页面看板" - elif "简报" in raw: - updates["output_form"] = "简报" - elif profile == "generic" and choice and missing_fields and missing_fields[0] == "output_form": - mapped = cls._generic_choice_value(field="output_form", choice=choice) - if mapped: - updates["output_form"] = mapped - - if profile != "it_project" and not card.get("cadence"): - if any(token in normalized for token in ("每次会后半小时内", "会后半小时内")): - updates["cadence"] = "每次会后半小时内" - elif any(token in normalized for token in ("每日收盘后", "每天收盘后", "收盘后")): - updates["cadence"] = "每日收盘后" - elif "开盘前" in raw: - updates["cadence"] = "开盘前" - elif "盘中" in raw: - updates["cadence"] = "盘中" - elif any(token in normalized for token in ("实时", "准实时")): - updates["cadence"] = "实时" - elif any(token in normalized for token in ("每天", "每日")) and any( - token in normalized for token in ("更新", "发", "推送", "同步", "生成", "刷新", "跑") + if not card.get("tech_route"): + if all(token in normalized for token in ("fastapi", "react")) and any( + token in normalized for token in ("celery", "redis", "postgres", "postgresql") ): - updates["cadence"] = "每日" - elif profile == "generic" and choice and missing_fields and missing_fields[0] == "cadence": - mapped = cls._generic_choice_value(field="cadence", choice=choice) + updates["tech_route"] = "任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" + elif any(token in normalized for token in ("shell", "ansible")) or "脚本增强" in normalized: + updates["tech_route"] = "脚本增强版(FastAPI + Shell/Ansible + PostgreSQL)" + elif any(token in normalized for token in ("k8s", "kubernetes", "helm", "argocd", "terraform")): + updates["tech_route"] = "云原生交付版(Kubernetes + Helm + ArgoCD/Terraform + FastAPI)" + elif "任务编排平台版" in cleaned_raw: + updates["tech_route"] = "任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" + elif choice and missing_fields and missing_fields[0] == "tech_route": + mapped = cls._requirement_plan_model_choice_value( + card=card, + field="tech_route", + choice=choice, + ) or cls._it_project_choice_value(field="tech_route", choice=choice) if mapped: - updates["cadence"] = mapped + updates["tech_route"] = mapped - if profile != "it_project" and not card.get("first_scope") and any( - token in normalized - for token in ("先保留", "先只做", "别做太重", "范围够轻", "方向判断", "先抽", "先提", "不做复杂") - ) and not any(token in normalized for token in ("先别创建", "别创建", "先收第一版", "先收一版")): - updates["first_scope"] = raw + if not card.get("first_scope"): + if any(token in cleaned_raw for token in ("批量执行闭环", "任务创建", "执行日志", "失败重试")): + updates["first_scope"] = cleaned_raw + elif any(token in cleaned_raw for token in ("环境接入闭环", "目标机器", "环境注册", "凭证管理")): + updates["first_scope"] = cleaned_raw + elif any(token in cleaned_raw for token in ("治理闭环", "审批", "回滚", "审计")): + updates["first_scope"] = cleaned_raw + elif missing_fields and missing_fields[0] == "first_scope" and len(cleaned_raw.strip()) >= 4: + updates["first_scope"] = cleaned_raw + elif choice and missing_fields and missing_fields[0] == "first_scope": + mapped = cls._requirement_plan_model_choice_value( + card=card, + field="first_scope", + choice=choice, + ) or cls._it_project_choice_value(field="first_scope", choice=choice) + if mapped: + updates["first_scope"] = mapped + if updates.get("first_scope") and not card.get("delivery_mode"): + updates["delivery_mode"] = "项目级试点(带基础审批和回滚)" - if not card.get("success_signal"): - if profile == "scoring" and all(token in normalized for token in ("分数", "解释")): - updates["success_signal"] = "至少输出一个分数和一句解释" - elif profile == "generic" and any(token in normalized for token in ("待办", "责任人", "截止时间")): - updates["success_signal"] = "至少稳定提取待办、责任人和截止时间" + if not card.get("delivery_mode"): + if any(token in normalized for token in ("poc", "验证版", "内部验证", "内部poc")): + updates["delivery_mode"] = "内部 PoC" + elif any(token in cleaned_raw for token in ("项目级试点", "试点")): + updates["delivery_mode"] = "项目级试点(带基础审批和回滚)" + elif any(token in cleaned_raw for token in ("平台化上线", "正式上线", "多团队接入")): + updates["delivery_mode"] = "平台化上线" + elif choice and missing_fields and missing_fields[0] == "delivery_mode": + mapped = cls._requirement_plan_model_choice_value( + card=card, + field="delivery_mode", + choice=choice, + ) or cls._it_project_choice_value(field="delivery_mode", choice=choice) + if mapped: + updates["delivery_mode"] = mapped return {key: value for key, value in updates.items() if str(value or "").strip()} @classmethod def _requirement_plan_missing_fields(cls, card: dict[str, Any]) -> list[str]: - profile = cls._detect_requirement_plan_profile(message_text="", card=card) - ordered_fields = ( - ("project_nature", "target_user", "tech_route", "first_scope", "delivery_mode") - if profile == "it_project" - else - ("scoring_basis", "target_user", "output_form", "cadence", "first_scope") - if profile == "scoring" - else ("source_material", "target_user", "output_form", "cadence", "first_scope") - ) + ordered_fields = ("project_nature", "target_user", "tech_route", "first_scope", "delivery_mode") return [field for field in ordered_fields if not str(card.get(field) or "").strip()] @classmethod def _build_requirement_plan_ack(cls, *, updates: dict[str, Any], profile: str) -> str: parts: list[str] = [] - if profile == "it_project": - if str(updates.get("project_nature") or "").strip(): - parts.append(f"项目先按{cls._summarize_it_project_choice_label(field='project_nature', value=updates['project_nature'])}来收") - if str(updates.get("target_user") or "").strip(): - parts.append(f"主要服务{cls._summarize_it_project_choice_label(field='target_user', value=updates['target_user'])}") - if str(updates.get("tech_route") or "").strip(): - parts.append(f"技术路线先按{cls._summarize_it_project_choice_label(field='tech_route', value=updates['tech_route'])}") - if str(updates.get("first_scope") or "").strip(): - parts.append(f"第一版先收在{cls._summarize_it_project_choice_label(field='first_scope', value=updates['first_scope'])}") - if str(updates.get("delivery_mode") or "").strip(): - parts.append(f"推进方式先按{cls._summarize_it_project_choice_label(field='delivery_mode', value=updates['delivery_mode'])}") - if not parts: - return "" - return f"好,我先按这个方向继续记:{';'.join(parts[:2])}。" - if profile == "scoring" and str(updates.get("scoring_basis") or "").strip(): - parts.append(f"第一版按{updates['scoring_basis']}来打分") - if profile == "generic": - source_material = str(updates.get("source_material") or "").strip() - if source_material and source_material != "用户当前需求描述": - parts.append(f"输入先按{source_material}来") + _ = profile + if str(updates.get("project_nature") or "").strip(): + parts.append(f"项目先按{cls._summarize_it_project_choice_label(field='project_nature', value=updates['project_nature'])}来收") if str(updates.get("target_user") or "").strip(): - parts.append(f"主要给{updates['target_user']}用") - if str(updates.get("output_form") or "").strip(): - parts.append(f"先按{updates['output_form']}给结果") - if str(updates.get("cadence") or "").strip(): - parts.append(f"时效先记为{updates['cadence']}") + parts.append(f"主要服务{cls._summarize_it_project_choice_label(field='target_user', value=updates['target_user'])}") + if str(updates.get("tech_route") or "").strip(): + parts.append(f"技术路线先按{cls._summarize_it_project_choice_label(field='tech_route', value=updates['tech_route'])}") + if str(updates.get("first_scope") or "").strip(): + parts.append(f"第一版先收在{cls._summarize_it_project_choice_label(field='first_scope', value=updates['first_scope'])}") + if str(updates.get("delivery_mode") or "").strip(): + parts.append(f"推进方式先按{cls._summarize_it_project_choice_label(field='delivery_mode', value=updates['delivery_mode'])}") if not parts: return "" - return f"好,我先记下:{','.join(parts[:2])}。" + return f"好,我先按这个方向继续记:{';'.join(parts[:2])}。" @classmethod - def _build_requirement_plan_next_question( + def _requirement_plan_field_label(cls, field: str) -> str: + return { + "project_nature": "项目性质", + "target_user": "目标用户", + "tech_route": "技术路线", + "first_scope": "第一版范围", + "delivery_mode": "推进方式", + "source_material": "输入材料", + "output_form": "输出形式", + "cadence": "响应时效", + "scoring_basis": "打分依据", + }.get(str(field or "").strip(), str(field or "").strip()) + + @classmethod + def _summarize_requirement_plan_field_value(cls, *, profile: str, field: str, value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + if str(profile or "").strip() == "it_project": + return cls._summarize_it_project_choice_label(field=field, value=text) + return cls._compact_requirement_plan_label(text).rstrip("。.!!??") + + @classmethod + def _summarize_requirement_plan_latest_updates( cls, *, + updates: Optional[dict[str, Any]], + profile: str, + ) -> str: + if not isinstance(updates, dict): + return "" + ordered_fields = ( + "project_nature", + "target_user", + "tech_route", + "first_scope", + "delivery_mode", + "source_material", + "output_form", + "cadence", + "scoring_basis", + ) + parts: list[str] = [] + for field in ordered_fields: + summary = cls._summarize_requirement_plan_field_value( + profile=profile, + field=field, + value=updates.get(field), + ) + if not summary: + continue + label = cls._requirement_plan_field_label(field) + parts.append(f"{label}={summary}") + return ";".join(parts[:2]) + + @classmethod + def _summarize_requirement_plan_collected_fields(cls, *, card: dict[str, Any], profile: str) -> str: + ordered_fields = ( + "project_nature", + "target_user", + "tech_route", + "first_scope", + "delivery_mode", + "source_material", + "output_form", + "cadence", + "scoring_basis", + ) + parts: list[str] = [] + for field in ordered_fields: + summary = cls._summarize_requirement_plan_field_value( + profile=profile, + field=field, + value=card.get(field), + ) + if not summary: + continue + label = cls._requirement_plan_field_label(field) + parts.append(f"{label}={summary}") + return ";".join(parts[:3]) + + @classmethod + def _build_requirement_plan_round_packet( + cls, + *, + missing_field: str, + profile: str, + message_text: str, + card: Optional[dict[str, Any]], + latest_updates: Optional[dict[str, Any]] = None, + ) -> dict[str, Any]: + current_card = dict(card or {}) + return { + "profile": str(profile or "it_project").strip() or "it_project", + "missing_field": str(missing_field or "").strip(), + "latest_user_message": str(message_text or "").strip(), + "latest_user_reply_summary": cls._summarize_requirement_plan_latest_updates( + updates=latest_updates, + profile=profile, + ), + "current_context": { + "title": str(current_card.get("title") or "").strip(), + "goal": str(current_card.get("goal") or "").strip(), + "question_round": int(current_card.get("question_round") or 0), + }, + "collected_fields": { + key: str(current_card.get(key) or "").strip() + for key in ("project_nature", "target_user", "tech_route", "first_scope", "delivery_mode") + if str(current_card.get(key) or "").strip() + }, + "collected_field_summary": cls._summarize_requirement_plan_collected_fields( + card=current_card, + profile=profile, + ), + "abc_template_contract": { + "required_option_keys": ["A", "B", "C"], + "option_fields": ["title", "best_for", "suggested_capability", "rationale", "tradeoff"], + "recommendation_key_must_be_one_of": ["A", "B", "C"], + "tone": "自然 PMFlow 项目经理口吻,贴合当前业务,不要机械表单化", + }, + "reply_budget": cls._requirement_plan_round_budget(), + "field_goal": cls._requirement_plan_field_goal(missing_field), + "forbidden": [ + "不要输出 packet 中没有出现过的产品名或旧项目名", + "不要把 OpenClaw、部署、Kubernetes、发布治理等旧场景套到无关需求里", + "不要返回固定模板占位句", + "不要直接创建项目或承诺已执行", + ], + } + + @staticmethod + def _requirement_plan_field_goal(missing_field: str) -> str: + return { + "project_nature": "判断第一版更像轻量工具、业务平台,还是长期能力底座;具体选项必须按当前需求重写。", + "target_user": "判断第一版最应该先服务的用户角色或团队。", + "tech_route": "判断第一版能力/技术路线,建议要能支撑后续迭代但不过度设计。", + "first_scope": "判断第一版先保留哪些范围、字段或闭环,哪些先不做。", + "delivery_mode": "判断第一版用什么推进方式验证价值,比如轻量验证、项目试点或平台化推进。", + }.get(str(missing_field or "").strip(), "围绕当前需求补齐下一步最关键的收敛缺口。") + + @staticmethod + def _requirement_plan_round_budget() -> dict[str, int]: + return { + "max_chars": 1500, + "opening": 64, + "focus": 90, + "option_title": 24, + "option_best_for": 52, + "option_suggested_capability": 56, + "option_rationale": 56, + "option_tradeoff": 48, + "recommendation_reason": 72, + "reply_instruction": 36, + } + + @classmethod + def _requirement_plan_budget_limit(cls, *, packet: dict[str, Any], key: str, default: int) -> int: + budget = packet.get("reply_budget") if isinstance(packet.get("reply_budget"), dict) else {} + try: + value = int(budget.get(key) or 0) + except Exception: + value = 0 + return value if value > 0 else int(default) + + @staticmethod + def _compact_round_text(value: Any, *, limit: int = 160) -> str: + text = re.sub(r"\s+", " ", str(value or "")).strip() + if len(text) <= limit: + return text + return text[:limit].rstrip() + "..." + + @classmethod + def _validate_requirement_plan_round_payload( + cls, + *, + payload: Any, + packet: dict[str, Any], + ) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError("requirement_plan_round payload is not a dict") + options = payload.get("options") + if not isinstance(options, list) or len(options) != 3: + raise ValueError("requirement_plan_round options must contain A/B/C") + normalized_options: list[dict[str, str]] = [] + expected_keys = ["A", "B", "C"] + for index, expected_key in enumerate(expected_keys): + item = options[index] + if not isinstance(item, dict): + raise ValueError("requirement_plan_round option is not a dict") + key = str(item.get("key") or "").strip().upper() + if key != expected_key: + raise ValueError("requirement_plan_round option keys must be A/B/C in order") + normalized = { + "key": expected_key, + "title": cls._compact_round_text( + item.get("title"), + limit=cls._requirement_plan_budget_limit(packet=packet, key="option_title", default=24), + ), + "best_for": cls._compact_round_text( + item.get("best_for"), + limit=cls._requirement_plan_budget_limit(packet=packet, key="option_best_for", default=52), + ), + "suggested_capability": cls._compact_round_text( + item.get("suggested_capability"), + limit=cls._requirement_plan_budget_limit(packet=packet, key="option_suggested_capability", default=56), + ), + "rationale": cls._compact_round_text( + item.get("rationale"), + limit=cls._requirement_plan_budget_limit(packet=packet, key="option_rationale", default=56), + ), + "tradeoff": cls._compact_round_text( + item.get("tradeoff"), + limit=cls._requirement_plan_budget_limit(packet=packet, key="option_tradeoff", default=48), + ), + } + if not all(normalized[field] for field in ("title", "best_for", "suggested_capability", "rationale", "tradeoff")): + raise ValueError("requirement_plan_round option fields are incomplete") + normalized_options.append(normalized) + recommendation = payload.get("recommendation") + if not isinstance(recommendation, dict): + raise ValueError("requirement_plan_round recommendation is missing") + recommendation_key = str(recommendation.get("key") or "").strip().upper() + if recommendation_key not in {"A", "B", "C"}: + raise ValueError("requirement_plan_round recommendation key must be A/B/C") + normalized_payload = { + "opening": cls._compact_round_text( + payload.get("opening"), + limit=cls._requirement_plan_budget_limit(packet=packet, key="opening", default=64), + ), + "focus": cls._compact_round_text( + payload.get("focus"), + limit=cls._requirement_plan_budget_limit(packet=packet, key="focus", default=90), + ), + "options": normalized_options, + "recommendation": { + "key": recommendation_key, + "reason": cls._compact_round_text( + recommendation.get("reason"), + limit=cls._requirement_plan_budget_limit(packet=packet, key="recommendation_reason", default=72), + ), + }, + "reply_instruction": cls._compact_round_text( + payload.get("reply_instruction") or "你回 A / B / C,我就顺着这条继续往下收。", + limit=cls._requirement_plan_budget_limit(packet=packet, key="reply_instruction", default=36), + ), + } + if not normalized_payload["opening"] or not normalized_payload["focus"] or not normalized_payload["recommendation"]["reason"]: + raise ValueError("requirement_plan_round core fields are incomplete") + cls._guard_requirement_plan_round_against_internal_context_leaks(payload=normalized_payload) + cls._guard_requirement_plan_round_against_stale_context(payload=normalized_payload, packet=packet) + return normalized_payload + + @classmethod + def _guard_requirement_plan_round_against_internal_context_leaks( + cls, + *, + payload: dict[str, Any], + ) -> None: + rendered = WorkflowSemanticPlanner._normalize_text(json.dumps(payload, ensure_ascii=False)) + internal_terms = ( + "current_context", + "latest_user_message", + "latest_user_reply_summary", + "collected_fields", + "collected_field_summary", + "reply_budget", + "missing_field", + "abc_template_contract", + "field_goal", + "question_round", + "project_nature", + "target_user", + "tech_route", + "first_scope", + "delivery_mode", + "requirement_plan_round_packet", + "字段需判断", + ) + leaked = [term for term in internal_terms if WorkflowSemanticPlanner._normalize_text(term) in rendered] + if leaked: + raise ValueError(f"requirement_plan_round leaks internal packet terms: {', '.join(leaked[:3])}") + + @classmethod + def _guard_requirement_plan_round_against_stale_context( + cls, + *, + payload: dict[str, Any], + packet: dict[str, Any], + ) -> None: + rendered = WorkflowSemanticPlanner._normalize_text(json.dumps(payload, ensure_ascii=False)) + context_material = { + "latest_user_message": packet.get("latest_user_message"), + "latest_user_reply_summary": packet.get("latest_user_reply_summary"), + "current_context": packet.get("current_context"), + "collected_fields": packet.get("collected_fields"), + "collected_field_summary": packet.get("collected_field_summary"), + } + context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False)) + if cls._requirement_plan_packet_allows_deployment_context(packet=packet): + return + stale_terms = ( + "openclaw", + "批量自动化部署", + "发布治理平台", + "云原生交付底座", + "平台运维团队", + "项目交付团队", + "任务编排平台版", + "脚本增强版", + "云原生交付版", + "环境接入闭环", + "治理闭环", + "部署治理", + "kubernetes", + "k8s", + "helm", + "argocd", + "terraform", + ) + leaked = [term for term in stale_terms if term in rendered and term not in context] + if leaked: + raise ValueError(f"requirement_plan_round contains stale context terms: {', '.join(leaked[:3])}") + + @classmethod + def _requirement_plan_packet_allows_deployment_context(cls, *, packet: dict[str, Any]) -> bool: + context_material = { + "latest_user_message": packet.get("latest_user_message"), + "latest_user_reply_summary": packet.get("latest_user_reply_summary"), + "current_context": packet.get("current_context"), + "collected_fields": packet.get("collected_fields"), + "collected_field_summary": packet.get("collected_field_summary"), + } + context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False)) + deployment_hints = ( + "openclaw", + "批量自动化部署", + "自动化部署", + "部署治理", + "发布治理", + "发布平台", + "部署平台", + "云原生交付底座", + "云原生交付版", + "任务编排平台版", + "平台运维", + "环境接入闭环", + "回滚", + "审批", + "kubernetes", + "k8s", + "helm", + "argocd", + "terraform", + ) + return any(term in context for term in deployment_hints) + + @classmethod + def _render_requirement_plan_round_payload(cls, payload: dict[str, Any]) -> str: + lines = [str(payload["opening"]).strip(), str(payload["focus"]).strip(), ""] + for option in payload["options"]: + lines.extend( + [ + f"{option['key']}. {option['title']}", + f"更适合:{option['best_for']}", + f"推荐技术/能力:{option['suggested_capability']}", + f"我为什么会考虑这个:{option['rationale']}", + f"代价:{option['tradeoff']}", + "", + ] + ) + recommendation = payload["recommendation"] + lines.extend( + [ + f"⭐ 当前我更推荐:{recommendation['key']}", + f"原因:{recommendation['reason']}", + "", + str(payload["reply_instruction"]).strip() or "你回 A / B / C,我就顺着这条继续往下收。", + ] + ) + return "\n".join(lines).strip() + + @classmethod + def _validate_requirement_plan_round_render_for_feishu( + cls, + *, + rendered_text: str, + packet: dict[str, Any], + ) -> None: + text = str(rendered_text or "").strip() + required_markers = ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:") + missing = [marker for marker in required_markers if marker not in text] + if missing: + raise ValueError("requirement_plan_round 渲染结果不完整,不能发送到飞书") + max_chars = cls._requirement_plan_budget_limit(packet=packet, key="max_chars", default=1500) + if len(text) > max_chars: + raise ValueError(f"requirement_plan_round 超出飞书文本预算:{len(text)}>{max_chars}") + + @classmethod + def _requirement_plan_context_allows_deployment_terms(cls, *, context_material: Any) -> bool: + context = WorkflowSemanticPlanner._normalize_text(json.dumps(context_material, ensure_ascii=False)) + deployment_hints = ( + "openclaw", + "批量自动化部署", + "自动化部署", + "部署治理", + "发布治理", + "发布平台", + "部署平台", + "云原生交付底座", + "云原生交付版", + "任务编排平台版", + "平台运维", + "环境接入闭环", + "回滚", + "审批", + "kubernetes", + "k8s", + "helm", + "argocd", + "terraform", + ) + return any(term in context for term in deployment_hints) + + @classmethod + def _build_requirement_plan_draft_packet( + cls, + *, + card: dict[str, Any], + revision_note: Optional[str] = None, + ) -> dict[str, Any]: + profile = cls._detect_requirement_plan_profile(message_text="", card=card) + current_card = dict(card or {}) + collected_fields = { + key: str(current_card.get(key) or "").strip() + for key in ( + "project_nature", + "target_user", + "tech_route", + "first_scope", + "delivery_mode", + "success_signal", + "source_material", + "output_form", + "cadence", + "scoring_basis", + ) + if str(current_card.get(key) or "").strip() + } + return { + "profile": profile, + "current_context": { + "title": str(current_card.get("title") or "").strip(), + "goal": str(current_card.get("goal") or "").strip(), + "question_round": int(current_card.get("question_round") or 0), + }, + "collected_fields": collected_fields, + "collected_field_summary": cls._summarize_requirement_plan_collected_fields( + card=current_card, + profile=profile, + ), + "pending_items": cls._build_requirement_plan_missing_info(card=current_card), + "known_constraints": { + "draft_kind": "formal_brief_for_confirmation", + "stay_in_current_business_context": True, + "do_not_leak_internal_keys": True, + "do_not_reuse_old_template_terms": True, + "external_signal_sources_only": True, + "external_signal_source_min_count": 3, + "external_signal_source_max_count": 5, + }, + "revision_request": str(revision_note or "").strip()[:500] or None, + } + + @staticmethod + def _normalize_requirement_plan_draft_list( + values: Any, + *, + item_limit: int = 72, + max_items: int = 5, + ) -> list[str]: + if isinstance(values, str): + raw_items = re.split(r"[;;,,\n]", values) + elif isinstance(values, (list, tuple, set)): + raw_items = list(values) + else: + raw_items = [] + items: list[str] = [] + for raw_item in raw_items: + text = re.sub(r"\s+", " ", str(raw_item or "")).strip() + if not text: + continue + text = text[:item_limit].rstrip(",,;;、 ") or text[:item_limit] + if text and text not in items: + items.append(text) + if len(items) >= max_items: + break + return items + + @classmethod + def _normalize_requirement_plan_draft_requirement_items( + cls, + value: Any, + ) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + items: list[dict[str, str]] = [] + for raw_item in value: + if not isinstance(raw_item, dict): + continue + section = cls._compact_round_text(raw_item.get("section"), limit=24) + title = cls._compact_round_text(raw_item.get("title"), limit=32) + description = cls._compact_round_text(raw_item.get("description"), limit=96) + if not section or not title or not description: + continue + items.append( + { + "section": section, + "title": title, + "description": description, + } + ) + if len(items) >= 8: + break + return items + + @classmethod + def _normalize_requirement_plan_draft_team_setup( + cls, + value: Any, + ) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + items: list[dict[str, str]] = [] + for raw_item in value: + if not isinstance(raw_item, dict): + continue + role = cls._compact_round_text(raw_item.get("role"), limit=24) + notes = cls._compact_round_text(raw_item.get("notes"), limit=96) + if not role and not notes: + continue + items.append({"role": role or "建议配置", "notes": notes or "待补充"}) + if len(items) >= 5: + break + return items + + @classmethod + def _normalize_requirement_plan_draft_external_signal_sources( + cls, + value: Any, + ) -> list[dict[str, str]]: + if not isinstance(value, list): + return [] + blocked_terms = ( + "飞书需求消息", + "需求消息", + "项目/任务/责任人数据", + "责任人", + "数据库", + "日志", + "工作流", + "审计", + "能力图谱", + "用户当前需求描述", + ) + items: list[dict[str, str]] = [] + seen: set[str] = set() + for raw_item in value: + if not isinstance(raw_item, dict): + continue + name = cls._compact_round_text(raw_item.get("name"), limit=28) + purpose = cls._compact_round_text(raw_item.get("purpose"), limit=72) + phase = cls._compact_round_text(raw_item.get("phase"), limit=20) + if not name or not purpose or not phase: + continue + normalized_name = WorkflowSemanticPlanner._normalize_text(name) + normalized_purpose = WorkflowSemanticPlanner._normalize_text(purpose) + if any( + WorkflowSemanticPlanner._normalize_text(term) in normalized_name + or WorkflowSemanticPlanner._normalize_text(term) in normalized_purpose + for term in blocked_terms + ): + continue + if normalized_name in seen: + continue + seen.add(normalized_name) + items.append({"name": name, "purpose": purpose, "phase": phase}) + if len(items) >= 5: + break + return items + + @classmethod + def _collect_requirement_topic_text_fragments(cls, value: Any) -> list[str]: + fragments: list[str] = [] + + def _walk(node: Any) -> None: + if node is None: + return + if isinstance(node, dict): + for item in node.values(): + _walk(item) + return + if isinstance(node, (list, tuple, set)): + for item in node: + _walk(item) + return + text = str(node or "").strip() + if text: + fragments.append(text) + + _walk(value) + return fragments + + @classmethod + def _extract_requirement_overlap_tokens(cls, text: Any) -> set[str]: + raw = str(text or "").strip().lower() + if not raw: + return set() + normalized = re.sub(r"\s+", "", raw) + ascii_tokens = { + token + for token in re.findall(r"[a-z0-9]{2,}", normalized, re.IGNORECASE) + if str(token or "").strip() + } + cjk_chunks = re.findall(r"[\u4e00-\u9fff]{2,}", normalized) + cjk_tokens: set[str] = set() + for chunk in cjk_chunks: + if len(chunk) <= 2: + cjk_tokens.add(chunk) + continue + for index in range(len(chunk) - 1): + cjk_tokens.add(chunk[index : index + 2]) + return ascii_tokens | cjk_tokens + + @classmethod + def _requirement_seed_overlap_ratio( + cls, + *, + message_text: str, + topic: Optional[MountedTopicWorkspace], + draft: Optional[MountedDraftWorkspace], + ) -> float: + message_tokens = cls._extract_requirement_overlap_tokens(message_text) + if not message_tokens: + return 0.0 + context_fragments: list[str] = [] + if topic is not None: + context_fragments.extend(cls._collect_requirement_topic_text_fragments(getattr(topic, "facts_json", None))) + context_fragments.extend(cls._collect_requirement_topic_text_fragments(getattr(topic, "goals_json", None))) + context_fragments.extend( + cls._collect_requirement_topic_text_fragments(getattr(topic, "constraints_json", None)) + ) + if draft is not None: + context_fragments.extend( + cls._collect_requirement_topic_text_fragments(getattr(draft, "requirement_card", None)) + ) + context_fragments.extend( + cls._collect_requirement_topic_text_fragments(getattr(draft, "draft_payload", None)) + ) + context_tokens: set[str] = set() + for fragment in context_fragments: + context_tokens.update(cls._extract_requirement_overlap_tokens(fragment)) + if not context_tokens: + return 0.0 + return len(message_tokens & context_tokens) / max(len(message_tokens), 1) + + @classmethod + def _guard_requirement_plan_draft_against_internal_context_leaks( + cls, + *, + payload: dict[str, Any], + ) -> None: + rendered = WorkflowSemanticPlanner._normalize_text( + " ".join(cls._collect_requirement_topic_text_fragments(payload)) + ) + internal_terms = ( + "current_context", + "collected_fields", + "collected_field_summary", + "known_constraints", + "question_round", + "requirement_plan_draft_packet", + ) + leaked = [term for term in internal_terms if WorkflowSemanticPlanner._normalize_text(term) in rendered] + if leaked: + raise ValueError(f"requirement_plan_draft leaks internal packet terms: {', '.join(leaked[:3])}") + + @classmethod + def _guard_requirement_plan_draft_against_stale_context( + cls, + *, + payload: dict[str, Any], + packet: dict[str, Any], + ) -> None: + rendered = WorkflowSemanticPlanner._normalize_text(json.dumps(payload, ensure_ascii=False)) + context_material = { + "current_context": packet.get("current_context"), + "collected_fields": packet.get("collected_fields"), + "collected_field_summary": packet.get("collected_field_summary"), + } + if cls._requirement_plan_context_allows_deployment_terms(context_material=context_material): + return + stale_terms = ( + "审批、审计、权限一次做满", + "人员能力图谱", + "工作流状态与审计日志", + "确认门禁", + "状态流转和回滚治理", + "部署目标清单", + "环境参数与凭证", + "执行日志与失败日志", + "平台运维团队", + "回滚治理", + "部署治理", + "kubernetes", + "k8s", + "helm", + "argocd", + "terraform", + ) + leaked = [term for term in stale_terms if term in rendered] + if leaked: + raise ValueError(f"requirement_plan_draft contains stale context terms: {', '.join(leaked[:3])}") + + @classmethod + def _validate_requirement_plan_draft_payload( + cls, + *, + payload: Any, + packet: dict[str, Any], + ) -> dict[str, Any]: + if not isinstance(payload, dict): + raise ValueError("requirement_plan_draft payload is not a dict") + + def _text(key: str, *, limit: int, default: str = "") -> str: + return cls._compact_round_text(payload.get(key) or default, limit=limit) + + requirement_items = cls._normalize_requirement_plan_draft_requirement_items(payload.get("requirement_items")) + if not requirement_items: + raise ValueError("requirement_plan_draft requirement_items are missing") + + team_setup = cls._normalize_requirement_plan_draft_team_setup(payload.get("team_setup")) + external_signal_sources = cls._normalize_requirement_plan_draft_external_signal_sources( + payload.get("external_signal_sources") + ) + validation_metrics_raw = payload.get("validation_metrics") if isinstance(payload.get("validation_metrics"), dict) else {} + validation_metrics = { + "product": cls._normalize_requirement_plan_draft_list( + validation_metrics_raw.get("product"), + item_limit=48, + max_items=5, + ) + } + + normalized_payload = { + "intro": _text("intro", limit=72, default="📋 我先按当前收敛结果,整理成一版需求草稿:"), + "title": _text("title", limit=80, default=str(packet.get("current_context", {}).get("title") or "待补充")), + "one_liner": _text("one_liner", limit=180), + "goal": _text("goal", limit=180, default=str(packet.get("current_context", {}).get("goal") or "")), + "project_nature": _text("project_nature", limit=120), + "target_user": _text("target_user", limit=120), + "signal_inputs": _text("signal_inputs", limit=180), + "known_info": _text("known_info", limit=220), + "tech_route": _text("tech_route", limit=180), + "first_scope": _text("first_scope", limit=180), + "core_flow": _text("core_flow", limit=180), + "delivery_mode": _text("delivery_mode", limit=160), + "success_signal": _text("success_signal", limit=180), + "risks": _text("risks", limit=240), + "pending_items": cls._normalize_requirement_plan_draft_list( + payload.get("pending_items"), + item_limit=48, + max_items=5, + ), + "closing": _text( + "closing", + limit=72, + default="如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + ), + "summary": _text("summary", limit=220), + "positioning_one_liner": _text("positioning_one_liner", limit=160), + "target_customers": _text("target_customers", limit=120), + "external_signal_sources": external_signal_sources, + "requirement_items": requirement_items, + "missing_info": cls._normalize_requirement_plan_draft_list( + payload.get("missing_info"), + item_limit=48, + max_items=6, + ), + "mvp_items": cls._normalize_requirement_plan_draft_list( + payload.get("mvp_items"), + item_limit=48, + max_items=6, + ), + "team_setup": team_setup, + "validation_metrics": validation_metrics, + } + required_fields = ( + "title", + "one_liner", + "goal", + "project_nature", + "target_user", + "signal_inputs", + "known_info", + "tech_route", + "first_scope", + "delivery_mode", + "success_signal", + "risks", + "summary", + "positioning_one_liner", + "target_customers", + ) + missing = [field for field in required_fields if not str(normalized_payload.get(field) or "").strip()] + if missing: + raise ValueError(f"requirement_plan_draft core fields are incomplete: {', '.join(missing[:3])}") + if len(external_signal_sources) < 3: + raise ValueError("requirement_plan_draft external_signal_sources must contain at least 3 external sources") + cls._guard_requirement_plan_draft_against_internal_context_leaks(payload=normalized_payload) + cls._guard_requirement_plan_draft_against_stale_context(payload=normalized_payload, packet=packet) + return normalized_payload + + @classmethod + def _render_requirement_plan_draft_payload(cls, payload: dict[str, Any]) -> str: + pending = ";".join(payload.get("pending_items") or []) or "暂无,已经可以继续整理成正式创建草稿" + team_setup = payload.get("team_setup") if isinstance(payload.get("team_setup"), list) else [] + external_signal_sources = ( + payload.get("external_signal_sources") if isinstance(payload.get("external_signal_sources"), list) else [] + ) + team_text = ";".join( + f"{str(item.get('role') or '').strip()}:{str(item.get('notes') or '').strip()}".strip(":") + for item in team_setup + if str(item.get("role") or "").strip() or str(item.get("notes") or "").strip() + ) + lines = [ + str(payload.get("intro") or "").strip(), + "", + f"🧾【需求名称】{str(payload.get('title') or '').strip()}", + "", + f"🧭【一句话判断】{str(payload.get('one_liner') or '').strip()}", + "", + f"🎯【需求目标】{str(payload.get('goal') or '').strip()}", + "", + f"🏗️【项目性质】{str(payload.get('project_nature') or '').strip()}", + "", + f"👥【服务对象】{str(payload.get('target_user') or '').strip()}", + "", + f"📥【基础输入】{str(payload.get('signal_inputs') or '').strip()}", + "", + f"📝【当前已知信息】{str(payload.get('known_info') or '').strip()}", + "", + f"⚙️【技术路线】{str(payload.get('tech_route') or '').strip()}", + "", + f"🧱【第一版范围】{str(payload.get('first_scope') or '').strip()}", + ] + if external_signal_sources: + lines.extend(["", "🌐【外部信号源】"]) + lines.extend( + f"{idx}. {str(item.get('name') or '').strip()}:{str(item.get('purpose') or '').strip()}({str(item.get('phase') or '').strip()})" + for idx, item in enumerate(external_signal_sources, start=1) + if str(item.get("name") or "").strip() and str(item.get("purpose") or "").strip() + ) + if str(payload.get("core_flow") or "").strip(): + lines.extend(["", f"🔄【建议核心流程】{str(payload.get('core_flow') or '').strip()}"]) + if team_text: + lines.extend(["", f"👤【人员配置】建议最小配置:{team_text}"]) + recommended_name = str(payload.get("recommended_assignee_name") or "").strip() + recommended_reason = str(payload.get("recommended_assignee_reason") or "").strip() + recommended_unavailable = str(payload.get("recommended_assignee_unavailable_reason") or "").strip() + if recommended_name: + workload = payload.get("recommended_assignee_workload") + workload_text = "" + try: + if workload is not None and workload != "": + workload_value = float(workload) + workload_text = f"(当前负载 {int(workload_value) if workload_value.is_integer() else round(workload_value, 1)}%)" + except Exception: + workload_text = "" + lines.extend(["", f"👤【推荐负责人】{recommended_name}{workload_text}"]) + if recommended_reason: + lines.append(f"理由:{recommended_reason}") + elif recommended_unavailable: + lines.extend(["", f"👤【推荐负责人】{recommended_unavailable}"]) + lines.extend( + [ + "", + f"🚀【推进方式】{str(payload.get('delivery_mode') or '').strip()}", + "", + f"✅【成功标准】{str(payload.get('success_signal') or '').strip()}", + "", + f"⚠️【风险与依赖】{str(payload.get('risks') or '').strip()}", + "", + f"❓【待确认】还需要你拍板:{pending}", + "", + str(payload.get("closing") or "").strip(), + ] + ) + return "\n".join(line for line in lines if line is not None).strip() + + @classmethod + def _build_requirement_plan_draft_prompt( + cls, + *, + packet: dict[str, Any], + repair_reason: Optional[str] = None, + ) -> str: + prompt = PMFLOW_REQUIREMENT_PLAN_DRAFT_PROMPT.replace( + "{draft_packet}", + json.dumps(packet, ensure_ascii=False, indent=2), + ) + if not str(repair_reason or "").strip(): + return prompt + return ( + "系统纠错说明:上一次草稿输出被系统拒绝,请重写整个 JSON,并修复以下问题:\n" + f"- {str(repair_reason).strip()}\n" + "- 不要复述任何 packet / current_context / collected_fields / known_constraints 之类的内部键名。\n" + "- 不要把旧部署治理场景词带到当前无关需求里,比如审批、审计、权限一次做满、人员能力图谱、工作流状态与审计日志、确认门禁。\n" + "- 只输出修正后的 JSON,不要解释。\n\n" + f"{prompt}" + ) + + async def _generate_requirement_plan_draft_with_retry( + self, + *, + packet: dict[str, Any], + ) -> dict[str, Any]: + last_error: Optional[Exception] = None + for attempt in range(2): + prompt = self._build_requirement_plan_draft_prompt( + packet=packet, + repair_reason=str(last_error) if last_error is not None else None, + ) + try: + extraction = await asyncio.wait_for( + llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=int(self.tenant_id), + function_name="pmflow_requirement_plan_draft", + use_prompt_profile=False, + temperature=0.0, + max_tokens=1800, + response_mode="fast", + ), + timeout=45, + ) + payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None + return self._validate_requirement_plan_draft_payload(payload=payload, packet=packet) + except Exception as exc: # noqa: BLE001 + last_error = exc + if attempt >= 1: + raise + if last_error is not None: + raise last_error + raise ValueError("requirement_plan_draft generation failed") + + async def _build_it_project_requirement_plan_draft_artifact( + self, + *, + card: dict[str, Any], + revision_note: Optional[str] = None, + ) -> tuple[str, dict[str, Any]]: + packet = self._build_requirement_plan_draft_packet(card=card, revision_note=revision_note) + normalized_payload = await self._generate_requirement_plan_draft_with_retry(packet=packet) + owner = await self._recommend_requirement_plan_owner_for_draft(card=card) + if isinstance(owner, dict): + normalized_payload["recommended_assignee_id"] = _safe_int(owner.get("user_id")) + normalized_payload["recommended_assignee_name"] = str(owner.get("name") or "").strip()[:40] + normalized_payload["recommended_assignee_workload"] = float(owner.get("workload_ratio") or 0.0) + normalized_payload["recommended_assignee_reason"] = str(owner.get("reason") or "").strip()[:120] + else: + normalized_payload["recommended_assignee_unavailable_reason"] = "当前系统里还没检索到可靠负责人,先不冒然指定。" + rendered_text = self._render_requirement_plan_draft_payload(normalized_payload) + if str(normalized_payload.get("delivery_mode") or "").strip(): + delivery_mode_text = str(normalized_payload.get("delivery_mode") or "").strip() + delivery_mode_label = self._summarize_it_project_choice_label( + field="delivery_mode", + value=delivery_mode_text, + ) + if delivery_mode_label == "项目级试点": + card["delivery_mode"] = "项目级试点(带基础审批和回滚)" + elif delivery_mode_label: + card["delivery_mode"] = delivery_mode_label + else: + card["delivery_mode"] = delivery_mode_text + if str(normalized_payload.get("success_signal") or "").strip(): + card["success_signal"] = str(normalized_payload.get("success_signal") or "").strip() + return rendered_text, normalized_payload + + async def _generate_requirement_plan_confirmation_draft( + self, + *, + card: dict[str, Any], + revision_note: Optional[str] = None, + ) -> tuple[str, Optional[dict[str, Any]], dict[str, Any]]: + profile = self._detect_requirement_plan_profile(message_text="", card=card) + structured_draft: Optional[dict[str, Any]] = None + if profile == "it_project": + answer, structured_draft = await self._build_it_project_requirement_plan_draft_artifact( + card=card, + revision_note=revision_note, + ) + else: + answer = self._build_requirement_plan_draft_text(card=card) + project_creation_draft = self._build_requirement_plan_project_creation_draft( + card=card, + draft_text=answer, + structured_draft=structured_draft, + ) + return answer, structured_draft, project_creation_draft + + @classmethod + def _build_requirement_plan_round_prompt( + cls, + *, + packet: dict[str, Any], + repair_reason: Optional[str] = None, + ) -> str: + prompt = PMFLOW_REQUIREMENT_PLAN_ROUND_PROMPT.replace( + "{round_packet}", + json.dumps(packet, ensure_ascii=False, indent=2), + ) + if not str(repair_reason or "").strip(): + return prompt + return ( + "系统纠错说明:上一次输出被系统拒绝,请重写整个 JSON,并修复以下问题:\n" + f"- {str(repair_reason).strip()}\n" + "- 不要复述任何 packet / current_context / latest_user_message / collected_fields 之类的内部键名。\n" + "- 不要把旧部署治理场景词带到当前无关需求里,比如发布治理平台、平台运维团队、任务编排平台版、云原生交付底座。\n" + "- 只输出修正后的 JSON,不要解释。\n\n" + f"{prompt}" + ) + + async def _generate_requirement_plan_round_with_retry( + self, + *, + packet: dict[str, Any], + ) -> tuple[dict[str, Any], str]: + last_error: Optional[Exception] = None + for attempt in range(2): + prompt = self._build_requirement_plan_round_prompt( + packet=packet, + repair_reason=str(last_error) if last_error is not None else None, + ) + try: + extraction = await asyncio.wait_for( + llm_service.generate_structured_object( + prompt, + db=self.db, + tenant_id=int(self.tenant_id), + function_name="pmflow_requirement_plan_round", + use_prompt_profile=False, + temperature=0.0, + max_tokens=1400, + response_mode="fast", + ), + timeout=45, + ) + payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else None + normalized_payload = self._validate_requirement_plan_round_payload(payload=payload, packet=packet) + rendered_text = self._render_requirement_plan_round_payload(normalized_payload) + self._validate_requirement_plan_round_render_for_feishu(rendered_text=rendered_text, packet=packet) + return normalized_payload, rendered_text + except Exception as exc: # noqa: BLE001 + last_error = exc + if attempt >= 1: + raise + if last_error is not None: + raise last_error + raise ValueError("requirement_plan_round generation failed") + + async def _build_it_project_requirement_plan_next_question( + self, + *, missing_field: str, profile: str, message_text: str = "", card: Optional[dict[str, Any]] = None, + latest_updates: Optional[dict[str, Any]] = None, ) -> str: - if profile == "it_project": - current_card = dict(card or {}) - current_project_nature = cls._summarize_it_project_choice_label(field="project_nature", value=current_card.get("project_nature") or "当前方向") - current_target_user = cls._summarize_it_project_choice_label(field="target_user", value=current_card.get("target_user") or "当前团队") - current_tech_route = cls._summarize_it_project_choice_label(field="tech_route", value=current_card.get("tech_route") or "当前技术路线") - current_first_scope = cls._summarize_it_project_choice_label(field="first_scope", value=current_card.get("first_scope") or "当前范围") - if missing_field == "project_nature": - return ( - "🧭 我先按“批量自动化部署 OpenClaw”这类 IT 项目来收。\n" - "现在最关键的,不是立刻展开做法,而是先定它更像工具、平台,还是长期底座。\n\n" - "A. 先做内部提效工具\n" - "更适合:先减少重复部署和手工操作\n" - "推荐技术/能力:FastAPI + React + PostgreSQL\n" - "我为什么会考虑这个:开发快,适合先把 MVP 跑通\n" - "代价:后续任务编排和治理能力要继续补\n\n" - "B. 先做发布治理平台\n" - "更适合:多人协作、多环境发布、需要审批和回滚\n" - "推荐技术/能力:FastAPI + React + PostgreSQL + Redis\n" - "我当前更偏这个:更适合承接状态流转、执行追踪和治理规则\n" - "代价:前期设计复杂度高于 A\n\n" - "C. 直接做云原生交付底座\n" - "更适合:目标是多环境、多实例、规模化标准交付\n" - "推荐技术/能力:Kubernetes + Helm + ArgoCD/Terraform\n" - "我为什么暂时不主推:长期上限最高,但基础设施门槛也最大\n" - "代价:基础设施门槛和早期投入最大\n\n" - "⭐ 当前我更推荐:B\n" - "原因:你提的是批量自动化部署,通常核心矛盾不只是提效,而是发布治理和流程标准化。\n\n" - "你回 A / B / C,我就顺着这条继续往下收。" - ) - if missing_field == "target_user": - return ( - f"🧭 我先按“{current_project_nature}”这个方向继续收。\n" - "现在要先把服务对象定住,不然后面做执行体验、治理能力还是项目复制,重心会飘。\n\n" - "A. 开发团队\n" - "更适合:开发自己发起部署、追执行结果\n" - "推荐技术/能力:一键执行、日志查看、失败重试\n" - "我为什么会考虑这个:反馈最快,最适合先验证使用价值\n" - "代价:治理能力会相对弱一些\n\n" - "B. 平台运维团队\n" - "更适合:统一管理环境、凭证、发布窗口和回滚\n" - "推荐技术/能力:批量执行、审批、审计、回滚\n" - "我当前更偏这个:最符合部署治理场景,风险控制也更强\n" - "代价:前期需要更清晰的权限和流程边界\n\n" - "C. 项目交付团队\n" - "更适合:按项目模板复制环境或服务\n" - "推荐技术/能力:项目模板、环境参数、标准交付流程\n" - "我为什么会考虑这个:适合多项目复制交付\n" - "代价:前提是流程已经比较稳定\n\n" - "⭐ 当前我更推荐:B\n" - "原因:先把平台运维场景收清楚,后面的技术路线、权限边界和治理要求会更稳。\n\n" - "你回 A / B / C,我就顺着这条继续往下收。" - ) - if missing_field == "tech_route": - return ( - f"🧭 我先按“{current_target_user}要长期使用”这个方向继续收。\n" - "现在最关键的,不是再补更多背景,而是先把技术路线定住,因为它会直接影响第一版工期和后续扩展方式。\n\n" - "A. 先做脚本增强版\n" - "更适合:先快速验证批量执行闭环\n" - "推荐技术/能力:FastAPI + Shell/Ansible + PostgreSQL\n" - "我为什么会考虑这个:最快能落地,适合先把执行链跑通\n" - "代价:任务编排和可观测性上限一般\n\n" - "B. 先做任务编排平台版\n" - "更适合:长期要做批量任务、状态机、失败重试和执行追踪\n" - "推荐技术/能力:FastAPI + React + Redis + Celery + PostgreSQL\n" - "我当前更偏这个:最平衡,既能落地又保留平台化空间\n" - "代价:系统复杂度高于 A\n\n" - "C. 直接做云原生交付版\n" - "更适合:目标是多环境、多实例和规模化标准交付\n" - "推荐技术/能力:Kubernetes + Helm + ArgoCD/Terraform + FastAPI\n" - "我为什么暂时不主推:环境一致性和规模化能力最强,但门槛也最高\n" - "代价:对基础设施成熟度要求最高\n\n" - "⭐ 当前我更推荐:B\n" - "原因:它最适合把批量自动化部署做成长期可用的平台,而不是一次性脚本工具。\n\n" - "你回 A / B / C,我就顺着这条继续往下收。" - ) - if missing_field == "first_scope": - return ( - f"🧭 我先按“{current_tech_route}”继续往下收。\n" - "这一步要定的,是第一版到底先证明什么价值,避免一上来摊太大。\n\n" - "A. 批量执行闭环\n" - "更适合:先证明平台真正能跑通部署任务\n" - "推荐技术/能力:任务创建、执行日志、失败重试\n" - "我当前更偏这个:最能体现平台核心价值\n" - "代价:环境接入和治理能力会稍后补\n\n" - "B. 环境接入闭环\n" - "更适合:先把服务器、集群、仓库和凭证接进来\n" - "推荐技术/能力:目标注册、凭证管理、环境选择\n" - "我为什么会考虑这个:能为后续自动化执行打基础\n" - "代价:短期可见价值不如 A 直接\n\n" - "C. 治理闭环\n" - "更适合:团队已经进入正式上线阶段\n" - "推荐技术/能力:审批、回滚、审计、权限\n" - "我为什么会考虑这个:能先控制上线风险和责任边界\n" - "代价:没有稳定执行闭环时,容易先做重了\n\n" - "⭐ 当前我更推荐:A\n" - "原因:MVP 最先要证明“能执行、能追踪、失败能恢复”,这样平台主线才会立住。\n\n" - "你回 A / B / C,我就顺着这条继续往下收。" - ) - return ( - f"🧭 我先按“{current_first_scope}”继续收尾。\n" - "现在要拍板的,是先用什么方式把第一版推起来:先轻量验证、先试点,还是直接平台化。\n\n" - "A. 内部 PoC\n" - "更适合:先验证 1-2 个典型部署任务\n" - "推荐技术/能力:轻量试用、快速反馈、低风险验证\n" - "我为什么会考虑这个:最适合快速试错\n" - "代价:对真实治理问题暴露不够充分\n\n" - "B. 项目级试点\n" - "更适合:选一个真实项目先落地\n" - "推荐技术/能力:试点接入、基础审批、基础回滚\n" - "我当前更偏这个:最能检验真实价值,也能尽早暴露流程问题\n" - "代价:需要更明确的项目配合和负责人\n\n" - "C. 平台化上线\n" - "更适合:一开始就计划多团队接入\n" - "推荐技术/能力:统一权限、统一规范、统一环境管理\n" - "我为什么暂时不主推:长期价值最大,但前期投入和协作复杂度也最高\n" - "代价:前期投入和协作复杂度最高\n\n" - "⭐ 当前我更推荐:B\n" - "原因:PoC 太轻,平台化太重,项目级试点最适合项目管理推进和真实验收。\n\n" - "你回 A / B / C,我就顺着这条继续往下收。" - ) - if profile == "scoring": - if missing_field == "scoring_basis": - return "那我先确认一个最关键的问题:这个分数主要想根据什么来打,是技术指标、情绪资金,还是你已经有一套规则想自动化?" - if missing_field == "target_user": - return "那我继续往下收一格:这个结果主要给谁看,是投顾、自己,还是内部研究/交易团队?" - if missing_field == "output_form": - return "那我再确认一个点:你更想怎么接收这个结果,是飞书日报、页面看板,还是只推送一个分数加一句解释?" - if missing_field == "cadence": - return "更新节奏我也一起记一下:这件事希望什么时候更新,是每日收盘后、盘中,还是开盘前?" - return "最后再收一下范围:第一版你最想先保留哪些输出,哪些先不做,才算足够轻?" + packet = self._build_requirement_plan_round_packet( + missing_field=missing_field, + profile=profile, + message_text=message_text, + card=card, + latest_updates=latest_updates, + ) + normalized_payload, rendered_text = await self._generate_requirement_plan_round_with_retry(packet=packet) + if isinstance(card, dict): + option_maps = dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {} + option_maps[str(missing_field or "").strip()] = { + str(option["key"]).strip().upper(): str(option["title"]).strip() + for option in normalized_payload["options"] + if str(option.get("key") or "").strip() + } + card["round_option_maps"] = option_maps + card["last_round_options"] = { + "field": str(missing_field or "").strip(), + "options": list(normalized_payload["options"]), + "recommendation": dict(normalized_payload["recommendation"]), + } + return rendered_text - normalized = WorkflowSemanticPlanner._normalize_text(message_text) - if missing_field == "source_material": - if any(token in normalized for token in ("复盘", "直播")): - return "第一版你最想先解决复盘里的哪一步:切片、总结,还是可执行清单?" - if any(token in normalized for token in ("雷达", "监控", "预警")): - return "第一版你最想先盯哪类信号:政策、舆情,还是竞品动作?" - return ( - "我先把输入来源定一下,不然后面收范围时容易越聊越散。\n\n" - "A. 先按当前聊天里的需求描述来\n" - "更适合:你先把目标讲清,我边聊边收第一版\n\n" - "B. 先按会议纪要 / 文档来\n" - "更适合:已经有结构化材料,重点是提炼结果\n\n" - "C. 先按录音转写 / 回放来\n" - "更适合:信息还比较散,需要先转成可整理内容\n\n" - "你回 A / B / C,也可以直接说更准确的输入来源。" - ) - if missing_field == "target_user": - return ( - "我先把结果主要给谁用定一下,这会直接影响后面的内容粒度和表达方式。\n\n" - "A. 核心执行人 / 内部 PM\n" - "更适合:先服务负责跟进、拆动作、推动落地的人\n\n" - "B. 负责人 / 管理者\n" - "更适合:先看结论、风险和待拍板项\n\n" - "C. 整个团队 / 终端用户\n" - "更适合:需要直接同步所有相关方,或结果就是给最终使用者看的\n\n" - "你回 A / B / C,也可以直接说更准确的对象。" - ) - if missing_field == "output_form": - return ( - "结果最后怎么给你,我先一起定掉,不然后面范围会偏。\n\n" - "A. 飞书总结\n" - "更适合:一次性看完整结论和重点\n\n" - "B. 飞书消息\n" - "更适合:更轻、更快,直接在会话里收结果\n\n" - "C. 单独页面\n" - "更适合:后面要长期查看、筛选或继续编辑\n\n" - "你回 A / B / C,也可以直接说你想要的交付方式。" - ) - if missing_field == "cadence": - return ( - "时效我也一起记一下,先把你能接受的出结果节奏定住。\n\n" - "A. 触发后半小时内\n" - "更适合:希望尽快看到第一版结果\n\n" - "B. 当天内\n" - "更适合:接受汇总后统一产出\n\n" - "C. 第二天\n" - "更适合:允许补充整理和复核\n\n" - "你回 A / B / C,也可以直接说更具体的时效。" - ) - return "第一版先保留哪些字段最有价值,比如待办、责任人、截止时间,哪些先不做?" + async def _build_requirement_plan_next_question( + self, + *, + missing_field: str, + profile: str, + message_text: str = "", + card: Optional[dict[str, Any]] = None, + latest_updates: Optional[dict[str, Any]] = None, + ) -> str: + return await self._build_it_project_requirement_plan_next_question( + missing_field=missing_field, + profile=profile, + message_text=message_text, + card=card, + latest_updates=latest_updates, + ) @classmethod def _build_it_project_signal_sources(cls, *, card: dict[str, Any]) -> str: - normalized = WorkflowSemanticPlanner._normalize_text( - " ".join( - str(card.get(key) or "").strip() - for key in ("title", "goal", "project_nature", "tech_route", "first_scope", "delivery_mode") - if str(card.get(key) or "").strip() - ) - ) sources = ["飞书需求消息"] - if any(token in normalized for token in ("部署", "发布", "openclaw", "回滚", "环境")): + if cls._is_deployment_it_project(card=card): sources.extend( [ "部署目标清单(服务/环境/实例)", @@ -1023,10 +1782,21 @@ class ExecuteQueryProjectMixin: ] ) else: - sources.extend(["目标对象清单", "代码与配置来源", "执行反馈日志"]) + sources.extend(["项目/任务/责任人数据", "人员能力图谱", "工作流状态与审计日志"]) deduped = list(dict.fromkeys(sources)) return f"当前至少要接入:{';'.join(deduped)}" + @classmethod + def _is_deployment_it_project(cls, *, card: dict[str, Any]) -> bool: + normalized = WorkflowSemanticPlanner._normalize_text( + " ".join( + str(card.get(key) or "").strip() + for key in ("title", "goal", "tech_route", "first_scope") + if str(card.get(key) or "").strip() + ) + ) + return any(token in normalized for token in ("部署", "openclaw", "回滚", "环境", "镜像", "仓库", "k8s", "kubernetes")) + @classmethod def _build_it_project_known_info(cls, *, card: dict[str, Any]) -> str: facts: list[str] = [] @@ -1056,19 +1826,27 @@ class ExecuteQueryProjectMixin: delivery_mode = cls._compact_requirement_plan_label(str(card.get("delivery_mode") or "内部 PoC")) if not delivery_mode: delivery_mode = "内部 PoC" + title = str(card.get("title") or "当前 IT 项目").strip() + first_scope = cls._trim_sentence_tail(card.get("first_scope") or "核心工作流") + if cls._is_deployment_it_project(card=card): + return ( + f"先按面向{target_user}的{delivery_mode}推进," + f"先把{title}的批量部署、执行追踪和失败恢复跑成一条闭环。" + ) return ( f"先按面向{target_user}的{delivery_mode}推进," - "先把 OpenClaw 的批量部署、执行追踪和失败恢复跑成一条闭环。" + f"先把{title}的{first_scope}跑成一条可验证闭环。" ) @staticmethod def _trim_sentence_tail(value: Any) -> str: return str(value or "").strip().rstrip("。.!!??") - @staticmethod - def _build_it_project_core_flow(*, card: dict[str, Any]) -> str: - _ = card - return "提交部署需求 -> 生成部署任务 -> 选择目标环境 -> 执行部署 -> 回传结果/日志 -> 失败重试或回滚 -> 留痕审计" + @classmethod + def _build_it_project_core_flow(cls, *, card: dict[str, Any]) -> str: + if cls._is_deployment_it_project(card=card): + return "提交部署需求 -> 生成部署任务 -> 选择目标环境 -> 执行部署 -> 回传结果/日志 -> 失败重试或回滚 -> 留痕审计" + return "提交需求信号 -> 生成项目 brief -> 拆出待办/责任人/截止时间 -> 校验人员能力与负载 -> 进入确认门禁 -> 留痕审计" @classmethod def _build_it_project_staffing(cls, *, card: dict[str, Any]) -> str: @@ -1108,13 +1886,22 @@ class ExecuteQueryProjectMixin: def _build_it_project_risks_and_dependencies(cls, *, card: dict[str, Any]) -> str: delivery_mode = str(card.get("delivery_mode") or "").strip() tech_route = str(card.get("tech_route") or "").strip() - risks = [ - "部署目标清单是否稳定", - "环境访问权限与凭证归属", - "仓库/镜像/配置规范", - "凭证管理", - "失败重试与回滚策略", - ] + if cls._is_deployment_it_project(card=card): + risks = [ + "部署目标清单是否稳定", + "环境访问权限与凭证归属", + "仓库/镜像/配置规范", + "凭证管理", + "失败重试与回滚策略", + ] + else: + risks = [ + "需求事实是否足够清晰", + "责任人与截止时间是否可验证", + "人员能力图谱与负载数据是否完整", + "确认门禁和审计链路是否闭环", + "生成草稿是否能保留证据来源", + ] if "项目级试点" in delivery_mode: risks.append("试点项目配合度") if "平台化上线" in delivery_mode: @@ -1123,19 +1910,35 @@ class ExecuteQueryProjectMixin: risks.append("K8s/基础设施成熟度") return "当前主要风险在" + "、".join(risks) + @classmethod + def _build_it_project_default_success_signal(cls, *, card: dict[str, Any]) -> str: + if cls._is_deployment_it_project(card=card): + return "先保证能稳定创建部署任务、看到执行结果,并能识别失败原因" + return "先保证能稳定生成项目 brief、拆出待办/责任人/截止时间,并进入确认门禁" + @classmethod def _build_it_project_pending_items(cls, *, card: dict[str, Any]) -> list[str]: pending: list[str] = [] if not str(card.get("success_signal") or "").strip(): pending.append("成功判断标准还没完全定死") - pending.extend( - [ - "部署目标范围(服务/环境/实例)", - "仓库/镜像/配置来源", - "环境权限与凭证归属", - "试点负责人和投入人天", - ] - ) + if cls._is_deployment_it_project(card=card): + pending.extend( + [ + "部署目标范围(服务/环境/实例)", + "仓库/镜像/配置来源", + "环境权限与凭证归属", + "试点负责人和投入人天", + ] + ) + else: + pending.extend( + [ + "需求事实来源与证据口径", + "责任人字段来源", + "截止时间字段来源", + "试点负责人和投入人天", + ] + ) tech_route = str(card.get("tech_route") or "").strip() if any(token in tech_route for token in ("Kubernetes", "ArgoCD", "Terraform")): pending.append("现有 K8s / GitOps 基础设施成熟度") @@ -1143,95 +1946,56 @@ class ExecuteQueryProjectMixin: @classmethod def _build_requirement_plan_draft_text(cls, *, card: dict[str, Any]) -> str: - profile = cls._detect_requirement_plan_profile(message_text="", card=card) - pending = [] - if not str(card.get("success_signal") or "").strip(): - pending.append("成功判断标准还没完全定死") - if profile == "it_project": - pending = cls._build_it_project_pending_items(card=card) - lines = [ - "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", - "", - f"🧾【需求名称】{str(card.get('title') or '待补充').strip()}", - "", - f"🧭【一句话判断】{cls._build_it_project_one_line_judgement(card=card)}", - "", - "🎯【需求目标】" - f"{cls._trim_sentence_tail(card.get('goal') or '待补充')}。" - " 这次先不追求把审批、审计、权限一次做满,而是先把主链路跑通。", - "", - f"🏗️【项目性质】这件事更像{str(card.get('project_nature') or '待补充').strip()},不像一次性脚本工具,后面需要承接状态流转和回滚治理。", - "", - f"👥【服务对象】第一版先服务{str(card.get('target_user') or '待补充').strip()},他们会最早发起部署、看执行结果、处理失败和判断是否回滚。", - "", - f"📡【信号源】{cls._build_it_project_signal_sources(card=card)}", - "", - f"📝【当前已知信息】{cls._build_it_project_known_info(card=card)}", - "", - "⚙️【技术路线】" - f"当前更适合走{str(card.get('tech_route') or '待补充').strip()}," - "因为它比脚本方案更稳,也没有一上来重到云原生底座。", - "", - "🧱【第一版范围】" - f"{cls._trim_sentence_tail(card.get('first_scope') or '先收一个最小可跑闭环')}。" - " 这版重点先放在任务创建、执行日志和失败重试。", - "", - f"🔄【建议核心流程】{cls._build_it_project_core_flow(card=card)}", - "", - f"👤【人员配置】{cls._build_it_project_staffing(card=card)}", - "", - "🚀【推进方式】" - f"先按{str(card.get('delivery_mode') or '待补充').strip()}推进," - "这样能尽快在真实项目里验证价值,也能更早暴露流程问题。", - "", - "✅【成功标准】" - f"{cls._trim_sentence_tail(card.get('success_signal') or '先保证能稳定创建部署任务、看到执行结果,并能识别失败原因')}," - "并且能在试点里跑完一次真实闭环。", - "", - f"⚠️【风险与依赖】{cls._build_it_project_risks_and_dependencies(card=card)}。", - "", - f"❓【待确认】还需要你拍板:{';'.join(pending) if pending else '暂无,已经可以继续整理成正式创建草稿'}", - "", - "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", - ] - return "\n".join(lines).strip() - if profile == "generic": - lines = [ - "📋 我先把当前收敛结果整理成一版需求草稿,你直接看这版就行:", - "", - f"【需求名称】{str(card.get('title') or '待补充').strip()}", - f"【需求目标】{str(card.get('goal') or '待补充').strip()}", - f"【输入材料】{str(card.get('source_material') or '待补充').strip()}", - f"【目标用户】{str(card.get('target_user') or '待补充').strip()}", - f"【输出形式】{str(card.get('output_form') or '待补充').strip()}", - f"【响应时效】{str(card.get('cadence') or '待补充').strip()}", - f"【第一版范围】{str(card.get('first_scope') or '先保留最关键的结构化字段').strip()}", - f"【成功标准】{str(card.get('success_signal') or '先保证会后能稳定产出一份可执行的总结').strip()}", - f"【待确认】{';'.join(pending) if pending else '暂无,已经可以继续整理成正式创建草稿'}", - "", - "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", - ] - else: - lines = [ - "📋 我先把当前收敛结果整理成一版需求草稿,你直接看这版就行:", - "", - f"【需求名称】{str(card.get('title') or '待补充').strip()}", - f"【需求目标】{str(card.get('goal') or '待补充').strip()}", - f"【目标用户】{str(card.get('target_user') or '待补充').strip()}", - f"【打分依据】{str(card.get('scoring_basis') or '待补充').strip()}", - f"【输出形式】{str(card.get('output_form') or '待补充').strip()}", - f"【更新频率】{str(card.get('cadence') or '待补充').strip()}", - f"【第一版范围】{str(card.get('first_scope') or '先保留一个分数、方向判断和一句解释').strip()}", - f"【成功标准】{str(card.get('success_signal') or '先保证每天能稳定给出分数和简短解释').strip()}", - f"【待确认】{';'.join(pending) if pending else '暂无,已经可以继续整理成正式创建草稿'}", - "", - "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", - ] + pending = cls._build_it_project_pending_items(card=card) + lines = [ + "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", + "", + f"🧾【需求名称】{str(card.get('title') or '待补充').strip()}", + "", + f"🧭【一句话判断】{cls._build_it_project_one_line_judgement(card=card)}", + "", + "🎯【需求目标】" + f"{cls._trim_sentence_tail(card.get('goal') or '待补充')}。" + " 这次先不追求把审批、审计、权限一次做满,而是先把主链路跑通。", + "", + f"🏗️【项目性质】这件事更像{str(card.get('project_nature') or '待补充').strip()},不像一次性脚本工具,后面需要承接状态流转和回滚治理。", + "", + f"👥【服务对象】第一版先服务{str(card.get('target_user') or '待补充').strip()},他们会最早补充需求、看收敛结果、处理待确认项和判断是否进入执行。", + "", + f"📡【信号源】{cls._build_it_project_signal_sources(card=card)}", + "", + f"📝【当前已知信息】{cls._build_it_project_known_info(card=card)}", + "", + "⚙️【技术路线】" + f"当前更适合走{str(card.get('tech_route') or '待补充').strip()}," + "因为它比脚本方案更稳,也没有一上来重到云原生底座。", + "", + "🧱【第一版范围】" + f"{cls._trim_sentence_tail(card.get('first_scope') or '先收一个最小可跑闭环')}。" + " 这版重点先放在可验证字段、责任边界和确认闭环。", + "", + f"🔄【建议核心流程】{cls._build_it_project_core_flow(card=card)}", + "", + f"👤【人员配置】{cls._build_it_project_staffing(card=card)}", + "", + "🚀【推进方式】" + f"先按{str(card.get('delivery_mode') or '待补充').strip()}推进," + "这样能尽快在真实项目里验证价值,也能更早暴露流程问题。", + "", + "✅【成功标准】" + f"{cls._trim_sentence_tail(card.get('success_signal') or cls._build_it_project_default_success_signal(card=card))}," + "并且能在试点里跑完一次真实闭环。", + "", + f"⚠️【风险与依赖】{cls._build_it_project_risks_and_dependencies(card=card)}。", + "", + f"❓【待确认】还需要你拍板:{';'.join(pending) if pending else '暂无,已经可以继续整理成正式创建草稿'}", + "", + "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + ] return "\n".join(lines).strip() @classmethod def _build_requirement_plan_requirement_items(cls, *, card: dict[str, Any]) -> list[dict[str, str]]: - profile = cls._detect_requirement_plan_profile(message_text="", card=card) items: list[dict[str, str]] = [] def _append(section: str, title: str, value: Any) -> None: @@ -1250,43 +2014,14 @@ class ExecuteQueryProjectMixin: _append("目标用户", "目标用户", card.get("target_user")) _append("第一版范围", "第一版范围", card.get("first_scope")) _append("成功标准", "成功标准", card.get("success_signal")) - if profile == "it_project": - _append("项目性质", "项目性质", card.get("project_nature")) - _append("技术路线", "技术路线", card.get("tech_route")) - _append("推进方式", "推进方式", card.get("delivery_mode")) - elif profile == "scoring": - _append("打分依据", "打分依据", card.get("scoring_basis")) - _append("输出形式", "输出形式", card.get("output_form")) - _append("更新频率", "更新频率", card.get("cadence")) - else: - _append("输入材料", "输入材料", card.get("source_material")) - _append("输出形式", "输出形式", card.get("output_form")) - _append("响应时效", "响应时效", card.get("cadence")) + _append("项目性质", "项目性质", card.get("project_nature")) + _append("技术路线", "技术路线", card.get("tech_route")) + _append("推进方式", "推进方式", card.get("delivery_mode")) return items[:12] @classmethod def _build_requirement_plan_missing_info(cls, *, card: dict[str, Any]) -> list[str]: - profile = cls._detect_requirement_plan_profile(message_text="", card=card) - if profile == "it_project": - return cls._build_it_project_pending_items(card=card) - - label_map = { - "source_material": "输入材料", - "target_user": "目标用户", - "output_form": "输出形式", - "cadence": "响应时效", - "scoring_basis": "打分依据", - "first_scope": "第一版范围", - "success_signal": "成功标准", - } - pending: list[str] = [] - for field in cls._requirement_plan_missing_fields(card): - label = label_map.get(str(field or "").strip()) - if label: - pending.append(label) - if not str(card.get("success_signal") or "").strip(): - pending.append("成功标准") - return list(dict.fromkeys(item for item in pending if str(item or "").strip())) + return cls._build_it_project_pending_items(card=card) @classmethod def _build_requirement_plan_project_creation_draft( @@ -1294,6 +2029,7 @@ class ExecuteQueryProjectMixin: *, card: dict[str, Any], draft_text: str, + structured_draft: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: profile = cls._detect_requirement_plan_profile(message_text="", card=card) title = str(card.get("title") or "需求收敛草稿").strip() or "需求收敛草稿" @@ -1301,35 +2037,92 @@ class ExecuteQueryProjectMixin: first_scope = str(card.get("first_scope") or "").strip() success_signal = str(card.get("success_signal") or "").strip() target_user = str(card.get("target_user") or "").strip() + structured = structured_draft if isinstance(structured_draft, dict) else {} summary_parts = [text for text in (goal, first_scope) if text] - summary = ";".join(dict.fromkeys(summary_parts))[:300] or title - positioning = "" - if profile == "it_project": + summary = str(structured.get("summary") or "").strip()[:300] or ";".join(dict.fromkeys(summary_parts))[:300] or title + positioning = str(structured.get("positioning_one_liner") or "").strip() + if not positioning and profile == "it_project": positioning = cls._build_it_project_one_line_judgement(card=card) if not positioning: positioning = first_scope or goal or title + target_customers = str(structured.get("target_customers") or "").strip()[:120] or target_user[:120] or None + requirement_items = ( + structured.get("requirement_items") + if isinstance(structured.get("requirement_items"), list) + else cls._build_requirement_plan_requirement_items(card=card) + ) + external_signal_sources = ( + structured.get("external_signal_sources") + if isinstance(structured.get("external_signal_sources"), list) + else [] + ) + for source in external_signal_sources[:5]: + if not isinstance(source, dict): + continue + source_name = str(source.get("name") or "").strip() + source_purpose = str(source.get("purpose") or "").strip() + source_phase = str(source.get("phase") or "").strip() + if not source_name or not source_purpose: + continue + requirement_items.append( + { + "section": "外部信号源", + "title": source_name, + "description": f"{source_purpose};{source_phase}".strip(";"), + } + ) + missing_info = ( + structured.get("missing_info") + if isinstance(structured.get("missing_info"), list) + else cls._build_requirement_plan_missing_info(card=card) + ) + mvp_items = ( + structured.get("mvp_items") + if isinstance(structured.get("mvp_items"), list) + else ([first_scope] if first_scope else []) + ) + validation_metrics = ( + structured.get("validation_metrics") + if isinstance(structured.get("validation_metrics"), dict) + else ({"product": [success_signal]} if success_signal else {}) + ) draft_payload = { "draft_source": "requirement_plan", "plan_profile": profile, - "title": title, + "title": str(structured.get("title") or "").strip() or title, "summary": summary, "positioning_one_liner": positioning[:200], - "target_customers": target_user[:120] or None, + "target_customers": target_customers, "original_text": str(draft_text or "").strip()[:4000] or summary, - "requirement_items": cls._build_requirement_plan_requirement_items(card=card), - "missing_info": cls._build_requirement_plan_missing_info(card=card), - "mvp_items": [first_scope] if first_scope else [], - "validation_metrics": ( - {"product": [success_signal]} - if success_signal - else {} - ), - "success_signal": success_signal or None, + "requirement_items": requirement_items, + "external_signal_sources": external_signal_sources, + "missing_info": missing_info, + "mvp_items": mvp_items, + "validation_metrics": validation_metrics, + "success_signal": str(structured.get("success_signal") or "").strip() or success_signal or None, "draft_text": str(draft_text or "").strip(), "requirement_card": dict(card), } - if profile == "it_project": + assignee_id = _safe_int(structured.get("recommended_assignee_id")) + assignee_name = str(structured.get("recommended_assignee_name") or "").strip()[:40] + assignee_reason = str(structured.get("recommended_assignee_reason") or "").strip()[:160] + assignee_workload = structured.get("recommended_assignee_workload") + if assignee_id is not None: + draft_payload["recommended_assignee_id"] = int(assignee_id) + if assignee_name: + draft_payload["recommended_assignee_name"] = assignee_name + if assignee_reason: + draft_payload["recommended_assignee_reason"] = assignee_reason + if assignee_workload is not None and assignee_workload != "": + try: + draft_payload["recommended_assignee_workload"] = float(assignee_workload) + except (TypeError, ValueError): + pass + team_setup = structured.get("team_setup") if isinstance(structured.get("team_setup"), list) else None + if team_setup is not None: + draft_payload["team_setup"] = team_setup + elif profile == "it_project": draft_payload["team_setup"] = [ { "role": "建议配置", @@ -1361,204 +2154,47 @@ class ExecuteQueryProjectMixin: if key not in {"plan_profile", "title", "goal"} } - @classmethod - def _topic_expects_requirement_plan_choice_reply(cls, *, topic: Optional[MountedTopicWorkspace]) -> bool: - if topic is None: - return False - plan_status = str(getattr(topic, "plan_status", "") or "").strip().lower() - if plan_status in {"awaiting_execute_confirm", "plan_ready", "suspended"}: - return False - pending_actions = getattr(topic, "pending_actions", None) - if isinstance(pending_actions, (list, tuple, set)) and "confirm_requirement_draft" in { - str(item or "").strip() for item in pending_actions - }: - return False - metadata = getattr(topic, "metadata_json", None) - if not isinstance(metadata, dict): - return False - last_prompt = str(metadata.get("plan_last_prompt") or "").strip() - if not last_prompt: - return False - normalized_prompt = WorkflowSemanticPlanner._normalize_text(last_prompt) - if "你回a/b/c" in normalized_prompt or "直接回a/b/c" in normalized_prompt: - return True - return bool(re.search(r"(^|\n)\s*[ABC]\.\s*", last_prompt)) - - @classmethod - def _should_continue_requirement_plan_from_workspace( - cls, + async def _backfill_query_semantic_bundle( + self, *, message_text: str, - topic: Optional[MountedTopicWorkspace] = None, - ) -> bool: - normalized = WorkflowSemanticPlanner._normalize_text(message_text) - if not normalized or WorkflowSemanticPlanner._looks_like_casual_chat_message(message_text): - return False - if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): - return False - # Active plan threads often continue with "A,xxx原因..." rather than a bare letter. - # Once a user is clearly responding to an A/B/C follow-up, keep the flow in plan mode. - if cls._extract_requirement_plan_choice(message_text): - return cls._topic_expects_requirement_plan_choice_reply(topic=topic) - if cls._requirement_plan_material_updates(message_text=message_text): - return True - return any(token in normalized for token in ("整理成草稿", "出草稿", "汇总一下", "按这版", "就这样先收")) - - @classmethod - def _looks_like_requirement_plan_seed(cls, *, message_text: str) -> bool: - normalized = WorkflowSemanticPlanner._normalize_text(message_text) - if not normalized: - return False - material_updates = cls._requirement_plan_material_updates(message_text=message_text) - has_updates = bool(material_updates) - if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): - topic_tail = "" - if "关于" in normalized: - topic_tail = normalized.partition("关于")[2] - topic_tail = re.sub(r"[@_a-z0-9]+", "", topic_tail) - has_topic_subject = len(topic_tail) >= 4 - return ( - has_updates - or WorkflowSemanticPlanner._looks_like_requirement_plan_chat(message_text) - or WorkflowSemanticPlanner.looks_like_project_create_request(message_text) - or has_topic_subject + source_system: str, + metadata: dict[str, Any], + requested_project_id: Optional[int], + source_chat_id: Optional[str], + source_sender_open_id: Optional[str], + thread_context: Optional[dict[str, Any]], + conversation_key: Optional[str], + active_requirement_plan_topic: Optional[MountedTopicWorkspace], + active_requirement_plan_draft: Optional[MountedDraftWorkspace], + ) -> dict[str, Any]: + planner_metadata = dict(metadata or {}) + if requested_project_id is not None: + planner_metadata.setdefault("project_id", int(requested_project_id)) + if source_chat_id: + planner_metadata.setdefault("source_chat_id", source_chat_id) + if source_sender_open_id: + planner_metadata.setdefault("source_sender_open_id", source_sender_open_id) + if conversation_key: + planner_metadata.setdefault("conversation_key", conversation_key) + if isinstance(thread_context, dict): + planner_metadata.setdefault("thread_context", dict(thread_context)) + if active_requirement_plan_topic is not None and active_requirement_plan_draft is not None: + planner_metadata.setdefault("active_requirement_plan_present", True) + planner_metadata.setdefault( + "active_requirement_plan_status", + str(getattr(active_requirement_plan_topic, "plan_status", "") or "").strip().lower() or None, ) - if has_updates: - return True - return ( - WorkflowSemanticPlanner._looks_like_requirement_plan_chat(message_text) - or WorkflowSemanticPlanner.looks_like_project_create_request(message_text) - or any(token in normalized for token in ("需求", "工具", "助手", "系统", "平台", "纪要", "复盘", "打分")) - ) - - @classmethod - def _looks_like_requirement_plan_continuation(cls, *, message_text: str) -> bool: - if cls._should_continue_requirement_plan_from_workspace(message_text=message_text): - return True - return WorkflowSemanticPlanner._looks_like_requirement_plan_slot_answer(message_text) - - @classmethod - def _collect_requirement_topic_text_fragments(cls, payload: Any) -> list[str]: - fragments: list[str] = [] - if isinstance(payload, dict): - for value in payload.values(): - fragments.extend(cls._collect_requirement_topic_text_fragments(value)) - return fragments - if isinstance(payload, (list, tuple, set)): - for item in payload: - fragments.extend(cls._collect_requirement_topic_text_fragments(item)) - return fragments - text = str(payload or "").strip() - if text: - fragments.append(text) - return fragments - - @classmethod - def _build_requirement_topic_overlap_tokens(cls, text: str) -> set[str]: - normalized = WorkflowSemanticPlanner._normalize_text(text) - if not normalized: - return set() - for stopword in ( - "想跟你聊", - "跟你聊", - "聊聊", - "聊个", - "聊需求", - "先不创建", - "先别创建", - "先不要创建", - "先收第一版", - "先收一版", - "第一版", - "关于", - "需求", - "项目", - "工具", - "系统", - "平台", - "方案", - "一个", - "这个", - "那个", - "我们", - "这次", - "现在", - ): - normalized = normalized.replace(stopword, "") - tokens: set[str] = { - token - for token in re.findall(r"[a-z][a-z0-9_-]{1,}", normalized) - if len(token) >= 2 - } - chinese_text = re.sub(r"[a-z0-9_-]+", "", normalized) - for index in range(len(chinese_text) - 1): - token = chinese_text[index : index + 2] - if len(token.strip()) == 2: - tokens.add(token) - return tokens - - @classmethod - def _compute_requirement_topic_overlap_score( - cls, - *, - topic: Optional[MountedTopicWorkspace], - draft: Optional[MountedDraftWorkspace], - message_text: str, - ) -> float: - context_parts: list[str] = [] - if topic is not None: - context_parts.extend(cls._collect_requirement_topic_text_fragments(topic.facts_json)) - context_parts.extend(cls._collect_requirement_topic_text_fragments(topic.goals_json)) - context_parts.extend(cls._collect_requirement_topic_text_fragments(topic.constraints_json)) - if draft is not None: - context_parts.extend(cls._collect_requirement_topic_text_fragments(draft.requirement_card)) - context_parts.extend(cls._collect_requirement_topic_text_fragments(draft.draft_payload)) - topic_tokens = cls._build_requirement_topic_overlap_tokens(" ".join(context_parts)) - message_tokens = cls._build_requirement_topic_overlap_tokens(message_text) - if not topic_tokens or not message_tokens: - return 0.0 - overlap = len(topic_tokens & message_tokens) - denominator = max(1, min(len(topic_tokens), len(message_tokens))) - return round(overlap / denominator, 4) - - @classmethod - def _looks_like_requirement_new_topic_candidate(cls, *, message_text: str) -> bool: - normalized = WorkflowSemanticPlanner._normalize_text(message_text) - if not normalized: - return False - if WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text): - return True - if cls._looks_like_requirement_plan_continuation(message_text=message_text): - return False - if not cls._looks_like_requirement_plan_seed(message_text=message_text): - return False - return ( - WorkflowSemanticPlanner._looks_like_requirement_plan_chat(message_text) - or WorkflowSemanticPlanner.looks_like_project_create_request(message_text) - or any(token in normalized for token in ("想做", "做个", "做一个", "有个需求", "关于")) - ) - - @classmethod - def _should_switch_requirement_topic_by_overlap( - cls, - *, - topic: MountedTopicWorkspace, - draft: Optional[MountedDraftWorkspace], - message_text: str, - ) -> bool: - if not cls._looks_like_requirement_new_topic_candidate(message_text=message_text): - return False - overlap_score = cls._compute_requirement_topic_overlap_score( - topic=topic, - draft=draft, + planner_metadata.setdefault( + "active_requirement_draft_state", + str(getattr(active_requirement_plan_draft, "state", "") or "").strip().lower() or None, + ) + bundle = await WorkflowSemanticPlanner.aplan_message( message_text=message_text, + source_system=source_system, + metadata=planner_metadata, ) - plan_status = str(getattr(topic, "plan_status", "") or "").strip().lower() - if plan_status in {"awaiting_execute_confirm", "plan_ready"}: - return overlap_score <= 0.12 - if plan_status == "options_ready": - return overlap_score <= 0.08 - return overlap_score == 0.0 + return bundle.to_dict() async def _find_active_requirement_plan_workspace( self, @@ -1609,7 +2245,7 @@ class ExecuteQueryProjectMixin: select(MountedDraftWorkspace).where( MountedDraftWorkspace.tenant_id == int(self.tenant_id), MountedDraftWorkspace.topic_workspace_id == int(topic.id), - MountedDraftWorkspace.state.in_(("collecting", "ready")), + MountedDraftWorkspace.state.in_(("collecting", "ready", "draft_pending_confirm")), ) ) ).scalar_one_or_none() @@ -1756,40 +2392,6 @@ class ExecuteQueryProjectMixin: ) return topic is not None and draft is not None - async def _infer_requirement_plan_topic_switch( - self, - *, - message_text: str, - source_system: str, - source_message_id: str, - source_chat_id: Optional[str], - sender: Optional[str], - source_sender_open_id: Optional[str], - thread_context: Optional[dict[str, Any]], - conversation_key: Optional[str], - message_intent: Optional[str], - ) -> Optional[str]: - if str(message_intent or "").strip().lower() != "requirement_plan": - return None - topic, draft = await self._find_active_requirement_plan_workspace( - source_system=source_system, - source_message_id=source_message_id, - source_chat_id=source_chat_id, - sender=sender, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - conversation_key=conversation_key, - ) - if topic is None: - return None - if self._should_switch_requirement_topic_by_overlap( - topic=topic, - draft=draft, - message_text=message_text, - ): - return "new" - return None - async def _build_requirement_planning_direct_result( self, *, @@ -1817,12 +2419,26 @@ class ExecuteQueryProjectMixin: if topic is None or draft is None: profile = self._detect_requirement_plan_profile(message_text=message_text, card={}) card = self._extract_requirement_plan_updates(message_text=message_text, card={"plan_profile": profile}) - next_question = self._build_requirement_plan_next_question( - missing_field=self._requirement_plan_missing_fields(card)[0] if self._requirement_plan_missing_fields(card) else "first_scope", - profile=profile, - message_text=message_text, - card=card, - ) + try: + next_question = await self._build_requirement_plan_next_question( + missing_field=self._requirement_plan_missing_fields(card)[0] if self._requirement_plan_missing_fields(card) else "first_scope", + profile=profile, + message_text=message_text, + card=card, + latest_updates=card, + ) + except Exception as exc: + logger.warning( + "requirement_plan_round_generation_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=source_message_id, + error=str(exc), + ) + return { + "status": "failed_safe", + "error_message": f"需求收敛选项生成失败:{str(exc)[:180]}", + } return self._build_direct_query_result( answer=f"可以,我们先不创建项目,先把这件事收成一版需求。\n\n{next_question}", scene_label="Mounted Runtime Plan", @@ -1831,7 +2447,10 @@ class ExecuteQueryProjectMixin: ) card = dict(draft.requirement_card or {}) - if force_new_topic and not self._looks_like_requirement_plan_seed(message_text=message_text): + has_material_input = bool(self._requirement_plan_material_updates(message_text=message_text)) + has_choice_reply = bool(self._extract_requirement_plan_choice(message_text)) + has_nontrivial_seed_text = len(self._sanitize_requirement_plan_text(message_text).strip()) >= 6 + if force_new_topic and not (has_material_input or has_choice_reply or has_nontrivial_seed_text): topic.plan_status = "collecting" topic.pending_actions = ["continue_requirement_plan"] topic.metadata_json = { @@ -1892,11 +2511,23 @@ class ExecuteQueryProjectMixin: topic.pending_actions = ["confirm_requirement_draft"] if should_emit_draft else ["continue_requirement_plan"] topic.readiness_score = 1.0 if should_emit_draft else readiness if should_emit_draft: - answer = self._build_requirement_plan_draft_text(card=card) - project_creation_draft = self._build_requirement_plan_project_creation_draft( - card=card, - draft_text=answer, - ) + structured_draft: Optional[dict[str, Any]] = None + try: + answer, structured_draft, project_creation_draft = await self._generate_requirement_plan_confirmation_draft( + card=card + ) + except Exception as exc: + logger.warning( + "requirement_plan_draft_generation_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=source_message_id, + error=str(exc), + ) + return { + "status": "failed_safe", + "error_message": f"需求草稿生成失败:{str(exc)[:180]}", + } draft.state = "ready" draft.draft_version = int(draft.draft_version or 0) + 1 draft.requirement_card = card @@ -1929,12 +2560,26 @@ class ExecuteQueryProjectMixin: } return result - next_question = self._build_requirement_plan_next_question( - missing_field=missing_fields[0] if missing_fields else "first_scope", - profile=profile, - message_text=message_text, - card=card, - ) + try: + next_question = await self._build_requirement_plan_next_question( + missing_field=missing_fields[0] if missing_fields else "first_scope", + profile=profile, + message_text=message_text, + card=card, + latest_updates=updates, + ) + except Exception as exc: + logger.warning( + "requirement_plan_round_generation_failed", + tenant_id=self.tenant_id, + source_system=source_system, + source_message_id=source_message_id, + error=str(exc), + ) + return { + "status": "failed_safe", + "error_message": f"需求收敛选项生成失败:{str(exc)[:180]}", + } ack = self._build_requirement_plan_ack(updates=updates, profile=profile) answer = f"{ack}\n\n{next_question}" if ack else f"可以,我们先不创建项目,先把这件事收成一版需求。\n\n{next_question}" draft.state = "collecting" @@ -2094,6 +2739,150 @@ class ExecuteQueryProjectMixin: "如果你要,我可以继续把推荐负责人写进草稿或再细分成主负责人与协作人。" ) + async def _recommend_requirement_plan_owner_for_draft( + self, + *, + card: dict[str, Any], + ) -> Optional[dict[str, Any]]: + from app.models import SkillTag, User + + current_card = dict(card or {}) + combined_text = WorkflowSemanticPlanner._normalize_text( + " ".join( + str(current_card.get(key) or "").strip() + for key in ( + "title", + "goal", + "project_nature", + "target_user", + "tech_route", + "first_scope", + "delivery_mode", + "success_signal", + ) + if str(current_card.get(key) or "").strip() + ) + ) + if not combined_text: + return None + + try: + user_rows = ( + await self.db.execute( + select(User, UserWorkload) + .outerjoin(UserWorkload, UserWorkload.user_id == User.id) + .where( + and_( + User.tenant_id == int(self.tenant_id), + User.is_active.is_(True), + User.status == "active", + ) + ) + .order_by(User.id.asc()) + ) + ).all() + except Exception as exc: # noqa: BLE001 + logger.warning( + "requirement_plan_owner_lookup_failed", + tenant_id=int(self.tenant_id), + error=str(exc), + ) + return None + if not user_rows: + return None + + user_ids = [int(user.id) for user, _ in user_rows] + binding_rows = [] + if user_ids: + try: + binding_rows = ( + await self.db.execute( + select(UserSkillBinding, SkillTag) + .join(SkillTag, SkillTag.id == UserSkillBinding.skill_tag_id) + .where( + and_( + UserSkillBinding.tenant_id == int(self.tenant_id), + UserSkillBinding.user_id.in_(user_ids), + ) + ) + ) + ).all() + except Exception as exc: # noqa: BLE001 + logger.warning( + "requirement_plan_owner_skill_lookup_failed", + tenant_id=int(self.tenant_id), + error=str(exc), + ) + binding_rows = [] + + binding_map: dict[int, list[tuple[str, str, float]]] = {} + matched_skill_tokens: set[str] = set() + for binding, skill in binding_rows: + token = WorkflowSemanticPlanner._normalize_text(getattr(skill, "name", "") or getattr(skill, "slug", "")) + if token and token in combined_text: + matched_skill_tokens.add(str(getattr(skill, "name", "") or getattr(skill, "slug", ""))) + binding_map.setdefault(int(binding.user_id), []).append( + ( + str(getattr(skill, "name", "") or getattr(skill, "slug", "")).strip(), + token, + float(getattr(binding, "proficiency", 0) or 0), + ) + ) + + candidates: list[dict[str, Any]] = [] + for user, workload in user_rows: + role_text = str(getattr(user, "role", "") or "") + role_tokens = {token.strip().lower() for token in re.split(r"[,/| ]+", role_text) if token.strip()} + is_managerial = bool(role_tokens & {"boss", "admin"}) or str(getattr(user, "name", "")).strip() in {"系统管理员", "老板"} + workload_ratio = float(getattr(workload, "workload_ratio", 0) or getattr(user, "workload", 0) or 0) + skills = binding_map.get(int(user.id), []) + matched_skills = [skill_name for skill_name, skill_token, _ in skills if skill_token and skill_token in combined_text] + avg_proficiency = (sum(skill_prof for _, _, skill_prof in skills) / len(skills)) if skills else 0.0 + score = len(matched_skills) * 30 + avg_proficiency * 0.25 - min(workload_ratio, 100.0) * 0.18 + if not skills: + score -= 8 + if is_managerial: + score -= 30 + candidates.append( + { + "user_id": int(user.id), + "name": str(getattr(user, "name", "") or f"成员{getattr(user, 'id', '')}").strip(), + "role": role_text, + "workload_ratio": round(workload_ratio, 1), + "matched_skills": matched_skills, + "score": round(score, 2), + "is_managerial": is_managerial, + } + ) + + ranked = sorted( + [item for item in candidates if not item["is_managerial"]] or candidates, + key=lambda item: (-item["score"], item["workload_ratio"], item["name"]), + ) + if not ranked: + return None + + primary = ranked[0] + backups = ranked[1:3] + if primary["matched_skills"]: + reason = f"系统检索到他命中技能 {', '.join(primary['matched_skills'][:3])},当前负载约 {primary['workload_ratio']}%。" + elif matched_skill_tokens: + reason = f"系统检索到他在现有能力图谱里更接近这类任务,当前负载约 {primary['workload_ratio']}%。" + else: + reason = f"系统先按当前负载和角色分布给出保守建议;他当前负载约 {primary['workload_ratio']}%。" + if backups: + reason += " 备选:" + "、".join( + f"{item['name']}(负载 {item['workload_ratio']}%)" for item in backups + ) + return { + "user_id": int(primary["user_id"]), + "name": str(primary["name"]), + "role": str(primary["role"]), + "workload_ratio": float(primary["workload_ratio"]), + "matched_skills": list(primary["matched_skills"]), + "reason": reason[:160], + } + @staticmethod def _build_direct_query_result(*, answer: str, scene_label: str, reason_code: str, query_type: str) -> dict[str, Any]: text = str(answer or "").strip() @@ -2227,19 +3016,17 @@ class ExecuteQueryProjectMixin: or semantic_diagnostics.get("force_thinking_mode") or "" ).strip() or None - explicit_new_requirement_topic = WorkflowSemanticPlanner.looks_like_explicit_new_requirement_topic(message_text) - if explicit_new_requirement_topic: - # "新需求 / 另一个需求" is a stronger signal than any stale upstream classify label. - # Force the conversation into a fresh requirement-plan topic so follow-up A/B/C replies - # cannot accidentally continue an unrelated historical draft in the same chat. - topic_switch = "new" - message_intent = "requirement_plan" - consultant_mode_preference = "clarify" - advise_subtype = None has_active_requirement_plan_workspace = False active_requirement_plan_topic: Optional[MountedTopicWorkspace] = None active_requirement_plan_draft: Optional[MountedDraftWorkspace] = None - if not message_intent: + has_requirement_anchor = bool( + isinstance(thread_context, dict) + and any( + str(thread_context.get(key) or "").strip() + for key in ("root_message_id", "reply_to_message_id", "thread_id") + ) + ) + if has_requirement_anchor: active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( source_system=source_system, source_message_id=source_message_id, @@ -2252,19 +3039,40 @@ class ExecuteQueryProjectMixin: has_active_requirement_plan_workspace = ( active_requirement_plan_topic is not None and active_requirement_plan_draft is not None ) - if has_active_requirement_plan_workspace and self._should_continue_requirement_plan_from_workspace( - message_text=message_text, - topic=active_requirement_plan_topic, - ): + if has_active_requirement_plan_workspace: message_intent = "requirement_plan" - consultant_mode_preference = consultant_mode_preference or "clarify" - if not topic_switch and explicit_new_requirement_topic: - topic_switch = "new" - message_intent = message_intent or "requirement_plan" - consultant_mode_preference = consultant_mode_preference or "clarify" - if not topic_switch: - topic_switch = await self._infer_requirement_plan_topic_switch( - message_text=message_text, + topic_switch = str(topic_switch or "continue").strip().lower() or "continue" + consultant_mode_preference = str(consultant_mode_preference or "exploratory").strip().lower() or "exploratory" + normalized_message_intent = str(message_intent or "").strip().lower() + has_current_semantic_bundle = isinstance(semantic_bundle, dict) and bool(semantic_bundle) + has_current_rag_semantics = bool( + isinstance(rag_request_context, dict) + and any( + str(rag_request_context.get(key) or "").strip() + for key in ( + "message_intent", + "consultant_mode_preference", + "advise_subtype", + "topic_switch", + "thinking_mode", + ) + ) + ) + needs_semantic_backfill = ( + not has_current_semantic_bundle + and not has_current_rag_semantics + and not has_active_requirement_plan_workspace + ) + if not normalized_message_intent: + needs_semantic_backfill = True + elif normalized_message_intent in {"business_review", "requirement_plan"} and not str( + consultant_mode_preference or "" + ).strip(): + needs_semantic_backfill = True + elif normalized_message_intent == "requirement_plan" and not str(topic_switch or "").strip(): + needs_semantic_backfill = True + if needs_semantic_backfill: + active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( source_system=source_system, source_message_id=source_message_id, source_chat_id=source_chat_id, @@ -2272,30 +3080,79 @@ class ExecuteQueryProjectMixin: source_sender_open_id=source_sender_open_id, thread_context=thread_context, conversation_key=conversation_key, - message_intent=message_intent, ) - if not message_intent and self._looks_like_requirement_plan_continuation(message_text=message_text): - if not has_active_requirement_plan_workspace: - active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( - source_system=source_system, - source_message_id=source_message_id, - source_chat_id=source_chat_id, - sender=None, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - conversation_key=conversation_key, - ) - has_active_requirement_plan_workspace = ( - str(topic_switch or "").strip().lower() != "new" - and active_requirement_plan_topic is not None - and active_requirement_plan_draft is not None - ) - if has_active_requirement_plan_workspace and self._should_continue_requirement_plan_from_workspace( + has_active_requirement_plan_workspace = ( + active_requirement_plan_topic is not None and active_requirement_plan_draft is not None + ) + semantic_bundle = await self._backfill_query_semantic_bundle( + message_text=message_text, + source_system=source_system, + metadata=metadata, + requested_project_id=requested_project_id if requested_project_id is not None else intent.project_id, + source_chat_id=source_chat_id, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + active_requirement_plan_topic=active_requirement_plan_topic if has_requirement_anchor else None, + active_requirement_plan_draft=active_requirement_plan_draft if has_requirement_anchor else None, + ) + semantic_diagnostics = ( + semantic_bundle.get("diagnostics") if isinstance(semantic_bundle.get("diagnostics"), dict) else {} + ) + refreshed_message_intent = str(semantic_diagnostics.get("message_intent") or "").strip() or None + refreshed_consultant_mode = ( + str(semantic_diagnostics.get("consultant_mode_preference") or "").strip().lower() or None + ) + refreshed_advise_subtype = ( + str(semantic_diagnostics.get("advise_subtype") or "").strip().lower() or None + ) + refreshed_topic_switch = str(semantic_diagnostics.get("topic_switch") or "").strip().lower() or None + refreshed_thinking_mode = str(semantic_diagnostics.get("force_thinking_mode") or "").strip() or None + if refreshed_message_intent: + message_intent = refreshed_message_intent + if refreshed_consultant_mode: + consultant_mode_preference = refreshed_consultant_mode + if refreshed_advise_subtype: + advise_subtype = refreshed_advise_subtype + if refreshed_topic_switch: + topic_switch = refreshed_topic_switch + if refreshed_thinking_mode: + thinking_mode = refreshed_thinking_mode + metadata = {**metadata, "semantic_bundle": semantic_bundle} + normalized_message_intent = str(message_intent or "").strip().lower() + if ( + has_active_requirement_plan_workspace + and not has_requirement_anchor + and normalized_message_intent == "requirement_plan" + and not self._extract_requirement_plan_choice(message_text) + ): + nontrivial_seed = self._sanitize_requirement_plan_text(message_text).strip() + overlap_ratio = self._requirement_seed_overlap_ratio( message_text=message_text, topic=active_requirement_plan_topic, - ): - message_intent = "requirement_plan" - consultant_mode_preference = consultant_mode_preference or "clarify" + draft=active_requirement_plan_draft, + ) + if len(nontrivial_seed) >= 6 and overlap_ratio < 0.18: + topic_switch = "new" + explicit_new_requirement_topic = ( + str(message_intent or "").strip().lower() == "requirement_plan" + and str(topic_switch or "").strip().lower() == "new" + ) + if not has_active_requirement_plan_workspace and str(message_intent or "").strip().lower() == "requirement_plan": + active_requirement_plan_topic, active_requirement_plan_draft = await self._find_active_requirement_plan_workspace( + source_system=source_system, + source_message_id=source_message_id, + source_chat_id=source_chat_id, + sender=None, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + conversation_key=conversation_key, + ) + has_active_requirement_plan_workspace = ( + str(topic_switch or "").strip().lower() != "new" + and active_requirement_plan_topic is not None + and active_requirement_plan_draft is not None + ) if self._should_use_lightweight_chat_answer( message_text=message_text, message_intent=message_intent, @@ -2375,6 +3232,7 @@ class ExecuteQueryProjectMixin: rag_service=rag_service, message_text=message_text, project_id=requested_project_id if requested_project_id is not None else intent.project_id, + message_intent=message_intent, ) if project_lookup_result is not None: return project_lookup_result diff --git a/backend/app/services/ai_pmo/consultant_dispatch.py b/backend/app/services/ai_pmo/consultant_dispatch.py index 77e7f709..03625e1e 100644 --- a/backend/app/services/ai_pmo/consultant_dispatch.py +++ b/backend/app/services/ai_pmo/consultant_dispatch.py @@ -42,19 +42,6 @@ CONSULTANT_CHANNEL_STRATEGIES: dict[str, dict[str, Any]] = { } _QUERY_OPERATION_CODES = {"query.answer", "query.read_only"} -_SCOPE_EXPANSION_RE = re.compile( - r"(整个系统|全部重做|全量重构|一次替换所有|一次性全部|推翻重来|从零重写|所有模块都|整体改造)", - re.IGNORECASE, -) -_BRAINSTORM_RE = re.compile( - r"(想.idea|想法|brainstorm|头脑风暴|一起想|给我几个方案|给几个方向|发散一下|有哪些思路)", - re.IGNORECASE, -) -_ADVISE_RE = re.compile( - r"(建议|怎么做|如何做|优先|取舍|方案|还是|更适合|更应该|值不值得|要不要)", - re.IGNORECASE, -) - _MISSING_FIELD_LABELS = { "project_id": "归属项目", "task_title": "任务标题", @@ -63,15 +50,6 @@ _MISSING_FIELD_LABELS = { "project_scope": "项目范围", } -_LONG_REPLY_SECTION_HINTS = ( - "结论:", - "依据:", - "不确定项:", - "事实:", - "建议:", - "待确认项:", -) - _DOMAIN_NEXT_ACTION_FALLBACKS: dict[str, dict[str, str]] = { DOMAIN_RAG: { MODE_ADVISE: "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。", @@ -121,14 +99,12 @@ def _normalize_action_text(text: str) -> str: return re.sub(r"\s+", " ", str(text or "")).strip() -def _looks_like_long_answer(text: str) -> bool: +def _is_multiline_or_long_answer(text: str) -> bool: raw = str(text or "") normalized = _normalize_action_text(raw) if not normalized: return False - if "\n" in raw or len(normalized) > 120: - return True - return any(token in normalized for token in _LONG_REPLY_SECTION_HINTS) + return "\n" in raw or len(normalized) > 120 def _domain_next_action(domain: str, mode: str) -> str: @@ -182,18 +158,6 @@ def _derive_allow_execute( return normalized_status in {"succeeded", "answered", "captured", "completed"} -def _looks_like_scope_expansion(message_text: str) -> bool: - return bool(_SCOPE_EXPANSION_RE.search(str(message_text or ""))) - - -def _looks_like_brainstorm(message_text: str) -> bool: - return bool(_BRAINSTORM_RE.search(str(message_text or ""))) - - -def _looks_like_advice(message_text: str) -> bool: - return bool(_ADVISE_RE.search(str(message_text or ""))) - - def _derive_mode( *, intent_name: str, @@ -204,20 +168,17 @@ def _derive_mode( allow_execute: bool, primary_operation_code: Optional[str], ) -> str: + _ = message_text if missing_fields: return MODE_CLARIFY - if _looks_like_scope_expansion(message_text): - return MODE_CHALLENGE if str(risk_level or "").strip().lower() == "high" and not allow_execute and not _is_query_like( primary_operation_code=primary_operation_code, intent_name=intent_name, ): return MODE_CHALLENGE - if _looks_like_brainstorm(message_text): - return MODE_BRAINSTORM if allow_execute and not _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name): return MODE_EXECUTE - if _looks_like_advice(message_text) or _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name): + if _is_query_like(primary_operation_code=primary_operation_code, intent_name=intent_name): return MODE_ADVISE if str(status or "").strip().lower() == "pending_manual": return MODE_CHALLENGE @@ -225,7 +186,7 @@ def _derive_mode( def _build_clarify_next_action(*, missing_fields: list[str], direct_reply_text: str) -> str: - if direct_reply_text and not _looks_like_long_answer(direct_reply_text): + if direct_reply_text and not _is_multiline_or_long_answer(direct_reply_text): return _normalize_action_text(direct_reply_text)[:120] labels = [_MISSING_FIELD_LABELS.get(item, item) for item in missing_fields] suffix = "、".join([label for label in labels if label]) or "关键信息" @@ -246,7 +207,7 @@ def _build_next_action( direct_reply_text=direct_reply_text, ) if mode == MODE_CHALLENGE: - if direct_reply_text and not _looks_like_long_answer(direct_reply_text): + if direct_reply_text and not _is_multiline_or_long_answer(direct_reply_text): return _normalize_action_text(direct_reply_text)[:120] if str(risk_level or "").strip().lower() == "high": return "建议先拆分范围,再确认是否继续推进高风险动作。"[:120] @@ -261,7 +222,7 @@ def _build_next_action( if domain == DOMAIN_SIGNAL: return "可继续确认归属项目,或查看后续自动处理结果。" return "可继续查看结果,或补充更多执行细节。" - if direct_reply_text and not _looks_like_long_answer(direct_reply_text): + if direct_reply_text and not _is_multiline_or_long_answer(direct_reply_text): return _normalize_action_text(direct_reply_text)[:120] return _domain_next_action(domain=domain, mode=mode) diff --git a/backend/app/services/ai_pmo/draft_confirm_handler.py b/backend/app/services/ai_pmo/draft_confirm_handler.py index 1ef75ef7..ec892bf6 100644 --- a/backend/app/services/ai_pmo/draft_confirm_handler.py +++ b/backend/app/services/ai_pmo/draft_confirm_handler.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Optional from . import ai_pmo_kernel_common_core as _common @@ -13,7 +13,10 @@ for _name, _value in vars(_common).items(): from ..ai_steward_service import AIStewardService from ..mounted_ai_runtime_write_service import MountedAIRuntimeWriteService -from .workflow_semantic_planner import WorkflowSemanticPlanner +from .ai_pmo_typed_executor_execute import mixin_execute_query_project +from .semantic_context.assembler import SemanticContextAssembler +from .semantic_context.gatekeeper import SemanticDecisionGatekeeper +from .semantic_context.semantic_decision_service import SemanticDecisionService from ..signal.draft_generator_service import DraftGeneratorService from ...models import MountedDraftWorkspace, MountedTopicWorkspace from ...models.feishu_reply_outbox import FeishuReplyOutbox @@ -25,7 +28,14 @@ class DraftConfirmHandler: _ACTION_CONFIRM = "confirm" _ACTION_REVISE = "revise" _ACTION_CANCEL = "cancel" + _ACTION_ANSWER_ONLY = "answer_only" _ACTION_UNKNOWN = "unknown" + _SEMANTIC_FOLLOWUP_ACTIONS = (_ACTION_CONFIRM, _ACTION_REVISE, _ACTION_CANCEL, _ACTION_ANSWER_ONLY) + _EXPLICIT_CONFIRM_COMMANDS = frozenset({"确认", "confirm", "ok", "好的", "好", "yes", "y"}) + _EXPLICIT_CANCEL_COMMANDS = frozenset({"取消", "cancel", "不用了", "算了"}) + _DEFAULT_DISPLAY_FEEDBACK_REPLY = ( + "收到,这是展示形式反馈:卡片可以更完整,聊天直发我会控制得更短;当前草稿内容我先不重起。" + ) _WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) @@ -35,11 +45,6 @@ class DraftConfirmHandler: 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]: - action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=message_text) - return action, note - async def handle_text_reply( self, *, @@ -51,11 +56,12 @@ class DraftConfirmHandler: source_sender_open_id: Optional[str], expected_version: Optional[int] = None, thread_context: Optional[dict[str, Any]] = None, + semantic_followup_override: Optional[dict[str, str]] = 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) - looks_like_revision = WorkflowSemanticPlanner.looks_like_natural_draft_revision(message_text) - + normalized_thread_context = self._normalize_thread_context(thread_context) + has_reply_anchor = bool(str(normalized_thread_context.get("reply_to_message_id") or "").strip()) + normalized_text = str(message_text or "").strip().lower() target = await self._resolve_pending_draft( sender=sender, source_system=source_system, @@ -65,7 +71,10 @@ class DraftConfirmHandler: thread_context=thread_context, ) if target is None: - if explicit_work_item_id is not None or action != self._ACTION_UNKNOWN or looks_like_revision: + if explicit_work_item_id is not None or ( + has_reply_anchor + and normalized_text in (self._EXPLICIT_CONFIRM_COMMANDS | self._EXPLICIT_CANCEL_COMMANDS) + ): return { "handled": True, "status": "failed_safe", @@ -90,6 +99,46 @@ class DraftConfirmHandler: } target_work_item, target_ingest = target + action = self._ACTION_UNKNOWN + revision_note = "" + active_mounted = await self._resolve_target_mounted_draft( + sender=sender, + source_system=source_system, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + target_work_item_id=int(target_work_item.id), + ) + semantic_followup = ( + { + "action": str((semantic_followup_override or {}).get("action") or "").strip().lower(), + "revision_note": str((semantic_followup_override or {}).get("revision_note") or "").strip(), + "assistant_reply": str((semantic_followup_override or {}).get("assistant_reply") or "").strip(), + } + if isinstance(semantic_followup_override, dict) + else await self._resolve_semantic_followup_decision( + message_text=message_text, + source_system=source_system, + thread_context=thread_context, + active_mounted=active_mounted, + ) + ) + if semantic_followup is not None: + action = str(semantic_followup.get("action") or action).strip().lower() or action + revision_note = str(semantic_followup.get("revision_note") or revision_note).strip() + if action == self._ACTION_ANSWER_ONLY: + return { + "handled": True, + "status": "pending_manual", + "followup_reply": str(semantic_followup.get("assistant_reply") or "").strip() + or self._DEFAULT_DISPLAY_FEEDBACK_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 "")), + } if action == self._ACTION_CONFIRM: return await self.confirm_draft( target_work_item=target_work_item, @@ -112,6 +161,18 @@ class DraftConfirmHandler: target_work_item=target_work_item, reason="用户取消草稿", ) + if explicit_work_item_id is None and not has_reply_anchor: + return { + "handled": False, + "status": "ignored", + "followup_reply": "", + "target_work_item_id": None, + "target_state": None, + "target_intent": None, + "project_id": None, + "signal_id": None, + "workflow_route": None, + } return { "handled": True, "status": "pending_manual", @@ -124,6 +185,131 @@ class DraftConfirmHandler: "workflow_route": RouteResolver.normalize_route_key(str(target_work_item.intent or "")), } + async def _resolve_target_mounted_draft( + self, + *, + sender: str, + source_system: str, + channel_ref: Optional[str], + source_sender_open_id: Optional[str], + thread_context: Optional[dict[str, Any]], + target_work_item_id: int, + ) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]: + if not hasattr(self.db, "execute"): + return None + active_mounted = await self._resolve_active_mounted_draft( + sender=sender, + source_system=source_system, + channel_ref=channel_ref, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + ) + if active_mounted is not None: + return active_mounted + return await self._resolve_mounted_draft_by_work_item_id(work_item_id=int(target_work_item_id)) + + async def _resolve_semantic_followup_decision( + self, + *, + message_text: str, + source_system: str, + thread_context: Optional[dict[str, Any]], + active_mounted: Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]], + ) -> Optional[dict[str, str]]: + if active_mounted is None: + return None + _, mounted_topic = active_mounted + try: + packet = await SemanticContextAssembler(self.db).assemble( + tenant_id=int(self.tenant_id), + operator_user_id=0, + topic_workspace_id=int(mounted_topic.id), + latest_user_message=message_text, + ingress_context={ + "candidate_kind": "draft_followup", + "source_system": source_system, + "reply_anchor_present": bool(self._normalize_thread_context(thread_context)), + "allowed_followup_actions": list(self._SEMANTIC_FOLLOWUP_ACTIONS), + "thread_context": self._normalize_thread_context(thread_context), + }, + ) + decision = await SemanticDecisionService(llm_service).decide(packet=packet) + followup_action = str(getattr(decision, "followup_action", "") or "").strip().lower() + if followup_action not in self._SEMANTIC_FOLLOWUP_ACTIONS: + return None + gate_result = SemanticDecisionGatekeeper.evaluate( + mode_card=packet.mode_card, + decision=decision, + ) + if not bool(gate_result.accepted): + return None + allowed_actions = { + str(item).strip() + for item in (getattr(packet.mode_card, "allowed_actions", None) or []) + if str(item or "").strip() + } + action_code = ( + "draft.followup.answer_only" + if followup_action == self._ACTION_ANSWER_ONLY + else f"draft.followup.{followup_action}" + ) + if action_code not in allowed_actions: + return None + revision_note = str(getattr(decision, "revision_note", "") or "").strip() + assistant_reply = str(getattr(decision, "assistant_reply", "") or "").strip() + except Exception as exc: + logger.warning( + "draft_confirm_semantic_followup_failed", + tenant_id=self.tenant_id, + topic_workspace_id=_safe_int(getattr(mounted_topic, "id", None)), + error=str(exc), + ) + return None + + if followup_action == self._ACTION_REVISE and not revision_note: + return { + "action": self._ACTION_REVISE, + "revision_note": "", + "assistant_reply": assistant_reply, + } + if followup_action == self._ACTION_ANSWER_ONLY and not assistant_reply: + assistant_reply = self._DEFAULT_DISPLAY_FEEDBACK_REPLY + return { + "action": followup_action, + "revision_note": revision_note, + "assistant_reply": assistant_reply, + } + + async def _resolve_mounted_draft_by_work_item_id( + self, + *, + work_item_id: int, + ) -> Optional[tuple[MountedDraftWorkspace, MountedTopicWorkspace]]: + if not hasattr(self.db, "execute"): + return None + row = ( + await self.db.execute( + select(MountedDraftWorkspace, MountedTopicWorkspace) + .join( + MountedTopicWorkspace, + MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id, + ) + .where( + and_( + MountedDraftWorkspace.tenant_id == int(self.tenant_id), + MountedTopicWorkspace.tenant_id == int(self.tenant_id), + MountedDraftWorkspace.active_work_item_id == int(work_item_id), + MountedTopicWorkspace.status == "active", + ) + ) + .order_by(MountedDraftWorkspace.id.desc()) + .limit(1) + ) + ).first() + if row is None: + return None + return row + async def handle_api_decision( self, *, @@ -523,13 +709,20 @@ class DraftConfirmHandler: 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, - ) + if self._is_requirement_plan_draft_payload(locked.draft_content): + generated = await self._revise_requirement_plan_draft( + locked=locked, + target_ingest=target_ingest, + revision_note=revision_note, + ) + else: + 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(getattr(locked, "context_snapshot", None), dict) else {}, + revision_note=revision_note, + ) locked.state = STATE_DRAFT_PENDING_CONFIRM locked.reply_template_key = "draft_pending_confirm" await self.db.flush() @@ -553,6 +746,73 @@ class DraftConfirmHandler: "draft_content": locked.draft_content if isinstance(locked.draft_content, dict) else None, } + @staticmethod + def _is_requirement_plan_draft_payload(draft: Any) -> bool: + return isinstance(draft, dict) and str(draft.get("draft_source") or "").strip() == "requirement_plan" + + async def _revise_requirement_plan_draft( + self, + *, + locked: WorkItem, + target_ingest: IngestEvent, + revision_note: str, + ) -> dict[str, Any]: + draft_payload = locked.draft_content if isinstance(locked.draft_content, dict) else {} + base_card = draft_payload.get("requirement_card") if isinstance(draft_payload.get("requirement_card"), dict) else {} + if not base_card: + raise RuntimeError("requirement_plan_draft_missing_card") + + runtime = _RequirementPlanDraftRuntime(self.db, tenant_id=int(self.tenant_id)) + merged_card = dict(base_card) + merged_card.update(runtime._extract_requirement_plan_updates(message_text=revision_note, card=merged_card)) + if not str(merged_card.get("title") or "").strip(): + merged_card["title"] = runtime._derive_requirement_plan_title(message_text=revision_note, card=merged_card) + if not str(merged_card.get("goal") or "").strip(): + merged_card["goal"] = runtime._derive_requirement_plan_goal(message_text=revision_note, card=merged_card) + + rendered_text, _, project_creation_draft = await runtime._generate_requirement_plan_confirmation_draft( + card=merged_card, + revision_note=revision_note, + ) + next_version = int(locked.draft_version or 0) + 1 + locked.draft_content = dict(project_creation_draft) + locked.draft_version = next_version + locked.draft_expires_at = _utc_now_naive() + timedelta(hours=24) + locked.execution_result = { + **(locked.execution_result if isinstance(locked.execution_result, dict) else {}), + "status": "draft_pending_confirm", + "draft": dict(project_creation_draft), + "draft_version": next_version, + "draft_expires_at": locked.draft_expires_at.isoformat() if locked.draft_expires_at else None, + "revision_note": str(revision_note or "")[:500] or None, + } + + mounted_draft = ( + await self.db.execute( + select(MountedDraftWorkspace) + .where( + and_( + MountedDraftWorkspace.tenant_id == int(self.tenant_id), + MountedDraftWorkspace.active_work_item_id == int(locked.id), + ) + ) + .order_by(MountedDraftWorkspace.id.desc()) + .limit(1) + ) + ).scalar_one_or_none() + if mounted_draft is not None: + mounted_draft.state = "ready" + mounted_draft.draft_version = next_version + mounted_draft.requirement_card = dict(merged_card) + mounted_draft.draft_payload = dict(project_creation_draft) + mounted_draft.last_ready_at = _utc_now_naive() + + return { + "draft": dict(project_creation_draft), + "draft_text": rendered_text, + "draft_version": next_version, + } + async def cancel_draft( self, *, @@ -963,13 +1223,6 @@ class DraftConfirmHandler: return None return _safe_int(matched.group(1)) - @classmethod - def _extract_revision_note(cls, text: str) -> str: - action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=text) - if action != cls._ACTION_REVISE: - return "" - return str(note or "")[:500] - @staticmethod def _safe_float(value: Any) -> Optional[float]: try: @@ -1134,6 +1387,21 @@ class DraftConfirmHandler: if body: requirement_lines.append(f"{prefix}{body}") _append_list_section("需求清单", requirement_lines) + external_signal_sources = ( + draft.get("external_signal_sources") if isinstance(draft.get("external_signal_sources"), list) else [] + ) + external_signal_lines: list[str] = [] + for item in external_signal_sources[:5]: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + purpose = str(item.get("purpose") or "").strip() + phase = str(item.get("phase") or "").strip() + if name and purpose and phase: + external_signal_lines.append(f"{name}:{purpose}({phase})") + elif name and purpose: + external_signal_lines.append(f"{name}:{purpose}") + _append_list_section("外部信号源", external_signal_lines) _append_list_section( "待确认信息", @@ -1147,7 +1415,7 @@ class DraftConfirmHandler: return "\n".join(lines).strip() @staticmethod - def _looks_like_project_creation_reextract_failure(error: Exception) -> bool: + def _is_project_creation_reextract_failure(error: Exception) -> bool: raw = str(error or "").strip() normalized = re.sub(r"\s+", " ", raw).lower() return any( @@ -1264,7 +1532,7 @@ class DraftConfirmHandler: return False @classmethod - def _looks_like_rich_project_creation_draft(cls, draft: dict[str, Any]) -> bool: + def _is_rich_project_creation_draft(cls, draft: dict[str, Any]) -> bool: if not isinstance(draft, dict): return False title = str(draft.get("title") or draft.get("project_title") or "").strip() @@ -1404,7 +1672,7 @@ class DraftConfirmHandler: assignee_id = _safe_int(draft.get("recommended_assignee_id")) if assignee_id is not None and assignee_id > 0: requested_member_user_ids = [int(assignee_id)] - if self._looks_like_rich_project_creation_draft(draft): + if self._is_rich_project_creation_draft(draft): logger.info( "draft_confirm_project_creation_use_structured_draft_directly", tenant_id=int(self.tenant_id), @@ -1426,7 +1694,7 @@ class DraftConfirmHandler: requested_member_user_ids=requested_member_user_ids or None, ) except Exception as exc: - if not self._looks_like_project_creation_reextract_failure(exc): + if not self._is_project_creation_reextract_failure(exc): raise logger.warning( "draft_confirm_project_creation_reextract_failed_fallback", @@ -1565,6 +1833,12 @@ class DraftConfirmHandler: message_text = summary return signal + +class _RequirementPlanDraftRuntime(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self, db: Any, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + @staticmethod def _build_execution_route(*, work_item: WorkItem) -> WorkflowRoute: normalized_key = RouteResolver.normalize_route_key(str(work_item.intent or "")) diff --git a/backend/app/services/ai_pmo/semantic_context/assembler.py b/backend/app/services/ai_pmo/semantic_context/assembler.py index cd3c251a..089d2c17 100644 --- a/backend/app/services/ai_pmo/semantic_context/assembler.py +++ b/backend/app/services/ai_pmo/semantic_context/assembler.py @@ -9,6 +9,7 @@ from ....models import MountedConversationRun, MountedDraftWorkspace, MountedTop from .mode_card_service import build_mode_card from .packet_schema import ( SemanticContextDraftSnapshot, + SemanticContextIngress, SemanticContextPacket, SemanticContextThread, SemanticContextTopic, @@ -109,6 +110,7 @@ class SemanticContextAssembler: operator_user_id: int, topic_workspace_id: int, latest_user_message: str, + ingress_context: Optional[dict[str, Any]] = None, ) -> SemanticContextPacket: topic_row = await self._load_topic( tenant_id=tenant_id, @@ -162,6 +164,44 @@ class SemanticContextAssembler: recent_turns=recent_turns, open_questions=self._normalize_list(topic_row.open_questions), ) + mode_card = build_mode_card( + topic_mode=topic_row.mode, + plan_status=topic_row.plan_status, + ) + ingress = None + if isinstance(ingress_context, dict): + ingress = SemanticContextIngress( + candidate_kind=str(ingress_context.get("candidate_kind") or "").strip() or None, + source_system=str(ingress_context.get("source_system") or "").strip() or None, + reply_anchor_present=bool(ingress_context.get("reply_anchor_present")), + active_context_kind=str(ingress_context.get("active_context_kind") or "").strip() or None, + allowed_followup_actions=self._normalize_list(ingress_context.get("allowed_followup_actions")), + allowed_context_scopes=self._normalize_list(ingress_context.get("allowed_context_scopes")), + thread_context=self._normalize_mapping(ingress_context.get("thread_context")), + ) + if ingress.candidate_kind == "draft_followup": + merged_allowed = list(mode_card.allowed_actions) + for action in ( + "draft.followup.confirm", + "draft.followup.revise", + "draft.followup.cancel", + "draft.followup.answer_only", + ): + if action not in merged_allowed: + merged_allowed.append(action) + mode_card.allowed_actions = merged_allowed + if ingress.candidate_kind == "active_context_followup": + merged_allowed = list(mode_card.allowed_actions) + for action in ( + "context.followup.current_requirement", + "context.followup.current_draft", + "context.followup.independent_workflow", + "context.followup.clarify", + ): + if action not in merged_allowed: + merged_allowed.append(action) + mode_card.allowed_actions = merged_allowed + return SemanticContextPacket( packet_version="v1", tenant_id=int(tenant_id), @@ -171,8 +211,6 @@ class SemanticContextAssembler: topic=topic, draft=draft, thread=thread, - mode_card=build_mode_card( - topic_mode=topic_row.mode, - plan_status=topic_row.plan_status, - ), + mode_card=mode_card, + ingress=ingress, ) diff --git a/backend/app/services/ai_pmo/semantic_context/decision_schema.py b/backend/app/services/ai_pmo/semantic_context/decision_schema.py index 8b4124e5..f377d7b4 100644 --- a/backend/app/services/ai_pmo/semantic_context/decision_schema.py +++ b/backend/app/services/ai_pmo/semantic_context/decision_schema.py @@ -15,12 +15,38 @@ class ModeCard(BaseModel): exit_conditions: list[str] = Field(default_factory=list) +class DecisionWorkflowPlan(BaseModel): + model_config = ConfigDict(extra="ignore") + + workflow_type: Literal[ + "query_flow", + "new_project_flow", + "project_flow", + "task_flow", + "signal_source_flow", + "project_signal_flow", + "followup_flow", + ] + workflow_action: str + target_scope: Literal["global", "project", "thread"] = "global" + operation_codes: list[str] = Field(default_factory=list) + requires_confirmation: bool = False + confidence: float = 0.0 + clarification_needed: bool = False + metadata: dict[str, Any] = Field(default_factory=dict) + + class SemanticDecision(BaseModel): model_config = ConfigDict(extra="ignore") topic_relation: Literal["continue_current_topic", "new_topic", "uncertain"] mode_decision: Literal["stay_plan", "ready_confirm", "request_execute", "answer_only"] + followup_scope: Literal["none", "current_requirement", "current_draft", "independent_workflow", "clarify"] = "none" + followup_action: Literal["none", "confirm", "revise", "cancel", "answer_only"] = "none" + revision_note: str = "" draft_patch: dict[str, Any] = Field(default_factory=dict) + workflow_plans: list[DecisionWorkflowPlan] = Field(default_factory=list) + scene_metadata: dict[str, Any] = Field(default_factory=dict) assistant_reply: str = "" requires_confirmation: bool = False requested_actions: list[str] = Field(default_factory=list) diff --git a/backend/app/services/ai_pmo/semantic_context/packet_schema.py b/backend/app/services/ai_pmo/semantic_context/packet_schema.py index e5b04691..093475e2 100644 --- a/backend/app/services/ai_pmo/semantic_context/packet_schema.py +++ b/backend/app/services/ai_pmo/semantic_context/packet_schema.py @@ -53,6 +53,18 @@ class SemanticContextDraftSnapshot(BaseModel): required_confirmations: list[str] = Field(default_factory=list) +class SemanticContextIngress(BaseModel): + model_config = ConfigDict(extra="ignore") + + candidate_kind: Optional[str] = None + source_system: Optional[str] = None + reply_anchor_present: bool = False + active_context_kind: Optional[str] = None + allowed_followup_actions: list[str] = Field(default_factory=list) + allowed_context_scopes: list[str] = Field(default_factory=list) + thread_context: Optional[Any] = None + + class SemanticContextPacket(BaseModel): model_config = ConfigDict(extra="ignore") @@ -65,3 +77,4 @@ class SemanticContextPacket(BaseModel): draft: SemanticContextDraftSnapshot | None = None thread: SemanticContextThread mode_card: ModeCard + ingress: SemanticContextIngress | None = None diff --git a/backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py b/backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py index 0b575939..f8c5db31 100644 --- a/backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py +++ b/backend/app/services/ai_pmo/semantic_context/semantic_decision_service.py @@ -17,8 +17,9 @@ class SemanticDecisionService: @staticmethod def build_prompt(packet: SemanticContextPacket) -> str: - return PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT.format( - semantic_packet=json.dumps(packet.model_dump(mode="json"), ensure_ascii=False, indent=2) + return PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT.replace( + "{semantic_packet}", + json.dumps(packet.model_dump(mode="json"), ensure_ascii=False, indent=2), ) async def decide(self, *, packet: SemanticContextPacket) -> SemanticDecision: diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py b/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py index c2f14b1c..cae063de 100644 --- a/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py +++ b/backend/app/services/ai_pmo/workflow_semantic_planner/constants.py @@ -1,622 +1,13 @@ from __future__ import annotations -import re - SEMANTIC_VERSION = "2026-03-27.workflow-v1" -INTENT_QUERY = "query.read_only" -INTENT_PROJECT_CREATE = "project.create" -INTENT_PROJECT_UPDATE = "project.update" -INTENT_PROJECT_ARCHIVE = "project.archive" -INTENT_PROJECT_REOPEN = "project.reopen" -INTENT_PROJECT_DELETE = "project.delete" -INTENT_TASK_CREATE = "task.create" -INTENT_TASK_UPDATE = "task.update" -INTENT_TASK_ASSIGN = "task.assign" -INTENT_TASK_PRIORITIZE = "task.prioritize" -INTENT_TASK_STATUS_TRANSITION = "task.status_transition" -INTENT_TASK_DELETE = "task.delete" -INTENT_SIGNAL_CREATE = "signal.create" -INTENT_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" -INTENT_FOLLOWUP_CONFIRM = "followup.confirm" -INTENT_FOLLOWUP_CONTINUE = "followup.continue" -INTENT_IGNORE = "ignore" - -ROUTE_QUERY_ANSWER = "query.answer" -ROUTE_PROJECT_CREATE = "project_create.create" -ROUTE_TASK_CREATE = "task.create" -ROUTE_SIGNAL_SOURCE_ONBOARD = "signal_source.onboard" -ROUTE_PROJECT_SIGNAL_CREATE = "project_signal.create" -ROUTE_PROJECT_SIGNAL_DISCOVER = "project_signal.discover" -ROUTE_PROJECT_SIGNAL_COMMIT = "project_signal.commit" -ROUTE_PROJECT_SIGNAL_CANCEL = "project_signal.cancel" -ROUTE_PROJECT_SIGNAL_REFRESH = "project_signal.refresh" -ROUTE_FOLLOWUP_CONFIRM = "followup.confirm" -ROUTE_FOLLOWUP_CONTINUE = "followup.continue" - -WORKFLOW_QUERY = "query_flow" -WORKFLOW_NEW_PROJECT = "new_project_flow" -WORKFLOW_PROJECT = "project_flow" -WORKFLOW_TASK = "task_flow" -WORKFLOW_PROJECT_SIGNAL = "project_signal_flow" -WORKFLOW_SIGNAL_SOURCE = "signal_source_flow" -WORKFLOW_FOLLOWUP = "followup_flow" - QUERY_SCENE_BUSINESS_REVIEW = "business_review" QUERY_SCENE_CORRECTION_ESCALATION = "correction_escalation" QUERY_SCENE_REQUIREMENT_PLAN = "requirement_plan" MESSAGE_GATE_AUTO = "auto_execute" MESSAGE_GATE_PENDING = "pending_confirmation" -MESSAGE_GATE_FAILED_SAFE = "failed_safe" REPLY_MODE_CARD = "aggregate_card" REPLY_MODE_TEXT = "aggregate_text" - -BACKGROUND_MARKERS = ("因为", "所以", "用来", "为了", "方便", "希望", "背景", "场景") -MULTI_SEGMENT_MARKERS = (",", ",", "、", ";", ";", "并且", "同时", "然后") - -INTENT_GROUP_KEYWORDS: dict[str, tuple[str, ...]] = { - "project": ("项目", "立项", "创建项目", "新增项目", "新建项目"), - "signal": ("信号", "信号源", "接入", "onboard", "source"), - "task": ("任务", "分配", "指派", "执行", "部署"), - "change": ("修改", "更新", "改为", "改成", "撤销", "回滚", "归档"), -} - -CORRECTION_ACTION_PATTERNS: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("undo_last", ("撤销", "撤回", "undo", "作废上一步", "取消上一步")), - ("rollback", ("回滚", "恢复到", "还原", "回退")), - ("replace_target", ("改为", "改成", "替换为", "不是", "不对", "纠正")), - ("reroute", ("重新识别", "重新判断", "重新路由", "再判断一次")), -) - -TRANSPORT_MODE_CORRECTION_HINTS = ( - "不是", - "不再", - "改为", - "改成", - "替换为", - "取代", - "现在都", - "现在是", -) - -CONTEXTUAL_CONFIRM_HINTS = ( - "来吧", - "那就", - "就这么办", - "就按这个", - "可以", - "好的", - "不错", - "开始吧", - "行", - "没问题", - "同意", - "确认", -) -CONTEXTUAL_DEICTIC_HINTS = ( - "这个", - "这个项目", - "这条", - "它", - "按这个", - "照这个", -) -CONTEXTUAL_WRITE_ACTION_HINTS = ( - "创建", - "新建", - "新增", - "增加", - "添加", - "执行", - "开始", - "继续处理", - "继续执行", - "推进", - "落地", -) - -BUSINESS_REVIEW_ANALYSIS_HINTS = ( - "分析", - "分析下", - "怎么做", - "如何做", - "方案", - "规划", - "梳理", - "评审", - "mvp", - "首期交付", - "需求", - "要做一个", - "想做一个", - "给我需求对应的mvp", - "我需要对应的需求mvp", - "需求mvp", - "对应的mvp", -) -BUSINESS_REVIEW_TARGET_HINTS = ( - "平台", - "系统", - "产品", - "业务", - "api", - "llm", - "模型", - "聚合", - "需求", - "mvp", - "方案", -) -ASSIGNEE_RECOMMENDATION_HINTS = ( - "分配给谁", - "谁做比较合适", - "谁更合适", - "推荐负责人", - "负责人怎么定", - "谁来负责", - "给谁做合适", -) -REQUIREMENT_PLAN_CHAT_HINTS = ( - "聊聊需求", - "聊需求", - "先聊需求", - "想聊需求", - "想跟你聊聊需求", - "一起聊需求", - "讨论需求", - "先不创建", - "先别创建", - "先不要创建", - "先不要直接创建", - "别直接创建", - "先不入库", - "先别入库", - "先不要入库", -) -REQUIREMENT_TOPIC_SWITCH_NEW_HINTS = ( - "聊个新需求", - "聊一个新需求", - "聊聊新需求", - "有个新需求", - "这是个新需求", - "这是新需求", - "这次是新需求", - "现在是新需求", - "换个新需求", - "换一个新需求", - "切到新需求", - "切个新需求", - "另一个需求", - "换个需求", - "换一个需求", - "新的需求", - "不是这个需求", - "不是刚才那个需求", - "别沿用刚才那版", - "不要沿用刚才那版", - "另起一个需求", - "重新开一个需求", - "单开一个需求", - "跟上一个无关", - "和上一个无关", - "不是延续刚才那个", - "不是接着刚才那个", -) -REQUIREMENT_TOPIC_SWITCH_NEW_PATTERNS = ( - re.compile(r"(?:聊|说|讨论|讲).{0,6}(?:个|一个)?新需求", re.IGNORECASE), - re.compile(r"(?:这是|这次是|现在是|我有|这边有|还有|另外有).{0,8}(?:个|一个)?新需求", re.IGNORECASE), - re.compile(r"(?:切|换|转).{0,4}(?:到|个|一个)?(?:新需求|新的需求|新话题|newtopic|topic)", re.IGNORECASE), - re.compile(r"(?:另起|单开|重新开|新开|开个新|拉个新|起个新).{0,6}(?:需求|话题|topic)", re.IGNORECASE), - re.compile(r"(?:跟上一个无关|和上一个无关|不是延续刚才那个|不是接着刚才那个)", re.IGNORECASE), -) -CORRECTION_ESCALATION_HINTS = ( - "重新回答", - "重答", - "调用高级模型", - "高级模型", - "更强模型", - "用更强模型", - "高配模型", -) - -NON_REQUIREMENT_EXACT = { - "收到", - "收到啦", - "好的", - "ok", - "okk", - "okay", - "yes", - "no", - "1", - "2", - "3", - "thanks", - "thx", - "赞", - "辛苦", - "你是谁", - "你是誰", - "你是谁?", - "你是誰?", - "说话", - "在吗", - "在不在", - "今天什么日期", - "今天几号", - "今天星期几", - "现在几点", - "当前日期", -} -NON_REQUIREMENT_CONTAINS = ( - "你是谁", - "什么身份", - "身份确认", - "今天什么日期", - "当前日期", - "日期疑问", - "询问日期", - "现在几点", - "几点了", - "今天周几", - "今天星期几", - "说话", - "在吗", - "在不在", -) -REQUIREMENT_HINTS = ( - "需求", - "优化", - "改一下", - "调整", - "修复", - "bug", - "故障", - "报错", - "项目", - "页面", - "接口", - "功能", - "排版", - "样式", - "任务", - "立项", - "上线", - "配置", -) -LOW_VALUE_CLOSURE_HINTS = ("缺少项目归属", "必须先确认项目", "无法创建", "不能创建") - -GENERIC_SIGNAL_SOURCE_MESSAGE_RE = re.compile( - r"^(?:请)?(?:按|按照)?(?:这个|上述|这条)?(?:分步|步骤)?(?:骤)?(?:把|将)?(?:它|该需求|这条需求)?(?:加入|接入|录入).{0,24}信号源(?:中)?$", - re.IGNORECASE, -) - -SOURCE_ONBOARDING_ACTION_RE = re.compile( - r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)", - re.IGNORECASE, -) - -MODULE_KEYWORD_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("用户管理与权限", ("用户管理", "角色权限", "crud", "批量导入", "成员管理")), - ("技能图谱", ("技能图谱", "技能标签", "能力图谱", "雷达图")), - ("工作量管理", ("工作量", "负载", "产能", "workload")), - ("SSTC 工作流", ("sstc", "charter", "signal", "task", "execution", "release", "review", "工作流")), - ("AI 能力编排", ("ai能力", "智能", "自动化", "推荐", "编排", "agent", "llm")), - ("信号源接入", ("信号源", "source", "onboard", "接入")), - ("飞书集成", ("飞书", "feishu", "群聊", "机器人")), - ("消息台与详情页", ("消息台", "详情页", "展开详情", "摘要", "时间戳", "按钮")), -) - -POLICY_GATE_RULES: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("delete_or_archive", ("删除项目", "删除信号", "归档项目", "归档", "archive", "清空项目")), - ("budget_overrun", ("超预算", "突破预算", "额外预算", "加预算")), - ("cross_project_staffing", ("跨项目抢人", "抽调", "抢占关键人力", "挪人")), - ("charter_rewrite", ("改写charter", "success criteria", "成功标准改", "范围重大变更")), - ("critical_path_change", ("关键路径", "里程碑改动", "影响多个项目", "跨项目依赖调整")), -) - -PROJECT_SIGNAL_DISCOVER_HINTS = ( - "候选", - "先分析", - "先看候选", - "给我确认", - "待确认", - "再让我确认", - "确认后入库", - "先不要入库", - "先别入库", -) -FOLLOWUP_REFRESH_HINTS = ("换一批", "再来一批", "刷新候选", "重新找", "再找一组", "换一组") -FOLLOWUP_CANCEL_HINTS = ("取消", "先不做", "先别做", "不要了", "算了", "reject", "驳回") -FOLLOWUP_COMMIT_HINTS = ("确认", "继续处理", "继续执行", "同意", "批准", "approve") -FOLLOWUP_CONFIRM_HINTS = ("确认归属", "确认项目", "确认到项目", "确认关联", "确认这条") -FOLLOWUP_CONTINUE_HINTS = ("继续处理", "继续执行", "接着处理", "往下做", "继续推进") -DRAFT_CONFIRM_EXACT_HINTS = ("确认", "好的", "就这样", "ok", "okay", "同意", "没问题", "y", "yes") -DRAFT_REVISION_PREFIX_HINTS = ("修改", "改成", "改为", "换成", "改") -DRAFT_NATURAL_REVISION_HINTS = ( - "第一版", - "首期", - "先做", - "先别做", - "只做", - "主要给", - "给运营", - "给团队", - "目标客户", - "用户", - "痛点", - "麻烦", - "能力", - "功能", - "数据", - "来源", - "信号源", - "团队", - "角色", - "负责人", - "技能", - "指标", - "目标", - "先接", - "补充", - "加上", - "加个", - "再加", - "再补", -) -FOLLOWUP_WORK_ITEM_ID_RE = re.compile(r"(?:work[_ -]?item|工作项|队列项|草稿)\s*(?:id|ID)?\s*[:#:]?\s*(\d+)", re.IGNORECASE) -FOLLOWUP_CONTINUE_RE = re.compile(r"(继续处理|继续推进|继续执行|接着处理|继续跑)", re.IGNORECASE) -FOLLOWUP_CREATE_CONTINUE_RE = re.compile(r"(继续创建|接着创建|继续建|接着建)", re.IGNORECASE) -FOLLOWUP_STATUS_RE = re.compile( - r"(进度|状态|处理到哪|处理了吗|现在怎么样|怎么样了|结果呢|是否已经创建|已经创建了吗|创建了吗|创建成功了吗|建好了吗|建成了吗)", - re.IGNORECASE, -) -FOLLOWUP_REFERENCE_RE = re.compile( - r"(刚才|刚刚|前面|之前|上次|上一条|我刚才要求|我刚才说|我说的|那个)", - re.IGNORECASE, -) - -QUESTION_HINTS = ( - "?", - "?", - "怎么", - "如何", - "为何", - "为什么", - "什么", - "是什么", - "哪些", - "有哪些", - "特点", - "技术特点", - "能力", - "介绍", - "说明", - "多少", - "是否", - "帮我看", - "状态", - "风险", - "能做什么", - "可以做什么", -) -OBVIOUS_QUESTION_RE = re.compile( - r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|什么|哪些|有哪些|在哪|哪一个|哪几个|特点|能力|介绍|说明|风险)", - re.IGNORECASE, -) -QUERY_INTENT_RE = re.compile( - r"(哪些|有哪些|特点|技术特点|能力|介绍|说明|怎么|如何|是什么|什么|能做什么|可以做什么|项目有哪些风险)", - re.IGNORECASE, -) -SMALLTALK_EXACT = { - "收到", - "收到啦", - "好的", - "ok", - "okk", - "yes", - "no", - "谢谢", - "thanks", - "thx", - "在吗", - "在不在", - "怎么样了", - "咋样了", - "辛苦了", -} -SMALLTALK_SHORT_HINTS = ("怎么样了", "咋样了", "在吗", "辛苦了", "收到", "谢谢", "ok") -SMALLTALK_STRUCTURED_HINTS = ("项目", "信号", "任务", "风险", "进度", "状态", "work_item", "工作项", "#") -FRONT_NOISE_HINTS = SMALLTALK_SHORT_HINTS -FRONT_NOISE_STRUCTURED_HINTS = SMALLTALK_STRUCTURED_HINTS - -PROJECT_INTENT_RE = re.compile(r"(立项|新项目|我们要做|需要开发|启动|规划)", re.IGNORECASE) -SIGNAL_FACT_RE = re.compile(r"(竞品|上线|发布|反馈|风险|bug|故障|告警|投诉)", re.IGNORECASE) -TIMELINE_RE = re.compile(r"(\d+\s*(天|周|月)|本周|本月|月底|下周|下月|Q[1-4]|\d{1,2}月\d{1,2}日)") -OWNER_RE = re.compile(r"(由|负责人|owner|owner:|@)(\S+)") -INDEPENDENT_REQUIREMENT_RE = re.compile( - r"(独立需求|单独需求|单开|另起|新增需求|新建项目|新增项目|单独立项|开个新|做个新)", - re.IGNORECASE, -) -SIMPLE_SIGNAL_REQUEST_RE = re.compile( - r"(新增|增加|加一个|加个|补充|支持|实现|接入|优化|修复|改进).{0,18}(功能|能力|接口|页面|按钮|流程|识别|提醒|校验|报表|字段|规则|模块)", - re.IGNORECASE, -) -PROJECT_COMPLEXITY_RE = re.compile( - r"(立项|新项目|项目章程|里程碑|预算|排期|roadmap|跨团队|跨部门|季度目标|年度目标|okr)", - re.IGNORECASE, -) -QUESTION_REQUIREMENT_VERB_RE = re.compile( - r"(新增|增加|加一个|加个|支持|接入|实现|优化|修复|改进|调整|补充|完善|同步|对齐|落库|入库)", - re.IGNORECASE, -) -QUESTION_REQUIREMENT_OBJECT_RE = re.compile( - r"(功能|能力|接口|页面|按钮|流程|校验|报表|字段|规则|模块|任务|项目|需求|通知|提醒|配置|归属|队列|信号)", - re.IGNORECASE, -) - -SIGNAL_CREATE_HINTS = ("新增一个信号", "新增一条信号", "增加一个信号", "录入一个信号", "关注", "跟踪") -TASK_CREATE_HINTS = ( - "创建任务", - "新建任务", - "新增任务", - "增加任务", - "添加任务", - "加个任务", - "加一个任务", - "安排任务", - "分配任务", - "派个任务", -) -PROJECT_CREATE_HINTS = ( - "创建项目", - "新建项目", - "建立项目", - "做个项目", - "立项", - "开个项目", - "新增项目", - "新增个项目", - "新增一个项目", - "增加项目", - "增加个项目", - "增加一个项目", - "添加项目", - "添加一个项目", - "加个项目", - "加一个项目", -) -NATURAL_PROJECT_CREATE_HINTS = ( - "想做个", - "想做一个", - "想弄个", - "想弄一个", - "想搞个", - "想搞一个", - "做个工具", - "做一个工具", - "做个小工具", - "做一个小工具", - "做个助手", - "做一个助手", - "做个系统", - "做一个系统", - "做个平台", - "做一个平台", - "做个产品", - "做一个产品", - "搞个工具", - "搞一个工具", - "弄个工具", - "弄一个工具", - "给运营用的小工具", - "给团队用的小工具", - "给销售用的小工具", - "希望做个", - "打算做个", - "准备做个", -) -PROJECT_CREATE_QUERY_GUARD_HINTS = ( - "先帮我分析", - "先帮我梳理", - "帮我分析", - "分析一下", - "分析下", - "先分析", - "怎么做", - "如何做", - "先聊方案", - "给我mvp", - "先给我mvp", - "先梳理一期", -) -PROJECT_CREATE_DEFERRED_CONFIRM_HINTS = ( - "先别直接创建", - "先别创建项目", - "先别建项目", - "不要直接创建", - "先不要直接创建", - "先不要创建", - "先别入库", - "最后确认再入库", - "补充几轮再确认", - "等我确认再入库", - "我再补充几轮", - "我还在补范围", - "还在补范围", - "范围还没定", -) -SHORT_CONFIRM_HINTS = ( - "是", - "好", - "对", - "确认", - "新增", - "加", - "嗯", - "yes", - "ok", - "好的", - "是的", - "行", - "可以", - "没问题", -) -HEALTHCHECK_RE = re.compile( - r"^(?:ping|pong|health(?:check)?|在线吗|活着吗|存活吗|还在吗|服务正常吗)\??$", - re.IGNORECASE, -) -SHORT_CONFIRM_RE = re.compile( - r"^(?:收到|好的|好|ok|okay|明白|了解|知道了|收到啦|嗯嗯|行|辛苦了|thanks?|thx)[!!.。]?$", - re.IGNORECASE, -) - -PROJECT_CREATE_REGEX = re.compile( - r"(?:新增|增加|添加|新建|创建|建立|开个|开一个|建个|建一个|做个|做一个|加个|加一个|立项)(?:一个|个)?(?:[^,。!?,.!?]{0,24})项目", - re.IGNORECASE, -) -NATURAL_PROJECT_CREATE_REGEX = re.compile( - r"(?:想|想要|希望|打算|准备).{0,8}(?:做|弄|搞|整).{0,8}(?:个|一个)?.{0,20}(?:工具|小工具|助手|系统|平台|产品|应用|服务)", - re.IGNORECASE, -) -TASK_CREATE_REGEX = re.compile( - r"(?:给|帮|请).{0,24}(?:新增|增加|添加|创建|新建|加).{0,16}(?:任务|工单|待办)", - re.IGNORECASE, -) -SIGNAL_CREATE_REGEX = re.compile( - r"(?:给|帮|请).{0,20}(?:新增|增加|添加|补充|加).{0,20}(?:信号|需求|事项)", - re.IGNORECASE, -) -PROJECT_LIST_QUERY_RE = re.compile( - r"(现在|当前|目前|系统里|系统中|项目里|项目中)?.{0,4}(有哪些项目|项目列表|项目清单|哪(些|几个)项目)", - re.IGNORECASE, -) -PROJECT_ID_QUERY_RE = re.compile( - r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)", - re.IGNORECASE, -) -LEGACY_COMMAND_PROJECT_CREATE_RE = re.compile( - r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目", - re.IGNORECASE, -) -LEGACY_COMMAND_VERB_RE = re.compile( - r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)", - re.IGNORECASE, -) -LEGACY_COMMAND_OBJECT_RE = re.compile( - r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)", - re.IGNORECASE, -) -CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE) -CORRECTION_CUE_RE = re.compile( - r"(不对|错了|理解错|不是这个|不是这个意思|我说的是|我要的是|不是啊|不是新增 md|不是写 markdown)", - re.IGNORECASE, -) -EXTERNAL_INTEGRATION_RE = re.compile( - r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)", - re.IGNORECASE, -) - -__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py index c6a369fe..388d0470 100644 --- a/backend/app/services/ai_pmo/workflow_semantic_planner/service.py +++ b/backend/app/services/ai_pmo/workflow_semantic_planner/service.py @@ -1,751 +1,201 @@ from __future__ import annotations +import asyncio import hashlib import re from typing import Any, Iterable, Optional +from ..semantic_context.decision_schema import DecisionWorkflowPlan, ModeCard, SemanticDecision +from ..semantic_context.packet_schema import ( + SemanticContextIngress, + SemanticContextPacket, + SemanticContextThread, + SemanticContextTopic, +) +from ..semantic_context.semantic_decision_service import SemanticDecisionService +from ...llm_service.service import LLMService from .constants import ( - ASSIGNEE_RECOMMENDATION_HINTS, - BACKGROUND_MARKERS, - BUSINESS_REVIEW_ANALYSIS_HINTS, - BUSINESS_REVIEW_TARGET_HINTS, - CONTEXTUAL_CONFIRM_HINTS, - CONTEXTUAL_DEICTIC_HINTS, - CONTEXTUAL_WRITE_ACTION_HINTS, - CORRECTION_ESCALATION_HINTS, - CREATE_ACTION_RE, - DRAFT_CONFIRM_EXACT_HINTS, - DRAFT_NATURAL_REVISION_HINTS, - DRAFT_REVISION_PREFIX_HINTS, - EXTERNAL_INTEGRATION_RE, - FOLLOWUP_CANCEL_HINTS, - FOLLOWUP_CREATE_CONTINUE_RE, - FOLLOWUP_COMMIT_HINTS, - FOLLOWUP_CONFIRM_HINTS, - FOLLOWUP_CONTINUE_HINTS, - FOLLOWUP_CONTINUE_RE, - FOLLOWUP_REFERENCE_RE, - FOLLOWUP_REFRESH_HINTS, - FOLLOWUP_STATUS_RE, - FOLLOWUP_WORK_ITEM_ID_RE, - GENERIC_SIGNAL_SOURCE_MESSAGE_RE, - HEALTHCHECK_RE, - INTENT_FOLLOWUP_CONFIRM, - INTENT_FOLLOWUP_CONTINUE, - INTENT_IGNORE, - INTENT_PROJECT_ARCHIVE, - INTENT_PROJECT_CREATE, - INTENT_PROJECT_DELETE, - INTENT_PROJECT_REOPEN, - INTENT_PROJECT_UPDATE, - INTENT_QUERY, - INTENT_SIGNAL_CREATE, - INTENT_SIGNAL_SOURCE_ONBOARD, - INTENT_TASK_ASSIGN, - INTENT_TASK_CREATE, - INTENT_TASK_DELETE, - INTENT_TASK_PRIORITIZE, - INTENT_TASK_STATUS_TRANSITION, - INTENT_TASK_UPDATE, MESSAGE_GATE_AUTO, MESSAGE_GATE_PENDING, - MULTI_SEGMENT_MARKERS, - NATURAL_PROJECT_CREATE_HINTS, - NATURAL_PROJECT_CREATE_REGEX, - PROJECT_CREATE_DEFERRED_CONFIRM_HINTS, - PROJECT_CREATE_HINTS, - PROJECT_CREATE_QUERY_GUARD_HINTS, - PROJECT_CREATE_REGEX, - PROJECT_ID_QUERY_RE, - PROJECT_LIST_QUERY_RE, - PROJECT_SIGNAL_DISCOVER_HINTS, - QUERY_SCENE_BUSINESS_REVIEW, - QUERY_SCENE_CORRECTION_ESCALATION, - QUERY_SCENE_REQUIREMENT_PLAN, - QUESTION_HINTS, - REQUIREMENT_PLAN_CHAT_HINTS, - REQUIREMENT_TOPIC_SWITCH_NEW_HINTS, - REQUIREMENT_TOPIC_SWITCH_NEW_PATTERNS, REPLY_MODE_CARD, REPLY_MODE_TEXT, - ROUTE_FOLLOWUP_CONFIRM, - ROUTE_FOLLOWUP_CONTINUE, - ROUTE_PROJECT_CREATE, - ROUTE_PROJECT_SIGNAL_CANCEL, - ROUTE_PROJECT_SIGNAL_COMMIT, - ROUTE_PROJECT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_DISCOVER, - ROUTE_PROJECT_SIGNAL_REFRESH, - ROUTE_QUERY_ANSWER, - ROUTE_SIGNAL_SOURCE_ONBOARD, - ROUTE_TASK_CREATE, SEMANTIC_VERSION, - SIGNAL_CREATE_HINTS, - SIGNAL_CREATE_REGEX, - SHORT_CONFIRM_RE, - SMALLTALK_EXACT, - SMALLTALK_SHORT_HINTS, - SMALLTALK_STRUCTURED_HINTS, - SOURCE_ONBOARDING_ACTION_RE, - TASK_CREATE_HINTS, - TASK_CREATE_REGEX, - LEGACY_COMMAND_OBJECT_RE, - LEGACY_COMMAND_PROJECT_CREATE_RE, - LEGACY_COMMAND_VERB_RE, - OBVIOUS_QUESTION_RE, - QUERY_INTENT_RE, - WORKFLOW_FOLLOWUP, - WORKFLOW_NEW_PROJECT, - WORKFLOW_PROJECT, - WORKFLOW_PROJECT_SIGNAL, - WORKFLOW_QUERY, - WORKFLOW_SIGNAL_SOURCE, - WORKFLOW_TASK, ) from .models import ReplyContract, WorkflowBundle, WorkflowPlan, workflow_slot class WorkflowSemanticPlanner: - _WRITE_VERBS = ( - "新增", - "增加", - "添加", - "加", - "新建", - "创建", - "建立", - "立项", - "接入", - "修复", - "优化", - "改成", - "改为", - "更新", - "分配", - "指派", - "归档", - "执行", + _SCENE_METADATA_KEYS = ( + "message_intent", + "review_framework", + "consultant_mode_preference", + "force_thinking_mode", + "advise_subtype", + "topic_switch", ) - _FOLLOWUP_ALLOWED_ACTIONS = ("continue", "confirm", "commit", "cancel", "refresh") - _FOLLOWUP_CONTEXT_KEYS = ("target_work_item_id", "followup_allowed_actions", "reply_to_message_id", "thread_anchor") @staticmethod def _normalize_text(text: str) -> str: return re.sub(r"\s+", "", str(text or "")).lower() - @classmethod - def _has_question_structure(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - return "?" in normalized or "?" in normalized or any(token in normalized for token in QUESTION_HINTS) - - @classmethod - def _looks_like_casual_chat_message(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if cls._is_smalltalk_message(text): - return True - if any( - token in normalized - for token in ( - "陪我聊", - "聊两句", - "最近有点累", - "先不推进需求", - "今天你在线吗", - "你在线吗", - "早上好", - "下午好", - "晚上好", - ) - ): - return True - if any(token in normalized for token in ("天气", "下雨", "温度", "散步", "走走")) and not any( - keyword in normalized for keyword in ("项目", "任务", "信号", "风险", "进度", "状态", "工作项", "系统") - ): - return True - return False - - @classmethod - def _looks_like_requirement_followup_clarify(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized or cls._has_question_structure(text): - return False - if cls._looks_like_casual_chat_message(text): - return False - has_followup_anchor = any( - token in normalized - for token in ( - "按上面的方向", - "按上面的思路", - "照上面的方向", - "先按上面", - "先按这个方向", - "继续", - "往下收", - "往下做", - ) - ) - has_scope_narrowing = any( - token in normalized - for token in ( - "别太重", - "轻一点", - "先收一版", - "先收一下", - "先缩一下", - "先聚焦", - "先只做", - "轻量版", - ) - ) - return (has_followup_anchor and has_scope_narrowing) or ("上面" in normalized and "别太重" in normalized) - - @classmethod - def _looks_like_requirement_scope_advise(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if cls._looks_like_casual_chat_message(text): - return False - has_scope_token = any( - token in normalized - for token in ( - "范围怎么收", - "范围怎么定", - "范围先收", - "先做什么", - "先保留什么", - "怎么收才稳", - "怎么做才稳", - "第一版先做", - "首期先做", - ) - ) - has_phase_token = any(token in normalized for token in ("mvp", "第一版", "首期")) - return has_scope_token and has_phase_token - - @classmethod - def _looks_like_requirement_plan_chat(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if cls._looks_like_casual_chat_message(text): - return False - if any(token in normalized for token in REQUIREMENT_PLAN_CHAT_HINTS): - return True - has_chat_verb = any( - token in normalized - for token in ("聊聊", "聊下", "聊一下", "讨论", "梳理", "收敛", "想跟你聊") - ) - has_requirement_target = any( - token in normalized - for token in ("需求", "想法", "功能", "方案", "mvp", "第一版", "产品") - ) - return has_chat_verb and has_requirement_target - - @classmethod - def looks_like_explicit_new_requirement_topic(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if cls._looks_like_casual_chat_message(text): - return False - if any(token in normalized for token in REQUIREMENT_TOPIC_SWITCH_NEW_HINTS): - return True - if any(pattern.search(str(text or "")) for pattern in REQUIREMENT_TOPIC_SWITCH_NEW_PATTERNS): - return True - if "现在是" in normalized and any(token in normalized for token in ("另一个需求", "新的需求", "新话题")): - return True - if "不对" in normalized and any(token in normalized for token in ("另一个需求", "不是这个需求", "别沿用", "不要沿用")): - return True - return False - - @classmethod - def looks_like_requirement_plan_choice_reply(cls, text: str) -> bool: - return bool(re.match(r"^\s*([ABCabc])(?:[\.\,,。、::)\]】\s]|$)", str(text or ""))) - - @classmethod - def looks_like_low_context_requirement_followup(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - if cls._looks_like_casual_chat_message(raw): - return False - if cls.looks_like_explicit_new_requirement_topic(raw): - return False - if cls._has_question_structure(raw) or cls.is_obvious_question(raw): - return False - if cls.looks_like_requirement_plan_choice_reply(raw): - return True - normalized = cls._normalize_text(raw) - if normalized in DRAFT_CONFIRM_EXACT_HINTS: - return True - return normalized in {"你自行推荐", "你来推荐", "你来定", "你决定"} - - @classmethod - def _looks_like_requirement_plan_slot_answer(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if cls._looks_like_casual_chat_message(text): - return False - if cls._has_question_structure(text) and not any( - token in normalized for token in ("草稿", "汇总", "整理", "先按", "第一版") - ): - return False - slot_hits = 0 - slot_hits += int(any(token in normalized for token in ("技术指标", "均线", "成交量", "涨跌比", "macd", "rsi", "规则"))) - slot_hits += int(any(token in normalized for token in ("情绪", "涨停家数", "北向", "北向资金", "舆情", "热度"))) - slot_hits += int( - any( - token in normalized - for token in ( - "投顾", - "研究员", - "研究团队", - "交易员", - "交易团队", - "负责人", - "研发负责人", - "内部pm", - "pm", - "给我看", - "给团队看", - "给谁看", - "给谁用", - ) - ) - ) - slot_hits += int(any(token in normalized for token in ("飞书日报", "飞书消息", "飞书总结", "简报", "看板", "页面", "仪表盘", "一句解释"))) - slot_hits += int(any(token in normalized for token in ("每天", "每日", "收盘后", "开盘前", "盘中", "实时", "会后", "半小时", "当天内"))) - slot_hits += int(any(token in normalized for token in ("第一版", "先保留", "先只做", "别做太重", "先做成", "先按"))) - has_planning_prefix = any( - token in normalized - for token in ("先按", "主要给", "先给", "先做成", "先保留") - ) - has_generic_target_user_shape = any( - token in normalized - for token in ("给内部", "给团队", "负责人用", "负责人看", "研发负责人", "内部pm") - ) - has_generic_cadence_shape = any( - token in normalized - for token in ("会后", "半小时", "当天内", "整理出来", "整理出", "产出") - ) - compact_length = len(normalized) - if cls._looks_like_requirement_plan_chat(text) and slot_hits <= 1 and not has_generic_target_user_shape and not has_generic_cadence_shape: - return False - return ( - slot_hits >= 2 - or (has_planning_prefix and slot_hits >= 1) - or (slot_hits >= 1 and compact_length <= 10) - or (has_generic_target_user_shape and slot_hits >= 1) - or (has_generic_cadence_shape and slot_hits >= 1) - ) - - @classmethod - def _derive_query_scene_metadata(cls, text: str) -> dict[str, Any]: - normalized = cls._normalize_text(text) - if not normalized: - return {} - if cls._looks_like_casual_chat_message(text): - return { - "message_intent": "casual_chat", - "consultant_mode_preference": "brainstorm", - } - if cls.looks_like_explicit_new_requirement_topic(text): - return { - "message_intent": QUERY_SCENE_REQUIREMENT_PLAN, - "consultant_mode_preference": "clarify", - "topic_switch": "new", - } - if cls._looks_like_requirement_followup_clarify(text): - return { - "message_intent": QUERY_SCENE_BUSINESS_REVIEW, - "consultant_mode_preference": "clarify", - } - if cls._looks_like_requirement_scope_advise(text): - return { - "message_intent": QUERY_SCENE_BUSINESS_REVIEW, - "consultant_mode_preference": "advise", - "advise_subtype": "scope", - } - if cls._looks_like_requirement_plan_chat(text): - return { - "message_intent": QUERY_SCENE_REQUIREMENT_PLAN, - "consultant_mode_preference": "clarify", - } - if cls._looks_like_requirement_plan_slot_answer(text): - return { - "message_intent": QUERY_SCENE_REQUIREMENT_PLAN, - "consultant_mode_preference": "clarify", - } - - has_correction_cue = any( - token in normalized - for token in ( - "不对", - "错了", - "答错", - "纠正", - "更正", - "重新回答", - "重答", - ) - ) - has_escalation_hint = any(token in normalized for token in CORRECTION_ESCALATION_HINTS) - if has_escalation_hint and (has_correction_cue or "高级模型" in normalized or "更强模型" in normalized): - return { - "message_intent": QUERY_SCENE_CORRECTION_ESCALATION, - "consultant_mode_preference": "challenge", - "force_thinking_mode": "deep", - } - has_review_analysis_hint = any(token in normalized for token in BUSINESS_REVIEW_ANALYSIS_HINTS) - has_review_target_hint = any(token in normalized for token in BUSINESS_REVIEW_TARGET_HINTS) - if has_review_analysis_hint and has_review_target_hint: - return { - "message_intent": QUERY_SCENE_BUSINESS_REVIEW, - "review_framework": "five_lenses", - "consultant_mode_preference": "clarify", - } - if cls._looks_like_assignee_recommendation(text): - return { - "message_intent": QUERY_SCENE_BUSINESS_REVIEW, - "consultant_mode_preference": "advise", - "advise_subtype": "assignee", - } - if cls._should_keep_requirement_discussion_in_query(text): - return { - "message_intent": QUERY_SCENE_BUSINESS_REVIEW, - "consultant_mode_preference": "clarify", - } - return {} - @classmethod def _build_query_bundle_diagnostics( cls, *, - intent_name: Optional[str] = None, - workflow_route: Optional[str] = None, scene_metadata: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: diagnostics: dict[str, Any] = {} - if str(intent_name or "").strip(): - diagnostics["intent_name"] = str(intent_name).strip() - if str(workflow_route or "").strip(): - diagnostics["workflow_route"] = str(workflow_route).strip() - for key in ("message_intent", "review_framework", "consultant_mode_preference", "force_thinking_mode", "advise_subtype", "topic_switch"): - value = None if not isinstance(scene_metadata, dict) else scene_metadata.get(key) - if value not in (None, ""): - diagnostics[key] = value + payload = scene_metadata if isinstance(scene_metadata, dict) else {} + for key in cls._SCENE_METADATA_KEYS: + value = payload.get(key) + if value in (None, ""): + continue + diagnostics[key] = value return diagnostics @classmethod - def _is_smalltalk_message(cls, text: str) -> bool: - compact = re.sub(r"[\s\??!!,,。~~、]+", "", str(text or "")).strip().lower() - if not compact: - return True - if any(keyword in compact for keyword in SMALLTALK_STRUCTURED_HINTS): - return False - if compact in SMALLTALK_EXACT: - return True - if len(compact) <= 8 and any(keyword in compact for keyword in SMALLTALK_SHORT_HINTS): - return True - return False - - @classmethod - def _looks_like_project_create(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if any(token in normalized for token in PROJECT_CREATE_QUERY_GUARD_HINTS): - return False - if any(token in normalized for token in PROJECT_CREATE_HINTS): - return True - if PROJECT_CREATE_REGEX.search(normalized): - return True - if any(token in normalized for token in NATURAL_PROJECT_CREATE_HINTS): - return True - return bool(NATURAL_PROJECT_CREATE_REGEX.search(normalized)) - - @classmethod - def _looks_like_assignee_recommendation(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if any(token in normalized for token in ASSIGNEE_RECOMMENDATION_HINTS): - return True - if not cls._has_question_structure(text): - return False - return any(token in normalized for token in ASSIGNEE_RECOMMENDATION_HINTS) - - @classmethod - def _should_keep_requirement_discussion_in_query(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if cls.looks_like_explicit_new_requirement_topic(text): - return True - if any(token in normalized for token in PROJECT_CREATE_QUERY_GUARD_HINTS): - return True - if any(token in normalized for token in PROJECT_CREATE_DEFERRED_CONFIRM_HINTS): - return True - if cls._looks_like_requirement_plan_chat(text): - return True - if cls._looks_like_requirement_plan_slot_answer(text): - return True - return cls._looks_like_assignee_recommendation(text) - - @classmethod - def _looks_like_task_create(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - return any(token in normalized for token in TASK_CREATE_HINTS) or bool(TASK_CREATE_REGEX.search(normalized)) - - @classmethod - def _looks_like_signal_create(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS): - return True - return any(token in normalized for token in SIGNAL_CREATE_HINTS) or bool(SIGNAL_CREATE_REGEX.search(normalized)) - - @classmethod - def looks_like_project_create_request(cls, text: str) -> bool: - return cls._looks_like_project_create(text) - - @classmethod - def looks_like_task_create_request(cls, text: str) -> bool: - return cls._looks_like_task_create(text) - - @classmethod - def looks_like_signal_create_request(cls, text: str) -> bool: - return cls._looks_like_signal_create(text) - - @classmethod - def looks_like_signal_source_request(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if len(normalized) < 3: - return False - if GENERIC_SIGNAL_SOURCE_MESSAGE_RE.search(normalized): - return True - return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized)) - - @staticmethod - def is_healthcheck_message(text: str) -> bool: - return bool(HEALTHCHECK_RE.search(str(text or "").strip())) - - @staticmethod - def is_short_confirmation_noise(text: str) -> bool: - return bool(SHORT_CONFIRM_RE.search(str(text or "").strip())) - - @classmethod - def wants_followup_continue(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - if FOLLOWUP_CREATE_CONTINUE_RE.search(raw): - return bool(FOLLOWUP_REFERENCE_RE.search(raw)) - return bool(FOLLOWUP_CONTINUE_RE.search(raw)) - - @staticmethod - def wants_followup_status(text: str) -> bool: - return bool(FOLLOWUP_STATUS_RE.search(str(text or ""))) - - @classmethod - def looks_like_external_connect_request(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - return bool(CREATE_ACTION_RE.search(raw) and EXTERNAL_INTEGRATION_RE.search(raw)) - - @classmethod - def is_legacy_business_command(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - if cls.is_query_like_question(raw): - return False - if LEGACY_COMMAND_PROJECT_CREATE_RE.search(raw): - return True - return bool(LEGACY_COMMAND_VERB_RE.search(raw) and LEGACY_COMMAND_OBJECT_RE.search(raw)) - - @classmethod - def looks_like_project_lookup_question(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - if cls.is_legacy_business_command(raw): - return False - normalized = cls._normalize_text(raw) - if PROJECT_LIST_QUERY_RE.search(raw): - return True - if PROJECT_ID_QUERY_RE.search(raw): - return True - if "项目" not in normalized: - return False - return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些")) - - @staticmethod - def extract_project_lookup_keyword(text: str) -> str: - raw = str(text or "").strip() - if not raw: - return "" - cleaned = re.sub( - r"(请问|麻烦问下|问下|现在|当前|目前|系统里|系统中|项目里|项目中|有没有|有无|有吗|是否有|是否存在|存在吗|这个|那个|一个|个|项目|列表|清单|有|吗|呢|呀|啊|么)", - " ", - raw, - flags=re.IGNORECASE, - ) - cleaned = re.sub(r"[,。,.;;::!??()()\\[\\]【】]+", " ", cleaned) - cleaned = re.sub(r"\s+", " ", cleaned).strip(" ??") - return cleaned[:40] - - @classmethod - def is_query_like_question(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - normalized = cls._normalize_text(raw) - has_question_structure = bool(OBVIOUS_QUESTION_RE.search(raw) or QUERY_INTENT_RE.search(raw)) - if not has_question_structure: - return False - has_operation_verb = bool(LEGACY_COMMAND_VERB_RE.search(raw)) - imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排") - has_imperative = any(token in normalized for token in imperative_tokens) - return not has_operation_verb and not has_imperative - - @classmethod - def is_obvious_question(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - if cls.is_query_like_question(raw): - return True - if cls.is_legacy_business_command(raw): - return False - if cls.looks_like_project_lookup_question(raw): - return True - return bool(OBVIOUS_QUESTION_RE.search(raw)) - - @classmethod - def looks_like_contextual_write_confirmation(cls, text: str) -> bool: - normalized = cls._normalize_text(text) - if not normalized: - return False - if cls._has_question_structure(text): - return False - has_write_action = any(token in normalized for token in CONTEXTUAL_WRITE_ACTION_HINTS) - if not has_write_action: - return False - has_confirm = any(token in normalized for token in CONTEXTUAL_CONFIRM_HINTS) - has_deictic = any(token in normalized for token in CONTEXTUAL_DEICTIC_HINTS) - return has_confirm or has_deictic - - @classmethod - def _looks_like_followup(cls, text: str, *, metadata: Optional[dict[str, Any]] = None) -> bool: - payload = metadata if isinstance(metadata, dict) else {} - if any(str(payload.get(key) or "").strip() for key in cls._FOLLOWUP_CONTEXT_KEYS): - return True - if FOLLOWUP_WORK_ITEM_ID_RE.search(str(text or "")): - return True - normalized = cls._normalize_text(text) - followup_tokens = (*FOLLOWUP_CONFIRM_HINTS, *FOLLOWUP_CONTINUE_HINTS, *FOLLOWUP_CANCEL_HINTS, *FOLLOWUP_REFRESH_HINTS) - return any(token in normalized for token in followup_tokens) or cls.wants_followup_continue(text) or cls.wants_followup_status(text) - - @classmethod - def parse_followup_action( + def _build_cold_start_packet( cls, *, + message_text: str, + source_system: str, 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 cls._FOLLOWUP_ALLOWED_ACTIONS: - return explicit_action - followup_context_present = bool( - payload.get("followup_context_present") - or payload.get("target_work_item_id") - or payload.get("followup_allowed_actions") - or payload.get("reply_to_message_id") - or payload.get("thread_anchor") + ) -> SemanticContextPacket: + payload = dict(metadata or {}) + scene_context = { + "project_id": payload.get("project_id"), + "session_last_intent": payload.get("session_last_intent"), + "session_project_id": payload.get("session_project_id"), + "world_model_project_id": payload.get("world_model_project_id"), + "requested_count": payload.get("requested_count"), + } + return SemanticContextPacket( + packet_version="v1", + tenant_id=0, + operator_user_id=0, + topic_workspace_id=0, + latest_user_message=str(message_text or "").strip(), + topic=SemanticContextTopic( + topic_workspace_id=0, + topic_key="cold_start", + source_system=str(source_system or "").strip().lower() or "unknown", + mode="CHAT", + status="active", + metadata=scene_context, + ), + draft=None, + thread=SemanticContextThread( + thread_key=None, + conversation_scope_key=None, + thread_summary=None, + recent_turns=[], + open_questions=[], + ), + mode_card=ModeCard( + workflow_mode="CHAT", + mode_state="IDLE", + allowed_actions=["workflow.bundle.compose"], + forbidden_actions=[], + exit_conditions=[], + ), + ingress=SemanticContextIngress( + candidate_kind="cold_start_route", + source_system=str(source_system or "").strip().lower() or None, + reply_anchor_present=bool(payload.get("reply_to_message_id") or payload.get("thread_anchor")), + active_context_kind=None, + allowed_context_scopes=[], + thread_context=payload, + ), ) - if followup_context_present: - normalized = cls._normalize_text(message_text) - if any(token in normalized for token in FOLLOWUP_REFRESH_HINTS): - return "refresh" - if any(token in normalized for token in FOLLOWUP_CANCEL_HINTS): - return "cancel" - if any(token in normalized for token in FOLLOWUP_COMMIT_HINTS): - return "commit" - if canonical_intent == INTENT_FOLLOWUP_CONFIRM: - return "confirm" - return "continue" @classmethod - def parse_draft_action(cls, *, message_text: str) -> tuple[str, str]: - raw_text = str(message_text or "").strip() - text = FOLLOWUP_WORK_ITEM_ID_RE.sub("", raw_text, count=1).strip() - lowered = cls._normalize_text(text) - if any(text.startswith(prefix) for prefix in DRAFT_REVISION_PREFIX_HINTS): - note = re.sub(r"^(?:修改|改成|改为|换成|改)\s*", "", text).strip() - return "revise", note or text - if cls._looks_like_scope_narrowing_revision(text): - return "revise", text - if any(hint in text for hint in FOLLOWUP_CANCEL_HINTS): - return "cancel", "" - if lowered in DRAFT_CONFIRM_EXACT_HINTS: - return "confirm", "" - if cls.looks_like_natural_draft_revision(text): - return "revise", text - return "unknown", "" + async def _decide_cold_start_semantics( + cls, + *, + message_text: str, + source_system: str, + metadata: Optional[dict[str, Any]] = None, + ) -> SemanticDecision: + packet = cls._build_cold_start_packet( + message_text=message_text, + source_system=source_system, + metadata=metadata, + ) + decision = await SemanticDecisionService(LLMService()).decide(packet=packet) + if "workflow.bundle.compose" not in { + str(item).strip() + for item in decision.requested_actions + if str(item or "").strip() + }: + decision.requested_actions = [*decision.requested_actions, "workflow.bundle.compose"] + if not decision.workflow_plans: + raise ValueError("cold_start_route returned no workflow_plans") + return decision @classmethod - def _looks_like_scope_narrowing_revision(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - normalized = cls._normalize_text(raw) - if not normalized: - return False - if any(token in normalized for token in ("取消", "不要了", "算了", "reject", "先不做了", "先别做了")): - return False - if "先别做" in normalized and any(token in normalized for token in ("太大", "太多", "这么大", "这么多")): - return True - if any(token in normalized for token in ("第一版", "首期", "先只做", "只做")) and any( - token in normalized - for token in ("能力", "功能", "数据", "团队", "指标", "切片", "总结", "清单", "模块", "范围", "三件事", "这几项", "这些") - ): - return True - return False + def _bundle_from_cold_start_decision( + cls, + *, + decision: SemanticDecision, + source_system: str, + message_text: str, + ) -> WorkflowBundle: + workflows: list[WorkflowPlan] = [] + for item in decision.workflow_plans: + normalized = item if isinstance(item, DecisionWorkflowPlan) else DecisionWorkflowPlan.model_validate(item) + workflows.append( + WorkflowPlan( + workflow_type=normalized.workflow_type, + workflow_action=normalized.workflow_action, + target_scope=normalized.target_scope, + operation_codes=[str(code) for code in normalized.operation_codes if str(code or "").strip()], + requires_confirmation=bool(normalized.requires_confirmation), + confidence=float(normalized.confidence or decision.confidence or 0.0), + clarification_needed=bool(normalized.clarification_needed), + metadata=dict(normalized.metadata or {}), + ) + ) + if not workflows: + raise ValueError("cold_start_route bundle has no workflows") + primary_workflow = workflows[0].workflow_type + return WorkflowBundle( + bundle_id=cls._bundle_id( + source_system=source_system, + message_text=message_text, + workflows=workflows, + ), + semantic_version=SEMANTIC_VERSION, + source_system=str(source_system or "").strip().lower() or "unknown", + primary_workflow=primary_workflow, + workflows=workflows, + message_gate=cls._gate_for_workflows(workflows), + reply_contract=cls._reply_contract( + source_system=source_system, + primary_workflow=primary_workflow, + ), + diagnostics=cls._build_query_bundle_diagnostics( + scene_metadata=dict(decision.scene_metadata or {}), + ), + ) @classmethod - def looks_like_natural_draft_revision(cls, text: str) -> bool: - raw = str(text or "").strip() - if not raw: - return False - normalized = cls._normalize_text(raw) - if not normalized: - return False - if cls.is_obvious_question(raw) or cls._has_question_structure(raw): - return False - if cls._is_smalltalk_message(raw): - return False - if cls._looks_like_scope_narrowing_revision(raw): - return True - if any(hint in raw for hint in FOLLOWUP_CANCEL_HINTS): - return False - if normalized in DRAFT_CONFIRM_EXACT_HINTS: - return False - if any(token in normalized for token in DRAFT_NATURAL_REVISION_HINTS): - return True - if len(normalized) < 8: - return False - if any(marker in raw for marker in MULTI_SEGMENT_MARKERS): - return True - return any(marker in normalized for marker in BACKGROUND_MARKERS) + async def aplan_message( + cls, + *, + message_text: str, + source_system: str, + metadata: Optional[dict[str, Any]] = None, + ) -> WorkflowBundle: + decision = await cls._decide_cold_start_semantics( + message_text=message_text, + source_system=source_system, + metadata=metadata, + ) + return cls._bundle_from_cold_start_decision( + decision=decision, + source_system=source_system, + message_text=str(message_text or "").strip(), + ) @classmethod def _bundle_id( @@ -776,154 +226,6 @@ class WorkflowSemanticPlanner: return MESSAGE_GATE_PENDING return MESSAGE_GATE_AUTO - @classmethod - def _plan_from_intent( - cls, - *, - intent_name: str, - message_text: str, - source_system: str, - workflow_route: str = "", - project_id: Optional[int] = None, - metadata: Optional[dict[str, Any]] = None, - ) -> WorkflowBundle: - payload = dict(metadata or {}) - normalized_route = str(workflow_route or payload.get("workflow_route") or "").strip().lower() - query_scene_metadata = cls._derive_query_scene_metadata(message_text) - workflows: list[WorkflowPlan] = [] - slots = [slot for slot in [workflow_slot("project_id", project_id)] if slot is not None] - should_hold_requirement_discussion = cls._should_keep_requirement_discussion_in_query(message_text) - if intent_name == INTENT_QUERY: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_QUERY, - workflow_action="answer", - target_scope="project" if project_id is not None else "global", - operation_codes=[ROUTE_QUERY_ANSWER], - requires_confirmation=False, - confidence=0.88, - slots=slots, - metadata=dict(query_scene_metadata), - ) - ) - elif intent_name == INTENT_PROJECT_CREATE and should_hold_requirement_discussion: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_QUERY, - workflow_action="answer", - target_scope="project" if project_id is not None else "global", - operation_codes=[ROUTE_QUERY_ANSWER], - requires_confirmation=False, - confidence=float(payload.get("confidence") or 0.88), - slots=slots, - metadata=dict(query_scene_metadata), - ) - ) - elif intent_name == INTENT_PROJECT_CREATE: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_NEW_PROJECT, - workflow_action="create", - target_scope="global", - operation_codes=["project.create"], - requires_confirmation=bool(payload.get("requires_confirmation")), - confidence=float(payload.get("confidence") or 0.9), - slots=slots, - ) - ) - elif intent_name in {INTENT_PROJECT_UPDATE, INTENT_PROJECT_ARCHIVE, INTENT_PROJECT_REOPEN, INTENT_PROJECT_DELETE}: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_PROJECT, - workflow_action=str(intent_name.split(".", 1)[1] if "." in intent_name else "update"), - target_scope="project", - operation_codes=[intent_name], - requires_confirmation=bool(payload.get("requires_confirmation")), - confidence=float(payload.get("confidence") or 0.82), - slots=slots, - ) - ) - elif intent_name in {INTENT_TASK_CREATE, INTENT_TASK_UPDATE, INTENT_TASK_ASSIGN, INTENT_TASK_PRIORITIZE, INTENT_TASK_STATUS_TRANSITION, INTENT_TASK_DELETE}: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_TASK, - workflow_action=str(intent_name.split(".", 1)[1] if "." in intent_name else "create"), - target_scope="project" if project_id is not None else "global", - operation_codes=[intent_name if intent_name != INTENT_TASK_CREATE else ROUTE_TASK_CREATE], - requires_confirmation=bool(payload.get("requires_confirmation")), - confidence=float(payload.get("confidence") or 0.82), - slots=slots, - ) - ) - elif intent_name == INTENT_SIGNAL_SOURCE_ONBOARD: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_SIGNAL_SOURCE, - workflow_action="onboard", - target_scope="project" if project_id is not None else "global", - operation_codes=[ROUTE_SIGNAL_SOURCE_ONBOARD], - requires_confirmation=bool(payload.get("requires_confirmation")), - confidence=float(payload.get("confidence") or 0.92), - slots=slots, - ) - ) - elif intent_name == INTENT_SIGNAL_CREATE: - route_to_action = { - ROUTE_PROJECT_SIGNAL_DISCOVER: "discover", - ROUTE_PROJECT_SIGNAL_COMMIT: "commit", - ROUTE_PROJECT_SIGNAL_CANCEL: "cancel", - ROUTE_PROJECT_SIGNAL_REFRESH: "refresh", - } - workflow_action = route_to_action.get(normalized_route, "create") - operation_codes = [normalized_route or ROUTE_PROJECT_SIGNAL_CREATE] - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_PROJECT_SIGNAL, - workflow_action=workflow_action, - target_scope="project" if project_id is not None else "global", - operation_codes=operation_codes, - requires_confirmation=workflow_action in {"discover", "commit", "cancel", "refresh"} or bool(payload.get("requires_confirmation")), - confidence=float(payload.get("confidence") or 0.8), - slots=slots, - ) - ) - elif intent_name in {INTENT_FOLLOWUP_CONFIRM, INTENT_FOLLOWUP_CONTINUE}: - followup_action = cls.parse_followup_action( - metadata={**payload, "followup_context_present": True}, - canonical_intent=intent_name, - message_text=message_text, - ) - work_item_id = payload.get("target_work_item_id") - followup_slots = [slot for slot in [*slots, workflow_slot("target_work_item_id", work_item_id)] if slot is not None] - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_FOLLOWUP, - workflow_action=followup_action, - target_scope="thread", - operation_codes=[ROUTE_FOLLOWUP_CONFIRM if followup_action == "confirm" else ROUTE_FOLLOWUP_CONTINUE], - requires_confirmation=False, - confidence=float(payload.get("confidence") or 0.82), - slots=followup_slots, - ) - ) - else: - return cls.plan_message(message_text=message_text, source_system=source_system, metadata=payload) - primary_workflow = workflows[0].workflow_type - return WorkflowBundle( - bundle_id=cls._bundle_id(source_system=source_system, message_text=message_text, workflows=workflows), - semantic_version=SEMANTIC_VERSION, - source_system=str(source_system or "").strip().lower() or "unknown", - primary_workflow=primary_workflow, - workflows=workflows, - message_gate=cls._gate_for_workflows(workflows), - reply_contract=cls._reply_contract(source_system=source_system, primary_workflow=primary_workflow), - diagnostics=cls._build_query_bundle_diagnostics( - intent_name=intent_name, - workflow_route=normalized_route or None, - scene_metadata=query_scene_metadata, - ), - ) - @classmethod def plan_message( cls, @@ -932,160 +234,17 @@ class WorkflowSemanticPlanner: source_system: str, metadata: Optional[dict[str, Any]] = None, ) -> WorkflowBundle: - text = str(message_text or "").strip() - payload = dict(metadata or {}) - normalized = cls._normalize_text(text) - query_scene_metadata = cls._derive_query_scene_metadata(text) - workflows: list[WorkflowPlan] = [] - if not text or cls._looks_like_casual_chat_message(text): - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_QUERY, - workflow_action="answer", - target_scope="global", - operation_codes=[ROUTE_QUERY_ANSWER], - requires_confirmation=False, - confidence=0.55, - clarification_needed=False, - metadata=dict(query_scene_metadata), + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run( + cls.aplan_message( + message_text=message_text, + source_system=source_system, + metadata=metadata, ) ) - elif cls._looks_like_followup(text, metadata=payload): - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_FOLLOWUP, - workflow_action=cls.parse_followup_action( - metadata={**payload, "followup_context_present": True}, - canonical_intent=str(payload.get("intent_name") or INTENT_FOLLOWUP_CONTINUE), - message_text=text, - ), - target_scope="thread", - operation_codes=[ROUTE_FOLLOWUP_CONTINUE], - requires_confirmation=False, - confidence=0.84, - slots=[ - slot - for slot in [ - workflow_slot( - "target_work_item_id", - int(FOLLOWUP_WORK_ITEM_ID_RE.search(text).group(1)) - if FOLLOWUP_WORK_ITEM_ID_RE.search(text) - else payload.get("target_work_item_id"), - ) - ] - if slot is not None - ], - ) - ) - else: - project_like = cls._looks_like_project_create(text) - signal_source_like = cls.looks_like_signal_source_request(text) - task_like = cls._looks_like_task_create(text) - signal_like = cls._looks_like_signal_create(text) - question_like = cls._has_question_structure(text) - hold_requirement_discussion = cls._should_keep_requirement_discussion_in_query(text) - deferred_project_confirm = any(token in normalized for token in PROJECT_CREATE_DEFERRED_CONFIRM_HINTS) - if hold_requirement_discussion: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_QUERY, - workflow_action="answer", - target_scope="global", - operation_codes=[ROUTE_QUERY_ANSWER], - requires_confirmation=False, - confidence=0.88 if question_like else 0.84, - metadata=dict(query_scene_metadata), - ) - ) - if project_like and not hold_requirement_discussion: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_NEW_PROJECT, - workflow_action="create", - target_scope="global", - operation_codes=["project.create"], - requires_confirmation=deferred_project_confirm, - confidence=0.9, - ) - ) - if signal_source_like: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_SIGNAL_SOURCE, - workflow_action="onboard", - target_scope="project" if project_like else "global", - operation_codes=[ROUTE_SIGNAL_SOURCE_ONBOARD], - requires_confirmation=False, - confidence=0.92, - ) - ) - if task_like and not project_like: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_TASK, - workflow_action="create", - target_scope="project", - operation_codes=[ROUTE_TASK_CREATE], - requires_confirmation=False, - confidence=0.82, - ) - ) - if signal_like and not signal_source_like: - workflow_action = "discover" if any(token in normalized for token in PROJECT_SIGNAL_DISCOVER_HINTS) else "create" - route_code = ROUTE_PROJECT_SIGNAL_DISCOVER if workflow_action == "discover" else ROUTE_PROJECT_SIGNAL_CREATE - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_PROJECT_SIGNAL, - workflow_action=workflow_action, - target_scope="project", - operation_codes=[route_code], - requires_confirmation=workflow_action == "discover", - confidence=0.78, - ) - ) - if not workflows: - workflows.append( - WorkflowPlan( - workflow_type=WORKFLOW_QUERY if question_like or not any(token in normalized for token in cls._WRITE_VERBS) else WORKFLOW_QUERY, - workflow_action="answer", - target_scope="global", - operation_codes=[ROUTE_QUERY_ANSWER], - requires_confirmation=False, - confidence=0.88 if question_like else 0.6, - metadata=dict(query_scene_metadata), - ) - ) - primary_workflow = workflows[0].workflow_type - return WorkflowBundle( - bundle_id=cls._bundle_id(source_system=source_system, message_text=text, workflows=workflows), - semantic_version=SEMANTIC_VERSION, - source_system=str(source_system or "").strip().lower() or "unknown", - primary_workflow=primary_workflow, - workflows=workflows, - message_gate=cls._gate_for_workflows(workflows), - reply_contract=cls._reply_contract(source_system=source_system, primary_workflow=primary_workflow), - diagnostics=cls._build_query_bundle_diagnostics(scene_metadata=query_scene_metadata), - ) - - @classmethod - def plan_from_intent( - cls, - *, - intent_name: str, - message_text: str, - source_system: str, - workflow_route: str = "", - project_id: Optional[int] = None, - metadata: Optional[dict[str, Any]] = None, - ) -> WorkflowBundle: - return cls._plan_from_intent( - intent_name=intent_name, - message_text=message_text, - source_system=source_system, - workflow_route=workflow_route, - project_id=project_id, - metadata=metadata, - ) + raise RuntimeError("WorkflowSemanticPlanner.plan_message cannot run inside an active event loop; use aplan_message") @classmethod def deserialize_bundle(cls, value: Any) -> Optional[WorkflowBundle]: @@ -1109,9 +268,19 @@ class WorkflowSemanticPlanner: requires_confirmation=bool(item.get("requires_confirmation")), confidence=float(item.get("confidence") or 0.0), slots=[ - workflow_slot(str(slot.get("name") or "").strip(), slot.get("value"), source=str(slot.get("source") or "semantic_planner")) + workflow_slot( + str(slot.get("name") or "").strip(), + slot.get("value"), + source=str(slot.get("source") or "semantic_planner"), + ) for slot in (slots_raw if isinstance(slots_raw, list) else []) - if isinstance(slot, dict) and workflow_slot(str(slot.get("name") or "").strip(), slot.get("value"), source=str(slot.get("source") or "semantic_planner")) is not None + if isinstance(slot, dict) + and workflow_slot( + str(slot.get("name") or "").strip(), + slot.get("value"), + source=str(slot.get("source") or "semantic_planner"), + ) + is not None ], clarification_needed=bool(item.get("clarification_needed")), metadata=dict(item.get("metadata") or {}), @@ -1132,62 +301,5 @@ class WorkflowSemanticPlanner: diagnostics=dict(value.get("diagnostics") or {}), ) - @classmethod - def classify_scene(cls, *, capture_result: dict[str, Any]) -> str: - bundle = cls.deserialize_bundle(capture_result.get("semantic_bundle")) - if bundle is not None and bundle.workflows: - if any(item.workflow_type != WORKFLOW_QUERY for item in bundle.workflows): - return "execute" - return "qa" - status = str(capture_result.get("status") or "").strip().lower() - classification = str(capture_result.get("classification") or "").strip().lower() - intent = str(capture_result.get("intent") or "").strip().lower() - workflow_route = str(capture_result.get("workflow_route") or "").strip().lower() - execute_intents = { - INTENT_PROJECT_CREATE, - INTENT_PROJECT_UPDATE, - INTENT_PROJECT_ARCHIVE, - INTENT_PROJECT_REOPEN, - INTENT_PROJECT_DELETE, - INTENT_TASK_CREATE, - INTENT_TASK_UPDATE, - INTENT_TASK_ASSIGN, - INTENT_TASK_PRIORITIZE, - INTENT_TASK_STATUS_TRANSITION, - INTENT_TASK_DELETE, - INTENT_SIGNAL_CREATE, - INTENT_SIGNAL_SOURCE_ONBOARD, - INTENT_FOLLOWUP_CONFIRM, - INTENT_FOLLOWUP_CONTINUE, - } - execute_routes = { - ROUTE_PROJECT_CREATE, - ROUTE_TASK_CREATE, - ROUTE_SIGNAL_SOURCE_ONBOARD, - ROUTE_PROJECT_SIGNAL_CREATE, - ROUTE_PROJECT_SIGNAL_DISCOVER, - ROUTE_PROJECT_SIGNAL_COMMIT, - ROUTE_PROJECT_SIGNAL_CANCEL, - ROUTE_PROJECT_SIGNAL_REFRESH, - ROUTE_FOLLOWUP_CONFIRM, - ROUTE_FOLLOWUP_CONTINUE, - } - if intent in execute_intents or workflow_route in execute_routes or classification in {"project", "signal", "command", "new_project", "project_signal"}: - return "execute" - if status == "error" or classification == "error": - return "error" - if status == "answered" or classification in {"question", "qa"}: - return "qa" - if status in {"captured", "ignored"}: - return "execute" - return "qa" - - @classmethod - def is_action_request_capture(cls, *, capture_result: dict[str, Any]) -> bool: - bundle = cls.deserialize_bundle(capture_result.get("semantic_bundle")) - if bundle is not None and bundle.workflows: - return any(item.workflow_type != WORKFLOW_QUERY for item in bundle.workflows) - return False - __all__ = ["WorkflowSemanticPlanner"] 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 07c578f9..b86f5553 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 @@ -1,7 +1,6 @@ from __future__ import annotations from .shared import * -from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class FeishuReceiveRoutingMixin: async def _route_scene_reply( @@ -152,7 +151,27 @@ class FeishuReceiveRoutingMixin: if not isinstance(capture_result, dict): return "error" _ = message_content - return WorkflowSemanticPlanner.classify_scene(capture_result=capture_result) + semantic_bundle = capture_result.get("semantic_bundle") + if isinstance(semantic_bundle, dict): + workflows = semantic_bundle.get("workflows") + if isinstance(workflows, list) and workflows: + workflow_types = { + str(item.get("workflow_type") or "").strip() + for item in workflows + if isinstance(item, dict) + } + if any(workflow_type and workflow_type != "query_flow" for workflow_type in workflow_types): + return "execute" + return "qa" + status = str(capture_result.get("status") or "").strip().lower() + classification = str(capture_result.get("classification") or "").strip().lower() + if status == "error" or classification == "error": + return "error" + if classification in {"project", "signal", "command", "new_project", "project_signal"}: + return "execute" + if status in {"captured", "ignored"}: + return "execute" + return "qa" @staticmethod def _build_error_reply_text() -> str: diff --git a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py index 0016e5d3..ce3111f3 100644 --- a/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py +++ b/backend/app/services/integrations/feishu_integration_service/mixin_reply_llm_core.py @@ -1,7 +1,6 @@ from __future__ import annotations from .shared import * -from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class FeishuReplyLLMCoreMixin: async def _reply_with_active_role_prompt( @@ -121,7 +120,7 @@ class FeishuReplyLLMCoreMixin: capture_result, reason="模型返回空内容", ) - elif self._looks_like_provider_error(reply_text): + elif self._is_provider_error(reply_text): reply_text = self._build_llm_failure_reply_text( capture_result, reason=reply_text, @@ -228,7 +227,17 @@ class FeishuReplyLLMCoreMixin: *, capture_result: Dict[str, Any], ) -> bool: - return WorkflowSemanticPlanner.is_action_request_capture(capture_result=capture_result) + semantic_bundle = capture_result.get("semantic_bundle") + if not isinstance(semantic_bundle, dict): + return False + workflows = semantic_bundle.get("workflows") + if not isinstance(workflows, list) or not workflows: + return False + return any( + str(item.get("workflow_type") or "").strip() not in {"", "query_flow"} + for item in workflows + if isinstance(item, dict) + ) @staticmethod def _build_consultant_dispatch_reply(capture_result: Dict[str, Any]) -> Optional[str]: @@ -245,8 +254,13 @@ class FeishuReplyLLMCoreMixin: _ = message_content if not cls._is_action_request_message(capture_result=capture_result): return None - bundle = WorkflowSemanticPlanner.deserialize_bundle(capture_result.get("semantic_bundle")) - workflow_types = [item.workflow_type for item in (bundle.workflows if bundle is not None else [])] + semantic_bundle = capture_result.get("semantic_bundle") + workflows = semantic_bundle.get("workflows") if isinstance(semantic_bundle, dict) else [] + workflow_types = [ + str(item.get("workflow_type") or "").strip() + for item in workflows + if isinstance(item, dict) + ] project_like = "new_project_flow" in workflow_types or "project_flow" in workflow_types signal_like = "signal_source_flow" in workflow_types or "project_signal_flow" in workflow_types if project_like and signal_like: @@ -283,7 +297,7 @@ class FeishuReplyLLMCoreMixin: return text @staticmethod - def _looks_like_provider_error(text: str) -> bool: + def _is_provider_error(text: str) -> bool: lowered = str(text or "").strip().lower() if not lowered: return False 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 891b1d5a..708c43b4 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 @@ -4,6 +4,7 @@ from .shared import * class FeishuReplyOutboxMixin: _CARD_MESSAGE_PREFIX = "__pmflow_feishu_card__:" + _PLAIN_TEXT_MESSAGE_LIMIT = 12000 @classmethod def _is_card_outbox_message(cls, value: Optional[str]) -> bool: @@ -26,6 +27,31 @@ class FeishuReplyOutboxMixin: return parsed return None + @staticmethod + def _is_requirement_plan_reply_text(value: Optional[str]) -> bool: + text = str(value or "") + return any(marker in text for marker in ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:")) + + @classmethod + def _guard_requirement_plan_reply_integrity(cls, value: Optional[str]) -> None: + text = str(value or "").strip() + if not cls._is_requirement_plan_reply_text(text): + return + required_markers = ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:") + if any(marker not in text for marker in required_markers): + raise ValueError("incomplete_requirement_plan_reply") + + @classmethod + def _normalize_reply_outbox_message_text(cls, message_text: Optional[str]) -> str: + raw_text = str(message_text or "") + if cls._is_card_outbox_message(raw_text): + return raw_text[:12000] + normalized = raw_text.strip() + cls._guard_requirement_plan_reply_integrity(normalized) + if len(normalized) > cls._PLAIN_TEXT_MESSAGE_LIMIT and cls._is_requirement_plan_reply_text(normalized): + raise ValueError("requirement_plan_reply_exceeds_plain_text_outbox_limit") + return normalized[: cls._PLAIN_TEXT_MESSAGE_LIMIT] or "已收到,正在处理。" + async def _safe_reply_text( self, *, @@ -116,10 +142,7 @@ class FeishuReplyOutboxMixin: ) -> int: async with AsyncSessionLocal() as db: normalized_source_message_id = (source_message_id or "")[:128] or None - if self._is_card_outbox_message(message_text): - normalized_message_text = (message_text or "")[:12000] - else: - normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。" + normalized_message_text = self._normalize_reply_outbox_message_text(message_text) reply_hash = self._build_reply_hash( source_message_id=normalized_source_message_id, message_text=normalized_message_text, @@ -382,8 +405,8 @@ class FeishuReplyOutboxMixin: text = text.replace("```", "").strip() if text.lower().startswith("json"): text = text[4:].strip() - looks_like_provider_error = getattr(cls, "_looks_like_provider_error", None) - has_provider_error = callable(looks_like_provider_error) and bool(looks_like_provider_error(text)) + provider_error_detector = getattr(cls, "_is_provider_error", None) + has_provider_error = callable(provider_error_detector) and bool(provider_error_detector(text)) if not text or has_provider_error: error_builder = getattr(cls, "_build_error_reply_text", None) if callable(error_builder): diff --git a/backend/app/services/integrations/feishu_outbox/service.py b/backend/app/services/integrations/feishu_outbox/service.py index 7a3614c9..2ac0b38e 100644 --- a/backend/app/services/integrations/feishu_outbox/service.py +++ b/backend/app/services/integrations/feishu_outbox/service.py @@ -14,6 +14,7 @@ from ....models import FeishuReplyOutbox class FeishuOutboxService: """复用 reply_hash 幂等策略的 outbox 写入入口。""" + _PLAIN_TEXT_MESSAGE_LIMIT = 12000 @staticmethod def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]: @@ -24,6 +25,28 @@ class FeishuOutboxService: raw = f"{source}{body}".encode("utf-8") return hashlib.md5(raw, usedforsecurity=False).hexdigest() + @staticmethod + def _is_requirement_plan_reply_text(value: Optional[str]) -> bool: + text = str(value or "") + return any(marker in text for marker in ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:")) + + @classmethod + def _guard_requirement_plan_reply_integrity(cls, value: Optional[str]) -> None: + text = str(value or "").strip() + if not cls._is_requirement_plan_reply_text(text): + return + required_markers = ("A. ", "B. ", "C. ", "⭐ 当前我更推荐:") + if any(marker not in text for marker in required_markers): + raise ValueError("incomplete_requirement_plan_reply") + + @classmethod + def _normalize_outbox_text(cls, value: Optional[str]) -> str: + normalized = str(value or "").strip() + cls._guard_requirement_plan_reply_integrity(normalized) + if len(normalized) > cls._PLAIN_TEXT_MESSAGE_LIMIT and cls._is_requirement_plan_reply_text(normalized): + raise ValueError("requirement_plan_reply_exceeds_plain_text_outbox_limit") + return normalized[: cls._PLAIN_TEXT_MESSAGE_LIMIT] or "已收到,正在处理。" + @classmethod async def enqueue( cls, @@ -39,7 +62,7 @@ class FeishuOutboxService: normalized_chat_id = str(chat_id or "").strip()[:128] if not normalized_chat_id: return None - normalized_text = str(text or "").strip()[:2000] or "已收到,正在处理。" + normalized_text = cls._normalize_outbox_text(text) normalized_source_message_id = (str(source_message_id or "").strip()[:128] or None) hash_source = normalized_source_message_id or (str(dedupe_seed or "").strip()[:128] or None) reply_hash = cls._build_reply_hash(source_message_id=hash_source, message_text=normalized_text) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py index 16ec94cb..2c95d78f 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_a.py @@ -6,7 +6,7 @@ from .shared import * class FeishuPipelineContextPrefillAMixin: @staticmethod - def _looks_like_placeholder_project_text(text: str) -> bool: + def _is_placeholder_project_text(text: str) -> bool: normalized = re.sub(r"\s+", "", str(text or "")).lower() if not normalized: return True @@ -33,7 +33,7 @@ class FeishuPipelineContextPrefillAMixin: ) @staticmethod - def _looks_like_project_request_artifact(text: str) -> bool: # semantic-lint: ignore - project context artifact detector, not workflow routing + def _is_project_request_artifact(text: str) -> bool: normalized = re.sub(r"\s+", "", str(text or "")).lower() if not normalized: return False @@ -60,7 +60,7 @@ class FeishuPipelineContextPrefillAMixin: ) @staticmethod - def _looks_like_unusable_project_context(text: str) -> bool: + def _is_unusable_project_context(text: str) -> bool: normalized = re.sub(r"\s+", "", str(text or "")).lower() if not normalized: return True @@ -209,7 +209,7 @@ class FeishuPipelineContextPrefillAMixin: text = self._strip_feishu_mention_prefix(str(row.message_text or "")) if not text: continue - if self._looks_like_unusable_project_context(text): + if self._is_unusable_project_context(text): continue return text[:4000] return "" @@ -224,19 +224,19 @@ class FeishuPipelineContextPrefillAMixin: return fields embedded_context = self._extract_embedded_project_context(message) - if not embedded_context or self._looks_like_unusable_project_context(embedded_context): + if not embedded_context or self._is_unusable_project_context(embedded_context): return fields current_description = str(fields.get("description") or "").strip() if ( - self._looks_like_placeholder_project_text(current_description) - or self._looks_like_project_request_artifact(current_description) + self._is_placeholder_project_text(current_description) + or self._is_project_request_artifact(current_description) ): current_description = "" current_scope = str(fields.get("project_scope") or "").strip() if ( - self._looks_like_placeholder_project_text(current_scope) - or self._looks_like_project_request_artifact(current_scope) + self._is_placeholder_project_text(current_scope) + or self._is_project_request_artifact(current_scope) ): current_scope = "" @@ -258,11 +258,11 @@ class FeishuPipelineContextPrefillAMixin: suggested_name = normalize_project_title_candidate( str(project_info.get("suggested_name") or "").strip() ) - if self._looks_like_placeholder_project_text(suggested_name): + if self._is_placeholder_project_text(suggested_name): suggested_name = "" title = title or suggested_name description = description or str(project_info.get("suggested_description") or "").strip()[:4000] - if self._looks_like_unusable_project_context(description): + if self._is_unusable_project_context(description): description = "" project_scope = project_scope or self._extract_project_scope(embedded_context, description=description) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py index 02edf566..7f4d3017 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_context_prefill_b.py @@ -18,7 +18,7 @@ class FeishuPipelineContextPrefillBMixin: ) -> dict[str, Any]: if fields.get("ready"): return fields - if not source_chat_id or not self._looks_like_contextual_project_request(message): + if not source_chat_id or not self._is_contextual_project_request(message): return fields turns = await self._load_recent_chat_turns( @@ -39,7 +39,7 @@ class FeishuPipelineContextPrefillBMixin: if recent_question: context_blocks.append(f"最近用户问题:{recent_question}") question_hint = recent_question - if recent_answer and not self._looks_like_unusable_project_context(recent_answer): + if recent_answer and not self._is_unusable_project_context(recent_answer): context_blocks.append(f"最近系统回答:{recent_answer}") answer_hint = recent_answer @@ -48,8 +48,8 @@ class FeishuPipelineContextPrefillBMixin: base_description = str(fields.get("description") or "").strip() if ( - self._looks_like_placeholder_project_text(base_description) - or self._looks_like_project_request_artifact(base_description) + self._is_placeholder_project_text(base_description) + or self._is_project_request_artifact(base_description) ): base_description = "" if not answer_hint and not base_description: @@ -59,8 +59,8 @@ class FeishuPipelineContextPrefillBMixin: heuristic_description = base_description or answer_hint[:4000] heuristic_scope = str(fields.get("project_scope") or "").strip() if ( - self._looks_like_placeholder_project_text(heuristic_scope) - or self._looks_like_project_request_artifact(heuristic_scope) + self._is_placeholder_project_text(heuristic_scope) + or self._is_project_request_artifact(heuristic_scope) ): heuristic_scope = "" if not heuristic_scope and heuristic_description: @@ -85,14 +85,14 @@ class FeishuPipelineContextPrefillBMixin: project_info = await self._extract_project_prefill(prefill_source) current_description = str(fields.get("description") or "").strip() if ( - self._looks_like_placeholder_project_text(current_description) - or self._looks_like_project_request_artifact(current_description) + self._is_placeholder_project_text(current_description) + or self._is_project_request_artifact(current_description) ): current_description = "" current_scope = str(fields.get("project_scope") or "").strip() if ( - self._looks_like_placeholder_project_text(current_scope) - or self._looks_like_project_request_artifact(current_scope) + self._is_placeholder_project_text(current_scope) + or self._is_project_request_artifact(current_scope) ): current_scope = "" title = ( @@ -103,7 +103,7 @@ class FeishuPipelineContextPrefillBMixin: description = current_description or str(project_info.get("suggested_description") or "").strip() if not description and answer_hint: description = answer_hint[:4000] - if self._looks_like_unusable_project_context(description): + if self._is_unusable_project_context(description): description = "" project_scope = current_scope if not project_scope: @@ -190,12 +190,12 @@ class FeishuPipelineContextPrefillBMixin: project_scope: str, ) -> str: normalized_title = normalize_project_title_candidate(title) - if normalized_title and not cls._looks_like_placeholder_project_text(normalized_title): + if normalized_title and not cls._is_placeholder_project_text(normalized_title): return normalized_title for candidate in (project_scope, description): cleaned = str(candidate or "").strip() - if not cleaned or cls._looks_like_placeholder_project_text(cleaned): + if not cleaned or cls._is_placeholder_project_text(cleaned): continue normalized = normalize_project_title_candidate(cleaned[:48]) if normalized: @@ -212,10 +212,10 @@ class FeishuPipelineContextPrefillBMixin: fallback_text: str, ) -> str: normalized_description = str(description or "").strip() - if cls._looks_like_placeholder_project_text(normalized_description): + if cls._is_placeholder_project_text(normalized_description): normalized_description = "" normalized_scope = str(project_scope or "").strip() - if cls._looks_like_placeholder_project_text(normalized_scope): + if cls._is_placeholder_project_text(normalized_scope): normalized_scope = "" fragments: list[str] = [] diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py index 88a40231..e358ac02 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_core_base.py @@ -2,7 +2,6 @@ from __future__ import annotations from .shared import * -from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class FeishuPipelineCoreBaseMixin: @@ -34,32 +33,12 @@ class FeishuPipelineCoreBaseMixin: @staticmethod def _is_healthcheck_message(message: str) -> bool: - return WorkflowSemanticPlanner.is_healthcheck_message(message) - - @staticmethod - def _is_short_confirmation_message(message: str) -> bool: - return WorkflowSemanticPlanner.is_short_confirmation_noise(message) + return bool(HEALTHCHECK_RE.search(str(message or "").strip())) @staticmethod def _is_correction_message(message: str) -> bool: return bool(CORRECTION_CUE_RE.search(str(message or "").strip())) - @staticmethod - def _looks_like_continue_followup(message: str) -> bool: - return WorkflowSemanticPlanner.wants_followup_continue(message) - - @staticmethod - def _looks_like_task_create_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.looks_like_task_create_request(message) - - @staticmethod - def _looks_like_project_create_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.looks_like_project_create_request(message) - - @staticmethod - def _looks_like_external_connect_request(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.looks_like_external_connect_request(message) - @staticmethod def _can_execute_high_value_action(user: Optional[User]) -> bool: if user is None: diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py index 46ea2629..5e0b5f4a 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_intent_gate_core.py @@ -2,6 +2,7 @@ from __future__ import annotations from .shared import * +from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class FeishuPipelineIntentGateCoreMixin: @@ -27,9 +28,23 @@ class FeishuPipelineIntentGateCoreMixin: "correction_match": None, } - explicit_project_request = self._looks_like_project_create_request(text) - explicit_task_request = self._looks_like_task_create_request(text) - explicit_external_request = self._looks_like_external_connect_request(text) + semantic_bundle = await WorkflowSemanticPlanner.aplan_message( + message_text=text, + source_system="feishu_signal_pipeline", + metadata={ + "project_id": inferred_project_id, + "source_chat_id": source_chat_id, + "source_sender_open_id": source_sender_open_id, + "sender": sender, + "source_message_id": source_message_id, + "thread_context": dict(thread_context or {}), + }, + ) + workflow_types = {str(item.workflow_type or "").strip().lower() for item in semantic_bundle.workflows} + query_only = bool(semantic_bundle.workflows) and workflow_types == {"query_flow"} + explicit_project_request = "new_project_flow" in workflow_types + explicit_task_request = "task_flow" in workflow_types + explicit_external_request = "signal_source_flow" in workflow_types correction_match: Optional[dict[str, Any]] = None if self.db is not None and len(text) >= 4: try: @@ -76,16 +91,6 @@ class FeishuPipelineIntentGateCoreMixin: "fields": {}, "correction_match": correction_match, } - if self._is_short_confirmation_message(text): - return { - "intent": INTENT_SMALLTALK, - "canonical_action": None, - "clarification_template": "", - "risk_level": "low", - "fields": {}, - "correction_match": correction_match, - } - if ( correction_match and float(correction_match.get("score") or 0.0) >= 0.84 @@ -175,17 +180,7 @@ class FeishuPipelineIntentGateCoreMixin: "correction_match": correction_match, } - if self._is_query_like_question(text): - return { - "intent": INTENT_QUERY, - "canonical_action": None, - "clarification_template": "", - "risk_level": "low", - "fields": {}, - "correction_match": correction_match, - } - - if self._is_obvious_question(text): + if query_only: return { "intent": INTENT_QUERY, "canonical_action": None, @@ -193,6 +188,7 @@ class FeishuPipelineIntentGateCoreMixin: "risk_level": "low", "fields": {}, "correction_match": correction_match, + "semantic_bundle": semantic_bundle.to_dict(), } return { @@ -257,7 +253,7 @@ class FeishuPipelineIntentGateCoreMixin: } @staticmethod - def _looks_like_contextual_project_request(message: str) -> bool: # semantic-lint: ignore - context prefill helper for missing fields only + def _is_contextual_project_request(message: str) -> bool: normalized = re.sub(r"\s+", "", str(message or "")).lower() if not normalized: return False diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py index 879649fc..ee09bb0a 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_lookup_and_block.py @@ -2,21 +2,50 @@ from __future__ import annotations from .shared import * -from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class FeishuPipelineLookupAndBlockMixin: @staticmethod - def _looks_like_project_create_command(message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.looks_like_project_create_request(message) + def _extract_project_lookup_keyword(message: str) -> str: + text = str(message or "").strip() + if not text: + return "" + text = text.replace("?", "?") + + direct_patterns = [ + r"(?:是否有|有没有|有无)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?", + r"(?:是否存在|存在)([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)?", + ] + for pattern in direct_patterns: + match = re.search(pattern, text) + if not match: + continue + value = str(match.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") + value = QUESTION_NOISE_RE.sub("", value).strip() + if len(value) >= 2: + return value[:24] + + anchored_matches = list(re.finditer(r"([\u4e00-\u9fffA-Za-z0-9_-]{1,24})(?:项目|系统)", text)) + for match in reversed(anchored_matches): + value = str(match.group(1) or "").strip(" ,。,.!??::;;“”\"'()()") + value = QUESTION_NOISE_RE.sub("", value).strip() + if len(value) >= 2: + return value[:24] + + return "" @classmethod - def _looks_like_project_lookup_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.looks_like_project_lookup_question(message) - - @classmethod - def _is_legacy_business_command(cls, message: str) -> bool: - return WorkflowSemanticPlanner.is_legacy_business_command(message) + def _is_project_lookup_question(cls, message: str) -> bool: + raw = str(message or "").strip() + if not raw: + return False + if PROJECT_ID_QUERY_RE.search(raw) or PROJECT_LIST_QUERY_RE.search(raw): + return True + if "项目" not in raw and "系统" not in raw: + return False + if "?" not in raw and "?" not in raw and not any(token in raw for token in ("有没有", "是否有", "有无", "存在", "列表", "清单")): + return False + return bool(cls._extract_project_lookup_keyword(raw)) async def _maybe_block_legacy_command_request( self, @@ -31,16 +60,18 @@ class FeishuPipelineLookupAndBlockMixin: projects: list[dict[str, Any]], process_started_at: float, ) -> Optional[dict[str, Any]]: - if not self._is_legacy_business_command(message): - return None - - _ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects - return await self._build_legacy_command_blocked_capture( - message=message, - feishu_message_id=feishu_message_id, - duration_ms=int((time.perf_counter() - process_started_at) * 1000), - reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。", + _ = ( + message, + sender, + source_chat_id, + source_sender_open_id, + feishu_message_id, + captured_at, + inferred_project_id, + projects, + process_started_at, ) + return None async def _maybe_answer_project_lookup_question( self, @@ -50,7 +81,7 @@ class FeishuPipelineLookupAndBlockMixin: process_started_at: float, projects: list[dict[str, Any]], ) -> Optional[dict[str, Any]]: - if not self._looks_like_project_lookup_question(message): + if not self._is_project_lookup_question(message): return None linked_project_ids: list[int] = [] @@ -71,7 +102,7 @@ class FeishuPipelineLookupAndBlockMixin: reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目") linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))] else: - keyword = WorkflowSemanticPlanner.extract_project_lookup_keyword(message) + keyword = self._extract_project_lookup_keyword(message) matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword) if matched_rows: linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))] @@ -195,9 +226,7 @@ class FeishuPipelineLookupAndBlockMixin: return True if cls._is_healthcheck_message(raw_message): return True - if cls._is_short_confirmation_message(raw_message): - return True - if cls._looks_like_project_lookup_question(raw_message): + if cls._is_project_lookup_question(raw_message): return True if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")): return True diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py index 1a5b69ac..56bc9795 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_default.py @@ -35,17 +35,14 @@ class FeishuPipelineProcessMessageDefaultMixin: intent_for_response = "statement" force_signal_fastpath = False rag_question_part = message + intent_result = await rag_service.classify_intent(message) + intent = str(intent_result.get("intent") or "statement").lower() + intent_for_response = intent + rag_question_part = str(intent_result.get("question_part") or "").strip() or message + rag_statement_part = str(intent_result.get("statement_part") or "").strip() - if self._is_obvious_question(message): - intent_result: dict[str, Any] = {} - try: - intent_result = await rag_service.classify_intent(message) - except Exception: - intent_result = {} - intent = str(intent_result.get("intent") or "question").lower() - rag_question_part = str(intent_result.get("question_part") or "").strip() or message - rag_statement_part = str(intent_result.get("statement_part") or "").strip() - if intent == "mixed" or self._is_requirement_like_question(message): + if intent == "question": + if self._is_requirement_like_question(message): mixed_signal_part = ( rag_statement_part or self._convert_requirement_question_to_signal_text( @@ -88,89 +85,34 @@ class FeishuPipelineProcessMessageDefaultMixin: inferred_project_id=None, feishu_message_id=feishu_message_id, process_started_at=process_started_at, - route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。", - intent_label="question_obvious", + route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。", + intent_label=intent, normalized_chat_type=normalized_chat_type, ) - else: - intent_result = await rag_service.classify_intent(message) - intent = str(intent_result.get("intent") or "statement").lower() - intent_for_response = intent - rag_question_part = str(intent_result.get("question_part") or "").strip() or message - rag_statement_part = str(intent_result.get("statement_part") or "").strip() - if intent == "question": - if self._is_requirement_like_question(message): - mixed_signal_part = ( - rag_statement_part - or self._convert_requirement_question_to_signal_text( - message=message, - fallback_content=message_content, - ) - ) - mixed_rag_result = await rag_service.answer_question( - question=rag_question_part, - asked_by=None, - asked_via="feishu", - project_id=None, - source_chat_id=source_chat_id, - source_message_id=feishu_message_id, - conversation_key=conversation_key, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - message_intent="mixed", - mixed_signal_part=mixed_signal_part, - ) - sidecar_rag_result = mixed_rag_result - intent_for_response = "question_requirement" - message = mixed_signal_part - force_signal_fastpath = True - else: - rag_result = await rag_service.answer_question( - question=rag_question_part, - asked_by=None, - asked_via="feishu", - project_id=None, - source_chat_id=source_chat_id, - source_message_id=feishu_message_id, - conversation_key=conversation_key, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - ) - return await self._finalize_direct_qa_capture_result( - message=message, - rag_result=rag_result, - inferred_project_id=None, - feishu_message_id=feishu_message_id, - process_started_at=process_started_at, - route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。", - intent_label=intent, - normalized_chat_type=normalized_chat_type, - ) - - if intent == "mixed": - mixed_signal_part = ( - rag_statement_part - or self._convert_requirement_question_to_signal_text( - message=message, - fallback_content=message_content, - ) + if intent == "mixed": + mixed_signal_part = ( + rag_statement_part + or self._convert_requirement_question_to_signal_text( + message=message, + fallback_content=message_content, ) - mixed_rag_result = await rag_service.answer_question( - question=rag_question_part, - asked_by=None, - asked_via="feishu", - project_id=None, - source_chat_id=source_chat_id, - source_message_id=feishu_message_id, - conversation_key=conversation_key, - source_sender_open_id=source_sender_open_id, - thread_context=thread_context, - message_intent="mixed", - mixed_signal_part=mixed_signal_part, - ) - message = mixed_signal_part - sidecar_rag_result = mixed_rag_result + ) + mixed_rag_result = await rag_service.answer_question( + question=rag_question_part, + asked_by=None, + asked_via="feishu", + project_id=None, + source_chat_id=source_chat_id, + source_message_id=feishu_message_id, + conversation_key=conversation_key, + source_sender_open_id=source_sender_open_id, + thread_context=thread_context, + message_intent="mixed", + mixed_signal_part=mixed_signal_part, + ) + message = mixed_signal_part + sidecar_rag_result = mixed_rag_result try: if force_signal_fastpath: diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py index 270593ca..0345a611 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_process_message_helpers_a.py @@ -2,7 +2,6 @@ from __future__ import annotations from .shared import * -from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class FeishuPipelineProcessMessageHelpersAMixin: @@ -175,11 +174,3 @@ class FeishuPipelineProcessMessageHelpersAMixin: text = re.sub(r"\n{2,}", "\n", text) text = re.sub(r"\s+", " ", text).strip() return text[:2000] - - @classmethod - def _is_obvious_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.is_obvious_question(message) - - @classmethod - def _is_query_like_question(cls, message: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.is_query_like_question(message) diff --git a/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py b/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py index e7d152a4..c276d15b 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/mixin_serialization.py @@ -68,9 +68,9 @@ class FeishuPipelineSerializationMixin: "ready": False, } if ( - self._looks_like_placeholder_project_text(project_fields["title"]) - or self._looks_like_placeholder_project_text(project_fields["description"]) - or self._looks_like_placeholder_project_text(project_fields["project_scope"]) + self._is_placeholder_project_text(project_fields["title"]) + or self._is_placeholder_project_text(project_fields["description"]) + or self._is_placeholder_project_text(project_fields["project_scope"]) ): project_fields = await self._maybe_prefill_project_fields_from_context( message=str(item.raw_message or ""), diff --git a/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py index 380752dc..d743fa72 100644 --- a/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py +++ b/backend/app/services/integrations/feishu_signal_pipeline/shared_core.py @@ -41,33 +41,6 @@ from ...ai_pmo.ai_pmo_kernel import ( gate_execution as kernel_gate_execution, resolve_correction as kernel_resolve_correction, ) -from ...ai_pmo.workflow_semantic_planner.constants import ( - CORRECTION_CUE_RE, - CREATE_ACTION_RE, - EXTERNAL_INTEGRATION_RE, - FOLLOWUP_CONTINUE_RE, - FOLLOWUP_CREATE_CONTINUE_RE, - FOLLOWUP_REFERENCE_RE, - FOLLOWUP_STATUS_RE, - HEALTHCHECK_RE, - INDEPENDENT_REQUIREMENT_RE, - LEGACY_COMMAND_OBJECT_RE, - LEGACY_COMMAND_PROJECT_CREATE_RE, - LEGACY_COMMAND_VERB_RE, - OBVIOUS_QUESTION_RE, - OWNER_RE, - PROJECT_COMPLEXITY_RE, - PROJECT_ID_QUERY_RE, - PROJECT_INTENT_RE, - PROJECT_LIST_QUERY_RE, - QUERY_INTENT_RE, - QUESTION_REQUIREMENT_OBJECT_RE, - QUESTION_REQUIREMENT_VERB_RE, - SHORT_CONFIRM_RE, - SIGNAL_FACT_RE, - SIMPLE_SIGNAL_REQUEST_RE, - TIMELINE_RE, -) from ...context_cache_service import context_cache_service from ...notification_service import NotificationService from ...operation_log_service import log_operation diff --git a/backend/app/services/signal/draft_generator_service.py b/backend/app/services/signal/draft_generator_service.py index 48b9854d..06c20ee8 100644 --- a/backend/app/services/signal/draft_generator_service.py +++ b/backend/app/services/signal/draft_generator_service.py @@ -153,7 +153,7 @@ class DraftGeneratorService: max_tokens=max_tokens, error=str(exc), ) - if self._looks_like_provider_budget_rejection(exc): + if self._is_provider_budget_rejection(exc): break continue payload = extraction.payload if extraction is not None and isinstance(extraction.payload, dict) else {} @@ -199,7 +199,7 @@ class DraftGeneratorService: return ordered @staticmethod - def _looks_like_provider_budget_rejection(exc: Exception) -> bool: + def _is_provider_budget_rejection(exc: Exception) -> bool: lowered = str(exc or "").lower() return "400 bad request" in lowered and any( token in lowered diff --git a/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py b/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py index 48fb862c..77843667 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py +++ b/backend/app/services/signal/signal_capture_service/mixin_analysis_normalize.py @@ -204,26 +204,11 @@ class SignalCaptureAnalysisNormalizeMixin: if not content: return True normalized = re.sub(r"\s+", "", content) - if content in NON_REQUIREMENT_EXACT: - return True - if normalized in NON_REQUIREMENT_EXACT: - return True - if any(k in normalized for k in NON_REQUIREMENT_CONTAINS) and not any( - hint in normalized for hint in REQUIREMENT_HINTS - ): - return True if len(content) <= 2 and content.isdigit(): return True - non_requirement_keywords = [ - "收到", - "辛苦了", - "赞", - "哈哈", - "good", - "nice", - "ok", - ] - if len(content) <= 8 and any(k in content for k in non_requirement_keywords): + # Keep this structural-only: very short acknowledgements/noise can be dropped, + # but anything longer should go to the model/kernel instead of keyword heuristics. + if len(normalized) <= 3 and not any(token in content for token in ("?", "?", "项目", "需求", "任务", "信号")): return True if ONLY_PUNCT_OR_SPACE_RE.match(content): return True 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 fb9c1e36..2ee5392b 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 @@ -11,7 +11,7 @@ class SignalCaptureEntryMixin: self.llm = LLMService() @classmethod - def _should_process_feishu_high_accuracy( + async def _should_process_feishu_high_accuracy( cls, *, message_text: str, @@ -20,7 +20,7 @@ class SignalCaptureEntryMixin: if not clean_text: return False, "empty" - bundle = WorkflowSemanticPlanner.plan_message( + bundle = await WorkflowSemanticPlanner.aplan_message( message_text=clean_text, source_system="feishu", ) @@ -131,7 +131,7 @@ class SignalCaptureEntryMixin: should_sync_process = bool(force_sync) sync_reason = "force_sync" if should_sync_process else "" if not should_sync_process: - should_sync_process, sync_reason = self._should_process_feishu_high_accuracy( + should_sync_process, sync_reason = await self._should_process_feishu_high_accuracy( message_text=cleaned_message, ) @@ -145,21 +145,13 @@ class SignalCaptureEntryMixin: source_message_id=source_message_id, thread_context=thread_context, ) - should_sync_process = kernel._is_short_confirmation_message( - message_text=cleaned_message, - session=enqueue_session, - message_sent_at=message_sent_at, - session_snapshot=session_snapshot, - ) - if should_sync_process: - sync_reason = "short_confirmation" if should_sync_process: semantic_bundle = None normalized_sync_reason = str(sync_reason or "").strip().lower() should_forward_semantic_bundle = bool(force_sync) or normalized_sync_reason.startswith("semantic:") if should_forward_semantic_bundle: - candidate_bundle = WorkflowSemanticPlanner.plan_message( + candidate_bundle = await WorkflowSemanticPlanner.aplan_message( message_text=cleaned_message, source_system="feishu", ) @@ -407,23 +399,26 @@ class SignalCaptureEntryMixin: ) except Exception: pass - try: - await self.db.commit() - except Exception as exc: - self._emit_ai_think( - "capture_kernel_commit_failed", - trace_id=trace_id, - error=str(exc), - ) - await self._rollback_capture_failure(trace_id=trace_id) - return self._build_kernel_error_result( - trace_id=trace_id, - reason="kernel_commit_failed", - error=str(exc), - ) + if hasattr(self.db, "commit"): + try: + await self.db.commit() + except Exception as exc: + self._emit_ai_think( + "capture_kernel_commit_failed", + trace_id=trace_id, + error=str(exc), + ) + await self._rollback_capture_failure(trace_id=trace_id) + return self._build_kernel_error_result( + trace_id=trace_id, + reason="kernel_commit_failed", + error=str(exc), + ) return pipeline_result async def _rollback_capture_failure(self, *, trace_id: str) -> None: + if not hasattr(self.db, "rollback"): + return try: await self.db.rollback() except Exception as rollback_exc: diff --git a/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py index af1526ba..8539b5e6 100644 --- a/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py +++ b/backend/app/services/signal/signal_capture_service/mixin_pipeline_cache.py @@ -111,7 +111,10 @@ class SignalCapturePipelineCacheMixin: return pipeline_result source_orchestration_result: Optional[Dict[str, Any]] = None - if WorkflowSemanticPlanner.looks_like_signal_source_request(cleaned_message): + if ( + str(pipeline_result.get("intent") or "").strip().lower() == INTENT_SIGNAL_SOURCE_ONBOARD + or str(pipeline_result.get("workflow_route") or "").strip().lower() == ROUTE_SIGNAL_SOURCE_ONBOARD + ): try: source_orchestration_result = await SignalSourceOrchestrationService(self.db).orchestrate_from_signal( tenant_id=tenant_id, diff --git a/backend/app/services/signal/signal_capture_service/shared.py b/backend/app/services/signal/signal_capture_service/shared.py index f8e8739b..52a0f257 100644 --- a/backend/app/services/signal/signal_capture_service/shared.py +++ b/backend/app/services/signal/signal_capture_service/shared.py @@ -41,11 +41,6 @@ from ...llm_service import LLMService from ...notification_service import NotificationService from ... import workload as workload_service from ...adaptive_rag_service import AdaptiveRAGService -from ...ai_pmo.workflow_semantic_planner.constants import ( - NON_REQUIREMENT_CONTAINS, - NON_REQUIREMENT_EXACT, - REQUIREMENT_HINTS, -) from ...ai_orchestration_run import AIOrchestrationRunService from ...context_cache_service import context_cache_service from ..signal_source_orchestration import SignalSourceOrchestrationService diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py b/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py index 55c196c0..ab99d877 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_entry_orchestrate.py @@ -1,16 +1,11 @@ from __future__ import annotations from .shared import * -from ...ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class SignalSourceEntryOrchestrateMixin: def __init__(self, db: AsyncSession) -> None: self.db = db - @classmethod - def looks_like_signal_source_request(cls, text: str) -> bool: # semantic-lint: ignore - compat wrapper over workflow_semantic_planner - return WorkflowSemanticPlanner.looks_like_signal_source_request(text) - async def orchestrate_from_signal( self, *, @@ -61,17 +56,6 @@ class SignalSourceEntryOrchestrateMixin: real_signal_id = signal_ref.id persisted_signal_id = int(real_signal_id) if real_signal_id and int(real_signal_id) > 0 else None - if not self.looks_like_signal_source_request(initiating_text): - return await self._handle_non_source_request( - tenant_id=tenant_id, - project_id=project_id, - persisted_signal_id=persisted_signal_id, - operator_user_id=operator_user_id, - trace_id=trace_id, - response_mode=response_mode, - run_service=run_service, - ) - plan, planner_status, planner_diagnostics = await self._plan_onboarding( tenant_id=tenant_id, project=project_ref, diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_planning_locator_roles.py b/backend/app/services/signal/signal_source_orchestration/mixin_planning_locator_roles.py index 1a346fbc..ae7874e5 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_planning_locator_roles.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_planning_locator_roles.py @@ -63,14 +63,6 @@ class SignalSourcePlanningLocatorRolesMixin: "复合", "复合信号源", } - for kind, hints in SOURCE_KIND_HINTS: - if kind != source_kind: - continue - generic_tokens.update( - re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(token)).lower() - for token in hints - ) - break return normalized in generic_tokens @staticmethod @@ -92,4 +84,3 @@ class SignalSourcePlanningLocatorRolesMixin: "请创建必要任务、分配合适负责人,并通知相关成员。" f"补充上下文:{str(initiating_text or '').strip()[:240]}" ) - diff --git a/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py b/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py index 6597f163..9b564e12 100644 --- a/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py +++ b/backend/app/services/signal/signal_source_orchestration/mixin_planning_onboard.py @@ -155,7 +155,12 @@ class SignalSourcePlanningOnboardMixin: fallback_text: str, requested_via: str, ) -> dict[str, Any]: - source_kind = self._normalize_source_kind(source_payload.get("source_kind"), text=fallback_text, requested_via=requested_via) + source_kind = self._normalize_source_kind( + source_payload.get("source_kind"), + text=fallback_text, + requested_via=requested_via, + source_payload=source_payload, + ) source_name = self._clean_source_name_candidate(str(source_payload.get("source_name") or "").strip()) if not source_name: source_name = self._extract_source_name(fallback_text, default=f"{source_kind}信号源") @@ -183,20 +188,36 @@ class SignalSourcePlanningOnboardMixin: } @staticmethod - def _normalize_source_kind(raw_kind: Any, *, text: str, requested_via: str) -> str: + def _normalize_source_kind(raw_kind: Any, *, text: str, requested_via: str, source_payload: Optional[dict[str, Any]] = None) -> str: cleaned = str(raw_kind or "").strip().lower() - for kind, hints in SOURCE_KIND_HINTS: - if cleaned == kind: - return kind - if any(token in cleaned for token in hints): - return kind - text_lower = str(text or "").lower() - for kind, hints in SOURCE_KIND_HINTS: - if any(token in text_lower for token in hints): - return kind + payload = source_payload if isinstance(source_payload, dict) else {} + config = payload.get("config") if isinstance(payload.get("config"), dict) else {} + if cleaned in SUPPORTED_SOURCE_KINDS: + return cleaned + via = str(requested_via or "").strip().lower() if via in {"feishu", "pmflow", "github", "browser"}: return via + + if any(str(payload.get(key) or config.get(key) or "").strip() for key in ("repo", "repository")): + return "github" + if any(str(payload.get(key) or config.get(key) or "").strip() for key in ("channel_id", "chat_id")): + return "feishu" + if any(str(payload.get(key) or config.get(key) or "").strip() for key in ("webhook_url",)): + return "webhook" + if any(str(payload.get(key) or config.get(key) or "").strip() for key in ("url",)): + return "url" + + explicit_locator = SignalSourcePlanningOnboardMixin._extract_explicit_source_locator( + source_payload=payload, + text=text, + ) + if explicit_locator.startswith("repo:"): + return "github" + if explicit_locator.startswith("chat:"): + return "feishu" + if explicit_locator.startswith(("http://", "https://")): + return "webhook" if cleaned == "webhook" else "url" return "custom" @classmethod @@ -268,4 +289,3 @@ class SignalSourcePlanningOnboardMixin: if normalized in {"", "项目id", "给项目id", "复合", "复合信号源", "信号源", "来源", "监控源"}: return "" return cleaned - diff --git a/backend/app/services/signal/signal_source_orchestration/shared.py b/backend/app/services/signal/signal_source_orchestration/shared.py index b2995bf0..c5380077 100644 --- a/backend/app/services/signal/signal_source_orchestration/shared.py +++ b/backend/app/services/signal/signal_source_orchestration/shared.py @@ -34,16 +34,8 @@ from ...model.prompt_profile import prompt_profile_service from ...llm_service import llm_service from ....utils.timezone import now_cn_naive -from ...ai_pmo.workflow_semantic_planner.constants import SOURCE_ONBOARDING_ACTION_RE -SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = ( - ("feishu", ("飞书", "lark", "群聊", "chat")), - ("github", ("github", "repo", "仓库", "pull request", "issue")), - ("webhook", ("webhook", "回调", "回传")), - ("url", ("url", "链接", "网页", "站点", "rss")), - ("browser", ("浏览器", "标签页", "页面")), - ("pmflow", ("pmflow", "内置ai", "ai管家", "steward")), -) +SUPPORTED_SOURCE_KINDS = ("feishu", "github", "webhook", "url", "browser", "pmflow", "custom") DEFAULT_WORKER_ROLE_KEYS = ("pm_dispatcher", "tech_architect", "delivery_coach") MAX_SOURCE_NAME_LEN = 120 MAX_SOURCE_IDENTIFIER_LEN = 180 diff --git a/backend/tests/test_adaptive_rag_mainline_contract.py b/backend/tests/test_adaptive_rag_mainline_contract.py index e51f7978..d60c3bdd 100644 --- a/backend/tests/test_adaptive_rag_mainline_contract.py +++ b/backend/tests/test_adaptive_rag_mainline_contract.py @@ -9,8 +9,10 @@ from app.services.adaptive_rag_service.mixin_deep_guard_rules import AdaptiveRAG from app.services.adaptive_rag_service.mixin_deterministic_query_core import ( AdaptiveRAGDeterministicQueryCoreMixin, ) +from app.services.adaptive_rag_service.mixin_requirement_ack_core import AdaptiveRAGRequirementAckCoreMixin from app.services.adaptive_rag_service.mixin_retrieval_vector import AdaptiveRAGRetrievalVectorMixin -from app.services.adaptive_rag_service.shared_part_02 import DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K +from app.services.adaptive_rag_service.shared_part_02 import ACTION_MODE_READ, DEEP_RETRIEVAL_STEPS, DEEP_VECTOR_TOP_K +from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner class _FakeAdaptiveRAGService( @@ -168,6 +170,49 @@ async def test_answer_entry_feishu_write_mode_still_goes_base_mainline() -> None assert int(service.base_kwargs["deep_vector_top_k"]) == DEEP_VECTOR_TOP_K +@pytest.mark.asyncio +async def test_requirement_ack_auto_mode_respects_structured_query_bundle(monkeypatch: pytest.MonkeyPatch) -> None: + class _ModeService(AdaptiveRAGRequirementAckCoreMixin): + pass + + async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): + _ = (cls, message_text, source_system, metadata) + return WorkflowSemanticPlanner.deserialize_bundle( + { + "bundle_id": "semantic-test", + "semantic_version": "v1", + "source_system": "adaptive_rag_ack", + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.91, + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto", + "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"}, + "diagnostics": {"message_intent": "question"}, + } + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) + + mode = await _ModeService._resolve_action_mode_async( + requested_mode="auto", + question="先不要创建任务,告诉我当前进展和风险", + effective_question="先不要创建任务,告诉我当前进展和风险", + asked_via="pmflow_manual", + ) + + assert mode == ACTION_MODE_READ + + def test_query_runtime_planner_prefers_fast_path_for_list_question_even_if_requested_deep() -> None: class _FakePlannerService(AdaptiveRAGAnswerMainlineMixin): pass diff --git a/backend/tests/test_ai_pmo_ingress_mainline.py b/backend/tests/test_ai_pmo_ingress_mainline.py index 42c2e201..a99a5793 100644 --- a/backend/tests/test_ai_pmo_ingress_mainline.py +++ b/backend/tests/test_ai_pmo_ingress_mainline.py @@ -1,4 +1,5 @@ import asyncio +import json from datetime import datetime, timedelta from types import SimpleNamespace @@ -35,13 +36,17 @@ from app.services.ai_pmo.ai_pmo_kernel.mixins.world_model import ( from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_mutations from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent +from app.services.ai_pmo.workflow_semantic_planner.models import ReplyContract, WorkflowBundle, WorkflowPlan from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.adaptive_rag_service import mixin_deterministic_query_core from app.services.adaptive_rag_service import mixin_fast_answer_project_main +from app.services.integrations.feishu_integration_service import mixin_reply_outbox as feishu_mixin_reply_outbox +from app.services.integrations.feishu_outbox.service import FeishuOutboxService from app.services.llm_service import ChatCompletionResult, StructuredExtractionResult from app.services.llm_service import mixin_tier_escalation from app.services.llm_service.mixin_tier_escalation import LLMTierEscalationMixin from app.models.base import Base +from app.models.feishu_reply_outbox import FeishuReplyOutbox from app.models.mounted_ai_runtime import MountedDraftWorkspace, MountedTopicWorkspace from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key @@ -88,6 +93,488 @@ async def requirement_plan_db_session(): await engine.dispose() +@pytest_asyncio.fixture +async def feishu_outbox_db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync( + lambda sync_conn: Base.metadata.create_all( + sync_conn, + tables=[FeishuReplyOutbox.__table__], + ) + ) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _build_long_requirement_plan_reply_text() -> str: + repeated = ",".join(["覆盖用户偏好、衣橱标签、天气温度、场景约束、历史反馈"] * 10) + return ( + "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。\n" + "现在最关键的是先把第一版定位定住。\n\n" + f"A. 轻量推荐工具\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n" + f"B. 业务平台能力\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n" + f"C. 长期能力底座\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n" + f"⭐ 当前我更推荐:B\n原因:{repeated}\n\n你回 A / B / C,我就顺着这条继续往下收。" + ) + + +def _install_requirement_plan_round_llm(monkeypatch, captured: list[dict[str, object]] | None = None) -> None: + def _extract_round_packet(prompt: str) -> dict[str, object]: + marker = "requirement_plan_round_packet:" + if marker not in prompt: + return {} + raw = prompt.split(marker, 1)[1].strip() + try: + payload = json.loads(raw) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + def _extract_draft_packet(prompt: str) -> dict[str, object]: + marker = "requirement_plan_draft_packet:" + if marker not in prompt: + return {} + raw = prompt.split(marker, 1)[1].strip() + try: + payload = json.loads(raw) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 + if captured is not None: + captured.append({"prompt": prompt, "kwargs": dict(kwargs)}) + function_name = str(kwargs.get("function_name") or "") + packet = _extract_round_packet(prompt) + context_blob = json.dumps(packet, ensure_ascii=False) + is_scoring_context = any( + token in context_blob + for token in ("大A趋势打分", "趋势打分", "投顾", "研究团队", "交易团队") + ) + if function_name == "pmflow_requirement_plan_draft": + draft_packet = _extract_draft_packet(prompt) + draft_blob = json.dumps(draft_packet, ensure_ascii=False) + if "换季系统自动推荐搭配衣服" in draft_blob or "个性化穿搭推荐平台" in draft_blob: + payload = { + "intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:", + "title": "换季系统自动推荐搭配衣服", + "one_liner": "先面向普通消费者做一版换季穿搭推荐试用,先把温度、场景和单品搭配建议跑成可验证闭环。", + "goal": "先验证用户是否愿意使用换季搭配推荐,并收集最基础的点击、收藏和反馈信号。", + "project_nature": "这件事更像个性化穿搭推荐平台的第一版,不是部署平台,也不是内部流程治理工具。", + "target_user": "第一版先服务普通消费者,重点看他们是否会点击、收藏和采纳推荐结果。", + "signal_inputs": "当前至少要用到:用户当前需求描述;季节/温度信息;衣物标签;基础反馈数据。", + "known_info": "目前已经明确:这是换季穿搭推荐需求;先按个性化穿搭推荐平台收敛;第一版先给普通消费者试用;技术路线先按规则推荐版;范围先保留温度、场景、单品搭配建议。", + "tech_route": "当前更适合先走规则推荐版,用季节、温度、场景和衣物标签跑通首轮推荐。", + "first_scope": "第一版先保留温度、场景、单品搭配建议,先验证推荐是否被点击、收藏和采纳。", + "core_flow": "用户进入换季推荐入口 -> 结合温度/场景生成搭配建议 -> 展示单品组合 -> 记录点击/收藏/反馈 -> 继续优化推荐规则", + "delivery_mode": "先做小范围真实用户试用,优先看真实点击、收藏和主观反馈。", + "success_signal": "先保证能稳定给出换季搭配建议,并拿到点击、收藏或反馈中的至少一类真实用户信号。", + "risks": "当前主要风险在冷启动标签质量、搭配规则是否过于死板,以及首轮反馈样本是否足够。", + "pending_items": ["衣物标签口径是否已有现成标准", "首轮试用用户范围和反馈采集方式", "是否需要同步接商品库或衣橱数据"], + "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + "positioning_one_liner": "面向普通消费者的小范围换季穿搭推荐试用,先跑通基础推荐闭环。", + "summary": "先用规则推荐跑通换季穿搭建议,再根据真实点击、收藏和反馈决定是否扩到更完整的平台能力。", + "target_customers": "普通消费者", + "external_signal_sources": [ + {"name": "天气 API", "purpose": "补充实时温度、体感和降雨变化", "phase": "首版优先接入"}, + {"name": "淘宝开放平台", "purpose": "补充可推荐商品的品类、颜色和季节属性", "phase": "首版优先接入"}, + {"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度和潮流变化", "phase": "第二阶段接入"}, + ], + "requirement_items": [ + {"section": "需求目标", "title": "验证用户价值", "description": "验证用户是否愿意使用换季搭配推荐,并留下点击、收藏或反馈。"}, + {"section": "服务对象", "title": "首批用户", "description": "第一版先服务普通消费者。"}, + {"section": "技术路线", "title": "规则推荐版", "description": "先基于季节、温度、场景和衣物标签生成推荐。"}, + {"section": "第一版范围", "title": "最小闭环", "description": "先保留温度、场景、单品搭配建议。"}, + ], + "missing_info": ["衣物标签标准", "试用用户范围", "反馈采集方式"], + "mvp_items": ["温度标签", "场景标签", "单品搭配建议", "基础点击/收藏反馈"], + "team_setup": [{"role": "建议配置", "notes": "产品/运营 1名、后端/算法 1名、前端 1名,先支撑小范围试用即可。"}], + "validation_metrics": {"product": ["推荐点击率", "收藏率", "用户主观反馈"]}, + } + elif any(token in draft_blob for token in ("大A趋势打分", "趋势打分", "投顾", "研究团队", "交易团队")): + payload = { + "intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:", + "title": "每日 A 股趋势打分工具", + "one_liner": "面向投顾与研究团队的轻量级趋势打分工具,先把每日评分、评分解释和使用反馈跑成闭环。", + "goal": "先验证趋势打分结果是否有参考价值,并让投顾或研究团队能快速查看、比对和反馈。", + "project_nature": "这件事更像一个投研辅助小工具,核心是先把每日趋势评分与解释能力跑通。", + "target_user": "第一版先服务投顾与研究团队,优先满足他们查看评分、理解原因和给反馈的需要。", + "signal_inputs": "当前至少要用到:行情数据、基础技术指标、评分规则、投顾或研究团队反馈。", + "known_info": "目前已经明确:这是每日 A 股趋势打分工具;当前按投研辅助工具收敛;主要给投顾或研究团队看;技术路线先按评分服务版;第一版先保留每日评分、评分解释和重点股票列表。", + "tech_route": "当前更适合先走评分服务版,用规则与指标服务生成每日趋势分数和解释。", + "first_scope": "第一版先保留每日趋势评分、评分解释和重点股票列表,先验证结果是否可用。", + "core_flow": "拉取行情与指标 -> 计算每日趋势分数 -> 输出评分解释与重点股票 -> 投顾查看与反馈 -> 根据反馈调整评分规则", + "delivery_mode": "先做小范围投研试用,优先看投顾和研究团队是否会实际参考这份评分结果。", + "success_signal": "先保证能稳定产出每日评分结果,并让投顾或研究团队留下查看、引用或反馈中的至少一类真实使用信号。", + "risks": "当前主要风险在评分口径是否足够稳定、解释是否被投顾认可,以及反馈样本是否足够支持后续优化。", + "pending_items": ["评分口径是否需要区分短线和中线", "重点股票列表的筛选边界", "反馈采集方式与频率"], + "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + "positioning_one_liner": "面向投顾与研究团队的每日趋势评分试用,先跑通评分与解释闭环。", + "summary": "先用轻量评分服务跑通每日趋势打分,再根据真实使用反馈决定是否扩到更完整的投研分析平台。", + "target_customers": "投顾、研究团队", + "external_signal_sources": [ + {"name": "Tushare API", "purpose": "补充日线行情、成交量和基础财务指标", "phase": "首版优先接入"}, + {"name": "同花顺 iFinD", "purpose": "补充板块热度与市场情绪相关指标", "phase": "第二阶段接入"}, + {"name": "Wind API", "purpose": "补充更完整的因子与指数成分数据", "phase": "第二阶段接入"}, + ], + "requirement_items": [ + {"section": "需求目标", "title": "验证评分可用性", "description": "验证每日趋势打分是否真的能帮助投顾或研究团队更快做初筛。"}, + {"section": "服务对象", "title": "首批用户", "description": "第一版先服务投顾与研究团队。"}, + {"section": "技术路线", "title": "评分服务版", "description": "先基于规则与指标服务生成每日评分与解释。"}, + {"section": "第一版范围", "title": "最小闭环", "description": "先保留每日评分、评分解释和重点股票列表。"}, + ], + "missing_info": ["评分口径边界", "重点股票筛选标准", "反馈采集方式"], + "mvp_items": ["每日趋势评分", "评分解释", "重点股票列表", "基础反馈记录"], + "team_setup": [{"role": "建议配置", "notes": "产品/投研 1名、后端/数据 1名、前端 1名,先支撑小范围投研试用即可。"}], + "validation_metrics": {"product": ["评分查看率", "投顾引用率", "研究团队反馈数"]}, + } + elif "批量自动化部署" in draft_blob or "openclaw" in draft_blob: + payload = { + "intro": "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", + "title": "OpenClaw 批量自动化部署平台", + "one_liner": "先按面向平台运维团队的项目级试点推进,先把 OpenClaw 批量部署、执行追踪和失败恢复跑成一条闭环。", + "goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", + "project_nature": "这件事更像发布治理平台,需要稳定承接批量执行、状态追踪和治理边界。", + "target_user": "第一版先服务平台运维团队,他们会最早使用、反馈和承担执行确认。", + "signal_inputs": "当前至少要接入:飞书需求消息;部署目标清单(服务/环境/实例);代码仓库/镜像信息;环境参数与凭证;执行日志与失败日志。", + "known_info": "目前已经明确:当前要解决的是 OpenClaw 批量自动化部署;当前按发布治理平台收敛;主要服务平台运维团队;技术路线先按任务编排平台版;第一版先按批量执行闭环推进;推进方式先按项目级试点(带基础审批和回滚)。", + "tech_route": "当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL),先把批量执行、状态追踪和失败重试稳定下来。", + "first_scope": "第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。", + "core_flow": "提交部署需求 -> 生成部署任务 -> 选择目标环境 -> 执行部署 -> 回传结果/日志 -> 失败重试或回滚 -> 留痕审计", + "delivery_mode": "先按项目级试点(带基础审批和回滚)推进,在真实项目里验证价值和流程问题。", + "success_signal": "先保证能稳定创建部署任务、看到执行结果、识别失败原因,并在试点里跑完一次真实闭环。", + "risks": "当前主要风险在部署目标范围、环境访问权限与凭证归属、仓库/镜像/配置规范、凭证管理,以及失败重试与回滚策略。", + "pending_items": ["部署目标范围(服务/环境/实例)", "仓库/镜像/配置来源", "环境权限与凭证归属", "试点负责人和投入人天"], + "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + "positioning_one_liner": "面向平台运维团队的发布治理平台试点,先跑通批量执行与失败恢复闭环。", + "summary": "先做发布治理平台试点,把批量执行闭环在真实项目里跑通,再继续扩治理能力。", + "target_customers": "平台运维团队", + "external_signal_sources": [ + {"name": "CMDB API", "purpose": "补充部署目标实例、环境和服务清单", "phase": "首版优先接入"}, + {"name": "GitLab API", "purpose": "补充仓库、分支、提交与流水线信息", "phase": "首版优先接入"}, + {"name": "Harbor Registry API", "purpose": "补充镜像标签、仓库和发布时间", "phase": "首版优先接入"}, + ], + "requirement_items": [ + {"section": "项目性质", "title": "发布治理平台", "description": "需要稳定承接批量执行、状态追踪和治理边界。"}, + {"section": "服务对象", "title": "平台运维团队", "description": "第一版先服务平台运维团队。"}, + {"section": "技术路线", "title": "任务编排平台版", "description": "用 FastAPI + React + Redis + Celery + PostgreSQL 承接任务编排。"}, + {"section": "第一版范围", "title": "批量执行闭环", "description": "先包含任务创建、执行日志和失败重试。"}, + ], + "missing_info": ["部署目标范围", "凭证归属", "试点负责人"], + "mvp_items": ["任务创建", "执行日志", "失败重试", "回滚能力"], + "team_setup": [{"role": "建议配置", "notes": "需求负责人/PM 1名;后端工程师 1名;前端工程师 1名;平台/运维工程师 1名;测试/试点验收 1名;试点负责人 1名。"}], + "validation_metrics": {"product": ["任务创建成功率", "部署执行完成率", "失败原因可识别率"]}, + } + else: + payload = { + "intro": "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", + "title": "企业级项目管理系统里的需求收敛功能", + "one_liner": "先按面向项目管理团队的项目级试点推进,先把需求收敛、待确认项和范围确认闭环跑通。", + "goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", + "project_nature": "这件事更像发布治理平台式的业务协作能力,需要承接多人协作、状态流转和留痕。", + "target_user": "第一版先服务项目管理团队,他们会最早补充需求、看收敛结果和处理待确认项。", + "signal_inputs": "当前至少要接入:飞书需求消息;项目/任务/责任人数据;需求变更记录;状态流转记录。", + "known_info": "目前已经明确:当前要解决的是企业级项目管理系统里的需求收敛功能;当前按发布治理平台收敛;主要服务项目管理团队;技术路线先按任务编排平台版;第一版先按待办、责任人、截止时间推进;推进方式先按项目级试点。", + "tech_route": "当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL),先把需求收敛和待确认项管理稳定下来。", + "first_scope": "待办、责任人、截止时间。", + "core_flow": "提交需求信号 -> 生成需求 brief -> 拆出待办/责任人/截止时间 -> 校验待确认项 -> 人工确认后继续推进", + "delivery_mode": "先按项目级试点(带基础审批和回滚)推进,尽快在真实项目里验证需求收敛价值。", + "success_signal": "先保证能稳定生成项目 brief、拆出待办/责任人/截止时间,并能支撑一轮真实确认闭环。", + "risks": "当前主要风险在需求事实是否足够清晰、责任人与截止时间来源是否稳定,以及确认规则是否一致。", + "pending_items": ["需求事实来源与证据口径", "责任人字段来源", "截止时间字段来源", "试点负责人和投入人天"], + "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + "positioning_one_liner": "面向项目管理团队的需求收敛试点,先跑通待办/责任人/截止时间闭环。", + "summary": "先把需求收敛主链路跑通,再根据试点结果决定是否扩到更完整的平台能力。", + "target_customers": "项目管理团队", + "external_signal_sources": [ + {"name": "飞书开放平台", "purpose": "补充消息原文、评论和消息元数据", "phase": "首版优先接入"}, + {"name": "Jira API", "purpose": "补充需求单、负责人和截止时间字段", "phase": "首版优先接入"}, + {"name": "Confluence API", "purpose": "补充需求背景文档和评审记录", "phase": "第二阶段接入"}, + ], + "requirement_items": [ + {"section": "项目性质", "title": "业务协作平台能力", "description": "承接多人协作、状态流转和范围确认。"}, + {"section": "服务对象", "title": "项目管理团队", "description": "第一版先服务项目管理团队。"}, + {"section": "技术路线", "title": "任务编排平台版", "description": "用 FastAPI + React + Redis + Celery + PostgreSQL 承接收敛链路。"}, + {"section": "第一版范围", "title": "核心字段", "description": "先保留待办、责任人、截止时间。"}, + ], + "missing_info": ["需求事实来源", "责任人字段来源", "截止时间字段来源"], + "mvp_items": ["需求 brief", "待办字段", "责任人字段", "截止时间字段"], + "team_setup": [{"role": "建议配置", "notes": "需求负责人/PM 1名;后端工程师 1名;前端工程师 1名;测试/试点验收 1名。"}], + "validation_metrics": {"product": ["brief 生成成功率", "字段抽取可用率", "确认闭环完成率"]}, + } + elif "换季系统自动推荐搭配衣服" in context_blob: + payload = { + "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", + "focus": "现在最关键的,是先定它更像一次性推荐小工具、可持续运营平台,还是长期穿搭决策能力。", + "options": [ + { + "key": "A", + "title": "先做穿搭推荐小工具", + "best_for": "先验证用户是否愿意按天气、季节和已有衣橱接收搭配建议", + "suggested_capability": "基础规则推荐、衣物标签、简单反馈", + "rationale": "落地快,能先验证推荐结果是否真的有用", + "tradeoff": "个性化和长期学习能力会比较弱", + }, + { + "key": "B", + "title": "先做个性化穿搭推荐平台", + "best_for": "需要持续沉淀用户偏好、衣橱数据和搭配反馈", + "suggested_capability": "用户画像、衣物标签体系、推荐策略、反馈闭环", + "rationale": "更贴近你说的自动推荐搭配衣服,也方便后续扩展", + "tradeoff": "第一版要先把数据口径和推荐边界说清楚", + }, + { + "key": "C", + "title": "直接做穿搭决策底座", + "best_for": "后续要接入多品牌、多场景和长期运营策略", + "suggested_capability": "多源数据接入、推荐服务、策略实验、效果评估", + "rationale": "长期上限更高", + "tradeoff": "早期投入最大,容易还没验证需求就做重", + }, + ], + "recommendation": {"key": "B", "reason": "它最符合“换季系统自动推荐搭配衣服”的持续价值,但还不会一开始做成过重底座。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + elif is_scoring_context and '"missing_field": "project_nature"' in prompt: + payload = { + "opening": "🧭 我先按“每日 A 股趋势打分工具”这类 IT 项目来收。", + "focus": "现在最关键的,是先定它更像一个轻量投研工具、可复用评分服务,还是长期分析底座。", + "options": [ + { + "key": "A", + "title": "先做轻量打分工具", + "best_for": "先验证每日评分结果是否有参考价值", + "suggested_capability": "日线数据拉取、评分规则、结果展示", + "rationale": "开发快,适合先验证评分是否真的有人用", + "tradeoff": "后续扩展多维因子和策略实验时需要补能力", + }, + { + "key": "B", + "title": "先做评分服务平台", + "best_for": "需要持续沉淀评分规则、指标口径和反馈闭环", + "suggested_capability": "评分服务、规则配置、反馈记录", + "rationale": "更方便后续持续优化评分逻辑", + "tradeoff": "第一版会比 A 多一点规则管理成本", + }, + { + "key": "C", + "title": "直接做投研分析底座", + "best_for": "后续要承接多策略、多因子和多团队复用", + "suggested_capability": "因子计算、策略实验、分析接口", + "rationale": "长期上限更高", + "tradeoff": "早期投入最大,需求没跑通前容易做重", + }, + ], + "recommendation": {"key": "B", "reason": "先按评分服务把规则、结果和反馈闭环收住,更适合后续持续优化。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + elif '"missing_field": "project_nature"' in prompt: + is_deployment_prompt = "批量自动化部署" in context_blob + subject = "OpenClaw 批量自动化部署平台" if is_deployment_prompt else "当前需求" + option_b_title = "先做发布治理平台" if is_deployment_prompt else "先做业务协作平台" + option_b_best_for = "多人协作、状态流转、需要审批和回滚" if is_deployment_prompt else "多人协作、状态流转、需要审批和留痕" + option_b_capability = "FastAPI + React + PostgreSQL + Redis" if is_deployment_prompt else "FastAPI + React + PostgreSQL + 审批流 + 审计日志" + payload = { + "opening": f"🧭 我先按“{subject}”这类 IT 项目来收。", + "focus": "现在最关键的,不是立刻展开做法,而是先定它更像工具、平台,还是长期底座。", + "options": [ + { + "key": "A", + "title": "先做内部提效工具", + "best_for": "先减少重复操作,把第一版价值快速跑通", + "suggested_capability": "FastAPI + React + PostgreSQL", + "rationale": "开发快,适合先验证 MVP", + "tradeoff": "后续任务编排和治理能力要继续补", + }, + { + "key": "B", + "title": option_b_title, + "best_for": option_b_best_for, + "suggested_capability": option_b_capability, + "rationale": "更适合承接长期治理和执行追踪", + "tradeoff": "前期设计复杂度高于 A", + }, + { + "key": "C", + "title": "直接做云原生交付底座" if is_deployment_prompt else "直接做长期能力底座", + "best_for": "目标是规模化、标准化和长期复用", + "suggested_capability": "平台能力、策略配置、审计与扩展接口", + "rationale": "长期上限更高", + "tradeoff": "早期投入最大", + }, + ], + "recommendation": {"key": "B", "reason": "它更适合把当前需求收成可持续的平台能力,同时不会一开始做成过重底座。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + elif '"missing_field": "target_user"' in prompt: + if is_scoring_context: + payload = { + "opening": "🧭 我先按“轻量打分工具”这个方向继续收。", + "focus": "现在要先把第一版服务对象定住,不然后面的评分口径会飘。", + "options": [ + {"key": "A", "title": "投顾", "best_for": "先服务一线需要快速看市场趋势和候选股票的人", "suggested_capability": "每日评分卡、重点股票列表、简短解释", "rationale": "最贴近直接使用评分结果的人", "tradeoff": "会更关注结果可读性和更新节奏"}, + {"key": "B", "title": "研究团队", "best_for": "先服务需要验证评分逻辑和指标口径的人", "suggested_capability": "评分拆解、指标明细、回看能力", "rationale": "更容易帮助我们先把评分口径校准", "tradeoff": "第一版结果会更偏分析工具而不是实战辅助"}, + {"key": "C", "title": "交易团队", "best_for": "直接结合交易节奏看信号", "suggested_capability": "盘前信号、风险提示、更新频率控制", "rationale": "最能验证时效价值", "tradeoff": "对稳定性和时效要求最高"}, + ], + "recommendation": {"key": "B", "reason": "先按研究团队校准评分口径,再扩到更直接的投顾使用会更稳。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + elif "换季系统自动推荐搭配衣服" in context_blob or "个性化穿搭推荐平台" in context_blob: + payload = { + "opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。", + "focus": "现在要先把第一版服务对象定住,不然后面推荐口径会飘。", + "options": [ + {"key": "A", "title": "普通消费者", "best_for": "先验证换季搭配推荐是否有用", "suggested_capability": "用户偏好、天气/季节标签、搭配反馈", "rationale": "最贴近最终使用者", "tradeoff": "需要处理偏好差异"}, + {"key": "B", "title": "穿搭运营团队", "best_for": "先由运营维护搭配规则和推荐口径", "suggested_capability": "规则配置、款式标签、人工校验", "rationale": "更容易控制第一版质量", "tradeoff": "自动化程度会弱一些"}, + {"key": "C", "title": "品牌导购团队", "best_for": "结合商品推荐和导购场景", "suggested_capability": "商品库、搭配卡片、导购反馈", "rationale": "更适合商业转化", "tradeoff": "会更依赖商品数据"}, + ], + "recommendation": {"key": "A", "reason": "先服务普通消费者最能验证自动推荐是否真的成立。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + else: + opening_subject = "云原生交付底座" if "云原生交付底座" in context_blob else "发布治理平台" + payload = { + "opening": f"🧭 我先按“{opening_subject}”这个方向继续收。", + "focus": "现在要先把服务对象定住,不然后面体验和边界会飘。", + "options": [ + {"key": "A", "title": "开发团队", "best_for": "先让开发自己使用", "suggested_capability": "轻量入口、日志查看", "rationale": "反馈快", "tradeoff": "治理弱"}, + {"key": "B", "title": "平台运维团队", "best_for": "统一管理流程和风险", "suggested_capability": "审批、审计、回滚", "rationale": "更稳", "tradeoff": "边界要清楚"}, + {"key": "C", "title": "项目交付团队", "best_for": "按项目复制流程", "suggested_capability": "模板、参数、交付流程", "rationale": "适合多项目", "tradeoff": "依赖流程稳定"}, + ], + "recommendation": {"key": "B", "reason": "它更适合承接责任边界和治理闭环。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + elif '"missing_field": "tech_route"' in prompt: + if is_scoring_context: + payload = { + "opening": "🧭 我先按“主要给投顾看”这个方向继续收。", + "focus": "现在最关键的,是先把第一版技术路线定住。", + "options": [ + {"key": "A", "title": "先做规则打分版", "best_for": "快速验证每日评分闭环", "suggested_capability": "FastAPI + PostgreSQL + 指标规则计算", "rationale": "最快把评分结果和解释跑通", "tradeoff": "后续扩展复杂因子时要继续补"}, + {"key": "B", "title": "先做评分服务版", "best_for": "长期沉淀评分规则、反馈和版本管理", "suggested_capability": "FastAPI + Redis + PostgreSQL + 评分服务", "rationale": "更适合稳定承接每日评分和后续优化", "tradeoff": "复杂度高于 A"}, + {"key": "C", "title": "直接做投研分析底座", "best_for": "多团队共用、多策略扩展和实验", "suggested_capability": "因子服务、分析接口、策略实验层", "rationale": "长期上限更高", "tradeoff": "首版投入最大"}, + ], + "recommendation": {"key": "B", "reason": "先做评分服务版,更适合稳定承接每日评分和后续反馈优化。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + else: + payload = { + "opening": "🧭 我先按“平台运维团队要长期使用”这个方向继续收。", + "focus": "现在最关键的,是先把第一版技术路线定住。", + "options": [ + {"key": "A", "title": "先做脚本增强版", "best_for": "快速验证闭环", "suggested_capability": "FastAPI + Shell/Ansible + PostgreSQL", "rationale": "最快落地", "tradeoff": "平台化弱"}, + {"key": "B", "title": "先做任务编排平台版", "best_for": "长期做任务状态和追踪", "suggested_capability": "FastAPI + React + Redis + Celery + PostgreSQL", "rationale": "平衡落地和扩展", "tradeoff": "复杂度高于 A"}, + {"key": "C", "title": "直接做长期能力底座", "best_for": "多团队标准化复用", "suggested_capability": "FastAPI + React + PostgreSQL + 规则配置 + 审计日志", "rationale": "上限高", "tradeoff": "门槛最高"}, + ], + "recommendation": {"key": "B", "reason": "它最适合把任务编排和治理能力留出空间。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + elif '"missing_field": "first_scope"' in prompt: + if is_scoring_context: + payload = { + "opening": "🧭 我先按“评分服务版”继续往下收。", + "focus": "这一步要定第一版先证明什么价值。", + "options": [ + {"key": "A", "title": "每日评分闭环", "best_for": "先证明每日分数能稳定产出并被查看", "suggested_capability": "每日评分、重点股票列表、查看记录", "rationale": "最能体现首版核心价值", "tradeoff": "解释能力先做轻一点"}, + {"key": "B", "title": "评分解释闭环", "best_for": "先证明用户能理解分数为什么这么给", "suggested_capability": "评分解释、指标拆解、结果回看", "rationale": "更适合投研场景验证可解释性", "tradeoff": "产出节奏会比 A 慢一点"}, + {"key": "C", "title": "反馈优化闭环", "best_for": "先把用户反馈接回来用于后续优化", "suggested_capability": "反馈记录、评分修正、版本对比", "rationale": "利于后续持续调优", "tradeoff": "首版会多一层反馈管理复杂度"}, + ], + "recommendation": {"key": "A", "reason": "MVP 最先要证明每日评分结果能稳定产出并被实际查看。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + else: + payload = { + "opening": "🧭 我先按“任务编排平台版”继续往下收。", + "focus": "这一步要定第一版到底先证明什么价值。", + "options": [ + {"key": "A", "title": "批量执行闭环", "best_for": "先证明任务能跑通", "suggested_capability": "任务创建、执行日志、失败重试", "rationale": "最能体现核心价值", "tradeoff": "治理稍后补"}, + {"key": "B", "title": "责任字段闭环", "best_for": "先把责任人和截止时间稳定下来", "suggested_capability": "字段抽取、责任校验、确认门禁", "rationale": "更适合项目管理试点", "tradeoff": "自动执行能力稍后补"}, + {"key": "C", "title": "治理闭环", "best_for": "先控制上线风险", "suggested_capability": "审批、审计、权限", "rationale": "责任边界清楚", "tradeoff": "可能做重"}, + ], + "recommendation": {"key": "A", "reason": "MVP 最先要证明核心闭环能跑起来。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + else: + payload = { + "opening": "🧭 我先按“当前范围”继续收尾。", + "focus": "现在要拍板第一版推进方式。", + "options": [ + {"key": "A", "title": "内部 PoC", "best_for": "低风险试错", "suggested_capability": "快速反馈", "rationale": "最轻", "tradeoff": "真实问题暴露少"}, + {"key": "B", "title": "项目级试点", "best_for": "真实项目验证", "suggested_capability": "试点接入、基础审批、基础回滚", "rationale": "更能验证价值", "tradeoff": "需要负责人配合"}, + {"key": "C", "title": "平台化上线", "best_for": "多团队接入", "suggested_capability": "统一权限、统一规范", "rationale": "长期价值最大", "tradeoff": "投入最高"}, + ], + "recommendation": {"key": "B", "reason": "项目级试点最适合真实验收。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + return StructuredExtractionResult( + payload=payload, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + + monkeypatch.setattr( + mixin_execute_query_project.llm_service, + "generate_structured_object", + fake_generate_structured_object, + ) + + +def _install_requirement_plan_query_bundle( + monkeypatch, + *, + message_intent: str = "requirement_plan", + consultant_mode_preference: str = "clarify", + topic_switch: str | None = None, +) -> None: + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + payload = dict(metadata or {}) + _ = message_text + effective_topic_switch = topic_switch + if effective_topic_switch is None: + explicit_topic_switch = str(payload.get("topic_switch") or "").strip() + if explicit_topic_switch: + effective_topic_switch = explicit_topic_switch + if effective_topic_switch is None: + has_thread = isinstance(payload.get("thread_context"), dict) and bool(payload.get("thread_context")) + has_active_requirement = bool(payload.get("active_requirement_plan_present")) + effective_topic_switch = "continue" if has_thread or has_active_requirement else "new" + return WorkflowBundle( + bundle_id="bundle-plan-query", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.92, + metadata={"message_intent": message_intent}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": message_intent, + "consultant_mode_preference": consultant_mode_preference, + "topic_switch": effective_topic_switch, + }, + ) + + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) + + @pytest.mark.asyncio async def test_process_text_route_uses_pmflow_manual_ingress(monkeypatch) -> None: captured: dict[str, object] = {} @@ -197,7 +684,7 @@ async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) channel_ref="pmflow-chat", source_sender_open_id="ou-pmflow-1", thread_context={"reply_to_message_id": "reply-1"}, - message_text="顺便问下现在进展如何,并且新增任务确认流", + message_text="新增任务确认流,也同步一下当前进展", project_hint_id=22, ) @@ -273,6 +760,128 @@ async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> assert "草稿" in result["direct_reply_text"] +@pytest.mark.asyncio +async def test_process_ingress_binds_active_draft_before_query_for_regenerate_request(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-regen-1", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=101), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=56), + "session": SimpleNamespace(last_intent="query.read_only"), + } + + 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("active draft regenerate request should not fall back to query sidecar") + + async def _fake_has_active_draft_session(self, **kwargs): + _ = self + _ = kwargs + return True + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + + kernel = FakeKernel() + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="feishu-regen-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_process_ingress_binds_active_requirement_before_query_for_short_followup(monkeypatch) -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + self.bound = False + + async def _prepare_ingress_envelope(self, **kwargs): + _ = kwargs + return { + "early_result": None, + "clean_text": "信号源呢?", + "synthetic_message_id": "s-msg-req-followup-1", + "closure_payload": {}, + "ingest_event": SimpleNamespace(id=102), + "operator": SimpleNamespace(id=8), + "work_item": SimpleNamespace(id=57), + "session": SimpleNamespace(last_intent="query.read_only"), + } + + async def _maybe_handle_active_context_followup(self, **kwargs): + if kwargs["force_bind"] is not True: + raise AssertionError("active requirement followup should return before non-forced retry") + self.bound = True + return { + "handled": True, + "status": "answered", + "classification": "question", + "direct_reply_required": True, + "direct_reply_text": "我先把外部信号源补到当前需求里。", + } + + async def _should_route_to_draft_confirmation_session(self, **kwargs): + _ = kwargs + raise AssertionError("active requirement followup should bypass draft confirmation routing") + + async def _prepare_mixed_sidecar_context(self, **kwargs): + _ = kwargs + raise AssertionError("active requirement followup should not fall back to query sidecar") + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + return False + + kernel = FakeKernel() + result = await kernel.process_ingress( + source_system="feishu", + source_message_id="feishu-req-followup-1", + message_text="信号源呢?", + sender="tester", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + ) + + assert kernel.bound is True + assert result["status"] == "answered" + assert "外部信号源" in result["direct_reply_text"] + + @pytest.mark.asyncio async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(monkeypatch) -> None: captured: dict[str, object] = {"calls": []} @@ -613,52 +1222,53 @@ def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> ) is True -def test_short_confirmation_requires_reply_anchor() -> None: - session = SimpleNamespace( - last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM, - last_active_at=datetime.now(), - ) - sent_at = datetime.now() + timedelta(seconds=1) - - assert ( - KernelIntentRulesMixin._is_short_confirmation_message( - message_text="确认", - session=session, - message_sent_at=sent_at, - session_snapshot=None, - reply_anchor_present=False, - ) - is False - ) - assert ( - KernelIntentRulesMixin._is_short_confirmation_message( - message_text="确认", - session=session, - message_sent_at=sent_at, - session_snapshot=None, - reply_anchor_present=True, - ) - is True - ) - - -def test_natural_revision_routes_to_draft_confirmation_without_reply_anchor() -> None: +def test_natural_revision_does_not_route_to_draft_confirmation_without_reply_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="第一版先做直播录屏和评论回放切片,再加亮点和掉点总结,最后给一份复盘清单。", ingress_options=None, - ) is True + ) is False -def test_scope_narrowing_revision_routes_to_draft_confirmation_without_reply_anchor() -> None: +def test_scope_narrowing_revision_does_not_route_to_draft_confirmation_without_reply_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="第一版先别做太大,先把三件事做好就行:直播录屏和评论回放切片、亮点和掉点自动总结、给团队一份能直接执行的复盘清单。", ingress_options=None, + ) is False + + +def test_continue_current_draft_does_not_route_to_draft_confirmation_without_reply_anchor() -> None: + session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="按这版继续", + ingress_options=None, + ) is False + + +def test_continue_current_draft_does_not_route_to_draft_confirmation_without_parse_action_dependency() -> None: + session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="按这版继续", + ingress_options=None, + ) is False + + +def test_reply_anchor_routes_to_draft_confirmation_without_revision_heuristics() -> None: + session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) + + assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="保持当前草稿结构并补完整信息", + ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, ) is True @@ -699,9 +1309,13 @@ async def test_should_route_to_draft_confirmation_session_does_not_hijack_explic called = True return True - monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) - kernel = FakeKernel() + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + monkeypatch.setattr( + kernel.__class__, + "_should_skip_no_anchor_draft_routing_by_bundle", + staticmethod(lambda **kwargs: asyncio.sleep(0, result=True)), + ) should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), sender="tester", @@ -731,6 +1345,38 @@ def test_should_inherit_recent_feishu_session_context_skips_explicit_new_require assert should_inherit is False +def test_should_inherit_recent_feishu_session_context_skips_draft_pending_confirm_session() -> None: + session = SimpleNamespace( + sender="feishu:tester", + last_active_at=datetime.now(), + last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM, + last_message_text="上一版草稿已经出来了", + ) + + should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context( + message_text="这个怎么做", + session=session, + ) + + assert should_inherit is False + + +def test_should_inherit_recent_feishu_session_context_skips_non_query_last_intent() -> None: + session = SimpleNamespace( + sender="feishu:tester", + last_active_at=datetime.now(), + last_intent="signal_source.onboard", + last_message_text="给项目2接一个 RTMP 信号源", + ) + + should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context( + message_text="这个怎么做", + session=session, + ) + + assert should_inherit is False + + @pytest.mark.asyncio async def test_explicit_new_requirement_clears_inherited_session_project_context() -> None: class _FakeDB: @@ -762,14 +1408,14 @@ async def test_explicit_new_requirement_clears_inherited_session_project_context message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", ) - assert cleared is True - assert session.current_project_id is None + assert cleared is False + assert session.current_project_id == 104 assert session.last_intent == "query.read_only" @pytest.mark.asyncio @pytest.mark.parametrize("message_text", ["A", "确认", "你自行推荐"]) -async def test_orphan_requirement_followup_returns_direct_reprompt_when_no_active_session(message_text: str) -> None: +async def test_orphan_requirement_followup_without_active_requirement_context_is_ignored(message_text: str) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 @@ -788,10 +1434,48 @@ async def test_orphan_requirement_followup_returns_direct_reprompt_when_no_activ message_text=message_text, ) - assert result is not None - assert result["handled"] is True - assert result["direct_reply_required"] is True - assert "没有进行中的 A/B/C 收敛轮次" in str(result["direct_reply_text"] or "") + assert result is None + + +@pytest.mark.asyncio +async def test_orphan_requirement_followup_clears_stale_session_project_context() -> None: + class _FakeDB: + async def flush(self): + return None + + class FakeKernel( + mixin_entry.KernelIngressFlowEntryMixin, + ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + async def _has_active_requirement_plan_workspace(self, **kwargs): + _ = kwargs + return False + + kernel = FakeKernel() + session = SimpleNamespace( + current_project_id=104, + last_intent="query.read_only", + last_message_text="A", + ) + + result = await kernel._maybe_handle_orphan_requirement_followup( + source_system="feishu", + source_message_id="msg-orphan-reply-stale-project", + channel_ref="chat-active", + source_sender_open_id="ou_active", + ingress_options=None, + message_text="A", + session=session, + sender="tester", + ) + + assert result is None + assert session.current_project_id == 104 + assert session.last_intent == "query.read_only" @pytest.mark.asyncio @@ -817,6 +1501,482 @@ async def test_orphan_requirement_followup_skips_guard_when_active_requirement_s assert result is None +def test_world_model_session_project_inheritance_rejects_low_context_followup() -> None: + class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin): + pass + + kernel = FakeKernel() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=104, + last_active_at=datetime.now(), + ) + + assert kernel._extract_inherited_project_id(session=session, message_text="A") is None + assert kernel._extract_inherited_project_id(session=session, message_text="你自行推荐") is None + + +def test_world_model_session_project_inheritance_rejects_stale_low_context_previous_turn() -> None: + class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin): + pass + + kernel = FakeKernel() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=104, + last_message_text="A", + last_active_at=datetime.now(), + ) + + assert kernel._extract_inherited_project_id(session=session, message_text="这个怎么做") is None + + +def test_draft_confirmation_router_keeps_requirement_plan_field_list_in_plan_flow() -> None: + session = SimpleNamespace(last_intent="query.read_only") + + assert ( + mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( + session=session, + message_text="待办、责任人、截止时间", + ingress_options={"thread_context": {"reply_to_message_id": "msg-plan-root"}}, + ) + is False + ) + + +@pytest.mark.asyncio +async def test_active_requirement_followup_prefers_current_requirement_context(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _find_active_requirement_plan_workspace(self, **kwargs): + captured["workspace_lookup"] = kwargs + return ( + SimpleNamespace(id=81, plan_status="collecting"), + SimpleNamespace(id=82, state="collecting"), + ) + + async def _build_requirement_planning_direct_result(self, **kwargs): + captured["direct_kwargs"] = kwargs + return { + "handled": True, + "status": "answered", + "classification": "question", + "direct_reply_required": True, + "direct_reply_text": "我先按当前需求把信号输入这一块补进去。", + "reply_template_key": "requirement_plan_followup", + } + + class FakeSemanticContextAssembler: + def __init__(self, db) -> None: + self.db = db + + async def assemble(self, **kwargs): + captured["packet_kwargs"] = kwargs + return SimpleNamespace( + mode_card=SimpleNamespace( + allowed_actions=[ + "reply.compose", + "context.followup.current_requirement", + "context.followup.independent_workflow", + ], + forbidden_actions=[], + ) + ) + + class FakeSemanticDecisionService: + def __init__(self, _llm_service) -> None: + pass + + async def decide(self, *, packet): + _ = packet + return SimpleNamespace( + topic_relation="continue_current_topic", + mode_decision="stay_plan", + followup_scope="current_requirement", + followup_action="none", + revision_note="", + assistant_reply="我先按当前需求把信号输入这一块补进去。", + requires_confirmation=False, + requested_actions=["reply.compose", "context.followup.current_requirement"], + ) + + monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) + + kernel = FakeKernel() + result = await kernel._maybe_handle_active_context_followup( + source_system="feishu", + source_message_id="msg-followup-1", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="信号源呢?", + thread_context=None, + sender="tester", + ) + + assert result is not None + assert result["handled"] is True + assert "信号输入" in result["direct_reply_text"] + assert captured["packet_kwargs"]["latest_user_message"] == "信号源呢?" + assert captured["packet_kwargs"]["ingress_context"]["candidate_kind"] == "active_context_followup" + assert captured["direct_kwargs"]["message_text"] == "信号源呢?" + + +@pytest.mark.asyncio +async def test_active_context_followup_prefers_active_mounted_draft_before_requirement_lookup(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _find_active_requirement_plan_workspace(self, **kwargs): + _ = kwargs + raise AssertionError("active mounted draft should bind before requirement workspace lookup") + + class FakeSemanticContextAssembler: + def __init__(self, db) -> None: + self.db = db + + async def assemble(self, **kwargs): + captured["packet_kwargs"] = kwargs + return SimpleNamespace( + mode_card=SimpleNamespace( + allowed_actions=["reply.compose", "context.followup.current_draft"], + forbidden_actions=[], + ) + ) + + class FakeSemanticDecisionService: + def __init__(self, _llm_service) -> None: + pass + + async def decide(self, *, packet): + _ = packet + return SimpleNamespace( + topic_relation="continue_current_topic", + mode_decision="stay_plan", + followup_scope="current_draft", + followup_action="revise", + revision_note="补充外部信号源。", + assistant_reply="", + requires_confirmation=False, + requested_actions=["reply.compose", "context.followup.current_draft"], + ) + + async def _fake_resolve_active_mounted_draft(self, **kwargs): + _ = (self, kwargs) + return ( + SimpleNamespace(id=92, state="draft_pending_confirm", active_work_item_id=812), + SimpleNamespace(id=91, plan_status="draft_pending_confirm"), + ) + + async def _fake_handle_text_reply(self, **kwargs): + _ = self + captured["draft_kwargs"] = kwargs + return { + "handled": True, + "status": "pending_manual", + "followup_reply": "我先在当前草稿里补外部信号源。", + } + + monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) + monkeypatch.setattr(DraftConfirmHandler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft) + monkeypatch.setattr(DraftConfirmHandler, "handle_text_reply", _fake_handle_text_reply) + + kernel = FakeKernel() + result = await kernel._maybe_handle_active_context_followup( + source_system="feishu", + source_message_id="msg-followup-draft-priority-1", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="再出一版草稿", + thread_context=None, + sender="tester", + ) + + assert result is not None + assert result["handled"] is True + assert "当前草稿" in result["followup_reply"] + assert captured["packet_kwargs"]["topic_workspace_id"] == 91 + assert captured["packet_kwargs"]["ingress_context"]["active_context_kind"] == "draft_pending_confirm" + assert captured["draft_kwargs"]["semantic_followup_override"]["action"] == "revise" + + +@pytest.mark.asyncio +async def test_active_context_followup_routes_short_draft_module_question_to_current_draft(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _find_active_requirement_plan_workspace(self, **kwargs): + captured["workspace_lookup"] = kwargs + return ( + SimpleNamespace(id=91, plan_status="awaiting_execute_confirm"), + SimpleNamespace(id=92, state="ready"), + ) + + class FakeSemanticContextAssembler: + def __init__(self, db) -> None: + self.db = db + + async def assemble(self, **kwargs): + captured["packet_kwargs"] = kwargs + return SimpleNamespace( + mode_card=SimpleNamespace( + allowed_actions=["reply.compose", "context.followup.current_draft"], + forbidden_actions=[], + ) + ) + + class FakeSemanticDecisionService: + def __init__(self, _llm_service) -> None: + pass + + async def decide(self, *, packet): + _ = packet + return SimpleNamespace( + topic_relation="continue_current_topic", + mode_decision="stay_plan", + followup_scope="current_draft", + followup_action="revise", + revision_note="补充当前草稿里的信号源输入与接入边界。", + assistant_reply="", + requires_confirmation=False, + requested_actions=["reply.compose", "context.followup.current_draft"], + ) + + async def _fake_handle_text_reply(self, **kwargs): + _ = self + captured["draft_kwargs"] = kwargs + return { + "handled": True, + "status": "pending_manual", + "followup_reply": "我先在当前草稿里补信号源输入。", + } + + monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) + monkeypatch.setattr(DraftConfirmHandler, "handle_text_reply", _fake_handle_text_reply) + + kernel = FakeKernel() + result = await kernel._maybe_handle_active_context_followup( + source_system="feishu", + source_message_id="msg-followup-2", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="信号源呢?", + thread_context=None, + sender="tester", + ) + + assert result is not None + assert result["handled"] is True + assert "当前草稿" in result["followup_reply"] + assert captured["draft_kwargs"]["message_text"] == "信号源呢?" + assert captured["draft_kwargs"]["semantic_followup_override"] == { + "action": "revise", + "revision_note": "补充当前草稿里的信号源输入与接入边界。", + "assistant_reply": "", + } + + +@pytest.mark.asyncio +async def test_active_context_followup_does_not_hijack_explicit_signal_source_request(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _find_active_requirement_plan_workspace(self, **kwargs): + captured["workspace_lookup"] = kwargs + return ( + SimpleNamespace(id=91, plan_status="awaiting_execute_confirm"), + SimpleNamespace(id=92, state="ready"), + ) + + class FakeSemanticContextAssembler: + def __init__(self, db) -> None: + self.db = db + + async def assemble(self, **kwargs): + captured["packet_kwargs"] = kwargs + return SimpleNamespace( + mode_card=SimpleNamespace( + allowed_actions=["reply.compose", "context.followup.independent_workflow"], + forbidden_actions=[], + ) + ) + + class FakeSemanticDecisionService: + def __init__(self, _llm_service) -> None: + pass + + async def decide(self, *, packet): + _ = packet + return SimpleNamespace( + topic_relation="continue_current_topic", + mode_decision="answer_only", + followup_scope="independent_workflow", + followup_action="none", + revision_note="", + assistant_reply="", + requires_confirmation=False, + requested_actions=["reply.compose", "context.followup.independent_workflow"], + ) + + monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) + monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) + kernel = FakeKernel() + result = await kernel._maybe_handle_active_context_followup( + source_system="feishu", + source_message_id="msg-followup-3", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。", + thread_context=None, + sender="tester", + ) + + assert result is None + assert captured["packet_kwargs"]["latest_user_message"] == "给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。" + + +@pytest.mark.asyncio +async def test_find_active_requirement_plan_workspace_includes_draft_pending_confirm_state( + requirement_plan_db_session, +) -> None: + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 7 + + executor = FakeExecutor() + conversation_scope_key = build_feishu_conversation_scope_key( + source_chat_id="chat-draft-pending", + source_sender_open_id="ou-draft-pending", + sender=None, + ) + topic = MountedTopicWorkspace( + tenant_id=7, + topic_key="feishu:chat-draft-pending:message:msg-draft-pending-root", + source_system="feishu", + channel_ref="chat-draft-pending", + conversation_scope_key=conversation_scope_key, + mode="plan", + status="active", + plan_status="draft_pending_confirm", + metadata_json={"plan_mode": True}, + facts_json={"title": "换季穿搭助手"}, + ) + requirement_plan_db_session.add(topic) + await requirement_plan_db_session.flush() + requirement_plan_db_session.add( + MountedDraftWorkspace( + tenant_id=7, + topic_workspace_id=int(topic.id), + state="draft_pending_confirm", + active_work_item_id=321, + draft_version=1, + requirement_card={"title": "换季穿搭助手"}, + draft_payload={"title": "换季穿搭助手"}, + required_confirmations=["需求草稿需要人工确认后才允许正式创建"], + ) + ) + await requirement_plan_db_session.flush() + + found_topic, found_draft = await executor._find_active_requirement_plan_workspace( + source_system="feishu", + source_message_id="msg-followup-pending", + source_chat_id="chat-draft-pending", + sender=None, + source_sender_open_id="ou-draft-pending", + thread_context=None, + conversation_key=conversation_scope_key, + ) + + assert found_topic is not None + assert found_draft is not None + assert int(found_topic.id) == int(topic.id) + assert str(found_draft.state) == "draft_pending_confirm" + + +def test_world_model_session_project_inheritance_keeps_meaningful_followup() -> None: + class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin): + pass + + kernel = FakeKernel() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=104, + last_active_at=datetime.now(), + ) + + assert kernel._extract_inherited_project_id(session=session, message_text="这个怎么做") is None + + +@pytest.mark.asyncio +async def test_world_model_resolve_project_does_not_bind_low_context_session_project() -> None: + class _ProjectListResult: + def scalars(self): + return SimpleNamespace(all=lambda: []) + + class _ProjectOneResult: + def scalar_one_or_none(self): + return SimpleNamespace(id=104, title="跨境直播复盘", name="跨境直播复盘") + + class _FakeDB: + def __init__(self) -> None: + self.execute_calls = 0 + + async def execute(self, _query): + self.execute_calls += 1 + if self.execute_calls == 1: + return _ProjectListResult() + return _ProjectOneResult() + + class FakeKernel( + mixin_project.KernelWorldModelCoreProjectMixin, + ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin, + ): + def __init__(self) -> None: + self.db = _FakeDB() + self.tenant_id = 1 + + kernel = FakeKernel() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=104, + last_active_at=datetime.now(), + ) + + project = await kernel._resolve_project( + message_text="A", + project_hint_id=None, + query_tables=set(), + session=session, + ) + + assert project is None + assert kernel.db.execute_calls == 1 + + def test_mounted_runtime_query_business_review_uses_plan_mode_and_non_executor_agent() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", @@ -1097,6 +2257,8 @@ def test_coerce_rag_business_review_scene_keeps_rag_ask_on_query_chain() -> None rag_request_context={ "asked_via": "pmflow_manual", "action_mode": "auto", + "message_intent": "business_review", + "consultant_mode_preference": "clarify", }, semantic_bundle=None, world_model={}, @@ -1243,11 +2405,27 @@ def test_coerce_feishu_assignee_recommendation_to_query_chain_from_direct_metada assert coerced.metadata["workflow_route"] == "query.answer" +def test_should_inherit_recent_feishu_session_context_is_disabled_for_query_session() -> None: + session = SimpleNamespace( + sender="feishu:tester", + last_active_at=datetime.now(), + last_intent="query.read_only", + last_message_text="上一轮在聊项目画像", + ) + + should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context( + message_text="我需要对应的需求mvp", + session=session, + ) + + assert should_inherit is False + + @pytest.mark.asyncio -async def test_kernel_intent_resolve_inherits_recent_feishu_session_context_without_reply_anchor() -> None: +async def test_kernel_intent_resolve_does_not_inherit_recent_feishu_session_context_without_reply_anchor() -> None: captured: dict[str, object] = {} - class FakeResolver(KernelIntentResolveMixin): + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 @@ -1286,8 +2464,298 @@ async def test_kernel_intent_resolve_inherits_recent_feishu_session_context_with ) assert source == "llm_only" - assert captured["session_last_intent_override"] == "query.read_only" - assert captured["session_project_id_override"] == 21 + assert captured["session_last_intent_override"] == "" + assert captured["session_project_id_override"] is None + assert captured["inherit_session_context"] is False + + +@pytest.mark.asyncio +async def test_kernel_intent_resolve_short_confirm_does_not_inherit_query_session_intent() -> None: + captured: dict[str, object] = {} + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + return True + + @staticmethod + def _intent_llm_enabled() -> bool: + return True + + async def _resolve_intent_with_llm(self, **kwargs): + captured.update(kwargs) + return CanonicalIntent( + name="query.read_only", + confidence=0.81, + project_id=None, + signal_title="", + signal_summary="", + metadata={}, + ) + + resolver = FakeResolver() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=21, + last_active_at=datetime.now(), + ) + + intent, source = await resolver._resolve_intent( + message_text="确认", + session=session, + world_model={}, + thread_context={"reply_to_message_id": "msg-1"}, + ) + + assert source == "llm_only" + assert intent.name == "query.read_only" + assert captured["inherit_session_context"] is True + + +@pytest.mark.asyncio +async def test_kernel_intent_resolve_short_confirm_stays_on_llm_path_with_bound_context() -> None: + captured: dict[str, object] = {} + + class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + @staticmethod + def _intent_llm_enabled() -> bool: + return True + + async def _resolve_intent_with_llm(self, **kwargs): + captured.update(kwargs) + return CanonicalIntent( + name="followup.confirm", + confidence=0.91, + project_id=21, + signal_title="", + signal_summary="", + metadata={"followup_action": "confirm"}, + ) + + resolver = FakeResolver() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="task.create", + current_project_id=21, + last_message_text="给项目 21 新建任务:补齐信号源配置", + last_active_at=datetime.now(), + ) + + intent, source = await resolver._resolve_intent( + message_text="确认", + session=session, + world_model={}, + thread_context={"reply_to_message_id": "msg-1"}, + ) + + assert source == "llm_only" + assert intent.name == "followup.confirm" + assert intent.project_id == 21 + assert captured["inherit_session_context"] is True + assert captured["session_last_intent_override"] == "task.create" + + +@pytest.mark.asyncio +async def test_kernel_intent_resolve_suppresses_session_context_for_low_context_followup() -> None: + captured: dict[str, object] = {} + + class FakeResolver(KernelIntentResolveMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + return False + + @staticmethod + def _intent_llm_enabled() -> bool: + return True + + async def _resolve_intent_with_llm(self, **kwargs): + captured.update(kwargs) + return CanonicalIntent( + name="query.read_only", + confidence=0.79, + project_id=None, + signal_title="", + signal_summary="", + metadata={}, + ) + + resolver = FakeResolver() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=104, + last_active_at=datetime.now(), + ) + + _, source = await resolver._resolve_intent( + message_text="A", + session=session, + world_model={}, + thread_context=None, + ) + + assert source == "llm_only" + assert captured["session_last_intent_override"] == "" + assert captured["session_project_id_override"] is None + assert captured["inherit_session_context"] is False + + +def test_recover_session_followup_write_intent_skips_query_parent_even_if_previous_text_looks_like_write() -> None: + class FakeResolver(KernelIntentRulesMixin, KernelIntentResolveMixin): + def __init__(self) -> None: + self.tenant_id = 1 + self.intent_resolver = SimpleNamespace(resolve=lambda **kwargs: CanonicalIntent("project.create", 0.9, 12, "", "", {})) + + resolver = FakeResolver() + session = SimpleNamespace( + last_intent="query.read_only", + last_message_text="帮我创建一个新项目,名字叫 Alpha", + current_project_id=12, + last_active_at=datetime.now(), + ) + rule_intent = CanonicalIntent("query.read_only", 0.6, None, "", "", {"fallback": True, "reason": "default_query_fallback"}) + + recovered = resolver._recover_session_followup_write_intent( + message_text="确认", + session=session, + world_model={}, + rule_intent=rule_intent, + ) + + assert recovered is None + + +def test_recover_session_followup_write_intent_is_disabled_even_for_write_parent() -> None: + class FakeResolver(KernelIntentRulesMixin, KernelIntentResolveMixin): + def __init__(self) -> None: + self.tenant_id = 1 + self.intent_resolver = SimpleNamespace(resolve=lambda **kwargs: CanonicalIntent("project.create", 0.9, 12, "", "", {})) + + resolver = FakeResolver() + session = SimpleNamespace( + last_intent="project.create", + last_message_text="帮我创建一个新项目,名字叫 Alpha", + current_project_id=12, + last_active_at=datetime.now(), + ) + rule_intent = CanonicalIntent("query.read_only", 0.6, None, "", "", {"fallback": True, "reason": "default_query_fallback"}) + + recovered = resolver._recover_session_followup_write_intent( + message_text="确认", + session=session, + world_model={}, + rule_intent=rule_intent, + ) + + assert recovered is None + + +@pytest.mark.asyncio +async def test_kernel_intent_resolve_suppresses_session_context_after_low_context_previous_turn() -> None: + captured: dict[str, object] = {} + + class FakeResolver(KernelIntentResolveMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + return False + + @staticmethod + def _intent_llm_enabled() -> bool: + return True + + async def _resolve_intent_with_llm(self, **kwargs): + captured.update(kwargs) + return CanonicalIntent( + name="query.read_only", + confidence=0.79, + project_id=None, + signal_title="", + signal_summary="", + metadata={}, + ) + + resolver = FakeResolver() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=104, + last_message_text="A", + last_active_at=datetime.now(), + ) + + _, source = await resolver._resolve_intent( + message_text="这个怎么做", + session=session, + world_model={}, + thread_context=None, + ) + + assert source == "llm_only" + assert captured["session_last_intent_override"] == "" + assert captured["session_project_id_override"] is None + assert captured["inherit_session_context"] is False + + +@pytest.mark.asyncio +async def test_kernel_intent_resolve_no_longer_calls_short_confirmation_helper() -> None: + captured: dict[str, object] = {} + + class FakeResolver(KernelIntentResolveMixin): + def __init__(self) -> None: + self.tenant_id = 1 + + def _is_short_confirmation_message(self, **kwargs): + _ = kwargs + raise AssertionError("legacy short confirmation helper should not be used") + + @staticmethod + def _intent_llm_enabled() -> bool: + return True + + async def _resolve_intent_with_llm(self, **kwargs): + captured.update(kwargs) + return CanonicalIntent( + name="query.read_only", + confidence=0.79, + project_id=None, + signal_title="", + signal_summary="", + metadata={}, + ) + + resolver = FakeResolver() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="project.create", + current_project_id=104, + last_message_text="帮我创建一个新项目", + last_active_at=datetime.now(), + ) + + _, source = await resolver._resolve_intent( + message_text="确认", + session=session, + world_model={}, + thread_context={"reply_to_message_id": "reply-1"}, + ) + + assert source == "llm_only" + assert captured["session_last_intent_override"] == "project.create" + assert captured["session_project_id_override"] == 104 + assert captured["inherit_session_context"] is True @pytest.mark.asyncio @@ -1395,6 +2863,76 @@ async def test_kernel_intent_llm_prompt_guides_natural_problem_statement_to_proj assert "先帮我分析" in prompt +@pytest.mark.asyncio +async def test_kernel_intent_llm_prompt_can_suppress_stale_session_project(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakeResolver(KernelIntentLLMMixin): + def __init__(self) -> None: + self.tenant_id = 1 + self.db = object() + + def _build_canonical_intent(self, *, intent_name, message_text, project_id, confidence, metadata): # noqa: ANN001 + return CanonicalIntent( + name=intent_name, + confidence=confidence, + project_id=project_id, + signal_title="", + signal_summary=message_text[:300], + metadata=metadata, + ) + + async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 + captured["prompt"] = prompt + captured["kwargs"] = kwargs + return StructuredExtractionResult( + payload={ + "family": "query", + "action": "answer", + "intent": "query.read_only", + "confidence": 0.74, + "project_id": 0, + "requested_count": 1, + "requires_confirmation": False, + "parameters": {}, + "reasoning": "低上下文输入,不能继承过期项目。", + }, + source_field="generated_text", + provider="mock", + model="mock-model", + raw_text='{"intent":"query.read_only"}', + validation="ok", + ) + + monkeypatch.setattr( + "app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_llm.llm_service.generate_structured_object", + fake_generate_structured_object, + ) + + resolver = FakeResolver() + session = SimpleNamespace( + sender="feishu:chat-1:ou_dm_1", + last_intent="query.read_only", + current_project_id=104, + last_active_at=datetime.now(), + ) + intent = await resolver._resolve_intent_with_llm( + message_text="A", + session=session, + world_model={}, + rule_intent=None, + session_last_intent_override="", + session_project_id_override=None, + inherit_session_context=False, + ) + + assert intent is not None + prompt = str(captured["prompt"] or "") + assert "- session_last_intent: " in prompt + assert "- session_project_id: None" in prompt + assert "104" not in prompt + + def test_reply_renderer_formats_project_delete_pending_and_success() -> None: renderer = ReplyRenderer( state_waiting_manual="waiting_manual", @@ -1438,6 +2976,74 @@ def test_reply_renderer_formats_project_delete_pending_and_success() -> None: assert "已删除项目#12《重复项目》" in success["text"] +@pytest.mark.asyncio +async def test_feishu_outbox_service_enqueue_preserves_requirement_plan_text_above_2000_chars( + feishu_outbox_db_session, +) -> None: + text = _build_long_requirement_plan_reply_text() + assert len(text) > 2000 + + outbox_id = await FeishuOutboxService.enqueue( + feishu_outbox_db_session, + tenant_id=3, + chat_id="oc_requirement_plan_budget", + text=text, + source_message_id="msg-requirement-plan-budget-service", + ) + + row = ( + await feishu_outbox_db_session.execute( + select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == int(outbox_id or 0)) + ) + ).scalar_one() + assert row.message_text == text + assert "C. 长期能力底座" in row.message_text + assert "⭐ 当前我更推荐:B" in row.message_text + + +@pytest.mark.asyncio +async def test_feishu_reply_outbox_mixin_enqueue_preserves_requirement_plan_text_above_2000_chars( + monkeypatch, + feishu_outbox_db_session, +) -> None: + maker = async_sessionmaker(feishu_outbox_db_session.bind, expire_on_commit=False) + + class FakeReply(feishu_mixin_reply_outbox.FeishuReplyOutboxMixin): + def __init__(self) -> None: + self.tenant_id = 3 + + @staticmethod + def _build_mention_prefix(*, open_id=None, display_name=None): # noqa: ANN001 + _ = (open_id, display_name) + return "" + + @staticmethod + async def _notify_outbox_ready(db, *, outbox_id): # noqa: ANN001 + _ = (db, outbox_id) + return None + + monkeypatch.setattr(feishu_mixin_reply_outbox, "AsyncSessionLocal", maker) + text = _build_long_requirement_plan_reply_text() + assert len(text) > 2000 + + outbox_id = await FakeReply()._enqueue_reply_outbox( + chat_id="oc_requirement_plan_budget", + message_text=text, + trace_id="trace-requirement-plan-budget", + source_message_id="msg-requirement-plan-budget-mixin", + ) + + async with maker() as verify_session: + row = ( + await verify_session.execute( + select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == int(outbox_id)) + ) + ).scalar_one() + assert row.message_text == text + assert "C. 长期能力底座" in row.message_text + assert "⭐ 当前我更推荐:B" in row.message_text + + @pytest.mark.asyncio async def test_finalize_waiting_manual_passes_execution_result_to_renderer(monkeypatch) -> None: captured: dict[str, object] = {} @@ -1602,6 +3208,8 @@ async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> N self.db = object() self.tenant_id = 3 + _install_requirement_plan_query_bundle(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( @@ -1825,7 +3433,41 @@ async def test_execute_query_intent_does_not_rollback_healthy_session_on_rag_fai def _build_query_error_message(exc: Exception) -> str: return f"query failed: {exc}" + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-plan-rollback", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.9, + metadata={"message_intent": "requirement_plan"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": "new", + }, + ) + + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) executor = FakeExecutor() intent = SimpleNamespace(project_id=12, metadata={}) @@ -1888,6 +3530,83 @@ async def test_execute_query_intent_short_circuits_casual_chat_without_rag(monke assert str(result["rag_result"]["query_type"]) == "chat" +@pytest.mark.asyncio +async def test_execute_query_intent_backfills_semantic_bundle_for_casual_chat_without_legacy_helpers( + monkeypatch, +) -> None: + planner_calls: list[dict[str, object]] = [] + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("casual chat should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 3 + + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + planner_calls.append( + { + "message_text": message_text, + "source_system": source_system, + "metadata": dict(metadata or {}), + } + ) + return WorkflowBundle( + bundle_id="bundle-casual", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.95, + metadata={"message_intent": "casual_chat"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "casual_chat", + "consultant_mode_preference": "brainstorm", + }, + ) + + def _legacy_helper_should_not_run(*args, **kwargs): # noqa: ANN002, ANN003 + raise AssertionError("legacy semantic helper should not run") + + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) + executor = FakeExecutor() + intent = SimpleNamespace(project_id=None, metadata={}) + + result = await executor._execute_query_intent( + intent=intent, + message_text="早上好,今天你在线吗", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-chat-backfill-1", + ) + + assert planner_calls, "missing semantic bundle should be backfilled through aplan_message" + assert result["status"] == "succeeded" + assert "我在线" in str(result["answer"] or "") + assert str(result["rag_result"]["query_type"]) == "chat" + + @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_followup_clarify_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: @@ -1951,7 +3670,41 @@ async def test_execute_query_intent_short_circuits_requirement_planning_without_ self.db = object() self.tenant_id = 3 + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-plan-direct", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.91, + metadata={"message_intent": "requirement_plan"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": "new", + }, + ) + + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) @@ -1966,11 +3719,89 @@ async def test_execute_query_intent_short_circuits_requirement_planning_without_ answer = str(result["answer"] or "") assert result["status"] == "succeeded" assert "不创建" in answer - assert "哪一步" in answer + assert "这类 IT 项目来收" in answer + assert "⭐ 当前我更推荐:B" in answer assert answer.count("?") <= 1 assert str(result["rag_result"]["query_type"]) == "plan" +@pytest.mark.asyncio +async def test_execute_query_intent_backfills_semantic_bundle_for_requirement_plan_without_legacy_helpers( + monkeypatch, +) -> None: + planner_calls: list[dict[str, object]] = [] + + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("requirement planning should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 3 + + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + planner_calls.append( + { + "message_text": message_text, + "source_system": source_system, + "metadata": dict(metadata or {}), + } + ) + return WorkflowBundle( + bundle_id="bundle-plan", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.91, + metadata={"message_intent": "requirement_plan"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": "new", + }, + ) + + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) + executor = FakeExecutor() + intent = SimpleNamespace(project_id=None, metadata={}) + + result = await executor._execute_query_intent( + intent=intent, + message_text="我们想做一个给电动车品牌用的直播复盘工具,先别创建项目,我还在补范围。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-chat-backfill-plan-1", + ) + + assert planner_calls, "missing requirement metadata should be backfilled through aplan_message" + assert result["status"] == "succeeded" + assert "不创建" in str(result["answer"] or "") + assert str(result["rag_result"]["query_type"]) == "plan" + + @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_scene_short_circuits_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: @@ -1986,6 +3817,8 @@ async def test_execute_query_intent_requirement_plan_scene_short_circuits_withou self.db = object() self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( @@ -2006,9 +3839,9 @@ async def test_execute_query_intent_requirement_plan_scene_short_circuits_withou answer = str(result["answer"] or "") assert result["status"] == "succeeded" - assert "主要想根据什么来打" in answer - assert "三件事" not in answer - assert answer.count("?") <= 1 + assert "这类 IT 项目来收" in answer + assert "⭐ 当前我更推荐:B" in answer + assert "A." in answer and "B." in answer and "C." in answer assert str(result["rag_result"]["query_type"]) == "plan" @@ -2030,7 +3863,41 @@ async def test_execute_query_intent_requirement_plan_followup_persists_progress_ self.db = requirement_plan_db_session self.tenant_id = 3 + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-plan-draft-friendly", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.92, + metadata={"message_intent": "requirement_plan"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": "new", + }, + ) + + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) executor = FakeExecutor() initial_intent = SimpleNamespace( @@ -2065,7 +3932,7 @@ async def test_execute_query_intent_requirement_plan_followup_persists_progress_ ) second = await executor._execute_query_intent( intent=followup_intent, - message_text="先按技术指标来,主要给投顾看。", + message_text="A,主要给投顾看。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-1", @@ -2073,28 +3940,41 @@ async def test_execute_query_intent_requirement_plan_followup_persists_progress_ assert first["status"] == "succeeded" assert second["status"] == "succeeded" - assert "根据什么来打" in str(first["answer"] or "") - assert "我先记下" in str(second["answer"] or "") - assert "技术指标" in str(second["answer"] or "") - assert "主要想根据什么来打" not in str(second["answer"] or "") - assert "怎么接收" in str(second["answer"] or "") or "输出" in str(second["answer"] or "") + assert "这类 IT 项目来收" in str(first["answer"] or "") + assert "⭐ 当前我更推荐:B" in str(first["answer"] or "") + assert "技术路线定住" in str(second["answer"] or "") + assert "A." in str(second["answer"] or "") + assert "B." in str(second["answer"] or "") + assert "C." in str(second["answer"] or "") topic = ( await requirement_plan_db_session.execute( - select(MountedTopicWorkspace).where(MountedTopicWorkspace.tenant_id == 3) + select(MountedTopicWorkspace) + .where( + MountedTopicWorkspace.tenant_id == 3, + MountedTopicWorkspace.channel_ref == "chat-plan-1", + ) + .order_by(MountedTopicWorkspace.id.desc()) + .limit(1) ) ).scalar_one() draft = ( await requirement_plan_db_session.execute( - select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) + select(MountedDraftWorkspace) + .join(MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id) + .where( + MountedDraftWorkspace.tenant_id == 3, + MountedTopicWorkspace.channel_ref == "chat-plan-1", + ) + .order_by(MountedDraftWorkspace.id.desc()) + .limit(1) ) ).scalar_one() card = draft.requirement_card or {} assert topic.mode == "plan" assert draft.state == "collecting" - assert card["scoring_basis"] - assert "技术指标" in str(card["scoring_basis"]) + assert card["project_nature"] == "轻量打分工具" assert "投顾" in str(card["target_user"]) @@ -2116,6 +3996,8 @@ async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draf self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -2151,33 +4033,41 @@ async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draf ) await executor._execute_query_intent( intent=intent_followup, - message_text="先按技术指标来,主要给投顾看。", + message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-2a", ) await executor._execute_query_intent( intent=intent_followup, - message_text="先做成飞书日报,每天收盘后给一个分数和一句解释。", + message_text="主要给投顾看。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-2b", ) + await executor._execute_query_intent( + intent=intent_followup, + message_text="任务编排平台版,用 FastAPI + React + Redis + Celery + PostgreSQL。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-reply-2c", + ) final_result = await executor._execute_query_intent( intent=intent_followup, message_text="第一版就先保留分数、方向判断和一句解释,别做太重。", operator_user_id=999, source_system="feishu", - source_message_id="msg-plan-reply-2c", + source_message_id="msg-plan-reply-2d", ) answer = str(final_result["answer"] or "") assert final_result["status"] == "succeeded" - assert "需求草稿" in answer - assert "【需求名称】" in answer - assert "【打分依据】" in answer - assert "【输出形式】" in answer - assert "【更新频率】" in answer + assert "📋 我先按当前收敛结果" in answer + assert "🧾【需求名称】" in answer + assert "🏗️【项目性质】" in answer + assert "👥【服务对象】" in answer + assert "⚙️【技术路线】" in answer + assert "🧱【第一版范围】" in answer assert str(final_result["rag_result"]["query_type"]) == "draft" assert final_result["reply_template_key"] == "draft_pending_confirm" assert final_result["draft_artifact"]["promote_to_confirmation"] is True @@ -2195,7 +4085,7 @@ async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draf assert draft.state == "ready" assert "每日 A 股趋势打分工具" in str((draft.draft_payload or {}).get("title") or "") assert draft.active_work_item_id is None - assert draft.latest_source_message_id == "msg-plan-reply-2c" + assert draft.latest_source_message_id == "msg-plan-reply-2d" assert draft.latest_reply_message_id is None assert topic.plan_status == "awaiting_execute_confirm" assert topic.pending_actions == ["confirm_requirement_draft"] @@ -2219,6 +4109,8 @@ async def test_execute_query_intent_explicit_new_requirement_topic_suspends_prev self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -2265,8 +4157,10 @@ async def test_execute_query_intent_explicit_new_requirement_topic_suspends_prev ) ).scalars().all() + answer = str(switch_result["answer"] or "") assert len(rows) == 2 - assert "切到新需求" in str(switch_result["answer"] or "") + assert "🧭 我先按" in answer + assert "A." in answer and "B." in answer and "C." in answer assert rows[0].status == "suspended" assert rows[0].plan_status == "suspended" assert rows[1].status == "active" @@ -2291,6 +4185,8 @@ async def test_execute_query_intent_explicit_new_it_requirement_overrides_stale_ self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) + _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -2375,7 +4271,7 @@ async def test_execute_query_intent_explicit_new_it_requirement_overrides_stale_ first_answer = str(first["answer"] or "") second_answer = str(second["answer"] or "") - assert "🧭 我先按“批量自动化部署 OpenClaw”这类 IT 项目来收。" in first_answer + assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in first_answer assert "云原生交付底座" in first_answer assert "目标对象" not in first_answer assert "A股趋势打分" not in first_answer @@ -2419,6 +4315,7 @@ async def test_execute_query_intent_explicit_new_requirement_topic_reuses_curren self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -2521,6 +4418,7 @@ async def test_execute_query_intent_explicit_new_requirement_topic_with_subject_ self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -2589,7 +4487,8 @@ async def test_execute_query_intent_explicit_new_requirement_topic_with_subject_ answer = str(result["answer"] or "") assert "你先发一句这次真正想解决什么问题" not in answer - assert "主要给谁用" in answer + assert "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。" in answer + assert "⭐ 当前我更推荐:B" in answer assert "会议纪要" not in answer assert "输入先按用户当前需求描述来" not in answer assert "A." in answer @@ -2617,6 +4516,7 @@ async def test_execute_query_intent_low_overlap_requirement_seed_switches_new_to self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -2721,6 +4621,7 @@ async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_an self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -2754,7 +4655,7 @@ async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_an ) second = await executor._execute_query_intent( intent=fragmented_followup_intent, - message_text="技术指标吧", + message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3a", @@ -2768,40 +4669,31 @@ async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_an ) fourth = await executor._execute_query_intent( intent=fragmented_followup_intent, - message_text="飞书日报就行", + message_text="B", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3c", ) - fifth = await executor._execute_query_intent( - intent=fragmented_followup_intent, - message_text="每天收盘后发", - operator_user_id=999, - source_system="feishu", - source_message_id="msg-plan-reply-3d", - ) final_result = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="第一版先保留分数、方向判断和一句解释就够了", operator_user_id=999, source_system="feishu", - source_message_id="msg-plan-reply-3e", + source_message_id="msg-plan-reply-3d", ) - assert "根据什么来打" in str(first["answer"] or "") - assert "给谁看" in str(second["answer"] or "") + assert "这类 IT 项目来收" in str(first["answer"] or "") + assert "服务对象定住" in str(second["answer"] or "") assert str(second["rag_result"]["query_type"]) == "plan" - assert "怎么接收" in str(third["answer"] or "") + assert "技术路线定住" in str(third["answer"] or "") assert str(third["rag_result"]["query_type"]) == "plan" - assert "什么时候更新" in str(fourth["answer"] or "") + assert "第一版先证明什么价值" in str(fourth["answer"] or "") assert str(fourth["rag_result"]["query_type"]) == "plan" - assert "先保留哪些输出" in str(fifth["answer"] or "") - assert str(fifth["rag_result"]["query_type"]) == "plan" final_answer = str(final_result["answer"] or "") - assert "需求草稿" in final_answer - assert "【需求名称】" in final_answer - assert "【第一版范围】" in final_answer + assert "📋 我先按当前收敛结果" in final_answer + assert "🧾【需求名称】" in final_answer + assert "🧱【第一版范围】" in final_answer assert str(final_result["rag_result"]["query_type"]) == "draft" draft = ( @@ -2810,15 +4702,14 @@ async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_an ) ).scalar_one() card = draft.requirement_card or {} - assert card["scoring_basis"] + assert card["project_nature"] == "轻量打分工具" assert "投顾" in str(card["target_user"] or "") - assert "飞书日报" in str(card["output_form"] or "") - assert "收盘后" in str(card["cadence"] or "") + assert "评分服务版" in str(card["tech_route"] or "") assert "方向判断" in str(card["first_scope"] or "") @pytest.mark.asyncio -async def test_execute_query_intent_requirement_plan_generalizes_for_meeting_minutes_tool( +async def test_execute_query_intent_requirement_plan_defaults_to_it_pm_template_for_current_system( monkeypatch, requirement_plan_db_session, ) -> None: @@ -2828,13 +4719,14 @@ async def test_execute_query_intent_requirement_plan_generalizes_for_meeting_min async def answer_question(self, **kwargs): _ = kwargs - raise AssertionError("generic requirement plan should not enter heavy RAG path") + raise AssertionError("PMFlow IT requirement plan should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -2861,61 +4753,67 @@ async def test_execute_query_intent_requirement_plan_generalizes_for_meeting_min first = await executor._execute_query_intent( intent=root_intent, - message_text="想跟你聊个需求,我们开完会以后待办经常散掉,想做个小工具自动从会议纪要里提待办,先别创建,先收第一版。", + message_text="想跟你聊个需求,做一个企业级项目管理系统里的需求收敛功能,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-root", ) second = await executor._execute_query_intent( intent=followup_intent, - message_text="先给内部 PM 和研发负责人用。", + message_text="B,先做发布治理平台。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-2", ) third = await executor._execute_query_intent( intent=followup_intent, - message_text="飞书总结就行,不用单独页面。", + message_text="B,主要给项目管理团队用。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-3", ) fourth = await executor._execute_query_intent( intent=followup_intent, - message_text="每次会后半小时内整理出来。", + message_text="B,走任务编排平台版,用 FastAPI + React + Redis + Celery + Postgres。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-4", ) final_result = await executor._execute_query_intent( intent=followup_intent, - message_text="第一版先抽待办、责任人和截止时间,不做复杂优先级。#meeting-plan-debug-tag", + message_text="待办、责任人、截止时间。#meeting-plan-debug-tag", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-5", ) - assert "主要给谁用" in str(first["answer"] or "") or "主要给谁看" in str(first["answer"] or "") + assert "这类 IT 项目来收" in str(first["answer"] or "") + assert "先定它更像工具、平台,还是长期底座" in str(first["answer"] or "") assert "A." in str(first["answer"] or "") assert "B." in str(first["answer"] or "") assert "C." in str(first["answer"] or "") - assert "怎么给结果" in str(second["answer"] or "") or "怎么接收" in str(second["answer"] or "") or "最后怎么给你" in str(second["answer"] or "") + assert "⭐ 当前我更推荐:B" in str(first["answer"] or "") + assert "平台运维团队" in str(second["answer"] or "") + assert "任务编排平台版" in str(third["answer"] or "") assert "A." in str(second["answer"] or "") assert "B." in str(second["answer"] or "") assert "C." in str(second["answer"] or "") - assert "多久整理出来" in str(third["answer"] or "") or "时效" in str(third["answer"] or "") + assert "批量执行闭环" in str(fourth["answer"] or "") + assert "⭐ 当前我更推荐:A" in str(fourth["answer"] or "") assert "A." in str(third["answer"] or "") assert "B." in str(third["answer"] or "") assert "C." in str(third["answer"] or "") - assert "先保留哪些字段" in str(fourth["answer"] or "") or "第一版先保留哪些字段" in str(fourth["answer"] or "") final_answer = str(final_result["answer"] or "") - assert "需求草稿" in final_answer - assert "【输入材料】会议纪要" in final_answer - assert "【目标用户】内部 PM 和研发负责人" in final_answer - assert "【输出形式】飞书总结" in final_answer - assert "【响应时效】每次会后半小时内" in final_answer - assert "【第一版范围】第一版先抽待办、责任人和截止时间,不做复杂优先级。" in final_answer + assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer + assert "🧭【一句话判断】" in final_answer + assert "【输入材料】" not in final_answer + assert "【响应时效】" not in final_answer + assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer + assert "👥【服务对象】第一版先服务项目管理团队" in final_answer + assert "⚙️【技术路线】当前更适合走任务编排平台版" in final_answer + assert "🧱【第一版范围】待办、责任人、截止时间" in final_answer + assert "🚀【推进方式】先按项目级试点(带基础审批和回滚)推进" in final_answer assert "#meeting-plan-debug-tag" not in final_answer assert str(final_result["rag_result"]["query_type"]) == "draft" @@ -2925,13 +4823,768 @@ async def test_execute_query_intent_requirement_plan_generalizes_for_meeting_min ) ).scalar_one() card = draft.requirement_card or {} - assert card["source_material"] == "会议纪要" - assert "内部 PM 和研发负责人" in str(card["target_user"] or "") - assert card["output_form"] == "飞书总结" - assert card["cadence"] == "每次会后半小时内" + assert card["plan_profile"] == "it_project" + assert card["project_nature"] == "发布治理平台" + assert "项目管理团队" in str(card["target_user"] or "") + assert "FastAPI" in str(card["tech_route"] or "") + assert card["delivery_mode"] == "项目级试点(带基础审批和回滚)" assert "待办" in str(card["first_scope"] or "") +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_injects_context_to_llm_for_abc_options( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("LLM-backed ABC requirement plan should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + captured: list[dict[str, object]] = [] + _install_requirement_plan_round_llm(monkeypatch, captured) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-outfit", + "source_sender_open_id": "ou-plan-outfit", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-root", + ) + + answer = str(result["answer"] or "") + assert result["status"] == "succeeded" + assert captured, "requirement_plan ABC options must be generated through the LLM round service" + assert str(captured[0]["kwargs"].get("function_name")) == "pmflow_requirement_plan_round" + assert "换季系统自动推荐搭配衣服" in str(captured[0]["prompt"]) + assert '"missing_field": "project_nature"' in str(captured[0]["prompt"]) + assert "A. 先做穿搭推荐小工具" in answer + assert "B. 先做个性化穿搭推荐平台" in answer + assert "C. 直接做穿搭决策底座" in answer + assert "⭐ 当前我更推荐:B" in answer + assert "批量自动化部署" not in answer + assert "发布治理平台" not in answer + assert "OpenClaw" not in answer + assert str(result["rag_result"]["query_type"]) == "plan" + + followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-outfit", + "source_sender_open_id": "ou-plan-outfit", + "thread_context": { + "root_message_id": "msg-plan-outfit-root", + "reply_to_message_id": "msg-plan-outfit-root", + }, + }, + ) + second = await executor._execute_query_intent( + intent=followup_intent, + message_text="B", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-2", + ) + assert second["status"] == "succeeded" + + draft = ( + await requirement_plan_db_session.execute( + select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) + ) + ).scalar_one() + card = draft.requirement_card or {} + assert card["project_nature"] == "个性化穿搭推荐平台" + assert card["project_nature"] != "发布治理平台" + + +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_generates_contextual_outfit_draft_instead_of_old_it_template( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("requirement plan draft should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + calls: list[dict[str, object]] = [] + + def _extract_packet(prompt: str, marker: str) -> dict[str, object]: + if marker not in prompt: + return {} + raw = prompt.split(marker, 1)[1].strip() + try: + payload = json.loads(raw) + except Exception: + return {} + return payload if isinstance(payload, dict) else {} + + async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 + function_name = str(kwargs.get("function_name") or "") + calls.append({"function_name": function_name, "prompt": prompt}) + if function_name == "pmflow_requirement_plan_round": + packet = _extract_packet(prompt, "requirement_plan_round_packet:") + context_blob = json.dumps(packet, ensure_ascii=False) + if '"missing_field": "project_nature"' in prompt: + payload = { + "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", + "focus": "现在最关键的是先把第一版定位定住。", + "options": [ + {"key": "A", "title": "先做穿搭推荐小工具", "best_for": "先验证换季推荐有没有真实使用价值", "suggested_capability": "规则推荐、基础标签、简单反馈", "rationale": "落地快,适合先跑通最小闭环", "tradeoff": "个性化能力会偏弱"}, + {"key": "B", "title": "先做个性化穿搭推荐平台", "best_for": "持续沉淀用户偏好、衣橱数据和搭配反馈", "suggested_capability": "用户画像、衣物标签、推荐策略、反馈闭环", "rationale": "更贴近自动推荐搭配衣服的长期价值", "tradeoff": "第一版要先说清边界"}, + {"key": "C", "title": "直接做穿搭决策底座", "best_for": "后续要接多品牌和多场景运营", "suggested_capability": "多源接入、推荐服务、效果评估", "rationale": "长期上限最高", "tradeoff": "早期投入最大"}, + ], + "recommendation": {"key": "B", "reason": "它最贴近这类需求的持续价值,同时不会一开始做得过重。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + elif '"missing_field": "target_user"' in prompt: + payload = { + "opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。", + "focus": "现在先把第一版服务对象定住,不然后面推荐口径会飘。", + "options": [ + {"key": "A", "title": "普通消费者", "best_for": "先验证换季搭配推荐是否真的有帮助", "suggested_capability": "偏好反馈、天气/季节标签、收藏行为", "rationale": "最贴近最终使用者,能尽快拿到真实反馈", "tradeoff": "需要处理偏好差异"}, + {"key": "B", "title": "穿搭运营团队", "best_for": "先由运营维护搭配规则和推荐口径", "suggested_capability": "规则配置、款式标签、人工校验", "rationale": "更容易控制第一版质量", "tradeoff": "自动化程度会弱一些"}, + {"key": "C", "title": "品牌导购团队", "best_for": "结合商品推荐和导购场景做转化验证", "suggested_capability": "商品库、搭配卡片、导购反馈", "rationale": "更适合商业转化场景", "tradeoff": "会更依赖商品数据"}, + ], + "recommendation": {"key": "A", "reason": "先服务普通消费者,最能验证自动推荐是否真的成立。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + elif '"missing_field": "tech_route"' in prompt and "换季系统自动推荐搭配衣服" in context_blob: + payload = { + "opening": "🧭 我先按“普通消费者先试用”这个方向继续收。", + "focus": "现在先把第一版能力路径定住,避免一开始做重。", + "options": [ + {"key": "A", "title": "规则推荐版", "best_for": "先用季节、温度、场景和衣物标签跑通推荐闭环", "suggested_capability": "标签规则、搭配模板、简单反馈", "rationale": "最快验证推荐是否有用", "tradeoff": "后续个性化能力要继续补"}, + {"key": "B", "title": "轻量模型推荐版", "best_for": "希望更早引入用户偏好和相似搭配学习", "suggested_capability": "基础召回、轻量排序、反馈学习", "rationale": "推荐体验会更自然", "tradeoff": "第一版调参成本更高"}, + {"key": "C", "title": "多模态推荐底座", "best_for": "一开始就要处理图片理解和风格迁移", "suggested_capability": "图文向量、风格标签、多模态检索", "rationale": "长期上限更高", "tradeoff": "首版复杂度太高"}, + ], + "recommendation": {"key": "A", "reason": "先用规则和标签跑通首轮推荐,更符合当前验证阶段。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + else: + payload = { + "opening": "🧭 我先按“规则推荐版”继续收尾。", + "focus": "最后定一下第一版先保留哪些推荐范围。", + "options": [ + {"key": "A", "title": "先保留温度、场景、单品搭配建议", "best_for": "先把最基础的换季推荐闭环跑通", "suggested_capability": "温度标签、场景标签、单品匹配规则", "rationale": "最容易验证用户是否愿意使用", "tradeoff": "短期内还没有风格迁移"}, + {"key": "B", "title": "加入用户偏好和历史收藏", "best_for": "希望一开始就看个性化提升", "suggested_capability": "偏好特征、收藏反馈、简单排序", "rationale": "推荐会更贴近个人习惯", "tradeoff": "冷启动成本更高"}, + {"key": "C", "title": "加入图片理解和风格迁移", "best_for": "要做更强的视觉搭配能力", "suggested_capability": "图像理解、风格识别、向量检索", "rationale": "长期价值更高", "tradeoff": "第一版会明显做重"}, + ], + "recommendation": {"key": "A", "reason": "先把温度、场景、单品搭配建议跑通,最适合验证首轮价值。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + } + return StructuredExtractionResult( + payload=payload, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + if function_name == "pmflow_requirement_plan_draft": + return StructuredExtractionResult( + payload={ + "intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:", + "title": "换季系统自动推荐搭配衣服", + "one_liner": "先面向普通消费者做一版换季穿搭推荐试用,先把温度、场景和单品搭配建议跑成可验证闭环。", + "goal": "先验证用户是否愿意使用换季搭配推荐,并收集最基础的点击、收藏和反馈信号。", + "project_nature": "这件事更像个性化穿搭推荐平台的第一版,不是部署平台,也不是内部流程治理工具。", + "target_user": "第一版先服务普通消费者,重点看他们是否会点击、收藏和采纳推荐结果。", + "signal_inputs": "当前至少要用到:用户当前需求描述;季节/温度信息;衣物标签;基础反馈数据。", + "known_info": "目前已经明确:这是换季穿搭推荐需求;先按个性化穿搭推荐平台收敛;第一版先给普通消费者试用;技术路线先按规则推荐版;范围先保留温度、场景、单品搭配建议。", + "tech_route": "当前更适合先走规则推荐版,用季节、温度、场景和衣物标签跑通首轮推荐。", + "first_scope": "第一版先保留温度、场景、单品搭配建议,先验证推荐是否被点击、收藏和采纳。", + "core_flow": "用户进入换季推荐入口 -> 结合温度/场景生成搭配建议 -> 展示单品组合 -> 记录点击/收藏/反馈 -> 继续优化推荐规则", + "delivery_mode": "先做小范围真实用户试用,优先看真实点击、收藏和主观反馈。", + "success_signal": "先保证能稳定给出换季搭配建议,并拿到点击、收藏或反馈中的至少一类真实用户信号。", + "risks": "当前主要风险在冷启动标签质量、搭配规则是否过于死板,以及首轮反馈样本是否足够。", + "pending_items": ["衣物标签口径是否已有现成标准", "首轮试用用户范围和反馈采集方式", "是否需要同步接商品库或衣橱数据"], + "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", + "positioning_one_liner": "面向普通消费者的小范围换季穿搭推荐试用,先跑通基础推荐闭环。", + "summary": "先用规则推荐跑通换季穿搭建议,再根据真实点击、收藏和反馈决定是否扩到更完整的平台能力。", + "target_customers": "普通消费者", + "external_signal_sources": [ + {"name": "天气 API", "purpose": "补充实时温度、体感和降雨变化", "phase": "首版优先接入"}, + {"name": "淘宝开放平台", "purpose": "补充可推荐商品的品类、颜色和季节属性", "phase": "首版优先接入"}, + {"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度和潮流变化", "phase": "第二阶段接入"}, + {"name": "小红书趋势榜", "purpose": "补充当季穿搭内容趋势和流行关键词", "phase": "第二阶段接入"}, + {"name": "微信导出回流", "purpose": "补充用户转发、收藏后的采纳反馈", "phase": "首版优先接入"}, + ], + "requirement_items": [ + {"section": "需求目标", "title": "验证用户价值", "description": "验证用户是否愿意使用换季搭配推荐,并留下点击、收藏或反馈。"}, + {"section": "服务对象", "title": "首批用户", "description": "第一版先服务普通消费者。"}, + {"section": "技术路线", "title": "规则推荐版", "description": "先基于季节、温度、场景和衣物标签生成推荐。"}, + {"section": "第一版范围", "title": "最小闭环", "description": "先保留温度、场景、单品搭配建议。"}, + ], + "missing_info": ["衣物标签标准", "试用用户范围", "反馈采集方式"], + "mvp_items": ["温度标签", "场景标签", "单品搭配建议", "基础点击/收藏反馈"], + "team_setup": [{"role": "建议配置", "notes": "产品/运营 1名、后端/算法 1名、前端 1名,先支撑小范围试用即可。"}], + "validation_metrics": {"product": ["推荐点击率", "收藏率", "用户主观反馈"]}, + }, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + raise AssertionError(f"unexpected function_name: {function_name}") + + monkeypatch.setattr( + mixin_execute_query_project.llm_service, + "generate_structured_object", + fake_generate_structured_object, + ) + async def _fake_recommend_requirement_plan_owner_for_draft(self, *, card: dict[str, object]): # noqa: ANN001 + _ = self, card + return { + "user_id": 18, + "name": "陈后端", + "role": "后端开发", + "workload_ratio": 28.0, + "reason": "系统检索到他当前负载较低,且技能更接近推荐与数据接入。", + "matched_skills": ["Python", "FastAPI", "数据接入"], + } + + monkeypatch.setattr( + mixin_execute_query_project.ExecuteQueryProjectMixin, + "_recommend_requirement_plan_owner_for_draft", + _fake_recommend_requirement_plan_owner_for_draft, + raising=False, + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + root_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-outfit-draft", + "source_sender_open_id": "ou-plan-outfit-draft", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-outfit-draft", + "source_sender_open_id": "ou-plan-outfit-draft", + "thread_context": { + "root_message_id": "msg-plan-outfit-draft-root", + "reply_to_message_id": "msg-plan-outfit-draft-root", + }, + }, + ) + + first_result = await executor._execute_query_intent( + intent=root_intent, + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-draft-root", + ) + await executor._execute_query_intent( + intent=followup_intent, + message_text="B", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-draft-2", + ) + await executor._execute_query_intent( + intent=followup_intent, + message_text="A", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-draft-3", + ) + await executor._execute_query_intent( + intent=followup_intent, + message_text="A", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-draft-4", + ) + final_result = await executor._execute_query_intent( + intent=followup_intent, + message_text="A", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-outfit-draft-5", + ) + + final_answer = str(final_result["answer"] or "") + assert first_result["status"] == "succeeded" + assert any(str(call.get("function_name")) == "pmflow_requirement_plan_draft" for call in calls) + assert "换季系统自动推荐搭配衣服" in final_answer + assert "普通消费者" in final_answer + assert "规则推荐版" in final_answer + assert "小范围真实用户试用" in final_answer + assert "🌐【外部信号源】" in final_answer + assert "天气 API" in final_answer + assert "淘宝开放平台" in final_answer + assert "得物开放数据" in final_answer + assert "小红书趋势榜" in final_answer + assert "👤【推荐负责人】陈后端" in final_answer + assert "系统检索到他当前负载较低" in final_answer + assert "人员能力图谱" not in final_answer + assert "工作流状态与审计日志" not in final_answer + assert "审批、审计、权限一次做满" not in final_answer + assert "确认门禁" not in final_answer + assert str(final_result["rag_result"]["query_type"]) == "draft" + + +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_retries_when_llm_leaks_internal_packet_keys( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("requirement plan should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + calls: list[dict[str, object]] = [] + + async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 + _ = kwargs + calls.append({"prompt": prompt}) + if len(calls) == 1: + return StructuredExtractionResult( + payload={ + "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", + "focus": "current_context 里已经有标题,这轮先收第一版定位。", + "options": [ + { + "key": "A", + "title": "先做穿搭推荐小工具", + "best_for": "先快速验证换季推荐是否有用", + "suggested_capability": "规则推荐、基础标签、简单反馈", + "rationale": "latest_user_message 说明这是个新需求,适合先做轻量闭环", + "tradeoff": "后续还要补个性化能力", + }, + { + "key": "B", + "title": "先做个性化穿搭推荐平台", + "best_for": "持续沉淀用户偏好和衣橱反馈", + "suggested_capability": "用户画像、衣物标签、反馈闭环", + "rationale": "collected_fields 还不多,先把主方向定住更重要", + "tradeoff": "第一版要先说清数据边界", + }, + { + "key": "C", + "title": "直接做穿搭决策底座", + "best_for": "后续接多品牌和多场景运营", + "suggested_capability": "多源接入、推荐服务、策略实验", + "rationale": "project_nature 如果一开始做重,上限会更高", + "tradeoff": "早期投入最大", + }, + ], + "recommendation": {"key": "B", "reason": "reply_budget 允许的范围内,B 更贴近长期价值。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + }, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + return StructuredExtractionResult( + payload={ + "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", + "focus": "现在先把第一版定位定住,再往下收目标用户和范围。", + "options": [ + { + "key": "A", + "title": "先做穿搭推荐小工具", + "best_for": "快速验证换季搭配推荐有没有真实使用价值", + "suggested_capability": "规则推荐、季节标签、基础反馈", + "rationale": "落地快,适合先把推荐闭环跑通", + "tradeoff": "个性化学习能力会偏弱", + }, + { + "key": "B", + "title": "先做个性化穿搭推荐平台", + "best_for": "持续沉淀用户偏好、衣橱数据和搭配反馈", + "suggested_capability": "用户画像、衣物标签、推荐策略、反馈闭环", + "rationale": "更贴近自动推荐搭配衣服这件事的长期价值", + "tradeoff": "第一版要先约束数据口径和推荐边界", + }, + { + "key": "C", + "title": "直接做穿搭决策底座", + "best_for": "后续要接多品牌、多场景和长期运营策略", + "suggested_capability": "多源数据接入、推荐服务、效果评估", + "rationale": "长期上限最高", + "tradeoff": "早期投入和依赖都更重", + }, + ], + "recommendation": {"key": "B", "reason": "它最贴近这类需求的长期价值,同时不会一开始就做得过重。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + }, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + + monkeypatch.setattr( + "app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object", + fake_generate_structured_object, + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-repair-internal-keys", + "source_sender_open_id": "ou-plan-repair-internal-keys", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-repair-internal-keys-root", + ) + + answer = str(result["answer"] or "") + assert result["status"] == "succeeded" + assert len(calls) == 2 + assert "current_context" not in answer + assert "latest_user_message" not in answer + assert "collected_fields" not in answer + assert "reply_budget" not in answer + assert "project_nature" not in answer + assert "⭐ 当前我更推荐:B" in answer + + +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_retries_stale_deployment_context_for_outfit_requirement( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("requirement plan should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + calls: list[str] = [] + + async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 + _ = kwargs + calls.append(prompt) + if '"missing_field": "project_nature"' in prompt: + return StructuredExtractionResult( + payload={ + "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", + "focus": "现在最关键的是先把第一版定位定住。", + "options": [ + { + "key": "A", + "title": "先做穿搭推荐小工具", + "best_for": "先快速验证搭配建议是否有用", + "suggested_capability": "规则推荐、天气标签、基础反馈", + "rationale": "适合先把最小闭环跑通", + "tradeoff": "个性化会偏弱", + }, + { + "key": "B", + "title": "先做个性化穿搭推荐平台", + "best_for": "持续沉淀用户偏好和衣橱反馈", + "suggested_capability": "用户画像、衣物标签、推荐策略", + "rationale": "更贴近自动推荐搭配衣服的长期价值", + "tradeoff": "第一版要先说清边界", + }, + { + "key": "C", + "title": "直接做穿搭决策底座", + "best_for": "后续要接更多品牌和场景", + "suggested_capability": "多源接入、策略实验、效果评估", + "rationale": "长期上限最高", + "tradeoff": "早期投入最大", + }, + ], + "recommendation": {"key": "B", "reason": "它更符合这类需求的长期价值,但不会一开始做得过重。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + }, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + if '"missing_field": "target_user"' in prompt and len(calls) == 2: + return StructuredExtractionResult( + payload={ + "opening": "🧭 我先按“发布治理平台”这个方向继续收。", + "focus": "现在要先把服务对象定住,不然后面治理边界会飘。", + "options": [ + { + "key": "A", + "title": "平台运维团队", + "best_for": "统一管理发布流程和回滚风险", + "suggested_capability": "审批、审计、回滚", + "rationale": "更适合承接部署治理闭环", + "tradeoff": "会偏离真实用户场景", + }, + { + "key": "B", + "title": "项目交付团队", + "best_for": "按项目复制部署流程", + "suggested_capability": "模板、参数、发布编排", + "rationale": "更适合多项目交付", + "tradeoff": "仍然偏旧部署场景", + }, + { + "key": "C", + "title": "开发团队", + "best_for": "自己发版自己看结果", + "suggested_capability": "日志查看、执行入口", + "rationale": "反馈快", + "tradeoff": "治理弱", + }, + ], + "recommendation": {"key": "A", "reason": "它最适合部署治理和回滚闭环。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + }, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + return StructuredExtractionResult( + payload={ + "opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。", + "focus": "现在先把第一版服务对象定住,不然后面推荐口径会飘。", + "options": [ + { + "key": "A", + "title": "普通消费者", + "best_for": "先验证换季搭配推荐是否真的有帮助", + "suggested_capability": "偏好反馈、天气/季节标签、收藏行为", + "rationale": "最贴近最终使用者,能尽快拿到真实反馈", + "tradeoff": "需要处理偏好差异", + }, + { + "key": "B", + "title": "穿搭运营团队", + "best_for": "先由运营维护搭配规则和推荐口径", + "suggested_capability": "规则配置、款式标签、人工校验", + "rationale": "更容易控制第一版质量", + "tradeoff": "自动化程度会弱一些", + }, + { + "key": "C", + "title": "品牌导购团队", + "best_for": "结合商品推荐和导购场景做转化验证", + "suggested_capability": "商品库、搭配卡片、导购反馈", + "rationale": "更适合商业转化场景", + "tradeoff": "会更依赖商品数据", + }, + ], + "recommendation": {"key": "A", "reason": "先服务普通消费者,最能验证自动推荐是否真的成立。"}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + }, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + + monkeypatch.setattr( + "app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object", + fake_generate_structured_object, + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + root_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-repair-stale-deployment", + "source_sender_open_id": "ou-plan-repair-stale-deployment", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + followup_intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-repair-stale-deployment", + "source_sender_open_id": "ou-plan-repair-stale-deployment", + "thread_context": { + "root_message_id": "msg-plan-repair-stale-deployment-root", + "reply_to_message_id": "msg-plan-repair-stale-deployment-root", + }, + }, + ) + + first = await executor._execute_query_intent( + intent=root_intent, + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-repair-stale-deployment-root", + ) + second = await executor._execute_query_intent( + intent=followup_intent, + message_text="B", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-repair-stale-deployment-2", + ) + + assert first["status"] == "succeeded" + assert second["status"] == "succeeded" + assert len(calls) == 3 + second_answer = str(second["answer"] or "") + assert "普通消费者" in second_answer + assert "穿搭运营团队" in second_answer + assert "品牌导购团队" in second_answer + assert "发布治理平台" not in second_answer + assert "平台运维团队" not in second_answer + assert "部署治理" not in second_answer + + +@pytest.mark.asyncio +async def test_execute_query_intent_requirement_plan_round_compacts_render_within_feishu_budget( + monkeypatch, + requirement_plan_db_session, +) -> None: + class FakeAdaptiveRAGService: + def __init__(self, db, tenant_id: int): + _ = (db, tenant_id) + + async def answer_question(self, **kwargs): + _ = kwargs + raise AssertionError("requirement plan should not enter heavy RAG path") + + class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): + def __init__(self) -> None: + self.db = requirement_plan_db_session + self.tenant_id = 3 + + repeated = ",".join(["覆盖用户偏好、衣橱标签、天气温度、场景约束、历史反馈"] * 12) + + async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 + _ = (prompt, kwargs) + return StructuredExtractionResult( + payload={ + "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。" + repeated, + "focus": "现在最关键的是先定第一版定位。" + repeated, + "options": [ + { + "key": "A", + "title": "轻量推荐工具", + "best_for": repeated, + "suggested_capability": repeated, + "rationale": repeated, + "tradeoff": repeated, + }, + { + "key": "B", + "title": "业务平台能力", + "best_for": repeated, + "suggested_capability": repeated, + "rationale": repeated, + "tradeoff": repeated, + }, + { + "key": "C", + "title": "长期能力底座", + "best_for": repeated, + "suggested_capability": repeated, + "rationale": repeated, + "tradeoff": repeated, + }, + ], + "recommendation": {"key": "B", "reason": repeated}, + "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", + }, + source_field="content", + provider="fake", + model="fake", + validation="ok", + ) + + monkeypatch.setattr( + "app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object", + fake_generate_structured_object, + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + executor = FakeExecutor() + + intent = SimpleNamespace( + project_id=None, + metadata={ + "source_chat_id": "chat-plan-over-budget", + "source_sender_open_id": "ou-plan-over-budget", + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + }, + ) + + result = await executor._execute_query_intent( + intent=intent, + message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", + operator_user_id=999, + source_system="feishu", + source_message_id="msg-plan-over-budget-root", + ) + + assert result["status"] == "succeeded" + answer = str(result["answer"] or "") + assert len(answer) <= 1600 + assert "A. 轻量推荐工具" in answer + assert "B. 业务平台能力" in answer + assert "C. 长期能力底座" in answer + assert "⭐ 当前我更推荐:B" in answer + + @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_pm_template( monkeypatch, @@ -2950,6 +5603,7 @@ async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_ self.db = requirement_plan_db_session self.tenant_id = 3 + _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() @@ -3009,15 +5663,9 @@ async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_ source_system="feishu", source_message_id="msg-plan-it-5", ) - final_result = await executor._execute_query_intent( - intent=followup_intent, - message_text="B,先做项目级试点,带基础审批和回滚。", - operator_user_id=999, - source_system="feishu", - source_message_id="msg-plan-it-6", - ) + final_result = fifth - assert "🧭 我先按“批量自动化部署 OpenClaw”这类 IT 项目来收。" in str(first["answer"] or "") + assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in str(first["answer"] or "") assert "A. 先做内部提效工具" in str(first["answer"] or "") assert "先定它更像工具、平台,还是长期底座" in str(first["answer"] or "") assert "⭐ 当前我更推荐:B" in str(first["answer"] or "") @@ -3028,7 +5676,7 @@ async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_ assert "⭐ 当前我更推荐:B" in str(second["answer"] or "") assert "🧭 我先按“平台运维团队要长期使用”这个方向继续收。" in str(third["answer"] or "") - assert "现在最关键的,不是再补更多背景,而是先把技术路线定住" in str(third["answer"] or "") + assert "第一版技术路线定住" in str(third["answer"] or "") assert "FastAPI + React + Redis + Celery + PostgreSQL" in str(third["answer"] or "") assert "⭐ 当前我更推荐:B" in str(third["answer"] or "") @@ -3036,16 +5684,12 @@ async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_ assert "批量执行闭环" in str(fourth["answer"] or "") assert "⭐ 当前我更推荐:A" in str(fourth["answer"] or "") - assert "🧭 我先按“批量执行闭环”继续收尾。" in str(fifth["answer"] or "") - assert "项目级试点" in str(fifth["answer"] or "") - assert "⭐ 当前我更推荐:B" in str(fifth["answer"] or "") - final_answer = str(final_result["answer"] or "") assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer assert "🧭【一句话判断】" in final_answer assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer assert "👥【服务对象】第一版先服务平台运维团队" in final_answer - assert "📡【信号源】当前至少要接入:" in final_answer + assert "📥【基础输入】当前至少要接入:" in final_answer assert "飞书需求消息" in final_answer assert "部署目标清单" in final_answer assert "📝【当前已知信息】目前已经明确:" in final_answer @@ -3242,7 +5886,39 @@ async def test_execute_query_intent_short_circuits_requirement_advise_by_text_wi self.db = object() self.tenant_id = 3 + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-advise-shortcut", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.92, + metadata={"message_intent": "business_review"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "business_review", + "consultant_mode_preference": "advise", + "advise_subtype": "scope", + }, + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) @@ -3278,7 +5954,39 @@ async def test_execute_query_intent_short_circuits_assignee_recommendation_witho _ = (message_text, project_id) return "如果现在先给一个保守负责人,我更倾向 AI工程师。" + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-assignee-shortcut", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.92, + metadata={"message_intent": "business_review"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "business_review", + "consultant_mode_preference": "advise", + "advise_subtype": "assignee", + }, + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) @@ -3361,7 +6069,38 @@ async def test_execute_query_intent_short_circuits_project_existence_lookup_with self.db = object() self.tenant_id = 3 + async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): + _ = (message_text, metadata) + return WorkflowBundle( + bundle_id="bundle-project-lookup-shortcut", + semantic_version="v1", + source_system=source_system, + primary_workflow="query_flow", + workflows=[ + WorkflowPlan( + workflow_type="query_flow", + workflow_action="answer", + target_scope="global", + operation_codes=["query.answer"], + requires_confirmation=False, + confidence=0.92, + metadata={"message_intent": "project_lookup_question"}, + ) + ], + message_gate="auto", + reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), + diagnostics={ + "message_intent": "project_lookup_question", + "consultant_mode_preference": "fact_retrieval", + }, + ) + monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) + monkeypatch.setattr( + mixin_execute_query_project.WorkflowSemanticPlanner, + "aplan_message", + _fake_aplan_message, + ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) @@ -3408,9 +6147,8 @@ async def test_should_route_to_draft_confirmation_session_uses_active_mounted_dr captured["kwargs"] = kwargs return True - monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) - kernel = FakeKernel() + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent="query.answer"), sender="tester", @@ -3461,8 +6199,94 @@ async def test_should_route_to_draft_confirmation_session_does_not_hijack_smallt ingress_options=None, ) + assert should_route is True + assert called is True + + +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_keeps_pending_confirm_session_without_keywords( + monkeypatch, +) -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + _ = (self, kwargs) + return False + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + + kernel = FakeKernel() + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), + sender="tester", + source_system="feishu", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="按这版继续", + ingress_options=None, + ) + + assert should_route is True + + +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_with_active_draft_does_not_require_revision_heuristics( + monkeypatch, +) -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + _ = (self, kwargs) + return True + + kernel = FakeKernel() + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent="query.answer"), + sender="tester", + source_system="feishu", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="保持当前草稿结构并补完整信息", + ingress_options=None, + ) + + assert should_route is True + + +@pytest.mark.asyncio +async def test_should_route_to_draft_confirmation_session_still_skips_explicit_independent_request_with_active_draft( + monkeypatch, +) -> None: + class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): + def __init__(self) -> None: + self.db = object() + self.tenant_id = 1 + + async def _fake_has_active_draft_session(self, **kwargs): + _ = (self, kwargs) + return True + + monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) + + kernel = FakeKernel() + should_route = await kernel._should_route_to_draft_confirmation_session( + session=SimpleNamespace(last_intent="query.answer"), + sender="tester", + source_system="feishu", + channel_ref="chat-active", + source_sender_open_id="ou_active", + message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。", + ingress_options=None, + ) + assert should_route is False - assert called is False def test_mounted_runtime_agent_name_uses_plan_coach_for_scope_advise() -> None: diff --git a/backend/tests/test_ai_pmo_signal_source_onboard_contract.py b/backend/tests/test_ai_pmo_signal_source_onboard_contract.py index 105f9180..768e17f3 100644 --- a/backend/tests/test_ai_pmo_signal_source_onboard_contract.py +++ b/backend/tests/test_ai_pmo_signal_source_onboard_contract.py @@ -9,6 +9,7 @@ from app.services.ai_pmo.ai_pmo_typed_executor_discovery.mixin_discover import ( from app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_signal_source import ( ExecuteSignalSourceMixin, ) +from app.services.signal.signal_source_orchestration import SignalSourceOrchestrationService def test_signal_source_onboard_idempotency_key_prefers_message_key() -> None: @@ -185,3 +186,27 @@ async def test_project_signal_discovery_uses_project_id_without_name_error() -> assert result["status"] == "pending_confirmation" assert result["project_id"] == 2 assert result["candidate_signals"][0]["title"] == "信号A" + + +def test_signal_source_kind_uses_explicit_repo_locator_without_hint_table() -> None: + assert ( + SignalSourceOrchestrationService._normalize_source_kind( + None, + text="请把这个来源接进来", + requested_via="", + source_payload={"repo": "acme/reco-engine"}, + ) + == "github" + ) + + +def test_signal_source_kind_uses_channel_config_without_hint_table() -> None: + assert ( + SignalSourceOrchestrationService._normalize_source_kind( + None, + text="先挂到当前群", + requested_via="", + source_payload={"config": {"channel_id": "oc_abc123"}}, + ) + == "feishu" + ) diff --git a/backend/tests/test_ai_pmo_task_intent_contract.py b/backend/tests/test_ai_pmo_task_intent_contract.py index 08eb396f..99590194 100644 --- a/backend/tests/test_ai_pmo_task_intent_contract.py +++ b/backend/tests/test_ai_pmo_task_intent_contract.py @@ -18,9 +18,38 @@ from app.services.ai_pmo.ai_pmo_intent import ( ) from app.services.ai_pmo.ai_pmo_kernel_common_core.imports import WORKFLOW_ROUTE_KEYS from app.services.ai_pmo.ai_pmo_typed_executor_execute.service import TypedExecutorExecuteMixin +from app.services.ai_pmo.semantic_context.decision_schema import SemanticDecision +from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner -def test_intent_resolver_recognizes_task_create() -> None: +def _planner_decision(*, workflows: list[dict]) -> SemanticDecision: + return SemanticDecision( + topic_relation="new_topic", + mode_decision="answer_only", + workflow_plans=workflows, + scene_metadata={}, + requested_actions=["workflow.bundle.compose"], + confidence=0.92, + ) + + +def test_intent_resolver_recognizes_task_create(monkeypatch: pytest.MonkeyPatch) -> None: + async def _fake_decide(cls, *, message_text: str, source_system: str, metadata=None): + _ = (message_text, source_system, metadata) + return _planner_decision( + workflows=[ + { + "workflow_type": "task_flow", + "workflow_action": "create", + "target_scope": "project", + "operation_codes": ["task.create"], + "confidence": 0.92, + } + ] + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide)) + intent = IntentResolver.resolve( text="请在项目#12 新建任务:修复登录超时", project_id=12, @@ -47,13 +76,54 @@ def test_route_resolver_builds_task_create_route() -> None: assert route.key == ROUTE_TASK_CREATE -def test_intent_resolver_negated_write_statement_falls_back_to_query_read_only() -> None: +def test_intent_resolver_negated_write_statement_follows_semantic_query_bundle(monkeypatch: pytest.MonkeyPatch) -> None: + async def _fake_decide(cls, *, message_text: str, source_system: str, metadata=None): + _ = (message_text, source_system, metadata) + return _planner_decision( + workflows=[ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "confidence": 0.86, + } + ] + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide)) + intent = IntentResolver.resolve( text="先不要创建任务,告诉我当前进展和风险", project_id=7, ) assert intent.name == INTENT_QUERY - assert str((intent.metadata or {}).get("reason") or "") == "negated_write_statement" + assert str((intent.metadata or {}).get("reason") or "") != "negated_write_statement" + + +def test_intent_resolver_followup_tokens_no_longer_override_semantic_query(monkeypatch: pytest.MonkeyPatch) -> None: + async def _fake_decide(cls, *, message_text: str, source_system: str, metadata=None): + _ = (cls, message_text, source_system, metadata) + return _planner_decision( + workflows=[ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "confidence": 0.86, + } + ] + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide)) + + intent = IntentResolver.resolve( + text="确认这条", + project_id=9, + ) + + assert intent.name == INTENT_QUERY def test_route_resolver_builds_query_answer_route() -> None: @@ -74,20 +144,20 @@ def test_route_resolver_builds_query_answer_route() -> None: def test_route_resolver_followup_action_requires_structured_metadata() -> None: - action = RouteResolver.parse_followup_action( + action = RouteResolver.resolve_followup_route_action( canonical_intent=INTENT_FOLLOWUP_CONTINUE, message_text="先取消这个工作项", ) assert action == ROUTE_ACTION_CONTINUE -def test_route_resolver_followup_action_allows_message_parsing_with_context() -> None: - action = RouteResolver.parse_followup_action( +def test_route_resolver_followup_action_no_longer_parses_message_text_with_context() -> None: + action = RouteResolver.resolve_followup_route_action( canonical_intent=INTENT_FOLLOWUP_CONTINUE, message_text="先取消这个工作项", metadata={"followup_context_present": True, "target_work_item_id": 8}, ) - assert action == ROUTE_ACTION_CANCEL + assert action == ROUTE_ACTION_CONTINUE def test_kernel_common_imports_exposes_workflow_route_keys() -> None: diff --git a/backend/tests/test_draft_confirm_handler.py b/backend/tests/test_draft_confirm_handler.py index b09c01de..e398ac0d 100644 --- a/backend/tests/test_draft_confirm_handler.py +++ b/backend/tests/test_draft_confirm_handler.py @@ -20,31 +20,6 @@ from app.services.integrations.feishu_integration_service.mixin_reply_llm_core i FeishuReplyLLMCoreMixin, ) - -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_draft_confirm_handler_thread_anchor_match() -> None: ingest = type( "Ingest", @@ -185,6 +160,47 @@ def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None: assert ack == "已收到,项目创建和信号源接入请求已记录。" +def test_feishu_reply_llm_core_action_ack_no_longer_calls_planner_action_classifier( + monkeypatch: pytest.MonkeyPatch, +) -> None: + _ = monkeypatch + + ack = FeishuReplyLLMCoreMixin._build_action_request_ack_reply( + message_content="随便一句不带动作的话", + capture_result={ + "status": "captured", + "semantic_bundle": { + "bundle_id": "semantic-1", + "semantic_version": "2026-03-27.workflow-v1", + "source_system": "feishu", + "primary_workflow": "signal_source_flow", + "workflows": [ + { + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "target_scope": "global", + "operation_codes": ["signal_source.onboard"], + "requires_confirmation": False, + "confidence": 0.92, + "slots": [], + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto_execute", + "reply_contract": { + "mode": "aggregate_card", + "template_key": "workflow_bundle", + "summary": "signal_source_flow", + }, + "diagnostics": {}, + }, + }, + ) + + assert ack == "已收到,信号源接入请求已记录。" + + def test_consultant_dispatch_contract_marks_incomplete_task_create_as_clarify() -> None: contract = build_consultant_dispatch_contract( intent_name="task.create", @@ -945,6 +961,121 @@ async def test_draft_confirm_handler_revise_without_note_requests_feedback(monke assert "修改" in result["followup_reply"] +@pytest.mark.asyncio +async def test_draft_confirm_handler_revise_requirement_plan_bypasses_generic_draft_generator(monkeypatch) -> None: + class _FakeResult: + def __init__(self, locked): + self._locked = locked + + def scalar_one_or_none(self): + return self._locked + + class _FakeDB: + def __init__(self, locked): + self._locked = locked + + async def execute(self, query): # noqa: ARG002 + return _FakeResult(self._locked) + + async def flush(self): + return None + + locked = SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + draft_expires_at=None, + confirm_message_id=None, + owner_user_id=77, + draft_content={ + "draft_source": "requirement_plan", + "plan_profile": "it_project", + "title": "换季穿搭助手", + "original_text": "做一个换季穿搭助手。", + "requirement_card": { + "plan_profile": "it_project", + "title": "换季穿搭助手", + "goal": "先验证换季穿搭推荐是否有用。", + "project_nature": "轻量级推荐工具", + "target_user": "穿搭新手个体用户", + "tech_route": "规则推荐版", + "first_scope": "基础属性匹配", + "delivery_mode": "小范围试用", + "question_round": 5, + }, + }, + execution_result={}, + ) + handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) + captured: dict[str, object] = {} + + async def _unexpected_generate_and_persist(**kwargs): # noqa: ANN001 + raise AssertionError(f"generic draft generator should not run for requirement_plan drafts: {kwargs}") + + async def _fake_revise_requirement_plan_draft(self, **kwargs): # noqa: ANN001 + captured["requirement_revise_kwargs"] = kwargs + locked.draft_version = 3 + locked.draft_content = { + **locked.draft_content, + "draft_text": ( + "📋 我先按当前收敛结果,整理成一版需求草稿:\n\n" + "🌐【外部信号源】\n" + "1. 天气 API:提供实时温度和体感变化。\n" + "2. 淘宝开放平台:补充当季商品与类目属性。\n" + "3. 得物开放数据:补充年轻用户偏好的款式热度。\n" + "4. 小红书趋势榜:补充换季搭配内容趋势。\n" + "5. 微信收藏/点击回流:补充用户采纳反馈。\n\n" + "👤【推荐负责人】陈后端(当前负载 28%)\n" + "理由:系统检索到他当前负载较低,且技能更接近推荐与数据接入。" + ), + "recommended_assignee_id": 18, + "recommended_assignee_name": "陈后端", + "recommended_assignee_workload": 28.0, + "recommended_assignee_reason": "系统检索到他当前负载较低,且技能更接近推荐与数据接入。", + "external_signal_sources": [ + {"name": "天气 API", "purpose": "提供实时温度和体感变化", "phase": "首版优先接入"}, + {"name": "淘宝开放平台", "purpose": "补充当季商品与类目属性", "phase": "首版优先接入"}, + {"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度", "phase": "第二阶段接入"}, + ], + } + return { + "draft_text": str(locked.draft_content["draft_text"]), + "draft_content": dict(locked.draft_content), + "draft_version": 3, + } + + monkeypatch.setattr(handler.draft_generator, "generate_and_persist", _unexpected_generate_and_persist) + monkeypatch.setattr( + DraftConfirmHandler, + "_revise_requirement_plan_draft", + _fake_revise_requirement_plan_draft, + raising=False, + ) + + result = await handler.revise_draft( + target_work_item=SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + ), + target_ingest=SimpleNamespace(payload={"message_text": "做一个换季穿搭助手。"}), + revision_note="补充外部信号源和推荐负责人。", + expected_version=2, + ) + + assert result["handled"] is True + assert result["status"] == "draft_pending_confirm" + assert "🌐【外部信号源】" in result["followup_reply"] + assert "天气 API" in result["followup_reply"] + assert "👤【推荐负责人】陈后端" in result["followup_reply"] + assert captured["requirement_revise_kwargs"]["revision_note"] == "补充外部信号源和推荐负责人。" + + @pytest.mark.asyncio async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() @@ -998,7 +1129,7 @@ async def test_draft_confirm_handler_confirm_without_target_returns_failed_safe( @pytest.mark.asyncio -async def test_draft_confirm_handler_natural_revision_without_target_returns_failed_safe(monkeypatch) -> None: +async def test_draft_confirm_handler_natural_revision_without_target_and_without_anchor_is_ignored(monkeypatch) -> None: kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() handler = DraftConfirmHandler(kernel) @@ -1015,12 +1146,362 @@ async def test_draft_confirm_handler_natural_revision_without_target_returns_fai source_message_id="msg-1", channel_ref="chat-1", source_sender_open_id="ou_xxx", + thread_context=None, + ) + + assert result["handled"] is False + assert result["status"] == "ignored" + assert result["followup_reply"] == "" + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_without_target_and_without_anchor_confirm_is_ignored(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return None + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + + result = await handler.handle_text_reply( + message_text="确认", + sender="林宇航", + source_system="feishu", + source_message_id="msg-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context=None, + ) + + assert result["handled"] is False + assert result["status"] == "ignored" + assert result["followup_reply"] == "" + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_display_feedback_uses_semantic_answer_only(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": SimpleNamespace(execute=object()), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_resolve_active_mounted_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="ready"), + SimpleNamespace(id=77, mode="plan", plan_status="awaiting_execute_confirm"), + ) + + class _FakeAssembler: + def __init__(self, db): + self.db = db + + async def assemble(self, **kwargs): + return SimpleNamespace( + mode_card=SimpleNamespace( + allowed_actions=["reply.compose", "draft.followup.answer_only"], + forbidden_actions=[], + ) + ) + + class _FakeDecisionService: + def __init__(self, llm_service): + self.llm_service = llm_service + + async def decide(self, *, packet): + _ = packet + return SimpleNamespace( + topic_relation="continue_current_topic", + mode_decision="answer_only", + followup_action="answer_only", + revision_note="", + assistant_reply="收到,这是展示形式反馈:卡片可以长,聊天直发需要更短,我不会重起草当前草稿。", + requires_confirmation=False, + requested_actions=["reply.compose", "draft.followup.answer_only"], + ) + + async def _unexpected_confirm(**kwargs): # noqa: ANN001 + raise AssertionError(f"confirm_draft should not run for display feedback: {kwargs}") + + async def _unexpected_revise(**kwargs): # noqa: ANN001 + raise AssertionError(f"revise_draft should not run for display feedback: {kwargs}") + + async def _unexpected_cancel(**kwargs): # noqa: ANN001 + raise AssertionError(f"cancel_draft should not run for display feedback: {kwargs}") + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft) + monkeypatch.setattr("app.services.ai_pmo.draft_confirm_handler.SemanticContextAssembler", _FakeAssembler, raising=False) + monkeypatch.setattr( + "app.services.ai_pmo.draft_confirm_handler.SemanticDecisionService", + _FakeDecisionService, + raising=False, + ) + monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm) + monkeypatch.setattr(handler, "revise_draft", _unexpected_revise) + monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel) + + result = await handler.handle_text_reply( + message_text="是这种的可以很长,但是聊天内容发的那种就不行这么长", + sender="杨宇", + source_system="feishu", + source_message_id="msg-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", thread_context={"reply_to_message_id": "reply-1"}, ) assert result["handled"] is True - assert result["status"] == "failed_safe" - assert "当前没有可确认草稿" in result["followup_reply"] + assert result["status"] == "pending_manual" + assert "展示形式反馈" in result["followup_reply"] + assert "不会重起草" in result["followup_reply"] + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_uses_work_item_bound_mounted_draft_for_semantic_followup(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": SimpleNamespace(execute=object()), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + captured: dict[str, object] = {} + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_resolve_active_mounted_draft(**kwargs): + _ = kwargs + return None + + async def _fake_resolve_mounted_draft_by_work_item_id(**kwargs): + captured["mounted_lookup"] = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"), + SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"), + ) + + async def _fake_semantic_followup(**kwargs): + captured["semantic_kwargs"] = kwargs + return { + "action": "revise", + "revision_note": "补充外部信号源。", + "assistant_reply": "", + } + + async def _fake_revise_draft(**kwargs): + captured["revise_kwargs"] = kwargs + return { + "handled": True, + "status": "draft_pending_confirm", + "followup_reply": "我先按当前草稿补外部信号源。", + } + + async def _unexpected_confirm(**kwargs): # noqa: ANN001 + raise AssertionError(f"confirm_draft should not run here: {kwargs}") + + async def _unexpected_cancel(**kwargs): # noqa: ANN001 + raise AssertionError(f"cancel_draft should not run here: {kwargs}") + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft) + monkeypatch.setattr(handler, "_resolve_mounted_draft_by_work_item_id", _fake_resolve_mounted_draft_by_work_item_id) + monkeypatch.setattr(handler, "_resolve_semantic_followup_decision", _fake_semantic_followup) + monkeypatch.setattr(handler, "revise_draft", _fake_revise_draft) + monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm) + monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel) + + result = await handler.handle_text_reply( + message_text="按这版继续", + sender="林宇航", + source_system="feishu", + source_message_id="msg-continue-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context=None, + ) + + assert result["handled"] is True + assert result["status"] == "draft_pending_confirm" + assert "当前草稿" in result["followup_reply"] + assert captured["mounted_lookup"] == {"work_item_id": 42} + assert captured["semantic_kwargs"]["active_mounted"][1].id == 77 + assert captured["revise_kwargs"]["revision_note"] == "补充外部信号源。" + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_target_bound_semantic_override_revises_without_parser_fallback(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_resolve_target_mounted_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"), + SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"), + ) + + async def _fake_revise_draft(**kwargs): + return { + "handled": True, + "status": "draft_pending_confirm", + "followup_reply": f"按当前草稿重起:{kwargs['revision_note']}", + } + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft) + monkeypatch.setattr(handler, "revise_draft", _fake_revise_draft) + + result = await handler.handle_text_reply( + message_text="把外部信号源补上", + sender="林宇航", + source_system="feishu", + source_message_id="msg-override-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context={"reply_to_message_id": "reply-1"}, + semantic_followup_override={ + "action": "revise", + "revision_note": "补充外部信号源。", + "assistant_reply": "", + }, + ) + + assert result["handled"] is True + assert result["status"] == "draft_pending_confirm" + assert "补充外部信号源" in result["followup_reply"] + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_target_bound_without_semantic_action_returns_pending_manual(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_resolve_target_mounted_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"), + SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"), + ) + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft) + + result = await handler.handle_text_reply( + message_text="把目标用户改成平台运维团队", + sender="林宇航", + source_system="feishu", + source_message_id="msg-no-semantic-action-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context={"reply_to_message_id": "reply-1"}, + semantic_followup_override={ + "action": "", + "revision_note": "", + "assistant_reply": "", + }, + ) + + assert result["handled"] is True + assert result["status"] == "pending_manual" + assert "请回复“确认”、“修改 + 你的意见”或“取消”" in result["followup_reply"] + + +@pytest.mark.asyncio +async def test_draft_confirm_handler_target_bound_without_anchor_and_without_semantic_action_is_ignored(monkeypatch) -> None: + kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})() + handler = DraftConfirmHandler(kernel) + + async def _fake_resolve_pending_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace( + id=42, + state=STATE_DRAFT_PENDING_CONFIRM, + intent="project.create", + project_id=None, + signal_id=None, + draft_version=2, + ), + SimpleNamespace(payload={"message_text": "原始需求"}), + ) + + async def _fake_resolve_target_mounted_draft(**kwargs): + _ = kwargs + return ( + SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"), + SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"), + ) + + monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft) + monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft) + + result = await handler.handle_text_reply( + message_text="收到,辛苦了", + sender="林宇航", + source_system="feishu", + source_message_id="msg-no-anchor-ignore-1", + channel_ref="chat-1", + source_sender_open_id="ou_xxx", + thread_context=None, + semantic_followup_override={ + "action": "", + "revision_note": "", + "assistant_reply": "", + }, + ) + + assert result["handled"] is False + assert result["status"] == "ignored" def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: diff --git a/backend/tests/test_feishu_pipeline_queue_contract.py b/backend/tests/test_feishu_pipeline_queue_contract.py index eb92a61b..c816a121 100644 --- a/backend/tests/test_feishu_pipeline_queue_contract.py +++ b/backend/tests/test_feishu_pipeline_queue_contract.py @@ -1,3 +1,4 @@ +import asyncio import inspect from types import SimpleNamespace @@ -355,6 +356,40 @@ async def test_intent_gate_query_does_not_inherit_project_context(monkeypatch) - async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeypatch) -> None: calls = {"sync": 0, "session": 0, "cache": 0} + class _FakeBundle: + def __init__(self) -> None: + self.primary_workflow = "query_flow" + self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + self.diagnostics = {} + + def to_dict(self) -> dict[str, object]: + return { + "bundle_id": "semantic-query", + "semantic_version": "2026-03-27.workflow-v1", + "source_system": "feishu", + "primary_workflow": self.primary_workflow, + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.9, + "slots": [], + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto_execute", + "reply_contract": { + "mode": "aggregate_card", + "template_key": "workflow_bundle", + "summary": "query_flow", + }, + "diagnostics": {}, + } + class _FakeKernel: @staticmethod def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str): @@ -395,6 +430,11 @@ async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeyp monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) service = ProactiveSignalCaptureService(object()) result = await service.capture_from_feishu( @@ -417,6 +457,27 @@ async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeyp async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel(monkeypatch) -> None: captured: dict[str, object] = {} + class _FakeBundle: + def __init__(self) -> None: + self.primary_workflow = "query_flow" + self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + self.diagnostics = { + "message_intent": "business_review", + "consultant_mode_preference": "clarify", + } + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + } + ], + "diagnostics": dict(self.diagnostics), + } + class _FakeKernel: @staticmethod def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str): @@ -454,6 +515,11 @@ async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) service = ProactiveSignalCaptureService(object()) result = await service.capture_from_feishu( @@ -477,6 +543,27 @@ async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_kernel(monkeypatch) -> None: captured: dict[str, object] = {} + class _FakeBundle: + def __init__(self) -> None: + self.primary_workflow = "query_flow" + self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + self.diagnostics = { + "message_intent": "business_review", + "consultant_mode_preference": "clarify", + } + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + } + ], + "diagnostics": dict(self.diagnostics), + } + class _FakeDB: async def commit(self) -> None: return None @@ -521,6 +608,11 @@ async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_ monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) service = ProactiveSignalCaptureService(_FakeDB()) result = await service.capture_from_feishu( @@ -730,8 +822,20 @@ def test_capture_from_feishu_no_longer_references_legacy_fallback() -> None: assert "_capture_from_feishu_legacy_flow" not in source -def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync() -> None: - should_sync, reason = mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( +@pytest.mark.asyncio +async def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync(monkeypatch) -> None: + class _FakeBundle: + primary_workflow = "query_flow" + workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + diagnostics = {"message_intent": "requirement_plan"} + + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) + + should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( message_text="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", ) @@ -739,8 +843,20 @@ def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync() - assert reason == "semantic:requirement_plan" -def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_answer_as_sync() -> None: - should_sync, reason = mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( +@pytest.mark.asyncio +async def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_answer_as_sync(monkeypatch) -> None: + class _FakeBundle: + primary_workflow = "query_flow" + workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + diagnostics = {"message_intent": "requirement_plan"} + + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) + + should_sync, reason = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy( message_text="@PMFlow 先按技术指标来,主要给投顾看。", ) @@ -752,6 +868,18 @@ def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_answer async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards_semantic_bundle(monkeypatch) -> None: captured: dict[str, object] = {} + class _FakeBundle: + primary_workflow = "query_flow" + workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + diagnostics = {"message_intent": "requirement_plan"} + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "diagnostics": dict(self.diagnostics), + } + class _FakeDB: async def commit(self) -> None: return None @@ -812,6 +940,11 @@ async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) service = ProactiveSignalCaptureService(_FakeDB()) result = await service.capture_from_feishu( @@ -836,6 +969,18 @@ async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards async def test_capture_from_feishu_requirement_plan_slot_answer_stays_sync_without_async_ack(monkeypatch) -> None: captured: dict[str, object] = {} + class _FakeBundle: + primary_workflow = "query_flow" + workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")] + diagnostics = {"message_intent": "requirement_plan"} + + def to_dict(self) -> dict[str, object]: + return { + "primary_workflow": "query_flow", + "workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}], + "diagnostics": dict(self.diagnostics), + } + class _FakeDB: async def commit(self) -> None: return None @@ -896,6 +1041,11 @@ async def test_capture_from_feishu_requirement_plan_slot_answer_stays_sync_witho monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel) monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService) monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache) + monkeypatch.setattr( + mixin_capture_entry.WorkflowSemanticPlanner, + "aplan_message", + classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())), + ) service = ProactiveSignalCaptureService(_FakeDB()) result = await service.capture_from_feishu( diff --git a/backend/tests/test_feishu_receive_orchestration_contract.py b/backend/tests/test_feishu_receive_orchestration_contract.py index 29f444d5..a2285581 100644 --- a/backend/tests/test_feishu_receive_orchestration_contract.py +++ b/backend/tests/test_feishu_receive_orchestration_contract.py @@ -71,6 +71,44 @@ def test_classify_message_scene_does_not_infer_execute_from_raw_text_only() -> N assert scene == "qa" +def test_classify_message_scene_uses_bundle_without_planner_classifier(monkeypatch: pytest.MonkeyPatch) -> None: + _ = monkeypatch + + scene = FeishuIntegrationService._classify_message_scene( + capture_result={ + "semantic_bundle": { + "bundle_id": "semantic-qa", + "semantic_version": "2026-03-27.workflow-v1", + "source_system": "feishu", + "primary_workflow": "query_flow", + "workflows": [ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "requires_confirmation": False, + "confidence": 0.9, + "slots": [], + "clarification_needed": False, + "metadata": {}, + } + ], + "message_gate": "auto_execute", + "reply_contract": { + "mode": "aggregate_card", + "template_key": "workflow_bundle", + "summary": "query_flow", + }, + "diagnostics": {}, + } + }, + message_content="帮我创建项目和信号源", + ) + + assert scene == "qa" + + @pytest.mark.asyncio async def test_handle_message_receive_orchestrates_capture_and_finalize(monkeypatch) -> None: service = FeishuIntegrationService(db=object()) diff --git a/backend/tests/test_semantic_context_assembler.py b/backend/tests/test_semantic_context_assembler.py index ba4353e8..4216998e 100644 --- a/backend/tests/test_semantic_context_assembler.py +++ b/backend/tests/test_semantic_context_assembler.py @@ -139,3 +139,101 @@ async def test_semantic_context_assembler_builds_packet_from_runtime_state(db_se assert packet.thread.recent_turns[0].role == "user" assert packet.thread.recent_turns[-1].role == "assistant" assert packet.thread.open_questions == ["当前是不是新需求 topic"] + + +@pytest.mark.asyncio +async def test_semantic_context_assembler_adds_draft_followup_ingress_actions(db_session) -> None: + topic = MountedTopicWorkspace( + tenant_id=7, + topic_key="feishu:chat_semantic:root:om_root_followup", + source_system="feishu", + channel_ref="chat_semantic", + conversation_scope_key="feishu:chat_semantic:ou_semantic", + thread_key="thread_semantic", + root_message_id="om_root_followup", + mode="plan", + status="active", + plan_status="awaiting_execute_confirm", + facts_json={"title": "换季穿搭推荐"}, + goals_json=["确认草稿"], + constraints_json={}, + open_questions=[], + pending_actions=[], + readiness_score=0.8, + metadata_json={"last_message_preview": "请确认当前草稿"}, + ) + db_session.add(topic) + await db_session.flush() + + packet = await SemanticContextAssembler(db_session).assemble( + tenant_id=7, + operator_user_id=99, + topic_workspace_id=int(topic.id), + latest_user_message="是这种的可以很长,但是聊天内容发的那种就不行这么长", + ingress_context={ + "candidate_kind": "draft_followup", + "source_system": "feishu", + "reply_anchor_present": True, + "allowed_followup_actions": ["confirm", "revise", "cancel", "answer_only"], + "thread_context": {"reply_to_message_id": "om_reply_1"}, + }, + ) + + assert packet.ingress is not None + assert packet.ingress.candidate_kind == "draft_followup" + assert packet.ingress.reply_anchor_present is True + assert "draft.followup.answer_only" in packet.mode_card.allowed_actions + assert "draft.followup.revise" in packet.mode_card.allowed_actions + + +@pytest.mark.asyncio +async def test_semantic_context_assembler_adds_active_context_followup_actions(db_session) -> None: + topic = MountedTopicWorkspace( + tenant_id=7, + topic_key="feishu:chat_semantic:root:om_root_active_followup", + source_system="feishu", + channel_ref="chat_semantic", + conversation_scope_key="feishu:chat_semantic:ou_semantic", + thread_key="thread_semantic", + root_message_id="om_root_active_followup", + mode="plan", + status="active", + plan_status="collecting", + facts_json={"title": "AI 直播复盘工具"}, + goals_json=["继续收敛信号源模块"], + constraints_json={}, + open_questions=["信号源这块第一版怎么收"], + pending_actions=[], + readiness_score=0.5, + metadata_json={"last_message_preview": "先把这版需求收清楚"}, + ) + db_session.add(topic) + await db_session.flush() + + packet = await SemanticContextAssembler(db_session).assemble( + tenant_id=7, + operator_user_id=99, + topic_workspace_id=int(topic.id), + latest_user_message="信号源呢?", + ingress_context={ + "candidate_kind": "active_context_followup", + "source_system": "feishu", + "reply_anchor_present": False, + "active_context_kind": "requirement_plan", + "allowed_context_scopes": [ + "current_requirement", + "current_draft", + "independent_workflow", + "clarify", + ], + "thread_context": {}, + }, + ) + + assert packet.ingress is not None + assert packet.ingress.candidate_kind == "active_context_followup" + assert packet.ingress.active_context_kind == "requirement_plan" + assert "context.followup.current_requirement" in packet.mode_card.allowed_actions + assert "context.followup.current_draft" in packet.mode_card.allowed_actions + assert "context.followup.independent_workflow" in packet.mode_card.allowed_actions + assert "context.followup.clarify" in packet.mode_card.allowed_actions diff --git a/backend/tests/test_semantic_decision_service.py b/backend/tests/test_semantic_decision_service.py index ecf4c7c3..746518a8 100644 --- a/backend/tests/test_semantic_decision_service.py +++ b/backend/tests/test_semantic_decision_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +from app.config.ai_prompts import PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT from app.services.ai_pmo.semantic_context.decision_schema import ( DecisionGateResult, ModeCard, @@ -58,3 +59,18 @@ def test_semantic_decision_gatekeeper_blocks_forbidden_action() -> None: assert result.accepted is False assert "forbidden_action:project.create" in result.blocked_reasons assert result.next_plan_status == "active" + + +def test_semantic_context_decision_prompt_avoids_business_semantic_examples() -> None: + forbidden_fragments = ( + "信号源呢?", + "负责人呢?", + "技术路线呢?", + "商业模式呢?", + "给项目2接入", + "GitHub webhook", + "RTMP/WebRTC", + ) + + for fragment in forbidden_fragments: + assert fragment not in PMFLOW_SEMANTIC_CONTEXT_DECISION_PROMPT diff --git a/backend/tests/test_semantic_lint.py b/backend/tests/test_semantic_lint.py index a91e1d20..baccba3a 100644 --- a/backend/tests/test_semantic_lint.py +++ b/backend/tests/test_semantic_lint.py @@ -32,7 +32,7 @@ def test_semantic_lint_flags_new_scattered_hint_tables(tmp_path) -> None: assert problems[0].rule_id == "semantic_hint_constant" -def test_semantic_lint_allows_workflow_semantic_planner_directory(tmp_path) -> None: +def test_semantic_lint_flags_workflow_semantic_planner_directory(tmp_path) -> None: module = _load_semantic_lint_module() target = tmp_path / "backend" / "app" / "services" / "ai_pmo" / "workflow_semantic_planner" / "foo.py" target.parent.mkdir(parents=True, exist_ok=True) @@ -44,7 +44,8 @@ def test_semantic_lint_allows_workflow_semantic_planner_directory(tmp_path) -> N baseline_path=Path("scripts/semantic_lint_baseline.txt"), ) - assert problems == [] + assert len(problems) == 1 + assert problems[0].rule_id == "semantic_hint_constant" def test_semantic_lint_repo_has_no_compat_baseline_exceptions() -> None: diff --git a/backend/tests/test_workflow_semantic_planner.py b/backend/tests/test_workflow_semantic_planner.py index 9ae72ac6..4b547bc3 100644 --- a/backend/tests/test_workflow_semantic_planner.py +++ b/backend/tests/test_workflow_semantic_planner.py @@ -1,278 +1,119 @@ +from __future__ import annotations + +import asyncio +from typing import Any + import pytest -from app.services.ai_pmo.workflow_semantic_planner import ( - WorkflowSemanticPlanner, -) +from app.services.ai_pmo.semantic_context.decision_schema import SemanticDecision +from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner -def test_workflow_semantic_planner_builds_multi_workflow_bundle() -> None: +def _decision(*, workflows: list[dict[str, Any]], scene_metadata: dict[str, Any] | None = None) -> SemanticDecision: + return SemanticDecision( + topic_relation="new_topic", + mode_decision="answer_only", + workflow_plans=workflows, + scene_metadata=scene_metadata or {}, + requested_actions=["workflow.bundle.compose"], + confidence=0.91, + ) + + +def test_workflow_semantic_planner_plan_message_uses_model_decision(monkeypatch: pytest.MonkeyPatch) -> None: + async def _fake_decide(cls, *, message_text: str, source_system: str, metadata: dict[str, Any] | None = None): + assert message_text == "帮我创建一个项目,并把这个需求接入信号源" + assert source_system == "feishu" + return _decision( + workflows=[ + { + "workflow_type": "new_project_flow", + "workflow_action": "create", + "target_scope": "global", + "operation_codes": ["project.create"], + "confidence": 0.93, + }, + { + "workflow_type": "signal_source_flow", + "workflow_action": "onboard", + "target_scope": "global", + "operation_codes": ["signal_source.onboard"], + "confidence": 0.92, + }, + ] + ) + + monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide)) + bundle = WorkflowSemanticPlanner.plan_message( message_text="帮我创建一个项目,并把这个需求接入信号源", source_system="feishu", ) - workflow_types = [item.workflow_type for item in bundle.workflows] - - assert bundle.source_system == "feishu" assert bundle.primary_workflow == "new_project_flow" - assert workflow_types == ["new_project_flow", "signal_source_flow"] + assert [item.workflow_type for item in bundle.workflows] == ["new_project_flow", "signal_source_flow"] assert bundle.reply_contract.mode == "aggregate_card" -def test_workflow_semantic_planner_derives_query_bundle() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="现在项目进展怎么样?", - source_system="pmflow_manual", - ) +@pytest.mark.asyncio +async def test_workflow_semantic_planner_aplan_message_preserves_scene_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + async def _fake_decide(cls, *, message_text: str, source_system: str, metadata: dict[str, Any] | None = None): + assert message_text == "想跟你聊个新需求" + return _decision( + workflows=[ + { + "workflow_type": "query_flow", + "workflow_action": "answer", + "target_scope": "global", + "operation_codes": ["query.answer"], + "confidence": 0.88, + "metadata": {"message_intent": "requirement_plan"}, + } + ], + scene_metadata={ + "message_intent": "requirement_plan", + "consultant_mode_preference": "clarify", + "topic_switch": "new", + }, + ) - assert bundle.primary_workflow == "query_flow" - assert [item.workflow_type for item in bundle.workflows] == ["query_flow"] - assert bundle.message_gate == "auto_execute" + monkeypatch.setattr(WorkflowSemanticPlanner, "_decide_cold_start_semantics", classmethod(_fake_decide)) - -def test_workflow_semantic_planner_helpers_cover_legacy_entry_semantics() -> None: - assert WorkflowSemanticPlanner.looks_like_project_lookup_question("现在有哪些项目?") is True - assert WorkflowSemanticPlanner.extract_project_lookup_keyword("请问项目 智能风控平台 有吗?") == "智能风控平台" - assert WorkflowSemanticPlanner.is_legacy_business_command("把项目状态改成已完成") is True - assert WorkflowSemanticPlanner.is_legacy_business_command("项目现在状态怎么样?") is False - assert WorkflowSemanticPlanner.wants_followup_continue("继续处理刚才那个") is True - assert WorkflowSemanticPlanner.wants_followup_status("它现在怎么样了?") is True - - -def test_workflow_semantic_planner_contextual_write_confirmation_is_stateful() -> None: - assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("那就按这个继续执行") is True - assert WorkflowSemanticPlanner.looks_like_contextual_write_confirmation("这个项目现在怎么样?") is False - - -def test_workflow_semantic_planner_routes_new_business_review_with_five_lenses() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="我要做一个大模型 API 聚合平台,帮我分析下要怎么做", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert [item.workflow_type for item in bundle.workflows] == ["query_flow"] - assert bundle.message_gate == "auto_execute" - assert bundle.workflows[0].metadata["message_intent"] == "business_review" - assert bundle.workflows[0].metadata["review_framework"] == "five_lenses" - assert bundle.diagnostics["message_intent"] == "business_review" - assert bundle.diagnostics["consultant_mode_preference"] == "clarify" - - -def test_workflow_semantic_planner_marks_correction_escalation_queries() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="不对,调用高级模型重新回答我的问题", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert bundle.workflows[0].metadata["message_intent"] == "correction_escalation" - assert bundle.workflows[0].metadata["force_thinking_mode"] == "deep" - assert bundle.diagnostics["message_intent"] == "correction_escalation" - assert bundle.diagnostics["consultant_mode_preference"] == "challenge" - - -def test_workflow_semantic_planner_treats_natural_problem_statement_as_new_project_candidate() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="最近直播复盘太慢了,我想做个给运营用的小工具,能自动切片、总结亮点掉点,再给复盘清单。", - source_system="feishu", - ) - - assert bundle.primary_workflow == "new_project_flow" - assert [item.workflow_type for item in bundle.workflows] == ["new_project_flow"] - assert bundle.workflows[0].workflow_action == "create" - - -def test_workflow_semantic_planner_keeps_natural_solution_consulting_in_query_flow() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="我们最近直播复盘很慢,先帮我分析一下第一版应该怎么做,不要直接创建。", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert [item.workflow_type for item in bundle.workflows] == ["query_flow"] - - -def test_workflow_semantic_planner_keeps_deferred_creation_request_in_new_project_flow() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert [item.workflow_type for item in bundle.workflows] == ["query_flow"] - assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify" - - -def test_workflow_semantic_planner_keeps_scope_framing_discussion_in_query_flow() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="我们想做一个面向医疗器械 BD 的政策雷达工具,先聚焦华东三省,先别创建项目,我还在补范围。", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert [item.workflow_type for item in bundle.workflows] == ["query_flow"] - assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify" - - -def test_workflow_semantic_planner_marks_explicit_new_requirement_topic_switch() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="不对啊,现在是另一个需求,先不要沿用刚才那版。", + bundle = await WorkflowSemanticPlanner.aplan_message( + message_text="想跟你聊个新需求", source_system="feishu", ) assert bundle.primary_workflow == "query_flow" assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" - assert bundle.workflows[0].metadata["topic_switch"] == "new" assert bundle.diagnostics["topic_switch"] == "new" -def test_workflow_semantic_planner_marks_chatty_new_requirement_phrase_as_topic_switch() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="想跟你聊个新需求关于批量自动化部署openclaw的", - source_system="feishu", +def test_workflow_semantic_planner_sync_wrapper_fails_inside_running_loop() -> None: + async def _runner() -> None: + with pytest.raises(RuntimeError): + WorkflowSemanticPlanner.plan_message(message_text="test", source_system="feishu") + + asyncio.run(_runner()) + + +def test_workflow_semantic_planner_no_longer_exposes_legacy_semantic_helpers() -> None: + legacy_names = ( + "plan_from_intent", + "classify_scene", + "is_action_request_capture", + "parse_followup_action", + "extract_project_lookup_keyword", + "wants_followup_continue", + "wants_followup_status", + "looks_like_project_create_request", + "looks_like_task_create_request", + "looks_like_signal_source_request", + "looks_like_external_connect_request", + "looks_like_contextual_write_confirmation", + "is_query_like_question", + "is_obvious_question", ) - assert bundle.primary_workflow == "query_flow" - assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" - assert bundle.workflows[0].metadata["topic_switch"] == "new" - assert bundle.diagnostics["topic_switch"] == "new" - - -def test_workflow_semantic_planner_routes_assignee_recommendation_back_to_query_flow() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="那你觉得分配给谁做比较合适?", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert [item.workflow_type for item in bundle.workflows] == ["query_flow"] - assert bundle.workflows[0].metadata["message_intent"] == "business_review" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "advise" - - -def test_workflow_semantic_planner_routes_plain_assignee_recommendation_without_question_mark() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="那你觉得这件事分配给谁做比较合适", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert bundle.workflows[0].metadata["message_intent"] == "business_review" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "advise" - - -def test_workflow_semantic_planner_marks_casual_chat_as_chat_query() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="最近有点累,今天先不推进需求了,陪我简单聊两句。", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert bundle.workflows[0].metadata["message_intent"] == "casual_chat" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "brainstorm" - - -def test_workflow_semantic_planner_marks_requirement_followup_clarify_as_business_review() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="那就先按上面的方向继续,但是别太重", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert bundle.workflows[0].metadata["message_intent"] == "business_review" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify" - - -def test_workflow_semantic_planner_marks_requirement_scope_advise_as_business_review() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="如果第一版先做 MVP,这个需求范围怎么收才比较稳?", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert bundle.workflows[0].metadata["message_intent"] == "business_review" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "advise" - - -def test_workflow_semantic_planner_routes_requirement_chat_into_requirement_plan() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="想跟你聊聊需求,关于自动给每日的大A趋势打分", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert [item.workflow_type for item in bundle.workflows] == ["query_flow"] - assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify" - assert bundle.diagnostics["message_intent"] == "requirement_plan" - - -@pytest.mark.parametrize( - "message_text", - [ - "技术指标吧", - "主要给投顾看", - "飞书日报就行", - "每天收盘后发", - "先给内部 PM 和研发负责人用", - "飞书总结就行", - "每次会后半小时内整理出来", - ], -) -def test_workflow_semantic_planner_treats_short_requirement_slot_answers_as_requirement_plan( - message_text: str, -) -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text=message_text, - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify" - - -def test_workflow_semantic_planner_routes_generic_requirement_chat_into_requirement_plan() -> None: - bundle = WorkflowSemanticPlanner.plan_message( - message_text="想跟你聊个需求,我们开完会以后待办经常散掉,想做个小工具自动从会议纪要里提待办,先别创建,先收第一版。", - source_system="feishu", - ) - - assert bundle.primary_workflow == "query_flow" - assert bundle.workflows[0].metadata["message_intent"] == "requirement_plan" - assert bundle.workflows[0].metadata["consultant_mode_preference"] == "clarify" - - -def test_workflow_semantic_planner_treats_natural_draft_details_as_revision() -> None: - action, note = WorkflowSemanticPlanner.parse_draft_action( - message_text="第一版先做直播录屏和评论回放切片,再加亮点和掉点总结,最后给一份可执行复盘清单。" - ) - - assert action == "revise" - assert note == "第一版先做直播录屏和评论回放切片,再加亮点和掉点总结,最后给一份可执行复盘清单。" - - -def test_workflow_semantic_planner_does_not_treat_smalltalk_as_revision() -> None: - action, note = WorkflowSemanticPlanner.parse_draft_action(message_text="收到,辛苦了") - - assert action == "unknown" - assert note == "" - - -def test_workflow_semantic_planner_treats_scope_narrowing_as_revision_not_cancel() -> None: - message = "第一版先别做太大,先把三件事做好就行:直播录屏和评论回放切片、亮点和掉点自动总结、给团队一份能直接执行的复盘清单。" - - action, note = WorkflowSemanticPlanner.parse_draft_action(message_text=message) - - assert action == "revise" - assert note == message - - -def test_workflow_semantic_planner_keeps_terminal_stop_phrase_as_cancel() -> None: - action, note = WorkflowSemanticPlanner.parse_draft_action(message_text="这个需求先别做了,取消吧。") - - assert action == "cancel" - assert note == "" + for name in legacy_names: + assert not hasattr(WorkflowSemanticPlanner, name), name diff --git a/scripts/semantic_lint.py b/scripts/semantic_lint.py index 5659b49d..ec29adde 100644 --- a/scripts/semantic_lint.py +++ b/scripts/semantic_lint.py @@ -17,7 +17,6 @@ DEFAULT_SCAN_ROOTS = ( Path("backend/app/api/ai_pmo_api"), ) ALLOWLIST_PREFIXES = ( - Path("backend/app/services/ai_pmo/workflow_semantic_planner"), Path("backend/app/config/ai_prompt_registry.py"), Path("backend/app/config/ai_prompts.py"), Path("backend/app/config/ai_prompts_catalog"), diff --git a/工单全字段报表_2026-04-07.xlsx b/工单全字段报表_2026-04-07.xlsx new file mode 100644 index 00000000..c17b8b3c Binary files /dev/null and b/工单全字段报表_2026-04-07.xlsx differ