370 lines
17 KiB
Python
370 lines
17 KiB
Python
import unittest
|
||
import asyncio
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
from app.models.feishu_item import FeishuItem
|
||
from app.services.feishu_signal_pipeline import FOLLOWUP_STATUS_RE, FeishuSignalPipelineService
|
||
|
||
|
||
class InboxQueueViewTest(unittest.TestCase):
|
||
def test_has_processed_message_ignores_ingress_trace_only(self) -> None:
|
||
service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type]
|
||
|
||
async def fake_execute(stmt):
|
||
sql = str(stmt)
|
||
if "FROM feishu_items" in sql:
|
||
return SimpleNamespace(scalar_one_or_none=lambda: None)
|
||
self.assertIn("ai_processing_logs.level IS NULL OR ai_processing_logs.level !=", sql)
|
||
self.assertIn(
|
||
"ai_processing_logs.routing_decision IS NULL OR ai_processing_logs.routing_decision !=",
|
||
sql,
|
||
)
|
||
return SimpleNamespace(scalar_one_or_none=lambda: None)
|
||
|
||
service.db.execute = AsyncMock(side_effect=fake_execute) # type: ignore[attr-defined]
|
||
|
||
exists = asyncio.run(service._has_processed_message("om_ingress_only"))
|
||
|
||
self.assertFalse(exists)
|
||
|
||
def test_dedupe_queue_items_aggregates_occurrence_count(self) -> None:
|
||
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
|
||
first = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="接入 GitHub 信号源",
|
||
level="signal",
|
||
card_type="signal",
|
||
route_status="pending",
|
||
dedupe_key="signal:3:group:external_integration",
|
||
occurrence_count=2,
|
||
)
|
||
second = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="接入 GitHub 信号源",
|
||
level="signal",
|
||
card_type="signal",
|
||
route_status="pending",
|
||
dedupe_key="signal:3:group:external_integration",
|
||
occurrence_count=1,
|
||
)
|
||
|
||
rows = service._dedupe_queue_items([first, second])
|
||
|
||
self.assertEqual(len(rows), 1)
|
||
self.assertEqual(int(rows[0].occurrence_count), 3)
|
||
|
||
def test_resolve_message_class_falls_back_from_route_status(self) -> None:
|
||
pending_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="同步 GitHub issue",
|
||
level="signal",
|
||
card_type="signal",
|
||
route_status="pending",
|
||
)
|
||
auto_done_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="同步 GitHub issue",
|
||
level="signal",
|
||
card_type="signal",
|
||
route_status="auto_processed",
|
||
)
|
||
|
||
self.assertEqual(FeishuSignalPipelineService._resolve_message_class(pending_item), "action_required")
|
||
self.assertEqual(FeishuSignalPipelineService._resolve_message_class(auto_done_item), "auto_done")
|
||
|
||
def test_resolve_message_class_supports_new_status_machine(self) -> None:
|
||
queued_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="新增一个项目",
|
||
level="project",
|
||
card_type="new_project_candidate",
|
||
route_status="queued_for_decision",
|
||
)
|
||
undone_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="新增一个项目",
|
||
level="command",
|
||
card_type="project_create",
|
||
route_status="undone",
|
||
)
|
||
|
||
self.assertEqual(FeishuSignalPipelineService._resolve_message_class(queued_item), "action_required")
|
||
self.assertEqual(FeishuSignalPipelineService._resolve_message_class(undone_item), "auto_done")
|
||
|
||
def test_extract_project_intent_fields_requires_title_description_and_scope(self) -> None:
|
||
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
|
||
|
||
fields = service._extract_project_intent_fields("新增一个项目,名称叫飞书协同中台,用于统一多群消息分发,范围是 PMFlow 主数据入库。")
|
||
|
||
self.assertTrue(fields["ready"])
|
||
self.assertEqual(fields["title"], "飞书协同中台")
|
||
self.assertIn("统一多群消息分发", fields["description"])
|
||
self.assertIn("PMFlow 主数据入库", fields["project_scope"])
|
||
|
||
def test_resolve_intent_gate_recognizes_explicit_project_create(self) -> None:
|
||
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
|
||
|
||
gate = asyncio.run(
|
||
service._resolve_intent_gate(
|
||
message="不对,是新增进 PMFlow 的项目列表里面,名称叫飞书协同中台,用于统一多群消息分发,范围是 PMFlow 主数据入库。",
|
||
inferred_project_id=None,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(gate["intent"], "create_project")
|
||
self.assertEqual(gate["canonical_action"], "project.create")
|
||
|
||
def test_resolve_intent_gate_uses_context_prefill_for_project_create(self) -> None:
|
||
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
|
||
service._maybe_prefill_project_fields_from_context = AsyncMock( # type: ignore[method-assign]
|
||
return_value={
|
||
"title": "医药十五五方向研究",
|
||
"description": "基于最近一轮医药方向问答整理投资与立项建议。",
|
||
"project_scope": "围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。",
|
||
"missing_fields": [],
|
||
"ready": True,
|
||
}
|
||
)
|
||
|
||
gate = asyncio.run(
|
||
service._resolve_intent_gate(
|
||
message="汇总以上内容给我创建个项目在项目列表",
|
||
inferred_project_id=None,
|
||
source_chat_id="oc_test",
|
||
source_sender_open_id="ou_test",
|
||
sender="林宇航",
|
||
)
|
||
)
|
||
|
||
self.assertEqual(gate["intent"], "create_project")
|
||
self.assertEqual(gate["fields"]["title"], "医药十五五方向研究")
|
||
|
||
def test_resolve_intent_gate_uses_embedded_message_context_for_project_create(self) -> None:
|
||
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
|
||
|
||
gate = asyncio.run(
|
||
service._resolve_intent_gate(
|
||
message=(
|
||
'目前"十五五"规划(2026-2030)还在制定中,但从已有信号来看,医药行业这几个方向值得关注:'
|
||
" 创新药会是核心 中医药传承创新 应对老龄化 供应链安全 出海国际化"
|
||
" 根据这个汇总一下,给我创建一个项目在项目列表"
|
||
),
|
||
inferred_project_id=None,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(gate["intent"], "create_project")
|
||
self.assertEqual(gate["fields"]["title"], "十五五医药方向研究")
|
||
self.assertIn("创新药会是核心", gate["fields"]["description"])
|
||
|
||
def test_resolve_intent_gate_blocks_project_create_when_recent_context_is_failure_text(self) -> None:
|
||
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
|
||
service._load_recent_chat_turns = AsyncMock( # type: ignore[method-assign]
|
||
return_value=[
|
||
{
|
||
"question": "目前十五五规划里,医药行业值得关注哪些方向?",
|
||
"answer": "我暂时无法生成可靠回答(模型服务异常)。为避免误导,本次不直接给结论;请稍后重试。",
|
||
}
|
||
]
|
||
)
|
||
service._load_recent_chat_reply_fallback_text = AsyncMock(return_value="") # type: ignore[method-assign]
|
||
|
||
gate = asyncio.run(
|
||
service._resolve_intent_gate(
|
||
message="根据上面汇总一下,给我创建一个项目在项目列表",
|
||
inferred_project_id=None,
|
||
source_chat_id="oc_test",
|
||
source_sender_open_id="ou_test",
|
||
sender="林宇航",
|
||
)
|
||
)
|
||
|
||
self.assertEqual(gate["intent"], "clarification_required")
|
||
self.assertIn("title", gate["fields"]["missing_fields"])
|
||
|
||
def test_followup_continue_accepts_project_reference_message(self) -> None:
|
||
self.assertTrue(
|
||
FeishuSignalPipelineService._looks_like_continue_followup("你应该继续创建我刚才要求的项目")
|
||
)
|
||
self.assertFalse(
|
||
FeishuSignalPipelineService._looks_like_continue_followup("继续创建一个项目,名称叫飞书协同中台")
|
||
)
|
||
|
||
def test_followup_status_regex_matches_creation_question(self) -> None:
|
||
self.assertTrue(bool(FOLLOWUP_STATUS_RE.search("是否已经创建")))
|
||
self.assertTrue(bool(FOLLOWUP_STATUS_RE.search("创建了吗")))
|
||
|
||
def test_select_followup_status_target_prefers_project_create(self) -> None:
|
||
source_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="新增一个Web搜索信号源",
|
||
level="command",
|
||
card_type="command",
|
||
route_status="auto_done",
|
||
result_payload={"operation_code": "source.connect"},
|
||
)
|
||
project_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="给我创建一个项目在项目列表",
|
||
level="command",
|
||
card_type="project_create",
|
||
route_status="auto_done",
|
||
result_payload={"operation_code": "project.create"},
|
||
)
|
||
|
||
target = FeishuSignalPipelineService._select_followup_status_target(
|
||
message="是否已经创建",
|
||
recent_items=[source_item, project_item],
|
||
)
|
||
|
||
self.assertIs(target, project_item)
|
||
|
||
def test_resolve_project_candidate_fields_blocks_placeholder_project(self) -> None:
|
||
service = FeishuSignalPipelineService(db=None, tenant_id=1) # type: ignore[arg-type]
|
||
item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="给我创建一个项目在项目列表",
|
||
level="project",
|
||
card_type="new_project_candidate",
|
||
route_status="queued_for_decision",
|
||
result_payload={
|
||
"operation_code": "project.create",
|
||
"operation_payload": {
|
||
"title": "",
|
||
"description": "",
|
||
},
|
||
"project_scope": "",
|
||
},
|
||
blocking_reason="未执行:当前建项目请求还缺少 项目名称。",
|
||
)
|
||
|
||
fields = service._resolve_project_candidate_fields(item=item, suggestion=None)
|
||
|
||
self.assertFalse(fields["ready"])
|
||
self.assertIn("title", fields["missing_fields"])
|
||
|
||
def test_create_project_gate_missing_fields_only_returns_clarification(self) -> None:
|
||
service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type]
|
||
service._resolve_command_operator_user = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign]
|
||
service._can_execute_high_value_action = lambda _operator: True # type: ignore[method-assign]
|
||
service._queue_project_intent_item = AsyncMock() # type: ignore[method-assign]
|
||
service._write_processing_log = AsyncMock() # type: ignore[method-assign]
|
||
service._execute_controlled_operation = AsyncMock( # type: ignore[method-assign]
|
||
side_effect=AssertionError("executor should not run")
|
||
)
|
||
|
||
result = asyncio.run(
|
||
service._handle_create_project_gate(
|
||
message="汇总以上内容给我创建个项目在项目列表",
|
||
sender="林宇航",
|
||
source_chat_id="oc_test",
|
||
source_sender_open_id="ou_test",
|
||
feishu_message_id="om_test",
|
||
captured_at=None,
|
||
intent_gate={
|
||
"intent": "clarification_required",
|
||
"canonical_action": "project.create",
|
||
"fields": {
|
||
"title": "",
|
||
"description": "汇总以上内容给我创建个项目在项目列表",
|
||
"project_scope": "",
|
||
"missing_fields": ["title"],
|
||
"ready": False,
|
||
},
|
||
"clarification_template": "未执行:当前建项目请求还缺少 项目名称。",
|
||
},
|
||
process_started_at=0.0,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(result["intent"], "clarification_required")
|
||
self.assertEqual(result["direct_reply_text"], "未执行:当前建项目请求还缺少 项目名称。")
|
||
self.assertEqual(result["results"], [])
|
||
service._queue_project_intent_item.assert_not_awaited()
|
||
service._execute_controlled_operation.assert_not_awaited()
|
||
|
||
def test_create_task_gate_missing_fields_only_returns_clarification(self) -> None:
|
||
service = FeishuSignalPipelineService(db=SimpleNamespace(), tenant_id=1) # type: ignore[arg-type]
|
||
service._resolve_command_operator_user = AsyncMock(return_value={"id": 1}) # type: ignore[method-assign]
|
||
service._can_execute_high_value_action = lambda _operator: True # type: ignore[method-assign]
|
||
service._queue_command_intent_item = AsyncMock() # type: ignore[method-assign]
|
||
service._write_processing_log = AsyncMock() # type: ignore[method-assign]
|
||
service._execute_controlled_operation = AsyncMock( # type: ignore[method-assign]
|
||
side_effect=AssertionError("executor should not run")
|
||
)
|
||
|
||
result = asyncio.run(
|
||
service._handle_create_task_gate(
|
||
message="帮我建个任务",
|
||
sender="林宇航",
|
||
source_chat_id="oc_test",
|
||
source_sender_open_id="ou_test",
|
||
feishu_message_id="om_test",
|
||
captured_at=None,
|
||
intent_gate={
|
||
"intent": "clarification_required",
|
||
"canonical_action": "task.create",
|
||
"fields": {
|
||
"project_id": None,
|
||
"title": "",
|
||
"description": "",
|
||
"missing_fields": ["project_id", "title"],
|
||
"ready": False,
|
||
},
|
||
"clarification_template": "未执行:当前建任务请求还缺少 归属项目、任务标题。",
|
||
},
|
||
process_started_at=0.0,
|
||
)
|
||
)
|
||
|
||
self.assertEqual(result["intent"], "clarification_required")
|
||
self.assertEqual(result["direct_reply_text"], "未执行:当前建任务请求还缺少 归属项目、任务标题。")
|
||
self.assertEqual(result["results"], [])
|
||
service._queue_command_intent_item.assert_not_awaited()
|
||
service._execute_controlled_operation.assert_not_awaited()
|
||
|
||
def test_should_hide_pending_item_from_inbox_for_query_and_missing_fields(self) -> None:
|
||
lookup_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="现在有哪些项目",
|
||
level="signal",
|
||
card_type="signal",
|
||
route_status="pending",
|
||
)
|
||
clarification_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="汇总以上内容给我创建个项目在项目列表",
|
||
level="project",
|
||
card_type="new_project_candidate",
|
||
route_status="queued_for_decision",
|
||
decision_reason="missing_required_fields",
|
||
)
|
||
status_item = FeishuItem(
|
||
tenant_id=1,
|
||
raw_message="你好 长连接时都已通",
|
||
level="signal",
|
||
card_type="signal",
|
||
route_status="pending",
|
||
)
|
||
|
||
self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(lookup_item))
|
||
self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(clarification_item))
|
||
self.assertTrue(FeishuSignalPipelineService._should_hide_pending_item_from_inbox(status_item))
|
||
|
||
def test_build_project_preview_prefers_structured_title_and_detail(self) -> None:
|
||
title = FeishuSignalPipelineService._build_project_preview_title(
|
||
title="",
|
||
description="基于最近一轮医药方向问答整理投资与立项建议。",
|
||
project_scope="围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。",
|
||
)
|
||
summary = FeishuSignalPipelineService._build_project_preview_summary(
|
||
description="基于最近一轮医药方向问答整理投资与立项建议。",
|
||
project_scope="围绕创新药、医疗器械、公共卫生与老龄化方向做机会梳理。",
|
||
blocking_reason="未执行:当前建项目请求还缺少 项目名称。",
|
||
fallback_text="汇总以上内容给我创建个项目在项目列表",
|
||
)
|
||
|
||
self.assertEqual(title, "围绕创新药")
|
||
self.assertIn("投资与立项建议", summary)
|