"""任务完成后的技能分更新服务。""" from __future__ import annotations import json from datetime import datetime from typing import Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..config.ai_prompts import PROMPT_SKILL_SCORE_UPDATE from ..database import AsyncSessionLocal from ..models import AICallLog, Task, UserSkillBinding from .llm import llm_service from ..utils.timezone import now_cn_naive PRIORITY_WEIGHT = { "critical": 1.5, "high": 1.2, "medium": 1.0, "low": 0.8, } def _clamp(value: float, lower: float, upper: float) -> float: return max(lower, min(upper, value)) def _normalize_status(status: Optional[str]) -> str: return (status or "").strip().lower() def _parse_json(text: str) -> dict: body = (text or "").strip() if body.startswith("```json"): body = body[7:].strip() if body.startswith("```"): body = body[3:].strip() if body.endswith("```"): body = body[:-3].strip() return json.loads(body) def _heuristic_deltas( task: Task, required_skill_ids: list[int], ) -> list[dict]: quality_score = float(task.quality_score or 75.0) estimated = max(float(task.estimated_hours or 0.0), 0.0) actual = max(float(task.actual_hours or estimated or 1.0), 1.0) efficiency_ratio = estimated / actual if estimated > 0 else 1.0 quality_delta = _clamp((quality_score - 75.0) / 10.0, -3.0, 3.0) efficiency_delta = _clamp((efficiency_ratio - 1.0) * 4.0, -3.0, 3.0) return [ { "skill_tag_id": skill_id, "quality_delta": quality_delta, "efficiency_delta": efficiency_delta, } for skill_id in required_skill_ids ] async def _apply_skill_deltas( db: AsyncSession, *, tenant_id: int, user_id: int, priority: str, deltas: list[dict], ) -> int: binding_result = await db.execute( select(UserSkillBinding).where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.user_id == user_id, ) ) ) bindings = list(binding_result.scalars().all()) binding_map = {int(row.skill_tag_id): row for row in bindings} weight = PRIORITY_WEIGHT.get(priority, 1.0) touched = 0 for item in deltas: try: skill_tag_id = int(item.get("skill_tag_id")) except (TypeError, ValueError): continue row = binding_map.get(skill_tag_id) if not row: row = UserSkillBinding( tenant_id=tenant_id, user_id=user_id, skill_tag_id=skill_tag_id, proficiency_level="beginner", level="beginner", proficiency=0, quality_score=0.0, efficiency_score=0.0, ) db.add(row) binding_map[skill_tag_id] = row quality_delta = float(item.get("quality_delta", 0.0)) * weight efficiency_delta = float(item.get("efficiency_delta", 0.0)) * weight row.quality_score = round( _clamp(float(row.quality_score or 0.0) + quality_delta, 0.0, 100.0), 1, ) row.efficiency_score = round( _clamp(float(row.efficiency_score or 0.0) + efficiency_delta, 0.0, 100.0), 1, ) combined = (float(row.quality_score or 0.0) * 0.5) + ( float(row.efficiency_score or 0.0) * 0.5 ) row.proficiency = int(_clamp(round(combined), 0, 100)) row.last_used_at = now_cn_naive() row.updated_at = now_cn_naive() touched += 1 return touched async def process_task_completion( *, task_id: int, tenant_id: int, operator_user_id: Optional[int] = None, ) -> dict: """任务完成后更新 assignee 的技能质量/效率分。""" async with AsyncSessionLocal() as db: task = ( await db.execute( select(Task).where( and_( Task.tenant_id == tenant_id, Task.id == task_id, ) ) ) ).scalar_one_or_none() if not task: return {"updated_count": 0, "reason": "task_not_found"} if _normalize_status(task.status) not in {"done", "completed", "closed"}: return {"updated_count": 0, "reason": "task_not_done"} if not task.assignee_id: return {"updated_count": 0, "reason": "task_no_assignee"} required_skill_ids = [ int(v) for v in (task.required_skill_tag_ids or []) if v is not None ] if not required_skill_ids: return {"updated_count": 0, "reason": "task_no_required_skills"} prompt = PROMPT_SKILL_SCORE_UPDATE.format( task_id=int(task.id), priority=str(task.priority or "medium"), quality_score=float(task.quality_score or 75.0), estimated_hours=float(task.estimated_hours or 0.0), actual_hours=float(task.actual_hours or float(task.estimated_hours or 1.0) or 1.0), required_skill_tag_ids=required_skill_ids, ) response_text = "" status = "success" deltas: list[dict] try: response_text = await llm_service.generate( prompt, function_name="skill_score_update", tenant_id=tenant_id, ) payload = _parse_json(response_text) parsed = payload.get("deltas", []) if isinstance(payload, dict) else [] deltas = [] for row in parsed: if not isinstance(row, dict): continue try: deltas.append( { "skill_tag_id": int(row.get("skill_tag_id")), "quality_delta": float(row.get("quality_delta", 0.0)), "efficiency_delta": float(row.get("efficiency_delta", 0.0)), } ) except (TypeError, ValueError): continue if not deltas: deltas = _heuristic_deltas(task, required_skill_ids) status = "fallback" except Exception as exc: response_text = str(exc) deltas = _heuristic_deltas(task, required_skill_ids) status = "fallback" updated = await _apply_skill_deltas( db, tenant_id=tenant_id, user_id=int(task.assignee_id), priority=str(task.priority or "medium").lower(), deltas=deltas, ) db.add( AICallLog( tenant_id=tenant_id, user_id=operator_user_id or int(task.assignee_id), function_name="skill_score_update", prompt=prompt, response=(response_text or json.dumps({"deltas": deltas}, ensure_ascii=False))[:8000], tokens_used=max(len(prompt) // 4, 1) + max(len(response_text) // 4, 0), cost=((max(len(prompt) // 4, 1) * 0.00015 + max(len(response_text) // 4, 0) * 0.0006) / 1000), status=status, ) ) await db.commit() return {"updated_count": updated, "status": status}