import asyncio import json from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock import pytest import pytest_asyncio from fastapi import HTTPException from sqlalchemy import JSON, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.api.ai_pmo_api import routes_core from app.api.ai_pmo_api import helpers_inbox_filters from app.api.ai_pmo_api import helpers_inbox_payload from app.api.ai_pmo_api.schemas import PMOProcessTextRequest from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_resolve import ( KernelIntentResolveMixin, ) from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import ( KernelIntentRulesMixin, ) from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer from app.services.ai_pmo.ai_pmo_kernel import ai_pmo_typed_executor_mixins_parse from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress import ai_pmo_kernel_mixins_ingress_render from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual from app.services.ai_pmo.ai_pmo_kernel_world_model_core import mixin_project from app.services.ai_pmo.ai_pmo_kernel.mixins.world_model import ( ai_pmo_kernel_mixins_world_model_session, ) 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 import WorkflowSemanticPlanner 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 ( MountedConversationRun, MountedDraftWorkspace, MountedTopicWorkspace, ) from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key def _use_sqlite_requirement_plan_json_types() -> None: for table in ( MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, ): for column in table.columns: if column.name in { "facts_json", "goals_json", "constraints_json", "options_json", "open_questions", "pending_actions", "bound_entities", "metadata_json", "requirement_card", "draft_payload", "required_confirmations", "context_layers_hit", "tool_calls", "execution_outcome", }: column.type = JSON() @pytest_asyncio.fixture async def requirement_plan_db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") _use_sqlite_requirement_plan_json_types() async with engine.begin() as conn: await conn.run_sync( lambda sync_conn: Base.metadata.create_all( sync_conn, tables=[ MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, MountedConversationRun.__table__, ], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield 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 test_build_contextual_requirement_plan_card_keeps_source_material_user_only() -> None: turns = [ { "user": "做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手,先不要创建项目,只收第一版需求。", "assistant": ( "可以,我们先不创建项目,先把这件事收成一版需求。\n\n" "A. 轻量工具型\nB. 业务平台型\nC. 能力底座型\n\n" "请回复 A/B/C 确认方向" ), }, { "user": "A", "assistant": ( "已确认选择 A:轻量工具型。当前需求草稿已完成第一轮关键判断," "正在整合生成结构化需求卡片,请稍候。" ), }, ] card = mixin_execute_query_project.ExecuteQueryProjectMixin._build_contextual_requirement_plan_card( topic=None, turns=turns, ) assert str(card.get("source_material") or "").startswith( "用户:做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手" ) assert "系统:" not in str(card.get("source_material") or "") assert "当前需求草稿已完成第一轮关键判断" not in str(card.get("source_material") or "") def test_build_contextual_requirement_plan_card_does_not_infer_business_fields_from_assistant_prose() -> None: turns = [ { "user": "做一个门店库存异常晨报助手,先不要创建项目,只收第一版需求。", "assistant": ( "已确认选项 A(运营团队),继续收第二轮关键信息。\n\n" "A. 单一区域经理\nB. 区域经理+店长\nC. 总部+区域+门店" ), } ] card = mixin_execute_query_project.ExecuteQueryProjectMixin._build_contextual_requirement_plan_card( topic=None, turns=turns, ) assert "target_user" not in card assert "project_nature" not in card assert str(card.get("source_material") or "").startswith("用户:做一个门店库存异常晨报助手") def test_build_requirement_plan_draft_packet_uses_contextual_goal_instead_of_generic_it_project() -> None: card = { "title": "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手", "goal": mixin_execute_query_project.ExecuteQueryProjectMixin._derive_requirement_plan_goal( message_text="做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手", card={}, ), "project_nature": "轻量工具型", "target_user": "仅限行政安保值班员", "tech_route": "本地化定时脚本+企业微信推送", "first_scope": "仅含当日超时未关+强开记录", "source_material": "用户:做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手", "question_round": 4, } packet = mixin_execute_query_project.ExecuteQueryProjectMixin._build_requirement_plan_draft_packet( card=card ) packet_blob = json.dumps(packet, ensure_ascii=False) assert "先把 IT 项目的第一版目标" not in packet_blob assert "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手" in packet_blob def test_sanitize_requirement_plan_card_runtime_context_cleans_legacy_draft_pollution() -> None: dirty_card = { "title": "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手", "goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", "plan_profile": "it_project", "source_material": ( "用户:做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手" " | 用户:A" " | 系统:已确认选择 A:轻量工具型。当前需求草稿已完成第一轮关键判断。" ), } cleaned = mixin_execute_query_project.ExecuteQueryProjectMixin._sanitize_requirement_plan_card_runtime_context( card=dirty_card ) assert "系统:" not in str(cleaned.get("source_material") or "") assert "当前需求草稿已完成第一轮关键判断" not in str(cleaned.get("source_material") or "") assert "先把 IT 项目的第一版目标" not in str(cleaned.get("goal") or "") assert "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手" in str(cleaned.get("goal") or "") 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] = {} class FakeKernel: def __init__(self, db, tenant_id: int): captured["init_db"] = db captured["init_tenant_id"] = tenant_id async def process_ingress(self, **kwargs): captured["ingress_kwargs"] = kwargs return { "handled": True, "status": "answered", "classification": "question", "direct_reply_required": True, "direct_reply_text": "当前主链正常。", } monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) request = PMOProcessTextRequest( message_text="现在进展怎么样?", sender="产品经理A", source_message_id="manual-1", project_id=11, ) current_user = SimpleNamespace(name="ignored", username="ignored") response = await routes_core.process_text( request=request, current_user=current_user, db=object(), tenant_id=5, ) assert response["code"] == 0 data = response["data"] assert data["status"] == "answered" kwargs = captured["ingress_kwargs"] assert isinstance(kwargs, dict) assert kwargs["source_system"] == "pmflow_manual" assert kwargs["source_message_id"] == "manual-1" assert kwargs["message_text"] == "现在进展怎么样?" assert kwargs["sender"] == "产品经理A" assert kwargs["channel_ref"] == "pmflow_manual" assert kwargs["chat_type"] == "manual" assert kwargs["project_hint_id"] == 11 @pytest.mark.asyncio async def test_process_text_route_rejects_unhandled_result(monkeypatch) -> None: class FakeKernel: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def process_ingress(self, **kwargs): _ = kwargs return {"handled": False, "reason": "kernel_not_handled"} monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) request = PMOProcessTextRequest(message_text="hello") current_user = SimpleNamespace(name="u", username="u") with pytest.raises(HTTPException) as exc_info: await routes_core.process_text( request=request, current_user=current_user, db=object(), tenant_id=1, ) assert exc_info.value.status_code == 400 assert str(exc_info.value.detail) == "kernel_not_handled" @pytest.mark.asyncio async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): captured["tenant_id"] = tenant_id _ = db async def classify_intent(self, text: str): captured["classified_text"] = text return { "intent": "mixed", "question_part": "现在进展如何", "statement_part": "新增任务确认流", } async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 9 monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) kernel = FakeKernel() clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( source_system="pmflow_manual", source_message_id="manual-msg-2", channel_ref="pmflow-chat", source_sender_open_id="ou-pmflow-1", thread_context={"reply_to_message_id": "reply-1"}, message_text="新增任务确认流,也同步一下当前进展", project_hint_id=22, ) assert clean_text == "新增任务确认流" assert mixed_meta["mixed_intent"] is True assert mixed_meta["mixed_question_part"] == "现在进展如何" assert mixed_meta["mixed_signal_part"] == "新增任务确认流" assert "[回答] 进展稳定" in str(mixed_meta.get("mixed_sidecar_reply") or "") answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_via"] == "pmflow_manual" assert answer_kwargs["project_id"] is None assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["source_sender_open_id"] == "ou-pmflow-1" assert answer_kwargs["thread_context"] == {"reply_to_message_id": "reply-1"} assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["conversation_key"] == "manual-msg-2" @pytest.mark.asyncio async def test_process_ingress_canonicalizes_thread_context_before_prepare_envelope(monkeypatch) -> None: captured: dict[str, object] = {} class FakeMountedAIRuntimeWriteService: def __init__(self, db) -> None: _ = db async def canonicalize_thread_context( self, *, tenant_id: int, source_system: str, channel_ref: str | None, thread_context: dict[str, object] | None, ) -> tuple[dict[str, object] | None, dict[str, str | None]]: captured["canonicalize_kwargs"] = { "tenant_id": tenant_id, "source_system": source_system, "channel_ref": channel_ref, "thread_context": dict(thread_context or {}), } return ( { "root_message_id": "om-source-1", "reply_to_message_id": "om-source-1", }, { "original_root_message_id": "om-bot-1", "canonical_root_message_id": "om-source-1", }, ) class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 9 async def _prepare_ingress_envelope(self, **kwargs): captured["prepare_kwargs"] = kwargs return { "early_result": {"handled": True, "status": "early"}, "clean_text": kwargs["message_text"], "synthetic_message_id": kwargs["source_message_id"] or "synthetic", "closure_payload": {}, "ingest_event": None, "operator": None, "work_item": None, "session": None, } monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="msg-canonical-1", message_text="C", sender="tester", channel_ref="chat-canonical-1", source_sender_open_id="ou-canonical-1", ingress_options={ "thread_context": { "root_message_id": "om-bot-1", "reply_to_message_id": "om-bot-1", } }, ) assert result == {"handled": True, "status": "early"} assert captured["canonicalize_kwargs"] == { "tenant_id": 9, "source_system": "feishu", "channel_ref": "chat-canonical-1", "thread_context": { "root_message_id": "om-bot-1", "reply_to_message_id": "om-bot-1", }, } prepare_kwargs = captured["prepare_kwargs"] assert isinstance(prepare_kwargs, dict) assert prepare_kwargs["thread_context"] == { "root_message_id": "om-source-1", "reply_to_message_id": "om-source-1", } @pytest.mark.asyncio @pytest.mark.parametrize("message_text", ["C", "我选C", "选C", "就C吧", "先按C", "确认A,继续往下收。"]) async def test_prepare_mixed_sidecar_context_skips_requirement_choice_reply_with_explicit_anchor( monkeypatch, message_text: str, ) -> None: captured: dict[str, object] = {"classify_calls": 0, "answer_calls": 0} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def classify_intent(self, text: str): captured["classify_calls"] = int(captured["classify_calls"]) + 1 return { "intent": "mixed", "question_part": "无法继续回答“C”,因缺少上一轮对话上下文。", "statement_part": "需求信息待补充,已标记为待澄清项。", } async def answer_question(self, **kwargs): captured["answer_calls"] = int(captured["answer_calls"]) + 1 return {"answer": "[回答] 无法继续回答“C”\n[已收到需求] 需求信息待补充"} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 9 monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) kernel = FakeKernel() clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( source_system="feishu", source_message_id="feishu-msg-choice", channel_ref="chat-choice", source_sender_open_id="ou-choice", thread_context={"reply_to_message_id": "reply-abc", "root_message_id": "reply-abc"}, message_text=message_text, project_hint_id=None, ) assert clean_text == message_text assert mixed_meta == {} assert captured["classify_calls"] == 0 assert captured["answer_calls"] == 0 @pytest.mark.asyncio async def test_prepare_mixed_sidecar_context_does_not_fallback_to_local_choice_guess_when_parser_errors( monkeypatch, ) -> None: captured: dict[str, object] = {"classify_calls": 0} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def classify_intent(self, text: str): captured["classify_calls"] = int(captured["classify_calls"]) + 1 return {"intent": "query"} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 9 def raise_parser_error(message_text: str): _ = message_text raise RuntimeError("parser unavailable") monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_parse_requirement_plan_choice_protocol", classmethod(lambda cls, message_text: raise_parser_error(message_text)), ) kernel = FakeKernel() clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( source_system="feishu", source_message_id="feishu-msg-choice-error", channel_ref="chat-choice", source_sender_open_id="ou-choice", thread_context={"reply_to_message_id": "reply-abc", "root_message_id": "reply-abc"}, message_text="C", project_hint_id=None, ) assert clean_text == "C" assert mixed_meta == {} assert captured["classify_calls"] == 1 @pytest.mark.parametrize( ("message_text", "expected_choice", "expected_remaining"), [ ("C", "C", ""), (" 我选C ", "C", ""), ("选C,继续聊主播脚本", "C", "继续聊主播脚本"), ("就C吧", "C", ""), ("先按C:继续收集范围", "C", "继续收集范围"), ("确认A,继续往下收。", "A", "继续往下收。"), ], ) def test_requirement_plan_choice_protocol_parser_returns_choice_and_remaining_text( message_text: str, expected_choice: str, expected_remaining: str, ) -> None: parsed = mixin_execute_query_project.ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text) assert isinstance(parsed, dict) assert parsed["choice"] == expected_choice assert parsed["remaining_text"] == expected_remaining def test_merge_requirement_plan_round_options_from_assistant_reply_uses_non_regex_line_parser() -> None: assistant_text = ( "第一轮收敛:这是什么性质的项目?\n\n" "A. 主播培训系统\n" "B. 主播数字分身工厂\n" "C. 主播协同作战平台\n\n" "⭐ 当前我更推荐:C\n" "你回 A / B / C,我就顺着这条继续往下收。" ) merged = mixin_execute_query_project.ExecuteQueryProjectMixin._merge_requirement_plan_round_options_from_assistant_reply( card={"question_round": 1}, assistant_text=assistant_text, ) assert merged["round_option_maps"]["project_nature"] == { "A": "主播培训系统", "B": "主播数字分身工厂", "C": "主播协同作战平台", } assert merged["last_round_options"]["recommendation"] == {"key": "C"} @pytest.mark.asyncio async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.draft_called = False async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "确认", "synthetic_message_id": "s-msg-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=100), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=55), "session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), } async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): self.draft_called = True _ = kwargs return { "handled": True, "status": "answered", "classification": "command", "direct_reply_required": True, "direct_reply_text": "已确认草稿并继续执行。", } def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False async def _prepare_mixed_sidecar_context(self, **kwargs): _ = kwargs raise AssertionError("draft_pending_confirm flow should bypass mixed sidecar precheck") kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-1", message_text="确认", sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}}, ) 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_draft_before_query_for_regenerate_request() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.active_context_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_active_context_followup(self, **kwargs): if kwargs["force_bind"] is not True: raise AssertionError("active draft regenerate request should bind current draft before fallback") self.active_context_called = True _ = kwargs return { "handled": True, "status": "answered", "classification": "question", "direct_reply_required": True, "direct_reply_text": "我先基于当前草稿再整理一版。", } async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): raise AssertionError("active draft regenerate request should stay on active-context semantics") def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False 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.active_context_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": []} class FakeMountedAIRuntimeWriteService: def __init__(self, db) -> None: captured["db"] = db async def begin_ingress_run(self, **kwargs): captured["calls"].append(("begin", kwargs)) return SimpleNamespace( topic_workspace=SimpleNamespace(id=91, tenant_id=1), conversation_run=SimpleNamespace(id=92), context_layers_hit={"current_message": True, "thread": True, "topic": False}, ) async def record_orchestrator_decision(self, **kwargs): captured["calls"].append(("record", kwargs)) return "plan" async def record_agent_trace(self, **kwargs): captured["calls"].append(("agent_trace", kwargs)) async def finalize_run(self, **kwargs): captured["calls"].append(("finalize", kwargs)) class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "确认", "synthetic_message_id": "s-msg-mounted-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=100), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=55), "session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), } async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): _ = kwargs return { "handled": True, "status": "answered", "classification": "command", "workflow_route": "followup.confirm", "reply_template_key": "action_succeeded", "work_item_id": 55, "direct_reply_required": True, "direct_reply_text": "已确认草稿并继续执行。", } def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False async def _prepare_mixed_sidecar_context(self, **kwargs): _ = kwargs raise AssertionError("draft confirmation flow should bypass mixed sidecar precheck") monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-mounted-1", message_text="确认", sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}}, ) assert result["status"] == "answered" assert [name for name, _ in captured["calls"]] == ["begin", "record", "agent_trace", "finalize"] begin_kwargs = captured["calls"][0][1] record_kwargs = captured["calls"][1][1] agent_trace_kwargs = captured["calls"][2][1] finalize_kwargs = captured["calls"][3][1] assert begin_kwargs["source_system"] == "feishu" assert begin_kwargs["source_message_id"] == "s-msg-mounted-1" assert record_kwargs["workflow_route"] == "followup.confirm" assert agent_trace_kwargs["agent_name"] not in {"ConversationOrchestrator", "ReplyComposer"} assert agent_trace_kwargs["output_summary"]["workflow_route"] == "followup.confirm" assert finalize_kwargs["result"]["direct_reply_text"] == "已确认草稿并继续执行。" @pytest.mark.asyncio async def test_process_ingress_records_semantic_shadow_before_legacy_finalize(monkeypatch) -> None: from app.services.ai_pmo.semantic_context.decision_schema import ModeCard, SemanticDecision from app.services.ai_pmo.semantic_context.packet_schema import ( SemanticContextPacket, SemanticContextThread, SemanticContextTopic, ) captured: dict[str, object] = {"calls": []} class FakeMountedAIRuntimeWriteService: def __init__(self, db) -> None: captured["db"] = db async def begin_ingress_run(self, **kwargs): captured["calls"].append(("begin", kwargs)) return SimpleNamespace( topic_workspace=SimpleNamespace(id=191, tenant_id=1, plan_status="idle"), conversation_run=SimpleNamespace(id=192, source_message_id="s-msg-semantic-shadow"), context_layers_hit={"current_message": True, "thread": False, "topic": False}, ) async def record_semantic_shadow(self, **kwargs): captured["calls"].append(("semantic_shadow", kwargs)) async def record_orchestrator_decision(self, **kwargs): captured["calls"].append(("record", kwargs)) return "chat" async def record_agent_trace(self, **kwargs): captured["calls"].append(("agent_trace", kwargs)) async def finalize_run(self, **kwargs): captured["calls"].append(("finalize", kwargs)) class FakeSemanticContextAssembler: def __init__(self, db) -> None: captured["semantic_db"] = db async def assemble(self, **kwargs): captured["semantic_packet_kwargs"] = kwargs return SemanticContextPacket( tenant_id=1, operator_user_id=8, topic_workspace_id=191, latest_user_message=str(kwargs["latest_user_message"]), topic=SemanticContextTopic( topic_workspace_id=191, topic_key="feishu:chat-shadow:root:om-root-shadow", source_system="feishu", channel_ref="chat-shadow", conversation_scope_key="feishu:chat-shadow:ou-shadow", thread_key="thread-shadow", mode="plan", status="active", plan_status="collecting", facts={"title": "语义 shadow"}, goals=["统一语义入口"], constraints={"must_not": ["db.write"]}, metadata={"source": "test"}, ), thread=SemanticContextThread( thread_key="thread-shadow", conversation_scope_key="feishu:chat-shadow:ou-shadow", thread_summary="继续整理语义上下文", recent_turns=[], open_questions=["是否继续当前 topic"], ), mode_card=ModeCard( workflow_mode="PLAN", mode_state="ACTIVE", allowed_actions=["reply.compose", "draft.patch"], forbidden_actions=["project.create", "db.write"], exit_conditions=["用户明确要求开始执行"], ), ) class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): captured["semantic_packet"] = packet.model_dump(mode="json") return SemanticDecision( topic_relation="continue_current_topic", mode_decision="stay_plan", draft_patch={"goal": "继续整理语义上下文"}, assistant_reply="我先继续整理上下文。", requires_confirmation=False, requested_actions=["reply.compose", "draft.patch"], confidence=0.88, ) class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.context_assembler = SimpleNamespace(assemble_world_model=self._assemble_world_model) self.route_resolver = SimpleNamespace(from_canonical=self._route_from_canonical) self.decision_engine = SimpleNamespace(decide=lambda **_: {"decision": "unused"}) async def _assemble_world_model(self, **kwargs): _ = kwargs return None, {"capacity": [], "skill_graph": [], "resolved_project_id": None} def _route_from_canonical(self, **kwargs): _ = kwargs return SimpleNamespace( key="query.answer", family="query", action="answer", requested_count=1, requires_confirmation=False, project_id=None, metadata={}, ) async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "继续整理语义上下文,不直接执行。", "synthetic_message_id": "s-msg-semantic-shadow", "closure_payload": {}, "ingest_event": SimpleNamespace(id=200), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=55, state=None, context_snapshot=None, intent=None, project_id=None), "session": SimpleNamespace(last_intent="query.answer"), "session_key": "session-shadow", } def _merge_ingress_thread_context(self, **kwargs): _ = kwargs return None def _should_route_to_draft_confirmation(self, **kwargs): _ = kwargs return False def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False async def _prepare_mixed_sidecar_context(self, **kwargs): return kwargs["message_text"], {} async def _resolve_intent(self, **kwargs): _ = kwargs return CanonicalIntent( name=mixin_entry.INTENT_IGNORE, confidence=0.91, project_id=None, signal_title="", signal_summary="", metadata={"message_intent": "requirement_plan"}, ), "llm" def _coerce_intent_for_query_scene(self, **kwargs): return kwargs["intent"] def _enrich_signal_source_intent_from_closure(self, **kwargs): return kwargs["intent"] async def _finalize_ignored_ingress(self, **kwargs): _ = kwargs return { "handled": True, "status": "ignored", "classification": "ignored", "direct_reply_required": False, "direct_reply_text": "这轮先按旧链路忽略处理。", } def _schedule_portfolio_snapshot_persist(self, **kwargs): _ = kwargs return None monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-shadow-1", message_text="继续整理语义上下文,不直接执行。", sender="tester", channel_ref="chat-shadow", source_sender_open_id="ou-shadow", ) assert result["status"] == "ignored" assert [name for name, _ in captured["calls"]] == [ "begin", "semantic_shadow", "record", "agent_trace", "finalize", ] semantic_kwargs = captured["calls"][1][1] assert semantic_kwargs["semantic_packet_summary"]["packet_version"] == "v1" assert semantic_kwargs["semantic_packet_summary"]["topic_key"] == "feishu:chat-shadow:root:om-root-shadow" assert semantic_kwargs["semantic_decision"]["mode_decision"] == "stay_plan" assert semantic_kwargs["semantic_gate_result"]["accepted"] is True assert semantic_kwargs["semantic_diff"]["legacy_route"] == "query.answer" def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options=None, ) is False assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options={"thread_context": {"thread_id": "th_123"}}, ) is False assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options={"thread_context": {"root_message_id": "root_123"}}, ) is False assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="创建时间是多少?", ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, ) is False assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, ) is True assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="工作项ID: 88 创建时间是多少?", ingress_options=None, ) is True def test_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 False 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_natural_followup_stays_out_of_draft_confirmation_pre_route() -> 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 False def test_smalltalk_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_new_project_statement_does_not_route_to_existing_draft_without_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="最近直播复盘太慢了,想做个给运营团队用的小工具,这个新需求先叫 FeishuWebFlow-abc。", ingress_options=None, ) is False @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_does_not_hijack_explicit_new_requirement_topic( monkeypatch, ) -> None: called = False 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): nonlocal called _ = (self, kwargs) called = True return True 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", source_system="feishu", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", ingress_options=None, ) assert should_route is False assert called is False def test_should_inherit_recent_feishu_session_context_skips_explicit_new_requirement_topic() -> None: session = SimpleNamespace( sender="feishu:tester", last_active_at=datetime.now(), last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM, ) 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_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_reset_session_project_context_does_not_guess_new_requirement_from_message_text() -> 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 kernel = FakeKernel() session = SimpleNamespace( current_project_id=104, last_intent="query.read_only", last_message_text="A", ) cleared = await kernel._maybe_reset_session_project_context_for_new_requirement( session=session, sender="tester", channel_ref="chat-active", source_sender_open_id="ou_active", source_message_id="msg-new-topic", thread_context=None, message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", ) 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_without_active_requirement_context_is_ignored(message_text: str) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 async def _has_active_requirement_plan_workspace(self, **kwargs): _ = kwargs return False kernel = FakeKernel() result = await kernel._maybe_handle_orphan_requirement_followup( source_system="feishu", source_message_id="msg-orphan-reply", channel_ref="chat-active", source_sender_open_id="ou_active", ingress_options=None, message_text=message_text, ) assert result is None @pytest.mark.asyncio async def test_orphan_requirement_followup_recovers_requirement_context_from_explicit_reply_before_refusing() -> None: captured: dict[str, object] = {} class FakeExecutor: async def _promote_contextual_reply_into_requirement_plan_workspace(self, **kwargs): captured["promote_kwargs"] = dict(kwargs) return SimpleNamespace(id=11, plan_status="collecting"), SimpleNamespace(id=29, state="collecting") class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 self.executor = FakeExecutor() async def _has_active_requirement_plan_workspace(self, **kwargs): _ = kwargs return False kernel = FakeKernel() session = SimpleNamespace( current_project_id=None, last_intent="requirement_plan", last_message_text="C", ) result = await kernel._maybe_handle_orphan_requirement_followup( source_system="feishu", source_message_id="msg-orphan-recover", channel_ref="chat-active", source_sender_open_id="ou_active", ingress_options={ "thread_context": { "root_message_id": "om-plan-root", "reply_to_message_id": "om-plan-root", } }, message_text="我选C", session=session, sender="tester", ) assert result is None promote_kwargs = captured.get("promote_kwargs") assert isinstance(promote_kwargs, dict) assert promote_kwargs["message_text"] == "我选C" assert promote_kwargs["thread_context"] == { "root_message_id": "om-plan-root", "reply_to_message_id": "om-plan-root", } @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 async def test_orphan_requirement_followup_skips_guard_when_active_requirement_session_exists() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 async def _has_active_requirement_plan_workspace(self, **kwargs): _ = kwargs return True kernel = FakeKernel() result = await kernel._maybe_handle_orphan_requirement_followup( source_system="feishu", source_message_id="msg-plan-reply", channel_ref="chat-active", source_sender_open_id="ou_active", ingress_options=None, message_text="A", ) 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_requirement_followup_uses_builder_result_to_persist_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=91, plan_status="collecting"), SimpleNamespace(id=92, 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": "第三轮:服务对象\n\nA. 单一层级区域经理\nB. 区域经理 + 店长\nC. 总部 + 区域 + 门店多层级", "reply_template_key": "requirement_plan_followup", "requirement_plan_context": { "question_round": 2, "last_round_options": { "field": "target_user", "options": [ {"key": "A", "title": "单一层级区域经理"}, {"key": "B", "title": "区域经理 + 店长"}, {"key": "C", "title": "总部 + 区域 + 门店多层级"}, ], "recommendation": {"key": "A"}, }, }, } 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", ], 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="已确认选项 A(单一层级区域经理),继续收第二轮关键信息。", 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-2", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="确认A,继续往下收。", thread_context={"reply_to_message_id": "om-plan-reply"}, sender="tester", ) assert result is not None assert result["handled"] is True assert result["direct_reply_text"].startswith("第三轮:服务对象") assert result["requirement_plan_context"]["last_round_options"]["field"] == "target_user" assert "direct_kwargs" in captured @pytest.mark.asyncio async def test_active_requirement_followup_defaults_missing_scope_to_bound_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=91, plan_status="collecting"), SimpleNamespace(id=92, 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": "第三轮:技术路线\n\nA. SQL + 飞书消息\nB. 多源拼接\nC. 外部系统 API", "reply_template_key": "requirement_plan_followup", "requirement_plan_context": { "question_round": 3, "last_round_options": { "field": "tech_route", "options": [ {"key": "A", "title": "SQL + 飞书消息"}, {"key": "B", "title": "多源拼接"}, {"key": "C", "title": "外部系统 API"}, ], "recommendation": {"key": "A"}, }, }, } 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", ], 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="none", followup_action="none", revision_note="", assistant_reply="已确认选项 A(单一层级区域经理),继续收第二轮关键信息。", 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-2b", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="确认A,继续往下收。", thread_context={"reply_to_message_id": "om-plan-reply"}, sender="tester", ) assert result is not None assert result["handled"] is True assert result["direct_reply_text"].startswith("第三轮:技术路线") assert result["requirement_plan_context"]["last_round_options"]["field"] == "tech_route" assert "direct_kwargs" in captured @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="regenerate", 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"] == "regenerate" @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" @pytest.mark.asyncio async def test_find_active_requirement_plan_workspace_recovers_plan_topic_marked_via_metadata_even_if_mode_drifted_to_chat( 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-plan-mode-drift", source_sender_open_id="ou-plan-mode-drift", sender=None, ) topic = MountedTopicWorkspace( tenant_id=7, topic_key="feishu:chat-plan-mode-drift:root:om-plan-root", source_system="feishu", channel_ref="chat-plan-mode-drift", conversation_scope_key=conversation_scope_key, root_message_id="om-plan-root", last_message_id="msg-plan-turn-3", mode="chat", status="active", plan_status="options_ready", metadata_json={"plan_mode": True, "last_reply_to_message_id": "msg-plan-turn-3"}, 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="collecting", draft_version=0, requirement_card={"title": "主播协同作战平台"}, draft_payload=None, 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-plan-turn-4", source_chat_id="chat-plan-mode-drift", sender=None, source_sender_open_id="ou-plan-mode-drift", thread_context={ "root_message_id": "msg-plan-turn-3", "reply_to_message_id": "msg-plan-turn-3", }, 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_topic.plan_status) == "options_ready" assert str(found_draft.state) == "collecting" 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", route_metadata={ "message_intent": "business_review", "consultant_mode_preference": "clarify", }, reply_template_key=None, ) agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", auto_execute=True, route_metadata={ "message_intent": "business_review", "consultant_mode_preference": "clarify", }, reply_template_key=None, mode_hint=mode, ) assert mode == "plan" assert agent == "PlanCoach" def test_mounted_runtime_query_advise_uses_assignee_recommender() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "business_review", "consultant_mode_preference": "advise", "advise_subtype": "assignee", }, reply_template_key=None, ) agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", auto_execute=True, route_metadata={ "message_intent": "business_review", "consultant_mode_preference": "advise", "advise_subtype": "assignee", }, reply_template_key=None, mode_hint=mode, ) assert mode == "plan" assert agent == "AssigneeRecommender" def test_mounted_runtime_query_requirement_plan_uses_plan_coach() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, reply_template_key=None, ) agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", auto_execute=True, route_metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, reply_template_key=None, mode_hint=mode, ) assert mode == "plan" assert agent == "PlanCoach" def test_mounted_runtime_query_requirement_plan_uses_direct_metadata_for_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, reply_template_key=None, ) assert mode == "plan" def test_mounted_runtime_query_consult_requirement_structured_discovery_uses_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "consult_requirement", "consultant_mode_preference": "structured_discovery", }, reply_template_key=None, ) assert mode == "plan" def test_mounted_runtime_query_requirement_gathering_stepwise_convergent_uses_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "requirement_gathering", "consultant_mode_preference": "stepwise_convergent", }, reply_template_key=None, ) assert mode == "plan" def test_mounted_runtime_query_consult_requirement_stepwise_plan_first_uses_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "consult_requirement", "consultant_mode_preference": "stepwise_plan_first", }, reply_template_key=None, ) assert mode == "plan" def test_mounted_runtime_query_consult_requirement_lightweight_plan_first_uses_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "consult_requirement", "consultant_mode_preference": "lightweight_plan_first", }, reply_template_key=None, ) assert mode == "plan" def test_mounted_runtime_query_consult_requirement_structured_analysis_first_uses_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "consult_requirement", "consultant_mode_preference": "structured_analysis_first", }, reply_template_key=None, ) assert mode == "plan" def test_mounted_runtime_query_requirement_gathering_stepwise_converge_uses_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "requirement_gathering", "consultant_mode_preference": "stepwise_converge", }, reply_template_key=None, ) assert mode == "plan" def test_build_canonical_intent_keeps_project_update_instead_of_dropping_to_ignore() -> None: intent = KernelIntentRulesMixin._build_canonical_intent( intent_name="project.update", message_text="把项目2的 success criteria 改成 48 小时内必须上线。", project_id=2, confidence=0.91, metadata={"field": "success_criteria"}, ) assert intent.name == "project.update" assert intent.project_id == 2 assert intent.metadata["field"] == "success_criteria" def test_explicit_named_project_create_skips_existing_project_name_binding() -> None: assert mixin_project.KernelWorldModelCoreProjectMixin._should_skip_implicit_project_binding( message_text=( "最近我们做跨境直播复盘太慢了,想做个给运营团队用的小工具," "这个需求先叫 FeishuWebFlow-1775301894322。" ), ) is True def test_project_lookup_question_keeps_existing_project_name_binding() -> None: assert mixin_project.KernelWorldModelCoreProjectMixin._should_skip_implicit_project_binding( message_text="帮我看下跨境直播复盘这个项目现在怎么样?", ) is False @pytest.mark.asyncio async def test_draft_confirm_handler_prefers_latest_sender_scope_candidate_without_anchor() -> None: latest_work_item = SimpleNamespace( id=202, state="draft_pending_confirm", source_message_id="msg-latest", ) old_work_item = SimpleNamespace( id=101, state="draft_pending_confirm", source_message_id="msg-old", ) ingest_payload = { "sender": "tester", "channel_ref": "chat-1", "source_sender_open_id": "ou_123", } rows = [ (latest_work_item, SimpleNamespace(payload=dict(ingest_payload))), (old_work_item, SimpleNamespace(payload=dict(ingest_payload))), ] class FakeResult: def __init__(self, value): self._value = value def all(self): return list(self._value) class FakeDB: async def execute(self, _stmt): return FakeResult(rows) handler = DraftConfirmHandler(SimpleNamespace(db=FakeDB(), tenant_id=1)) target = await handler._resolve_pending_draft( sender="tester", source_system="feishu", channel_ref="chat-1", source_sender_open_id="ou_123", explicit_work_item_id=None, thread_context=None, ) assert target == rows[0] @pytest.mark.asyncio async def test_draft_confirm_handler_skips_followup_wrapper_when_resolving_latest_draft() -> None: followup_wrapper = SimpleNamespace( id=303, state="draft_pending_confirm", source_message_id="msg-followup", intent="followup.confirm", ) latest_project_draft = SimpleNamespace( id=202, state="draft_pending_confirm", source_message_id="msg-project", intent="project_create.create", ) ingest_payload = { "sender": "tester", "channel_ref": "chat-1", "source_sender_open_id": "ou_123", } rows = [ (followup_wrapper, SimpleNamespace(payload=dict(ingest_payload))), (latest_project_draft, SimpleNamespace(payload=dict(ingest_payload))), ] class FakeResult: def __init__(self, value): self._value = value def all(self): return list(self._value) class FakeDB: async def execute(self, _stmt): return FakeResult(rows) handler = DraftConfirmHandler(SimpleNamespace(db=FakeDB(), tenant_id=1)) target = await handler._resolve_pending_draft( sender="tester", source_system="feishu", channel_ref="chat-1", source_sender_open_id="ou_123", explicit_work_item_id=None, thread_context=None, ) assert target == rows[1] def test_build_feishu_thread_key_prefers_actor_scope_without_anchor() -> None: assert build_feishu_thread_key( source_chat_id="chat-1", source_sender_open_id="ou_dm_1", sender="产品经理A", source_message_id="om_current_message", thread_context=None, ) == "feishu:chat-1:ou_dm_1" def test_low_value_closure_payload_helper_handles_summary_text() -> None: assert helpers_inbox_filters._is_low_value_closure_payload( {"closure_summary": "待补充核心痛点"} ) is False def test_serialize_work_item_summary_handles_message_text_without_nameerror() -> None: row = SimpleNamespace( id=9, source_system="pmflow_manual", source_message_id="msg-9", draft_content={}, state="received", review_summary={}, action_bundle={}, intent="query.read_only", project_id=None, signal_id=None, reply_template_key=None, execution_result={}, draft_expires_at=None, draft_version=0, confirm_message_id=None, created_at=None, updated_at=None, decision_level="portfolio", ) ingest = SimpleNamespace(payload={"message_text": " 最近 状态 怎么样 "}) payload = helpers_inbox_payload._serialize_work_item_summary( row=row, ingest=ingest, project=None, ) assert payload["source_message_text"] == "最近 状态 怎么样" def test_coerce_rag_business_review_scene_keeps_rag_ask_on_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="signal.create", confidence=0.91, project_id=None, signal_title="大模型 API 聚合平台", signal_summary="我现在要做一个大模型api聚合平台,你帮我分析下要怎么做,给出mvp需求", metadata={"llm_function": "classify_intent_ai_pmo_kernel"}, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="pmflow_manual", message_text="我现在要做一个大模型api聚合平台,你帮我分析下要怎么做,给出mvp需求", rag_request_context={ "asked_via": "pmflow_manual", "action_mode": "auto", "message_intent": "business_review", "consultant_mode_preference": "clarify", }, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "business_review" assert coerced.metadata["consultant_mode_preference"] == "clarify" assert coerced.metadata["workflow_route"] == "query.answer" assert "semantic_bundle" not in coerced.metadata def test_coerce_feishu_direct_business_review_metadata_keeps_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="signal.create", confidence=0.93, project_id=None, signal_title="大模型 API 聚合平台", signal_summary="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,", metadata={ "llm_function": "classify_intent_ai_pmo_kernel", "message_intent": "business_review", "review_framework": "five_lenses", "consultant_mode_preference": "clarify", }, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="feishu", message_text="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,", rag_request_context=None, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "business_review" assert coerced.metadata["workflow_route"] == "query.answer" def test_coerce_feishu_deferred_requirement_discussion_to_query_chain_from_direct_metadata() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="project.create", confidence=0.94, project_id=None, signal_title="直播复盘工具", signal_summary="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。", metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="feishu", message_text="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。", rag_request_context=None, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "requirement_plan" assert coerced.metadata["consultant_mode_preference"] == "clarify" assert coerced.metadata["workflow_route"] == "query.answer" def test_coerce_feishu_requirement_plan_from_direct_metadata_keeps_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="project.create", confidence=0.95, project_id=None, signal_title="A股趋势日评打分工具", signal_summary="做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", metadata={ "llm_function": "classify_intent_ai_pmo_kernel", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="feishu", message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", rag_request_context=None, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "requirement_plan" assert coerced.metadata["consultant_mode_preference"] == "clarify" assert coerced.metadata["workflow_route"] == "query.answer" def test_coerce_feishu_assignee_recommendation_to_query_chain_from_direct_metadata() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="project.create", confidence=0.88, project_id=None, signal_title="直播复盘工具", signal_summary="那你觉得分配给谁做比较合适?", metadata={ "message_intent": "business_review", "consultant_mode_preference": "advise", }, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="feishu", message_text="那你觉得分配给谁做比较合适?", rag_request_context=None, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "business_review" assert coerced.metadata["consultant_mode_preference"] == "advise" 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_uses_workflow_bundle_without_reply_anchor( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): captured["message_text"] = message_text captured["source_system"] = source_system captured["metadata"] = dict(metadata or {}) return WorkflowBundle( bundle_id="bundle-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.93, metadata={}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={"message_intent": "requirement_plan"}, ) monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), ) class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 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="我需要对应的需求mvp", session=session, world_model={}, thread_context=None, ) assert source == "workflow_bundle" assert intent.name == "query.read_only" assert intent.metadata["workflow_route"] == "query.answer" assert intent.metadata["llm_function"] == "pmflow_semantic_context_decision" assert intent.metadata["context_source"] == "workflow_bundle" assert intent.metadata["message_intent"] == "requirement_plan" assert isinstance(intent.metadata.get("semantic_bundle"), dict) assert captured["source_system"] == "feishu" assert captured["metadata"]["session_last_intent"] == "" assert captured["metadata"]["session_project_id"] is None @pytest.mark.asyncio async def test_kernel_intent_resolve_reply_anchor_binds_session_context_into_workflow_packet( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): captured["message_text"] = message_text captured["source_system"] = source_system captured["metadata"] = dict(metadata or {}) return WorkflowBundle( bundle_id="bundle-followup", semantic_version="v1", source_system=source_system, primary_workflow="followup_flow", workflows=[ WorkflowPlan( workflow_type="followup_flow", workflow_action="confirm", target_scope="thread", operation_codes=["followup.confirm"], requires_confirmation=False, confidence=0.91, metadata={}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="followup_flow"), diagnostics={}, ) monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), ) class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 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 == "workflow_bundle" assert intent.name == "followup.confirm" assert intent.project_id == 21 assert captured["metadata"]["session_last_intent"] == "task.create" assert captured["metadata"]["session_project_id"] == 21 assert captured["metadata"]["reply_to_message_id"] == "msg-1" @pytest.mark.asyncio async def test_kernel_intent_resolve_requirement_initiation_stays_on_query_chain( monkeypatch: pytest.MonkeyPatch, ) -> None: async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, source_system, metadata) return WorkflowBundle( bundle_id="bundle-requirement-init", 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_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "requirement_initiation", "consultant_mode_preference": "clarify", }, ) monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), ) class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 resolver = FakeResolver() intent, source = await resolver._resolve_intent( message_text="想做个新需求,关于给运营公司打造主播推流的需求。", session=None, world_model={}, thread_context=None, ) assert source == "workflow_bundle" assert intent.name == "query.read_only" assert intent.metadata["workflow_route"] == "query.answer" assert intent.metadata["message_intent"] == "requirement_initiation" assert intent.metadata["consultant_mode_preference"] == "clarify" assert intent.signal_title == "" 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( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): captured["message_text"] = message_text captured["source_system"] = source_system captured["metadata"] = dict(metadata or {}) return WorkflowBundle( bundle_id="bundle-low-context", 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.79, metadata={}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={}, ) monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), ) class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 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 == "workflow_bundle" assert captured["metadata"]["session_last_intent"] == "" assert captured["metadata"]["session_project_id"] is None assert captured["metadata"]["source_message_context"] == "cold_start" @pytest.mark.asyncio async def test_kernel_intent_resolve_no_longer_calls_short_confirmation_helper( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): captured["message_text"] = message_text captured["source_system"] = source_system captured["metadata"] = dict(metadata or {}) return WorkflowBundle( bundle_id="bundle-short-confirm", semantic_version="v1", source_system=source_system, primary_workflow="followup_flow", workflows=[ WorkflowPlan( workflow_type="followup_flow", workflow_action="confirm", target_scope="thread", operation_codes=["followup.confirm"], requires_confirmation=False, confidence=0.9, metadata={}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="followup_flow"), diagnostics={}, ) monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), ) class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): 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") 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 == "workflow_bundle" assert captured["metadata"]["session_last_intent"] == "project.create" assert captured["metadata"]["session_project_id"] == 104 assert captured["metadata"]["reply_to_message_id"] == "reply-1" @pytest.mark.asyncio async def test_kernel_intent_resolve_uses_workflow_bundle_for_signal_source_request( monkeypatch: pytest.MonkeyPatch, ) -> None: captured: dict[str, object] = {} async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): captured["message_text"] = message_text captured["source_system"] = source_system captured["metadata"] = dict(metadata or {}) return WorkflowBundle( bundle_id="bundle-signal-source", semantic_version="v1", source_system=source_system, primary_workflow="signal_source_flow", workflows=[ WorkflowPlan( workflow_type="signal_source_flow", workflow_action="onboard", target_scope="project", operation_codes=["signal_source.onboard"], requires_confirmation=True, confidence=0.92, metadata={"project_id": 2}, ) ], message_gate="pending", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="signal_source_flow"), diagnostics={}, ) monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)), ) class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 resolver = FakeResolver() intent, source = await resolver._resolve_intent( message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。", session=None, world_model={"resolved_project_id": 2}, thread_context=None, ) assert source == "workflow_bundle" assert intent.name == "signal_source.onboard" assert intent.project_id == 2 assert intent.metadata["source_request"] is True def test_reply_renderer_formats_project_delete_pending_and_success() -> None: renderer = ReplyRenderer( state_waiting_manual="waiting_manual", state_failed="failed", receipt_phase_action="action", receipt_phase_pending="pending", receipt_phase_error="error", ) intent = CanonicalIntent( name="project.delete", confidence=0.96, project_id=None, signal_title="", signal_summary="", metadata={"llm_parameters": {"project_id": 12, "project_title": "重复项目"}}, ) pending = renderer.render( work_state="waiting_manual", intent=intent, decision_record={"approval_requirement": {"message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。"}}, execution_result={ "status": "waiting_manual", "action_contract": { "operation_code": "project.delete", "params": {"project_id": 12, "project_title": "重复项目"}, }, }, ) assert pending["template_key"] == "pending_manual" assert "重复项目" in pending["text"] assert "人工确认" in pending["text"] success = renderer.render( work_state="succeeded", intent=intent, decision_record={"approval_requirement": {}}, execution_result={"status": "succeeded", "operation_code": "project.delete", "project_id": 12}, ) assert success["template_key"] == "action_succeeded" assert "已删除项目#12《重复项目》" in success["text"] @pytest.mark.asyncio async def test_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] = {} class FakeReviewService: async def review_work_item(self, **kwargs): _ = kwargs return {"verified": False, "issues": ["waiting_manual"], "next_step": "请确认"} class FakeRenderer: def render(self, **kwargs): captured["render_kwargs"] = kwargs return { "receipt_phase": "pending", "template_key": "pending_manual", "payload": {"intent": kwargs["intent"].name}, "text": "ok", } class FakeKernel(mixin_prepare_manual.KernelIngressFlowPrepareManualMixin): def __init__(self) -> None: class _FakeDB: async def commit(self): return None self.db = _FakeDB() self.tenant_id = 7 self.review_service = FakeReviewService() self.reply_renderer = FakeRenderer() async def _maybe_emit_risk_cadence_snapshot(self, **kwargs): _ = kwargs async def _claim_reply_receipt(self, **kwargs): _ = kwargs return True async def _write_processing_log(self, **kwargs): _ = kwargs async def _update_session(self, **kwargs): _ = kwargs async def _upsert_operation_execution_ledger(self, **kwargs): _ = kwargs return None def _extract_execution_operation_code(self, execution_result): if not isinstance(execution_result, dict): return None action_contract = execution_result.get("action_contract") if isinstance(execution_result.get("action_contract"), dict) else {} if isinstance(action_contract, dict): operation_code = str(action_contract.get("operation_code") or "").strip().lower() if operation_code: return operation_code operation_code = str(execution_result.get("operation_code") or "").strip().lower() return operation_code or None def _normalize_execution_ledger_status(self, *, work_item_state, execution_status): _ = execution_status return "pending_manual" if str(work_item_state or "").strip().lower() == "waiting_manual" else "processing" async def _project_waiting_manual(self, **kwargs): captured["project_waiting_manual"] = kwargs def _apply_execution_action_contract(self, **kwargs): execution_result = dict(kwargs["execution_result"]) execution_result["action_contract"] = { "operation_code": "project.delete", "params": {"project_id": 12, "project_title": "重复项目"}, } return execution_result def _build_capture_result(self, *, work_item, intent, execution_result, render, direct_reply_required, status, classification): _ = (work_item, intent, execution_result, render, direct_reply_required, classification) return {"status": status, "render": render} kernel = FakeKernel() work_item = SimpleNamespace( state=None, action_bundle=None, execution_result=None, review_summary=None, reply_template_key=None, id=99, project_id=12, signal_id=None, intent="project.delete", source_message_id="msg-1", draft_version=None, draft_expires_at=None, ) session = SimpleNamespace() ingest_event = SimpleNamespace() decision_payload = { "policy": {"approval_requirement": {"message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。"}}, "execution_plan": {"actions": []}, "simulation": {}, } workflow_route = SimpleNamespace(key="project.delete", action="delete") intent = CanonicalIntent( name="project.delete", confidence=0.94, project_id=12, signal_title="", signal_summary="", metadata={"llm_parameters": {"project_id": 12, "project_title": "重复项目"}}, ) result = await kernel._finalize_waiting_manual_ingress( work_item=work_item, session=session, intent=intent, world_model={}, decision_payload=decision_payload, workflow_route=workflow_route, decision_trace={"reason": "test"}, source_system="feishu", source_message_id="msg-1", message_text="删除重复项目", sender="tester", ingest_event=ingest_event, started=0.0, ) assert result["status"] == "captured" render_kwargs = captured["render_kwargs"] assert isinstance(render_kwargs, dict) assert render_kwargs["execution_result"]["action_contract"]["operation_code"] == "project.delete" assert render_kwargs["execution_result"]["action_contract"]["params"]["project_title"] == "重复项目" def test_gate_execution_ignores_low_confidence_but_keeps_risk_review() -> None: low_confidence = gate_execution(confidence=0.02, risk_level="medium") assert low_confidence.action == "auto_execute" assert low_confidence.auto_execute is True assert low_confidence.gate_code == "auto_execute_allowed" assert "置信度" not in low_confidence.reason high_risk = gate_execution(confidence=0.02, risk_level="high") assert high_risk.action == "auto_execute" assert high_risk.auto_execute is True assert high_risk.gate_code == "auto_execute_allowed" assert "人工确认" not in high_risk.reason @pytest.mark.asyncio async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "来自统一 ingress 的回答", "session_id": 7} class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 _install_requirement_plan_query_bundle(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=12, metadata={ "rag_request_context": { "asked_by": 101, "asked_via": "pmflow_manual", "project_id": 19, "source_chat_id": "chat-abc", "session_id": 55, "force_new_session": True, "thinking_mode": "deep", "action_mode": "read_write", "sync_requirement": True, "message_intent": "mixed", "mixed_signal_part": "新增任务确认流", "consultant_mode_preference": "challenge", } }, ) result = await executor._execute_query_intent( intent=intent, message_text="请同步更新状态", operator_user_id=999, source_system="feishu", source_message_id="msg-1", ) assert result["status"] == "succeeded" assert "统一 ingress" in result["answer"] answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_by"] == 101 assert answer_kwargs["asked_via"] == "pmflow_manual" assert answer_kwargs["project_id"] == 19 assert answer_kwargs["source_chat_id"] == "chat-abc" assert answer_kwargs["session_id"] == 55 assert answer_kwargs["force_new_session"] is True assert answer_kwargs["thinking_mode"] == "deep" assert answer_kwargs["action_mode"] == "read_write" assert answer_kwargs["sync_requirement"] is True assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["consultant_mode_preference"] == "challenge" @pytest.mark.asyncio async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "fallback-answer", "session_id": 7} class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_backfill_query_semantic_bundle", AsyncMock(return_value={"diagnostics": {"message_intent": "query", "force_thinking_mode": "normal"}}), ) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "thread_context": {"root_message_id": "root-1"}, "source_sender_open_id": "ou-feishu-99", }, ) result = await executor._execute_query_intent( intent=intent, message_text="最近AI行业有什么动态", operator_user_id=999, source_system="feishu", source_message_id="msg-feishu-1", ) assert result["status"] == "succeeded" answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_by"] is None assert answer_kwargs["asked_via"] == "feishu" assert answer_kwargs["source_chat_id"] is None assert answer_kwargs["source_sender_open_id"] == "ou-feishu-99" assert answer_kwargs["thread_context"] == {"root_message_id": "root-1"} assert answer_kwargs["conversation_key"] == build_feishu_conversation_scope_key( source_chat_id=None, source_sender_open_id="ou-feishu-99", sender=None, ) @pytest.mark.asyncio async def test_execute_query_intent_uses_direct_scene_metadata_when_no_explicit_rag_request(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "corrected-answer", "session_id": 9} class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-dm-9", "source_sender_open_id": "ou-feishu-9", "message_intent": "correction_escalation", "consultant_mode_preference": "challenge", "force_thinking_mode": "deep", }, ) result = await executor._execute_query_intent( intent=intent, message_text="不对,调用高级模型来回答我的问题", operator_user_id=999, source_system="feishu", source_message_id="msg-feishu-9", ) assert result["status"] == "succeeded" answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_by"] is None assert answer_kwargs["asked_via"] == "feishu" assert answer_kwargs["source_chat_id"] == "chat-dm-9" assert answer_kwargs["source_sender_open_id"] == "ou-feishu-9" assert answer_kwargs["message_intent"] == "correction_escalation" assert answer_kwargs["consultant_mode_preference"] == "challenge" assert answer_kwargs["thinking_mode"] == "deep" assert answer_kwargs["conversation_key"] == build_feishu_conversation_scope_key( source_chat_id="chat-dm-9", source_sender_open_id="ou-feishu-9", sender=None, ) @pytest.mark.asyncio async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise RuntimeError("rag failed before response") class FakeDB: def __init__(self) -> None: self.rollback_calls = 0 self.sync_session = SimpleNamespace(is_active=False) async def rollback(self) -> None: self.rollback_calls += 1 class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 3 @staticmethod def _build_query_error_message(exc: Exception) -> str: return f"query failed: {exc}" monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_backfill_query_semantic_bundle", AsyncMock(return_value={"diagnostics": {"message_intent": "query"}}), ) executor = FakeExecutor() intent = SimpleNamespace(project_id=12, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="现在完整调用链是什么?", operator_user_id=999, source_system="pmflow_manual", source_message_id="msg-rollback-1", ) assert result["status"] == "failed_safe" assert "rag failed" in str(result["error_message"] or "") assert executor.db.rollback_calls == 1 @pytest.mark.asyncio async def test_execute_query_intent_does_not_rollback_healthy_session_on_rag_failure(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise RuntimeError("rag failed before response") class FakeDB: def __init__(self) -> None: self.rollback_calls = 0 self.sync_session = SimpleNamespace(is_active=True) async def rollback(self) -> None: self.rollback_calls += 1 class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 3 @staticmethod 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={}) result = await executor._execute_query_intent( intent=intent, message_text="先分析,不要直接创建。", operator_user_id=999, source_system="pmflow_manual", source_message_id="msg-rollback-2", ) assert result["status"] == "succeeded" assert "先不创建项目" in str(result["answer"] or "") assert executor.db.rollback_calls == 0 @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_casual_chat_without_rag(monkeypatch) -> None: 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 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "semantic_bundle": { "diagnostics": { "message_intent": "casual_chat", "consultant_mode_preference": "brainstorm", } } }, ) result = await executor._execute_query_intent( intent=intent, message_text="早上好,今天你在线吗", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-1", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert "我在线" in answer assert "A." not in answer assert "B." not in answer assert "C." not in answer 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: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement clarify follow-up should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "thread_context": {"root_message_id": "root-1", "reply_to_message_id": "root-1"}, "semantic_bundle": { "diagnostics": { "message_intent": "business_review", "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-chat-2", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert "轻量版继续理解" in answer assert "A." in answer assert "B." in answer assert "C." in answer assert "直接回 A/B/C" in answer assert str(result["rag_result"]["query_type"]) == "clarify" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_planning_without_rag(monkeypatch) -> None: 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): _ = (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={}) result = await executor._execute_query_intent( intent=intent, message_text="我们想做一个给电动车品牌用的直播复盘工具,先别创建项目,我还在补范围。", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-2b", ) answer = str(result["answer"] or "") assert result["status"] == "succeeded" 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: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement plan scene should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: 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( project_id=None, metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) result = await executor._execute_query_intent( intent=intent, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-plan-1", ) answer = str(result["answer"] or "") assert result["status"] == "succeeded" 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" @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_followup_persists_progress_and_asks_next_question( 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 follow-up 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 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( project_id=None, metadata={ "source_chat_id": "chat-plan-1", "source_sender_open_id": "ou-plan-1", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-1", "source_sender_open_id": "ou-plan-1", "thread_context": { "root_message_id": "msg-plan-root", "reply_to_message_id": "msg-plan-root", }, "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) first = await executor._execute_query_intent( intent=initial_intent, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-root", ) second = await executor._execute_query_intent( intent=followup_intent, message_text="A,主要给投顾看。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-1", ) assert first["status"] == "succeeded" assert second["status"] == "succeeded" 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, MountedTopicWorkspace.channel_ref == "chat-plan-1", ) .order_by(MountedTopicWorkspace.id.desc()) .limit(1) ) ).scalar_one() draft = ( await requirement_plan_db_session.execute( 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["project_nature"] == "轻量打分工具" assert "投顾" in str(card["target_user"]) @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draft_after_enough_answers( 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 _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent_root = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-2", "source_sender_open_id": "ou-plan-2", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) intent_followup = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-2", "source_sender_open_id": "ou-plan-2", "thread_context": { "root_message_id": "msg-plan-root-2", "reply_to_message_id": "msg-plan-root-2", }, "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) await executor._execute_query_intent( intent=intent_root, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-root-2", ) await executor._execute_query_intent( intent=intent_followup, 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="主要给投顾看。", 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-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 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 draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace).where(MountedTopicWorkspace.tenant_id == 3) ) ).scalar_one() 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-2d" assert draft.latest_reply_message_id is None assert topic.plan_status == "awaiting_execute_confirm" assert topic.pending_actions == ["confirm_requirement_draft"] @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_requirement_topic_suspends_previous_plan( 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("explicit new topic switching 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) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() active_plan_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-switch", "source_sender_open_id": "ou-plan-switch", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) explicit_switch_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-switch", "source_sender_open_id": "ou-plan-switch", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) await executor._execute_query_intent( intent=active_plan_intent, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-switch-root", ) switch_result = await executor._execute_query_intent( intent=explicit_switch_intent, message_text="不对啊,现在是另一个需求,先别沿用刚才那版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-switch-new", ) rows = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where(MountedTopicWorkspace.tenant_id == 3) .order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() answer = str(switch_result["answer"] or "") assert len(rows) == 2 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" assert rows[1].plan_status == "collecting" @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_it_requirement_overrides_stale_clarify_and_avoids_old_scoring_topic( 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("new IT requirement topic should stay in direct plan flow") 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) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-it-switch", source_sender_open_id="ou-plan-it-switch", sender=None, ) old_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-it-switch:message:msg-old-root", source_system="feishu", channel_ref="chat-plan-it-switch", conversation_scope_key=conversation_scope_key, root_message_id="msg-old-root", mode="plan", status="active", plan_status="awaiting_execute_confirm", metadata_json={"plan_mode": True}, facts_json={"plan_profile": "scoring", "title": "每日 A 股趋势打分工具"}, ) requirement_plan_db_session.add(old_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(old_topic.id), state="ready", draft_version=1, requirement_card={ "plan_profile": "scoring", "title": "每日 A 股趋势打分工具", "goal": "每天产出一个可解释的 A 股趋势分数", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "target_user": "投顾", "output_form": "飞书日报", "cadence": "每日", "first_scope": "第一版先做 A 股趋势分数和一句解释", }, draft_payload={"title": "每日 A 股趋势打分工具"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() stale_clarify_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-it-switch", "source_sender_open_id": "ou-plan-it-switch", "message_intent": "business_review", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-it-switch", "source_sender_open_id": "ou-plan-it-switch", "thread_context": { "root_message_id": "msg-new-it-root", "reply_to_message_id": "msg-new-it-root", }, }, ) first = await executor._execute_query_intent( intent=stale_clarify_intent, message_text="想跟你聊个新需求关于批量自动化部署openclaw的,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-new-it-root", ) second = await executor._execute_query_intent( intent=followup_intent, message_text="C,先按云原生交付底座来收。", operator_user_id=999, source_system="feishu", source_message_id="msg-new-it-2", ) first_answer = str(first["answer"] or "") second_answer = str(second["answer"] or "") assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in first_answer assert "云原生交付底座" in first_answer assert "目标对象" not in first_answer assert "A股趋势打分" not in first_answer assert "🧭 我先按“云原生交付底座”这个方向继续收。" in second_answer assert "现在要先把服务对象定住" in second_answer assert "A股趋势打分" not in second_answer assert "飞书日报" not in second_answer rows = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where(MountedTopicWorkspace.tenant_id == 3) .order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() assert len(rows) == 2 assert rows[0].status == "suspended" assert rows[0].plan_status == "suspended" assert rows[1].status == "active" assert rows[1].plan_status == "collecting" assert rows[1].root_message_id == "msg-new-it-root" @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_requirement_topic_reuses_current_tracking_topic( 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("explicit new topic switching 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() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-switch-reuse", source_sender_open_id="ou-plan-switch-reuse", sender=None, ) existing_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-switch-reuse:message:msg-plan-switch-root-reuse", source_system="feishu", channel_ref="chat-plan-switch-reuse", conversation_scope_key=conversation_scope_key, root_message_id="msg-plan-switch-root-reuse", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(existing_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(existing_topic.id), state="collecting", draft_version=0, requirement_card={"title": "旧需求"}, draft_payload=None, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) tracking_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-switch-reuse:message:msg-plan-switch-new-reuse", source_system="feishu", channel_ref="chat-plan-switch-reuse", conversation_scope_key=conversation_scope_key, mode="chat", status="active", plan_status="idle", metadata_json={"tracking": True}, ) requirement_plan_db_session.add(tracking_topic) await requirement_plan_db_session.flush() explicit_switch_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-switch-reuse", "source_sender_open_id": "ou-plan-switch-reuse", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) switch_result = await executor._execute_query_intent( intent=explicit_switch_intent, message_text="想跟你聊个新需求,关于批量自动化部署openclaw,先不创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-switch-new-reuse", ) rows = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where(MountedTopicWorkspace.tenant_id == 3) .order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() assert len(rows) == 2 assert switch_result["status"] == "succeeded" assert rows[0].status == "suspended" assert rows[0].plan_status == "suspended" assert rows[1].id == tracking_topic.id assert rows[1].status == "active" assert rows[1].mode == "plan" assert rows[1].plan_status == "collecting" @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_requirement_topic_with_subject_starts_collection_immediately( 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("explicit new topic seed 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) _install_requirement_plan_query_bundle(monkeypatch, topic_switch="new") monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-switch-subject", source_sender_open_id="ou-plan-switch-subject", sender=None, ) existing_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-switch-subject:message:msg-plan-switch-subject-root", source_system="feishu", channel_ref="chat-plan-switch-subject", conversation_scope_key=conversation_scope_key, root_message_id="msg-plan-switch-subject-root", mode="plan", status="active", plan_status="draft_pending_confirm", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(existing_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(existing_topic.id), state="draft_pending_confirm", draft_version=1, requirement_card={"title": "旧需求"}, draft_payload={"title": "旧需求"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) tracking_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-switch-subject:message:msg-plan-switch-subject-new", source_system="feishu", channel_ref="chat-plan-switch-subject", conversation_scope_key=conversation_scope_key, mode="chat", status="active", plan_status="idle", metadata_json={"tracking": True}, ) requirement_plan_db_session.add(tracking_topic) await requirement_plan_db_session.flush() explicit_switch_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-switch-subject", "source_sender_open_id": "ou-plan-switch-subject", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) result = await executor._execute_query_intent( intent=explicit_switch_intent, message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-switch-subject-new", ) answer = str(result["answer"] or "") assert "你先发一句这次真正想解决什么问题" not in answer assert "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。" in answer assert "⭐ 当前我更推荐:B" in answer assert "会议纪要" not in answer assert "输入先按用户当前需求描述来" not in answer assert "A." in answer assert "B." in answer assert "C." in answer assert "你回 A / B / C" in answer assert str(result["rag_result"]["query_type"]) == "plan" @pytest.mark.asyncio async def test_execute_query_intent_low_overlap_requirement_seed_switches_new_topic( 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("low-overlap requirement switching 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) _install_requirement_plan_query_bundle(monkeypatch, topic_switch="new") monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() current_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-overlap:root:msg-plan-overlap-root", source_system="feishu", channel_ref="chat-plan-overlap", conversation_scope_key=build_feishu_conversation_scope_key( source_chat_id="chat-plan-overlap", source_sender_open_id="ou-plan-overlap", sender=None, ), root_message_id="msg-plan-overlap-root", mode="plan", status="active", plan_status="awaiting_execute_confirm", facts_json={ "title": "每日 A 股趋势打分工具", "target_user": "投顾", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "output_form": "飞书日报", }, goals_json=["每天产出一个可解释的 A 股趋势分数,辅助判断和记录。"], constraints_json={"first_scope": "先保留一个分数、方向判断和一句解释"}, pending_actions=["confirm_requirement_draft"], metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(current_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(current_topic.id), state="ready", draft_version=1, requirement_card={ "title": "每日 A 股趋势打分工具", "goal": "每天产出一个可解释的 A 股趋势分数,辅助判断和记录。", "target_user": "投顾", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "output_form": "飞书日报", "cadence": "每日", "first_scope": "先保留一个分数、方向判断和一句解释", "plan_profile": "scoring", }, draft_payload={"title": "每日 A 股趋势打分工具"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() implicit_switch_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-overlap", "source_sender_open_id": "ou-plan-overlap", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) switch_result = await executor._execute_query_intent( intent=implicit_switch_intent, message_text="想跟你聊需求,关于批量自动化部署openclaw,先不创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-overlap-new", ) rows = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where(MountedTopicWorkspace.tenant_id == 3) .order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() assert len(rows) == 2 assert "每日 A 股趋势打分工具" not in str(switch_result["answer"] or "") assert rows[0].status == "suspended" assert rows[0].plan_status == "suspended" assert rows[1].status == "active" assert rows[1].plan_status == "collecting" @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_answers_and_only_drafts_when_scope_arrives( 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("fragmented 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() root_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-3", "source_sender_open_id": "ou-plan-3", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) fragmented_followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-3", "source_sender_open_id": "ou-plan-3", "thread_context": { "root_message_id": "msg-plan-root-3", "reply_to_message_id": "msg-plan-root-3", }, }, ) first = await executor._execute_query_intent( intent=root_intent, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-root-3", ) second = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3a", ) third = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="主要给投顾看", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3b", ) fourth = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="B", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3c", ) 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-3d", ) 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 str(third["rag_result"]["query_type"]) == "plan" assert "第一版先证明什么价值" in str(fourth["answer"] or "") assert str(fourth["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 str(final_result["rag_result"]["query_type"]) == "draft" 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 "投顾" in str(card["target_user"] 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_defaults_to_it_pm_template_for_current_system( 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("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() root_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-meeting", "source_sender_open_id": "ou-plan-meeting", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-meeting", "source_sender_open_id": "ou-plan-meeting", "thread_context": { "root_message_id": "msg-plan-meeting-root", "reply_to_message_id": "msg-plan-meeting-root", }, }, ) first = await executor._execute_query_intent( intent=root_intent, 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="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="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="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", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-5", ) 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 "⭐ 当前我更推荐: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(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 "") final_answer = str(final_result["answer"] or "") 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" draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() card = draft.requirement_card or {} 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_promotes_explicit_choice_followup_into_requirement_plan( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"explicit requirement followup should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "我先按主播协同作战平台这条需求继续收范围。", "rag_result": { "answer": "我先按主播协同作战平台这条需求继续收范围。", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) chat_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-choice:root:om-plan-choice-root", source_system="feishu", channel_ref="chat-plan-choice", conversation_scope_key=build_feishu_conversation_scope_key( source_chat_id="chat-plan-choice", source_sender_open_id="ou-plan-choice", sender=None, ), root_message_id="om-plan-choice-root", mode="chat", status="active", plan_status="idle", metadata_json={"last_message_preview": "C"}, ) requirement_plan_db_session.add(chat_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add_all( [ MountedConversationRun( tenant_id=3, trace_id="trace-plan-choice-1", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-plan-choice-1", status="completed", final_reply=( "🧭 我先按“给运营公司打造主播”这类需求来收。\n\n" "A. 主播培训系统\nB. 主播数字分身工厂\nC. 主播协同作战平台\n\n" "⭐ 当前我更推荐:C\n你回 A / B / C,我就顺着这条继续往下收。" ), execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"}, ), MountedConversationRun( tenant_id=3, trace_id="trace-plan-choice-2", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-plan-choice-2", status="completed", final_reply=( "你当前在聊“给运营公司打造主播”这个需求的 C 选项——主播协同作战平台。" "咱们还没确认它到底要优先解决哪类业务卡点。" ), execution_outcome={"source_message_preview": "C"}, ), ] ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-choice", "source_sender_open_id": "ou-plan-choice", "message_intent": "continue_contextual_qa", "thread_context": { "root_message_id": "om-plan-choice-root", "reply_to_message_id": "om-plan-choice-root", }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="三个问题都有", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-choice-3", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" assert str(result["workflow_route"]) == "requirement_plan.collect" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "三个问题都有" assert str(direct_kwargs["source_chat_id"]) == "chat-plan-choice" assert direct_kwargs["thread_context"] == { "root_message_id": "om-plan-choice-root", "reply_to_message_id": "om-plan-choice-root", } @pytest.mark.asyncio async def test_execute_query_intent_continues_same_scope_requirement_plan_without_reply_anchor( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"same-scope requirement followup should bypass heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "第三轮:技术路线\n\nA. 飞书 bot + 公开数据\nB. RAG 增强分析\nC. 信号流编排", "rag_result": { "answer": "第三轮:技术路线\n\nA. 飞书 bot + 公开数据\nB. RAG 增强分析\nC. 信号流编排", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-same-scope-plan", source_sender_open_id="ou-same-scope-plan", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-same-scope-plan:root:om-same-scope-plan-root", source_system="feishu", channel_ref="chat-same-scope-plan", conversation_scope_key=conversation_scope_key, mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, facts_json={"title": "自动给每日大A趋势打分的小工具"}, ) requirement_plan_db_session.add(topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "自动给每日大A趋势打分的小工具", "goal": "先收第一版需求,不创建项目。", "project_nature": "日报简报流", "target_user": "投研小群", "question_round": 2, "last_round_options": { "field": "target_user", "options": [ {"key": "A", "title": "个人盯盘者"}, {"key": "B", "title": "投研小群"}, {"key": "C", "title": "决策会议组"}, ], "recommendation": {"key": "B"}, }, "round_option_maps": { "target_user": { "A": "个人盯盘者", "B": "投研小群", "C": "决策会议组", } }, }, draft_payload=None, required_confirmations=[], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-same-scope-plan", "source_sender_open_id": "ou-same-scope-plan", "message_intent": "query.answer", "workflow_route": "query.answer", }, ) result = await executor._execute_query_intent( intent=intent, message_text="选B,固定成员,继续往下收。", operator_user_id=999, source_system="feishu", source_message_id="msg-same-scope-plan-3", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "选B,固定成员,继续往下收。" assert str(direct_kwargs["source_chat_id"]) == "chat-same-scope-plan" assert direct_kwargs["thread_context"] is None @pytest.mark.asyncio async def test_execute_query_intent_converts_query_answer_requirement_contract_into_requirement_plan_result( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): captured["rag_kwargs"] = dict(kwargs) return { "answer": ( "你聊的是「自动给每日大A趋势打分的小工具」,目标是先收敛第一版需求,不创建项目、不执行。\n\n" "第一轮收敛:项目性质?\n" "A. 日报简报流:固定指标 + 简单打分逻辑\n" "B. 可调分析流:支持切换打分模型和自定义权重\n" "C. 决策辅助流:对接交易信号库或策略回测结果\n\n" "⭐ 当前我更推荐:A,原因:最匹配“先不创建项目”的轻量启动方式。" ), "query_type": "explain", } class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "可以,我们先不创建项目,先把这件事收成一版需求。\n\n第一轮收敛:项目性质?", "rag_result": { "answer": "可以,我们先不创建项目,先把这件事收成一版需求。\n\n第一轮收敛:项目性质?", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-query-contract", "source_sender_open_id": "ou-query-contract", "message_intent": "query.answer", "workflow_route": "query.answer", }, ) result = await executor._execute_query_intent( intent=intent, message_text="@PMFlow 做一个自动给每日大A趋势打分的小工具,先不创建项目,只收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-query-contract-1", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" assert str(result["workflow_route"]) == "requirement_plan.collect" assert isinstance(captured.get("rag_kwargs"), dict) direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "@PMFlow 做一个自动给每日大A趋势打分的小工具,先不创建项目,只收第一版。" assert str(direct_kwargs["source_chat_id"]) == "chat-query-contract" @pytest.mark.asyncio async def test_execute_query_intent_converts_new_requirement_contract_into_new_topic_when_old_draft_pending_confirm( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): captured["rag_kwargs"] = dict(kwargs) return { "answer": ( "你想先收一个会议室投屏故障晨报助手的第一版需求,不创建项目、只做需求收敛。\n\n" "第一轮收敛:项目性质?\n" "A. 轻量晨报工具:汇总投屏失败并准点推送\n" "B. 运维协同台:支持IT与行政共用处置看板\n" "C. 能力底座:沉淀会议室故障规则与扩展接口\n\n" "⭐ 当前我更推荐:A,原因:最符合‘先不创建项目’和快速验证诉求。" ), "query_type": "explain", } class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "可以,我们先把会议室投屏故障晨报助手收成一版需求。", "rag_result": { "answer": "可以,我们先把会议室投屏故障晨报助手收成一版需求。", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) _install_requirement_plan_query_bundle( monkeypatch, message_intent="query.answer", consultant_mode_preference="", ) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-query-contract-shadow", source_sender_open_id="ou-query-contract-shadow", sender=None, ) old_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-query-contract-shadow:root:om-old-requirement-root", source_system="feishu", channel_ref="chat-query-contract-shadow", conversation_scope_key=conversation_scope_key, root_message_id="om-old-requirement-root", mode="plan", status="active", plan_status="draft_pending_confirm", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(old_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(old_topic.id), state="draft_pending_confirm", draft_version=2, requirement_card={ "plan_profile": "it_project", "title": "门店促销价签异常预警助手", "goal": "先把价签异常预警助手的首版需求收敛清楚。", "project_nature": "轻量自动化工具", "target_user": "大区督导", "tech_route": "规则引擎+人工复核看板", "first_scope": "最小闭环:仅覆盖高风险价签", "delivery_mode": "项目级试点(带基础审批和回滚)", }, draft_payload={"title": "门店促销价签异常预警助手"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-query-contract-shadow", "source_sender_open_id": "ou-query-contract-shadow", "message_intent": "query.answer", "workflow_route": "query.answer", }, ) result = await executor._execute_query_intent( intent=intent, message_text="@PMFlow 真实飞书验真V4:做一个会议室投屏故障晨报助手,想每天自动汇总会议室投屏失败并发给行政IT,先不创建项目,只收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-query-contract-shadow-1", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["topic_switch"]) == "new" assert str(direct_kwargs["message_text"]) == ( "@PMFlow 真实飞书验真V4:做一个会议室投屏故障晨报助手,想每天自动汇总会议室投屏失败并发给行政IT,先不创建项目,只收第一版。" ) @pytest.mark.asyncio async def test_execute_query_intent_treats_consult_requirement_structured_discovery_as_requirement_plan( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"consult requirement discovery should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "可以,我们先不创建,先把这件事收成一版需求。", "rag_result": { "answer": "可以,我们先不创建,先把这件事收成一版需求。", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-requirement-discovery", "source_sender_open_id": "ou-requirement-discovery", "message_intent": "consult_requirement", "consultant_mode_preference": "structured_discovery", "topic_switch": "new", }, ) result = await executor._execute_query_intent( intent=intent, message_text="跟你聊个需求,关于每日自动部署工单数据功能发送给业务。", operator_user_id=999, source_system="feishu", source_message_id="msg-requirement-discovery-1", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "跟你聊个需求,关于每日自动部署工单数据功能发送给业务。" assert str(direct_kwargs["source_chat_id"]) == "chat-requirement-discovery" assert "Feature Catalog" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_execute_query_intent_treats_requirement_gathering_stepwise_convergent_as_requirement_plan( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"requirement gathering should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "可以,我们先不创建,先把这件事收成一版需求。", "rag_result": { "answer": "可以,我们先不创建,先把这件事收成一版需求。", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-requirement-gathering", "source_sender_open_id": "ou-requirement-gathering", "message_intent": "requirement_gathering", "consultant_mode_preference": "stepwise_convergent", "topic_switch": "new", }, ) result = await executor._execute_query_intent( intent=intent, message_text="跟你聊个需求,关于每日自动部署工单数据功能发送给业务,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-requirement-gathering-1", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "跟你聊个需求,关于每日自动部署工单数据功能发送给业务,先别创建,先收第一版。" assert str(direct_kwargs["source_chat_id"]) == "chat-requirement-gathering" assert "Feature Catalog" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_execute_query_intent_treats_consult_requirement_stepwise_plan_first_as_requirement_plan( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"stepwise plan-first should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "可以,我们先不创建,先把这件事收成一版需求。", "rag_result": { "answer": "可以,我们先不创建,先把这件事收成一版需求。", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-consult-stepwise-plan-first", "source_sender_open_id": "ou-consult-stepwise-plan-first", "message_intent": "consult_requirement", "consultant_mode_preference": "stepwise_plan_first", "topic_switch": "new", }, ) result = await executor._execute_query_intent( intent=intent, message_text="跟你聊个需求,关于每日自动部署工单数据功能发送给业务,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-consult-stepwise-plan-first-1", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "跟你聊个需求,关于每日自动部署工单数据功能发送给业务,先别创建,先收第一版。" assert str(direct_kwargs["source_chat_id"]) == "chat-consult-stepwise-plan-first" assert "Feature Catalog" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_execute_query_intent_treats_consult_requirement_lightweight_plan_first_as_requirement_plan( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"lightweight plan-first should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "可以,我们先不创建,先把这件事收成一版需求。", "rag_result": { "answer": "可以,我们先不创建,先把这件事收成一版需求。", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-consult-lightweight-plan-first", "source_sender_open_id": "ou-consult-lightweight-plan-first", "message_intent": "consult_requirement", "consultant_mode_preference": "lightweight_plan_first", "topic_switch": "new", }, ) result = await executor._execute_query_intent( intent=intent, message_text="再聊个新需求,关于每日自动汇总门店库存异常发给区域经理,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-consult-lightweight-plan-first-1", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "再聊个新需求,关于每日自动汇总门店库存异常发给区域经理,先别创建,先收第一版。" assert str(direct_kwargs["source_chat_id"]) == "chat-consult-lightweight-plan-first" assert "Feature Catalog" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_execute_query_intent_treats_consult_requirement_structured_analysis_first_as_requirement_plan( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"structured analysis-first should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "可以,我们先不创建,先把这件事收成一版需求。", "rag_result": { "answer": "可以,我们先不创建,先把这件事收成一版需求。", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-consult-structured-analysis-first", "source_sender_open_id": "ou-consult-structured-analysis-first", "message_intent": "consult_requirement", "consultant_mode_preference": "structured_analysis_first", "topic_switch": "new", }, ) result = await executor._execute_query_intent( intent=intent, message_text="跟你聊个需求,关于每周自动汇总销售线索跟进状态发给销售经理,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-consult-structured-analysis-first-1", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "跟你聊个需求,关于每周自动汇总销售线索跟进状态发给销售经理,先别创建,先收第一版。" assert str(direct_kwargs["source_chat_id"]) == "chat-consult-structured-analysis-first" assert "Feature Catalog" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_execute_query_intent_promotes_requirement_followup_from_structured_round_context_without_render_markers( monkeypatch, requirement_plan_db_session, ) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"structured requirement followup should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001 _ = self captured["direct_kwargs"] = dict(kwargs) return { "status": "succeeded", "answer": "我先按主播协同作战平台这条需求继续收范围。", "rag_result": { "answer": "我先按主播协同作战平台这条需求继续收范围。", "query_type": "plan", }, "reply_template_key": "requirement_plan_followup", } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _fake_build_requirement_planning_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) chat_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-choice-structured:root:om-plan-choice-structured-root", source_system="feishu", channel_ref="chat-plan-choice-structured", conversation_scope_key=build_feishu_conversation_scope_key( source_chat_id="chat-plan-choice-structured", source_sender_open_id="ou-plan-choice-structured", sender=None, ), root_message_id="om-plan-choice-structured-root", mode="chat", status="active", plan_status="idle", metadata_json={"last_message_preview": "C"}, ) requirement_plan_db_session.add(chat_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add_all( [ MountedConversationRun( tenant_id=3, trace_id="trace-plan-choice-structured-1", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-plan-choice-structured-1", status="completed", final_reply="我建议先往主播协同作战平台这个方向继续收。", execution_outcome={ "source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。", "rag_result": { "query_type": "plan", "sources": [ { "source": "system", "label": "Mounted Runtime Plan", "score": 0.92, "metadata": { "mode": "mounted_runtime_direct_query", "reason_code": "requirement_planning_direct", }, } ], }, "requirement_plan_context": { "round_option_maps": { "project_nature": { "A": "主播培训系统", "B": "主播数字分身工厂", "C": "主播协同作战平台", } }, "last_round_options": { "field": "project_nature", "options": [ {"key": "A", "title": "主播培训系统"}, {"key": "B", "title": "主播数字分身工厂"}, {"key": "C", "title": "主播协同作战平台"}, ], "recommendation": {"key": "C"}, }, }, }, ), MountedConversationRun( tenant_id=3, trace_id="trace-plan-choice-structured-2", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-plan-choice-structured-2", status="completed", final_reply="收到,我先继续。", execution_outcome={"source_message_preview": "C"}, ), ] ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-choice-structured", "source_sender_open_id": "ou-plan-choice-structured", "message_intent": "continue_contextual_qa", "thread_context": { "root_message_id": "om-plan-choice-structured-root", "reply_to_message_id": "om-plan-choice-structured-root", }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="三个问题都有", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-choice-structured-3", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "plan" direct_kwargs = captured.get("direct_kwargs") assert isinstance(direct_kwargs, dict) assert str(direct_kwargs["message_text"]) == "三个问题都有" assert str(direct_kwargs["source_chat_id"]) == "chat-plan-choice-structured" assert direct_kwargs["thread_context"] == { "root_message_id": "om-plan-choice-structured-root", "reply_to_message_id": "om-plan-choice-structured-root", } @pytest.mark.asyncio @pytest.mark.parametrize("choice_message", ["C", "我选C", "选C", "就C吧", "先按C"]) async def test_execute_query_intent_reply_choice_uses_previous_round_option_map_instead_of_it_defaults( monkeypatch, requirement_plan_db_session, choice_message: str, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"requirement plan choice reply should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_plan_next_question( self, *, missing_field: str, profile: str, message_text: str = "", card: dict[str, object] | None = None, latest_updates: dict[str, object] | None = None, ) -> str: _ = (self, profile, message_text, card, latest_updates) assert missing_field == "tech_route" return "下一轮先说清楚:第一版更希望优先补开播前脚本、直播中应变,还是下播后复盘?" monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_plan_next_question", _fake_build_requirement_plan_next_question, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) chat_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-choice-map:root:om-choice-map-root", source_system="feishu", channel_ref="chat-choice-map", conversation_scope_key=build_feishu_conversation_scope_key( source_chat_id="chat-choice-map", source_sender_open_id="ou-choice-map", sender=None, ), root_message_id="om-choice-map-root", mode="chat", status="active", plan_status="idle", metadata_json={"last_message_preview": "A/B/C 需求轮次"}, ) requirement_plan_db_session.add(chat_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedConversationRun( tenant_id=3, trace_id="trace-choice-map-1", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-choice-map-1", status="completed", final_reply=( "你提到的是给运营公司打造主播这个新需求。\n" "第一轮收敛:这是什么性质的项目?\n\n" "A. 主播培训系统\n" "B. 主播数字分身工厂\n" "C. 主播协同作战平台\n\n" "⭐ 当前我更推荐:C\n" "你回 A / B / C,我就顺着这条继续往下收。" ), execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"}, ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-choice-map", "source_sender_open_id": "ou-choice-map", "message_intent": "continue_contextual_qa", "thread_context": { "root_message_id": "om-choice-map-root", "reply_to_message_id": "om-choice-map-root", }, }, ) result = await executor._execute_query_intent( intent=intent, message_text=choice_message, operator_user_id=999, source_system="feishu", source_message_id="msg-choice-map-2", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert "主播协同作战平台" in answer assert "项目交付团队" not in answer assert "云原生交付底座" not in answer draft_row = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).order_by(MountedDraftWorkspace.id.desc()) ) ).scalar_one() assert isinstance(draft_row.requirement_card, dict) assert draft_row.requirement_card["project_nature"] == "主播协同作战平台" assert draft_row.requirement_card["target_user"] == "运营公司" @pytest.mark.asyncio async def test_execute_query_intent_reply_choice_confirmation_phrase_uses_previous_round_option_map( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"requirement plan choice reply should not enter heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_plan_next_question( self, *, missing_field: str, profile: str, message_text: str = "", card: dict[str, object] | None = None, latest_updates: dict[str, object] | None = None, ) -> str: _ = (self, profile, message_text, card, latest_updates) assert missing_field == "tech_route" return "下一轮先说清楚:第一版更希望优先补开播前脚本、直播中应变,还是下播后复盘?" monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_plan_next_question", _fake_build_requirement_plan_next_question, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) chat_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-choice-confirm-map:root:om-choice-confirm-map-root", source_system="feishu", channel_ref="chat-choice-confirm-map", conversation_scope_key=build_feishu_conversation_scope_key( source_chat_id="chat-choice-confirm-map", source_sender_open_id="ou-choice-confirm-map", sender=None, ), root_message_id="om-choice-confirm-map-root", mode="chat", status="active", plan_status="idle", metadata_json={"last_message_preview": "A/B/C 需求轮次"}, ) requirement_plan_db_session.add(chat_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedConversationRun( tenant_id=3, trace_id="trace-choice-confirm-map-1", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-choice-confirm-map-1", status="completed", final_reply=( "你提到的是给运营公司打造主播这个新需求。\n" "第一轮收敛:这是什么性质的项目?\n\n" "A. 主播培训系统\n" "B. 主播数字分身工厂\n" "C. 主播协同作战平台\n\n" "⭐ 当前我更推荐:C\n" "你回 A / B / C,我就顺着这条继续往下收。" ), execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"}, ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-choice-confirm-map", "source_sender_open_id": "ou-choice-confirm-map", "message_intent": "continue_contextual_qa", "thread_context": { "root_message_id": "om-choice-confirm-map-root", "reply_to_message_id": "om-choice-confirm-map-root", }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="确认A,继续往下收。", operator_user_id=999, source_system="feishu", source_message_id="msg-choice-confirm-map-2", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert "主播培训系统" in answer assert "主播协同作战平台" not in answer draft_row = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).order_by(MountedDraftWorkspace.id.desc()) ) ).scalar_one() assert isinstance(draft_row.requirement_card, dict) assert draft_row.requirement_card["project_nature"] == "主播培训系统" assert draft_row.requirement_card["target_user"] == "运营公司" @pytest.mark.asyncio async def test_execute_query_intent_low_information_choice_without_round_option_map_fails_explicitly( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"low-information choice reply should not fall back to heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-choice-missing-map", source_sender_open_id="ou-choice-missing-map", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-choice-missing-map:root:om-choice-missing-map-root", source_system="feishu", channel_ref="chat-choice-missing-map", conversation_scope_key=conversation_scope_key, root_message_id="om-choice-missing-map-root", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(topic) await requirement_plan_db_session.flush() draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "给运营公司打造主播", "goal": "先收敛业务方向", "target_user": "运营公司", }, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(draft) await requirement_plan_db_session.flush() executor = FakeExecutor() result = await executor._build_requirement_planning_direct_result( message_text="我选C", source_system="feishu", source_message_id="msg-choice-missing-map-1", source_chat_id="chat-choice-missing-map", sender=None, source_sender_open_id="ou-choice-missing-map", thread_context={ "root_message_id": "om-choice-missing-map-root", "reply_to_message_id": "om-choice-missing-map-root", }, conversation_key=conversation_scope_key, topic_switch=None, ) assert result["status"] == "succeeded" assert "我没拿到上一轮 A/B/C 选项表" in str(result["answer"] or "") assert "云原生交付底座" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_build_requirement_planning_direct_result_emits_structured_requirement_plan_context( monkeypatch, 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 = 3 async def _fake_build_requirement_plan_next_question( self, *, missing_field: str, profile: str, message_text: str = "", card: dict[str, object] | None = None, latest_updates: dict[str, object] | None = None, ) -> str: _ = (self, profile, message_text, latest_updates) assert missing_field == "target_user" assert isinstance(card, dict) card["round_option_maps"] = { **(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}), "target_user": { "A": "单一层级区域经理", "B": "区域经理 + 店长", "C": "总部 + 区域 + 门店多层级", }, } card["last_round_options"] = { "field": "target_user", "options": [ {"key": "A", "title": "单一层级区域经理"}, {"key": "B", "title": "区域经理 + 店长"}, {"key": "C", "title": "总部 + 区域 + 门店多层级"}, ], "recommendation": {"key": "A"}, } return "第三轮:服务对象\n\nA. 单一层级区域经理\nB. 区域经理 + 店长\nC. 总部 + 区域 + 门店多层级" monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_plan_next_question", _fake_build_requirement_plan_next_question, raising=False, ) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-context-snapshot", source_sender_open_id="ou-plan-context-snapshot", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-context-snapshot:root:om-plan-context-snapshot-root", source_system="feishu", channel_ref="chat-plan-context-snapshot", conversation_scope_key=conversation_scope_key, root_message_id="om-plan-context-snapshot-root", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(topic) await requirement_plan_db_session.flush() draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "门店库存异常日报助手", "goal": "先把日报助手的第一版需求收敛清楚。", "project_nature": "轻量定时简报", "question_round": 1, "round_option_maps": { "project_nature": { "A": "轻量定时简报", "B": "可交互看板简报", "C": "AI 增强简报", } }, "last_round_options": { "field": "project_nature", "options": [ {"key": "A", "title": "轻量定时简报"}, {"key": "B", "title": "可交互看板简报"}, {"key": "C", "title": "AI 增强简报"}, ], "recommendation": {"key": "A"}, }, }, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(draft) await requirement_plan_db_session.flush() executor = FakeExecutor() result = await executor._build_requirement_planning_direct_result( message_text="确认A,继续往下收。", source_system="feishu", source_message_id="msg-plan-context-snapshot-2", source_chat_id="chat-plan-context-snapshot", sender=None, source_sender_open_id="ou-plan-context-snapshot", thread_context={ "root_message_id": "om-plan-context-snapshot-root", "reply_to_message_id": "om-plan-context-snapshot-root", }, conversation_key=conversation_scope_key, topic_switch=None, ) assert result["status"] == "succeeded" requirement_plan_context = result.get("requirement_plan_context") assert isinstance(requirement_plan_context, dict) assert requirement_plan_context["question_round"] == 2 assert requirement_plan_context["last_round_options"]["field"] == "target_user" assert requirement_plan_context["round_option_maps"]["target_user"]["A"] == "单一层级区域经理" assert "第三轮:服务对象" in str(result["answer"] or "") @pytest.mark.asyncio async def test_repair_requirement_plan_workspace_from_reply_context_prefers_structured_reply_snapshot( 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 = 3 conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-context-repair", source_sender_open_id="ou-plan-context-repair", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-context-repair:message:om-plan-context-repair-root", source_system="feishu", channel_ref="chat-plan-context-repair", conversation_scope_key=conversation_scope_key, root_message_id="om-plan-context-repair-root", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(topic) await requirement_plan_db_session.flush() draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "需求收敛草稿", "goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", "target_user": "轻量工具型", "question_round": 1, "round_option_maps": { "project_nature": { "A": "轻量工具型", "B": "业务平台型", "C": "能力底座型", } }, "last_round_options": { "field": "project_nature", "options": [ {"key": "A", "title": "轻量工具型"}, {"key": "B", "title": "业务平台型"}, {"key": "C", "title": "能力底座型"}, ], "recommendation": {"key": "A"}, }, }, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(draft) requirement_plan_db_session.add_all( [ MountedConversationRun( tenant_id=3, trace_id="trace-plan-context-repair-0", topic_workspace_id=int(topic.id), source_system="feishu", source_message_id="msg-plan-context-repair-0", status="completed", final_reply=( "你希望每日自动汇总门店库存异常并发给区域经理,当前确认走「轻量定时简报」路线。\n\n" "第二轮:服务对象\n" "A. 单一区域经理\n" "B. 区域经理 + 店长\n" "C. 总部 + 区域 + 门店多层级" ), execution_outcome={ "source_message_preview": "做一个门店库存异常日报助手,想每日自动汇总门店库存异常并发给区域经理,先不创建项目,只收第一版。", "rag_result": { "query_type": "plan", "sources": [ { "source": "system", "metadata": {"reason_code": "requirement_planning_direct"}, } ], }, }, ), MountedConversationRun( tenant_id=3, trace_id="trace-plan-context-repair-1", topic_workspace_id=int(topic.id), source_system="feishu", source_message_id="msg-plan-context-repair-1", status="completed", final_reply="收到,确认A方向,继续收敛项目性质。", execution_outcome={ "source_message_preview": "确认A,继续往下收。", "rag_result": { "query_type": "plan", "sources": [ { "source": "system", "metadata": {"reason_code": "requirement_planning_direct"}, } ], }, "requirement_plan_context": { "plan_profile": "it_project", "title": "门店库存异常日报助手", "goal": "先把门店库存异常日报助手的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", "project_nature": "轻量定时简报", "target_user": "单一区域经理", "question_round": 2, "round_option_maps": { "target_user": { "A": "单一区域经理", "B": "区域经理 + 店长", "C": "总部 + 区域 + 门店多层级", } }, "last_round_options": { "field": "target_user", "options": [ {"key": "A", "title": "单一区域经理"}, {"key": "B", "title": "区域经理 + 店长"}, {"key": "C", "title": "总部 + 区域 + 门店多层级"}, ], "recommendation": {"key": "A"}, }, }, "semantic_decision": { "assistant_reply": ( "已确认选项 A(单一层级区域经理),继续收第二轮关键信息 👇\n\n" "→ 第三轮:目标用户层级\n" "A. 单一区域经理\n" "B. 区域经理 + 店长\n" "C. 总部 + 区域 + 门店多层级" ), }, "semantic_gate_result": {"accepted": True}, }, ), ] ) await requirement_plan_db_session.flush() executor = FakeExecutor() repaired_topic, repaired_draft = await executor._repair_requirement_plan_workspace_from_reply_context( topic=topic, draft=draft, source_system="feishu", source_message_id="msg-plan-context-repair-2", source_chat_id="chat-plan-context-repair", source_sender_open_id="ou-plan-context-repair", thread_context={ "root_message_id": "om-plan-context-repair-root", "reply_to_message_id": "om-plan-context-repair-root", }, conversation_key=conversation_scope_key, message_text="确认A,继续往下收。", ) assert repaired_topic is topic assert repaired_draft is draft assert draft.requirement_card["project_nature"] == "轻量定时简报" assert draft.requirement_card["target_user"] == "单一区域经理" assert draft.requirement_card["last_round_options"]["field"] == "target_user" assert draft.requirement_card["round_option_maps"]["target_user"]["A"] == "单一区域经理" @pytest.mark.asyncio async def test_build_requirement_planning_direct_result_forces_new_topic_when_same_scope_identity_conflicts( monkeypatch, 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 = 3 async def _fake_build_requirement_plan_next_question( self, *, missing_field: str, profile: str, message_text: str = "", card: dict[str, object] | None = None, latest_updates: dict[str, object] | None = None, ) -> str: _ = (self, profile, message_text, latest_updates) assert isinstance(card, dict) card["round_option_maps"] = { **(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}), str(missing_field): { "A": "轻量晨报工具", "B": "业务平台入口", "C": "能力底座组件", }, } card["last_round_options"] = { "field": str(missing_field), "options": [ {"key": "A", "title": "轻量晨报工具"}, {"key": "B", "title": "业务平台入口"}, {"key": "C", "title": "能力底座组件"}, ], "recommendation": {"key": "A"}, } return "第一轮:项目性质\n\nA. 轻量晨报工具\nB. 业务平台入口\nC. 能力底座组件" monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_plan_next_question", _fake_build_requirement_plan_next_question, raising=False, ) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-same-scope-conflict", source_sender_open_id="ou-plan-same-scope-conflict", sender=None, ) old_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-same-scope-conflict:message:om-old-plan-root", source_system="feishu", channel_ref="chat-plan-same-scope-conflict", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="om-old-plan-root", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(old_topic) await requirement_plan_db_session.flush() old_draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(old_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "直播复盘辅助工具", "goal": "先把直播复盘辅助工具的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", "project_nature": "轻量工具", "target_user": "运营团队", "question_round": 3, }, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(old_draft) current_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-same-scope-conflict:message:msg-plan-same-scope-conflict-root", source_system="feishu", channel_ref="chat-plan-same-scope-conflict", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="msg-plan-same-scope-conflict-root", mode="chat", status="active", plan_status="idle", metadata_json={}, ) requirement_plan_db_session.add(current_topic) await requirement_plan_db_session.flush() executor = FakeExecutor() result = await executor._build_requirement_planning_direct_result( message_text="正式验真T9A-0410:做一个给区域经理用的门店库存异常晨报助手,只收第一版需求,不要直接创建项目。第一版先看负库存和超阈值缺货,每天自动汇总并发给区域经理。", source_system="feishu", source_message_id="msg-plan-same-scope-conflict-root", source_chat_id="chat-plan-same-scope-conflict", sender=None, source_sender_open_id="ou-plan-same-scope-conflict", thread_context=None, conversation_key=conversation_scope_key, topic_switch=None, ) await requirement_plan_db_session.flush() current_draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where( MountedDraftWorkspace.topic_workspace_id == int(current_topic.id) ) ) ).scalar_one_or_none() assert result["status"] == "succeeded" requirement_plan_context = result.get("requirement_plan_context") assert isinstance(requirement_plan_context, dict) assert requirement_plan_context["title"] == "给区域经理用的门店库存异常晨报助手" assert requirement_plan_context["target_user"] == "区域经理" assert current_draft is not None assert current_draft.requirement_card["title"] == "给区域经理用的门店库存异常晨报助手" assert old_draft.requirement_card["title"] == "直播复盘辅助工具" assert int(current_draft.topic_workspace_id) == int(current_topic.id) @pytest.mark.asyncio async def test_build_requirement_planning_direct_result_only_persists_topic_index_facts( monkeypatch, 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 = 3 async def _fake_build_requirement_plan_next_question( self, *, missing_field: str, profile: str, message_text: str = "", card: dict[str, object] | None = None, latest_updates: dict[str, object] | None = None, ) -> str: _ = (self, profile, message_text, latest_updates) assert isinstance(card, dict) card["round_option_maps"] = { **(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}), str(missing_field): { "A": "轻量晨报工具", "B": "业务平台入口", "C": "能力底座组件", }, } card["last_round_options"] = { "field": str(missing_field), "options": [ {"key": "A", "title": "轻量晨报工具"}, {"key": "B", "title": "业务平台入口"}, {"key": "C", "title": "能力底座组件"}, ], "recommendation": {"key": "A"}, } return "第一轮:项目性质\n\nA. 轻量晨报工具\nB. 业务平台入口\nC. 能力底座组件" monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_plan_next_question", _fake_build_requirement_plan_next_question, raising=False, ) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-topic-index-facts", source_sender_open_id="ou-plan-topic-index-facts", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-topic-index-facts:message:msg-plan-topic-index-facts-root", source_system="feishu", channel_ref="chat-plan-topic-index-facts", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="msg-plan-topic-index-facts-root", mode="chat", status="active", plan_status="idle", metadata_json={}, ) requirement_plan_db_session.add(topic) await requirement_plan_db_session.flush() executor = FakeExecutor() result = await executor._build_requirement_planning_direct_result( message_text="做一个给采购经理用的跨仓调拨异常晚报助手,只收第一版需求,不要直接创建项目。", source_system="feishu", source_message_id="msg-plan-topic-index-facts-root", source_chat_id="chat-plan-topic-index-facts", sender=None, source_sender_open_id="ou-plan-topic-index-facts", thread_context=None, conversation_key=conversation_scope_key, topic_switch=None, ) await requirement_plan_db_session.flush() saved_topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(topic.id)) ) ).scalar_one() assert result["status"] == "succeeded" assert saved_topic.facts_json == { "title": "给采购经理用的跨仓调拨异常晚报助手", "plan_profile": "it_project", } @pytest.mark.asyncio async def test_build_requirement_planning_direct_result_prefers_reply_anchor_topic_over_same_scope_sibling( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"reply-anchor requirement followup should bypass heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_build_requirement_plan_next_question( self, *, missing_field: str, profile: str, message_text: str = "", card: dict[str, object] | None = None, latest_updates: dict[str, object] | None = None, ) -> str: _ = (self, profile, message_text, latest_updates) assert missing_field == "tech_route" assert isinstance(card, dict) assert str(card.get("title") or "") == "门店库存异常晨报助手" assert str(card.get("project_nature") or "") == "轻量晨报工具" card["round_option_maps"] = { **(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}), "tech_route": { "A": "飞书消息 + SQL 晨报", "B": "轻量看板 + 异常下钻", "C": "规则引擎 + 多源归因平台", }, } card["last_round_options"] = { "field": "tech_route", "options": [ {"key": "A", "title": "飞书消息 + SQL 晨报"}, {"key": "B", "title": "轻量看板 + 异常下钻"}, {"key": "C", "title": "规则引擎 + 多源归因平台"}, ], "recommendation": {"key": "A"}, } return "第三轮:技术路线\n\nA. 飞书消息 + SQL 晨报\nB. 轻量看板 + 异常下钻\nC. 规则引擎 + 多源归因平台" monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_plan_next_question", _fake_build_requirement_plan_next_question, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-anchor-priority", source_sender_open_id="ou-plan-anchor-priority", sender=None, ) target_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-anchor-priority:message:om-plan-anchor-target-root", source_system="feishu", channel_ref="chat-plan-anchor-priority", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="om-plan-anchor-target-root", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(target_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(target_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "门店库存异常晨报助手", "goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。", "target_user": "区域经理", "question_round": 1, "round_option_maps": { "project_nature": { "A": "轻量晨报工具", "B": "业务平台入口", "C": "能力底座组件", } }, "last_round_options": { "field": "project_nature", "options": [ {"key": "A", "title": "轻量晨报工具"}, {"key": "B", "title": "业务平台入口"}, {"key": "C", "title": "能力底座组件"}, ], "recommendation": {"key": "A"}, }, }, draft_payload=None, required_confirmations=[], ) ) sibling_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-anchor-priority:message:om-plan-anchor-sibling-root", source_system="feishu", channel_ref="chat-plan-anchor-priority", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="om-plan-anchor-sibling-root", mode="plan", status="active", plan_status="options_ready", metadata_json={"plan_mode": True}, facts_json={"title": "直播复盘辅助工具"}, ) requirement_plan_db_session.add(sibling_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(sibling_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "直播复盘辅助工具", "goal": "先把直播复盘辅助工具的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", "project_nature": "轻量工具", "target_user": "运营团队", "tech_route": "端侧轻量AI处理", "question_round": 3, }, draft_payload=None, required_confirmations=[], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() result = await executor._build_requirement_planning_direct_result( message_text="A,先按轻量晨报工具继续往下收。", source_system="feishu", source_message_id="msg-plan-anchor-followup", source_chat_id="chat-plan-anchor-priority", sender=None, source_sender_open_id="ou-plan-anchor-priority", thread_context={"reply_to_message_id": "om-plan-anchor-target-root"}, conversation_key=conversation_scope_key, topic_switch=None, ) assert result["status"] == "succeeded" requirement_plan_context = result.get("requirement_plan_context") assert isinstance(requirement_plan_context, dict) assert requirement_plan_context["title"] == "门店库存异常晨报助手" assert requirement_plan_context["project_nature"] == "轻量晨报工具" assert requirement_plan_context["last_round_options"]["field"] == "tech_route" assert "第三轮:技术路线" in str(result["answer"] or "") assert "直播复盘辅助工具" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_repair_requirement_plan_workspace_from_reply_context_blocks_sibling_topic_injection( monkeypatch, 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 = 3 conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-cross-topic-repair", source_sender_open_id="ou-plan-cross-topic-repair", sender=None, ) target_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-cross-topic-repair:message:om-plan-cross-topic-target-root", source_system="feishu", channel_ref="chat-plan-cross-topic-repair", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="om-plan-cross-topic-target-root", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(target_topic) await requirement_plan_db_session.flush() target_draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(target_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "门店库存异常晨报助手", "goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。", "project_nature": "轻量晨报工具", "target_user": "区域经理", "question_round": 2, }, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(target_draft) sibling_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-cross-topic-repair:message:om-plan-cross-topic-sibling-root", source_system="feishu", channel_ref="chat-plan-cross-topic-repair", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="om-plan-cross-topic-sibling-root", mode="plan", status="active", plan_status="options_ready", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(sibling_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add_all( [ MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(sibling_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "直播复盘辅助工具", "goal": "先把直播复盘辅助工具的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", "project_nature": "轻量工具", "target_user": "运营团队", "tech_route": "端侧轻量AI处理", "question_round": 4, }, draft_payload=None, required_confirmations=[], ), MountedConversationRun( tenant_id=3, trace_id="trace-plan-cross-topic-sibling-1", topic_workspace_id=int(sibling_topic.id), source_system="feishu", source_message_id="msg-plan-cross-topic-sibling-1", status="completed", final_reply=( "好,我先按这个方向继续记:技术路线先按端侧轻量AI处理。\n\n" "收到,按端侧轻量AI+运营晨报场景收敛第一版范围。\n" "直播复盘辅助工具首版该包含哪些核心闭环?\n\n" "A. 聚焦单场复盘速报\n" "B. 加入观众行为轻洞察\n" "C. 打通回放片段跳转\n\n" "⭐ 当前我更推荐:A" ), execution_outcome={ "source_message_preview": "A,先按轻量晨报工具继续往下收。", "requirement_plan_context": { "plan_profile": "it_project", "title": "直播复盘辅助工具", "goal": "先把直播复盘辅助工具的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", "project_nature": "轻量工具", "target_user": "运营团队", "tech_route": "端侧轻量AI处理", "question_round": 4, }, "rag_result": { "query_type": "plan", "sources": [ { "source": "system", "metadata": {"reason_code": "requirement_planning_direct"}, } ], }, }, ), ] ) await requirement_plan_db_session.flush() executor = FakeExecutor() async def _fake_find_contextual_reply_topic(self, **kwargs): # noqa: ANN001 _ = (self, kwargs) return sibling_topic monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_find_contextual_reply_topic", _fake_find_contextual_reply_topic, raising=False, ) repaired_topic, repaired_draft = await executor._repair_requirement_plan_workspace_from_reply_context( topic=target_topic, draft=target_draft, source_system="feishu", source_message_id="msg-plan-cross-topic-target-followup", source_chat_id="chat-plan-cross-topic-repair", source_sender_open_id="ou-plan-cross-topic-repair", thread_context={"reply_to_message_id": "om-plan-cross-topic-target-root"}, conversation_key=conversation_scope_key, message_text="A,先按轻量晨报工具继续往下收。", ) assert repaired_topic is target_topic assert repaired_draft is target_draft assert target_draft.requirement_card["title"] == "门店库存异常晨报助手" assert target_draft.requirement_card["target_user"] == "区域经理" assert target_draft.requirement_card["project_nature"] == "轻量晨报工具" assert "tech_route" not in target_draft.requirement_card @pytest.mark.asyncio async def test_repair_requirement_plan_workspace_from_reply_context_prefers_contextual_draft_snapshot_over_turn_history( monkeypatch, 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 = 3 conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-draft-snapshot-repair", source_sender_open_id="ou-plan-draft-snapshot-repair", sender=None, ) current_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-draft-snapshot-repair:message:om-plan-current-root", source_system="feishu", channel_ref="chat-plan-draft-snapshot-repair", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="om-plan-current-root", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(current_topic) await requirement_plan_db_session.flush() current_draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(current_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "门店库存异常晨报助手", "goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。", "question_round": 1, }, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(current_draft) contextual_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-draft-snapshot-repair:message:om-plan-context-root", source_system="feishu", channel_ref="chat-plan-draft-snapshot-repair", conversation_scope_key=conversation_scope_key, root_message_id=None, last_message_id="om-plan-context-root", mode="plan", status="active", plan_status="options_ready", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(contextual_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(contextual_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "门店库存异常晨报助手", "goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。", "project_nature": "轻量晨报工具", "target_user": "区域经理", "question_round": 2, "round_option_maps": { "project_nature": { "A": "轻量晨报工具", "B": "业务平台入口", "C": "能力底座组件", } }, "last_round_options": { "field": "project_nature", "options": [ {"key": "A", "title": "轻量晨报工具"}, {"key": "B", "title": "业务平台入口"}, {"key": "C", "title": "能力底座组件"}, ], "recommendation": {"key": "A"}, }, }, draft_payload=None, required_confirmations=[], ) ) await requirement_plan_db_session.flush() async def _fake_find_contextual_reply_topic(self, **kwargs): # noqa: ANN001 _ = (self, kwargs) return contextual_topic monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_find_contextual_reply_topic", _fake_find_contextual_reply_topic, raising=False, ) executor = FakeExecutor() repaired_topic, repaired_draft = await executor._repair_requirement_plan_workspace_from_reply_context( topic=current_topic, draft=current_draft, source_system="feishu", source_message_id="msg-plan-draft-snapshot-repair-followup", source_chat_id="chat-plan-draft-snapshot-repair", source_sender_open_id="ou-plan-draft-snapshot-repair", thread_context=None, conversation_key=conversation_scope_key, message_text="A,继续往下收。", ) assert repaired_topic is current_topic assert repaired_draft is current_draft assert current_draft.requirement_card["project_nature"] == "轻量晨报工具" assert current_draft.requirement_card["target_user"] == "区域经理" assert current_draft.requirement_card["last_round_options"]["field"] == "project_nature" @pytest.mark.asyncio async def test_execute_query_intent_contextual_draft_generation_bootstraps_requirement_plan_from_reply_topic( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): raise AssertionError(f"contextual draft generation should bypass heavy RAG: {kwargs}") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 function_name = str(kwargs.get("function_name") or "") if function_name != "pmflow_requirement_plan_draft": raise AssertionError(f"unexpected function_name: {function_name}") return StructuredExtractionResult( payload={ "intro": "📋 我先按当前上下文整理出一版主播协同作战平台草稿:", "title": "给运营公司打造主播协同作战平台", "one_liner": "先围绕开播前脚本、直播中应变、下播后复盘三类卡点,做一版真人主播+AI陪跑的协同平台草稿。", "goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。", "project_nature": "主播协同作战平台", "target_user": "运营公司主播团队", "signal_inputs": "当前至少包含主播脚本准备、直播中应变、下播后复盘三类上下文信息。", "known_info": "目前已经确认:需求围绕给运营公司打造主播能力;用户选择了 C 方向;且明确三类问题都会遇到。", "tech_route": "先做真人主播+AI陪跑协同版,后续再补更重的实时分析能力。", "first_scope": "第一版先保留开播前脚本辅助、直播中应变提示、下播后复盘整理三个闭环。", "core_flow": "主播进入直播准备 -> 生成脚本与注意事项 -> 直播中接收应变提示 -> 下播后自动整理复盘 -> 沉淀可复用策略", "delivery_mode": "先在单个主播团队内做试点陪跑。", "success_signal": "先验证主播实际采用率、直播中应变响应率和复盘复用率。", "risks": "主要风险在主播现场响应节奏、脚本质量和复盘结构化沉淀的稳定性。", "pending_items": ["优先陪跑哪类主播", "直播平台接入方式", "复盘模板颗粒度"], "closing": "如果你认可,我下一轮就按这版继续细化执行方案。", "summary": "先把三类卡点做成一条可陪跑的主播协同闭环,再决定是否扩成更重的平台。", "positioning_one_liner": "面向运营公司主播团队的真人+AI陪跑协同平台首版。", "target_customers": "运营公司主播团队", "external_signal_sources": [ {"name": "抖音开放平台", "purpose": "补直播事件与互动节奏数据", "phase": "首版优先接入"}, {"name": "快手服务商 API", "purpose": "补直播中场控和互动数据", "phase": "首版优先接入"}, {"name": "企业微信会话归档", "purpose": "补主播复盘与督导回流", "phase": "第二阶段接入"}, ], "requirement_items": [ {"section": "需求目标", "title": "先补三类卡点", "description": "覆盖开播前脚本、直播中应变、下播后复盘。"}, ], "missing_info": ["优先主播类型"], "mvp_items": ["脚本辅助", "应变提示", "复盘整理"], "team_setup": [{"role": "建议配置", "notes": "产品/运营 1 名、后端/AI 1 名、前端 1 名即可试点。"}], "validation_metrics": {"product": ["采用率", "应变响应率", "复盘复用率"]}, }, source_field="content", provider="fake", model="fake", validation="ok", ) async def _fake_recommend_requirement_plan_owner_for_draft(self, *, card: dict[str, object]): # noqa: ANN001 _ = self, card return { "user_id": 28, "name": "赵陪跑", "role": "AI 产品经理", "workload_ratio": 32.0, "reason": "当前更接近直播陪跑和信号编排场景,适合先负责首版草稿推进。", "matched_skills": ["直播场景", "AI 编排"], } monkeypatch.setattr( mixin_execute_query_project.llm_service, "generate_structured_object", fake_generate_structured_object, ) 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) chat_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-anchor:root:om-anchor-reply", source_system="feishu", channel_ref="chat-anchor", conversation_scope_key=build_feishu_conversation_scope_key( source_chat_id="chat-anchor", source_sender_open_id="ou-anchor", sender=None, ), root_message_id="om-anchor-reply", mode="chat", status="active", plan_status="idle", metadata_json={"last_message_preview": "三个问题都有"}, ) requirement_plan_db_session.add(chat_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add_all( [ MountedConversationRun( tenant_id=3, trace_id="trace-anchor-1", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-anchor-1", status="completed", final_reply="你提到的是给运营公司打造主播这个新需求,我更推荐 C:主播协同作战平台。", execution_outcome={ "source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。", "rag_result": { "query_type": "plan", "sources": [{"source": "system", "metadata": {"reason_code": "requirement_planning_direct"}}], }, }, ), MountedConversationRun( tenant_id=3, trace_id="trace-anchor-2", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-anchor-2", status="completed", final_reply="咱们还没确认它到底要解决哪类业务卡点:是开播前脚本耗时、直播中突发问题不会接、还是下播后不知道优化啥?", execution_outcome={ "source_message_preview": "C", "rag_result": { "query_type": "plan", "sources": [{"source": "system", "metadata": {"reason_code": "requirement_planning_direct"}}], }, "requirement_plan_context": { "plan_profile": "it_project", "title": "给运营公司打造主播协同作战平台", "goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。", "project_nature": "主播协同作战平台", "question_round": 1, "round_option_maps": { "project_nature": { "A": "脚本提效助手", "B": "直播问题应答台", "C": "主播协同作战平台", } }, "last_round_options": { "field": "project_nature", "options": [ {"key": "A", "title": "脚本提效助手"}, {"key": "B", "title": "直播问题应答台"}, {"key": "C", "title": "主播协同作战平台"}, ], "recommendation": {"key": "C"}, }, "source_material": "用户:想跟你聊个新需求,关于给运营公司打造主播推流的需求。 | 用户:C", }, }, ), MountedConversationRun( tenant_id=3, trace_id="trace-anchor-3", topic_workspace_id=int(chat_topic.id), source_system="feishu", source_message_id="msg-anchor-3", status="completed", final_reply="收到,我会按当前上下文继续。", execution_outcome={ "source_message_preview": "三个问题都有", "rag_result": { "query_type": "plan", "sources": [{"source": "system", "metadata": {"reason_code": "contextual_requirement_plan_draft"}}], }, "requirement_plan_context": { "plan_profile": "it_project", "title": "给运营公司打造主播协同作战平台", "goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。", "project_nature": "主播协同作战平台", "first_scope": "开播前脚本、直播中应变、下播后复盘", "question_round": 2, "source_material": ( "用户:想跟你聊个新需求,关于给运营公司打造主播推流的需求。" " | 用户:C | 用户:三个问题都有" ), }, }, ), ] ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-anchor", "source_sender_open_id": "ou-anchor", "message_intent": "request_draft_generation_from_context", "consultant_mode_preference": "plan_first", "thread_context": { "root_message_id": "om-anchor-reply", "reply_to_message_id": "om-anchor-reply", }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="依据当前信息给我直接生成一版本草稿", operator_user_id=999, source_system="feishu", source_message_id="msg-contextual-draft-1", ) assert result["status"] == "succeeded" assert str(result["reply_template_key"]) == "draft_pending_confirm" assert str(result["rag_result"]["query_type"]) == "draft" assert "主播协同作战平台" in str(result["answer"] or "") assert "三个闭环" in str(result["answer"] or "") assert isinstance(result.get("draft_artifact"), dict) assert bool(result["draft_artifact"].get("promote_to_confirmation")) is True @pytest.mark.asyncio async def test_execute_query_intent_contextual_draft_generation_fails_explicitly_when_reply_context_missing( 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 return { "answer": "结论:草稿已生成,实际上这是普通 query.answer 的假成功。", "query_type": "explain", } class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-missing-anchor", "source_sender_open_id": "ou-missing-anchor", "message_intent": "request_draft_generation_from_context", "consultant_mode_preference": "plan_first", "thread_context": { "root_message_id": "om-missing-anchor", "reply_to_message_id": "om-missing-anchor", }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="依据当前信息给我直接生成一版本草稿", operator_user_id=999, source_system="feishu", source_message_id="msg-contextual-draft-missing", ) assert result["status"] == "failed_safe" assert "当前上下文" in str(result["error_message"] or "") assert "草稿已生成" not in str(result.get("answer") or "") @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_internal_solutioning_leaks_for_non_tech_round( 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 len(calls) == 1: return StructuredExtractionResult( payload={ "opening": "🧭 我先按“销售线索跟进状态周报”这类 IT 项目来收。", "focus": "现在最关键的是先把第一版项目性质定住。", "options": [ { "key": "A", "title": "轻量定时报表", "best_for": "先把每周汇总稳定发出去", "suggested_capability": "Feishu Bot + 定时 SQL 查询", "rationale": "直接复用现有发送链路,改动最小", "tradeoff": "交互性弱", }, { "key": "B", "title": "可交互看板模块", "best_for": "后续还要继续追问和筛选", "suggested_capability": "Feishu 多维表格 API /projects 页面嵌入", "rationale": "能复用现有页面和接口,扩展快", "tradeoff": "接入成本更高", }, { "key": "C", "title": "AI 增强调度器", "best_for": "后续要做异常识别和自动建议", "suggested_capability": "adaptive_rag_service.py + feishu_integration.py", "rationale": "已有 RAG 和飞书集成模块,可快速组合", "tradeoff": "实现会更重", }, ], "recommendation": {"key": "B", "reason": "当前 /projects 页面已就绪,适合先复用已有能力。"}, "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": "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-solutioning-leak", "source_sender_open_id": "ou-plan-repair-solutioning-leak", "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-solutioning-leak-root", ) answer = str(result["answer"] or "") assert result["status"] == "succeeded" assert len(calls) == 2 assert "轻量定时简报" in answer assert "可追问的数据看板" in answer assert "异常预警助手" in answer assert "Feishu Bot" not in answer assert "SQL" not in answer assert "API" not in answer assert ".py" not in answer assert "/projects" not in answer assert "RAG" 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, 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("it project 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() root_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-it", "source_sender_open_id": "ou-plan-it", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-it", "source_sender_open_id": "ou-plan-it", "thread_context": { "root_message_id": "msg-plan-it-root", "reply_to_message_id": "msg-plan-it-root", }, }, ) first = await executor._execute_query_intent( intent=root_intent, message_text="想跟你聊个 IT 项目需求,做一个批量自动化部署 OpenClaw 的平台,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-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-it-2", ) third = await executor._execute_query_intent( intent=followup_intent, message_text="B,主要给平台运维团队用。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-3", ) fourth = await executor._execute_query_intent( intent=followup_intent, message_text="B,走任务编排平台版,用 FastAPI + React + Redis + Celery + Postgres。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-4", ) fifth = await executor._execute_query_intent( intent=followup_intent, message_text="A,第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-5", ) final_result = fifth 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 "") assert "你回 A / B / C" in str(first["answer"] or "") assert "🧭 我先按“发布治理平台”这个方向继续收。" in str(second["answer"] or "") assert "平台运维团队" in str(second["answer"] or "") assert "⭐ 当前我更推荐:B" in str(second["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 "") assert "🧭 我先按“任务编排平台版”继续往下收。" in str(fourth["answer"] or "") assert "批量执行闭环" in str(fourth["answer"] or "") assert "⭐ 当前我更推荐:A" in str(fourth["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 "⚙️【技术路线】当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" 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 assert "❓【待确认】还需要你拍板:" in final_answer assert "部署目标范围" in final_answer assert "试点负责人" in final_answer assert str(final_result["rag_result"]["query_type"]) == "draft" draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() card = draft.requirement_card or {} assert card["plan_profile"] == "it_project" assert card["project_nature"] == "发布治理平台" assert card["target_user"] == "平台运维团队" assert "FastAPI" in str(card["tech_route"] or "") assert "批量执行闭环" in str(card["first_scope"] or "") assert "项目级试点" in str(card["delivery_mode"] or "") @pytest.mark.asyncio async def test_execute_query_intent_single_choice_does_not_resume_awaiting_requirement_draft( 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 return { "answer": "当前没有进行中的 A/B/C 收敛轮次。你先发一句新需求,我按新的 topic 接着收。", "query_type": "status", "confidence": 0.42, "missing_fields": [], } class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_query_bundle(monkeypatch, message_intent="query", consultant_mode_preference="") monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-old-draft", source_sender_open_id="ou-old-draft", sender=None, ) old_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-old-draft:message:sim-plan-1431-1", source_system="feishu", channel_ref="chat-old-draft", conversation_scope_key=conversation_scope_key, root_message_id="sim-plan-1431-1", mode="plan", status="active", plan_status="awaiting_execute_confirm", pending_actions=["confirm_requirement_draft"], metadata_json={ "plan_mode": True, "plan_question_round": 17, "plan_last_prompt": "下一个关键问题:你更想怎么接收这个结果,是飞书日报、页面看板,还是只推送一个分数加一句解释?", }, facts_json={ "title": "每日 A 股趋势打分工具", "plan_profile": "scoring", "target_user": "投顾", }, ) requirement_plan_db_session.add(old_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(old_topic.id), state="ready", draft_version=1, requirement_card={ "plan_profile": "scoring", "title": "每日 A 股趋势打分工具", "goal": "每天产出一个可解释的 A 股趋势分数", "target_user": "投顾", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "output_form": "飞书日报", "cadence": "每日", "first_scope": "第一版先做 A 股趋势分数和一句解释", }, draft_payload={"title": "每日 A 股趋势打分工具"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-old-draft", "source_sender_open_id": "ou-old-draft", }, ) result = await executor._execute_query_intent( intent=followup_intent, message_text="C", operator_user_id=999, source_system="feishu", source_message_id="msg-old-draft-c", ) answer = str(result["answer"] or "") assert "每日 A 股趋势打分工具" not in answer assert "需求草稿" not in answer assert "新需求" in answer assert str(result["rag_result"]["query_type"]) == "status" topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(old_topic.id)) ) ).scalar_one() assert topic.plan_status == "awaiting_execute_confirm" assert topic.pending_actions == ["confirm_requirement_draft"] @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_advise_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement advice should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "semantic_bundle": { "diagnostics": { "message_intent": "business_review", "consultant_mode_preference": "advise", } }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="如果这个政策雷达项目先做 MVP,你觉得范围怎么收才比较稳?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3", ) assert result["status"] == "succeeded" assert "只保留一个核心用户对象" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "advice" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_advise_by_text_without_metadata(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("scope advice 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): _ = (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={}) result = await executor._execute_query_intent( intent=intent, message_text="如果第一版先做 MVP,这个需求范围怎么收才比较稳?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3b", ) assert result["status"] == "succeeded" assert "只保留一个核心用户对象" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "advice" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_assignee_recommendation_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("assignee recommendation 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 _build_assignee_recommendation_answer(self, *, message_text: str, project_id): _ = (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={}) result = await executor._execute_query_intent( intent=intent, message_text="那你觉得分配给谁做比较合适?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3c", ) assert result["status"] == "succeeded" assert "AI工程师" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "recommendation" @pytest.mark.asyncio async def test_execute_query_intent_prefers_direct_advise_subtype_for_assignee(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("direct assignee subtype should bypass heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 async def _build_assignee_recommendation_answer(self, *, message_text: str, project_id): _ = (message_text, project_id) return "如果先给一个 owner,我会优先推荐 AI工程师。" monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "message_intent": "business_review", "consultant_mode_preference": "advise", "advise_subtype": "assignee", }, ) result = await executor._execute_query_intent( intent=intent, message_text="那你觉得分配给谁做比较合适?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3c-direct", ) assert result["status"] == "succeeded" assert "AI工程师" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "recommendation" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_project_existence_lookup_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id): _ = (clean_q, project_id) return { "answer": "有,当前匹配到 1 个相关项目:\n#101 AI客服(状态:active)", "sources": [{"source": "db", "label": "Project database", "score": 0.99, "metadata": {"table": "projects"}}], "db_query_ms": 8, } async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("project existence lookup 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): _ = (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={}) result = await executor._execute_query_intent( intent=intent, message_text="一个 AI 客服工具,这个项目有吗", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-project-exists-1", ) assert result["status"] == "succeeded" assert "当前匹配到 1 个相关项目" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "status" @pytest.mark.asyncio async def test_draft_confirmation_route_does_not_hijack_explicit_signal_source_request() -> None: class FakeSession: last_intent = mixin_entry.STATE_DRAFT_PENDING_CONFIRM should_route = mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=FakeSession(), message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。", ingress_options={}, ) assert should_route is False @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_uses_active_mounted_draft_without_anchor( monkeypatch, ) -> None: captured: dict[str, object] = {} 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 captured["kwargs"] = 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 assert captured["kwargs"] == { "sender": "tester", "source_system": "feishu", "channel_ref": "chat-active", "source_sender_open_id": "ou_active", "thread_context": {}, } @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_prioritizes_explicit_reply_anchor_over_bundle_skip( monkeypatch, ) -> None: captured: dict[str, object] = {"skip_called": False} 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 captured["kwargs"] = kwargs return True async def _fake_skip(*, message_text: str, source_system: str) -> bool: _ = (message_text, source_system) captured["skip_called"] = True return True monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) monkeypatch.setattr( mixin_entry.KernelIngressFlowEntryMixin, "_should_skip_no_anchor_draft_routing_by_bundle", staticmethod(_fake_skip), ) 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="确认", ingress_options={"thread_context": {"reply_to_message_id": "msg-bot-reply-1"}}, ) assert should_route is True assert captured["skip_called"] is False assert captured["kwargs"] == { "sender": "tester", "source_system": "feishu", "channel_ref": "chat-active", "source_sender_open_id": "ou_active", "thread_context": {"reply_to_message_id": "msg-bot-reply-1"}, } @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_does_not_hijack_smalltalk_even_if_active_draft_exists( monkeypatch, ) -> None: called = False 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): nonlocal called _ = (self, kwargs) called = True 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="收到,辛苦了", ingress_options=None, ) assert should_route is False 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 False @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 False @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_does_not_hijack_regenerate_request_even_if_active_draft_exists( monkeypatch, ) -> None: called = False 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): nonlocal called _ = (self, kwargs) called = True 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="生成一版给我看看", ingress_options=None, ) assert should_route is False assert called 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 def test_mounted_runtime_agent_name_uses_plan_coach_for_scope_advise() -> None: agent_name = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", auto_execute=False, route_metadata={"consultant_mode_preference": "advise", "advise_subtype": "scope"}, reply_template_key=None, mode_hint="plan", ) assert agent_name == "PlanCoach" @pytest.mark.asyncio async def test_execute_project_update_intent_resolves_string_project_id_without_name_error() -> None: class FakeDB: def __init__(self) -> None: self.commits = 0 self.refreshed: list[object] = [] async def commit(self) -> None: self.commits += 1 async def refresh(self, row) -> None: self.refreshed.append(row) class FakeExecutor(mixin_execute_mutations.ExecuteMutationsMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 async def _resolve_project(self, *, project_id, project_title, project_name): _ = (project_title, project_name) return ( SimpleNamespace( id=project_id, title="旧标题", name="旧标题", description=None, status="active", domain_id=None, assigned_role=None, owner_id=None, signal_count=0, created_at=None, updated_at=None, ), [], ) async def _log_operation(self, **kwargs): _ = kwargs async def _invalidate_project_cache(self, *, project_id: int) -> None: _ = project_id executor = FakeExecutor() intent = SimpleNamespace(name="project.update", project_id=None, metadata={}) result = await executor._execute_project_update_intent( intent=intent, params={"project_id": "104", "title": "新标题"}, operator_user_id=9, ) assert result["status"] == "succeeded" assert result["project"]["id"] == 104 assert result["project"]["title"] == "新标题" assert executor.db.commits == 1 @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions() -> None: project = SimpleNamespace(id=104, title="跨境直播项目", name="跨境直播项目", status="active") tasks = [ SimpleNamespace(status="todo", updated_at=datetime(2026, 4, 5, 10, 0), created_at=datetime(2026, 4, 5, 9, 0)), SimpleNamespace(status="in_progress", updated_at=datetime(2026, 4, 5, 11, 0), created_at=datetime(2026, 4, 5, 8, 0)), SimpleNamespace(status="done", updated_at=datetime(2026, 4, 5, 7, 0), created_at=datetime(2026, 4, 5, 6, 0)), ] class FakeScalarResult: def __init__(self, rows): self._rows = rows def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) class FakeDB: async def execute(self, stmt): _ = stmt return FakeScalarResult(tasks) class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 @staticmethod def _extract_project_lookup_keyword(question: str) -> str: _ = question return "跨境直播" @staticmethod def _extract_query_terms(question: str, preferred: str): _ = question return [preferred] async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int): _ = (keyword, project_id, limit) return [project] rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看跨境直播项目最近有哪些风险和阻塞", project_id=None, ) assert result is not None assert "风险/阻塞台账字段" in str(result["answer"] or "") assert "待处理任务数 1" in str(result["answer"] or "") @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions_without_tasks() -> None: project = SimpleNamespace(id=101, title="AI客服", name="AI客服", status="active") class FakeScalarResult: def __init__(self, rows): self._rows = rows def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) class FakeDB: async def execute(self, stmt): _ = stmt return FakeScalarResult([]) class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 @staticmethod def _extract_project_lookup_keyword(question: str) -> str: _ = question return "AI客服" @staticmethod def _extract_query_terms(question: str, preferred: str): _ = question return [preferred] async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int): _ = (keyword, project_id, limit) return [project] rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看 AI 客服工具项目最近有哪些风险和阻塞", project_id=None, ) assert result is not None assert "风险/阻塞台账字段" in str(result["answer"] or "") assert "没有可用的任务拆解记录" in str(result["answer"] or "") def test_typed_executor_common_exports_regex_helpers_to_query_mixins() -> None: assert mixin_execute_query_project.ExecuteQueryProjectMixin._extract_task_priority("p0 紧急任务") == "high" assert ( ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._extract_requested_signal_count( message_text="请帮我分析 3 个信号", metadata={}, ) == 3 ) assert ( ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._requires_strict_signal_analysis( message_text="先分析后入库 2 个信号", requested_count=2, ) is True ) def test_consultant_dispatch_uses_short_next_action_for_query_answers() -> None: dispatch = build_consultant_dispatch_contract( intent_name="query.answer", message_text="PMFlow 现在完整调用链是什么?", source_system="pmflow_manual", status="answered", classification="question", primary_operation_code="query.answer", risk_level="low", missing_fields=[], direct_reply_text=( "结论:PMFlow 当前完整调用链无法完整还原。\n\n" "依据:前端 `/ai-steward` 页面通过 `/rag/ask` 发起请求," "并继续进入 `AdaptiveRAGService.upsert_pmflow_record_async`。" ), ) assert dispatch["allow_execute"] is False assert dispatch["mode"] == "advise" assert dispatch["next_action"] == "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。" assert "结论:" not in dispatch["next_action"] assert "/rag/ask" not in dispatch["next_action"] def test_build_capture_result_no_longer_exposes_consultant_dispatch() -> None: class _FakeKernel(ai_pmo_kernel_mixins_ingress_render.KernelIngressRenderMixin): tenant_id = 1 work_item = SimpleNamespace( id=18, project_id=7, signal_id=None, state="succeeded", intent="query.answer", reply_template_key="action_succeeded", decision_level="team", review_summary={"verified": True}, raw_message="现在进展怎么样?", source_system="pmflow_manual", draft_version=0, ) intent = CanonicalIntent( name="query.answer", confidence=0.93, project_id=7, signal_title="", signal_summary="当前主链正常。", metadata={}, ) result = _FakeKernel()._build_capture_result( work_item=work_item, intent=intent, execution_result={ "workflow_route": "query.answer", "rag_result": {"answer": "当前主链正常。"}, }, render={ "template_key": "action_succeeded", "text": "当前主链正常。", }, direct_reply_required=True, status="answered", classification="question", ) assert "consultant_dispatch" not in result assert "consultant_dispatch" not in result["rag_result"] @pytest.mark.asyncio async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } high_engine = { "id": "high-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "high-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, high_engine], } openclaw_bundle = { "rules": [ { "name": "engine_high_reasoning_glm5", "enabled": True, "priority": 220, "tier": "high", "provider": "openai", "model": "GLM-5", "api_env": "OPENAI_API_KEY", "match_any": ["adaptive_rag_answer"], } ] } class FakeLLM(LLMTierEscalationMixin): def _low_to_high_escalation_enabled(self) -> bool: return True def _is_low_tier_function(self, function_name: str) -> bool: return function_name == "adaptive_rag_answer_normal" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False async def _chat_completion(self, **kwargs): captured["chat_kwargs"] = kwargs return "GLM-5 answer" llm = FakeLLM() text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="adaptive_rag_answer_normal", failed_engine=dict(low_engine), prompt="请回答一个问题", system_prompt="system prompt", temperature=0.2, max_tokens=512, response_mode="complete", error=ValueError("LLM 返回空内容"), ) assert text == "GLM-5 answer" assert isinstance(resolved_engine, dict) resolved_meta = resolved_engine["_routing_meta"] assert resolved_meta["planned_tier"] == "low" assert resolved_meta["executed_tier"] == "high" assert resolved_meta["routing_decision"] == "low_to_high_escalated" assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) assert "高阶升级成功" in log_text @pytest.mark.asyncio async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } alternate_high_engine = { "id": "high-deepseek-1", "provider": "deepseek", "model": "deepseek-chat", "base_url": "https://api.deepseek.com/v1", "api_key": "deepseek-key", } glm5_engine = { "id": "high-glm5-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "glm5-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, alternate_high_engine, glm5_engine], } openclaw_bundle = { "rules": [ { "name": "engine_low_semantic_context_exact", "enabled": True, "priority": 310, "tier": "low", "provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "function_exact": "pmflow_semantic_context_decision", } ] } class FakeLLM(LLMTierEscalationMixin): def _low_to_high_escalation_enabled(self) -> bool: return True def _is_low_tier_function(self, function_name: str) -> bool: return function_name == "pmflow_semantic_context_decision" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _resolve_tier_engine(self, *, tier, engines, active_engine): _ = (engines, active_engine) if str(tier).strip().lower() == "high": return dict(alternate_high_engine) return dict(low_engine) def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False async def _chat_completion(self, **kwargs): captured["chat_kwargs"] = kwargs return "GLM-5 answer" llm = FakeLLM() text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="pmflow_semantic_context_decision", failed_engine=dict(low_engine), prompt="请返回结构化意图", system_prompt="system prompt", temperature=0.2, max_tokens=512, response_mode="complete", error=ValueError("LLM 返回空内容"), ) assert text == "GLM-5 answer" assert isinstance(resolved_engine, dict) resolved_meta = resolved_engine["_routing_meta"] assert resolved_meta["planned_tier"] == "low" assert resolved_meta["executed_tier"] == "high" assert resolved_meta["routing_decision"] == "low_to_high_escalated" assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) assert "高阶升级成功" in log_text @pytest.mark.asyncio async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } alternate_high_engine = { "id": "high-deepseek-1", "provider": "deepseek", "model": "deepseek-chat", "base_url": "https://api.deepseek.com/v1", "api_key": "deepseek-key", } glm5_engine = { "id": "high-glm5-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "glm5-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, alternate_high_engine, glm5_engine], } openclaw_bundle = { "rules": [ { "name": "engine_low_semantic_context_exact", "enabled": True, "priority": 310, "tier": "low", "provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "function_exact": "pmflow_semantic_context_decision", } ] } class FakeLLM(LLMTierEscalationMixin): def _low_to_high_escalation_enabled(self) -> bool: return True def _is_low_tier_function(self, function_name: str) -> bool: return function_name == "pmflow_semantic_context_decision" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _resolve_tier_engine(self, *, tier, engines, active_engine): _ = (engines, active_engine) if str(tier).strip().lower() == "high": return dict(alternate_high_engine) return dict(low_engine) def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False def _extract_structured_object_from_response( self, *, function_name, response, fallback_text, provider, model, ): _ = (function_name, response, fallback_text) return StructuredExtractionResult( payload={"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}, source_field="generated_text", provider=provider, model=model, raw_text='{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}', validation="ok", ) async def _chat_completion_raw(self, **kwargs): captured["chat_kwargs"] = kwargs raw_text = '{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}' return ChatCompletionResult(text=raw_text, raw_response=raw_text) llm = FakeLLM() result, resolved_engine = await llm._retry_structured_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="pmflow_semantic_context_decision", failed_engine=dict(low_engine), prompt="请返回结构化意图", system_prompt="system prompt", temperature=0.2, max_tokens=512, response_mode="complete", error=ValueError("LLM 返回空内容"), ) assert result is not None assert result.payload["intent"] == "query.read_only" assert isinstance(resolved_engine, dict) resolved_meta = resolved_engine["_routing_meta"] assert resolved_meta["planned_tier"] == "low" assert resolved_meta["executed_tier"] == "high" assert resolved_meta["routing_decision"] == "low_to_high_escalated" assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) assert "高阶结构化升级成功" in log_text