4189 lines
154 KiB
Python
4189 lines
154 KiB
Python
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
|