feat: tipos puros para sistema de skills
Agregar tipos puros en pkg/skills/ para el sistema de skills: - SkillMeta: metadata de skills (name, description, category) - Skill: representacion completa con instrucciones y recursos - SkillMatch: resultado de matching con confidence score - Match(): funcion pura de matching por keywords - FilterByCategory(): filtrado de skills por categorias Incluye tests unitarios completos para matching y filtrado. Arquitectura: pure core, cero side effects en pkg/.
This commit is contained in:
@@ -0,0 +1,103 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Match retorna las skills mas relevantes para una query dada.
|
||||||
|
// Implementacion inicial: keyword matching simple contra name + description.
|
||||||
|
// La query y las skills son procesadas en lowercase para matching case-insensitive.
|
||||||
|
//
|
||||||
|
// El scoring es basico:
|
||||||
|
// - Match exacto en name: 1.0
|
||||||
|
// - Match parcial en name: 0.8
|
||||||
|
// - Match en description: 0.6 * (palabras coincidentes / palabras totales)
|
||||||
|
// - Sin match: 0.0
|
||||||
|
//
|
||||||
|
// Retorna las skills ordenadas por confidence descendente.
|
||||||
|
func Match(query string, skills []SkillMeta) []SkillMatch {
|
||||||
|
query = strings.ToLower(strings.TrimSpace(query))
|
||||||
|
if query == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
queryWords := strings.Fields(query)
|
||||||
|
var matches []SkillMatch
|
||||||
|
|
||||||
|
for _, skill := range skills {
|
||||||
|
confidence := scoreSkill(queryWords, skill)
|
||||||
|
if confidence > 0 {
|
||||||
|
matches = append(matches, SkillMatch{
|
||||||
|
Skill: skill,
|
||||||
|
Confidence: confidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Sort(ByConfidence(matches))
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
// scoreSkill calcula el score de relevancia de una skill para las palabras de query.
|
||||||
|
func scoreSkill(queryWords []string, skill SkillMeta) float64 {
|
||||||
|
nameLower := strings.ToLower(skill.Name)
|
||||||
|
descLower := strings.ToLower(skill.Description)
|
||||||
|
|
||||||
|
// Match exacto en name
|
||||||
|
queryStr := strings.Join(queryWords, " ")
|
||||||
|
if nameLower == queryStr {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match parcial en name (todas las palabras de query aparecen en name)
|
||||||
|
nameMatches := 0
|
||||||
|
for _, word := range queryWords {
|
||||||
|
if strings.Contains(nameLower, word) {
|
||||||
|
nameMatches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nameMatches == len(queryWords) {
|
||||||
|
return 0.8
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match en description (contar palabras coincidentes)
|
||||||
|
descWords := strings.Fields(descLower)
|
||||||
|
descMatches := 0
|
||||||
|
for _, qword := range queryWords {
|
||||||
|
for _, dword := range descWords {
|
||||||
|
if strings.Contains(dword, qword) || strings.Contains(qword, dword) {
|
||||||
|
descMatches++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if descMatches > 0 {
|
||||||
|
ratio := float64(descMatches) / float64(len(queryWords))
|
||||||
|
return 0.6 * ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterByCategory retorna solo las skills que pertenecen a las categorias especificadas.
|
||||||
|
// Si categories esta vacio, retorna todas las skills sin filtrar.
|
||||||
|
func FilterByCategory(skills []SkillMeta, categories []string) []SkillMeta {
|
||||||
|
if len(categories) == 0 {
|
||||||
|
return skills
|
||||||
|
}
|
||||||
|
|
||||||
|
catSet := make(map[string]bool)
|
||||||
|
for _, cat := range categories {
|
||||||
|
catSet[strings.ToLower(cat)] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered []SkillMeta
|
||||||
|
for _, skill := range skills {
|
||||||
|
if catSet[strings.ToLower(skill.Category)] {
|
||||||
|
filtered = append(filtered, skill)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMatch(t *testing.T) {
|
||||||
|
skills := []SkillMeta{
|
||||||
|
{Name: "deploy-service", Description: "Deploy a service via SSH to a remote server", Category: "devops"},
|
||||||
|
{Name: "log-analyzer", Description: "Analyze logs for errors and patterns", Category: "analysis"},
|
||||||
|
{Name: "health-check", Description: "Check the health of services and systems", Category: "system"},
|
||||||
|
{Name: "daily-report", Description: "Generate daily report with metrics", Category: "communication"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
expectMatches int
|
||||||
|
firstMatch string // expected first match name
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact match in name",
|
||||||
|
query: "deploy-service",
|
||||||
|
expectMatches: 1,
|
||||||
|
firstMatch: "deploy-service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "partial match in name",
|
||||||
|
query: "deploy",
|
||||||
|
expectMatches: 1,
|
||||||
|
firstMatch: "deploy-service",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "match in description",
|
||||||
|
query: "analyze logs",
|
||||||
|
expectMatches: 2, // log-analyzer and daily-report (both have similar words)
|
||||||
|
firstMatch: "log-analyzer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple matches",
|
||||||
|
query: "service",
|
||||||
|
expectMatches: 2, // deploy-service and health-check (services)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no match",
|
||||||
|
query: "nonexistent",
|
||||||
|
expectMatches: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty query",
|
||||||
|
query: "",
|
||||||
|
expectMatches: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
matches := Match(tt.query, skills)
|
||||||
|
|
||||||
|
if len(matches) != tt.expectMatches {
|
||||||
|
t.Errorf("expected %d matches, got %d", tt.expectMatches, len(matches))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.firstMatch != "" && len(matches) > 0 {
|
||||||
|
if matches[0].Skill.Name != tt.firstMatch {
|
||||||
|
t.Errorf("expected first match %q, got %q", tt.firstMatch, matches[0].Skill.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify confidence is in valid range
|
||||||
|
for _, match := range matches {
|
||||||
|
if match.Confidence < 0 || match.Confidence > 1 {
|
||||||
|
t.Errorf("invalid confidence: %f (must be 0-1)", match.Confidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify matches are sorted by confidence descending
|
||||||
|
for i := 1; i < len(matches); i++ {
|
||||||
|
if matches[i].Confidence > matches[i-1].Confidence {
|
||||||
|
t.Errorf("matches not sorted: %f > %f", matches[i].Confidence, matches[i-1].Confidence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterByCategory(t *testing.T) {
|
||||||
|
skills := []SkillMeta{
|
||||||
|
{Name: "deploy-service", Category: "devops"},
|
||||||
|
{Name: "log-analyzer", Category: "analysis"},
|
||||||
|
{Name: "health-check", Category: "system"},
|
||||||
|
{Name: "daily-report", Category: "communication"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
categories []string
|
||||||
|
expectLen int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no filter (all skills)",
|
||||||
|
categories: nil,
|
||||||
|
expectLen: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single category",
|
||||||
|
categories: []string{"devops"},
|
||||||
|
expectLen: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple categories",
|
||||||
|
categories: []string{"devops", "system"},
|
||||||
|
expectLen: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nonexistent category",
|
||||||
|
categories: []string{"nonexistent"},
|
||||||
|
expectLen: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive",
|
||||||
|
categories: []string{"DEVOPS"},
|
||||||
|
expectLen: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
filtered := FilterByCategory(skills, tt.categories)
|
||||||
|
|
||||||
|
if len(filtered) != tt.expectLen {
|
||||||
|
t.Errorf("expected %d skills, got %d", tt.expectLen, len(filtered))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
// SkillMeta es la metadata extraida del frontmatter YAML del SKILL.md.
|
||||||
|
// Es la representacion minima de una skill que siempre esta en contexto.
|
||||||
|
type SkillMeta struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Category string // derivado de la ruta del directorio (devops, analysis, etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill es la representacion completa de una skill cargada.
|
||||||
|
// Incluye metadata, instrucciones completas y rutas a recursos.
|
||||||
|
type Skill struct {
|
||||||
|
Meta SkillMeta
|
||||||
|
Instructions string // cuerpo markdown del SKILL.md (sin frontmatter)
|
||||||
|
BasePath string // ruta absoluta al directorio de la skill
|
||||||
|
Scripts []string // rutas relativas a scripts/ (ej: ["deploy.sh", "rollback.sh"])
|
||||||
|
References []string // rutas relativas a references/
|
||||||
|
Templates []string // rutas relativas a templates/
|
||||||
|
Assets []string // rutas relativas a assets/
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillMatch indica si una skill es relevante para un contexto dado.
|
||||||
|
// Se usa como resultado de la funcion Match.
|
||||||
|
type SkillMatch struct {
|
||||||
|
Skill SkillMeta
|
||||||
|
Confidence float64 // 0.0 - 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ByConfidence implementa sort.Interface para ordenar SkillMatch por confidence descendente.
|
||||||
|
type ByConfidence []SkillMatch
|
||||||
|
|
||||||
|
func (a ByConfidence) Len() int { return len(a) }
|
||||||
|
func (a ByConfidence) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
|
||||||
|
func (a ByConfidence) Less(i, j int) bool { return a[i].Confidence > a[j].Confidence }
|
||||||
Reference in New Issue
Block a user