Compile personal facts for contextual assistance #386
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user