import asyncio import json from datetime import datetime, timedelta from types import SimpleNamespace 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_llm import ( KernelIntentLLMMixin, ) 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.models import ReplyContract, WorkflowBundle, WorkflowPlan from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.adaptive_rag_service import mixin_deterministic_query_core from app.services.adaptive_rag_service import mixin_fast_answer_project_main from app.services.integrations.feishu_integration_service import mixin_reply_outbox as feishu_mixin_reply_outbox from app.services.integrations.feishu_outbox.service import FeishuOutboxService from app.services.llm_service import ChatCompletionResult, StructuredExtractionResult from app.services.llm_service import mixin_tier_escalation from app.services.llm_service.mixin_tier_escalation import LLMTierEscalationMixin from app.models.base import Base from app.models.feishu_reply_outbox import FeishuReplyOutbox from app.models.mounted_ai_runtime import MountedDraftWorkspace, MountedTopicWorkspace from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key def _use_sqlite_requirement_plan_json_types() -> None: for table in ( MountedTopicWorkspace.__table__, MountedDraftWorkspace.__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", }: 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__, ], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() @pytest_asyncio.fixture async def feishu_outbox_db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") async with engine.begin() as conn: await conn.run_sync( lambda sync_conn: Base.metadata.create_all( sync_conn, tables=[FeishuReplyOutbox.__table__], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() def _build_long_requirement_plan_reply_text() -> str: repeated = ",".join(["覆盖用户偏好、衣橱标签、天气温度、场景约束、历史反馈"] * 10) return ( "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。\n" "现在最关键的是先把第一版定位定住。\n\n" f"A. 轻量推荐工具\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n" f"B. 业务平台能力\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n" f"C. 长期能力底座\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n" f"⭐ 当前我更推荐:B\n原因:{repeated}\n\n你回 A / B / C,我就顺着这条继续往下收。" ) def _install_requirement_plan_round_llm(monkeypatch, captured: list[dict[str, object]] | None = None) -> None: def _extract_round_packet(prompt: str) -> dict[str, object]: marker = "requirement_plan_round_packet:" if marker not in prompt: return {} raw = prompt.split(marker, 1)[1].strip() try: payload = json.loads(raw) except Exception: return {} return payload if isinstance(payload, dict) else {} def _extract_draft_packet(prompt: str) -> dict[str, object]: marker = "requirement_plan_draft_packet:" if marker not in prompt: return {} raw = prompt.split(marker, 1)[1].strip() try: payload = json.loads(raw) except Exception: return {} return payload if isinstance(payload, dict) else {} async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 if captured is not None: captured.append({"prompt": prompt, "kwargs": dict(kwargs)}) function_name = str(kwargs.get("function_name") or "") packet = _extract_round_packet(prompt) context_blob = json.dumps(packet, ensure_ascii=False) is_scoring_context = any( token in context_blob for token in ("大A趋势打分", "趋势打分", "投顾", "研究团队", "交易团队") ) if function_name == "pmflow_requirement_plan_draft": draft_packet = _extract_draft_packet(prompt) draft_blob = json.dumps(draft_packet, ensure_ascii=False) if "换季系统自动推荐搭配衣服" in draft_blob or "个性化穿搭推荐平台" in draft_blob: payload = { "intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:", "title": "换季系统自动推荐搭配衣服", "one_liner": "先面向普通消费者做一版换季穿搭推荐试用,先把温度、场景和单品搭配建议跑成可验证闭环。", "goal": "先验证用户是否愿意使用换季搭配推荐,并收集最基础的点击、收藏和反馈信号。", "project_nature": "这件事更像个性化穿搭推荐平台的第一版,不是部署平台,也不是内部流程治理工具。", "target_user": "第一版先服务普通消费者,重点看他们是否会点击、收藏和采纳推荐结果。", "signal_inputs": "当前至少要用到:用户当前需求描述;季节/温度信息;衣物标签;基础反馈数据。", "known_info": "目前已经明确:这是换季穿搭推荐需求;先按个性化穿搭推荐平台收敛;第一版先给普通消费者试用;技术路线先按规则推荐版;范围先保留温度、场景、单品搭配建议。", "tech_route": "当前更适合先走规则推荐版,用季节、温度、场景和衣物标签跑通首轮推荐。", "first_scope": "第一版先保留温度、场景、单品搭配建议,先验证推荐是否被点击、收藏和采纳。", "core_flow": "用户进入换季推荐入口 -> 结合温度/场景生成搭配建议 -> 展示单品组合 -> 记录点击/收藏/反馈 -> 继续优化推荐规则", "delivery_mode": "先做小范围真实用户试用,优先看真实点击、收藏和主观反馈。", "success_signal": "先保证能稳定给出换季搭配建议,并拿到点击、收藏或反馈中的至少一类真实用户信号。", "risks": "当前主要风险在冷启动标签质量、搭配规则是否过于死板,以及首轮反馈样本是否足够。", "pending_items": ["衣物标签口径是否已有现成标准", "首轮试用用户范围和反馈采集方式", "是否需要同步接商品库或衣橱数据"], "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", "positioning_one_liner": "面向普通消费者的小范围换季穿搭推荐试用,先跑通基础推荐闭环。", "summary": "先用规则推荐跑通换季穿搭建议,再根据真实点击、收藏和反馈决定是否扩到更完整的平台能力。", "target_customers": "普通消费者", "external_signal_sources": [ {"name": "天气 API", "purpose": "补充实时温度、体感和降雨变化", "phase": "首版优先接入"}, {"name": "淘宝开放平台", "purpose": "补充可推荐商品的品类、颜色和季节属性", "phase": "首版优先接入"}, {"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度和潮流变化", "phase": "第二阶段接入"}, ], "requirement_items": [ {"section": "需求目标", "title": "验证用户价值", "description": "验证用户是否愿意使用换季搭配推荐,并留下点击、收藏或反馈。"}, {"section": "服务对象", "title": "首批用户", "description": "第一版先服务普通消费者。"}, {"section": "技术路线", "title": "规则推荐版", "description": "先基于季节、温度、场景和衣物标签生成推荐。"}, {"section": "第一版范围", "title": "最小闭环", "description": "先保留温度、场景、单品搭配建议。"}, ], "missing_info": ["衣物标签标准", "试用用户范围", "反馈采集方式"], "mvp_items": ["温度标签", "场景标签", "单品搭配建议", "基础点击/收藏反馈"], "team_setup": [{"role": "建议配置", "notes": "产品/运营 1名、后端/算法 1名、前端 1名,先支撑小范围试用即可。"}], "validation_metrics": {"product": ["推荐点击率", "收藏率", "用户主观反馈"]}, } elif any(token in draft_blob for token in ("大A趋势打分", "趋势打分", "投顾", "研究团队", "交易团队")): payload = { "intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:", "title": "每日 A 股趋势打分工具", "one_liner": "面向投顾与研究团队的轻量级趋势打分工具,先把每日评分、评分解释和使用反馈跑成闭环。", "goal": "先验证趋势打分结果是否有参考价值,并让投顾或研究团队能快速查看、比对和反馈。", "project_nature": "这件事更像一个投研辅助小工具,核心是先把每日趋势评分与解释能力跑通。", "target_user": "第一版先服务投顾与研究团队,优先满足他们查看评分、理解原因和给反馈的需要。", "signal_inputs": "当前至少要用到:行情数据、基础技术指标、评分规则、投顾或研究团队反馈。", "known_info": "目前已经明确:这是每日 A 股趋势打分工具;当前按投研辅助工具收敛;主要给投顾或研究团队看;技术路线先按评分服务版;第一版先保留每日评分、评分解释和重点股票列表。", "tech_route": "当前更适合先走评分服务版,用规则与指标服务生成每日趋势分数和解释。", "first_scope": "第一版先保留每日趋势评分、评分解释和重点股票列表,先验证结果是否可用。", "core_flow": "拉取行情与指标 -> 计算每日趋势分数 -> 输出评分解释与重点股票 -> 投顾查看与反馈 -> 根据反馈调整评分规则", "delivery_mode": "先做小范围投研试用,优先看投顾和研究团队是否会实际参考这份评分结果。", "success_signal": "先保证能稳定产出每日评分结果,并让投顾或研究团队留下查看、引用或反馈中的至少一类真实使用信号。", "risks": "当前主要风险在评分口径是否足够稳定、解释是否被投顾认可,以及反馈样本是否足够支持后续优化。", "pending_items": ["评分口径是否需要区分短线和中线", "重点股票列表的筛选边界", "反馈采集方式与频率"], "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", "positioning_one_liner": "面向投顾与研究团队的每日趋势评分试用,先跑通评分与解释闭环。", "summary": "先用轻量评分服务跑通每日趋势打分,再根据真实使用反馈决定是否扩到更完整的投研分析平台。", "target_customers": "投顾、研究团队", "external_signal_sources": [ {"name": "Tushare API", "purpose": "补充日线行情、成交量和基础财务指标", "phase": "首版优先接入"}, {"name": "同花顺 iFinD", "purpose": "补充板块热度与市场情绪相关指标", "phase": "第二阶段接入"}, {"name": "Wind API", "purpose": "补充更完整的因子与指数成分数据", "phase": "第二阶段接入"}, ], "requirement_items": [ {"section": "需求目标", "title": "验证评分可用性", "description": "验证每日趋势打分是否真的能帮助投顾或研究团队更快做初筛。"}, {"section": "服务对象", "title": "首批用户", "description": "第一版先服务投顾与研究团队。"}, {"section": "技术路线", "title": "评分服务版", "description": "先基于规则与指标服务生成每日评分与解释。"}, {"section": "第一版范围", "title": "最小闭环", "description": "先保留每日评分、评分解释和重点股票列表。"}, ], "missing_info": ["评分口径边界", "重点股票筛选标准", "反馈采集方式"], "mvp_items": ["每日趋势评分", "评分解释", "重点股票列表", "基础反馈记录"], "team_setup": [{"role": "建议配置", "notes": "产品/投研 1名、后端/数据 1名、前端 1名,先支撑小范围投研试用即可。"}], "validation_metrics": {"product": ["评分查看率", "投顾引用率", "研究团队反馈数"]}, } elif "批量自动化部署" in draft_blob or "openclaw" in draft_blob: payload = { "intro": "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", "title": "OpenClaw 批量自动化部署平台", "one_liner": "先按面向平台运维团队的项目级试点推进,先把 OpenClaw 批量部署、执行追踪和失败恢复跑成一条闭环。", "goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", "project_nature": "这件事更像发布治理平台,需要稳定承接批量执行、状态追踪和治理边界。", "target_user": "第一版先服务平台运维团队,他们会最早使用、反馈和承担执行确认。", "signal_inputs": "当前至少要接入:飞书需求消息;部署目标清单(服务/环境/实例);代码仓库/镜像信息;环境参数与凭证;执行日志与失败日志。", "known_info": "目前已经明确:当前要解决的是 OpenClaw 批量自动化部署;当前按发布治理平台收敛;主要服务平台运维团队;技术路线先按任务编排平台版;第一版先按批量执行闭环推进;推进方式先按项目级试点(带基础审批和回滚)。", "tech_route": "当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL),先把批量执行、状态追踪和失败重试稳定下来。", "first_scope": "第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。", "core_flow": "提交部署需求 -> 生成部署任务 -> 选择目标环境 -> 执行部署 -> 回传结果/日志 -> 失败重试或回滚 -> 留痕审计", "delivery_mode": "先按项目级试点(带基础审批和回滚)推进,在真实项目里验证价值和流程问题。", "success_signal": "先保证能稳定创建部署任务、看到执行结果、识别失败原因,并在试点里跑完一次真实闭环。", "risks": "当前主要风险在部署目标范围、环境访问权限与凭证归属、仓库/镜像/配置规范、凭证管理,以及失败重试与回滚策略。", "pending_items": ["部署目标范围(服务/环境/实例)", "仓库/镜像/配置来源", "环境权限与凭证归属", "试点负责人和投入人天"], "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", "positioning_one_liner": "面向平台运维团队的发布治理平台试点,先跑通批量执行与失败恢复闭环。", "summary": "先做发布治理平台试点,把批量执行闭环在真实项目里跑通,再继续扩治理能力。", "target_customers": "平台运维团队", "external_signal_sources": [ {"name": "CMDB API", "purpose": "补充部署目标实例、环境和服务清单", "phase": "首版优先接入"}, {"name": "GitLab API", "purpose": "补充仓库、分支、提交与流水线信息", "phase": "首版优先接入"}, {"name": "Harbor Registry API", "purpose": "补充镜像标签、仓库和发布时间", "phase": "首版优先接入"}, ], "requirement_items": [ {"section": "项目性质", "title": "发布治理平台", "description": "需要稳定承接批量执行、状态追踪和治理边界。"}, {"section": "服务对象", "title": "平台运维团队", "description": "第一版先服务平台运维团队。"}, {"section": "技术路线", "title": "任务编排平台版", "description": "用 FastAPI + React + Redis + Celery + PostgreSQL 承接任务编排。"}, {"section": "第一版范围", "title": "批量执行闭环", "description": "先包含任务创建、执行日志和失败重试。"}, ], "missing_info": ["部署目标范围", "凭证归属", "试点负责人"], "mvp_items": ["任务创建", "执行日志", "失败重试", "回滚能力"], "team_setup": [{"role": "建议配置", "notes": "需求负责人/PM 1名;后端工程师 1名;前端工程师 1名;平台/运维工程师 1名;测试/试点验收 1名;试点负责人 1名。"}], "validation_metrics": {"product": ["任务创建成功率", "部署执行完成率", "失败原因可识别率"]}, } else: payload = { "intro": "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:", "title": "企业级项目管理系统里的需求收敛功能", "one_liner": "先按面向项目管理团队的项目级试点推进,先把需求收敛、待确认项和范围确认闭环跑通。", "goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。", "project_nature": "这件事更像发布治理平台式的业务协作能力,需要承接多人协作、状态流转和留痕。", "target_user": "第一版先服务项目管理团队,他们会最早补充需求、看收敛结果和处理待确认项。", "signal_inputs": "当前至少要接入:飞书需求消息;项目/任务/责任人数据;需求变更记录;状态流转记录。", "known_info": "目前已经明确:当前要解决的是企业级项目管理系统里的需求收敛功能;当前按发布治理平台收敛;主要服务项目管理团队;技术路线先按任务编排平台版;第一版先按待办、责任人、截止时间推进;推进方式先按项目级试点。", "tech_route": "当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL),先把需求收敛和待确认项管理稳定下来。", "first_scope": "待办、责任人、截止时间。", "core_flow": "提交需求信号 -> 生成需求 brief -> 拆出待办/责任人/截止时间 -> 校验待确认项 -> 人工确认后继续推进", "delivery_mode": "先按项目级试点(带基础审批和回滚)推进,尽快在真实项目里验证需求收敛价值。", "success_signal": "先保证能稳定生成项目 brief、拆出待办/责任人/截止时间,并能支撑一轮真实确认闭环。", "risks": "当前主要风险在需求事实是否足够清晰、责任人与截止时间来源是否稳定,以及确认规则是否一致。", "pending_items": ["需求事实来源与证据口径", "责任人字段来源", "截止时间字段来源", "试点负责人和投入人天"], "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", "positioning_one_liner": "面向项目管理团队的需求收敛试点,先跑通待办/责任人/截止时间闭环。", "summary": "先把需求收敛主链路跑通,再根据试点结果决定是否扩到更完整的平台能力。", "target_customers": "项目管理团队", "external_signal_sources": [ {"name": "飞书开放平台", "purpose": "补充消息原文、评论和消息元数据", "phase": "首版优先接入"}, {"name": "Jira API", "purpose": "补充需求单、负责人和截止时间字段", "phase": "首版优先接入"}, {"name": "Confluence API", "purpose": "补充需求背景文档和评审记录", "phase": "第二阶段接入"}, ], "requirement_items": [ {"section": "项目性质", "title": "业务协作平台能力", "description": "承接多人协作、状态流转和范围确认。"}, {"section": "服务对象", "title": "项目管理团队", "description": "第一版先服务项目管理团队。"}, {"section": "技术路线", "title": "任务编排平台版", "description": "用 FastAPI + React + Redis + Celery + PostgreSQL 承接收敛链路。"}, {"section": "第一版范围", "title": "核心字段", "description": "先保留待办、责任人、截止时间。"}, ], "missing_info": ["需求事实来源", "责任人字段来源", "截止时间字段来源"], "mvp_items": ["需求 brief", "待办字段", "责任人字段", "截止时间字段"], "team_setup": [{"role": "建议配置", "notes": "需求负责人/PM 1名;后端工程师 1名;前端工程师 1名;测试/试点验收 1名。"}], "validation_metrics": {"product": ["brief 生成成功率", "字段抽取可用率", "确认闭环完成率"]}, } elif "换季系统自动推荐搭配衣服" in context_blob: payload = { "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", "focus": "现在最关键的,是先定它更像一次性推荐小工具、可持续运营平台,还是长期穿搭决策能力。", "options": [ { "key": "A", "title": "先做穿搭推荐小工具", "best_for": "先验证用户是否愿意按天气、季节和已有衣橱接收搭配建议", "suggested_capability": "基础规则推荐、衣物标签、简单反馈", "rationale": "落地快,能先验证推荐结果是否真的有用", "tradeoff": "个性化和长期学习能力会比较弱", }, { "key": "B", "title": "先做个性化穿搭推荐平台", "best_for": "需要持续沉淀用户偏好、衣橱数据和搭配反馈", "suggested_capability": "用户画像、衣物标签体系、推荐策略、反馈闭环", "rationale": "更贴近你说的自动推荐搭配衣服,也方便后续扩展", "tradeoff": "第一版要先把数据口径和推荐边界说清楚", }, { "key": "C", "title": "直接做穿搭决策底座", "best_for": "后续要接入多品牌、多场景和长期运营策略", "suggested_capability": "多源数据接入、推荐服务、策略实验、效果评估", "rationale": "长期上限更高", "tradeoff": "早期投入最大,容易还没验证需求就做重", }, ], "recommendation": {"key": "B", "reason": "它最符合“换季系统自动推荐搭配衣服”的持续价值,但还不会一开始做成过重底座。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } elif is_scoring_context and '"missing_field": "project_nature"' in prompt: payload = { "opening": "🧭 我先按“每日 A 股趋势打分工具”这类 IT 项目来收。", "focus": "现在最关键的,是先定它更像一个轻量投研工具、可复用评分服务,还是长期分析底座。", "options": [ { "key": "A", "title": "先做轻量打分工具", "best_for": "先验证每日评分结果是否有参考价值", "suggested_capability": "日线数据拉取、评分规则、结果展示", "rationale": "开发快,适合先验证评分是否真的有人用", "tradeoff": "后续扩展多维因子和策略实验时需要补能力", }, { "key": "B", "title": "先做评分服务平台", "best_for": "需要持续沉淀评分规则、指标口径和反馈闭环", "suggested_capability": "评分服务、规则配置、反馈记录", "rationale": "更方便后续持续优化评分逻辑", "tradeoff": "第一版会比 A 多一点规则管理成本", }, { "key": "C", "title": "直接做投研分析底座", "best_for": "后续要承接多策略、多因子和多团队复用", "suggested_capability": "因子计算、策略实验、分析接口", "rationale": "长期上限更高", "tradeoff": "早期投入最大,需求没跑通前容易做重", }, ], "recommendation": {"key": "B", "reason": "先按评分服务把规则、结果和反馈闭环收住,更适合后续持续优化。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } elif '"missing_field": "project_nature"' in prompt: is_deployment_prompt = "批量自动化部署" in context_blob subject = "OpenClaw 批量自动化部署平台" if is_deployment_prompt else "当前需求" option_b_title = "先做发布治理平台" if is_deployment_prompt else "先做业务协作平台" option_b_best_for = "多人协作、状态流转、需要审批和回滚" if is_deployment_prompt else "多人协作、状态流转、需要审批和留痕" option_b_capability = "FastAPI + React + PostgreSQL + Redis" if is_deployment_prompt else "FastAPI + React + PostgreSQL + 审批流 + 审计日志" payload = { "opening": f"🧭 我先按“{subject}”这类 IT 项目来收。", "focus": "现在最关键的,不是立刻展开做法,而是先定它更像工具、平台,还是长期底座。", "options": [ { "key": "A", "title": "先做内部提效工具", "best_for": "先减少重复操作,把第一版价值快速跑通", "suggested_capability": "FastAPI + React + PostgreSQL", "rationale": "开发快,适合先验证 MVP", "tradeoff": "后续任务编排和治理能力要继续补", }, { "key": "B", "title": option_b_title, "best_for": option_b_best_for, "suggested_capability": option_b_capability, "rationale": "更适合承接长期治理和执行追踪", "tradeoff": "前期设计复杂度高于 A", }, { "key": "C", "title": "直接做云原生交付底座" if is_deployment_prompt else "直接做长期能力底座", "best_for": "目标是规模化、标准化和长期复用", "suggested_capability": "平台能力、策略配置、审计与扩展接口", "rationale": "长期上限更高", "tradeoff": "早期投入最大", }, ], "recommendation": {"key": "B", "reason": "它更适合把当前需求收成可持续的平台能力,同时不会一开始做成过重底座。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } elif '"missing_field": "target_user"' in prompt: if is_scoring_context: payload = { "opening": "🧭 我先按“轻量打分工具”这个方向继续收。", "focus": "现在要先把第一版服务对象定住,不然后面的评分口径会飘。", "options": [ {"key": "A", "title": "投顾", "best_for": "先服务一线需要快速看市场趋势和候选股票的人", "suggested_capability": "每日评分卡、重点股票列表、简短解释", "rationale": "最贴近直接使用评分结果的人", "tradeoff": "会更关注结果可读性和更新节奏"}, {"key": "B", "title": "研究团队", "best_for": "先服务需要验证评分逻辑和指标口径的人", "suggested_capability": "评分拆解、指标明细、回看能力", "rationale": "更容易帮助我们先把评分口径校准", "tradeoff": "第一版结果会更偏分析工具而不是实战辅助"}, {"key": "C", "title": "交易团队", "best_for": "直接结合交易节奏看信号", "suggested_capability": "盘前信号、风险提示、更新频率控制", "rationale": "最能验证时效价值", "tradeoff": "对稳定性和时效要求最高"}, ], "recommendation": {"key": "B", "reason": "先按研究团队校准评分口径,再扩到更直接的投顾使用会更稳。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } elif "换季系统自动推荐搭配衣服" in context_blob or "个性化穿搭推荐平台" in context_blob: payload = { "opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。", "focus": "现在要先把第一版服务对象定住,不然后面推荐口径会飘。", "options": [ {"key": "A", "title": "普通消费者", "best_for": "先验证换季搭配推荐是否有用", "suggested_capability": "用户偏好、天气/季节标签、搭配反馈", "rationale": "最贴近最终使用者", "tradeoff": "需要处理偏好差异"}, {"key": "B", "title": "穿搭运营团队", "best_for": "先由运营维护搭配规则和推荐口径", "suggested_capability": "规则配置、款式标签、人工校验", "rationale": "更容易控制第一版质量", "tradeoff": "自动化程度会弱一些"}, {"key": "C", "title": "品牌导购团队", "best_for": "结合商品推荐和导购场景", "suggested_capability": "商品库、搭配卡片、导购反馈", "rationale": "更适合商业转化", "tradeoff": "会更依赖商品数据"}, ], "recommendation": {"key": "A", "reason": "先服务普通消费者最能验证自动推荐是否真的成立。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } else: opening_subject = "云原生交付底座" if "云原生交付底座" in context_blob else "发布治理平台" payload = { "opening": f"🧭 我先按“{opening_subject}”这个方向继续收。", "focus": "现在要先把服务对象定住,不然后面体验和边界会飘。", "options": [ {"key": "A", "title": "开发团队", "best_for": "先让开发自己使用", "suggested_capability": "轻量入口、日志查看", "rationale": "反馈快", "tradeoff": "治理弱"}, {"key": "B", "title": "平台运维团队", "best_for": "统一管理流程和风险", "suggested_capability": "审批、审计、回滚", "rationale": "更稳", "tradeoff": "边界要清楚"}, {"key": "C", "title": "项目交付团队", "best_for": "按项目复制流程", "suggested_capability": "模板、参数、交付流程", "rationale": "适合多项目", "tradeoff": "依赖流程稳定"}, ], "recommendation": {"key": "B", "reason": "它更适合承接责任边界和治理闭环。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } elif '"missing_field": "tech_route"' in prompt: if is_scoring_context: payload = { "opening": "🧭 我先按“主要给投顾看”这个方向继续收。", "focus": "现在最关键的,是先把第一版技术路线定住。", "options": [ {"key": "A", "title": "先做规则打分版", "best_for": "快速验证每日评分闭环", "suggested_capability": "FastAPI + PostgreSQL + 指标规则计算", "rationale": "最快把评分结果和解释跑通", "tradeoff": "后续扩展复杂因子时要继续补"}, {"key": "B", "title": "先做评分服务版", "best_for": "长期沉淀评分规则、反馈和版本管理", "suggested_capability": "FastAPI + Redis + PostgreSQL + 评分服务", "rationale": "更适合稳定承接每日评分和后续优化", "tradeoff": "复杂度高于 A"}, {"key": "C", "title": "直接做投研分析底座", "best_for": "多团队共用、多策略扩展和实验", "suggested_capability": "因子服务、分析接口、策略实验层", "rationale": "长期上限更高", "tradeoff": "首版投入最大"}, ], "recommendation": {"key": "B", "reason": "先做评分服务版,更适合稳定承接每日评分和后续反馈优化。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } else: payload = { "opening": "🧭 我先按“平台运维团队要长期使用”这个方向继续收。", "focus": "现在最关键的,是先把第一版技术路线定住。", "options": [ {"key": "A", "title": "先做脚本增强版", "best_for": "快速验证闭环", "suggested_capability": "FastAPI + Shell/Ansible + PostgreSQL", "rationale": "最快落地", "tradeoff": "平台化弱"}, {"key": "B", "title": "先做任务编排平台版", "best_for": "长期做任务状态和追踪", "suggested_capability": "FastAPI + React + Redis + Celery + PostgreSQL", "rationale": "平衡落地和扩展", "tradeoff": "复杂度高于 A"}, {"key": "C", "title": "直接做长期能力底座", "best_for": "多团队标准化复用", "suggested_capability": "FastAPI + React + PostgreSQL + 规则配置 + 审计日志", "rationale": "上限高", "tradeoff": "门槛最高"}, ], "recommendation": {"key": "B", "reason": "它最适合把任务编排和治理能力留出空间。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } elif '"missing_field": "first_scope"' in prompt: if is_scoring_context: payload = { "opening": "🧭 我先按“评分服务版”继续往下收。", "focus": "这一步要定第一版先证明什么价值。", "options": [ {"key": "A", "title": "每日评分闭环", "best_for": "先证明每日分数能稳定产出并被查看", "suggested_capability": "每日评分、重点股票列表、查看记录", "rationale": "最能体现首版核心价值", "tradeoff": "解释能力先做轻一点"}, {"key": "B", "title": "评分解释闭环", "best_for": "先证明用户能理解分数为什么这么给", "suggested_capability": "评分解释、指标拆解、结果回看", "rationale": "更适合投研场景验证可解释性", "tradeoff": "产出节奏会比 A 慢一点"}, {"key": "C", "title": "反馈优化闭环", "best_for": "先把用户反馈接回来用于后续优化", "suggested_capability": "反馈记录、评分修正、版本对比", "rationale": "利于后续持续调优", "tradeoff": "首版会多一层反馈管理复杂度"}, ], "recommendation": {"key": "A", "reason": "MVP 最先要证明每日评分结果能稳定产出并被实际查看。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } else: payload = { "opening": "🧭 我先按“任务编排平台版”继续往下收。", "focus": "这一步要定第一版到底先证明什么价值。", "options": [ {"key": "A", "title": "批量执行闭环", "best_for": "先证明任务能跑通", "suggested_capability": "任务创建、执行日志、失败重试", "rationale": "最能体现核心价值", "tradeoff": "治理稍后补"}, {"key": "B", "title": "责任字段闭环", "best_for": "先把责任人和截止时间稳定下来", "suggested_capability": "字段抽取、责任校验、确认门禁", "rationale": "更适合项目管理试点", "tradeoff": "自动执行能力稍后补"}, {"key": "C", "title": "治理闭环", "best_for": "先控制上线风险", "suggested_capability": "审批、审计、权限", "rationale": "责任边界清楚", "tradeoff": "可能做重"}, ], "recommendation": {"key": "A", "reason": "MVP 最先要证明核心闭环能跑起来。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } else: payload = { "opening": "🧭 我先按“当前范围”继续收尾。", "focus": "现在要拍板第一版推进方式。", "options": [ {"key": "A", "title": "内部 PoC", "best_for": "低风险试错", "suggested_capability": "快速反馈", "rationale": "最轻", "tradeoff": "真实问题暴露少"}, {"key": "B", "title": "项目级试点", "best_for": "真实项目验证", "suggested_capability": "试点接入、基础审批、基础回滚", "rationale": "更能验证价值", "tradeoff": "需要负责人配合"}, {"key": "C", "title": "平台化上线", "best_for": "多团队接入", "suggested_capability": "统一权限、统一规范", "rationale": "长期价值最大", "tradeoff": "投入最高"}, ], "recommendation": {"key": "B", "reason": "项目级试点最适合真实验收。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } return StructuredExtractionResult( payload=payload, source_field="content", provider="fake", model="fake", validation="ok", ) monkeypatch.setattr( mixin_execute_query_project.llm_service, "generate_structured_object", fake_generate_structured_object, ) def _install_requirement_plan_query_bundle( monkeypatch, *, message_intent: str = "requirement_plan", consultant_mode_preference: str = "clarify", topic_switch: str | None = None, ) -> None: async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): payload = dict(metadata or {}) _ = message_text effective_topic_switch = topic_switch if effective_topic_switch is None: explicit_topic_switch = str(payload.get("topic_switch") or "").strip() if explicit_topic_switch: effective_topic_switch = explicit_topic_switch if effective_topic_switch is None: has_thread = isinstance(payload.get("thread_context"), dict) and bool(payload.get("thread_context")) has_active_requirement = bool(payload.get("active_requirement_plan_present")) effective_topic_switch = "continue" if has_thread or has_active_requirement else "new" return WorkflowBundle( bundle_id="bundle-plan-query", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.92, metadata={"message_intent": message_intent}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": message_intent, "consultant_mode_preference": consultant_mode_preference, "topic_switch": effective_topic_switch, }, ) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) @pytest.mark.asyncio async def test_process_text_route_uses_pmflow_manual_ingress(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel: def __init__(self, db, tenant_id: int): captured["init_db"] = db captured["init_tenant_id"] = tenant_id async def process_ingress(self, **kwargs): captured["ingress_kwargs"] = kwargs return { "handled": True, "status": "answered", "classification": "question", "direct_reply_required": True, "direct_reply_text": "当前主链正常。", } monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) request = PMOProcessTextRequest( message_text="现在进展怎么样?", sender="产品经理A", source_message_id="manual-1", project_id=11, ) current_user = SimpleNamespace(name="ignored", username="ignored") response = await routes_core.process_text( request=request, current_user=current_user, db=object(), tenant_id=5, ) assert response["code"] == 0 data = response["data"] assert data["status"] == "answered" kwargs = captured["ingress_kwargs"] assert isinstance(kwargs, dict) assert kwargs["source_system"] == "pmflow_manual" assert kwargs["source_message_id"] == "manual-1" assert kwargs["message_text"] == "现在进展怎么样?" assert kwargs["sender"] == "产品经理A" assert kwargs["channel_ref"] == "pmflow_manual" assert kwargs["chat_type"] == "manual" assert kwargs["project_hint_id"] == 11 @pytest.mark.asyncio async def test_process_text_route_rejects_unhandled_result(monkeypatch) -> None: class FakeKernel: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def process_ingress(self, **kwargs): _ = kwargs return {"handled": False, "reason": "kernel_not_handled"} monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) request = PMOProcessTextRequest(message_text="hello") current_user = SimpleNamespace(name="u", username="u") with pytest.raises(HTTPException) as exc_info: await routes_core.process_text( request=request, current_user=current_user, db=object(), tenant_id=1, ) assert exc_info.value.status_code == 400 assert str(exc_info.value.detail) == "kernel_not_handled" @pytest.mark.asyncio async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): captured["tenant_id"] = tenant_id _ = db async def classify_intent(self, text: str): captured["classified_text"] = text return { "intent": "mixed", "question_part": "现在进展如何", "statement_part": "新增任务确认流", } async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 9 monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) kernel = FakeKernel() clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( source_system="pmflow_manual", source_message_id="manual-msg-2", channel_ref="pmflow-chat", source_sender_open_id="ou-pmflow-1", thread_context={"reply_to_message_id": "reply-1"}, message_text="新增任务确认流,也同步一下当前进展", project_hint_id=22, ) assert clean_text == "新增任务确认流" assert mixed_meta["mixed_intent"] is True assert mixed_meta["mixed_question_part"] == "现在进展如何" assert mixed_meta["mixed_signal_part"] == "新增任务确认流" assert "[回答] 进展稳定" in str(mixed_meta.get("mixed_sidecar_reply") or "") answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_via"] == "pmflow_manual" assert answer_kwargs["project_id"] is None assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["source_sender_open_id"] == "ou-pmflow-1" assert answer_kwargs["thread_context"] == {"reply_to_message_id": "reply-1"} assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["conversation_key"] == "manual-msg-2" @pytest.mark.asyncio async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.draft_called = False async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "确认", "synthetic_message_id": "s-msg-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=100), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=55), "session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), } async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): self.draft_called = True _ = kwargs return { "handled": True, "status": "answered", "classification": "command", "direct_reply_required": True, "direct_reply_text": "已确认草稿并继续执行。", } def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False async def _prepare_mixed_sidecar_context(self, **kwargs): _ = kwargs raise AssertionError("draft_pending_confirm flow should bypass mixed sidecar precheck") kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-1", message_text="确认", sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}}, ) assert kernel.draft_called is True assert result["status"] == "answered" assert "草稿" in result["direct_reply_text"] @pytest.mark.asyncio async def test_process_ingress_binds_active_draft_before_query_for_regenerate_request(monkeypatch) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.draft_called = False async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "再出一版草稿", "synthetic_message_id": "s-msg-regen-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=101), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=56), "session": SimpleNamespace(last_intent="query.read_only"), } async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): self.draft_called = True _ = kwargs return { "handled": True, "status": "answered", "classification": "command", "direct_reply_required": True, "direct_reply_text": "我先基于当前草稿再整理一版。", } def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False async def _prepare_mixed_sidecar_context(self, **kwargs): _ = kwargs raise AssertionError("active draft regenerate request should not fall back to query sidecar") async def _fake_has_active_draft_session(self, **kwargs): _ = self _ = kwargs return True monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-regen-1", message_text="再出一版草稿", sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", ) assert kernel.draft_called is True assert result["status"] == "answered" assert "当前草稿" in result["direct_reply_text"] @pytest.mark.asyncio async def test_process_ingress_binds_active_requirement_before_query_for_short_followup(monkeypatch) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.bound = False async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "信号源呢?", "synthetic_message_id": "s-msg-req-followup-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=102), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=57), "session": SimpleNamespace(last_intent="query.read_only"), } async def _maybe_handle_active_context_followup(self, **kwargs): if kwargs["force_bind"] is not True: raise AssertionError("active requirement followup should return before non-forced retry") self.bound = True return { "handled": True, "status": "answered", "classification": "question", "direct_reply_required": True, "direct_reply_text": "我先把外部信号源补到当前需求里。", } async def _should_route_to_draft_confirmation_session(self, **kwargs): _ = kwargs raise AssertionError("active requirement followup should bypass draft confirmation routing") async def _prepare_mixed_sidecar_context(self, **kwargs): _ = kwargs raise AssertionError("active requirement followup should not fall back to query sidecar") def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-req-followup-1", message_text="信号源呢?", sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", ) assert kernel.bound is True assert result["status"] == "answered" assert "外部信号源" in result["direct_reply_text"] @pytest.mark.asyncio async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(monkeypatch) -> None: captured: dict[str, object] = {"calls": []} class FakeMountedAIRuntimeWriteService: def __init__(self, db) -> None: captured["db"] = db async def begin_ingress_run(self, **kwargs): captured["calls"].append(("begin", kwargs)) return SimpleNamespace( topic_workspace=SimpleNamespace(id=91, tenant_id=1), conversation_run=SimpleNamespace(id=92), context_layers_hit={"current_message": True, "thread": True, "topic": False}, ) async def record_orchestrator_decision(self, **kwargs): captured["calls"].append(("record", kwargs)) return "plan" async def record_agent_trace(self, **kwargs): captured["calls"].append(("agent_trace", kwargs)) async def finalize_run(self, **kwargs): captured["calls"].append(("finalize", kwargs)) class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "确认", "synthetic_message_id": "s-msg-mounted-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=100), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=55), "session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), } async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): _ = kwargs return { "handled": True, "status": "answered", "classification": "command", "workflow_route": "followup.confirm", "reply_template_key": "action_succeeded", "work_item_id": 55, "direct_reply_required": True, "direct_reply_text": "已确认草稿并继续执行。", } def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False async def _prepare_mixed_sidecar_context(self, **kwargs): _ = kwargs raise AssertionError("draft confirmation flow should bypass mixed sidecar precheck") monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-mounted-1", message_text="确认", sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}}, ) assert result["status"] == "answered" assert [name for name, _ in captured["calls"]] == ["begin", "record", "agent_trace", "finalize"] begin_kwargs = captured["calls"][0][1] record_kwargs = captured["calls"][1][1] agent_trace_kwargs = captured["calls"][2][1] finalize_kwargs = captured["calls"][3][1] assert begin_kwargs["source_system"] == "feishu" assert begin_kwargs["source_message_id"] == "s-msg-mounted-1" assert record_kwargs["workflow_route"] == "followup.confirm" assert agent_trace_kwargs["agent_name"] not in {"ConversationOrchestrator", "ReplyComposer"} assert agent_trace_kwargs["output_summary"]["workflow_route"] == "followup.confirm" assert finalize_kwargs["result"]["direct_reply_text"] == "已确认草稿并继续执行。" @pytest.mark.asyncio async def test_process_ingress_records_semantic_shadow_before_legacy_finalize(monkeypatch) -> None: from app.services.ai_pmo.semantic_context.decision_schema import ModeCard, SemanticDecision from app.services.ai_pmo.semantic_context.packet_schema import ( SemanticContextPacket, SemanticContextThread, SemanticContextTopic, ) captured: dict[str, object] = {"calls": []} class FakeMountedAIRuntimeWriteService: def __init__(self, db) -> None: captured["db"] = db async def begin_ingress_run(self, **kwargs): captured["calls"].append(("begin", kwargs)) return SimpleNamespace( topic_workspace=SimpleNamespace(id=191, tenant_id=1, plan_status="idle"), conversation_run=SimpleNamespace(id=192, source_message_id="s-msg-semantic-shadow"), context_layers_hit={"current_message": True, "thread": False, "topic": False}, ) async def record_semantic_shadow(self, **kwargs): captured["calls"].append(("semantic_shadow", kwargs)) async def record_orchestrator_decision(self, **kwargs): captured["calls"].append(("record", kwargs)) return "chat" async def record_agent_trace(self, **kwargs): captured["calls"].append(("agent_trace", kwargs)) async def finalize_run(self, **kwargs): captured["calls"].append(("finalize", kwargs)) class FakeSemanticContextAssembler: def __init__(self, db) -> None: captured["semantic_db"] = db async def assemble(self, **kwargs): captured["semantic_packet_kwargs"] = kwargs return SemanticContextPacket( tenant_id=1, operator_user_id=8, topic_workspace_id=191, latest_user_message=str(kwargs["latest_user_message"]), topic=SemanticContextTopic( topic_workspace_id=191, topic_key="feishu:chat-shadow:root:om-root-shadow", source_system="feishu", channel_ref="chat-shadow", conversation_scope_key="feishu:chat-shadow:ou-shadow", thread_key="thread-shadow", mode="plan", status="active", plan_status="collecting", facts={"title": "语义 shadow"}, goals=["统一语义入口"], constraints={"must_not": ["db.write"]}, metadata={"source": "test"}, ), thread=SemanticContextThread( thread_key="thread-shadow", conversation_scope_key="feishu:chat-shadow:ou-shadow", thread_summary="继续整理语义上下文", recent_turns=[], open_questions=["是否继续当前 topic"], ), mode_card=ModeCard( workflow_mode="PLAN", mode_state="ACTIVE", allowed_actions=["reply.compose", "draft.patch"], forbidden_actions=["project.create", "db.write"], exit_conditions=["用户明确要求开始执行"], ), ) class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): captured["semantic_packet"] = packet.model_dump(mode="json") return SemanticDecision( topic_relation="continue_current_topic", mode_decision="stay_plan", draft_patch={"goal": "继续整理语义上下文"}, assistant_reply="我先继续整理上下文。", requires_confirmation=False, requested_actions=["reply.compose", "draft.patch"], confidence=0.88, ) class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.context_assembler = SimpleNamespace(assemble_world_model=self._assemble_world_model) self.route_resolver = SimpleNamespace(from_canonical=self._route_from_canonical) self.decision_engine = SimpleNamespace(decide=lambda **_: {"decision": "unused"}) async def _assemble_world_model(self, **kwargs): _ = kwargs return None, {"capacity": [], "skill_graph": [], "resolved_project_id": None} def _route_from_canonical(self, **kwargs): _ = kwargs return SimpleNamespace( key="query.answer", family="query", action="answer", requested_count=1, requires_confirmation=False, project_id=None, metadata={}, ) async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "继续整理语义上下文,不直接执行。", "synthetic_message_id": "s-msg-semantic-shadow", "closure_payload": {}, "ingest_event": SimpleNamespace(id=200), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=55, state=None, context_snapshot=None, intent=None, project_id=None), "session": SimpleNamespace(last_intent="query.answer"), "session_key": "session-shadow", } def _merge_ingress_thread_context(self, **kwargs): _ = kwargs return None def _should_route_to_draft_confirmation(self, **kwargs): _ = kwargs return False def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False async def _prepare_mixed_sidecar_context(self, **kwargs): return kwargs["message_text"], {} async def _resolve_intent(self, **kwargs): _ = kwargs return CanonicalIntent( name=mixin_entry.INTENT_IGNORE, confidence=0.91, project_id=None, signal_title="", signal_summary="", metadata={"message_intent": "requirement_plan"}, ), "llm" def _coerce_intent_for_query_scene(self, **kwargs): return kwargs["intent"] def _enrich_signal_source_intent_from_closure(self, **kwargs): return kwargs["intent"] async def _finalize_ignored_ingress(self, **kwargs): _ = kwargs return { "handled": True, "status": "ignored", "classification": "ignored", "direct_reply_required": False, "direct_reply_text": "这轮先按旧链路忽略处理。", } def _schedule_portfolio_snapshot_persist(self, **kwargs): _ = kwargs return None monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService) monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-shadow-1", message_text="继续整理语义上下文,不直接执行。", sender="tester", channel_ref="chat-shadow", source_sender_open_id="ou-shadow", ) assert result["status"] == "ignored" assert [name for name, _ in captured["calls"]] == [ "begin", "semantic_shadow", "record", "agent_trace", "finalize", ] semantic_kwargs = captured["calls"][1][1] assert semantic_kwargs["semantic_packet_summary"]["packet_version"] == "v1" assert semantic_kwargs["semantic_packet_summary"]["topic_key"] == "feishu:chat-shadow:root:om-root-shadow" assert semantic_kwargs["semantic_decision"]["mode_decision"] == "stay_plan" assert semantic_kwargs["semantic_gate_result"]["accepted"] is True assert semantic_kwargs["semantic_diff"]["legacy_route"] == "query.answer" def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options=None, ) is False assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options={"thread_context": {"thread_id": "th_123"}}, ) is False assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options={"thread_context": {"root_message_id": "root_123"}}, ) is False assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="创建时间是多少?", ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, ) is False assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="确认", ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, ) is True assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="工作项ID: 88 创建时间是多少?", ingress_options=None, ) is True def test_natural_revision_does_not_route_to_draft_confirmation_without_reply_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="第一版先做直播录屏和评论回放切片,再加亮点和掉点总结,最后给一份复盘清单。", ingress_options=None, ) is False def test_scope_narrowing_revision_does_not_route_to_draft_confirmation_without_reply_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="第一版先别做太大,先把三件事做好就行:直播录屏和评论回放切片、亮点和掉点自动总结、给团队一份能直接执行的复盘清单。", ingress_options=None, ) is False def test_continue_current_draft_does_not_route_to_draft_confirmation_without_reply_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="按这版继续", ingress_options=None, ) is False def test_continue_current_draft_does_not_route_to_draft_confirmation_without_parse_action_dependency() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="按这版继续", ingress_options=None, ) is False def test_reply_anchor_routes_to_draft_confirmation_without_revision_heuristics() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="保持当前草稿结构并补完整信息", ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}}, ) is True def test_smalltalk_does_not_route_to_draft_confirmation_without_reply_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="收到,辛苦了", ingress_options=None, ) is False def test_new_project_statement_does_not_route_to_existing_draft_without_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="最近直播复盘太慢了,想做个给运营团队用的小工具,这个新需求先叫 FeishuWebFlow-abc。", ingress_options=None, ) is False @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_does_not_hijack_explicit_new_requirement_topic( monkeypatch, ) -> None: called = False class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _fake_has_active_draft_session(self, **kwargs): nonlocal called _ = (self, kwargs) called = True return True kernel = FakeKernel() monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) monkeypatch.setattr( kernel.__class__, "_should_skip_no_anchor_draft_routing_by_bundle", staticmethod(lambda **kwargs: asyncio.sleep(0, result=True)), ) should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), sender="tester", source_system="feishu", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", ingress_options=None, ) assert should_route is False assert called is False def test_should_inherit_recent_feishu_session_context_skips_explicit_new_requirement_topic() -> None: session = SimpleNamespace( sender="feishu:tester", last_active_at=datetime.now(), last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM, ) should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context( message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", session=session, ) assert should_inherit is False def test_should_inherit_recent_feishu_session_context_skips_draft_pending_confirm_session() -> None: session = SimpleNamespace( sender="feishu:tester", last_active_at=datetime.now(), last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM, last_message_text="上一版草稿已经出来了", ) should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context( message_text="这个怎么做", session=session, ) assert should_inherit is False def test_should_inherit_recent_feishu_session_context_skips_non_query_last_intent() -> None: session = SimpleNamespace( sender="feishu:tester", last_active_at=datetime.now(), last_intent="signal_source.onboard", last_message_text="给项目2接一个 RTMP 信号源", ) should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context( message_text="这个怎么做", session=session, ) assert should_inherit is False @pytest.mark.asyncio async def test_explicit_new_requirement_clears_inherited_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 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_clears_stale_session_project_context() -> None: class _FakeDB: async def flush(self): return None class FakeKernel( mixin_entry.KernelIngressFlowEntryMixin, ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin, ): def __init__(self) -> None: self.db = _FakeDB() self.tenant_id = 1 async def _has_active_requirement_plan_workspace(self, **kwargs): _ = kwargs return False kernel = FakeKernel() session = SimpleNamespace( current_project_id=104, last_intent="query.read_only", last_message_text="A", ) result = await kernel._maybe_handle_orphan_requirement_followup( source_system="feishu", source_message_id="msg-orphan-reply-stale-project", channel_ref="chat-active", source_sender_open_id="ou_active", ingress_options=None, message_text="A", session=session, sender="tester", ) assert result is None assert session.current_project_id == 104 assert session.last_intent == "query.read_only" @pytest.mark.asyncio async def test_orphan_requirement_followup_skips_guard_when_active_requirement_session_exists() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 async def _has_active_requirement_plan_workspace(self, **kwargs): _ = kwargs return True kernel = FakeKernel() result = await kernel._maybe_handle_orphan_requirement_followup( source_system="feishu", source_message_id="msg-plan-reply", channel_ref="chat-active", source_sender_open_id="ou_active", ingress_options=None, message_text="A", ) assert result is None def test_world_model_session_project_inheritance_rejects_low_context_followup() -> None: class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin): pass kernel = FakeKernel() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=104, last_active_at=datetime.now(), ) assert kernel._extract_inherited_project_id(session=session, message_text="A") is None assert kernel._extract_inherited_project_id(session=session, message_text="你自行推荐") is None def test_world_model_session_project_inheritance_rejects_stale_low_context_previous_turn() -> None: class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin): pass kernel = FakeKernel() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=104, last_message_text="A", last_active_at=datetime.now(), ) assert kernel._extract_inherited_project_id(session=session, message_text="这个怎么做") is None def test_draft_confirmation_router_keeps_requirement_plan_field_list_in_plan_flow() -> None: session = SimpleNamespace(last_intent="query.read_only") assert ( mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="待办、责任人、截止时间", ingress_options={"thread_context": {"reply_to_message_id": "msg-plan-root"}}, ) is False ) @pytest.mark.asyncio async def test_active_requirement_followup_prefers_current_requirement_context(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _find_active_requirement_plan_workspace(self, **kwargs): captured["workspace_lookup"] = kwargs return ( SimpleNamespace(id=81, plan_status="collecting"), SimpleNamespace(id=82, state="collecting"), ) async def _build_requirement_planning_direct_result(self, **kwargs): captured["direct_kwargs"] = kwargs return { "handled": True, "status": "answered", "classification": "question", "direct_reply_required": True, "direct_reply_text": "我先按当前需求把信号输入这一块补进去。", "reply_template_key": "requirement_plan_followup", } class FakeSemanticContextAssembler: def __init__(self, db) -> None: self.db = db async def assemble(self, **kwargs): captured["packet_kwargs"] = kwargs return SimpleNamespace( mode_card=SimpleNamespace( allowed_actions=[ "reply.compose", "context.followup.current_requirement", "context.followup.independent_workflow", ], forbidden_actions=[], ) ) class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): _ = packet return SimpleNamespace( topic_relation="continue_current_topic", mode_decision="stay_plan", followup_scope="current_requirement", followup_action="none", revision_note="", assistant_reply="我先按当前需求把信号输入这一块补进去。", requires_confirmation=False, requested_actions=["reply.compose", "context.followup.current_requirement"], ) monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) kernel = FakeKernel() result = await kernel._maybe_handle_active_context_followup( source_system="feishu", source_message_id="msg-followup-1", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="信号源呢?", thread_context=None, sender="tester", ) assert result is not None assert result["handled"] is True assert "信号输入" in result["direct_reply_text"] assert captured["packet_kwargs"]["latest_user_message"] == "信号源呢?" assert captured["packet_kwargs"]["ingress_context"]["candidate_kind"] == "active_context_followup" assert captured["direct_kwargs"]["message_text"] == "信号源呢?" @pytest.mark.asyncio async def test_active_context_followup_prefers_active_mounted_draft_before_requirement_lookup(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _find_active_requirement_plan_workspace(self, **kwargs): _ = kwargs raise AssertionError("active mounted draft should bind before requirement workspace lookup") class FakeSemanticContextAssembler: def __init__(self, db) -> None: self.db = db async def assemble(self, **kwargs): captured["packet_kwargs"] = kwargs return SimpleNamespace( mode_card=SimpleNamespace( allowed_actions=["reply.compose", "context.followup.current_draft"], forbidden_actions=[], ) ) class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): _ = packet return SimpleNamespace( topic_relation="continue_current_topic", mode_decision="stay_plan", followup_scope="current_draft", followup_action="revise", revision_note="补充外部信号源。", assistant_reply="", requires_confirmation=False, requested_actions=["reply.compose", "context.followup.current_draft"], ) async def _fake_resolve_active_mounted_draft(self, **kwargs): _ = (self, kwargs) return ( SimpleNamespace(id=92, state="draft_pending_confirm", active_work_item_id=812), SimpleNamespace(id=91, plan_status="draft_pending_confirm"), ) async def _fake_handle_text_reply(self, **kwargs): _ = self captured["draft_kwargs"] = kwargs return { "handled": True, "status": "pending_manual", "followup_reply": "我先在当前草稿里补外部信号源。", } monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) monkeypatch.setattr(DraftConfirmHandler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft) monkeypatch.setattr(DraftConfirmHandler, "handle_text_reply", _fake_handle_text_reply) kernel = FakeKernel() result = await kernel._maybe_handle_active_context_followup( source_system="feishu", source_message_id="msg-followup-draft-priority-1", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="再出一版草稿", thread_context=None, sender="tester", ) assert result is not None assert result["handled"] is True assert "当前草稿" in result["followup_reply"] assert captured["packet_kwargs"]["topic_workspace_id"] == 91 assert captured["packet_kwargs"]["ingress_context"]["active_context_kind"] == "draft_pending_confirm" assert captured["draft_kwargs"]["semantic_followup_override"]["action"] == "revise" @pytest.mark.asyncio async def test_active_context_followup_routes_short_draft_module_question_to_current_draft(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _find_active_requirement_plan_workspace(self, **kwargs): captured["workspace_lookup"] = kwargs return ( SimpleNamespace(id=91, plan_status="awaiting_execute_confirm"), SimpleNamespace(id=92, state="ready"), ) class FakeSemanticContextAssembler: def __init__(self, db) -> None: self.db = db async def assemble(self, **kwargs): captured["packet_kwargs"] = kwargs return SimpleNamespace( mode_card=SimpleNamespace( allowed_actions=["reply.compose", "context.followup.current_draft"], forbidden_actions=[], ) ) class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): _ = packet return SimpleNamespace( topic_relation="continue_current_topic", mode_decision="stay_plan", followup_scope="current_draft", followup_action="revise", revision_note="补充当前草稿里的信号源输入与接入边界。", assistant_reply="", requires_confirmation=False, requested_actions=["reply.compose", "context.followup.current_draft"], ) async def _fake_handle_text_reply(self, **kwargs): _ = self captured["draft_kwargs"] = kwargs return { "handled": True, "status": "pending_manual", "followup_reply": "我先在当前草稿里补信号源输入。", } monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) monkeypatch.setattr(DraftConfirmHandler, "handle_text_reply", _fake_handle_text_reply) kernel = FakeKernel() result = await kernel._maybe_handle_active_context_followup( source_system="feishu", source_message_id="msg-followup-2", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="信号源呢?", thread_context=None, sender="tester", ) assert result is not None assert result["handled"] is True assert "当前草稿" in result["followup_reply"] assert captured["draft_kwargs"]["message_text"] == "信号源呢?" assert captured["draft_kwargs"]["semantic_followup_override"] == { "action": "revise", "revision_note": "补充当前草稿里的信号源输入与接入边界。", "assistant_reply": "", } @pytest.mark.asyncio async def test_active_context_followup_does_not_hijack_explicit_signal_source_request(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _find_active_requirement_plan_workspace(self, **kwargs): captured["workspace_lookup"] = kwargs return ( SimpleNamespace(id=91, plan_status="awaiting_execute_confirm"), SimpleNamespace(id=92, state="ready"), ) class FakeSemanticContextAssembler: def __init__(self, db) -> None: self.db = db async def assemble(self, **kwargs): captured["packet_kwargs"] = kwargs return SimpleNamespace( mode_card=SimpleNamespace( allowed_actions=["reply.compose", "context.followup.independent_workflow"], forbidden_actions=[], ) ) class FakeSemanticDecisionService: def __init__(self, _llm_service) -> None: pass async def decide(self, *, packet): _ = packet return SimpleNamespace( topic_relation="continue_current_topic", mode_decision="answer_only", followup_scope="independent_workflow", followup_action="none", revision_note="", assistant_reply="", requires_confirmation=False, requested_actions=["reply.compose", "context.followup.independent_workflow"], ) monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler) monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService) kernel = FakeKernel() result = await kernel._maybe_handle_active_context_followup( source_system="feishu", source_message_id="msg-followup-3", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。", thread_context=None, sender="tester", ) assert result is None assert captured["packet_kwargs"]["latest_user_message"] == "给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。" @pytest.mark.asyncio async def test_find_active_requirement_plan_workspace_includes_draft_pending_confirm_state( requirement_plan_db_session, ) -> None: class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 7 executor = FakeExecutor() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-draft-pending", source_sender_open_id="ou-draft-pending", sender=None, ) topic = MountedTopicWorkspace( tenant_id=7, topic_key="feishu:chat-draft-pending:message:msg-draft-pending-root", source_system="feishu", channel_ref="chat-draft-pending", conversation_scope_key=conversation_scope_key, mode="plan", status="active", plan_status="draft_pending_confirm", metadata_json={"plan_mode": True}, facts_json={"title": "换季穿搭助手"}, ) requirement_plan_db_session.add(topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=7, topic_workspace_id=int(topic.id), state="draft_pending_confirm", active_work_item_id=321, draft_version=1, requirement_card={"title": "换季穿搭助手"}, draft_payload={"title": "换季穿搭助手"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() found_topic, found_draft = await executor._find_active_requirement_plan_workspace( source_system="feishu", source_message_id="msg-followup-pending", source_chat_id="chat-draft-pending", sender=None, source_sender_open_id="ou-draft-pending", thread_context=None, conversation_key=conversation_scope_key, ) assert found_topic is not None assert found_draft is not None assert int(found_topic.id) == int(topic.id) assert str(found_draft.state) == "draft_pending_confirm" def test_world_model_session_project_inheritance_keeps_meaningful_followup() -> None: class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin): pass kernel = FakeKernel() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=104, last_active_at=datetime.now(), ) assert kernel._extract_inherited_project_id(session=session, message_text="这个怎么做") is None @pytest.mark.asyncio async def test_world_model_resolve_project_does_not_bind_low_context_session_project() -> None: class _ProjectListResult: def scalars(self): return SimpleNamespace(all=lambda: []) class _ProjectOneResult: def scalar_one_or_none(self): return SimpleNamespace(id=104, title="跨境直播复盘", name="跨境直播复盘") class _FakeDB: def __init__(self) -> None: self.execute_calls = 0 async def execute(self, _query): self.execute_calls += 1 if self.execute_calls == 1: return _ProjectListResult() return _ProjectOneResult() class FakeKernel( mixin_project.KernelWorldModelCoreProjectMixin, ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin, ): def __init__(self) -> None: self.db = _FakeDB() self.tenant_id = 1 kernel = FakeKernel() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=104, last_active_at=datetime.now(), ) project = await kernel._resolve_project( message_text="A", project_hint_id=None, query_tables=set(), session=session, ) assert project is None assert kernel.db.execute_calls == 1 def test_mounted_runtime_query_business_review_uses_plan_mode_and_non_executor_agent() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", 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_build_canonical_intent_keeps_project_update_instead_of_dropping_to_ignore() -> None: intent = KernelIntentRulesMixin._build_canonical_intent( intent_name="project.update", message_text="把项目2的 success criteria 改成 48 小时内必须上线。", project_id=2, confidence=0.91, metadata={"field": "success_criteria"}, ) assert intent.name == "project.update" assert intent.project_id == 2 assert intent.metadata["field"] == "success_criteria" def test_explicit_named_project_create_skips_existing_project_name_binding() -> None: assert mixin_project.KernelWorldModelCoreProjectMixin._should_skip_implicit_project_binding( message_text=( "最近我们做跨境直播复盘太慢了,想做个给运营团队用的小工具," "这个需求先叫 FeishuWebFlow-1775301894322。" ), ) is True def test_project_lookup_question_keeps_existing_project_name_binding() -> None: assert mixin_project.KernelWorldModelCoreProjectMixin._should_skip_implicit_project_binding( message_text="帮我看下跨境直播复盘这个项目现在怎么样?", ) is False @pytest.mark.asyncio async def test_draft_confirm_handler_prefers_latest_sender_scope_candidate_without_anchor() -> None: latest_work_item = SimpleNamespace( id=202, state="draft_pending_confirm", source_message_id="msg-latest", ) old_work_item = SimpleNamespace( id=101, state="draft_pending_confirm", source_message_id="msg-old", ) ingest_payload = { "sender": "tester", "channel_ref": "chat-1", "source_sender_open_id": "ou_123", } rows = [ (latest_work_item, SimpleNamespace(payload=dict(ingest_payload))), (old_work_item, SimpleNamespace(payload=dict(ingest_payload))), ] class FakeResult: def __init__(self, value): self._value = value def all(self): return list(self._value) class FakeDB: async def execute(self, _stmt): return FakeResult(rows) handler = DraftConfirmHandler(SimpleNamespace(db=FakeDB(), tenant_id=1)) target = await handler._resolve_pending_draft( sender="tester", source_system="feishu", channel_ref="chat-1", source_sender_open_id="ou_123", explicit_work_item_id=None, thread_context=None, ) assert target == rows[0] @pytest.mark.asyncio async def test_draft_confirm_handler_skips_followup_wrapper_when_resolving_latest_draft() -> None: followup_wrapper = SimpleNamespace( id=303, state="draft_pending_confirm", source_message_id="msg-followup", intent="followup.confirm", ) latest_project_draft = SimpleNamespace( id=202, state="draft_pending_confirm", source_message_id="msg-project", intent="project_create.create", ) ingest_payload = { "sender": "tester", "channel_ref": "chat-1", "source_sender_open_id": "ou_123", } rows = [ (followup_wrapper, SimpleNamespace(payload=dict(ingest_payload))), (latest_project_draft, SimpleNamespace(payload=dict(ingest_payload))), ] class FakeResult: def __init__(self, value): self._value = value def all(self): return list(self._value) class FakeDB: async def execute(self, _stmt): return FakeResult(rows) handler = DraftConfirmHandler(SimpleNamespace(db=FakeDB(), tenant_id=1)) target = await handler._resolve_pending_draft( sender="tester", source_system="feishu", channel_ref="chat-1", source_sender_open_id="ou_123", explicit_work_item_id=None, thread_context=None, ) assert target == rows[1] def test_build_feishu_thread_key_prefers_actor_scope_without_anchor() -> None: assert build_feishu_thread_key( source_chat_id="chat-1", source_sender_open_id="ou_dm_1", sender="产品经理A", source_message_id="om_current_message", thread_context=None, ) == "feishu:chat-1:ou_dm_1" def test_low_value_closure_payload_helper_handles_summary_text() -> None: assert helpers_inbox_filters._is_low_value_closure_payload( {"closure_summary": "待补充核心痛点"} ) is False def test_serialize_work_item_summary_handles_message_text_without_nameerror() -> None: row = SimpleNamespace( id=9, source_system="pmflow_manual", source_message_id="msg-9", draft_content={}, state="received", review_summary={}, action_bundle={}, intent="query.read_only", project_id=None, signal_id=None, reply_template_key=None, execution_result={}, draft_expires_at=None, draft_version=0, confirm_message_id=None, created_at=None, updated_at=None, decision_level="portfolio", ) ingest = SimpleNamespace(payload={"message_text": " 最近 状态 怎么样 "}) payload = helpers_inbox_payload._serialize_work_item_summary( row=row, ingest=ingest, project=None, ) assert payload["source_message_text"] == "最近 状态 怎么样" def test_coerce_rag_business_review_scene_keeps_rag_ask_on_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="signal.create", confidence=0.91, project_id=None, signal_title="大模型 API 聚合平台", signal_summary="我现在要做一个大模型api聚合平台,你帮我分析下要怎么做,给出mvp需求", metadata={"llm_function": "classify_intent_ai_pmo_kernel"}, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="pmflow_manual", message_text="我现在要做一个大模型api聚合平台,你帮我分析下要怎么做,给出mvp需求", rag_request_context={ "asked_via": "pmflow_manual", "action_mode": "auto", "message_intent": "business_review", "consultant_mode_preference": "clarify", }, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "business_review" assert coerced.metadata["consultant_mode_preference"] == "clarify" assert coerced.metadata["workflow_route"] == "query.answer" assert "semantic_bundle" not in coerced.metadata def test_coerce_feishu_direct_business_review_metadata_keeps_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="signal.create", confidence=0.93, project_id=None, signal_title="大模型 API 聚合平台", signal_summary="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,", metadata={ "llm_function": "classify_intent_ai_pmo_kernel", "message_intent": "business_review", "review_framework": "five_lenses", "consultant_mode_preference": "clarify", }, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="feishu", message_text="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,", rag_request_context=None, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "business_review" assert coerced.metadata["workflow_route"] == "query.answer" def test_coerce_feishu_deferred_requirement_discussion_to_query_chain_from_direct_metadata() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="project.create", confidence=0.94, project_id=None, signal_title="直播复盘工具", signal_summary="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。", metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="feishu", message_text="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。", rag_request_context=None, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "requirement_plan" assert coerced.metadata["consultant_mode_preference"] == "clarify" assert coerced.metadata["workflow_route"] == "query.answer" def test_coerce_feishu_requirement_plan_from_direct_metadata_keeps_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="project.create", confidence=0.95, project_id=None, signal_title="A股趋势日评打分工具", signal_summary="做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", metadata={ "llm_function": "classify_intent_ai_pmo_kernel", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="feishu", message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", rag_request_context=None, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "requirement_plan" assert coerced.metadata["consultant_mode_preference"] == "clarify" assert coerced.metadata["workflow_route"] == "query.answer" def test_coerce_feishu_assignee_recommendation_to_query_chain_from_direct_metadata() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="project.create", confidence=0.88, project_id=None, signal_title="直播复盘工具", signal_summary="那你觉得分配给谁做比较合适?", metadata={ "message_intent": "business_review", "consultant_mode_preference": "advise", }, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="feishu", message_text="那你觉得分配给谁做比较合适?", rag_request_context=None, semantic_bundle=None, world_model={}, ) assert coerced.name == "query.read_only" assert coerced.metadata["message_intent"] == "business_review" assert coerced.metadata["consultant_mode_preference"] == "advise" assert coerced.metadata["workflow_route"] == "query.answer" def test_should_inherit_recent_feishu_session_context_is_disabled_for_query_session() -> None: session = SimpleNamespace( sender="feishu:tester", last_active_at=datetime.now(), last_intent="query.read_only", last_message_text="上一轮在聊项目画像", ) should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context( message_text="我需要对应的需求mvp", session=session, ) assert should_inherit is False @pytest.mark.asyncio async def test_kernel_intent_resolve_does_not_inherit_recent_feishu_session_context_without_reply_anchor() -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): captured.update(kwargs) return CanonicalIntent( name="query.read_only", confidence=0.93, project_id=21, signal_title="", signal_summary="", metadata={}, ) resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=21, last_active_at=datetime.now(), ) _, source = await resolver._resolve_intent( message_text="我需要对应的需求mvp", session=session, world_model={}, thread_context=None, ) assert source == "llm_only" assert captured["session_last_intent_override"] == "" assert captured["session_project_id_override"] is None assert captured["inherit_session_context"] is False @pytest.mark.asyncio async def test_kernel_intent_resolve_short_confirm_does_not_inherit_query_session_intent() -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 def _is_short_confirmation_message(self, **kwargs): _ = kwargs return True @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): captured.update(kwargs) return CanonicalIntent( name="query.read_only", confidence=0.81, project_id=None, signal_title="", signal_summary="", metadata={}, ) resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=21, last_active_at=datetime.now(), ) intent, source = await resolver._resolve_intent( message_text="确认", session=session, world_model={}, thread_context={"reply_to_message_id": "msg-1"}, ) assert source == "llm_only" assert intent.name == "query.read_only" assert captured["inherit_session_context"] is True @pytest.mark.asyncio async def test_kernel_intent_resolve_short_confirm_stays_on_llm_path_with_bound_context() -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): captured.update(kwargs) return CanonicalIntent( name="followup.confirm", confidence=0.91, project_id=21, signal_title="", signal_summary="", metadata={"followup_action": "confirm"}, ) resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="task.create", current_project_id=21, last_message_text="给项目 21 新建任务:补齐信号源配置", last_active_at=datetime.now(), ) intent, source = await resolver._resolve_intent( message_text="确认", session=session, world_model={}, thread_context={"reply_to_message_id": "msg-1"}, ) assert source == "llm_only" assert intent.name == "followup.confirm" assert intent.project_id == 21 assert captured["inherit_session_context"] is True assert captured["session_last_intent_override"] == "task.create" @pytest.mark.asyncio async def test_kernel_intent_resolve_suppresses_session_context_for_low_context_followup() -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentResolveMixin): def __init__(self) -> None: self.tenant_id = 1 def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): captured.update(kwargs) return CanonicalIntent( name="query.read_only", confidence=0.79, project_id=None, signal_title="", signal_summary="", metadata={}, ) resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=104, last_active_at=datetime.now(), ) _, source = await resolver._resolve_intent( message_text="A", session=session, world_model={}, thread_context=None, ) assert source == "llm_only" assert captured["session_last_intent_override"] == "" assert captured["session_project_id_override"] is None assert captured["inherit_session_context"] is False def test_recover_session_followup_write_intent_skips_query_parent_even_if_previous_text_looks_like_write() -> None: class FakeResolver(KernelIntentRulesMixin, KernelIntentResolveMixin): def __init__(self) -> None: self.tenant_id = 1 self.intent_resolver = SimpleNamespace(resolve=lambda **kwargs: CanonicalIntent("project.create", 0.9, 12, "", "", {})) resolver = FakeResolver() session = SimpleNamespace( last_intent="query.read_only", last_message_text="帮我创建一个新项目,名字叫 Alpha", current_project_id=12, last_active_at=datetime.now(), ) rule_intent = CanonicalIntent("query.read_only", 0.6, None, "", "", {"fallback": True, "reason": "default_query_fallback"}) recovered = resolver._recover_session_followup_write_intent( message_text="确认", session=session, world_model={}, rule_intent=rule_intent, ) assert recovered is None def test_recover_session_followup_write_intent_is_disabled_even_for_write_parent() -> None: class FakeResolver(KernelIntentRulesMixin, KernelIntentResolveMixin): def __init__(self) -> None: self.tenant_id = 1 self.intent_resolver = SimpleNamespace(resolve=lambda **kwargs: CanonicalIntent("project.create", 0.9, 12, "", "", {})) resolver = FakeResolver() session = SimpleNamespace( last_intent="project.create", last_message_text="帮我创建一个新项目,名字叫 Alpha", current_project_id=12, last_active_at=datetime.now(), ) rule_intent = CanonicalIntent("query.read_only", 0.6, None, "", "", {"fallback": True, "reason": "default_query_fallback"}) recovered = resolver._recover_session_followup_write_intent( message_text="确认", session=session, world_model={}, rule_intent=rule_intent, ) assert recovered is None @pytest.mark.asyncio async def test_kernel_intent_resolve_suppresses_session_context_after_low_context_previous_turn() -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentResolveMixin): def __init__(self) -> None: self.tenant_id = 1 def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): captured.update(kwargs) return CanonicalIntent( name="query.read_only", confidence=0.79, project_id=None, signal_title="", signal_summary="", metadata={}, ) resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=104, last_message_text="A", last_active_at=datetime.now(), ) _, source = await resolver._resolve_intent( message_text="这个怎么做", session=session, world_model={}, thread_context=None, ) assert source == "llm_only" assert captured["session_last_intent_override"] == "" assert captured["session_project_id_override"] is None assert captured["inherit_session_context"] is False @pytest.mark.asyncio async def test_kernel_intent_resolve_no_longer_calls_short_confirmation_helper() -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentResolveMixin): def __init__(self) -> None: self.tenant_id = 1 def _is_short_confirmation_message(self, **kwargs): _ = kwargs raise AssertionError("legacy short confirmation helper should not be used") @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): captured.update(kwargs) return CanonicalIntent( name="query.read_only", confidence=0.79, project_id=None, signal_title="", signal_summary="", metadata={}, ) resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="project.create", current_project_id=104, last_message_text="帮我创建一个新项目", last_active_at=datetime.now(), ) _, source = await resolver._resolve_intent( message_text="确认", session=session, world_model={}, thread_context={"reply_to_message_id": "reply-1"}, ) assert source == "llm_only" assert captured["session_last_intent_override"] == "project.create" assert captured["session_project_id_override"] == 104 assert captured["inherit_session_context"] is True @pytest.mark.asyncio async def test_kernel_intent_resolve_uses_llm_for_signal_source_request() -> None: class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 self.called = False def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): _ = kwargs self.called = True return CanonicalIntent( name="signal_source.onboard", confidence=0.92, project_id=2, signal_title="", signal_summary="", metadata={"source_request": True}, ) 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 == "llm_only" assert resolver.called is True assert intent.name == "signal_source.onboard" assert intent.project_id == 2 assert intent.metadata["source_request"] is True @pytest.mark.asyncio async def test_kernel_intent_llm_prompt_guides_natural_problem_statement_to_project_create(monkeypatch) -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentLLMMixin): def __init__(self) -> None: self.tenant_id = 1 self.db = object() def _build_canonical_intent(self, *, intent_name, message_text, project_id, confidence, metadata): # noqa: ANN001 return CanonicalIntent( name=intent_name, confidence=confidence, project_id=project_id, signal_title="", signal_summary=message_text[:300], metadata=metadata, ) async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 captured["prompt"] = prompt captured["kwargs"] = kwargs return StructuredExtractionResult( payload={ "family": "project_create", "action": "create", "intent": "project.create", "confidence": 0.93, "project_id": None, "requested_count": 1, "requires_confirmation": False, "parameters": {}, "reasoning": "用户在描述真实业务痛点,并明确表达想做一个工具解决。", }, source_field="generated_text", provider="mock", model="mock-model", raw_text='{"intent":"project.create"}', validation="ok", ) monkeypatch.setattr( "app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_llm.llm_service.generate_structured_object", fake_generate_structured_object, ) resolver = FakeResolver() intent = await resolver._resolve_intent_with_llm( message_text="最近直播复盘太慢了,我想做个给运营用的小工具,能自动切片、总结亮点掉点。", session=None, world_model={}, rule_intent=None, ) assert intent is not None assert intent.name == "project.create" prompt = str(captured["prompt"] or "") assert "自然语言" in prompt assert "想做个工具" in prompt assert "先帮我分析" in prompt @pytest.mark.asyncio async def test_kernel_intent_llm_prompt_can_suppress_stale_session_project(monkeypatch) -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentLLMMixin): def __init__(self) -> None: self.tenant_id = 1 self.db = object() def _build_canonical_intent(self, *, intent_name, message_text, project_id, confidence, metadata): # noqa: ANN001 return CanonicalIntent( name=intent_name, confidence=confidence, project_id=project_id, signal_title="", signal_summary=message_text[:300], metadata=metadata, ) async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 captured["prompt"] = prompt captured["kwargs"] = kwargs return StructuredExtractionResult( payload={ "family": "query", "action": "answer", "intent": "query.read_only", "confidence": 0.74, "project_id": 0, "requested_count": 1, "requires_confirmation": False, "parameters": {}, "reasoning": "低上下文输入,不能继承过期项目。", }, source_field="generated_text", provider="mock", model="mock-model", raw_text='{"intent":"query.read_only"}', validation="ok", ) monkeypatch.setattr( "app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_llm.llm_service.generate_structured_object", fake_generate_structured_object, ) resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=104, last_active_at=datetime.now(), ) intent = await resolver._resolve_intent_with_llm( message_text="A", session=session, world_model={}, rule_intent=None, session_last_intent_override="", session_project_id_override=None, inherit_session_context=False, ) assert intent is not None prompt = str(captured["prompt"] or "") assert "- session_last_intent: " in prompt assert "- session_project_id: None" in prompt assert "104" not in prompt def test_reply_renderer_formats_project_delete_pending_and_success() -> None: renderer = ReplyRenderer( state_waiting_manual="waiting_manual", state_failed="failed", receipt_phase_action="action", receipt_phase_pending="pending", receipt_phase_error="error", ) intent = CanonicalIntent( name="project.delete", confidence=0.96, project_id=None, signal_title="", signal_summary="", metadata={"llm_parameters": {"project_id": 12, "project_title": "重复项目"}}, ) pending = renderer.render( work_state="waiting_manual", intent=intent, decision_record={"approval_requirement": {"message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。"}}, execution_result={ "status": "waiting_manual", "action_contract": { "operation_code": "project.delete", "params": {"project_id": 12, "project_title": "重复项目"}, }, }, ) assert pending["template_key"] == "pending_manual" assert "重复项目" in pending["text"] assert "人工确认" in pending["text"] success = renderer.render( work_state="succeeded", intent=intent, decision_record={"approval_requirement": {}}, execution_result={"status": "succeeded", "operation_code": "project.delete", "project_id": 12}, ) assert success["template_key"] == "action_succeeded" assert "已删除项目#12《重复项目》" in success["text"] @pytest.mark.asyncio async def test_feishu_outbox_service_enqueue_preserves_requirement_plan_text_above_2000_chars( feishu_outbox_db_session, ) -> None: text = _build_long_requirement_plan_reply_text() assert len(text) > 2000 outbox_id = await FeishuOutboxService.enqueue( feishu_outbox_db_session, tenant_id=3, chat_id="oc_requirement_plan_budget", text=text, source_message_id="msg-requirement-plan-budget-service", ) row = ( await feishu_outbox_db_session.execute( select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == int(outbox_id or 0)) ) ).scalar_one() assert row.message_text == text assert "C. 长期能力底座" in row.message_text assert "⭐ 当前我更推荐:B" in row.message_text @pytest.mark.asyncio async def test_feishu_reply_outbox_mixin_enqueue_preserves_requirement_plan_text_above_2000_chars( monkeypatch, feishu_outbox_db_session, ) -> None: maker = async_sessionmaker(feishu_outbox_db_session.bind, expire_on_commit=False) class FakeReply(feishu_mixin_reply_outbox.FeishuReplyOutboxMixin): def __init__(self) -> None: self.tenant_id = 3 @staticmethod def _build_mention_prefix(*, open_id=None, display_name=None): # noqa: ANN001 _ = (open_id, display_name) return "" @staticmethod async def _notify_outbox_ready(db, *, outbox_id): # noqa: ANN001 _ = (db, outbox_id) return None monkeypatch.setattr(feishu_mixin_reply_outbox, "AsyncSessionLocal", maker) text = _build_long_requirement_plan_reply_text() assert len(text) > 2000 outbox_id = await FakeReply()._enqueue_reply_outbox( chat_id="oc_requirement_plan_budget", message_text=text, trace_id="trace-requirement-plan-budget", source_message_id="msg-requirement-plan-budget-mixin", ) async with maker() as verify_session: row = ( await verify_session.execute( select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == int(outbox_id)) ) ).scalar_one() assert row.message_text == text assert "C. 长期能力底座" in row.message_text assert "⭐ 当前我更推荐:B" in row.message_text @pytest.mark.asyncio async def test_finalize_waiting_manual_passes_execution_result_to_renderer(monkeypatch) -> None: captured: dict[str, object] = {} class FakeReviewService: async def review_work_item(self, **kwargs): _ = kwargs return {"verified": False, "issues": ["waiting_manual"], "next_step": "请确认"} class FakeRenderer: def render(self, **kwargs): captured["render_kwargs"] = kwargs return { "receipt_phase": "pending", "template_key": "pending_manual", "payload": {"intent": kwargs["intent"].name}, "text": "ok", } class FakeKernel(mixin_prepare_manual.KernelIngressFlowPrepareManualMixin): def __init__(self) -> None: class _FakeDB: async def commit(self): return None self.db = _FakeDB() self.tenant_id = 7 self.review_service = FakeReviewService() self.reply_renderer = FakeRenderer() async def _maybe_emit_risk_cadence_snapshot(self, **kwargs): _ = kwargs async def _claim_reply_receipt(self, **kwargs): _ = kwargs return True async def _write_processing_log(self, **kwargs): _ = kwargs async def _update_session(self, **kwargs): _ = kwargs async def _upsert_operation_execution_ledger(self, **kwargs): _ = kwargs return None def _extract_execution_operation_code(self, execution_result): if not isinstance(execution_result, dict): return None action_contract = execution_result.get("action_contract") if isinstance(execution_result.get("action_contract"), dict) else {} if isinstance(action_contract, dict): operation_code = str(action_contract.get("operation_code") or "").strip().lower() if operation_code: return operation_code operation_code = str(execution_result.get("operation_code") or "").strip().lower() return operation_code or None def _normalize_execution_ledger_status(self, *, work_item_state, execution_status): _ = execution_status return "pending_manual" if str(work_item_state or "").strip().lower() == "waiting_manual" else "processing" async def _project_waiting_manual(self, **kwargs): captured["project_waiting_manual"] = kwargs def _apply_execution_action_contract(self, **kwargs): execution_result = dict(kwargs["execution_result"]) execution_result["action_contract"] = { "operation_code": "project.delete", "params": {"project_id": 12, "project_title": "重复项目"}, } return execution_result def _build_capture_result(self, *, work_item, intent, execution_result, render, direct_reply_required, status, classification): _ = (work_item, intent, execution_result, render, direct_reply_required, classification) return {"status": status, "render": render} kernel = FakeKernel() work_item = SimpleNamespace( state=None, action_bundle=None, execution_result=None, review_summary=None, reply_template_key=None, id=99, project_id=12, signal_id=None, intent="project.delete", source_message_id="msg-1", draft_version=None, draft_expires_at=None, ) session = SimpleNamespace() ingest_event = SimpleNamespace() decision_payload = { "policy": {"approval_requirement": {"message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。"}}, "execution_plan": {"actions": []}, "simulation": {}, } workflow_route = SimpleNamespace(key="project.delete", action="delete") intent = CanonicalIntent( name="project.delete", confidence=0.94, project_id=12, signal_title="", signal_summary="", metadata={"llm_parameters": {"project_id": 12, "project_title": "重复项目"}}, ) result = await kernel._finalize_waiting_manual_ingress( work_item=work_item, session=session, intent=intent, world_model={}, decision_payload=decision_payload, workflow_route=workflow_route, decision_trace={"reason": "test"}, source_system="feishu", source_message_id="msg-1", message_text="删除重复项目", sender="tester", ingest_event=ingest_event, started=0.0, ) assert result["status"] == "captured" render_kwargs = captured["render_kwargs"] assert isinstance(render_kwargs, dict) assert render_kwargs["execution_result"]["action_contract"]["operation_code"] == "project.delete" assert render_kwargs["execution_result"]["action_contract"]["params"]["project_title"] == "重复项目" def test_gate_execution_ignores_low_confidence_but_keeps_risk_review() -> None: low_confidence = gate_execution(confidence=0.02, risk_level="medium") assert low_confidence.action == "auto_execute" assert low_confidence.auto_execute is True assert low_confidence.gate_code == "auto_execute_allowed" assert "置信度" not in low_confidence.reason high_risk = gate_execution(confidence=0.02, risk_level="high") assert high_risk.action == "auto_execute" assert high_risk.auto_execute is True assert high_risk.gate_code == "auto_execute_allowed" assert "人工确认" not in high_risk.reason @pytest.mark.asyncio async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "来自统一 ingress 的回答", "session_id": 7} class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 _install_requirement_plan_query_bundle(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=12, metadata={ "rag_request_context": { "asked_by": 101, "asked_via": "pmflow_manual", "project_id": 19, "source_chat_id": "chat-abc", "session_id": 55, "force_new_session": True, "thinking_mode": "deep", "action_mode": "read_write", "sync_requirement": True, "message_intent": "mixed", "mixed_signal_part": "新增任务确认流", "consultant_mode_preference": "challenge", } }, ) result = await executor._execute_query_intent( intent=intent, message_text="请同步更新状态", operator_user_id=999, source_system="feishu", source_message_id="msg-1", ) assert result["status"] == "succeeded" assert "统一 ingress" in result["answer"] answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_by"] == 101 assert answer_kwargs["asked_via"] == "pmflow_manual" assert answer_kwargs["project_id"] == 19 assert answer_kwargs["source_chat_id"] == "chat-abc" assert answer_kwargs["session_id"] == 55 assert answer_kwargs["force_new_session"] is True assert answer_kwargs["thinking_mode"] == "deep" assert answer_kwargs["action_mode"] == "read_write" assert answer_kwargs["sync_requirement"] is True assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["consultant_mode_preference"] == "challenge" @pytest.mark.asyncio async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "fallback-answer", "session_id": 7} class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) 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) executor = FakeExecutor() intent = SimpleNamespace(project_id=12, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="现在完整调用链是什么?", operator_user_id=999, source_system="pmflow_manual", source_message_id="msg-rollback-1", ) assert result["status"] == "failed_safe" assert "rag failed" in str(result["error_message"] or "") assert executor.db.rollback_calls == 1 @pytest.mark.asyncio async def test_execute_query_intent_does_not_rollback_healthy_session_on_rag_failure(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise RuntimeError("rag failed before response") class FakeDB: def __init__(self) -> None: self.rollback_calls = 0 self.sync_session = SimpleNamespace(is_active=True) async def rollback(self) -> None: self.rollback_calls += 1 class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 3 @staticmethod def _build_query_error_message(exc: Exception) -> str: return f"query failed: {exc}" async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, metadata) return WorkflowBundle( bundle_id="bundle-plan-rollback", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.9, metadata={"message_intent": "requirement_plan"}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) executor = FakeExecutor() intent = SimpleNamespace(project_id=12, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="先分析,不要直接创建。", operator_user_id=999, source_system="pmflow_manual", source_message_id="msg-rollback-2", ) assert result["status"] == "succeeded" assert "先不创建项目" in str(result["answer"] or "") assert executor.db.rollback_calls == 0 @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_casual_chat_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("casual chat should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "semantic_bundle": { "diagnostics": { "message_intent": "casual_chat", "consultant_mode_preference": "brainstorm", } } }, ) result = await executor._execute_query_intent( intent=intent, message_text="早上好,今天你在线吗", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-1", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert "我在线" in answer assert "A." not in answer assert "B." not in answer assert "C." not in answer assert str(result["rag_result"]["query_type"]) == "chat" @pytest.mark.asyncio async def test_execute_query_intent_backfills_semantic_bundle_for_casual_chat_without_legacy_helpers( monkeypatch, ) -> None: planner_calls: list[dict[str, object]] = [] class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("casual chat should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): planner_calls.append( { "message_text": message_text, "source_system": source_system, "metadata": dict(metadata or {}), } ) return WorkflowBundle( bundle_id="bundle-casual", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.95, metadata={"message_intent": "casual_chat"}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "casual_chat", "consultant_mode_preference": "brainstorm", }, ) def _legacy_helper_should_not_run(*args, **kwargs): # noqa: ANN002, ANN003 raise AssertionError("legacy semantic helper should not run") monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="早上好,今天你在线吗", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-backfill-1", ) assert planner_calls, "missing semantic bundle should be backfilled through aplan_message" assert result["status"] == "succeeded" assert "我在线" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "chat" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_followup_clarify_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement clarify follow-up should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "thread_context": {"root_message_id": "root-1", "reply_to_message_id": "root-1"}, "semantic_bundle": { "diagnostics": { "message_intent": "business_review", "consultant_mode_preference": "clarify", } }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="那就先按上面的方向继续,但是别太重", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-2", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert "轻量版继续理解" in answer assert "A." in answer assert "B." in answer assert "C." in answer assert "直接回 A/B/C" in answer assert str(result["rag_result"]["query_type"]) == "clarify" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_planning_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement planning should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, metadata) return WorkflowBundle( bundle_id="bundle-plan-direct", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.91, metadata={"message_intent": "requirement_plan"}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="我们想做一个给电动车品牌用的直播复盘工具,先别创建项目,我还在补范围。", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-2b", ) answer = str(result["answer"] or "") assert result["status"] == "succeeded" assert "不创建" in answer assert "这类 IT 项目来收" in answer assert "⭐ 当前我更推荐:B" in answer assert answer.count("?") <= 1 assert str(result["rag_result"]["query_type"]) == "plan" @pytest.mark.asyncio async def test_execute_query_intent_backfills_semantic_bundle_for_requirement_plan_without_legacy_helpers( monkeypatch, ) -> None: planner_calls: list[dict[str, object]] = [] class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement planning should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): planner_calls.append( { "message_text": message_text, "source_system": source_system, "metadata": dict(metadata or {}), } ) return WorkflowBundle( bundle_id="bundle-plan", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.91, metadata={"message_intent": "requirement_plan"}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="我们想做一个给电动车品牌用的直播复盘工具,先别创建项目,我还在补范围。", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-backfill-plan-1", ) assert planner_calls, "missing requirement metadata should be backfilled through aplan_message" assert result["status"] == "succeeded" assert "不创建" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "plan" @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_scene_short_circuits_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement plan scene should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) result = await executor._execute_query_intent( intent=intent, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-plan-1", ) answer = str(result["answer"] or "") assert result["status"] == "succeeded" assert "这类 IT 项目来收" in answer assert "⭐ 当前我更推荐:B" in answer assert "A." in answer and "B." in answer and "C." in answer assert str(result["rag_result"]["query_type"]) == "plan" @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_followup_persists_progress_and_asks_next_question( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement plan follow-up should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, metadata) return WorkflowBundle( bundle_id="bundle-plan-draft-friendly", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.92, metadata={"message_intent": "requirement_plan"}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) executor = FakeExecutor() initial_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-1", "source_sender_open_id": "ou-plan-1", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-1", "source_sender_open_id": "ou-plan-1", "thread_context": { "root_message_id": "msg-plan-root", "reply_to_message_id": "msg-plan-root", }, "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) first = await executor._execute_query_intent( intent=initial_intent, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-root", ) second = await executor._execute_query_intent( intent=followup_intent, message_text="A,主要给投顾看。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-1", ) assert first["status"] == "succeeded" assert second["status"] == "succeeded" assert "这类 IT 项目来收" in str(first["answer"] or "") assert "⭐ 当前我更推荐:B" in str(first["answer"] or "") assert "技术路线定住" in str(second["answer"] or "") assert "A." in str(second["answer"] or "") assert "B." in str(second["answer"] or "") assert "C." in str(second["answer"] or "") topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where( MountedTopicWorkspace.tenant_id == 3, MountedTopicWorkspace.channel_ref == "chat-plan-1", ) .order_by(MountedTopicWorkspace.id.desc()) .limit(1) ) ).scalar_one() draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace) .join(MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id) .where( MountedDraftWorkspace.tenant_id == 3, MountedTopicWorkspace.channel_ref == "chat-plan-1", ) .order_by(MountedDraftWorkspace.id.desc()) .limit(1) ) ).scalar_one() card = draft.requirement_card or {} assert topic.mode == "plan" assert draft.state == "collecting" assert card["project_nature"] == "轻量打分工具" assert "投顾" in str(card["target_user"]) @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draft_after_enough_answers( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement plan draft should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent_root = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-2", "source_sender_open_id": "ou-plan-2", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) intent_followup = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-2", "source_sender_open_id": "ou-plan-2", "thread_context": { "root_message_id": "msg-plan-root-2", "reply_to_message_id": "msg-plan-root-2", }, "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) await executor._execute_query_intent( intent=intent_root, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-root-2", ) await executor._execute_query_intent( intent=intent_followup, message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-2a", ) await executor._execute_query_intent( intent=intent_followup, message_text="主要给投顾看。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-2b", ) await executor._execute_query_intent( intent=intent_followup, message_text="任务编排平台版,用 FastAPI + React + Redis + Celery + PostgreSQL。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-2c", ) final_result = await executor._execute_query_intent( intent=intent_followup, message_text="第一版就先保留分数、方向判断和一句解释,别做太重。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-2d", ) answer = str(final_result["answer"] or "") assert final_result["status"] == "succeeded" assert "📋 我先按当前收敛结果" in answer assert "🧾【需求名称】" in answer assert "🏗️【项目性质】" in answer assert "👥【服务对象】" in answer assert "⚙️【技术路线】" in answer assert "🧱【第一版范围】" in answer assert str(final_result["rag_result"]["query_type"]) == "draft" assert final_result["reply_template_key"] == "draft_pending_confirm" assert final_result["draft_artifact"]["promote_to_confirmation"] is True draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace).where(MountedTopicWorkspace.tenant_id == 3) ) ).scalar_one() assert draft.state == "ready" assert "每日 A 股趋势打分工具" in str((draft.draft_payload or {}).get("title") or "") assert draft.active_work_item_id is None assert draft.latest_source_message_id == "msg-plan-reply-2d" assert draft.latest_reply_message_id is None assert topic.plan_status == "awaiting_execute_confirm" assert topic.pending_actions == ["confirm_requirement_draft"] @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_requirement_topic_suspends_previous_plan( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("explicit new topic switching should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() active_plan_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-switch", "source_sender_open_id": "ou-plan-switch", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) explicit_switch_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-switch", "source_sender_open_id": "ou-plan-switch", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) await executor._execute_query_intent( intent=active_plan_intent, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-switch-root", ) switch_result = await executor._execute_query_intent( intent=explicit_switch_intent, message_text="不对啊,现在是另一个需求,先别沿用刚才那版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-switch-new", ) rows = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where(MountedTopicWorkspace.tenant_id == 3) .order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() answer = str(switch_result["answer"] or "") assert len(rows) == 2 assert "🧭 我先按" in answer assert "A." in answer and "B." in answer and "C." in answer assert rows[0].status == "suspended" assert rows[0].plan_status == "suspended" assert rows[1].status == "active" assert rows[1].plan_status == "collecting" @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_it_requirement_overrides_stale_clarify_and_avoids_old_scoring_topic( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("new IT requirement topic should stay in direct plan flow") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) _install_requirement_plan_query_bundle(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-it-switch", source_sender_open_id="ou-plan-it-switch", sender=None, ) old_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-it-switch:message:msg-old-root", source_system="feishu", channel_ref="chat-plan-it-switch", conversation_scope_key=conversation_scope_key, root_message_id="msg-old-root", mode="plan", status="active", plan_status="awaiting_execute_confirm", metadata_json={"plan_mode": True}, facts_json={"plan_profile": "scoring", "title": "每日 A 股趋势打分工具"}, ) requirement_plan_db_session.add(old_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(old_topic.id), state="ready", draft_version=1, requirement_card={ "plan_profile": "scoring", "title": "每日 A 股趋势打分工具", "goal": "每天产出一个可解释的 A 股趋势分数", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "target_user": "投顾", "output_form": "飞书日报", "cadence": "每日", "first_scope": "第一版先做 A 股趋势分数和一句解释", }, draft_payload={"title": "每日 A 股趋势打分工具"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() stale_clarify_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-it-switch", "source_sender_open_id": "ou-plan-it-switch", "message_intent": "business_review", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-it-switch", "source_sender_open_id": "ou-plan-it-switch", "thread_context": { "root_message_id": "msg-new-it-root", "reply_to_message_id": "msg-new-it-root", }, }, ) first = await executor._execute_query_intent( intent=stale_clarify_intent, message_text="想跟你聊个新需求关于批量自动化部署openclaw的,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-new-it-root", ) second = await executor._execute_query_intent( intent=followup_intent, message_text="C,先按云原生交付底座来收。", operator_user_id=999, source_system="feishu", source_message_id="msg-new-it-2", ) first_answer = str(first["answer"] or "") second_answer = str(second["answer"] or "") assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in first_answer assert "云原生交付底座" in first_answer assert "目标对象" not in first_answer assert "A股趋势打分" not in first_answer assert "🧭 我先按“云原生交付底座”这个方向继续收。" in second_answer assert "现在要先把服务对象定住" in second_answer assert "A股趋势打分" not in second_answer assert "飞书日报" not in second_answer rows = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where(MountedTopicWorkspace.tenant_id == 3) .order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() assert len(rows) == 2 assert rows[0].status == "suspended" assert rows[0].plan_status == "suspended" assert rows[1].status == "active" assert rows[1].plan_status == "collecting" assert rows[1].root_message_id == "msg-new-it-root" @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_requirement_topic_reuses_current_tracking_topic( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("explicit new topic switching should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-plan-switch-reuse", source_sender_open_id="ou-plan-switch-reuse", sender=None, ) existing_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-switch-reuse:message:msg-plan-switch-root-reuse", source_system="feishu", channel_ref="chat-plan-switch-reuse", conversation_scope_key=conversation_scope_key, root_message_id="msg-plan-switch-root-reuse", mode="plan", status="active", plan_status="collecting", metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(existing_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(existing_topic.id), state="collecting", draft_version=0, requirement_card={"title": "旧需求"}, draft_payload=None, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) tracking_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-switch-reuse:message:msg-plan-switch-new-reuse", source_system="feishu", channel_ref="chat-plan-switch-reuse", conversation_scope_key=conversation_scope_key, mode="chat", status="active", plan_status="idle", metadata_json={"tracking": True}, ) requirement_plan_db_session.add(tracking_topic) await requirement_plan_db_session.flush() explicit_switch_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-switch-reuse", "source_sender_open_id": "ou-plan-switch-reuse", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", "topic_switch": "new", }, ) switch_result = await executor._execute_query_intent( intent=explicit_switch_intent, message_text="想跟你聊个新需求,关于批量自动化部署openclaw,先不创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-switch-new-reuse", ) rows = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where(MountedTopicWorkspace.tenant_id == 3) .order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() assert len(rows) == 2 assert switch_result["status"] == "succeeded" assert rows[0].status == "suspended" assert rows[0].plan_status == "suspended" assert rows[1].id == tracking_topic.id assert rows[1].status == "active" assert rows[1].mode == "plan" assert rows[1].plan_status == "collecting" @pytest.mark.asyncio async def test_execute_query_intent_explicit_new_requirement_topic_with_subject_starts_collection_immediately( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("explicit new topic seed should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) 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) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() current_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-plan-overlap:root:msg-plan-overlap-root", source_system="feishu", channel_ref="chat-plan-overlap", conversation_scope_key=build_feishu_conversation_scope_key( source_chat_id="chat-plan-overlap", source_sender_open_id="ou-plan-overlap", sender=None, ), root_message_id="msg-plan-overlap-root", mode="plan", status="active", plan_status="awaiting_execute_confirm", facts_json={ "title": "每日 A 股趋势打分工具", "target_user": "投顾", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "output_form": "飞书日报", }, goals_json=["每天产出一个可解释的 A 股趋势分数,辅助判断和记录。"], constraints_json={"first_scope": "先保留一个分数、方向判断和一句解释"}, pending_actions=["confirm_requirement_draft"], metadata_json={"plan_mode": True}, ) requirement_plan_db_session.add(current_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(current_topic.id), state="ready", draft_version=1, requirement_card={ "title": "每日 A 股趋势打分工具", "goal": "每天产出一个可解释的 A 股趋势分数,辅助判断和记录。", "target_user": "投顾", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "output_form": "飞书日报", "cadence": "每日", "first_scope": "先保留一个分数、方向判断和一句解释", "plan_profile": "scoring", }, draft_payload={"title": "每日 A 股趋势打分工具"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() implicit_switch_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-overlap", "source_sender_open_id": "ou-plan-overlap", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) switch_result = await executor._execute_query_intent( intent=implicit_switch_intent, message_text="想跟你聊需求,关于批量自动化部署openclaw,先不创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-overlap-new", ) rows = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace) .where(MountedTopicWorkspace.tenant_id == 3) .order_by(MountedTopicWorkspace.id.asc()) ) ).scalars().all() assert len(rows) == 2 assert "每日 A 股趋势打分工具" not in str(switch_result["answer"] or "") assert rows[0].status == "suspended" assert rows[0].plan_status == "suspended" assert rows[1].status == "active" assert rows[1].plan_status == "collecting" @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_answers_and_only_drafts_when_scope_arrives( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("fragmented requirement plan should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() root_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-3", "source_sender_open_id": "ou-plan-3", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) fragmented_followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-3", "source_sender_open_id": "ou-plan-3", "thread_context": { "root_message_id": "msg-plan-root-3", "reply_to_message_id": "msg-plan-root-3", }, }, ) first = await executor._execute_query_intent( intent=root_intent, message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-root-3", ) second = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3a", ) third = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="主要给投顾看", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3b", ) fourth = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="B", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3c", ) final_result = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="第一版先保留分数、方向判断和一句解释就够了", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3d", ) assert "这类 IT 项目来收" in str(first["answer"] or "") assert "服务对象定住" in str(second["answer"] or "") assert str(second["rag_result"]["query_type"]) == "plan" assert "技术路线定住" in str(third["answer"] or "") assert str(third["rag_result"]["query_type"]) == "plan" assert "第一版先证明什么价值" in str(fourth["answer"] or "") assert str(fourth["rag_result"]["query_type"]) == "plan" final_answer = str(final_result["answer"] or "") assert "📋 我先按当前收敛结果" in final_answer assert "🧾【需求名称】" in final_answer assert "🧱【第一版范围】" in final_answer assert str(final_result["rag_result"]["query_type"]) == "draft" draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() card = draft.requirement_card or {} assert card["project_nature"] == "轻量打分工具" assert "投顾" in str(card["target_user"] or "") assert "评分服务版" in str(card["tech_route"] or "") assert "方向判断" in str(card["first_scope"] or "") @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_defaults_to_it_pm_template_for_current_system( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("PMFlow IT requirement plan should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() root_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-meeting", "source_sender_open_id": "ou-plan-meeting", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-meeting", "source_sender_open_id": "ou-plan-meeting", "thread_context": { "root_message_id": "msg-plan-meeting-root", "reply_to_message_id": "msg-plan-meeting-root", }, }, ) first = await executor._execute_query_intent( intent=root_intent, message_text="想跟你聊个需求,做一个企业级项目管理系统里的需求收敛功能,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-root", ) second = await executor._execute_query_intent( intent=followup_intent, message_text="B,先做发布治理平台。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-2", ) third = await executor._execute_query_intent( intent=followup_intent, message_text="B,主要给项目管理团队用。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-3", ) fourth = await executor._execute_query_intent( intent=followup_intent, message_text="B,走任务编排平台版,用 FastAPI + React + Redis + Celery + Postgres。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-4", ) final_result = await executor._execute_query_intent( intent=followup_intent, message_text="待办、责任人、截止时间。#meeting-plan-debug-tag", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-meeting-5", ) assert "这类 IT 项目来收" in str(first["answer"] or "") assert "先定它更像工具、平台,还是长期底座" in str(first["answer"] or "") assert "A." in str(first["answer"] or "") assert "B." in str(first["answer"] or "") assert "C." in str(first["answer"] or "") assert "⭐ 当前我更推荐:B" in str(first["answer"] or "") assert "平台运维团队" in str(second["answer"] or "") assert "任务编排平台版" in str(third["answer"] or "") assert "A." in str(second["answer"] or "") assert "B." in str(second["answer"] or "") assert "C." in str(second["answer"] or "") assert "批量执行闭环" in str(fourth["answer"] or "") assert "⭐ 当前我更推荐:A" in str(fourth["answer"] or "") assert "A." in str(third["answer"] or "") assert "B." in str(third["answer"] or "") assert "C." in str(third["answer"] or "") final_answer = str(final_result["answer"] or "") assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer assert "🧭【一句话判断】" in final_answer assert "【输入材料】" not in final_answer assert "【响应时效】" not in final_answer assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer assert "👥【服务对象】第一版先服务项目管理团队" in final_answer assert "⚙️【技术路线】当前更适合走任务编排平台版" in final_answer assert "🧱【第一版范围】待办、责任人、截止时间" in final_answer assert "🚀【推进方式】先按项目级试点(带基础审批和回滚)推进" in final_answer assert "#meeting-plan-debug-tag" not in final_answer assert str(final_result["rag_result"]["query_type"]) == "draft" draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() card = draft.requirement_card or {} assert card["plan_profile"] == "it_project" assert card["project_nature"] == "发布治理平台" assert "项目管理团队" in str(card["target_user"] or "") assert "FastAPI" in str(card["tech_route"] or "") assert card["delivery_mode"] == "项目级试点(带基础审批和回滚)" assert "待办" in str(card["first_scope"] or "") @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_injects_context_to_llm_for_abc_options( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("LLM-backed ABC requirement plan should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 captured: list[dict[str, object]] = [] _install_requirement_plan_round_llm(monkeypatch, captured) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-outfit", "source_sender_open_id": "ou-plan-outfit", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) result = await executor._execute_query_intent( intent=intent, message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-outfit-root", ) answer = str(result["answer"] or "") assert result["status"] == "succeeded" assert captured, "requirement_plan ABC options must be generated through the LLM round service" assert str(captured[0]["kwargs"].get("function_name")) == "pmflow_requirement_plan_round" assert "换季系统自动推荐搭配衣服" in str(captured[0]["prompt"]) assert '"missing_field": "project_nature"' in str(captured[0]["prompt"]) assert "A. 先做穿搭推荐小工具" in answer assert "B. 先做个性化穿搭推荐平台" in answer assert "C. 直接做穿搭决策底座" in answer assert "⭐ 当前我更推荐:B" in answer assert "批量自动化部署" not in answer assert "发布治理平台" not in answer assert "OpenClaw" not in answer assert str(result["rag_result"]["query_type"]) == "plan" followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-outfit", "source_sender_open_id": "ou-plan-outfit", "thread_context": { "root_message_id": "msg-plan-outfit-root", "reply_to_message_id": "msg-plan-outfit-root", }, }, ) second = await executor._execute_query_intent( intent=followup_intent, message_text="B", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-outfit-2", ) assert second["status"] == "succeeded" draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() card = draft.requirement_card or {} assert card["project_nature"] == "个性化穿搭推荐平台" assert card["project_nature"] != "发布治理平台" @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_generates_contextual_outfit_draft_instead_of_old_it_template( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement plan draft should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 calls: list[dict[str, object]] = [] def _extract_packet(prompt: str, marker: str) -> dict[str, object]: if marker not in prompt: return {} raw = prompt.split(marker, 1)[1].strip() try: payload = json.loads(raw) except Exception: return {} return payload if isinstance(payload, dict) else {} async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 function_name = str(kwargs.get("function_name") or "") calls.append({"function_name": function_name, "prompt": prompt}) if function_name == "pmflow_requirement_plan_round": packet = _extract_packet(prompt, "requirement_plan_round_packet:") context_blob = json.dumps(packet, ensure_ascii=False) if '"missing_field": "project_nature"' in prompt: payload = { "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", "focus": "现在最关键的是先把第一版定位定住。", "options": [ {"key": "A", "title": "先做穿搭推荐小工具", "best_for": "先验证换季推荐有没有真实使用价值", "suggested_capability": "规则推荐、基础标签、简单反馈", "rationale": "落地快,适合先跑通最小闭环", "tradeoff": "个性化能力会偏弱"}, {"key": "B", "title": "先做个性化穿搭推荐平台", "best_for": "持续沉淀用户偏好、衣橱数据和搭配反馈", "suggested_capability": "用户画像、衣物标签、推荐策略、反馈闭环", "rationale": "更贴近自动推荐搭配衣服的长期价值", "tradeoff": "第一版要先说清边界"}, {"key": "C", "title": "直接做穿搭决策底座", "best_for": "后续要接多品牌和多场景运营", "suggested_capability": "多源接入、推荐服务、效果评估", "rationale": "长期上限最高", "tradeoff": "早期投入最大"}, ], "recommendation": {"key": "B", "reason": "它最贴近这类需求的持续价值,同时不会一开始做得过重。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } elif '"missing_field": "target_user"' in prompt: payload = { "opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。", "focus": "现在先把第一版服务对象定住,不然后面推荐口径会飘。", "options": [ {"key": "A", "title": "普通消费者", "best_for": "先验证换季搭配推荐是否真的有帮助", "suggested_capability": "偏好反馈、天气/季节标签、收藏行为", "rationale": "最贴近最终使用者,能尽快拿到真实反馈", "tradeoff": "需要处理偏好差异"}, {"key": "B", "title": "穿搭运营团队", "best_for": "先由运营维护搭配规则和推荐口径", "suggested_capability": "规则配置、款式标签、人工校验", "rationale": "更容易控制第一版质量", "tradeoff": "自动化程度会弱一些"}, {"key": "C", "title": "品牌导购团队", "best_for": "结合商品推荐和导购场景做转化验证", "suggested_capability": "商品库、搭配卡片、导购反馈", "rationale": "更适合商业转化场景", "tradeoff": "会更依赖商品数据"}, ], "recommendation": {"key": "A", "reason": "先服务普通消费者,最能验证自动推荐是否真的成立。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } elif '"missing_field": "tech_route"' in prompt and "换季系统自动推荐搭配衣服" in context_blob: payload = { "opening": "🧭 我先按“普通消费者先试用”这个方向继续收。", "focus": "现在先把第一版能力路径定住,避免一开始做重。", "options": [ {"key": "A", "title": "规则推荐版", "best_for": "先用季节、温度、场景和衣物标签跑通推荐闭环", "suggested_capability": "标签规则、搭配模板、简单反馈", "rationale": "最快验证推荐是否有用", "tradeoff": "后续个性化能力要继续补"}, {"key": "B", "title": "轻量模型推荐版", "best_for": "希望更早引入用户偏好和相似搭配学习", "suggested_capability": "基础召回、轻量排序、反馈学习", "rationale": "推荐体验会更自然", "tradeoff": "第一版调参成本更高"}, {"key": "C", "title": "多模态推荐底座", "best_for": "一开始就要处理图片理解和风格迁移", "suggested_capability": "图文向量、风格标签、多模态检索", "rationale": "长期上限更高", "tradeoff": "首版复杂度太高"}, ], "recommendation": {"key": "A", "reason": "先用规则和标签跑通首轮推荐,更符合当前验证阶段。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } else: payload = { "opening": "🧭 我先按“规则推荐版”继续收尾。", "focus": "最后定一下第一版先保留哪些推荐范围。", "options": [ {"key": "A", "title": "先保留温度、场景、单品搭配建议", "best_for": "先把最基础的换季推荐闭环跑通", "suggested_capability": "温度标签、场景标签、单品匹配规则", "rationale": "最容易验证用户是否愿意使用", "tradeoff": "短期内还没有风格迁移"}, {"key": "B", "title": "加入用户偏好和历史收藏", "best_for": "希望一开始就看个性化提升", "suggested_capability": "偏好特征、收藏反馈、简单排序", "rationale": "推荐会更贴近个人习惯", "tradeoff": "冷启动成本更高"}, {"key": "C", "title": "加入图片理解和风格迁移", "best_for": "要做更强的视觉搭配能力", "suggested_capability": "图像理解、风格识别、向量检索", "rationale": "长期价值更高", "tradeoff": "第一版会明显做重"}, ], "recommendation": {"key": "A", "reason": "先把温度、场景、单品搭配建议跑通,最适合验证首轮价值。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", } return StructuredExtractionResult( payload=payload, source_field="content", provider="fake", model="fake", validation="ok", ) if function_name == "pmflow_requirement_plan_draft": return StructuredExtractionResult( payload={ "intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:", "title": "换季系统自动推荐搭配衣服", "one_liner": "先面向普通消费者做一版换季穿搭推荐试用,先把温度、场景和单品搭配建议跑成可验证闭环。", "goal": "先验证用户是否愿意使用换季搭配推荐,并收集最基础的点击、收藏和反馈信号。", "project_nature": "这件事更像个性化穿搭推荐平台的第一版,不是部署平台,也不是内部流程治理工具。", "target_user": "第一版先服务普通消费者,重点看他们是否会点击、收藏和采纳推荐结果。", "signal_inputs": "当前至少要用到:用户当前需求描述;季节/温度信息;衣物标签;基础反馈数据。", "known_info": "目前已经明确:这是换季穿搭推荐需求;先按个性化穿搭推荐平台收敛;第一版先给普通消费者试用;技术路线先按规则推荐版;范围先保留温度、场景、单品搭配建议。", "tech_route": "当前更适合先走规则推荐版,用季节、温度、场景和衣物标签跑通首轮推荐。", "first_scope": "第一版先保留温度、场景、单品搭配建议,先验证推荐是否被点击、收藏和采纳。", "core_flow": "用户进入换季推荐入口 -> 结合温度/场景生成搭配建议 -> 展示单品组合 -> 记录点击/收藏/反馈 -> 继续优化推荐规则", "delivery_mode": "先做小范围真实用户试用,优先看真实点击、收藏和主观反馈。", "success_signal": "先保证能稳定给出换季搭配建议,并拿到点击、收藏或反馈中的至少一类真实用户信号。", "risks": "当前主要风险在冷启动标签质量、搭配规则是否过于死板,以及首轮反馈样本是否足够。", "pending_items": ["衣物标签口径是否已有现成标准", "首轮试用用户范围和反馈采集方式", "是否需要同步接商品库或衣橱数据"], "closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。", "positioning_one_liner": "面向普通消费者的小范围换季穿搭推荐试用,先跑通基础推荐闭环。", "summary": "先用规则推荐跑通换季穿搭建议,再根据真实点击、收藏和反馈决定是否扩到更完整的平台能力。", "target_customers": "普通消费者", "external_signal_sources": [ {"name": "天气 API", "purpose": "补充实时温度、体感和降雨变化", "phase": "首版优先接入"}, {"name": "淘宝开放平台", "purpose": "补充可推荐商品的品类、颜色和季节属性", "phase": "首版优先接入"}, {"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度和潮流变化", "phase": "第二阶段接入"}, {"name": "小红书趋势榜", "purpose": "补充当季穿搭内容趋势和流行关键词", "phase": "第二阶段接入"}, {"name": "微信导出回流", "purpose": "补充用户转发、收藏后的采纳反馈", "phase": "首版优先接入"}, ], "requirement_items": [ {"section": "需求目标", "title": "验证用户价值", "description": "验证用户是否愿意使用换季搭配推荐,并留下点击、收藏或反馈。"}, {"section": "服务对象", "title": "首批用户", "description": "第一版先服务普通消费者。"}, {"section": "技术路线", "title": "规则推荐版", "description": "先基于季节、温度、场景和衣物标签生成推荐。"}, {"section": "第一版范围", "title": "最小闭环", "description": "先保留温度、场景、单品搭配建议。"}, ], "missing_info": ["衣物标签标准", "试用用户范围", "反馈采集方式"], "mvp_items": ["温度标签", "场景标签", "单品搭配建议", "基础点击/收藏反馈"], "team_setup": [{"role": "建议配置", "notes": "产品/运营 1名、后端/算法 1名、前端 1名,先支撑小范围试用即可。"}], "validation_metrics": {"product": ["推荐点击率", "收藏率", "用户主观反馈"]}, }, source_field="content", provider="fake", model="fake", validation="ok", ) raise AssertionError(f"unexpected function_name: {function_name}") monkeypatch.setattr( mixin_execute_query_project.llm_service, "generate_structured_object", fake_generate_structured_object, ) async def _fake_recommend_requirement_plan_owner_for_draft(self, *, card: dict[str, object]): # noqa: ANN001 _ = self, card return { "user_id": 18, "name": "陈后端", "role": "后端开发", "workload_ratio": 28.0, "reason": "系统检索到他当前负载较低,且技能更接近推荐与数据接入。", "matched_skills": ["Python", "FastAPI", "数据接入"], } monkeypatch.setattr( mixin_execute_query_project.ExecuteQueryProjectMixin, "_recommend_requirement_plan_owner_for_draft", _fake_recommend_requirement_plan_owner_for_draft, raising=False, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() root_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-outfit-draft", "source_sender_open_id": "ou-plan-outfit-draft", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-outfit-draft", "source_sender_open_id": "ou-plan-outfit-draft", "thread_context": { "root_message_id": "msg-plan-outfit-draft-root", "reply_to_message_id": "msg-plan-outfit-draft-root", }, }, ) first_result = await executor._execute_query_intent( intent=root_intent, message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-outfit-draft-root", ) await executor._execute_query_intent( intent=followup_intent, message_text="B", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-outfit-draft-2", ) await executor._execute_query_intent( intent=followup_intent, message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-outfit-draft-3", ) await executor._execute_query_intent( intent=followup_intent, message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-outfit-draft-4", ) final_result = await executor._execute_query_intent( intent=followup_intent, message_text="A", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-outfit-draft-5", ) final_answer = str(final_result["answer"] or "") assert first_result["status"] == "succeeded" assert any(str(call.get("function_name")) == "pmflow_requirement_plan_draft" for call in calls) assert "换季系统自动推荐搭配衣服" in final_answer assert "普通消费者" in final_answer assert "规则推荐版" in final_answer assert "小范围真实用户试用" in final_answer assert "🌐【外部信号源】" in final_answer assert "天气 API" in final_answer assert "淘宝开放平台" in final_answer assert "得物开放数据" in final_answer assert "小红书趋势榜" in final_answer assert "👤【推荐负责人】陈后端" in final_answer assert "系统检索到他当前负载较低" in final_answer assert "人员能力图谱" not in final_answer assert "工作流状态与审计日志" not in final_answer assert "审批、审计、权限一次做满" not in final_answer assert "确认门禁" not in final_answer assert str(final_result["rag_result"]["query_type"]) == "draft" @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_retries_when_llm_leaks_internal_packet_keys( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement plan should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 calls: list[dict[str, object]] = [] async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 _ = kwargs calls.append({"prompt": prompt}) if len(calls) == 1: return StructuredExtractionResult( payload={ "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", "focus": "current_context 里已经有标题,这轮先收第一版定位。", "options": [ { "key": "A", "title": "先做穿搭推荐小工具", "best_for": "先快速验证换季推荐是否有用", "suggested_capability": "规则推荐、基础标签、简单反馈", "rationale": "latest_user_message 说明这是个新需求,适合先做轻量闭环", "tradeoff": "后续还要补个性化能力", }, { "key": "B", "title": "先做个性化穿搭推荐平台", "best_for": "持续沉淀用户偏好和衣橱反馈", "suggested_capability": "用户画像、衣物标签、反馈闭环", "rationale": "collected_fields 还不多,先把主方向定住更重要", "tradeoff": "第一版要先说清数据边界", }, { "key": "C", "title": "直接做穿搭决策底座", "best_for": "后续接多品牌和多场景运营", "suggested_capability": "多源接入、推荐服务、策略实验", "rationale": "project_nature 如果一开始做重,上限会更高", "tradeoff": "早期投入最大", }, ], "recommendation": {"key": "B", "reason": "reply_budget 允许的范围内,B 更贴近长期价值。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", }, source_field="content", provider="fake", model="fake", validation="ok", ) return StructuredExtractionResult( payload={ "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", "focus": "现在先把第一版定位定住,再往下收目标用户和范围。", "options": [ { "key": "A", "title": "先做穿搭推荐小工具", "best_for": "快速验证换季搭配推荐有没有真实使用价值", "suggested_capability": "规则推荐、季节标签、基础反馈", "rationale": "落地快,适合先把推荐闭环跑通", "tradeoff": "个性化学习能力会偏弱", }, { "key": "B", "title": "先做个性化穿搭推荐平台", "best_for": "持续沉淀用户偏好、衣橱数据和搭配反馈", "suggested_capability": "用户画像、衣物标签、推荐策略、反馈闭环", "rationale": "更贴近自动推荐搭配衣服这件事的长期价值", "tradeoff": "第一版要先约束数据口径和推荐边界", }, { "key": "C", "title": "直接做穿搭决策底座", "best_for": "后续要接多品牌、多场景和长期运营策略", "suggested_capability": "多源数据接入、推荐服务、效果评估", "rationale": "长期上限最高", "tradeoff": "早期投入和依赖都更重", }, ], "recommendation": {"key": "B", "reason": "它最贴近这类需求的长期价值,同时不会一开始就做得过重。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", }, source_field="content", provider="fake", model="fake", validation="ok", ) monkeypatch.setattr( "app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object", fake_generate_structured_object, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-repair-internal-keys", "source_sender_open_id": "ou-plan-repair-internal-keys", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) result = await executor._execute_query_intent( intent=intent, message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-repair-internal-keys-root", ) answer = str(result["answer"] or "") assert result["status"] == "succeeded" assert len(calls) == 2 assert "current_context" not in answer assert "latest_user_message" not in answer assert "collected_fields" not in answer assert "reply_budget" not in answer assert "project_nature" not in answer assert "⭐ 当前我更推荐:B" in answer @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_retries_stale_deployment_context_for_outfit_requirement( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement plan should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 calls: list[str] = [] async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 _ = kwargs calls.append(prompt) if '"missing_field": "project_nature"' in prompt: return StructuredExtractionResult( payload={ "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。", "focus": "现在最关键的是先把第一版定位定住。", "options": [ { "key": "A", "title": "先做穿搭推荐小工具", "best_for": "先快速验证搭配建议是否有用", "suggested_capability": "规则推荐、天气标签、基础反馈", "rationale": "适合先把最小闭环跑通", "tradeoff": "个性化会偏弱", }, { "key": "B", "title": "先做个性化穿搭推荐平台", "best_for": "持续沉淀用户偏好和衣橱反馈", "suggested_capability": "用户画像、衣物标签、推荐策略", "rationale": "更贴近自动推荐搭配衣服的长期价值", "tradeoff": "第一版要先说清边界", }, { "key": "C", "title": "直接做穿搭决策底座", "best_for": "后续要接更多品牌和场景", "suggested_capability": "多源接入、策略实验、效果评估", "rationale": "长期上限最高", "tradeoff": "早期投入最大", }, ], "recommendation": {"key": "B", "reason": "它更符合这类需求的长期价值,但不会一开始做得过重。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", }, source_field="content", provider="fake", model="fake", validation="ok", ) if '"missing_field": "target_user"' in prompt and len(calls) == 2: return StructuredExtractionResult( payload={ "opening": "🧭 我先按“发布治理平台”这个方向继续收。", "focus": "现在要先把服务对象定住,不然后面治理边界会飘。", "options": [ { "key": "A", "title": "平台运维团队", "best_for": "统一管理发布流程和回滚风险", "suggested_capability": "审批、审计、回滚", "rationale": "更适合承接部署治理闭环", "tradeoff": "会偏离真实用户场景", }, { "key": "B", "title": "项目交付团队", "best_for": "按项目复制部署流程", "suggested_capability": "模板、参数、发布编排", "rationale": "更适合多项目交付", "tradeoff": "仍然偏旧部署场景", }, { "key": "C", "title": "开发团队", "best_for": "自己发版自己看结果", "suggested_capability": "日志查看、执行入口", "rationale": "反馈快", "tradeoff": "治理弱", }, ], "recommendation": {"key": "A", "reason": "它最适合部署治理和回滚闭环。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", }, source_field="content", provider="fake", model="fake", validation="ok", ) return StructuredExtractionResult( payload={ "opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。", "focus": "现在先把第一版服务对象定住,不然后面推荐口径会飘。", "options": [ { "key": "A", "title": "普通消费者", "best_for": "先验证换季搭配推荐是否真的有帮助", "suggested_capability": "偏好反馈、天气/季节标签、收藏行为", "rationale": "最贴近最终使用者,能尽快拿到真实反馈", "tradeoff": "需要处理偏好差异", }, { "key": "B", "title": "穿搭运营团队", "best_for": "先由运营维护搭配规则和推荐口径", "suggested_capability": "规则配置、款式标签、人工校验", "rationale": "更容易控制第一版质量", "tradeoff": "自动化程度会弱一些", }, { "key": "C", "title": "品牌导购团队", "best_for": "结合商品推荐和导购场景做转化验证", "suggested_capability": "商品库、搭配卡片、导购反馈", "rationale": "更适合商业转化场景", "tradeoff": "会更依赖商品数据", }, ], "recommendation": {"key": "A", "reason": "先服务普通消费者,最能验证自动推荐是否真的成立。"}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", }, source_field="content", provider="fake", model="fake", validation="ok", ) monkeypatch.setattr( "app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object", fake_generate_structured_object, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() root_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-repair-stale-deployment", "source_sender_open_id": "ou-plan-repair-stale-deployment", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-repair-stale-deployment", "source_sender_open_id": "ou-plan-repair-stale-deployment", "thread_context": { "root_message_id": "msg-plan-repair-stale-deployment-root", "reply_to_message_id": "msg-plan-repair-stale-deployment-root", }, }, ) first = await executor._execute_query_intent( intent=root_intent, message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-repair-stale-deployment-root", ) second = await executor._execute_query_intent( intent=followup_intent, message_text="B", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-repair-stale-deployment-2", ) assert first["status"] == "succeeded" assert second["status"] == "succeeded" assert len(calls) == 3 second_answer = str(second["answer"] or "") assert "普通消费者" in second_answer assert "穿搭运营团队" in second_answer assert "品牌导购团队" in second_answer assert "发布治理平台" not in second_answer assert "平台运维团队" not in second_answer assert "部署治理" not in second_answer @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_round_compacts_render_within_feishu_budget( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement plan should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 repeated = ",".join(["覆盖用户偏好、衣橱标签、天气温度、场景约束、历史反馈"] * 12) async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 _ = (prompt, kwargs) return StructuredExtractionResult( payload={ "opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。" + repeated, "focus": "现在最关键的是先定第一版定位。" + repeated, "options": [ { "key": "A", "title": "轻量推荐工具", "best_for": repeated, "suggested_capability": repeated, "rationale": repeated, "tradeoff": repeated, }, { "key": "B", "title": "业务平台能力", "best_for": repeated, "suggested_capability": repeated, "rationale": repeated, "tradeoff": repeated, }, { "key": "C", "title": "长期能力底座", "best_for": repeated, "suggested_capability": repeated, "rationale": repeated, "tradeoff": repeated, }, ], "recommendation": {"key": "B", "reason": repeated}, "reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。", }, source_field="content", provider="fake", model="fake", validation="ok", ) monkeypatch.setattr( "app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object", fake_generate_structured_object, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-over-budget", "source_sender_open_id": "ou-plan-over-budget", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) result = await executor._execute_query_intent( intent=intent, message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-over-budget-root", ) assert result["status"] == "succeeded" answer = str(result["answer"] or "") assert len(answer) <= 1600 assert "A. 轻量推荐工具" in answer assert "B. 业务平台能力" in answer assert "C. 长期能力底座" in answer assert "⭐ 当前我更推荐:B" in answer @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_pm_template( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("it project requirement plan should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 _install_requirement_plan_round_llm(monkeypatch) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() root_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-it", "source_sender_open_id": "ou-plan-it", "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, ) followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-plan-it", "source_sender_open_id": "ou-plan-it", "thread_context": { "root_message_id": "msg-plan-it-root", "reply_to_message_id": "msg-plan-it-root", }, }, ) first = await executor._execute_query_intent( intent=root_intent, message_text="想跟你聊个 IT 项目需求,做一个批量自动化部署 OpenClaw 的平台,先别创建,先收第一版。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-root", ) second = await executor._execute_query_intent( intent=followup_intent, message_text="B,先做发布治理平台。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-2", ) third = await executor._execute_query_intent( intent=followup_intent, message_text="B,主要给平台运维团队用。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-3", ) fourth = await executor._execute_query_intent( intent=followup_intent, message_text="B,走任务编排平台版,用 FastAPI + React + Redis + Celery + Postgres。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-4", ) fifth = await executor._execute_query_intent( intent=followup_intent, message_text="A,第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-5", ) final_result = fifth assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in str(first["answer"] or "") assert "A. 先做内部提效工具" in str(first["answer"] or "") assert "先定它更像工具、平台,还是长期底座" in str(first["answer"] or "") assert "⭐ 当前我更推荐:B" in str(first["answer"] or "") assert "你回 A / B / C" in str(first["answer"] or "") assert "🧭 我先按“发布治理平台”这个方向继续收。" in str(second["answer"] or "") assert "平台运维团队" in str(second["answer"] or "") assert "⭐ 当前我更推荐:B" in str(second["answer"] or "") assert "🧭 我先按“平台运维团队要长期使用”这个方向继续收。" in str(third["answer"] or "") assert "第一版技术路线定住" in str(third["answer"] or "") assert "FastAPI + React + Redis + Celery + PostgreSQL" in str(third["answer"] or "") assert "⭐ 当前我更推荐:B" in str(third["answer"] or "") assert "🧭 我先按“任务编排平台版”继续往下收。" in str(fourth["answer"] or "") assert "批量执行闭环" in str(fourth["answer"] or "") assert "⭐ 当前我更推荐:A" in str(fourth["answer"] or "") final_answer = str(final_result["answer"] or "") assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer assert "🧭【一句话判断】" in final_answer assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer assert "👥【服务对象】第一版先服务平台运维团队" in final_answer assert "📥【基础输入】当前至少要接入:" in final_answer assert "飞书需求消息" in final_answer assert "部署目标清单" in final_answer assert "📝【当前已知信息】目前已经明确:" in final_answer assert "⚙️【技术路线】当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" in final_answer assert "🧱【第一版范围】第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。" in final_answer assert "🔄【建议核心流程】" in final_answer assert "生成部署任务" in final_answer assert "👤【人员配置】建议最小配置:" in final_answer assert "平台/运维工程师" in final_answer assert "🚀【推进方式】先按项目级试点(带基础审批和回滚)推进" in final_answer assert "⚠️【风险与依赖】当前主要风险在" in final_answer assert "凭证管理" in final_answer assert "❓【待确认】还需要你拍板:" in final_answer assert "部署目标范围" in final_answer assert "试点负责人" in final_answer assert str(final_result["rag_result"]["query_type"]) == "draft" draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() card = draft.requirement_card or {} assert card["plan_profile"] == "it_project" assert card["project_nature"] == "发布治理平台" assert card["target_user"] == "平台运维团队" assert "FastAPI" in str(card["tech_route"] or "") assert "批量执行闭环" in str(card["first_scope"] or "") assert "项目级试点" in str(card["delivery_mode"] or "") @pytest.mark.asyncio async def test_execute_query_intent_single_choice_does_not_resume_awaiting_requirement_draft( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs return { "answer": "当前没有进行中的 A/B/C 收敛轮次。你先发一句新需求,我按新的 topic 接着收。", "query_type": "status", "confidence": 0.42, "missing_fields": [], } class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-old-draft", source_sender_open_id="ou-old-draft", sender=None, ) old_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-old-draft:message:sim-plan-1431-1", source_system="feishu", channel_ref="chat-old-draft", conversation_scope_key=conversation_scope_key, root_message_id="sim-plan-1431-1", mode="plan", status="active", plan_status="awaiting_execute_confirm", pending_actions=["confirm_requirement_draft"], metadata_json={ "plan_mode": True, "plan_question_round": 17, "plan_last_prompt": "下一个关键问题:你更想怎么接收这个结果,是飞书日报、页面看板,还是只推送一个分数加一句解释?", }, facts_json={ "title": "每日 A 股趋势打分工具", "plan_profile": "scoring", "target_user": "投顾", }, ) requirement_plan_db_session.add(old_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(old_topic.id), state="ready", draft_version=1, requirement_card={ "plan_profile": "scoring", "title": "每日 A 股趋势打分工具", "goal": "每天产出一个可解释的 A 股趋势分数", "target_user": "投顾", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "output_form": "飞书日报", "cadence": "每日", "first_scope": "第一版先做 A 股趋势分数和一句解释", }, draft_payload={"title": "每日 A 股趋势打分工具"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-old-draft", "source_sender_open_id": "ou-old-draft", }, ) result = await executor._execute_query_intent( intent=followup_intent, message_text="C", operator_user_id=999, source_system="feishu", source_message_id="msg-old-draft-c", ) answer = str(result["answer"] or "") assert "每日 A 股趋势打分工具" not in answer assert "需求草稿" not in answer assert "新需求" in answer assert str(result["rag_result"]["query_type"]) == "status" topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(old_topic.id)) ) ).scalar_one() assert topic.plan_status == "awaiting_execute_confirm" assert topic.pending_actions == ["confirm_requirement_draft"] @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_advise_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement advice should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "semantic_bundle": { "diagnostics": { "message_intent": "business_review", "consultant_mode_preference": "advise", } }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="如果这个政策雷达项目先做 MVP,你觉得范围怎么收才比较稳?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3", ) assert result["status"] == "succeeded" assert "只保留一个核心用户对象" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "advice" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_advise_by_text_without_metadata(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("scope advice should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, metadata) return WorkflowBundle( bundle_id="bundle-advise-shortcut", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.92, metadata={"message_intent": "business_review"}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "business_review", "consultant_mode_preference": "advise", "advise_subtype": "scope", }, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="如果第一版先做 MVP,这个需求范围怎么收才比较稳?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3b", ) assert result["status"] == "succeeded" assert "只保留一个核心用户对象" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "advice" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_assignee_recommendation_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("assignee recommendation should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 async def _build_assignee_recommendation_answer(self, *, message_text: str, project_id): _ = (message_text, project_id) return "如果现在先给一个保守负责人,我更倾向 AI工程师。" async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, metadata) return WorkflowBundle( bundle_id="bundle-assignee-shortcut", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.92, metadata={"message_intent": "business_review"}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "business_review", "consultant_mode_preference": "advise", "advise_subtype": "assignee", }, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="那你觉得分配给谁做比较合适?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3c", ) assert result["status"] == "succeeded" assert "AI工程师" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "recommendation" @pytest.mark.asyncio async def test_execute_query_intent_prefers_direct_advise_subtype_for_assignee(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("direct assignee subtype should bypass heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 async def _build_assignee_recommendation_answer(self, *, message_text: str, project_id): _ = (message_text, project_id) return "如果先给一个 owner,我会优先推荐 AI工程师。" monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "message_intent": "business_review", "consultant_mode_preference": "advise", "advise_subtype": "assignee", }, ) result = await executor._execute_query_intent( intent=intent, message_text="那你觉得分配给谁做比较合适?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3c-direct", ) assert result["status"] == "succeeded" assert "AI工程师" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "recommendation" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_project_existence_lookup_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id): _ = (clean_q, project_id) return { "answer": "有,当前匹配到 1 个相关项目:\n#101 AI客服(状态:active)", "sources": [{"source": "db", "label": "Project database", "score": 0.99, "metadata": {"table": "projects"}}], "db_query_ms": 8, } async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("project existence lookup should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None): _ = (message_text, metadata) return WorkflowBundle( bundle_id="bundle-project-lookup-shortcut", semantic_version="v1", source_system=source_system, primary_workflow="query_flow", workflows=[ WorkflowPlan( workflow_type="query_flow", workflow_action="answer", target_scope="global", operation_codes=["query.answer"], requires_confirmation=False, confidence=0.92, metadata={"message_intent": "project_lookup_question"}, ) ], message_gate="auto", reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"), diagnostics={ "message_intent": "project_lookup_question", "consultant_mode_preference": "fact_retrieval", }, ) monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) monkeypatch.setattr( mixin_execute_query_project.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message, ) executor = FakeExecutor() intent = SimpleNamespace(project_id=None, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="一个 AI 客服工具,这个项目有吗", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-project-exists-1", ) assert result["status"] == "succeeded" assert "当前匹配到 1 个相关项目" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "status" @pytest.mark.asyncio async def test_draft_confirmation_route_does_not_hijack_explicit_signal_source_request() -> None: class FakeSession: last_intent = mixin_entry.STATE_DRAFT_PENDING_CONFIRM should_route = mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=FakeSession(), message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。", ingress_options={}, ) assert should_route is False @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_uses_active_mounted_draft_without_anchor( monkeypatch, ) -> None: captured: dict[str, object] = {} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _fake_has_active_draft_session(self, **kwargs): _ = self captured["kwargs"] = kwargs return True kernel = FakeKernel() monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent="query.answer"), sender="tester", source_system="feishu", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="确认", ingress_options=None, ) assert should_route is True assert captured["kwargs"] == { "sender": "tester", "source_system": "feishu", "channel_ref": "chat-active", "source_sender_open_id": "ou_active", "thread_context": {}, } @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_does_not_hijack_smalltalk_even_if_active_draft_exists( monkeypatch, ) -> None: called = False class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _fake_has_active_draft_session(self, **kwargs): nonlocal called _ = (self, kwargs) called = True return True monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) kernel = FakeKernel() should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent="query.answer"), sender="tester", source_system="feishu", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="收到,辛苦了", ingress_options=None, ) assert should_route is True assert called is True @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_keeps_pending_confirm_session_without_keywords( monkeypatch, ) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _fake_has_active_draft_session(self, **kwargs): _ = (self, kwargs) return False monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) kernel = FakeKernel() should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), sender="tester", source_system="feishu", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="按这版继续", ingress_options=None, ) assert should_route is True @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_with_active_draft_does_not_require_revision_heuristics( monkeypatch, ) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _fake_has_active_draft_session(self, **kwargs): _ = (self, kwargs) return True kernel = FakeKernel() monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent="query.answer"), sender="tester", source_system="feishu", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="保持当前草稿结构并补完整信息", ingress_options=None, ) assert should_route is True @pytest.mark.asyncio async def test_should_route_to_draft_confirmation_session_still_skips_explicit_independent_request_with_active_draft( monkeypatch, ) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 async def _fake_has_active_draft_session(self, **kwargs): _ = (self, kwargs) return True monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) kernel = FakeKernel() should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent="query.answer"), sender="tester", source_system="feishu", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。", ingress_options=None, ) assert should_route is False 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 def test_deterministic_query_type_treats_project_risk_question_as_status() -> None: class FakeRAG(mixin_deterministic_query_core.AdaptiveRAGDeterministicQueryCoreMixin): pass rag = FakeRAG() assert rag._classify_query_type("帮我看看跨境直播项目最近有哪些风险和阻塞") == "status" @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions() -> None: project = SimpleNamespace(id=104, title="跨境直播项目", name="跨境直播项目", status="active") tasks = [ SimpleNamespace(status="todo", updated_at=datetime(2026, 4, 5, 10, 0), created_at=datetime(2026, 4, 5, 9, 0)), SimpleNamespace(status="in_progress", updated_at=datetime(2026, 4, 5, 11, 0), created_at=datetime(2026, 4, 5, 8, 0)), SimpleNamespace(status="done", updated_at=datetime(2026, 4, 5, 7, 0), created_at=datetime(2026, 4, 5, 6, 0)), ] class FakeScalarResult: def __init__(self, rows): self._rows = rows def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) class FakeDB: async def execute(self, stmt): _ = stmt return FakeScalarResult(tasks) class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 @staticmethod def _extract_project_lookup_keyword(question: str) -> str: _ = question return "跨境直播" @staticmethod def _extract_query_terms(question: str, preferred: str): _ = question return [preferred] async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int): _ = (keyword, project_id, limit) return [project] rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看跨境直播项目最近有哪些风险和阻塞", project_id=None, ) assert result is not None assert "风险/阻塞台账字段" in str(result["answer"] or "") assert "待处理任务数 1" in str(result["answer"] or "") @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions_without_tasks() -> None: project = SimpleNamespace(id=101, title="AI客服", name="AI客服", status="active") class FakeScalarResult: def __init__(self, rows): self._rows = rows def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) class FakeDB: async def execute(self, stmt): _ = stmt return FakeScalarResult([]) class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 @staticmethod def _extract_project_lookup_keyword(question: str) -> str: _ = question return "AI客服" @staticmethod def _extract_query_terms(question: str, preferred: str): _ = question return [preferred] async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int): _ = (keyword, project_id, limit) return [project] rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看 AI 客服工具项目最近有哪些风险和阻塞", project_id=None, ) assert result is not None assert "风险/阻塞台账字段" in str(result["answer"] or "") assert "没有可用的任务拆解记录" in str(result["answer"] or "") def test_typed_executor_common_exports_regex_helpers_to_query_mixins() -> None: assert mixin_execute_query_project.ExecuteQueryProjectMixin._extract_task_priority("p0 紧急任务") == "high" assert ( ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._extract_requested_signal_count( message_text="请帮我分析 3 个信号", metadata={}, ) == 3 ) assert ( ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._requires_strict_signal_analysis( message_text="先分析后入库 2 个信号", requested_count=2, ) is True ) def test_consultant_dispatch_uses_short_next_action_for_query_answers() -> None: dispatch = build_consultant_dispatch_contract( intent_name="query.answer", message_text="PMFlow 现在完整调用链是什么?", source_system="pmflow_manual", status="answered", classification="question", primary_operation_code="query.answer", risk_level="low", missing_fields=[], direct_reply_text=( "结论:PMFlow 当前完整调用链无法完整还原。\n\n" "依据:前端 `/ai-steward` 页面通过 `/rag/ask` 发起请求," "并继续进入 `AdaptiveRAGService.upsert_pmflow_record_async`。" ), ) assert dispatch["allow_execute"] is False assert dispatch["mode"] == "advise" assert dispatch["next_action"] == "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。" assert "结论:" not in dispatch["next_action"] assert "/rag/ask" not in dispatch["next_action"] def test_build_capture_result_no_longer_exposes_consultant_dispatch() -> None: class _FakeKernel(ai_pmo_kernel_mixins_ingress_render.KernelIngressRenderMixin): tenant_id = 1 work_item = SimpleNamespace( id=18, project_id=7, signal_id=None, state="succeeded", intent="query.answer", reply_template_key="action_succeeded", decision_level="team", review_summary={"verified": True}, raw_message="现在进展怎么样?", source_system="pmflow_manual", draft_version=0, ) intent = CanonicalIntent( name="query.answer", confidence=0.93, project_id=7, signal_title="", signal_summary="当前主链正常。", metadata={}, ) result = _FakeKernel()._build_capture_result( work_item=work_item, intent=intent, execution_result={ "workflow_route": "query.answer", "rag_result": {"answer": "当前主链正常。"}, }, render={ "template_key": "action_succeeded", "text": "当前主链正常。", }, direct_reply_required=True, status="answered", classification="question", ) assert "consultant_dispatch" not in result assert "consultant_dispatch" not in result["rag_result"] @pytest.mark.asyncio async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } high_engine = { "id": "high-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "high-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, high_engine], } openclaw_bundle = { "rules": [ { "name": "engine_high_reasoning_glm5", "enabled": True, "priority": 220, "tier": "high", "provider": "openai", "model": "GLM-5", "api_env": "OPENAI_API_KEY", "match_any": ["adaptive_rag_answer"], } ] } class FakeLLM(LLMTierEscalationMixin): def _low_to_high_escalation_enabled(self) -> bool: return True def _is_low_tier_function(self, function_name: str) -> bool: return function_name == "adaptive_rag_answer_normal" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False async def _chat_completion(self, **kwargs): captured["chat_kwargs"] = kwargs return "GLM-5 answer" llm = FakeLLM() text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="adaptive_rag_answer_normal", failed_engine=dict(low_engine), prompt="请回答一个问题", system_prompt="system prompt", temperature=0.2, max_tokens=512, response_mode="complete", error=ValueError("LLM 返回空内容"), ) assert text == "GLM-5 answer" assert isinstance(resolved_engine, dict) resolved_meta = resolved_engine["_routing_meta"] assert resolved_meta["planned_tier"] == "low" assert resolved_meta["executed_tier"] == "high" assert resolved_meta["routing_decision"] == "low_to_high_escalated" assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) assert "高阶升级成功" in log_text @pytest.mark.asyncio async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } alternate_high_engine = { "id": "high-deepseek-1", "provider": "deepseek", "model": "deepseek-chat", "base_url": "https://api.deepseek.com/v1", "api_key": "deepseek-key", } glm5_engine = { "id": "high-glm5-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "glm5-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, alternate_high_engine, glm5_engine], } openclaw_bundle = { "rules": [ { "name": "engine_low_intent_kernel_exact", "enabled": True, "priority": 310, "tier": "low", "provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "function_exact": "classify_intent_ai_pmo_kernel", } ] } 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 == "classify_intent_ai_pmo_kernel" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _resolve_tier_engine(self, *, tier, engines, active_engine): _ = (engines, active_engine) if str(tier).strip().lower() == "high": return dict(alternate_high_engine) return dict(low_engine) def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False async def _chat_completion(self, **kwargs): captured["chat_kwargs"] = kwargs return "GLM-5 answer" llm = FakeLLM() text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="classify_intent_ai_pmo_kernel", failed_engine=dict(low_engine), prompt="请返回结构化意图", system_prompt="system prompt", temperature=0.2, max_tokens=512, response_mode="complete", error=ValueError("LLM 返回空内容"), ) assert text == "GLM-5 answer" assert isinstance(resolved_engine, dict) resolved_meta = resolved_engine["_routing_meta"] assert resolved_meta["planned_tier"] == "low" assert resolved_meta["executed_tier"] == "high" assert resolved_meta["routing_decision"] == "low_to_high_escalated" assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) assert "高阶升级成功" in log_text @pytest.mark.asyncio async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } alternate_high_engine = { "id": "high-deepseek-1", "provider": "deepseek", "model": "deepseek-chat", "base_url": "https://api.deepseek.com/v1", "api_key": "deepseek-key", } glm5_engine = { "id": "high-glm5-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "glm5-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, alternate_high_engine, glm5_engine], } openclaw_bundle = { "rules": [ { "name": "engine_low_intent_kernel_exact", "enabled": True, "priority": 310, "tier": "low", "provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "function_exact": "classify_intent_ai_pmo_kernel", } ] } 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 == "classify_intent_ai_pmo_kernel" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _resolve_tier_engine(self, *, tier, engines, active_engine): _ = (engines, active_engine) if str(tier).strip().lower() == "high": return dict(alternate_high_engine) return dict(low_engine) def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False def _extract_structured_object_from_response( self, *, function_name, response, fallback_text, provider, model, ): _ = (function_name, response, fallback_text) return StructuredExtractionResult( payload={"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}, source_field="generated_text", provider=provider, model=model, raw_text='{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}', validation="ok", ) async def _chat_completion_raw(self, **kwargs): captured["chat_kwargs"] = kwargs raw_text = '{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}' return ChatCompletionResult(text=raw_text, raw_response=raw_text) llm = FakeLLM() result, resolved_engine = await llm._retry_structured_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="classify_intent_ai_pmo_kernel", 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