PMFlow/backend/app/services/workload/ai_optimize.py

292 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
from .shared import *
def _skill_match_score(task_skill_ids: list[int], user_skill_ids: set[int]) -> float:
if not task_skill_ids:
return 0.0
matched = sum(1 for sid in task_skill_ids if sid in user_skill_ids)
return matched / len(task_skill_ids)
def _safe_int_list(values: object) -> list[int]:
"""兼容历史脏数据:数组中可能混入技能名称等非数字值。"""
source = values
if source is None:
return []
if isinstance(source, str):
text = source.strip()
if not text:
return []
try:
source = json.loads(text)
except (TypeError, ValueError, json.JSONDecodeError):
return []
if not isinstance(source, list):
return []
parsed: list[int] = []
for item in source:
if item is None or isinstance(item, bool):
continue
if isinstance(item, int):
parsed.append(item)
continue
if isinstance(item, float):
if item.is_integer():
parsed.append(int(item))
continue
if isinstance(item, str):
text = item.strip()
if text and text.lstrip("-").isdigit():
parsed.append(int(text))
return parsed
def _parse_json_response(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)
async def build_ai_optimize_preview(
db: AsyncSession,
tenant_id: int,
*,
project_id: Optional[int] = None,
max_moves: int = 20,
unassigned_only: bool = False,
) -> list[dict]:
"""生成任务重分配建议(预览,不落库)。"""
task_query = select(Task).where(
and_(
Task.tenant_id == tenant_id,
func.lower(Task.status).notin_(["done", "completed", "closed"]),
)
)
if unassigned_only:
task_query = task_query.where(Task.assignee_id.is_(None))
if project_id is not None:
task_query = task_query.where(Task.project_id == project_id)
task_rows = (await db.execute(task_query.order_by(Task.updated_at.desc()))).scalars().all()
if not task_rows:
return []
active_users_query = select(User).where(
and_(
User.tenant_id == tenant_id,
User.status == "active",
User.is_active.is_(True),
)
)
users = (await db.execute(active_users_query)).scalars().all()
if not users:
return []
workload_rows = (
await db.execute(
select(UserWorkload).where(
and_(
UserWorkload.tenant_id == tenant_id,
UserWorkload.user_id.in_([int(u.id) for u in users]),
)
)
)
).scalars().all()
workload_map = {int(w.user_id): float(w.workload_ratio or 0.0) for w in workload_rows}
skill_rows = (
await db.execute(
select(UserSkillBinding.user_id, UserSkillBinding.skill_tag_id).where(
UserSkillBinding.tenant_id == tenant_id
)
)
).all()
user_skill_map: dict[int, set[int]] = {}
for uid, sid in skill_rows:
user_skill_map.setdefault(int(uid), set()).add(int(sid))
users_by_project: dict[int, list[User]] = {}
for task in task_rows:
if int(task.project_id) in users_by_project:
continue
links = (
await db.execute(
select(UserProject.user_id).where(
and_(
UserProject.tenant_id == tenant_id,
UserProject.project_id == int(task.project_id),
)
)
)
).scalars().all()
allowed_ids = {int(uid) for uid in links}
users_by_project[int(task.project_id)] = [u for u in users if int(u.id) in allowed_ids]
llm_suggestion_map: dict[int, dict] = {}
members_payload = []
for user in users:
uid = int(user.id)
members_payload.append(
{
"user_id": uid,
"user_name": user.name,
"workload_ratio": round(workload_map.get(uid, float(user.workload or 0)), 2),
"skill_tag_ids": sorted(list(user_skill_map.get(uid, set()))),
}
)
tasks_payload = [
{
"task_id": int(task.id),
"project_id": int(task.project_id),
"title": task.title,
"assignee_id": int(task.assignee_id) if task.assignee_id else None,
"required_skill_tag_ids": _safe_int_list(task.required_skill_tag_ids),
"estimated_hours": float(task.estimated_hours or 0),
"status": task.status,
}
for task in task_rows
]
llm_prompt = PROMPT_WORKLOAD_OPTIMIZATION.format(
members_json=json.dumps(members_payload, ensure_ascii=False),
tasks_json=json.dumps(tasks_payload, ensure_ascii=False),
)
llm_text = ""
llm_status = "success"
try:
llm_text = await llm_service.generate(
llm_prompt,
function_name="workload_optimize_prompt",
tenant_id=tenant_id,
)
parsed = _parse_json_response(llm_text)
for item in parsed.get("proposals", []) if isinstance(parsed, dict) else []:
try:
tid = int(item.get("task_id"))
llm_suggestion_map[tid] = {
"new_assignee_id": int(item.get("new_assignee_id")),
"reason": str(item.get("reason") or "llm_suggested"),
}
except (TypeError, ValueError):
continue
except Exception as exc:
llm_status = "failed"
llm_text = str(exc)
db.add(
AICallLog(
tenant_id=tenant_id,
user_id=None,
function_name="workload_optimize_prompt",
prompt=llm_prompt,
response=(llm_text or "")[:10000],
tokens_used=max(len(llm_prompt) // 4, 1) + max(len(llm_text) // 4, 0),
cost=((max(len(llm_prompt) // 4, 1) * 0.00015 + max(len(llm_text) // 4, 0) * 0.0006) / 1000),
status=llm_status,
)
)
await db.commit()
proposals: list[dict] = []
for task in task_rows:
current_assignee = int(task.assignee_id or 0)
is_unassigned = current_assignee <= 0
candidates = users_by_project.get(int(task.project_id), [])
if not candidates:
continue
required_skill_ids = _safe_int_list(task.required_skill_tag_ids)
ranked = []
llm_target_id = llm_suggestion_map.get(int(task.id), {}).get("new_assignee_id")
llm_reason = llm_suggestion_map.get(int(task.id), {}).get("reason")
for candidate in candidates:
uid = int(candidate.id)
if not is_unassigned and uid == current_assignee:
continue
ratio = workload_map.get(uid, float(candidate.workload or 0))
skill_score = _skill_match_score(required_skill_ids, user_skill_map.get(uid, set()))
# workload 越低越优,skill_score 越高越优
total_score = (skill_score * 0.7) + ((max(0.0, 120.0 - ratio) / 120.0) * 0.3)
if llm_target_id and uid == int(llm_target_id):
total_score += 0.2
ranked.append((total_score, ratio, candidate))
ranked.sort(key=lambda x: (-x[0], x[1]))
if not ranked:
continue
best_score, _best_ratio, best_user = ranked[0]
current_ratio = workload_map.get(current_assignee, 0.0) if not is_unassigned else 0.0
if is_unassigned:
if best_score < 0.2:
continue
reason = f"initial_assignment_score={best_score:.2f}"
else:
if current_ratio <= 100 and best_score < 0.5:
continue
reason = f"skill_match+workload_score={best_score:.2f}"
if llm_target_id and int(best_user.id) == int(llm_target_id):
reason = f"{reason};llm={llm_reason or 'suggested'}"
proposals.append(
{
"task_id": int(task.id),
"task_title": task.title,
"project_id": int(task.project_id),
"old_assignee_id": current_assignee if not is_unassigned else None,
"new_assignee_id": int(best_user.id),
"old_ratio": round(current_ratio, 2) if not is_unassigned else None,
"new_ratio": round(workload_map.get(int(best_user.id), 0.0), 2),
"reason": reason,
}
)
if len(proposals) >= max_moves:
break
return proposals
async def apply_ai_optimize_plan(
db: AsyncSession,
tenant_id: int,
proposals: list[dict],
) -> dict:
"""应用优化方案并重算涉及成员负载。"""
touched_user_ids: set[int] = set()
applied = 0
for item in proposals:
task_id = int(item.get("task_id", 0))
new_assignee_id = int(item.get("new_assignee_id", 0))
if task_id <= 0 or new_assignee_id <= 0:
continue
task = (
await db.execute(
select(Task).where(
and_(
Task.tenant_id == tenant_id,
Task.id == task_id,
)
)
)
).scalar_one_or_none()
if not task:
continue
if task.assignee_id:
touched_user_ids.add(int(task.assignee_id))
task.assignee_id = new_assignee_id
touched_user_ids.add(new_assignee_id)
applied += 1
if touched_user_ids:
for uid in touched_user_ids:
await refresh_user_assigned_hours_from_tasks(db, tenant_id, uid)
else:
await db.commit()
return {"applied_count": applied, "affected_users": len(touched_user_ids)}