mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
cbb684be01
When os.Getwd fails, wd is empty and builtinSkillsDir resolves to relative path, causing confusing downstream errors. Fall back to config.GetHome on error.
221 lines
5.2 KiB
Go
221 lines
5.2 KiB
Go
package evolution
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/skills"
|
|
)
|
|
|
|
type SkillsRecaller struct {
|
|
workspace string
|
|
loader *skills.SkillsLoader
|
|
}
|
|
|
|
func NewSkillsRecaller(workspace string) *SkillsRecaller {
|
|
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
|
|
if builtinSkillsDir == "" {
|
|
wd, err := os.Getwd()
|
|
if err != nil {
|
|
wd = config.GetHome()
|
|
}
|
|
builtinSkillsDir = filepath.Join(wd, "skills")
|
|
}
|
|
|
|
globalSkillsDir := filepath.Join(config.GetHome(), "skills")
|
|
return &SkillsRecaller{
|
|
workspace: workspace,
|
|
loader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
|
|
}
|
|
}
|
|
|
|
func (r *SkillsRecaller) RecallSimilarSkills(rule LearningRecord) ([]skills.SkillInfo, error) {
|
|
if r == nil || r.loader == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
all := r.loader.ListSkills()
|
|
if names := explicitRecallSkillNames(rule); len(names) > 0 {
|
|
return filterSkillsByExplicitNames(all, names), nil
|
|
}
|
|
|
|
type scored struct {
|
|
info skills.SkillInfo
|
|
score int
|
|
sourceRank int
|
|
}
|
|
|
|
scoredList := make([]scored, 0, len(all))
|
|
for _, skill := range all {
|
|
score := scoreSkillMatch(rule, skill)
|
|
if score <= 0 {
|
|
continue
|
|
}
|
|
|
|
if body, ok := r.loader.LoadSkill(skill.Name); ok {
|
|
score += scoreSkillBody(rule, body)
|
|
}
|
|
|
|
scoredList = append(scoredList, scored{
|
|
info: skill,
|
|
score: score,
|
|
sourceRank: skillSourceRank(skill.Source),
|
|
})
|
|
}
|
|
|
|
sort.Slice(scoredList, func(i, j int) bool {
|
|
if scoredList[i].score != scoredList[j].score {
|
|
return scoredList[i].score > scoredList[j].score
|
|
}
|
|
if scoredList[i].sourceRank != scoredList[j].sourceRank {
|
|
return scoredList[i].sourceRank < scoredList[j].sourceRank
|
|
}
|
|
return scoredList[i].info.Name < scoredList[j].info.Name
|
|
})
|
|
|
|
out := make([]skills.SkillInfo, 0, len(scoredList))
|
|
for _, item := range scoredList {
|
|
out = append(out, item.info)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func explicitRecallSkillNames(rule LearningRecord) []string {
|
|
names := make([]string, 0, len(rule.WinningPath)+len(rule.MatchedSkillNames)+len(rule.LateAddedSkills))
|
|
names = append(names, normalizePath(rule.WinningPath)...)
|
|
names = append(names, normalizePath(rule.MatchedSkillNames)...)
|
|
names = append(names, normalizePath(rule.LateAddedSkills)...)
|
|
return uniqueTrimmedNames(names)
|
|
}
|
|
|
|
func filterSkillsByExplicitNames(all []skills.SkillInfo, names []string) []skills.SkillInfo {
|
|
if len(all) == 0 || len(names) == 0 {
|
|
return nil
|
|
}
|
|
|
|
byName := make(map[string]skills.SkillInfo, len(all))
|
|
for _, skill := range all {
|
|
name := strings.ToLower(strings.TrimSpace(skill.Name))
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if _, exists := byName[name]; exists {
|
|
continue
|
|
}
|
|
byName[name] = skill
|
|
}
|
|
|
|
out := make([]skills.SkillInfo, 0, len(names))
|
|
for _, name := range names {
|
|
if skill, ok := byName[strings.ToLower(strings.TrimSpace(name))]; ok {
|
|
out = append(out, skill)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scoreSkillMatch(rule LearningRecord, skill skills.SkillInfo) int {
|
|
score := 0
|
|
skillName := strings.ToLower(strings.TrimSpace(skill.Name))
|
|
ruleSummary := strings.ToLower(rule.Summary)
|
|
|
|
if skillName != "" {
|
|
if containsNormalized(rule.WinningPath, skillName) {
|
|
score += 8
|
|
}
|
|
if containsNormalized(rule.MatchedSkillNames, skillName) {
|
|
score += 6
|
|
}
|
|
if strings.Contains(ruleSummary, skillName) {
|
|
score += 4
|
|
}
|
|
}
|
|
|
|
score += 2 * tokenOverlap(ruleTokens(rule), tokenizeForEvolution(skill.Name+" "+skill.Description))
|
|
return score
|
|
}
|
|
|
|
func scoreSkillBody(rule LearningRecord, body string) int {
|
|
return minInt(tokenOverlap(ruleTokens(rule), tokenizeForEvolution(body)), 3)
|
|
}
|
|
|
|
func skillSourceRank(source string) int {
|
|
switch source {
|
|
case "workspace":
|
|
return 0
|
|
case "global":
|
|
return 1
|
|
case "builtin":
|
|
return 2
|
|
default:
|
|
return 3
|
|
}
|
|
}
|
|
|
|
func ruleTokens(rule LearningRecord) []string {
|
|
parts := make([]string, 0, len(rule.WinningPath)+len(rule.MatchedSkillNames)+4)
|
|
parts = append(parts, normalizePath(rule.WinningPath)...)
|
|
parts = append(parts, normalizePath(rule.MatchedSkillNames)...)
|
|
parts = append(parts, tokenizeForEvolution(rule.Summary)...)
|
|
return parts
|
|
}
|
|
|
|
func containsNormalized(values []string, target string) bool {
|
|
target = strings.ToLower(strings.TrimSpace(target))
|
|
for _, value := range values {
|
|
if strings.ToLower(strings.TrimSpace(value)) == target {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func tokenOverlap(left, right []string) int {
|
|
if len(left) == 0 || len(right) == 0 {
|
|
return 0
|
|
}
|
|
|
|
leftSet := make(map[string]struct{}, len(left))
|
|
for _, token := range left {
|
|
leftSet[token] = struct{}{}
|
|
}
|
|
|
|
seen := make(map[string]struct{}, len(right))
|
|
count := 0
|
|
for _, token := range right {
|
|
if _, ok := seen[token]; ok {
|
|
continue
|
|
}
|
|
seen[token] = struct{}{}
|
|
if _, ok := leftSet[token]; ok {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func tokenizeForEvolution(text string) []string {
|
|
fields := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
|
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9')
|
|
})
|
|
|
|
out := make([]string, 0, len(fields))
|
|
for _, field := range fields {
|
|
if field == "" {
|
|
continue
|
|
}
|
|
out = append(out, field)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|