Compile personal facts for contextual assistance #386

Merged
m merged 2 commits from codex/household-travel-query-fix into main 2026-08-25 19:51:48 +00:00
9 changed files with 196 additions and 34 deletions
+8
View File
@@ -72,3 +72,11 @@ func hasChatRoute(routes []chatRoute, kind chatRouteKind) bool {
}
return false
}
// specializedRouteBypassesGeneralInterpretation keeps a concrete capability
// route stable. General cognition may still be used later to assess evidence,
// but it must not replace an executable research or media route with a vague
// conversational clarification before the capability is attempted.
func specializedRouteBypassesGeneralInterpretation(routes []chatRoute) bool {
return hasChatRoute(routes, chatRouteMedia) || hasChatRoute(routes, chatRouteResearch)
}
@@ -39,3 +39,15 @@ func TestBuildChatRoutePlanAlwaysKeepsLocalConversationRoute(t *testing.T) {
t.Fatalf("unexpected local route: %+v", planned)
}
}
func TestSpecializedRouteBypassesGeneralInterpretation(t *testing.T) {
if !specializedRouteBypassesGeneralInterpretation([]chatRoute{{Kind: chatRouteResearch}}) {
t.Fatal("research route must execute before general cognition")
}
if !specializedRouteBypassesGeneralInterpretation([]chatRoute{{Kind: chatRouteMedia}}) {
t.Fatal("media route must execute before general cognition")
}
if specializedRouteBypassesGeneralInterpretation([]chatRoute{{Kind: chatRouteConversation}}) {
t.Fatal("conversation-only route must retain general cognition")
}
}
+6 -2
View File
@@ -31,19 +31,23 @@ type cognitiveInterpretationClient struct {
// ReviewResponse asks the cognitive gateway to turn a locally assembled draft
// into a natural answer. The draft remains the safe fallback when cognition is
// unavailable; the model does not mutate canonical state or execute actions.
func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, session identity.CreatorSession, userMessage, draft string, meaning cognitive.TaskMeaning) (string, error) {
func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, session identity.CreatorSession, userMessage, draft, personalMemory string, meaning cognitive.TaskMeaning) (string, error) {
requestID, err := ids.New()
if err != nil {
return "", err
}
prompt := "Ты редактор ответа Домового. Проверь черновик по смыслу и подготовь естественный ответ человеку. Учитывай весь запрос, включая недостающие параметры для конкретного результата. Если данных не хватает, задай один короткий полезный уточняющий вопрос, например откуда и куда ехать. Не выдавай поисковые сниппеты как выполненное действие и не показывай служебные URL поисковой выдачи. Сохрани проверяемые факты, названия источников, прямые ссылки и ограничения. Не выдумывай факты и не утверждай невыполненные действия. Верни только готовый ответ без разбора проверки, служебных слов и упоминания моделей. Если черновик уже адекватен, верни его без лишних добавлений.\n\nСообщение пользователя:\n" + userMessage + "\n\nЧерновик ответа:\n" + draft
context := []cognitive.ContextExcerpt{}
if strings.TrimSpace(personalMemory) != "" {
context = append(context, cognitive.ContextExcerpt{ID: "personal-memory", SourceKind: "derived-personal-memory", SourceID: "interaction:human-utterances", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: personalMemory})
}
target := *c.baseURL
target.Path = "/v1/advisories"
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
ActorID: session.ActorID, PrincipalID: session.PrincipalID,
ScopeID: authority.ScopeID("domovoy-global-scope"), Message: prompt,
Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
Context: context, Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
Meaning: cognitive.TaskMeaning{
SpeechAct: "RESPONSE_REVIEW",
CapabilityHints: meaning.CapabilityHints,
@@ -25,6 +25,9 @@ func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing.
if request.Message == "" || request.Meaning.SpeechAct == "" {
t.Fatalf("review request did not carry semantic context: %+v", request)
}
if len(request.Context) != 1 || !strings.Contains(request.Context[0].Text, "красный цвет") {
t.Fatalf("review request did not carry compact personal memory: %+v", request.Context)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Меня зовут Дед. Я Домовой.", FinishReason: cognitive.FinishCompleted})
}))
@@ -33,7 +36,7 @@ func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing.
if err != nil {
t.Fatal(err)
}
reviewed, err := client.ReviewResponse(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "кто ты?", "Я — Домовой.", cognitive.TaskMeaning{SpeechAct: "QUESTION"})
reviewed, err := client.ReviewResponse(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "кто ты?", "Я — Домовой.", "Предпочтение пользователя (его сообщение): красный цвет", cognitive.TaskMeaning{SpeechAct: "QUESTION"})
if err != nil {
t.Fatal(err)
}
+6 -5
View File
@@ -533,6 +533,7 @@ func main() {
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
}
conversationHistory = append(conversationHistory, recentUserHistory...)
personalMemory := interactiondomain.CompilePersonalMemory(conversationHistory)
if !creativeUserRequest && locationReply == "" {
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
continuationMessage = continuation
@@ -653,10 +654,10 @@ func main() {
cognitiveAvailable := true
semanticInterpretationRequested := shouldRequestSemanticInterpretation(semantic)
semanticReflexAudit := shouldAuditDeterministicReflex(semantic, string(utterance.ID), now)
// A selected specialized media route already has an executable semantic
// outcome. Do not block it on the general cognitive channel: image
// generation must reach its own adapter even when cognition is degraded.
if hasChatRoute(routePlan, chatRouteMedia) {
// A selected specialized route already has an executable semantic outcome.
// Do not block it on the general cognitive channel: research and image
// generation must reach their own adapters even when cognition is degraded.
if specializedRouteBypassesGeneralInterpretation(routePlan) {
semanticInterpretationRequested = false
semanticReflexAudit = false
}
@@ -959,7 +960,7 @@ func main() {
}
if strings.TrimSpace(planningReply) != "" {
reviewContext, cancelReview := context.WithTimeout(r.Context(), 12*time.Second)
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, cognitive.TaskMeaning{
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, personalMemory.ContextText(), cognitive.TaskMeaning{
SpeechAct: string(semantic.Interpretation.SpeechAct),
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
+2 -8
View File
@@ -701,14 +701,8 @@ func informationRequestKind(message string, candidate interaction.SemanticCandid
}
func LocationContextReply(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]) + "."
}
if location := personalCurrentLocation(message); location != "" {
return "Запомнил: сейчас вы в " + location + "."
}
return ""
}
@@ -0,0 +1,127 @@
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
Value string
ConfidenceMillis int64
SourceUtteranceID interaction.HumanUtteranceID
ObservedAtUnixMillis int64
}
const personalMemoryCurrentLocation = "CURRENT_LOCATION"
// 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",
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
}
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")
}
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 {
return value
}
}
return ""
}
@@ -0,0 +1,30 @@
package domain
import (
"strings"
"testing"
"git.cin.su/m/Assistant/contracts/interaction"
)
func TestCompilePersonalMemoryKeepsLatestLocationAndPreferences(t *testing.T) {
memory := CompilePersonalMemory([]interaction.HumanUtterance{
{ID: "location-old", Content: "я сейчас в Санкт-Петербурге", ReceivedAtUnixMillis: 100},
{ID: "color", Content: "мне нравится красный цвет", ReceivedAtUnixMillis: 200},
{ID: "location-new", Content: "я сейчас в Москве", ReceivedAtUnixMillis: 300},
})
if got := memory.CurrentLocation(); got != "Москве" {
t.Fatalf("current location = %q", got)
}
context := memory.ContextText()
if !strings.Contains(context, "Москве") || !strings.Contains(context, "красный цвет") || strings.Contains(context, "Санкт-Петербурге") {
t.Fatalf("unexpected compact memory context: %q", context)
}
}
func TestCompilePersonalMemoryDoesNotTreatThirdPartyStatementAsPersonalFact(t *testing.T) {
memory := CompilePersonalMemory([]interaction.HumanUtterance{{Content: "Погода сейчас в Москве", ReceivedAtUnixMillis: 100}})
if memory.CurrentLocation() != "" || memory.ContextText() != "" {
t.Fatalf("ordinary factual question became personal memory: %+v", memory)
}
}
+1 -18
View File
@@ -352,24 +352,7 @@ func travelContextDestination(history []interaction.HumanUtterance) string {
}
func travelContextOrigin(history []interaction.HumanUtterance) string {
latestAt := int64(-1)
latest := ""
for _, utterance := range history {
tokens := semanticTokens(utterance.Content)
if len(tokens) < 3 || tokens[0] != "я" || !containsStemInTokens(tokens, "сейчас", "нахож", "жив") {
continue
}
for tokenIndex, token := range tokens {
if (token == "в" || token == "из") && tokenIndex+1 < len(tokens) {
if utterance.ReceivedAtUnixMillis >= latestAt {
latestAt = utterance.ReceivedAtUnixMillis
latest = capitalizeWord(tokens[tokenIndex+1])
}
break
}
}
}
return latest
return CompilePersonalMemory(history).CurrentLocation()
}
func travelContextCue(tokens []string) bool {