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)}