Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
379 lines
14 KiB
Python
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()
|