618 lines
19 KiB
Python
618 lines
19 KiB
Python
"""
|
||
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),
|
||
}
|