import json from types import SimpleNamespace import pytest from app.config.ai_prompts import ( PROMPT_AI_PMO_REQUIREMENT_PLAN_QA, PROMPT_EXTERNAL_TREND_QA, PROMPT_FEISHU_DIRECT_QA, PROMPT_FEISHU_MIXED_QA, PROMPT_PROJECT_STATUS_QA, ) from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_SUCCEEDED from app.services.ai_pmo.draft_confirm_handler import DraftConfirmHandler from app.services.ai_pmo.ai_pmo_kernel_common_core.constants import STATE_DRAFT_PENDING_CONFIRM from app.services.ai_pmo.ai_pmo_kernel_ingress_flow import mixin_auto from app.services.adaptive_rag_service.mixin_deep_guard_answer import AdaptiveRAGDeepGuardAnswerMixin from app.services.ai_pmo.consultant_dispatch import build_consultant_dispatch_contract from app.services.integrations.feishu_integration_service.mixin_reply_llm_core import ( FeishuReplyLLMCoreMixin, ) def test_parse_action_confirm() -> None: action, note = DraftConfirmHandler.parse_action_from_text(message_text="确认") assert action == "confirm" assert note == "" def test_parse_action_revise_with_note() -> None: action, note = DraftConfirmHandler.parse_action_from_text(message_text="修改 改成每周跟进") assert action == "revise" assert note == "改成每周跟进" def test_parse_action_cancel() -> None: action, note = DraftConfirmHandler.parse_action_from_text(message_text="取消") assert action == "cancel" assert note == "" def test_parse_action_yes_as_confirm() -> None: action, note = DraftConfirmHandler.parse_action_from_text(message_text="yes") assert action == "confirm" assert note == "" def test_draft_confirm_handler_thread_anchor_match() -> None: ingest = type( "Ingest", (), { "payload": { "thread_id": "th_1", "root_message_id": "root_1", "reply_to_message_id": "reply_1", }, "source_message_id": "src_1", }, )() work_item = type("WorkItem", (), {"source_message_id": "wi_1"})() assert DraftConfirmHandler._matches_thread_anchor( work_item=work_item, ingest=ingest, anchor_context={"reply_to_message_id": "reply_1"}, ) is True assert DraftConfirmHandler._matches_thread_anchor( work_item=work_item, ingest=ingest, anchor_context={"reply_to_message_id": "missing"}, ) is False assert DraftConfirmHandler._normalize_thread_context( {"thread_id": "th_1", "root_message_id": "root_1", "reply_to_message_id": "reply_1"} ) == { "thread_id": "th_1", "root_message_id": "root_1", "reply_to_message_id": "reply_1", } def test_draft_confirm_handler_thread_anchor_matches_draft_reply_message_id() -> None: ingest = type( "Ingest", (), { "payload": { "thread_id": "th_1", "root_message_id": "draft_msg_1", "reply_to_message_id": "draft_msg_1", }, "source_message_id": "original_msg_1", }, )() work_item = type("WorkItem", (), {"source_message_id": "original_msg_1"})() assert DraftConfirmHandler._matches_thread_anchor( work_item=work_item, ingest=ingest, anchor_context={"root_message_id": "draft_msg_1"}, draft_reply_message_id="draft_msg_1", ) is True def test_reply_outbox_extract_sent_message_id() -> None: from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin assert FeishuReplyOutboxMixin._extract_sent_message_id({"data": {"message_id": "msg_123"}}) == "msg_123" assert FeishuReplyOutboxMixin._extract_sent_message_id({"message_id": "msg_456"}) == "msg_456" def test_reply_outbox_normalize_beautifies_structured_pm_sections() -> None: from app.services.integrations.feishu_integration_service.mixin_reply_outbox import FeishuReplyOutboxMixin raw = ( "结论:项目 #104 当前还不能直接判断是否启动后端。\n" "依据:当前只命中静态代码索引,缺少真实归属和负责人信息。\n" "下一步:先确认后端代码归属。\n" "信息不足,缺少:负责人、仓库路径" ) normalized = FeishuReplyOutboxMixin._normalize_outgoing_reply_text(raw) assert "🎯 结论:" in normalized assert "🔎 依据:" in normalized assert "➡️ 下一步:" in normalized assert "🧩 信息不足,缺少:" in normalized assert "\n\n🔎 依据:" in normalized def test_feishu_reply_llm_core_strips_action_process_prefaces() -> None: reply = ( "好的,我来帮你创建项目和信号源。\n\n" "让我先检查一下后端服务是否运行,然后通过API创建项目。" ) assert FeishuReplyLLMCoreMixin._strip_leading_thinking_paragraphs(reply) == "" def test_feishu_reply_llm_core_action_ack_uses_semantic_bundle_only() -> None: ack = FeishuReplyLLMCoreMixin._build_action_request_ack_reply( message_content="随便一句不带动作的话", capture_result={ "status": "captured", "semantic_bundle": { "bundle_id": "semantic-1", "semantic_version": "2026-03-27.workflow-v1", "source_system": "feishu", "primary_workflow": "new_project_flow", "workflows": [ { "workflow_type": "new_project_flow", "workflow_action": "create", "target_scope": "global", "operation_codes": ["project.create"], "requires_confirmation": False, "confidence": 0.9, "slots": [], "clarification_needed": False, "metadata": {}, }, { "workflow_type": "signal_source_flow", "workflow_action": "onboard", "target_scope": "global", "operation_codes": ["signal_source.onboard"], "requires_confirmation": False, "confidence": 0.92, "slots": [], "clarification_needed": False, "metadata": {}, }, ], "message_gate": "auto_execute", "reply_contract": { "mode": "aggregate_card", "template_key": "workflow_bundle", "summary": "new_project_flow", }, "diagnostics": {}, }, }, ) assert ack == "已收到,项目创建和信号源接入请求已记录。" def test_consultant_dispatch_contract_marks_incomplete_task_create_as_clarify() -> None: contract = build_consultant_dispatch_contract( intent_name="task.create", message_text="帮我建个任务", source_system="pmflow_manual", status="pending_manual", classification="command", primary_operation_code="task.create", risk_level="medium", missing_fields=["project_id", "task_title"], direct_reply_text="未执行:当前建任务请求还缺少 归属项目、任务标题。请补充后我再创建。", ) assert contract["domain"] == "task" assert contract["mode"] == "clarify" assert contract["allow_execute"] is False assert contract["missing_fields"] == ["project_id", "task_title"] assert "补充" in contract["next_action"] def test_consultant_dispatch_contract_marks_scope_expansion_as_challenge() -> None: contract = build_consultant_dispatch_contract( intent_name="project.create", message_text="把整个系统全部重做并一次替换所有模块", source_system="pmflow_manual", status="pending_manual", classification="command", primary_operation_code="project.create", risk_level="high", missing_fields=[], direct_reply_text="当前建议先拆分范围,再确认是否继续推进。", ) assert contract["domain"] == "project" assert contract["mode"] == "challenge" assert contract["allow_execute"] is False assert contract["next_action"] def test_feishu_reply_llm_core_no_longer_uses_consultant_dispatch_reply() -> None: clarify = FeishuReplyLLMCoreMixin._build_consultant_dispatch_reply( { "direct_reply_text": "", "consultant_dispatch": { "domain": "task", "mode": "clarify", "risk_level": "medium", "missing_fields": ["project_id", "task_title"], "allow_execute": False, "next_action": "请补充归属项目和任务标题后继续。", }, } ) assert clarify is None @pytest.mark.asyncio async def test_draft_confirm_handler_repeated_confirm_returns_inbox_success_copy() -> None: class _FakeResult: def __init__(self, locked): self._locked = locked def scalar_one_or_none(self): return self._locked class _FakeDB: def __init__(self, locked): self._locked = locked async def execute(self, query): # noqa: ARG002 return _FakeResult(self._locked) locked = SimpleNamespace( id=42, state=STATE_SUCCEEDED, intent="draft", project_id=7, signal_id=9, draft_version=1, draft_expires_at=None, ) handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) result = await handler.confirm_draft( target_work_item=SimpleNamespace(id=42, state=STATE_SUCCEEDED, intent="draft", project_id=7, signal_id=9), target_ingest=SimpleNamespace(), sender="测试用户", source_system="feishu", source_message_id="msg-1", message_text="确认", expected_version=None, ) assert result["handled"] is True assert result["status"] == "succeeded" assert result["followup_reply"] == "已入库成功,无需重复确认。" @pytest.mark.asyncio async def test_draft_confirm_handler_project_create_confirm_persists_rich_structured_draft_without_reextract( monkeypatch, ) -> None: captured: dict[str, object] = {} class _FakeResult: def __init__(self, locked): self._locked = locked def scalar_one_or_none(self): return self._locked class _FakeDB: def __init__(self, locked): self._locked = locked async def execute(self, query): # noqa: ARG002 return _FakeResult(self._locked) async def flush(self): captured["flushed"] = True class _FakeStewardService: def __init__(self, db): captured["steward_db"] = db async def create_project_from_natural_language(self, **kwargs): # noqa: ANN001 raise AssertionError(f"rich structured draft should bypass reextract path: {kwargs}") async def _normalize_project_creation_draft(self, **kwargs): # noqa: ANN001 captured["normalize_kwargs"] = kwargs return { "project": {"id": "101", "title": "跨境 DTC 品牌 AI 运营平台", "name": "跨境 DTC 品牌 AI 运营平台"}, "signal": {"content": "main signal", "impact_score": 0.9, "priority": "high", "strategic_analysis": {}}, "charter": {"title": "跨境 DTC 品牌 AI 运营平台项目宪章", "content": {"summary": "demo"}}, "tasks": kwargs["draft"].get("tasks", []), "task_audit": [], "risks": [], "creator_id": 77, } async def _generate_task_chain_draft(self, **kwargs): # noqa: ANN001 captured["task_chain_kwargs"] = kwargs return [ { "title": "直播数据采集与清洗", "description": "打通直播录屏、评论和订单数据。", "priority": "high", "estimated_hours": 24, "required_skills": ["Python", "FastAPI"], } ] async def _persist_project_creation_draft(self, **kwargs): # noqa: ANN001 captured["persist_kwargs"] = kwargs return { "project": {"id": "101", "title": "跨境 DTC 品牌 AI 运营平台", "name": "跨境 DTC 品牌 AI 运营平台"}, "signal": {"id": "202", "content": "main signal"}, "charter": {"id": "303", "title": "宪章", "content": {"summary": "demo"}}, "tasks": [{"id": "401", "title": "直播数据采集与清洗"}], "risks": [], "signal_sources": [ { "id": "501", "source_kind": "custom", "source_name": "核心痛点 - 内容本地化", "source_identifier": "custom:core-pain-content-localization", } ], } locked = SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, draft_version=2, draft_expires_at=None, confirm_message_id=None, owner_user_id=77, draft_content={ "title": "跨境 DTC 品牌 AI 运营平台", "summary": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化、社媒运营、用户洞察。", "positioning_one_liner": "帮助跨境 DTC 团队快速完成直播复盘与动作沉淀。", "target_customers": "北美 DTC 品牌运营团队", "original_text": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化、社媒运营、用户洞察。", "core_pain_points": [ { "pain": "人工复盘慢", "status": "单场耗时超过 2 小时", "opportunity": "压缩到 10 分钟内", } ], "team_setup": [ {"role": "PM", "notes": "负责 MVP 边界与验收"}, {"role": "后端开发", "notes": "负责 Python 服务和数据对接"}, ], "recommended_assignee_id": 88, "recommended_assignee_name": "Alice", "requirement_items": [ { "section": "核心痛点", "title": "内容本地化", "description": "翻译公司贵、慢、不地道", } ], }, execution_result={}, ) handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) monkeypatch.setattr("app.services.ai_pmo.draft_confirm_handler.AIStewardService", _FakeStewardService) result = await handler.confirm_draft( target_work_item=SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, owner_user_id=77, ), target_ingest=SimpleNamespace(payload={"message_text": "帮我做一个出海 DTC 项目"}), sender="测试用户", source_system="feishu", source_message_id="msg-1", message_text="确认", expected_version=2, ) assert result["handled"] is True assert result["status"] == "succeeded" assert result["project_id"] == 101 assert result["signal_id"] == 202 assert "信号源" in result["followup_reply"] assert captured["normalize_kwargs"]["creator_id"] == 77 assert captured["persist_kwargs"]["requested_member_user_ids"] == [88] project_input_text = str(captured["normalize_kwargs"]["input_text"]) assert "项目标题:跨境 DTC 品牌 AI 运营平台" in project_input_text assert "目标客户:北美 DTC 品牌运营团队" in project_input_text assert "团队配置:" in project_input_text assert "PM:负责 MVP 边界与验收" in project_input_text assert "人工复盘慢" in project_input_text assert "📋 我先按产品经理视角起草一版评审建议,请确认:" not in project_input_text charter_content = captured["normalize_kwargs"]["draft"]["charter"]["content"] assert charter_content["schema_version"] == "charter_ai_first_v1" assert sorted(charter_content["modules"].keys()) == [ "module_1", "module_2", "module_3", "module_4", "module_5", "module_6", ] assert "北美 DTC 品牌运营团队" in charter_content["modules"]["module_1"]["problem_statement"] assert "内容本地化" in charter_content["modules"]["module_2"]["in_scope"] assert captured["persist_kwargs"]["normalized"]["tasks"][0]["title"] == "直播数据采集与清洗" assert captured["task_chain_kwargs"]["analysis"]["project_title"] == "跨境 DTC 品牌 AI 运营平台" @pytest.mark.asyncio async def test_draft_confirm_handler_project_create_confirm_uses_project_creation_flow_for_sparse_draft( monkeypatch, ) -> None: captured: dict[str, object] = {} class _FakeResult: def __init__(self, locked): self._locked = locked def scalar_one_or_none(self): return self._locked class _FakeDB: def __init__(self, locked): self._locked = locked async def execute(self, query): # noqa: ARG002 return _FakeResult(self._locked) async def flush(self): captured["flushed"] = True class _FakeStewardService: def __init__(self, db): captured["steward_db"] = db async def create_project_from_natural_language(self, **kwargs): # noqa: ANN001 captured["steward_kwargs"] = kwargs return { "project": {"id": "101", "title": "跨境 DTC 品牌 AI 运营平台", "name": "跨境 DTC 品牌 AI 运营平台"}, "signal": {"id": "202", "content": "main signal"}, "charter": {"id": "303", "title": "宪章", "content": {"summary": "demo"}}, "tasks": [{"id": "401", "title": "任务 1"}], "risks": [], "signal_sources": [ { "id": "501", "source_kind": "custom", "source_name": "核心痛点 - 内容本地化", "source_identifier": "custom:core-pain-content-localization", } ], } locked = SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, draft_version=2, draft_expires_at=None, confirm_message_id=None, owner_user_id=77, draft_content={ "title": "跨境 DTC 品牌 AI 运营平台", "summary": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化。", "original_text": "帮助中国 DTC 品牌用 AI 快速完成海外市场的内容本地化。", "recommended_assignee_id": 88, "recommended_assignee_name": "Alice", }, execution_result={}, ) handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) monkeypatch.setattr("app.services.ai_pmo.draft_confirm_handler.AIStewardService", _FakeStewardService) result = await handler.confirm_draft( target_work_item=SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, owner_user_id=77, ), target_ingest=SimpleNamespace(payload={"message_text": "帮我做一个出海 DTC 项目"}), sender="测试用户", source_system="feishu", source_message_id="msg-1", message_text="确认", expected_version=2, ) assert result["handled"] is True assert result["status"] == "succeeded" assert result["project_id"] == 101 assert result["signal_id"] == 202 assert "steward_kwargs" in captured assert captured["steward_kwargs"]["creator_id"] == 77 assert captured["steward_kwargs"]["requested_member_user_ids"] == [88] project_input_text = str(captured["steward_kwargs"]["input_text"]) assert "项目标题:跨境 DTC 品牌 AI 运营平台" in project_input_text @pytest.mark.asyncio async def test_draft_confirm_handler_project_create_confirm_falls_back_to_structured_draft_when_llm_reextract_fails( monkeypatch, ) -> None: captured: dict[str, object] = {} class _FakeResult: def __init__(self, locked): self._locked = locked def scalar_one_or_none(self): return self._locked class _FakeDB: def __init__(self, locked): self._locked = locked async def execute(self, query): # noqa: ARG002 return _FakeResult(self._locked) async def flush(self): captured["flushed"] = True locked = SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, draft_version=2, draft_expires_at=None, confirm_message_id=None, owner_user_id=77, draft_content={ "title": "跨境 DTC 品牌 AI 运营平台", "summary": "帮助中国 DTC 品牌用 AI 快速完成海外市场内容运营。", "target_customers": "北美 DTC 品牌运营团队", "original_text": "帮助中国 DTC 品牌用 AI 快速完成海外市场内容运营。", "recommended_assignee_id": 88, "recommended_assignee_name": "Alice", "team_setup": [{"role": "PM", "notes": "负责 MVP 边界与验收"}], "requirement_items": [ { "section": "核心痛点", "title": "内容本地化", "description": "翻译公司贵、慢、不地道", } ], }, execution_result={}, ) handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) async def _fail_create_project_from_natural_language(self, **kwargs): # noqa: ANN001 captured["create_kwargs"] = kwargs raise RuntimeError( "AI 管家项目创建步骤失败:function=project_requirement_extraction, reason=provider_circuit_open" ) async def _fake_normalize_project_creation_draft(self, **kwargs): # noqa: ANN001 captured["normalize_kwargs"] = kwargs return { "analysis": {}, "project": { "name": "跨境 DTC 品牌 AI 运营平台", "title": "跨境 DTC 品牌 AI 运营平台", "description": "项目:跨境 DTC 品牌 AI 运营平台", "assigned_role": "backend", "domain_id": None, }, "signal": { "content": "main signal", "impact_score": 0.9, "priority": "high", "strategic_analysis": {}, }, "charter": { "title": "跨境 DTC 品牌 AI 运营平台项目宪章", "content": {"summary": "demo"}, }, "tasks": kwargs["draft"].get("tasks", []), "task_audit": [], "risks": [], "creator_id": 77, } async def _fake_generate_task_chain_draft(self, **kwargs): # noqa: ANN001 captured["task_chain_kwargs"] = kwargs return [ { "title": "接入 TikTok 与 Shopify 数据", "description": "打通直播、订单与退款数据并完成字段对齐", "priority": "high", "estimated_hours": 16, "required_skills": ["Python", "数据分析"], } ] async def _fake_persist_project_creation_draft(self, **kwargs): # noqa: ANN001 captured["persist_kwargs"] = kwargs return { "project": {"id": "101", "title": "跨境 DTC 品牌 AI 运营平台", "name": "跨境 DTC 品牌 AI 运营平台"}, "project_id": 101, "signal": {"id": "202", "content": "main signal"}, "signal_id": 202, "charter": {"id": "303", "title": "宪章", "content": {"summary": "demo"}}, "tasks": [ { "id": "401", "title": "接入 TikTok 与 Shopify 数据", "description": "打通直播、订单与退款数据并完成字段对齐", "priority": "high", "status": "todo", "estimated_hours": 16, } ], "risks": [], "signal_sources": [ { "id": "501", "source_kind": "custom", "source_name": "核心痛点 - 内容本地化", "source_identifier": "custom:core-pain-content-localization", } ], } monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService.create_project_from_natural_language", _fail_create_project_from_natural_language, ) monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService._normalize_project_creation_draft", _fake_normalize_project_creation_draft, ) monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService._generate_task_chain_draft", _fake_generate_task_chain_draft, ) monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService._persist_project_creation_draft", _fake_persist_project_creation_draft, ) result = await handler.confirm_draft( target_work_item=SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, owner_user_id=77, ), target_ingest=SimpleNamespace(payload={"message_text": "帮我做一个出海 DTC 项目"}), sender="测试用户", source_system="feishu", source_message_id="msg-1", message_text="确认", expected_version=2, ) assert result["handled"] is True assert result["status"] == "succeeded" assert result["project_id"] == 101 assert result["signal_id"] == 202 assert captured["normalize_kwargs"]["draft"]["project"]["title"] == "跨境 DTC 品牌 AI 运营平台" assert captured["normalize_kwargs"]["draft"]["tasks"][0]["title"] == "接入 TikTok 与 Shopify 数据" assert captured["persist_kwargs"]["normalized"]["project"]["title"] == "跨境 DTC 品牌 AI 运营平台" assert captured["persist_kwargs"]["normalized"]["tasks"][0]["title"] == "接入 TikTok 与 Shopify 数据" assert captured["persist_kwargs"]["requested_member_user_ids"] == [88] assert captured["task_chain_kwargs"]["analysis"]["project_title"] == "跨境 DTC 品牌 AI 运营平台" assert "录屏" not in str(captured["task_chain_kwargs"]["analysis"].get("project_title") or "") @pytest.mark.asyncio async def test_draft_confirm_handler_project_create_confirm_survives_charter_task_provider_failure( monkeypatch, ) -> None: captured: dict[str, object] = {} class _FakeResult: def __init__(self, locked): self._locked = locked def scalar_one_or_none(self): return self._locked class _FakeDB: def __init__(self, locked): self._locked = locked async def execute(self, query): # noqa: ARG002 return _FakeResult(self._locked) async def flush(self): captured["flushed"] = True locked = SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, draft_version=2, draft_expires_at=None, confirm_message_id=None, owner_user_id=77, draft_content={ "title": "跨境直播复盘助手", "summary": "帮助跨境 DTC 团队完成直播复盘。", "positioning_one_liner": "围绕直播录屏、评论与订单数据形成复盘闭环。", "target_customers": "北美 DTC 品牌运营团队", "original_text": "做一个跨境直播复盘助手。", "recommended_assignee_id": 88, "recommended_assignee_name": "Alice", "team_setup": [ {"role": "PM", "notes": "负责 MVP 边界与验收"}, {"role": "后端开发", "notes": "负责 Python 服务与数据对接"}, {"role": "前端开发", "notes": "负责复盘看板"}, ], "mvp_items": [ "直播录屏与评论回放切片", "亮点和掉点自动总结", "可执行复盘清单", ], "validation_metrics": { "product": ["7 天内复盘时长下降 60%", "高频问题复现率下降 30%"], }, "requirement_items": [ { "section": "信号源", "title": "TikTok Shop 直播数据", "description": "提供观看、互动与成交数据", } ], }, execution_result={}, ) handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) async def _fail_create_project_from_natural_language(self, **kwargs): # noqa: ANN001 captured["create_kwargs"] = kwargs raise RuntimeError( "AI 管家项目创建步骤失败:function=project_requirement_extraction, reason=provider_circuit_open" ) async def _fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001 _ = self, tenant_id, limit return ["Python", "FastAPI", "React", "TypeScript", "数据分析", "项目管理"] async def _fake_normalize_project_creation_draft(self, **kwargs): # noqa: ANN001 captured["normalize_kwargs"] = kwargs return { "analysis": {}, "project": { "name": "跨境直播复盘助手", "title": "跨境直播复盘助手", "description": "项目:跨境直播复盘助手", "assigned_role": "backend", "domain_id": None, }, "signal": { "content": "main signal", "impact_score": 0.9, "priority": "high", "strategic_analysis": {}, }, "charter": { "title": "跨境直播复盘助手项目宪章", "content": kwargs["draft"]["charter"]["content"], }, "tasks": kwargs["draft"].get("tasks", []), "task_audit": [], "risks": [], "creator_id": 77, } async def _fake_persist_project_creation_draft(self, **kwargs): # noqa: ANN001 captured["persist_kwargs"] = kwargs return { "project": {"id": "101", "title": "跨境直播复盘助手", "name": "跨境直播复盘助手"}, "project_id": 101, "signal": {"id": "202", "content": "main signal"}, "signal_id": 202, "charter": { "id": "303", "title": "跨境直播复盘助手项目宪章", "content": kwargs["normalized"]["charter"]["content"], }, "tasks": [ { "id": "401", "title": kwargs["normalized"]["tasks"][0]["title"], "description": kwargs["normalized"]["tasks"][0]["description"], "priority": kwargs["normalized"]["tasks"][0]["priority"], "status": "todo", "estimated_hours": kwargs["normalized"]["tasks"][0]["estimated_hours"], } ], "risks": [], "signal_sources": [], } async def _fail_decompose_tasks_from_charter(self, charter_content, project_id, tenant_id="default", db=None): # noqa: ANN001 _ = self, charter_content, project_id, tenant_id, db raise RuntimeError("provider_circuit_open:openai") monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService.create_project_from_natural_language", _fail_create_project_from_natural_language, ) monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService._load_available_skill_names", _fake_load_available_skill_names, ) monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService._normalize_project_creation_draft", _fake_normalize_project_creation_draft, ) monkeypatch.setattr( "app.services.ai_pmo.draft_confirm_handler.AIStewardService._persist_project_creation_draft", _fake_persist_project_creation_draft, ) monkeypatch.setattr( "app.services.ai_service_core.service.AIService.decompose_tasks_from_charter", _fail_decompose_tasks_from_charter, ) result = await handler.confirm_draft( target_work_item=SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, owner_user_id=77, ), target_ingest=SimpleNamespace(payload={"message_text": "帮我做一个跨境直播复盘项目"}), sender="测试用户", source_system="feishu", source_message_id="msg-1", message_text="确认", expected_version=2, ) assert result["handled"] is True assert result["status"] == "succeeded" assert result["project_id"] == 101 assert captured["persist_kwargs"]["requested_member_user_ids"] == [88] assert captured["normalize_kwargs"]["draft"]["tasks"][0]["title"] == "直播录屏与评论回放切片" assert captured["normalize_kwargs"]["draft"]["tasks"][0]["required_skills"] == ["Python", "FastAPI", "数据分析"] @pytest.mark.asyncio async def test_draft_confirm_handler_revise_without_note_requests_feedback(monkeypatch) -> None: class _FakeResult: def __init__(self, locked): self._locked = locked def scalar_one_or_none(self): return self._locked class _FakeDB: def __init__(self, locked): self._locked = locked async def execute(self, query): # noqa: ARG002 return _FakeResult(self._locked) async def flush(self): return None locked = SimpleNamespace( id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None, draft_version=2, draft_expires_at=None, draft_content={"title": "跨境 DTC 品牌 AI 运营平台", "original_text": "原始需求"}, execution_result={}, ) handler = DraftConfirmHandler(type("Kernel", (), {"db": _FakeDB(locked), "tenant_id": 1})()) async def _fail_generate_and_persist(**kwargs): # noqa: ANN001 raise AssertionError(f"draft generator should not run without a revision note: {kwargs}") monkeypatch.setattr(handler.draft_generator, "generate_and_persist", _fail_generate_and_persist) result = await handler.revise_draft( target_work_item=SimpleNamespace(id=42, state=STATE_DRAFT_PENDING_CONFIRM, intent="project.create", project_id=None, signal_id=None), target_ingest=SimpleNamespace(payload={"message_text": "原始需求"}), revision_note="", expected_version=2, ) assert result["handled"] is True assert result["status"] == "draft_pending_confirm" assert result["target_state"] == STATE_DRAFT_PENDING_CONFIRM assert "修改" in result["followup_reply"] @pytest.mark.asyncio async def test_draft_confirm_handler_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_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"] def test_kernel_ingress_mixin_auto_safe_int_regression() -> None: assert callable(mixin_auto._safe_int) assert mixin_auto._safe_int("123") == 123 assert mixin_auto._safe_int("") is None assert mixin_auto._safe_int(None) is None def test_adaptive_rag_process_entry_and_mixed_format() -> None: mixin = AdaptiveRAGDeepGuardAnswerMixin() assert mixin._is_ai_pmo_process_entry("pmflow_manual") is True assert mixin._is_ai_pmo_process_entry("feishu") is False mixed = mixin._format_mixed_two_block_output( answer_text="[回答] 接口契约已对齐\n[已收到需求] 新增任务创建确认流", question_part="现在进展怎么样", signal_part="补齐任务确认", ) assert mixed.startswith("[回答] ") assert "\n[已收到需求] " in mixed assert mixed.endswith(",已入库处理") class _FakePromptResult: @staticmethod def scalar_one_or_none(): return None class _FakePromptDB: async def execute(self, query): # noqa: ARG002 return _FakePromptResult() def _make_adaptive_rag_mixin() -> AdaptiveRAGDeepGuardAnswerMixin: mixin = AdaptiveRAGDeepGuardAnswerMixin() mixin.db = _FakePromptDB() mixin.tenant_id = 1 return mixin @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_prefers_mixed() -> None: mixin = _make_adaptive_rag_mixin() scene_key, prompt = await mixin._select_scene_prompt( question="现在进展怎么样?", project_id=7, asked_via="pmflow_manual", message_intent="mixed", mixed_signal_part="新增任务确认流", conversation_text="无", context_blocks_text="无", system_snapshot={}, user_snapshot={}, system_snapshot_text="{}", user_snapshot_text="{}", relevance_max=0.0, ) assert scene_key == "mixed" assert "现在进展怎么样" in prompt assert "新增任务确认流" in prompt @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_ai_pmo_manual_entry() -> None: mixin = _make_adaptive_rag_mixin() scene_key, prompt = await mixin._select_scene_prompt( question="帮我总结这个项目", project_id=9, asked_via="pmflow_manual", message_intent=None, mixed_signal_part=None, conversation_text="无", context_blocks_text="无", system_snapshot={}, user_snapshot={}, system_snapshot_text="{}", user_snapshot_text="{}", relevance_max=0.0, ) assert scene_key == "ai_pmo" assert "帮我总结这个项目" in prompt @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_prefers_decision_choice() -> None: mixin = _make_adaptive_rag_mixin() scene_key, prompt = await mixin._select_scene_prompt( question="如果 AI 助手第一次上线只允许一种提醒,你会把它做成桌面通知还是飞书消息?为什么?", project_id=None, asked_via="feishu", message_intent=None, mixed_signal_part=None, conversation_text="无", context_blocks_text="无", system_snapshot={}, user_snapshot={}, system_snapshot_text="{}", user_snapshot_text="{}", relevance_max=0.0, ) assert scene_key == "decision_choice" assert "明确选择" in prompt assert "飞书消息" in prompt @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_prefers_industry_trend_without_project_context() -> None: mixin = _make_adaptive_rag_mixin() scene_key, prompt = await mixin._select_scene_prompt( question="帮我看看宠物行业最新动态", project_id=None, asked_via="feishu", message_intent=None, mixed_signal_part=None, conversation_text="无", context_blocks_text="无", system_snapshot={"assistant_profile": {"name": "PMFlow"}}, user_snapshot={"projects": [{"id": 1}]}, system_snapshot_text="{}", user_snapshot_text="{}", relevance_max=0.0, ) assert scene_key == "industry_trend" assert "当前未接入实时数据" in prompt assert "project_context" not in prompt @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_filters_member_context_noise() -> None: mixin = _make_adaptive_rag_mixin() scene_key, prompt = await mixin._select_scene_prompt( question="杨宇现在负载情况如何?", project_id=None, asked_via="feishu", message_intent=None, mixed_signal_part=None, conversation_text="无", context_blocks_text=( "[1] 来源: project\n相关度: 0.991\n内容: Enterprise Knowledge Base 项目列表\n" "[2] 来源: workload\n相关度: 0.884\n内容: 杨宇 当前负载 265.56% 超载\n" ), system_snapshot={"team_members": []}, user_snapshot={"projects": [{"id": 1}]}, system_snapshot_text="{\"team_members\": []}", user_snapshot_text="{\"projects\": [{\"id\": 1}]}", relevance_max=0.0, ) assert scene_key == "member_workload" assert "Enterprise Knowledge Base 项目列表" not in prompt assert "杨宇 当前负载 265.56% 超载" in prompt assert '"projects"' not in prompt @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_ignores_negated_project_context_for_trend() -> None: mixin = _make_adaptive_rag_mixin() scene_key, prompt = await mixin._select_scene_prompt( question="帮我看看宠物行业最新动态,重点只说消费侧和产品侧的变化,不要提项目。", project_id=7, asked_via="feishu", message_intent=None, mixed_signal_part=None, conversation_text="无", context_blocks_text="无", system_snapshot={"assistant_profile": {"name": "PMFlow"}}, user_snapshot={"projects": [{"id": 1}]}, system_snapshot_text="{}", user_snapshot_text="{}", relevance_max=0.0, ) assert scene_key == "industry_trend" assert "当前未接入实时数据" in prompt assert "project_context" not in prompt @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_uses_project_trend_when_project_context_explicit() -> None: mixin = _make_adaptive_rag_mixin() scene_key, prompt = await mixin._select_scene_prompt( question="这个项目所在行业最新动态怎么样?", project_id=7, asked_via="feishu", message_intent=None, mixed_signal_part=None, conversation_text="无", context_blocks_text="无", system_snapshot={"assistant_profile": {"name": "PMFlow"}}, user_snapshot={"projects": [{"id": 1}]}, system_snapshot_text="{}", user_snapshot_text="{}", relevance_max=0.0, ) assert scene_key == "external_trend" assert "项目上下文" in prompt assert "外部搜索结果" in prompt @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_prefers_project_status_for_progress_questions() -> None: mixin = _make_adaptive_rag_mixin() scene_key, prompt = await mixin._select_scene_prompt( question="这个项目最新进展怎么样?", project_id=7, asked_via="feishu", message_intent=None, mixed_signal_part=None, conversation_text="无", context_blocks_text="无", system_snapshot={"assistant_profile": {"name": "PMFlow"}}, user_snapshot={"projects": [{"id": 1}]}, system_snapshot_text="{}", user_snapshot_text="{}", relevance_max=0.0, query_type="status", ) assert scene_key == "project_status" assert "项目状态分析助手" in prompt assert "完成率" in prompt assert "当前阶段" in prompt @pytest.mark.asyncio async def test_adaptive_rag_select_scene_prompt_feishu_direct_compacts_snapshot_into_manifest_and_focus() -> None: mixin = _make_adaptive_rag_mixin() system_snapshot = { "assistant_profile": {"name": "PMFlow", "identity": "项目管理顾问"}, "platform_profile": { "backend": {"module_counts": {"projects": 28, "tasks": 146, "signals": 41}}, "feature_catalog": [ {"name": "项目管理", "route": "/projects"}, {"name": "信号接入", "route": "/signals"}, {"name": "任务看板", "route": "/board"}, {"name": "知识图谱", "route": "/kg"}, {"name": "训练中心", "route": "/training"}, ], }, } user_snapshot = { "current_user": {"name": "林宇航"}, "projects": [{"id": idx, "title": f"项目{idx}"} for idx in range(1, 15)], "tasks": [{"id": idx, "title": f"任务{idx}"} for idx in range(1, 10)], } scene_key, prompt = await mixin._select_scene_prompt( question="信息不全,继续提供", project_id=7, asked_via="feishu", message_intent=None, mixed_signal_part=None, conversation_text="Q: 上一轮你提到这个需求还缺目标对象\nA: 我建议先补齐范围和交付目标", context_blocks_text=( "[M] 项目画像\n项目#7 Alpha(进行中);功能 18 个;KG 实体 12,关系 19。\n" "[1] 来源: task\n相关度: 0.882\n内容: 当前需求缺少目标对象、验收口径和时间边界。\n" "[2] 来源: project\n相关度: 0.701\n内容: 这是一个很长的项目列表噪声块,不应该整段原样进入飞书直答。" ), system_snapshot=system_snapshot, user_snapshot=user_snapshot, system_snapshot_text=json.dumps(system_snapshot, ensure_ascii=False), user_snapshot_text=json.dumps(user_snapshot, ensure_ascii=False), relevance_max=0.0, ) assert scene_key == "feishu_direct" assert "项目画像:" in prompt assert "项目#7 Alpha(进行中)" in prompt assert "系统摘要:" in prompt assert "用户焦点:" in prompt assert "林宇航" in prompt assert '"projects"' not in prompt assert "项目14" not in prompt def test_feishu_qa_prompts_prefer_professional_answer_style() -> None: assert "先给结论" in PROMPT_FEISHU_DIRECT_QA assert "信息不足,缺少" in PROMPT_FEISHU_DIRECT_QA assert "适合飞书消息阅读" in PROMPT_FEISHU_DIRECT_QA assert "轻量 emoji" in PROMPT_FEISHU_DIRECT_QA assert "项目画像" in PROMPT_FEISHU_DIRECT_QA assert "系统摘要" in PROMPT_FEISHU_DIRECT_QA assert "用户焦点" in PROMPT_FEISHU_DIRECT_QA assert "先回答问题部分" in PROMPT_FEISHU_MIXED_QA assert "[已收到需求]" in PROMPT_FEISHU_MIXED_QA assert "专业" in PROMPT_FEISHU_MIXED_QA assert "五轮" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA assert "A/B/C" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA assert "推荐技术/能力" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA assert "当前我更推荐" in PROMPT_AI_PMO_REQUIREMENT_PLAN_QA assert "XXXX年" not in PROMPT_EXTERNAL_TREND_QA assert "通用行业认知" in PROMPT_EXTERNAL_TREND_QA assert "项目状态分析助手" in PROMPT_PROJECT_STATUS_QA def test_feishu_reply_llm_fallback_is_failure_oriented() -> None: reply_text = FeishuReplyLLMCoreMixin._fallback_reply_text( { "status": "captured", "classification": "project", } ) assert "当前回复" in reply_text assert "已收到需求并完成初步识别" not in reply_text assert "已入库处理" not in reply_text