Files
JARVIS/backend/app/services/skill_service.py

379 lines
14 KiB
Python

"""
Skill Service - 技能管理服务层
负责技能的创建、查询、更新、删除等操作
"""
import hashlib
from datetime import UTC, datetime, timedelta
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, or_
from app.agents.schemas.learning import SkillCandidate
from app.agents.skills.models import SkillLifecycleDecision
from app.models.skill import Skill
from app.models.user import User
class SkillService:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, user_id: str, data: dict) -> Skill:
"""创建新技能"""
skill = Skill(
owner_id=user_id,
name=data.get("name"),
description=data.get("description"),
instructions=data.get("instructions"),
agent_type=data.get("agent_type"),
tools=data.get("tools", []),
required_context=data.get("required_context", []),
output_format=data.get("output_format"),
visibility=data.get("visibility", "private"),
team_id=data.get("team_id"),
is_active=data.get("is_active", True),
status=data.get("status", "active"),
scope=data.get("scope", []),
effectiveness=data.get("effectiveness", 0.0),
review_after=data.get("review_after"),
)
self.db.add(skill)
await self.db.commit()
await self.db.refresh(skill)
return skill
async def get_by_id(self, skill_id: str) -> Optional[Skill]:
"""根据ID获取技能"""
result = await self.db.execute(
select(Skill).where(Skill.id == skill_id)
)
return result.scalar_one_or_none()
async def get_by_name_for_user(self, user_id: str, name: str) -> Optional[Skill]:
access_scope = or_(
Skill.owner_id == user_id,
Skill.visibility == "market",
Skill.team_id == user_id,
)
result = await self.db.execute(
select(Skill).where(and_(Skill.name == name, access_scope))
)
return result.scalar_one_or_none()
async def list_for_user(
self,
user_id: str,
agent_type: Optional[str] = None,
visibility: Optional[str] = None,
) -> list[Skill]:
"""
列出用户可访问的技能:自己的 + 市场的 + 团队的
"""
access_scope = or_(
Skill.owner_id == user_id,
Skill.visibility == "market",
Skill.team_id == user_id,
)
filters = [access_scope, Skill.is_active == True, Skill.status != "retired"]
if agent_type:
filters.append(Skill.agent_type == agent_type)
if visibility:
filters.append(Skill.visibility == visibility)
query = select(Skill).where(and_(*filters))
result = await self.db.execute(query)
return list(result.scalars().all())
async def update(self, skill_id: str, user_id: str, data: dict) -> Optional[Skill]:
"""更新技能(仅所有者可更新)"""
skill = await self.get_by_id(skill_id)
if not skill:
return None
# 检查是否是所有者
if skill.owner_id != user_id:
return None
# 更新字段
update_fields = [
"name", "description", "instructions", "agent_type",
"tools", "required_context", "output_format", "visibility",
"team_id", "is_active", "status", "scope", "effectiveness", "review_after"
]
for field in update_fields:
if field in data:
setattr(skill, field, data[field])
await self.db.commit()
await self.db.refresh(skill)
return skill
async def delete(self, skill_id: str, user_id: str) -> bool:
"""删除技能(仅所有者可删除)"""
skill = await self.get_by_id(skill_id)
if not skill:
return False
# 检查是否是所有者
if skill.owner_id != user_id:
return False
await self.db.delete(skill)
await self.db.commit()
return True
async def get_by_agent_type(self, agent_type: str) -> list[Skill]:
"""
获取指定 agent_type 的技能(用于 agent 运行时:市场 + 私有)
"""
result = await self.db.execute(
select(Skill).where(
and_(
Skill.agent_type == agent_type,
Skill.is_active == True,
Skill.status == "active",
or_(
Skill.visibility == "market",
Skill.visibility == "private"
)
)
)
)
return list(result.scalars().all())
async def list_runtime_candidates(
self,
user_id: str,
*,
agent_type: Optional[str] = None,
include_shadow: bool = True,
include_learned: bool = True,
) -> list[Skill]:
allowed_statuses = ["active", "shadow"] if include_shadow else ["active"]
access_scope = or_(
Skill.owner_id == user_id,
Skill.visibility == "market",
Skill.team_id == user_id,
)
filters = [
access_scope,
Skill.is_active == True,
Skill.status.in_(allowed_statuses),
]
if not include_learned:
filters.append(Skill.is_builtin == True)
if agent_type:
filters.append(Skill.agent_type == agent_type)
result = await self.db.execute(select(Skill).where(and_(*filters)))
return list(result.scalars().all())
async def upsert_learned_candidate(
self,
*,
user_id: str,
candidate: SkillCandidate,
primary_agent: str | None,
evidence_refs: list[dict] | None = None,
) -> SkillLifecycleDecision:
source_hash = self._build_candidate_source_hash(candidate)
skill = await self.get_by_name_for_user(user_id, candidate.name)
if skill is None:
review_after = datetime.now(UTC) + timedelta(days=7)
skill = Skill(
owner_id=user_id,
name=candidate.name,
description=candidate.summary,
instructions=candidate.summary,
agent_type=primary_agent or "master",
tools=[],
required_context=[],
output_format=None,
visibility="private",
is_active=True,
status="candidate",
scope=[primary_agent or "master", "learned", candidate.candidate_type],
effectiveness=candidate.confidence,
review_after=review_after,
candidate_count=1,
candidate_source_hashes=[source_hash],
)
self.db.add(skill)
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action="created_candidate",
previous_status=None,
new_status="candidate",
reason="First learned candidate created from retrospective evidence.",
evidence_refs=evidence_refs or [],
confidence=candidate.confidence,
review_after=review_after,
)
previous_status = skill.status
known_hashes = list(skill.candidate_source_hashes or [])
is_duplicate_candidate = source_hash in known_hashes
if not is_duplicate_candidate:
skill.candidate_count = int(skill.candidate_count or 0) + 1
known_hashes.append(source_hash)
skill.candidate_source_hashes = known_hashes
current_effectiveness = float(skill.effectiveness or 0.0)
skill.effectiveness = round(max(current_effectiveness, float(candidate.confidence or 0.0)), 3)
skill.review_after = datetime.now(UTC) + timedelta(days=7)
if primary_agent and primary_agent not in (skill.scope or []):
skill.scope = [*(skill.scope or []), primary_agent]
action = "no_change"
reason = "Candidate evidence refreshed."
if is_duplicate_candidate:
reason = "Duplicate candidate evidence ignored for promotion counting."
if (
not is_duplicate_candidate
and skill.status == "candidate"
and skill.candidate_count >= 2
and skill.effectiveness >= 0.6
):
skill.status = "shadow"
action = "promoted_to_shadow"
reason = "Repeated candidate evidence promoted the learned skill to shadow."
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
evidence_refs=evidence_refs or [],
confidence=skill.effectiveness,
review_after=skill.review_after,
)
async def record_activation_feedback(
self,
*,
user_id: str,
skill_name: str,
outcome_score: float,
evidence_refs: list[dict] | None = None,
) -> SkillLifecycleDecision | None:
skill = await self.get_by_name_for_user(user_id, skill_name)
if skill is None or skill.status not in {"shadow", "active", "deprecated"}:
return None
previous_status = skill.status
previous_activation_count = int(skill.activation_count or 0)
skill.activation_count = previous_activation_count + 1
skill.last_activated_at = datetime.now(UTC)
previous_effectiveness = float(skill.effectiveness or 0.0)
if previous_activation_count <= 0:
skill.effectiveness = round(outcome_score, 3)
else:
skill.effectiveness = round(
((previous_effectiveness * previous_activation_count) + outcome_score)
/ skill.activation_count,
3,
)
action = "feedback_recorded"
reason = "Activation outcome recorded."
if skill.status == "shadow" and skill.activation_count >= 2 and skill.effectiveness >= 0.7:
skill.status = "active"
action = "promoted_to_active"
reason = "Shadow skill proved effective enough to become active."
elif skill.status == "active" and skill.activation_count >= 3 and skill.effectiveness < 0.35:
skill.status = "deprecated"
action = "degraded_to_deprecated"
reason = "Active skill underperformed repeatedly and was deprecated."
elif skill.status == "deprecated" and skill.activation_count >= 4 and skill.effectiveness < 0.2:
skill.status = "retired"
action = "retired"
reason = "Deprecated skill stayed ineffective and was retired."
elif skill.status == "deprecated" and skill.effectiveness >= 0.65 and outcome_score >= 0.8:
skill.status = "active"
action = "reactivated"
reason = "Deprecated skill recovered with strong positive feedback."
skill.review_after = datetime.now(UTC) + timedelta(days=7)
await self.db.commit()
await self.db.refresh(skill)
return SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
evidence_refs=evidence_refs or [],
confidence=skill.effectiveness,
review_after=skill.review_after,
)
async def run_decay_review(
self,
*,
user_id: str,
as_of: datetime | None = None,
) -> list[SkillLifecycleDecision]:
review_time = as_of or datetime.now(UTC)
result = await self.db.execute(
select(Skill).where(
and_(
Skill.owner_id == user_id,
Skill.is_active == True,
Skill.status.in_(["shadow", "active", "deprecated"]),
Skill.review_after.is_not(None),
Skill.review_after <= review_time,
)
)
)
skills = list(result.scalars().all())
decisions: list[SkillLifecycleDecision] = []
for skill in skills:
previous_status = skill.status
action = "no_change"
reason = "Review completed without status change."
if skill.status == "shadow" and float(skill.effectiveness or 0.0) < 0.45:
skill.status = "deprecated"
action = "degraded_to_deprecated"
reason = "Shadow skill review found low effectiveness."
elif skill.status == "deprecated" and float(skill.effectiveness or 0.0) < 0.2:
skill.status = "retired"
action = "retired"
reason = "Deprecated skill remained weak through review."
skill.review_after = review_time + timedelta(days=7)
decisions.append(
SkillLifecycleDecision(
skill_name=skill.name,
action=action,
previous_status=previous_status,
new_status=skill.status,
reason=reason,
confidence=skill.effectiveness,
review_after=skill.review_after,
)
)
await self.db.commit()
return decisions
@staticmethod
def _build_candidate_source_hash(candidate: SkillCandidate) -> str:
raw = (
f"{candidate.name}|{candidate.summary}|"
f"{','.join(candidate.source_pattern_ids)}|"
f"{len(candidate.evidence_refs)}"
).encode("utf-8")
return hashlib.sha1(raw).hexdigest()