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 ], } )