554 lines
20 KiB
Python
554 lines
20 KiB
Python
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from app.config.ai_prompts import (
|
||
PROMPT_EXTERNAL_TREND_QA,
|
||
PROMPT_FEISHU_DIRECT_QA,
|
||
PROMPT_FEISHU_MIXED_QA,
|
||
PROMPT_PROJECT_STATUS_QA,
|
||
)
|
||
from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_SUCCEEDED
|
||
from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler
|
||
from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_DRAFT_PENDING_CONFIRM
|
||
from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto
|
||
from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin
|
||
from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import (
|
||
FeishuReplyLLMCoreMixin,
|
||
)
|
||
|
||
|
||
def test_parse_action_confirm() -> None:
|
||
action, note = DraftConfirmHandler.parse_action_from_text(message_text="确认")
|
||
assert action == "confirm"
|
||
assert note == ""
|
||
|
||
|
||
def test_parse_action_revise_with_note() -> None:
|
||
action, note = DraftConfirmHandler.parse_action_from_text(message_text="修改 改成每周跟进")
|
||
assert action == "revise"
|
||
assert note == "改成每周跟进"
|
||
|
||
|
||
def test_parse_action_cancel() -> None:
|
||
action, note = DraftConfirmHandler.parse_action_from_text(message_text="取消")
|
||
assert action == "cancel"
|
||
assert note == ""
|
||
|
||
|
||
def test_parse_action_yes_as_confirm() -> None:
|
||
action, note = DraftConfirmHandler.parse_action_from_text(message_text="yes")
|
||
assert action == "confirm"
|
||
assert note == ""
|
||
|
||
|
||
def test_draft_confirm_handler_thread_anchor_match() -> None:
|
||
ingest = type(
|
||
"Ingest",
|
||
(),
|
||
{
|
||
"payload": {
|
||
"thread_id": "th_1",
|
||
"root_message_id": "root_1",
|
||
"reply_to_message_id": "reply_1",
|
||
},
|
||
"source_message_id": "src_1",
|
||
},
|
||
)()
|
||
work_item = type("WorkItem", (), {"source_message_id": "wi_1"})()
|
||
|
||
assert DraftConfirmHandler._matches_thread_anchor(
|
||
work_item=work_item,
|
||
ingest=ingest,
|
||
anchor_context={"reply_to_message_id": "reply_1"},
|
||
) is True
|
||
assert DraftConfirmHandler._matches_thread_anchor(
|
||
work_item=work_item,
|
||
ingest=ingest,
|
||
anchor_context={"reply_to_message_id": "missing"},
|
||
) is False
|
||
assert DraftConfirmHandler._normalize_thread_context(
|
||
{"thread_id": "th_1", "root_message_id": "root_1", "reply_to_message_id": "reply_1"}
|
||
) == {
|
||
"thread_id": "th_1",
|
||
"root_message_id": "root_1",
|
||
"reply_to_message_id": "reply_1",
|
||
}
|
||
|
||
|
||
def test_draft_confirm_handler_thread_anchor_matches_draft_reply_message_id() -> None:
|
||
ingest = type(
|
||
"Ingest",
|
||
(),
|
||
{
|
||
"payload": {
|
||
"thread_id": "th_1",
|
||
"root_message_id": "draft_msg_1",
|
||
"reply_to_message_id": "draft_msg_1",
|
||
},
|
||
"source_message_id": "original_msg_1",
|
||
},
|
||
)()
|
||
work_item = type("WorkItem", (), {"source_message_id": "original_msg_1"})()
|
||
|
||
assert DraftConfirmHandler._matches_thread_anchor(
|
||
work_item=work_item,
|
||
ingest=ingest,
|
||
anchor_context={"root_message_id": "draft_msg_1"},
|
||
draft_reply_message_id="draft_msg_1",
|
||
) is True
|
||
|
||
|
||
def test_reply_outbox_extract_sent_message_id() -> None:
|
||
from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin
|
||
|
||
assert FeishuReplyOutboxMixin._extract_sent_message_id({"data": {"message_id": "msg_123"}}) == "msg_123"
|
||
assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456"
|
||
|
||
|
||
def test_feishu_reply_llm_core_strips_action_process_prefaces() -> None:
|
||
reply = (
|
||
"好的,我来帮你创建项目和信号源。\n\n"
|
||
"让我先检查一下后端服务是否运行,然后通过API创建项目。"
|
||
)
|
||
|
||
assert FeishuReplyLLMCoreMixin._strip_leading_thinking_paragraphs(reply) == ""
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None:
|
||
class _FakeResult:
|
||
def __init__(self, locked):
|
||
self._locked = locked
|
||
|
||
def scalar_one_or_none(self):
|
||
return self._locked
|
||
|
||
class _FakeDB:
|
||
def __init__(self, locked):
|
||
self._locked = locked
|
||
|
||
async def execute(self, query): # noqa: ARG002
|
||
return _FakeResult(self._locked)
|
||
|
||
locked = SimpleNamespace(
|
||
id=42,
|
||
state=STATE_SUCCEEDED,
|
||
intent="draft",
|
||
project_id=7,
|
||
signal_id=9,
|
||
draft_version=1,
|
||
draft_expires_at=None,
|
||
)
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})())
|
||
|
||
result = await handler.confirm_draft(
|
||
target_work_item=SimpleNamespace(id=42, state=STATE_SUCCEEDED, intent="draft", project_id=7, signal_id=9),
|
||
target_ingest=SimpleNamespace(),
|
||
sender="测试用户",
|
||
source_system="feishu",
|
||
source_message_id="msg-1",
|
||
message_text="确认",
|
||
expected_version=None,
|
||
)
|
||
|
||
assert result["handled"] is True
|
||
assert result["status"] == "succeeded"
|
||
assert result["followup_reply"] == "已入库成功,无需重复确认。"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_project_create_confirm_uses_project_creation_flow(monkeypatch) -> None:
|
||
captured: dict[str, object] = {}
|
||
|
||
class _FakeResult:
|
||
def __init__(self, locked):
|
||
self._locked = locked
|
||
|
||
def scalar_one_or_none(self):
|
||
return self._locked
|
||
|
||
class _FakeDB:
|
||
def __init__(self, locked):
|
||
self._locked = locked
|
||
|
||
async def execute(self, query): # noqa: ARG002
|
||
return _FakeResult(self._locked)
|
||
|
||
async def flush(self):
|
||
captured["flushed"] = True
|
||
|
||
class _FakeStewardService:
|
||
def __init__(self, db):
|
||
captured["steward_db"] = db
|
||
|
||
async def create_project_from_natural_language(self, **kwargs): # noqa: ANN001
|
||
captured["steward_kwargs"] = kwargs
|
||
return {
|
||
"project": {"id": "101", "title": "跨境 DTC 品牌 AI 运营平台", "name": "跨境 DTC 品牌 AI 运营平台"},
|
||
"signal": {"id": "202", "content": "main signal"},
|
||
"charter": {"id": "303", "title": "宪章", "content": {"summary": "demo"}},
|
||
"tasks": [{"id": "401", "title": "任务 1"}],
|
||
"risks": [],
|
||
"signal_sources": [
|
||
{
|
||
"id": "501",
|
||
"source_kind": "custom",
|
||
"source_name": "核心痛点 - 内容本地化",
|
||
"source_identifier": "custom:core-pain-content-localization",
|
||
}
|
||
],
|
||
}
|
||
|
||
locked = SimpleNamespace(
|
||
id=42,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
intent="project.create",
|
||
project_id=None,
|
||
signal_id=None,
|
||
draft_version=2,
|
||
draft_expires_at=None,
|
||
confirm_message_id=None,
|
||
owner_user_id=77,
|
||
draft_content={
|
||
"title": "跨境 DTC 品牌 AI 运营平台",
|
||
"summary": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化、社媒运营、用户洞察。",
|
||
"original_text": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化、社媒运营、用户洞察。",
|
||
"recommended_assignee_id": 88,
|
||
"recommended_assignee_name": "Alice",
|
||
"requirement_items": [
|
||
{
|
||
"section": "核心痛点",
|
||
"title": "内容本地化",
|
||
"description": "翻译公司贵、慢、不地道",
|
||
}
|
||
],
|
||
},
|
||
execution_result={},
|
||
)
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})())
|
||
|
||
monkeypatch.setattr("app.services.ai_pmo.draft_confirm_handler.AIStewardService", _FakeStewardService)
|
||
|
||
result = await handler.confirm_draft(
|
||
target_work_item=SimpleNamespace(
|
||
id=42,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
intent="project.create",
|
||
project_id=None,
|
||
signal_id=None,
|
||
owner_user_id=77,
|
||
),
|
||
target_ingest=SimpleNamespace(payload={"message_text": "帮我做一个出海 DTC 项目"}),
|
||
sender="测试用户",
|
||
source_system="feishu",
|
||
source_message_id="msg-1",
|
||
message_text="确认",
|
||
expected_version=2,
|
||
)
|
||
|
||
assert result["handled"] is True
|
||
assert result["status"] == "succeeded"
|
||
assert result["project_id"] == 101
|
||
assert result["signal_id"] == 202
|
||
assert "信号源" in result["followup_reply"]
|
||
assert "steward_kwargs" in captured
|
||
assert captured["steward_kwargs"]["creator_id"] == 77
|
||
assert captured["steward_kwargs"]["requested_member_user_ids"] == [88]
|
||
assert "跨境 DTC 品牌 AI 运营平台" in str(captured["steward_kwargs"]["input_text"])
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_revise_without_note_requests_feedback(monkeypatch) -> None:
|
||
class _FakeResult:
|
||
def __init__(self, locked):
|
||
self._locked = locked
|
||
|
||
def scalar_one_or_none(self):
|
||
return self._locked
|
||
|
||
class _FakeDB:
|
||
def __init__(self, locked):
|
||
self._locked = locked
|
||
|
||
async def execute(self, query): # noqa: ARG002
|
||
return _FakeResult(self._locked)
|
||
|
||
async def flush(self):
|
||
return None
|
||
|
||
locked = SimpleNamespace(
|
||
id=42,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
intent="project.create",
|
||
project_id=None,
|
||
signal_id=None,
|
||
draft_version=2,
|
||
draft_expires_at=None,
|
||
draft_content={"title": "跨境 DTC 品牌 AI 运营平台", "original_text": "原始需求"},
|
||
execution_result={},
|
||
)
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})())
|
||
|
||
async def _fail_generate_and_persist(**kwargs): # noqa: ANN001
|
||
raise AssertionError(f"draft generator should not run without a revision note: {kwargs}")
|
||
|
||
monkeypatch.setattr(handler.draft_generator, "generate_and_persist", _fail_generate_and_persist)
|
||
|
||
result = await handler.revise_draft(
|
||
target_work_item=SimpleNamespace(id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None),
|
||
target_ingest=SimpleNamespace(payload={"message_text": "原始需求"}),
|
||
revision_note="",
|
||
expected_version=2,
|
||
)
|
||
|
||
assert result["handled"] is True
|
||
assert result["status"] == "draft_pending_confirm"
|
||
assert result["target_state"] == STATE_DRAFT_PENDING_CONFIRM
|
||
assert "修改" in result["followup_reply"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_ignores_non_draft_reply_without_target(monkeypatch) -> None:
|
||
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
|
||
handler = DraftConfirmHandler(kernel)
|
||
|
||
async def _fake_resolve_pending_draft(**kwargs):
|
||
_ = kwargs
|
||
return None
|
||
|
||
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
|
||
|
||
result = await handler.handle_text_reply(
|
||
message_text="创建时间是多少?",
|
||
sender="林宇航",
|
||
source_system="feishu",
|
||
source_message_id="msg-1",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
thread_context={"reply_to_message_id": "reply-1"},
|
||
)
|
||
|
||
assert result["handled"] is False
|
||
assert result["status"] == "ignored"
|
||
assert result["followup_reply"] == ""
|
||
|
||
|
||
def test_kernel_ingress_mixin_auto_safe_int_regression() -> None:
|
||
assert callable(mixin_auto._safe_int)
|
||
assert mixin_auto._safe_int("123") == 123
|
||
assert mixin_auto._safe_int("") is None
|
||
assert mixin_auto._safe_int(None) is None
|
||
|
||
|
||
def test_adaptive_rag_process_entry_and_mixed_format() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
assert mixin._is_ai_pmo_process_entry("pmflow_manual") is True
|
||
assert mixin._is_ai_pmo_process_entry("feishu") is False
|
||
|
||
mixed = mixin._format_mixed_two_block_output(
|
||
answer_text="[回答] 接口契约已对齐\n[已收到需求] 新增任务创建确认流",
|
||
question_part="现在进展怎么样",
|
||
signal_part="补齐任务确认",
|
||
)
|
||
assert mixed.startswith("[回答] ")
|
||
assert "\n[已收到需求] " in mixed
|
||
assert mixed.endswith(",已入库处理")
|
||
|
||
|
||
def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
scene_key, prompt = mixin._select_scene_prompt(
|
||
question="现在进展怎么样?",
|
||
project_id=7,
|
||
asked_via="pmflow_manual",
|
||
message_intent="mixed",
|
||
mixed_signal_part="新增任务确认流",
|
||
conversation_text="无",
|
||
context_blocks_text="无",
|
||
system_snapshot={},
|
||
user_snapshot={},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
assert scene_key == "mixed"
|
||
assert "现在进展怎么样" in prompt
|
||
assert "新增任务确认流" in prompt
|
||
|
||
|
||
def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
scene_key, prompt = mixin._select_scene_prompt(
|
||
question="帮我总结这个项目",
|
||
project_id=9,
|
||
asked_via="pmflow_manual",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text="无",
|
||
system_snapshot={},
|
||
user_snapshot={},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
assert scene_key == "ai_pmo"
|
||
assert "帮我总结这个项目" in prompt
|
||
|
||
|
||
def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
scene_key, prompt = mixin._select_scene_prompt(
|
||
question="如果 AI 助手第一次上线只允许一种提醒,你会把它做成桌面通知还是飞书消息?为什么?",
|
||
project_id=None,
|
||
asked_via="feishu",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text="无",
|
||
system_snapshot={},
|
||
user_snapshot={},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
assert scene_key == "decision_choice"
|
||
assert "明确选择" in prompt
|
||
assert "飞书消息" in prompt
|
||
|
||
|
||
def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
scene_key, prompt = mixin._select_scene_prompt(
|
||
question="帮我看看宠物行业最新动态",
|
||
project_id=None,
|
||
asked_via="feishu",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text="无",
|
||
system_snapshot={"assistant_profile": {"name": "PMFlow"}},
|
||
user_snapshot={"projects": [{"id": 1}]},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
assert scene_key == "industry_trend"
|
||
assert "当前未接入实时数据" in prompt
|
||
assert "project_context" not in prompt
|
||
|
||
|
||
def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
scene_key, prompt = mixin._select_scene_prompt(
|
||
question="杨宇现在负载情况如何?",
|
||
project_id=None,
|
||
asked_via="feishu",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text=(
|
||
"[1] 来源: project\n相关度: 0.991\n内容: Enterprise Knowledge Base 项目列表\n"
|
||
"[2] 来源: workload\n相关度: 0.884\n内容: 杨宇 当前负载 265.56% 超载\n"
|
||
),
|
||
system_snapshot={"team_members": []},
|
||
user_snapshot={"projects": [{"id": 1}]},
|
||
system_snapshot_text="{\"team_members\": []}",
|
||
user_snapshot_text="{\"projects\": [{\"id\": 1}]}",
|
||
relevance_max=0.0,
|
||
)
|
||
assert scene_key == "member_workload"
|
||
assert "Enterprise Knowledge Base 项目列表" not in prompt
|
||
assert "杨宇 当前负载 265.56% 超载" in prompt
|
||
assert '"projects"' not in prompt
|
||
|
||
|
||
def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_trend() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
scene_key, prompt = mixin._select_scene_prompt(
|
||
question="帮我看看宠物行业最新动态,重点只说消费侧和产品侧的变化,不要提项目。",
|
||
project_id=7,
|
||
asked_via="feishu",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text="无",
|
||
system_snapshot={"assistant_profile": {"name": "PMFlow"}},
|
||
user_snapshot={"projects": [{"id": 1}]},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
assert scene_key == "industry_trend"
|
||
assert "当前未接入实时数据" in prompt
|
||
assert "project_context" not in prompt
|
||
|
||
|
||
def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_context_explicit() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
scene_key, prompt = mixin._select_scene_prompt(
|
||
question="这个项目所在行业最新动态怎么样?",
|
||
project_id=7,
|
||
asked_via="feishu",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text="无",
|
||
system_snapshot={"assistant_profile": {"name": "PMFlow"}},
|
||
user_snapshot={"projects": [{"id": 1}]},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
assert scene_key == "external_trend"
|
||
assert "项目上下文" in prompt
|
||
assert "外部搜索结果" in prompt
|
||
|
||
|
||
def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None:
|
||
mixin = AdaptiveRAGDeepGuardAnswerMixin()
|
||
scene_key, prompt = mixin._select_scene_prompt(
|
||
question="这个项目最新进展怎么样?",
|
||
project_id=7,
|
||
asked_via="feishu",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text="无",
|
||
system_snapshot={"assistant_profile": {"name": "PMFlow"}},
|
||
user_snapshot={"projects": [{"id": 1}]},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
query_type="status",
|
||
)
|
||
assert scene_key == "project_status"
|
||
assert "项目状态分析助手" in prompt
|
||
assert "完成率" in prompt
|
||
assert "当前阶段" in prompt
|
||
|
||
|
||
def test_feishu_qa_prompts_prefer_professional_answer_style() -> None:
|
||
assert "先给结论" in PROMPT_FEISHU_DIRECT_QA
|
||
assert "信息不足,缺少" in PROMPT_FEISHU_DIRECT_QA
|
||
assert "适合飞书消息阅读" in PROMPT_FEISHU_DIRECT_QA
|
||
assert "先回答问题部分" in PROMPT_FEISHU_MIXED_QA
|
||
assert "[已收到需求]" in PROMPT_FEISHU_MIXED_QA
|
||
assert "专业" in PROMPT_FEISHU_MIXED_QA
|
||
assert "XXXX年" not in PROMPT_EXTERNAL_TREND_QA
|
||
assert "通用行业认知" in PROMPT_EXTERNAL_TREND_QA
|
||
assert "项目状态分析助手" in PROMPT_PROJECT_STATUS_QA
|
||
|
||
|
||
def test_feishu_reply_llm_fallback_is_failure_oriented() -> None:
|
||
reply_text = FeishuReplyLLMCoreMixin._fallback_reply_text(
|
||
{
|
||
"status": "captured",
|
||
"classification": "project",
|
||
}
|
||
)
|
||
assert "当前回复" in reply_text
|
||
assert "已收到需求并完成初步识别" not in reply_text
|
||
assert "已入库处理" not in reply_text
|