feat: add scoped adaptive experience foundation

This commit is contained in:
m
2026-08-30 20:06:58 +03:00
parent e648d0423d
commit a6273d0134
6 changed files with 154 additions and 9 deletions
@@ -0,0 +1,89 @@
package domain
import (
"sort"
"strings"
"git.cin.su/m/Assistant/contracts/epistemics"
)
type ExperienceRetrieval struct {
Positive []epistemics.ExperienceRecord
Negative []epistemics.ExperienceRecord
Contradictions []epistemics.ExperienceRecord
}
// RetrieveExperience filters principal and scope before text relevance. It is
// a derived view over append-only Epistemics records, not a Memory owner.
func RetrieveExperience(records []epistemics.ExperienceRecord, principalID, scopeRef, query string, limit int) ExperienceRetrieval {
if limit < 1 {
limit = 1
}
queryTokens := retrievalTokens(query)
eligible := make([]epistemics.ExperienceRecord, 0, len(records))
for _, record := range records {
if principalID != "" && record.PrincipalID != principalID {
continue
}
if scopeRef != "" && record.ContextScopeRef != scopeRef {
continue
}
if len(queryTokens) > 0 && overlap(queryTokens, retrievalTokens(record.ProblemStatement+" "+record.OutcomeDetail+" "+record.WhySucceededOrFailed)) == 0 {
continue
}
eligible = append(eligible, record)
}
sort.SliceStable(eligible, func(i, j int) bool { return eligible[i].RecordedAtUnixMillis > eligible[j].RecordedAtUnixMillis })
result := ExperienceRetrieval{}
for _, record := range eligible {
if len(result.Positive)+len(result.Negative) >= limit {
break
}
switch record.ActualOutcome {
case epistemics.OutcomeSuccess, epistemics.OutcomePartialSuccess:
result.Positive = append(result.Positive, record)
default:
result.Negative = append(result.Negative, record)
if record.ActualOutcome == epistemics.OutcomeConflict || record.ActualOutcome == epistemics.OutcomeUserCorrected || record.ActualOutcome == epistemics.OutcomeStale {
result.Contradictions = append(result.Contradictions, record)
}
}
}
return result
}
func BuildConsolidationCandidates(records []epistemics.ExperienceRecord) []epistemics.ConsolidationCandidate {
byProblem := map[string][]epistemics.ExperienceRecord{}
for _, record := range records {
key := strings.TrimSpace(strings.ToLower(record.ProblemStatement))
if key != "" {
byProblem[key] = append(byProblem[key], record)
}
}
result := make([]epistemics.ConsolidationCandidate, 0, len(byProblem))
for problem, grouped := range byProblem {
refs := make([]epistemics.ExperienceRecordID, 0, len(grouped))
for _, record := range grouped {
refs = append(refs, record.ID)
}
result = append(result, epistemics.ConsolidationCandidate{EpisodeRef: "derived:experience:" + problem, ExperienceRefs: refs, PropositionText: problem, SkillCandidate: len(grouped) > 1})
}
return result
}
func retrievalTokens(value string) []string {
return strings.FieldsFunc(strings.ToLower(value), func(r rune) bool { return r < '0' || r > 'z' && (r < 'а' || r > 'я') })
}
func overlap(left, right []string) int {
seen := map[string]bool{}
for _, v := range left {
seen[v] = true
}
n := 0
for _, v := range right {
if seen[v] {
n++
}
}
return n
}
@@ -0,0 +1,25 @@
package domain
import (
"git.cin.su/m/Assistant/contracts/epistemics"
"testing"
)
func TestRetrieveExperienceFiltersScopeBeforeRelevanceAndKeepsFailures(t *testing.T) {
records := []epistemics.ExperienceRecord{
{ID: "success-a", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeSuccess, RecordedAtUnixMillis: 2},
{ID: "failed-a", PrincipalID: "p", ContextScopeRef: "company-a", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeFailed, OutcomeDetail: "old connector failed", RecordedAtUnixMillis: 1},
{ID: "success-b", PrincipalID: "p", ContextScopeRef: "company-b", ProblemStatement: "sync catalog", ActualOutcome: epistemics.OutcomeSuccess, RecordedAtUnixMillis: 3},
}
got := RetrieveExperience(records, "p", "company-a", "sync catalog", 4)
if len(got.Positive) != 1 || got.Positive[0].ID != "success-a" || len(got.Negative) != 1 || got.Negative[0].ID != "failed-a" {
t.Fatalf("retrieval=%#v", got)
}
}
func TestConsolidationPreservesEvidenceWithoutSkillActivation(t *testing.T) {
got := BuildConsolidationCandidates([]epistemics.ExperienceRecord{{ID: "a", ProblemStatement: "sync catalog"}, {ID: "b", ProblemStatement: "sync catalog"}})
if len(got) != 1 || len(got[0].ExperienceRefs) != 2 || !got[0].SkillCandidate {
t.Fatalf("candidates=%#v", got)
}
}