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

305 lines
11 KiB
Python

from __future__ import annotations
import json
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
from ...services.context_cache_service import context_cache_service
from ...services.feedback_center import FeedbackCenterService, map_task_breakdown_to_record_payload
from ...services.task_breakdown import (
build_task_breakdown_ai_payload,
normalize_task_breakdown_payload,
resolve_task_breakdown_skill_tag_ids,
should_auto_assign_breakdown_task,
)
router = APIRouter(tags=["sstc"])
_TITLE_NORMALIZE_RE = re.compile(r"[\s\-_]+")
_AUTO_ASSIGN_CONFIDENCE_THRESHOLD = 0.85
_AUTO_ASSIGN_MATCH_SCORE_THRESHOLD = 75.0
_AUTO_ASSIGN_WORKLOAD_THRESHOLD = 70.0
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,
context=charter_context,
available_skills=available_skills,
)
try:
structured = await llm_service.generate_structured_object(
prompt,
db=db,
tenant_id=tenant_id,
project_id=pid,
function_name="sstc_ai_breakdown",
)
if not structured.payload:
raise ValueError("AI 拆解返回空结果")
payload = normalize_task_breakdown_payload(structured.payload)
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
if not payload.tasks:
raise HTTPException(status_code=500, detail="AI 拆解返回空结果")
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)
ai_breakdown_payload = build_task_breakdown_ai_payload(payload)
ai_breakdown_payload["source_context"] = charter_context
created = []
skipped_duplicates = 0
auto_assigned_count = 0
prompt_tokens = max(len(prompt) // 4, 1)
completion_tokens = max(len(structured.raw_text or json.dumps(payload.model_dump(mode="json"), ensure_ascii=False)) // 4, 0)
project_user_ids_result = await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == pid,
)
)
)
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()}
for item in payload.tasks:
title = compact_title((item.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)
resolved_skill_tag_ids, _skill_resolution = await resolve_task_breakdown_skill_tag_ids(
db,
tenant_id=tenant_id,
task=item,
)
required_skills = list(resolved_skill_tag_ids or item.required_skill_tag_ids or item.required_skill_names)
recommendations_payload = await recommend_personnel(
db=db,
tenant_id=tenant_id,
required_skills=required_skills,
task_description=item.description or item.title,
task_id=str(item.task_key),
limit=10,
)
filtered_recommendations = []
for rec in recommendations_payload.get("recommendations", []):
try:
uid = int(rec.get("user_id"))
except (TypeError, ValueError):
continue
if uid in project_user_ids and uid in active_user_ids:
filtered_recommendations.append(rec)
recommendations_payload["recommendations"] = filtered_recommendations
recommendations_payload["total_count"] = len(filtered_recommendations)
top_rec = filtered_recommendations[0] if filtered_recommendations else None
assignee_id = None
if top_rec is not None:
try:
top_match_score = float(top_rec.get("match_score") or 0)
except (TypeError, ValueError):
top_match_score = 0.0
try:
top_workload_rate = float(top_rec.get("workload_rate") or 0)
except (TypeError, ValueError):
top_workload_rate = 100.0
if should_auto_assign_breakdown_task(
task=item,
top_match_score=top_match_score,
top_workload_rate=top_workload_rate,
confidence_threshold=_AUTO_ASSIGN_CONFIDENCE_THRESHOLD,
match_score_threshold=_AUTO_ASSIGN_MATCH_SCORE_THRESHOLD,
workload_threshold=_AUTO_ASSIGN_WORKLOAD_THRESHOLD,
):
try:
assignee_id = int(top_rec.get("user_id"))
except (TypeError, ValueError):
assignee_id = None
task_ai_breakdown_payload = dict(ai_breakdown_payload)
task_ai_breakdown_payload["task"] = item.model_dump(mode="json")
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.description,
assignee_id=assignee_id,
required_skill_tag_ids=resolved_skill_tag_ids or item.required_skill_tag_ids,
ai_breakdown_payload=task_ai_breakdown_payload,
manual_overrides={},
priority=item.priority or "medium",
status="todo",
estimated_hours=float(item.estimated_hours or 0) or None,
ai_recommended_assignees=recommendations_payload,
),
actor_user_id=int(current_user.id),
action="task.create",
remark="sstc.ai_breakdown",
commit=False,
refresh_workload=False,
)
created.append(task)
if assignee_id is not None:
auto_assigned_count += 1
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},auto_assigned_count={auto_assigned_count}",
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,
"auto_assigned_count": auto_assigned_count,
},
)
await db.commit()
assigned_user_ids = {
int(task.assignee_id)
for task in created
if task.assignee_id is not None
}
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 "",
)
for user_id in assigned_user_ids:
await workload_service.refresh_user_assigned_hours_from_tasks(
db,
tenant_id=tenant_id,
user_id=user_id,
)
await context_cache_service.invalidate_system_context(tenant_id=tenant_id)
try:
payloads = [
map_task_breakdown_to_record_payload(
task=t,
project_title=project.title,
source_context=ai_breakdown_payload["source_context"],
)
for t in created
]
if payloads:
await FeedbackCenterService(db).ingest_payloads(payloads=payloads, commit=True)
except Exception:
if hasattr(db, "rollback"):
try:
await db.rollback()
except Exception:
pass
return success_response(
{
"created_count": len(created),
"skipped_duplicates": skipped_duplicates,
"auto_assigned_count": auto_assigned_count,
"items": [
{
"id": str(t.id),
"title": t.title,
"priority": t.priority,
"status": t.status,
"assignee_id": str(t.assignee_id) if t.assignee_id else None,
"required_skill_tag_ids": list(t.required_skill_tag_ids or []),
"ai_breakdown_payload": t.ai_breakdown_payload,
"manual_overrides": t.manual_overrides,
"auto_assigned": bool(t.assignee_id) and not bool((t.manual_overrides or {}).get("assignee_id")),
}
for t in created
],
}
)