2874 lines
104 KiB
Python
2874 lines
104 KiB
Python
import asyncio
|
||
import inspect
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||
from app.services.ai_pmo.ai_pmo_typed_executor_execute import mixin_execute_query_project
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_gate_project import (
|
||
FeishuPipelineGateProjectMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_command_commit_project import (
|
||
FeishuPipelineCommandCommitProjectMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_command_commit_task import (
|
||
FeishuPipelineCommandCommitTaskMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_context_prefill_b import (
|
||
FeishuPipelineContextPrefillBMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_extraction import (
|
||
FeishuPipelineExtractionMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_item_processing import (
|
||
FeishuPipelineItemProcessingMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_followup_a import (
|
||
FeishuPipelineFollowupAMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_followup_b import FeishuPipelineFollowupBMixin
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_gate_dispatch import (
|
||
FeishuPipelineGateDispatchMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_item_finalize_routing import (
|
||
FeishuPipelineItemFinalizeRoutingMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_message_utils_b import (
|
||
FeishuPipelineMessageUtilsBMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_message_utils_a import (
|
||
FeishuPipelineMessageUtilsAMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_process_message_helpers_b import (
|
||
FeishuPipelineProcessMessageHelpersBMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_queue_confirm_undo import (
|
||
FeishuPipelineQueueConfirmUndoMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.service import FeishuSignalPipelineService
|
||
from app.services.integrations.feishu_signal_pipeline import mixin_process_message_core
|
||
from app.services.integrations.feishu_signal_pipeline import mixin_process_message_default
|
||
from app.services.signal.signal_capture_service import mixin_capture_entry
|
||
from app.services.signal.signal_capture_service.service import ProactiveSignalCaptureService
|
||
from app.services.signal.signal_capture_service.mixin_task_orchestration_main import (
|
||
SignalCaptureTaskOrchestrationMainMixin,
|
||
)
|
||
from app.services.signal.signal_capture_service.mixin_task_orchestration_skill import (
|
||
SignalCaptureTaskOrchestrationSkillMixin,
|
||
)
|
||
|
||
|
||
class _Item:
|
||
def __init__(self, *, item_id: int, level: str, operation_code: str, raw_message: str = "") -> None:
|
||
self.id = item_id
|
||
self.level = level
|
||
self.card_type = operation_code.replace(".", "_")
|
||
self.raw_message = raw_message
|
||
self.result_payload = {"operation_code": operation_code}
|
||
self.feishu_message_id = f"msg-{item_id}"
|
||
|
||
|
||
def test_followup_status_target_requires_unique_match() -> None:
|
||
items = [
|
||
_Item(item_id=1, level="command", operation_code="project.create"),
|
||
_Item(item_id=2, level="command", operation_code="project.create"),
|
||
]
|
||
|
||
target = FeishuPipelineFollowupBMixin._select_followup_status_target(
|
||
message="刚才创建项目现在怎么样了",
|
||
recent_items=items,
|
||
)
|
||
|
||
assert target is None
|
||
|
||
|
||
def test_followup_status_target_rejects_single_item_without_anchor() -> None:
|
||
items = [
|
||
_Item(item_id=1, level="project", operation_code="project.create"),
|
||
]
|
||
|
||
target = FeishuPipelineFollowupBMixin._select_followup_status_target(
|
||
message="它现在怎么样?",
|
||
recent_items=items,
|
||
)
|
||
|
||
assert target is None
|
||
|
||
|
||
def test_followup_status_target_does_not_use_lexical_project_hints_without_anchor() -> None:
|
||
items = [
|
||
_Item(item_id=1, level="project", operation_code="project.create"),
|
||
]
|
||
|
||
target = FeishuPipelineFollowupBMixin._select_followup_status_target(
|
||
message="刚才创建项目现在怎么样了",
|
||
recent_items=items,
|
||
)
|
||
|
||
assert target is None
|
||
|
||
|
||
def test_followup_status_target_prefers_thread_anchor() -> None:
|
||
items = [
|
||
_Item(item_id=1, level="project", operation_code="project.create"),
|
||
_Item(item_id=2, level="project", operation_code="project.create"),
|
||
]
|
||
items[0].feishu_message_id = "root-1"
|
||
items[1].feishu_message_id = "root-2"
|
||
|
||
target = FeishuPipelineFollowupBMixin._select_followup_status_target(
|
||
message="他是什么时候创建的?",
|
||
recent_items=items,
|
||
thread_context={"root_message_id": "root-1"},
|
||
)
|
||
|
||
assert target is items[0]
|
||
|
||
|
||
def test_followup_status_target_rejects_unmatched_thread_anchor() -> None:
|
||
items = [
|
||
_Item(item_id=1, level="project", operation_code="project.create"),
|
||
_Item(item_id=2, level="project", operation_code="project.create"),
|
||
]
|
||
items[0].feishu_message_id = "root-1"
|
||
items[1].feishu_message_id = "root-2"
|
||
|
||
target = FeishuPipelineFollowupBMixin._select_followup_status_target(
|
||
message="他是什么时候创建的?",
|
||
recent_items=items,
|
||
thread_context={"reply_to_message_id": "reply-miss"},
|
||
)
|
||
|
||
assert target is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_followup_status_reply_reports_project_created_at(monkeypatch) -> None:
|
||
from datetime import datetime
|
||
|
||
class _FakeDBResult:
|
||
def __init__(self, value) -> None:
|
||
self._value = value
|
||
|
||
def scalar_one_or_none(self):
|
||
return self._value
|
||
|
||
class _FakeDB:
|
||
async def execute(self, query):
|
||
_ = query
|
||
return _FakeDBResult(type("ProjectRow", (), {"id": 7, "tenant_id": 1, "title": "自动开票系统", "name": "自动开票系统", "created_at": datetime(2026, 3, 1, 9, 30)})())
|
||
|
||
class _FakeFollowup(FeishuPipelineFollowupBMixin):
|
||
def __init__(self) -> None:
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
async def _get_project_suggestion(self, item_id: int):
|
||
_ = item_id
|
||
return type("Suggestion", (), {"created_project_id": 7})()
|
||
|
||
@staticmethod
|
||
def _normalize_route_status(value):
|
||
return str(value)
|
||
|
||
item = _Item(item_id=1, level="project", operation_code="project.create")
|
||
item.route_status = "auto_done"
|
||
item.matched_project_id = 7
|
||
|
||
reply = await _FakeFollowup()._build_followup_status_reply(item, message="他是什么时候创建的?")
|
||
|
||
assert "创建于 2026-03-01 09:30" in reply
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_followup_queue_handler_ignores_unmatched_thread_anchor() -> None:
|
||
class _FakeItem:
|
||
def __init__(self) -> None:
|
||
self.id = 1
|
||
self.route_status = "queued"
|
||
self.level = "project"
|
||
self.feishu_message_id = "queue-root-1"
|
||
self.blocking_reason = "请确认"
|
||
|
||
class _FakeFollowup(FeishuPipelineFollowupAMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
@staticmethod
|
||
def _looks_like_continue_followup(message: str) -> bool:
|
||
_ = message
|
||
return False
|
||
|
||
async def _load_recent_chat_items(self, *, source_chat_id, source_sender_open_id, sender, limit=5):
|
||
_ = (source_chat_id, source_sender_open_id, sender, limit)
|
||
return [_FakeItem()]
|
||
|
||
result = await _FakeFollowup()._maybe_handle_queue_followup(
|
||
message="他是什么时候创建的?",
|
||
sender="测试用户",
|
||
source_chat_id="chat-1",
|
||
source_sender_open_id="ou-1",
|
||
feishu_message_id="msg-1",
|
||
process_started_at=0.0,
|
||
thread_context={"reply_to_message_id": "reply-miss"},
|
||
)
|
||
|
||
assert result is None
|
||
|
||
|
||
def test_process_message_no_longer_calls_legacy_queue_followup() -> None:
|
||
source = inspect.getsource(mixin_process_message_core.FeishuPipelineProcessMessageCoreMixin.process_message)
|
||
|
||
assert "_maybe_handle_queue_followup" not in source
|
||
|
||
|
||
def test_process_message_no_longer_calls_legacy_project_lookup_shortcut() -> None:
|
||
source = inspect.getsource(mixin_process_message_core.FeishuPipelineProcessMessageCoreMixin.process_message)
|
||
|
||
assert "_maybe_answer_project_lookup_question" not in source
|
||
|
||
|
||
def test_process_message_default_no_longer_calls_heuristic_level_fallback() -> None:
|
||
source = inspect.getsource(
|
||
mixin_process_message_default.FeishuPipelineProcessMessageDefaultMixin._process_message_default_capture
|
||
)
|
||
|
||
assert "_heuristic_level_fallback" not in source
|
||
|
||
|
||
def test_feishu_signal_pipeline_service_runtime_excludes_legacy_followup_mixins() -> None:
|
||
source = inspect.getsource(FeishuSignalPipelineService)
|
||
|
||
assert "FeishuPipelineFollowupAMixin" not in source
|
||
assert "FeishuPipelineFollowupBMixin" not in source
|
||
|
||
|
||
def test_proactive_signal_capture_service_runtime_excludes_legacy_capture_mixin() -> None:
|
||
source = inspect.getsource(ProactiveSignalCaptureService)
|
||
|
||
assert "SignalCaptureLegacyMixin" not in source
|
||
|
||
|
||
def test_decorate_capture_result_adds_queue_action_card_metadata() -> None:
|
||
result = FeishuPipelineProcessMessageHelpersBMixin._decorate_capture_result(
|
||
{
|
||
"status": "captured",
|
||
"results": [{"item_id": 301, "route_status": "queued_for_decision"}],
|
||
"direct_reply_text": "已转待确认。",
|
||
},
|
||
chat_type="group",
|
||
normalized_text="请创建项目",
|
||
route="group.intent_gate.project_create",
|
||
db_mutations=[],
|
||
)
|
||
|
||
assert result["reply_template_key"] == "queue_pending_confirm"
|
||
assert result["queue_item_id"] == 301
|
||
|
||
|
||
def test_async_processing_ack_result_uses_queue_receipt_instead_of_legacy_processing_ack() -> None:
|
||
result = mixin_capture_entry.SignalCaptureEntryMixin._build_async_processing_ack_result(
|
||
trace_id="trace-async-ack",
|
||
)
|
||
|
||
assert result["reply_template_key"] == "async_processing_ack"
|
||
assert "已收到,正在处理。" not in str(result.get("direct_reply_text") or "")
|
||
assert "待确认队列" in str(result.get("direct_reply_text") or "")
|
||
|
||
|
||
def test_normalize_direct_reply_text_strips_internal_debug_lines() -> None:
|
||
raw = """
|
||
结论:这类问题应该先按外部事实问答处理。
|
||
|
||
证据链:
|
||
- backend/app/services/adaptive_rag_service/mixin_answer_retrieval.py line 323
|
||
- Knowledge Graph #2-#4 命中 Project#104 与 #codex-plan
|
||
|
||
来源: URL[https://openai.com]
|
||
""".strip()
|
||
|
||
cleaned = FeishuPipelineProcessMessageHelpersBMixin._normalize_direct_reply_text(raw)
|
||
|
||
assert "结论:这类问题应该先按外部事实问答处理。" in cleaned
|
||
assert "来源: URL[https://openai.com]" in cleaned
|
||
assert "mixin_answer_retrieval.py" not in cleaned
|
||
assert "Knowledge Graph" not in cleaned
|
||
assert "Project#104" not in cleaned
|
||
assert "#codex-plan" not in cleaned
|
||
assert "line 323" not in cleaned
|
||
|
||
|
||
def test_rag_conversation_key_uses_stable_scope_without_reply_anchor() -> None:
|
||
key = FeishuPipelineMessageUtilsAMixin._build_rag_conversation_key(
|
||
source_chat_id="chat-1",
|
||
source_sender_open_id="ou-1",
|
||
sender="测试用户",
|
||
source_message_id="msg-123",
|
||
thread_context=None,
|
||
)
|
||
|
||
assert key == "feishu:chat-1:ou-1"
|
||
|
||
|
||
def test_rag_conversation_key_prefers_thread_anchor_when_reply_present() -> None:
|
||
key = FeishuPipelineMessageUtilsAMixin._build_rag_conversation_key(
|
||
source_chat_id="chat-1",
|
||
source_sender_open_id="ou-1",
|
||
sender="测试用户",
|
||
source_message_id="msg-123",
|
||
thread_context={"reply_to_message_id": "om-bot-reply-1"},
|
||
)
|
||
|
||
assert key == "feishu:chat-1:om-bot-reply-1"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_build_direct_qa_capture_exposes_reason_code_and_anchor_kind() -> None:
|
||
class _FakeHelpers(FeishuPipelineProcessMessageHelpersBMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _write_processing_log(self, **kwargs):
|
||
_ = kwargs
|
||
|
||
def _build_rag_reply_text(self, *, answer: str, sources: list[dict[str, object]]) -> str:
|
||
_ = sources
|
||
return answer
|
||
|
||
helpers = _FakeHelpers()
|
||
|
||
capture = await helpers._build_direct_qa_capture(
|
||
message="这个是哪个模型回复的?",
|
||
rag_result={
|
||
"answer": "这条回复命中的逻辑层是 low。",
|
||
"clarification_code": None,
|
||
"reason_code": "meta_followup_answered",
|
||
"anchor_kind": "meta_recent_reply",
|
||
"meta_followup_kind": "reply_model_identity",
|
||
"sources": [],
|
||
},
|
||
inferred_project_id=None,
|
||
feishu_message_id="msg-1",
|
||
process_started_at=0.0,
|
||
route_reason="direct qa",
|
||
intent_label="question",
|
||
)
|
||
|
||
assert capture["reason_code"] == "meta_followup_answered"
|
||
assert capture["anchor_kind"] == "meta_recent_reply"
|
||
assert capture["meta_followup_kind"] == "reply_model_identity"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_intent_gate_query_does_not_inherit_project_context(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": "question", "question_part": text}
|
||
|
||
async def answer_question(self, **kwargs):
|
||
captured["answer_kwargs"] = kwargs
|
||
return {"answer": "AI 行业动态正常", "sources": []}
|
||
|
||
class _FakeGate(FeishuPipelineGateDispatchMixin, FeishuPipelineProcessMessageHelpersBMixin):
|
||
def __init__(self) -> None:
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
async def _write_processing_log(self, **kwargs):
|
||
captured["write_log"] = kwargs
|
||
|
||
def _build_rag_conversation_key(self, **kwargs):
|
||
_ = kwargs
|
||
return "feishu:chat-1"
|
||
|
||
def _build_rag_reply_text(self, *, answer: str, sources: list[dict[str, object]]) -> str:
|
||
_ = sources
|
||
return answer
|
||
|
||
monkeypatch.setattr(
|
||
"app.services.integrations.feishu_signal_pipeline.mixin_gate_dispatch.AdaptiveRAGService",
|
||
_FakeAdaptiveRAGService,
|
||
)
|
||
|
||
result = await _FakeGate()._maybe_handle_intent_gate_capture(
|
||
intent_gate={"intent": "query"},
|
||
message="最近AI行业有什么动态",
|
||
sender="测试用户",
|
||
source_chat_id="chat-1",
|
||
source_sender_open_id="ou-1",
|
||
feishu_message_id="msg-1",
|
||
captured_at=None,
|
||
inferred_project_id=6,
|
||
projects=[],
|
||
process_started_at=0.0,
|
||
normalized_chat_type="group",
|
||
thread_context={"reply_to_message_id": "msg-root-1"},
|
||
)
|
||
|
||
assert isinstance(result, dict)
|
||
assert result["linked_project_ids"] == []
|
||
assert captured["answer_kwargs"]["project_id"] is None
|
||
assert captured["answer_kwargs"]["source_sender_open_id"] == "ou-1"
|
||
assert captured["answer_kwargs"]["thread_context"] == {"reply_to_message_id": "msg-root-1"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_feishu_question_like_messages_use_sync_high_accuracy_path(monkeypatch) -> None:
|
||
calls = {"sync": 0, "session": 0, "cache": 0}
|
||
|
||
class _FakeBundle:
|
||
def __init__(self) -> None:
|
||
self.primary_workflow = "query_flow"
|
||
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
self.diagnostics = {}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"bundle_id": "semantic-query",
|
||
"semantic_version": "2026-03-27.workflow-v1",
|
||
"source_system": "feishu",
|
||
"primary_workflow": self.primary_workflow,
|
||
"workflows": [
|
||
{
|
||
"workflow_type": "query_flow",
|
||
"workflow_action": "answer",
|
||
"target_scope": "global",
|
||
"operation_codes": ["query.answer"],
|
||
"requires_confirmation": False,
|
||
"confidence": 0.9,
|
||
"slots": [],
|
||
"clarification_needed": False,
|
||
"metadata": {},
|
||
}
|
||
],
|
||
"message_gate": "auto_execute",
|
||
"reply_contract": {
|
||
"mode": "aggregate_card",
|
||
"template_key": "workflow_bundle",
|
||
"summary": "query_flow",
|
||
},
|
||
"diagnostics": {},
|
||
}
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-1"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
calls["session"] += 1
|
||
_ = kwargs
|
||
raise AssertionError("question-like Feishu messages should not create an async queue session")
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
calls["sync"] += 1
|
||
_ = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-1",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "垂直行业建议先看目标客户与交付边界。",
|
||
}
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
calls["cache"] += 1
|
||
_ = kwargs
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(object())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-1",
|
||
message_content="我感觉第一个垂直应用前景比较好,但是垂直哪个行业呢?",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
mentioned_bot_names=["PMFlow"],
|
||
feishu_message_id="msg-1",
|
||
chat_type="group",
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert "已收到,正在处理。" not in str(result.get("direct_reply_text") or "")
|
||
assert calls["sync"] == 1
|
||
assert calls["session"] == 0
|
||
assert calls["cache"] == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_feishu_question_like_messages_require_explicit_group_trigger(monkeypatch) -> None:
|
||
calls = {"sync": 0, "session": 0}
|
||
|
||
class _FakeBundle:
|
||
def __init__(self) -> None:
|
||
self.primary_workflow = "query_flow"
|
||
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
self.diagnostics = {}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-query-no-trigger"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
calls["session"] += 1
|
||
_ = kwargs
|
||
raise AssertionError("group query without explicit trigger should not create an async queue session")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
calls["sync"] += 1
|
||
_ = kwargs
|
||
raise AssertionError("group query without explicit trigger should not run sync QA")
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(object())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-query-no-trigger",
|
||
message_content="最近AI行业有什么动态",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
mentioned_bot_names=[],
|
||
feishu_message_id="msg-query-no-trigger",
|
||
chat_type="group",
|
||
thread_context=None,
|
||
)
|
||
|
||
assert result["status"] == "ignored"
|
||
assert result["reason"] == "group_explicit_trigger_required"
|
||
assert result["direct_reply_required"] is False
|
||
assert calls["sync"] == 0
|
||
assert calls["session"] == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_feishu_business_review_query_forwards_semantic_bundle_into_kernel(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
def __init__(self) -> None:
|
||
self.primary_workflow = "query_flow"
|
||
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
self.diagnostics = {
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "clarify",
|
||
}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [
|
||
{
|
||
"workflow_type": "query_flow",
|
||
"workflow_action": "answer",
|
||
}
|
||
],
|
||
"diagnostics": dict(self.diagnostics),
|
||
}
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-business-review"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("business review sync path should not create async queue session")
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-br",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "五大口径评审中。",
|
||
}
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(object())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-br-1",
|
||
message_content="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
mentioned_bot_names=["PMFlow"],
|
||
feishu_message_id="msg-business-review",
|
||
chat_type="group",
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
kwargs = captured["kwargs"]
|
||
assert isinstance(kwargs, dict)
|
||
semantic_bundle = kwargs["semantic_bundle"]
|
||
assert semantic_bundle["primary_workflow"] == "query_flow"
|
||
assert semantic_bundle["diagnostics"]["message_intent"] == "business_review"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_feishu_business_review_query_reuses_semantic_bundle_without_second_planner_call(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
planner_calls: list[dict[str, object]] = []
|
||
|
||
class _FakeBundle:
|
||
def __init__(self) -> None:
|
||
self.primary_workflow = "query_flow"
|
||
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
self.diagnostics = {
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "clarify",
|
||
}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [
|
||
{
|
||
"workflow_type": "query_flow",
|
||
"workflow_action": "answer",
|
||
}
|
||
],
|
||
"diagnostics": dict(self.diagnostics),
|
||
}
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-business-review-reuse"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("business review sync path should not create async queue session")
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-br-reuse",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "五大口径评审中。",
|
||
}
|
||
|
||
async def _fake_aplan_message(cls, **kwargs):
|
||
_ = cls
|
||
planner_calls.append(dict(kwargs))
|
||
return _FakeBundle()
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(_fake_aplan_message),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(object())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-br-reuse",
|
||
message_content="我现在要创建需求为一个大模型api聚合平台,你帮我分析下要怎么做,",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
mentioned_bot_names=["PMFlow"],
|
||
feishu_message_id="msg-business-review-reuse",
|
||
chat_type="group",
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert len(planner_calls) == 1
|
||
kwargs = captured["kwargs"]
|
||
assert isinstance(kwargs, dict)
|
||
semantic_bundle = kwargs["semantic_bundle"]
|
||
assert semantic_bundle["primary_workflow"] == "query_flow"
|
||
assert semantic_bundle["diagnostics"]["message_intent"] == "business_review"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_force_sync_feishu_query_flow_still_forwards_semantic_bundle_into_kernel(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
def __init__(self) -> None:
|
||
self.primary_workflow = "query_flow"
|
||
self.workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
self.diagnostics = {
|
||
"message_intent": "business_review",
|
||
"consultant_mode_preference": "clarify",
|
||
}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [
|
||
{
|
||
"workflow_type": "query_flow",
|
||
"workflow_action": "answer",
|
||
}
|
||
],
|
||
"diagnostics": dict(self.diagnostics),
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-force-sync-query"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("force_sync query path should not create async queue session")
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-force-sync-query",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "先做行业调研和需求收敛。",
|
||
}
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-force-sync-query",
|
||
message_content="想了解下当前电动车的行业,以此做一个mvp需求",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
feishu_message_id="msg-force-sync-query",
|
||
chat_type="group",
|
||
force_sync=True,
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
kwargs = captured["kwargs"]
|
||
assert isinstance(kwargs, dict)
|
||
semantic_bundle = kwargs["semantic_bundle"]
|
||
assert semantic_bundle["primary_workflow"] == "query_flow"
|
||
assert semantic_bundle["diagnostics"]["message_intent"] == "business_review"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_feishu_with_kernel_sync_rolls_back_current_session_and_surfaces_real_error() -> None:
|
||
class _FakeDB:
|
||
def __init__(self) -> None:
|
||
self.rollback_calls = 0
|
||
|
||
async def rollback(self) -> None:
|
||
self.rollback_calls += 1
|
||
|
||
class _FakeKernel:
|
||
async def process_feishu_message(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"handled": False,
|
||
"reason": "kernel_exception",
|
||
"error": "No row was found when one was required",
|
||
}
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
|
||
result = await service._process_feishu_with_kernel_sync(
|
||
trace_id="trace-kernel-error",
|
||
tenant_id=1,
|
||
kernel=_FakeKernel(),
|
||
group_chat_id="chat-rollback",
|
||
cleaned_message="先不要直接创建,我还在补充需求。",
|
||
proposer_name="测试用户",
|
||
proposer_open_id="ou-rollback",
|
||
captured_dt=mixin_capture_entry.now_cn_aware(),
|
||
feishu_message_id="msg-rollback",
|
||
chat_type="group",
|
||
)
|
||
|
||
assert service.db.rollback_calls == 1
|
||
assert result["status"] == "error"
|
||
assert result["reason"] == "kernel_exception"
|
||
assert "No row was found when one was required" in result["direct_reply_text"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_process_feishu_with_kernel_sync_commits_successful_runtime_finalize() -> None:
|
||
class _FakeDB:
|
||
def __init__(self) -> None:
|
||
self.commit_calls = 0
|
||
self.rollback_calls = 0
|
||
|
||
async def commit(self) -> None:
|
||
self.commit_calls += 1
|
||
|
||
async def rollback(self) -> None:
|
||
self.rollback_calls += 1
|
||
|
||
class _FakeKernel:
|
||
async def process_feishu_message(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "captured",
|
||
"classification": "command",
|
||
"work_item_id": 55,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已进入 mounted runtime。",
|
||
}
|
||
|
||
class _FakeService(ProactiveSignalCaptureService):
|
||
async def _apply_pipeline_post_actions(self, **kwargs):
|
||
return kwargs["pipeline_result"]
|
||
|
||
async def _sync_feishu_recent_cache(self, **kwargs):
|
||
_ = kwargs
|
||
return None
|
||
|
||
service = _FakeService(_FakeDB())
|
||
|
||
result = await service._process_feishu_with_kernel_sync(
|
||
trace_id="trace-sync-commit",
|
||
tenant_id=1,
|
||
kernel=_FakeKernel(),
|
||
group_chat_id="chat-sync-commit",
|
||
cleaned_message="先不要创建,我还在补范围。",
|
||
proposer_name="测试用户",
|
||
proposer_open_id="ou-sync-commit",
|
||
captured_dt=mixin_capture_entry.now_cn_aware(),
|
||
feishu_message_id="msg-sync-commit",
|
||
chat_type="group",
|
||
)
|
||
|
||
assert result["status"] == "captured"
|
||
assert service.db.commit_calls == 1
|
||
assert service.db.rollback_calls == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_outer_exception_rolls_back_current_session_and_surfaces_error(monkeypatch) -> None:
|
||
class _FakeDB:
|
||
def __init__(self) -> None:
|
||
self.rollback_calls = 0
|
||
|
||
async def rollback(self) -> None:
|
||
self.rollback_calls += 1
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-outer-exception"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
_ = kwargs
|
||
raise RuntimeError("boom")
|
||
|
||
class _FakeSemanticBundle:
|
||
primary_workflow = "query_flow"
|
||
diagnostics: dict[str, object] = {}
|
||
workflows: list[object] = []
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": self.primary_workflow,
|
||
"diagnostics": {},
|
||
"workflows": [],
|
||
}
|
||
|
||
async def _fake_aplan_message(**kwargs):
|
||
_ = kwargs
|
||
return _FakeSemanticBundle()
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry.WorkflowSemanticPlanner, "aplan_message", _fake_aplan_message)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-outer-exception",
|
||
message_content="帮我看看这个需求",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
feishu_message_id="msg-outer-exception",
|
||
chat_type="group",
|
||
force_sync=True,
|
||
)
|
||
|
||
assert service.db.rollback_calls == 1
|
||
assert result["status"] == "error"
|
||
assert result["reason"] == "kernel_failed"
|
||
assert "boom" in result["direct_reply_text"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_skips_when_source_activation_whitelist_misses(monkeypatch) -> None:
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-gated"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
raise AssertionError("source activation miss should stop before queue session creation")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
raise AssertionError("source activation miss should not reach kernel processing")
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return None, True
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
|
||
service = ProactiveSignalCaptureService(object())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-not-whitelisted",
|
||
message_content="这个需求帮我继续推进一下",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
feishu_message_id="msg-gated",
|
||
chat_type="group",
|
||
)
|
||
|
||
assert result["status"] == "ignored"
|
||
assert result["reason"] == "mounted_runtime_scope_not_activated"
|
||
assert result["direct_reply_required"] is False
|
||
|
||
|
||
def test_capture_from_feishu_no_longer_references_legacy_fallback() -> None:
|
||
source = inspect.getsource(mixin_capture_entry.SignalCaptureEntryMixin.capture_from_feishu)
|
||
|
||
assert "_capture_from_feishu_legacy_flow" not in source
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_process_feishu_high_accuracy_treats_requirement_plan_as_sync(monkeypatch) -> None:
|
||
class _FakeBundle:
|
||
primary_workflow = "query_flow"
|
||
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
diagnostics = {"message_intent": "requirement_plan"}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
||
message_text="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
)
|
||
|
||
assert should_sync is True
|
||
assert reason == "semantic:requirement_plan"
|
||
assert bundle is not None
|
||
assert getattr(bundle, "diagnostics", {}).get("message_intent") == "requirement_plan"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_process_feishu_high_accuracy_treats_requirement_plan_slot_answer_as_sync(monkeypatch) -> None:
|
||
class _FakeBundle:
|
||
primary_workflow = "query_flow"
|
||
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
diagnostics = {"message_intent": "requirement_plan"}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
||
message_text="@PMFlow 先按技术指标来,主要给投顾看。",
|
||
)
|
||
|
||
assert should_sync is True
|
||
assert reason == "semantic:requirement_plan"
|
||
assert bundle is not None
|
||
assert getattr(bundle, "diagnostics", {}).get("message_intent") == "requirement_plan"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_should_process_feishu_high_accuracy_no_longer_uses_question_suffix_fallback(monkeypatch) -> None:
|
||
class _FakeBundle:
|
||
primary_workflow = "project_signal_flow"
|
||
workflows = [SimpleNamespace(workflow_type="project_signal_flow", workflow_action="capture")]
|
||
diagnostics = {"message_intent": "casual_chat"}
|
||
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
should_sync, reason, bundle = await mixin_capture_entry.SignalCaptureEntryMixin._should_process_feishu_high_accuracy(
|
||
message_text="@PMFlow 现在几点?",
|
||
)
|
||
|
||
assert should_sync is False
|
||
assert reason == "async_queue"
|
||
assert bundle is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_requirement_plan_skips_async_ack_and_forwards_semantic_bundle(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "query_flow"
|
||
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
diagnostics = {"message_intent": "requirement_plan"}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||
"diagnostics": dict(self.diagnostics),
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-plan-sync"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan sync path should not create async queue session")
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-plan-sync",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "可以,我们先只聊需求,不创建。先定一个最关键的问题:你这个分数主要想根据什么来打,比如技术指标、情绪数据,或者你已经有一套规则想自动化?",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-plan-sync",
|
||
message_content="@PMFlow 想跟你聊聊需求,做一个自动给每日的大A趋势打分的小工具,先不创建,先收敛第一版。",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
feishu_message_id="msg-plan-sync",
|
||
chat_type="group",
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert result["direct_reply_text"].startswith("可以,我们先只聊需求")
|
||
kwargs = captured["kwargs"]
|
||
assert isinstance(kwargs, dict)
|
||
semantic_bundle = kwargs["semantic_bundle"]
|
||
assert semantic_bundle["diagnostics"]["message_intent"] == "requirement_plan"
|
||
assert semantic_bundle["primary_workflow"] == "query_flow"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_requirement_plan_slot_answer_stays_sync_without_async_ack(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "query_flow"
|
||
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
diagnostics = {"message_intent": "requirement_plan"}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||
"diagnostics": dict(self.diagnostics),
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-plan-followup-sync"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("requirement plan slot answers should not create async queue session")
|
||
|
||
def _is_short_confirmation_message(self, **kwargs):
|
||
_ = kwargs
|
||
return False
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-plan-followup-sync",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "好,我先记下:第一版按技术指标打分,主要给投顾看。下一个关键问题:你更想让系统怎么给你这个结果,是飞书日报、页面看板,还是只推送一个分数加一句解释?",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-plan-followup-sync",
|
||
message_content="@PMFlow 先按技术指标来,主要给投顾看。",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
feishu_message_id="msg-plan-followup-sync",
|
||
chat_type="group",
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert "技术指标" in result["direct_reply_text"]
|
||
semantic_bundle = captured["kwargs"]["semantic_bundle"]
|
||
assert semantic_bundle["diagnostics"]["message_intent"] == "requirement_plan"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_allows_group_choice_protocol_without_repeat_mention_when_requirement_plan_active(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "followup_flow"
|
||
workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")]
|
||
diagnostics = {}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "followup_flow",
|
||
"workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-plan-choice-followup"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _bind_active_context_target(self, **kwargs):
|
||
captured["bind_kwargs"] = dict(kwargs)
|
||
return SimpleNamespace(id=185), SimpleNamespace(id=31, state="collecting"), "requirement_plan"
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("active requirement choice followup should stay sync")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-plan-choice-followup",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已确认选项 A,继续下一轮。",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-plan-choice-followup",
|
||
message_content="A",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
proposer_open_id="ou-plan-choice-followup",
|
||
mentioned_bot_names=[],
|
||
feishu_message_id="msg-plan-choice-followup",
|
||
chat_type="group",
|
||
thread_context=None,
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert result["direct_reply_text"] == "已确认选项 A,继续下一轮。"
|
||
assert isinstance(captured.get("bind_kwargs"), dict)
|
||
assert str(captured["bind_kwargs"]["message_text"]) == "A"
|
||
assert isinstance(captured.get("kwargs"), dict)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_allows_group_confirm_without_repeat_mention_when_draft_pending_confirm_active(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "followup_flow"
|
||
workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")]
|
||
diagnostics = {}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "followup_flow",
|
||
"workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-draft-confirm-followup"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _bind_active_context_target(self, **kwargs):
|
||
captured["bind_kwargs"] = dict(kwargs)
|
||
return SimpleNamespace(id=185), SimpleNamespace(id=31, state="draft_pending_confirm"), "draft_pending_confirm"
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("active draft confirm followup should stay sync")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [119],
|
||
"signal_id": 408,
|
||
"created_project_id": 119,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-draft-confirm-followup",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "✅ 已入库",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-draft-confirm-followup",
|
||
message_content="确认",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
proposer_open_id="ou-draft-confirm-followup",
|
||
mentioned_bot_names=[],
|
||
feishu_message_id="msg-draft-confirm-followup",
|
||
chat_type="group",
|
||
thread_context=None,
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert result["direct_reply_text"] == "✅ 已入库"
|
||
assert isinstance(captured.get("bind_kwargs"), dict)
|
||
assert str(captured["bind_kwargs"]["message_text"]) == "确认"
|
||
assert isinstance(captured.get("kwargs"), dict)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_resolves_active_mounted_draft_when_topic_mode_is_draft_ready(
|
||
monkeypatch,
|
||
) -> None:
|
||
class _FakeResult:
|
||
def __init__(self, rows) -> None:
|
||
self._rows = list(rows)
|
||
|
||
def all(self):
|
||
return list(self._rows)
|
||
|
||
draft_row = SimpleNamespace(
|
||
id=31,
|
||
topic_workspace_id=185,
|
||
active_work_item_id=901,
|
||
state="draft_pending_confirm",
|
||
latest_reply_message_id=None,
|
||
latest_source_message_id="msg-draft-root",
|
||
)
|
||
topic_row = SimpleNamespace(
|
||
id=185,
|
||
tenant_id=1,
|
||
source_system="feishu",
|
||
mode="draft_ready",
|
||
status="active",
|
||
channel_ref="chat-draft-confirm-followup",
|
||
conversation_scope_key="feishu:chat-draft-confirm-followup:ou-draft-confirm-followup",
|
||
plan_status="draft_pending_confirm",
|
||
root_message_id="msg-draft-root",
|
||
thread_key="",
|
||
last_message_id="msg-draft-root",
|
||
updated_at=None,
|
||
)
|
||
|
||
class _FakeDB:
|
||
async def execute(self, query):
|
||
sql = str(
|
||
query.compile(compile_kwargs={"literal_binds": True})
|
||
).lower()
|
||
if "mounted_topic_workspaces.mode = 'plan'" in sql:
|
||
return _FakeResult([])
|
||
if "'draft_ready'" in sql and "'plan'" in sql:
|
||
return _FakeResult([(draft_row, topic_row)])
|
||
return _FakeResult([])
|
||
|
||
async def _fake_load_work_item_target_by_id(self, *, work_item_id: int):
|
||
return SimpleNamespace(id=work_item_id, state="draft_pending_confirm"), None
|
||
|
||
async def _fake_release_mounted_draft_binding(self, **kwargs):
|
||
raise AssertionError(f"active mounted draft should not be released: {kwargs}")
|
||
|
||
monkeypatch.setattr(
|
||
DraftConfirmHandler,
|
||
"_load_work_item_target_by_id",
|
||
_fake_load_work_item_target_by_id,
|
||
)
|
||
monkeypatch.setattr(
|
||
DraftConfirmHandler,
|
||
"_release_mounted_draft_binding",
|
||
_fake_release_mounted_draft_binding,
|
||
)
|
||
|
||
handler = DraftConfirmHandler(SimpleNamespace(db=_FakeDB(), tenant_id=1))
|
||
result = await handler._resolve_active_mounted_draft(
|
||
sender="测试用户",
|
||
source_system="feishu",
|
||
channel_ref="chat-draft-confirm-followup",
|
||
source_sender_open_id="ou-draft-confirm-followup",
|
||
thread_context={"reply_to_message_id": "msg-draft-root"},
|
||
)
|
||
|
||
assert result == (draft_row, topic_row)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_group_choice_protocol_prefers_active_requirement_over_older_pending_draft(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "followup_flow"
|
||
workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")]
|
||
diagnostics = {}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "followup_flow",
|
||
"workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-plan-choice-priority"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["finder_kwargs"] = dict(kwargs)
|
||
return SimpleNamespace(id=185), SimpleNamespace(id=31, state="collecting")
|
||
|
||
@staticmethod
|
||
def _derive_active_context_kind(*, topic, draft):
|
||
_ = (topic, draft)
|
||
return "requirement_plan"
|
||
|
||
async def _bind_active_context_target(self, **kwargs):
|
||
captured["bind_kwargs"] = dict(kwargs)
|
||
return SimpleNamespace(id=178), SimpleNamespace(id=30, state="draft_pending_confirm"), "draft_pending_confirm"
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("choice protocol should stay sync on active requirement")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-plan-choice-priority",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已确认选项 A,继续下一轮。",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
def _bomb_requirement_choice_parser(message_text: str):
|
||
_ = message_text
|
||
raise AssertionError("legacy requirement choice lexical parser should not be used")
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_execute_query_project.ExecuteQueryProjectMixin,
|
||
"_parse_requirement_plan_choice_protocol",
|
||
classmethod(lambda cls, message_text: _bomb_requirement_choice_parser(message_text)),
|
||
)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-plan-choice-priority",
|
||
message_content="A",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
proposer_open_id="ou-plan-choice-priority",
|
||
mentioned_bot_names=[],
|
||
feishu_message_id="msg-plan-choice-priority",
|
||
chat_type="group",
|
||
thread_context=None,
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert result["direct_reply_text"] == "已确认选项 A,继续下一轮。"
|
||
assert isinstance(captured.get("finder_kwargs"), dict)
|
||
assert isinstance(captured.get("kwargs"), dict)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_group_choice_protocol_uses_executor_requirement_finder_when_kernel_lacks_direct_finder(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "followup_flow"
|
||
workflows = [SimpleNamespace(workflow_type="followup_flow", workflow_action="confirm")]
|
||
diagnostics = {}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "followup_flow",
|
||
"workflows": [{"workflow_type": "followup_flow", "workflow_action": "confirm"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeExecutor:
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["finder_kwargs"] = dict(kwargs)
|
||
return SimpleNamespace(id=185, root_message_id="om-active-plan-root"), SimpleNamespace(id=31, state="collecting")
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-plan-choice-executor-finder"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
self.executor = _FakeExecutor()
|
||
|
||
@staticmethod
|
||
def _derive_active_context_kind(*, topic, draft):
|
||
_ = (topic, draft)
|
||
return "requirement_plan"
|
||
|
||
async def _bind_active_context_target(self, **kwargs):
|
||
captured["bind_kwargs"] = dict(kwargs)
|
||
return None
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("executor-based requirement finder should keep choice followup sync")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-plan-choice-executor-finder",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已确认选项 A,继续下一轮。",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-plan-choice-executor-finder",
|
||
message_content="A",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
proposer_open_id="ou-plan-choice-executor-finder",
|
||
mentioned_bot_names=[],
|
||
feishu_message_id="msg-plan-choice-executor-finder",
|
||
chat_type="group",
|
||
thread_context=None,
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert result["direct_reply_text"] == "已确认选项 A,继续下一轮。"
|
||
assert isinstance(captured.get("finder_kwargs"), dict)
|
||
assert isinstance(captured.get("kwargs"), dict)
|
||
thread_context = captured["kwargs"]["thread_context"]
|
||
assert isinstance(thread_context, dict)
|
||
assert thread_context["root_message_id"] == "om-active-plan-root"
|
||
assert thread_context["reply_to_message_id"] == "om-active-plan-root"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_explicit_reply_choice_protocol_recovers_active_requirement_thread_context(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "query_flow"
|
||
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
diagnostics = {}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-plan-choice-explicit-reply"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["finder_kwargs"] = dict(kwargs)
|
||
return (
|
||
SimpleNamespace(id=185, root_message_id="om-active-plan-root", last_message_id="om-active-plan-root"),
|
||
SimpleNamespace(id=31, state="collecting"),
|
||
)
|
||
|
||
@staticmethod
|
||
def _derive_active_context_kind(*, topic, draft):
|
||
_ = (topic, draft)
|
||
return "requirement_plan"
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("explicit reply choice followup should stay sync")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-plan-choice-explicit-reply",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已确认选项 B,继续下一轮。",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-plan-choice-explicit-reply",
|
||
message_content="@PMFlow 我选B 继续下一轮",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
proposer_open_id="ou-plan-choice-explicit-reply",
|
||
mentioned_bot_names=[],
|
||
feishu_message_id="msg-plan-choice-explicit-reply",
|
||
chat_type="group",
|
||
thread_context={"reply_to_message_id": "om-bot-reply-1"},
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert result["direct_reply_text"] == "已确认选项 B,继续下一轮。"
|
||
assert isinstance(captured.get("finder_kwargs"), dict)
|
||
kwargs = captured["kwargs"]
|
||
assert isinstance(kwargs, dict)
|
||
thread_context = kwargs["thread_context"]
|
||
assert isinstance(thread_context, dict)
|
||
assert thread_context["root_message_id"] == "om-active-plan-root"
|
||
assert thread_context["reply_to_message_id"] == "om-active-plan-root"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_explicit_reply_choice_protocol_canonicalizes_bot_reply_anchor_before_requirement_lookup(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "query_flow"
|
||
workflows = [SimpleNamespace(workflow_type="query_flow", workflow_action="answer")]
|
||
diagnostics = {}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "query_flow",
|
||
"workflows": [{"workflow_type": "query_flow", "workflow_action": "answer"}],
|
||
"diagnostics": {},
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-plan-choice-explicit-canonicalized"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["finder_kwargs"] = dict(kwargs)
|
||
return (
|
||
SimpleNamespace(id=185, root_message_id="om-active-plan-root", last_message_id="om-active-plan-root"),
|
||
SimpleNamespace(id=31, state="collecting"),
|
||
)
|
||
|
||
@staticmethod
|
||
def _derive_active_context_kind(*, topic, draft):
|
||
_ = (topic, draft)
|
||
return "requirement_plan"
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("explicit reply choice followup should stay sync")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-plan-choice-explicit-canonicalized",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已确认选项 C,继续下一轮。",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
captured["canonicalize_kwargs"] = {
|
||
"tenant_id": tenant_id,
|
||
"source_system": source_system,
|
||
"channel_ref": channel_ref,
|
||
"thread_context": dict(thread_context or {}),
|
||
}
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return (
|
||
{
|
||
"root_message_id": "om-source-plan-root",
|
||
"reply_to_message_id": "om-source-plan-root",
|
||
},
|
||
{
|
||
"original_reply_to_message_id": "om-bot-reply-1",
|
||
"canonical_reply_to_message_id": "om-source-plan-root",
|
||
},
|
||
)
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-plan-choice-explicit-canonicalized",
|
||
message_content="@PMFlow 我选C 继续",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
proposer_open_id="ou-plan-choice-explicit-canonicalized",
|
||
mentioned_bot_names=[],
|
||
feishu_message_id="msg-plan-choice-explicit-canonicalized",
|
||
chat_type="group",
|
||
thread_context={"reply_to_message_id": "om-bot-reply-1"},
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert result["direct_reply_text"] == "已确认选项 C,继续下一轮。"
|
||
assert captured["canonicalize_kwargs"]["thread_context"] == {"reply_to_message_id": "om-bot-reply-1"}
|
||
finder_kwargs = captured["finder_kwargs"]
|
||
assert isinstance(finder_kwargs, dict)
|
||
assert finder_kwargs["thread_context"] == {
|
||
"root_message_id": "om-source-plan-root",
|
||
"reply_to_message_id": "om-source-plan-root",
|
||
}
|
||
kwargs = captured["kwargs"]
|
||
assert isinstance(kwargs, dict)
|
||
assert kwargs["thread_context"] == {
|
||
"root_message_id": "om-active-plan-root",
|
||
"reply_to_message_id": "om-active-plan-root",
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_capture_from_feishu_explicit_reply_choice_protocol_forces_sync_even_when_planner_would_async_queue(
|
||
monkeypatch,
|
||
) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeBundle:
|
||
primary_workflow = "project_signal_flow"
|
||
workflows = [SimpleNamespace(workflow_type="project_signal_flow", workflow_action="capture")]
|
||
diagnostics = {"message_intent": "casual_chat"}
|
||
|
||
def to_dict(self) -> dict[str, object]:
|
||
return {
|
||
"primary_workflow": "project_signal_flow",
|
||
"workflows": [{"workflow_type": "project_signal_flow", "workflow_action": "capture"}],
|
||
"diagnostics": dict(self.diagnostics),
|
||
}
|
||
|
||
class _FakeDB:
|
||
async def commit(self) -> None:
|
||
return None
|
||
|
||
async def rollback(self) -> None:
|
||
return None
|
||
|
||
class _FakeKernel:
|
||
@staticmethod
|
||
def _build_source_message_id(*, source_system: str, source_message_id, channel_ref: str, sender: str, message_text: str):
|
||
_ = (source_system, source_message_id, channel_ref, sender, message_text)
|
||
return "msg-plan-choice-explicit-force-sync"
|
||
|
||
def __init__(self, db, tenant_id: int):
|
||
_ = (db, tenant_id)
|
||
|
||
async def _find_active_requirement_plan_workspace(self, **kwargs):
|
||
captured["finder_kwargs"] = dict(kwargs)
|
||
return (
|
||
SimpleNamespace(id=185, root_message_id="om-active-plan-root", last_message_id="om-active-plan-root"),
|
||
SimpleNamespace(id=31, state="collecting"),
|
||
)
|
||
|
||
@staticmethod
|
||
def _derive_active_context_kind(*, topic, draft):
|
||
_ = (topic, draft)
|
||
return "requirement_plan"
|
||
|
||
async def _get_or_create_session(self, **kwargs):
|
||
_ = kwargs
|
||
raise AssertionError("explicit reply choice protocol should bypass async queue")
|
||
|
||
async def process_feishu_message(self, **kwargs):
|
||
captured["kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": "trace-plan-choice-explicit-force-sync",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "已确认选项 B,继续下一轮。",
|
||
}
|
||
|
||
class _FakeMountedRuntimeWriteService:
|
||
def __init__(self, db):
|
||
_ = db
|
||
|
||
async def canonicalize_thread_context(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref)
|
||
return thread_context, True
|
||
|
||
async def resolve_source_activation_scope(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
source_system: str,
|
||
channel_ref: str | None,
|
||
thread_context: dict | None,
|
||
):
|
||
_ = (tenant_id, source_system, channel_ref, thread_context)
|
||
return SimpleNamespace(id=1, source_system="feishu"), True
|
||
|
||
async def _noop_sync_recent_cache(self, **kwargs):
|
||
_ = (self, kwargs)
|
||
|
||
monkeypatch.setattr(mixin_capture_entry, "AIPMOKernelService", _FakeKernel)
|
||
monkeypatch.setattr(mixin_capture_entry, "MountedAIRuntimeWriteService", _FakeMountedRuntimeWriteService)
|
||
monkeypatch.setattr(ProactiveSignalCaptureService, "_sync_feishu_recent_cache", _noop_sync_recent_cache)
|
||
monkeypatch.setattr(
|
||
mixin_capture_entry.WorkflowSemanticPlanner,
|
||
"aplan_message",
|
||
classmethod(lambda cls, **kwargs: asyncio.sleep(0, result=_FakeBundle())),
|
||
)
|
||
|
||
service = ProactiveSignalCaptureService(_FakeDB())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-plan-choice-explicit-force-sync",
|
||
message_content="@PMFlow 我选B",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
proposer_open_id="ou-plan-choice-explicit-force-sync",
|
||
mentioned_bot_names=[],
|
||
feishu_message_id="msg-plan-choice-explicit-force-sync",
|
||
chat_type="group",
|
||
thread_context={"reply_to_message_id": "om-bot-reply-1"},
|
||
)
|
||
|
||
assert result["status"] == "answered"
|
||
assert result["direct_reply_text"] == "已确认选项 B,继续下一轮。"
|
||
assert isinstance(captured.get("finder_kwargs"), dict)
|
||
assert isinstance(captured.get("kwargs"), dict)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_context_prefill_skips_chatwide_reply_fallback(monkeypatch) -> None:
|
||
called = {"fallback": False}
|
||
|
||
class _FakePrefill(FeishuPipelineContextPrefillBMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
async def _load_recent_chat_turns(self, **kwargs):
|
||
_ = kwargs
|
||
return []
|
||
|
||
async def _load_recent_chat_reply_fallback_text(self, *, source_chat_id: str, limit: int = 5) -> str:
|
||
_ = (source_chat_id, limit)
|
||
called["fallback"] = True
|
||
raise AssertionError("reply fallback should not be used when no recent turns are available")
|
||
|
||
fields = {
|
||
"title": "",
|
||
"description": "",
|
||
"project_scope": "",
|
||
"missing_fields": ["title", "description", "project_scope"],
|
||
"ready": False,
|
||
}
|
||
result = await _FakePrefill()._maybe_prefill_project_fields_from_context(
|
||
message="根据这个创建一个项目",
|
||
source_chat_id="chat-1",
|
||
source_sender_open_id="ou-1",
|
||
sender="测试用户",
|
||
fields=fields,
|
||
source_message_id="msg-1",
|
||
thread_context={"reply_to_message_id": "msg-root-1"},
|
||
)
|
||
|
||
assert result == fields
|
||
assert called["fallback"] is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_prefill_fallback_leaves_owner_and_timeline_empty() -> None:
|
||
class _FakeLLM:
|
||
async def generate(self, *args, **kwargs):
|
||
_ = args, kwargs
|
||
raise RuntimeError("boom")
|
||
|
||
class _FakeExtraction(FeishuPipelineExtractionMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
self.llm = _FakeLLM()
|
||
|
||
result = await _FakeExtraction()._extract_project_prefill(
|
||
"我们想做一个内部知识库升级项目,先把项目名称和范围整理出来。"
|
||
)
|
||
|
||
assert result["suggested_owner"] is None
|
||
assert result["suggested_timeline"] is None
|
||
assert result["suggested_scope"] is None
|
||
assert float(result["confidence"]) <= 0.35
|
||
assert len(result["reasoning"]) >= 1
|
||
assert "规则" not in " ".join(result["reasoning"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_signal_extraction_failure_returns_degraded_payload_without_keyword_reasoning() -> None:
|
||
class _FakeLLM:
|
||
async def generate(self, *args, **kwargs):
|
||
_ = args, kwargs
|
||
raise RuntimeError("boom")
|
||
|
||
class _FakeExtraction(FeishuPipelineExtractionMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
self.llm = _FakeLLM()
|
||
|
||
result = await _FakeExtraction()._extract_signal(
|
||
"请把自动开票系统需要关注的外部回执信号整理一下",
|
||
projects=[],
|
||
)
|
||
|
||
assert result["matched_project_id"] is None
|
||
assert float(result["confidence"]) <= 0.4
|
||
assert "关键词规则" not in " ".join(result["reasoning"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_level_classification_failure_returns_degraded_payload_without_rule_reasoning() -> None:
|
||
class _FakeLLM:
|
||
async def generate(self, *args, **kwargs):
|
||
_ = args, kwargs
|
||
raise RuntimeError("boom")
|
||
|
||
class _FakeExtraction(FeishuPipelineExtractionMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
self.llm = _FakeLLM()
|
||
|
||
result = await _FakeExtraction()._classify_level(
|
||
message="如果今天票开不出去,自动开票系统最先会从哪里看出来",
|
||
projects=[],
|
||
)
|
||
|
||
assert result["level"] == "signal"
|
||
assert float(result["level_confidence"]) <= 0.4
|
||
assert "规则" not in str(result["level_reasoning"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_create_project_suggestion_preserves_uncertain_owner_and_timeline() -> None:
|
||
class _FakeDB:
|
||
def __init__(self) -> None:
|
||
self.added = []
|
||
|
||
def add(self, obj):
|
||
self.added.append(obj)
|
||
|
||
async def flush(self):
|
||
return None
|
||
|
||
class _FakeProcessing(FeishuPipelineItemProcessingMixin):
|
||
def __init__(self) -> None:
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 1
|
||
|
||
async def _extract_project_prefill(self, message: str):
|
||
_ = message
|
||
return {
|
||
"suggested_name": "内部知识库升级",
|
||
"suggested_description": "围绕内部知识库问答能力升级",
|
||
"suggested_owner": None,
|
||
"suggested_timeline": None,
|
||
"confidence": 0.31,
|
||
"reasoning": ["需求里没有明确负责人", "需求里没有明确时间线"],
|
||
}
|
||
|
||
project_info, suggestion = await _FakeProcessing()._create_project_suggestion(
|
||
item_id=12,
|
||
content="我们想做一个内部知识库升级项目,先把项目名称和范围整理出来。",
|
||
sender="测试用户",
|
||
fallback_confidence=0.31,
|
||
)
|
||
|
||
assert project_info["suggested_owner"] is None
|
||
assert project_info["suggested_timeline"] is None
|
||
assert suggestion.suggested_owner is None
|
||
assert suggestion.suggested_timeline is None
|
||
|
||
|
||
def test_project_create_idempotency_key_prefers_message_key() -> None:
|
||
key_one = FeishuPipelineCommandCommitProjectMixin._build_project_create_idempotency_key(
|
||
idempotency_key="feishu:sm-123",
|
||
trace_id="trace-1",
|
||
)
|
||
key_two = FeishuPipelineCommandCommitProjectMixin._build_project_create_idempotency_key(
|
||
idempotency_key="feishu:sm-123",
|
||
trace_id="trace-2",
|
||
)
|
||
fallback_key = FeishuPipelineCommandCommitProjectMixin._build_project_create_idempotency_key(
|
||
idempotency_key=None,
|
||
trace_id="trace-3",
|
||
)
|
||
|
||
assert key_one == "feishu:sm-123"
|
||
assert key_two == "feishu:sm-123"
|
||
assert fallback_key == "trace-3"
|
||
|
||
|
||
def test_task_orchestration_charter_context_flattens_modules() -> None:
|
||
charter = SimpleNamespace(
|
||
id=55,
|
||
title="内部知识库升级宪章",
|
||
version=2,
|
||
status="aligning",
|
||
content={
|
||
"modules": {
|
||
"module_1": {
|
||
"problem_statement": "减少重复检索",
|
||
"goal_statement": "提升命中率 / 降低重复提问",
|
||
"kpis": "首响 < 3s",
|
||
},
|
||
"module_2": {
|
||
"in_scope": "问答检索,知识补充",
|
||
"out_of_scope": "权限模型重构",
|
||
},
|
||
"module_4": {
|
||
"milestones": "需求冻结\n开发完成",
|
||
},
|
||
"module_5": {
|
||
"risk_register": "需求膨胀; 依赖不清",
|
||
},
|
||
"module_6": {
|
||
"governance_rule": "附件仅作证据引用",
|
||
},
|
||
}
|
||
},
|
||
)
|
||
|
||
context = SignalCaptureTaskOrchestrationMainMixin._build_task_orchestration_charter_context(charter)
|
||
|
||
assert context["title"] == "内部知识库升级宪章"
|
||
assert context["summary"] == "减少重复检索"
|
||
assert context["objectives"] == ["提升命中率", "降低重复提问"]
|
||
assert context["in_scope"] == ["问答检索", "知识补充"]
|
||
assert context["out_of_scope"] == ["权限模型重构"]
|
||
assert context["milestones"] == ["需求冻结", "开发完成"]
|
||
assert "附件仅作证据引用" in context["constraints"][0]
|
||
|
||
|
||
def test_task_orchestration_prompt_embeds_charter_context() -> None:
|
||
prompt = SignalCaptureTaskOrchestrationMainMixin._build_task_orchestration_prompt(
|
||
project_payload={
|
||
"id": 6,
|
||
"title": "内部知识库升级",
|
||
"description": "围绕内部知识库问答能力升级",
|
||
"assigned_role": "backend",
|
||
"status": "active",
|
||
},
|
||
charter_payload={
|
||
"id": 55,
|
||
"title": "内部知识库升级宪章",
|
||
"version": 2,
|
||
"status": "aligning",
|
||
"summary": "减少重复检索",
|
||
"objectives": ["提升命中率", "降低重复提问"],
|
||
"scope": "知识库检索与问答",
|
||
"in_scope": ["问答检索", "知识补充"],
|
||
"out_of_scope": ["权限模型重构"],
|
||
"success_criteria": ["首响 < 3s"],
|
||
"constraints": ["附件仅作证据引用"],
|
||
"milestones": ["需求冻结", "开发完成"],
|
||
"risks": ["需求膨胀"],
|
||
},
|
||
signal_payload={
|
||
"signal_id": 8,
|
||
"message": "支持导出知识库内容",
|
||
"classification": {"classification": "new_project"},
|
||
"proposer": "测试用户",
|
||
"captured_at": "2026-03-25T10:00:00+08:00",
|
||
},
|
||
team_members=[
|
||
{"user_id": 1, "name": "Alice", "workload_ratio": 42.0, "skill_tag_ids": [11, 12]},
|
||
],
|
||
)
|
||
|
||
assert "charter_json" in prompt
|
||
assert "内部知识库升级宪章" in prompt
|
||
assert "success_criteria" in prompt
|
||
assert "附件仅作证据引用" in prompt
|
||
|
||
|
||
def test_task_orchestration_fallback_uses_charter_context() -> None:
|
||
class _FakeSkill(SignalCaptureTaskOrchestrationSkillMixin):
|
||
@staticmethod
|
||
def _normalize_single_line_text(text: str, max_len: int = 64) -> str:
|
||
return " ".join(str(text or "").split())[:max_len]
|
||
|
||
tasks = _FakeSkill()._build_task_orchestration_fallback(
|
||
cleaned_message="新增内部知识库导出需求",
|
||
classification={"summary": "补充内部知识库导出能力"},
|
||
charter_context={
|
||
"title": "内部知识库升级宪章",
|
||
"summary": "围绕内部知识库问答体验升级",
|
||
},
|
||
)
|
||
|
||
assert len(tasks) == 2
|
||
assert all("内部知识库升级宪章" in task["description"] for task in tasks)
|
||
|
||
|
||
def test_queue_confirm_undo_prefers_feishu_message_id_for_idempotency() -> None:
|
||
item = _Item(item_id=42, level="command", operation_code="project.create")
|
||
key = FeishuPipelineQueueConfirmUndoMixin._build_operation_idempotency_key(item=item)
|
||
assert key == "feishu:msg-42"
|
||
|
||
|
||
def test_task_create_idempotency_key_prefers_message_key() -> None:
|
||
key_one = FeishuPipelineCommandCommitTaskMixin._build_task_create_idempotency_key(
|
||
idempotency_key="feishu:sm-456",
|
||
trace_id="trace-1",
|
||
)
|
||
key_two = FeishuPipelineCommandCommitTaskMixin._build_task_create_idempotency_key(
|
||
idempotency_key="feishu:sm-456",
|
||
trace_id="trace-2",
|
||
)
|
||
fallback_key = FeishuPipelineCommandCommitTaskMixin._build_task_create_idempotency_key(
|
||
idempotency_key=None,
|
||
trace_id="trace-3",
|
||
)
|
||
|
||
assert key_one == "feishu:sm-456"
|
||
assert key_two == "feishu:sm-456"
|
||
assert fallback_key == "trace-3"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_task_create_replays_existing_ledger_without_persisting_task(monkeypatch) -> None:
|
||
from app.services.integrations.feishu_signal_pipeline import mixin_command_commit_task as task_commit_module
|
||
|
||
class _FakeResult:
|
||
def __init__(self, value) -> None:
|
||
self._value = value
|
||
|
||
def scalar_one_or_none(self):
|
||
return self._value
|
||
|
||
def scalars(self):
|
||
return self
|
||
|
||
def first(self):
|
||
return self._value
|
||
|
||
class _FakeDB:
|
||
def __init__(self) -> None:
|
||
self.calls = 0
|
||
self.committed = False
|
||
|
||
async def execute(self, query):
|
||
self.calls += 1
|
||
if self.calls == 1:
|
||
return _FakeResult(type("ProjectRow", (), {"id": 11, "tenant_id": 1})())
|
||
if self.calls == 2:
|
||
return _FakeResult(type("TruthNodeRow", (), {"id": 22, "tenant_id": 1})())
|
||
raise AssertionError("unexpected execute")
|
||
|
||
async def commit(self):
|
||
self.committed = True
|
||
|
||
class _FakeLedger:
|
||
def __init__(self) -> None:
|
||
self.status = "applied"
|
||
self.result_payload = {
|
||
"status": "applied",
|
||
"message": "task created #777",
|
||
"entity_type": "task",
|
||
"entity_id": "777",
|
||
"result": {
|
||
"task_id": 777,
|
||
"title": "补充埋点",
|
||
"project_id": 11,
|
||
},
|
||
}
|
||
|
||
class _FakeCommit(FeishuPipelineCommandCommitTaskMixin):
|
||
def __init__(self) -> None:
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 1
|
||
self.sync_called = False
|
||
|
||
async def _load_task_create_ledger(self, *, idempotency_key):
|
||
self.loaded_key = idempotency_key
|
||
return _FakeLedger()
|
||
|
||
async def _claim_task_create_ledger(self, *, idempotency_key, trace_id, payload):
|
||
raise AssertionError("ledger claim should not be reached when replaying an applied result")
|
||
|
||
def _sync_task_rag(self, *, task):
|
||
self.sync_called = True
|
||
|
||
async def _fail_persist_task(*args, **kwargs):
|
||
raise AssertionError("persist_task should not be called when replaying an existing ledger")
|
||
|
||
monkeypatch.setattr(task_commit_module, "persist_task", _fail_persist_task)
|
||
|
||
kernel = _FakeCommit()
|
||
result = await kernel._commit_task_create(
|
||
operator_user=type("User", (), {"id": 9})(),
|
||
payload={"project_id": 11, "title": "补充埋点", "description": "补充行为埋点"},
|
||
trace_id="trace-task-1",
|
||
idempotency_key="feishu:msg-777",
|
||
)
|
||
|
||
assert result["status"] == "applied"
|
||
assert result["result"]["task_id"] == 777
|
||
assert kernel.loaded_key == "feishu:msg-777"
|
||
assert kernel.db.committed is False
|
||
assert kernel.sync_called is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_project_gate_always_queues_ready_create(monkeypatch) -> None:
|
||
called: dict[str, object] = {}
|
||
|
||
class _FakeGate(FeishuPipelineGateProjectMixin):
|
||
def __init__(self) -> None:
|
||
self.db = object()
|
||
self.tenant_id = 1
|
||
|
||
@staticmethod
|
||
def _build_project_clarification_message(missing_fields):
|
||
return "请补充项目信息。"
|
||
|
||
@staticmethod
|
||
def _stable_positive_int(*parts):
|
||
return 42
|
||
|
||
async def _resolve_command_operator_user(self, *, sender):
|
||
called["operator_sender"] = sender
|
||
return object()
|
||
|
||
def _can_execute_high_value_action(self, operator_user):
|
||
called["can_execute"] = operator_user
|
||
return True
|
||
|
||
async def _queue_project_intent_item(self, **kwargs):
|
||
called["queue_kwargs"] = kwargs
|
||
return {
|
||
"item_id": 42,
|
||
"level": "project",
|
||
"project_id": None,
|
||
"signal_id": None,
|
||
"route_status": "queued",
|
||
"clarification_message": "建项目已进入待确认队列,请点击继续执行。",
|
||
}
|
||
|
||
async def _write_processing_log(self, **kwargs):
|
||
called["write_processing_log"] = kwargs
|
||
|
||
async def _execute_controlled_operation(self, **kwargs):
|
||
raise AssertionError("project.create should not execute before confirmation")
|
||
|
||
kernel = _FakeGate()
|
||
result = await kernel._handle_create_project_gate(
|
||
message="请创建一个新项目《AI PMO闭环重构实战-0314》:目标是完成工作流路由重构并上线验收。",
|
||
sender="林宇航",
|
||
source_chat_id="chat-1",
|
||
source_sender_open_id="ou-1",
|
||
feishu_message_id="msg-1",
|
||
captured_at=None,
|
||
intent_gate={
|
||
"intent": "project.create",
|
||
"fields": {
|
||
"ready": True,
|
||
"title": "AI PMO闭环重构实战-0314",
|
||
"description": "目标是完成工作流路由重构并上线验收。",
|
||
"project_scope": "workflow routing",
|
||
},
|
||
},
|
||
process_started_at=0.0,
|
||
)
|
||
|
||
assert result["direct_reply_required"] is True
|
||
assert result["created_project_id"] is None
|
||
assert result["results"][0]["item_id"] == 42
|
||
assert result["reply_template_key"] == "queue_pending_confirm"
|
||
assert result["queue_item_id"] == 42
|
||
assert called["queue_kwargs"]["message"].startswith("请创建一个新项目")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_signal_route_item_ignores_low_confidence_threshold_when_project_is_matched() -> None:
|
||
class _FakeDBResult:
|
||
def scalar_one_or_none(self):
|
||
return None
|
||
|
||
class _FakeDB:
|
||
def __init__(self) -> None:
|
||
self.added = []
|
||
|
||
async def execute(self, query):
|
||
_ = query
|
||
return _FakeDBResult()
|
||
|
||
def add(self, obj):
|
||
self.added.append(obj)
|
||
|
||
async def flush(self):
|
||
return None
|
||
|
||
class _FakeFinalize(FeishuPipelineItemFinalizeRoutingMixin, FeishuPipelineMessageUtilsBMixin):
|
||
def __init__(self) -> None:
|
||
self.db = _FakeDB()
|
||
self.tenant_id = 1
|
||
self.log_kwargs: dict[str, object] = {}
|
||
self.notified: tuple[int, int] | None = None
|
||
|
||
async def _ensure_signal_link(self, *, signal_id: int, project_id: int) -> None:
|
||
_ = (signal_id, project_id)
|
||
|
||
async def _write_processing_log(self, **kwargs):
|
||
self.log_kwargs = kwargs
|
||
|
||
async def _notify_signal_auto_entered(self, *, project_id: int, signal) -> None:
|
||
self.notified = (project_id, int(signal.id))
|
||
|
||
item = SimpleNamespace(id=1)
|
||
signal = SimpleNamespace(id=88, title="直播礼物量分析")
|
||
|
||
kernel = _FakeFinalize()
|
||
result = await kernel._route_signal_item(
|
||
item=item,
|
||
signal=signal,
|
||
matched_project_id=62,
|
||
confidence=0.03,
|
||
cold_start_enabled=False,
|
||
signal_auto_enter_threshold=0.80,
|
||
)
|
||
|
||
assert result == 62
|
||
assert item.safe_to_auto_execute is True
|
||
assert item.auto_execute_state == "done"
|
||
assert item.blocking_reason is None
|
||
assert item.route_status == "auto_done"
|
||
assert kernel.notified == (62, 88)
|