mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
b3a7b7ad64
* feat: add agent self-evolution * fix ci * delete unused doc * fix lint * fix evolution review issues
398 lines
9.1 KiB
Go
398 lines
9.1 KiB
Go
package evolution
|
|
|
|
import (
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type OrganizerOptions struct {
|
|
MinCaseCount int
|
|
MinSuccessRate float64
|
|
Now func() time.Time
|
|
}
|
|
|
|
type Organizer struct {
|
|
minCaseCount int
|
|
minSuccessRate float64
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewOrganizer(opts OrganizerOptions) *Organizer {
|
|
now := opts.Now
|
|
if now == nil {
|
|
now = time.Now
|
|
}
|
|
|
|
minCaseCount := opts.MinCaseCount
|
|
if minCaseCount <= 0 {
|
|
minCaseCount = 3
|
|
}
|
|
|
|
minSuccessRate := opts.MinSuccessRate
|
|
if minSuccessRate <= 0 {
|
|
minSuccessRate = 0.7
|
|
}
|
|
|
|
return &Organizer{
|
|
minCaseCount: minCaseCount,
|
|
minSuccessRate: minSuccessRate,
|
|
now: now,
|
|
}
|
|
}
|
|
|
|
func (o *Organizer) BuildRules(records []LearningRecord) ([]LearningRecord, error) {
|
|
clusters := make(map[string][]LearningRecord)
|
|
keys := make([]string, 0)
|
|
|
|
for _, record := range records {
|
|
if !isTaskRecordKind(record.Kind) {
|
|
continue
|
|
}
|
|
|
|
key := normalizeRuleKey(record)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
|
|
clusterKey := record.WorkspaceID + "\x00" + key
|
|
if _, ok := clusters[clusterKey]; !ok {
|
|
keys = append(keys, clusterKey)
|
|
}
|
|
clusters[clusterKey] = append(clusters[clusterKey], record)
|
|
}
|
|
|
|
sort.Strings(keys)
|
|
|
|
rules := make([]LearningRecord, 0, len(keys))
|
|
for _, clusterKey := range keys {
|
|
cluster := append([]LearningRecord(nil), clusters[clusterKey]...)
|
|
sortCaseCluster(cluster)
|
|
|
|
if len(cluster) < o.minCaseCount {
|
|
continue
|
|
}
|
|
|
|
successRate := clusterSuccessRate(cluster)
|
|
if successRate < o.minSuccessRate {
|
|
continue
|
|
}
|
|
|
|
ruleKey := clusterKey[strings.Index(clusterKey, "\x00")+1:]
|
|
winningPath := clusterWinningPath(cluster)
|
|
lateAddedSkills, finalSnapshotTrigger := clusterLateAddedSkills(cluster, winningPath)
|
|
matchedSkillNames := append([]string(nil), winningPath...)
|
|
|
|
rules = append(rules, LearningRecord{
|
|
ID: stableRuleID(cluster[0].WorkspaceID, ruleKey),
|
|
Kind: RecordKindPattern,
|
|
WorkspaceID: cluster[0].WorkspaceID,
|
|
CreatedAt: o.now(),
|
|
Summary: buildRuleSummary(cluster, ruleKey, winningPath),
|
|
Source: map[string]any{"cluster_key": ruleKey},
|
|
Status: RecordStatus("ready"),
|
|
SourceRecordIDs: collectRecordIDs(cluster),
|
|
EventCount: len(cluster),
|
|
SuccessRate: successRate,
|
|
MaturityScore: computeMaturityScore(len(cluster), successRate),
|
|
WinningPath: winningPath,
|
|
LateAddedSkills: lateAddedSkills,
|
|
FinalSnapshotTrigger: finalSnapshotTrigger,
|
|
MatchedSkillNames: matchedSkillNames,
|
|
})
|
|
}
|
|
|
|
return rules, nil
|
|
}
|
|
|
|
func normalizeRuleKey(record LearningRecord) string {
|
|
if path := preferredRulePath(record); len(path) > 0 {
|
|
return strings.Join(path, " ")
|
|
}
|
|
if path := normalizePath(record.ToolKinds); len(path) > 0 {
|
|
return strings.Join(path, " ")
|
|
}
|
|
|
|
tokens := tokenizeForEvolution(record.Summary)
|
|
if len(tokens) == 0 {
|
|
return ""
|
|
}
|
|
if len(tokens) > 6 {
|
|
tokens = tokens[:6]
|
|
}
|
|
return strings.Join(tokens, " ")
|
|
}
|
|
|
|
func preferredRulePath(record LearningRecord) []string {
|
|
if path := normalizeFinalSuccessfulPath(record); len(path) > 0 {
|
|
return path
|
|
}
|
|
if path := normalizePath(record.UsedSkillNames); len(path) > 0 {
|
|
return path
|
|
}
|
|
if path := normalizePath(record.AddedSkillNames); len(path) > 0 {
|
|
return path
|
|
}
|
|
if path := normalizeAttemptedSkills(record); len(path) > 0 {
|
|
return path
|
|
}
|
|
if path := normalizePath(record.ActiveSkillNames); len(path) > 0 {
|
|
return path
|
|
}
|
|
if path := normalizePath(record.MatchedSkillNames); len(path) > 0 {
|
|
return path
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizePath(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
if value == "" {
|
|
continue
|
|
}
|
|
out = append(out, value)
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeFinalSuccessfulPath(record LearningRecord) []string {
|
|
if record.AttemptTrail == nil {
|
|
return nil
|
|
}
|
|
return normalizePath(record.AttemptTrail.FinalSuccessfulPath)
|
|
}
|
|
|
|
func normalizeAttemptedSkills(record LearningRecord) []string {
|
|
if record.AttemptTrail == nil {
|
|
return nil
|
|
}
|
|
return normalizePath(record.AttemptTrail.AttemptedSkills)
|
|
}
|
|
|
|
func sortCaseCluster(cluster []LearningRecord) {
|
|
sort.Slice(cluster, func(i, j int) bool {
|
|
if !cluster[i].CreatedAt.Equal(cluster[j].CreatedAt) {
|
|
return cluster[i].CreatedAt.Before(cluster[j].CreatedAt)
|
|
}
|
|
return cluster[i].ID < cluster[j].ID
|
|
})
|
|
}
|
|
|
|
func clusterSuccessRate(cluster []LearningRecord) float64 {
|
|
if len(cluster) == 0 {
|
|
return 0
|
|
}
|
|
|
|
successes := 0
|
|
for _, record := range cluster {
|
|
if record.Success != nil && *record.Success {
|
|
successes++
|
|
}
|
|
}
|
|
return float64(successes) / float64(len(cluster))
|
|
}
|
|
|
|
func clusterWinningPath(cluster []LearningRecord) []string {
|
|
type pathScore struct {
|
|
path []string
|
|
count int
|
|
}
|
|
|
|
bestKey := ""
|
|
best := pathScore{}
|
|
paths := make(map[string]pathScore)
|
|
order := make([]string, 0)
|
|
|
|
for _, record := range cluster {
|
|
path := preferredRulePath(record)
|
|
if len(path) == 0 {
|
|
path = normalizePath(record.ToolKinds)
|
|
}
|
|
if len(path) == 0 {
|
|
continue
|
|
}
|
|
|
|
key := strings.Join(path, "\x00")
|
|
score := paths[key]
|
|
if score.path == nil {
|
|
score.path = append([]string(nil), path...)
|
|
order = append(order, key)
|
|
}
|
|
score.count++
|
|
paths[key] = score
|
|
}
|
|
|
|
for _, key := range order {
|
|
score := paths[key]
|
|
if score.count > best.count {
|
|
best = score
|
|
bestKey = key
|
|
}
|
|
}
|
|
|
|
if bestKey == "" {
|
|
return nil
|
|
}
|
|
return best.path
|
|
}
|
|
|
|
func clusterLateAddedSkills(cluster []LearningRecord, winningPath []string) ([]string, string) {
|
|
type lateAddedScore struct {
|
|
skills []string
|
|
trigger string
|
|
count int
|
|
}
|
|
|
|
bestKey := ""
|
|
best := lateAddedScore{}
|
|
scores := make(map[string]lateAddedScore)
|
|
order := make([]string, 0)
|
|
|
|
for _, record := range cluster {
|
|
skills, trigger := lateAddedSkillsFromRecord(record)
|
|
if len(skills) == 0 {
|
|
continue
|
|
}
|
|
if len(winningPath) > 0 && !pathsEqual(skills, tailAddedWithinWinningPath(winningPath, skills)) {
|
|
continue
|
|
}
|
|
|
|
key := trigger + "\x00" + strings.Join(skills, "\x00")
|
|
score := scores[key]
|
|
if score.skills == nil {
|
|
score.skills = append([]string(nil), skills...)
|
|
score.trigger = trigger
|
|
order = append(order, key)
|
|
}
|
|
score.count++
|
|
scores[key] = score
|
|
}
|
|
|
|
for _, key := range order {
|
|
score := scores[key]
|
|
if score.count > best.count {
|
|
bestKey = key
|
|
best = score
|
|
}
|
|
}
|
|
|
|
if bestKey == "" {
|
|
return nil, ""
|
|
}
|
|
return best.skills, best.trigger
|
|
}
|
|
|
|
func lateAddedSkillsFromRecord(record LearningRecord) ([]string, string) {
|
|
if skills := normalizePath(record.AddedSkillNames); len(skills) > 0 {
|
|
return skills, "loaded_during_task"
|
|
}
|
|
if record.AttemptTrail == nil || len(record.AttemptTrail.SkillContextSnapshots) == 0 {
|
|
return nil, ""
|
|
}
|
|
|
|
snapshots := record.AttemptTrail.SkillContextSnapshots
|
|
last := snapshots[len(snapshots)-1]
|
|
if len(last.SkillNames) == 0 {
|
|
return nil, ""
|
|
}
|
|
if len(snapshots) == 1 {
|
|
return nil, strings.TrimSpace(last.Trigger)
|
|
}
|
|
|
|
prev := snapshots[len(snapshots)-2]
|
|
prevSet := make(map[string]struct{}, len(prev.SkillNames))
|
|
for _, skill := range normalizePath(prev.SkillNames) {
|
|
prevSet[skill] = struct{}{}
|
|
}
|
|
|
|
added := make([]string, 0, len(last.SkillNames))
|
|
for _, skill := range normalizePath(last.SkillNames) {
|
|
if _, ok := prevSet[skill]; ok {
|
|
continue
|
|
}
|
|
added = append(added, skill)
|
|
}
|
|
if len(added) == 0 {
|
|
return nil, strings.TrimSpace(last.Trigger)
|
|
}
|
|
return added, strings.TrimSpace(last.Trigger)
|
|
}
|
|
|
|
func tailAddedWithinWinningPath(winningPath, lateAdded []string) []string {
|
|
if len(winningPath) == 0 || len(lateAdded) == 0 || len(lateAdded) > len(winningPath) {
|
|
return nil
|
|
}
|
|
tail := winningPath[len(winningPath)-len(lateAdded):]
|
|
if !pathsEqual(tail, lateAdded) {
|
|
return nil
|
|
}
|
|
return tail
|
|
}
|
|
|
|
func pathsEqual(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func collectRecordIDs(cluster []LearningRecord) []string {
|
|
ids := make([]string, 0, len(cluster))
|
|
for _, record := range cluster {
|
|
ids = append(ids, record.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func computeMaturityScore(caseCount int, successRate float64) float64 {
|
|
return float64(caseCount) * successRate
|
|
}
|
|
|
|
func stableRuleID(workspaceID, key string) string {
|
|
sum := sha1.Sum([]byte(workspaceID + "\x00" + key))
|
|
return "rule-" + hex.EncodeToString(sum[:6])
|
|
}
|
|
|
|
func buildRuleSummary(cluster []LearningRecord, key string, winningPath []string) string {
|
|
if goal := representativeGoal(cluster); goal != "" && len(winningPath) > 0 {
|
|
return goal + " via " + strings.Join(winningPath, " -> ")
|
|
}
|
|
if goal := representativeGoal(cluster); goal != "" {
|
|
return goal
|
|
}
|
|
if len(winningPath) > 0 {
|
|
return strings.Join(winningPath, " -> ")
|
|
}
|
|
return key
|
|
}
|
|
|
|
func representativeGoal(cluster []LearningRecord) string {
|
|
for _, record := range cluster {
|
|
if goal := strings.TrimSpace(record.UserGoal); goal != "" {
|
|
return goal
|
|
}
|
|
}
|
|
for _, record := range cluster {
|
|
if summary := strings.TrimSpace(record.Summary); summary != "" {
|
|
return summary
|
|
}
|
|
}
|
|
return ""
|
|
}
|