picoclaw/pkg/evolution/drafts.go

380 lines
10 KiB
Go

package evolution
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/skills"
)
type DraftGenerator interface {
GenerateDraft(ctx context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error)
}
func ValidateDraft(draft SkillDraft) []string {
findings := make([]string, 0, 5)
if strings.TrimSpace(draft.TargetSkillName) == "" {
findings = append(findings, "target_skill_name is required")
} else if err := skills.ValidateSkillName(draft.TargetSkillName); err != nil {
findings = append(findings, "target_skill_name is invalid: "+err.Error())
}
if strings.TrimSpace(draft.HumanSummary) == "" {
findings = append(findings, "human_summary is required")
}
if strings.TrimSpace(draft.BodyOrPatch) == "" {
findings = append(findings, "body_or_patch is required")
}
switch draft.DraftType {
case DraftTypeWorkflow, DraftTypeShortcut:
default:
findings = append(findings, "draft_type is invalid")
}
switch draft.ChangeKind {
case ChangeKindCreate, ChangeKindAppend, ChangeKindReplace, ChangeKindMerge:
default:
findings = append(findings, "change_kind is invalid")
}
return findings
}
type DefaultDraftGenerator struct {
loader *skills.SkillsLoader
}
func NewDefaultDraftGenerator(workspace string) *DefaultDraftGenerator {
builtinSkillsDir := strings.TrimSpace(os.Getenv(config.EnvBuiltinSkills))
if builtinSkillsDir == "" {
wd, _ := os.Getwd()
builtinSkillsDir = filepath.Join(wd, "skills")
}
globalSkillsDir := filepath.Join(config.GetHome(), "skills")
return &DefaultDraftGenerator{
loader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
}
}
func (g *DefaultDraftGenerator) GenerateDraft(_ context.Context, rule LearningRecord, matches []skills.SkillInfo) (SkillDraft, error) {
target := inferTargetSkillName(rule, matches)
if target == "" {
target = "learned-skill"
}
_, hasExisting, err := g.loadBaseSkillContent(target, matches)
if err != nil {
return SkillDraft{}, err
}
draftType := DraftTypeWorkflow
if len(rule.WinningPath) <= 1 {
draftType = DraftTypeShortcut
}
changeKind := ChangeKindCreate
body := g.buildNewSkillBody(target, rule)
if hasExisting {
changeKind = ChangeKindAppend
body = g.buildAppendBody(rule)
}
return SkillDraft{
TargetSkillName: target,
DraftType: draftType,
ChangeKind: changeKind,
HumanSummary: g.buildHumanSummary(target, rule, hasExisting),
IntendedUseCases: inferIntendedUseCases(rule),
PreferredEntryPath: inferPreferredEntryPath(rule),
AvoidPatterns: inferAvoidPatterns(rule),
BodyOrPatch: body,
}, nil
}
func inferTargetSkillName(rule LearningRecord, matches []skills.SkillInfo) string {
if target := inferCombinedSkillName(rule); target != "" {
return target
}
if len(matches) > 0 && strings.TrimSpace(matches[0].Name) != "" {
return strings.TrimSpace(matches[0].Name)
}
if len(rule.LateAddedSkills) > 0 && strings.TrimSpace(rule.LateAddedSkills[0]) != "" {
return strings.TrimSpace(rule.LateAddedSkills[0])
}
if len(rule.WinningPath) > 0 && strings.TrimSpace(rule.WinningPath[0]) != "" {
return strings.TrimSpace(rule.WinningPath[0])
}
if len(rule.MatchedSkillNames) > 0 && strings.TrimSpace(rule.MatchedSkillNames[0]) != "" {
return strings.TrimSpace(rule.MatchedSkillNames[0])
}
tokens := tokenizeForEvolution(rule.Summary)
if len(tokens) > 0 {
return tokens[0]
}
return ""
}
func inferCombinedSkillName(rule LearningRecord) string {
path := normalizePath(rule.WinningPath)
if len(path) < 2 {
return ""
}
tokens := tokenizeForEvolution(rule.Summary)
suffix := commonWinningPathSuffix(path)
if len(tokens) == 1 && isNumericToken(tokens[0]) && suffix != "" {
if candidate := validSkillNameOrEmpty("calculate-" + tokens[0] + "-via-" + pluralizeSuffix(suffix)); candidate != "" {
return candidate
}
}
if len(tokens) >= 2 {
prefix := strings.Join(tokens[:minInt(len(tokens), 4)], "-")
if suffix != "" {
if candidate := validSkillNameOrEmpty(prefix + "-via-" + pluralizeSuffix(suffix)); candidate != "" {
return candidate
}
}
if candidate := validSkillNameOrEmpty(prefix + "-shortcut"); candidate != "" {
return candidate
}
}
compressedPath := compressedWinningPathName(path)
if candidate := validSkillNameOrEmpty("combined-" + compressedPath); candidate != "" {
return candidate
}
if candidate := validSkillNameOrEmpty(path[0] + "-to-" + path[len(path)-1] + "-shortcut"); candidate != "" {
return candidate
}
return ""
}
func commonWinningPathSuffix(path []string) string {
if len(path) < 2 {
return ""
}
var suffix string
for i, name := range path {
parts := strings.Split(strings.TrimSpace(name), "-")
if len(parts) == 0 {
return ""
}
last := strings.TrimSpace(parts[len(parts)-1])
if last == "" {
return ""
}
if i == 0 {
suffix = last
continue
}
if suffix != last {
return ""
}
}
return suffix
}
func compressedWinningPathName(path []string) string {
suffix := commonWinningPathSuffix(path)
fragments := make([]string, 0, len(path)+1)
for _, name := range path {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
continue
}
if suffix != "" {
trimmed = strings.TrimSuffix(trimmed, "-"+suffix)
trimmed = strings.TrimSuffix(trimmed, suffix)
trimmed = strings.Trim(trimmed, "-")
}
if trimmed != "" {
fragments = append(fragments, trimmed)
}
}
if suffix != "" {
fragments = append(fragments, pluralizeSuffix(suffix))
}
if len(fragments) == 0 {
return strings.Join(path, "-")
}
return strings.Join(fragments, "-")
}
func pluralizeSuffix(suffix string) string {
suffix = strings.TrimSpace(strings.ToLower(suffix))
if suffix == "" {
return ""
}
if strings.HasSuffix(suffix, "s") {
return suffix
}
return suffix + "s"
}
func isNumericToken(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
func validSkillNameOrEmpty(candidate string) string {
candidate = strings.Trim(candidate, "-")
candidate = strings.Join(strings.FieldsFunc(candidate, func(r rune) bool {
return !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9')
}), "-")
candidate = strings.ToLower(strings.Trim(candidate, "-"))
if candidate == "" {
return ""
}
if len(candidate) > skills.MaxNameLength {
return ""
}
if err := skills.ValidateSkillName(candidate); err != nil {
return ""
}
return candidate
}
func (g *DefaultDraftGenerator) loadBaseSkillContent(target string, matches []skills.SkillInfo) (string, bool, error) {
for _, match := range matches {
if match.Name != target || strings.TrimSpace(match.Path) == "" {
continue
}
data, err := os.ReadFile(match.Path)
if err != nil {
return "", false, err
}
return string(data), true, nil
}
if g.loader == nil {
return "", false, nil
}
content, ok := g.loader.LoadSkill(target)
if !ok {
return "", false, nil
}
description := fmt.Sprintf("Learned workflow for %s.", target)
return buildSkillDocument(target, description, content), true, nil
}
func (g *DefaultDraftGenerator) buildHumanSummary(target string, rule LearningRecord, hasExisting bool) string {
if hasExisting {
return fmt.Sprintf("Refresh %s with learned pattern: %s", target, rule.Summary)
}
return fmt.Sprintf("Create %s from learned pattern: %s", target, rule.Summary)
}
func (g *DefaultDraftGenerator) buildNewSkillBody(target string, rule LearningRecord) string {
description := fmt.Sprintf("Learned workflow for %s.", target)
body := strings.Join([]string{
"# " + titleCaseSkillName(target),
"",
"## Start Here",
g.startHereLine(rule),
"",
"## When To Use",
fmt.Sprintf("Use this skill when the task matches `%s`.", strings.TrimSpace(rule.Summary)),
"",
"## Learned Pattern",
g.learnedPatternLine(rule),
"",
"## Winning Path",
g.winningPathLine(rule),
"",
"## Evidence",
g.evidenceLine(rule),
}, "\n")
return buildSkillDocument(target, description, body)
}
func (g *DefaultDraftGenerator) buildAppendBody(rule LearningRecord) string {
return strings.Join([]string{
"## Learned Evolution",
fmt.Sprintf("- Summary: %s", strings.TrimSpace(rule.Summary)),
fmt.Sprintf("- Learned pattern: %s", g.learnedPatternLine(rule)),
fmt.Sprintf("- Winning path: %s", g.winningPathLine(rule)),
fmt.Sprintf("- Evidence: %s", g.evidenceLine(rule)),
"",
}, "\n")
}
func buildSkillDocument(name, description, body string) string {
return strings.Join([]string{
"---",
"name: " + strings.TrimSpace(name),
"description: " + strings.TrimSpace(description),
"---",
"",
strings.TrimSpace(body),
"",
}, "\n")
}
func titleCaseSkillName(name string) string {
parts := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' || r == ' ' })
for i, part := range parts {
if part == "" {
continue
}
parts[i] = strings.ToUpper(part[:1]) + part[1:]
}
if len(parts) == 0 {
return "Learned Skill"
}
return strings.Join(parts, " ")
}
func (g *DefaultDraftGenerator) startHereLine(rule LearningRecord) string {
if len(rule.WinningPath) > 0 {
return fmt.Sprintf("Start with `%s` before trying other paths.", strings.Join(rule.WinningPath, " -> "))
}
return fmt.Sprintf("Start from the learned path for `%s`.", strings.TrimSpace(rule.Summary))
}
func (g *DefaultDraftGenerator) learnedPatternLine(rule LearningRecord) string {
if len(rule.LateAddedSkills) > 0 {
return fmt.Sprintf(
"Late-added skill `%s` was repeatedly introduced immediately before success%s.",
strings.Join(rule.LateAddedSkills, " -> "),
triggerSuffix(rule.FinalSnapshotTrigger),
)
}
if len(rule.WinningPath) > 0 {
return fmt.Sprintf("Prefer `%s` because it was the most reliable recent path.", strings.Join(rule.WinningPath, " -> "))
}
return fmt.Sprintf("Prefer the pattern summarized as `%s`.", strings.TrimSpace(rule.Summary))
}
func (g *DefaultDraftGenerator) winningPathLine(rule LearningRecord) string {
if len(rule.WinningPath) == 0 {
return "No explicit winning path was recorded."
}
return strings.Join(rule.WinningPath, " -> ")
}
func (g *DefaultDraftGenerator) evidenceLine(rule LearningRecord) string {
return fmt.Sprintf("%d cases, success rate %.2f", rule.EventCount, rule.SuccessRate)
}
func triggerSuffix(trigger string) string {
trigger = strings.TrimSpace(trigger)
if trigger == "" {
return ""
}
return fmt.Sprintf(" during `%s`", trigger)
}