From f4690a2ba630142c83c98a4e0609644dcc80bcfe Mon Sep 17 00:00:00 2001 From: Enmanuel Date: Sun, 8 Mar 2026 22:13:05 +0000 Subject: [PATCH] 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/. --- pkg/skills/match.go | 103 +++++++++++++++++++++++++++++ pkg/skills/match_test.go | 136 +++++++++++++++++++++++++++++++++++++++ pkg/skills/types.go | 35 ++++++++++ 3 files changed, 274 insertions(+) create mode 100644 pkg/skills/match.go create mode 100644 pkg/skills/match_test.go create mode 100644 pkg/skills/types.go diff --git a/pkg/skills/match.go b/pkg/skills/match.go new file mode 100644 index 0000000..0edbf38 --- /dev/null +++ b/pkg/skills/match.go @@ -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 +} diff --git a/pkg/skills/match_test.go b/pkg/skills/match_test.go new file mode 100644 index 0000000..f902cc2 --- /dev/null +++ b/pkg/skills/match_test.go @@ -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)) + } + }) + } +} diff --git a/pkg/skills/types.go b/pkg/skills/types.go new file mode 100644 index 0000000..c1a264f --- /dev/null +++ b/pkg/skills/types.go @@ -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 }