diff --git a/cmd/core-runtime/chat_orchestrator.go b/cmd/core-runtime/chat_orchestrator.go index 8ee76ce..bcb551c 100644 --- a/cmd/core-runtime/chat_orchestrator.go +++ b/cmd/core-runtime/chat_orchestrator.go @@ -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) +} diff --git a/cmd/core-runtime/chat_orchestrator_test.go b/cmd/core-runtime/chat_orchestrator_test.go index a70150f..ed88618 100644 --- a/cmd/core-runtime/chat_orchestrator_test.go +++ b/cmd/core-runtime/chat_orchestrator_test.go @@ -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") + } +} diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index beed5a0..ddb88bc 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -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, diff --git a/cmd/core-runtime/cognitive_interpretation_test.go b/cmd/core-runtime/cognitive_interpretation_test.go index 169756e..74f6c83 100644 --- a/cmd/core-runtime/cognitive_interpretation_test.go +++ b/cmd/core-runtime/cognitive_interpretation_test.go @@ -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) } diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 32d3e5e..96247e9 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -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, diff --git a/modules/interaction/domain/chat.go b/modules/interaction/domain/chat.go index 3768987..412edf5 100644 --- a/modules/interaction/domain/chat.go +++ b/modules/interaction/domain/chat.go @@ -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 "" } diff --git a/modules/interaction/domain/personal_memory.go b/modules/interaction/domain/personal_memory.go new file mode 100644 index 0000000..e759bd7 --- /dev/null +++ b/modules/interaction/domain/personal_memory.go @@ -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 "" +} diff --git a/modules/interaction/domain/personal_memory_test.go b/modules/interaction/domain/personal_memory_test.go new file mode 100644 index 0000000..986f405 --- /dev/null +++ b/modules/interaction/domain/personal_memory_test.go @@ -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) + } +} diff --git a/modules/interaction/domain/travel.go b/modules/interaction/domain/travel.go index 6cdd0d4..022202f 100644 --- a/modules/interaction/domain/travel.go +++ b/modules/interaction/domain/travel.go @@ -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 {