801 lines
28 KiB
Python
801 lines
28 KiB
Python
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
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_process_message_helpers_b import (
|
||
FeishuPipelineProcessMessageHelpersBMixin,
|
||
)
|
||
from app.services.integrations.feishu_signal_pipeline.mixin_queue_confirm_undo import (
|
||
FeishuPipelineQueueConfirmUndoMixin,
|
||
)
|
||
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_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_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
|
||
|
||
|
||
@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
|
||
|
||
|
||
@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 _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)
|
||
|
||
service = ProactiveSignalCaptureService(object())
|
||
result = await service.capture_from_feishu(
|
||
group_chat_id="chat-1",
|
||
message_content="我感觉第一个垂直应用前景比较好,但是垂直哪个行业呢?",
|
||
sender="测试用户",
|
||
tenant_id=1,
|
||
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_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
|
||
|
||
@staticmethod
|
||
def _looks_like_contextual_project_request(message: str) -> bool:
|
||
return True
|
||
|
||
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 float(result["confidence"]) <= 0.35
|
||
assert len(result["reasoning"]) >= 1
|
||
|
||
|
||
@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)
|