ducklm/duck_core/skills/registry.py

69 lines
2.2 KiB
Python

from pathlib import Path
import yaml
from pydantic import BaseModel
class Skill(BaseModel):
id: str
title: str
description: str
version: int
tags: list[str] = []
required_tools: list[str] = []
risk_level: str = "low"
inputs: list[str] = []
outputs: list[str] = []
success_criteria: list[str] = []
procedure: str = ""
examples: str = ""
notes: str = ""
class SkillCandidate(BaseModel):
skill: Skill
score: float
reason: str
class SkillRegistry:
def __init__(self, skills_dir: str = "skills"):
self.skills_dir = Path(skills_dir)
self._cache: dict[str, Skill] | None = None
def load_skills(self) -> list[Skill]:
skills: dict[str, Skill] = {}
if not self.skills_dir.exists():
self._cache = {}
return []
for path in sorted(self.skills_dir.glob("*/skill.yaml")):
data = yaml.safe_load(path.read_text()) or {}
root = path.parent
data["procedure"] = self._read_optional(root / "procedure.md")
data["examples"] = self._read_optional(root / "examples.md")
data["notes"] = self._read_optional(root / "notes.md")
skill = Skill(**data)
skills[skill.id] = skill
self._cache = skills
return list(skills.values())
def get_skill(self, skill_id: str) -> Skill | None:
if self._cache is None:
self.load_skills()
return (self._cache or {}).get(skill_id)
async def find_candidate_skills(self, user_request: str, limit: int = 3) -> list[SkillCandidate]:
terms = set(user_request.lower().split())
candidates: list[SkillCandidate] = []
for skill in self.load_skills():
haystack = " ".join([skill.title, skill.description, " ".join(skill.tags)]).lower()
score = sum(1 for term in terms if term in haystack)
if score:
candidates.append(
SkillCandidate(skill=skill, score=float(score), reason="keyword match")
)
return sorted(candidates, key=lambda item: item.score, reverse=True)[:limit]
def _read_optional(self, path: Path) -> str:
return path.read_text() if path.exists() else ""