package domain 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. // PersonalMemory remains a compatibility fallback and is never written here. func BuildGen2Context(history []interaction.HumanUtterance) ContextProjection { return BuildScopedGen2Context(history, "") } // BuildScopedGen2Context is the Gen2 retrieval boundary for household // context. The caller supplies already-authorized history; this defensive // 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 { if item.PrincipalID == "" || string(item.PrincipalID) == principalID { scoped = append(scoped, item) } } 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 corrects interaction.HumanUtteranceID } 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, corrects: utterance.CorrectsUtteranceID, }) } return result } func selectCurrentAssertions(candidates []assertionCandidate) ([]ContextFragment, []ContextFragment) { latest := make(map[string]assertionCandidate) for _, candidate := range candidates { current, exists := latest[candidate.subject] if !exists || supersedesAssertion(candidate, current) { 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 } // supersedesAssertion first respects an explicit correction relation, then // uses observation time only as a deterministic tie-breaker. Applicability is // already established by principal and typed Scope filtering before this step. func supersedesAssertion(candidate, current assertionCandidate) bool { if candidate.corrects == current.fragment.UtteranceID { return true } if current.corrects == candidate.fragment.UtteranceID { return false } return candidate.observed > current.observed || candidate.observed == current.observed && candidate.fragment.UtteranceID > current.fragment.UtteranceID } 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 }