from __future__ import annotations import json from pathlib import Path from types import SimpleNamespace import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient from app.api.ai_steward_api import routes_capture from app.api.ai_steward_api.schemas import ProjectFromTextResponse from app.core.database import get_db from app.dependencies.auth import get_current_user from app.dependencies.tenant import get_tenant_id from app.services.ai_steward_service import mixin_creation_normalize_persist from app.services.ai_steward_service import AIStewardService from app.services.task_persistence_core import SkillTagResolution @pytest.mark.asyncio async def test_project_from_text_returns_enveloped_signal_sources(monkeypatch) -> None: async def fake_create_project_from_natural_language(self, **kwargs): # noqa: ANN001 _ = (self, kwargs) return { "project": {"id": "11", "name": "AI 客服", "title": "AI 客服平台", "description": "demo", "status": "active"}, "signal": {"id": "21", "content": "main signal", "impact_score": 0.91}, "charter": {"id": "31", "title": "宪章", "content": {"summary": "demo"}}, "tasks": [], "risks": [], "analysis": { "project_name": "AI 客服", "project_title": "AI 客服平台", "tech_stack": ["FastAPI", "React"], "timeline": "3 周", "primary_role": "backend", "requirement_items": [ { "section": "核心痛点", "title": "内容本地化", "description": "翻译公司贵、慢、不地道", } ], }, "signal_sources": [ { "id": "101", "source_kind": "custom", "source_name": "核心痛点 - 内容本地化", "source_identifier": "custom:core-pain-content-localization", "display_source_kind": "自定义来源", "technical_identifier": "custom:core-pain-content-localization", "description": "翻译公司贵、慢、不地道", "section": "核心痛点", } ], } async def _fake_get_db(): yield object() monkeypatch.setattr(AIStewardService, "create_project_from_natural_language", fake_create_project_from_natural_language) app = FastAPI() app.include_router(routes_capture.router) app.dependency_overrides[get_db] = _fake_get_db app.dependency_overrides[get_tenant_id] = lambda: 1 app.dependency_overrides[get_current_user] = lambda: SimpleNamespace( id=9, tenant_id=1, name="管理员", username="admin", role="ADMIN", ) transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: response = await client.post("/ai-steward/project-from-text", json={"input_text": "我们要做一个 AI 客服工具"}) assert response.status_code == 200 payload = response.json() assert payload["code"] == 0 assert payload["message"] == "success" assert payload["data"]["project"]["id"] == "11" assert payload["data"]["signal_sources"][0]["source_name"] == "核心痛点 - 内容本地化" assert payload["data"]["signal_sources"][0]["display_source_kind"] == "自定义来源" assert payload["data"]["signal_sources"][0]["technical_identifier"] == "custom:core-pain-content-localization" def test_project_from_text_response_schema_exposes_signal_source_display_fields() -> None: schema = ProjectFromTextResponse.model_json_schema() signal_source = schema["$defs"]["ProjectFromTextSignalSource"] assert "display_source_kind" in signal_source["properties"] assert "technical_identifier" in signal_source["properties"] def test_ai_steward_no_longer_keeps_instruction_or_stub_heuristics_in_runtime_modules() -> None: repo_root = Path(__file__).resolve().parents[1] title_utils = (repo_root / "app/services/ai_steward_service/mixin_title_utils.py").read_text(encoding="utf-8") json_parsing = (repo_root / "app/services/ai_steward_service/mixin_json_parsing.py").read_text(encoding="utf-8") shared = (repo_root / "app/services/ai_steward_service/shared.py").read_text(encoding="utf-8") assert "_looks_like_instruction_sentence" not in title_utils assert "_looks_like_project_creation_stub_response" not in json_parsing assert "PROJECT_CREATION_STUB_MARKERS" not in shared @pytest.mark.asyncio async def test_project_requirement_analysis_uses_canonical_function_name(monkeypatch) -> None: service = AIStewardService(object()) captured: dict[str, object] = {} async def fake_generate(prompt: str, **kwargs): # noqa: ANN001 _ = prompt captured["function_name"] = kwargs.get("function_name") captured["max_tokens"] = kwargs.get("max_tokens") return '{"project_title":"AI 客服","requirement_items":[]}' monkeypatch.setattr(service.llm, "generate", fake_generate) analysis = await service._analyze_project_input("请创建一个 AI 客服项目", tenant_id=1) assert analysis["project_title"] == "AI 客服" assert captured["function_name"] == "project_requirement_extraction" assert captured["max_tokens"] == 2_200 @pytest.mark.asyncio async def test_project_requirement_analysis_escalates_token_budget_low_to_high(monkeypatch) -> None: service = AIStewardService(object()) token_attempts: list[int] = [] async def fake_generate(prompt: str, **kwargs): # noqa: ANN001 _ = prompt token_attempts.append(int(kwargs.get("max_tokens") or 0)) if len(token_attempts) == 1: return '{"project_title":"AI 客服"' return '{"project_title":"AI 客服","requirement_items":[]}' monkeypatch.setattr(service.llm, "generate", fake_generate) analysis = await service._analyze_project_input("请创建一个 AI 客服项目", tenant_id=1) assert analysis["project_title"] == "AI 客服" assert token_attempts[:2] == [2_200, 4_000] @pytest.mark.asyncio async def test_normalize_project_creation_draft_auto_assigns_best_skill_match(monkeypatch) -> None: service = AIStewardService(object()) captured: dict[str, object] = {} async def fake_resolve_skill_tags(db, *, tenant_id: int, skill_names): # noqa: ANN001 _ = db captured["resolved_tenant_id"] = tenant_id captured["skill_names"] = list(skill_names) return SkillTagResolution( input_names=list(skill_names), matched_ids=[101, 202], matched_items=[ {"id": 101, "name": "FastAPI"}, {"id": 202, "name": "React"}, ], unmatched_names=[], ) async def fake_recommend_personnel( db, # noqa: ANN001 tenant_id: int, required_skills, task_description=None, task_id=None, limit: int = 5, ): _ = (db, task_description, task_id, limit) captured["recommend_tenant_id"] = tenant_id captured["required_skills"] = list(required_skills) return { "task_id": None, "task_description": "搭建 AI 客服核心接口", "required_skills": list(required_skills), "recommendations": [ { "user_id": "9", "user_name": "Ada", "match_score": 95.0, "reason": "FastAPI 和 React 都命中,且负载适中", "skill_match_count": 2, "avg_proficiency": 88.0, "workload_rate": 45.0, "recommended_role": "backend", } ], "total_count": 1, } monkeypatch.setattr( mixin_creation_normalize_persist, "resolve_skill_tag_ids_by_names", fake_resolve_skill_tags, ) monkeypatch.setattr( mixin_creation_normalize_persist, "recommend_personnel", fake_recommend_personnel, ) normalized = await service._normalize_project_creation_draft( draft={ "analysis": {}, "project": { "name": "AI 客服", "title": "AI 客服平台", "description": "创建一个 AI 客服平台", "assigned_role": "backend", }, "signal": { "content": "main signal", "impact_score": 0.9, "priority": "high", "strategic_analysis": {}, }, "charter": { "title": "AI 客服平台宪章", "content": { "objectives": ["提升客服效率"], "success_criteria": ["首响 < 10 秒"], "constraints": ["先做 MVP"], }, }, "tasks": [ { "title": "搭建 AI 客服核心接口", "description": "实现工单接入与回复编排", "priority": "high", "required_skills": ["FastAPI", "React"], } ], "risks": [], }, input_text="创建一个 AI 客服平台", creator_id=1, tenant_id=1, ) task_draft = normalized["tasks"][0] assert task_draft.assignee_id == 9 assert task_draft.required_skill_tag_ids == [101, 202] assert task_draft.ai_recommended_assignees is not None assert task_draft.ai_recommended_assignees["final_assignee_id"] == 9 assert task_draft.ai_recommended_assignees["auto_selected"] is True assert task_draft.ai_recommended_assignees["recommended_candidates"][0]["user_id"] == "9" assert captured["skill_names"] == ["FastAPI", "React"] assert captured["required_skills"] == [101, 202] @pytest.mark.asyncio async def test_normalize_project_creation_draft_prefers_explicit_fixed_project_title() -> None: service = AIStewardService(object()) normalized = await service._normalize_project_creation_draft( draft={ "analysis": {}, "project": { "name": "跨境AI复盘", "title": "跨境AI复盘", "description": "通用跨境直播复盘项目", "assigned_role": "backend", }, "signal": { "content": "main signal", "impact_score": 0.9, "priority": "high", "strategic_analysis": {}, }, "charter": { "title": "跨境AI复盘项目宪章", "content": { "objectives": ["提升复盘效率"], "success_criteria": ["复盘耗时下降"], "constraints": ["先做 MVP"], }, }, "tasks": [], "risks": [], }, input_text=( "请发起一个全新的项目需求,项目名固定为 FeishuWebFlow-1775111724952。 " "这是一个从 0 到 1 的需求:做面向跨境 DTC 团队的 AI 直播复盘助手。" ), creator_id=1, tenant_id=1, ) assert normalized["project"]["title"] == "FeishuWebFlow-1775111724952" assert normalized["project"]["name"] == "FeishuWebFlow-1775111724952" assert normalized["charter"]["title"] == "FeishuWebFlow-1775111724952项目宪章" @pytest.mark.asyncio async def test_normalize_project_creation_draft_prefers_natural_named_project_title() -> None: service = AIStewardService(object()) normalized = await service._normalize_project_creation_draft( draft={ "analysis": {}, "project": { "name": "跨境复盘快版", "title": "跨境复盘快版", "description": "为北美 DTC 运营团队提供 3 分钟级 AI 复盘工具", "assigned_role": "backend", }, "signal": { "content": "main signal", "impact_score": 0.9, "priority": "high", "strategic_analysis": {}, }, "charter": { "title": "跨境复盘快版项目宪章", "content": { "objectives": ["提升复盘效率"], "success_criteria": ["复盘耗时下降"], "constraints": ["先做 MVP"], }, }, "tasks": [], "risks": [], }, input_text=( "最近我们做跨境直播复盘太慢了,想做个给运营团队用的小工具," "这个需求先叫 FeishuWebFlow-1775134530933。" ), creator_id=1, tenant_id=1, ) assert normalized["project"]["title"] == "FeishuWebFlow-1775134530933" assert normalized["project"]["name"] == "FeishuWebFlow-1775134530933" assert normalized["charter"]["title"] == "FeishuWebFlow-1775134530933项目宪章" @pytest.mark.asyncio async def test_generate_task_chain_prompt_includes_rich_requirement_context_and_skill_catalog( monkeypatch, ) -> None: service = AIStewardService(object()) captured: dict[str, object] = {} async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 20): # noqa: ANN001 _ = self, limit captured["skill_catalog_tenant_id"] = tenant_id return ["Python", "FastAPI", "React", "TypeScript", "LLM 调优"] async def fake_generate(prompt: str, **kwargs): # noqa: ANN001 captured["prompt"] = prompt captured["function_name"] = kwargs.get("function_name") return json.dumps( { "tasks": [ { "title": "直播数据采集与清洗", "type": "backend", "estimated_hours": 16, "description": "接入录屏、评论与交易数据并完成清洗。", "priority": "high", "required_skills": ["Python", "FastAPI"], } ] }, ensure_ascii=False, ) async def fake_resolve_skill_tags(db, *, tenant_id: int, skill_names): # noqa: ANN001 _ = db, tenant_id input_names = list(skill_names) return SkillTagResolution( input_names=input_names, matched_ids=[1, 3], matched_items=[ {"input_name": "Python", "skill_tag_id": 1, "skill_tag_name": "Python", "skill_tag_slug": "python"}, {"input_name": "FastAPI", "skill_tag_id": 3, "skill_tag_name": "FastAPI", "skill_tag_slug": "fastapi"}, ], unmatched_names=[], ) monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) monkeypatch.setattr( "app.services.ai_steward_service.mixin_draft_generation.resolve_skill_tag_ids_by_names", fake_resolve_skill_tags, ) monkeypatch.setattr(service.llm, "generate", fake_generate) tasks = await service._generate_task_chain_draft( analysis={ "project_title": "跨境直播复盘", "timeline": "8周", "target_audience": "北美 DTC 品牌运营团队", "key_features": [ "直播录屏与评论回放切片", "亮点和掉点自动总结", "可执行复盘清单", ], "success_metrics": [ "7天内复盘时长下降60%", "高频问题复现率下降30%", ], "team_setup": [ {"role": "PM", "notes": "负责需求边界与验收口径"}, {"role": "后端", "notes": "负责直播数据与订单数据接入"}, ], "requirement_items": [ { "section": "信号源", "title": "Shopify 订单与退款数据", "description": "用于转化归因与退款波动分析", }, { "section": "信号源", "title": "TikTok Shop 直播数据", "description": "提供观看、互动与成交数据", }, ], }, tenant_id=1, ) assert tasks[0]["title"] == "直播数据采集与清洗" assert captured["function_name"] == "project_from_text_task_chain" assert captured["skill_catalog_tenant_id"] == 1 prompt = str(captured["prompt"] or "") assert "直播录屏与评论回放切片" in prompt assert "7天内复盘时长下降60%" in prompt assert "Shopify 订单与退款数据" in prompt assert "PM" in prompt assert "Python" in prompt assert "FastAPI" in prompt assert "LLM 调优" in prompt @pytest.mark.asyncio async def test_generate_task_chain_retries_when_first_result_has_only_unmappable_skills(monkeypatch) -> None: service = AIStewardService(object()) prompts: list[str] = [] async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 20): # noqa: ANN001 _ = self, tenant_id, limit return ["Python", "FastAPI", "React", "TypeScript", "LLM 调优"] async def fake_resolve_skill_tags(db, *, tenant_id: int, skill_names): # noqa: ANN001 _ = db, tenant_id input_names = list(skill_names) mapping = { "Python": (1, "Python"), "FastAPI": (3, "FastAPI"), "React": (4, "React"), "TypeScript": (2, "TypeScript"), "LLM 调优": (7, "LLM 调优"), } matched_items = [] matched_ids = [] unmatched_names = [] for item in input_names: matched = mapping.get(str(item)) if matched is None: unmatched_names.append(str(item)) continue matched_ids.append(matched[0]) matched_items.append( { "input_name": str(item), "skill_tag_id": matched[0], "skill_tag_name": matched[1], "skill_tag_slug": matched[1].lower(), } ) return SkillTagResolution( input_names=input_names, matched_ids=matched_ids, matched_items=matched_items, unmatched_names=unmatched_names, ) invalid_response = json.dumps( { "tasks": [ { "title": "用户权限管理模块", "type": "backend", "estimated_hours": 20, "description": "实现用户登录鉴权与角色权限管理。", "priority": "low", "required_skills": ["JWT", "Node.js", "Database Design"], } ] }, ensure_ascii=False, ) valid_response = json.dumps( { "tasks": [ { "title": "直播数据采集与清洗", "type": "backend", "estimated_hours": 24, "description": "接入直播录屏与评论数据并完成清洗。", "priority": "high", "required_skills": ["Python", "FastAPI"], }, { "title": "复盘报告看板", "type": "frontend", "estimated_hours": 24, "description": "展示亮点、掉点与复盘清单。", "priority": "medium", "required_skills": ["React", "TypeScript"], }, ] }, ensure_ascii=False, ) async def fake_generate(prompt: str, **kwargs): # noqa: ANN001 _ = kwargs prompts.append(prompt) return invalid_response if len(prompts) == 1 else valid_response monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) monkeypatch.setattr( "app.services.ai_steward_service.mixin_draft_generation.resolve_skill_tag_ids_by_names", fake_resolve_skill_tags, ) monkeypatch.setattr(service.llm, "generate", fake_generate) tasks = await service._generate_task_chain_draft( analysis={ "project_title": "跨境直播复盘", "timeline": "8周", "key_features": [ "直播录屏与评论回放切片", "亮点和掉点自动总结", ], "success_metrics": ["7天内复盘时长下降60%"], "requirement_items": [ { "section": "MVP 功能", "title": "可执行复盘清单", "description": "输出待办清单与复盘建议", } ], }, tenant_id=1, ) assert len(prompts) == 2 assert tasks[0]["title"] == "直播数据采集与清洗" assert tasks[0]["required_skills"] == ["Python", "FastAPI"] assert "用户权限管理模块" in prompts[1] assert "JWT" in prompts[1] assert "Python" in prompts[1] assert "FastAPI" in prompts[1] @pytest.mark.asyncio async def test_generate_task_chain_falls_back_to_charter_decomposition_when_primary_route_fails( monkeypatch, ) -> None: service = AIStewardService(object()) captured: dict[str, object] = {} async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 20): # noqa: ANN001 _ = self, tenant_id, limit return ["Python", "FastAPI", "React", "TypeScript", "LLM 调优"] async def fake_generate_project_creation_object(self, **kwargs): # noqa: ANN001 _ = self captured["primary_function_name"] = kwargs.get("function_name") raise RuntimeError("provider_circuit_open:openai") async def fake_decompose_tasks_from_charter( self, charter_content, project_id, tenant_id="default", db=None, routing_meta=None, ): # noqa: ANN001 _ = self, project_id, db, routing_meta captured["charter_content"] = charter_content captured["decompose_tenant_id"] = tenant_id return { "tasks": [ { "task_key": "task-1", "title": "直播数据采集与清洗", "description": "接入直播录屏与评论数据并完成清洗。", "type": "backend", "estimated_hours": 24, "required_skill_names": ["Python", "FastAPI"], "priority": "high", }, { "task_key": "task-2", "title": "复盘报告看板", "description": "展示亮点、掉点与复盘清单。", "type": "frontend", "estimated_hours": 20, "required_skill_names": ["React", "TypeScript"], "priority": "medium", }, ] } async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001 _ = self, tenant_id return { "tasks": tasks, "invalid_tasks": [], "mappable_task_count": len(tasks), "total_task_count": len(tasks), } monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object) monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate) monkeypatch.setattr( "app.services.ai_service_core.service.AIService.decompose_tasks_from_charter", fake_decompose_tasks_from_charter, ) tasks = await service._generate_task_chain_draft( analysis={ "project_title": "跨境直播复盘", "target_audience": "北美 DTC 品牌运营团队", "key_features": ["直播录屏与评论回放切片", "亮点和掉点自动总结", "可执行复盘清单"], "success_metrics": ["7天内复盘时长下降60%", "高频问题复现率下降30%"], "requirement_items": [ { "section": "信号源", "title": "Shopify 订单与退款数据", "description": "用于转化归因与退款波动分析", } ], "team_setup": [{"role": "后端", "notes": "负责直播数据与订单数据接入"}], }, tenant_id=1, ) assert captured["primary_function_name"] == "project_from_text_task_chain" assert captured["decompose_tenant_id"] == 1 assert "直播录屏与评论回放切片" in str(captured["charter_content"] or "") assert tasks[0]["title"] == "直播数据采集与清洗" assert tasks[0]["required_skills"] == ["Python", "FastAPI"] @pytest.mark.asyncio async def test_generate_task_chain_prefers_six_module_charter_content_when_present(monkeypatch) -> None: service = AIStewardService(object()) captured: dict[str, object] = {} 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_generate_project_creation_object(self, **kwargs): # noqa: ANN001 _ = self, kwargs raise AssertionError("six-module charter path should bypass project_from_text_task_chain prompt") async def fake_decompose_tasks_from_charter( self, charter_content, project_id, tenant_id="default", db=None, routing_meta=None, ): # noqa: ANN001 _ = self, project_id, db captured["charter_content"] = charter_content captured["decompose_tenant_id"] = tenant_id captured["routing_meta"] = routing_meta return { "tasks": [ { "task_key": "task-1", "title": "直播数据采集与清洗", "description": "接入直播录屏与评论数据并完成清洗。", "type": "backend", "estimated_hours": 24, "required_skill_names": ["Python", "FastAPI"], "priority": "high", } ] } async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001 _ = self, tenant_id return { "tasks": tasks, "invalid_tasks": [], "mappable_task_count": len(tasks), "total_task_count": len(tasks), } monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object) monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate) monkeypatch.setattr( "app.services.ai_service_core.service.AIService.decompose_tasks_from_charter", fake_decompose_tasks_from_charter, ) tasks = await service._generate_task_chain_draft( analysis={ "project_title": "跨境直播复盘", "target_audience": "北美 DTC 品牌运营团队", "key_features": ["直播录屏与评论回放切片", "亮点和掉点自动总结", "可执行复盘清单"], }, tenant_id=1, charter_content={ "schema_version": "charter_ai_first_v1", "modules": { "module_1": { "problem_statement": "北美 DTC 品牌运营团队直播结束后复盘慢、素材复用率低。", "goal_statement": "- 将复盘压缩到 10 分钟内", "kpis": "- 7 天内复盘时长下降 60%", }, "module_2": { "in_scope": "- 直播录屏与评论回放切片\n- 亮点和掉点自动总结", "out_of_scope": "- 不做达人 CRM", "alignment_qa": "Q1: why now", }, "module_3": {}, "module_4": {"milestones": "- 第 1 周完成数据接入"}, "module_5": {"risk_register": "- 数据口径不一致"}, "module_6": {"governance_rule": "- 复盘记录保留 180 天"}, }, }, ) assert captured["decompose_tenant_id"] == 1 assert "\"modules\"" in str(captured["charter_content"] or "") assert "直播录屏与评论回放切片" in str(captured["charter_content"] or "") assert captured["routing_meta"]["tier_alias"] == "low" assert captured["routing_meta"]["routing_decision"] == "force_low_first" assert captured["routing_meta"]["response_contract"] == "structured" assert tasks[0]["title"] == "直播数据采集与清洗" assert tasks[0]["required_skills"] == ["Python", "FastAPI"] @pytest.mark.asyncio async def test_generate_task_chain_raises_when_charter_decomposition_provider_fails(monkeypatch) -> None: service = AIStewardService(object()) 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_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") monkeypatch.setattr( "app.services.ai_service_core.service.AIService.decompose_tasks_from_charter", fake_decompose_tasks_from_charter, ) monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) with pytest.raises(RuntimeError, match="provider_circuit_open:openai"): await service._generate_task_chain_draft( analysis={ "project_title": "跨境直播复盘", "primary_role": "backend", }, tenant_id=1, charter_content={ "schema_version": "charter_ai_first_v1", "modules": { "module_1": { "problem_statement": "直播复盘慢,问题复现率高。", "goal_statement": "- 将复盘压缩到 10 分钟内", "kpis": "- 7 天内复盘时长下降 60%", }, "module_2": { "in_scope": "- 直播录屏与评论回放切片\n- 亮点和掉点自动总结\n- 可执行复盘清单", "out_of_scope": "- 不做达人 CRM", "alignment_qa": "优先交付复盘效率闭环", }, "module_3": {"engineering_view": "Python FastAPI React TypeScript 数据分析"}, "module_4": {"milestones": "- 第 1 周完成直播与订单数据对齐"}, "module_5": {"risk_register": "- 数据口径不一致"}, "module_6": {"governance_rule": "- 复盘记录保留 180 天"}, }, }, ) @pytest.mark.asyncio async def test_generate_task_chain_primary_prompt_uses_low_first_routing_meta(monkeypatch) -> None: service = AIStewardService(object()) captured: dict[str, object] = {} async def fake_load_available_skill_names(self, *, tenant_id: int, limit: int = 24): # noqa: ANN001 _ = self, tenant_id, limit return ["Python", "FastAPI", "React"] async def fake_generate_project_creation_object(self, **kwargs): # noqa: ANN001 _ = self captured["function_name"] = kwargs.get("function_name") captured["routing_meta"] = kwargs.get("routing_meta") return { "tasks": [ { "title": "直播数据采集与清洗", "description": "接入录屏与评论回放。", "priority": "high", "estimated_hours": 16, "required_skills": ["Python", "FastAPI"], } ] } async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001 _ = self, tenant_id return { "tasks": tasks, "invalid_tasks": [], "mappable_task_count": len(tasks), "total_task_count": len(tasks), } monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object) monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate) tasks = await service._generate_task_chain_draft( analysis={ "project_title": "跨境直播复盘", "primary_role": "backend", "key_features": ["直播录屏与评论回放切片"], "success_metrics": ["7天内复盘时长下降60%"], }, tenant_id=1, ) assert captured["function_name"] == "project_from_text_task_chain" assert captured["routing_meta"]["tier_alias"] == "low" assert captured["routing_meta"]["routing_decision"] == "force_low_first" assert captured["routing_meta"]["response_contract"] == "structured" assert tasks[0]["title"] == "直播数据采集与清洗" @pytest.mark.asyncio async def test_generate_task_chain_escalates_to_high_when_low_route_returns_low_quality_tasks( monkeypatch, ) -> None: service = AIStewardService(object()) captured_tiers: list[str] = [] 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_generate_project_creation_object(self, **kwargs): # noqa: ANN001 _ = self routing_meta = kwargs.get("routing_meta") or {} captured_tiers.append(str(routing_meta.get("tier_alias") or "")) if routing_meta.get("tier_alias") == "low": return { "tasks": [ { "title": "权限系统", "description": "实现统一权限系统。", "priority": "high", "estimated_hours": 16, "required_skills": ["JWT", "Node.js"], } ] } return { "tasks": [ { "title": "直播数据采集与清洗", "description": "接入录屏与评论回放。", "priority": "high", "estimated_hours": 16, "required_skills": ["Python", "FastAPI"], } ] } async def fake_inspect_task_chain_candidate(self, *, tasks, tenant_id): # noqa: ANN001 _ = self, tenant_id required_skills = list(tasks[0].get("required_skills") or []) if tasks else [] if required_skills == ["Python", "FastAPI"]: return { "tasks": tasks, "invalid_tasks": [], "mappable_task_count": len(tasks), "total_task_count": len(tasks), } return { "tasks": tasks, "invalid_tasks": [ { "title": str(tasks[0].get("title") or ""), "required_skills": required_skills, "reason": "required_skills_unmappable", } ], "mappable_task_count": 0, "total_task_count": len(tasks), } monkeypatch.setattr(AIStewardService, "_load_available_skill_names", fake_load_available_skill_names) monkeypatch.setattr(AIStewardService, "_generate_project_creation_object", fake_generate_project_creation_object) monkeypatch.setattr(AIStewardService, "_inspect_task_chain_candidate", fake_inspect_task_chain_candidate) tasks = await service._generate_task_chain_draft( analysis={ "project_title": "跨境直播复盘", "primary_role": "backend", "key_features": ["直播录屏与评论回放切片"], }, tenant_id=1, ) assert captured_tiers[0] == "low" assert "high" in captured_tiers assert tasks[0]["title"] == "直播数据采集与清洗" assert tasks[0]["required_skills"] == ["Python", "FastAPI"] @pytest.mark.asyncio async def test_generate_project_creation_draft_keeps_task_generation_pending_when_task_chain_fails( monkeypatch, ) -> None: service = AIStewardService(object()) async def fake_generate_signal(self, *, analysis, tenant_id): # noqa: ANN001 _ = self, analysis, tenant_id return { "content": "直播复盘主信号", "impact_score": 0.9, "priority": "high", "strategic_analysis": {}, } async def fake_generate_charter(self, *, analysis, tenant_id): # noqa: ANN001 _ = self, analysis, tenant_id return { "title": "跨境直播复盘项目宪章", "content": { "schema_version": "charter_ai_first_v1", "modules": { "module_1": {"problem_statement": "复盘慢"}, "module_2": {"in_scope": "- 直播复盘"}, "module_3": {}, "module_4": {}, "module_5": {}, "module_6": {}, }, }, } async def fake_generate_task_chain(self, *, analysis, tenant_id, charter_content=None): # noqa: ANN001 _ = self, analysis, tenant_id, charter_content raise RuntimeError("provider_circuit_open:openai") monkeypatch.setattr(AIStewardService, "_generate_competitive_signal_draft", fake_generate_signal) monkeypatch.setattr(AIStewardService, "_generate_charter_draft", fake_generate_charter) monkeypatch.setattr(AIStewardService, "_generate_task_chain_draft", fake_generate_task_chain) draft = await service._generate_project_creation_draft( input_text="做一个给北美 DTC 运营团队用的跨境直播复盘助手", analysis={ "project_name": "跨境直播复盘", "project_title": "跨境直播复盘", "primary_role": "backend", }, tenant_id=1, ) assert draft["project"]["title"] == "跨境直播复盘" assert draft["signal"]["content"] == "直播复盘主信号" assert draft["charter"]["title"] == "跨境直播复盘项目宪章" assert draft["tasks"] == [] assert draft["task_generation"]["status"] == "pending" assert "provider_circuit_open:openai" in str(draft["task_generation"]["error_message"] or "")