218 lines
7.6 KiB
Go
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 ""
|
|
}
|