PMFlow/backend/tests/test_ai_steward_project_fro...

998 lines
39 KiB
Python

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="REVIEWER",
)
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 "")