953 lines
37 KiB
Python
953 lines
37 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.api import legacy_ai_steward_api
|
|
from app.api.ai_steward_api import routes_capture
|
|
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_gone_envelope() -> None:
|
|
app = FastAPI()
|
|
app.include_router(routes_capture.router)
|
|
|
|
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 == 410
|
|
payload = response.json()
|
|
assert payload["code"] == 410
|
|
assert payload["message"] == "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。"
|
|
assert payload["data"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_project_from_text_gone_route_is_core_mounted() -> None:
|
|
router_source = Path("app/api/router.py").read_text(encoding="utf-8")
|
|
assert '"legacy_ai_steward_api"' in router_source
|
|
|
|
app = FastAPI()
|
|
app.include_router(legacy_ai_steward_api.router)
|
|
|
|
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 == 410
|
|
assert response.json() == {
|
|
"code": 410,
|
|
"message": "旧项目创建入口已下线,请使用新项目启动台生成草案并确认创建。",
|
|
"data": None,
|
|
}
|
|
|
|
|
|
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 "")
|