222 lines
7.2 KiB
Python
222 lines
7.2 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
|
|
|
|
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 = datetime.utcnow()
|
|
row.updated_at = datetime.utcnow()
|
|
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}
|
|
|