10004 lines
414 KiB
Python
10004 lines
414 KiB
Python
import asyncio
|
||
import json
|
||
from datetime import datetime, timedelta
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
import pytest_asyncio
|
||
from fastapi import HTTPException
|
||
from sqlalchemy import JSON, select
|
||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||
|
||
from app.api.ai_pmo_api import routes_core
|
||
from app.api.ai_pmo_api import helpers_inbox_filters
|
||
from app.api.ai_pmo_api import helpers_inbox_payload
|
||
from app.api.ai_pmo_api.schemas import PMOProcessTextRequest
|
||
from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract
|
||
from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_resolve import (
|
||
KernelIntentResolveMixin,
|
||
)
|
||
from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import (
|
||
KernelIntentRulesMixin,
|
||
)
|
||
from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution
|
||
from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer
|
||
from app.services.ai_pmo.ai_pmo_kernel import ai_pmo_typed_executor_mixins_parse
|
||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry
|
||
from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress import ai_pmo_kernel_mixins_ingress_render
|
||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual
|
||
from app.services.ai_pmo.ai_pmo_kernel_world_model_core import mixin_project
|
||
from app.services.ai_pmo.ai_pmo_kernel.mixins.world_model import (
|
||
ai_pmo_kernel_mixins_world_model_session,
|
||
)
|
||
from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project
|
||
from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_mutations
|
||
from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent
|
||
from app.services.ai_pmo.workflow_semantic_planner import WorkflowSemanticPlanner
|
||
from app.services.ai_pmo.workflow_semantic_planner.models import ReplyContract, WorkflowBundle, WorkflowPlan
|
||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||
from app.services.adaptive_rag_service import mixin_deterministic_query_core
|
||
from app.services.adaptive_rag_service import mixin_fast_answer_project_main
|
||
from app.services.integrations.feishu_integration_service import mixin_reply_outbox as feishu_mixin_reply_outbox
|
||
from app.services.integrations.feishu_outbox.service import FeishuOutboxService
|
||
from app.services.llm_service import ChatCompletionResult, StructuredExtractionResult
|
||
from app.services.llm_service import mixin_tier_escalation
|
||
from app.services.llm_service.mixin_tier_escalation import LLMTierEscalationMixin
|
||
from app.models.base import Base
|
||
from app.models.feishu_reply_outbox import FeishuReplyOutbox
|
||
from app.models.mounted_ai_runtime import (
|
||
MountedConversationRun,
|
||
MountedDraftWorkspace,
|
||
MountedTopicWorkspace,
|
||
)
|
||
from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key
|
||
|
||
|
||
def _use_sqlite_requirement_plan_json_types() -> None:
|
||
for table in (
|
||
MountedTopicWorkspace.__table__,
|
||
MountedDraftWorkspace.__table__,
|
||
MountedConversationRun.__table__,
|
||
):
|
||
for column in table.columns:
|
||
if column.name in {
|
||
"facts_json",
|
||
"goals_json",
|
||
"constraints_json",
|
||
"options_json",
|
||
"open_questions",
|
||
"pending_actions",
|
||
"bound_entities",
|
||
"metadata_json",
|
||
"requirement_card",
|
||
"draft_payload",
|
||
"required_confirmations",
|
||
"context_layers_hit",
|
||
"tool_calls",
|
||
"execution_outcome",
|
||
}:
|
||
column.type = JSON()
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def requirement_plan_db_session():
|
||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||
_use_sqlite_requirement_plan_json_types()
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(
|
||
lambda sync_conn: Base.metadata.create_all(
|
||
sync_conn,
|
||
tables=[
|
||
MountedTopicWorkspace.__table__,
|
||
MountedDraftWorkspace.__table__,
|
||
MountedConversationRun.__table__,
|
||
],
|
||
)
|
||
)
|
||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||
async with maker() as session:
|
||
yield session
|
||
await engine.dispose()
|
||
|
||
|
||
@pytest_asyncio.fixture
|
||
async def feishu_outbox_db_session():
|
||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||
async with engine.begin() as conn:
|
||
await conn.run_sync(
|
||
lambda sync_conn: Base.metadata.create_all(
|
||
sync_conn,
|
||
tables=[FeishuReplyOutbox.__table__],
|
||
)
|
||
)
|
||
maker = async_sessionmaker(engine, expire_on_commit=False)
|
||
async with maker() as session:
|
||
yield session
|
||
await engine.dispose()
|
||
|
||
|
||
def _build_long_requirement_plan_reply_text() -> str:
|
||
repeated = ",".join(["覆盖用户偏好、衣橱标签、天气温度、场景约束、历史反馈"] * 10)
|
||
return (
|
||
"🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。\n"
|
||
"现在最关键的是先把第一版定位定住。\n\n"
|
||
f"A. 轻量推荐工具\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n"
|
||
f"B. 业务平台能力\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n"
|
||
f"C. 长期能力底座\n更适合:{repeated}\n推荐技术/能力:{repeated}\n我为什么会考虑这个:{repeated}\n代价:{repeated}\n\n"
|
||
f"⭐ 当前我更推荐:B\n原因:{repeated}\n\n你回 A / B / C,我就顺着这条继续往下收。"
|
||
)
|
||
|
||
|
||
def _install_requirement_plan_round_llm(monkeypatch, captured: list[dict[str, object]] | None = None) -> None:
|
||
def _extract_round_packet(prompt: str) -> dict[str, object]:
|
||
marker = "requirement_plan_round_packet:"
|
||
if marker not in prompt:
|
||
return {}
|
||
raw = prompt.split(marker, 1)[1].strip()
|
||
try:
|
||
payload = json.loads(raw)
|
||
except Exception:
|
||
return {}
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
def _extract_draft_packet(prompt: str) -> dict[str, object]:
|
||
marker = "requirement_plan_draft_packet:"
|
||
if marker not in prompt:
|
||
return {}
|
||
raw = prompt.split(marker, 1)[1].strip()
|
||
try:
|
||
payload = json.loads(raw)
|
||
except Exception:
|
||
return {}
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001
|
||
if captured is not None:
|
||
captured.append({"prompt": prompt, "kwargs": dict(kwargs)})
|
||
function_name = str(kwargs.get("function_name") or "")
|
||
packet = _extract_round_packet(prompt)
|
||
context_blob = json.dumps(packet, ensure_ascii=False)
|
||
is_scoring_context = any(
|
||
token in context_blob
|
||
for token in ("大A趋势打分", "趋势打分", "投顾", "研究团队", "交易团队")
|
||
)
|
||
if function_name == "pmflow_requirement_plan_draft":
|
||
draft_packet = _extract_draft_packet(prompt)
|
||
draft_blob = json.dumps(draft_packet, ensure_ascii=False)
|
||
if "换季系统自动推荐搭配衣服" in draft_blob or "个性化穿搭推荐平台" in draft_blob:
|
||
payload = {
|
||
"intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:",
|
||
"title": "换季系统自动推荐搭配衣服",
|
||
"one_liner": "先面向普通消费者做一版换季穿搭推荐试用,先把温度、场景和单品搭配建议跑成可验证闭环。",
|
||
"goal": "先验证用户是否愿意使用换季搭配推荐,并收集最基础的点击、收藏和反馈信号。",
|
||
"project_nature": "这件事更像个性化穿搭推荐平台的第一版,不是部署平台,也不是内部流程治理工具。",
|
||
"target_user": "第一版先服务普通消费者,重点看他们是否会点击、收藏和采纳推荐结果。",
|
||
"signal_inputs": "当前至少要用到:用户当前需求描述;季节/温度信息;衣物标签;基础反馈数据。",
|
||
"known_info": "目前已经明确:这是换季穿搭推荐需求;先按个性化穿搭推荐平台收敛;第一版先给普通消费者试用;技术路线先按规则推荐版;范围先保留温度、场景、单品搭配建议。",
|
||
"tech_route": "当前更适合先走规则推荐版,用季节、温度、场景和衣物标签跑通首轮推荐。",
|
||
"first_scope": "第一版先保留温度、场景、单品搭配建议,先验证推荐是否被点击、收藏和采纳。",
|
||
"core_flow": "用户进入换季推荐入口 -> 结合温度/场景生成搭配建议 -> 展示单品组合 -> 记录点击/收藏/反馈 -> 继续优化推荐规则",
|
||
"delivery_mode": "先做小范围真实用户试用,优先看真实点击、收藏和主观反馈。",
|
||
"success_signal": "先保证能稳定给出换季搭配建议,并拿到点击、收藏或反馈中的至少一类真实用户信号。",
|
||
"risks": "当前主要风险在冷启动标签质量、搭配规则是否过于死板,以及首轮反馈样本是否足够。",
|
||
"pending_items": ["衣物标签口径是否已有现成标准", "首轮试用用户范围和反馈采集方式", "是否需要同步接商品库或衣橱数据"],
|
||
"closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。",
|
||
"positioning_one_liner": "面向普通消费者的小范围换季穿搭推荐试用,先跑通基础推荐闭环。",
|
||
"summary": "先用规则推荐跑通换季穿搭建议,再根据真实点击、收藏和反馈决定是否扩到更完整的平台能力。",
|
||
"target_customers": "普通消费者",
|
||
"external_signal_sources": [
|
||
{"name": "天气 API", "purpose": "补充实时温度、体感和降雨变化", "phase": "首版优先接入"},
|
||
{"name": "淘宝开放平台", "purpose": "补充可推荐商品的品类、颜色和季节属性", "phase": "首版优先接入"},
|
||
{"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度和潮流变化", "phase": "第二阶段接入"},
|
||
],
|
||
"requirement_items": [
|
||
{"section": "需求目标", "title": "验证用户价值", "description": "验证用户是否愿意使用换季搭配推荐,并留下点击、收藏或反馈。"},
|
||
{"section": "服务对象", "title": "首批用户", "description": "第一版先服务普通消费者。"},
|
||
{"section": "技术路线", "title": "规则推荐版", "description": "先基于季节、温度、场景和衣物标签生成推荐。"},
|
||
{"section": "第一版范围", "title": "最小闭环", "description": "先保留温度、场景、单品搭配建议。"},
|
||
],
|
||
"missing_info": ["衣物标签标准", "试用用户范围", "反馈采集方式"],
|
||
"mvp_items": ["温度标签", "场景标签", "单品搭配建议", "基础点击/收藏反馈"],
|
||
"team_setup": [{"role": "建议配置", "notes": "产品/运营 1名、后端/算法 1名、前端 1名,先支撑小范围试用即可。"}],
|
||
"validation_metrics": {"product": ["推荐点击率", "收藏率", "用户主观反馈"]},
|
||
}
|
||
elif any(token in draft_blob for token in ("大A趋势打分", "趋势打分", "投顾", "研究团队", "交易团队")):
|
||
payload = {
|
||
"intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:",
|
||
"title": "每日 A 股趋势打分工具",
|
||
"one_liner": "面向投顾与研究团队的轻量级趋势打分工具,先把每日评分、评分解释和使用反馈跑成闭环。",
|
||
"goal": "先验证趋势打分结果是否有参考价值,并让投顾或研究团队能快速查看、比对和反馈。",
|
||
"project_nature": "这件事更像一个投研辅助小工具,核心是先把每日趋势评分与解释能力跑通。",
|
||
"target_user": "第一版先服务投顾与研究团队,优先满足他们查看评分、理解原因和给反馈的需要。",
|
||
"signal_inputs": "当前至少要用到:行情数据、基础技术指标、评分规则、投顾或研究团队反馈。",
|
||
"known_info": "目前已经明确:这是每日 A 股趋势打分工具;当前按投研辅助工具收敛;主要给投顾或研究团队看;技术路线先按评分服务版;第一版先保留每日评分、评分解释和重点股票列表。",
|
||
"tech_route": "当前更适合先走评分服务版,用规则与指标服务生成每日趋势分数和解释。",
|
||
"first_scope": "第一版先保留每日趋势评分、评分解释和重点股票列表,先验证结果是否可用。",
|
||
"core_flow": "拉取行情与指标 -> 计算每日趋势分数 -> 输出评分解释与重点股票 -> 投顾查看与反馈 -> 根据反馈调整评分规则",
|
||
"delivery_mode": "先做小范围投研试用,优先看投顾和研究团队是否会实际参考这份评分结果。",
|
||
"success_signal": "先保证能稳定产出每日评分结果,并让投顾或研究团队留下查看、引用或反馈中的至少一类真实使用信号。",
|
||
"risks": "当前主要风险在评分口径是否足够稳定、解释是否被投顾认可,以及反馈样本是否足够支持后续优化。",
|
||
"pending_items": ["评分口径是否需要区分短线和中线", "重点股票列表的筛选边界", "反馈采集方式与频率"],
|
||
"closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。",
|
||
"positioning_one_liner": "面向投顾与研究团队的每日趋势评分试用,先跑通评分与解释闭环。",
|
||
"summary": "先用轻量评分服务跑通每日趋势打分,再根据真实使用反馈决定是否扩到更完整的投研分析平台。",
|
||
"target_customers": "投顾、研究团队",
|
||
"external_signal_sources": [
|
||
{"name": "Tushare API", "purpose": "补充日线行情、成交量和基础财务指标", "phase": "首版优先接入"},
|
||
{"name": "同花顺 iFinD", "purpose": "补充板块热度与市场情绪相关指标", "phase": "第二阶段接入"},
|
||
{"name": "Wind API", "purpose": "补充更完整的因子与指数成分数据", "phase": "第二阶段接入"},
|
||
],
|
||
"requirement_items": [
|
||
{"section": "需求目标", "title": "验证评分可用性", "description": "验证每日趋势打分是否真的能帮助投顾或研究团队更快做初筛。"},
|
||
{"section": "服务对象", "title": "首批用户", "description": "第一版先服务投顾与研究团队。"},
|
||
{"section": "技术路线", "title": "评分服务版", "description": "先基于规则与指标服务生成每日评分与解释。"},
|
||
{"section": "第一版范围", "title": "最小闭环", "description": "先保留每日评分、评分解释和重点股票列表。"},
|
||
],
|
||
"missing_info": ["评分口径边界", "重点股票筛选标准", "反馈采集方式"],
|
||
"mvp_items": ["每日趋势评分", "评分解释", "重点股票列表", "基础反馈记录"],
|
||
"team_setup": [{"role": "建议配置", "notes": "产品/投研 1名、后端/数据 1名、前端 1名,先支撑小范围投研试用即可。"}],
|
||
"validation_metrics": {"product": ["评分查看率", "投顾引用率", "研究团队反馈数"]},
|
||
}
|
||
elif "批量自动化部署" in draft_blob or "openclaw" in draft_blob:
|
||
payload = {
|
||
"intro": "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:",
|
||
"title": "OpenClaw 批量自动化部署平台",
|
||
"one_liner": "先按面向平台运维团队的项目级试点推进,先把 OpenClaw 批量部署、执行追踪和失败恢复跑成一条闭环。",
|
||
"goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。",
|
||
"project_nature": "这件事更像发布治理平台,需要稳定承接批量执行、状态追踪和治理边界。",
|
||
"target_user": "第一版先服务平台运维团队,他们会最早使用、反馈和承担执行确认。",
|
||
"signal_inputs": "当前至少要接入:飞书需求消息;部署目标清单(服务/环境/实例);代码仓库/镜像信息;环境参数与凭证;执行日志与失败日志。",
|
||
"known_info": "目前已经明确:当前要解决的是 OpenClaw 批量自动化部署;当前按发布治理平台收敛;主要服务平台运维团队;技术路线先按任务编排平台版;第一版先按批量执行闭环推进;推进方式先按项目级试点(带基础审批和回滚)。",
|
||
"tech_route": "当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL),先把批量执行、状态追踪和失败重试稳定下来。",
|
||
"first_scope": "第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。",
|
||
"core_flow": "提交部署需求 -> 生成部署任务 -> 选择目标环境 -> 执行部署 -> 回传结果/日志 -> 失败重试或回滚 -> 留痕审计",
|
||
"delivery_mode": "先按项目级试点(带基础审批和回滚)推进,在真实项目里验证价值和流程问题。",
|
||
"success_signal": "先保证能稳定创建部署任务、看到执行结果、识别失败原因,并在试点里跑完一次真实闭环。",
|
||
"risks": "当前主要风险在部署目标范围、环境访问权限与凭证归属、仓库/镜像/配置规范、凭证管理,以及失败重试与回滚策略。",
|
||
"pending_items": ["部署目标范围(服务/环境/实例)", "仓库/镜像/配置来源", "环境权限与凭证归属", "试点负责人和投入人天"],
|
||
"closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。",
|
||
"positioning_one_liner": "面向平台运维团队的发布治理平台试点,先跑通批量执行与失败恢复闭环。",
|
||
"summary": "先做发布治理平台试点,把批量执行闭环在真实项目里跑通,再继续扩治理能力。",
|
||
"target_customers": "平台运维团队",
|
||
"external_signal_sources": [
|
||
{"name": "CMDB API", "purpose": "补充部署目标实例、环境和服务清单", "phase": "首版优先接入"},
|
||
{"name": "GitLab API", "purpose": "补充仓库、分支、提交与流水线信息", "phase": "首版优先接入"},
|
||
{"name": "Harbor Registry API", "purpose": "补充镜像标签、仓库和发布时间", "phase": "首版优先接入"},
|
||
],
|
||
"requirement_items": [
|
||
{"section": "项目性质", "title": "发布治理平台", "description": "需要稳定承接批量执行、状态追踪和治理边界。"},
|
||
{"section": "服务对象", "title": "平台运维团队", "description": "第一版先服务平台运维团队。"},
|
||
{"section": "技术路线", "title": "任务编排平台版", "description": "用 FastAPI + React + Redis + Celery + PostgreSQL 承接任务编排。"},
|
||
{"section": "第一版范围", "title": "批量执行闭环", "description": "先包含任务创建、执行日志和失败重试。"},
|
||
],
|
||
"missing_info": ["部署目标范围", "凭证归属", "试点负责人"],
|
||
"mvp_items": ["任务创建", "执行日志", "失败重试", "回滚能力"],
|
||
"team_setup": [{"role": "建议配置", "notes": "需求负责人/PM 1名;后端工程师 1名;前端工程师 1名;平台/运维工程师 1名;测试/试点验收 1名;试点负责人 1名。"}],
|
||
"validation_metrics": {"product": ["任务创建成功率", "部署执行完成率", "失败原因可识别率"]},
|
||
}
|
||
else:
|
||
payload = {
|
||
"intro": "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:",
|
||
"title": "企业级项目管理系统里的需求收敛功能",
|
||
"one_liner": "先按面向项目管理团队的项目级试点推进,先把需求收敛、待确认项和范围确认闭环跑通。",
|
||
"goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。",
|
||
"project_nature": "这件事更像发布治理平台式的业务协作能力,需要承接多人协作、状态流转和留痕。",
|
||
"target_user": "第一版先服务项目管理团队,他们会最早补充需求、看收敛结果和处理待确认项。",
|
||
"signal_inputs": "当前至少要接入:飞书需求消息;项目/任务/责任人数据;需求变更记录;状态流转记录。",
|
||
"known_info": "目前已经明确:当前要解决的是企业级项目管理系统里的需求收敛功能;当前按发布治理平台收敛;主要服务项目管理团队;技术路线先按任务编排平台版;第一版先按待办、责任人、截止时间推进;推进方式先按项目级试点。",
|
||
"tech_route": "当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL),先把需求收敛和待确认项管理稳定下来。",
|
||
"first_scope": "待办、责任人、截止时间。",
|
||
"core_flow": "提交需求信号 -> 生成需求 brief -> 拆出待办/责任人/截止时间 -> 校验待确认项 -> 人工确认后继续推进",
|
||
"delivery_mode": "先按项目级试点(带基础审批和回滚)推进,尽快在真实项目里验证需求收敛价值。",
|
||
"success_signal": "先保证能稳定生成项目 brief、拆出待办/责任人/截止时间,并能支撑一轮真实确认闭环。",
|
||
"risks": "当前主要风险在需求事实是否足够清晰、责任人与截止时间来源是否稳定,以及确认规则是否一致。",
|
||
"pending_items": ["需求事实来源与证据口径", "责任人字段来源", "截止时间字段来源", "试点负责人和投入人天"],
|
||
"closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。",
|
||
"positioning_one_liner": "面向项目管理团队的需求收敛试点,先跑通待办/责任人/截止时间闭环。",
|
||
"summary": "先把需求收敛主链路跑通,再根据试点结果决定是否扩到更完整的平台能力。",
|
||
"target_customers": "项目管理团队",
|
||
"external_signal_sources": [
|
||
{"name": "飞书开放平台", "purpose": "补充消息原文、评论和消息元数据", "phase": "首版优先接入"},
|
||
{"name": "Jira API", "purpose": "补充需求单、负责人和截止时间字段", "phase": "首版优先接入"},
|
||
{"name": "Confluence API", "purpose": "补充需求背景文档和评审记录", "phase": "第二阶段接入"},
|
||
],
|
||
"requirement_items": [
|
||
{"section": "项目性质", "title": "业务协作平台能力", "description": "承接多人协作、状态流转和范围确认。"},
|
||
{"section": "服务对象", "title": "项目管理团队", "description": "第一版先服务项目管理团队。"},
|
||
{"section": "技术路线", "title": "任务编排平台版", "description": "用 FastAPI + React + Redis + Celery + PostgreSQL 承接收敛链路。"},
|
||
{"section": "第一版范围", "title": "核心字段", "description": "先保留待办、责任人、截止时间。"},
|
||
],
|
||
"missing_info": ["需求事实来源", "责任人字段来源", "截止时间字段来源"],
|
||
"mvp_items": ["需求 brief", "待办字段", "责任人字段", "截止时间字段"],
|
||
"team_setup": [{"role": "建议配置", "notes": "需求负责人/PM 1名;后端工程师 1名;前端工程师 1名;测试/试点验收 1名。"}],
|
||
"validation_metrics": {"product": ["brief 生成成功率", "字段抽取可用率", "确认闭环完成率"]},
|
||
}
|
||
elif "换季系统自动推荐搭配衣服" in context_blob:
|
||
payload = {
|
||
"opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。",
|
||
"focus": "现在最关键的,是先定它更像一次性推荐小工具、可持续运营平台,还是长期穿搭决策能力。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "先做穿搭推荐小工具",
|
||
"best_for": "先验证用户是否愿意按天气、季节和已有衣橱接收搭配建议",
|
||
"suggested_capability": "基础规则推荐、衣物标签、简单反馈",
|
||
"rationale": "落地快,能先验证推荐结果是否真的有用",
|
||
"tradeoff": "个性化和长期学习能力会比较弱",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "先做个性化穿搭推荐平台",
|
||
"best_for": "需要持续沉淀用户偏好、衣橱数据和搭配反馈",
|
||
"suggested_capability": "用户画像、衣物标签体系、推荐策略、反馈闭环",
|
||
"rationale": "更贴近你说的自动推荐搭配衣服,也方便后续扩展",
|
||
"tradeoff": "第一版要先把数据口径和推荐边界说清楚",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "直接做穿搭决策底座",
|
||
"best_for": "后续要接入多品牌、多场景和长期运营策略",
|
||
"suggested_capability": "多源数据接入、推荐服务、策略实验、效果评估",
|
||
"rationale": "长期上限更高",
|
||
"tradeoff": "早期投入最大,容易还没验证需求就做重",
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "它最符合“换季系统自动推荐搭配衣服”的持续价值,但还不会一开始做成过重底座。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
elif is_scoring_context and '"missing_field": "project_nature"' in prompt:
|
||
payload = {
|
||
"opening": "🧭 我先按“每日 A 股趋势打分工具”这类 IT 项目来收。",
|
||
"focus": "现在最关键的,是先定它更像一个轻量投研工具、可复用评分服务,还是长期分析底座。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "先做轻量打分工具",
|
||
"best_for": "先验证每日评分结果是否有参考价值",
|
||
"suggested_capability": "日线数据拉取、评分规则、结果展示",
|
||
"rationale": "开发快,适合先验证评分是否真的有人用",
|
||
"tradeoff": "后续扩展多维因子和策略实验时需要补能力",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "先做评分服务平台",
|
||
"best_for": "需要持续沉淀评分规则、指标口径和反馈闭环",
|
||
"suggested_capability": "评分服务、规则配置、反馈记录",
|
||
"rationale": "更方便后续持续优化评分逻辑",
|
||
"tradeoff": "第一版会比 A 多一点规则管理成本",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "直接做投研分析底座",
|
||
"best_for": "后续要承接多策略、多因子和多团队复用",
|
||
"suggested_capability": "因子计算、策略实验、分析接口",
|
||
"rationale": "长期上限更高",
|
||
"tradeoff": "早期投入最大,需求没跑通前容易做重",
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "先按评分服务把规则、结果和反馈闭环收住,更适合后续持续优化。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
elif '"missing_field": "project_nature"' in prompt:
|
||
is_deployment_prompt = "批量自动化部署" in context_blob
|
||
subject = "OpenClaw 批量自动化部署平台" if is_deployment_prompt else "当前需求"
|
||
option_b_title = "先做发布治理平台" if is_deployment_prompt else "先做业务协作平台"
|
||
option_b_best_for = "多人协作、状态流转、需要审批和回滚" if is_deployment_prompt else "多人协作、状态流转、需要审批和留痕"
|
||
option_b_capability = "FastAPI + React + PostgreSQL + Redis" if is_deployment_prompt else "FastAPI + React + PostgreSQL + 审批流 + 审计日志"
|
||
payload = {
|
||
"opening": f"🧭 我先按“{subject}”这类 IT 项目来收。",
|
||
"focus": "现在最关键的,不是立刻展开做法,而是先定它更像工具、平台,还是长期底座。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "先做内部提效工具",
|
||
"best_for": "先减少重复操作,把第一版价值快速跑通",
|
||
"suggested_capability": "FastAPI + React + PostgreSQL",
|
||
"rationale": "开发快,适合先验证 MVP",
|
||
"tradeoff": "后续任务编排和治理能力要继续补",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": option_b_title,
|
||
"best_for": option_b_best_for,
|
||
"suggested_capability": option_b_capability,
|
||
"rationale": "更适合承接长期治理和执行追踪",
|
||
"tradeoff": "前期设计复杂度高于 A",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "直接做云原生交付底座" if is_deployment_prompt else "直接做长期能力底座",
|
||
"best_for": "目标是规模化、标准化和长期复用",
|
||
"suggested_capability": "平台能力、策略配置、审计与扩展接口",
|
||
"rationale": "长期上限更高",
|
||
"tradeoff": "早期投入最大",
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "它更适合把当前需求收成可持续的平台能力,同时不会一开始做成过重底座。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
elif '"missing_field": "target_user"' in prompt:
|
||
if is_scoring_context:
|
||
payload = {
|
||
"opening": "🧭 我先按“轻量打分工具”这个方向继续收。",
|
||
"focus": "现在要先把第一版服务对象定住,不然后面的评分口径会飘。",
|
||
"options": [
|
||
{"key": "A", "title": "投顾", "best_for": "先服务一线需要快速看市场趋势和候选股票的人", "suggested_capability": "每日评分卡、重点股票列表、简短解释", "rationale": "最贴近直接使用评分结果的人", "tradeoff": "会更关注结果可读性和更新节奏"},
|
||
{"key": "B", "title": "研究团队", "best_for": "先服务需要验证评分逻辑和指标口径的人", "suggested_capability": "评分拆解、指标明细、回看能力", "rationale": "更容易帮助我们先把评分口径校准", "tradeoff": "第一版结果会更偏分析工具而不是实战辅助"},
|
||
{"key": "C", "title": "交易团队", "best_for": "直接结合交易节奏看信号", "suggested_capability": "盘前信号、风险提示、更新频率控制", "rationale": "最能验证时效价值", "tradeoff": "对稳定性和时效要求最高"},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "先按研究团队校准评分口径,再扩到更直接的投顾使用会更稳。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
elif "换季系统自动推荐搭配衣服" in context_blob or "个性化穿搭推荐平台" in context_blob:
|
||
payload = {
|
||
"opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。",
|
||
"focus": "现在要先把第一版服务对象定住,不然后面推荐口径会飘。",
|
||
"options": [
|
||
{"key": "A", "title": "普通消费者", "best_for": "先验证换季搭配推荐是否有用", "suggested_capability": "用户偏好、天气/季节标签、搭配反馈", "rationale": "最贴近最终使用者", "tradeoff": "需要处理偏好差异"},
|
||
{"key": "B", "title": "穿搭运营团队", "best_for": "先由运营维护搭配规则和推荐口径", "suggested_capability": "规则配置、款式标签、人工校验", "rationale": "更容易控制第一版质量", "tradeoff": "自动化程度会弱一些"},
|
||
{"key": "C", "title": "品牌导购团队", "best_for": "结合商品推荐和导购场景", "suggested_capability": "商品库、搭配卡片、导购反馈", "rationale": "更适合商业转化", "tradeoff": "会更依赖商品数据"},
|
||
],
|
||
"recommendation": {"key": "A", "reason": "先服务普通消费者最能验证自动推荐是否真的成立。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
else:
|
||
opening_subject = "云原生交付底座" if "云原生交付底座" in context_blob else "发布治理平台"
|
||
payload = {
|
||
"opening": f"🧭 我先按“{opening_subject}”这个方向继续收。",
|
||
"focus": "现在要先把服务对象定住,不然后面体验和边界会飘。",
|
||
"options": [
|
||
{"key": "A", "title": "开发团队", "best_for": "先让开发自己使用", "suggested_capability": "轻量入口、日志查看", "rationale": "反馈快", "tradeoff": "治理弱"},
|
||
{"key": "B", "title": "平台运维团队", "best_for": "统一管理流程和风险", "suggested_capability": "审批、审计、回滚", "rationale": "更稳", "tradeoff": "边界要清楚"},
|
||
{"key": "C", "title": "项目交付团队", "best_for": "按项目复制流程", "suggested_capability": "模板、参数、交付流程", "rationale": "适合多项目", "tradeoff": "依赖流程稳定"},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "它更适合承接责任边界和治理闭环。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
elif '"missing_field": "tech_route"' in prompt:
|
||
if is_scoring_context:
|
||
payload = {
|
||
"opening": "🧭 我先按“主要给投顾看”这个方向继续收。",
|
||
"focus": "现在最关键的,是先把第一版技术路线定住。",
|
||
"options": [
|
||
{"key": "A", "title": "先做规则打分版", "best_for": "快速验证每日评分闭环", "suggested_capability": "FastAPI + PostgreSQL + 指标规则计算", "rationale": "最快把评分结果和解释跑通", "tradeoff": "后续扩展复杂因子时要继续补"},
|
||
{"key": "B", "title": "先做评分服务版", "best_for": "长期沉淀评分规则、反馈和版本管理", "suggested_capability": "FastAPI + Redis + PostgreSQL + 评分服务", "rationale": "更适合稳定承接每日评分和后续优化", "tradeoff": "复杂度高于 A"},
|
||
{"key": "C", "title": "直接做投研分析底座", "best_for": "多团队共用、多策略扩展和实验", "suggested_capability": "因子服务、分析接口、策略实验层", "rationale": "长期上限更高", "tradeoff": "首版投入最大"},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "先做评分服务版,更适合稳定承接每日评分和后续反馈优化。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
else:
|
||
payload = {
|
||
"opening": "🧭 我先按“平台运维团队要长期使用”这个方向继续收。",
|
||
"focus": "现在最关键的,是先把第一版技术路线定住。",
|
||
"options": [
|
||
{"key": "A", "title": "先做脚本增强版", "best_for": "快速验证闭环", "suggested_capability": "FastAPI + Shell/Ansible + PostgreSQL", "rationale": "最快落地", "tradeoff": "平台化弱"},
|
||
{"key": "B", "title": "先做任务编排平台版", "best_for": "长期做任务状态和追踪", "suggested_capability": "FastAPI + React + Redis + Celery + PostgreSQL", "rationale": "平衡落地和扩展", "tradeoff": "复杂度高于 A"},
|
||
{"key": "C", "title": "直接做长期能力底座", "best_for": "多团队标准化复用", "suggested_capability": "FastAPI + React + PostgreSQL + 规则配置 + 审计日志", "rationale": "上限高", "tradeoff": "门槛最高"},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "它最适合把任务编排和治理能力留出空间。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
elif '"missing_field": "first_scope"' in prompt:
|
||
if is_scoring_context:
|
||
payload = {
|
||
"opening": "🧭 我先按“评分服务版”继续往下收。",
|
||
"focus": "这一步要定第一版先证明什么价值。",
|
||
"options": [
|
||
{"key": "A", "title": "每日评分闭环", "best_for": "先证明每日分数能稳定产出并被查看", "suggested_capability": "每日评分、重点股票列表、查看记录", "rationale": "最能体现首版核心价值", "tradeoff": "解释能力先做轻一点"},
|
||
{"key": "B", "title": "评分解释闭环", "best_for": "先证明用户能理解分数为什么这么给", "suggested_capability": "评分解释、指标拆解、结果回看", "rationale": "更适合投研场景验证可解释性", "tradeoff": "产出节奏会比 A 慢一点"},
|
||
{"key": "C", "title": "反馈优化闭环", "best_for": "先把用户反馈接回来用于后续优化", "suggested_capability": "反馈记录、评分修正、版本对比", "rationale": "利于后续持续调优", "tradeoff": "首版会多一层反馈管理复杂度"},
|
||
],
|
||
"recommendation": {"key": "A", "reason": "MVP 最先要证明每日评分结果能稳定产出并被实际查看。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
else:
|
||
payload = {
|
||
"opening": "🧭 我先按“任务编排平台版”继续往下收。",
|
||
"focus": "这一步要定第一版到底先证明什么价值。",
|
||
"options": [
|
||
{"key": "A", "title": "批量执行闭环", "best_for": "先证明任务能跑通", "suggested_capability": "任务创建、执行日志、失败重试", "rationale": "最能体现核心价值", "tradeoff": "治理稍后补"},
|
||
{"key": "B", "title": "责任字段闭环", "best_for": "先把责任人和截止时间稳定下来", "suggested_capability": "字段抽取、责任校验、确认门禁", "rationale": "更适合项目管理试点", "tradeoff": "自动执行能力稍后补"},
|
||
{"key": "C", "title": "治理闭环", "best_for": "先控制上线风险", "suggested_capability": "审批、审计、权限", "rationale": "责任边界清楚", "tradeoff": "可能做重"},
|
||
],
|
||
"recommendation": {"key": "A", "reason": "MVP 最先要证明核心闭环能跑起来。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
else:
|
||
payload = {
|
||
"opening": "🧭 我先按“当前范围”继续收尾。",
|
||
"focus": "现在要拍板第一版推进方式。",
|
||
"options": [
|
||
{"key": "A", "title": "内部 PoC", "best_for": "低风险试错", "suggested_capability": "快速反馈", "rationale": "最轻", "tradeoff": "真实问题暴露少"},
|
||
{"key": "B", "title": "项目级试点", "best_for": "真实项目验证", "suggested_capability": "试点接入、基础审批、基础回滚", "rationale": "更能验证价值", "tradeoff": "需要负责人配合"},
|
||
{"key": "C", "title": "平台化上线", "best_for": "多团队接入", "suggested_capability": "统一权限、统一规范", "rationale": "长期价值最大", "tradeoff": "投入最高"},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "项目级试点最适合真实验收。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
return StructuredExtractionResult(
|
||
payload=payload,
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.llm_service,
|
||
"generate_structured_object",
|
||
fake_generate_structured_object,
|
||
)
|
||
|
||
|
||
def test_build_contextual_requirement_plan_card_keeps_source_material_user_only() -> None:
|
||
turns = [
|
||
{
|
||
"user": "做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手,先不要创建项目,只收第一版需求。",
|
||
"assistant": (
|
||
"可以,我们先不创建项目,先把这件事收成一版需求。\n\n"
|
||
"A. 轻量工具型\nB. 业务平台型\nC. 能力底座型\n\n"
|
||
"请回复 A/B/C 确认方向"
|
||
),
|
||
},
|
||
{
|
||
"user": "A",
|
||
"assistant": (
|
||
"已确认选择 A:轻量工具型。当前需求草稿已完成第一轮关键判断,"
|
||
"正在整合生成结构化需求卡片,请稍候。"
|
||
),
|
||
},
|
||
]
|
||
|
||
card = mixin_execute_query_project.ExecuteQueryProjectMixin._build_contextual_requirement_plan_card(
|
||
topic=None,
|
||
turns=turns,
|
||
)
|
||
|
||
assert str(card.get("source_material") or "").startswith(
|
||
"用户:做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手"
|
||
)
|
||
assert "系统:" not in str(card.get("source_material") or "")
|
||
assert "当前需求草稿已完成第一轮关键判断" not in str(card.get("source_material") or "")
|
||
|
||
|
||
def test_build_requirement_plan_draft_packet_uses_contextual_goal_instead_of_generic_it_project() -> None:
|
||
card = {
|
||
"title": "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手",
|
||
"goal": mixin_execute_query_project.ExecuteQueryProjectMixin._derive_requirement_plan_goal(
|
||
message_text="做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手",
|
||
card={},
|
||
),
|
||
"project_nature": "轻量工具型",
|
||
"target_user": "仅限行政安保值班员",
|
||
"tech_route": "本地化定时脚本+企业微信推送",
|
||
"first_scope": "仅含当日超时未关+强开记录",
|
||
"source_material": "用户:做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手",
|
||
"question_round": 4,
|
||
}
|
||
|
||
packet = mixin_execute_query_project.ExecuteQueryProjectMixin._build_requirement_plan_draft_packet(
|
||
card=card
|
||
)
|
||
packet_blob = json.dumps(packet, ensure_ascii=False)
|
||
|
||
assert "先把 IT 项目的第一版目标" not in packet_blob
|
||
assert "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手" in packet_blob
|
||
|
||
|
||
def test_sanitize_requirement_plan_card_runtime_context_cleans_legacy_draft_pollution() -> None:
|
||
dirty_card = {
|
||
"title": "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手",
|
||
"goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。",
|
||
"plan_profile": "it_project",
|
||
"source_material": (
|
||
"用户:做一个每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手"
|
||
" | 用户:A"
|
||
" | 系统:已确认选择 A:轻量工具型。当前需求草稿已完成第一轮关键判断。"
|
||
),
|
||
}
|
||
|
||
cleaned = mixin_execute_query_project.ExecuteQueryProjectMixin._sanitize_requirement_plan_card_runtime_context(
|
||
card=dirty_card
|
||
)
|
||
|
||
assert "系统:" not in str(cleaned.get("source_material") or "")
|
||
assert "当前需求草稿已完成第一轮关键判断" not in str(cleaned.get("source_material") or "")
|
||
assert "先把 IT 项目的第一版目标" not in str(cleaned.get("goal") or "")
|
||
assert "每晚自动汇总门禁异常开门记录并发给行政安保的轻量助手" in str(cleaned.get("goal") or "")
|
||
|
||
|
||
def _install_requirement_plan_query_bundle(
|
||
monkeypatch,
|
||
*,
|
||
message_intent: str = "requirement_plan",
|
||
consultant_mode_preference: str = "clarify",
|
||
topic_switch: str | None = None,
|
||
) -> None:
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
payload = dict(metadata or {})
|
||
_ = message_text
|
||
effective_topic_switch = topic_switch
|
||
if effective_topic_switch is None:
|
||
explicit_topic_switch = str(payload.get("topic_switch") or "").strip()
|
||
if explicit_topic_switch:
|
||
effective_topic_switch = explicit_topic_switch
|
||
if effective_topic_switch is None:
|
||
has_thread = isinstance(payload.get("thread_context"), dict) and bool(payload.get("thread_context"))
|
||
has_active_requirement = bool(payload.get("active_requirement_plan_present"))
|
||
effective_topic_switch = "continue" if has_thread or has_active_requirement else "new"
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-plan-query",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.92,
|
||
metadata={"message_intent": message_intent},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": message_intent,
|
||
"consultant_mode_preference": consultant_mode_preference,
|
||
"topic_switch": effective_topic_switch,
|
||
},
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_text_route_uses_pmflow_manual_ingress(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeKernel:
|
||
def __init__(self, db, tenant_id: int):
|
||
captured["init_db"] = db
|
||
captured["init_tenant_id"] = tenant_id
|
||
|
||
async def process_ingress(self, **kwargs):
|
||
captured["ingress_kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "当前主链正常。",
|
||
}
|
||
|
||
monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel)
|
||
|
||
request = PMOProcessTextRequest(
|
||
message_text="现在进展怎么样?",
|
||
sender="产品经理A",
|
||
source_message_id="manual-1",
|
||
project_id=11,
|
||
)
|
||
current_user = SimpleNamespace(name="ignored", username="ignored")
|
||
|
||
response = await routes_core.process_text(
|
||
request=request,
|
||
current_user=current_user,
|
||
db=object(),
|
||
tenant_id=5,
|
||
)
|
||
|
||
assert response["code"] == 0
|
||
data = response["data"]
|
||
assert data["status"] == "answered"
|
||
kwargs = captured["ingress_kwargs"]
|
||
assert isinstance(kwargs, dict)
|
||
assert kwargs["source_system"] == "pmflow_manual"
|
||
assert kwargs["source_message_id"] == "manual-1"
|
||
assert kwargs["message_text"] == "现在进展怎么样?"
|
||
assert kwargs["sender"] == "产品经理A"
|
||
assert kwargs["channel_ref"] == "pmflow_manual"
|
||
assert kwargs["chat_type"] == "manual"
|
||
assert kwargs["project_hint_id"] == 11
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_text_route_rejects_unhandled_result(monkeypatch) -> None:
|
||
class FakeKernel:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def process_ingress(self, **kwargs):
|
||
_ = kwargs
|
||
return {"handled": False, "reason": "kernel_not_handled"}
|
||
|
||
monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel)
|
||
request = PMOProcessTextRequest(message_text="hello")
|
||
current_user = SimpleNamespace(name="u", username="u")
|
||
|
||
with pytest.raises(HTTPException) as exc_info:
|
||
await routes_core.process_text(
|
||
request=request,
|
||
current_user=current_user,
|
||
db=object(),
|
||
tenant_id=1,
|
||
)
|
||
assert exc_info.value.status_code == 400
|
||
assert str(exc_info.value.detail) == "kernel_not_handled"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
captured["tenant_id"] = tenant_id
|
||
_ = db
|
||
|
||
async def classify_intent(self, text: str):
|
||
captured["classified_text"] = text
|
||
return {
|
||
"intent": "mixed",
|
||
"question_part": "现在进展如何",
|
||
"statement_part": "新增任务确认流",
|
||
}
|
||
|
||
async def answer_question(self, **kwargs):
|
||
captured["answer_kwargs"] = kwargs
|
||
return {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 9
|
||
|
||
monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
kernel = FakeKernel()
|
||
clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context(
|
||
source_system="pmflow_manual",
|
||
source_message_id="manual-msg-2",
|
||
channel_ref="pmflow-chat",
|
||
source_sender_open_id="ou-pmflow-1",
|
||
thread_context={"reply_to_message_id": "reply-1"},
|
||
message_text="新增任务确认流,也同步一下当前进展",
|
||
project_hint_id=22,
|
||
)
|
||
|
||
assert clean_text == "新增任务确认流"
|
||
assert mixed_meta["mixed_intent"] is True
|
||
assert mixed_meta["mixed_question_part"] == "现在进展如何"
|
||
assert mixed_meta["mixed_signal_part"] == "新增任务确认流"
|
||
assert "[回答] 进展稳定" in str(mixed_meta.get("mixed_sidecar_reply") or "")
|
||
answer_kwargs = captured["answer_kwargs"]
|
||
assert isinstance(answer_kwargs, dict)
|
||
assert answer_kwargs["asked_via"] == "pmflow_manual"
|
||
assert answer_kwargs["project_id"] is None
|
||
assert answer_kwargs["message_intent"] == "mixed"
|
||
assert answer_kwargs["source_sender_open_id"] == "ou-pmflow-1"
|
||
assert answer_kwargs["thread_context"] == {"reply_to_message_id": "reply-1"}
|
||
assert answer_kwargs["mixed_signal_part"] == "新增任务确认流"
|
||
assert answer_kwargs["conversation_key"] == "manual-msg-2"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_ingress_canonicalizes_thread_context_before_prepare_envelope(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeMountedAIRuntimeWriteService:
|
||
def __init__(self, db) -> None:
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict[str, object] | None,
|
||
) -> tuple[dict[str, object] | None, dict[str, str | None]]:
|
||
captured["canonicalize_kwargs"] = {
|
||
"tenant_id": tenant_id,
|
||
"source_system": source_system,
|
||
"channel_ref": channel_ref,
|
||
"thread_context": dict(thread_context or {}),
|
||
}
|
||
return (
|
||
{
|
||
"root_message_id": "om-source-1",
|
||
"reply_to_message_id": "om-source-1",
|
||
},
|
||
{
|
||
"original_root_message_id": "om-bot-1",
|
||
"canonical_root_message_id": "om-source-1",
|
||
},
|
||
)
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 9
|
||
|
||
async def _prepare_ingress_envelope(self, **kwargs):
|
||
captured["prepare_kwargs"] = kwargs
|
||
return {
|
||
"early_result": {"handled": True, "status": "early"},
|
||
"clean_text": kwargs["message_text"],
|
||
"synthetic_message_id": kwargs["source_message_id"] or "synthetic",
|
||
"closure_payload": {},
|
||
"ingest_event": None,
|
||
"operator": None,
|
||
"work_item": None,
|
||
"session": None,
|
||
}
|
||
|
||
monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService)
|
||
kernel = FakeKernel()
|
||
result = await kernel.process_ingress(
|
||
source_system="feishu",
|
||
source_message_id="msg-canonical-1",
|
||
message_text="C",
|
||
sender="tester",
|
||
channel_ref="chat-canonical-1",
|
||
source_sender_open_id="ou-canonical-1",
|
||
ingress_options={
|
||
"thread_context": {
|
||
"root_message_id": "om-bot-1",
|
||
"reply_to_message_id": "om-bot-1",
|
||
}
|
||
},
|
||
)
|
||
|
||
assert result == {"handled": True, "status": "early"}
|
||
assert captured["canonicalize_kwargs"] == {
|
||
"tenant_id": 9,
|
||
"source_system": "feishu",
|
||
"channel_ref": "chat-canonical-1",
|
||
"thread_context": {
|
||
"root_message_id": "om-bot-1",
|
||
"reply_to_message_id": "om-bot-1",
|
||
},
|
||
}
|
||
prepare_kwargs = captured["prepare_kwargs"]
|
||
assert isinstance(prepare_kwargs, dict)
|
||
assert prepare_kwargs["thread_context"] == {
|
||
"root_message_id": "om-source-1",
|
||
"reply_to_message_id": "om-source-1",
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("message_text", ["C", "我选C", "选C", "就C吧", "先按C", "确认A,继续往下收。"])
|
||
async def test_prepare_mixed_sidecar_context_skips_requirement_choice_reply_with_explicit_anchor(
|
||
monkeypatch,
|
||
message_text: str,
|
||
) -> None:
|
||
captured: dict[str, object] = {"classify_calls": 0, "answer_calls": 0}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def classify_intent(self, text: str):
|
||
captured["classify_calls"] = int(captured["classify_calls"]) + 1
|
||
return {
|
||
"intent": "mixed",
|
||
"question_part": "无法继续回答“C”,因缺少上一轮对话上下文。",
|
||
"statement_part": "需求信息待补充,已标记为待澄清项。",
|
||
}
|
||
|
||
async def answer_question(self, **kwargs):
|
||
captured["answer_calls"] = int(captured["answer_calls"]) + 1
|
||
return {"answer": "[回答] 无法继续回答“C”\n[已收到需求] 需求信息待补充"}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 9
|
||
|
||
monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
kernel = FakeKernel()
|
||
clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context(
|
||
source_system="feishu",
|
||
source_message_id="feishu-msg-choice",
|
||
channel_ref="chat-choice",
|
||
source_sender_open_id="ou-choice",
|
||
thread_context={"reply_to_message_id": "reply-abc", "root_message_id": "reply-abc"},
|
||
message_text=message_text,
|
||
project_hint_id=None,
|
||
)
|
||
|
||
assert clean_text == message_text
|
||
assert mixed_meta == {}
|
||
assert captured["classify_calls"] == 0
|
||
assert captured["answer_calls"] == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_prepare_mixed_sidecar_context_does_not_fallback_to_local_choice_guess_when_parser_errors(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {"classify_calls": 0}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def classify_intent(self, text: str):
|
||
captured["classify_calls"] = int(captured["classify_calls"]) + 1
|
||
return {"intent": "query"}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 9
|
||
|
||
def raise_parser_error(message_text: str):
|
||
_ = message_text
|
||
raise RuntimeError("parser unavailable")
|
||
|
||
monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_parse_requirement_plan_choice_protocol",
|
||
classmethod(lambda cls, message_text: raise_parser_error(message_text)),
|
||
)
|
||
kernel = FakeKernel()
|
||
|
||
clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context(
|
||
source_system="feishu",
|
||
source_message_id="feishu-msg-choice-error",
|
||
channel_ref="chat-choice",
|
||
source_sender_open_id="ou-choice",
|
||
thread_context={"reply_to_message_id": "reply-abc", "root_message_id": "reply-abc"},
|
||
message_text="C",
|
||
project_hint_id=None,
|
||
)
|
||
|
||
assert clean_text == "C"
|
||
assert mixed_meta == {}
|
||
assert captured["classify_calls"] == 1
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
("message_text", "expected_choice", "expected_remaining"),
|
||
[
|
||
("C", "C", ""),
|
||
(" 我选C ", "C", ""),
|
||
("选C,继续聊主播脚本", "C", "继续聊主播脚本"),
|
||
("就C吧", "C", ""),
|
||
("先按C:继续收集范围", "C", "继续收集范围"),
|
||
("确认A,继续往下收。", "A", "继续往下收。"),
|
||
],
|
||
)
|
||
def test_requirement_plan_choice_protocol_parser_returns_choice_and_remaining_text(
|
||
message_text: str,
|
||
expected_choice: str,
|
||
expected_remaining: str,
|
||
) -> None:
|
||
parsed = mixin_execute_query_project.ExecuteQueryProjectMixin._parse_requirement_plan_choice_protocol(message_text)
|
||
|
||
assert isinstance(parsed, dict)
|
||
assert parsed["choice"] == expected_choice
|
||
assert parsed["remaining_text"] == expected_remaining
|
||
|
||
|
||
def test_merge_requirement_plan_round_options_from_assistant_reply_uses_non_regex_line_parser() -> None:
|
||
assistant_text = (
|
||
"第一轮收敛:这是什么性质的项目?\n\n"
|
||
"A. 主播培训系统\n"
|
||
"B. 主播数字分身工厂\n"
|
||
"C. 主播协同作战平台\n\n"
|
||
"⭐ 当前我更推荐:C\n"
|
||
"你回 A / B / C,我就顺着这条继续往下收。"
|
||
)
|
||
|
||
merged = mixin_execute_query_project.ExecuteQueryProjectMixin._merge_requirement_plan_round_options_from_assistant_reply(
|
||
card={"question_round": 1},
|
||
assistant_text=assistant_text,
|
||
)
|
||
|
||
assert merged["round_option_maps"]["project_nature"] == {
|
||
"A": "主播培训系统",
|
||
"B": "主播数字分身工厂",
|
||
"C": "主播协同作战平台",
|
||
}
|
||
assert merged["last_round_options"]["recommendation"] == {"key": "C"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
self.draft_called = False
|
||
|
||
async def _prepare_ingress_envelope(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"early_result": None,
|
||
"clean_text": "确认",
|
||
"synthetic_message_id": "s-msg-1",
|
||
"closure_payload": {},
|
||
"ingest_event": SimpleNamespace(id=100),
|
||
"operator": SimpleNamespace(id=8),
|
||
"work_item": SimpleNamespace(id=55),
|
||
"session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM),
|
||
}
|
||
|
||
async def _maybe_handle_draft_confirmation_ingress(self, **kwargs):
|
||
self.draft_called = True
|
||
_ = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "command",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已确认草稿并继续执行。",
|
||
}
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def _prepare_mixed_sidecar_context(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("draft_pending_confirm flow should bypass mixed sidecar precheck")
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel.process_ingress(
|
||
source_system="feishu",
|
||
source_message_id="feishu-1",
|
||
message_text="确认",
|
||
sender="tester",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}},
|
||
)
|
||
|
||
assert kernel.draft_called is True
|
||
assert result["status"] == "answered"
|
||
assert "草稿" in result["direct_reply_text"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_ingress_binds_active_draft_before_query_for_regenerate_request() -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
self.active_context_called = False
|
||
|
||
async def _prepare_ingress_envelope(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"early_result": None,
|
||
"clean_text": "再出一版草稿",
|
||
"synthetic_message_id": "s-msg-regen-1",
|
||
"closure_payload": {},
|
||
"ingest_event": SimpleNamespace(id=101),
|
||
"operator": SimpleNamespace(id=8),
|
||
"work_item": SimpleNamespace(id=56),
|
||
"session": SimpleNamespace(last_intent="query.read_only"),
|
||
}
|
||
|
||
async def _maybe_handle_active_context_followup(self, **kwargs):
|
||
if kwargs["force_bind"] is not True:
|
||
raise AssertionError("active draft regenerate request should bind current draft before fallback")
|
||
self.active_context_called = True
|
||
_ = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "我先基于当前草稿再整理一版。",
|
||
}
|
||
|
||
async def _maybe_handle_draft_confirmation_ingress(self, **kwargs):
|
||
raise AssertionError("active draft regenerate request should stay on active-context semantics")
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel.process_ingress(
|
||
source_system="feishu",
|
||
source_message_id="feishu-regen-1",
|
||
message_text="再出一版草稿",
|
||
sender="tester",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
)
|
||
|
||
assert kernel.active_context_called is True
|
||
assert result["status"] == "answered"
|
||
assert "当前草稿" in result["direct_reply_text"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_ingress_binds_active_requirement_before_query_for_short_followup(monkeypatch) -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
self.bound = False
|
||
|
||
async def _prepare_ingress_envelope(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"early_result": None,
|
||
"clean_text": "信号源呢?",
|
||
"synthetic_message_id": "s-msg-req-followup-1",
|
||
"closure_payload": {},
|
||
"ingest_event": SimpleNamespace(id=102),
|
||
"operator": SimpleNamespace(id=8),
|
||
"work_item": SimpleNamespace(id=57),
|
||
"session": SimpleNamespace(last_intent="query.read_only"),
|
||
}
|
||
|
||
async def _maybe_handle_active_context_followup(self, **kwargs):
|
||
if kwargs["force_bind"] is not True:
|
||
raise AssertionError("active requirement followup should return before non-forced retry")
|
||
self.bound = True
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "我先把外部信号源补到当前需求里。",
|
||
}
|
||
|
||
async def _should_route_to_draft_confirmation_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("active requirement followup should bypass draft confirmation routing")
|
||
|
||
async def _prepare_mixed_sidecar_context(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("active requirement followup should not fall back to query sidecar")
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel.process_ingress(
|
||
source_system="feishu",
|
||
source_message_id="feishu-req-followup-1",
|
||
message_text="信号源呢?",
|
||
sender="tester",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
)
|
||
|
||
assert kernel.bound is True
|
||
assert result["status"] == "answered"
|
||
assert "外部信号源" in result["direct_reply_text"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_ingress_records_mounted_runtime_for_draft_confirmation(monkeypatch) -> None:
|
||
captured: dict[str, object] = {"calls": []}
|
||
|
||
class FakeMountedAIRuntimeWriteService:
|
||
def __init__(self, db) -> None:
|
||
captured["db"] = db
|
||
|
||
async def begin_ingress_run(self, **kwargs):
|
||
captured["calls"].append(("begin", kwargs))
|
||
return SimpleNamespace(
|
||
topic_workspace=SimpleNamespace(id=91, tenant_id=1),
|
||
conversation_run=SimpleNamespace(id=92),
|
||
context_layers_hit={"current_message": True, "thread": True, "topic": False},
|
||
)
|
||
|
||
async def record_orchestrator_decision(self, **kwargs):
|
||
captured["calls"].append(("record", kwargs))
|
||
return "plan"
|
||
|
||
async def record_agent_trace(self, **kwargs):
|
||
captured["calls"].append(("agent_trace", kwargs))
|
||
|
||
async def finalize_run(self, **kwargs):
|
||
captured["calls"].append(("finalize", kwargs))
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _prepare_ingress_envelope(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"early_result": None,
|
||
"clean_text": "确认",
|
||
"synthetic_message_id": "s-msg-mounted-1",
|
||
"closure_payload": {},
|
||
"ingest_event": SimpleNamespace(id=100),
|
||
"operator": SimpleNamespace(id=8),
|
||
"work_item": SimpleNamespace(id=55),
|
||
"session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM),
|
||
}
|
||
|
||
async def _maybe_handle_draft_confirmation_ingress(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "command",
|
||
"workflow_route": "followup.confirm",
|
||
"reply_template_key": "action_succeeded",
|
||
"work_item_id": 55,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已确认草稿并继续执行。",
|
||
}
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def _prepare_mixed_sidecar_context(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("draft confirmation flow should bypass mixed sidecar precheck")
|
||
|
||
monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService)
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel.process_ingress(
|
||
source_system="feishu",
|
||
source_message_id="feishu-mounted-1",
|
||
message_text="确认",
|
||
sender="tester",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}},
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert [name for name, _ in captured["calls"]] == ["begin", "record", "agent_trace", "finalize"]
|
||
begin_kwargs = captured["calls"][0][1]
|
||
record_kwargs = captured["calls"][1][1]
|
||
agent_trace_kwargs = captured["calls"][2][1]
|
||
finalize_kwargs = captured["calls"][3][1]
|
||
assert begin_kwargs["source_system"] == "feishu"
|
||
assert begin_kwargs["source_message_id"] == "s-msg-mounted-1"
|
||
assert record_kwargs["workflow_route"] == "followup.confirm"
|
||
assert agent_trace_kwargs["agent_name"] not in {"ConversationOrchestrator", "ReplyComposer"}
|
||
assert agent_trace_kwargs["output_summary"]["workflow_route"] == "followup.confirm"
|
||
assert finalize_kwargs["result"]["direct_reply_text"] == "已确认草稿并继续执行。"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_ingress_records_semantic_shadow_before_legacy_finalize(monkeypatch) -> None:
|
||
from app.services.ai_pmo.semantic_context.decision_schema import ModeCard, SemanticDecision
|
||
from app.services.ai_pmo.semantic_context.packet_schema import (
|
||
SemanticContextPacket,
|
||
SemanticContextThread,
|
||
SemanticContextTopic,
|
||
)
|
||
|
||
captured: dict[str, object] = {"calls": []}
|
||
|
||
class FakeMountedAIRuntimeWriteService:
|
||
def __init__(self, db) -> None:
|
||
captured["db"] = db
|
||
|
||
async def begin_ingress_run(self, **kwargs):
|
||
captured["calls"].append(("begin", kwargs))
|
||
return SimpleNamespace(
|
||
topic_workspace=SimpleNamespace(id=191, tenant_id=1, plan_status="idle"),
|
||
conversation_run=SimpleNamespace(id=192, source_message_id="s-msg-semantic-shadow"),
|
||
context_layers_hit={"current_message": True, "thread": False, "topic": False},
|
||
)
|
||
|
||
async def record_semantic_shadow(self, **kwargs):
|
||
captured["calls"].append(("semantic_shadow", kwargs))
|
||
|
||
async def record_orchestrator_decision(self, **kwargs):
|
||
captured["calls"].append(("record", kwargs))
|
||
return "chat"
|
||
|
||
async def record_agent_trace(self, **kwargs):
|
||
captured["calls"].append(("agent_trace", kwargs))
|
||
|
||
async def finalize_run(self, **kwargs):
|
||
captured["calls"].append(("finalize", kwargs))
|
||
|
||
class FakeSemanticContextAssembler:
|
||
def __init__(self, db) -> None:
|
||
captured["semantic_db"] = db
|
||
|
||
async def assemble(self, **kwargs):
|
||
captured["semantic_packet_kwargs"] = kwargs
|
||
return SemanticContextPacket(
|
||
tenant_id=1,
|
||
operator_user_id=8,
|
||
topic_workspace_id=191,
|
||
latest_user_message=str(kwargs["latest_user_message"]),
|
||
topic=SemanticContextTopic(
|
||
topic_workspace_id=191,
|
||
topic_key="feishu:chat-shadow:root:om-root-shadow",
|
||
source_system="feishu",
|
||
channel_ref="chat-shadow",
|
||
conversation_scope_key="feishu:chat-shadow:ou-shadow",
|
||
thread_key="thread-shadow",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
facts={"title": "语义 shadow"},
|
||
goals=["统一语义入口"],
|
||
constraints={"must_not": ["db.write"]},
|
||
metadata={"source": "test"},
|
||
),
|
||
thread=SemanticContextThread(
|
||
thread_key="thread-shadow",
|
||
conversation_scope_key="feishu:chat-shadow:ou-shadow",
|
||
thread_summary="继续整理语义上下文",
|
||
recent_turns=[],
|
||
open_questions=["是否继续当前 topic"],
|
||
),
|
||
mode_card=ModeCard(
|
||
workflow_mode="PLAN",
|
||
mode_state="ACTIVE",
|
||
allowed_actions=["reply.compose", "draft.patch"],
|
||
forbidden_actions=["project.create", "db.write"],
|
||
exit_conditions=["用户明确要求开始执行"],
|
||
),
|
||
)
|
||
|
||
class FakeSemanticDecisionService:
|
||
def __init__(self, _llm_service) -> None:
|
||
pass
|
||
|
||
async def decide(self, *, packet):
|
||
captured["semantic_packet"] = packet.model_dump(mode="json")
|
||
return SemanticDecision(
|
||
topic_relation="continue_current_topic",
|
||
mode_decision="stay_plan",
|
||
draft_patch={"goal": "继续整理语义上下文"},
|
||
assistant_reply="我先继续整理上下文。",
|
||
requires_confirmation=False,
|
||
requested_actions=["reply.compose", "draft.patch"],
|
||
confidence=0.88,
|
||
)
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
self.context_assembler = SimpleNamespace(assemble_world_model=self._assemble_world_model)
|
||
self.route_resolver = SimpleNamespace(from_canonical=self._route_from_canonical)
|
||
self.decision_engine = SimpleNamespace(decide=lambda **_: {"decision": "unused"})
|
||
|
||
async def _assemble_world_model(self, **kwargs):
|
||
_ = kwargs
|
||
return None, {"capacity": [], "skill_graph": [], "resolved_project_id": None}
|
||
|
||
def _route_from_canonical(self, **kwargs):
|
||
_ = kwargs
|
||
return SimpleNamespace(
|
||
key="query.answer",
|
||
family="query",
|
||
action="answer",
|
||
requested_count=1,
|
||
requires_confirmation=False,
|
||
project_id=None,
|
||
metadata={},
|
||
)
|
||
|
||
async def _prepare_ingress_envelope(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"early_result": None,
|
||
"clean_text": "继续整理语义上下文,不直接执行。",
|
||
"synthetic_message_id": "s-msg-semantic-shadow",
|
||
"closure_payload": {},
|
||
"ingest_event": SimpleNamespace(id=200),
|
||
"operator": SimpleNamespace(id=8),
|
||
"work_item": SimpleNamespace(id=55, state=None, context_snapshot=None, intent=None, project_id=None),
|
||
"session": SimpleNamespace(last_intent="query.answer"),
|
||
"session_key": "session-shadow",
|
||
}
|
||
|
||
def _merge_ingress_thread_context(self, **kwargs):
|
||
_ = kwargs
|
||
return None
|
||
|
||
def _should_route_to_draft_confirmation(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def _prepare_mixed_sidecar_context(self, **kwargs):
|
||
return kwargs["message_text"], {}
|
||
|
||
async def _resolve_intent(self, **kwargs):
|
||
_ = kwargs
|
||
return CanonicalIntent(
|
||
name=mixin_entry.INTENT_IGNORE,
|
||
confidence=0.91,
|
||
project_id=None,
|
||
signal_title="",
|
||
signal_summary="",
|
||
metadata={"message_intent": "requirement_plan"},
|
||
), "llm"
|
||
|
||
def _coerce_intent_for_query_scene(self, **kwargs):
|
||
return kwargs["intent"]
|
||
|
||
def _enrich_signal_source_intent_from_closure(self, **kwargs):
|
||
return kwargs["intent"]
|
||
|
||
async def _finalize_ignored_ingress(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "ignored",
|
||
"classification": "ignored",
|
||
"direct_reply_required": False,
|
||
"direct_reply_text": "这轮先按旧链路忽略处理。",
|
||
}
|
||
|
||
def _schedule_portfolio_snapshot_persist(self, **kwargs):
|
||
_ = kwargs
|
||
return None
|
||
|
||
monkeypatch.setattr(mixin_entry, "MountedAIRuntimeWriteService", FakeMountedAIRuntimeWriteService)
|
||
monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler)
|
||
monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService)
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel.process_ingress(
|
||
source_system="feishu",
|
||
source_message_id="feishu-shadow-1",
|
||
message_text="继续整理语义上下文,不直接执行。",
|
||
sender="tester",
|
||
channel_ref="chat-shadow",
|
||
source_sender_open_id="ou-shadow",
|
||
)
|
||
|
||
assert result["status"] == "ignored"
|
||
assert [name for name, _ in captured["calls"]] == [
|
||
"begin",
|
||
"semantic_shadow",
|
||
"record",
|
||
"agent_trace",
|
||
"finalize",
|
||
]
|
||
semantic_kwargs = captured["calls"][1][1]
|
||
assert semantic_kwargs["semantic_packet_summary"]["packet_version"] == "v1"
|
||
assert semantic_kwargs["semantic_packet_summary"]["topic_key"] == "feishu:chat-shadow:root:om-root-shadow"
|
||
assert semantic_kwargs["semantic_decision"]["mode_decision"] == "stay_plan"
|
||
assert semantic_kwargs["semantic_gate_result"]["accepted"] is True
|
||
assert semantic_kwargs["semantic_diff"]["legacy_route"] == "query.answer"
|
||
|
||
|
||
def test_should_route_to_draft_confirmation_requires_anchor_or_explicit_id() -> None:
|
||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="确认",
|
||
ingress_options=None,
|
||
) is False
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="确认",
|
||
ingress_options={"thread_context": {"thread_id": "th_123"}},
|
||
) is False
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="确认",
|
||
ingress_options={"thread_context": {"root_message_id": "root_123"}},
|
||
) is False
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="创建时间是多少?",
|
||
ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}},
|
||
) is False
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="确认",
|
||
ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}},
|
||
) is True
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="工作项ID: 88 创建时间是多少?",
|
||
ingress_options=None,
|
||
) is True
|
||
|
||
|
||
def test_natural_revision_does_not_route_to_draft_confirmation_without_reply_anchor() -> None:
|
||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="第一版先做直播录屏和评论回放切片,再加亮点和掉点总结,最后给一份复盘清单。",
|
||
ingress_options=None,
|
||
) is False
|
||
|
||
|
||
def test_scope_narrowing_revision_does_not_route_to_draft_confirmation_without_reply_anchor() -> None:
|
||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="第一版先别做太大,先把三件事做好就行:直播录屏和评论回放切片、亮点和掉点自动总结、给团队一份能直接执行的复盘清单。",
|
||
ingress_options=None,
|
||
) is False
|
||
|
||
|
||
def test_continue_current_draft_does_not_route_to_draft_confirmation_without_reply_anchor() -> None:
|
||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="按这版继续",
|
||
ingress_options=None,
|
||
) is False
|
||
|
||
|
||
def test_continue_current_draft_does_not_route_to_draft_confirmation_without_parse_action_dependency() -> None:
|
||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="按这版继续",
|
||
ingress_options=None,
|
||
) is False
|
||
|
||
|
||
def test_reply_anchor_natural_followup_stays_out_of_draft_confirmation_pre_route() -> None:
|
||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="保持当前草稿结构并补完整信息",
|
||
ingress_options={"thread_context": {"reply_to_message_id": "msg-123"}},
|
||
) is False
|
||
|
||
|
||
def test_smalltalk_does_not_route_to_draft_confirmation_without_reply_anchor() -> None:
|
||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="收到,辛苦了",
|
||
ingress_options=None,
|
||
) is False
|
||
|
||
|
||
def test_new_project_statement_does_not_route_to_existing_draft_without_anchor() -> None:
|
||
session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM)
|
||
|
||
assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="最近直播复盘太慢了,想做个给运营团队用的小工具,这个新需求先叫 FeishuWebFlow-abc。",
|
||
ingress_options=None,
|
||
) is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_route_to_draft_confirmation_session_does_not_hijack_explicit_new_requirement_topic(
|
||
monkeypatch,
|
||
) -> None:
|
||
called = False
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _fake_has_active_draft_session(self, **kwargs):
|
||
nonlocal called
|
||
_ = (self, kwargs)
|
||
called = True
|
||
return True
|
||
|
||
kernel = FakeKernel()
|
||
monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session)
|
||
monkeypatch.setattr(
|
||
kernel.__class__,
|
||
"_should_skip_no_anchor_draft_routing_by_bundle",
|
||
staticmethod(lambda **kwargs: asyncio.sleep(0, result=True)),
|
||
)
|
||
should_route = await kernel._should_route_to_draft_confirmation_session(
|
||
session=SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM),
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服",
|
||
ingress_options=None,
|
||
)
|
||
|
||
assert should_route is False
|
||
assert called is False
|
||
|
||
|
||
def test_should_inherit_recent_feishu_session_context_skips_explicit_new_requirement_topic() -> None:
|
||
session = SimpleNamespace(
|
||
sender="feishu:tester",
|
||
last_active_at=datetime.now(),
|
||
last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM,
|
||
)
|
||
|
||
should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context(
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服",
|
||
session=session,
|
||
)
|
||
|
||
assert should_inherit is False
|
||
|
||
|
||
def test_should_inherit_recent_feishu_session_context_skips_draft_pending_confirm_session() -> None:
|
||
session = SimpleNamespace(
|
||
sender="feishu:tester",
|
||
last_active_at=datetime.now(),
|
||
last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM,
|
||
last_message_text="上一版草稿已经出来了",
|
||
)
|
||
|
||
should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context(
|
||
message_text="这个怎么做",
|
||
session=session,
|
||
)
|
||
|
||
assert should_inherit is False
|
||
|
||
|
||
def test_should_inherit_recent_feishu_session_context_skips_non_query_last_intent() -> None:
|
||
session = SimpleNamespace(
|
||
sender="feishu:tester",
|
||
last_active_at=datetime.now(),
|
||
last_intent="signal_source.onboard",
|
||
last_message_text="给项目2接一个 RTMP 信号源",
|
||
)
|
||
|
||
should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context(
|
||
message_text="这个怎么做",
|
||
session=session,
|
||
)
|
||
|
||
assert should_inherit is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reset_session_project_context_does_not_guess_new_requirement_from_message_text() -> None:
|
||
class _FakeDB:
|
||
async def flush(self):
|
||
return None
|
||
|
||
class FakeKernel(
|
||
mixin_entry.KernelIngressFlowEntryMixin,
|
||
ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
kernel = FakeKernel()
|
||
session = SimpleNamespace(
|
||
current_project_id=104,
|
||
last_intent="query.read_only",
|
||
last_message_text="A",
|
||
)
|
||
|
||
cleared = await kernel._maybe_reset_session_project_context_for_new_requirement(
|
||
session=session,
|
||
sender="tester",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
source_message_id="msg-new-topic",
|
||
thread_context=None,
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服",
|
||
)
|
||
|
||
assert cleared is False
|
||
assert session.current_project_id == 104
|
||
assert session.last_intent == "query.read_only"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("message_text", ["A", "确认", "你自行推荐"])
|
||
async def test_orphan_requirement_followup_without_active_requirement_context_is_ignored(message_text: str) -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
async def _has_active_requirement_plan_workspace(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel._maybe_handle_orphan_requirement_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-orphan-reply",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
ingress_options=None,
|
||
message_text=message_text,
|
||
)
|
||
|
||
assert result is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_orphan_requirement_followup_recovers_requirement_context_from_explicit_reply_before_refusing() -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeExecutor:
|
||
async def _promote_contextual_reply_into_requirement_plan_workspace(self, **kwargs):
|
||
captured["promote_kwargs"] = dict(kwargs)
|
||
return SimpleNamespace(id=11, plan_status="collecting"), SimpleNamespace(id=29, state="collecting")
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
self.executor = FakeExecutor()
|
||
|
||
async def _has_active_requirement_plan_workspace(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
kernel = FakeKernel()
|
||
session = SimpleNamespace(
|
||
current_project_id=None,
|
||
last_intent="requirement_plan",
|
||
last_message_text="C",
|
||
)
|
||
|
||
result = await kernel._maybe_handle_orphan_requirement_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-orphan-recover",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
ingress_options={
|
||
"thread_context": {
|
||
"root_message_id": "om-plan-root",
|
||
"reply_to_message_id": "om-plan-root",
|
||
}
|
||
},
|
||
message_text="我选C",
|
||
session=session,
|
||
sender="tester",
|
||
)
|
||
|
||
assert result is None
|
||
promote_kwargs = captured.get("promote_kwargs")
|
||
assert isinstance(promote_kwargs, dict)
|
||
assert promote_kwargs["message_text"] == "我选C"
|
||
assert promote_kwargs["thread_context"] == {
|
||
"root_message_id": "om-plan-root",
|
||
"reply_to_message_id": "om-plan-root",
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_orphan_requirement_followup_clears_stale_session_project_context() -> None:
|
||
class _FakeDB:
|
||
async def flush(self):
|
||
return None
|
||
|
||
class FakeKernel(
|
||
mixin_entry.KernelIngressFlowEntryMixin,
|
||
ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
async def _has_active_requirement_plan_workspace(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
kernel = FakeKernel()
|
||
session = SimpleNamespace(
|
||
current_project_id=104,
|
||
last_intent="query.read_only",
|
||
last_message_text="A",
|
||
)
|
||
|
||
result = await kernel._maybe_handle_orphan_requirement_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-orphan-reply-stale-project",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
ingress_options=None,
|
||
message_text="A",
|
||
session=session,
|
||
sender="tester",
|
||
)
|
||
|
||
assert result is None
|
||
assert session.current_project_id == 104
|
||
assert session.last_intent == "query.read_only"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_orphan_requirement_followup_skips_guard_when_active_requirement_session_exists() -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
async def _has_active_requirement_plan_workspace(self, **kwargs):
|
||
_ = kwargs
|
||
return True
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel._maybe_handle_orphan_requirement_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
ingress_options=None,
|
||
message_text="A",
|
||
)
|
||
|
||
assert result is None
|
||
|
||
|
||
def test_world_model_session_project_inheritance_rejects_low_context_followup() -> None:
|
||
class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin):
|
||
pass
|
||
|
||
kernel = FakeKernel()
|
||
session = SimpleNamespace(
|
||
sender="feishu:chat-1:ou_dm_1",
|
||
last_intent="query.read_only",
|
||
current_project_id=104,
|
||
last_active_at=datetime.now(),
|
||
)
|
||
|
||
assert kernel._extract_inherited_project_id(session=session, message_text="A") is None
|
||
assert kernel._extract_inherited_project_id(session=session, message_text="你自行推荐") is None
|
||
|
||
|
||
def test_world_model_session_project_inheritance_rejects_stale_low_context_previous_turn() -> None:
|
||
class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin):
|
||
pass
|
||
|
||
kernel = FakeKernel()
|
||
session = SimpleNamespace(
|
||
sender="feishu:chat-1:ou_dm_1",
|
||
last_intent="query.read_only",
|
||
current_project_id=104,
|
||
last_message_text="A",
|
||
last_active_at=datetime.now(),
|
||
)
|
||
|
||
assert kernel._extract_inherited_project_id(session=session, message_text="这个怎么做") is None
|
||
|
||
|
||
def test_draft_confirmation_router_keeps_requirement_plan_field_list_in_plan_flow() -> None:
|
||
session = SimpleNamespace(last_intent="query.read_only")
|
||
|
||
assert (
|
||
mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=session,
|
||
message_text="待办、责任人、截止时间",
|
||
ingress_options={"thread_context": {"reply_to_message_id": "msg-plan-root"}},
|
||
)
|
||
is False
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_active_requirement_followup_prefers_current_requirement_context(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["workspace_lookup"] = kwargs
|
||
return (
|
||
SimpleNamespace(id=81, plan_status="collecting"),
|
||
SimpleNamespace(id=82, state="collecting"),
|
||
)
|
||
|
||
async def _build_requirement_planning_direct_result(self, **kwargs):
|
||
captured["direct_kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "我先按当前需求把信号输入这一块补进去。",
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
class FakeSemanticContextAssembler:
|
||
def __init__(self, db) -> None:
|
||
self.db = db
|
||
|
||
async def assemble(self, **kwargs):
|
||
captured["packet_kwargs"] = kwargs
|
||
return SimpleNamespace(
|
||
mode_card=SimpleNamespace(
|
||
allowed_actions=[
|
||
"reply.compose",
|
||
"context.followup.current_requirement",
|
||
"context.followup.independent_workflow",
|
||
],
|
||
forbidden_actions=[],
|
||
)
|
||
)
|
||
|
||
class FakeSemanticDecisionService:
|
||
def __init__(self, _llm_service) -> None:
|
||
pass
|
||
|
||
async def decide(self, *, packet):
|
||
_ = packet
|
||
return SimpleNamespace(
|
||
topic_relation="continue_current_topic",
|
||
mode_decision="stay_plan",
|
||
followup_scope="current_requirement",
|
||
followup_action="none",
|
||
revision_note="",
|
||
assistant_reply="我先按当前需求把信号输入这一块补进去。",
|
||
requires_confirmation=False,
|
||
requested_actions=["reply.compose", "context.followup.current_requirement"],
|
||
)
|
||
|
||
monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler)
|
||
monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService)
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel._maybe_handle_active_context_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-followup-1",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="信号源呢?",
|
||
thread_context=None,
|
||
sender="tester",
|
||
)
|
||
|
||
assert result is not None
|
||
assert result["handled"] is True
|
||
assert "信号输入" in result["direct_reply_text"]
|
||
assert captured["packet_kwargs"]["latest_user_message"] == "信号源呢?"
|
||
assert captured["packet_kwargs"]["ingress_context"]["candidate_kind"] == "active_context_followup"
|
||
assert captured["direct_kwargs"]["message_text"] == "信号源呢?"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_active_requirement_followup_uses_builder_result_to_persist_requirement_context(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["workspace_lookup"] = kwargs
|
||
return (
|
||
SimpleNamespace(id=91, plan_status="collecting"),
|
||
SimpleNamespace(id=92, state="collecting"),
|
||
)
|
||
|
||
async def _build_requirement_planning_direct_result(self, **kwargs):
|
||
captured["direct_kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "第三轮:服务对象\n\nA. 单一层级区域经理\nB. 区域经理 + 店长\nC. 总部 + 区域 + 门店多层级",
|
||
"reply_template_key": "requirement_plan_followup",
|
||
"requirement_plan_context": {
|
||
"question_round": 2,
|
||
"last_round_options": {
|
||
"field": "target_user",
|
||
"options": [
|
||
{"key": "A", "title": "单一层级区域经理"},
|
||
{"key": "B", "title": "区域经理 + 店长"},
|
||
{"key": "C", "title": "总部 + 区域 + 门店多层级"},
|
||
],
|
||
"recommendation": {"key": "A"},
|
||
},
|
||
},
|
||
}
|
||
|
||
class FakeSemanticContextAssembler:
|
||
def __init__(self, db) -> None:
|
||
self.db = db
|
||
|
||
async def assemble(self, **kwargs):
|
||
captured["packet_kwargs"] = kwargs
|
||
return SimpleNamespace(
|
||
mode_card=SimpleNamespace(
|
||
allowed_actions=[
|
||
"reply.compose",
|
||
"context.followup.current_requirement",
|
||
],
|
||
forbidden_actions=[],
|
||
)
|
||
)
|
||
|
||
class FakeSemanticDecisionService:
|
||
def __init__(self, _llm_service) -> None:
|
||
pass
|
||
|
||
async def decide(self, *, packet):
|
||
_ = packet
|
||
return SimpleNamespace(
|
||
topic_relation="continue_current_topic",
|
||
mode_decision="stay_plan",
|
||
followup_scope="current_requirement",
|
||
followup_action="none",
|
||
revision_note="",
|
||
assistant_reply="已确认选项 A(单一层级区域经理),继续收第二轮关键信息。",
|
||
requires_confirmation=False,
|
||
requested_actions=["reply.compose", "context.followup.current_requirement"],
|
||
)
|
||
|
||
monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler)
|
||
monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService)
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel._maybe_handle_active_context_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-followup-2",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="确认A,继续往下收。",
|
||
thread_context={"reply_to_message_id": "om-plan-reply"},
|
||
sender="tester",
|
||
)
|
||
|
||
assert result is not None
|
||
assert result["handled"] is True
|
||
assert result["direct_reply_text"].startswith("第三轮:服务对象")
|
||
assert result["requirement_plan_context"]["last_round_options"]["field"] == "target_user"
|
||
assert "direct_kwargs" in captured
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_active_requirement_followup_defaults_missing_scope_to_bound_requirement_context(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["workspace_lookup"] = kwargs
|
||
return (
|
||
SimpleNamespace(id=91, plan_status="collecting"),
|
||
SimpleNamespace(id=92, state="collecting"),
|
||
)
|
||
|
||
async def _build_requirement_planning_direct_result(self, **kwargs):
|
||
captured["direct_kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "第三轮:技术路线\n\nA. SQL + 飞书消息\nB. 多源拼接\nC. 外部系统 API",
|
||
"reply_template_key": "requirement_plan_followup",
|
||
"requirement_plan_context": {
|
||
"question_round": 3,
|
||
"last_round_options": {
|
||
"field": "tech_route",
|
||
"options": [
|
||
{"key": "A", "title": "SQL + 飞书消息"},
|
||
{"key": "B", "title": "多源拼接"},
|
||
{"key": "C", "title": "外部系统 API"},
|
||
],
|
||
"recommendation": {"key": "A"},
|
||
},
|
||
},
|
||
}
|
||
|
||
class FakeSemanticContextAssembler:
|
||
def __init__(self, db) -> None:
|
||
self.db = db
|
||
|
||
async def assemble(self, **kwargs):
|
||
captured["packet_kwargs"] = kwargs
|
||
return SimpleNamespace(
|
||
mode_card=SimpleNamespace(
|
||
allowed_actions=[
|
||
"reply.compose",
|
||
"context.followup.current_requirement",
|
||
],
|
||
forbidden_actions=[],
|
||
)
|
||
)
|
||
|
||
class FakeSemanticDecisionService:
|
||
def __init__(self, _llm_service) -> None:
|
||
pass
|
||
|
||
async def decide(self, *, packet):
|
||
_ = packet
|
||
return SimpleNamespace(
|
||
topic_relation="continue_current_topic",
|
||
mode_decision="stay_plan",
|
||
followup_scope="none",
|
||
followup_action="none",
|
||
revision_note="",
|
||
assistant_reply="已确认选项 A(单一层级区域经理),继续收第二轮关键信息。",
|
||
requires_confirmation=False,
|
||
requested_actions=["reply.compose", "context.followup.current_requirement"],
|
||
)
|
||
|
||
monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler)
|
||
monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService)
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel._maybe_handle_active_context_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-followup-2b",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="确认A,继续往下收。",
|
||
thread_context={"reply_to_message_id": "om-plan-reply"},
|
||
sender="tester",
|
||
)
|
||
|
||
assert result is not None
|
||
assert result["handled"] is True
|
||
assert result["direct_reply_text"].startswith("第三轮:技术路线")
|
||
assert result["requirement_plan_context"]["last_round_options"]["field"] == "tech_route"
|
||
assert "direct_kwargs" in captured
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_active_context_followup_prefers_active_mounted_draft_before_requirement_lookup(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("active mounted draft should bind before requirement workspace lookup")
|
||
|
||
class FakeSemanticContextAssembler:
|
||
def __init__(self, db) -> None:
|
||
self.db = db
|
||
|
||
async def assemble(self, **kwargs):
|
||
captured["packet_kwargs"] = kwargs
|
||
return SimpleNamespace(
|
||
mode_card=SimpleNamespace(
|
||
allowed_actions=["reply.compose", "context.followup.current_draft"],
|
||
forbidden_actions=[],
|
||
)
|
||
)
|
||
|
||
class FakeSemanticDecisionService:
|
||
def __init__(self, _llm_service) -> None:
|
||
pass
|
||
|
||
async def decide(self, *, packet):
|
||
_ = packet
|
||
return SimpleNamespace(
|
||
topic_relation="continue_current_topic",
|
||
mode_decision="stay_plan",
|
||
followup_scope="current_draft",
|
||
followup_action="regenerate",
|
||
revision_note="",
|
||
assistant_reply="",
|
||
requires_confirmation=False,
|
||
requested_actions=["reply.compose", "context.followup.current_draft"],
|
||
)
|
||
|
||
async def _fake_resolve_active_mounted_draft(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
return (
|
||
SimpleNamespace(id=92, state="draft_pending_confirm", active_work_item_id=812),
|
||
SimpleNamespace(id=91, plan_status="draft_pending_confirm"),
|
||
)
|
||
|
||
async def _fake_handle_text_reply(self, **kwargs):
|
||
_ = self
|
||
captured["draft_kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "pending_manual",
|
||
"followup_reply": "我先在当前草稿里补外部信号源。",
|
||
}
|
||
|
||
monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler)
|
||
monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService)
|
||
monkeypatch.setattr(DraftConfirmHandler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
|
||
monkeypatch.setattr(DraftConfirmHandler, "handle_text_reply", _fake_handle_text_reply)
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel._maybe_handle_active_context_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-followup-draft-priority-1",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="再出一版草稿",
|
||
thread_context=None,
|
||
sender="tester",
|
||
)
|
||
|
||
assert result is not None
|
||
assert result["handled"] is True
|
||
assert "当前草稿" in result["followup_reply"]
|
||
assert captured["packet_kwargs"]["topic_workspace_id"] == 91
|
||
assert captured["packet_kwargs"]["ingress_context"]["active_context_kind"] == "draft_pending_confirm"
|
||
assert captured["draft_kwargs"]["semantic_followup_override"]["action"] == "regenerate"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_active_context_followup_routes_short_draft_module_question_to_current_draft(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["workspace_lookup"] = kwargs
|
||
return (
|
||
SimpleNamespace(id=91, plan_status="awaiting_execute_confirm"),
|
||
SimpleNamespace(id=92, state="ready"),
|
||
)
|
||
|
||
class FakeSemanticContextAssembler:
|
||
def __init__(self, db) -> None:
|
||
self.db = db
|
||
|
||
async def assemble(self, **kwargs):
|
||
captured["packet_kwargs"] = kwargs
|
||
return SimpleNamespace(
|
||
mode_card=SimpleNamespace(
|
||
allowed_actions=["reply.compose", "context.followup.current_draft"],
|
||
forbidden_actions=[],
|
||
)
|
||
)
|
||
|
||
class FakeSemanticDecisionService:
|
||
def __init__(self, _llm_service) -> None:
|
||
pass
|
||
|
||
async def decide(self, *, packet):
|
||
_ = packet
|
||
return SimpleNamespace(
|
||
topic_relation="continue_current_topic",
|
||
mode_decision="stay_plan",
|
||
followup_scope="current_draft",
|
||
followup_action="revise",
|
||
revision_note="补充当前草稿里的信号源输入与接入边界。",
|
||
assistant_reply="",
|
||
requires_confirmation=False,
|
||
requested_actions=["reply.compose", "context.followup.current_draft"],
|
||
)
|
||
|
||
async def _fake_handle_text_reply(self, **kwargs):
|
||
_ = self
|
||
captured["draft_kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "pending_manual",
|
||
"followup_reply": "我先在当前草稿里补信号源输入。",
|
||
}
|
||
|
||
monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler)
|
||
monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService)
|
||
monkeypatch.setattr(DraftConfirmHandler, "handle_text_reply", _fake_handle_text_reply)
|
||
|
||
kernel = FakeKernel()
|
||
result = await kernel._maybe_handle_active_context_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-followup-2",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="信号源呢?",
|
||
thread_context=None,
|
||
sender="tester",
|
||
)
|
||
|
||
assert result is not None
|
||
assert result["handled"] is True
|
||
assert "当前草稿" in result["followup_reply"]
|
||
assert captured["draft_kwargs"]["message_text"] == "信号源呢?"
|
||
assert captured["draft_kwargs"]["semantic_followup_override"] == {
|
||
"action": "revise",
|
||
"revision_note": "补充当前草稿里的信号源输入与接入边界。",
|
||
"assistant_reply": "",
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_active_context_followup_does_not_hijack_explicit_signal_source_request(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["workspace_lookup"] = kwargs
|
||
return (
|
||
SimpleNamespace(id=91, plan_status="awaiting_execute_confirm"),
|
||
SimpleNamespace(id=92, state="ready"),
|
||
)
|
||
|
||
class FakeSemanticContextAssembler:
|
||
def __init__(self, db) -> None:
|
||
self.db = db
|
||
|
||
async def assemble(self, **kwargs):
|
||
captured["packet_kwargs"] = kwargs
|
||
return SimpleNamespace(
|
||
mode_card=SimpleNamespace(
|
||
allowed_actions=["reply.compose", "context.followup.independent_workflow"],
|
||
forbidden_actions=[],
|
||
)
|
||
)
|
||
|
||
class FakeSemanticDecisionService:
|
||
def __init__(self, _llm_service) -> None:
|
||
pass
|
||
|
||
async def decide(self, *, packet):
|
||
_ = packet
|
||
return SimpleNamespace(
|
||
topic_relation="continue_current_topic",
|
||
mode_decision="answer_only",
|
||
followup_scope="independent_workflow",
|
||
followup_action="none",
|
||
revision_note="",
|
||
assistant_reply="",
|
||
requires_confirmation=False,
|
||
requested_actions=["reply.compose", "context.followup.independent_workflow"],
|
||
)
|
||
|
||
monkeypatch.setattr(mixin_entry, "SemanticContextAssembler", FakeSemanticContextAssembler)
|
||
monkeypatch.setattr(mixin_entry, "SemanticDecisionService", FakeSemanticDecisionService)
|
||
kernel = FakeKernel()
|
||
result = await kernel._maybe_handle_active_context_followup(
|
||
source_system="feishu",
|
||
source_message_id="msg-followup-3",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。",
|
||
thread_context=None,
|
||
sender="tester",
|
||
)
|
||
|
||
assert result is None
|
||
assert captured["packet_kwargs"]["latest_user_message"] == "给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_find_active_requirement_plan_workspace_includes_draft_pending_confirm_state(
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 7
|
||
|
||
executor = FakeExecutor()
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-draft-pending",
|
||
source_sender_open_id="ou-draft-pending",
|
||
sender=None,
|
||
)
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=7,
|
||
topic_key="feishu:chat-draft-pending:message:msg-draft-pending-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-draft-pending",
|
||
conversation_scope_key=conversation_scope_key,
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="draft_pending_confirm",
|
||
metadata_json={"plan_mode": True},
|
||
facts_json={"title": "换季穿搭助手"},
|
||
)
|
||
requirement_plan_db_session.add(topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=7,
|
||
topic_workspace_id=int(topic.id),
|
||
state="draft_pending_confirm",
|
||
active_work_item_id=321,
|
||
draft_version=1,
|
||
requirement_card={"title": "换季穿搭助手"},
|
||
draft_payload={"title": "换季穿搭助手"},
|
||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
found_topic, found_draft = await executor._find_active_requirement_plan_workspace(
|
||
source_system="feishu",
|
||
source_message_id="msg-followup-pending",
|
||
source_chat_id="chat-draft-pending",
|
||
sender=None,
|
||
source_sender_open_id="ou-draft-pending",
|
||
thread_context=None,
|
||
conversation_key=conversation_scope_key,
|
||
)
|
||
|
||
assert found_topic is not None
|
||
assert found_draft is not None
|
||
assert int(found_topic.id) == int(topic.id)
|
||
assert str(found_draft.state) == "draft_pending_confirm"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_find_active_requirement_plan_workspace_recovers_plan_topic_marked_via_metadata_even_if_mode_drifted_to_chat(
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 7
|
||
|
||
executor = FakeExecutor()
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-mode-drift",
|
||
source_sender_open_id="ou-plan-mode-drift",
|
||
sender=None,
|
||
)
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=7,
|
||
topic_key="feishu:chat-plan-mode-drift:root:om-plan-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-mode-drift",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="om-plan-root",
|
||
last_message_id="msg-plan-turn-3",
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="options_ready",
|
||
metadata_json={"plan_mode": True, "last_reply_to_message_id": "msg-plan-turn-3"},
|
||
facts_json={"title": "主播协同作战平台"},
|
||
)
|
||
requirement_plan_db_session.add(topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=7,
|
||
topic_workspace_id=int(topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={"title": "主播协同作战平台"},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
found_topic, found_draft = await executor._find_active_requirement_plan_workspace(
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-turn-4",
|
||
source_chat_id="chat-plan-mode-drift",
|
||
sender=None,
|
||
source_sender_open_id="ou-plan-mode-drift",
|
||
thread_context={
|
||
"root_message_id": "msg-plan-turn-3",
|
||
"reply_to_message_id": "msg-plan-turn-3",
|
||
},
|
||
conversation_key=conversation_scope_key,
|
||
)
|
||
|
||
assert found_topic is not None
|
||
assert found_draft is not None
|
||
assert int(found_topic.id) == int(topic.id)
|
||
assert str(found_topic.plan_status) == "options_ready"
|
||
assert str(found_draft.state) == "collecting"
|
||
|
||
|
||
def test_world_model_session_project_inheritance_keeps_meaningful_followup() -> None:
|
||
class FakeKernel(ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin):
|
||
pass
|
||
|
||
kernel = FakeKernel()
|
||
session = SimpleNamespace(
|
||
sender="feishu:chat-1:ou_dm_1",
|
||
last_intent="query.read_only",
|
||
current_project_id=104,
|
||
last_active_at=datetime.now(),
|
||
)
|
||
|
||
assert kernel._extract_inherited_project_id(session=session, message_text="这个怎么做") is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_world_model_resolve_project_does_not_bind_low_context_session_project() -> None:
|
||
class _ProjectListResult:
|
||
def scalars(self):
|
||
return SimpleNamespace(all=lambda: [])
|
||
|
||
class _ProjectOneResult:
|
||
def scalar_one_or_none(self):
|
||
return SimpleNamespace(id=104, title="跨境直播复盘", name="跨境直播复盘")
|
||
|
||
class _FakeDB:
|
||
def __init__(self) -> None:
|
||
self.execute_calls = 0
|
||
|
||
async def execute(self, _query):
|
||
self.execute_calls += 1
|
||
if self.execute_calls == 1:
|
||
return _ProjectListResult()
|
||
return _ProjectOneResult()
|
||
|
||
class FakeKernel(
|
||
mixin_project.KernelWorldModelCoreProjectMixin,
|
||
ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin,
|
||
):
|
||
def __init__(self) -> None:
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
kernel = FakeKernel()
|
||
session = SimpleNamespace(
|
||
sender="feishu:chat-1:ou_dm_1",
|
||
last_intent="query.read_only",
|
||
current_project_id=104,
|
||
last_active_at=datetime.now(),
|
||
)
|
||
|
||
project = await kernel._resolve_project(
|
||
message_text="A",
|
||
project_hint_id=None,
|
||
query_tables=set(),
|
||
session=session,
|
||
)
|
||
|
||
assert project is None
|
||
assert kernel.db.execute_calls == 1
|
||
|
||
|
||
def test_mounted_runtime_query_business_review_uses_plan_mode_and_non_executor_agent() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name(
|
||
workflow_route="query.answer",
|
||
auto_execute=True,
|
||
route_metadata={
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
reply_template_key=None,
|
||
mode_hint=mode,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
assert agent == "PlanCoach"
|
||
|
||
|
||
def test_mounted_runtime_query_advise_uses_assignee_recommender() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "advise",
|
||
"advise_subtype": "assignee",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name(
|
||
workflow_route="query.answer",
|
||
auto_execute=True,
|
||
route_metadata={
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "advise",
|
||
"advise_subtype": "assignee",
|
||
},
|
||
reply_template_key=None,
|
||
mode_hint=mode,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
assert agent == "AssigneeRecommender"
|
||
|
||
|
||
def test_mounted_runtime_query_requirement_plan_uses_plan_coach() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name(
|
||
workflow_route="query.answer",
|
||
auto_execute=True,
|
||
route_metadata={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
reply_template_key=None,
|
||
mode_hint=mode,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
assert agent == "PlanCoach"
|
||
|
||
|
||
def test_mounted_runtime_query_requirement_plan_uses_direct_metadata_for_plan_mode() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
|
||
|
||
def test_mounted_runtime_query_consult_requirement_structured_discovery_uses_plan_mode() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "consult_requirement",
|
||
"consultant_mode_preference": "structured_discovery",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
|
||
|
||
def test_mounted_runtime_query_requirement_gathering_stepwise_convergent_uses_plan_mode() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "requirement_gathering",
|
||
"consultant_mode_preference": "stepwise_convergent",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
|
||
|
||
def test_mounted_runtime_query_consult_requirement_stepwise_plan_first_uses_plan_mode() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "consult_requirement",
|
||
"consultant_mode_preference": "stepwise_plan_first",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
|
||
|
||
def test_mounted_runtime_query_consult_requirement_lightweight_plan_first_uses_plan_mode() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "consult_requirement",
|
||
"consultant_mode_preference": "lightweight_plan_first",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
|
||
|
||
def test_mounted_runtime_query_consult_requirement_structured_analysis_first_uses_plan_mode() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "consult_requirement",
|
||
"consultant_mode_preference": "structured_analysis_first",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
|
||
|
||
def test_mounted_runtime_query_requirement_gathering_stepwise_converge_uses_plan_mode() -> None:
|
||
mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override(
|
||
workflow_route="query.answer",
|
||
route_metadata={
|
||
"message_intent": "requirement_gathering",
|
||
"consultant_mode_preference": "stepwise_converge",
|
||
},
|
||
reply_template_key=None,
|
||
)
|
||
|
||
assert mode == "plan"
|
||
|
||
|
||
def test_build_canonical_intent_keeps_project_update_instead_of_dropping_to_ignore() -> None:
|
||
intent = KernelIntentRulesMixin._build_canonical_intent(
|
||
intent_name="project.update",
|
||
message_text="把项目2的 success criteria 改成 48 小时内必须上线。",
|
||
project_id=2,
|
||
confidence=0.91,
|
||
metadata={"field": "success_criteria"},
|
||
)
|
||
|
||
assert intent.name == "project.update"
|
||
assert intent.project_id == 2
|
||
assert intent.metadata["field"] == "success_criteria"
|
||
|
||
|
||
def test_explicit_named_project_create_skips_existing_project_name_binding() -> None:
|
||
assert mixin_project.KernelWorldModelCoreProjectMixin._should_skip_implicit_project_binding(
|
||
message_text=(
|
||
"最近我们做跨境直播复盘太慢了,想做个给运营团队用的小工具,"
|
||
"这个需求先叫 FeishuWebFlow-1775301894322。"
|
||
),
|
||
) is True
|
||
|
||
|
||
def test_project_lookup_question_keeps_existing_project_name_binding() -> None:
|
||
assert mixin_project.KernelWorldModelCoreProjectMixin._should_skip_implicit_project_binding(
|
||
message_text="帮我看下跨境直播复盘这个项目现在怎么样?",
|
||
) is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_prefers_latest_sender_scope_candidate_without_anchor() -> None:
|
||
latest_work_item = SimpleNamespace(
|
||
id=202,
|
||
state="draft_pending_confirm",
|
||
source_message_id="msg-latest",
|
||
)
|
||
old_work_item = SimpleNamespace(
|
||
id=101,
|
||
state="draft_pending_confirm",
|
||
source_message_id="msg-old",
|
||
)
|
||
ingest_payload = {
|
||
"sender": "tester",
|
||
"channel_ref": "chat-1",
|
||
"source_sender_open_id": "ou_123",
|
||
}
|
||
rows = [
|
||
(latest_work_item, SimpleNamespace(payload=dict(ingest_payload))),
|
||
(old_work_item, SimpleNamespace(payload=dict(ingest_payload))),
|
||
]
|
||
|
||
class FakeResult:
|
||
def __init__(self, value):
|
||
self._value = value
|
||
|
||
def all(self):
|
||
return list(self._value)
|
||
|
||
class FakeDB:
|
||
async def execute(self, _stmt):
|
||
return FakeResult(rows)
|
||
|
||
handler = DraftConfirmHandler(SimpleNamespace(db=FakeDB(), tenant_id=1))
|
||
target = await handler._resolve_pending_draft(
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_123",
|
||
explicit_work_item_id=None,
|
||
thread_context=None,
|
||
)
|
||
|
||
assert target == rows[0]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_skips_followup_wrapper_when_resolving_latest_draft() -> None:
|
||
followup_wrapper = SimpleNamespace(
|
||
id=303,
|
||
state="draft_pending_confirm",
|
||
source_message_id="msg-followup",
|
||
intent="followup.confirm",
|
||
)
|
||
latest_project_draft = SimpleNamespace(
|
||
id=202,
|
||
state="draft_pending_confirm",
|
||
source_message_id="msg-project",
|
||
intent="project_create.create",
|
||
)
|
||
ingest_payload = {
|
||
"sender": "tester",
|
||
"channel_ref": "chat-1",
|
||
"source_sender_open_id": "ou_123",
|
||
}
|
||
rows = [
|
||
(followup_wrapper, SimpleNamespace(payload=dict(ingest_payload))),
|
||
(latest_project_draft, SimpleNamespace(payload=dict(ingest_payload))),
|
||
]
|
||
|
||
class FakeResult:
|
||
def __init__(self, value):
|
||
self._value = value
|
||
|
||
def all(self):
|
||
return list(self._value)
|
||
|
||
class FakeDB:
|
||
async def execute(self, _stmt):
|
||
return FakeResult(rows)
|
||
|
||
handler = DraftConfirmHandler(SimpleNamespace(db=FakeDB(), tenant_id=1))
|
||
target = await handler._resolve_pending_draft(
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_123",
|
||
explicit_work_item_id=None,
|
||
thread_context=None,
|
||
)
|
||
|
||
assert target == rows[1]
|
||
|
||
|
||
def test_build_feishu_thread_key_prefers_actor_scope_without_anchor() -> None:
|
||
assert build_feishu_thread_key(
|
||
source_chat_id="chat-1",
|
||
source_sender_open_id="ou_dm_1",
|
||
sender="产品经理A",
|
||
source_message_id="om_current_message",
|
||
thread_context=None,
|
||
) == "feishu:chat-1:ou_dm_1"
|
||
|
||
|
||
def test_low_value_closure_payload_helper_handles_summary_text() -> None:
|
||
assert helpers_inbox_filters._is_low_value_closure_payload(
|
||
{"closure_summary": "待补充核心痛点"}
|
||
) is False
|
||
|
||
|
||
def test_serialize_work_item_summary_handles_message_text_without_nameerror() -> None:
|
||
row = SimpleNamespace(
|
||
id=9,
|
||
source_system="pmflow_manual",
|
||
source_message_id="msg-9",
|
||
draft_content={},
|
||
state="received",
|
||
review_summary={},
|
||
action_bundle={},
|
||
intent="query.read_only",
|
||
project_id=None,
|
||
signal_id=None,
|
||
reply_template_key=None,
|
||
execution_result={},
|
||
draft_expires_at=None,
|
||
draft_version=0,
|
||
confirm_message_id=None,
|
||
created_at=None,
|
||
updated_at=None,
|
||
decision_level="portfolio",
|
||
)
|
||
ingest = SimpleNamespace(payload={"message_text": " 最近 状态 怎么样 "})
|
||
|
||
payload = helpers_inbox_payload._serialize_work_item_summary(
|
||
row=row,
|
||
ingest=ingest,
|
||
project=None,
|
||
)
|
||
|
||
assert payload["source_message_text"] == "最近 状态 怎么样"
|
||
|
||
|
||
def test_coerce_rag_business_review_scene_keeps_rag_ask_on_query_chain() -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
kernel = FakeKernel()
|
||
original_intent = mixin_entry.CanonicalIntent(
|
||
name="signal.create",
|
||
confidence=0.91,
|
||
project_id=None,
|
||
signal_title="大模型 API 聚合平台",
|
||
signal_summary="我现在要做一个大模型api聚合平台,你帮我分析下要怎么做,给出mvp需求",
|
||
metadata={"llm_function": "classify_intent_ai_pmo_kernel"},
|
||
)
|
||
|
||
coerced = kernel._coerce_intent_for_query_scene(
|
||
intent=original_intent,
|
||
source_system="pmflow_manual",
|
||
message_text="我现在要做一个大模型api聚合平台,你帮我分析下要怎么做,给出mvp需求",
|
||
rag_request_context={
|
||
"asked_via": "pmflow_manual",
|
||
"action_mode": "auto",
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
semantic_bundle=None,
|
||
world_model={},
|
||
)
|
||
|
||
assert coerced.name == "query.read_only"
|
||
assert coerced.metadata["message_intent"] == "business_review"
|
||
assert coerced.metadata["consultant_mode_preference"] == "clarify"
|
||
assert coerced.metadata["workflow_route"] == "query.answer"
|
||
assert "semantic_bundle" not in coerced.metadata
|
||
|
||
|
||
def test_coerce_feishu_direct_business_review_metadata_keeps_query_chain() -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
kernel = FakeKernel()
|
||
original_intent = mixin_entry.CanonicalIntent(
|
||
name="signal.create",
|
||
confidence=0.93,
|
||
project_id=None,
|
||
signal_title="大模型 API 聚合平台",
|
||
signal_summary="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,",
|
||
metadata={
|
||
"llm_function": "classify_intent_ai_pmo_kernel",
|
||
"message_intent": "business_review",
|
||
"review_framework": "five_lenses",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
coerced = kernel._coerce_intent_for_query_scene(
|
||
intent=original_intent,
|
||
source_system="feishu",
|
||
message_text="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,",
|
||
rag_request_context=None,
|
||
semantic_bundle=None,
|
||
world_model={},
|
||
)
|
||
|
||
assert coerced.name == "query.read_only"
|
||
assert coerced.metadata["message_intent"] == "business_review"
|
||
assert coerced.metadata["workflow_route"] == "query.answer"
|
||
|
||
|
||
def test_coerce_feishu_deferred_requirement_discussion_to_query_chain_from_direct_metadata() -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
kernel = FakeKernel()
|
||
original_intent = mixin_entry.CanonicalIntent(
|
||
name="project.create",
|
||
confidence=0.94,
|
||
project_id=None,
|
||
signal_title="直播复盘工具",
|
||
signal_summary="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。",
|
||
metadata={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
coerced = kernel._coerce_intent_for_query_scene(
|
||
intent=original_intent,
|
||
source_system="feishu",
|
||
message_text="最近直播复盘太慢了,想做个给运营团队用的小工具,先不要直接创建,我再补充几轮,最后确认再入库。",
|
||
rag_request_context=None,
|
||
semantic_bundle=None,
|
||
world_model={},
|
||
)
|
||
|
||
assert coerced.name == "query.read_only"
|
||
assert coerced.metadata["message_intent"] == "requirement_plan"
|
||
assert coerced.metadata["consultant_mode_preference"] == "clarify"
|
||
assert coerced.metadata["workflow_route"] == "query.answer"
|
||
|
||
|
||
def test_coerce_feishu_requirement_plan_from_direct_metadata_keeps_query_chain() -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
kernel = FakeKernel()
|
||
original_intent = mixin_entry.CanonicalIntent(
|
||
name="project.create",
|
||
confidence=0.95,
|
||
project_id=None,
|
||
signal_title="A股趋势日评打分工具",
|
||
signal_summary="做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
metadata={
|
||
"llm_function": "classify_intent_ai_pmo_kernel",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
coerced = kernel._coerce_intent_for_query_scene(
|
||
intent=original_intent,
|
||
source_system="feishu",
|
||
message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
rag_request_context=None,
|
||
semantic_bundle=None,
|
||
world_model={},
|
||
)
|
||
|
||
assert coerced.name == "query.read_only"
|
||
assert coerced.metadata["message_intent"] == "requirement_plan"
|
||
assert coerced.metadata["consultant_mode_preference"] == "clarify"
|
||
assert coerced.metadata["workflow_route"] == "query.answer"
|
||
|
||
|
||
def test_coerce_feishu_assignee_recommendation_to_query_chain_from_direct_metadata() -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
kernel = FakeKernel()
|
||
original_intent = mixin_entry.CanonicalIntent(
|
||
name="project.create",
|
||
confidence=0.88,
|
||
project_id=None,
|
||
signal_title="直播复盘工具",
|
||
signal_summary="那你觉得分配给谁做比较合适?",
|
||
metadata={
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "advise",
|
||
},
|
||
)
|
||
|
||
coerced = kernel._coerce_intent_for_query_scene(
|
||
intent=original_intent,
|
||
source_system="feishu",
|
||
message_text="那你觉得分配给谁做比较合适?",
|
||
rag_request_context=None,
|
||
semantic_bundle=None,
|
||
world_model={},
|
||
)
|
||
|
||
assert coerced.name == "query.read_only"
|
||
assert coerced.metadata["message_intent"] == "business_review"
|
||
assert coerced.metadata["consultant_mode_preference"] == "advise"
|
||
assert coerced.metadata["workflow_route"] == "query.answer"
|
||
|
||
|
||
def test_should_inherit_recent_feishu_session_context_is_disabled_for_query_session() -> None:
|
||
session = SimpleNamespace(
|
||
sender="feishu:tester",
|
||
last_active_at=datetime.now(),
|
||
last_intent="query.read_only",
|
||
last_message_text="上一轮在聊项目画像",
|
||
)
|
||
|
||
should_inherit = KernelIntentResolveMixin._should_inherit_recent_feishu_session_context(
|
||
message_text="我需要对应的需求mvp",
|
||
session=session,
|
||
)
|
||
|
||
assert should_inherit is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_kernel_intent_resolve_uses_workflow_bundle_without_reply_anchor(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
captured["message_text"] = message_text
|
||
captured["source_system"] = source_system
|
||
captured["metadata"] = dict(metadata or {})
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-query",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.93,
|
||
metadata={},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={"message_intent": "requirement_plan"},
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)),
|
||
)
|
||
|
||
class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
resolver = FakeResolver()
|
||
session = SimpleNamespace(
|
||
sender="feishu:chat-1:ou_dm_1",
|
||
last_intent="query.read_only",
|
||
current_project_id=21,
|
||
last_active_at=datetime.now(),
|
||
)
|
||
|
||
intent, source = await resolver._resolve_intent(
|
||
message_text="我需要对应的需求mvp",
|
||
session=session,
|
||
world_model={},
|
||
thread_context=None,
|
||
)
|
||
|
||
assert source == "workflow_bundle"
|
||
assert intent.name == "query.read_only"
|
||
assert intent.metadata["workflow_route"] == "query.answer"
|
||
assert intent.metadata["llm_function"] == "pmflow_semantic_context_decision"
|
||
assert intent.metadata["context_source"] == "workflow_bundle"
|
||
assert intent.metadata["message_intent"] == "requirement_plan"
|
||
assert isinstance(intent.metadata.get("semantic_bundle"), dict)
|
||
assert captured["source_system"] == "feishu"
|
||
assert captured["metadata"]["session_last_intent"] == ""
|
||
assert captured["metadata"]["session_project_id"] is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_kernel_intent_resolve_reply_anchor_binds_session_context_into_workflow_packet(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
captured["message_text"] = message_text
|
||
captured["source_system"] = source_system
|
||
captured["metadata"] = dict(metadata or {})
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-followup",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="followup_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="followup_flow",
|
||
workflow_action="confirm",
|
||
target_scope="thread",
|
||
operation_codes=["followup.confirm"],
|
||
requires_confirmation=False,
|
||
confidence=0.91,
|
||
metadata={},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="followup_flow"),
|
||
diagnostics={},
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)),
|
||
)
|
||
|
||
class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
resolver = FakeResolver()
|
||
session = SimpleNamespace(
|
||
sender="feishu:chat-1:ou_dm_1",
|
||
last_intent="task.create",
|
||
current_project_id=21,
|
||
last_message_text="给项目 21 新建任务:补齐信号源配置",
|
||
last_active_at=datetime.now(),
|
||
)
|
||
|
||
intent, source = await resolver._resolve_intent(
|
||
message_text="确认",
|
||
session=session,
|
||
world_model={},
|
||
thread_context={"reply_to_message_id": "msg-1"},
|
||
)
|
||
|
||
assert source == "workflow_bundle"
|
||
assert intent.name == "followup.confirm"
|
||
assert intent.project_id == 21
|
||
assert captured["metadata"]["session_last_intent"] == "task.create"
|
||
assert captured["metadata"]["session_project_id"] == 21
|
||
assert captured["metadata"]["reply_to_message_id"] == "msg-1"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_kernel_intent_resolve_requirement_initiation_stays_on_query_chain(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
_ = (message_text, source_system, metadata)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-requirement-init",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.95,
|
||
metadata={},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "requirement_initiation",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)),
|
||
)
|
||
|
||
class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
resolver = FakeResolver()
|
||
|
||
intent, source = await resolver._resolve_intent(
|
||
message_text="想做个新需求,关于给运营公司打造主播推流的需求。",
|
||
session=None,
|
||
world_model={},
|
||
thread_context=None,
|
||
)
|
||
|
||
assert source == "workflow_bundle"
|
||
assert intent.name == "query.read_only"
|
||
assert intent.metadata["workflow_route"] == "query.answer"
|
||
assert intent.metadata["message_intent"] == "requirement_initiation"
|
||
assert intent.metadata["consultant_mode_preference"] == "clarify"
|
||
assert intent.signal_title == ""
|
||
|
||
|
||
def test_recover_session_followup_write_intent_skips_query_parent_even_if_previous_text_looks_like_write() -> None:
|
||
class FakeResolver(KernelIntentRulesMixin, KernelIntentResolveMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
self.intent_resolver = SimpleNamespace(resolve=lambda **kwargs: CanonicalIntent("project.create", 0.9, 12, "", "", {}))
|
||
|
||
resolver = FakeResolver()
|
||
session = SimpleNamespace(
|
||
last_intent="query.read_only",
|
||
last_message_text="帮我创建一个新项目,名字叫 Alpha",
|
||
current_project_id=12,
|
||
last_active_at=datetime.now(),
|
||
)
|
||
rule_intent = CanonicalIntent("query.read_only", 0.6, None, "", "", {"fallback": True, "reason": "default_query_fallback"})
|
||
|
||
recovered = resolver._recover_session_followup_write_intent(
|
||
message_text="确认",
|
||
session=session,
|
||
world_model={},
|
||
rule_intent=rule_intent,
|
||
)
|
||
|
||
assert recovered is None
|
||
|
||
|
||
def test_recover_session_followup_write_intent_is_disabled_even_for_write_parent() -> None:
|
||
class FakeResolver(KernelIntentRulesMixin, KernelIntentResolveMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
self.intent_resolver = SimpleNamespace(resolve=lambda **kwargs: CanonicalIntent("project.create", 0.9, 12, "", "", {}))
|
||
|
||
resolver = FakeResolver()
|
||
session = SimpleNamespace(
|
||
last_intent="project.create",
|
||
last_message_text="帮我创建一个新项目,名字叫 Alpha",
|
||
current_project_id=12,
|
||
last_active_at=datetime.now(),
|
||
)
|
||
rule_intent = CanonicalIntent("query.read_only", 0.6, None, "", "", {"fallback": True, "reason": "default_query_fallback"})
|
||
|
||
recovered = resolver._recover_session_followup_write_intent(
|
||
message_text="确认",
|
||
session=session,
|
||
world_model={},
|
||
rule_intent=rule_intent,
|
||
)
|
||
|
||
assert recovered is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_kernel_intent_resolve_suppresses_session_context_after_low_context_previous_turn(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
captured["message_text"] = message_text
|
||
captured["source_system"] = source_system
|
||
captured["metadata"] = dict(metadata or {})
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-low-context",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.79,
|
||
metadata={},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={},
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)),
|
||
)
|
||
|
||
class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
resolver = FakeResolver()
|
||
session = SimpleNamespace(
|
||
sender="feishu:chat-1:ou_dm_1",
|
||
last_intent="query.read_only",
|
||
current_project_id=104,
|
||
last_message_text="A",
|
||
last_active_at=datetime.now(),
|
||
)
|
||
|
||
_, source = await resolver._resolve_intent(
|
||
message_text="这个怎么做",
|
||
session=session,
|
||
world_model={},
|
||
thread_context=None,
|
||
)
|
||
|
||
assert source == "workflow_bundle"
|
||
assert captured["metadata"]["session_last_intent"] == ""
|
||
assert captured["metadata"]["session_project_id"] is None
|
||
assert captured["metadata"]["source_message_context"] == "cold_start"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_kernel_intent_resolve_no_longer_calls_short_confirmation_helper(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
captured["message_text"] = message_text
|
||
captured["source_system"] = source_system
|
||
captured["metadata"] = dict(metadata or {})
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-short-confirm",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="followup_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="followup_flow",
|
||
workflow_action="confirm",
|
||
target_scope="thread",
|
||
operation_codes=["followup.confirm"],
|
||
requires_confirmation=False,
|
||
confidence=0.9,
|
||
metadata={},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="followup_flow"),
|
||
diagnostics={},
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)),
|
||
)
|
||
|
||
class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("legacy short confirmation helper should not be used")
|
||
|
||
resolver = FakeResolver()
|
||
session = SimpleNamespace(
|
||
sender="feishu:chat-1:ou_dm_1",
|
||
last_intent="project.create",
|
||
current_project_id=104,
|
||
last_message_text="帮我创建一个新项目",
|
||
last_active_at=datetime.now(),
|
||
)
|
||
|
||
_, source = await resolver._resolve_intent(
|
||
message_text="确认",
|
||
session=session,
|
||
world_model={},
|
||
thread_context={"reply_to_message_id": "reply-1"},
|
||
)
|
||
|
||
assert source == "workflow_bundle"
|
||
assert captured["metadata"]["session_last_intent"] == "project.create"
|
||
assert captured["metadata"]["session_project_id"] == 104
|
||
assert captured["metadata"]["reply_to_message_id"] == "reply-1"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_kernel_intent_resolve_uses_workflow_bundle_for_signal_source_request(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
captured["message_text"] = message_text
|
||
captured["source_system"] = source_system
|
||
captured["metadata"] = dict(metadata or {})
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-signal-source",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="signal_source_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="signal_source_flow",
|
||
workflow_action="onboard",
|
||
target_scope="project",
|
||
operation_codes=["signal_source.onboard"],
|
||
requires_confirmation=True,
|
||
confidence=0.92,
|
||
metadata={"project_id": 2},
|
||
)
|
||
],
|
||
message_gate="pending",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="signal_source_flow"),
|
||
diagnostics={},
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: _fake_aplan_message(**kwargs)),
|
||
)
|
||
|
||
class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 1
|
||
|
||
resolver = FakeResolver()
|
||
|
||
intent, source = await resolver._resolve_intent(
|
||
message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。",
|
||
session=None,
|
||
world_model={"resolved_project_id": 2},
|
||
thread_context=None,
|
||
)
|
||
|
||
assert source == "workflow_bundle"
|
||
assert intent.name == "signal_source.onboard"
|
||
assert intent.project_id == 2
|
||
assert intent.metadata["source_request"] is True
|
||
|
||
|
||
def test_reply_renderer_formats_project_delete_pending_and_success() -> None:
|
||
renderer = ReplyRenderer(
|
||
state_waiting_manual="waiting_manual",
|
||
state_failed="failed",
|
||
receipt_phase_action="action",
|
||
receipt_phase_pending="pending",
|
||
receipt_phase_error="error",
|
||
)
|
||
intent = CanonicalIntent(
|
||
name="project.delete",
|
||
confidence=0.96,
|
||
project_id=None,
|
||
signal_title="",
|
||
signal_summary="",
|
||
metadata={"llm_parameters": {"project_id": 12, "project_title": "重复项目"}},
|
||
)
|
||
|
||
pending = renderer.render(
|
||
work_state="waiting_manual",
|
||
intent=intent,
|
||
decision_record={"approval_requirement": {"message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。"}},
|
||
execution_result={
|
||
"status": "waiting_manual",
|
||
"action_contract": {
|
||
"operation_code": "project.delete",
|
||
"params": {"project_id": 12, "project_title": "重复项目"},
|
||
},
|
||
},
|
||
)
|
||
assert pending["template_key"] == "pending_manual"
|
||
assert "重复项目" in pending["text"]
|
||
assert "人工确认" in pending["text"]
|
||
|
||
success = renderer.render(
|
||
work_state="succeeded",
|
||
intent=intent,
|
||
decision_record={"approval_requirement": {}},
|
||
execution_result={"status": "succeeded", "operation_code": "project.delete", "project_id": 12},
|
||
)
|
||
assert success["template_key"] == "action_succeeded"
|
||
assert "已删除项目#12《重复项目》" in success["text"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_feishu_outbox_service_enqueue_preserves_requirement_plan_text_above_2000_chars(
|
||
feishu_outbox_db_session,
|
||
) -> None:
|
||
text = _build_long_requirement_plan_reply_text()
|
||
assert len(text) > 2000
|
||
|
||
outbox_id = await FeishuOutboxService.enqueue(
|
||
feishu_outbox_db_session,
|
||
tenant_id=3,
|
||
chat_id="oc_requirement_plan_budget",
|
||
text=text,
|
||
source_message_id="msg-requirement-plan-budget-service",
|
||
)
|
||
|
||
row = (
|
||
await feishu_outbox_db_session.execute(
|
||
select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == int(outbox_id or 0))
|
||
)
|
||
).scalar_one()
|
||
assert row.message_text == text
|
||
assert "C. 长期能力底座" in row.message_text
|
||
assert "⭐ 当前我更推荐:B" in row.message_text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_feishu_reply_outbox_mixin_enqueue_preserves_requirement_plan_text_above_2000_chars(
|
||
monkeypatch,
|
||
feishu_outbox_db_session,
|
||
) -> None:
|
||
maker = async_sessionmaker(feishu_outbox_db_session.bind, expire_on_commit=False)
|
||
|
||
class FakeReply(feishu_mixin_reply_outbox.FeishuReplyOutboxMixin):
|
||
def __init__(self) -> None:
|
||
self.tenant_id = 3
|
||
|
||
@staticmethod
|
||
def _build_mention_prefix(*, open_id=None, display_name=None): # noqa: ANN001
|
||
_ = (open_id, display_name)
|
||
return ""
|
||
|
||
@staticmethod
|
||
async def _notify_outbox_ready(db, *, outbox_id): # noqa: ANN001
|
||
_ = (db, outbox_id)
|
||
return None
|
||
|
||
monkeypatch.setattr(feishu_mixin_reply_outbox, "AsyncSessionLocal", maker)
|
||
text = _build_long_requirement_plan_reply_text()
|
||
assert len(text) > 2000
|
||
|
||
outbox_id = await FakeReply()._enqueue_reply_outbox(
|
||
chat_id="oc_requirement_plan_budget",
|
||
message_text=text,
|
||
trace_id="trace-requirement-plan-budget",
|
||
source_message_id="msg-requirement-plan-budget-mixin",
|
||
)
|
||
|
||
async with maker() as verify_session:
|
||
row = (
|
||
await verify_session.execute(
|
||
select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == int(outbox_id))
|
||
)
|
||
).scalar_one()
|
||
assert row.message_text == text
|
||
assert "C. 长期能力底座" in row.message_text
|
||
assert "⭐ 当前我更推荐:B" in row.message_text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_finalize_waiting_manual_passes_execution_result_to_renderer(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeReviewService:
|
||
async def review_work_item(self, **kwargs):
|
||
_ = kwargs
|
||
return {"verified": False, "issues": ["waiting_manual"], "next_step": "请确认"}
|
||
|
||
class FakeRenderer:
|
||
def render(self, **kwargs):
|
||
captured["render_kwargs"] = kwargs
|
||
return {
|
||
"receipt_phase": "pending",
|
||
"template_key": "pending_manual",
|
||
"payload": {"intent": kwargs["intent"].name},
|
||
"text": "ok",
|
||
}
|
||
|
||
class FakeKernel(mixin_prepare_manual.KernelIngressFlowPrepareManualMixin):
|
||
def __init__(self) -> None:
|
||
class _FakeDB:
|
||
async def commit(self):
|
||
return None
|
||
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 7
|
||
self.review_service = FakeReviewService()
|
||
self.reply_renderer = FakeRenderer()
|
||
|
||
async def _maybe_emit_risk_cadence_snapshot(self, **kwargs):
|
||
_ = kwargs
|
||
|
||
async def _claim_reply_receipt(self, **kwargs):
|
||
_ = kwargs
|
||
return True
|
||
|
||
async def _write_processing_log(self, **kwargs):
|
||
_ = kwargs
|
||
|
||
async def _update_session(self, **kwargs):
|
||
_ = kwargs
|
||
|
||
async def _upsert_operation_execution_ledger(self, **kwargs):
|
||
_ = kwargs
|
||
return None
|
||
|
||
def _extract_execution_operation_code(self, execution_result):
|
||
if not isinstance(execution_result, dict):
|
||
return None
|
||
action_contract = execution_result.get("action_contract") if isinstance(execution_result.get("action_contract"), dict) else {}
|
||
if isinstance(action_contract, dict):
|
||
operation_code = str(action_contract.get("operation_code") or "").strip().lower()
|
||
if operation_code:
|
||
return operation_code
|
||
operation_code = str(execution_result.get("operation_code") or "").strip().lower()
|
||
return operation_code or None
|
||
|
||
def _normalize_execution_ledger_status(self, *, work_item_state, execution_status):
|
||
_ = execution_status
|
||
return "pending_manual" if str(work_item_state or "").strip().lower() == "waiting_manual" else "processing"
|
||
|
||
async def _project_waiting_manual(self, **kwargs):
|
||
captured["project_waiting_manual"] = kwargs
|
||
|
||
def _apply_execution_action_contract(self, **kwargs):
|
||
execution_result = dict(kwargs["execution_result"])
|
||
execution_result["action_contract"] = {
|
||
"operation_code": "project.delete",
|
||
"params": {"project_id": 12, "project_title": "重复项目"},
|
||
}
|
||
return execution_result
|
||
|
||
def _build_capture_result(self, *, work_item, intent, execution_result, render, direct_reply_required, status, classification):
|
||
_ = (work_item, intent, execution_result, render, direct_reply_required, classification)
|
||
return {"status": status, "render": render}
|
||
|
||
kernel = FakeKernel()
|
||
work_item = SimpleNamespace(
|
||
state=None,
|
||
action_bundle=None,
|
||
execution_result=None,
|
||
review_summary=None,
|
||
reply_template_key=None,
|
||
id=99,
|
||
project_id=12,
|
||
signal_id=None,
|
||
intent="project.delete",
|
||
source_message_id="msg-1",
|
||
draft_version=None,
|
||
draft_expires_at=None,
|
||
)
|
||
session = SimpleNamespace()
|
||
ingest_event = SimpleNamespace()
|
||
decision_payload = {
|
||
"policy": {"approval_requirement": {"message": "检测到删除或归档类写操作,必须进入显式审批/人工确认。"}},
|
||
"execution_plan": {"actions": []},
|
||
"simulation": {},
|
||
}
|
||
workflow_route = SimpleNamespace(key="project.delete", action="delete")
|
||
intent = CanonicalIntent(
|
||
name="project.delete",
|
||
confidence=0.94,
|
||
project_id=12,
|
||
signal_title="",
|
||
signal_summary="",
|
||
metadata={"llm_parameters": {"project_id": 12, "project_title": "重复项目"}},
|
||
)
|
||
|
||
result = await kernel._finalize_waiting_manual_ingress(
|
||
work_item=work_item,
|
||
session=session,
|
||
intent=intent,
|
||
world_model={},
|
||
decision_payload=decision_payload,
|
||
workflow_route=workflow_route,
|
||
decision_trace={"reason": "test"},
|
||
source_system="feishu",
|
||
source_message_id="msg-1",
|
||
message_text="删除重复项目",
|
||
sender="tester",
|
||
ingest_event=ingest_event,
|
||
started=0.0,
|
||
)
|
||
|
||
assert result["status"] == "captured"
|
||
render_kwargs = captured["render_kwargs"]
|
||
assert isinstance(render_kwargs, dict)
|
||
assert render_kwargs["execution_result"]["action_contract"]["operation_code"] == "project.delete"
|
||
assert render_kwargs["execution_result"]["action_contract"]["params"]["project_title"] == "重复项目"
|
||
|
||
|
||
def test_gate_execution_ignores_low_confidence_but_keeps_risk_review() -> None:
|
||
low_confidence = gate_execution(confidence=0.02, risk_level="medium")
|
||
assert low_confidence.action == "auto_execute"
|
||
assert low_confidence.auto_execute is True
|
||
assert low_confidence.gate_code == "auto_execute_allowed"
|
||
assert "置信度" not in low_confidence.reason
|
||
|
||
high_risk = gate_execution(confidence=0.02, risk_level="high")
|
||
assert high_risk.action == "auto_execute"
|
||
assert high_risk.auto_execute is True
|
||
assert high_risk.gate_code == "auto_execute_allowed"
|
||
assert "人工确认" not in high_risk.reason
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_honors_rag_request_context(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = db
|
||
captured["tenant_id"] = tenant_id
|
||
|
||
async def answer_question(self, **kwargs):
|
||
captured["answer_kwargs"] = kwargs
|
||
return {"answer": "来自统一 ingress 的回答", "session_id": 7}
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=12,
|
||
metadata={
|
||
"rag_request_context": {
|
||
"asked_by": 101,
|
||
"asked_via": "pmflow_manual",
|
||
"project_id": 19,
|
||
"source_chat_id": "chat-abc",
|
||
"session_id": 55,
|
||
"force_new_session": True,
|
||
"thinking_mode": "deep",
|
||
"action_mode": "read_write",
|
||
"sync_requirement": True,
|
||
"message_intent": "mixed",
|
||
"mixed_signal_part": "新增任务确认流",
|
||
"consultant_mode_preference": "challenge",
|
||
}
|
||
},
|
||
)
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="请同步更新状态",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert "统一 ingress" in result["answer"]
|
||
answer_kwargs = captured["answer_kwargs"]
|
||
assert isinstance(answer_kwargs, dict)
|
||
assert answer_kwargs["asked_by"] == 101
|
||
assert answer_kwargs["asked_via"] == "pmflow_manual"
|
||
assert answer_kwargs["project_id"] == 19
|
||
assert answer_kwargs["source_chat_id"] == "chat-abc"
|
||
assert answer_kwargs["session_id"] == 55
|
||
assert answer_kwargs["force_new_session"] is True
|
||
assert answer_kwargs["thinking_mode"] == "deep"
|
||
assert answer_kwargs["action_mode"] == "read_write"
|
||
assert answer_kwargs["sync_requirement"] is True
|
||
assert answer_kwargs["message_intent"] == "mixed"
|
||
assert answer_kwargs["mixed_signal_part"] == "新增任务确认流"
|
||
assert answer_kwargs["consultant_mode_preference"] == "challenge"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = db
|
||
captured["tenant_id"] = tenant_id
|
||
|
||
async def answer_question(self, **kwargs):
|
||
captured["answer_kwargs"] = kwargs
|
||
return {"answer": "fallback-answer", "session_id": 7}
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_backfill_query_semantic_bundle",
|
||
AsyncMock(return_value={"diagnostics": {"message_intent": "query", "force_thinking_mode": "normal"}}),
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"thread_context": {"root_message_id": "root-1"},
|
||
"source_sender_open_id": "ou-feishu-99",
|
||
},
|
||
)
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="最近AI行业有什么动态",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-feishu-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
answer_kwargs = captured["answer_kwargs"]
|
||
assert isinstance(answer_kwargs, dict)
|
||
assert answer_kwargs["asked_by"] is None
|
||
assert answer_kwargs["asked_via"] == "feishu"
|
||
assert answer_kwargs["source_chat_id"] is None
|
||
assert answer_kwargs["source_sender_open_id"] == "ou-feishu-99"
|
||
assert answer_kwargs["thread_context"] == {"root_message_id": "root-1"}
|
||
assert answer_kwargs["conversation_key"] == build_feishu_conversation_scope_key(
|
||
source_chat_id=None,
|
||
source_sender_open_id="ou-feishu-99",
|
||
sender=None,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_uses_direct_scene_metadata_when_no_explicit_rag_request(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = db
|
||
captured["tenant_id"] = tenant_id
|
||
|
||
async def answer_question(self, **kwargs):
|
||
captured["answer_kwargs"] = kwargs
|
||
return {"answer": "corrected-answer", "session_id": 9}
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-dm-9",
|
||
"source_sender_open_id": "ou-feishu-9",
|
||
"message_intent": "correction_escalation",
|
||
"consultant_mode_preference": "challenge",
|
||
"force_thinking_mode": "deep",
|
||
},
|
||
)
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="不对,调用高级模型来回答我的问题",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-feishu-9",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
answer_kwargs = captured["answer_kwargs"]
|
||
assert isinstance(answer_kwargs, dict)
|
||
assert answer_kwargs["asked_by"] is None
|
||
assert answer_kwargs["asked_via"] == "feishu"
|
||
assert answer_kwargs["source_chat_id"] == "chat-dm-9"
|
||
assert answer_kwargs["source_sender_open_id"] == "ou-feishu-9"
|
||
assert answer_kwargs["message_intent"] == "correction_escalation"
|
||
assert answer_kwargs["consultant_mode_preference"] == "challenge"
|
||
assert answer_kwargs["thinking_mode"] == "deep"
|
||
assert answer_kwargs["conversation_key"] == build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-dm-9",
|
||
source_sender_open_id="ou-feishu-9",
|
||
sender=None,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise RuntimeError("rag failed before response")
|
||
|
||
class FakeDB:
|
||
def __init__(self) -> None:
|
||
self.rollback_calls = 0
|
||
self.sync_session = SimpleNamespace(is_active=False)
|
||
|
||
async def rollback(self) -> None:
|
||
self.rollback_calls += 1
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = FakeDB()
|
||
self.tenant_id = 3
|
||
|
||
@staticmethod
|
||
def _build_query_error_message(exc: Exception) -> str:
|
||
return f"query failed: {exc}"
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_backfill_query_semantic_bundle",
|
||
AsyncMock(return_value={"diagnostics": {"message_intent": "query"}}),
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(project_id=12, metadata={})
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="现在完整调用链是什么?",
|
||
operator_user_id=999,
|
||
source_system="pmflow_manual",
|
||
source_message_id="msg-rollback-1",
|
||
)
|
||
|
||
assert result["status"] == "failed_safe"
|
||
assert "rag failed" in str(result["error_message"] or "")
|
||
assert executor.db.rollback_calls == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_does_not_rollback_healthy_session_on_rag_failure(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise RuntimeError("rag failed before response")
|
||
|
||
class FakeDB:
|
||
def __init__(self) -> None:
|
||
self.rollback_calls = 0
|
||
self.sync_session = SimpleNamespace(is_active=True)
|
||
|
||
async def rollback(self) -> None:
|
||
self.rollback_calls += 1
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = FakeDB()
|
||
self.tenant_id = 3
|
||
|
||
@staticmethod
|
||
def _build_query_error_message(exc: Exception) -> str:
|
||
return f"query failed: {exc}"
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
_ = (message_text, metadata)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-plan-rollback",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.9,
|
||
metadata={"message_intent": "requirement_plan"},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(project_id=12, metadata={})
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="先分析,不要直接创建。",
|
||
operator_user_id=999,
|
||
source_system="pmflow_manual",
|
||
source_message_id="msg-rollback-2",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert "先不创建项目" in str(result["answer"] or "")
|
||
assert executor.db.rollback_calls == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_short_circuits_casual_chat_without_rag(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("casual chat should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"semantic_bundle": {
|
||
"diagnostics": {
|
||
"message_intent": "casual_chat",
|
||
"consultant_mode_preference": "brainstorm",
|
||
}
|
||
}
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="早上好,今天你在线吗",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
answer = str(result["answer"] or "")
|
||
assert "我在线" in answer
|
||
assert "A." not in answer
|
||
assert "B." not in answer
|
||
assert "C." not in answer
|
||
assert str(result["rag_result"]["query_type"]) == "chat"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_backfills_semantic_bundle_for_casual_chat_without_legacy_helpers(
|
||
monkeypatch,
|
||
) -> None:
|
||
planner_calls: list[dict[str, object]] = []
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("casual chat should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
planner_calls.append(
|
||
{
|
||
"message_text": message_text,
|
||
"source_system": source_system,
|
||
"metadata": dict(metadata or {}),
|
||
}
|
||
)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-casual",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.95,
|
||
metadata={"message_intent": "casual_chat"},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "casual_chat",
|
||
"consultant_mode_preference": "brainstorm",
|
||
},
|
||
)
|
||
|
||
def _legacy_helper_should_not_run(*args, **kwargs): # noqa: ANN002, ANN003
|
||
raise AssertionError("legacy semantic helper should not run")
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(project_id=None, metadata={})
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="早上好,今天你在线吗",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-backfill-1",
|
||
)
|
||
|
||
assert planner_calls, "missing semantic bundle should be backfilled through aplan_message"
|
||
assert result["status"] == "succeeded"
|
||
assert "我在线" in str(result["answer"] or "")
|
||
assert str(result["rag_result"]["query_type"]) == "chat"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_short_circuits_requirement_followup_clarify_without_rag(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement clarify follow-up should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"thread_context": {"root_message_id": "root-1", "reply_to_message_id": "root-1"},
|
||
"semantic_bundle": {
|
||
"diagnostics": {
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "clarify",
|
||
}
|
||
},
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="那就先按上面的方向继续,但是别太重",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-2",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
answer = str(result["answer"] or "")
|
||
assert "轻量版继续理解" in answer
|
||
assert "A." in answer
|
||
assert "B." in answer
|
||
assert "C." in answer
|
||
assert "直接回 A/B/C" in answer
|
||
assert str(result["rag_result"]["query_type"]) == "clarify"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_short_circuits_requirement_planning_without_rag(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement planning should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
_ = (message_text, metadata)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-plan-direct",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.91,
|
||
metadata={"message_intent": "requirement_plan"},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(project_id=None, metadata={})
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="我们想做一个给电动车品牌用的直播复盘工具,先别创建项目,我还在补范围。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-2b",
|
||
)
|
||
|
||
answer = str(result["answer"] or "")
|
||
assert result["status"] == "succeeded"
|
||
assert "不创建" in answer
|
||
assert "这类 IT 项目来收" in answer
|
||
assert "⭐ 当前我更推荐:B" in answer
|
||
assert answer.count("?") <= 1
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_backfills_semantic_bundle_for_requirement_plan_without_legacy_helpers(
|
||
monkeypatch,
|
||
) -> None:
|
||
planner_calls: list[dict[str, object]] = []
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement planning should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
planner_calls.append(
|
||
{
|
||
"message_text": message_text,
|
||
"source_system": source_system,
|
||
"metadata": dict(metadata or {}),
|
||
}
|
||
)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-plan",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.91,
|
||
metadata={"message_intent": "requirement_plan"},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(project_id=None, metadata={})
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="我们想做一个给电动车品牌用的直播复盘工具,先别创建项目,我还在补范围。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-backfill-plan-1",
|
||
)
|
||
|
||
assert planner_calls, "missing requirement metadata should be backfilled through aplan_message"
|
||
assert result["status"] == "succeeded"
|
||
assert "不创建" in str(result["answer"] or "")
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_scene_short_circuits_without_rag(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan scene should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-plan-1",
|
||
)
|
||
|
||
answer = str(result["answer"] or "")
|
||
assert result["status"] == "succeeded"
|
||
assert "这类 IT 项目来收" in answer
|
||
assert "⭐ 当前我更推荐:B" in answer
|
||
assert "A." in answer and "B." in answer and "C." in answer
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_followup_persists_progress_and_asks_next_question(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan follow-up should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
_ = (message_text, metadata)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-plan-draft-friendly",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.92,
|
||
metadata={"message_intent": "requirement_plan"},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
executor = FakeExecutor()
|
||
|
||
initial_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-1",
|
||
"source_sender_open_id": "ou-plan-1",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-1",
|
||
"source_sender_open_id": "ou-plan-1",
|
||
"thread_context": {
|
||
"root_message_id": "msg-plan-root",
|
||
"reply_to_message_id": "msg-plan-root",
|
||
},
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
first = await executor._execute_query_intent(
|
||
intent=initial_intent,
|
||
message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-root",
|
||
)
|
||
second = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="A,主要给投顾看。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-1",
|
||
)
|
||
|
||
assert first["status"] == "succeeded"
|
||
assert second["status"] == "succeeded"
|
||
assert "这类 IT 项目来收" in str(first["answer"] or "")
|
||
assert "⭐ 当前我更推荐:B" in str(first["answer"] or "")
|
||
assert "技术路线定住" in str(second["answer"] or "")
|
||
assert "A." in str(second["answer"] or "")
|
||
assert "B." in str(second["answer"] or "")
|
||
assert "C." in str(second["answer"] or "")
|
||
|
||
topic = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedTopicWorkspace)
|
||
.where(
|
||
MountedTopicWorkspace.tenant_id == 3,
|
||
MountedTopicWorkspace.channel_ref == "chat-plan-1",
|
||
)
|
||
.order_by(MountedTopicWorkspace.id.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one()
|
||
draft = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace)
|
||
.join(MountedTopicWorkspace, MountedTopicWorkspace.id == MountedDraftWorkspace.topic_workspace_id)
|
||
.where(
|
||
MountedDraftWorkspace.tenant_id == 3,
|
||
MountedTopicWorkspace.channel_ref == "chat-plan-1",
|
||
)
|
||
.order_by(MountedDraftWorkspace.id.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one()
|
||
|
||
card = draft.requirement_card or {}
|
||
assert topic.mode == "plan"
|
||
assert draft.state == "collecting"
|
||
assert card["project_nature"] == "轻量打分工具"
|
||
assert "投顾" in str(card["target_user"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_builds_feishu_friendly_draft_after_enough_answers(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan draft should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
intent_root = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-2",
|
||
"source_sender_open_id": "ou-plan-2",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
intent_followup = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-2",
|
||
"source_sender_open_id": "ou-plan-2",
|
||
"thread_context": {
|
||
"root_message_id": "msg-plan-root-2",
|
||
"reply_to_message_id": "msg-plan-root-2",
|
||
},
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
await executor._execute_query_intent(
|
||
intent=intent_root,
|
||
message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-root-2",
|
||
)
|
||
await executor._execute_query_intent(
|
||
intent=intent_followup,
|
||
message_text="A",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-2a",
|
||
)
|
||
await executor._execute_query_intent(
|
||
intent=intent_followup,
|
||
message_text="主要给投顾看。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-2b",
|
||
)
|
||
await executor._execute_query_intent(
|
||
intent=intent_followup,
|
||
message_text="任务编排平台版,用 FastAPI + React + Redis + Celery + PostgreSQL。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-2c",
|
||
)
|
||
final_result = await executor._execute_query_intent(
|
||
intent=intent_followup,
|
||
message_text="第一版就先保留分数、方向判断和一句解释,别做太重。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-2d",
|
||
)
|
||
|
||
answer = str(final_result["answer"] or "")
|
||
assert final_result["status"] == "succeeded"
|
||
assert "📋 我先按当前收敛结果" in answer
|
||
assert "🧾【需求名称】" in answer
|
||
assert "🏗️【项目性质】" in answer
|
||
assert "👥【服务对象】" in answer
|
||
assert "⚙️【技术路线】" in answer
|
||
assert "🧱【第一版范围】" in answer
|
||
assert str(final_result["rag_result"]["query_type"]) == "draft"
|
||
assert final_result["reply_template_key"] == "draft_pending_confirm"
|
||
assert final_result["draft_artifact"]["promote_to_confirmation"] is True
|
||
|
||
draft = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3)
|
||
)
|
||
).scalar_one()
|
||
topic = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedTopicWorkspace).where(MountedTopicWorkspace.tenant_id == 3)
|
||
)
|
||
).scalar_one()
|
||
assert draft.state == "ready"
|
||
assert "每日 A 股趋势打分工具" in str((draft.draft_payload or {}).get("title") or "")
|
||
assert draft.active_work_item_id is None
|
||
assert draft.latest_source_message_id == "msg-plan-reply-2d"
|
||
assert draft.latest_reply_message_id is None
|
||
assert topic.plan_status == "awaiting_execute_confirm"
|
||
assert topic.pending_actions == ["confirm_requirement_draft"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_explicit_new_requirement_topic_suspends_previous_plan(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("explicit new topic switching should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
active_plan_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-switch",
|
||
"source_sender_open_id": "ou-plan-switch",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
explicit_switch_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-switch",
|
||
"source_sender_open_id": "ou-plan-switch",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
await executor._execute_query_intent(
|
||
intent=active_plan_intent,
|
||
message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-switch-root",
|
||
)
|
||
switch_result = await executor._execute_query_intent(
|
||
intent=explicit_switch_intent,
|
||
message_text="不对啊,现在是另一个需求,先别沿用刚才那版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-switch-new",
|
||
)
|
||
|
||
rows = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedTopicWorkspace)
|
||
.where(MountedTopicWorkspace.tenant_id == 3)
|
||
.order_by(MountedTopicWorkspace.id.asc())
|
||
)
|
||
).scalars().all()
|
||
|
||
answer = str(switch_result["answer"] or "")
|
||
assert len(rows) == 2
|
||
assert "🧭 我先按" in answer
|
||
assert "A." in answer and "B." in answer and "C." in answer
|
||
assert rows[0].status == "suspended"
|
||
assert rows[0].plan_status == "suspended"
|
||
assert rows[1].status == "active"
|
||
assert rows[1].plan_status == "collecting"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_explicit_new_it_requirement_overrides_stale_clarify_and_avoids_old_scoring_topic(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("new IT requirement topic should stay in direct plan flow")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-it-switch",
|
||
source_sender_open_id="ou-plan-it-switch",
|
||
sender=None,
|
||
)
|
||
old_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-it-switch:message:msg-old-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-it-switch",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="msg-old-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="awaiting_execute_confirm",
|
||
metadata_json={"plan_mode": True},
|
||
facts_json={"plan_profile": "scoring", "title": "每日 A 股趋势打分工具"},
|
||
)
|
||
requirement_plan_db_session.add(old_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(old_topic.id),
|
||
state="ready",
|
||
draft_version=1,
|
||
requirement_card={
|
||
"plan_profile": "scoring",
|
||
"title": "每日 A 股趋势打分工具",
|
||
"goal": "每天产出一个可解释的 A 股趋势分数",
|
||
"scoring_basis": "技术指标(均线、成交量、涨跌比等)",
|
||
"target_user": "投顾",
|
||
"output_form": "飞书日报",
|
||
"cadence": "每日",
|
||
"first_scope": "第一版先做 A 股趋势分数和一句解释",
|
||
},
|
||
draft_payload={"title": "每日 A 股趋势打分工具"},
|
||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
stale_clarify_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-it-switch",
|
||
"source_sender_open_id": "ou-plan-it-switch",
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-it-switch",
|
||
"source_sender_open_id": "ou-plan-it-switch",
|
||
"thread_context": {
|
||
"root_message_id": "msg-new-it-root",
|
||
"reply_to_message_id": "msg-new-it-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
first = await executor._execute_query_intent(
|
||
intent=stale_clarify_intent,
|
||
message_text="想跟你聊个新需求关于批量自动化部署openclaw的,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-new-it-root",
|
||
)
|
||
second = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="C,先按云原生交付底座来收。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-new-it-2",
|
||
)
|
||
|
||
first_answer = str(first["answer"] or "")
|
||
second_answer = str(second["answer"] or "")
|
||
|
||
assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in first_answer
|
||
assert "云原生交付底座" in first_answer
|
||
assert "目标对象" not in first_answer
|
||
assert "A股趋势打分" not in first_answer
|
||
|
||
assert "🧭 我先按“云原生交付底座”这个方向继续收。" in second_answer
|
||
assert "现在要先把服务对象定住" in second_answer
|
||
assert "A股趋势打分" not in second_answer
|
||
assert "飞书日报" not in second_answer
|
||
|
||
rows = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedTopicWorkspace)
|
||
.where(MountedTopicWorkspace.tenant_id == 3)
|
||
.order_by(MountedTopicWorkspace.id.asc())
|
||
)
|
||
).scalars().all()
|
||
|
||
assert len(rows) == 2
|
||
assert rows[0].status == "suspended"
|
||
assert rows[0].plan_status == "suspended"
|
||
assert rows[1].status == "active"
|
||
assert rows[1].plan_status == "collecting"
|
||
assert rows[1].root_message_id == "msg-new-it-root"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_explicit_new_requirement_topic_reuses_current_tracking_topic(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("explicit new topic switching should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-switch-reuse",
|
||
source_sender_open_id="ou-plan-switch-reuse",
|
||
sender=None,
|
||
)
|
||
existing_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-switch-reuse:message:msg-plan-switch-root-reuse",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-switch-reuse",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="msg-plan-switch-root-reuse",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(existing_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(existing_topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={"title": "旧需求"},
|
||
draft_payload=None,
|
||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||
)
|
||
)
|
||
tracking_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-switch-reuse:message:msg-plan-switch-new-reuse",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-switch-reuse",
|
||
conversation_scope_key=conversation_scope_key,
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="idle",
|
||
metadata_json={"tracking": True},
|
||
)
|
||
requirement_plan_db_session.add(tracking_topic)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
explicit_switch_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-switch-reuse",
|
||
"source_sender_open_id": "ou-plan-switch-reuse",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
switch_result = await executor._execute_query_intent(
|
||
intent=explicit_switch_intent,
|
||
message_text="想跟你聊个新需求,关于批量自动化部署openclaw,先不创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-switch-new-reuse",
|
||
)
|
||
|
||
rows = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedTopicWorkspace)
|
||
.where(MountedTopicWorkspace.tenant_id == 3)
|
||
.order_by(MountedTopicWorkspace.id.asc())
|
||
)
|
||
).scalars().all()
|
||
|
||
assert len(rows) == 2
|
||
assert switch_result["status"] == "succeeded"
|
||
assert rows[0].status == "suspended"
|
||
assert rows[0].plan_status == "suspended"
|
||
assert rows[1].id == tracking_topic.id
|
||
assert rows[1].status == "active"
|
||
assert rows[1].mode == "plan"
|
||
assert rows[1].plan_status == "collecting"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_explicit_new_requirement_topic_with_subject_starts_collection_immediately(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("explicit new topic seed should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch, topic_switch="new")
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-switch-subject",
|
||
source_sender_open_id="ou-plan-switch-subject",
|
||
sender=None,
|
||
)
|
||
existing_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-switch-subject:message:msg-plan-switch-subject-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-switch-subject",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="msg-plan-switch-subject-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="draft_pending_confirm",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(existing_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(existing_topic.id),
|
||
state="draft_pending_confirm",
|
||
draft_version=1,
|
||
requirement_card={"title": "旧需求"},
|
||
draft_payload={"title": "旧需求"},
|
||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||
)
|
||
)
|
||
tracking_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-switch-subject:message:msg-plan-switch-subject-new",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-switch-subject",
|
||
conversation_scope_key=conversation_scope_key,
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="idle",
|
||
metadata_json={"tracking": True},
|
||
)
|
||
requirement_plan_db_session.add(tracking_topic)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
explicit_switch_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-switch-subject",
|
||
"source_sender_open_id": "ou-plan-switch-subject",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=explicit_switch_intent,
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-switch-subject-new",
|
||
)
|
||
|
||
answer = str(result["answer"] or "")
|
||
assert "你先发一句这次真正想解决什么问题" not in answer
|
||
assert "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。" in answer
|
||
assert "⭐ 当前我更推荐:B" in answer
|
||
assert "会议纪要" not in answer
|
||
assert "输入先按用户当前需求描述来" not in answer
|
||
assert "A." in answer
|
||
assert "B." in answer
|
||
assert "C." in answer
|
||
assert "你回 A / B / C" in answer
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_low_overlap_requirement_seed_switches_new_topic(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("low-overlap requirement switching should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
_install_requirement_plan_query_bundle(monkeypatch, topic_switch="new")
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
current_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-overlap:root:msg-plan-overlap-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-overlap",
|
||
conversation_scope_key=build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-overlap",
|
||
source_sender_open_id="ou-plan-overlap",
|
||
sender=None,
|
||
),
|
||
root_message_id="msg-plan-overlap-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="awaiting_execute_confirm",
|
||
facts_json={
|
||
"title": "每日 A 股趋势打分工具",
|
||
"target_user": "投顾",
|
||
"scoring_basis": "技术指标(均线、成交量、涨跌比等)",
|
||
"output_form": "飞书日报",
|
||
},
|
||
goals_json=["每天产出一个可解释的 A 股趋势分数,辅助判断和记录。"],
|
||
constraints_json={"first_scope": "先保留一个分数、方向判断和一句解释"},
|
||
pending_actions=["confirm_requirement_draft"],
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(current_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(current_topic.id),
|
||
state="ready",
|
||
draft_version=1,
|
||
requirement_card={
|
||
"title": "每日 A 股趋势打分工具",
|
||
"goal": "每天产出一个可解释的 A 股趋势分数,辅助判断和记录。",
|
||
"target_user": "投顾",
|
||
"scoring_basis": "技术指标(均线、成交量、涨跌比等)",
|
||
"output_form": "飞书日报",
|
||
"cadence": "每日",
|
||
"first_scope": "先保留一个分数、方向判断和一句解释",
|
||
"plan_profile": "scoring",
|
||
},
|
||
draft_payload={"title": "每日 A 股趋势打分工具"},
|
||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
implicit_switch_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-overlap",
|
||
"source_sender_open_id": "ou-plan-overlap",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
switch_result = await executor._execute_query_intent(
|
||
intent=implicit_switch_intent,
|
||
message_text="想跟你聊需求,关于批量自动化部署openclaw,先不创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-overlap-new",
|
||
)
|
||
|
||
rows = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedTopicWorkspace)
|
||
.where(MountedTopicWorkspace.tenant_id == 3)
|
||
.order_by(MountedTopicWorkspace.id.asc())
|
||
)
|
||
).scalars().all()
|
||
|
||
assert len(rows) == 2
|
||
assert "每日 A 股趋势打分工具" not in str(switch_result["answer"] or "")
|
||
assert rows[0].status == "suspended"
|
||
assert rows[0].plan_status == "suspended"
|
||
assert rows[1].status == "active"
|
||
assert rows[1].plan_status == "collecting"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_accepts_fragmented_short_answers_and_only_drafts_when_scope_arrives(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("fragmented requirement plan should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
root_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-3",
|
||
"source_sender_open_id": "ou-plan-3",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
fragmented_followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-3",
|
||
"source_sender_open_id": "ou-plan-3",
|
||
"thread_context": {
|
||
"root_message_id": "msg-plan-root-3",
|
||
"reply_to_message_id": "msg-plan-root-3",
|
||
},
|
||
},
|
||
)
|
||
|
||
first = await executor._execute_query_intent(
|
||
intent=root_intent,
|
||
message_text="想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-root-3",
|
||
)
|
||
second = await executor._execute_query_intent(
|
||
intent=fragmented_followup_intent,
|
||
message_text="A",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-3a",
|
||
)
|
||
third = await executor._execute_query_intent(
|
||
intent=fragmented_followup_intent,
|
||
message_text="主要给投顾看",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-3b",
|
||
)
|
||
fourth = await executor._execute_query_intent(
|
||
intent=fragmented_followup_intent,
|
||
message_text="B",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-3c",
|
||
)
|
||
final_result = await executor._execute_query_intent(
|
||
intent=fragmented_followup_intent,
|
||
message_text="第一版先保留分数、方向判断和一句解释就够了",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-reply-3d",
|
||
)
|
||
|
||
assert "这类 IT 项目来收" in str(first["answer"] or "")
|
||
assert "服务对象定住" in str(second["answer"] or "")
|
||
assert str(second["rag_result"]["query_type"]) == "plan"
|
||
assert "技术路线定住" in str(third["answer"] or "")
|
||
assert str(third["rag_result"]["query_type"]) == "plan"
|
||
assert "第一版先证明什么价值" in str(fourth["answer"] or "")
|
||
assert str(fourth["rag_result"]["query_type"]) == "plan"
|
||
|
||
final_answer = str(final_result["answer"] or "")
|
||
assert "📋 我先按当前收敛结果" in final_answer
|
||
assert "🧾【需求名称】" in final_answer
|
||
assert "🧱【第一版范围】" in final_answer
|
||
assert str(final_result["rag_result"]["query_type"]) == "draft"
|
||
|
||
draft = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3)
|
||
)
|
||
).scalar_one()
|
||
card = draft.requirement_card or {}
|
||
assert card["project_nature"] == "轻量打分工具"
|
||
assert "投顾" in str(card["target_user"] or "")
|
||
assert "评分服务版" in str(card["tech_route"] or "")
|
||
assert "方向判断" in str(card["first_scope"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_defaults_to_it_pm_template_for_current_system(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("PMFlow IT requirement plan should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
root_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-meeting",
|
||
"source_sender_open_id": "ou-plan-meeting",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-meeting",
|
||
"source_sender_open_id": "ou-plan-meeting",
|
||
"thread_context": {
|
||
"root_message_id": "msg-plan-meeting-root",
|
||
"reply_to_message_id": "msg-plan-meeting-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
first = await executor._execute_query_intent(
|
||
intent=root_intent,
|
||
message_text="想跟你聊个需求,做一个企业级项目管理系统里的需求收敛功能,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-meeting-root",
|
||
)
|
||
second = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B,先做发布治理平台。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-meeting-2",
|
||
)
|
||
third = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B,主要给项目管理团队用。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-meeting-3",
|
||
)
|
||
fourth = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B,走任务编排平台版,用 FastAPI + React + Redis + Celery + Postgres。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-meeting-4",
|
||
)
|
||
final_result = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="待办、责任人、截止时间。#meeting-plan-debug-tag",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-meeting-5",
|
||
)
|
||
|
||
assert "这类 IT 项目来收" in str(first["answer"] or "")
|
||
assert "先定它更像工具、平台,还是长期底座" in str(first["answer"] or "")
|
||
assert "A." in str(first["answer"] or "")
|
||
assert "B." in str(first["answer"] or "")
|
||
assert "C." in str(first["answer"] or "")
|
||
assert "⭐ 当前我更推荐:B" in str(first["answer"] or "")
|
||
assert "平台运维团队" in str(second["answer"] or "")
|
||
assert "任务编排平台版" in str(third["answer"] or "")
|
||
assert "A." in str(second["answer"] or "")
|
||
assert "B." in str(second["answer"] or "")
|
||
assert "C." in str(second["answer"] or "")
|
||
assert "批量执行闭环" in str(fourth["answer"] or "")
|
||
assert "⭐ 当前我更推荐:A" in str(fourth["answer"] or "")
|
||
assert "A." in str(third["answer"] or "")
|
||
assert "B." in str(third["answer"] or "")
|
||
assert "C." in str(third["answer"] or "")
|
||
|
||
final_answer = str(final_result["answer"] or "")
|
||
assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer
|
||
assert "🧭【一句话判断】" in final_answer
|
||
assert "【输入材料】" not in final_answer
|
||
assert "【响应时效】" not in final_answer
|
||
assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer
|
||
assert "👥【服务对象】第一版先服务项目管理团队" in final_answer
|
||
assert "⚙️【技术路线】当前更适合走任务编排平台版" in final_answer
|
||
assert "🧱【第一版范围】待办、责任人、截止时间" in final_answer
|
||
assert "🚀【推进方式】先按项目级试点(带基础审批和回滚)推进" in final_answer
|
||
assert "#meeting-plan-debug-tag" not in final_answer
|
||
assert str(final_result["rag_result"]["query_type"]) == "draft"
|
||
|
||
draft = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3)
|
||
)
|
||
).scalar_one()
|
||
card = draft.requirement_card or {}
|
||
assert card["plan_profile"] == "it_project"
|
||
assert card["project_nature"] == "发布治理平台"
|
||
assert "项目管理团队" in str(card["target_user"] or "")
|
||
assert "FastAPI" in str(card["tech_route"] or "")
|
||
assert card["delivery_mode"] == "项目级试点(带基础审批和回滚)"
|
||
assert "待办" in str(card["first_scope"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_injects_context_to_llm_for_abc_options(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("LLM-backed ABC requirement plan should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
captured: list[dict[str, object]] = []
|
||
_install_requirement_plan_round_llm(monkeypatch, captured)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-outfit",
|
||
"source_sender_open_id": "ou-plan-outfit",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-outfit-root",
|
||
)
|
||
|
||
answer = str(result["answer"] or "")
|
||
assert result["status"] == "succeeded"
|
||
assert captured, "requirement_plan ABC options must be generated through the LLM round service"
|
||
assert str(captured[0]["kwargs"].get("function_name")) == "pmflow_requirement_plan_round"
|
||
assert "换季系统自动推荐搭配衣服" in str(captured[0]["prompt"])
|
||
assert '"missing_field": "project_nature"' in str(captured[0]["prompt"])
|
||
assert "A. 先做穿搭推荐小工具" in answer
|
||
assert "B. 先做个性化穿搭推荐平台" in answer
|
||
assert "C. 直接做穿搭决策底座" in answer
|
||
assert "⭐ 当前我更推荐:B" in answer
|
||
assert "批量自动化部署" not in answer
|
||
assert "发布治理平台" not in answer
|
||
assert "OpenClaw" not in answer
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
|
||
followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-outfit",
|
||
"source_sender_open_id": "ou-plan-outfit",
|
||
"thread_context": {
|
||
"root_message_id": "msg-plan-outfit-root",
|
||
"reply_to_message_id": "msg-plan-outfit-root",
|
||
},
|
||
},
|
||
)
|
||
second = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-outfit-2",
|
||
)
|
||
assert second["status"] == "succeeded"
|
||
|
||
draft = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3)
|
||
)
|
||
).scalar_one()
|
||
card = draft.requirement_card or {}
|
||
assert card["project_nature"] == "个性化穿搭推荐平台"
|
||
assert card["project_nature"] != "发布治理平台"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_generates_contextual_outfit_draft_instead_of_old_it_template(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan draft should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
calls: list[dict[str, object]] = []
|
||
|
||
def _extract_packet(prompt: str, marker: str) -> dict[str, object]:
|
||
if marker not in prompt:
|
||
return {}
|
||
raw = prompt.split(marker, 1)[1].strip()
|
||
try:
|
||
payload = json.loads(raw)
|
||
except Exception:
|
||
return {}
|
||
return payload if isinstance(payload, dict) else {}
|
||
|
||
async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001
|
||
function_name = str(kwargs.get("function_name") or "")
|
||
calls.append({"function_name": function_name, "prompt": prompt})
|
||
if function_name == "pmflow_requirement_plan_round":
|
||
packet = _extract_packet(prompt, "requirement_plan_round_packet:")
|
||
context_blob = json.dumps(packet, ensure_ascii=False)
|
||
if '"missing_field": "project_nature"' in prompt:
|
||
payload = {
|
||
"opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。",
|
||
"focus": "现在最关键的是先把第一版定位定住。",
|
||
"options": [
|
||
{"key": "A", "title": "先做穿搭推荐小工具", "best_for": "先验证换季推荐有没有真实使用价值", "suggested_capability": "规则推荐、基础标签、简单反馈", "rationale": "落地快,适合先跑通最小闭环", "tradeoff": "个性化能力会偏弱"},
|
||
{"key": "B", "title": "先做个性化穿搭推荐平台", "best_for": "持续沉淀用户偏好、衣橱数据和搭配反馈", "suggested_capability": "用户画像、衣物标签、推荐策略、反馈闭环", "rationale": "更贴近自动推荐搭配衣服的长期价值", "tradeoff": "第一版要先说清边界"},
|
||
{"key": "C", "title": "直接做穿搭决策底座", "best_for": "后续要接多品牌和多场景运营", "suggested_capability": "多源接入、推荐服务、效果评估", "rationale": "长期上限最高", "tradeoff": "早期投入最大"},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "它最贴近这类需求的持续价值,同时不会一开始做得过重。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
elif '"missing_field": "target_user"' in prompt:
|
||
payload = {
|
||
"opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。",
|
||
"focus": "现在先把第一版服务对象定住,不然后面推荐口径会飘。",
|
||
"options": [
|
||
{"key": "A", "title": "普通消费者", "best_for": "先验证换季搭配推荐是否真的有帮助", "suggested_capability": "偏好反馈、天气/季节标签、收藏行为", "rationale": "最贴近最终使用者,能尽快拿到真实反馈", "tradeoff": "需要处理偏好差异"},
|
||
{"key": "B", "title": "穿搭运营团队", "best_for": "先由运营维护搭配规则和推荐口径", "suggested_capability": "规则配置、款式标签、人工校验", "rationale": "更容易控制第一版质量", "tradeoff": "自动化程度会弱一些"},
|
||
{"key": "C", "title": "品牌导购团队", "best_for": "结合商品推荐和导购场景做转化验证", "suggested_capability": "商品库、搭配卡片、导购反馈", "rationale": "更适合商业转化场景", "tradeoff": "会更依赖商品数据"},
|
||
],
|
||
"recommendation": {"key": "A", "reason": "先服务普通消费者,最能验证自动推荐是否真的成立。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
elif '"missing_field": "tech_route"' in prompt and "换季系统自动推荐搭配衣服" in context_blob:
|
||
payload = {
|
||
"opening": "🧭 我先按“普通消费者先试用”这个方向继续收。",
|
||
"focus": "现在先把第一版能力路径定住,避免一开始做重。",
|
||
"options": [
|
||
{"key": "A", "title": "规则推荐版", "best_for": "先用季节、温度、场景和衣物标签跑通推荐闭环", "suggested_capability": "标签规则、搭配模板、简单反馈", "rationale": "最快验证推荐是否有用", "tradeoff": "后续个性化能力要继续补"},
|
||
{"key": "B", "title": "轻量模型推荐版", "best_for": "希望更早引入用户偏好和相似搭配学习", "suggested_capability": "基础召回、轻量排序、反馈学习", "rationale": "推荐体验会更自然", "tradeoff": "第一版调参成本更高"},
|
||
{"key": "C", "title": "多模态推荐底座", "best_for": "一开始就要处理图片理解和风格迁移", "suggested_capability": "图文向量、风格标签、多模态检索", "rationale": "长期上限更高", "tradeoff": "首版复杂度太高"},
|
||
],
|
||
"recommendation": {"key": "A", "reason": "先用规则和标签跑通首轮推荐,更符合当前验证阶段。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
else:
|
||
payload = {
|
||
"opening": "🧭 我先按“规则推荐版”继续收尾。",
|
||
"focus": "最后定一下第一版先保留哪些推荐范围。",
|
||
"options": [
|
||
{"key": "A", "title": "先保留温度、场景、单品搭配建议", "best_for": "先把最基础的换季推荐闭环跑通", "suggested_capability": "温度标签、场景标签、单品匹配规则", "rationale": "最容易验证用户是否愿意使用", "tradeoff": "短期内还没有风格迁移"},
|
||
{"key": "B", "title": "加入用户偏好和历史收藏", "best_for": "希望一开始就看个性化提升", "suggested_capability": "偏好特征、收藏反馈、简单排序", "rationale": "推荐会更贴近个人习惯", "tradeoff": "冷启动成本更高"},
|
||
{"key": "C", "title": "加入图片理解和风格迁移", "best_for": "要做更强的视觉搭配能力", "suggested_capability": "图像理解、风格识别、向量检索", "rationale": "长期价值更高", "tradeoff": "第一版会明显做重"},
|
||
],
|
||
"recommendation": {"key": "A", "reason": "先把温度、场景、单品搭配建议跑通,最适合验证首轮价值。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
}
|
||
return StructuredExtractionResult(
|
||
payload=payload,
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
if function_name == "pmflow_requirement_plan_draft":
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"intro": "📋 我先按当前收敛结果,整理成一版更贴近当前业务的需求草稿:",
|
||
"title": "换季系统自动推荐搭配衣服",
|
||
"one_liner": "先面向普通消费者做一版换季穿搭推荐试用,先把温度、场景和单品搭配建议跑成可验证闭环。",
|
||
"goal": "先验证用户是否愿意使用换季搭配推荐,并收集最基础的点击、收藏和反馈信号。",
|
||
"project_nature": "这件事更像个性化穿搭推荐平台的第一版,不是部署平台,也不是内部流程治理工具。",
|
||
"target_user": "第一版先服务普通消费者,重点看他们是否会点击、收藏和采纳推荐结果。",
|
||
"signal_inputs": "当前至少要用到:用户当前需求描述;季节/温度信息;衣物标签;基础反馈数据。",
|
||
"known_info": "目前已经明确:这是换季穿搭推荐需求;先按个性化穿搭推荐平台收敛;第一版先给普通消费者试用;技术路线先按规则推荐版;范围先保留温度、场景、单品搭配建议。",
|
||
"tech_route": "当前更适合先走规则推荐版,用季节、温度、场景和衣物标签跑通首轮推荐。",
|
||
"first_scope": "第一版先保留温度、场景、单品搭配建议,先验证推荐是否被点击、收藏和采纳。",
|
||
"core_flow": "用户进入换季推荐入口 -> 结合温度/场景生成搭配建议 -> 展示单品组合 -> 记录点击/收藏/反馈 -> 继续优化推荐规则",
|
||
"delivery_mode": "先做小范围真实用户试用,优先看真实点击、收藏和主观反馈。",
|
||
"success_signal": "先保证能稳定给出换季搭配建议,并拿到点击、收藏或反馈中的至少一类真实用户信号。",
|
||
"risks": "当前主要风险在冷启动标签质量、搭配规则是否过于死板,以及首轮反馈样本是否足够。",
|
||
"pending_items": ["衣物标签口径是否已有现成标准", "首轮试用用户范围和反馈采集方式", "是否需要同步接商品库或衣橱数据"],
|
||
"closing": "如果你认可,我下一轮就按这版继续细化;如果要改,直接说改哪一项,我就在这版上改。",
|
||
"positioning_one_liner": "面向普通消费者的小范围换季穿搭推荐试用,先跑通基础推荐闭环。",
|
||
"summary": "先用规则推荐跑通换季穿搭建议,再根据真实点击、收藏和反馈决定是否扩到更完整的平台能力。",
|
||
"target_customers": "普通消费者",
|
||
"external_signal_sources": [
|
||
{"name": "天气 API", "purpose": "补充实时温度、体感和降雨变化", "phase": "首版优先接入"},
|
||
{"name": "淘宝开放平台", "purpose": "补充可推荐商品的品类、颜色和季节属性", "phase": "首版优先接入"},
|
||
{"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度和潮流变化", "phase": "第二阶段接入"},
|
||
{"name": "小红书趋势榜", "purpose": "补充当季穿搭内容趋势和流行关键词", "phase": "第二阶段接入"},
|
||
{"name": "微信导出回流", "purpose": "补充用户转发、收藏后的采纳反馈", "phase": "首版优先接入"},
|
||
],
|
||
"requirement_items": [
|
||
{"section": "需求目标", "title": "验证用户价值", "description": "验证用户是否愿意使用换季搭配推荐,并留下点击、收藏或反馈。"},
|
||
{"section": "服务对象", "title": "首批用户", "description": "第一版先服务普通消费者。"},
|
||
{"section": "技术路线", "title": "规则推荐版", "description": "先基于季节、温度、场景和衣物标签生成推荐。"},
|
||
{"section": "第一版范围", "title": "最小闭环", "description": "先保留温度、场景、单品搭配建议。"},
|
||
],
|
||
"missing_info": ["衣物标签标准", "试用用户范围", "反馈采集方式"],
|
||
"mvp_items": ["温度标签", "场景标签", "单品搭配建议", "基础点击/收藏反馈"],
|
||
"team_setup": [{"role": "建议配置", "notes": "产品/运营 1名、后端/算法 1名、前端 1名,先支撑小范围试用即可。"}],
|
||
"validation_metrics": {"product": ["推荐点击率", "收藏率", "用户主观反馈"]},
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
raise AssertionError(f"unexpected function_name: {function_name}")
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.llm_service,
|
||
"generate_structured_object",
|
||
fake_generate_structured_object,
|
||
)
|
||
async def _fake_recommend_requirement_plan_owner_for_draft(self, *, card: dict[str, object]): # noqa: ANN001
|
||
_ = self, card
|
||
return {
|
||
"user_id": 18,
|
||
"name": "陈后端",
|
||
"role": "后端开发",
|
||
"workload_ratio": 28.0,
|
||
"reason": "系统检索到他当前负载较低,且技能更接近推荐与数据接入。",
|
||
"matched_skills": ["Python", "FastAPI", "数据接入"],
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_recommend_requirement_plan_owner_for_draft",
|
||
_fake_recommend_requirement_plan_owner_for_draft,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
root_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-outfit-draft",
|
||
"source_sender_open_id": "ou-plan-outfit-draft",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-outfit-draft",
|
||
"source_sender_open_id": "ou-plan-outfit-draft",
|
||
"thread_context": {
|
||
"root_message_id": "msg-plan-outfit-draft-root",
|
||
"reply_to_message_id": "msg-plan-outfit-draft-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
first_result = await executor._execute_query_intent(
|
||
intent=root_intent,
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-outfit-draft-root",
|
||
)
|
||
await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-outfit-draft-2",
|
||
)
|
||
await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="A",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-outfit-draft-3",
|
||
)
|
||
await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="A",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-outfit-draft-4",
|
||
)
|
||
final_result = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="A",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-outfit-draft-5",
|
||
)
|
||
|
||
final_answer = str(final_result["answer"] or "")
|
||
assert first_result["status"] == "succeeded"
|
||
assert any(str(call.get("function_name")) == "pmflow_requirement_plan_draft" for call in calls)
|
||
assert "换季系统自动推荐搭配衣服" in final_answer
|
||
assert "普通消费者" in final_answer
|
||
assert "规则推荐版" in final_answer
|
||
assert "小范围真实用户试用" in final_answer
|
||
assert "🌐【外部信号源】" in final_answer
|
||
assert "天气 API" in final_answer
|
||
assert "淘宝开放平台" in final_answer
|
||
assert "得物开放数据" in final_answer
|
||
assert "小红书趋势榜" in final_answer
|
||
assert "👤【推荐负责人】陈后端" in final_answer
|
||
assert "系统检索到他当前负载较低" in final_answer
|
||
assert "人员能力图谱" not in final_answer
|
||
assert "工作流状态与审计日志" not in final_answer
|
||
assert "审批、审计、权限一次做满" not in final_answer
|
||
assert "确认门禁" not in final_answer
|
||
assert str(final_result["rag_result"]["query_type"]) == "draft"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_promotes_explicit_choice_followup_into_requirement_plan(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"explicit requirement followup should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "我先按主播协同作战平台这条需求继续收范围。",
|
||
"rag_result": {
|
||
"answer": "我先按主播协同作战平台这条需求继续收范围。",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
chat_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-choice:root:om-plan-choice-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-choice",
|
||
conversation_scope_key=build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-choice",
|
||
source_sender_open_id="ou-plan-choice",
|
||
sender=None,
|
||
),
|
||
root_message_id="om-plan-choice-root",
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="idle",
|
||
metadata_json={"last_message_preview": "C"},
|
||
)
|
||
requirement_plan_db_session.add(chat_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add_all(
|
||
[
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-plan-choice-1",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-choice-1",
|
||
status="completed",
|
||
final_reply=(
|
||
"🧭 我先按“给运营公司打造主播”这类需求来收。\n\n"
|
||
"A. 主播培训系统\nB. 主播数字分身工厂\nC. 主播协同作战平台\n\n"
|
||
"⭐ 当前我更推荐:C\n你回 A / B / C,我就顺着这条继续往下收。"
|
||
),
|
||
execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"},
|
||
),
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-plan-choice-2",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-choice-2",
|
||
status="completed",
|
||
final_reply=(
|
||
"你当前在聊“给运营公司打造主播”这个需求的 C 选项——主播协同作战平台。"
|
||
"咱们还没确认它到底要优先解决哪类业务卡点。"
|
||
),
|
||
execution_outcome={"source_message_preview": "C"},
|
||
),
|
||
]
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-choice",
|
||
"source_sender_open_id": "ou-plan-choice",
|
||
"message_intent": "continue_contextual_qa",
|
||
"thread_context": {
|
||
"root_message_id": "om-plan-choice-root",
|
||
"reply_to_message_id": "om-plan-choice-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="三个问题都有",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-choice-3",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "三个问题都有"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-plan-choice"
|
||
assert direct_kwargs["thread_context"] == {
|
||
"root_message_id": "om-plan-choice-root",
|
||
"reply_to_message_id": "om-plan-choice-root",
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_continues_same_scope_requirement_plan_without_reply_anchor(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"same-scope requirement followup should bypass heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "第三轮:技术路线\n\nA. 飞书 bot + 公开数据\nB. RAG 增强分析\nC. 信号流编排",
|
||
"rag_result": {
|
||
"answer": "第三轮:技术路线\n\nA. 飞书 bot + 公开数据\nB. RAG 增强分析\nC. 信号流编排",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-same-scope-plan",
|
||
source_sender_open_id="ou-same-scope-plan",
|
||
sender=None,
|
||
)
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-same-scope-plan:root:om-same-scope-plan-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-same-scope-plan",
|
||
conversation_scope_key=conversation_scope_key,
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True},
|
||
facts_json={"title": "自动给每日大A趋势打分的小工具"},
|
||
)
|
||
requirement_plan_db_session.add(topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "自动给每日大A趋势打分的小工具",
|
||
"goal": "先收第一版需求,不创建项目。",
|
||
"project_nature": "日报简报流",
|
||
"target_user": "投研小群",
|
||
"question_round": 2,
|
||
"last_round_options": {
|
||
"field": "target_user",
|
||
"options": [
|
||
{"key": "A", "title": "个人盯盘者"},
|
||
{"key": "B", "title": "投研小群"},
|
||
{"key": "C", "title": "决策会议组"},
|
||
],
|
||
"recommendation": {"key": "B"},
|
||
},
|
||
"round_option_maps": {
|
||
"target_user": {
|
||
"A": "个人盯盘者",
|
||
"B": "投研小群",
|
||
"C": "决策会议组",
|
||
}
|
||
},
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-same-scope-plan",
|
||
"source_sender_open_id": "ou-same-scope-plan",
|
||
"message_intent": "query.answer",
|
||
"workflow_route": "query.answer",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="选B,固定成员,继续往下收。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-same-scope-plan-3",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "选B,固定成员,继续往下收。"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-same-scope-plan"
|
||
assert direct_kwargs["thread_context"] is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_converts_query_answer_requirement_contract_into_requirement_plan_result(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
captured["rag_kwargs"] = dict(kwargs)
|
||
return {
|
||
"answer": (
|
||
"你聊的是「自动给每日大A趋势打分的小工具」,目标是先收敛第一版需求,不创建项目、不执行。\n\n"
|
||
"第一轮收敛:项目性质?\n"
|
||
"A. 日报简报流:固定指标 + 简单打分逻辑\n"
|
||
"B. 可调分析流:支持切换打分模型和自定义权重\n"
|
||
"C. 决策辅助流:对接交易信号库或策略回测结果\n\n"
|
||
"⭐ 当前我更推荐:A,原因:最匹配“先不创建项目”的轻量启动方式。"
|
||
),
|
||
"query_type": "explain",
|
||
}
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "可以,我们先不创建项目,先把这件事收成一版需求。\n\n第一轮收敛:项目性质?",
|
||
"rag_result": {
|
||
"answer": "可以,我们先不创建项目,先把这件事收成一版需求。\n\n第一轮收敛:项目性质?",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-query-contract",
|
||
"source_sender_open_id": "ou-query-contract",
|
||
"message_intent": "query.answer",
|
||
"workflow_route": "query.answer",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="@PMFlow 做一个自动给每日大A趋势打分的小工具,先不创建项目,只收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-query-contract-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
assert isinstance(captured.get("rag_kwargs"), dict)
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "@PMFlow 做一个自动给每日大A趋势打分的小工具,先不创建项目,只收第一版。"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-query-contract"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_converts_new_requirement_contract_into_new_topic_when_old_draft_pending_confirm(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
captured["rag_kwargs"] = dict(kwargs)
|
||
return {
|
||
"answer": (
|
||
"你想先收一个会议室投屏故障晨报助手的第一版需求,不创建项目、只做需求收敛。\n\n"
|
||
"第一轮收敛:项目性质?\n"
|
||
"A. 轻量晨报工具:汇总投屏失败并准点推送\n"
|
||
"B. 运维协同台:支持IT与行政共用处置看板\n"
|
||
"C. 能力底座:沉淀会议室故障规则与扩展接口\n\n"
|
||
"⭐ 当前我更推荐:A,原因:最符合‘先不创建项目’和快速验证诉求。"
|
||
),
|
||
"query_type": "explain",
|
||
}
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "可以,我们先把会议室投屏故障晨报助手收成一版需求。",
|
||
"rag_result": {
|
||
"answer": "可以,我们先把会议室投屏故障晨报助手收成一版需求。",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
_install_requirement_plan_query_bundle(
|
||
monkeypatch,
|
||
message_intent="query.answer",
|
||
consultant_mode_preference="",
|
||
)
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-query-contract-shadow",
|
||
source_sender_open_id="ou-query-contract-shadow",
|
||
sender=None,
|
||
)
|
||
old_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-query-contract-shadow:root:om-old-requirement-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-query-contract-shadow",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="om-old-requirement-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="draft_pending_confirm",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(old_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(old_topic.id),
|
||
state="draft_pending_confirm",
|
||
draft_version=2,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "门店促销价签异常预警助手",
|
||
"goal": "先把价签异常预警助手的首版需求收敛清楚。",
|
||
"project_nature": "轻量自动化工具",
|
||
"target_user": "大区督导",
|
||
"tech_route": "规则引擎+人工复核看板",
|
||
"first_scope": "最小闭环:仅覆盖高风险价签",
|
||
"delivery_mode": "项目级试点(带基础审批和回滚)",
|
||
},
|
||
draft_payload={"title": "门店促销价签异常预警助手"},
|
||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-query-contract-shadow",
|
||
"source_sender_open_id": "ou-query-contract-shadow",
|
||
"message_intent": "query.answer",
|
||
"workflow_route": "query.answer",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="@PMFlow 真实飞书验真V4:做一个会议室投屏故障晨报助手,想每天自动汇总会议室投屏失败并发给行政IT,先不创建项目,只收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-query-contract-shadow-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["topic_switch"]) == "new"
|
||
assert str(direct_kwargs["message_text"]) == (
|
||
"@PMFlow 真实飞书验真V4:做一个会议室投屏故障晨报助手,想每天自动汇总会议室投屏失败并发给行政IT,先不创建项目,只收第一版。"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_treats_consult_requirement_structured_discovery_as_requirement_plan(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"consult requirement discovery should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"rag_result": {
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-requirement-discovery",
|
||
"source_sender_open_id": "ou-requirement-discovery",
|
||
"message_intent": "consult_requirement",
|
||
"consultant_mode_preference": "structured_discovery",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="跟你聊个需求,关于每日自动部署工单数据功能发送给业务。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-requirement-discovery-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "跟你聊个需求,关于每日自动部署工单数据功能发送给业务。"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-requirement-discovery"
|
||
assert "Feature Catalog" not in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_treats_requirement_gathering_stepwise_convergent_as_requirement_plan(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"requirement gathering should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"rag_result": {
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-requirement-gathering",
|
||
"source_sender_open_id": "ou-requirement-gathering",
|
||
"message_intent": "requirement_gathering",
|
||
"consultant_mode_preference": "stepwise_convergent",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="跟你聊个需求,关于每日自动部署工单数据功能发送给业务,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-requirement-gathering-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "跟你聊个需求,关于每日自动部署工单数据功能发送给业务,先别创建,先收第一版。"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-requirement-gathering"
|
||
assert "Feature Catalog" not in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_treats_consult_requirement_stepwise_plan_first_as_requirement_plan(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"stepwise plan-first should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"rag_result": {
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-consult-stepwise-plan-first",
|
||
"source_sender_open_id": "ou-consult-stepwise-plan-first",
|
||
"message_intent": "consult_requirement",
|
||
"consultant_mode_preference": "stepwise_plan_first",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="跟你聊个需求,关于每日自动部署工单数据功能发送给业务,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-consult-stepwise-plan-first-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "跟你聊个需求,关于每日自动部署工单数据功能发送给业务,先别创建,先收第一版。"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-consult-stepwise-plan-first"
|
||
assert "Feature Catalog" not in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_treats_consult_requirement_lightweight_plan_first_as_requirement_plan(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"lightweight plan-first should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"rag_result": {
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-consult-lightweight-plan-first",
|
||
"source_sender_open_id": "ou-consult-lightweight-plan-first",
|
||
"message_intent": "consult_requirement",
|
||
"consultant_mode_preference": "lightweight_plan_first",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="再聊个新需求,关于每日自动汇总门店库存异常发给区域经理,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-consult-lightweight-plan-first-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "再聊个新需求,关于每日自动汇总门店库存异常发给区域经理,先别创建,先收第一版。"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-consult-lightweight-plan-first"
|
||
assert "Feature Catalog" not in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_treats_consult_requirement_structured_analysis_first_as_requirement_plan(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"structured analysis-first should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"rag_result": {
|
||
"answer": "可以,我们先不创建,先把这件事收成一版需求。",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-consult-structured-analysis-first",
|
||
"source_sender_open_id": "ou-consult-structured-analysis-first",
|
||
"message_intent": "consult_requirement",
|
||
"consultant_mode_preference": "structured_analysis_first",
|
||
"topic_switch": "new",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="跟你聊个需求,关于每周自动汇总销售线索跟进状态发给销售经理,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-consult-structured-analysis-first-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "跟你聊个需求,关于每周自动汇总销售线索跟进状态发给销售经理,先别创建,先收第一版。"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-consult-structured-analysis-first"
|
||
assert "Feature Catalog" not in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_promotes_requirement_followup_from_structured_round_context_without_render_markers(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"structured requirement followup should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_planning_direct_result(self, **kwargs): # noqa: ANN001
|
||
_ = self
|
||
captured["direct_kwargs"] = dict(kwargs)
|
||
return {
|
||
"status": "succeeded",
|
||
"answer": "我先按主播协同作战平台这条需求继续收范围。",
|
||
"rag_result": {
|
||
"answer": "我先按主播协同作战平台这条需求继续收范围。",
|
||
"query_type": "plan",
|
||
},
|
||
"reply_template_key": "requirement_plan_followup",
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_planning_direct_result",
|
||
_fake_build_requirement_planning_direct_result,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
chat_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-choice-structured:root:om-plan-choice-structured-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-choice-structured",
|
||
conversation_scope_key=build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-choice-structured",
|
||
source_sender_open_id="ou-plan-choice-structured",
|
||
sender=None,
|
||
),
|
||
root_message_id="om-plan-choice-structured-root",
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="idle",
|
||
metadata_json={"last_message_preview": "C"},
|
||
)
|
||
requirement_plan_db_session.add(chat_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add_all(
|
||
[
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-plan-choice-structured-1",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-choice-structured-1",
|
||
status="completed",
|
||
final_reply="我建议先往主播协同作战平台这个方向继续收。",
|
||
execution_outcome={
|
||
"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。",
|
||
"rag_result": {
|
||
"query_type": "plan",
|
||
"sources": [
|
||
{
|
||
"source": "system",
|
||
"label": "Mounted Runtime Plan",
|
||
"score": 0.92,
|
||
"metadata": {
|
||
"mode": "mounted_runtime_direct_query",
|
||
"reason_code": "requirement_planning_direct",
|
||
},
|
||
}
|
||
],
|
||
},
|
||
"requirement_plan_context": {
|
||
"round_option_maps": {
|
||
"project_nature": {
|
||
"A": "主播培训系统",
|
||
"B": "主播数字分身工厂",
|
||
"C": "主播协同作战平台",
|
||
}
|
||
},
|
||
"last_round_options": {
|
||
"field": "project_nature",
|
||
"options": [
|
||
{"key": "A", "title": "主播培训系统"},
|
||
{"key": "B", "title": "主播数字分身工厂"},
|
||
{"key": "C", "title": "主播协同作战平台"},
|
||
],
|
||
"recommendation": {"key": "C"},
|
||
},
|
||
},
|
||
},
|
||
),
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-plan-choice-structured-2",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-choice-structured-2",
|
||
status="completed",
|
||
final_reply="收到,我先继续。",
|
||
execution_outcome={"source_message_preview": "C"},
|
||
),
|
||
]
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-choice-structured",
|
||
"source_sender_open_id": "ou-plan-choice-structured",
|
||
"message_intent": "continue_contextual_qa",
|
||
"thread_context": {
|
||
"root_message_id": "om-plan-choice-structured-root",
|
||
"reply_to_message_id": "om-plan-choice-structured-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="三个问题都有",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-choice-structured-3",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["rag_result"]["query_type"]) == "plan"
|
||
direct_kwargs = captured.get("direct_kwargs")
|
||
assert isinstance(direct_kwargs, dict)
|
||
assert str(direct_kwargs["message_text"]) == "三个问题都有"
|
||
assert str(direct_kwargs["source_chat_id"]) == "chat-plan-choice-structured"
|
||
assert direct_kwargs["thread_context"] == {
|
||
"root_message_id": "om-plan-choice-structured-root",
|
||
"reply_to_message_id": "om-plan-choice-structured-root",
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("choice_message", ["C", "我选C", "选C", "就C吧", "先按C"])
|
||
async def test_execute_query_intent_reply_choice_uses_previous_round_option_map_instead_of_it_defaults(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
choice_message: str,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"requirement plan choice reply should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_plan_next_question(
|
||
self,
|
||
*,
|
||
missing_field: str,
|
||
profile: str,
|
||
message_text: str = "",
|
||
card: dict[str, object] | None = None,
|
||
latest_updates: dict[str, object] | None = None,
|
||
) -> str:
|
||
_ = (self, profile, message_text, card, latest_updates)
|
||
assert missing_field == "tech_route"
|
||
return "下一轮先说清楚:第一版更希望优先补开播前脚本、直播中应变,还是下播后复盘?"
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_plan_next_question",
|
||
_fake_build_requirement_plan_next_question,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
chat_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-choice-map:root:om-choice-map-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-choice-map",
|
||
conversation_scope_key=build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-choice-map",
|
||
source_sender_open_id="ou-choice-map",
|
||
sender=None,
|
||
),
|
||
root_message_id="om-choice-map-root",
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="idle",
|
||
metadata_json={"last_message_preview": "A/B/C 需求轮次"},
|
||
)
|
||
requirement_plan_db_session.add(chat_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-choice-map-1",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-choice-map-1",
|
||
status="completed",
|
||
final_reply=(
|
||
"你提到的是给运营公司打造主播这个新需求。\n"
|
||
"第一轮收敛:这是什么性质的项目?\n\n"
|
||
"A. 主播培训系统\n"
|
||
"B. 主播数字分身工厂\n"
|
||
"C. 主播协同作战平台\n\n"
|
||
"⭐ 当前我更推荐:C\n"
|
||
"你回 A / B / C,我就顺着这条继续往下收。"
|
||
),
|
||
execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"},
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-choice-map",
|
||
"source_sender_open_id": "ou-choice-map",
|
||
"message_intent": "continue_contextual_qa",
|
||
"thread_context": {
|
||
"root_message_id": "om-choice-map-root",
|
||
"reply_to_message_id": "om-choice-map-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text=choice_message,
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-choice-map-2",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
answer = str(result["answer"] or "")
|
||
assert "主播协同作战平台" in answer
|
||
assert "项目交付团队" not in answer
|
||
assert "云原生交付底座" not in answer
|
||
draft_row = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace).order_by(MountedDraftWorkspace.id.desc())
|
||
)
|
||
).scalar_one()
|
||
assert isinstance(draft_row.requirement_card, dict)
|
||
assert draft_row.requirement_card["project_nature"] == "主播协同作战平台"
|
||
assert draft_row.requirement_card["target_user"] == "运营公司"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_reply_choice_confirmation_phrase_uses_previous_round_option_map(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"requirement plan choice reply should not enter heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_plan_next_question(
|
||
self,
|
||
*,
|
||
missing_field: str,
|
||
profile: str,
|
||
message_text: str = "",
|
||
card: dict[str, object] | None = None,
|
||
latest_updates: dict[str, object] | None = None,
|
||
) -> str:
|
||
_ = (self, profile, message_text, card, latest_updates)
|
||
assert missing_field == "tech_route"
|
||
return "下一轮先说清楚:第一版更希望优先补开播前脚本、直播中应变,还是下播后复盘?"
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_plan_next_question",
|
||
_fake_build_requirement_plan_next_question,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
chat_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-choice-confirm-map:root:om-choice-confirm-map-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-choice-confirm-map",
|
||
conversation_scope_key=build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-choice-confirm-map",
|
||
source_sender_open_id="ou-choice-confirm-map",
|
||
sender=None,
|
||
),
|
||
root_message_id="om-choice-confirm-map-root",
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="idle",
|
||
metadata_json={"last_message_preview": "A/B/C 需求轮次"},
|
||
)
|
||
requirement_plan_db_session.add(chat_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-choice-confirm-map-1",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-choice-confirm-map-1",
|
||
status="completed",
|
||
final_reply=(
|
||
"你提到的是给运营公司打造主播这个新需求。\n"
|
||
"第一轮收敛:这是什么性质的项目?\n\n"
|
||
"A. 主播培训系统\n"
|
||
"B. 主播数字分身工厂\n"
|
||
"C. 主播协同作战平台\n\n"
|
||
"⭐ 当前我更推荐:C\n"
|
||
"你回 A / B / C,我就顺着这条继续往下收。"
|
||
),
|
||
execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"},
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-choice-confirm-map",
|
||
"source_sender_open_id": "ou-choice-confirm-map",
|
||
"message_intent": "continue_contextual_qa",
|
||
"thread_context": {
|
||
"root_message_id": "om-choice-confirm-map-root",
|
||
"reply_to_message_id": "om-choice-confirm-map-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="确认A,继续往下收。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-choice-confirm-map-2",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
answer = str(result["answer"] or "")
|
||
assert "主播培训系统" in answer
|
||
assert "主播协同作战平台" not in answer
|
||
draft_row = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace).order_by(MountedDraftWorkspace.id.desc())
|
||
)
|
||
).scalar_one()
|
||
assert isinstance(draft_row.requirement_card, dict)
|
||
assert draft_row.requirement_card["project_nature"] == "主播培训系统"
|
||
assert draft_row.requirement_card["target_user"] == "运营公司"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_low_information_choice_without_round_option_map_fails_explicitly(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"low-information choice reply should not fall back to heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-choice-missing-map",
|
||
source_sender_open_id="ou-choice-missing-map",
|
||
sender=None,
|
||
)
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-choice-missing-map:root:om-choice-missing-map-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-choice-missing-map",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="om-choice-missing-map-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(topic)
|
||
await requirement_plan_db_session.flush()
|
||
draft = MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "给运营公司打造主播",
|
||
"goal": "先收敛业务方向",
|
||
"target_user": "运营公司",
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
requirement_plan_db_session.add(draft)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
result = await executor._build_requirement_planning_direct_result(
|
||
message_text="我选C",
|
||
source_system="feishu",
|
||
source_message_id="msg-choice-missing-map-1",
|
||
source_chat_id="chat-choice-missing-map",
|
||
sender=None,
|
||
source_sender_open_id="ou-choice-missing-map",
|
||
thread_context={
|
||
"root_message_id": "om-choice-missing-map-root",
|
||
"reply_to_message_id": "om-choice-missing-map-root",
|
||
},
|
||
conversation_key=conversation_scope_key,
|
||
topic_switch=None,
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert "我没拿到上一轮 A/B/C 选项表" in str(result["answer"] or "")
|
||
assert "云原生交付底座" not in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_build_requirement_planning_direct_result_emits_structured_requirement_plan_context(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_plan_next_question(
|
||
self,
|
||
*,
|
||
missing_field: str,
|
||
profile: str,
|
||
message_text: str = "",
|
||
card: dict[str, object] | None = None,
|
||
latest_updates: dict[str, object] | None = None,
|
||
) -> str:
|
||
_ = (self, profile, message_text, latest_updates)
|
||
assert missing_field == "target_user"
|
||
assert isinstance(card, dict)
|
||
card["round_option_maps"] = {
|
||
**(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}),
|
||
"target_user": {
|
||
"A": "单一层级区域经理",
|
||
"B": "区域经理 + 店长",
|
||
"C": "总部 + 区域 + 门店多层级",
|
||
},
|
||
}
|
||
card["last_round_options"] = {
|
||
"field": "target_user",
|
||
"options": [
|
||
{"key": "A", "title": "单一层级区域经理"},
|
||
{"key": "B", "title": "区域经理 + 店长"},
|
||
{"key": "C", "title": "总部 + 区域 + 门店多层级"},
|
||
],
|
||
"recommendation": {"key": "A"},
|
||
}
|
||
return "第三轮:服务对象\n\nA. 单一层级区域经理\nB. 区域经理 + 店长\nC. 总部 + 区域 + 门店多层级"
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_plan_next_question",
|
||
_fake_build_requirement_plan_next_question,
|
||
raising=False,
|
||
)
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-context-snapshot",
|
||
source_sender_open_id="ou-plan-context-snapshot",
|
||
sender=None,
|
||
)
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-context-snapshot:root:om-plan-context-snapshot-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-context-snapshot",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="om-plan-context-snapshot-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(topic)
|
||
await requirement_plan_db_session.flush()
|
||
draft = MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "门店库存异常日报助手",
|
||
"goal": "先把日报助手的第一版需求收敛清楚。",
|
||
"project_nature": "轻量定时简报",
|
||
"question_round": 1,
|
||
"round_option_maps": {
|
||
"project_nature": {
|
||
"A": "轻量定时简报",
|
||
"B": "可交互看板简报",
|
||
"C": "AI 增强简报",
|
||
}
|
||
},
|
||
"last_round_options": {
|
||
"field": "project_nature",
|
||
"options": [
|
||
{"key": "A", "title": "轻量定时简报"},
|
||
{"key": "B", "title": "可交互看板简报"},
|
||
{"key": "C", "title": "AI 增强简报"},
|
||
],
|
||
"recommendation": {"key": "A"},
|
||
},
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
requirement_plan_db_session.add(draft)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
result = await executor._build_requirement_planning_direct_result(
|
||
message_text="确认A,继续往下收。",
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-context-snapshot-2",
|
||
source_chat_id="chat-plan-context-snapshot",
|
||
sender=None,
|
||
source_sender_open_id="ou-plan-context-snapshot",
|
||
thread_context={
|
||
"root_message_id": "om-plan-context-snapshot-root",
|
||
"reply_to_message_id": "om-plan-context-snapshot-root",
|
||
},
|
||
conversation_key=conversation_scope_key,
|
||
topic_switch=None,
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
requirement_plan_context = result.get("requirement_plan_context")
|
||
assert isinstance(requirement_plan_context, dict)
|
||
assert requirement_plan_context["question_round"] == 2
|
||
assert requirement_plan_context["last_round_options"]["field"] == "target_user"
|
||
assert requirement_plan_context["round_option_maps"]["target_user"]["A"] == "单一层级区域经理"
|
||
assert "第三轮:服务对象" in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_repair_requirement_plan_workspace_from_reply_context_prefers_semantic_reply_snapshot(
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-context-repair",
|
||
source_sender_open_id="ou-plan-context-repair",
|
||
sender=None,
|
||
)
|
||
topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-context-repair:message:om-plan-context-repair-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-context-repair",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="om-plan-context-repair-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(topic)
|
||
await requirement_plan_db_session.flush()
|
||
draft = MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "需求收敛草稿",
|
||
"goal": "先把 IT 项目的第一版目标、技术路线和推进方式收清楚,再决定是否正式创建。",
|
||
"target_user": "轻量工具型",
|
||
"question_round": 1,
|
||
"round_option_maps": {
|
||
"project_nature": {
|
||
"A": "轻量工具型",
|
||
"B": "业务平台型",
|
||
"C": "能力底座型",
|
||
}
|
||
},
|
||
"last_round_options": {
|
||
"field": "project_nature",
|
||
"options": [
|
||
{"key": "A", "title": "轻量工具型"},
|
||
{"key": "B", "title": "业务平台型"},
|
||
{"key": "C", "title": "能力底座型"},
|
||
],
|
||
"recommendation": {"key": "A"},
|
||
},
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
requirement_plan_db_session.add(draft)
|
||
requirement_plan_db_session.add_all(
|
||
[
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-plan-context-repair-0",
|
||
topic_workspace_id=int(topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-context-repair-0",
|
||
status="completed",
|
||
final_reply=(
|
||
"你希望每日自动汇总门店库存异常并发给区域经理,当前确认走「轻量定时简报」路线。\n\n"
|
||
"第二轮:服务对象\n"
|
||
"A. 单一区域经理\n"
|
||
"B. 区域经理 + 店长\n"
|
||
"C. 总部 + 区域 + 门店多层级"
|
||
),
|
||
execution_outcome={
|
||
"source_message_preview": "做一个门店库存异常日报助手,想每日自动汇总门店库存异常并发给区域经理,先不创建项目,只收第一版。",
|
||
"rag_result": {
|
||
"query_type": "plan",
|
||
"sources": [
|
||
{
|
||
"source": "system",
|
||
"metadata": {"reason_code": "requirement_planning_direct"},
|
||
}
|
||
],
|
||
},
|
||
},
|
||
),
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-plan-context-repair-1",
|
||
topic_workspace_id=int(topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-context-repair-1",
|
||
status="completed",
|
||
final_reply="收到,确认A方向,继续收敛项目性质。",
|
||
execution_outcome={
|
||
"source_message_preview": "确认A,继续往下收。",
|
||
"rag_result": {
|
||
"query_type": "plan",
|
||
"sources": [
|
||
{
|
||
"source": "system",
|
||
"metadata": {"reason_code": "requirement_planning_direct"},
|
||
}
|
||
],
|
||
},
|
||
"semantic_decision": {
|
||
"assistant_reply": (
|
||
"已确认选项 A(单一层级区域经理),继续收第二轮关键信息 👇\n\n"
|
||
"→ 第三轮:目标用户层级\n"
|
||
"A. 单一区域经理\n"
|
||
"B. 区域经理 + 店长\n"
|
||
"C. 总部 + 区域 + 门店多层级"
|
||
),
|
||
},
|
||
"semantic_gate_result": {"accepted": True},
|
||
},
|
||
),
|
||
]
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
repaired_topic, repaired_draft = await executor._repair_requirement_plan_workspace_from_reply_context(
|
||
topic=topic,
|
||
draft=draft,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-context-repair-2",
|
||
source_chat_id="chat-plan-context-repair",
|
||
source_sender_open_id="ou-plan-context-repair",
|
||
thread_context={
|
||
"root_message_id": "om-plan-context-repair-root",
|
||
"reply_to_message_id": "om-plan-context-repair-root",
|
||
},
|
||
conversation_key=conversation_scope_key,
|
||
message_text="确认A,继续往下收。",
|
||
)
|
||
|
||
assert repaired_topic is topic
|
||
assert repaired_draft is draft
|
||
assert draft.requirement_card["project_nature"] == "轻量定时简报"
|
||
assert draft.requirement_card["target_user"] == "单一区域经理"
|
||
assert draft.requirement_card["last_round_options"]["field"] == "target_user"
|
||
assert draft.requirement_card["round_option_maps"]["target_user"]["A"] == "单一区域经理"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_build_requirement_planning_direct_result_forces_new_topic_when_same_scope_identity_conflicts(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_plan_next_question(
|
||
self,
|
||
*,
|
||
missing_field: str,
|
||
profile: str,
|
||
message_text: str = "",
|
||
card: dict[str, object] | None = None,
|
||
latest_updates: dict[str, object] | None = None,
|
||
) -> str:
|
||
_ = (self, profile, message_text, latest_updates)
|
||
assert isinstance(card, dict)
|
||
card["round_option_maps"] = {
|
||
**(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}),
|
||
str(missing_field): {
|
||
"A": "轻量晨报工具",
|
||
"B": "业务平台入口",
|
||
"C": "能力底座组件",
|
||
},
|
||
}
|
||
card["last_round_options"] = {
|
||
"field": str(missing_field),
|
||
"options": [
|
||
{"key": "A", "title": "轻量晨报工具"},
|
||
{"key": "B", "title": "业务平台入口"},
|
||
{"key": "C", "title": "能力底座组件"},
|
||
],
|
||
"recommendation": {"key": "A"},
|
||
}
|
||
return "第一轮:项目性质\n\nA. 轻量晨报工具\nB. 业务平台入口\nC. 能力底座组件"
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_plan_next_question",
|
||
_fake_build_requirement_plan_next_question,
|
||
raising=False,
|
||
)
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-same-scope-conflict",
|
||
source_sender_open_id="ou-plan-same-scope-conflict",
|
||
sender=None,
|
||
)
|
||
old_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-same-scope-conflict:message:om-old-plan-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-same-scope-conflict",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id=None,
|
||
last_message_id="om-old-plan-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(old_topic)
|
||
await requirement_plan_db_session.flush()
|
||
old_draft = MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(old_topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "直播复盘辅助工具",
|
||
"goal": "先把直播复盘辅助工具的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。",
|
||
"project_nature": "轻量工具",
|
||
"target_user": "运营团队",
|
||
"question_round": 3,
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
requirement_plan_db_session.add(old_draft)
|
||
|
||
current_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-same-scope-conflict:message:msg-plan-same-scope-conflict-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-same-scope-conflict",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id=None,
|
||
last_message_id="msg-plan-same-scope-conflict-root",
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="idle",
|
||
metadata_json={},
|
||
)
|
||
requirement_plan_db_session.add(current_topic)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
result = await executor._build_requirement_planning_direct_result(
|
||
message_text="正式验真T9A-0410:做一个给区域经理用的门店库存异常晨报助手,只收第一版需求,不要直接创建项目。第一版先看负库存和超阈值缺货,每天自动汇总并发给区域经理。",
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-same-scope-conflict-root",
|
||
source_chat_id="chat-plan-same-scope-conflict",
|
||
sender=None,
|
||
source_sender_open_id="ou-plan-same-scope-conflict",
|
||
thread_context=None,
|
||
conversation_key=conversation_scope_key,
|
||
topic_switch=None,
|
||
)
|
||
|
||
await requirement_plan_db_session.flush()
|
||
current_draft = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace).where(
|
||
MountedDraftWorkspace.topic_workspace_id == int(current_topic.id)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
assert result["status"] == "succeeded"
|
||
requirement_plan_context = result.get("requirement_plan_context")
|
||
assert isinstance(requirement_plan_context, dict)
|
||
assert requirement_plan_context["title"] == "给区域经理用的门店库存异常晨报助手"
|
||
assert requirement_plan_context["target_user"] == "区域经理"
|
||
assert current_draft is not None
|
||
assert current_draft.requirement_card["title"] == "给区域经理用的门店库存异常晨报助手"
|
||
assert old_draft.requirement_card["title"] == "直播复盘辅助工具"
|
||
assert int(current_draft.topic_workspace_id) == int(current_topic.id)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_build_requirement_planning_direct_result_prefers_reply_anchor_topic_over_same_scope_sibling(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"reply-anchor requirement followup should bypass heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_build_requirement_plan_next_question(
|
||
self,
|
||
*,
|
||
missing_field: str,
|
||
profile: str,
|
||
message_text: str = "",
|
||
card: dict[str, object] | None = None,
|
||
latest_updates: dict[str, object] | None = None,
|
||
) -> str:
|
||
_ = (self, profile, message_text, latest_updates)
|
||
assert missing_field == "tech_route"
|
||
assert isinstance(card, dict)
|
||
assert str(card.get("title") or "") == "门店库存异常晨报助手"
|
||
assert str(card.get("project_nature") or "") == "轻量晨报工具"
|
||
card["round_option_maps"] = {
|
||
**(dict(card.get("round_option_maps") or {}) if isinstance(card.get("round_option_maps"), dict) else {}),
|
||
"tech_route": {
|
||
"A": "飞书消息 + SQL 晨报",
|
||
"B": "轻量看板 + 异常下钻",
|
||
"C": "规则引擎 + 多源归因平台",
|
||
},
|
||
}
|
||
card["last_round_options"] = {
|
||
"field": "tech_route",
|
||
"options": [
|
||
{"key": "A", "title": "飞书消息 + SQL 晨报"},
|
||
{"key": "B", "title": "轻量看板 + 异常下钻"},
|
||
{"key": "C", "title": "规则引擎 + 多源归因平台"},
|
||
],
|
||
"recommendation": {"key": "A"},
|
||
}
|
||
return "第三轮:技术路线\n\nA. 飞书消息 + SQL 晨报\nB. 轻量看板 + 异常下钻\nC. 规则引擎 + 多源归因平台"
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_build_requirement_plan_next_question",
|
||
_fake_build_requirement_plan_next_question,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-anchor-priority",
|
||
source_sender_open_id="ou-plan-anchor-priority",
|
||
sender=None,
|
||
)
|
||
target_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-anchor-priority:message:om-plan-anchor-target-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-anchor-priority",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id=None,
|
||
last_message_id="om-plan-anchor-target-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(target_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(target_topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "门店库存异常晨报助手",
|
||
"goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。",
|
||
"target_user": "区域经理",
|
||
"question_round": 1,
|
||
"round_option_maps": {
|
||
"project_nature": {
|
||
"A": "轻量晨报工具",
|
||
"B": "业务平台入口",
|
||
"C": "能力底座组件",
|
||
}
|
||
},
|
||
"last_round_options": {
|
||
"field": "project_nature",
|
||
"options": [
|
||
{"key": "A", "title": "轻量晨报工具"},
|
||
{"key": "B", "title": "业务平台入口"},
|
||
{"key": "C", "title": "能力底座组件"},
|
||
],
|
||
"recommendation": {"key": "A"},
|
||
},
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
)
|
||
sibling_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-anchor-priority:message:om-plan-anchor-sibling-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-anchor-priority",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id=None,
|
||
last_message_id="om-plan-anchor-sibling-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="options_ready",
|
||
metadata_json={"plan_mode": True},
|
||
facts_json={"title": "直播复盘辅助工具"},
|
||
)
|
||
requirement_plan_db_session.add(sibling_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(sibling_topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "直播复盘辅助工具",
|
||
"goal": "先把直播复盘辅助工具的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。",
|
||
"project_nature": "轻量工具",
|
||
"target_user": "运营团队",
|
||
"tech_route": "端侧轻量AI处理",
|
||
"question_round": 3,
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
result = await executor._build_requirement_planning_direct_result(
|
||
message_text="A,先按轻量晨报工具继续往下收。",
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-anchor-followup",
|
||
source_chat_id="chat-plan-anchor-priority",
|
||
sender=None,
|
||
source_sender_open_id="ou-plan-anchor-priority",
|
||
thread_context={"reply_to_message_id": "om-plan-anchor-target-root"},
|
||
conversation_key=conversation_scope_key,
|
||
topic_switch=None,
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
requirement_plan_context = result.get("requirement_plan_context")
|
||
assert isinstance(requirement_plan_context, dict)
|
||
assert requirement_plan_context["title"] == "门店库存异常晨报助手"
|
||
assert requirement_plan_context["project_nature"] == "轻量晨报工具"
|
||
assert requirement_plan_context["last_round_options"]["field"] == "tech_route"
|
||
assert "第三轮:技术路线" in str(result["answer"] or "")
|
||
assert "直播复盘辅助工具" not in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_repair_requirement_plan_workspace_from_reply_context_blocks_sibling_topic_injection(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-plan-cross-topic-repair",
|
||
source_sender_open_id="ou-plan-cross-topic-repair",
|
||
sender=None,
|
||
)
|
||
target_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-cross-topic-repair:message:om-plan-cross-topic-target-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-cross-topic-repair",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id=None,
|
||
last_message_id="om-plan-cross-topic-target-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="collecting",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(target_topic)
|
||
await requirement_plan_db_session.flush()
|
||
target_draft = MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(target_topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "门店库存异常晨报助手",
|
||
"goal": "先把门店库存异常晨报助手的第一版需求收敛清楚。",
|
||
"project_nature": "轻量晨报工具",
|
||
"target_user": "区域经理",
|
||
"question_round": 2,
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
)
|
||
requirement_plan_db_session.add(target_draft)
|
||
|
||
sibling_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-plan-cross-topic-repair:message:om-plan-cross-topic-sibling-root",
|
||
source_system="feishu",
|
||
channel_ref="chat-plan-cross-topic-repair",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id=None,
|
||
last_message_id="om-plan-cross-topic-sibling-root",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="options_ready",
|
||
metadata_json={"plan_mode": True},
|
||
)
|
||
requirement_plan_db_session.add(sibling_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add_all(
|
||
[
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(sibling_topic.id),
|
||
state="collecting",
|
||
draft_version=0,
|
||
requirement_card={
|
||
"plan_profile": "it_project",
|
||
"title": "直播复盘辅助工具",
|
||
"goal": "先把直播复盘辅助工具的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。",
|
||
"project_nature": "轻量工具",
|
||
"target_user": "运营团队",
|
||
"tech_route": "端侧轻量AI处理",
|
||
"question_round": 4,
|
||
},
|
||
draft_payload=None,
|
||
required_confirmations=[],
|
||
),
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-plan-cross-topic-sibling-1",
|
||
topic_workspace_id=int(sibling_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-cross-topic-sibling-1",
|
||
status="completed",
|
||
final_reply=(
|
||
"好,我先按这个方向继续记:技术路线先按端侧轻量AI处理。\n\n"
|
||
"收到,按端侧轻量AI+运营晨报场景收敛第一版范围。\n"
|
||
"直播复盘辅助工具首版该包含哪些核心闭环?\n\n"
|
||
"A. 聚焦单场复盘速报\n"
|
||
"B. 加入观众行为轻洞察\n"
|
||
"C. 打通回放片段跳转\n\n"
|
||
"⭐ 当前我更推荐:A"
|
||
),
|
||
execution_outcome={
|
||
"source_message_preview": "A,先按轻量晨报工具继续往下收。",
|
||
"requirement_plan_context": {
|
||
"plan_profile": "it_project",
|
||
"title": "直播复盘辅助工具",
|
||
"goal": "先把直播复盘辅助工具的目标、服务对象、第一版范围和交付方式收清楚,再决定是否正式创建。",
|
||
"project_nature": "轻量工具",
|
||
"target_user": "运营团队",
|
||
"tech_route": "端侧轻量AI处理",
|
||
"question_round": 4,
|
||
},
|
||
"rag_result": {
|
||
"query_type": "plan",
|
||
"sources": [
|
||
{
|
||
"source": "system",
|
||
"metadata": {"reason_code": "requirement_planning_direct"},
|
||
}
|
||
],
|
||
},
|
||
},
|
||
),
|
||
]
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
|
||
async def _fake_find_contextual_reply_topic(self, **kwargs): # noqa: ANN001
|
||
_ = (self, kwargs)
|
||
return sibling_topic
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_find_contextual_reply_topic",
|
||
_fake_find_contextual_reply_topic,
|
||
raising=False,
|
||
)
|
||
|
||
repaired_topic, repaired_draft = await executor._repair_requirement_plan_workspace_from_reply_context(
|
||
topic=target_topic,
|
||
draft=target_draft,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-cross-topic-target-followup",
|
||
source_chat_id="chat-plan-cross-topic-repair",
|
||
source_sender_open_id="ou-plan-cross-topic-repair",
|
||
thread_context={"reply_to_message_id": "om-plan-cross-topic-target-root"},
|
||
conversation_key=conversation_scope_key,
|
||
message_text="A,先按轻量晨报工具继续往下收。",
|
||
)
|
||
|
||
assert repaired_topic is target_topic
|
||
assert repaired_draft is target_draft
|
||
assert target_draft.requirement_card["title"] == "门店库存异常晨报助手"
|
||
assert target_draft.requirement_card["target_user"] == "区域经理"
|
||
assert target_draft.requirement_card["project_nature"] == "轻量晨报工具"
|
||
assert "tech_route" not in target_draft.requirement_card
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_contextual_draft_generation_bootstraps_requirement_plan_from_reply_topic(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
raise AssertionError(f"contextual draft generation should bypass heavy RAG: {kwargs}")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001
|
||
function_name = str(kwargs.get("function_name") or "")
|
||
if function_name != "pmflow_requirement_plan_draft":
|
||
raise AssertionError(f"unexpected function_name: {function_name}")
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"intro": "📋 我先按当前上下文整理出一版主播协同作战平台草稿:",
|
||
"title": "给运营公司打造主播协同作战平台",
|
||
"one_liner": "先围绕开播前脚本、直播中应变、下播后复盘三类卡点,做一版真人主播+AI陪跑的协同平台草稿。",
|
||
"goal": "先验证主播在关键节点是否能得到更及时的脚本、应变和复盘支持。",
|
||
"project_nature": "主播协同作战平台",
|
||
"target_user": "运营公司主播团队",
|
||
"signal_inputs": "当前至少包含主播脚本准备、直播中应变、下播后复盘三类上下文信息。",
|
||
"known_info": "目前已经确认:需求围绕给运营公司打造主播能力;用户选择了 C 方向;且明确三类问题都会遇到。",
|
||
"tech_route": "先做真人主播+AI陪跑协同版,后续再补更重的实时分析能力。",
|
||
"first_scope": "第一版先保留开播前脚本辅助、直播中应变提示、下播后复盘整理三个闭环。",
|
||
"core_flow": "主播进入直播准备 -> 生成脚本与注意事项 -> 直播中接收应变提示 -> 下播后自动整理复盘 -> 沉淀可复用策略",
|
||
"delivery_mode": "先在单个主播团队内做试点陪跑。",
|
||
"success_signal": "先验证主播实际采用率、直播中应变响应率和复盘复用率。",
|
||
"risks": "主要风险在主播现场响应节奏、脚本质量和复盘结构化沉淀的稳定性。",
|
||
"pending_items": ["优先陪跑哪类主播", "直播平台接入方式", "复盘模板颗粒度"],
|
||
"closing": "如果你认可,我下一轮就按这版继续细化执行方案。",
|
||
"summary": "先把三类卡点做成一条可陪跑的主播协同闭环,再决定是否扩成更重的平台。",
|
||
"positioning_one_liner": "面向运营公司主播团队的真人+AI陪跑协同平台首版。",
|
||
"target_customers": "运营公司主播团队",
|
||
"external_signal_sources": [
|
||
{"name": "抖音开放平台", "purpose": "补直播事件与互动节奏数据", "phase": "首版优先接入"},
|
||
{"name": "快手服务商 API", "purpose": "补直播中场控和互动数据", "phase": "首版优先接入"},
|
||
{"name": "企业微信会话归档", "purpose": "补主播复盘与督导回流", "phase": "第二阶段接入"},
|
||
],
|
||
"requirement_items": [
|
||
{"section": "需求目标", "title": "先补三类卡点", "description": "覆盖开播前脚本、直播中应变、下播后复盘。"},
|
||
],
|
||
"missing_info": ["优先主播类型"],
|
||
"mvp_items": ["脚本辅助", "应变提示", "复盘整理"],
|
||
"team_setup": [{"role": "建议配置", "notes": "产品/运营 1 名、后端/AI 1 名、前端 1 名即可试点。"}],
|
||
"validation_metrics": {"product": ["采用率", "应变响应率", "复盘复用率"]},
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
|
||
async def _fake_recommend_requirement_plan_owner_for_draft(self, *, card: dict[str, object]): # noqa: ANN001
|
||
_ = self, card
|
||
return {
|
||
"user_id": 28,
|
||
"name": "赵陪跑",
|
||
"role": "AI 产品经理",
|
||
"workload_ratio": 32.0,
|
||
"reason": "当前更接近直播陪跑和信号编排场景,适合先负责首版草稿推进。",
|
||
"matched_skills": ["直播场景", "AI 编排"],
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.llm_service,
|
||
"generate_structured_object",
|
||
fake_generate_structured_object,
|
||
)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_recommend_requirement_plan_owner_for_draft",
|
||
_fake_recommend_requirement_plan_owner_for_draft,
|
||
raising=False,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
chat_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-anchor:root:om-anchor-reply",
|
||
source_system="feishu",
|
||
channel_ref="chat-anchor",
|
||
conversation_scope_key=build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-anchor",
|
||
source_sender_open_id="ou-anchor",
|
||
sender=None,
|
||
),
|
||
root_message_id="om-anchor-reply",
|
||
mode="chat",
|
||
status="active",
|
||
plan_status="idle",
|
||
metadata_json={"last_message_preview": "三个问题都有"},
|
||
)
|
||
requirement_plan_db_session.add(chat_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add_all(
|
||
[
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-anchor-1",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-anchor-1",
|
||
status="completed",
|
||
final_reply="你提到的是给运营公司打造主播这个新需求,我更推荐 C:主播协同作战平台。",
|
||
execution_outcome={"source_message_preview": "想跟你聊个新需求,关于给运营公司打造主播推流的需求。"},
|
||
),
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-anchor-2",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-anchor-2",
|
||
status="completed",
|
||
final_reply="咱们还没确认它到底要解决哪类业务卡点:是开播前脚本耗时、直播中突发问题不会接、还是下播后不知道优化啥?",
|
||
execution_outcome={"source_message_preview": "C"},
|
||
),
|
||
MountedConversationRun(
|
||
tenant_id=3,
|
||
trace_id="trace-anchor-3",
|
||
topic_workspace_id=int(chat_topic.id),
|
||
source_system="feishu",
|
||
source_message_id="msg-anchor-3",
|
||
status="completed",
|
||
final_reply="收到,我会按当前上下文继续。",
|
||
execution_outcome={"source_message_preview": "三个问题都有"},
|
||
),
|
||
]
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-anchor",
|
||
"source_sender_open_id": "ou-anchor",
|
||
"message_intent": "request_draft_generation_from_context",
|
||
"consultant_mode_preference": "plan_first",
|
||
"thread_context": {
|
||
"root_message_id": "om-anchor-reply",
|
||
"reply_to_message_id": "om-anchor-reply",
|
||
},
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="依据当前信息给我直接生成一版本草稿",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-contextual-draft-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert str(result["reply_template_key"]) == "draft_pending_confirm"
|
||
assert str(result["rag_result"]["query_type"]) == "draft"
|
||
assert "主播协同作战平台" in str(result["answer"] or "")
|
||
assert "三个闭环" in str(result["answer"] or "")
|
||
assert isinstance(result.get("draft_artifact"), dict)
|
||
assert bool(result["draft_artifact"].get("promote_to_confirmation")) is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_contextual_draft_generation_fails_explicitly_when_reply_context_missing(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"answer": "结论:草稿已生成,实际上这是普通 query.answer 的假成功。",
|
||
"query_type": "explain",
|
||
}
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-missing-anchor",
|
||
"source_sender_open_id": "ou-missing-anchor",
|
||
"message_intent": "request_draft_generation_from_context",
|
||
"consultant_mode_preference": "plan_first",
|
||
"thread_context": {
|
||
"root_message_id": "om-missing-anchor",
|
||
"reply_to_message_id": "om-missing-anchor",
|
||
},
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="依据当前信息给我直接生成一版本草稿",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-contextual-draft-missing",
|
||
)
|
||
|
||
assert result["status"] == "failed_safe"
|
||
assert "当前上下文" in str(result["error_message"] or "")
|
||
assert "草稿已生成" not in str(result.get("answer") or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_retries_when_llm_leaks_internal_packet_keys(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
calls: list[dict[str, object]] = []
|
||
|
||
async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001
|
||
_ = kwargs
|
||
calls.append({"prompt": prompt})
|
||
if len(calls) == 1:
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。",
|
||
"focus": "current_context 里已经有标题,这轮先收第一版定位。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "先做穿搭推荐小工具",
|
||
"best_for": "先快速验证换季推荐是否有用",
|
||
"suggested_capability": "规则推荐、基础标签、简单反馈",
|
||
"rationale": "latest_user_message 说明这是个新需求,适合先做轻量闭环",
|
||
"tradeoff": "后续还要补个性化能力",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "先做个性化穿搭推荐平台",
|
||
"best_for": "持续沉淀用户偏好和衣橱反馈",
|
||
"suggested_capability": "用户画像、衣物标签、反馈闭环",
|
||
"rationale": "collected_fields 还不多,先把主方向定住更重要",
|
||
"tradeoff": "第一版要先说清数据边界",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "直接做穿搭决策底座",
|
||
"best_for": "后续接多品牌和多场景运营",
|
||
"suggested_capability": "多源接入、推荐服务、策略实验",
|
||
"rationale": "project_nature 如果一开始做重,上限会更高",
|
||
"tradeoff": "早期投入最大",
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "reply_budget 允许的范围内,B 更贴近长期价值。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。",
|
||
"focus": "现在先把第一版定位定住,再往下收目标用户和范围。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "先做穿搭推荐小工具",
|
||
"best_for": "快速验证换季搭配推荐有没有真实使用价值",
|
||
"suggested_capability": "规则推荐、季节标签、基础反馈",
|
||
"rationale": "落地快,适合先把推荐闭环跑通",
|
||
"tradeoff": "个性化学习能力会偏弱",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "先做个性化穿搭推荐平台",
|
||
"best_for": "持续沉淀用户偏好、衣橱数据和搭配反馈",
|
||
"suggested_capability": "用户画像、衣物标签、推荐策略、反馈闭环",
|
||
"rationale": "更贴近自动推荐搭配衣服这件事的长期价值",
|
||
"tradeoff": "第一版要先约束数据口径和推荐边界",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "直接做穿搭决策底座",
|
||
"best_for": "后续要接多品牌、多场景和长期运营策略",
|
||
"suggested_capability": "多源数据接入、推荐服务、效果评估",
|
||
"rationale": "长期上限最高",
|
||
"tradeoff": "早期投入和依赖都更重",
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "它最贴近这类需求的长期价值,同时不会一开始就做得过重。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object",
|
||
fake_generate_structured_object,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-repair-internal-keys",
|
||
"source_sender_open_id": "ou-plan-repair-internal-keys",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-repair-internal-keys-root",
|
||
)
|
||
|
||
answer = str(result["answer"] or "")
|
||
assert result["status"] == "succeeded"
|
||
assert len(calls) == 2
|
||
assert "current_context" not in answer
|
||
assert "latest_user_message" not in answer
|
||
assert "collected_fields" not in answer
|
||
assert "reply_budget" not in answer
|
||
assert "project_nature" not in answer
|
||
assert "⭐ 当前我更推荐:B" in answer
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_retries_internal_solutioning_leaks_for_non_tech_round(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
calls: list[str] = []
|
||
|
||
async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001
|
||
_ = kwargs
|
||
calls.append(prompt)
|
||
if len(calls) == 1:
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"opening": "🧭 我先按“销售线索跟进状态周报”这类 IT 项目来收。",
|
||
"focus": "现在最关键的是先把第一版项目性质定住。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "轻量定时报表",
|
||
"best_for": "先把每周汇总稳定发出去",
|
||
"suggested_capability": "Feishu Bot + 定时 SQL 查询",
|
||
"rationale": "直接复用现有发送链路,改动最小",
|
||
"tradeoff": "交互性弱",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "可交互看板模块",
|
||
"best_for": "后续还要继续追问和筛选",
|
||
"suggested_capability": "Feishu 多维表格 API /projects 页面嵌入",
|
||
"rationale": "能复用现有页面和接口,扩展快",
|
||
"tradeoff": "接入成本更高",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "AI 增强调度器",
|
||
"best_for": "后续要做异常识别和自动建议",
|
||
"suggested_capability": "adaptive_rag_service.py + feishu_integration.py",
|
||
"rationale": "已有 RAG 和飞书集成模块,可快速组合",
|
||
"tradeoff": "实现会更重",
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "当前 /projects 页面已就绪,适合先复用已有能力。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"opening": "🧭 我先按“销售线索跟进状态周报”这件事继续收。",
|
||
"focus": "现在先判断第一版更像哪种项目形态。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "轻量定时简报",
|
||
"best_for": "先把周报稳定汇总并发给销售经理",
|
||
"suggested_capability": "定时汇总并发送固定周报",
|
||
"rationale": "最快验证业务是否真的需要这类周报",
|
||
"tradeoff": "后续追问和筛选能力会弱一些",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "可追问的数据看板",
|
||
"best_for": "后续还要按负责人、阶段继续查看明细",
|
||
"suggested_capability": "汇总展示加按维度继续追问",
|
||
"rationale": "更适合把周报和后续跟进放在同一条业务链路里",
|
||
"tradeoff": "第一版准备工作会多一点",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "异常预警助手",
|
||
"best_for": "重点盯逾期、无人跟进和长时间停滞线索",
|
||
"suggested_capability": "自动识别异常并提醒相关负责人",
|
||
"rationale": "更容易让销售经理快速看到问题点",
|
||
"tradeoff": "需要先把异常判断口径说清楚",
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "它兼顾周报汇总和后续追问,比只发静态周报更贴近真实使用场景。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object",
|
||
fake_generate_structured_object,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-repair-solutioning-leak",
|
||
"source_sender_open_id": "ou-plan-repair-solutioning-leak",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="跟你聊个新需求,关于每周自动汇总销售线索跟进状态发给销售经理,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-repair-solutioning-leak-root",
|
||
)
|
||
|
||
answer = str(result["answer"] or "")
|
||
assert result["status"] == "succeeded"
|
||
assert len(calls) == 2
|
||
assert "轻量定时简报" in answer
|
||
assert "可追问的数据看板" in answer
|
||
assert "异常预警助手" in answer
|
||
assert "Feishu Bot" not in answer
|
||
assert "SQL" not in answer
|
||
assert "API" not in answer
|
||
assert ".py" not in answer
|
||
assert "/projects" not in answer
|
||
assert "RAG" not in answer
|
||
assert "⭐ 当前我更推荐:B" in answer
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_retries_stale_deployment_context_for_outfit_requirement(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
calls: list[str] = []
|
||
|
||
async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001
|
||
_ = kwargs
|
||
calls.append(prompt)
|
||
if '"missing_field": "project_nature"' in prompt:
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。",
|
||
"focus": "现在最关键的是先把第一版定位定住。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "先做穿搭推荐小工具",
|
||
"best_for": "先快速验证搭配建议是否有用",
|
||
"suggested_capability": "规则推荐、天气标签、基础反馈",
|
||
"rationale": "适合先把最小闭环跑通",
|
||
"tradeoff": "个性化会偏弱",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "先做个性化穿搭推荐平台",
|
||
"best_for": "持续沉淀用户偏好和衣橱反馈",
|
||
"suggested_capability": "用户画像、衣物标签、推荐策略",
|
||
"rationale": "更贴近自动推荐搭配衣服的长期价值",
|
||
"tradeoff": "第一版要先说清边界",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "直接做穿搭决策底座",
|
||
"best_for": "后续要接更多品牌和场景",
|
||
"suggested_capability": "多源接入、策略实验、效果评估",
|
||
"rationale": "长期上限最高",
|
||
"tradeoff": "早期投入最大",
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": "它更符合这类需求的长期价值,但不会一开始做得过重。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
if '"missing_field": "target_user"' in prompt and len(calls) == 2:
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"opening": "🧭 我先按“发布治理平台”这个方向继续收。",
|
||
"focus": "现在要先把服务对象定住,不然后面治理边界会飘。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "平台运维团队",
|
||
"best_for": "统一管理发布流程和回滚风险",
|
||
"suggested_capability": "审批、审计、回滚",
|
||
"rationale": "更适合承接部署治理闭环",
|
||
"tradeoff": "会偏离真实用户场景",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "项目交付团队",
|
||
"best_for": "按项目复制部署流程",
|
||
"suggested_capability": "模板、参数、发布编排",
|
||
"rationale": "更适合多项目交付",
|
||
"tradeoff": "仍然偏旧部署场景",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "开发团队",
|
||
"best_for": "自己发版自己看结果",
|
||
"suggested_capability": "日志查看、执行入口",
|
||
"rationale": "反馈快",
|
||
"tradeoff": "治理弱",
|
||
},
|
||
],
|
||
"recommendation": {"key": "A", "reason": "它最适合部署治理和回滚闭环。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"opening": "🧭 我先按“个性化穿搭推荐平台”这个方向继续收。",
|
||
"focus": "现在先把第一版服务对象定住,不然后面推荐口径会飘。",
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "普通消费者",
|
||
"best_for": "先验证换季搭配推荐是否真的有帮助",
|
||
"suggested_capability": "偏好反馈、天气/季节标签、收藏行为",
|
||
"rationale": "最贴近最终使用者,能尽快拿到真实反馈",
|
||
"tradeoff": "需要处理偏好差异",
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "穿搭运营团队",
|
||
"best_for": "先由运营维护搭配规则和推荐口径",
|
||
"suggested_capability": "规则配置、款式标签、人工校验",
|
||
"rationale": "更容易控制第一版质量",
|
||
"tradeoff": "自动化程度会弱一些",
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "品牌导购团队",
|
||
"best_for": "结合商品推荐和导购场景做转化验证",
|
||
"suggested_capability": "商品库、搭配卡片、导购反馈",
|
||
"rationale": "更适合商业转化场景",
|
||
"tradeoff": "会更依赖商品数据",
|
||
},
|
||
],
|
||
"recommendation": {"key": "A", "reason": "先服务普通消费者,最能验证自动推荐是否真的成立。"},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object",
|
||
fake_generate_structured_object,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
root_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-repair-stale-deployment",
|
||
"source_sender_open_id": "ou-plan-repair-stale-deployment",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-repair-stale-deployment",
|
||
"source_sender_open_id": "ou-plan-repair-stale-deployment",
|
||
"thread_context": {
|
||
"root_message_id": "msg-plan-repair-stale-deployment-root",
|
||
"reply_to_message_id": "msg-plan-repair-stale-deployment-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
first = await executor._execute_query_intent(
|
||
intent=root_intent,
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-repair-stale-deployment-root",
|
||
)
|
||
second = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-repair-stale-deployment-2",
|
||
)
|
||
|
||
assert first["status"] == "succeeded"
|
||
assert second["status"] == "succeeded"
|
||
assert len(calls) == 3
|
||
second_answer = str(second["answer"] or "")
|
||
assert "普通消费者" in second_answer
|
||
assert "穿搭运营团队" in second_answer
|
||
assert "品牌导购团队" in second_answer
|
||
assert "发布治理平台" not in second_answer
|
||
assert "平台运维团队" not in second_answer
|
||
assert "部署治理" not in second_answer
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_round_compacts_render_within_feishu_budget(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
repeated = ",".join(["覆盖用户偏好、衣橱标签、天气温度、场景约束、历史反馈"] * 12)
|
||
|
||
async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001
|
||
_ = (prompt, kwargs)
|
||
return StructuredExtractionResult(
|
||
payload={
|
||
"opening": "🧭 我先按“换季系统自动推荐搭配衣服”这类 IT 项目来收。" + repeated,
|
||
"focus": "现在最关键的是先定第一版定位。" + repeated,
|
||
"options": [
|
||
{
|
||
"key": "A",
|
||
"title": "轻量推荐工具",
|
||
"best_for": repeated,
|
||
"suggested_capability": repeated,
|
||
"rationale": repeated,
|
||
"tradeoff": repeated,
|
||
},
|
||
{
|
||
"key": "B",
|
||
"title": "业务平台能力",
|
||
"best_for": repeated,
|
||
"suggested_capability": repeated,
|
||
"rationale": repeated,
|
||
"tradeoff": repeated,
|
||
},
|
||
{
|
||
"key": "C",
|
||
"title": "长期能力底座",
|
||
"best_for": repeated,
|
||
"suggested_capability": repeated,
|
||
"rationale": repeated,
|
||
"tradeoff": repeated,
|
||
},
|
||
],
|
||
"recommendation": {"key": "B", "reason": repeated},
|
||
"reply_instruction": "你回 A / B / C,我就顺着这条继续往下收。",
|
||
},
|
||
source_field="content",
|
||
provider="fake",
|
||
model="fake",
|
||
validation="ok",
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.ai_pmo.ai_pmo_typed_executor_execute.mixin_execute_query_project.llm_service.generate_structured_object",
|
||
fake_generate_structured_object,
|
||
)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-over-budget",
|
||
"source_sender_open_id": "ou-plan-over-budget",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-over-budget-root",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
answer = str(result["answer"] or "")
|
||
assert len(answer) <= 1600
|
||
assert "A. 轻量推荐工具" in answer
|
||
assert "B. 业务平台能力" in answer
|
||
assert "C. 长期能力底座" in answer
|
||
assert "⭐ 当前我更推荐:B" in answer
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_requirement_plan_it_project_runs_five_round_pm_template(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("it project requirement plan should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_round_llm(monkeypatch)
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
root_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-it",
|
||
"source_sender_open_id": "ou-plan-it",
|
||
"message_intent": "requirement_plan",
|
||
"consultant_mode_preference": "clarify",
|
||
},
|
||
)
|
||
followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-plan-it",
|
||
"source_sender_open_id": "ou-plan-it",
|
||
"thread_context": {
|
||
"root_message_id": "msg-plan-it-root",
|
||
"reply_to_message_id": "msg-plan-it-root",
|
||
},
|
||
},
|
||
)
|
||
|
||
first = await executor._execute_query_intent(
|
||
intent=root_intent,
|
||
message_text="想跟你聊个 IT 项目需求,做一个批量自动化部署 OpenClaw 的平台,先别创建,先收第一版。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-it-root",
|
||
)
|
||
second = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B,先做发布治理平台。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-it-2",
|
||
)
|
||
third = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B,主要给平台运维团队用。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-it-3",
|
||
)
|
||
fourth = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="B,走任务编排平台版,用 FastAPI + React + Redis + Celery + Postgres。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-it-4",
|
||
)
|
||
fifth = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="A,第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-plan-it-5",
|
||
)
|
||
final_result = fifth
|
||
|
||
assert "🧭 我先按“OpenClaw 批量自动化部署平台”这类 IT 项目来收。" in str(first["answer"] or "")
|
||
assert "A. 先做内部提效工具" in str(first["answer"] or "")
|
||
assert "先定它更像工具、平台,还是长期底座" in str(first["answer"] or "")
|
||
assert "⭐ 当前我更推荐:B" in str(first["answer"] or "")
|
||
assert "你回 A / B / C" in str(first["answer"] or "")
|
||
|
||
assert "🧭 我先按“发布治理平台”这个方向继续收。" in str(second["answer"] or "")
|
||
assert "平台运维团队" in str(second["answer"] or "")
|
||
assert "⭐ 当前我更推荐:B" in str(second["answer"] or "")
|
||
|
||
assert "🧭 我先按“平台运维团队要长期使用”这个方向继续收。" in str(third["answer"] or "")
|
||
assert "第一版技术路线定住" in str(third["answer"] or "")
|
||
assert "FastAPI + React + Redis + Celery + PostgreSQL" in str(third["answer"] or "")
|
||
assert "⭐ 当前我更推荐:B" in str(third["answer"] or "")
|
||
|
||
assert "🧭 我先按“任务编排平台版”继续往下收。" in str(fourth["answer"] or "")
|
||
assert "批量执行闭环" in str(fourth["answer"] or "")
|
||
assert "⭐ 当前我更推荐:A" in str(fourth["answer"] or "")
|
||
|
||
final_answer = str(final_result["answer"] or "")
|
||
assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer
|
||
assert "🧭【一句话判断】" in final_answer
|
||
assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer
|
||
assert "👥【服务对象】第一版先服务平台运维团队" in final_answer
|
||
assert "📥【基础输入】当前至少要接入:" in final_answer
|
||
assert "飞书需求消息" in final_answer
|
||
assert "部署目标清单" in final_answer
|
||
assert "📝【当前已知信息】目前已经明确:" in final_answer
|
||
assert "⚙️【技术路线】当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" in final_answer
|
||
assert "🧱【第一版范围】第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。" in final_answer
|
||
assert "🔄【建议核心流程】" in final_answer
|
||
assert "生成部署任务" in final_answer
|
||
assert "👤【人员配置】建议最小配置:" in final_answer
|
||
assert "平台/运维工程师" in final_answer
|
||
assert "🚀【推进方式】先按项目级试点(带基础审批和回滚)推进" in final_answer
|
||
assert "⚠️【风险与依赖】当前主要风险在" in final_answer
|
||
assert "凭证管理" in final_answer
|
||
assert "❓【待确认】还需要你拍板:" in final_answer
|
||
assert "部署目标范围" in final_answer
|
||
assert "试点负责人" in final_answer
|
||
assert str(final_result["rag_result"]["query_type"]) == "draft"
|
||
|
||
draft = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3)
|
||
)
|
||
).scalar_one()
|
||
card = draft.requirement_card or {}
|
||
assert card["plan_profile"] == "it_project"
|
||
assert card["project_nature"] == "发布治理平台"
|
||
assert card["target_user"] == "平台运维团队"
|
||
assert "FastAPI" in str(card["tech_route"] or "")
|
||
assert "批量执行闭环" in str(card["first_scope"] or "")
|
||
assert "项目级试点" in str(card["delivery_mode"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_single_choice_does_not_resume_awaiting_requirement_draft(
|
||
monkeypatch,
|
||
requirement_plan_db_session,
|
||
) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"answer": "当前没有进行中的 A/B/C 收敛轮次。你先发一句新需求,我按新的 topic 接着收。",
|
||
"query_type": "status",
|
||
"confidence": 0.42,
|
||
"missing_fields": [],
|
||
}
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = requirement_plan_db_session
|
||
self.tenant_id = 3
|
||
|
||
_install_requirement_plan_query_bundle(monkeypatch, message_intent="query", consultant_mode_preference="")
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
|
||
conversation_scope_key = build_feishu_conversation_scope_key(
|
||
source_chat_id="chat-old-draft",
|
||
source_sender_open_id="ou-old-draft",
|
||
sender=None,
|
||
)
|
||
old_topic = MountedTopicWorkspace(
|
||
tenant_id=3,
|
||
topic_key="feishu:chat-old-draft:message:sim-plan-1431-1",
|
||
source_system="feishu",
|
||
channel_ref="chat-old-draft",
|
||
conversation_scope_key=conversation_scope_key,
|
||
root_message_id="sim-plan-1431-1",
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="awaiting_execute_confirm",
|
||
pending_actions=["confirm_requirement_draft"],
|
||
metadata_json={
|
||
"plan_mode": True,
|
||
"plan_question_round": 17,
|
||
"plan_last_prompt": "下一个关键问题:你更想怎么接收这个结果,是飞书日报、页面看板,还是只推送一个分数加一句解释?",
|
||
},
|
||
facts_json={
|
||
"title": "每日 A 股趋势打分工具",
|
||
"plan_profile": "scoring",
|
||
"target_user": "投顾",
|
||
},
|
||
)
|
||
requirement_plan_db_session.add(old_topic)
|
||
await requirement_plan_db_session.flush()
|
||
requirement_plan_db_session.add(
|
||
MountedDraftWorkspace(
|
||
tenant_id=3,
|
||
topic_workspace_id=int(old_topic.id),
|
||
state="ready",
|
||
draft_version=1,
|
||
requirement_card={
|
||
"plan_profile": "scoring",
|
||
"title": "每日 A 股趋势打分工具",
|
||
"goal": "每天产出一个可解释的 A 股趋势分数",
|
||
"target_user": "投顾",
|
||
"scoring_basis": "技术指标(均线、成交量、涨跌比等)",
|
||
"output_form": "飞书日报",
|
||
"cadence": "每日",
|
||
"first_scope": "第一版先做 A 股趋势分数和一句解释",
|
||
},
|
||
draft_payload={"title": "每日 A 股趋势打分工具"},
|
||
required_confirmations=["需求草稿需要人工确认后才允许正式创建"],
|
||
)
|
||
)
|
||
await requirement_plan_db_session.flush()
|
||
|
||
followup_intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"source_chat_id": "chat-old-draft",
|
||
"source_sender_open_id": "ou-old-draft",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=followup_intent,
|
||
message_text="C",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-old-draft-c",
|
||
)
|
||
|
||
answer = str(result["answer"] or "")
|
||
assert "每日 A 股趋势打分工具" not in answer
|
||
assert "需求草稿" not in answer
|
||
assert "新需求" in answer
|
||
assert str(result["rag_result"]["query_type"]) == "status"
|
||
|
||
topic = (
|
||
await requirement_plan_db_session.execute(
|
||
select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(old_topic.id))
|
||
)
|
||
).scalar_one()
|
||
assert topic.plan_status == "awaiting_execute_confirm"
|
||
assert topic.pending_actions == ["confirm_requirement_draft"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_short_circuits_requirement_advise_without_rag(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement advice should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"semantic_bundle": {
|
||
"diagnostics": {
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "advise",
|
||
}
|
||
},
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="如果这个政策雷达项目先做 MVP,你觉得范围怎么收才比较稳?",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-3",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert "只保留一个核心用户对象" in str(result["answer"] or "")
|
||
assert str(result["rag_result"]["query_type"]) == "advice"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_short_circuits_requirement_advise_by_text_without_metadata(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("scope advice should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
_ = (message_text, metadata)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-advise-shortcut",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.92,
|
||
metadata={"message_intent": "business_review"},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "advise",
|
||
"advise_subtype": "scope",
|
||
},
|
||
)
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(project_id=None, metadata={})
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="如果第一版先做 MVP,这个需求范围怎么收才比较稳?",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-3b",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert "只保留一个核心用户对象" in str(result["answer"] or "")
|
||
assert str(result["rag_result"]["query_type"]) == "advice"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_short_circuits_assignee_recommendation_without_rag(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("assignee recommendation should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
async def _build_assignee_recommendation_answer(self, *, message_text: str, project_id):
|
||
_ = (message_text, project_id)
|
||
return "如果现在先给一个保守负责人,我更倾向 AI工程师。"
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
_ = (message_text, metadata)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-assignee-shortcut",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.92,
|
||
metadata={"message_intent": "business_review"},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "advise",
|
||
"advise_subtype": "assignee",
|
||
},
|
||
)
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(project_id=None, metadata={})
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="那你觉得分配给谁做比较合适?",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-3c",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert "AI工程师" in str(result["answer"] or "")
|
||
assert str(result["rag_result"]["query_type"]) == "recommendation"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_prefers_direct_advise_subtype_for_assignee(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("direct assignee subtype should bypass heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
async def _build_assignee_recommendation_answer(self, *, message_text: str, project_id):
|
||
_ = (message_text, project_id)
|
||
return "如果先给一个 owner,我会优先推荐 AI工程师。"
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(
|
||
project_id=None,
|
||
metadata={
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "advise",
|
||
"advise_subtype": "assignee",
|
||
},
|
||
)
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="那你觉得分配给谁做比较合适?",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-3c-direct",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert "AI工程师" in str(result["answer"] or "")
|
||
assert str(result["rag_result"]["query_type"]) == "recommendation"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_query_intent_short_circuits_project_existence_lookup_without_rag(monkeypatch) -> None:
|
||
class FakeAdaptiveRAGService:
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _try_fast_project_existence_answer(self, *, clean_q: str, project_id):
|
||
_ = (clean_q, project_id)
|
||
return {
|
||
"answer": "有,当前匹配到 1 个相关项目:\n#101 AI客服(状态:active)",
|
||
"sources": [{"source": "db", "label": "Project database", "score": 0.99, "metadata": {"table": "projects"}}],
|
||
"db_query_ms": 8,
|
||
}
|
||
|
||
async def answer_question(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("project existence lookup should not enter heavy RAG path")
|
||
|
||
class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 3
|
||
|
||
async def _fake_aplan_message(*, message_text: str, source_system: str, metadata=None):
|
||
_ = (message_text, metadata)
|
||
return WorkflowBundle(
|
||
bundle_id="bundle-project-lookup-shortcut",
|
||
semantic_version="v1",
|
||
source_system=source_system,
|
||
primary_workflow="query_flow",
|
||
workflows=[
|
||
WorkflowPlan(
|
||
workflow_type="query_flow",
|
||
workflow_action="answer",
|
||
target_scope="global",
|
||
operation_codes=["query.answer"],
|
||
requires_confirmation=False,
|
||
confidence=0.92,
|
||
metadata={"message_intent": "project_lookup_question"},
|
||
)
|
||
],
|
||
message_gate="auto",
|
||
reply_contract=ReplyContract(mode="text", template_key="workflow_bundle", summary="query_flow"),
|
||
diagnostics={
|
||
"message_intent": "project_lookup_question",
|
||
"consultant_mode_preference": "fact_retrieval",
|
||
},
|
||
)
|
||
|
||
monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
_fake_aplan_message,
|
||
)
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(project_id=None, metadata={})
|
||
|
||
result = await executor._execute_query_intent(
|
||
intent=intent,
|
||
message_text="一个 AI 客服工具,这个项目有吗",
|
||
operator_user_id=999,
|
||
source_system="feishu",
|
||
source_message_id="msg-chat-project-exists-1",
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert "当前匹配到 1 个相关项目" in str(result["answer"] or "")
|
||
assert str(result["rag_result"]["query_type"]) == "status"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirmation_route_does_not_hijack_explicit_signal_source_request() -> None:
|
||
class FakeSession:
|
||
last_intent = mixin_entry.STATE_DRAFT_PENDING_CONFIRM
|
||
|
||
should_route = mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation(
|
||
session=FakeSession(),
|
||
message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。",
|
||
ingress_options={},
|
||
)
|
||
|
||
assert should_route is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_route_to_draft_confirmation_session_uses_active_mounted_draft_without_anchor(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _fake_has_active_draft_session(self, **kwargs):
|
||
_ = self
|
||
captured["kwargs"] = kwargs
|
||
return True
|
||
|
||
kernel = FakeKernel()
|
||
monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session)
|
||
should_route = await kernel._should_route_to_draft_confirmation_session(
|
||
session=SimpleNamespace(last_intent="query.answer"),
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="确认",
|
||
ingress_options=None,
|
||
)
|
||
|
||
assert should_route is True
|
||
assert captured["kwargs"] == {
|
||
"sender": "tester",
|
||
"source_system": "feishu",
|
||
"channel_ref": "chat-active",
|
||
"source_sender_open_id": "ou_active",
|
||
"thread_context": {},
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_route_to_draft_confirmation_session_does_not_hijack_smalltalk_even_if_active_draft_exists(
|
||
monkeypatch,
|
||
) -> None:
|
||
called = False
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _fake_has_active_draft_session(self, **kwargs):
|
||
nonlocal called
|
||
_ = (self, kwargs)
|
||
called = True
|
||
return True
|
||
|
||
monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session)
|
||
|
||
kernel = FakeKernel()
|
||
should_route = await kernel._should_route_to_draft_confirmation_session(
|
||
session=SimpleNamespace(last_intent="query.answer"),
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="收到,辛苦了",
|
||
ingress_options=None,
|
||
)
|
||
|
||
assert should_route is False
|
||
assert called is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_route_to_draft_confirmation_session_keeps_pending_confirm_session_without_keywords(
|
||
monkeypatch,
|
||
) -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _fake_has_active_draft_session(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
return False
|
||
|
||
monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session)
|
||
|
||
kernel = FakeKernel()
|
||
should_route = await kernel._should_route_to_draft_confirmation_session(
|
||
session=SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM),
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="按这版继续",
|
||
ingress_options=None,
|
||
)
|
||
|
||
assert should_route is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_route_to_draft_confirmation_session_with_active_draft_does_not_require_revision_heuristics(
|
||
monkeypatch,
|
||
) -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _fake_has_active_draft_session(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
return True
|
||
|
||
kernel = FakeKernel()
|
||
monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session)
|
||
should_route = await kernel._should_route_to_draft_confirmation_session(
|
||
session=SimpleNamespace(last_intent="query.answer"),
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="保持当前草稿结构并补完整信息",
|
||
ingress_options=None,
|
||
)
|
||
|
||
assert should_route is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_route_to_draft_confirmation_session_does_not_hijack_regenerate_request_even_if_active_draft_exists(
|
||
monkeypatch,
|
||
) -> None:
|
||
called = False
|
||
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _fake_has_active_draft_session(self, **kwargs):
|
||
nonlocal called
|
||
_ = (self, kwargs)
|
||
called = True
|
||
return True
|
||
|
||
monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session)
|
||
|
||
kernel = FakeKernel()
|
||
should_route = await kernel._should_route_to_draft_confirmation_session(
|
||
session=SimpleNamespace(last_intent="query.answer"),
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="生成一版给我看看",
|
||
ingress_options=None,
|
||
)
|
||
|
||
assert should_route is False
|
||
assert called is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_route_to_draft_confirmation_session_still_skips_explicit_independent_request_with_active_draft(
|
||
monkeypatch,
|
||
) -> None:
|
||
class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _fake_has_active_draft_session(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
return True
|
||
|
||
monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session)
|
||
|
||
kernel = FakeKernel()
|
||
should_route = await kernel._should_route_to_draft_confirmation_session(
|
||
session=SimpleNamespace(last_intent="query.answer"),
|
||
sender="tester",
|
||
source_system="feishu",
|
||
channel_ref="chat-active",
|
||
source_sender_open_id="ou_active",
|
||
message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。",
|
||
ingress_options=None,
|
||
)
|
||
|
||
assert should_route is False
|
||
|
||
|
||
def test_mounted_runtime_agent_name_uses_plan_coach_for_scope_advise() -> None:
|
||
agent_name = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name(
|
||
workflow_route="query.answer",
|
||
auto_execute=False,
|
||
route_metadata={"consultant_mode_preference": "advise", "advise_subtype": "scope"},
|
||
reply_template_key=None,
|
||
mode_hint="plan",
|
||
)
|
||
|
||
assert agent_name == "PlanCoach"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_execute_project_update_intent_resolves_string_project_id_without_name_error() -> None:
|
||
class FakeDB:
|
||
def __init__(self) -> None:
|
||
self.commits = 0
|
||
self.refreshed: list[object] = []
|
||
|
||
async def commit(self) -> None:
|
||
self.commits += 1
|
||
|
||
async def refresh(self, row) -> None:
|
||
self.refreshed.append(row)
|
||
|
||
class FakeExecutor(mixin_execute_mutations.ExecuteMutationsMixin):
|
||
def __init__(self) -> None:
|
||
self.db = FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
async def _resolve_project(self, *, project_id, project_title, project_name):
|
||
_ = (project_title, project_name)
|
||
return (
|
||
SimpleNamespace(
|
||
id=project_id,
|
||
title="旧标题",
|
||
name="旧标题",
|
||
description=None,
|
||
status="active",
|
||
domain_id=None,
|
||
assigned_role=None,
|
||
owner_id=None,
|
||
signal_count=0,
|
||
created_at=None,
|
||
updated_at=None,
|
||
),
|
||
[],
|
||
)
|
||
|
||
async def _log_operation(self, **kwargs):
|
||
_ = kwargs
|
||
|
||
async def _invalidate_project_cache(self, *, project_id: int) -> None:
|
||
_ = project_id
|
||
|
||
executor = FakeExecutor()
|
||
intent = SimpleNamespace(name="project.update", project_id=None, metadata={})
|
||
|
||
result = await executor._execute_project_update_intent(
|
||
intent=intent,
|
||
params={"project_id": "104", "title": "新标题"},
|
||
operator_user_id=9,
|
||
)
|
||
|
||
assert result["status"] == "succeeded"
|
||
assert result["project"]["id"] == 104
|
||
assert result["project"]["title"] == "新标题"
|
||
assert executor.db.commits == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fast_project_progress_answer_handles_risk_blocker_questions() -> None:
|
||
project = SimpleNamespace(id=104, title="跨境直播项目", name="跨境直播项目", status="active")
|
||
tasks = [
|
||
SimpleNamespace(status="todo", updated_at=datetime(2026, 4, 5, 10, 0), created_at=datetime(2026, 4, 5, 9, 0)),
|
||
SimpleNamespace(status="in_progress", updated_at=datetime(2026, 4, 5, 11, 0), created_at=datetime(2026, 4, 5, 8, 0)),
|
||
SimpleNamespace(status="done", updated_at=datetime(2026, 4, 5, 7, 0), created_at=datetime(2026, 4, 5, 6, 0)),
|
||
]
|
||
|
||
class FakeScalarResult:
|
||
def __init__(self, rows):
|
||
self._rows = rows
|
||
|
||
def scalars(self):
|
||
return SimpleNamespace(all=lambda: list(self._rows))
|
||
|
||
class FakeDB:
|
||
async def execute(self, stmt):
|
||
_ = stmt
|
||
return FakeScalarResult(tasks)
|
||
|
||
class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin):
|
||
def __init__(self) -> None:
|
||
self.db = FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
@staticmethod
|
||
def _extract_project_lookup_keyword(question: str) -> str:
|
||
_ = question
|
||
return "跨境直播"
|
||
|
||
@staticmethod
|
||
def _extract_query_terms(question: str, preferred: str):
|
||
_ = question
|
||
return [preferred]
|
||
|
||
async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int):
|
||
_ = (keyword, project_id, limit)
|
||
return [project]
|
||
|
||
rag = FakeRAG()
|
||
result = await rag._try_fast_project_progress_answer(
|
||
clean_q="帮我看看跨境直播项目最近有哪些风险和阻塞",
|
||
project_id=None,
|
||
)
|
||
|
||
assert result is not None
|
||
assert "风险/阻塞台账字段" in str(result["answer"] or "")
|
||
assert "待处理任务数 1" in str(result["answer"] or "")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_fast_project_progress_answer_handles_risk_blocker_questions_without_tasks() -> None:
|
||
project = SimpleNamespace(id=101, title="AI客服", name="AI客服", status="active")
|
||
|
||
class FakeScalarResult:
|
||
def __init__(self, rows):
|
||
self._rows = rows
|
||
|
||
def scalars(self):
|
||
return SimpleNamespace(all=lambda: list(self._rows))
|
||
|
||
class FakeDB:
|
||
async def execute(self, stmt):
|
||
_ = stmt
|
||
return FakeScalarResult([])
|
||
|
||
class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin):
|
||
def __init__(self) -> None:
|
||
self.db = FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
@staticmethod
|
||
def _extract_project_lookup_keyword(question: str) -> str:
|
||
_ = question
|
||
return "AI客服"
|
||
|
||
@staticmethod
|
||
def _extract_query_terms(question: str, preferred: str):
|
||
_ = question
|
||
return [preferred]
|
||
|
||
async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int):
|
||
_ = (keyword, project_id, limit)
|
||
return [project]
|
||
|
||
rag = FakeRAG()
|
||
result = await rag._try_fast_project_progress_answer(
|
||
clean_q="帮我看看 AI 客服工具项目最近有哪些风险和阻塞",
|
||
project_id=None,
|
||
)
|
||
|
||
assert result is not None
|
||
assert "风险/阻塞台账字段" in str(result["answer"] or "")
|
||
assert "没有可用的任务拆解记录" in str(result["answer"] or "")
|
||
|
||
|
||
def test_typed_executor_common_exports_regex_helpers_to_query_mixins() -> None:
|
||
assert mixin_execute_query_project.ExecuteQueryProjectMixin._extract_task_priority("p0 紧急任务") == "high"
|
||
assert (
|
||
ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._extract_requested_signal_count(
|
||
message_text="请帮我分析 3 个信号",
|
||
metadata={},
|
||
)
|
||
== 3
|
||
)
|
||
assert (
|
||
ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._requires_strict_signal_analysis(
|
||
message_text="先分析后入库 2 个信号",
|
||
requested_count=2,
|
||
)
|
||
is True
|
||
)
|
||
|
||
|
||
def test_consultant_dispatch_uses_short_next_action_for_query_answers() -> None:
|
||
dispatch = build_consultant_dispatch_contract(
|
||
intent_name="query.answer",
|
||
message_text="PMFlow 现在完整调用链是什么?",
|
||
source_system="pmflow_manual",
|
||
status="answered",
|
||
classification="question",
|
||
primary_operation_code="query.answer",
|
||
risk_level="low",
|
||
missing_fields=[],
|
||
direct_reply_text=(
|
||
"结论:PMFlow 当前完整调用链无法完整还原。\n\n"
|
||
"依据:前端 `/ai-steward` 页面通过 `/rag/ask` 发起请求,"
|
||
"并继续进入 `AdaptiveRAGService.upsert_pmflow_record_async`。"
|
||
),
|
||
)
|
||
|
||
assert dispatch["allow_execute"] is False
|
||
assert dispatch["mode"] == "advise"
|
||
assert dispatch["next_action"] == "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。"
|
||
assert "结论:" not in dispatch["next_action"]
|
||
assert "/rag/ask" not in dispatch["next_action"]
|
||
|
||
|
||
def test_build_capture_result_no_longer_exposes_consultant_dispatch() -> None:
|
||
class _FakeKernel(ai_pmo_kernel_mixins_ingress_render.KernelIngressRenderMixin):
|
||
tenant_id = 1
|
||
|
||
work_item = SimpleNamespace(
|
||
id=18,
|
||
project_id=7,
|
||
signal_id=None,
|
||
state="succeeded",
|
||
intent="query.answer",
|
||
reply_template_key="action_succeeded",
|
||
decision_level="team",
|
||
review_summary={"verified": True},
|
||
raw_message="现在进展怎么样?",
|
||
source_system="pmflow_manual",
|
||
draft_version=0,
|
||
)
|
||
intent = CanonicalIntent(
|
||
name="query.answer",
|
||
confidence=0.93,
|
||
project_id=7,
|
||
signal_title="",
|
||
signal_summary="当前主链正常。",
|
||
metadata={},
|
||
)
|
||
result = _FakeKernel()._build_capture_result(
|
||
work_item=work_item,
|
||
intent=intent,
|
||
execution_result={
|
||
"workflow_route": "query.answer",
|
||
"rag_result": {"answer": "当前主链正常。"},
|
||
},
|
||
render={
|
||
"template_key": "action_succeeded",
|
||
"text": "当前主链正常。",
|
||
},
|
||
direct_reply_required=True,
|
||
status="answered",
|
||
classification="question",
|
||
)
|
||
|
||
assert "consultant_dispatch" not in result
|
||
assert "consultant_dispatch" not in result["rag_result"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None:
|
||
captured: dict[str, object] = {"logs": []}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_tier_escalation,
|
||
"_runtime_log",
|
||
lambda scope, message: captured["logs"].append((scope, message)),
|
||
)
|
||
|
||
low_engine = {
|
||
"id": "low-1",
|
||
"provider": "glm",
|
||
"model": "qwen-plus",
|
||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||
"api_key": "low-key",
|
||
}
|
||
high_engine = {
|
||
"id": "high-1",
|
||
"provider": "openai",
|
||
"model": "GLM-5",
|
||
"base_url": "https://api.openai.com/v1",
|
||
"api_key": "high-key",
|
||
}
|
||
bundle = {
|
||
"settings": {},
|
||
"active_engine_id": "low-1",
|
||
"engines": [low_engine, high_engine],
|
||
}
|
||
openclaw_bundle = {
|
||
"rules": [
|
||
{
|
||
"name": "engine_high_reasoning_glm5",
|
||
"enabled": True,
|
||
"priority": 220,
|
||
"tier": "high",
|
||
"provider": "openai",
|
||
"model": "GLM-5",
|
||
"api_env": "OPENAI_API_KEY",
|
||
"match_any": ["adaptive_rag_answer"],
|
||
}
|
||
]
|
||
}
|
||
|
||
class FakeLLM(LLMTierEscalationMixin):
|
||
def _low_to_high_escalation_enabled(self) -> bool:
|
||
return True
|
||
|
||
def _is_low_tier_function(self, function_name: str) -> bool:
|
||
return function_name == "adaptive_rag_answer_normal"
|
||
|
||
@staticmethod
|
||
def _rule_matches_function(rule, function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if not fn:
|
||
return False
|
||
if bool(rule.get("match_all", False)):
|
||
return True
|
||
exact = str(rule.get("function_exact") or "").strip().lower()
|
||
if exact and fn == exact:
|
||
return True
|
||
prefix = str(rule.get("function_prefix") or "").strip().lower()
|
||
if prefix and fn.startswith(prefix):
|
||
return True
|
||
match_any = rule.get("match_any")
|
||
if isinstance(match_any, list):
|
||
for hint in match_any:
|
||
token = str(hint or "").strip().lower()
|
||
if token and token in fn:
|
||
return True
|
||
return False
|
||
|
||
async def _get_or_init_bundle(self, db, *, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
return SimpleNamespace(), bundle
|
||
|
||
def _resolve_active_engine_from_bundle(self, bundle_value):
|
||
_ = bundle_value
|
||
return dict(low_engine)
|
||
|
||
def _load_openclaw_routing_bundle(self):
|
||
return dict(openclaw_bundle)
|
||
|
||
def _resolve_engine_from_rule(self, *, rule, engines, active_engine):
|
||
_ = active_engine
|
||
for item in engines:
|
||
if (
|
||
str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower()
|
||
and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip()
|
||
):
|
||
return dict(item)
|
||
return None
|
||
|
||
def _ensure_openclaw_control_plane_engine(self, engine):
|
||
return dict(engine)
|
||
|
||
def _normalize_engine_settings(self, raw):
|
||
_ = raw
|
||
return {}
|
||
|
||
def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label):
|
||
_ = (function_name, settings, engine_label)
|
||
return dict(engine)
|
||
|
||
def _is_invalid_structured_output(self, *, function_name, response_text):
|
||
_ = (function_name, response_text)
|
||
return False
|
||
|
||
async def _chat_completion(self, **kwargs):
|
||
captured["chat_kwargs"] = kwargs
|
||
return "GLM-5 answer"
|
||
|
||
llm = FakeLLM()
|
||
text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure(
|
||
db=object(),
|
||
tenant_id=1,
|
||
function_name="adaptive_rag_answer_normal",
|
||
failed_engine=dict(low_engine),
|
||
prompt="请回答一个问题",
|
||
system_prompt="system prompt",
|
||
temperature=0.2,
|
||
max_tokens=512,
|
||
response_mode="complete",
|
||
error=ValueError("LLM 返回空内容"),
|
||
)
|
||
|
||
assert text == "GLM-5 answer"
|
||
assert isinstance(resolved_engine, dict)
|
||
resolved_meta = resolved_engine["_routing_meta"]
|
||
assert resolved_meta["planned_tier"] == "low"
|
||
assert resolved_meta["executed_tier"] == "high"
|
||
assert resolved_meta["routing_decision"] == "low_to_high_escalated"
|
||
assert captured["chat_kwargs"]["provider"] == "openai"
|
||
assert captured["chat_kwargs"]["model"] == "GLM-5"
|
||
log_text = "\n".join(message for _, message in captured["logs"])
|
||
assert "高阶升级成功" in log_text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None:
|
||
captured: dict[str, object] = {"logs": []}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_tier_escalation,
|
||
"_runtime_log",
|
||
lambda scope, message: captured["logs"].append((scope, message)),
|
||
)
|
||
|
||
low_engine = {
|
||
"id": "low-1",
|
||
"provider": "glm",
|
||
"model": "qwen-plus",
|
||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||
"api_key": "low-key",
|
||
}
|
||
alternate_high_engine = {
|
||
"id": "high-deepseek-1",
|
||
"provider": "deepseek",
|
||
"model": "deepseek-chat",
|
||
"base_url": "https://api.deepseek.com/v1",
|
||
"api_key": "deepseek-key",
|
||
}
|
||
glm5_engine = {
|
||
"id": "high-glm5-1",
|
||
"provider": "openai",
|
||
"model": "GLM-5",
|
||
"base_url": "https://api.openai.com/v1",
|
||
"api_key": "glm5-key",
|
||
}
|
||
bundle = {
|
||
"settings": {},
|
||
"active_engine_id": "low-1",
|
||
"engines": [low_engine, alternate_high_engine, glm5_engine],
|
||
}
|
||
openclaw_bundle = {
|
||
"rules": [
|
||
{
|
||
"name": "engine_low_semantic_context_exact",
|
||
"enabled": True,
|
||
"priority": 310,
|
||
"tier": "low",
|
||
"provider": "glm",
|
||
"model": "qwen-plus",
|
||
"api_env": "DASHSCOPE_API_KEY",
|
||
"function_exact": "pmflow_semantic_context_decision",
|
||
}
|
||
]
|
||
}
|
||
|
||
class FakeLLM(LLMTierEscalationMixin):
|
||
def _low_to_high_escalation_enabled(self) -> bool:
|
||
return True
|
||
|
||
def _is_low_tier_function(self, function_name: str) -> bool:
|
||
return function_name == "pmflow_semantic_context_decision"
|
||
|
||
@staticmethod
|
||
def _rule_matches_function(rule, function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if not fn:
|
||
return False
|
||
if bool(rule.get("match_all", False)):
|
||
return True
|
||
exact = str(rule.get("function_exact") or "").strip().lower()
|
||
if exact and fn == exact:
|
||
return True
|
||
prefix = str(rule.get("function_prefix") or "").strip().lower()
|
||
if prefix and fn.startswith(prefix):
|
||
return True
|
||
match_any = rule.get("match_any")
|
||
if isinstance(match_any, list):
|
||
for hint in match_any:
|
||
token = str(hint or "").strip().lower()
|
||
if token and token in fn:
|
||
return True
|
||
return False
|
||
|
||
async def _get_or_init_bundle(self, db, *, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
return SimpleNamespace(), bundle
|
||
|
||
def _resolve_active_engine_from_bundle(self, bundle_value):
|
||
_ = bundle_value
|
||
return dict(low_engine)
|
||
|
||
def _load_openclaw_routing_bundle(self):
|
||
return dict(openclaw_bundle)
|
||
|
||
def _resolve_engine_from_rule(self, *, rule, engines, active_engine):
|
||
_ = active_engine
|
||
for item in engines:
|
||
if (
|
||
str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower()
|
||
and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip()
|
||
):
|
||
return dict(item)
|
||
return None
|
||
|
||
def _resolve_tier_engine(self, *, tier, engines, active_engine):
|
||
_ = (engines, active_engine)
|
||
if str(tier).strip().lower() == "high":
|
||
return dict(alternate_high_engine)
|
||
return dict(low_engine)
|
||
|
||
def _ensure_openclaw_control_plane_engine(self, engine):
|
||
return dict(engine)
|
||
|
||
def _normalize_engine_settings(self, raw):
|
||
_ = raw
|
||
return {}
|
||
|
||
def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label):
|
||
_ = (function_name, settings, engine_label)
|
||
return dict(engine)
|
||
|
||
def _is_invalid_structured_output(self, *, function_name, response_text):
|
||
_ = (function_name, response_text)
|
||
return False
|
||
|
||
async def _chat_completion(self, **kwargs):
|
||
captured["chat_kwargs"] = kwargs
|
||
return "GLM-5 answer"
|
||
|
||
llm = FakeLLM()
|
||
text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure(
|
||
db=object(),
|
||
tenant_id=1,
|
||
function_name="pmflow_semantic_context_decision",
|
||
failed_engine=dict(low_engine),
|
||
prompt="请返回结构化意图",
|
||
system_prompt="system prompt",
|
||
temperature=0.2,
|
||
max_tokens=512,
|
||
response_mode="complete",
|
||
error=ValueError("LLM 返回空内容"),
|
||
)
|
||
|
||
assert text == "GLM-5 answer"
|
||
assert isinstance(resolved_engine, dict)
|
||
resolved_meta = resolved_engine["_routing_meta"]
|
||
assert resolved_meta["planned_tier"] == "low"
|
||
assert resolved_meta["executed_tier"] == "high"
|
||
assert resolved_meta["routing_decision"] == "low_to_high_escalated"
|
||
assert captured["chat_kwargs"]["provider"] == "openai"
|
||
assert captured["chat_kwargs"]["model"] == "GLM-5"
|
||
log_text = "\n".join(message for _, message in captured["logs"])
|
||
assert "高阶升级成功" in log_text
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> None:
|
||
captured: dict[str, object] = {"logs": []}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_tier_escalation,
|
||
"_runtime_log",
|
||
lambda scope, message: captured["logs"].append((scope, message)),
|
||
)
|
||
|
||
low_engine = {
|
||
"id": "low-1",
|
||
"provider": "glm",
|
||
"model": "qwen-plus",
|
||
"base_url": "https://open.bigmodel.cn/api/paas/v4",
|
||
"api_key": "low-key",
|
||
}
|
||
alternate_high_engine = {
|
||
"id": "high-deepseek-1",
|
||
"provider": "deepseek",
|
||
"model": "deepseek-chat",
|
||
"base_url": "https://api.deepseek.com/v1",
|
||
"api_key": "deepseek-key",
|
||
}
|
||
glm5_engine = {
|
||
"id": "high-glm5-1",
|
||
"provider": "openai",
|
||
"model": "GLM-5",
|
||
"base_url": "https://api.openai.com/v1",
|
||
"api_key": "glm5-key",
|
||
}
|
||
bundle = {
|
||
"settings": {},
|
||
"active_engine_id": "low-1",
|
||
"engines": [low_engine, alternate_high_engine, glm5_engine],
|
||
}
|
||
openclaw_bundle = {
|
||
"rules": [
|
||
{
|
||
"name": "engine_low_semantic_context_exact",
|
||
"enabled": True,
|
||
"priority": 310,
|
||
"tier": "low",
|
||
"provider": "glm",
|
||
"model": "qwen-plus",
|
||
"api_env": "DASHSCOPE_API_KEY",
|
||
"function_exact": "pmflow_semantic_context_decision",
|
||
}
|
||
]
|
||
}
|
||
|
||
class FakeLLM(LLMTierEscalationMixin):
|
||
def _low_to_high_escalation_enabled(self) -> bool:
|
||
return True
|
||
|
||
def _is_low_tier_function(self, function_name: str) -> bool:
|
||
return function_name == "pmflow_semantic_context_decision"
|
||
|
||
@staticmethod
|
||
def _rule_matches_function(rule, function_name: str) -> bool:
|
||
fn = str(function_name or "").strip().lower()
|
||
if not fn:
|
||
return False
|
||
if bool(rule.get("match_all", False)):
|
||
return True
|
||
exact = str(rule.get("function_exact") or "").strip().lower()
|
||
if exact and fn == exact:
|
||
return True
|
||
prefix = str(rule.get("function_prefix") or "").strip().lower()
|
||
if prefix and fn.startswith(prefix):
|
||
return True
|
||
match_any = rule.get("match_any")
|
||
if isinstance(match_any, list):
|
||
for hint in match_any:
|
||
token = str(hint or "").strip().lower()
|
||
if token and token in fn:
|
||
return True
|
||
return False
|
||
|
||
async def _get_or_init_bundle(self, db, *, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
return SimpleNamespace(), bundle
|
||
|
||
def _resolve_active_engine_from_bundle(self, bundle_value):
|
||
_ = bundle_value
|
||
return dict(low_engine)
|
||
|
||
def _load_openclaw_routing_bundle(self):
|
||
return dict(openclaw_bundle)
|
||
|
||
def _resolve_engine_from_rule(self, *, rule, engines, active_engine):
|
||
_ = active_engine
|
||
for item in engines:
|
||
if (
|
||
str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower()
|
||
and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip()
|
||
):
|
||
return dict(item)
|
||
return None
|
||
|
||
def _resolve_tier_engine(self, *, tier, engines, active_engine):
|
||
_ = (engines, active_engine)
|
||
if str(tier).strip().lower() == "high":
|
||
return dict(alternate_high_engine)
|
||
return dict(low_engine)
|
||
|
||
def _ensure_openclaw_control_plane_engine(self, engine):
|
||
return dict(engine)
|
||
|
||
def _normalize_engine_settings(self, raw):
|
||
_ = raw
|
||
return {}
|
||
|
||
def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label):
|
||
_ = (function_name, settings, engine_label)
|
||
return dict(engine)
|
||
|
||
def _is_invalid_structured_output(self, *, function_name, response_text):
|
||
_ = (function_name, response_text)
|
||
return False
|
||
|
||
def _extract_structured_object_from_response(
|
||
self,
|
||
*,
|
||
function_name,
|
||
response,
|
||
fallback_text,
|
||
provider,
|
||
model,
|
||
):
|
||
_ = (function_name, response, fallback_text)
|
||
return StructuredExtractionResult(
|
||
payload={"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"},
|
||
source_field="generated_text",
|
||
provider=provider,
|
||
model=model,
|
||
raw_text='{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}',
|
||
validation="ok",
|
||
)
|
||
|
||
async def _chat_completion_raw(self, **kwargs):
|
||
captured["chat_kwargs"] = kwargs
|
||
raw_text = '{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}'
|
||
return ChatCompletionResult(text=raw_text, raw_response=raw_text)
|
||
|
||
llm = FakeLLM()
|
||
result, resolved_engine = await llm._retry_structured_with_high_tier_after_low_model_failure(
|
||
db=object(),
|
||
tenant_id=1,
|
||
function_name="pmflow_semantic_context_decision",
|
||
failed_engine=dict(low_engine),
|
||
prompt="请返回结构化意图",
|
||
system_prompt="system prompt",
|
||
temperature=0.2,
|
||
max_tokens=512,
|
||
response_mode="complete",
|
||
error=ValueError("LLM 返回空内容"),
|
||
)
|
||
|
||
assert result is not None
|
||
assert result.payload["intent"] == "query.read_only"
|
||
assert isinstance(resolved_engine, dict)
|
||
resolved_meta = resolved_engine["_routing_meta"]
|
||
assert resolved_meta["planned_tier"] == "low"
|
||
assert resolved_meta["executed_tier"] == "high"
|
||
assert resolved_meta["routing_decision"] == "low_to_high_escalated"
|
||
assert captured["chat_kwargs"]["provider"] == "openai"
|
||
assert captured["chat_kwargs"]["model"] == "GLM-5"
|
||
log_text = "\n".join(message for _, message in captured["logs"])
|
||
assert "高阶结构化升级成功" in log_text
|