PMFlow/backend/app/services/skill_score_service.py

223 lines
7.3 KiB
Python

"""任务完成后的技能分更新服务。"""
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}