310 lines
11 KiB
Python
310 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.ai_project_control_service import AIProjectControlService
|
|
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
|
|
|
|
control = AIProjectControlService(db, tenant_id=tenant_id)
|
|
active_binding = await control.get_active_binding_summary(project_id=pid)
|
|
payload = control.inject_model_tasks(payload=payload, active_binding=active_binding)
|
|
|
|
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
|
|
],
|
|
}
|
|
)
|