import asyncio from datetime import datetime, timedelta from types import SimpleNamespace import pytest import pytest_asyncio from fastapi import HTTPException from sqlalchemy import JSON, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app.api.ai_pmo_api import routes_core from app.api.ai_pmo_api import helpers_inbox_filters from app.api.ai_pmo_api import helpers_inbox_payload from app.api.ai_pmo_api.schemas import PMOProcessTextRequest from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_resolve import ( KernelIntentResolveMixin, ) from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_llm import ( KernelIntentLLMMixin, ) from app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_rules import ( KernelIntentRulesMixin, ) from app.services.ai_pmo.ai_pmo_kernel.ai_agent_kernel import gate_execution from app.services.ai_pmo.ai_pmo_kernel.ai_pmo_reply_renderer import ReplyRenderer from app.services.ai_pmo.ai_pmo_kernel import ai_pmo_typed_executor_mixins_parse from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_entry from app.services.ai_pmo.ai_pmo_kernel.mixins.ingress import ai_pmo_kernel_mixins_ingress_render from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_prepare_manual from app.services.ai_pmo.ai_pmo_kernel_world_model_core import mixin_project from app.services.ai_pmo.ai_pmo_kernel.mixins.world_model import ( ai_pmo_kernel_mixins_world_model_session, ) from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_mutations from app.services.ai_pmo.ai_pmo_intent import CanonicalIntent from app.services.ai_pmo.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.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.mounted_ai_runtime import MountedDraftWorkspace, MountedTopicWorkspace from app.utils.thread_context import build_feishu_conversation_scope_key, build_feishu_thread_key def _use_sqlite_requirement_plan_json_types() -> None: for table in ( MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, ): for column in table.columns: if column.name in { "facts_json", "goals_json", "constraints_json", "options_json", "open_questions", "pending_actions", "bound_entities", "metadata_json", "requirement_card", "draft_payload", "required_confirmations", }: column.type = JSON() @pytest_asyncio.fixture async def requirement_plan_db_session(): engine = create_async_engine("sqlite+aiosqlite:///:memory:") _use_sqlite_requirement_plan_json_types() async with engine.begin() as conn: await conn.run_sync( lambda sync_conn: Base.metadata.create_all( sync_conn, tables=[ MountedTopicWorkspace.__table__, MountedDraftWorkspace.__table__, ], ) ) maker = async_sessionmaker(engine, expire_on_commit=False) async with maker() as session: yield session await engine.dispose() @pytest.mark.asyncio async def test_process_text_route_uses_pmflow_manual_ingress(monkeypatch) -> None: captured: dict[str, object] = {} class FakeKernel: def __init__(self, db, tenant_id: int): captured["init_db"] = db captured["init_tenant_id"] = tenant_id async def process_ingress(self, **kwargs): captured["ingress_kwargs"] = kwargs return { "handled": True, "status": "answered", "classification": "question", "direct_reply_required": True, "direct_reply_text": "当前主链正常。", } monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) request = PMOProcessTextRequest( message_text="现在进展怎么样?", sender="产品经理A", source_message_id="manual-1", project_id=11, ) current_user = SimpleNamespace(name="ignored", username="ignored") response = await routes_core.process_text( request=request, current_user=current_user, db=object(), tenant_id=5, ) assert response["code"] == 0 data = response["data"] assert data["status"] == "answered" kwargs = captured["ingress_kwargs"] assert isinstance(kwargs, dict) assert kwargs["source_system"] == "pmflow_manual" assert kwargs["source_message_id"] == "manual-1" assert kwargs["message_text"] == "现在进展怎么样?" assert kwargs["sender"] == "产品经理A" assert kwargs["channel_ref"] == "pmflow_manual" assert kwargs["chat_type"] == "manual" assert kwargs["project_hint_id"] == 11 @pytest.mark.asyncio async def test_process_text_route_rejects_unhandled_result(monkeypatch) -> None: class FakeKernel: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def process_ingress(self, **kwargs): _ = kwargs return {"handled": False, "reason": "kernel_not_handled"} monkeypatch.setattr(routes_core, "AIPMOKernelService", FakeKernel) request = PMOProcessTextRequest(message_text="hello") current_user = SimpleNamespace(name="u", username="u") with pytest.raises(HTTPException) as exc_info: await routes_core.process_text( request=request, current_user=current_user, db=object(), tenant_id=1, ) assert exc_info.value.status_code == 400 assert str(exc_info.value.detail) == "kernel_not_handled" @pytest.mark.asyncio async def test_prepare_mixed_sidecar_context_passes_mixed_contract(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): captured["tenant_id"] = tenant_id _ = db async def classify_intent(self, text: str): captured["classified_text"] = text return { "intent": "mixed", "question_part": "现在进展如何", "statement_part": "新增任务确认流", } async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "[回答] 进展稳定\n[已收到需求] 新增任务确认流"} class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 9 monkeypatch.setattr(mixin_entry, "AdaptiveRAGService", FakeAdaptiveRAGService) kernel = FakeKernel() clean_text, mixed_meta = await kernel._prepare_mixed_sidecar_context( source_system="pmflow_manual", source_message_id="manual-msg-2", channel_ref="pmflow-chat", source_sender_open_id="ou-pmflow-1", thread_context={"reply_to_message_id": "reply-1"}, message_text="顺便问下现在进展如何,并且新增任务确认流", project_hint_id=22, ) assert clean_text == "新增任务确认流" assert mixed_meta["mixed_intent"] is True assert mixed_meta["mixed_question_part"] == "现在进展如何" assert mixed_meta["mixed_signal_part"] == "新增任务确认流" assert "[回答] 进展稳定" in str(mixed_meta.get("mixed_sidecar_reply") or "") answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_via"] == "pmflow_manual" assert answer_kwargs["project_id"] is None assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["source_sender_open_id"] == "ou-pmflow-1" assert answer_kwargs["thread_context"] == {"reply_to_message_id": "reply-1"} assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["conversation_key"] == "manual-msg-2" @pytest.mark.asyncio async def test_process_ingress_prioritizes_draft_pending_confirm(monkeypatch) -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 1 self.draft_called = False async def _prepare_ingress_envelope(self, **kwargs): _ = kwargs return { "early_result": None, "clean_text": "确认", "synthetic_message_id": "s-msg-1", "closure_payload": {}, "ingest_event": SimpleNamespace(id=100), "operator": SimpleNamespace(id=8), "work_item": SimpleNamespace(id=55), "session": SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM), } async def _maybe_handle_draft_confirmation_ingress(self, **kwargs): self.draft_called = True _ = kwargs return { "handled": True, "status": "answered", "classification": "command", "direct_reply_required": True, "direct_reply_text": "已确认草稿并继续执行。", } def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False async def _prepare_mixed_sidecar_context(self, **kwargs): _ = kwargs raise AssertionError("draft_pending_confirm flow should bypass mixed sidecar precheck") kernel = FakeKernel() result = await kernel.process_ingress( source_system="feishu", source_message_id="feishu-1", message_text="确认", sender="tester", channel_ref="chat-1", source_sender_open_id="ou_xxx", ingress_options={"thread_context": {"reply_to_message_id": "msg-root-1"}}, ) assert kernel.draft_called is True assert result["status"] == "answered" assert "草稿" in result["direct_reply_text"] @pytest.mark.asyncio async def test_process_ingress_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_short_confirmation_requires_reply_anchor() -> None: session = SimpleNamespace( last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM, last_active_at=datetime.now(), ) sent_at = datetime.now() + timedelta(seconds=1) assert ( KernelIntentRulesMixin._is_short_confirmation_message( message_text="确认", session=session, message_sent_at=sent_at, session_snapshot=None, reply_anchor_present=False, ) is False ) assert ( KernelIntentRulesMixin._is_short_confirmation_message( message_text="确认", session=session, message_sent_at=sent_at, session_snapshot=None, reply_anchor_present=True, ) is True ) def test_natural_revision_routes_to_draft_confirmation_without_reply_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="第一版先做直播录屏和评论回放切片,再加亮点和掉点总结,最后给一份复盘清单。", ingress_options=None, ) is True def test_scope_narrowing_revision_routes_to_draft_confirmation_without_reply_anchor() -> None: session = SimpleNamespace(last_intent=mixin_entry.STATE_DRAFT_PENDING_CONFIRM) assert mixin_entry.KernelIngressFlowEntryMixin._should_route_to_draft_confirmation( session=session, message_text="第一版先别做太大,先把三件事做好就行:直播录屏和评论回放切片、亮点和掉点自动总结、给团队一份能直接执行的复盘清单。", ingress_options=None, ) is True 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 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 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 @pytest.mark.asyncio async def test_explicit_new_requirement_clears_inherited_session_project_context() -> None: class _FakeDB: async def flush(self): return None class FakeKernel( mixin_entry.KernelIngressFlowEntryMixin, ai_pmo_kernel_mixins_world_model_session.KernelWorldModelSessionMixin, ): def __init__(self) -> None: self.db = _FakeDB() self.tenant_id = 1 kernel = FakeKernel() session = SimpleNamespace( current_project_id=104, last_intent="query.read_only", last_message_text="A", ) cleared = await kernel._maybe_reset_session_project_context_for_new_requirement( session=session, sender="tester", channel_ref="chat-active", source_sender_open_id="ou_active", source_message_id="msg-new-topic", thread_context=None, message_text="跟你聊个新需求,关于换季系统自动推荐搭配衣服", ) assert cleared is True assert session.current_project_id is None assert session.last_intent == "query.read_only" @pytest.mark.asyncio @pytest.mark.parametrize("message_text", ["A", "确认", "你自行推荐"]) async def test_orphan_requirement_followup_returns_direct_reprompt_when_no_active_session(message_text: str) -> None: 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 not None assert result["handled"] is True assert result["direct_reply_required"] is True assert "没有进行中的 A/B/C 收敛轮次" in str(result["direct_reply_text"] or "") @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_mounted_runtime_query_business_review_uses_plan_mode_and_non_executor_agent() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "business_review", "consultant_mode_preference": "clarify", }, reply_template_key=None, ) agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", auto_execute=True, route_metadata={ "message_intent": "business_review", "consultant_mode_preference": "clarify", }, reply_template_key=None, mode_hint=mode, ) assert mode == "plan" assert agent == "PlanCoach" def test_mounted_runtime_query_advise_uses_assignee_recommender() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "business_review", "consultant_mode_preference": "advise", "advise_subtype": "assignee", }, reply_template_key=None, ) agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", auto_execute=True, route_metadata={ "message_intent": "business_review", "consultant_mode_preference": "advise", "advise_subtype": "assignee", }, reply_template_key=None, mode_hint=mode, ) assert mode == "plan" assert agent == "AssigneeRecommender" def test_mounted_runtime_query_requirement_plan_uses_plan_coach() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, reply_template_key=None, ) agent = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", auto_execute=True, route_metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, reply_template_key=None, mode_hint=mode, ) assert mode == "plan" assert agent == "PlanCoach" def test_mounted_runtime_query_requirement_plan_uses_direct_metadata_for_plan_mode() -> None: mode = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_mode_override( workflow_route="query.answer", route_metadata={ "message_intent": "requirement_plan", "consultant_mode_preference": "clarify", }, reply_template_key=None, ) assert mode == "plan" def test_build_canonical_intent_keeps_project_update_instead_of_dropping_to_ignore() -> None: intent = KernelIntentRulesMixin._build_canonical_intent( intent_name="project.update", message_text="把项目2的 success criteria 改成 48 小时内必须上线。", project_id=2, confidence=0.91, metadata={"field": "success_criteria"}, ) assert intent.name == "project.update" assert intent.project_id == 2 assert intent.metadata["field"] == "success_criteria" def test_explicit_named_project_create_skips_existing_project_name_binding() -> None: assert mixin_project.KernelWorldModelCoreProjectMixin._should_skip_implicit_project_binding( message_text=( "最近我们做跨境直播复盘太慢了,想做个给运营团队用的小工具," "这个需求先叫 FeishuWebFlow-1775301894322。" ), ) is True def test_project_lookup_question_keeps_existing_project_name_binding() -> None: assert mixin_project.KernelWorldModelCoreProjectMixin._should_skip_implicit_project_binding( message_text="帮我看下跨境直播复盘这个项目现在怎么样?", ) is False @pytest.mark.asyncio async def test_draft_confirm_handler_prefers_latest_sender_scope_candidate_without_anchor() -> None: latest_work_item = SimpleNamespace( id=202, state="draft_pending_confirm", source_message_id="msg-latest", ) old_work_item = SimpleNamespace( id=101, state="draft_pending_confirm", source_message_id="msg-old", ) ingest_payload = { "sender": "tester", "channel_ref": "chat-1", "source_sender_open_id": "ou_123", } rows = [ (latest_work_item, SimpleNamespace(payload=dict(ingest_payload))), (old_work_item, SimpleNamespace(payload=dict(ingest_payload))), ] class FakeResult: def __init__(self, value): self._value = value def all(self): return list(self._value) class FakeDB: async def execute(self, _stmt): return FakeResult(rows) handler = DraftConfirmHandler(SimpleNamespace(db=FakeDB(), tenant_id=1)) target = await handler._resolve_pending_draft( sender="tester", source_system="feishu", channel_ref="chat-1", source_sender_open_id="ou_123", explicit_work_item_id=None, thread_context=None, ) assert target == rows[0] @pytest.mark.asyncio async def test_draft_confirm_handler_skips_followup_wrapper_when_resolving_latest_draft() -> None: followup_wrapper = SimpleNamespace( id=303, state="draft_pending_confirm", source_message_id="msg-followup", intent="followup.confirm", ) latest_project_draft = SimpleNamespace( id=202, state="draft_pending_confirm", source_message_id="msg-project", intent="project_create.create", ) ingest_payload = { "sender": "tester", "channel_ref": "chat-1", "source_sender_open_id": "ou_123", } rows = [ (followup_wrapper, SimpleNamespace(payload=dict(ingest_payload))), (latest_project_draft, SimpleNamespace(payload=dict(ingest_payload))), ] class FakeResult: def __init__(self, value): self._value = value def all(self): return list(self._value) class FakeDB: async def execute(self, _stmt): return FakeResult(rows) handler = DraftConfirmHandler(SimpleNamespace(db=FakeDB(), tenant_id=1)) target = await handler._resolve_pending_draft( sender="tester", source_system="feishu", channel_ref="chat-1", source_sender_open_id="ou_123", explicit_work_item_id=None, thread_context=None, ) assert target == rows[1] def test_build_feishu_thread_key_prefers_actor_scope_without_anchor() -> None: assert build_feishu_thread_key( source_chat_id="chat-1", source_sender_open_id="ou_dm_1", sender="产品经理A", source_message_id="om_current_message", thread_context=None, ) == "feishu:chat-1:ou_dm_1" def test_low_value_closure_payload_helper_handles_summary_text() -> None: assert helpers_inbox_filters._is_low_value_closure_payload( {"closure_summary": "待补充核心痛点"} ) is False def test_serialize_work_item_summary_handles_message_text_without_nameerror() -> None: row = SimpleNamespace( id=9, source_system="pmflow_manual", source_message_id="msg-9", draft_content={}, state="received", review_summary={}, action_bundle={}, intent="query.read_only", project_id=None, signal_id=None, reply_template_key=None, execution_result={}, draft_expires_at=None, draft_version=0, confirm_message_id=None, created_at=None, updated_at=None, decision_level="portfolio", ) ingest = SimpleNamespace(payload={"message_text": " 最近 状态 怎么样 "}) payload = helpers_inbox_payload._serialize_work_item_summary( row=row, ingest=ingest, project=None, ) assert payload["source_message_text"] == "最近 状态 怎么样" def test_coerce_rag_business_review_scene_keeps_rag_ask_on_query_chain() -> None: class FakeKernel(mixin_entry.KernelIngressFlowEntryMixin): def __init__(self) -> None: self.tenant_id = 1 kernel = FakeKernel() original_intent = mixin_entry.CanonicalIntent( name="signal.create", confidence=0.91, project_id=None, signal_title="大模型 API 聚合平台", signal_summary="我现在要做一个大模型api聚合平台,你帮我分析下要怎么做,给出mvp需求", metadata={"llm_function": "classify_intent_ai_pmo_kernel"}, ) coerced = kernel._coerce_intent_for_query_scene( intent=original_intent, source_system="pmflow_manual", message_text="我现在要做一个大模型api聚合平台,你帮我分析下要怎么做,给出mvp需求", rag_request_context={ "asked_via": "pmflow_manual", "action_mode": "auto", }, 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" @pytest.mark.asyncio async def test_kernel_intent_resolve_inherits_recent_feishu_session_context_without_reply_anchor() -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentResolveMixin): def __init__(self) -> None: self.tenant_id = 1 def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): captured.update(kwargs) return CanonicalIntent( name="query.read_only", confidence=0.93, project_id=21, signal_title="", signal_summary="", metadata={}, ) resolver = FakeResolver() session = SimpleNamespace( sender="feishu:chat-1:ou_dm_1", last_intent="query.read_only", current_project_id=21, last_active_at=datetime.now(), ) _, source = await resolver._resolve_intent( message_text="我需要对应的需求mvp", session=session, world_model={}, thread_context=None, ) assert source == "llm_only" assert captured["session_last_intent_override"] == "query.read_only" assert captured["session_project_id_override"] == 21 @pytest.mark.asyncio async def test_kernel_intent_resolve_uses_llm_for_signal_source_request() -> None: class FakeResolver(KernelIntentResolveMixin, KernelIntentRulesMixin): def __init__(self) -> None: self.tenant_id = 1 self.called = False def _is_short_confirmation_message(self, **kwargs): _ = kwargs return False @staticmethod def _intent_llm_enabled() -> bool: return True async def _resolve_intent_with_llm(self, **kwargs): _ = kwargs self.called = True return CanonicalIntent( name="signal_source.onboard", confidence=0.92, project_id=2, signal_title="", signal_summary="", metadata={"source_request": True}, ) resolver = FakeResolver() intent, source = await resolver._resolve_intent( message_text="给项目ID: 2 接一个 YouTube 录屏加评论回放的信号源,先不要执行,先出草稿。", session=None, world_model={"resolved_project_id": 2}, thread_context=None, ) assert source == "llm_only" assert resolver.called is True assert intent.name == "signal_source.onboard" assert intent.project_id == 2 assert intent.metadata["source_request"] is True @pytest.mark.asyncio async def test_kernel_intent_llm_prompt_guides_natural_problem_statement_to_project_create(monkeypatch) -> None: captured: dict[str, object] = {} class FakeResolver(KernelIntentLLMMixin): def __init__(self) -> None: self.tenant_id = 1 self.db = object() def _build_canonical_intent(self, *, intent_name, message_text, project_id, confidence, metadata): # noqa: ANN001 return CanonicalIntent( name=intent_name, confidence=confidence, project_id=project_id, signal_title="", signal_summary=message_text[:300], metadata=metadata, ) async def fake_generate_structured_object(prompt: str, **kwargs): # noqa: ANN001 captured["prompt"] = prompt captured["kwargs"] = kwargs return StructuredExtractionResult( payload={ "family": "project_create", "action": "create", "intent": "project.create", "confidence": 0.93, "project_id": None, "requested_count": 1, "requires_confirmation": False, "parameters": {}, "reasoning": "用户在描述真实业务痛点,并明确表达想做一个工具解决。", }, source_field="generated_text", provider="mock", model="mock-model", raw_text='{"intent":"project.create"}', validation="ok", ) monkeypatch.setattr( "app.services.ai_pmo.ai_pmo_kernel.mixins.intent.ai_pmo_kernel_mixins_intent_llm.llm_service.generate_structured_object", fake_generate_structured_object, ) resolver = FakeResolver() intent = await resolver._resolve_intent_with_llm( message_text="最近直播复盘太慢了,我想做个给运营用的小工具,能自动切片、总结亮点掉点。", session=None, world_model={}, rule_intent=None, ) assert intent is not None assert intent.name == "project.create" prompt = str(captured["prompt"] or "") assert "自然语言" in prompt assert "想做个工具" in prompt assert "先帮我分析" in prompt 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_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 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=12, metadata={ "rag_request_context": { "asked_by": 101, "asked_via": "pmflow_manual", "project_id": 19, "source_chat_id": "chat-abc", "session_id": 55, "force_new_session": True, "thinking_mode": "deep", "action_mode": "read_write", "sync_requirement": True, "message_intent": "mixed", "mixed_signal_part": "新增任务确认流", "consultant_mode_preference": "challenge", } }, ) result = await executor._execute_query_intent( intent=intent, message_text="请同步更新状态", operator_user_id=999, source_system="feishu", source_message_id="msg-1", ) assert result["status"] == "succeeded" assert "统一 ingress" in result["answer"] answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_by"] == 101 assert answer_kwargs["asked_via"] == "pmflow_manual" assert answer_kwargs["project_id"] == 19 assert answer_kwargs["source_chat_id"] == "chat-abc" assert answer_kwargs["session_id"] == 55 assert answer_kwargs["force_new_session"] is True assert answer_kwargs["thinking_mode"] == "deep" assert answer_kwargs["action_mode"] == "read_write" assert answer_kwargs["sync_requirement"] is True assert answer_kwargs["message_intent"] == "mixed" assert answer_kwargs["mixed_signal_part"] == "新增任务确认流" assert answer_kwargs["consultant_mode_preference"] == "challenge" @pytest.mark.asyncio async def test_execute_query_intent_feishu_fallback_uses_thread_key(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "fallback-answer", "session_id": 7} class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "thread_context": {"root_message_id": "root-1"}, "source_sender_open_id": "ou-feishu-99", }, ) result = await executor._execute_query_intent( intent=intent, message_text="最近AI行业有什么动态", operator_user_id=999, source_system="feishu", source_message_id="msg-feishu-1", ) assert result["status"] == "succeeded" answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_by"] is None assert answer_kwargs["asked_via"] == "feishu" assert answer_kwargs["source_chat_id"] is None assert answer_kwargs["source_sender_open_id"] == "ou-feishu-99" assert answer_kwargs["thread_context"] == {"root_message_id": "root-1"} assert answer_kwargs["conversation_key"] == build_feishu_conversation_scope_key( source_chat_id=None, source_sender_open_id="ou-feishu-99", sender=None, ) @pytest.mark.asyncio async def test_execute_query_intent_uses_direct_scene_metadata_when_no_explicit_rag_request(monkeypatch) -> None: captured: dict[str, object] = {} class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = db captured["tenant_id"] = tenant_id async def answer_question(self, **kwargs): captured["answer_kwargs"] = kwargs return {"answer": "corrected-answer", "session_id": 9} class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-dm-9", "source_sender_open_id": "ou-feishu-9", "message_intent": "correction_escalation", "consultant_mode_preference": "challenge", "force_thinking_mode": "deep", }, ) result = await executor._execute_query_intent( intent=intent, message_text="不对,调用高级模型来回答我的问题", operator_user_id=999, source_system="feishu", source_message_id="msg-feishu-9", ) assert result["status"] == "succeeded" answer_kwargs = captured["answer_kwargs"] assert isinstance(answer_kwargs, dict) assert answer_kwargs["asked_by"] is None assert answer_kwargs["asked_via"] == "feishu" assert answer_kwargs["source_chat_id"] == "chat-dm-9" assert answer_kwargs["source_sender_open_id"] == "ou-feishu-9" assert answer_kwargs["message_intent"] == "correction_escalation" assert answer_kwargs["consultant_mode_preference"] == "challenge" assert answer_kwargs["thinking_mode"] == "deep" assert answer_kwargs["conversation_key"] == build_feishu_conversation_scope_key( source_chat_id="chat-dm-9", source_sender_open_id="ou-feishu-9", sender=None, ) @pytest.mark.asyncio async def test_execute_query_intent_rolls_back_failed_rag_transaction(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise RuntimeError("rag failed before response") class FakeDB: def __init__(self) -> None: self.rollback_calls = 0 self.sync_session = SimpleNamespace(is_active=False) async def rollback(self) -> None: self.rollback_calls += 1 class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 3 @staticmethod def _build_query_error_message(exc: Exception) -> str: return f"query failed: {exc}" monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace(project_id=12, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="现在完整调用链是什么?", operator_user_id=999, source_system="pmflow_manual", source_message_id="msg-rollback-1", ) assert result["status"] == "failed_safe" assert "rag failed" in str(result["error_message"] or "") assert executor.db.rollback_calls == 1 @pytest.mark.asyncio async def test_execute_query_intent_does_not_rollback_healthy_session_on_rag_failure(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise RuntimeError("rag failed before response") class FakeDB: def __init__(self) -> None: self.rollback_calls = 0 self.sync_session = SimpleNamespace(is_active=True) async def rollback(self) -> None: self.rollback_calls += 1 class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 3 @staticmethod def _build_query_error_message(exc: Exception) -> str: return f"query failed: {exc}" monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace(project_id=12, metadata={}) result = await executor._execute_query_intent( intent=intent, message_text="先分析,不要直接创建。", operator_user_id=999, source_system="pmflow_manual", source_message_id="msg-rollback-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_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 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) 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 "哪一步" in answer assert answer.count("?") <= 1 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 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 "主要想根据什么来打" in answer assert "三件事" not in answer assert answer.count("?") <= 1 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 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) 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="先按技术指标来,主要给投顾看。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-1", ) assert first["status"] == "succeeded" assert second["status"] == "succeeded" assert "根据什么来打" in str(first["answer"] or "") assert "我先记下" in str(second["answer"] or "") assert "技术指标" in str(second["answer"] or "") assert "主要想根据什么来打" not in str(second["answer"] or "") assert "怎么接收" in str(second["answer"] or "") or "输出" in str(second["answer"] or "") topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace).where(MountedTopicWorkspace.tenant_id == 3) ) ).scalar_one() draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() card = draft.requirement_card or {} assert topic.mode == "plan" assert draft.state == "collecting" assert card["scoring_basis"] assert "技术指标" in str(card["scoring_basis"]) assert "投顾" 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 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="先按技术指标来,主要给投顾看。", 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", ) final_result = await executor._execute_query_intent( intent=intent_followup, message_text="第一版就先保留分数、方向判断和一句解释,别做太重。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-2c", ) 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 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-2c" 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 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() assert len(rows) == 2 assert "切到新需求" 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_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 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 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 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 "主要给谁用" 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 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 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="技术指标吧", 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="飞书日报就行", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3c", ) fifth = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="每天收盘后发", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3d", ) final_result = await executor._execute_query_intent( intent=fragmented_followup_intent, message_text="第一版先保留分数、方向判断和一句解释就够了", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-reply-3e", ) assert "根据什么来打" 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" assert "先保留哪些输出" in str(fifth["answer"] or "") assert str(fifth["rag_result"]["query_type"]) == "plan" final_answer = str(final_result["answer"] or "") assert "需求草稿" in final_answer assert "【需求名称】" in final_answer assert "【第一版范围】" in final_answer assert 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["scoring_basis"] assert "投顾" in str(card["target_user"] or "") assert "飞书日报" in str(card["output_form"] or "") assert "收盘后" in str(card["cadence"] or "") assert "方向判断" in str(card["first_scope"] or "") @pytest.mark.asyncio async def test_execute_query_intent_requirement_plan_generalizes_for_meeting_minutes_tool( 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("generic 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 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="先给内部 PM 和研发负责人用。", 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="飞书总结就行,不用单独页面。", 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="每次会后半小时内整理出来。", 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 "主要给谁用" in str(first["answer"] or "") or "主要给谁看" in str(first["answer"] or "") assert "A." in str(first["answer"] or "") assert "B." in str(first["answer"] or "") assert "C." in str(first["answer"] or "") assert "怎么给结果" in str(second["answer"] or "") or "怎么接收" in str(second["answer"] or "") or "最后怎么给你" in str(second["answer"] or "") assert "A." in str(second["answer"] or "") assert "B." in str(second["answer"] or "") assert "C." in str(second["answer"] or "") assert "多久整理出来" in str(third["answer"] or "") or "时效" in str(third["answer"] or "") assert "A." in str(third["answer"] or "") assert "B." in str(third["answer"] or "") assert "C." in str(third["answer"] or "") assert "先保留哪些字段" in str(fourth["answer"] or "") or "第一版先保留哪些字段" in str(fourth["answer"] or "") final_answer = str(final_result["answer"] or "") assert "需求草稿" in final_answer assert "【输入材料】会议纪要" in final_answer assert "【目标用户】内部 PM 和研发负责人" in final_answer assert "【输出形式】飞书总结" in final_answer assert "【响应时效】每次会后半小时内" in final_answer assert "【第一版范围】第一版先抽待办、责任人和截止时间,不做复杂优先级。" in final_answer assert "#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["source_material"] == "会议纪要" assert "内部 PM 和研发负责人" in str(card["target_user"] or "") assert card["output_form"] == "飞书总结" assert card["cadence"] == "每次会后半小时内" assert "待办" in str(card["first_scope"] or "") @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 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 = await executor._execute_query_intent( intent=followup_intent, message_text="B,先做项目级试点,带基础审批和回滚。", operator_user_id=999, source_system="feishu", source_message_id="msg-plan-it-6", ) 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 "") assert "🧭 我先按“批量执行闭环”继续收尾。" in str(fifth["answer"] or "") assert "项目级试点" in str(fifth["answer"] or "") assert "⭐ 当前我更推荐:B" in str(fifth["answer"] or "") final_answer = str(final_result["answer"] or "") assert "📋 我先按当前收敛结果,整理成一版更像正式 brief 的需求草稿:" in final_answer assert "🧭【一句话判断】" in final_answer assert "🏗️【项目性质】这件事更像发布治理平台" in final_answer assert "👥【服务对象】第一版先服务平台运维团队" in final_answer assert "📡【信号源】当前至少要接入:" in final_answer assert "飞书需求消息" in final_answer assert "部署目标清单" in final_answer assert "📝【当前已知信息】目前已经明确:" in final_answer assert "⚙️【技术路线】当前更适合走任务编排平台版(FastAPI + React + Redis + Celery + PostgreSQL)" in final_answer assert "🧱【第一版范围】第一版先做批量执行闭环,包含任务创建、执行日志和失败重试。" in final_answer assert "🔄【建议核心流程】" in final_answer assert "生成部署任务" in final_answer assert "👤【人员配置】建议最小配置:" in final_answer assert "平台/运维工程师" in final_answer assert "🚀【推进方式】先按项目级试点(带基础审批和回滚)推进" in final_answer assert "⚠️【风险与依赖】当前主要风险在" in final_answer assert "凭证管理" in final_answer assert "❓【待确认】还需要你拍板:" in final_answer assert "部署目标范围" in final_answer assert "试点负责人" in final_answer assert str(final_result["rag_result"]["query_type"]) == "draft" draft = ( await requirement_plan_db_session.execute( select(MountedDraftWorkspace).where(MountedDraftWorkspace.tenant_id == 3) ) ).scalar_one() card = draft.requirement_card or {} assert card["plan_profile"] == "it_project" assert card["project_nature"] == "发布治理平台" assert card["target_user"] == "平台运维团队" assert "FastAPI" in str(card["tech_route"] or "") assert "批量执行闭环" in str(card["first_scope"] or "") assert "项目级试点" in str(card["delivery_mode"] or "") @pytest.mark.asyncio async def test_execute_query_intent_single_choice_does_not_resume_awaiting_requirement_draft( monkeypatch, requirement_plan_db_session, ) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs return { "answer": "当前没有进行中的 A/B/C 收敛轮次。你先发一句新需求,我按新的 topic 接着收。", "query_type": "status", "confidence": 0.42, "missing_fields": [], } class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = requirement_plan_db_session self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() conversation_scope_key = build_feishu_conversation_scope_key( source_chat_id="chat-old-draft", source_sender_open_id="ou-old-draft", sender=None, ) old_topic = MountedTopicWorkspace( tenant_id=3, topic_key="feishu:chat-old-draft:message:sim-plan-1431-1", source_system="feishu", channel_ref="chat-old-draft", conversation_scope_key=conversation_scope_key, root_message_id="sim-plan-1431-1", mode="plan", status="active", plan_status="awaiting_execute_confirm", pending_actions=["confirm_requirement_draft"], metadata_json={ "plan_mode": True, "plan_question_round": 17, "plan_last_prompt": "下一个关键问题:你更想怎么接收这个结果,是飞书日报、页面看板,还是只推送一个分数加一句解释?", }, facts_json={ "title": "每日 A 股趋势打分工具", "plan_profile": "scoring", "target_user": "投顾", }, ) requirement_plan_db_session.add(old_topic) await requirement_plan_db_session.flush() requirement_plan_db_session.add( MountedDraftWorkspace( tenant_id=3, topic_workspace_id=int(old_topic.id), state="ready", draft_version=1, requirement_card={ "plan_profile": "scoring", "title": "每日 A 股趋势打分工具", "goal": "每天产出一个可解释的 A 股趋势分数", "target_user": "投顾", "scoring_basis": "技术指标(均线、成交量、涨跌比等)", "output_form": "飞书日报", "cadence": "每日", "first_scope": "第一版先做 A 股趋势分数和一句解释", }, draft_payload={"title": "每日 A 股趋势打分工具"}, required_confirmations=["需求草稿需要人工确认后才允许正式创建"], ) ) await requirement_plan_db_session.flush() followup_intent = SimpleNamespace( project_id=None, metadata={ "source_chat_id": "chat-old-draft", "source_sender_open_id": "ou-old-draft", }, ) result = await executor._execute_query_intent( intent=followup_intent, message_text="C", operator_user_id=999, source_system="feishu", source_message_id="msg-old-draft-c", ) answer = str(result["answer"] or "") assert "每日 A 股趋势打分工具" not in answer assert "需求草稿" not in answer assert "新需求" in answer assert str(result["rag_result"]["query_type"]) == "status" topic = ( await requirement_plan_db_session.execute( select(MountedTopicWorkspace).where(MountedTopicWorkspace.id == int(old_topic.id)) ) ).scalar_one() assert topic.plan_status == "awaiting_execute_confirm" assert topic.pending_actions == ["confirm_requirement_draft"] @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_advise_without_rag(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("requirement advice should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) executor = FakeExecutor() intent = SimpleNamespace( project_id=None, metadata={ "semantic_bundle": { "diagnostics": { "message_intent": "business_review", "consultant_mode_preference": "advise", } }, }, ) result = await executor._execute_query_intent( intent=intent, message_text="如果这个政策雷达项目先做 MVP,你觉得范围怎么收才比较稳?", operator_user_id=999, source_system="feishu", source_message_id="msg-chat-3", ) assert result["status"] == "succeeded" assert "只保留一个核心用户对象" in str(result["answer"] or "") assert str(result["rag_result"]["query_type"]) == "advice" @pytest.mark.asyncio async def test_execute_query_intent_short_circuits_requirement_advise_by_text_without_metadata(monkeypatch) -> None: class FakeAdaptiveRAGService: def __init__(self, db, tenant_id: int): _ = (db, tenant_id) async def answer_question(self, **kwargs): _ = kwargs raise AssertionError("scope advice should not enter heavy RAG path") class FakeExecutor(mixin_execute_query_project.ExecuteQueryProjectMixin): def __init__(self) -> None: self.db = object() self.tenant_id = 3 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) 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工程师。" monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) 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 monkeypatch.setattr(mixin_execute_query_project, "AdaptiveRAGService", FakeAdaptiveRAGService) 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 monkeypatch.setattr(DraftConfirmHandler, "has_active_draft_session", _fake_has_active_draft_session) kernel = FakeKernel() should_route = await kernel._should_route_to_draft_confirmation_session( session=SimpleNamespace(last_intent="query.answer"), sender="tester", source_system="feishu", channel_ref="chat-active", source_sender_open_id="ou_active", message_text="确认", ingress_options=None, ) assert should_route is True assert 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 False def test_mounted_runtime_agent_name_uses_plan_coach_for_scope_advise() -> None: agent_name = mixin_entry.KernelIngressFlowEntryMixin._resolve_mounted_runtime_agent_name( workflow_route="query.answer", auto_execute=False, route_metadata={"consultant_mode_preference": "advise", "advise_subtype": "scope"}, reply_template_key=None, mode_hint="plan", ) assert agent_name == "PlanCoach" @pytest.mark.asyncio async def test_execute_project_update_intent_resolves_string_project_id_without_name_error() -> None: class FakeDB: def __init__(self) -> None: self.commits = 0 self.refreshed: list[object] = [] async def commit(self) -> None: self.commits += 1 async def refresh(self, row) -> None: self.refreshed.append(row) class FakeExecutor(mixin_execute_mutations.ExecuteMutationsMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 async def _resolve_project(self, *, project_id, project_title, project_name): _ = (project_title, project_name) return ( SimpleNamespace( id=project_id, title="旧标题", name="旧标题", description=None, status="active", domain_id=None, assigned_role=None, owner_id=None, signal_count=0, created_at=None, updated_at=None, ), [], ) async def _log_operation(self, **kwargs): _ = kwargs async def _invalidate_project_cache(self, *, project_id: int) -> None: _ = project_id executor = FakeExecutor() intent = SimpleNamespace(name="project.update", project_id=None, metadata={}) result = await executor._execute_project_update_intent( intent=intent, params={"project_id": "104", "title": "新标题"}, operator_user_id=9, ) assert result["status"] == "succeeded" assert result["project"]["id"] == 104 assert result["project"]["title"] == "新标题" assert executor.db.commits == 1 def test_deterministic_query_type_treats_project_risk_question_as_status() -> None: class FakeRAG(mixin_deterministic_query_core.AdaptiveRAGDeterministicQueryCoreMixin): pass rag = FakeRAG() assert rag._classify_query_type("帮我看看跨境直播项目最近有哪些风险和阻塞") == "status" @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions() -> None: project = SimpleNamespace(id=104, title="跨境直播项目", name="跨境直播项目", status="active") tasks = [ SimpleNamespace(status="todo", updated_at=datetime(2026, 4, 5, 10, 0), created_at=datetime(2026, 4, 5, 9, 0)), SimpleNamespace(status="in_progress", updated_at=datetime(2026, 4, 5, 11, 0), created_at=datetime(2026, 4, 5, 8, 0)), SimpleNamespace(status="done", updated_at=datetime(2026, 4, 5, 7, 0), created_at=datetime(2026, 4, 5, 6, 0)), ] class FakeScalarResult: def __init__(self, rows): self._rows = rows def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) class FakeDB: async def execute(self, stmt): _ = stmt return FakeScalarResult(tasks) class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 @staticmethod def _extract_project_lookup_keyword(question: str) -> str: _ = question return "跨境直播" @staticmethod def _extract_query_terms(question: str, preferred: str): _ = question return [preferred] async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int): _ = (keyword, project_id, limit) return [project] rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看跨境直播项目最近有哪些风险和阻塞", project_id=None, ) assert result is not None assert "风险/阻塞台账字段" in str(result["answer"] or "") assert "待处理任务数 1" in str(result["answer"] or "") @pytest.mark.asyncio async def test_fast_project_progress_answer_handles_risk_blocker_questions_without_tasks() -> None: project = SimpleNamespace(id=101, title="AI客服", name="AI客服", status="active") class FakeScalarResult: def __init__(self, rows): self._rows = rows def scalars(self): return SimpleNamespace(all=lambda: list(self._rows)) class FakeDB: async def execute(self, stmt): _ = stmt return FakeScalarResult([]) class FakeRAG(mixin_fast_answer_project_main.AdaptiveRAGFastAnswerProjectMainMixin): def __init__(self) -> None: self.db = FakeDB() self.tenant_id = 1 @staticmethod def _extract_project_lookup_keyword(question: str) -> str: _ = question return "AI客服" @staticmethod def _extract_query_terms(question: str, preferred: str): _ = question return [preferred] async def _search_projects_by_keyword(self, *, keyword: str, project_id, limit: int): _ = (keyword, project_id, limit) return [project] rag = FakeRAG() result = await rag._try_fast_project_progress_answer( clean_q="帮我看看 AI 客服工具项目最近有哪些风险和阻塞", project_id=None, ) assert result is not None assert "风险/阻塞台账字段" in str(result["answer"] or "") assert "没有可用的任务拆解记录" in str(result["answer"] or "") def test_typed_executor_common_exports_regex_helpers_to_query_mixins() -> None: assert mixin_execute_query_project.ExecuteQueryProjectMixin._extract_task_priority("p0 紧急任务") == "high" assert ( ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._extract_requested_signal_count( message_text="请帮我分析 3 个信号", metadata={}, ) == 3 ) assert ( ai_pmo_typed_executor_mixins_parse.TypedExecutorParseMixin._requires_strict_signal_analysis( message_text="先分析后入库 2 个信号", requested_count=2, ) is True ) def test_consultant_dispatch_uses_short_next_action_for_query_answers() -> None: dispatch = build_consultant_dispatch_contract( intent_name="query.answer", message_text="PMFlow 现在完整调用链是什么?", source_system="pmflow_manual", status="answered", classification="question", primary_operation_code="query.answer", risk_level="low", missing_fields=[], direct_reply_text=( "结论:PMFlow 当前完整调用链无法完整还原。\n\n" "依据:前端 `/ai-steward` 页面通过 `/rag/ask` 发起请求," "并继续进入 `AdaptiveRAGService.upsert_pmflow_record_async`。" ), ) assert dispatch["allow_execute"] is False assert dispatch["mode"] == "advise" assert dispatch["next_action"] == "继续追问具体模块、入口函数或文件路径,我会把调用链收窄到那一段。" assert "结论:" not in dispatch["next_action"] assert "/rag/ask" not in dispatch["next_action"] def test_build_capture_result_no_longer_exposes_consultant_dispatch() -> None: class _FakeKernel(ai_pmo_kernel_mixins_ingress_render.KernelIngressRenderMixin): tenant_id = 1 work_item = SimpleNamespace( id=18, project_id=7, signal_id=None, state="succeeded", intent="query.answer", reply_template_key="action_succeeded", decision_level="team", review_summary={"verified": True}, raw_message="现在进展怎么样?", source_system="pmflow_manual", draft_version=0, ) intent = CanonicalIntent( name="query.answer", confidence=0.93, project_id=7, signal_title="", signal_summary="当前主链正常。", metadata={}, ) result = _FakeKernel()._build_capture_result( work_item=work_item, intent=intent, execution_result={ "workflow_route": "query.answer", "rag_result": {"answer": "当前主链正常。"}, }, render={ "template_key": "action_succeeded", "text": "当前主链正常。", }, direct_reply_required=True, status="answered", classification="question", ) assert "consultant_dispatch" not in result assert "consultant_dispatch" not in result["rag_result"] @pytest.mark.asyncio async def test_low_tier_rag_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } high_engine = { "id": "high-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "high-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, high_engine], } openclaw_bundle = { "rules": [ { "name": "engine_high_reasoning_glm5", "enabled": True, "priority": 220, "tier": "high", "provider": "openai", "model": "GLM-5", "api_env": "OPENAI_API_KEY", "match_any": ["adaptive_rag_answer"], } ] } class FakeLLM(LLMTierEscalationMixin): def _low_to_high_escalation_enabled(self) -> bool: return True def _is_low_tier_function(self, function_name: str) -> bool: return function_name == "adaptive_rag_answer_normal" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False async def _chat_completion(self, **kwargs): captured["chat_kwargs"] = kwargs return "GLM-5 answer" llm = FakeLLM() text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="adaptive_rag_answer_normal", failed_engine=dict(low_engine), prompt="请回答一个问题", system_prompt="system prompt", temperature=0.2, max_tokens=512, response_mode="complete", error=ValueError("LLM 返回空内容"), ) assert text == "GLM-5 answer" assert isinstance(resolved_engine, dict) resolved_meta = resolved_engine["_routing_meta"] assert resolved_meta["planned_tier"] == "low" assert resolved_meta["executed_tier"] == "high" assert resolved_meta["routing_decision"] == "low_to_high_escalated" assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) assert "高阶升级成功" in log_text @pytest.mark.asyncio async def test_low_tier_intent_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } alternate_high_engine = { "id": "high-deepseek-1", "provider": "deepseek", "model": "deepseek-chat", "base_url": "https://api.deepseek.com/v1", "api_key": "deepseek-key", } glm5_engine = { "id": "high-glm5-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "glm5-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, alternate_high_engine, glm5_engine], } openclaw_bundle = { "rules": [ { "name": "engine_low_intent_kernel_exact", "enabled": True, "priority": 310, "tier": "low", "provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "function_exact": "classify_intent_ai_pmo_kernel", } ] } class FakeLLM(LLMTierEscalationMixin): def _low_to_high_escalation_enabled(self) -> bool: return True def _is_low_tier_function(self, function_name: str) -> bool: return function_name == "classify_intent_ai_pmo_kernel" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _resolve_tier_engine(self, *, tier, engines, active_engine): _ = (engines, active_engine) if str(tier).strip().lower() == "high": return dict(alternate_high_engine) return dict(low_engine) def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False async def _chat_completion(self, **kwargs): captured["chat_kwargs"] = kwargs return "GLM-5 answer" llm = FakeLLM() text, resolved_engine = await llm._retry_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="classify_intent_ai_pmo_kernel", failed_engine=dict(low_engine), prompt="请返回结构化意图", system_prompt="system prompt", temperature=0.2, max_tokens=512, response_mode="complete", error=ValueError("LLM 返回空内容"), ) assert text == "GLM-5 answer" assert isinstance(resolved_engine, dict) resolved_meta = resolved_engine["_routing_meta"] assert resolved_meta["planned_tier"] == "low" assert resolved_meta["executed_tier"] == "high" assert resolved_meta["routing_decision"] == "low_to_high_escalated" assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) assert "高阶升级成功" in log_text @pytest.mark.asyncio async def test_low_tier_structured_failure_retries_high_tier(monkeypatch) -> None: captured: dict[str, object] = {"logs": []} monkeypatch.setattr( mixin_tier_escalation, "_runtime_log", lambda scope, message: captured["logs"].append((scope, message)), ) low_engine = { "id": "low-1", "provider": "glm", "model": "qwen-plus", "base_url": "https://open.bigmodel.cn/api/paas/v4", "api_key": "low-key", } alternate_high_engine = { "id": "high-deepseek-1", "provider": "deepseek", "model": "deepseek-chat", "base_url": "https://api.deepseek.com/v1", "api_key": "deepseek-key", } glm5_engine = { "id": "high-glm5-1", "provider": "openai", "model": "GLM-5", "base_url": "https://api.openai.com/v1", "api_key": "glm5-key", } bundle = { "settings": {}, "active_engine_id": "low-1", "engines": [low_engine, alternate_high_engine, glm5_engine], } openclaw_bundle = { "rules": [ { "name": "engine_low_intent_kernel_exact", "enabled": True, "priority": 310, "tier": "low", "provider": "glm", "model": "qwen-plus", "api_env": "DASHSCOPE_API_KEY", "function_exact": "classify_intent_ai_pmo_kernel", } ] } class FakeLLM(LLMTierEscalationMixin): def _low_to_high_escalation_enabled(self) -> bool: return True def _is_low_tier_function(self, function_name: str) -> bool: return function_name == "classify_intent_ai_pmo_kernel" @staticmethod def _rule_matches_function(rule, function_name: str) -> bool: fn = str(function_name or "").strip().lower() if not fn: return False if bool(rule.get("match_all", False)): return True exact = str(rule.get("function_exact") or "").strip().lower() if exact and fn == exact: return True prefix = str(rule.get("function_prefix") or "").strip().lower() if prefix and fn.startswith(prefix): return True match_any = rule.get("match_any") if isinstance(match_any, list): for hint in match_any: token = str(hint or "").strip().lower() if token and token in fn: return True return False async def _get_or_init_bundle(self, db, *, tenant_id: int): _ = (db, tenant_id) return SimpleNamespace(), bundle def _resolve_active_engine_from_bundle(self, bundle_value): _ = bundle_value return dict(low_engine) def _load_openclaw_routing_bundle(self): return dict(openclaw_bundle) def _resolve_engine_from_rule(self, *, rule, engines, active_engine): _ = active_engine for item in engines: if ( str(item.get("provider") or "").strip().lower() == str(rule.get("provider") or "").strip().lower() and str(item.get("model") or "").strip() == str(rule.get("model") or "").strip() ): return dict(item) return None def _resolve_tier_engine(self, *, tier, engines, active_engine): _ = (engines, active_engine) if str(tier).strip().lower() == "high": return dict(alternate_high_engine) return dict(low_engine) def _ensure_openclaw_control_plane_engine(self, engine): return dict(engine) def _normalize_engine_settings(self, raw): _ = raw return {} def _ensure_engine_allowed_for_function(self, *, engine, function_name, settings, engine_label): _ = (function_name, settings, engine_label) return dict(engine) def _is_invalid_structured_output(self, *, function_name, response_text): _ = (function_name, response_text) return False def _extract_structured_object_from_response( self, *, function_name, response, fallback_text, provider, model, ): _ = (function_name, response, fallback_text) return StructuredExtractionResult( payload={"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}, source_field="generated_text", provider=provider, model=model, raw_text='{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}', validation="ok", ) async def _chat_completion_raw(self, **kwargs): captured["chat_kwargs"] = kwargs raw_text = '{"confidence": 0.91, "reasoning": "ok", "intent": "query.read_only"}' return ChatCompletionResult(text=raw_text, raw_response=raw_text) llm = FakeLLM() result, resolved_engine = await llm._retry_structured_with_high_tier_after_low_model_failure( db=object(), tenant_id=1, function_name="classify_intent_ai_pmo_kernel", failed_engine=dict(low_engine), prompt="请返回结构化意图", system_prompt="system prompt", temperature=0.2, max_tokens=512, response_mode="complete", error=ValueError("LLM 返回空内容"), ) assert result is not None assert result.payload["intent"] == "query.read_only" assert isinstance(resolved_engine, dict) resolved_meta = resolved_engine["_routing_meta"] assert resolved_meta["planned_tier"] == "low" assert resolved_meta["executed_tier"] == "high" assert resolved_meta["routing_decision"] == "low_to_high_escalated" assert captured["chat_kwargs"]["provider"] == "openai" assert captured["chat_kwargs"]["model"] == "GLM-5" log_text = "\n".join(message for _, message in captured["logs"]) assert "高阶结构化升级成功" in log_text