551 lines
21 KiB
Python
551 lines
21 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_context_prefers_derived_task_seeds() -> None:
|
|
project = SimpleNamespace(
|
|
id=6,
|
|
title="内部知识库升级",
|
|
name="内部知识库升级",
|
|
description="围绕内部知识库问答能力升级",
|
|
status="active",
|
|
assigned_role="backend",
|
|
)
|
|
charter = SimpleNamespace(
|
|
id=55,
|
|
title="内部知识库升级宪章",
|
|
version=2,
|
|
status="aligned",
|
|
content={
|
|
"modules": {
|
|
"module_1": {
|
|
"problem_statement": "减少重复检索",
|
|
"goal_statement": "提升命中率 / 降低重复提问",
|
|
"kpis": "首响 < 3s",
|
|
},
|
|
"module_4": {
|
|
"milestones": "需求冻结\n开发完成",
|
|
},
|
|
},
|
|
"derived_artifacts": {
|
|
"task_seeds": [
|
|
{
|
|
"title": "先补检索评估基线",
|
|
"description": "补齐离线评测与验收口径",
|
|
"evidence_refs": ["module_1.kpis"],
|
|
},
|
|
{
|
|
"title": "再实现导出链路",
|
|
"description": "基于基线结果实现最小闭环",
|
|
"evidence_refs": ["module_4.milestones"],
|
|
},
|
|
]
|
|
},
|
|
},
|
|
)
|
|
|
|
context = _build_task_breakdown_context(
|
|
description="支持导出知识库内容",
|
|
project=project,
|
|
charter=charter,
|
|
)
|
|
|
|
assert context["charter"]["task_seeds"][0]["title"] == "先补检索评估基线"
|
|
assert context["charter"]["task_seeds"][1]["evidence_refs"] == ["module_4.milestones"]
|
|
|
|
|
|
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="REVIEWER"),
|
|
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
|