PMFlow/backend/tests/test_draft_confirm_handler.py

554 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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