From 00af176a200142bd3de67ba914e542b2299f43d5 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 30 Aug 2026 19:12:52 +0300 Subject: [PATCH] fix: compile scoped interaction assertions --- adapters/postgres/interaction_repository.go | 65 ++++++ cmd/core-runtime/cognitive_interpretation.go | 29 +++ .../cognitive_interpretation_test.go | 10 + cmd/core-runtime/main.go | 21 +- dev/ACTIVE_TASK.json | 2 +- ...02-restore-production-cognitive-route.json | 7 +- modules/interaction/domain/gen2_context.go | 203 +++++++++++++++++- .../interaction/domain/gen2_context_test.go | 56 ++++- 8 files changed, 382 insertions(+), 11 deletions(-) diff --git a/adapters/postgres/interaction_repository.go b/adapters/postgres/interaction_repository.go index 25c7bc9..30c29ee 100644 --- a/adapters/postgres/interaction_repository.go +++ b/adapters/postgres/interaction_repository.go @@ -100,6 +100,22 @@ func (r *InteractionRepository) Utterances(ctx context.Context, id interaction.C return out, rows.Err() } +// UtterancesWithProjectReferences returns canonical utterances together with +// their already-persisted Interaction-owned project references. The caller +// uses the pair only as a read projection; this method does not infer or write +// scope membership. +func (r *InteractionRepository) UtterancesWithProjectReferences(ctx context.Context, id interaction.ConversationID) ([]interaction.HumanUtterance, []interaction.ProjectReference, error) { + utterances, err := r.Utterances(ctx, id) + if err != nil { + return nil, nil, err + } + references, err := r.projectReferencesForUtterances(ctx, utterances) + if err != nil { + return nil, nil, err + } + return utterances, references, nil +} + // RecentUtterancesForPrincipal provides durable conversational context when a // client starts a fresh conversation instead of carrying the previous ID. func (r *InteractionRepository) RecentUtterancesForPrincipal(ctx context.Context, principalID identity.PrincipalID, limit int) ([]interaction.HumanUtterance, error) { @@ -118,3 +134,52 @@ func (r *InteractionRepository) RecentUtterancesForPrincipal(ctx context.Context } return out, rows.Err() } + +// RecentUtterancesForPrincipalWithProjectReferences keeps a new conversation +// within the same durable Interaction read boundary as its scoped context. +func (r *InteractionRepository) RecentUtterancesForPrincipalWithProjectReferences(ctx context.Context, principalID identity.PrincipalID, limit int) ([]interaction.HumanUtterance, []interaction.ProjectReference, error) { + utterances, err := r.RecentUtterancesForPrincipal(ctx, principalID, limit) + if err != nil { + return nil, nil, err + } + references, err := r.projectReferencesForUtterances(ctx, utterances) + if err != nil { + return nil, nil, err + } + return utterances, references, nil +} + +func (r *InteractionRepository) projectReferencesForUtterances(ctx context.Context, utterances []interaction.HumanUtterance) ([]interaction.ProjectReference, error) { + if len(utterances) == 0 { + return nil, nil + } + ids := make([]string, 0, len(utterances)) + seen := make(map[interaction.HumanUtteranceID]struct{}, len(utterances)) + for _, utterance := range utterances { + if utterance.ID == "" { + continue + } + if _, exists := seen[utterance.ID]; exists { + continue + } + seen[utterance.ID] = struct{}{} + ids = append(ids, string(utterance.ID)) + } + if len(ids) == 0 { + return nil, nil + } + rows, err := r.pool.Query(ctx, `SELECT project_reference_id,utterance_id,project_id,project_display_name,role,provenance,confidence_millis,recorded_at_unix_millis FROM interaction.project_references WHERE utterance_id = ANY($1::text[]) ORDER BY recorded_at_unix_millis,project_reference_id`, ids) + if err != nil { + return nil, err + } + defer rows.Close() + refs := make([]interaction.ProjectReference, 0) + for rows.Next() { + var ref interaction.ProjectReference + if err := rows.Scan(&ref.ID, &ref.UtteranceID, &ref.ProjectID, &ref.ProjectDisplayName, &ref.Role, &ref.Provenance, &ref.ConfidenceMillis, &ref.RecordedAtUnixMillis); err != nil { + return nil, err + } + refs = append(refs, ref) + } + return refs, rows.Err() +} diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index 10fb842..2d24ea3 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -28,6 +28,35 @@ type cognitiveInterpretationClient struct { media *http.Client } +// compileInteractionContext is the Core-side handoff from the existing +// Interaction retrieval projection to the existing Context Compiler. The +// compiler receives only already-authorized, typed fragments and applies its +// scope and byte-budget guards before the cognitive gateway sees them. +func compileInteractionContext(projection interactiondomain.ContextProjection, focusProjectID string) (string, error) { + if len(projection.Fragments) == 0 { + return projection.Text, nil + } + candidates := make([]cognitive.ContextCandidate, 0, len(projection.Fragments)) + for _, fragment := range projection.Fragments { + candidates = append(candidates, cognitive.ContextCandidate{ + Excerpt: cognitive.ContextExcerpt{ID: cognitive.ContextExcerptID("interaction:" + string(fragment.UtteranceID)), SourceKind: "interaction-gen2-retrieval", SourceID: "interaction:utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: fragment.Text}, + Relevance: fragment.Relevance, + Lane: "interaction", + ContextScopeRef: fragment.ScopeRef, + FreshnessState: "CURRENT", + }) + } + bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{Candidates: candidates, DataPolicy: capability.DataPolicyLocalOnly, MaxBytes: 2048, ContextScopeRef: focusProjectID, FreshnessRequirement: "conversation-current", RiskClass: "low"}) + if err != nil { + return "", err + } + parts := make([]string, 0, len(bundle.Excerpts)) + for _, excerpt := range bundle.Excerpts { + parts = append(parts, excerpt.Text) + } + return strings.Join(parts, "\n"), nil +} + const ( // Household interpretation and response review are intentionally compact // calls. Keeping their output bounded prevents a loaded local model from diff --git a/cmd/core-runtime/cognitive_interpretation_test.go b/cmd/core-runtime/cognitive_interpretation_test.go index 0c9fb13..938e3c6 100644 --- a/cmd/core-runtime/cognitive_interpretation_test.go +++ b/cmd/core-runtime/cognitive_interpretation_test.go @@ -70,6 +70,16 @@ func TestAnswerStableKnowledgeUsesLocalOnlyCognitiveRoute(t *testing.T) { } } +func TestCompileInteractionContextAppliesTypedScopeBeforeGateway(t *testing.T) { + context, err := compileInteractionContext(interactiondomain.ContextProjection{Fragments: []interactiondomain.ContextFragment{ + {UtteranceID: "a", Text: "контрольный код = ALPHA", ScopeRef: "company-a", Relevance: 2}, + {UtteranceID: "b", Text: "контрольный код = BETA", ScopeRef: "company-b", Relevance: 3}, + }}, "company-a") + if err != nil || context != "контрольный код = ALPHA" { + t.Fatalf("context=%q err=%v", context, err) + } +} + func TestGenerateImageUsesDedicatedCognitiveMediaRoute(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/v1/multimodal/generate-image" { diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 9083494..bf3e92d 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -571,9 +571,10 @@ func main() { travelContinuation := false var previousTask *interaction.HumanUtterance var conversationHistory []interaction.HumanUtterance + var contextProjectReferences []interaction.ProjectReference if request.ConversationID != "" { var historyErr error - conversationHistory, historyErr = interactionRepository.Utterances(r.Context(), conversation.ID) + conversationHistory, contextProjectReferences, historyErr = interactionRepository.UtterancesWithProjectReferences(r.Context(), conversation.ID) if historyErr != nil { log.Printf("interaction conversation history unavailable: %v", historyErr) } @@ -581,14 +582,24 @@ func main() { // Keep durable user context available even when the web client carries a // conversation ID; a new or rotated conversation must not erase learned // preferences. - recentUserHistory, historyErr := interactionRepository.RecentUtterancesForPrincipal(r.Context(), session.PrincipalID, 64) + recentUserHistory, recentProjectReferences, historyErr := interactionRepository.RecentUtterancesForPrincipalWithProjectReferences(r.Context(), session.PrincipalID, 64) if historyErr != nil { log.Printf("interaction principal history unavailable: %v", historyErr) } else { log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory)) } conversationHistory = append(conversationHistory, recentUserHistory...) - gen2Context := interactiondomain.BuildScopedGen2Context(conversationHistory, string(session.PrincipalID)) + contextProjectReferences = append(contextProjectReferences, recentProjectReferences...) + focusProjectID := "" + if previousFocus != nil { + focusProjectID = string(previousFocus.ProjectID) + } + gen2Context := interactiondomain.BuildScopedGen2ContextForQuery(conversationHistory, contextProjectReferences, string(session.PrincipalID), focusProjectID, effectiveTaskMessage) + if compiledContext, contextErr := compileInteractionContext(gen2Context, focusProjectID); contextErr != nil { + log.Printf("interaction context compilation unavailable: %v", contextErr) + } else { + gen2Context.Text = compiledContext + } if gen2Context.UsedPersonalMemoryFallback { telemetry.RecordCompatibility("personal-memory-fallback", "core-runtime", "read", "gen2-context-empty", true, true, false, "USED", utterance.CorrelationID, string(session.PrincipalID), now) } @@ -887,7 +898,7 @@ func main() { planningReply = retrievedMemoryReply needsCognition = false } - if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) { + if hasChatRoute(routePlan, chatRouteResearch) && len(gen2Context.Fragments) == 0 && semantic.Interpretation.Clarification != interaction.ClarificationRequired && shouldRunGeneralResearch(semantic.InformationRequestKind) { researchAttempted = true if planID, idErr := ids.New(); idErr == nil { parts := interactiondomain.IndependentRequestParts(effectiveTaskMessage) @@ -954,7 +965,7 @@ func main() { } } canGenerateTextReply := shouldGenerateNonMutatingTextDirective(semantic, routePlan, helpPath, creativeRequest) && planningReply == deterministicReply - if (planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion || canGenerateTextReply) && !hasChatRoute(routePlan, chatRouteResearch) && !creativeRequest { + if (planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion || canGenerateTextReply) && (!hasChatRoute(routePlan, chatRouteResearch) || len(gen2Context.Fragments) > 0) && !creativeRequest { answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second) answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, effectiveTaskMessage, gen2Context.Text, cognitive.TaskMeaning{ SpeechAct: string(semantic.Interpretation.SpeechAct), CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), RequestedOutcome: semantic.Interpretation.RequestedOutcome, InformationRequestKind: semantic.InformationRequestKind, ConfidenceMillis: semantic.Interpretation.ConfidenceMillis, diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index ce2cf40..7ad23c8 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1 +1 @@ -{"task_file":"dev/tasks/GEN2-COGNITIVE-002-restore-production-cognitive-route.json","task_id":"GEN2-COGNITIVE-002","task_revision":2} +{"task_file":"dev/tasks/GEN2-COGNITIVE-002-restore-production-cognitive-route.json","task_id":"GEN2-COGNITIVE-002","task_revision":3} diff --git a/dev/tasks/GEN2-COGNITIVE-002-restore-production-cognitive-route.json b/dev/tasks/GEN2-COGNITIVE-002-restore-production-cognitive-route.json index 2add252..d6f2c73 100644 --- a/dev/tasks/GEN2-COGNITIVE-002-restore-production-cognitive-route.json +++ b/dev/tasks/GEN2-COGNITIVE-002-restore-production-cognitive-route.json @@ -1,6 +1,6 @@ { "task_id": "GEN2-COGNITIVE-002", - "task_revision": 2, + "task_revision": 3, "architecture_revision": "ARCH-GEN2-000", "base_repo_revision": "gen2-integration", "risk_tier": "E", @@ -13,6 +13,7 @@ "cmd/core-runtime/**", "modules/interaction/domain/gen2_context.go", "modules/interaction/domain/gen2_context_test.go", + "adapters/postgres/interaction_repository.go", "contracts/capability/**", "contracts/cognitive/**", "modules/capability/**", @@ -36,9 +37,9 @@ "may_change": ["typed capability/provider selection and invocation wiring only within declared paths", "the existing Interaction Gen2 retrieval projection for bounded, scoped human assertions; no new Memory owner or reader"] }, "semantic_owner": "Capability remains the sole owner of provider/model/binding lifecycle; cognitive-worker invokes providers through adapters; Core consumes the governed gateway; Supervisor alone controls traffic promotion and rollback.", - "canonical_state": ["existing Capability provider/model/binding/evaluation evidence", "canonical Interaction human utterance history read through the existing Gen2 retrieval projection", "no new canonical writer, owner, schema, role, or migration"], + "canonical_state": ["existing Capability provider/model/binding/evaluation evidence", "canonical Interaction human utterance history and existing typed ProjectReference read through the existing Gen2 retrieval projection", "no new canonical writer, owner, schema, role, or migration"], "state_transitions": ["trace stable and candidate loaded binding heads -> correct stale/divergent lifecycle or invocation defect -> governed repair/qualification -> scoped Interaction projection selects bounded current human assertions -> isolated candidate validation -> Supervisor canary -> promote or rollback"], - "side_effects_and_privileges": ["production candidate deployment and Supervisor canary/promotion are explicitly authorized", "adapters/models/** may change only after trace proves a model adapter, provider endpoint, injection, timeout, rate-limit, context budget, or invocation defect there", "cmd/core-runtime/** may change only after trace proves Core-to-worker request construction, route selection, or response handling is causal", "modules/interaction/domain/gen2_context.go may change only to extend the existing bounded Gen2 read projection over canonical Interaction utterances with Principal hard filtering, current applicability and relevance selection; it must not write state, become a Memory owner, or promote PersonalMemory V1 to primary", "no direct SQL semantic mutation", "no credential, cookie, token, prompt, or user content read/emitted", "Lifeboat may be observed as fallback but cannot mask Local MEDIUM primary failure"], + "side_effects_and_privileges": ["production candidate deployment and Supervisor canary/promotion are explicitly authorized", "adapters/models/** may change only after trace proves a model adapter, provider endpoint, injection, timeout, rate-limit, context budget, or invocation defect there", "cmd/core-runtime/** may change only after trace proves Core-to-worker request construction, route selection, or response handling is causal", "modules/interaction/domain/gen2_context.go may change only to extend the existing bounded Gen2 read projection over canonical Interaction utterances with Principal hard filtering, current applicability and relevance selection; it must not write state, become a Memory owner, or promote PersonalMemory V1 to primary", "adapters/postgres/interaction_repository.go may only read the already-persisted typed interaction.ProjectReference records for the selected canonical Interaction utterances; it may not create schema, write state, or bypass Interaction ownership", "no direct SQL semantic mutation", "no credential, cookie, token, prompt, or user content read/emitted", "Lifeboat may be observed as fallback but cannot mask Local MEDIUM primary failure"], "persistence_and_migrations": ["no schema/migration change is authorized", "adapters/postgres/capability_repository.go may change only after trace proves durable Capability head/state loading diverges from the correct persisted state; reuse its existing repository boundary"], "failure_retry_restart": ["discovery health, invocation health, and availability remain distinct", "CPU/GPU gaps must not suppress a valid Local MEDIUM", "restart reloads a durable active/validated binding head", "failed candidate rolls traffic to Supervisor LKG without stable recreation"], "assumptions": ["isolated candidate slot B and Supervisor controls from GEN2-DEPLOY-003 are available", "an existing authenticated production session may be used only through ordinary runtime interaction", "GEN2-COMPAT-OBS-001 remains paused and V1 paths remain unchanged"], diff --git a/modules/interaction/domain/gen2_context.go b/modules/interaction/domain/gen2_context.go index 21a6e50..d7172ec 100644 --- a/modules/interaction/domain/gen2_context.go +++ b/modules/interaction/domain/gen2_context.go @@ -1,11 +1,31 @@ package domain -import "git.cin.su/m/Assistant/contracts/interaction" +import ( + "sort" + "strings" + + "git.cin.su/m/Assistant/contracts/interaction" +) + +const ( + gen2ContextMaxCandidates = 4 + gen2ContextMaxBytes = 2048 +) + +type ContextFragment struct { + UtteranceID interaction.HumanUtteranceID + Text string + ScopeRef string + Relevance int + Historical bool +} type ContextProjection struct { Text string Source string UsedPersonalMemoryFallback bool + Fragments []ContextFragment + Historical []ContextFragment } // BuildGen2Context is a read-side projection over canonical utterance history. @@ -19,6 +39,14 @@ func BuildGen2Context(history []interaction.HumanUtterance) ContextProjection { // filter prevents accidental cross-principal projection when a mixed source // is passed during degraded operation. func BuildScopedGen2Context(history []interaction.HumanUtterance, principalID string) ContextProjection { + return BuildScopedGen2ContextForQuery(history, nil, principalID, "", "") +} + +// BuildScopedGen2ContextForQuery projects bounded, explicit human assertions +// from the canonical Interaction history. Principal and typed project refs are +// hard filters before relevance ranking. Older values remain lineage in +// Historical and are never represented as the current value. +func BuildScopedGen2ContextForQuery(history []interaction.HumanUtterance, references []interaction.ProjectReference, principalID, focusProjectID, query string) ContextProjection { if principalID != "" { scoped := make([]interaction.HumanUtterance, 0, len(history)) for _, item := range history { @@ -28,9 +56,182 @@ func BuildScopedGen2Context(history []interaction.HumanUtterance, principalID st } history = scoped } + projectRefs := make(map[interaction.HumanUtteranceID][]interaction.ProjectReference, len(references)) + for _, ref := range references { + projectRefs[ref.UtteranceID] = append(projectRefs[ref.UtteranceID], ref) + } + assertions := explicitAssertionCandidates(history, projectRefs, focusProjectID, query) + if len(assertions) > 0 { + current, historical := selectCurrentAssertions(assertions) + return contextProjectionFromAssertions(current, historical) + } text := CompilePersonalMemory(history).ContextText() if text != "" { return ContextProjection{Text: text, Source: "retrieval:interaction-utterances"} } return ContextProjection{Text: text, Source: "personal-memory-v1-fallback", UsedPersonalMemoryFallback: true} } + +type assertionCandidate struct { + fragment ContextFragment + subject string + observed int64 +} + +func explicitAssertionCandidates(history []interaction.HumanUtterance, projectRefs map[interaction.HumanUtteranceID][]interaction.ProjectReference, focusProjectID, query string) []assertionCandidate { + queryTokens := significantContextTokens(query) + result := make([]assertionCandidate, 0) + for _, utterance := range history { + subject, value, ok := explicitAssertion(utterance.Content) + if !ok { + continue + } + refs := projectRefs[utterance.ID] + scopeRef := "" + if focusProjectID != "" { + matched := false + for _, ref := range refs { + if string(ref.ProjectID) == focusProjectID { + matched = true + scopeRef = focusProjectID + break + } + } + if !matched { + continue + } + } else if len(refs) == 1 { + scopeRef = string(refs[0].ProjectID) + } + relevance := contextRelevance(subject, queryTokens) + if len(queryTokens) > 0 && relevance == 0 { + continue + } + result = append(result, assertionCandidate{ + fragment: ContextFragment{UtteranceID: utterance.ID, Text: subject + " = " + value, ScopeRef: scopeRef, Relevance: relevance}, + subject: normalizedContextSubject(subject), + observed: utterance.ReceivedAtUnixMillis, + }) + } + return result +} + +func selectCurrentAssertions(candidates []assertionCandidate) ([]ContextFragment, []ContextFragment) { + latest := make(map[string]assertionCandidate) + for _, candidate := range candidates { + current, exists := latest[candidate.subject] + if !exists || candidate.observed > current.observed || candidate.observed == current.observed && candidate.fragment.UtteranceID > current.fragment.UtteranceID { + latest[candidate.subject] = candidate + } + } + current := make([]ContextFragment, 0, len(latest)) + historical := make([]ContextFragment, 0, len(candidates)-len(latest)) + for _, candidate := range candidates { + selected := latest[candidate.subject] + if candidate.fragment.UtteranceID == selected.fragment.UtteranceID { + current = append(current, candidate.fragment) + continue + } + candidate.fragment.Historical = true + historical = append(historical, candidate.fragment) + } + sort.SliceStable(current, func(i, j int) bool { + if current[i].Relevance != current[j].Relevance { + return current[i].Relevance > current[j].Relevance + } + return current[i].UtteranceID > current[j].UtteranceID + }) + return current, historical +} + +func contextProjectionFromAssertions(current, historical []ContextFragment) ContextProjection { + selected := make([]ContextFragment, 0, len(current)) + text := make([]string, 0, len(current)) + usedBytes := 0 + for _, candidate := range current { + if len(selected) == gen2ContextMaxCandidates { + break + } + bytes := len([]byte(candidate.Text)) + if bytes == 0 || usedBytes+bytes > gen2ContextMaxBytes { + continue + } + usedBytes += bytes + selected = append(selected, candidate) + text = append(text, candidate.Text) + } + if len(selected) == 0 { + return ContextProjection{Source: "retrieval:interaction-assertions", Historical: historical} + } + return ContextProjection{Text: strings.Join(text, "\n"), Source: "retrieval:interaction-assertions", Fragments: selected, Historical: historical} +} + +func explicitAssertion(message string) (string, string, bool) { + text := strings.TrimSpace(StripAssistantVocative(message)) + if text == "" || strings.Contains(text, "?") { + return "", "", false + } + lower := strings.ToLower(text) + if index := strings.Index(lower, "запомни:"); index >= 0 { + text = strings.TrimSpace(text[index+len("запомни:"):]) + lower = strings.ToLower(text) + } + for _, prefix := range []string{"запомни:", "запомни", "помни:"} { + if strings.HasPrefix(lower, prefix) { + text = strings.TrimSpace(text[len(prefix):]) + break + } + } + for _, separator := range []string{"=", "—"} { + subject, value, found := strings.Cut(text, separator) + if !found { + continue + } + subject, value = strings.TrimSpace(subject), strings.TrimSpace(value) + if subject != "" && value != "" && len([]rune(subject)) <= 160 && len([]rune(value)) <= 512 { + return subject, value, true + } + } + return "", "", false +} + +func normalizedContextSubject(subject string) string { + return strings.Join(significantContextTokens(subject), " ") +} + +func significantContextTokens(value string) []string { + ignored := map[string]struct{}{"какой": {}, "какая": {}, "какое": {}, "какие": {}, "текущий": {}, "текущая": {}, "текущее": {}, "я": {}, "ты": {}, "назвал": {}, "назвала": {}, "сообщил": {}, "сообщила": {}} + result := make([]string, 0) + seen := make(map[string]struct{}) + for _, token := range semanticTokens(value) { + if len([]rune(token)) < 3 { + continue + } + if _, skip := ignored[token]; skip { + continue + } + if _, duplicate := seen[token]; duplicate { + continue + } + seen[token] = struct{}{} + result = append(result, token) + } + return result +} + +func contextRelevance(subject string, queryTokens []string) int { + if len(queryTokens) == 0 { + return 1 + } + subjectTokens := significantContextTokens(subject) + matched := 0 + for _, query := range queryTokens { + for _, token := range subjectTokens { + if query == token { + matched++ + break + } + } + } + return matched +} diff --git a/modules/interaction/domain/gen2_context_test.go b/modules/interaction/domain/gen2_context_test.go index 937df92..6d741d7 100644 --- a/modules/interaction/domain/gen2_context_test.go +++ b/modules/interaction/domain/gen2_context_test.go @@ -32,7 +32,61 @@ func TestGen2ContextProjectsExplicitRememberedAssertion(t *testing.T) { got := domain.BuildScopedGen2Context([]interaction.HumanUtterance{ {ID: "assertion", PrincipalID: "principal-1", Content: "Для этого разговора запомни: мой тестовый цвет — синий.", ReceivedAtUnixMillis: 1}, }, "principal-1") - if got.Source != "retrieval:interaction-utterances" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет — синий") { + if got.Source != "retrieval:interaction-assertions" || got.UsedPersonalMemoryFallback || !strings.Contains(got.Text, "тестовый цвет = синий") { t.Fatalf("remembered assertion was not projected: %#v", got) } } + +func TestGen2ContextSelectsArbitraryRelevantCurrentAssertion(t *testing.T) { + history := []interaction.HumanUtterance{ + {ID: "assertion", PrincipalID: "principal-1", Content: "контрольный код = ORBIT-731", ReceivedAtUnixMillis: 1}, + {ID: "turn-1", PrincipalID: "principal-1", Content: "Спасибо", ReceivedAtUnixMillis: 2}, + {ID: "turn-2", PrincipalID: "principal-1", Content: "Продолжим", ReceivedAtUnixMillis: 3}, + } + got := domain.BuildScopedGen2ContextForQuery(history, nil, "principal-1", "", "Какой контрольный код я назвал?") + if got.Source != "retrieval:interaction-assertions" || !strings.Contains(got.Text, "ORBIT-731") || len(got.Fragments) != 1 { + t.Fatalf("arbitrary assertion was not selected: %#v", got) + } +} + +func TestGen2ContextKeepsHistoricalAssertionOutOfCurrentProjection(t *testing.T) { + history := []interaction.HumanUtterance{ + {ID: "old", PrincipalID: "principal-1", Content: "контрольный код = OLD-111", ReceivedAtUnixMillis: 1}, + {ID: "new", PrincipalID: "principal-1", Content: "контрольный код = NEW-222", ReceivedAtUnixMillis: 2}, + } + got := domain.BuildScopedGen2ContextForQuery(history, nil, "principal-1", "", "Какой контрольный код?") + if strings.Contains(got.Text, "OLD-111") || !strings.Contains(got.Text, "NEW-222") || len(got.Historical) != 1 || !strings.Contains(got.Historical[0].Text, "OLD-111") { + t.Fatalf("current/historical assertion separation failed: %#v", got) + } +} + +func TestGen2ContextHardFiltersProjectReferencesBeforeRanking(t *testing.T) { + history := []interaction.HumanUtterance{ + {ID: "a", PrincipalID: "principal-1", Content: "контрольный код = ALPHA", ReceivedAtUnixMillis: 1}, + {ID: "b", PrincipalID: "principal-1", Content: "контрольный код = BETA", ReceivedAtUnixMillis: 2}, + } + references := []interaction.ProjectReference{ + {ID: "ref-a", UtteranceID: "a", ProjectID: "company-a"}, + {ID: "ref-b", UtteranceID: "b", ProjectID: "company-b"}, + } + for projectID, expected := range map[string]struct{ want, absent string }{ + "company-a": {want: "ALPHA", absent: "BETA"}, + "company-b": {want: "BETA", absent: "ALPHA"}, + } { + got := domain.BuildScopedGen2ContextForQuery(history, references, "principal-1", projectID, "Какой контрольный код?") + if !strings.Contains(got.Text, expected.want) || strings.Contains(got.Text, expected.absent) || len(got.Fragments) != 1 || got.Fragments[0].ScopeRef != projectID { + t.Fatalf("scope %s leaked context: %#v", projectID, got) + } + } +} + +func TestGen2ContextExcludesIrrelevantAssertionsFromBoundedProjection(t *testing.T) { + history := []interaction.HumanUtterance{ + {ID: "code", PrincipalID: "principal-1", Content: "контрольный код = ORBIT-731", ReceivedAtUnixMillis: 1}, + {ID: "city", PrincipalID: "principal-1", Content: "город встречи = Казань", ReceivedAtUnixMillis: 2}, + } + got := domain.BuildScopedGen2ContextForQuery(history, nil, "principal-1", "", "Какой контрольный код?") + if strings.Contains(got.Text, "Казань") || len(got.Fragments) != 1 { + t.Fatalf("irrelevant assertion entered bounded projection: %#v", got) + } +}