From a6273d013487ab659d88dc2d8a5bc95bf2b7f036 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 30 Aug 2026 20:06:58 +0300 Subject: [PATCH] feat: add scoped adaptive experience foundation --- adapters/postgres/epistemics_repository.go | 18 ++-- cmd/core-runtime/learning.go | 5 ++ contracts/epistemics/types.go | 10 +++ .../051_gen2_adaptive_experience_scope.sql | 16 ++++ .../epistemics/domain/adaptive_retrieval.go | 89 +++++++++++++++++++ .../domain/adaptive_retrieval_test.go | 25 ++++++ 6 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 database/migrations/051_gen2_adaptive_experience_scope.sql create mode 100644 modules/epistemics/domain/adaptive_retrieval.go create mode 100644 modules/epistemics/domain/adaptive_retrieval_test.go diff --git a/adapters/postgres/epistemics_repository.go b/adapters/postgres/epistemics_repository.go index 2f9c0fd..60f7feb 100644 --- a/adapters/postgres/epistemics_repository.go +++ b/adapters/postgres/epistemics_repository.go @@ -101,10 +101,10 @@ func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limi return nil, fmt.Errorf("invalid experience limit") } rows, err := r.pool.Query(ctx, `SELECT - experience_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref, + experience_id,principal_id,source_kind,original_intent_id,source_utterance_id,context_refs,help_method,help_method_ref, capability_implementation_ref,capability_usage_observation_ref,capability_gap_ref,skill_ref,expectation_id, expected_outcome,actual_outcome,outcome_ref,outcome_detail,human_response_ref,correction_utterance_id, - verification_evidence,confidence_millis,recorded_at_unix_millis + verification_evidence,confidence_millis,recorded_at_unix_millis,context_scope_ref,problem_statement,applicability_conditions,why_succeeded_or_failed FROM epistemics.experience_records ORDER BY recorded_at_unix_millis DESC, experience_id DESC LIMIT $1`, limit) if err != nil { return nil, err @@ -113,10 +113,10 @@ func (r *EpistemicsRepository) RecentExperienceRecords(ctx context.Context, limi var result []epistemics.ExperienceRecord for rows.Next() { var value epistemics.ExperienceRecord - if err := rows.Scan(&value.ID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef, + if err := rows.Scan(&value.ID, &value.PrincipalID, &value.SourceKind, &value.OriginalIntentID, &value.SourceUtteranceID, &value.ContextRefs, &value.HelpMethod, &value.HelpMethodRef, &value.CapabilityImplementationRef, &value.CapabilityUsageObservationRef, &value.CapabilityGapRef, &value.SkillRef, &value.ExpectationID, &value.ExpectedOutcome, &value.ActualOutcome, &value.OutcomeRef, &value.OutcomeDetail, &value.HumanResponseRef, &value.CorrectionUtteranceID, - &value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis); err != nil { + &value.VerificationEvidence, &value.ConfidenceMillis, &value.RecordedAtUnixMillis, &value.ContextScopeRef, &value.ProblemStatement, &value.ApplicabilityConditions, &value.WhySucceededOrFailed); err != nil { return nil, err } result = append(result, value) @@ -205,15 +205,15 @@ func (r *EpistemicsRepository) WaitForReflectionCandidate(ctx context.Context, i func (r *EpistemicsRepository) SaveExperienceRecord(ctx context.Context, value epistemics.ExperienceRecord) error { _, err := r.pool.Exec(ctx, `INSERT INTO epistemics.experience_records ( - experience_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref, + experience_id, principal_id, source_kind, original_intent_id, source_utterance_id, context_refs, help_method, help_method_ref, capability_implementation_ref, capability_usage_observation_ref, capability_gap_ref, skill_ref, expectation_id, expected_outcome, actual_outcome, outcome_ref, outcome_detail, human_response_ref, correction_utterance_id, - verification_evidence, confidence_millis, recorded_at_unix_millis) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21) ON CONFLICT DO NOTHING`, - value.ID, value.SourceKind, value.OriginalIntentID, value.SourceUtteranceID, value.ContextRefs, value.HelpMethod, value.HelpMethodRef, + verification_evidence, confidence_millis, recorded_at_unix_millis, context_scope_ref, problem_statement, applicability_conditions, why_succeeded_or_failed) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26) ON CONFLICT DO NOTHING`, + value.ID, value.PrincipalID, value.SourceKind, value.OriginalIntentID, value.SourceUtteranceID, value.ContextRefs, value.HelpMethod, value.HelpMethodRef, value.CapabilityImplementationRef, value.CapabilityUsageObservationRef, value.CapabilityGapRef, value.SkillRef, value.ExpectationID, value.ExpectedOutcome, value.ActualOutcome, value.OutcomeRef, value.OutcomeDetail, value.HumanResponseRef, value.CorrectionUtteranceID, - value.VerificationEvidence, value.ConfidenceMillis, value.RecordedAtUnixMillis) + value.VerificationEvidence, value.ConfidenceMillis, value.RecordedAtUnixMillis, value.ContextScopeRef, value.ProblemStatement, value.ApplicabilityConditions, value.WhySucceededOrFailed) return err } diff --git a/cmd/core-runtime/learning.go b/cmd/core-runtime/learning.go index 25e762b..0acb551 100644 --- a/cmd/core-runtime/learning.go +++ b/cmd/core-runtime/learning.go @@ -113,6 +113,7 @@ func (r learningRecorder) recordReminderResponse(ctx context.Context, input remi actual, confidence := outcomeForHumanResponse(input.Result.Response) experience := epistemics.ExperienceRecord{ SourceKind: epistemics.ExperienceSourceContinuity, + PrincipalID: string(input.Utterance.PrincipalID), OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), ContextRefs: contextRefs( @@ -143,6 +144,7 @@ func (r learningRecorder) recordCorrection(ctx context.Context, input correction } experience := epistemics.ExperienceRecord{ SourceKind: epistemics.ExperienceSourceInteraction, + PrincipalID: string(input.Utterance.PrincipalID), OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), HelpMethod: epistemics.HelpMethodClarification, @@ -175,6 +177,7 @@ func (r learningRecorder) recordFastPathDecline(ctx context.Context, input fastP if input.Result.Response.ID != "" { experience.ContextRefs = contextRefs("continuity:response-candidate:" + string(input.Result.Response.Operation)) } + experience.PrincipalID = string(input.Utterance.PrincipalID) saved, ok := r.saveExperienceAndReflection(ctx, experience) if ok { r.updateReminderSkill(ctx, saved, capability.UsageSkipped, false) @@ -196,6 +199,7 @@ func (r learningRecorder) recordCapabilityUse(ctx context.Context, input capabil } experience := epistemics.ExperienceRecord{ SourceKind: epistemics.ExperienceSourceCapability, + PrincipalID: string(input.Utterance.PrincipalID), OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), HelpMethod: epistemics.HelpMethodCapabilityExecution, @@ -232,6 +236,7 @@ func (r learningRecorder) recordDelegationOutcome(ctx context.Context, input del } experience := epistemics.ExperienceRecord{ SourceKind: epistemics.ExperienceSourceCapability, + PrincipalID: string(input.Utterance.PrincipalID), OriginalIntentID: epistemics.OriginalIntentRef(input.Intent.ID), SourceUtteranceID: epistemics.SourceUtteranceRef(input.Utterance.ID), ContextRefs: contextRefs("work:delegation:"+input.DelegationID, "work:run:"+input.RunID), diff --git a/contracts/epistemics/types.go b/contracts/epistemics/types.go index 812c338..8299cb3 100644 --- a/contracts/epistemics/types.go +++ b/contracts/epistemics/types.go @@ -102,6 +102,7 @@ type SystemExpectation struct { // state or grants authority. type ExperienceRecord struct { ID ExperienceRecordID + PrincipalID string SourceKind ExperienceSourceKind OriginalIntentID OriginalIntentRef SourceUtteranceID SourceUtteranceRef @@ -133,6 +134,15 @@ type ExperienceRecord struct { WhySucceededOrFailed string } +// ConsolidationCandidate is a rebuildable Epistemics projection. It preserves +// source ExperienceRecord references and cannot activate a Skill by itself. +type ConsolidationCandidate struct { + EpisodeRef string + ExperienceRefs []ExperienceRecordID + PropositionText string + SkillCandidate bool +} + // ReflectionCandidate records that broader reflection may be warranted. // Cognitive reflection, if used, must go through Cognitive Gateway/Router and // produce candidates only; it cannot directly rewrite canonical state. diff --git a/database/migrations/051_gen2_adaptive_experience_scope.sql b/database/migrations/051_gen2_adaptive_experience_scope.sql new file mode 100644 index 0000000..b28b47d --- /dev/null +++ b/database/migrations/051_gen2_adaptive_experience_scope.sql @@ -0,0 +1,16 @@ +BEGIN; + +SET ROLE epistemics_role; + +ALTER TABLE epistemics.experience_records + ADD COLUMN IF NOT EXISTS principal_id TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS context_scope_ref TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS problem_statement TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS applicability_conditions TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], + ADD COLUMN IF NOT EXISTS why_succeeded_or_failed TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS experience_records_principal_scope_recorded_idx + ON epistemics.experience_records (principal_id, context_scope_ref, recorded_at_unix_millis DESC); + +RESET ROLE; +COMMIT; diff --git a/modules/epistemics/domain/adaptive_retrieval.go b/modules/epistemics/domain/adaptive_retrieval.go new file mode 100644 index 0000000..4c315bd --- /dev/null +++ b/modules/epistemics/domain/adaptive_retrieval.go @@ -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 +} diff --git a/modules/epistemics/domain/adaptive_retrieval_test.go b/modules/epistemics/domain/adaptive_retrieval_test.go new file mode 100644 index 0000000..88b1fcc --- /dev/null +++ b/modules/epistemics/domain/adaptive_retrieval_test.go @@ -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) + } +}