PMFlow/backend/app/api/tasks.py

153 lines
5.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""任务相关 API。"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..core.database import get_db
from ..dependencies import get_current_user, require_manager_user
from ..models import Task, User, UserProject
from ..schemas import TaskBreakdownRequest, TaskBreakdownResponse
from ..services.llm_service import llm_service
from ..services.integrations.integration_core.plane_service import plane_service
from ..services.skill import recommend_personnel
from ..services.sstc.charter_data import (
_build_task_breakdown_prompt,
_load_available_skill_names,
_load_task_breakdown_context,
)
from ..services.task_breakdown import normalize_task_breakdown_payload
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.post("/breakdown", response_model=TaskBreakdownResponse)
async def breakdown_tasks(
request: TaskBreakdownRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_manager_user),
):
"""AI 任务拆解"""
tenant_id = int(current_user.tenant_id)
context = await _load_task_breakdown_context(
db,
tenant_id=tenant_id,
description=request.description,
project_id=request.project_id,
charter_id=request.charter_id,
)
if request.project_id is not None and not context.get("project"):
raise HTTPException(status_code=404, detail="Project not found")
if request.charter_id is not None and not context.get("charter"):
raise HTTPException(status_code=404, detail="Charter not found")
available_skills = await _load_available_skill_names(db, tenant_id=tenant_id)
prompt = _build_task_breakdown_prompt(
project_id=request.project_id,
context=context,
available_skills=available_skills,
)
try:
structured = await llm_service.generate_structured_object(
prompt,
db=db,
tenant_id=tenant_id,
function_name="generate_task_breakdown",
)
if not structured.payload:
raise ValueError("AI 拆解返回空结果")
payload = normalize_task_breakdown_payload(structured.payload)
except RuntimeError:
raise HTTPException(status_code=403, detail="预算超限,AI 调用被阻止(hard limit)")
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to generate task breakdown: {exc}")
tasks = [task.model_dump(mode="json") for task in payload.tasks]
sync_result = None
if request.sync_to_plane:
sync_result = await plane_service.sync_tasks(tasks)
return {"tasks": tasks, "sync_result": sync_result}
@router.get("/recommend-assignees")
async def recommend_assignees(
task_id: int = Query(..., description="任务 ID"),
limit: int = Query(5, ge=1, le=20),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
推荐任务负责人(按技能匹配 + 熟练度 + 负载)。
仅在任务所属项目成员中选择,且排除禁用用户。
"""
tenant_id = int(current_user.tenant_id)
task = (
await db.execute(
select(Task).where(
and_(
Task.id == task_id,
Task.tenant_id == tenant_id,
)
)
)
).scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
required_skills = list(task.required_skill_tag_ids or [])
recommendations = await recommend_personnel(
db=db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=task.description or task.title,
task_id=str(task.id),
limit=limit * 2,
)
project_user_ids_result = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == int(task.project_id),
)
)
)
project_user_ids = {int(uid) for uid in project_user_ids_result.scalars().all()}
active_users_result = await db.execute(
select(User.id).where(
and_(
User.tenant_id == tenant_id,
User.status == "active",
User.is_active.is_(True),
)
)
)
active_user_ids = {int(uid) for uid in active_users_result.scalars().all()}
filtered = []
for item in recommendations.get("recommendations", []):
try:
uid = int(item.get("user_id"))
except (TypeError, ValueError):
continue
if uid in project_user_ids and uid in active_user_ids:
filtered.append(item)
filtered.sort(
key=lambda x: (
float(x.get("workload_rate", 9999)) >= 70.0,
float(x.get("workload_rate", 9999)),
-float(x.get("match_score", 0)),
)
)
final_items = filtered[:limit]
return {
"task_id": str(task.id),
"required_skills": required_skills,
"recommendations": final_items,
"total_count": len(final_items),
}