Files
Assistant/modules/interaction/domain/gen2_context.go
T

253 lines
8.4 KiB
Go

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
}