PMFlow/backend/tests/test_draft_confirm_handler.py

1807 lines
69 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.

import json
from types import SimpleNamespace
import pytest
from app.config.ai_prompts import (
PROMPT_AI_PMO_REQUIREMENT_PLAN_QA,
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.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract
from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import (
FeishuReplyLLMCoreMixin,
)
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_reply_outbox_normalize_beautifies_structured_pm_sections() -> None:
from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin
raw = (
"结论:项目 #104 当前还不能直接判断是否启动后端。\n"
"依据:当前只命中静态代码索引,缺少真实归属和负责人信息。\n"
"下一步:先确认后端代码归属。\n"
"信息不足,缺少:负责人、仓库路径"
)
normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw)
assert "🎯 结论:" in normalized
assert "🔎 依据:" in normalized
assert "➡️ 下一步:" in normalized
assert "🧩 信息不足,缺少:" in normalized
assert "\n\n🔎 依据:" in normalized
def test_feishu_reply_llm_core_strips_action_process_prefaces() -> None:
reply = (
"好的,我来帮你创建项目和信号源。\n\n"
"让我先检查一下后端服务是否运行,然后通过API创建项目。"
)
assert FeishuReplyLLMCoreMixin._strip_leading_thinking_paragraphs(reply) == ""
def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None:
ack = FeishuReplyLLMCoreMixin._build_action_request_ack_reply(
message_content="随便一句不带动作的话",
capture_result={
"status": "captured",
"semantic_bundle": {
"bundle_id": "semantic-1",
"semantic_version": "2026-03-27.workflow-v1",
"source_system": "feishu",
"primary_workflow": "new_project_flow",
"workflows": [
{
"workflow_type": "new_project_flow",
"workflow_action": "create",
"target_scope": "global",
"operation_codes": ["project.create"],
"requires_confirmation": False,
"confidence": 0.9,
"slots": [],
"clarification_needed": False,
"metadata": {},
},
{
"workflow_type": "signal_source_flow",
"workflow_action": "onboard",
"target_scope": "global",
"operation_codes": ["signal_source.onboard"],
"requires_confirmation": False,
"confidence": 0.92,
"slots": [],
"clarification_needed": False,
"metadata": {},
},
],
"message_gate": "auto_execute",
"reply_contract": {
"mode": "aggregate_card",
"template_key": "workflow_bundle",
"summary": "new_project_flow",
},
"diagnostics": {},
},
},
)
assert ack == "已收到,项目创建和信号源接入请求已记录。"
def test_feishu_reply_llm_core_action_ack_no_longer_calls_planner_action_classifier(
monkeypatch: pytest.MonkeyPatch,
) -> None:
_ = monkeypatch
ack = FeishuReplyLLMCoreMixin._build_action_request_ack_reply(
message_content="随便一句不带动作的话",
capture_result={
"status": "captured",
"semantic_bundle": {
"bundle_id": "semantic-1",
"semantic_version": "2026-03-27.workflow-v1",
"source_system": "feishu",
"primary_workflow": "signal_source_flow",
"workflows": [
{
"workflow_type": "signal_source_flow",
"workflow_action": "onboard",
"target_scope": "global",
"operation_codes": ["signal_source.onboard"],
"requires_confirmation": False,
"confidence": 0.92,
"slots": [],
"clarification_needed": False,
"metadata": {},
}
],
"message_gate": "auto_execute",
"reply_contract": {
"mode": "aggregate_card",
"template_key": "workflow_bundle",
"summary": "signal_source_flow",
},
"diagnostics": {},
},
},
)
assert ack == "已收到,信号源接入请求已记录。"
def test_consultant_dispatch_contract_marks_incomplete_task_create_as_clarify() -> None:
contract = build_consultant_dispatch_contract(
intent_name="task.create",
message_text="帮我建个任务",
source_system="pmflow_manual",
status="pending_manual",
classification="command",
primary_operation_code="task.create",
risk_level="medium",
missing_fields=["project_id", "task_title"],
direct_reply_text="未执行:当前建任务请求还缺少 归属项目、任务标题。请补充后我再创建。",
)
assert contract["domain"] == "task"
assert contract["mode"] == "clarify"
assert contract["allow_execute"] is False
assert contract["missing_fields"] == ["project_id", "task_title"]
assert "补充" in contract["next_action"]
def test_consultant_dispatch_contract_marks_scope_expansion_as_challenge() -> None:
contract = build_consultant_dispatch_contract(
intent_name="project.create",
message_text="把整个系统全部重做并一次替换所有模块",
source_system="pmflow_manual",
status="pending_manual",
classification="command",
primary_operation_code="project.create",
risk_level="high",
missing_fields=[],
direct_reply_text="当前建议先拆分范围,再确认是否继续推进。",
)
assert contract["domain"] == "project"
assert contract["mode"] == "challenge"
assert contract["allow_execute"] is False
assert contract["next_action"]
def test_feishu_reply_llm_core_no_longer_uses_consultant_dispatch_reply() -> None:
clarify = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply(
{
"direct_reply_text": "",
"consultant_dispatch": {
"domain": "task",
"mode": "clarify",
"risk_level": "medium",
"missing_fields": ["project_id", "task_title"],
"allow_execute": False,
"next_action": "请补充归属项目和任务标题后继续。",
},
}
)
assert clarify is None
@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_persists_rich_structured_draft_without_reextract(
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
raise AssertionError(f"rich structured draft should bypass reextract path: {kwargs}")
async def _normalize_project_creation_draft(self, **kwargs): # noqa: ANN001
captured["normalize_kwargs"] = kwargs
return {
"project": {"id": "101", "title": "跨境 DTC 品牌 AI 运营平台", "name": "跨境 DTC 品牌 AI 运营平台"},
"signal": {"content": "main signal", "impact_score": 0.9, "priority": "high", "strategic_analysis": {}},
"charter": {"title": "跨境 DTC 品牌 AI 运营平台项目宪章", "content": {"summary": "demo"}},
"tasks": kwargs["draft"].get("tasks", []),
"task_audit": [],
"risks": [],
"creator_id": 77,
}
async def _generate_task_chain_draft(self, **kwargs): # noqa: ANN001
captured["task_chain_kwargs"] = kwargs
return [
{
"title": "直播数据采集与清洗",
"description": "打通直播录屏、评论和订单数据。",
"priority": "high",
"estimated_hours": 24,
"required_skills": ["Python", "FastAPI"],
}
]
async def _persist_project_creation_draft(self, **kwargs): # noqa: ANN001
captured["persist_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": "直播数据采集与清洗"}],
"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 快速完成海外市场的内容本地化、社媒运营、用户洞察。",
"positioning_one_liner": "帮助跨境 DTC 团队快速完成直播复盘与动作沉淀。",
"target_customers": "北美 DTC 品牌运营团队",
"original_text": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化、社媒运营、用户洞察。",
"core_pain_points": [
{
"pain": "人工复盘慢",
"status": "单场耗时超过 2 小时",
"opportunity": "压缩到 10 分钟内",
}
],
"team_setup": [
{"role": "PM", "notes": "负责 MVP 边界与验收"},
{"role": "后端开发", "notes": "负责 Python 服务和数据对接"},
],
"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 captured["normalize_kwargs"]["creator_id"] == 77
assert captured["persist_kwargs"]["requested_member_user_ids"] == [88]
project_input_text = str(captured["normalize_kwargs"]["input_text"])
assert "项目标题:跨境 DTC 品牌 AI 运营平台" in project_input_text
assert "目标客户:北美 DTC 品牌运营团队" in project_input_text
assert "团队配置:" in project_input_text
assert "PM:负责 MVP 边界与验收" in project_input_text
assert "人工复盘慢" in project_input_text
assert "📋 我先按产品经理视角起草一版评审建议,请确认:" not in project_input_text
charter_content = captured["normalize_kwargs"]["draft"]["charter"]["content"]
assert charter_content["schema_version"] == "charter_ai_first_v1"
assert sorted(charter_content["modules"].keys()) == [
"module_1",
"module_2",
"module_3",
"module_4",
"module_5",
"module_6",
]
assert "北美 DTC 品牌运营团队" in charter_content["modules"]["module_1"]["problem_statement"]
assert "内容本地化" in charter_content["modules"]["module_2"]["in_scope"]
assert captured["persist_kwargs"]["normalized"]["tasks"][0]["title"] == "直播数据采集与清洗"
assert captured["task_chain_kwargs"]["analysis"]["project_title"] == "跨境 DTC 品牌 AI 运营平台"
@pytest.mark.asyncio
async def test_draft_confirm_handler_project_create_confirm_uses_project_creation_flow_for_sparse_draft(
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",
},
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 "steward_kwargs" in captured
assert captured["steward_kwargs"]["creator_id"] == 77
assert captured["steward_kwargs"]["requested_member_user_ids"] == [88]
project_input_text = str(captured["steward_kwargs"]["input_text"])
assert "项目标题:跨境 DTC 品牌 AI 运营平台" in project_input_text
@pytest.mark.asyncio
async def test_draft_confirm_handler_project_create_confirm_falls_back_to_structured_draft_when_llm_reextract_fails(
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
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 快速完成海外市场内容运营。",
"target_customers": "北美 DTC 品牌运营团队",
"original_text": "帮助中国 DTC 品牌用 AI 快速完成海外市场内容运营。",
"recommended_assignee_id": 88,
"recommended_assignee_name": "Alice",
"team_setup": [{"role": "PM", "notes": "负责 MVP 边界与验收"}],
"requirement_items": [
{
"section": "核心痛点",
"title": "内容本地化",
"description": "翻译公司贵、慢、不地道",
}
],
},
execution_result={},
)
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})())
async def _fail_create_project_from_natural_language(self, **kwargs): # noqa: ANN001
captured["create_kwargs"] = kwargs
raise RuntimeError(
"AI 管家项目创建步骤失败:function=project_requirement_extraction, reason=provider_circuit_open"
)
async def _fake_normalize_project_creation_draft(self, **kwargs): # noqa: ANN001
captured["normalize_kwargs"] = kwargs
return {
"analysis": {},
"project": {
"name": "跨境 DTC 品牌 AI 运营平台",
"title": "跨境 DTC 品牌 AI 运营平台",
"description": "项目:跨境 DTC 品牌 AI 运营平台",
"assigned_role": "backend",
"domain_id": None,
},
"signal": {
"content": "main signal",
"impact_score": 0.9,
"priority": "high",
"strategic_analysis": {},
},
"charter": {
"title": "跨境 DTC 品牌 AI 运营平台项目宪章",
"content": {"summary": "demo"},
},
"tasks": kwargs["draft"].get("tasks", []),
"task_audit": [],
"risks": [],
"creator_id": 77,
}
async def _fake_generate_task_chain_draft(self, **kwargs): # noqa: ANN001
captured["task_chain_kwargs"] = kwargs
return [
{
"title": "接入 TikTok 与 Shopify 数据",
"description": "打通直播、订单与退款数据并完成字段对齐",
"priority": "high",
"estimated_hours": 16,
"required_skills": ["Python", "数据分析"],
}
]
async def _fake_persist_project_creation_draft(self, **kwargs): # noqa: ANN001
captured["persist_kwargs"] = kwargs
return {
"project": {"id": "101", "title": "跨境 DTC 品牌 AI 运营平台", "name": "跨境 DTC 品牌 AI 运营平台"},
"project_id": 101,
"signal": {"id": "202", "content": "main signal"},
"signal_id": 202,
"charter": {"id": "303", "title": "宪章", "content": {"summary": "demo"}},
"tasks": [
{
"id": "401",
"title": "接入 TikTok 与 Shopify 数据",
"description": "打通直播、订单与退款数据并完成字段对齐",
"priority": "high",
"status": "todo",
"estimated_hours": 16,
}
],
"risks": [],
"signal_sources": [
{
"id": "501",
"source_kind": "custom",
"source_name": "核心痛点 - 内容本地化",
"source_identifier": "custom:core-pain-content-localization",
}
],
}
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService.create_project_from_natural_language",
_fail_create_project_from_natural_language,
)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService._normalize_project_creation_draft",
_fake_normalize_project_creation_draft,
)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService._generate_task_chain_draft",
_fake_generate_task_chain_draft,
)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService._persist_project_creation_draft",
_fake_persist_project_creation_draft,
)
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 captured["normalize_kwargs"]["draft"]["project"]["title"] == "跨境 DTC 品牌 AI 运营平台"
assert captured["normalize_kwargs"]["draft"]["tasks"][0]["title"] == "接入 TikTok 与 Shopify 数据"
assert captured["persist_kwargs"]["normalized"]["project"]["title"] == "跨境 DTC 品牌 AI 运营平台"
assert captured["persist_kwargs"]["normalized"]["tasks"][0]["title"] == "接入 TikTok 与 Shopify 数据"
assert captured["persist_kwargs"]["requested_member_user_ids"] == [88]
assert captured["task_chain_kwargs"]["analysis"]["project_title"] == "跨境 DTC 品牌 AI 运营平台"
assert "录屏" not in str(captured["task_chain_kwargs"]["analysis"].get("project_title") or "")
@pytest.mark.asyncio
async def test_draft_confirm_handler_project_create_confirm_survives_charter_task_provider_failure(
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
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": "跨境直播复盘助手",
"summary": "帮助跨境 DTC 团队完成直播复盘。",
"positioning_one_liner": "围绕直播录屏、评论与订单数据形成复盘闭环。",
"target_customers": "北美 DTC 品牌运营团队",
"original_text": "做一个跨境直播复盘助手。",
"recommended_assignee_id": 88,
"recommended_assignee_name": "Alice",
"team_setup": [
{"role": "PM", "notes": "负责 MVP 边界与验收"},
{"role": "后端开发", "notes": "负责 Python 服务与数据对接"},
{"role": "前端开发", "notes": "负责复盘看板"},
],
"mvp_items": [
"直播录屏与评论回放切片",
"亮点和掉点自动总结",
"可执行复盘清单",
],
"validation_metrics": {
"product": ["7 天内复盘时长下降 60%", "高频问题复现率下降 30%"],
},
"requirement_items": [
{
"section": "信号源",
"title": "TikTok Shop 直播数据",
"description": "提供观看、互动与成交数据",
}
],
},
execution_result={},
)
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})())
async def _fail_create_project_from_natural_language(self, **kwargs): # noqa: ANN001
captured["create_kwargs"] = kwargs
raise RuntimeError(
"AI 管家项目创建步骤失败:function=project_requirement_extraction, reason=provider_circuit_open"
)
async def _fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001
_ = self, tenant_id, limit
return ["Python", "FastAPI", "React", "TypeScript", "数据分析", "项目管理"]
async def _fake_normalize_project_creation_draft(self, **kwargs): # noqa: ANN001
captured["normalize_kwargs"] = kwargs
return {
"analysis": {},
"project": {
"name": "跨境直播复盘助手",
"title": "跨境直播复盘助手",
"description": "项目:跨境直播复盘助手",
"assigned_role": "backend",
"domain_id": None,
},
"signal": {
"content": "main signal",
"impact_score": 0.9,
"priority": "high",
"strategic_analysis": {},
},
"charter": {
"title": "跨境直播复盘助手项目宪章",
"content": kwargs["draft"]["charter"]["content"],
},
"tasks": kwargs["draft"].get("tasks", []),
"task_audit": [],
"risks": [],
"creator_id": 77,
}
async def _fake_persist_project_creation_draft(self, **kwargs): # noqa: ANN001
captured["persist_kwargs"] = kwargs
return {
"project": {"id": "101", "title": "跨境直播复盘助手", "name": "跨境直播复盘助手"},
"project_id": 101,
"signal": {"id": "202", "content": "main signal"},
"signal_id": 202,
"charter": {
"id": "303",
"title": "跨境直播复盘助手项目宪章",
"content": kwargs["normalized"]["charter"]["content"],
},
"tasks": [
{
"id": "401",
"title": kwargs["normalized"]["tasks"][0]["title"],
"description": kwargs["normalized"]["tasks"][0]["description"],
"priority": kwargs["normalized"]["tasks"][0]["priority"],
"status": "todo",
"estimated_hours": kwargs["normalized"]["tasks"][0]["estimated_hours"],
}
],
"risks": [],
"signal_sources": [],
}
async def _fail_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001
_ = self, charter_content, project_id, tenant_id, db
raise RuntimeError("provider_circuit_open:openai")
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService.create_project_from_natural_language",
_fail_create_project_from_natural_language,
)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService._load_available_skill_names",
_fake_load_available_skill_names,
)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService._normalize_project_creation_draft",
_fake_normalize_project_creation_draft,
)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.AIStewardService._persist_project_creation_draft",
_fake_persist_project_creation_draft,
)
monkeypatch.setattr(
"app.services.ai_service_core.service.AIService.decompose_tasks_from_charter",
_fail_decompose_tasks_from_charter,
)
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": "帮我做一个跨境直播复盘项目"}),
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 captured["persist_kwargs"]["requested_member_user_ids"] == [88]
assert captured["normalize_kwargs"]["draft"]["tasks"][0]["title"] == "直播录屏与评论回放切片"
assert captured["normalize_kwargs"]["draft"]["tasks"][0]["required_skills"] == ["Python", "FastAPI", "数据分析"]
@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_revise_requirement_plan_bypasses_generic_draft_generator(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,
confirm_message_id=None,
owner_user_id=77,
draft_content={
"draft_source": "requirement_plan",
"plan_profile": "it_project",
"title": "换季穿搭助手",
"original_text": "做一个换季穿搭助手。",
"requirement_card": {
"plan_profile": "it_project",
"title": "换季穿搭助手",
"goal": "先验证换季穿搭推荐是否有用。",
"project_nature": "轻量级推荐工具",
"target_user": "穿搭新手个体用户",
"tech_route": "规则推荐版",
"first_scope": "基础属性匹配",
"delivery_mode": "小范围试用",
"question_round": 5,
},
},
execution_result={},
)
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})())
captured: dict[str, object] = {}
async def _unexpected_generate_and_persist(**kwargs): # noqa: ANN001
raise AssertionError(f"generic draft generator should not run for requirement_plan drafts: {kwargs}")
async def _fake_revise_requirement_plan_draft(self, **kwargs): # noqa: ANN001
captured["requirement_revise_kwargs"] = kwargs
locked.draft_version = 3
locked.draft_content = {
**locked.draft_content,
"draft_text": (
"📋 我先按当前收敛结果,整理成一版需求草稿:\n\n"
"🌐【外部信号源】\n"
"1. 天气 API:提供实时温度和体感变化。\n"
"2. 淘宝开放平台:补充当季商品与类目属性。\n"
"3. 得物开放数据:补充年轻用户偏好的款式热度。\n"
"4. 小红书趋势榜:补充换季搭配内容趋势。\n"
"5. 微信收藏/点击回流:补充用户采纳反馈。\n\n"
"👤【推荐负责人】陈后端(当前负载 28%)\n"
"理由:系统检索到他当前负载较低,且技能更接近推荐与数据接入。"
),
"recommended_assignee_id": 18,
"recommended_assignee_name": "陈后端",
"recommended_assignee_workload": 28.0,
"recommended_assignee_reason": "系统检索到他当前负载较低,且技能更接近推荐与数据接入。",
"external_signal_sources": [
{"name": "天气 API", "purpose": "提供实时温度和体感变化", "phase": "首版优先接入"},
{"name": "淘宝开放平台", "purpose": "补充当季商品与类目属性", "phase": "首版优先接入"},
{"name": "得物开放数据", "purpose": "补充年轻用户偏好的款式热度", "phase": "第二阶段接入"},
],
}
return {
"draft_text": str(locked.draft_content["draft_text"]),
"draft_content": dict(locked.draft_content),
"draft_version": 3,
}
monkeypatch.setattr(handler.draft_generator, "generate_and_persist", _unexpected_generate_and_persist)
monkeypatch.setattr(
DraftConfirmHandler,
"_revise_requirement_plan_draft",
_fake_revise_requirement_plan_draft,
raising=False,
)
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 "🌐【外部信号源】" in result["followup_reply"]
assert "天气 API" in result["followup_reply"]
assert "👤【推荐负责人】陈后端" in result["followup_reply"]
assert captured["requirement_revise_kwargs"]["revision_note"] == "补充外部信号源和推荐负责人。"
@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"] == ""
@pytest.mark.asyncio
async def test_draft_confirm_handler_confirm_without_target_returns_failed_safe(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 True
assert result["status"] == "failed_safe"
assert "当前没有可确认草稿" in result["followup_reply"]
@pytest.mark.asyncio
async def test_draft_confirm_handler_natural_revision_without_target_and_without_anchor_is_ignored(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=None,
)
assert result["handled"] is False
assert result["status"] == "ignored"
assert result["followup_reply"] == ""
@pytest.mark.asyncio
async def test_draft_confirm_handler_without_target_and_without_anchor_confirm_is_ignored(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=None,
)
assert result["handled"] is False
assert result["status"] == "ignored"
assert result["followup_reply"] == ""
@pytest.mark.asyncio
async def test_draft_confirm_handler_display_feedback_uses_semantic_answer_only(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": SimpleNamespace(execute=object()), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_active_mounted_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="ready"),
SimpleNamespace(id=77, mode="plan", plan_status="awaiting_execute_confirm"),
)
class _FakeAssembler:
def __init__(self, db):
self.db = db
async def assemble(self, **kwargs):
return SimpleNamespace(
mode_card=SimpleNamespace(
allowed_actions=["reply.compose", "draft.followup.answer_only"],
forbidden_actions=[],
)
)
class _FakeDecisionService:
def __init__(self, llm_service):
self.llm_service = llm_service
async def decide(self, *, packet):
_ = packet
return SimpleNamespace(
topic_relation="continue_current_topic",
mode_decision="answer_only",
followup_action="answer_only",
revision_note="",
assistant_reply="收到,这是展示形式反馈:卡片可以长,聊天直发需要更短,我不会重起草当前草稿。",
requires_confirmation=False,
requested_actions=["reply.compose", "draft.followup.answer_only"],
)
async def _unexpected_confirm(**kwargs): # noqa: ANN001
raise AssertionError(f"confirm_draft should not run for display feedback: {kwargs}")
async def _unexpected_revise(**kwargs): # noqa: ANN001
raise AssertionError(f"revise_draft should not run for display feedback: {kwargs}")
async def _unexpected_cancel(**kwargs): # noqa: ANN001
raise AssertionError(f"cancel_draft should not run for display feedback: {kwargs}")
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
monkeypatch.setattr("app.services.ai_pmo.draft_confirm_handler.SemanticContextAssembler", _FakeAssembler, raising=False)
monkeypatch.setattr(
"app.services.ai_pmo.draft_confirm_handler.SemanticDecisionService",
_FakeDecisionService,
raising=False,
)
monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm)
monkeypatch.setattr(handler, "revise_draft", _unexpected_revise)
monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel)
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 True
assert result["status"] == "pending_manual"
assert "展示形式反馈" in result["followup_reply"]
assert "不会重起草" in result["followup_reply"]
@pytest.mark.asyncio
async def test_draft_confirm_handler_uses_work_item_bound_mounted_draft_for_semantic_followup(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": SimpleNamespace(execute=object()), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
captured: dict[str, object] = {}
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_active_mounted_draft(**kwargs):
_ = kwargs
return None
async def _fake_resolve_mounted_draft_by_work_item_id(**kwargs):
captured["mounted_lookup"] = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"),
SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"),
)
async def _fake_semantic_followup(**kwargs):
captured["semantic_kwargs"] = kwargs
return {
"action": "revise",
"revision_note": "补充外部信号源。",
"assistant_reply": "",
}
async def _fake_revise_draft(**kwargs):
captured["revise_kwargs"] = kwargs
return {
"handled": True,
"status": "draft_pending_confirm",
"followup_reply": "我先按当前草稿补外部信号源。",
}
async def _unexpected_confirm(**kwargs): # noqa: ANN001
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
async def _unexpected_cancel(**kwargs): # noqa: ANN001
raise AssertionError(f"cancel_draft should not run here: {kwargs}")
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
monkeypatch.setattr(handler, "_resolve_mounted_draft_by_work_item_id", _fake_resolve_mounted_draft_by_work_item_id)
monkeypatch.setattr(handler, "_resolve_semantic_followup_decision", _fake_semantic_followup)
monkeypatch.setattr(handler, "revise_draft", _fake_revise_draft)
monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm)
monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel)
result = await handler.handle_text_reply(
message_text="按这版继续",
sender="林宇航",
source_system="feishu",
source_message_id="msg-continue-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context=None,
)
assert result["handled"] is True
assert result["status"] == "draft_pending_confirm"
assert "当前草稿" in result["followup_reply"]
assert captured["mounted_lookup"] == {"work_item_id": 42}
assert captured["semantic_kwargs"]["active_mounted"][1].id == 77
assert captured["revise_kwargs"]["revision_note"] == "补充外部信号源。"
@pytest.mark.asyncio
async def test_draft_confirm_handler_target_bound_semantic_override_revises_without_parser_fallback(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_target_mounted_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"),
SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"),
)
async def _fake_revise_draft(**kwargs):
return {
"handled": True,
"status": "draft_pending_confirm",
"followup_reply": f"按当前草稿重起:{kwargs['revision_note']}",
}
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft)
monkeypatch.setattr(handler, "revise_draft", _fake_revise_draft)
result = await handler.handle_text_reply(
message_text="把外部信号源补上",
sender="林宇航",
source_system="feishu",
source_message_id="msg-override-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context={"reply_to_message_id": "reply-1"},
semantic_followup_override={
"action": "revise",
"revision_note": "补充外部信号源。",
"assistant_reply": "",
},
)
assert result["handled"] is True
assert result["status"] == "draft_pending_confirm"
assert "补充外部信号源" in result["followup_reply"]
@pytest.mark.asyncio
async def test_draft_confirm_handler_target_bound_without_semantic_action_returns_pending_manual(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_target_mounted_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"),
SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"),
)
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft)
result = await handler.handle_text_reply(
message_text="把目标用户改成平台运维团队",
sender="林宇航",
source_system="feishu",
source_message_id="msg-no-semantic-action-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context={"reply_to_message_id": "reply-1"},
semantic_followup_override={
"action": "",
"revision_note": "",
"assistant_reply": "",
},
)
assert result["handled"] is True
assert result["status"] == "pending_manual"
assert "请回复“确认”、“修改 + 你的意见”或“取消”" in result["followup_reply"]
@pytest.mark.asyncio
async def test_draft_confirm_handler_target_bound_without_anchor_and_without_semantic_action_is_ignored(monkeypatch) -> None:
kernel = type("Kernel", (), {"db": object(), "tenant_id": 1})()
handler = DraftConfirmHandler(kernel)
async def _fake_resolve_pending_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(
id=42,
state=STATE_DRAFT_PENDING_CONFIRM,
intent="project.create",
project_id=None,
signal_id=None,
draft_version=2,
),
SimpleNamespace(payload={"message_text": "原始需求"}),
)
async def _fake_resolve_target_mounted_draft(**kwargs):
_ = kwargs
return (
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="draft_pending_confirm"),
SimpleNamespace(id=77, mode="plan", plan_status="draft_pending_confirm"),
)
monkeypatch.setattr(handler, "_resolve_pending_draft", _fake_resolve_pending_draft)
monkeypatch.setattr(handler, "_resolve_target_mounted_draft", _fake_resolve_target_mounted_draft)
result = await handler.handle_text_reply(
message_text="收到,辛苦了",
sender="林宇航",
source_system="feishu",
source_message_id="msg-no-anchor-ignore-1",
channel_ref="chat-1",
source_sender_open_id="ou_xxx",
thread_context=None,
semantic_followup_override={
"action": "",
"revision_note": "",
"assistant_reply": "",
},
)
assert result["handled"] is False
assert result["status"] == "ignored"
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(",已入库处理")
class _FakePromptResult:
@staticmethod
def scalar_one_or_none():
return None
class _FakePromptDB:
async def execute(self, query): # noqa: ARG002
return _FakePromptResult()
def _make_adaptive_rag_mixin() -> AdaptiveRAGDeepGuardAnswerMixin:
mixin = AdaptiveRAGDeepGuardAnswerMixin()
mixin.db = _FakePromptDB()
mixin.tenant_id = 1
return mixin
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await 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
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await 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
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await 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
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await 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
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await 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
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_trend() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await 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
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_context_explicit() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await 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
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None:
mixin = _make_adaptive_rag_mixin()
scene_key, prompt = await 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
@pytest.mark.asyncio
async def test_adaptive_rag_select_scene_prompt_feishu_direct_compacts_snapshot_into_manifest_and_focus() -> None:
mixin = _make_adaptive_rag_mixin()
system_snapshot = {
"assistant_profile": {"name": "PMFlow", "identity": "项目管理顾问"},
"platform_profile": {
"backend": {"module_counts": {"projects": 28, "tasks": 146, "signals": 41}},
"feature_catalog": [
{"name": "项目管理", "route": "/projects"},
{"name": "信号接入", "route": "/signals"},
{"name": "任务看板", "route": "/board"},
{"name": "知识图谱", "route": "/kg"},
{"name": "训练中心", "route": "/training"},
],
},
}
user_snapshot = {
"current_user": {"name": "林宇航"},
"projects": [{"id": idx, "title": f"项目{idx}"} for idx in range(1, 15)],
"tasks": [{"id": idx, "title": f"任务{idx}"} for idx in range(1, 10)],
}
scene_key, prompt = await mixin._select_scene_prompt(
question="信息不全,继续提供",
project_id=7,
asked_via="feishu",
message_intent=None,
mixed_signal_part=None,
conversation_text="Q: 上一轮你提到这个需求还缺目标对象\nA: 我建议先补齐范围和交付目标",
context_blocks_text=(
"[M] 项目画像\n项目#7 Alpha(进行中);功能 18 个;KG 实体 12,关系 19。\n"
"[1] 来源: task\n相关度: 0.882\n内容: 当前需求缺少目标对象、验收口径和时间边界。\n"
"[2] 来源: project\n相关度: 0.701\n内容: 这是一个很长的项目列表噪声块,不应该整段原样进入飞书直答。"
),
system_snapshot=system_snapshot,
user_snapshot=user_snapshot,
system_snapshot_text=json.dumps(system_snapshot, ensure_ascii=False),
user_snapshot_text=json.dumps(user_snapshot, ensure_ascii=False),
relevance_max=0.0,
)
assert scene_key == "feishu_direct"
assert "项目画像:" in prompt
assert "项目#7 Alpha(进行中)" in prompt
assert "系统摘要:" in prompt
assert "用户焦点:" in prompt
assert "林宇航" in prompt
assert '"projects"' not in prompt
assert "项目14" not 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 "轻量 emoji" in PROMPT_FEISHU_DIRECT_QA
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 "五轮" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
assert "A/B/C" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
assert "推荐技术/能力" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA
assert "当前我更推荐" in PROMPT_AI_PMO_REQUIREMENT_PLAN_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