""" 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()