Files
m 49b3165caa
CI / test (push) Failing after 28s
normalize marked memory subjects
2026-08-30 00:43:07 +03:00

218 lines
7.6 KiB
Go

package domain
import (
"sort"
"strings"
"git.cin.su/m/Assistant/contracts/interaction"
)
// PersonalMemory is a rebuildable context projection. Its source of truth is
// the append-only human utterance history; it is deliberately not a second
// canonical store. Each fact retains the source utterance and confidence so a
// later cognitive or capability route can distinguish a human statement from
// an externally verified fact.
type PersonalMemory struct {
Facts []PersonalMemoryFact
}
type PersonalMemoryFact struct {
Kind string
Subject string
Value string
ConfidenceMillis int64
SourceUtteranceID interaction.HumanUtteranceID
ObservedAtUnixMillis int64
}
const personalMemoryCurrentLocation = "CURRENT_LOCATION"
// personalMemoryMaxValueRunes keeps the compatibility projection bounded.
// Canonical utterances remain complete in storage; only the rebuildable
// cognitive context is capped so old long-form instructions cannot become a
// provider prompt or crowd out a current household fact.
const personalMemoryMaxValueRunes = 512
// CompilePersonalMemory turns direct, first-person human statements into a
// small typed context. It deliberately preserves preferences as statements
// rather than pretending that an arbitrary preference is a verified fact.
func CompilePersonalMemory(history []interaction.HumanUtterance) PersonalMemory {
latestLocation := PersonalMemoryFact{}
preferences := make([]PersonalMemoryFact, 0, 4)
for _, utterance := range history {
if location := personalCurrentLocation(utterance.Content); location != "" {
fact := PersonalMemoryFact{
Kind: personalMemoryCurrentLocation,
Value: location,
ConfidenceMillis: 950,
SourceUtteranceID: utterance.ID,
ObservedAtUnixMillis: utterance.ReceivedAtUnixMillis,
}
if fact.ObservedAtUnixMillis >= latestLocation.ObservedAtUnixMillis {
latestLocation = fact
}
}
if preference := personalPreference(utterance.Content); preference != "" {
preferences = append(preferences, PersonalMemoryFact{
Kind: "PREFERENCE",
Subject: preferenceSubject(preference),
Value: preference,
ConfidenceMillis: 900,
SourceUtteranceID: utterance.ID,
ObservedAtUnixMillis: utterance.ReceivedAtUnixMillis,
})
}
}
result := PersonalMemory{Facts: make([]PersonalMemoryFact, 0, 1+len(preferences))}
if latestLocation.Kind != "" {
result.Facts = append(result.Facts, latestLocation)
}
sort.SliceStable(preferences, func(left, right int) bool {
return preferences[left].ObservedAtUnixMillis > preferences[right].ObservedAtUnixMillis
})
if len(preferences) > 8 {
preferences = preferences[:8]
}
result.Facts = append(result.Facts, preferences...)
return result
}
// LatestPreference returns the current value for a semantic subject while
// retaining older facts for historical evidence.
func (m PersonalMemory) LatestPreference(subject string) (PersonalMemoryFact, bool) {
var latest PersonalMemoryFact
for _, fact := range m.Facts {
if fact.Kind != "PREFERENCE" || fact.Subject != subject {
continue
}
if latest.Kind == "" || fact.ObservedAtUnixMillis >= latest.ObservedAtUnixMillis {
latest = fact
}
}
return latest, latest.Kind != ""
}
func (m PersonalMemory) CurrentLocation() string {
for _, fact := range m.Facts {
if fact.Kind == personalMemoryCurrentLocation {
return fact.Value
}
}
return ""
}
// ContextText is compact, source-aware context for a cognitive or skill
// request. It contains facts rather than a copied chat transcript.
func (m PersonalMemory) ContextText() string {
lines := make([]string, 0, len(m.Facts))
for _, fact := range m.Facts {
value := strings.TrimSpace(fact.Value)
if value == "" {
continue
}
switch fact.Kind {
case personalMemoryCurrentLocation:
lines = append(lines, "Текущее место пользователя (его сообщение): "+value)
case "PREFERENCE":
lines = append(lines, "Предпочтение пользователя (его сообщение): "+value)
}
}
return strings.Join(lines, "\n")
}
// RecallPersonalPreference answers the narrow, read-only memory question from
// the rebuildable projection. It is intentionally conservative: only an
// explicit question about the remembered test color is handled here; other
// questions continue through the normal cognitive route.
func RecallPersonalPreference(history []interaction.HumanUtterance, question string) string {
lowerQuestion := strings.ToLower(question)
if !strings.Contains(lowerQuestion, "цвет") || (!strings.Contains(lowerQuestion, "какой") && !strings.Contains(lowerQuestion, "назвал")) {
return ""
}
memory := CompilePersonalMemory(history)
for _, fact := range memory.Facts {
if fact.Kind != "PREFERENCE" || fact.Subject != "тестовый цвет" {
continue
}
current, ok := memory.LatestPreference(fact.Subject)
if !ok || current.SourceUtteranceID != fact.SourceUtteranceID {
continue
}
value := strings.TrimSpace(current.Value)
if separator := strings.Index(value, "—"); separator >= 0 {
value = strings.TrimSpace(value[separator+len("—"):])
}
if value != "" {
return "Вы называли тестовый цвет: " + value + "."
}
}
return ""
}
func preferenceSubject(preference string) string {
if separator := strings.Index(preference, "—"); separator >= 0 {
preference = preference[:separator]
}
subject := strings.TrimSpace(preference)
// Operational markers belong to the preference value, not a new subject.
if strings.HasPrefix(strings.ToLower(subject), "тестовый цвет") {
return "тестовый цвет"
}
return subject
}
func personalCurrentLocation(message string) string {
tokens := semanticTokens(message)
if len(tokens) < 4 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
return ""
}
for index, token := range tokens {
if (token == "в" || token == "на") && index+1 < len(tokens) {
return capitalizeWord(tokens[index+1])
}
}
return ""
}
func personalPreference(message string) string {
lower := strings.ToLower(strings.TrimSpace(message))
for _, cue := range []string{"мне нравится", "я люблю"} {
index := strings.Index(lower, cue)
if index < 0 {
continue
}
value := strings.TrimSpace(lower[index+len(cue):])
value = strings.Trim(value, " .,!?:;—-")
if len([]rune(value)) >= 2 && len([]rune(value)) <= personalMemoryMaxValueRunes {
return value
}
}
// Explicit remembered assertions are still human-provided context, not
// verified facts. Project the compact key/value statement without storing a
// second copy outside the canonical utterance history.
if index := strings.Index(lower, "запомни"); index >= 0 {
remembered := strings.TrimSpace(lower[index+len("запомни"):])
remembered = strings.TrimLeft(remembered, " :—-")
for _, prefix := range []string{"мой ", "моя ", "моё ", "мое "} {
if !strings.HasPrefix(remembered, prefix) {
continue
}
statement := strings.TrimSpace(remembered[len(prefix):])
separator := strings.IndexAny(statement, "—-:")
if separator <= 0 || separator >= len(statement)-1 {
continue
}
key := strings.TrimSpace(statement[:separator])
separatorWidth := 1
if strings.HasPrefix(statement[separator:], "—") {
separatorWidth = len("—")
}
value := strings.Trim(strings.TrimSpace(statement[separator+separatorWidth:]), " .,!?:;—-")
if key != "" && len([]rune(value)) >= 2 && len([]rune(value)) <= personalMemoryMaxValueRunes {
return key + " — " + value
}
}
}
return ""
}