PMFlow/backend/tests/test_sstc_tasks_ai_breakdow...

499 lines
19 KiB
Python

import json
from types import SimpleNamespace
import pytest
from app.api import tasks as tasks_api
from app.api.sstc_api import tasks_ai_breakdown as sstc_tasks_ai_breakdown_api
from app.api.sstc_api import tasks_recommend_optimize as sstc_tasks_recommend_optimize_api
from app.services.llm_service import StructuredExtractionResult
from app.services.sstc.charter_data import (
_build_task_breakdown_context,
_build_task_breakdown_prompt,
)
from app.schemas import TaskBreakdownRequest
def test_task_breakdown_context_flattens_charter_modules() -> None:
project = SimpleNamespace(
id=6,
title="内部知识库升级",
name="内部知识库升级",
description="围绕内部知识库问答能力升级",
status="active",
assigned_role="backend",
)
charter = SimpleNamespace(
id=55,
title="内部知识库升级宪章",
version=2,
status="aligning",
content={
"modules": {
"module_1": {
"problem_statement": "减少重复检索",
"goal_statement": "提升命中率 / 降低重复提问",
"kpis": "首响 < 3s",
},
"module_2": {
"in_scope": "问答检索,知识补充",
"out_of_scope": "权限模型重构",
},
"module_4": {
"milestones": "需求冻结\n开发完成",
},
"module_5": {
"risk_register": "需求膨胀; 依赖不清",
},
"module_6": {
"governance_rule": "附件仅作证据引用",
},
}
},
)
context = _build_task_breakdown_context(
description="支持导出知识库内容",
project=project,
charter=charter,
)
assert context["project"]["title"] == "内部知识库升级"
assert context["project"]["status"] == "active"
assert context["charter"]["summary"] == "减少重复检索"
assert context["charter"]["objectives"] == ["提升命中率", "降低重复提问"]
assert context["charter"]["in_scope"] == ["问答检索", "知识补充"]
assert context["charter"]["out_of_scope"] == ["权限模型重构"]
assert context["charter"]["milestones"] == ["需求冻结", "开发完成"]
assert "附件仅作证据引用" in context["charter"]["constraints"][0]
def test_task_breakdown_prompt_embeds_charter_context() -> None:
prompt = _build_task_breakdown_prompt(
project_id=6,
context={
"project": {
"id": 6,
"title": "内部知识库升级",
"description": "围绕内部知识库问答能力升级",
"status": "active",
"assigned_role": "backend",
},
"charter": {
"id": 55,
"title": "内部知识库升级宪章",
"version": 2,
"status": "aligning",
"summary": "减少重复检索",
"objectives": ["提升命中率", "降低重复提问"],
"scope": "知识库检索与问答",
"in_scope": ["问答检索", "知识补充"],
"out_of_scope": ["权限模型重构"],
"success_criteria": ["首响 < 3s"],
"constraints": ["附件仅作证据引用"],
"milestones": ["需求冻结", "开发完成"],
"risks": ["需求膨胀"],
"signal_ids": [12],
},
},
available_skills="FastAPI, React, LangChain",
)
assert "需求宪章与项目上下文" in prompt
assert "内部知识库升级宪章" in prompt
assert "FastAPI, React, LangChain" in prompt
assert "objectives" in prompt
assert "constraints" in prompt
@pytest.mark.asyncio
async def test_general_task_breakdown_route_uses_project_and_charter_context(monkeypatch) -> None:
captured: dict[str, object] = {}
async def _fake_load_context(db, *, tenant_id: int, description: str, project_id=None, charter_id=None):
_ = (db, tenant_id, project_id, charter_id)
captured["description"] = description
return {
"request": {"description": description},
"project": {
"id": 6,
"title": "内部知识库升级",
"description": "围绕内部知识库问答能力升级",
"status": "active",
"assigned_role": "backend",
},
"charter": {
"id": 55,
"title": "内部知识库升级宪章",
"version": 2,
"status": "aligning",
"summary": "减少重复检索",
"objectives": ["提升命中率", "降低重复提问"],
"scope": "知识库检索与问答",
"in_scope": ["问答检索", "知识补充"],
"out_of_scope": ["权限模型重构"],
"success_criteria": ["首响 < 3s"],
"constraints": ["附件仅作证据引用"],
"milestones": ["需求冻结", "开发完成"],
"risks": ["需求膨胀"],
"signal_ids": [12],
},
}
async def _fake_load_skills(db, *, tenant_id: int):
_ = (db, tenant_id)
return "FastAPI, React, LangChain"
class _FakeLLM:
async def generate_structured_object(self, prompt: str, **kwargs):
captured["prompt"] = prompt
captured["llm_kwargs"] = kwargs
payload = {
"tasks": [
{
"task_key": "task-1",
"title": "需求澄清与范围确认",
"description": "确认导出知识库的范围",
"type": "ai",
"estimated_hours": 4,
"required_skill_names": ["FastAPI"],
"required_skill_tag_ids": [101],
"depends_on_task_keys": [],
"risk_level": "low",
"risk_notes": "边界明确",
"confidence": 0.94,
"evidence_refs": ["charter.objectives.0"],
},
{
"task_key": "task-2",
"title": "核心方案落地",
"description": "实现导出与验证流程",
"type": "backend",
"estimated_hours": 12,
"required_skill_names": ["React", "LangChain"],
"required_skill_tag_ids": [202, 303],
"depends_on_task_keys": ["task-1"],
"risk_level": "medium",
"risk_notes": "依赖范围确认结果",
"confidence": 0.87,
"evidence_refs": ["charter.success_criteria.0"],
},
],
"detected_skills": ["FastAPI", "React", "LangChain"],
}
return StructuredExtractionResult(
payload=payload,
source_field="message.content",
provider="glm",
model="qwen-plus",
raw_text=json.dumps(payload, ensure_ascii=False),
validation="ok",
)
class _FakePlane:
async def sync_tasks(self, tasks, is_urgent: bool = False):
_ = is_urgent
return {"synced_count": len(tasks)}
monkeypatch.setattr(tasks_api, "_load_task_breakdown_context", _fake_load_context)
monkeypatch.setattr(tasks_api, "_load_available_skill_names", _fake_load_skills)
monkeypatch.setattr(tasks_api, "llm_service", _FakeLLM())
monkeypatch.setattr(tasks_api, "plane_service", _FakePlane())
result = await tasks_api.breakdown_tasks(
TaskBreakdownRequest(
description="支持导出知识库内容",
project_id=6,
charter_id=55,
sync_to_plane=True,
),
db=SimpleNamespace(),
current_user=SimpleNamespace(tenant_id=1, id=1),
)
assert "内部知识库升级宪章" in str(captured["prompt"])
assert "FastAPI, React, LangChain" in str(captured["prompt"])
assert result["sync_result"]["synced_count"] == 2
assert result["tasks"][0]["task_key"] == "task-1"
assert result["tasks"][0]["required_skill_tag_ids"] == [101]
assert result["tasks"][1]["depends_on_task_keys"] == ["task-1"]
@pytest.mark.asyncio
async def test_sstc_task_breakdown_route_persists_business_object_and_payload(monkeypatch) -> None:
captured: dict[str, object] = {"drafts": []}
async def _fake_load_context(db, *, tenant_id: int, description: str, project_id=None, charter_id=None):
_ = (db, tenant_id, project_id, charter_id)
captured["description"] = description
return {
"request": {"description": description},
"project": {
"id": 6,
"title": "内部知识库升级",
"description": "围绕内部知识库问答能力升级",
"status": "active",
"assigned_role": "backend",
},
"charter": {
"id": 55,
"title": "内部知识库升级宪章",
"version": 2,
"status": "aligning",
"summary": "减少重复检索",
"objectives": ["提升命中率", "降低重复提问"],
"scope": "知识库检索与问答",
"in_scope": ["问答检索", "知识补充"],
"out_of_scope": ["权限模型重构"],
"success_criteria": ["首响 < 3s"],
"constraints": ["附件仅作证据引用"],
"milestones": ["需求冻结", "开发完成"],
"risks": ["需求膨胀"],
"signal_ids": [12],
},
}
async def _fake_load_skills(db, *, tenant_id: int):
_ = (db, tenant_id)
return "FastAPI, React, LangChain"
class _FakeLLM:
async def generate_structured_object(self, prompt: str, **kwargs):
captured["prompt"] = prompt
captured["llm_kwargs"] = kwargs
payload = {
"tasks": [
{
"task_key": "task-1",
"title": "需求澄清与范围确认",
"description": "确认导出知识库的范围",
"type": "ai",
"estimated_hours": 4,
"required_skill_names": ["FastAPI"],
"required_skill_tag_ids": [101],
"depends_on_task_keys": [],
"risk_level": "low",
"risk_notes": "边界明确",
"confidence": 0.94,
"evidence_refs": ["charter.objectives.0"],
},
{
"task_key": "task-2",
"title": "核心方案落地",
"description": "实现导出与验证流程",
"type": "backend",
"estimated_hours": 12,
"required_skill_names": ["React", "LangChain"],
"required_skill_tag_ids": [202, 303],
"depends_on_task_keys": ["task-1"],
"risk_level": "medium",
"risk_notes": "依赖范围确认结果",
"confidence": 0.87,
"evidence_refs": ["charter.success_criteria.0"],
},
],
"detected_skills": ["FastAPI", "React", "LangChain"],
}
return StructuredExtractionResult(
payload=payload,
source_field="message.content",
provider="glm",
model="qwen-plus",
raw_text=json.dumps(payload, ensure_ascii=False),
validation="ok",
)
class _FakeResult:
def __init__(self, titles=None):
self._titles = titles or []
def scalars(self):
return self
def all(self):
return self._titles
def first(self):
return self._titles[0] if self._titles else None
class _FakeDB:
def __init__(self):
self.added = []
self.committed = False
async def execute(self, stmt):
_ = stmt
return _FakeResult([])
def add(self, value):
self.added.append(value)
async def commit(self):
self.committed = True
async def refresh(self, obj):
_ = obj
return None
async def _fake_persist_task(db, **kwargs):
_ = db
draft = kwargs["draft"]
captured["drafts"].append(draft)
return SimpleNamespace(
id=len(captured["drafts"]),
title=draft.title,
description=draft.description,
priority=draft.priority,
status=draft.status,
assignee_id=draft.assignee_id,
required_skill_tag_ids=list(draft.required_skill_tag_ids or []),
ai_breakdown_payload=draft.ai_breakdown_payload,
manual_overrides=draft.manual_overrides,
)
async def _fake_ensure_project(service, project_id):
_ = (service, project_id)
return SimpleNamespace(
id=6,
title="内部知识库升级",
name="内部知识库升级",
description="围绕内部知识库问答能力升级",
status="active",
assigned_role="backend",
)
async def _fake_ensure_charter(db, *, tenant_id: int, project_id: int, charter_id: int):
_ = (db, tenant_id, project_id, charter_id)
return SimpleNamespace(
id=55,
title="内部知识库升级宪章",
version=2,
content={"modules": {}},
status="aligning",
)
def _fake_assert_charter_aligned(charter):
_ = charter
return None
async def _fake_log_operation(*args, **kwargs):
_ = (args, kwargs)
return None
async def _fake_invalidate_system_context(*args, **kwargs):
_ = (args, kwargs)
return None
def _fake_sync_pmflow_rag_async(*args, **kwargs):
_ = (args, kwargs)
return None
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_ensure_project", _fake_ensure_project)
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_ensure_charter", _fake_ensure_charter)
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_assert_charter_aligned", _fake_assert_charter_aligned)
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_load_available_skill_names", _fake_load_skills)
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "llm_service", _FakeLLM())
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "persist_task", _fake_persist_task)
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "log_operation", _fake_log_operation)
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "_sync_pmflow_rag_async", _fake_sync_pmflow_rag_async)
monkeypatch.setattr(sstc_tasks_ai_breakdown_api, "context_cache_service", SimpleNamespace(invalidate_system_context=_fake_invalidate_system_context))
result = await sstc_tasks_ai_breakdown_api.ai_breakdown_tasks(
project_id="6",
charter_id="55",
db=_FakeDB(),
current_user=SimpleNamespace(tenant_id=1, id=1, role="ADMIN"),
permission_user=SimpleNamespace(),
)
assert "内部知识库升级宪章" in str(captured["prompt"])
assert len(captured["drafts"]) == 2
assert captured["drafts"][0].required_skill_tag_ids == [101]
assert captured["drafts"][0].ai_breakdown_payload["tasks"][0]["task_key"] == "task-1"
assert captured["drafts"][0].manual_overrides == {}
assert result["data"]["created_count"] == 2
assert result["data"]["items"][0]["title"] == "需求澄清与范围确认"
@pytest.mark.asyncio
async def test_recommend_assignee_route_uses_skill_tag_ids_semantically(monkeypatch) -> None:
captured: dict[str, object] = {}
class _FakeTask:
id = 77
title = "导出知识库"
description = "支持导出知识库内容"
project_id = 6
required_skill_tag_ids = [101, 202]
async def _fake_ensure_project(service, project_id):
_ = (service, project_id)
return None
async def _fake_ensure_task(db, *, tenant_id: int, project_id: int, task_id: int):
_ = (db, tenant_id, project_id, task_id)
return _FakeTask()
async def _fake_recommend_personnel(*, db, tenant_id: int, required_skills, task_description=None, task_id=None, limit=5):
_ = (db, tenant_id, task_description, task_id, limit)
captured["required_skills"] = list(required_skills)
return {
"task_id": task_id,
"task_description": task_description,
"required_skills": list(required_skills),
"recommendations": [
{
"user_id": "9",
"user_name": "Ada",
"match_score": 91,
"reason": "strong match",
"skill_match_count": 2,
"avg_proficiency": 88,
"workload_rate": 45,
"recommended_role": "backend",
}
],
"total_count": 1,
}
class _FakeResult:
def __init__(self, values):
self._values = values
def scalars(self):
return self
def all(self):
return self._values
async def _fake_ensure_project(service, project_id):
_ = (service, project_id)
return SimpleNamespace(id=6)
async def _fake_ensure_task(db, *, tenant_id: int, project_id: int, task_id: int):
_ = (db, tenant_id, project_id, task_id)
return _FakeTask()
class _FakeDB:
async def execute(self, stmt):
sql = str(stmt)
if "user_projects" in sql.lower():
return _FakeResult([9])
return _FakeResult([9])
monkeypatch.setattr(sstc_tasks_recommend_optimize_api, "_ensure_project", _fake_ensure_project)
monkeypatch.setattr(sstc_tasks_recommend_optimize_api, "_ensure_task", _fake_ensure_task)
monkeypatch.setattr(sstc_tasks_recommend_optimize_api, "recommend_personnel", _fake_recommend_personnel)
result = await sstc_tasks_recommend_optimize_api.recommend_assignee_for_task(
project_id="6",
task_id="77",
db=_FakeDB(),
current_user=SimpleNamespace(tenant_id=1, id=1),
permission_user=SimpleNamespace(),
)
assert captured["required_skills"] == [101, 202]
assert result["data"]["recommendations"][0]["user_id"] == "9"
assert result["data"]["total_count"] == 1