""" PMFlow 人员能力图谱 - 技能标签与用户技能绑定服务 - 系统预设 AI 开发技能标签初始化 - SkillTag CRUD(系统标签禁止删改) - UserSkillBinding 批量更新与查询 - UserWorkload 自动更新 """ import uuid as uuid_module from datetime import datetime, timedelta from typing import List, Optional from sqlalchemy import and_, func, select, text from sqlalchemy.ext.asyncio import AsyncSession from ..models import SkillTag, UserSkillBinding, UserWorkload, User, UserStatus, Task from ..database import _is_sqlite # 系统预设 AI 开发专属技能标签 SYSTEM_SKILL_TAGS = [ {"name": "Python", "slug": "python", "category": "language", "sort_order": 1}, {"name": "TypeScript", "slug": "typescript", "category": "language", "sort_order": 2}, {"name": "FastAPI", "slug": "fastapi", "category": "framework", "sort_order": 3}, {"name": "React", "slug": "react", "category": "framework", "sort_order": 4}, {"name": "SQLAlchemy", "slug": "sqlalchemy", "category": "framework", "sort_order": 5}, {"name": "LangChain", "slug": "langchain", "category": "framework", "sort_order": 6}, {"name": "LLM 调优", "slug": "llm-tuning", "category": "skill", "sort_order": 7}, {"name": "RAG 构建", "slug": "rag", "category": "skill", "sort_order": 8}, {"name": "向量数据库", "slug": "vector-db", "category": "tool", "sort_order": 9}, {"name": "Ollama", "slug": "ollama", "category": "tool", "sort_order": 10}, {"name": "Docker", "slug": "docker", "category": "tool", "sort_order": 11}, {"name": "Git", "slug": "git", "category": "tool", "sort_order": 12}, ] async def init_system_skill_tags(db: AsyncSession, tenant_id: int = 1) -> int: """ 初始化系统预设 AI 开发技能标签。 项目启动时自动执行,已存在的标签不会重复创建。 SQLite 下用 raw INSERT 显式指定 id,避免 NOT NULL constraint failed: skill_tags.id。 """ result = await db.execute( select(SkillTag).where( and_(SkillTag.tenant_id == tenant_id, SkillTag.is_system == True) ) ) existing = result.scalars().all() existing_slugs = {t.slug for t in existing} to_create = [item for item in SYSTEM_SKILL_TAGS if item["slug"] not in existing_slugs] if not to_create: return 0 if _is_sqlite(): # SQLite 下部分表 id 无自增或为 UUID,用 raw SQL 显式指定 id r = await db.execute(text("SELECT MAX(id) FROM skill_tags")) max_id = r.scalar() try: next_id = int(max_id or 0) + 1 use_int_id = True except (TypeError, ValueError): use_int_id = False for item in to_create: if use_int_id: tag_id = next_id next_id += 1 else: tag_id = str(uuid_module.uuid4()).replace("-", "")[:32] await db.execute( text(""" INSERT INTO skill_tags (id, name, slug, category, description, is_system, sort_order, tenant_id) VALUES (:id, :name, :slug, :category, :description, :is_system, :sort_order, :tenant_id) """), { "id": tag_id, "name": item["name"], "slug": item["slug"], "category": item.get("category") or "tool", "description": None, "is_system": 1, "sort_order": item.get("sort_order", 0), "tenant_id": tenant_id, }, ) await db.commit() return len(to_create) created = 0 for item in to_create: tag = SkillTag( tenant_id=tenant_id, name=item["name"], slug=item["slug"], category=item.get("category"), is_system=True, sort_order=item.get("sort_order", 0), ) db.add(tag) created += 1 await db.commit() return created # -------------------------------------------------------------------------- # SkillTag CRUD # -------------------------------------------------------------------------- async def create_skill_tag( db: AsyncSession, tenant_id: int, name: str, slug: str, category: Optional[str] = None, description: Optional[str] = None, sort_order: int = 0, ) -> SkillTag: """创建技能标签(仅自定义标签)""" existing = await db.execute( select(SkillTag).where( and_(SkillTag.tenant_id == tenant_id, SkillTag.slug == slug) ) ) if existing.scalar_one_or_none(): raise ValueError(f"技能标签 slug 已存在: {slug}") tag = SkillTag( tenant_id=tenant_id, name=name, slug=slug, category=category, description=description, is_system=False, sort_order=sort_order, ) db.add(tag) await db.commit() await db.refresh(tag) return tag async def list_skill_tags( db: AsyncSession, tenant_id: int, category: Optional[str] = None, include_system: bool = True, ) -> List[SkillTag]: """列出技能标签""" query = select(SkillTag).where(SkillTag.tenant_id == tenant_id) if category: query = query.where(SkillTag.category == category) if not include_system: query = query.where(SkillTag.is_system == False) query = query.order_by(SkillTag.sort_order, SkillTag.name) result = await db.execute(query) return list(result.scalars().all()) async def get_skill_tag( db: AsyncSession, tag_id: int, tenant_id: int ) -> Optional[SkillTag]: """获取单个技能标签""" result = await db.execute( select(SkillTag).where( and_(SkillTag.id == tag_id, SkillTag.tenant_id == tenant_id) ) ) return result.scalar_one_or_none() async def update_skill_tag( db: AsyncSession, tag_id: int, tenant_id: int, name: Optional[str] = None, category: Optional[str] = None, description: Optional[str] = None, sort_order: Optional[int] = None, ) -> Optional[SkillTag]: """更新技能标签(系统标签禁止修改)""" tag = await get_skill_tag(db, tag_id, tenant_id) if not tag: return None if tag.is_system: raise ValueError("系统预设技能标签不可修改") if name is not None: tag.name = name if category is not None: tag.category = category if description is not None: tag.description = description if sort_order is not None: tag.sort_order = sort_order tag.updated_at = datetime.utcnow() await db.commit() await db.refresh(tag) return tag async def delete_skill_tag( db: AsyncSession, tag_id: int, tenant_id: int ) -> bool: """删除技能标签(系统标签禁止删除)""" tag = await get_skill_tag(db, tag_id, tenant_id) if not tag: return False if tag.is_system: raise ValueError("系统预设技能标签不可删除") await db.delete(tag) await db.commit() return True # -------------------------------------------------------------------------- # UserSkillBinding # -------------------------------------------------------------------------- async def init_user_skill_bindings( db: AsyncSession, tenant_id: int, user_id: int, *, include_system_only: bool = True, commit: bool = False, ) -> int: """ 初始化用户技能绑定(空基线)。 为用户补齐未绑定的标签,默认 `beginner + proficiency=0`。 """ tags_query = select(SkillTag.id).where(SkillTag.tenant_id == tenant_id) if include_system_only: tags_query = tags_query.where(SkillTag.is_system == True) tag_rows = await db.execute(tags_query) tag_ids = [int(v) for v in tag_rows.scalars().all()] if not tag_ids: return 0 existing_rows = await db.execute( select(UserSkillBinding.skill_tag_id).where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.user_id == user_id, UserSkillBinding.skill_tag_id.in_(tag_ids), ) ) ) existing_ids = {int(v) for v in existing_rows.scalars().all()} created = 0 for skill_tag_id in tag_ids: if skill_tag_id in existing_ids: continue db.add( 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, ) ) created += 1 if created: await db.flush() if commit: await db.commit() return created async def batch_update_user_skill_bindings( db: AsyncSession, tenant_id: int, user_id: int, bindings: List[dict], ) -> List[UserSkillBinding]: """ 批量更新用户技能绑定。 若 skill_tag_id 已存在则更新,否则创建。 """ result_bindings = [] for item in bindings: skill_tag_id = item.get("skill_tag_id") if skill_tag_id is None: continue if isinstance(skill_tag_id, str): try: skill_tag_id = int(skill_tag_id) except ValueError: continue proficiency = item.get("proficiency", 50) level = item.get("level", "intermediate") years = item.get("years_experience") existing = await db.execute( select(UserSkillBinding).where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.user_id == user_id, UserSkillBinding.skill_tag_id == skill_tag_id, ) ) ) binding = existing.scalar_one_or_none() if binding: binding.proficiency = proficiency binding.level = level binding.years_experience = years binding.proficiency_level = level else: binding = UserSkillBinding( tenant_id=tenant_id, user_id=user_id, skill_tag_id=skill_tag_id, proficiency_level=level, proficiency=proficiency, level=level, years_experience=years, ) db.add(binding) result_bindings.append(binding) await db.commit() for b in result_bindings: await db.refresh(b) return result_bindings async def get_user_skill_bindings( db: AsyncSession, tenant_id: int, user_id: int, ) -> List[UserSkillBinding]: """按用户查询技能绑定""" result = await db.execute( select(UserSkillBinding) .where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.user_id == user_id, ) ) .order_by(UserSkillBinding.proficiency.desc()) ) return list(result.scalars().all()) async def get_skill_tag_user_bindings( db: AsyncSession, tenant_id: int, skill_tag_id: int, ) -> List[UserSkillBinding]: """按技能标签查询用户绑定""" result = await db.execute( select(UserSkillBinding) .where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.skill_tag_id == skill_tag_id, ) ) .order_by(UserSkillBinding.proficiency.desc()) ) return list(result.scalars().all()) async def get_user_skill_task_history( db: AsyncSession, tenant_id: int, user_id: int, skill_tag_id: int, limit: int = 50, ) -> List[dict]: """ 查询用户某技能下的历史任务。 口径: - assignee_id == user_id - required_skill_tag_ids 包含 skill_tag_id """ if _is_sqlite(): # SQLite 下 ARRAY 以 JSON 文本存储,先拉取后在 Python 侧做包含判断。 result = await db.execute( select(Task) .where( and_( Task.tenant_id == tenant_id, Task.assignee_id == user_id, ) ) .order_by(Task.updated_at.desc()) .limit(max(limit * 5, 100)) ) tasks = result.scalars().all() filtered = [ t for t in tasks if isinstance(t.required_skill_tag_ids, list) and skill_tag_id in t.required_skill_tag_ids ][:limit] else: result = await db.execute( select(Task) .where( and_( Task.tenant_id == tenant_id, Task.assignee_id == user_id, Task.required_skill_tag_ids.contains([skill_tag_id]), ) ) .order_by(Task.updated_at.desc()) .limit(limit) ) filtered = list(result.scalars().all()) return [ { "id": str(t.id), "title": t.title, "description": t.description, "status": t.status, "priority": t.priority, "estimated_hours": float(t.estimated_hours) if t.estimated_hours is not None else None, "project_id": str(t.project_id) if t.project_id is not None else None, "truth_node_id": str(t.truth_node_id) if t.truth_node_id is not None else None, "updated_at": t.updated_at.isoformat() if t.updated_at else None, } for t in filtered ] # -------------------------------------------------------------------------- # UserWorkload 自动更新 # -------------------------------------------------------------------------- async def update_user_workload( db: AsyncSession, tenant_id: int, user_id: int, total_tasks_delta: int = 0, completed_tasks_delta: int = 0, in_progress_delta: int = 0, estimated_hours_delta: float = 0, completed_hours_delta: float = 0, ) -> Optional[UserWorkload]: """ 更新用户工作量。 新 schema: current_assigned_hours, workload_ratio """ result = await db.execute( select(UserWorkload).where( and_( UserWorkload.tenant_id == tenant_id, UserWorkload.user_id == user_id, ) ) ) workload = result.scalar_one_or_none() if not workload: workload = UserWorkload( tenant_id=tenant_id, user_id=user_id, current_assigned_hours=0.0, max_available_hours_per_week=40.0, workload_ratio=0.0, ) db.add(workload) await db.flush() delta = estimated_hours_delta + completed_hours_delta workload.current_assigned_hours = max( 0, float(workload.current_assigned_hours or 0) + delta ) max_hours = float(workload.max_available_hours_per_week or 40) workload.workload_ratio = min( 100.0, (workload.current_assigned_hours / max_hours) * 100 ) await db.commit() await db.refresh(workload) user_result = await db.execute(select(User).where(User.id == user_id)) user = user_result.scalar_one_or_none() if user: user.workload = int(workload.workload_ratio) await db.commit() return workload # -------------------------------------------------------------------------- # 人员推荐 # -------------------------------------------------------------------------- async def recommend_personnel( db: AsyncSession, tenant_id: int, required_skills: List[str], task_description: Optional[str] = None, task_id: Optional[str] = None, limit: int = 5, ) -> dict: """ 根据所需技能推荐匹配人员。 返回匹配度评分、推荐理由等。 """ if not required_skills: return { "task_id": task_id, "task_description": task_description, "required_skills": [], "recommendations": [], "total_count": 0, } # 通过 slug 或 name 匹配技能标签 skill_tags_result = await db.execute( select(SkillTag).where( and_( SkillTag.tenant_id == tenant_id, (SkillTag.slug.in_(required_skills) | SkillTag.name.in_(required_skills)), ) ) ) tags = skill_tags_result.scalars().all() tag_ids = [t.id for t in tags] if not tag_ids: return { "task_id": task_id, "task_description": task_description, "required_skills": required_skills, "recommendations": [], "total_count": 0, } # 查询拥有这些技能的用户及熟练度 bindings_result = await db.execute( select(UserSkillBinding, SkillTag, User) .join(SkillTag, UserSkillBinding.skill_tag_id == SkillTag.id) .join(User, UserSkillBinding.user_id == User.id) .where( and_( UserSkillBinding.tenant_id == tenant_id, UserSkillBinding.skill_tag_id.in_(tag_ids), User.status == UserStatus.ACTIVE, ) ) ) rows = bindings_result.all() # 按用户聚合 user_scores: dict = {} for b, st, u in rows: uid = str(u.id) if uid not in user_scores: user_scores[uid] = { "user": u, "skills": [], "proficiencies": [], "match_count": 0, } user_scores[uid]["skills"].append(st.name) user_scores[uid]["proficiencies"].append(b.proficiency) user_scores[uid]["match_count"] = len(user_scores[uid]["skills"]) # 计算匹配度并排序 recommendations = [] for uid, data in user_scores.items(): u = data["user"] avg_prof = ( sum(data["proficiencies"]) / len(data["proficiencies"]) if data["proficiencies"] else 0 ) match_ratio = len(data["skills"]) / len(required_skills) if required_skills else 0 match_score = min(100, (match_ratio * 60 + avg_prof * 0.4)) reason = f"匹配技能: {', '.join(data['skills'])}" recommendations.append( { "user_id": uid, "user_name": u.name, "match_score": round(match_score, 1), "reason": reason, "skill_match_count": data["match_count"], "avg_proficiency": round(avg_prof, 1), "workload_rate": float(u.workload) if u.workload else 0, "recommended_role": u.role.value if hasattr(u.role, "value") else str(u.role), } ) recommendations.sort(key=lambda x: (-x["match_score"], -x["skill_match_count"])) recommendations = recommendations[:limit] return { "task_id": task_id, "task_description": task_description, "required_skills": required_skills, "recommendations": recommendations, "total_count": len(recommendations), }