253 lines
8.4 KiB
Go
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
|
|
}
|