2937 lines
110 KiB
Python
2937 lines
110 KiB
Python
import asyncio
|
||
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_INDUSTRY_TREND_QA,
|
||
PROMPT_PROJECT_STATUS_QA,
|
||
)
|
||
from app.config.ai_prompts_catalog.part_scenes_01 import FEISHU_QA_RESPONSE_STYLE
|
||
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_reply_outbox_normalize_strips_internal_feature_catalog_block() -> None:
|
||
from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin
|
||
|
||
raw = (
|
||
"我先帮你收敛需求。\n\n"
|
||
"证据补充(Feature Catalog):\n"
|
||
"1. AI 管家 | 路由: /ai-steward | 页面: frontend/src/pages/AI/Steward.tsx | API: /ai-steward/\n"
|
||
"2. 工单中心 | 路由: /tickets | 页面: frontend/src/pages/Tickets.tsx | API: /tickets/list"
|
||
)
|
||
|
||
normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw)
|
||
|
||
assert normalized == "我先帮你收敛需求。"
|
||
assert "Feature Catalog" not in normalized
|
||
assert "frontend/src/" not in normalized
|
||
assert "/ai-steward" not in normalized
|
||
|
||
|
||
def test_reply_outbox_normalize_strips_internal_function_and_route_markers() -> None:
|
||
from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin
|
||
|
||
raw = (
|
||
"AI 管家项目创建步骤失败:function=project_requirement_extraction, "
|
||
"workflow_route=query.answer, reason=provider_circuit_open"
|
||
)
|
||
|
||
normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw)
|
||
|
||
assert "function=" not in normalized
|
||
assert "workflow_route=" not in normalized
|
||
assert normalized == "AI 管家项目创建步骤失败:provider_circuit_open"
|
||
|
||
|
||
def test_reply_outbox_normalize_keeps_provider_failure_reason_while_stripping_internal_debug_fields() -> None:
|
||
from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin
|
||
|
||
raw = (
|
||
"草稿确认入库失败:模型调用失败:function=decompose_tasks_from_charter, "
|
||
"planned_tier=high, executed_tier=high, source=default, "
|
||
"rule=engine_high_reasoning_glm5, decision=rule_match, "
|
||
"reason=provider_circuit_open:openai"
|
||
)
|
||
|
||
normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw)
|
||
|
||
assert "function=" not in normalized
|
||
assert "planned_tier=" not in normalized
|
||
assert "rule=" not in normalized
|
||
assert normalized == "草稿确认入库失败:模型调用失败:provider_circuit_open:openai"
|
||
|
||
|
||
def test_reply_outbox_normalize_keeps_long_requirement_plan_reply_complete() -> None:
|
||
from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin
|
||
|
||
filler = "补充说明" * 120
|
||
raw = (
|
||
"可以,我们先不创建项目,先把这件事收成一版需求。\n\n"
|
||
"A. 轻量工具型\n"
|
||
f"{filler}\n\n"
|
||
"B. 业务平台型\n"
|
||
f"{filler}\n\n"
|
||
"C. 能力底座型\n"
|
||
f"{filler}\n\n"
|
||
"⭐ 当前我更推荐:A\n"
|
||
"你回 A / B / C,我就顺着这条继续往下收。"
|
||
)
|
||
|
||
normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw)
|
||
|
||
assert len(normalized) > 800
|
||
assert "⭐ 当前我更推荐:A" in normalized
|
||
assert "你回 A / B / C" in normalized
|
||
|
||
|
||
def test_feishu_integration_service_normalize_outgoing_reply_text_keeps_long_requirement_plan_reply_complete() -> None:
|
||
from app.services.integrations.feishu_integration_service import FeishuIntegrationService
|
||
|
||
filler = "补充说明" * 120
|
||
raw = (
|
||
"可以,我们先不创建项目,先把这件事收成一版需求。\n\n"
|
||
"A. 轻量工具型\n"
|
||
f"{filler}\n\n"
|
||
"B. 业务平台型\n"
|
||
f"{filler}\n\n"
|
||
"C. 能力底座型\n"
|
||
f"{filler}\n\n"
|
||
"⭐ 当前我更推荐:A\n"
|
||
"你回 A / B / C,我就顺着这条继续往下收。"
|
||
)
|
||
|
||
normalized = FeishuIntegrationService._normalize_outgoing_reply_text(raw)
|
||
|
||
assert len(normalized) > 800
|
||
assert "⭐ 当前我更推荐:A" in normalized
|
||
assert "你回 A / B / C" 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_fails_when_charter_task_provider_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": "跨境直播复盘助手",
|
||
"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 _fail_decompose_tasks_from_charter(
|
||
self,
|
||
charter_content,
|
||
project_id,
|
||
tenant_id="default",
|
||
db=None,
|
||
routing_meta=None,
|
||
): # noqa: ANN001
|
||
_ = self, charter_content, project_id, tenant_id, db, routing_meta
|
||
raise RuntimeError("provider_circuit_open:openai")
|
||
|
||
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": {"summary": "demo"},
|
||
},
|
||
"tasks": kwargs["draft"].get("tasks", []),
|
||
"task_audit": [],
|
||
"task_generation": kwargs["draft"].get("task_generation", {}),
|
||
"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": {"summary": "demo"}},
|
||
"tasks": [],
|
||
"task_generation": kwargs["normalized"].get("task_generation", {}),
|
||
"risks": [],
|
||
"signal_sources": [],
|
||
}
|
||
|
||
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_service_core.service.AIService.decompose_tasks_from_charter",
|
||
_fail_decompose_tasks_from_charter,
|
||
)
|
||
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,
|
||
)
|
||
|
||
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 result["signal_id"] == 202
|
||
assert "任务拆解:待补全" in str(result["followup_reply"] or "")
|
||
assert captured["normalize_kwargs"]["draft"]["tasks"] == []
|
||
assert captured["normalize_kwargs"]["draft"]["task_generation"]["status"] == "pending"
|
||
assert "provider_circuit_open:openai" in str(
|
||
captured["normalize_kwargs"]["draft"]["task_generation"]["error_message"] or ""
|
||
)
|
||
assert captured["persist_kwargs"]["normalized"]["tasks"] == []
|
||
assert captured["persist_kwargs"]["normalized"]["task_generation"]["status"] == "pending"
|
||
|
||
|
||
@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_semantic_regenerate_routes_to_regenerate_draft(monkeypatch) -> None:
|
||
kernel = type("Kernel", (), {"db": 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 (
|
||
SimpleNamespace(id=8, topic_workspace_id=77, active_work_item_id=42, state="ready"),
|
||
SimpleNamespace(id=77, mode="plan", plan_status="awaiting_execute_confirm"),
|
||
)
|
||
|
||
async def _fake_semantic_followup(**kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"action": "regenerate",
|
||
"revision_note": "",
|
||
"assistant_reply": "",
|
||
}
|
||
|
||
async def _fake_regenerate_draft(**kwargs):
|
||
captured["regenerate_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 for regenerate: {kwargs}")
|
||
|
||
async def _unexpected_revise(**kwargs): # noqa: ANN001
|
||
raise AssertionError(f"revise_draft should not run for regenerate: {kwargs}")
|
||
|
||
async def _unexpected_cancel(**kwargs): # noqa: ANN001
|
||
raise AssertionError(f"cancel_draft should not run for regenerate: {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_semantic_followup_decision", _fake_semantic_followup)
|
||
monkeypatch.setattr(handler, "regenerate_draft", _fake_regenerate_draft, 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-regenerate-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"] == "draft_pending_confirm"
|
||
assert "当前草稿" in result["followup_reply"]
|
||
assert captured["regenerate_kwargs"]["message_text"] == "生成一版给我看看"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_pending_draft_releases_stale_active_work_item_binding(monkeypatch) -> None:
|
||
class _FakeResult:
|
||
def __init__(self, rows) -> None:
|
||
self._rows = list(rows)
|
||
|
||
def all(self):
|
||
return list(self._rows)
|
||
|
||
class _FakeDB:
|
||
async def execute(self, stmt): # pragma: no cover - structure only
|
||
_ = stmt
|
||
return _FakeResult([])
|
||
|
||
kernel = type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})()
|
||
handler = DraftConfirmHandler(kernel)
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_resolve_active_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", status="active", plan_status="draft_pending_confirm"),
|
||
)
|
||
|
||
async def _fake_load_work_item_target_by_id(**kwargs):
|
||
captured["load_kwargs"] = kwargs
|
||
return (
|
||
SimpleNamespace(
|
||
id=42,
|
||
state="cancelled",
|
||
intent="project.create",
|
||
project_id=None,
|
||
signal_id=None,
|
||
),
|
||
SimpleNamespace(payload={"message_text": "原始需求"}),
|
||
)
|
||
|
||
async def _fake_release_binding(**kwargs):
|
||
captured["release_kwargs"] = kwargs
|
||
|
||
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
|
||
monkeypatch.setattr(handler, "_load_work_item_target_by_id", _fake_load_work_item_target_by_id)
|
||
monkeypatch.setattr(handler, "_release_mounted_draft_binding", _fake_release_binding, raising=False)
|
||
|
||
result = await handler._resolve_pending_draft(
|
||
sender="林宇航",
|
||
source_system="feishu",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
explicit_work_item_id=None,
|
||
thread_context=None,
|
||
)
|
||
|
||
assert result is None
|
||
assert captured["load_kwargs"] == {"work_item_id": 42}
|
||
assert captured["release_kwargs"]["work_item_id"] == 42
|
||
assert captured["release_kwargs"]["final_state"] == "cancelled"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_active_mounted_draft_keeps_retryable_pending_confirm_without_active_work_item_when_anchor_matches(
|
||
monkeypatch,
|
||
) -> None:
|
||
class _FakeResult:
|
||
def __init__(self, rows) -> None:
|
||
self._rows = list(rows)
|
||
|
||
def all(self):
|
||
return list(self._rows)
|
||
|
||
draft_row = SimpleNamespace(
|
||
id=8,
|
||
topic_workspace_id=77,
|
||
active_work_item_id=None,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
latest_reply_message_id="om-bot-retry-1",
|
||
latest_source_message_id="msg-draft-root",
|
||
)
|
||
topic_row = SimpleNamespace(
|
||
id=77,
|
||
tenant_id=1,
|
||
source_system="feishu",
|
||
mode="plan",
|
||
status="active",
|
||
channel_ref="chat-1",
|
||
conversation_scope_key="feishu:chat-1:ou_xxx",
|
||
plan_status=STATE_DRAFT_PENDING_CONFIRM,
|
||
root_message_id="msg-draft-root",
|
||
thread_key="",
|
||
last_message_id="msg-draft-root",
|
||
updated_at=None,
|
||
)
|
||
|
||
class _FakeDB:
|
||
async def execute(self, query): # noqa: ARG002
|
||
return _FakeResult([(draft_row, topic_row)])
|
||
|
||
async def _unexpected_load(*args, **kwargs):
|
||
raise AssertionError(f"released retryable draft should not load active work item: {args} {kwargs}")
|
||
|
||
async def _unexpected_release(*args, **kwargs):
|
||
raise AssertionError(f"released retryable draft should not release binding again: {args} {kwargs}")
|
||
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
|
||
monkeypatch.setattr(handler, "_load_work_item_target_by_id", _unexpected_load)
|
||
monkeypatch.setattr(handler, "_release_mounted_draft_binding", _unexpected_release)
|
||
|
||
result = await handler._resolve_active_mounted_draft(
|
||
sender="林宇航",
|
||
source_system="feishu",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
thread_context={"reply_to_message_id": "om-bot-retry-1"},
|
||
)
|
||
|
||
assert result == (draft_row, topic_row)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_pending_draft_falls_back_to_anchor_matched_pending_work_item_when_active_mounted_draft_has_no_active_work_item_id(
|
||
monkeypatch,
|
||
) -> None:
|
||
class _FakeResult:
|
||
def __init__(self, rows) -> None:
|
||
self._rows = list(rows)
|
||
|
||
def all(self):
|
||
return list(self._rows)
|
||
|
||
canonical_work_item = SimpleNamespace(
|
||
id=1092,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
intent="project_create.create",
|
||
project_id=None,
|
||
signal_id=None,
|
||
source_message_id="src-draft-confirm-1",
|
||
)
|
||
canonical_ingest = SimpleNamespace(
|
||
payload={
|
||
"sender": "Codex真实验收机器人",
|
||
"channel_ref": "chat-1",
|
||
"source_sender_open_id": "ou_xxx",
|
||
"reply_to_message_id": "src-draft-confirm-1",
|
||
"root_message_id": "msg-draft-root",
|
||
},
|
||
source_message_id="src-draft-confirm-1",
|
||
)
|
||
wrong_newer_work_item = SimpleNamespace(
|
||
id=1097,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
intent="project_create.create",
|
||
project_id=None,
|
||
signal_id=None,
|
||
source_message_id="src-wrong-newer-confirm",
|
||
)
|
||
wrong_newer_ingest = SimpleNamespace(
|
||
payload={
|
||
"sender": "Codex真实验收机器人",
|
||
"channel_ref": "chat-1",
|
||
"source_sender_open_id": "ou_xxx",
|
||
"reply_to_message_id": "src-draft-confirm-1",
|
||
"root_message_id": "msg-draft-root",
|
||
},
|
||
source_message_id="src-wrong-newer-confirm",
|
||
)
|
||
|
||
class _FakeDB:
|
||
async def execute(self, query):
|
||
sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower()
|
||
if "from work_items" in sql:
|
||
return _FakeResult(
|
||
[
|
||
(wrong_newer_work_item, wrong_newer_ingest),
|
||
(canonical_work_item, canonical_ingest),
|
||
]
|
||
)
|
||
if "from feishu_reply_outbox" in sql:
|
||
return _FakeResult(
|
||
[
|
||
("src-wrong-newer-confirm", "om-bot-retry-1"),
|
||
("src-draft-confirm-1", "om-bot-retry-1"),
|
||
]
|
||
)
|
||
raise AssertionError(f"unexpected query: {sql}")
|
||
|
||
released_mounted = (
|
||
SimpleNamespace(
|
||
id=8,
|
||
topic_workspace_id=77,
|
||
active_work_item_id=None,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
latest_reply_message_id="om-bot-retry-1",
|
||
latest_source_message_id="src-draft-confirm-1",
|
||
),
|
||
SimpleNamespace(
|
||
id=77,
|
||
mode="plan",
|
||
status="active",
|
||
plan_status=STATE_DRAFT_PENDING_CONFIRM,
|
||
channel_ref="chat-1",
|
||
conversation_scope_key="feishu:chat-1:ou_xxx",
|
||
root_message_id="msg-draft-root",
|
||
thread_key="",
|
||
last_message_id="src-draft-confirm-1",
|
||
updated_at=None,
|
||
),
|
||
)
|
||
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
|
||
|
||
async def _fake_resolve_active_mounted_draft(**kwargs):
|
||
_ = kwargs
|
||
return released_mounted
|
||
|
||
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
|
||
|
||
result = await handler._resolve_pending_draft(
|
||
sender="Codex真实验收机器人",
|
||
source_system="feishu",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
explicit_work_item_id=None,
|
||
thread_context={
|
||
"reply_to_message_id": "om-bot-retry-1",
|
||
"root_message_id": "msg-draft-root",
|
||
},
|
||
)
|
||
|
||
assert result == (canonical_work_item, canonical_ingest)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_pending_draft_materializes_retryable_work_item_from_mounted_draft_when_only_wrong_candidate_matches_anchor(
|
||
monkeypatch,
|
||
) -> None:
|
||
class _FakeResult:
|
||
def __init__(self, rows) -> None:
|
||
self._rows = list(rows)
|
||
|
||
def all(self):
|
||
return list(self._rows)
|
||
|
||
wrong_newer_work_item = SimpleNamespace(
|
||
id=1097,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
intent="project_create.create",
|
||
project_id=None,
|
||
signal_id=None,
|
||
source_message_id="src-wrong-newer-confirm",
|
||
draft_content={"title": "需求确认立项"},
|
||
)
|
||
wrong_newer_ingest = SimpleNamespace(
|
||
payload={
|
||
"sender": "Codex真实验收机器人",
|
||
"channel_ref": "chat-1",
|
||
"source_sender_open_id": "ou_xxx",
|
||
"reply_to_message_id": "src-draft-confirm-1",
|
||
"root_message_id": "msg-draft-root",
|
||
},
|
||
source_message_id="src-wrong-newer-confirm",
|
||
)
|
||
mounted_draft = SimpleNamespace(
|
||
id=8,
|
||
topic_workspace_id=77,
|
||
active_work_item_id=None,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
latest_reply_message_id="om-bot-retry-1",
|
||
latest_source_message_id="src-draft-confirm-1",
|
||
draft_payload={"title": "直播复盘辅助工具(速记版)"},
|
||
draft_version=1,
|
||
requirement_card={"title": "直播复盘辅助工具"},
|
||
)
|
||
mounted_topic = SimpleNamespace(
|
||
id=77,
|
||
mode="plan",
|
||
status="active",
|
||
plan_status=STATE_DRAFT_PENDING_CONFIRM,
|
||
channel_ref="chat-1",
|
||
conversation_scope_key="feishu:chat-1:ou_xxx",
|
||
root_message_id="msg-draft-root",
|
||
thread_key="",
|
||
last_message_id="src-draft-confirm-1",
|
||
updated_at=None,
|
||
metadata_json={},
|
||
)
|
||
|
||
class _FakeDB:
|
||
async def execute(self, query):
|
||
sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower()
|
||
if "from work_items" in sql:
|
||
return _FakeResult([(wrong_newer_work_item, wrong_newer_ingest)])
|
||
if "from feishu_reply_outbox" in sql:
|
||
return _FakeResult([("src-wrong-newer-confirm", "om-bot-retry-1")])
|
||
raise AssertionError(f"unexpected query: {sql}")
|
||
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
|
||
materialized = (
|
||
SimpleNamespace(id=2001, state=STATE_DRAFT_PENDING_CONFIRM, source_message_id="draft-retry-2001"),
|
||
SimpleNamespace(source_message_id="draft-retry-2001", payload={"message_text": "mounted draft retry"}),
|
||
)
|
||
captured: dict[str, object] = {}
|
||
|
||
async def _fake_resolve_active_mounted_draft(**kwargs):
|
||
_ = kwargs
|
||
return (mounted_draft, mounted_topic)
|
||
|
||
async def _fake_materialize(**kwargs):
|
||
captured.update(kwargs)
|
||
return materialized
|
||
|
||
monkeypatch.setattr(handler, "_resolve_active_mounted_draft", _fake_resolve_active_mounted_draft)
|
||
monkeypatch.setattr(handler, "_materialize_retryable_pending_draft_from_mounted", _fake_materialize)
|
||
|
||
result = await handler._resolve_pending_draft(
|
||
sender="Codex真实验收机器人",
|
||
source_system="feishu",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
explicit_work_item_id=None,
|
||
thread_context={
|
||
"reply_to_message_id": "om-bot-retry-1",
|
||
"root_message_id": "msg-draft-root",
|
||
},
|
||
)
|
||
|
||
assert result == materialized
|
||
assert captured["mounted"] == (mounted_draft, mounted_topic)
|
||
assert captured["source_system"] == "feishu"
|
||
assert captured["channel_ref"] == "chat-1"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_pending_draft_does_not_fall_back_to_same_scope_pending_work_item_without_explicit_anchor(
|
||
) -> None:
|
||
pending_work_item = SimpleNamespace(
|
||
id=1099,
|
||
state=STATE_DRAFT_PENDING_CONFIRM,
|
||
intent="followup.confirm",
|
||
project_id=None,
|
||
signal_id=None,
|
||
source_message_id="src-pending-draft-1",
|
||
)
|
||
pending_ingest = SimpleNamespace(
|
||
payload={
|
||
"sender": "林宇航",
|
||
"channel_ref": "chat-1",
|
||
"source_sender_open_id": "ou_xxx",
|
||
"message_text": "旧草稿确认",
|
||
},
|
||
source_message_id="src-pending-draft-1",
|
||
)
|
||
|
||
class _FakeResult:
|
||
def __init__(self, rows) -> None:
|
||
self._rows = list(rows)
|
||
|
||
def all(self):
|
||
return list(self._rows)
|
||
|
||
class _FakeDB:
|
||
async def execute(self, query):
|
||
sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower()
|
||
if "from work_items" in sql:
|
||
return _FakeResult([(pending_work_item, pending_ingest)])
|
||
raise AssertionError(f"unexpected query: {sql}")
|
||
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
|
||
handler._resolve_active_mounted_draft = lambda **kwargs: asyncio.sleep(0, result=None) # type: ignore[method-assign]
|
||
|
||
result = await handler._resolve_pending_draft(
|
||
sender="林宇航",
|
||
source_system="feishu",
|
||
channel_ref="chat-1",
|
||
source_sender_open_id="ou_xxx",
|
||
explicit_work_item_id=None,
|
||
thread_context=None,
|
||
)
|
||
|
||
assert result is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_retryable_draft_owner_user_id_prefers_latest_source_message_id(monkeypatch) -> None:
|
||
class _FakeScalarResult:
|
||
def __init__(self, value) -> None:
|
||
self._value = value
|
||
|
||
def scalar_one_or_none(self):
|
||
return self._value
|
||
|
||
class _FakeDB:
|
||
async def execute(self, query):
|
||
sql = str(query.compile(compile_kwargs={"literal_binds": True})).lower()
|
||
assert "from work_items" in sql
|
||
assert "src-draft-confirm-1" in sql
|
||
return _FakeScalarResult(42)
|
||
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
|
||
owner_user_id = await handler._resolve_retryable_draft_owner_user_id(
|
||
mounted_draft=SimpleNamespace(latest_source_message_id="src-draft-confirm-1"),
|
||
mounted_topic=SimpleNamespace(last_message_id="src-wrong-newer-confirm", root_message_id="msg-draft-root"),
|
||
)
|
||
|
||
assert owner_user_id == 42
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_release_mounted_draft_binding_keeps_retryable_failed_confirm_in_pending_confirm() -> None:
|
||
class _FakeDB:
|
||
async def execute(self, query): # noqa: ARG002
|
||
raise AssertionError("mounted rows are injected directly; execute should not be called")
|
||
|
||
async def flush(self):
|
||
return None
|
||
|
||
handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(), "tenant_id": 1})())
|
||
draft = SimpleNamespace(
|
||
id=8,
|
||
topic_workspace_id=77,
|
||
active_work_item_id=42,
|
||
confirm_message_id="msg-confirm-1",
|
||
state="executing",
|
||
draft_payload={"title": "跨境直播复盘助手"},
|
||
)
|
||
topic = SimpleNamespace(
|
||
id=77,
|
||
mode="plan",
|
||
status="active",
|
||
plan_status="executing",
|
||
pending_actions=[],
|
||
metadata_json={"active_draft_work_item_id": 42},
|
||
)
|
||
|
||
await handler._release_mounted_draft_binding(
|
||
work_item_id=42,
|
||
final_state="failed",
|
||
mounted=(draft, topic),
|
||
)
|
||
|
||
assert draft.active_work_item_id is None
|
||
assert draft.confirm_message_id is None
|
||
assert draft.state == STATE_DRAFT_PENDING_CONFIRM
|
||
assert topic.status == "active"
|
||
assert topic.plan_status == STATE_DRAFT_PENDING_CONFIRM
|
||
assert topic.pending_actions == ["confirm_requirement_draft"]
|
||
assert topic.metadata_json["active_draft_work_item_id"] is None
|
||
assert topic.metadata_json["draft_lifecycle_state"] == "failed"
|
||
|
||
|
||
@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
|
||
@pytest.mark.parametrize("message_text", ["确认", "确认入库", "确认入库。"])
|
||
async def test_draft_confirm_handler_explicit_confirm_without_semantic_action_routes_to_confirm(
|
||
monkeypatch,
|
||
message_text: str,
|
||
) -> None:
|
||
kernel = type("Kernel", (), {"db": 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_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_confirm_draft(**kwargs):
|
||
captured["confirm_kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "succeeded",
|
||
"followup_reply": "✅ 已确认入库",
|
||
"target_work_item_id": 42,
|
||
"target_state": STATE_SUCCEEDED,
|
||
"target_intent": "project.create",
|
||
"project_id": 123,
|
||
"signal_id": 456,
|
||
"workflow_route": "project_create.create",
|
||
}
|
||
|
||
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_target_mounted_draft", _fake_resolve_target_mounted_draft)
|
||
monkeypatch.setattr(handler, "confirm_draft", _fake_confirm_draft)
|
||
monkeypatch.setattr(handler, "cancel_draft", _unexpected_cancel)
|
||
|
||
result = await handler.handle_text_reply(
|
||
message_text=message_text,
|
||
sender="林宇航",
|
||
source_system="feishu",
|
||
source_message_id="msg-confirm-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"] == "succeeded"
|
||
assert result["followup_reply"] == "✅ 已确认入库"
|
||
assert captured["confirm_kwargs"]["message_text"] == message_text
|
||
assert captured["confirm_kwargs"]["source_message_id"] == "msg-confirm-1"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("message_text", ["修改", "修改。", "修改 + 你的意见"])
|
||
async def test_draft_confirm_handler_explicit_revise_without_semantic_action_routes_to_revise(
|
||
monkeypatch,
|
||
message_text: str,
|
||
) -> None:
|
||
kernel = type("Kernel", (), {"db": 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_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):
|
||
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_target_mounted_draft", _fake_resolve_target_mounted_draft)
|
||
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=message_text,
|
||
sender="林宇航",
|
||
source_system="feishu",
|
||
source_message_id="msg-revise-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"] == "draft_pending_confirm"
|
||
assert "补一版" in result["followup_reply"]
|
||
assert captured["revise_kwargs"]["revision_note"] == ""
|
||
assert captured["revise_kwargs"]["revision_note"] == ""
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("message_text", ["取消", "取消。", "取消本版。"])
|
||
async def test_draft_confirm_handler_explicit_cancel_without_semantic_action_routes_to_cancel(
|
||
monkeypatch,
|
||
message_text: str,
|
||
) -> None:
|
||
kernel = type("Kernel", (), {"db": 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_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_cancel_draft(**kwargs):
|
||
captured["cancel_kwargs"] = kwargs
|
||
return {
|
||
"handled": True,
|
||
"status": "cancelled",
|
||
"followup_reply": "已取消,如需重新提交请再次发送。",
|
||
"target_work_item_id": 42,
|
||
"target_state": "cancelled",
|
||
"target_intent": "project.create",
|
||
"project_id": None,
|
||
"signal_id": None,
|
||
"workflow_route": "project_create.create",
|
||
}
|
||
|
||
async def _unexpected_confirm(**kwargs): # noqa: ANN001
|
||
raise AssertionError(f"confirm_draft should not run here: {kwargs}")
|
||
|
||
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, "cancel_draft", _fake_cancel_draft)
|
||
monkeypatch.setattr(handler, "confirm_draft", _unexpected_confirm)
|
||
|
||
result = await handler.handle_text_reply(
|
||
message_text=message_text,
|
||
sender="林宇航",
|
||
source_system="feishu",
|
||
source_message_id="msg-cancel-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"] == "cancelled"
|
||
assert result["followup_reply"] == "已取消,如需重新提交请再次发送。"
|
||
assert captured["cancel_kwargs"]["reason"] == "用户取消草稿"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_explicit_regenerate_without_semantic_action_routes_to_regenerate(monkeypatch) -> None:
|
||
kernel = type("Kernel", (), {"db": 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_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_regenerate_draft(**kwargs):
|
||
captured["regenerate_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_target_mounted_draft", _fake_resolve_target_mounted_draft)
|
||
monkeypatch.setattr(handler, "regenerate_draft", _fake_regenerate_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-regenerate-2",
|
||
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"] == "draft_pending_confirm"
|
||
assert "重出一版" in result["followup_reply"]
|
||
assert captured["regenerate_kwargs"]["message_text"] == "重出一版。"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_draft_confirm_handler_target_bound_without_anchor_and_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-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 True
|
||
assert result["status"] == "pending_manual"
|
||
assert "请回复“确认”、“修改 + 你的意见”、“重出一版”或“取消”" in 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(",已入库处理")
|
||
|
||
|
||
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
|
||
|
||
async def _fake_scene_semantics(**kwargs):
|
||
question = str(kwargs.get("question") or "").strip()
|
||
asked_via = str(kwargs.get("asked_via") or "").strip().lower()
|
||
message_intent = str(kwargs.get("message_intent") or "").strip().lower()
|
||
query_type = str(kwargs.get("query_type") or "").strip().lower()
|
||
normalized = question.replace(" ", "")
|
||
|
||
if message_intent == "mixed":
|
||
scene_key = "mixed"
|
||
elif message_intent == "requirement_plan":
|
||
scene_key = "ai_pmo_requirement_plan"
|
||
elif message_intent == "business_review":
|
||
scene_key = "ai_pmo_business_review"
|
||
elif message_intent == "correction_escalation":
|
||
scene_key = "ai_pmo_correction"
|
||
elif any(token in normalized for token in ("负载", "工作量", "工时")):
|
||
scene_key = "member_workload"
|
||
elif any(token in normalized for token in ("宠物行业最新动态", "项目所在行业最新动态")) and any(token in normalized for token in ("这个项目", "项目所在行业")) and "不要提项目" not in normalized:
|
||
scene_key = "external_trend"
|
||
elif any(token in normalized for token in ("宠物行业最新动态", "行业最新动态")):
|
||
scene_key = "industry_trend"
|
||
elif query_type == "status" or "最新进展" in normalized:
|
||
scene_key = "project_status"
|
||
elif any(token in normalized for token in ("只允许一种提醒", "桌面通知", "飞书通知", "定一个方向", "还是")):
|
||
scene_key = "decision_choice"
|
||
elif asked_via in {"pmflow_manual", "/ai-pmo/process-text", "ai-pmo-process-text", "ai_pmo", "ai_pmo_ui"}:
|
||
scene_key = "ai_pmo"
|
||
elif asked_via in {"feishu", "lark", "feishu_group", "feishu_dm"}:
|
||
scene_key = "feishu_direct"
|
||
else:
|
||
scene_key = "default"
|
||
|
||
snapshot_focus = "none"
|
||
if any(token in normalized for token in ("你是谁", "你会什么", "你叫什么")):
|
||
snapshot_focus = "identity"
|
||
elif any(token in normalized for token in ("系统架构", "技术栈", "数据库", "redis")):
|
||
snapshot_focus = "architecture"
|
||
elif any(token in normalized for token in ("功能", "入口", "在哪", "用途")):
|
||
snapshot_focus = "feature"
|
||
|
||
return {
|
||
"scene_key": scene_key,
|
||
"snapshot_focus": snapshot_focus,
|
||
"deep_intent": "none",
|
||
"confidence": 0.9,
|
||
}
|
||
|
||
mixin._resolve_answer_scene_semantics = _fake_scene_semantics
|
||
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_structured_scene_semantics(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
mixin = _make_adaptive_rag_mixin()
|
||
|
||
async def _fake_scene_semantics(**kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"scene_key": "decision_choice",
|
||
"snapshot_focus": "none",
|
||
"deep_intent": "none",
|
||
"confidence": 0.94,
|
||
}
|
||
|
||
monkeypatch.setattr(
|
||
mixin,
|
||
"_resolve_answer_scene_semantics",
|
||
_fake_scene_semantics,
|
||
raising=False,
|
||
)
|
||
|
||
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={},
|
||
user_snapshot={},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
|
||
assert scene_key == "decision_choice"
|
||
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_downgrades_external_trend_without_explicit_project_context() -> None:
|
||
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):
|
||
def __init__(self) -> None:
|
||
self.db = _FakePromptDB()
|
||
self.tenant_id = 1
|
||
|
||
async def _resolve_answer_scene_semantics(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"scene_key": "external_trend",
|
||
"snapshot_focus": "none",
|
||
"deep_intent": "none",
|
||
"confidence": 0.91,
|
||
}
|
||
|
||
mixin = _FakeService()
|
||
scene_key, prompt = await mixin._select_scene_prompt(
|
||
question="codex 目前最高模型出到什么了?",
|
||
project_id=None,
|
||
asked_via="feishu",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text="[1] 来源: graph\n内容: Project#104 #codex-plan",
|
||
system_snapshot={"assistant_profile": {"name": "PMFlow"}},
|
||
user_snapshot={},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
|
||
assert scene_key == "industry_trend"
|
||
assert "当前未接入实时数据" in prompt
|
||
assert "项目上下文" not in prompt
|
||
assert "外部搜索结果" in prompt
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_adaptive_rag_select_scene_prompt_promotes_external_model_brand_query_from_ai_pmo() -> None:
|
||
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):
|
||
def __init__(self) -> None:
|
||
self.db = _FakePromptDB()
|
||
self.tenant_id = 1
|
||
|
||
async def _resolve_answer_scene_semantics(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"scene_key": "ai_pmo",
|
||
"snapshot_focus": "identity",
|
||
"deep_intent": "none",
|
||
"confidence": 0.92,
|
||
}
|
||
|
||
mixin = _FakeService()
|
||
scene_key, prompt = await mixin._select_scene_prompt(
|
||
question="当前codex最新的模型用的是什么?",
|
||
project_id=None,
|
||
asked_via="feishu",
|
||
message_intent=None,
|
||
mixed_signal_part=None,
|
||
conversation_text="无",
|
||
context_blocks_text="[1] 来源: graph\n内容: Project#104 #codex-plan",
|
||
system_snapshot={"assistant_profile": {"name": "PMFlow"}},
|
||
user_snapshot={},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
|
||
assert scene_key == "industry_trend"
|
||
assert "当前未接入实时数据" in prompt
|
||
assert "项目上下文" not in prompt
|
||
assert "外部搜索结果" in prompt
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_adaptive_rag_select_scene_prompt_keeps_explicit_internal_model_config_question_in_ai_pmo() -> None:
|
||
class _FakeService(AdaptiveRAGDeepGuardAnswerMixin):
|
||
def __init__(self) -> None:
|
||
self.db = _FakePromptDB()
|
||
self.tenant_id = 1
|
||
|
||
async def _resolve_answer_scene_semantics(self, **kwargs):
|
||
_ = kwargs
|
||
return {
|
||
"scene_key": "ai_pmo",
|
||
"snapshot_focus": "identity",
|
||
"deep_intent": "none",
|
||
"confidence": 0.92,
|
||
}
|
||
|
||
mixin = _FakeService()
|
||
scene_key, prompt = await mixin._select_scene_prompt(
|
||
question="我们系统当前接的是哪个 codex 模型配置?",
|
||
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={},
|
||
system_snapshot_text="{}",
|
||
user_snapshot_text="{}",
|
||
relevance_max=0.0,
|
||
)
|
||
|
||
assert scene_key == "ai_pmo"
|
||
assert "World Model:" in prompt
|
||
assert "当前未接入实时数据" not 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
|
||
assert "内部函数名" in FEISHU_QA_RESPONSE_STYLE
|
||
assert "文件路径" in FEISHU_QA_RESPONSE_STYLE
|
||
assert "内部函数名" in PROMPT_EXTERNAL_TREND_QA
|
||
assert "文件路径" in PROMPT_EXTERNAL_TREND_QA
|
||
assert "内部函数名" in PROMPT_INDUSTRY_TREND_QA
|
||
assert "文件路径" in PROMPT_INDUSTRY_TREND_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
|