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