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 AdaptiveRAGService from app.services.adaptive_rag_service.mixin_conversation_utils_a_p1 import ( AdaptiveRAGConversationUtilsAMixinP1, ) 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.adaptive_rag_service.shared_part_02 import RetrievedItem from app.services.mounted_ai_runtime_write_service import MountedAIRuntimeWriteService 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.ingest_event import IngestEvent from app.models.feishu_reply_outbox import FeishuReplyOutbox from app.models.mounted_ai_runtime import ( MountedConversationRun, MountedDraftWorkspace, MountedTopicWorkspace, ) from app.models.qa_feedback import QAFeedback from app.models.qa_record import QARecord from app.models.qa_session import QASession from app.models.qa_session_state import QASessionState from app.models.work_item import WorkItem 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__, WorkItem.__table__, IngestEvent.__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", "context_snapshot", "action_bundle", "execution_result", "review_summary", "draft_content", "payload", }: 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__, WorkItem.__table__, IngestEvent.__table__, ], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() def _use_sqlite_external_qa_json_types() -> None: for table in (QARecord.__table__, QASessionState.__table__, QAFeedback.__table__): for column in table.columns: if column.name in { "sources", "domains_used", "routing_snapshot", "citations", "sources_used", "evidence_pack", "tool_trace", "retrieval_metrics", "last_entity_ids", "rewrite_history", "pending_clarification", "error_types", }: column.type = JSON() @pytest_asyncio.fixture async def external_qa_db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") _use_sqlite_external_qa_json_types() async with engine.begin() as conn: await conn.run_sync( lambda sync_conn: Base.metadata.create_all( sync_conn, tables=[ QASession.__table__, QARecord.__table__, QAFeedback.__table__, QASessionState.__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 "pmflow 本地路由层" in draft_blob: payload = { "intro": "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", "title": "PMFlow 本地路由层批量自动化部署平台", "one_liner": "先按面向平台运维团队的项目级试点推进,先把 PMFlow 本地路由层的批量部署、执行追踪和失败恢复跑成一条闭环。", "goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", "project_nature": "这件事更像发布治理平台,需要稳定承接批量执行、状态追踪和治理边界。", "target_user": "第一版先服务平台运维团队,他们会最早使用、反馈和承担执行确认。", "signal_inputs": "当前至少要接入:飞书需求消息;部署目标清单(服务/环境/实例);代码仓库/镜像信息;环境参数与凭证;执行日志与失败日志。", "known_info": "目前已经明确:当前要解决的是 PMFlow 本地路由层批量自动化部署;当前按发布治理平台收敛;主要服务平台运维团队;技术路线先按任务编排平台版;第一版先按批量执行闭环推进;推进方式先按项目级试点(带基础审批和回滚)。", "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 = "PMFlow 本地路由层批量自动化部署平台" 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_prepare_mixed_sidecar_context_reuses_non_mixed_semantic_bundle_without_classify( monkeypatch, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def classify_intent(self, text: str): raise AssertionError(f"classify_intent should not run for non-mixed semantic bundle: {text}") 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="msg-query-1", channel_ref="chat-1", source_sender_open_id="ou-1", thread_context=None, message_text="采购经理每日实时库存上报服务这个需求是否存在系统?", project_hint_id=None, semantic_bundle={ "primary_workflow": "query_flow", "workflows": [ { "workflow_type": "query_flow", "workflow_action": "answer", } ], "diagnostics": { "message_intent": "system_existence_inquiry", }, }, ) assert clean_text == "采购经理每日实时库存上报服务这个需求是否存在系统?" assert mixed_meta == {} @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 terminate_requirement_plan_scope(self, **kwargs): _ = kwargs return { "terminated_count": 0, "terminated_topic_ids": [], "terminated_work_item_ids": [], "conversation_scope_key": None, } 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", ""), ("先按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_extract_requirement_plan_updates_prefers_choice_mapping_over_protocol_continuation_text_for_first_scope() -> None: updates = mixin_execute_query_project.ExecuteQueryProjectMixin._extract_requirement_plan_updates( message_text="我选C 继续下一轮", card={ "plan_profile": "it_project", "title": "打车聚合助手", "goal": "先把打车聚合助手的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", "project_nature": "业务平台型", "target_user": "业务主管与协同方", "tech_route": "模块化微服务", "round_option_maps": { "first_scope": { "A": "最小闭环先行", "B": "双角色轻量并行", "C": "模块化分步上线", } }, }, ) assert updates["first_scope"] == "模块化分步上线" assert "继续下一轮" not in str(updates.get("first_scope") or "") 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"} def test_render_requirement_plan_round_payload_adds_d_option_and_requirement_actions() -> None: rendered = mixin_execute_query_project.ExecuteQueryProjectMixin._render_requirement_plan_round_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 / D;也可以回复:补一句 / 出草稿 / 重出这轮。", } ) assert "D. 以上都不对,我补充一句" in rendered assert "直接回复:A / B / C / D" in rendered assert "出草稿" in rendered assert "重出这轮" in rendered @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_should_not_route_no_anchor_confirm_into_active_draft_lock(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-1", source_sender_open_id="ou_xxx", message_text="确认入库", ingress_options=None, ) assert should_route is False assert called is False @pytest.mark.asyncio async def test_terminate_requirement_plan_scope_cancels_active_plan_and_draft_rows( requirement_plan_db_session, ) -> None: service = MountedAIRuntimeWriteService(requirement_plan_db_session) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-cleanup", source_sender_open_id="ou-cleanup", sender="林宇航", ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-cleanup:message:msg-cleanup-root", source_system="feishu", channel_ref="chat-cleanup", conversation_scope_key=conversation_scope_key, root_message_id="msg-cleanup-root", mode="plan", status="active", plan_status="draft_pending_confirm", pending_actions=["confirm_requirement_draft"], open_questions=["下一步要确认什么?"], metadata_json={ "plan_mode": True, "active_draft_work_item_id": 41, }, facts_json={ "title": "旧 plan", }, ) requirement_plan_db_session.add(topic) await requirement_plan_db_session.flush() ingest = IngestEvent( tenant_id=3, channel="feishu", event_key="cleanup-event-1", status="completed", payload={"message_text": "确认入库"}, ) requirement_plan_db_session.add(ingest) await requirement_plan_db_session.flush() work_item = WorkItem( tenant_id=3, ingest_event_id=int(ingest.id), source_system="feishu", source_message_id="msg-cleanup-work-item", state="draft_pending_confirm", intent="project.create", execution_result={ "status": "draft_pending_confirm", "workflow_route": "project.create", }, reply_template_key="draft_pending_confirm", draft_content={ "title": "旧 plan", "source_message_id": "msg-cleanup-root", }, ) requirement_plan_db_session.add(work_item) await requirement_plan_db_session.flush() draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(topic.id), state="draft_pending_confirm", draft_version=1, requirement_card={ "title": "旧 plan", "plan_profile": "it_project", }, draft_payload={ "title": "旧 plan", }, active_work_item_id=int(work_item.id), confirm_message_id="msg-confirm-1", ) requirement_plan_db_session.add(draft) await requirement_plan_db_session.flush() result = await service.terminate_requirement_plan_scope( tenant_id=3, source_system="feishu", channel_ref="chat-cleanup", sender="林宇航", source_sender_open_id="ou-cleanup", source_message_id="msg-cleanup-trigger", ) assert result["terminated_count"] == 1 assert result["terminated_topic_ids"] == [int(topic.id)] assert result["terminated_work_item_ids"] == [int(work_item.id)] stored_topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(topic.id)) ) ).scalar_one() stored_draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.id == int(draft.id)) ) ).scalar_one() stored_work_item = ( await requirement_plan_db_session.execute( select(WorkItem).where(WorkItem.id == int(work_item.id)) ) ).scalar_one() assert stored_topic.mode == "chat" assert stored_topic.status == "suspended" assert stored_topic.plan_status == "suspended" assert stored_topic.pending_actions == [] assert stored_topic.open_questions == [] assert stored_topic.metadata_json["plan_mode"] is False assert stored_topic.metadata_json["plan_terminated"] is True assert stored_topic.metadata_json["plan_terminated_by_message_id"] == "msg-cleanup-trigger" assert stored_draft.state == "cancelled" assert stored_draft.active_work_item_id is None assert stored_draft.confirm_message_id is None assert stored_work_item.state == "cancelled" assert stored_work_item.reply_template_key == "draft_cancelled" assert stored_work_item.execution_result["status"] == "cancelled" assert stored_work_item.execution_result["cancel_reason"] == "stale_requirement_scope_terminated" @pytest.mark.asyncio async def test_terminate_requirement_plan_scope_cancels_orphan_pending_work_items_in_same_scope( requirement_plan_db_session, ) -> None: service = MountedAIRuntimeWriteService(requirement_plan_db_session) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-cleanup-orphan", source_sender_open_id="ou-cleanup-orphan", sender="林宇航", ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-cleanup-orphan:message:msg-cleanup-root", source_system="feishu", channel_ref="chat-cleanup-orphan", conversation_scope_key=conversation_scope_key, root_message_id="msg-cleanup-root", mode="plan", status="active", plan_status="draft_pending_confirm", pending_actions=["confirm_requirement_draft"], metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(topic) await requirement_plan_db_session.flush() primary_ingest = IngestEvent( tenant_id=3, channel="feishu", event_key="cleanup-orphan-event-1", status="completed", payload={ "sender": "林宇航", "channel_ref": "chat-cleanup-orphan", "source_sender_open_id": "ou-cleanup-orphan", "message_text": "旧草稿确认", }, ) orphan_ingest = IngestEvent( tenant_id=3, channel="feishu", event_key="cleanup-orphan-event-2", status="completed", payload={ "sender": "林宇航", "channel_ref": "chat-cleanup-orphan", "source_sender_open_id": "ou-cleanup-orphan", "message_text": "普通问句被污染成草稿确认", }, ) other_scope_ingest = IngestEvent( tenant_id=3, channel="feishu", event_key="cleanup-orphan-event-3", status="completed", payload={ "sender": "林宇航", "channel_ref": "chat-other", "source_sender_open_id": "ou-other", "message_text": "别的 scope", }, ) requirement_plan_db_session.add_all([primary_ingest, orphan_ingest, other_scope_ingest]) await requirement_plan_db_session.flush() primary_work_item = WorkItem( tenant_id=3, ingest_event_id=int(primary_ingest.id), source_system="feishu", source_message_id="msg-cleanup-primary", state="draft_pending_confirm", intent="project.create", execution_result={"status": "draft_pending_confirm"}, reply_template_key="draft_pending_confirm", ) orphan_work_item = WorkItem( tenant_id=3, ingest_event_id=int(orphan_ingest.id), source_system="feishu", source_message_id="msg-cleanup-orphan", state="draft_pending_confirm", intent="followup.confirm", execution_result={"status": "draft_pending_confirm"}, reply_template_key="draft_pending_confirm", ) other_scope_work_item = WorkItem( tenant_id=3, ingest_event_id=int(other_scope_ingest.id), source_system="feishu", source_message_id="msg-cleanup-other-scope", state="draft_pending_confirm", intent="followup.confirm", execution_result={"status": "draft_pending_confirm"}, reply_template_key="draft_pending_confirm", ) requirement_plan_db_session.add_all([primary_work_item, orphan_work_item, other_scope_work_item]) await requirement_plan_db_session.flush() draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(topic.id), state="draft_pending_confirm", draft_version=1, requirement_card={"title": "旧 plan"}, draft_payload={"title": "旧 plan"}, active_work_item_id=int(primary_work_item.id), confirm_message_id="msg-confirm-orphan-1", ) requirement_plan_db_session.add(draft) await requirement_plan_db_session.flush() result = await service.terminate_requirement_plan_scope( tenant_id=3, source_system="feishu", channel_ref="chat-cleanup-orphan", sender="林宇航", source_sender_open_id="ou-cleanup-orphan", source_message_id="msg-cleanup-trigger-orphan", ) assert result["terminated_topic_ids"] == [int(topic.id)] assert sorted(result["terminated_work_item_ids"]) == sorted( [int(primary_work_item.id), int(orphan_work_item.id)] ) stored_primary = ( await requirement_plan_db_session.execute( select(WorkItem).where(WorkItem.id == int(primary_work_item.id)) ) ).scalar_one() stored_orphan = ( await requirement_plan_db_session.execute( select(WorkItem).where(WorkItem.id == int(orphan_work_item.id)) ) ).scalar_one() stored_other_scope = ( await requirement_plan_db_session.execute( select(WorkItem).where(WorkItem.id == int(other_scope_work_item.id)) ) ).scalar_one() assert stored_primary.state == "cancelled" assert stored_orphan.state == "cancelled" assert stored_orphan.execution_result["cancel_reason"] == "stale_requirement_scope_terminated" assert stored_other_scope.state == "draft_pending_confirm" @pytest.mark.asyncio async def test_process_ingress_does_not_terminate_requirement_scope_before_active_context_followup(monkeypatch) -> None: cleanup_calls: list[dict[str, object]] = [] 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 self.active_context_called = False self.prepared_session: SimpleNamespace | None = None async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs session = SimpleNamespace( sender="feishu:林宇航", current_project_id=99, last_intent="requirement_plan", last_message_text="上一轮 plan", ) self.prepared_session = session return { "early_result": None, "clean_text": "我当前系统的ai架构是怎么样的?", "synthetic_message_id": "s-msg-terminate-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=103), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=56), "session": session, } async def _maybe_handle_active_context_followup(self, **kwargs): self.active_context_called = True _ = kwargs assert self.prepared_session is not None assert str(getattr(self.prepared_session, "last_intent", "") or "") == "requirement_plan" assert getattr(self.prepared_session, "current_project_id", None) == 99 return { "handled": True, "status": "answered", "classification": "question", "direct_reply_required": True, "direct_reply_text": "普通问答结果", } async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): _ = kwargs raise AssertionError("active context followup should short-circuit before draft confirmation routing") async def _prepare_mixed_sidecar_context(self, **kwargs): _ = kwargs raise AssertionError("active context followup should short-circuit before query sidecar") async def _fake_terminate_requirement_plan_scope(self, **kwargs): cleanup_calls.append(kwargs) return { "terminated_count": 1, "terminated_topic_ids": [11], "terminated_work_item_ids": [29], "conversation_scope_key": "scope", } monkeypatch.setattr(MountedAIRuntimeWriteService, "terminate_requirement_plan_scope", _fake_terminate_requirement_plan_scope) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-cleanup-1", message_text="我当前系统的ai架构是怎么样的?", sender="林宇航", channel_ref="chat-cleanup", source_sender_open_id="ou-cleanup", ingress_options={"thread_context": {"reply_to_message_id": ""}}, ) assert cleanup_calls == [] assert kernel.active_context_called is True assert 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 terminate_requirement_plan_scope(self, **kwargs): captured["calls"].append(("terminate_scope", kwargs)) return { "terminated_count": 0, "terminated_topic_ids": [], "terminated_work_item_ids": [], "conversation_scope_key": None, } 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", "terminate_scope", "record", "agent_trace", "finalize"] begin_kwargs = captured["calls"][0][1] record_kwargs = captured["calls"][2][1] agent_trace_kwargs = captured["calls"][3][1] finalize_kwargs = captured["calls"][4][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 terminate_requirement_plan_scope(self, **kwargs): captured["calls"].append(("terminate_scope", kwargs)) return { "terminated_count": 0, "terminated_topic_ids": [], "terminated_work_item_ids": [], "conversation_scope_key": None, } 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" call_names = [name for name, _ in captured["calls"]] assert call_names[0] == "begin" assert "semantic_shadow" in call_names assert "record" in call_names assert "finalize" in call_names assert call_names.index("semantic_shadow") < call_names.index("record") < call_names.index("finalize") semantic_kwargs = next(kwargs for name, kwargs in captured["calls"] if name == "semantic_shadow") 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" @pytest.mark.asyncio async def test_process_ingress_semantic_shadow_reuses_upstream_bundle_without_replanning( monkeypatch, ) -> None: from app.services.ai_pmo.semantic_context.decision_schema import ModeCard 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 terminate_requirement_plan_scope(self, **kwargs): captured["calls"].append(("terminate_scope", kwargs)) return { "terminated_count": 0, "terminated_topic_ids": [], "terminated_work_item_ids": [], "conversation_scope_key": None, } async def begin_ingress_run(self, **kwargs): captured["calls"].append(("begin", kwargs)) return SimpleNamespace( topic_workspace=SimpleNamespace(id=291, tenant_id=1, plan_status="idle"), conversation_run=SimpleNamespace(id=292, source_message_id="s-msg-semantic-shadow-compat"), 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=291, latest_user_message=str(kwargs["latest_user_message"]), topic=SemanticContextTopic( topic_workspace_id=291, topic_key="feishu:chat-shadow:root:om-root-shadow-compat", source_system="feishu", channel_ref="chat-shadow", conversation_scope_key="feishu:chat-shadow:ou-shadow", thread_key="thread-shadow", mode="chat", status="active", plan_status="idle", facts=None, goals=None, constraints=None, metadata={"source": "test"}, ), thread=SemanticContextThread( thread_key="thread-shadow", conversation_scope_key="feishu:chat-shadow:ou-shadow", thread_summary="查询项目是否存在", recent_turns=[], open_questions=[], ), mode_card=ModeCard( workflow_mode="CHAT", mode_state="IDLE", allowed_actions=["reply.compose", "clarify.ask"], forbidden_actions=["project.create", "db.write"], exit_conditions=[], ), ) class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: raise AssertionError("semantic shadow should reuse upstream bundle instead of replanning") 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-compat", "closure_payload": {}, "ingest_event": SimpleNamespace(id=300), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=65, state=None, context_snapshot=None, intent=None, project_id=None), "session": SimpleNamespace(last_intent="query.answer"), "session_key": "session-shadow-compat", } 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): semantic_bundle = kwargs.get("semantic_bundle_override") or {} return CanonicalIntent( name=mixin_entry.INTENT_IGNORE, confidence=0.91, project_id=None, signal_title="", signal_summary="", metadata={ "message_intent": "system_existence_inquiry", "semantic_bundle": semantic_bundle, }, ), "workflow_bundle" 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-compat-1", message_text="继续确认这个需求是否存在系统。", sender="tester", channel_ref="chat-shadow", source_sender_open_id="ou-shadow", ingress_options={ "semantic_bundle": { "bundle_id": "bundle-shadow-compat", "primary_workflow": "query_flow", "workflows": [ { "workflow_type": "query_flow", "workflow_action": "answer", "target_scope": "global", "operation_codes": ["query.answer"], "requires_confirmation": False, "confidence": 0.95, "metadata": { "message_intent": "system_existence_inquiry", }, } ], "diagnostics": { "message_intent": "system_existence_inquiry", }, } }, ) assert result["status"] == "ignored" semantic_kwargs = next(kwargs for name, kwargs in captured["calls"] if name == "semantic_shadow") assert semantic_kwargs["semantic_decision"]["mode_decision"] == "answer_only" assert semantic_kwargs["semantic_decision"]["uncertainties"] == ["shadow_reused_upstream_bundle"] assert semantic_kwargs["semantic_gate_result"]["accepted"] is True assert semantic_kwargs["semantic_diff"]["legacy_message_intent"] == "system_existence_inquiry" 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 True assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options={"thread_context": {"root_message_id": "root_123"}}, ) is True 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="#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_lock_explicit_new_requirement_topic_without_anchor( monkeypatch, ) -> None: captured = {"called": False, "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): captured["called"] = True _ = (self, kwargs) 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=captured.__setitem__("skip_called", True) or 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 captured["called"] is False assert captured["skip_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 result["reply_template_key"] == "requirement_plan_followup" assert result["work_item_id"] == 81 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_choice_protocol_followup_bypasses_semantic_decision_gate(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": "第三轮:技术路线\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 BombSemanticContextAssembler: def __init__(self, db) -> None: self.db = db async def assemble(self, **kwargs): _ = kwargs raise AssertionError("choice protocol followup should bypass semantic decision assembly") class BombSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): _ = packet raise AssertionError("choice protocol followup should bypass semantic decision") monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", BombSemanticContextAssembler) monkeypatch.setattr(mixin_entry, "SemanticDecisionService", BombSemanticDecisionService) kernel = FakeKernel() result = await kernel._maybe_handle_active_context_followup( source_system="feishu", source_message_id="msg-followup-choice-fast-path", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="我选C 继续", 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 captured["direct_kwargs"]["message_text"] == "我选C 继续" @pytest.mark.asyncio async def test_active_requirement_exit_request_suspends_bound_requirement_context_before_semantic_decision( monkeypatch, ) -> None: flush_mock = AsyncMock() topic = SimpleNamespace( id=181, status="active", plan_status="collecting", pending_actions=[], metadata_json={"plan_mode": True}, ) draft = SimpleNamespace(id=182, state="collecting") async def _executor_find_active_requirement_plan_workspace(**kwargs): _ = kwargs return topic, draft class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = SimpleNamespace(flush=flush_mock) self.tenant_id = 1 self.executor = SimpleNamespace( _find_active_requirement_plan_workspace=_executor_find_active_requirement_plan_workspace, ) class BombSemanticContextAssembler: def __init__(self, db) -> None: self.db = db async def assemble(self, **kwargs): _ = kwargs raise AssertionError("explicit exit should bypass semantic decision assembly") class BombSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): _ = packet raise AssertionError("explicit exit should bypass semantic decision") monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", BombSemanticContextAssembler) monkeypatch.setattr(mixin_entry, "SemanticDecisionService", BombSemanticDecisionService) kernel = FakeKernel() result = await kernel._maybe_handle_active_context_followup( source_system="feishu", source_message_id="msg-plan-exit-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 result["reply_template_key"] == "requirement_mode_suspended" assert result["workflow_route"] == mixin_entry.ROUTE_REQUIREMENT_PLAN_COLLECT assert result["topic_workspace_id"] == 181 flush_mock.assert_awaited_once() @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_topic.plan_status) == "draft_pending_confirm" 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) @pytest.mark.asyncio async def test_find_active_requirement_plan_workspace_prefers_live_draft_topic_over_newer_idle_scope_match( 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-prefer-live-draft", source_sender_open_id="ou-plan-prefer-live-draft", sender=None, ) live_topic = MountedTopicWorkspace( tenant_id=7, topic_key="feishu:chat-plan-prefer-live-draft:message:msg-plan-root", source_system="feishu", channel_ref="chat-plan-prefer-live-draft", conversation_scope_key=conversation_scope_key, mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(live_topic) await requirement_plan_db_session.flush() live_draft = MountedDraftWorkspace( tenant_id=7, topic_workspace_id=int(live_topic.id), state="collecting", draft_version=0, requirement_card={"title": "采购库存报表助手"}, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(live_draft) await requirement_plan_db_session.flush() idle_query_topic = MountedTopicWorkspace( tenant_id=7, topic_key="feishu:chat-plan-prefer-live-draft:message:msg-later-query", source_system="feishu", channel_ref="chat-plan-prefer-live-draft", conversation_scope_key=conversation_scope_key, mode="chat", status="active", plan_status="idle", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(idle_query_topic) 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-exit", source_chat_id="chat-plan-prefer-live-draft", sender=None, source_sender_open_id="ou-plan-prefer-live-draft", 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(live_topic.id) assert int(found_draft.id) == int(live_draft.id) 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_consultation_structured_analysis_uses_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "requirement_consultation", "consultant_mode_preference": "structured_analysis", }, reply_template_key=None, ) assert mode == "plan" def test_mounted_runtime_query_consult_requirement_requirements_analysis_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": "requirements_analysis", }, 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_does_not_resolve_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 is None @pytest.mark.asyncio async def test_draft_confirm_handler_does_not_resolve_project_draft_without_anchor() -> 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 is None 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_serialize_work_item_summary_exposes_plan_mode_summary_fields() -> None: row = SimpleNamespace( id=19, source_system="pmflow_manual", source_message_id="msg-plan-19", draft_content={}, state="draft_pending_confirm", review_summary={}, action_bundle={}, intent="query.answer", project_id=52, signal_id=None, reply_template_key="requirement_plan_followup", execution_result={ "plan_mode": True, "plan_status": "collecting", "requirement_plan_context": { "title": "换季搭配推荐", "goal": "先验证用户是否愿意采用推荐搭配。", "missing_info": ["衣橱标签", "首批试用人群", "反馈采集方式"], }, }, context_snapshot={ "plan_mode": True, "plan_status": "collecting", }, draft_expires_at=None, draft_version=2, confirm_message_id="om-plan-confirm-19", created_at=None, updated_at=None, decision_level="project", ) ingest = SimpleNamespace(payload={"message_text": "帮我继续收这个需求"}) payload = helpers_inbox_payload._serialize_work_item_summary( row=row, ingest=ingest, project=None, ) assert payload["plan_mode"] is True assert payload["plan_status"] == "collecting" assert payload["requirement_plan_context_summary"]["title"] == "换季搭配推荐" assert payload["requirement_plan_context_summary"]["missing_info"] == ["衣橱标签", "首批试用人群", "反馈采集方式"] 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 == "" @pytest.mark.asyncio async def test_kernel_intent_resolve_degrades_when_semantic_planner_fails( monkeypatch: pytest.MonkeyPatch, ) -> None: async def _failing_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, source_system, metadata) raise RuntimeError("upstream_empty_or_invalid_response") monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _failing_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 == "semantic_planner_degraded" assert intent.name == "query.read_only" assert intent.metadata["workflow_route"] == "query.answer" assert intent.metadata["context_source"] == "semantic_planner_degraded" assert intent.metadata["semantic_planner_degraded"] is True assert "upstream_empty_or_invalid_response" in str(intent.metadata["semantic_planner_error"]) assert "message_intent" not in intent.metadata assert "consultant_mode_preference" not in intent.metadata assert intent.signal_title == "" @pytest.mark.asyncio async def test_kernel_intent_resolve_reuses_upstream_semantic_bundle_without_replanning( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: (_ for _ in ()).throw(AssertionError("semantic planner should not run"))), ) class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 resolver = FakeResolver() bundle = WorkflowBundle( bundle_id="bundle-upstream-query", semantic_version="v1", source_system="feishu", 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": "business_review"}, ) 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, semantic_bundle_override=bundle.to_dict(), ) assert source == "workflow_bundle" assert intent.name == "query.read_only" assert intent.metadata["workflow_route"] == "query.answer" assert intent.metadata["context_source"] == "workflow_bundle" assert intent.metadata["message_intent"] == "business_review" assert intent.metadata["semantic_bundle"]["bundle_id"] == "bundle-upstream-query" 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"] def test_reply_renderer_preserves_requirement_plan_followup_template_for_query_intent() -> 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="query.read_only", confidence=0.97, project_id=None, signal_title="", signal_summary="", metadata={}, ) rendered = renderer.render( work_state="succeeded", intent=intent, decision_record={"approval_requirement": {}}, execution_result={ "status": "succeeded", "workflow_route": "requirement_plan.collect", "answer": "A. 轻量工具\nB. 协同平台\nC. 能力底座\n\n⭐ 当前我更推荐:A", "reply_template_key": "requirement_plan_followup", }, ) assert rendered["template_key"] == "requirement_plan_followup" assert rendered["text"].startswith("A. 轻量工具") @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_external_source_policy_bypasses_casual_chat_shortcut(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def preview_query_route_decision(self, **kwargs): captured["route_preview_kwargs"] = dict(kwargs) return { "query_semantics": { "source_policy": "uncertain", "effective_source_policy": "external", "query_type": "status", "retrieval_domains": ["url"], "query_profile": { "needs_private_context": False, "needs_freshness": True, "referenced_objects": ["weather"], }, }, "route_decision": { "route_mode": "external", "effective_source_policy": "external", "decision_origin": "external_probe", "reply_contract": {"mode": "explicit_source", "head": "外部实时查询:", "tail_hint": None}, "probe_snapshot_summary": { "internal": {"config_hit_count": 0}, "external": {"trusted_domain_hit_count": 1, "answerable_page_hit_count": 1}, }, }, } async def answer_question(self, **kwargs): captured["answer_kwargs"] = dict(kwargs) return { "answer": "广州荔湾区今天多云,午后有阵雨。", "sources": [{"source": "url", "label": "Weather", "score": 0.93, "metadata": {"url": "https://weather.example"}}], "query_type": "status", } 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-weather-1", ) assert result["status"] == "succeeded" assert "广州荔湾区今天多云" in str(result["answer"] or "") answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["source_policy"] == "uncertain" assert answer_kwargs["route_decision"]["effective_source_policy"] == "external" @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="想跟你聊个新需求,关于批量自动化部署 PMFlow 本地路由层,先别创建,先收第一版。", 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 "🧭 我先按“PMFlow 本地路由层批量自动化部署平台”这类 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 "现在要先把服务对象定住" 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="想跟你聊个新需求,关于批量自动化部署 PMFlow 本地路由层,先不创建,先收第一版。", 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="想跟你聊需求,关于批量自动化部署 PMFlow 本地路由层,先不创建,先收第一版。", 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 "历史旧名" 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_locked_requirement_mode_redirects_casual_chat_to_requirement_notice( 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"locked requirement mode 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 _install_requirement_plan_query_bundle( monkeypatch, message_intent="casual_chat", consultant_mode_preference="", ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-lock-plan", source_sender_open_id="ou-lock-plan", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-lock-plan:root:om-lock-plan-root", source_system="feishu", channel_ref="chat-lock-plan", conversation_scope_key=conversation_scope_key, mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, root_message_id="om-lock-plan-root", ) 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": "买手换季搭配助手", "goal": "先把买手换季搭配助手的首版需求收清楚。", "project_nature": "买手协同工具", "target_user": "买手与选品经理", "question_round": 2, "last_round_options": { "field": "tech_route", "options": [ {"key": "A", "title": "规则引擎"}, {"key": "B", "title": "轻量模型辅助"}, {"key": "C", "title": "混合增强路径"}, ], "recommendation": {"key": "C"}, }, }, required_confirmations=[], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-lock-plan", "source_sender_open_id": "ou-lock-plan", "message_intent": "casual_chat", }, ) result = await executor._execute_query_intent( intent=intent, message_text="你在吗", operator_user_id=999, source_system="feishu", source_message_id="msg-lock-plan-chat-1", ) assert result["status"] == "succeeded" assert "当前正在需求模式" in str(result["answer"] or "") assert "退出需求模式" in str(result["answer"] or "") assert str(result["workflow_route"]) == "requirement_plan.collect" assert str(result["rag_result"]["query_type"]) == "plan" @pytest.mark.asyncio async def test_execute_query_intent_locked_requirement_mode_redirects_generic_query_to_requirement_notice( 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"locked requirement mode should not fall through to generic RAG: {kwargs}") 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) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-lock-plan-query", source_sender_open_id="ou-lock-plan-query", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-lock-plan-query:root:om-lock-plan-query-root", source_system="feishu", channel_ref="chat-lock-plan-query", conversation_scope_key=conversation_scope_key, mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, root_message_id="om-lock-plan-query-root", ) 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": "采购库存报表助手", "goal": "先把采购库存报表助手的首版需求收清楚。", "target_user": "采购经理", "question_round": 2, "last_round_options": { "field": "target_user", "options": [ {"key": "A", "title": "只服务采购经理本人"}, {"key": "B", "title": "采购经理+仓管员"}, {"key": "C", "title": "采购经理+财务复核人"}, ], "recommendation": {"key": "A"}, }, }, required_confirmations=[], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-lock-plan-query", "source_sender_open_id": "ou-lock-plan-query", "message_intent": "query", }, ) result = await executor._execute_query_intent( intent=intent, message_text="系统里面有哪些项目?", operator_user_id=999, source_system="feishu", source_message_id="msg-lock-plan-query-1", ) assert result["status"] == "succeeded" assert "当前正在需求模式" in str(result["answer"] or "") assert "退出需求模式" in str(result["answer"] or "") assert str(result["workflow_route"]) == "requirement_plan.collect" assert str(result["rag_result"]["query_type"]) == "plan" @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_treats_requirement_consultation_structured_analysis_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 consultation 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-consultation-structured-analysis", "source_sender_open_id": "ou-requirement-consultation-structured-analysis", "message_intent": "requirement_consultation", "consultant_mode_preference": "structured_analysis", "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-consultation-structured-analysis-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-consultation-structured-analysis" assert "Feature Catalog" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_execute_query_intent_treats_consult_requirement_requirements_analysis_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"requirements analysis 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-requirements-analysis", "source_sender_open_id": "ou-consult-requirements-analysis", "message_intent": "consult_requirement", "consultant_mode_preference": "requirements_analysis", "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-requirements-analysis-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-requirements-analysis" assert "Feature Catalog" not in str(result["answer"] or "") @pytest.mark.asyncio async def test_execute_query_intent_semantic_planner_degraded_factual_query_falls_back_to_regular_qa( 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": "根据公开页面,目前走普通事实问答链。", "query_type": "explain", "sources": [{"source": "web", "label": "External", "score": 0.9}], } class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _failing_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, source_system, metadata) raise RuntimeError("upstream_empty_or_invalid_response") async def _unexpected_requirement_direct_result(self, **kwargs): # noqa: ANN001 raise AssertionError(f"degraded factual query should not enter requirement plan direct path: {kwargs}") monkeypatch.setattr( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _failing_aplan_message(**kwargs)), ) monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_planning_direct_result", _unexpected_requirement_direct_result, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-degraded-factual", "source_sender_open_id": "ou-degraded-factual", "semantic_planner_degraded": True, }, ) result = await executor._execute_query_intent( intent=intent, message_text="当前claude最新的模型用的是什么?", operator_user_id=999, source_system="feishu", source_message_id="msg-degraded-factual-1", ) assert result["status"] == "succeeded" assert str(result["rag_result"]["query_type"]) == "explain" assert "收成一版需求" not in str(result["answer"] or "") assert "requirement_plan_context" not in result assert isinstance(captured.get("rag_kwargs"), dict) @pytest.mark.asyncio async def test_execute_query_intent_semantic_planner_degraded_active_requirement_scope_stays_in_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"active requirement scope 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 _failing_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, source_system, metadata) raise RuntimeError("upstream_empty_or_invalid_response") 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( WorkflowSemanticPlanner, "aplan_message", classmethod(lambda cls, **kwargs: _failing_aplan_message(**kwargs)), ) 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-degraded-plan", source_sender_open_id="ou-degraded-plan", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-degraded-plan:root:om-degraded-plan-root", source_system="feishu", channel_ref="chat-degraded-plan", conversation_scope_key=conversation_scope_key, mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, facts_json={"title": "Claude 模型信息卡片"}, ) 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": "Claude 模型信息卡片", "goal": "先把模型信息卡片需求收清楚。", "project_nature": "轻量即用型工具", "question_round": 1, "last_round_options": { "field": "project_nature", "options": [ {"key": "A", "title": "轻量即用型工具"}, {"key": "B", "title": "业务协同平台"}, {"key": "C", "title": "AI能力底座模块"}, ], "recommendation": {"key": "A"}, }, "round_option_maps": { "project_nature": { "A": "轻量即用型工具", "B": "业务协同平台", "C": "AI能力底座模块", } }, }, required_confirmations=[], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-degraded-plan", "source_sender_open_id": "ou-degraded-plan", "semantic_planner_degraded": True, }, ) result = await executor._execute_query_intent( intent=intent, message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-degraded-plan-1", ) assert result["status"] == "succeeded" assert str(result["workflow_route"]) == "requirement_plan.collect" assert str(result["rag_result"]["query_type"]) == "plan" assert isinstance(captured.get("direct_kwargs"), dict) @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_execute_query_intent_locked_requirement_mode_choice_d_requests_clarification( 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"locked requirement mode D choice 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 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) _install_requirement_plan_query_bundle( monkeypatch, message_intent="continue_contextual_qa", consultant_mode_preference="", ) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-choice-d", source_sender_open_id="ou-choice-d", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-choice-d:root:om-choice-d-root", source_system="feishu", channel_ref="chat-choice-d", conversation_scope_key=conversation_scope_key, root_message_id="om-choice-d-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() 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": "买手换季搭配助手", "goal": "先把买手换季搭配助手的首版需求收清楚。", "project_nature": "买手协同工具", "target_user": "买手与选品经理", "tech_route": "混合增强路径", "question_round": 3, "last_round_options": { "field": "first_scope", "options": [ {"key": "A", "title": "搭配建议闭环"}, {"key": "B", "title": "替换单品闭环"}, {"key": "C", "title": "搭配沉淀闭环"}, ], "recommendation": {"key": "B"}, }, "round_option_maps": { "first_scope": { "A": "搭配建议闭环", "B": "替换单品闭环", "C": "搭配沉淀闭环", } }, }, required_confirmations=[], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-choice-d", "source_sender_open_id": "ou-choice-d", "message_intent": "continue_contextual_qa", }, ) result = await executor._execute_query_intent( intent=intent, message_text="D", operator_user_id=999, source_system="feishu", source_message_id="msg-choice-d-1", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert "这轮不用硬选" in answer assert "第一版范围" in answer assert "补一句" in answer assert str(result["workflow_route"]) == "requirement_plan.collect" @pytest.mark.asyncio async def test_execute_query_intent_locked_requirement_mode_choice_then_draft_fails_explicitly_when_fields_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): raise AssertionError(f"choice plus draft 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 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) _install_requirement_plan_query_bundle( monkeypatch, message_intent="continue_contextual_qa", consultant_mode_preference="", ) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-choice-draft-missing", source_sender_open_id="ou-choice-draft-missing", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-choice-draft-missing:root:om-choice-draft-missing-root", source_system="feishu", channel_ref="chat-choice-draft-missing", conversation_scope_key=conversation_scope_key, root_message_id="om-choice-draft-missing-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() 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": "买手换季搭配助手", "goal": "先把买手换季搭配助手的首版需求收清楚。", "project_nature": "买手协同工具", "target_user": "买手与选品经理", "question_round": 2, "last_round_options": { "field": "tech_route", "options": [ {"key": "A", "title": "规则引擎驱动"}, {"key": "B", "title": "轻量模型辅助"}, {"key": "C", "title": "混合增强路径"}, ], "recommendation": {"key": "C"}, }, "round_option_maps": { "tech_route": { "A": "规则引擎驱动", "B": "轻量模型辅助", "C": "混合增强路径", } }, }, required_confirmations=[], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-choice-draft-missing", "source_sender_open_id": "ou-choice-draft-missing", "message_intent": "continue_contextual_qa", }, ) result = await executor._execute_query_intent( intent=intent, message_text="我选C 继续出草稿", operator_user_id=999, source_system="feishu", source_message_id="msg-choice-draft-missing-1", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert "不能直接出草稿" in answer assert "第一版范围" in answer assert "推进方式" in answer assert str(result["workflow_route"]) == "requirement_plan.collect" assert str(result.get("reply_template_key") or "") != "draft_pending_confirm" @pytest.mark.asyncio async def test_execute_query_intent_locked_requirement_mode_rerolls_current_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): raise AssertionError(f"reroll should stay on requirement-plan mainline: {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 == "first_scope" assert isinstance(card, dict) card["last_round_options"] = { "field": "first_scope", "options": [ {"key": "A", "title": "搭配建议闭环"}, {"key": "B", "title": "替换单品闭环"}, {"key": "C", "title": "搭配沉淀闭环"}, ], "recommendation": {"key": "C"}, } return "这轮只定第一版范围。\n\nA. 搭配建议闭环\nB. 替换单品闭环\nC. 搭配沉淀闭环\nD. 以上都不对,我补充一句" monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_build_requirement_plan_next_question", _fake_build_requirement_plan_next_question, raising=False, ) _install_requirement_plan_query_bundle( monkeypatch, message_intent="continue_contextual_qa", consultant_mode_preference="", ) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-reroll", source_sender_open_id="ou-reroll", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-reroll:root:om-reroll-root", source_system="feishu", channel_ref="chat-reroll", conversation_scope_key=conversation_scope_key, root_message_id="om-reroll-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() 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": "买手换季搭配助手", "goal": "先把买手换季搭配助手的首版需求收清楚。", "project_nature": "买手协同工具", "target_user": "买手与选品经理", "tech_route": "混合增强路径", "question_round": 3, "last_round_options": { "field": "first_scope", "options": [ {"key": "A", "title": "搭配建议闭环"}, {"key": "B", "title": "替换单品闭环"}, {"key": "C", "title": "搭配沉淀闭环"}, ], "recommendation": {"key": "B"}, }, "round_option_maps": { "first_scope": { "A": "搭配建议闭环", "B": "替换单品闭环", "C": "搭配沉淀闭环", } }, }, required_confirmations=[], ) ) await requirement_plan_db_session.flush() executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-reroll", "source_sender_open_id": "ou-reroll", "message_intent": "continue_contextual_qa", }, ) result = await executor._execute_query_intent( intent=intent, message_text="重出这轮", operator_user_id=999, source_system="feishu", source_message_id="msg-reroll-1", ) assert result["status"] == "succeeded" assert "重出一版" in str(result["answer"] or "") assert str(result["workflow_route"]) == "requirement_plan.collect" @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_build_requirement_planning_direct_result_refuses_choice_only_draft_when_requirement_seed_is_missing( 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 _unexpected_generate_requirement_plan_confirmation_draft(self, *, card): # noqa: ANN001 raise AssertionError(f"seed-missing requirement card should not draft: {card}") monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_generate_requirement_plan_confirmation_draft", _unexpected_generate_requirement_plan_confirmation_draft, raising=False, ) conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-seed-missing", source_sender_open_id="ou-plan-seed-missing", sender=None, ) topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-seed-missing:message:om-plan-seed-missing-root", source_system="feishu", channel_ref="chat-plan-seed-missing", conversation_scope_key=conversation_scope_key, root_message_id="om-plan-seed-missing-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": "业务平台型", "target_user": "业务主管与协同方", "tech_route": "模块化微服务", "source_material": "用户:我选B | 用户:我选B 继续下一轮", "question_round": 4, "round_option_maps": { "first_scope": { "A": "最小闭环先行", "B": "双角色轻量并行", "C": "模块化分步上线", } }, "last_round_options": { "field": "first_scope", "options": [ {"key": "A", "title": "最小闭环先行"}, {"key": "B", "title": "双角色轻量并行"}, {"key": "C", "title": "模块化分步上线"}, ], "recommendation": {"key": "C"}, }, }, 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-plan-seed-missing-next", source_chat_id="chat-plan-seed-missing", sender=None, source_sender_open_id="ou-plan-seed-missing", thread_context={ "root_message_id": "om-plan-seed-missing-root", "reply_to_message_id": "om-plan-seed-missing-root", }, conversation_key=conversation_scope_key, topic_switch=None, ) assert result["status"] == "succeeded" assert result["workflow_route"] == mixin_execute_query_project.ROUTE_REQUIREMENT_PLAN_COLLECT assert "缺少最初的需求描述" in str(result["answer"] or "") assert "draft_artifact" not in result refreshed_draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.id == int(draft.id)) ) ).scalar_one() assert refreshed_draft.state == "collecting" assert refreshed_draft.draft_payload is None @pytest.mark.asyncio async def test_build_requirement_planning_direct_result_repairs_polluted_thread_from_unique_grounded_scope_snapshot( 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) assert card["title"] == "打车平台 AI 推荐第三方打车" assert "第三方平台直接打车" in str(card["source_material"]) assert card["first_scope"] == "嵌入式插件" 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": "B"}, } 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-scope-repair", source_sender_open_id="ou-plan-scope-repair", sender=None, ) polluted_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-scope-repair:message:om-plan-scope-repair-polluted-root", source_system="feishu", channel_ref="chat-plan-scope-repair", conversation_scope_key=conversation_scope_key, root_message_id="om-plan-scope-repair-polluted-root", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(polluted_topic) await requirement_plan_db_session.flush() polluted_draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(polluted_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "需求收敛草稿", "goal": "先把这项需求的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。", "project_nature": "业务平台型", "tech_route": "模块化微服务", "source_material": "用户:我选B | 用户:我选C 继续", "question_round": 4, "round_option_maps": { "first_scope": { "A": "聚合型中间件", "B": "代理型服务台", "C": "嵌入式插件", } }, "last_round_options": { "field": "first_scope", "options": [ {"key": "A", "title": "聚合型中间件"}, {"key": "B", "title": "代理型服务台"}, {"key": "C", "title": "嵌入式插件"}, ], "recommendation": {"key": "B"}, }, }, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(polluted_draft) grounded_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-scope-repair:message:om-plan-scope-repair-grounded-root", source_system="feishu", channel_ref="chat-plan-scope-repair", conversation_scope_key=conversation_scope_key, root_message_id="om-plan-scope-repair-grounded-root", mode="plan", status="suspended", plan_status="options_ready", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(grounded_topic) await requirement_plan_db_session.flush() grounded_draft = MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(grounded_topic.id), state="collecting", draft_version=0, requirement_card={ "plan_profile": "it_project", "title": "打车平台 AI 推荐第三方打车", "goal": "为用户提供跨平台比价和一键跳转的第三方打车能力,先验证真实下单闭环。", "project_nature": "业务平台型", "tech_route": "代理型服务台", "source_material": "用户:关于打车平台 ai 智能推荐性价比最高的第三方打车,直接连通第三方平台直接打车", "question_round": 2, "round_option_maps": { "first_scope": { "A": "聚合型中间件", "B": "代理型服务台", "C": "嵌入式插件", } }, "last_round_options": { "field": "first_scope", "options": [ {"key": "A", "title": "聚合型中间件"}, {"key": "B", "title": "代理型服务台"}, {"key": "C", "title": "嵌入式插件"}, ], "recommendation": {"key": "B"}, }, }, draft_payload=None, required_confirmations=[], ) requirement_plan_db_session.add(grounded_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-plan-scope-repair-followup", source_chat_id="chat-plan-scope-repair", sender=None, source_sender_open_id="ou-plan-scope-repair", thread_context={ "root_message_id": "om-plan-scope-repair-polluted-root", "reply_to_message_id": "om-plan-scope-repair-polluted-root", }, conversation_key=conversation_scope_key, topic_switch=None, ) assert result["status"] == "succeeded" assert result["workflow_route"] == mixin_execute_query_project.ROUTE_REQUIREMENT_PLAN_COLLECT assert "第三轮:服务对象" in str(result["answer"] or "") assert "缺少最初的需求描述" not in str(result["answer"] or "") requirement_plan_context = result.get("requirement_plan_context") assert isinstance(requirement_plan_context, dict) assert requirement_plan_context["title"] == "打车平台 AI 推荐第三方打车" assert requirement_plan_context["first_scope"] == "嵌入式插件" assert "第三方平台直接打车" in str(requirement_plan_context["source_material"]) refreshed_polluted_draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.id == int(polluted_draft.id)) ) ).scalar_one() assert refreshed_polluted_draft.requirement_card["title"] == "打车平台 AI 推荐第三方打车" assert refreshed_polluted_draft.requirement_card["first_scope"] == "嵌入式插件" assert "第三方平台直接打车" in str(refreshed_polluted_draft.requirement_card["source_material"]) @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 项目需求,做一个批量自动化部署 PMFlow 本地路由层的平台,先别创建,先收第一版。", 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", ) assert "🧭 我先按“PMFlow 本地路由层批量自动化部署平台”这类 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 "平台运维团队" 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 "第一版技术路线定住" 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 "") 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", ) draft_result = fifth final_answer = str(draft_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(draft_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_requires_explicit_anchor_or_work_item( 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 False assert captured == {} @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_lock_smalltalk_without_anchor( 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 False @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_does_not_route_free_text_followup_without_anchor( 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_external_freshness_followup_stays_in_query_answer_without_internal_target( external_qa_db_session, monkeypatch, ) -> None: conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-external-qa", source_sender_open_id="ou-external-qa", sender=None, ) session = QASession( tenant_id=1, user_id=None, title="外部事实问答", external_conversation_key=conversation_scope_key, message_count=1, ) external_qa_db_session.add(session) await external_qa_db_session.flush() external_qa_db_session.add( QARecord( tenant_id=1, question="当前codex最新的模型用的是什么?", answer="当前未接入实时外部数据,暂时无法确认最新结论。", sources=[], intent="question", domains_used=[], response_time_ms=23, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, source_message_id="om-external-question-1", source_chat_id="chat-external-qa", source_sender_open_id="ou-external-qa", conversation_scope_key=conversation_scope_key, grounding_status="insufficient_evidence", policy_version="retrieval-v2.1|routing:v3|prompt:industry_trend", sources_used=["External Freshness Guard"], ) ) await external_qa_db_session.commit() class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = external_qa_db_session self.tenant_id = 1 async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): _ = (cls, message_text, source_system, metadata) return WorkflowSemanticPlanner.deserialize_bundle( { "bundle_id": "freshness-followup-query", "semantic_version": "v1", "source_system": "feishu", "primary_workflow": "query_flow", "workflows": [ { "workflow_type": "query_flow", "workflow_action": "answer", "target_scope": "global", "operation_codes": ["query.answer"], "requires_confirmation": False, "confidence": 0.91, "clarification_needed": False, "metadata": {}, } ], "message_gate": "auto", "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"}, "diagnostics": {"message_intent": "question"}, } ) monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) kernel = FakeKernel() result = await kernel._maybe_handle_external_freshness_followup( source_system="feishu", source_message_id="om-external-followup-1", channel_ref="chat-external-qa", source_sender_open_id="ou-external-qa", message_text="重新联网核实一下", thread_context={"reply_to_message_id": "om-external-question-1"}, ) assert result is not None assert result["workflow_route"] == "query.answer" assert result["classification"] == "question" assert "实时外部来源" in result["direct_reply_text"] assert "任务编号" not in result["direct_reply_text"] assert "已收到需求" not in result["direct_reply_text"] @pytest.mark.asyncio async def test_qa_session_detail_and_state_replay_expose_rich_runtime_fields(external_qa_db_session) -> None: session = QASession( tenant_id=1, user_id=101, title="Alpha 外部问答", project_id=88, message_count=1, ) external_qa_db_session.add(session) await external_qa_db_session.flush() external_qa_db_session.add( QARecord( tenant_id=1, question="Alpha 项目最近有外部公开动态吗?", answer="外部实时查询:目前证据不足,建议先明确要查融资还是招聘动态。", sources=[], intent="question", domains_used=["news"], response_time_ms=42, asked_by=101, asked_via="pmflow", project_id=88, session_id=int(session.id), turn_index=1, routing_snapshot={ "query_route": { "route_mode": "external", "effective_source_policy": "external", "decision_origin": "freshness_guard", } }, answer_mode="clarify", grounding_status="insufficient_evidence", policy_version="retrieval-v2.1|routing:v3|prompt:industry_trend", citations=[{"label": "外部趋势守卫"}], sources_used=[{"display_label": "外部实时查询"}], evidence_pack={ "summary": { "conclusion": "需要进一步澄清外部目标。", "evidence": [], "uncertainties": ["未明确要查的外部主题"], }, "tool_trace": [{"tool": "external_query_gateway"}], }, ) ) external_qa_db_session.add( QASessionState( tenant_id=1, session_id=int(session.id), last_subject="Alpha项目", last_entity_ids=[88], last_intent="question", ambiguity="needs_clarification", rewrite_history=[ { "question": "Alpha 项目最近有外部公开动态吗?", "effective_question": "围绕“Alpha项目”回答:Alpha 项目最近有外部公开动态吗?", "query_type": "referential", } ], pending_clarification={ "kind": "query_route_selection", "option_map": {"A": "external", "B": "internal"}, "options": [ {"key": "A", "label": "外部公开事实"}, {"key": "B", "label": "系统内部对象"}, ], }, pending_clarification_expires_at=datetime.now() + timedelta(minutes=5), ) ) await external_qa_db_session.commit() service = AdaptiveRAGService(external_qa_db_session, tenant_id=1) detail = await service.get_qa_session_detail(session_id=int(session.id), user_id=101) assert detail["messages"][0]["route_decision"]["effective_source_policy"] == "external" assert detail["messages"][0]["answer_mode"] == "clarify" assert detail["messages"][0]["grounding_status"] == "insufficient_evidence" assert detail["messages"][0]["policy_version"] == "retrieval-v2.1|routing:v3|prompt:industry_trend" assert detail["messages"][0]["evidence_pack"]["summary"]["conclusion"] == "需要进一步澄清外部目标。" state = await service.get_session_state_replay(session_id=int(session.id), user_id=101) assert state["last_subject"] == "Alpha项目" assert state["pending_clarification"]["kind"] == "query_route_selection" assert state["rewrite_history"][0]["query_type"] == "referential" @pytest.mark.asyncio async def test_external_freshness_followup_allows_explicit_internal_source_request( external_qa_db_session, monkeypatch, ) -> None: conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-external-qa", source_sender_open_id="ou-external-qa", sender=None, ) session = QASession( tenant_id=1, user_id=None, title="外部事实问答", external_conversation_key=conversation_scope_key, message_count=1, ) external_qa_db_session.add(session) await external_qa_db_session.flush() external_qa_db_session.add( QARecord( tenant_id=1, question="当前codex最新的模型用的是什么?", answer="当前未接入实时外部数据,暂时无法确认最新结论。", sources=[], intent="question", domains_used=[], response_time_ms=23, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, source_message_id="om-external-question-2", source_chat_id="chat-external-qa", source_sender_open_id="ou-external-qa", conversation_scope_key=conversation_scope_key, grounding_status="insufficient_evidence", policy_version="retrieval-v2.1|routing:v3|prompt:industry_trend", sources_used=["External Freshness Guard"], ) ) await external_qa_db_session.commit() class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = external_qa_db_session self.tenant_id = 1 async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): _ = (cls, message_text, source_system, metadata) return WorkflowSemanticPlanner.deserialize_bundle( { "bundle_id": "freshness-followup-signal-source", "semantic_version": "v1", "source_system": "feishu", "primary_workflow": "signal_source_flow", "workflows": [ { "workflow_type": "signal_source_flow", "workflow_action": "onboard", "target_scope": "global", "operation_codes": ["signal_source.onboard"], "requires_confirmation": False, "confidence": 0.95, "clarification_needed": False, "metadata": {}, } ], "message_gate": "auto", "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "signal_source_flow"}, "diagnostics": {"message_intent": "question"}, } ) monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) kernel = FakeKernel() result = await kernel._maybe_handle_external_freshness_followup( source_system="feishu", source_message_id="om-external-followup-2", channel_ref="chat-external-qa", source_sender_open_id="ou-external-qa", message_text="给 /ai-config 接一个外部数据源来查 codex 最新模型,先出需求草稿。", thread_context={"reply_to_message_id": "om-external-question-2"}, ) assert result is None @pytest.mark.asyncio async def test_process_ingress_short_circuits_external_freshness_followup_before_mixed_or_write_path( external_qa_db_session, monkeypatch, ) -> None: conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-external-qa", source_sender_open_id="ou-external-qa", sender=None, ) session = QASession( tenant_id=1, user_id=None, title="外部事实问答", external_conversation_key=conversation_scope_key, message_count=1, ) external_qa_db_session.add(session) await external_qa_db_session.flush() external_qa_db_session.add( QARecord( tenant_id=1, question="当前codex最新的模型用的是什么?", answer="当前未接入实时外部数据,暂时无法确认最新结论。", sources=[], intent="question", domains_used=[], response_time_ms=23, asked_by=None, asked_via="feishu", project_id=None, session_id=int(session.id), turn_index=1, source_message_id="om-external-question-3", source_chat_id="chat-external-qa", source_sender_open_id="ou-external-qa", conversation_scope_key=conversation_scope_key, grounding_status="insufficient_evidence", policy_version="retrieval-v2.1|routing:v3|prompt:industry_trend", sources_used=["External Freshness Guard"], ) ) await external_qa_db_session.commit() class FakeMountedAIRuntimeWriteService: def __init__(self, db) -> None: _ = db async def canonicalize_thread_context(self, **kwargs): _ = kwargs return kwargs.get("thread_context"), {} async def begin_ingress_run(self, **kwargs): _ = kwargs return None async def terminate_requirement_plan_scope(self, **kwargs): _ = kwargs return { "terminated_count": 0, "terminated_topic_ids": [], "terminated_work_item_ids": [], "conversation_scope_key": None, } class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = external_qa_db_session self.tenant_id = 1 self.context_assembler = SimpleNamespace( assemble_world_model=AsyncMock(side_effect=AssertionError("should short-circuit before world model")) ) async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "接入外部来源继续核实", "synthetic_message_id": "om-external-followup-3", "closure_payload": {}, "ingest_event": SimpleNamespace(id=301, status="pending"), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=302), "session": SimpleNamespace(last_intent="query.answer", current_project_id=None), } def _merge_ingress_thread_context(self, **kwargs): _ = kwargs return None async def _maybe_handle_active_context_followup(self, **kwargs): _ = kwargs return None async def _should_route_to_draft_confirmation_session(self, **kwargs): _ = kwargs return False async def _maybe_reset_session_project_context_for_new_requirement(self, **kwargs): _ = kwargs return False async def _maybe_handle_orphan_requirement_followup(self, **kwargs): _ = kwargs return None async def _prepare_mixed_sidecar_context(self, **kwargs): raise AssertionError("external freshness followup should not enter mixed sidecar") async def _fake_aplan_message(cls, *, message_text: str, source_system: str, metadata=None): _ = (cls, message_text, source_system, metadata) return WorkflowSemanticPlanner.deserialize_bundle( { "bundle_id": "freshness-followup-query-process-ingress", "semantic_version": "v1", "source_system": "feishu", "primary_workflow": "query_flow", "workflows": [ { "workflow_type": "query_flow", "workflow_action": "answer", "target_scope": "global", "operation_codes": ["query.answer"], "requires_confirmation": False, "confidence": 0.93, "clarification_needed": False, "metadata": {}, } ], "message_gate": "auto", "reply_contract": {"mode": "text", "template_key": "workflow_bundle", "summary": "query_flow"}, "diagnostics": {"message_intent": "question"}, } ) monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) monkeypatch.setattr(WorkflowSemanticPlanner, "aplan_message", classmethod(_fake_aplan_message)) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="om-external-followup-3", message_text="继续联网查最新结果", sender="林宇航", channel_ref="chat-external-qa", source_sender_open_id="ou-external-qa", ingress_options={"thread_context": {"reply_to_message_id": "om-external-question-3"}}, ) assert result["handled"] is True assert result["workflow_route"] == "query.answer" assert "实时外部来源" in result["direct_reply_text"] assert "已收到需求" not in result["direct_reply_text"] @pytest.mark.asyncio async def test_process_ingress_short_circuits_query_route_pending_clarification_followup( external_qa_db_session, monkeypatch, ) -> None: conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-query-route-clarify", source_sender_open_id="ou-query-route-clarify", sender=None, ) session = QASession( tenant_id=1, user_id=None, title="内部外部澄清", external_conversation_key=conversation_scope_key, message_count=1, ) external_qa_db_session.add(session) await external_qa_db_session.flush() external_qa_db_session.add( QASessionState( tenant_id=1, session_id=int(session.id), ambiguity="needs_clarification", pending_clarification={ "kind": "query_route_selection", "original_question": "当前系统内是否有为采购经理提出的需求?", "option_map": {"A": "external", "B": "internal"}, "options": [ {"key": "A", "label": "外部公开事实"}, {"key": "B", "label": "系统内部配置/项目对象"}, ], }, pending_clarification_expires_at=datetime.now() + timedelta(minutes=5), ) ) await external_qa_db_session.commit() captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): captured["tenant_id"] = tenant_id captured["db"] = db async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return { "answer": "内部知识库查询:当前系统内已存在采购经理提出的需求。", "grounding_status": "grounded", "policy_version": "retrieval-v2.1|routing:v3|prompt:internal_knowledge", } class FakeMountedAIRuntimeWriteService: def __init__(self, db) -> None: _ = db async def canonicalize_thread_context(self, **kwargs): _ = kwargs return kwargs.get("thread_context"), {} async def begin_ingress_run(self, **kwargs): _ = kwargs return None async def terminate_requirement_plan_scope(self, **kwargs): _ = kwargs return { "terminated_count": 0, "terminated_topic_ids": [], "terminated_work_item_ids": [], "conversation_scope_key": None, } class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = external_qa_db_session self.tenant_id = 1 self.context_assembler = SimpleNamespace( assemble_world_model=AsyncMock(side_effect=AssertionError("should short-circuit before world model")) ) async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "内部配置", "synthetic_message_id": "om-query-route-followup-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=401, status="pending"), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=402), "session": SimpleNamespace(last_intent="query.answer", current_project_id=None), } def _merge_ingress_thread_context(self, **kwargs): _ = kwargs return None async def _maybe_handle_active_context_followup(self, **kwargs): _ = kwargs return None async def _should_route_to_draft_confirmation_session(self, **kwargs): _ = kwargs return False async def _maybe_reset_session_project_context_for_new_requirement(self, **kwargs): _ = kwargs return False async def _maybe_handle_orphan_requirement_followup(self, **kwargs): _ = kwargs return None async def _prepare_mixed_sidecar_context(self, **kwargs): raise AssertionError("pending clarification followup should not enter mixed sidecar") monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="om-query-route-followup-1", message_text="内部配置", sender="林宇航", channel_ref="chat-query-route-clarify", source_sender_open_id="ou-query-route-clarify", ) assert result["handled"] is True assert result["workflow_route"] == "query.answer" assert "内部知识库查询" in result["direct_reply_text"] assert captured["tenant_id"] == 1 assert captured["answer_kwargs"]["question"] == "内部配置" assert captured["answer_kwargs"]["session_id"] == int(session.id) assert captured["answer_kwargs"]["conversation_key"] == conversation_scope_key def test_query_pending_clarification_expiry_uses_same_naive_cn_rule_as_adaptive_rag() -> None: expires_at = datetime.now() - timedelta(minutes=1) assert mixin_entry.KernelIngressFlowEntryMixin._is_query_pending_clarification_expired(expires_at) is True @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_does_not_route_regenerate_request_without_anchor( 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 False @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_keeps_independent_request_out_of_draft_without_anchor( 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", owner_id=None, updated_at=datetime(2026, 4, 5, 12, 0), description="跨境直播业务推进和竞品动作跟踪。", ) 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, *, scalar=None, rows=None) -> None: self._scalar = scalar self._rows = list(rows or []) def scalar_one_or_none(self): return self._scalar def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) def all(self): return list(self._rows) class FakeDB: async def execute(self, stmt): sql = str(stmt).lower() if "from projects" in sql: return FakeScalarResult(scalar=project) if "from tasks" in sql: return FakeScalarResult(rows=tasks) if "from signals" in sql: return FakeScalarResult(rows=[]) if "from users" in sql: return FakeScalarResult(scalar=None) raise AssertionError(sql) class FakeRAG( mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, AdaptiveRAGConversationUtilsAMixinP1, ): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 async def _resolve_project_scope_for_question(self, **kwargs): _ = kwargs return (104, "跨境直播项目", "跨境直播") async def _execute_external_query_gateway(self, **kwargs): _ = kwargs return ([], {"mode": "external_query_gateway_empty"}) rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看跨境直播项目最近有哪些风险和阻塞", project_id=None, query_semantics={"subject_focus": "project", "status_focus": "risk_or_blocker"}, ) assert result is not None answer = str(result["answer"] or "") assert "项目 #104《跨境直播项目》" in answer assert "项目目前处于:执行中但存在阻塞" in answer assert "任务进度:共 3 项,已完成 1,进行中 1,待处理 1" in answer assert "负责人需要拍板:确认当前阻塞点属于范围问题、资源问题还是依赖问题。" in answer @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", owner_id=None, updated_at=datetime(2026, 4, 6, 12, 0), description="AI 客服工具推进。", ) class FakeScalarResult: def __init__(self, *, scalar=None, rows=None) -> None: self._scalar = scalar self._rows = list(rows or []) def scalar_one_or_none(self): return self._scalar def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) def all(self): return list(self._rows) class FakeDB: async def execute(self, stmt): sql = str(stmt).lower() if "from projects" in sql: return FakeScalarResult(scalar=project) if "from tasks" in sql: return FakeScalarResult(rows=[]) if "from signals" in sql: return FakeScalarResult(rows=[]) if "from users" in sql: return FakeScalarResult(scalar=None) raise AssertionError(sql) class FakeRAG( mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, AdaptiveRAGConversationUtilsAMixinP1, ): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 async def _resolve_project_scope_for_question(self, **kwargs): _ = kwargs return (101, "AI客服", "AI客服") async def _execute_external_query_gateway(self, **kwargs): _ = kwargs return ([], {"mode": "external_query_gateway_empty"}) rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看 AI 客服工具项目最近有哪些风险和阻塞", project_id=None, query_semantics={"subject_focus": "project", "status_focus": "risk_or_blocker"}, ) assert result is not None answer = str(result["answer"] or "") assert "项目 #101《AI客服》" in answer assert "项目目前处于:立项后待拆解阶段" in answer assert "当前最主要的卡点:项目还没有形成任务拆解和执行节奏,这也是当前最直接的阻塞信号。" in answer @pytest.mark.asyncio async def test_fast_project_progress_answer_renders_project_weekly_report_for_status_questions( monkeypatch, ) -> None: project = SimpleNamespace( id=62, title="关于直播同行竞品分析", name="关于直播同行竞品分析", status="active", owner_id=9, updated_at=datetime(2026, 3, 11, 20, 15, 47), description="关注直播电商规则变化、同行竞品动作和平台流量机制。", ) owner = SimpleNamespace(name="系统管理员") tasks = [ SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 9, 0), created_at=datetime(2026, 3, 12, 8, 0)), SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 10, 0), created_at=datetime(2026, 3, 12, 8, 30)), SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 11, 0), created_at=datetime(2026, 3, 12, 9, 0)), SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 12, 0), created_at=datetime(2026, 3, 12, 9, 30)), SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 13, 0), created_at=datetime(2026, 3, 12, 10, 0)), SimpleNamespace(status="待处理", updated_at=datetime(2026, 3, 12, 14, 0), created_at=datetime(2026, 3, 12, 10, 30)), ] signals = [ ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ("待处理", False, "pending"), ] class FakeScalarResult: def __init__(self, *, scalar=None, rows=None) -> None: self._scalar = scalar self._rows = list(rows or []) def scalar_one_or_none(self): return self._scalar def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) def all(self): return list(self._rows) class FakeDB: async def execute(self, stmt): sql = str(stmt).lower() if "from projects" in sql: return FakeScalarResult(scalar=project) if "from users" in sql: return FakeScalarResult(scalar=owner) if "from tasks" in sql: return FakeScalarResult(rows=tasks) if "from signals" in sql: return FakeScalarResult(rows=signals) raise AssertionError(sql) async def _fake_render_prompt(self, *, fallback_template: str, **kwargs): _ = self return str(fallback_template).format(**kwargs).strip() monkeypatch.setattr( "app.services.prompt_release.service.PromptReleaseService.render_prompt", _fake_render_prompt, ) class _FakeLLM: async def generate(self, prompt: str, **kwargs): _ = (prompt, kwargs) return json.dumps( { "handled": True, "summary": "项目已经立起来了,但推进还没真正跑起来。", "stage_judgement": "启动后停滞阶段", "evidence_summary": "当前没有进行中的任务,6 个任务待处理,13 条信号待处理。", "core_gap": "任务和信号都在堆积,但还没有转成明确的本周执行动作。", "external_impact": "如果直播电商平台规则继续收紧,这个项目的优先级需要前移。", "actions": [ {"slot": "this_week", "text": "先把 6 个待处理任务拆成本周必须启动和可以后排两档。"}, {"slot": "owner_decision", "text": "明确每个任务的推进人和本周交付口径。"}, {"slot": "protect_priority", "text": "优先清掉最影响交付的 2 条待处理信号。"}, ], }, ensure_ascii=False, ) class FakeRAG( mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin, AdaptiveRAGConversationUtilsAMixinP1, ): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 self.llm = _FakeLLM() async def _resolve_project_scope_for_question(self, **kwargs): _ = kwargs return (62, "关于直播同行竞品分析", "直播同行竞品分析") async def _execute_external_query_gateway(self, **kwargs): _ = kwargs return ( [ RetrievedItem( source="url", content="直播电商监督管理办法已发布,平台合规和经营者责任要求更明确。", score=0.93, source_label="URL[直播电商监督管理办法]", metadata={ "title": "直播电商监督管理办法", "url": "https://www.cac.gov.cn/2026-01/07/example", }, ), RetrievedItem( source="url", content="国家市场监管总局同步披露了直播电商监管办法 PDF 版本。", score=0.88, source_label="URL[直播电商监督管理办法 - 国家市场监督管理总局]", metadata={ "title": "直播电商监督管理办法 - 国家市场监督管理总局", "url": "https://www.samr.gov.cn/2026-01/example.pdf", }, ), ], {"mode": "external_query_gateway", "adapter": "live_search"}, ) rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看下关于直播同行竞品分析这个项目现在怎么样?", project_id=None, query_semantics={"subject_focus": "project", "status_focus": "snapshot"}, ) assert result is not None answer = str(result["answer"] or "") assert "项目 #62《关于直播同行竞品分析》" in answer assert "当前结论:项目已经立起来了,但推进还没真正跑起来。" in answer assert "1. 📍 当前盘面" in answer assert "负责人:系统管理员" in answer assert "任务进度:共 6 项,已完成 0,进行中 0,待处理 6" in answer assert "信号情况:共 13 条,待处理 13 条" in answer assert "2. 🧠 进度判断" in answer assert "项目目前处于:启动后停滞阶段" in answer assert "3. 🌐 外部变化" in answer assert "最新外部信息 1:直播电商监督管理办法" in answer assert "4. ✅ 下一步建议" in answer assert "这周先推进:先把 6 个待处理任务拆成本周必须启动和可以后排两档。" in answer assert "负责人需要拍板:明确每个任务的推进人和本周交付口径。" in answer assert "如果资源不够,先保住:优先清掉最影响交付的 2 条待处理信号。" in answer assert "来源摘要:项目库 + 外部实时来源" in answer 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], } control_plane_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_control_plane_routing_bundle(self): return dict(control_plane_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_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], } control_plane_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_control_plane_routing_bundle(self): return dict(control_plane_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_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], } control_plane_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_control_plane_routing_bundle(self): return dict(control_plane_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_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