PMFlow/backend/app/api/sstc_api/tasks_ai_breakdown.py

168 lines
5.6 KiB
Python

from __future__ import annotations
import re
from .shared import *
from ...services.sstc.charter_data import (
_build_task_breakdown_context,
_build_task_breakdown_prompt,
_load_available_skill_names,
)
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.governance import _assert_charter_aligned, _require_manager
from ...services.sstc.signal_core import _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
_TITLE_NORMALIZE_RE = re.compile(r"[\s\-_]+")
def _task_title_key(value: str) -> str:
text = str(value or "").strip().lower()
return _TITLE_NORMALIZE_RE.sub("", text)
@router.post("/projects/{project_id}/charters/{charter_id}/tasks/ai-breakdown")
async def ai_breakdown_tasks(
project_id: str,
charter_id: str,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
_require_manager(current_user)
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
project = await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
_assert_charter_aligned(charter)
charter_context = _build_task_breakdown_context(
description=str(charter.title or project.title or ""),
project=project,
charter=charter,
)
available_skills = await _load_available_skill_names(db, tenant_id=tenant_id)
prompt = _build_task_breakdown_prompt(
project_id=pid,
charter_context=charter_context,
available_skills=available_skills,
)
try:
raw_text = await llm_service.generate(
prompt,
db=db,
tenant_id=tenant_id,
project_id=pid,
function_name="sstc_ai_breakdown",
)
rows = llm_service._parse_json_array(raw_text)
if not rows:
raise ValueError("AI 拆解返回空结果或无法解析为 JSON 数组")
except RuntimeError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"AI 拆解失败: {exc}") from exc
existing_task_title_result = await db.execute(
select(Task.title).where(
and_(
Task.truth_node_id == cid,
Task.project_id == pid,
Task.tenant_id == tenant_id,
)
)
)
existing_title_keys = {
_task_title_key(str(title))
for title in existing_task_title_result.scalars().all()
if str(title or "").strip()
}
seen_title_keys = set(existing_title_keys)
created = []
skipped_duplicates = 0
prompt_tokens = max(len(prompt) // 4, 1)
completion_tokens = max(len(raw_text) // 4, 0)
for item in rows or []:
title = compact_title((item.get("task_name") or item.get("title") or ""), max_len=32)
if not title:
continue
title_key = _task_title_key(title)
if not title_key:
continue
if title_key in seen_title_keys:
skipped_duplicates += 1
continue
seen_title_keys.add(title_key)
priority = str(item.get("priority") or "P1").lower()
mapped_priority = "high" if priority == "p0" else "medium" if priority == "p1" else "low"
task = await persist_task(
db,
tenant_id=tenant_id,
truth_node_id=cid,
project_id=pid,
draft=build_task_create_draft(
title=title,
description=item.get("description"),
required_skill_tag_ids=[],
priority=mapped_priority,
status="todo",
estimated_hours=float(item.get("estimated_hours") or 0) or None,
),
actor_user_id=int(current_user.id),
action="task.create",
remark="sstc.ai_breakdown",
commit=False,
refresh_workload=False,
)
created.append(task)
db.add(
AICallLog(
tenant_id=tenant_id,
user_id=int(current_user.id),
function_name="sstc_ai_breakdown",
prompt=prompt,
response=f"created_count={len(created)},skipped_duplicates={skipped_duplicates}",
tokens_used=prompt_tokens + completion_tokens,
cost=((prompt_tokens * 0.00015 + completion_tokens * 0.0006) / 1000),
status="success",
)
)
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="task",
entity_id=str(cid),
action="ai_breakdown",
after_data={"created_count": len(created), "skipped_duplicates": skipped_duplicates},
)
await db.commit()
for task in created:
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="task",
record_id=int(task.id),
project_id=pid,
title=task.title,
content=task.description or "",
)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
return success_response(
{
"created_count": len(created),
"skipped_duplicates": skipped_duplicates,
"items": [
{"id": str(t.id), "title": t.title, "priority": t.priority, "status": t.status}
for t in created
],
}
)