293 lines
9.9 KiB
Python
293 lines
9.9 KiB
Python
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 "")[:8000],
|
||
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)}
|
||
|