PMFlow/backend/app/services/skill_service.py

624 lines
19 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.

"""
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 sqlalchemy.orm import selectinload
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)
.options(selectinload(UserSkillBinding.skill_tag))
.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)
.options(
selectinload(UserSkillBinding.user),
selectinload(UserSkillBinding.skill_tag),
)
.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),
}