From 4e3d153557adcd1e008762bd624c30a6a6b8570a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Wed, 26 Aug 2026 04:31:18 +0300 Subject: [PATCH] Route household knowledge through cognition --- cmd/core-runtime/cognitive_interpretation.go | 48 +++++++++++++++++++ .../cognitive_interpretation_test.go | 22 +++++++++ cmd/core-runtime/main.go | 34 +++++++++---- cmd/core-runtime/main_test.go | 10 ++-- modules/interaction/domain/semantics_test.go | 10 ++++ modules/interaction/domain/travel.go | 26 ++++++++++ 6 files changed, 136 insertions(+), 14 deletions(-) diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index ddb88bc..6489c2f 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -82,6 +82,54 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi return strings.TrimSpace(result.AdvisoryText), nil } +// AnswerStableKnowledge asks the local cognitive route for a short answer to a +// non-current question only after evidence retrieval did not produce a usable +// result. The model decides whether its knowledge is stable enough; it must +// explicitly decline changing or source-dependent facts instead of guessing. +func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context, session identity.CreatorSession, userMessage, personalMemory string, meaning cognitive.TaskMeaning) (string, error) { + requestID, err := ids.New() + if err != nil { + return "", err + } + prompt := "Ты отвечаешь как Домовой на обычный вопрос пользователя. Сначала пойми цель вопроса и верни короткий естественный ответ по устойчивому общеизвестному знанию. Не называй модель или внутреннюю систему. Если истинность ответа зависит от текущего наблюдения, внешнего источника или инструмента, верни ровно NEEDS_VERIFIED_RESEARCH. Не придумывай факты.\n\nВопрос пользователя:\n" + userMessage + 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, Context: context, Locale: "ru", MaxOutputBytes: 1024, DataPolicy: capability.DataPolicyLocalOnly, + Meaning: cognitive.TaskMeaning{SpeechAct: "STABLE_KNOWLEDGE_ANSWER", CapabilityHints: meaning.CapabilityHints, RequestedOutcome: meaning.RequestedOutcome, InformationRequestKind: meaning.InformationRequestKind, ConfidenceMillis: meaning.ConfidenceMillis}, + }) + if err != nil { + return "", err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload)) + if err != nil { + return "", err + } + request.Header.Set("Content-Type", "application/json") + response, err := c.client.Do(request) + if err != nil { + return "", err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return "", fmt.Errorf("cognitive stable knowledge status %d", response.StatusCode) + } + var result cognitive.GatewayAdvisoryResponse + if err := json.NewDecoder(response.Body).Decode(&result); err != nil { + return "", err + } + if result.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(result.AdvisoryText) == "" { + return "", fmt.Errorf("cognitive stable knowledge incomplete: %s", result.FinishReason) + } + return strings.TrimSpace(result.AdvisoryText), nil +} + func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) { base, err := url.Parse(rawURL) if err != nil || base.Scheme == "" || base.Host == "" { diff --git a/cmd/core-runtime/cognitive_interpretation_test.go b/cmd/core-runtime/cognitive_interpretation_test.go index 74f6c83..db19f43 100644 --- a/cmd/core-runtime/cognitive_interpretation_test.go +++ b/cmd/core-runtime/cognitive_interpretation_test.go @@ -45,6 +45,28 @@ func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing. } } +func TestAnswerStableKnowledgeUsesLocalOnlyCognitiveRoute(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request cognitive.GatewayAdvisoryRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode stable knowledge request: %v", err) + } + if request.DataPolicy != capability.DataPolicyLocalOnly || request.Meaning.SpeechAct != "STABLE_KNOWLEDGE_ANSWER" { + t.Fatalf("unexpected stable knowledge request: %+v", request) + } + _ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Санкт-Петербург — город в России.", FinishReason: cognitive.FinishCompleted}) + })) + defer server.Close() + client, err := newCognitiveInterpretationClient(server.URL, server.Client()) + if err != nil { + t.Fatal(err) + } + answer, err := client.AnswerStableKnowledge(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "Питер — это где?", "", cognitive.TaskMeaning{SpeechAct: "QUESTION"}) + if err != nil || answer != "Санкт-Петербург — город в России." { + t.Fatalf("answer=%q err=%v", answer, err) + } +} + func TestGenerateImageUsesDedicatedCognitiveMediaRoute(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/v1/multimodal/generate-image" { diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 88124d9..7ecd8f4 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -821,8 +821,18 @@ func main() { Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID(fmt.Sprintf("plan:%s:%d", planID, index)), Question: searchQuery, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"}, PropositionKey: fmt.Sprintf("question:%s:%d", utterance.ID, index), Query: searchQuery, }) - cancelResearch() reply, backed := renderResearchReply(searchQuery, result, researchErr) + if !backed && cognitiveAvailable { + answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second) + answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, part, personalMemory.ContextText(), cognitive.TaskMeaning{ + SpeechAct: string(semantic.Interpretation.SpeechAct), CapabilityHints: toCapabilityKinds(semantic.CapabilityHints), RequestedOutcome: semantic.Interpretation.RequestedOutcome, InformationRequestKind: semantic.InformationRequestKind, ConfidenceMillis: semantic.Interpretation.ConfidenceMillis, + }) + cancelAnswer() + if answerErr == nil && stableKnowledgeAnswer(answer) { + reply, backed = answer, true + } + } + cancelResearch() if backed { researchBacked = true } @@ -1791,12 +1801,10 @@ func renderResearchReply(query string, result epistemics.ResearchResult, researc if nearestFlightQuery(query) && !containsFlightTimeEvidence(strings.Join(lines, " ")) { return "Нашёл расписание по маршруту, но точное время ближайшего вылета в доступных фрагментах не указано. Откройте источник и выберите ближайший рейс:\n\n" + strings.Join(lines, "\n\n"), true } - reply := "Нашёл проверяемые данные:\n\n" + strings.Join(lines, "\n\n") - lowerQuery := strings.ToLower(query) - if (strings.Contains(lowerQuery, "цен") || strings.Contains(lowerQuery, "стоим")) && !containsPriceEvidence(reply) { - reply = "Актуальная цена в найденных фрагментах не указана, поэтому не буду придумывать сумму. Вот страницы, где её можно проверить:\n" + reply + if trainScheduleQuery(query) && !containsScheduleTimeEvidence(strings.Join(lines, " ")) { + return "Нашёл страницу расписания по маршруту, но в доступных фрагментах нет точного времени отправления:\n\n" + strings.Join(lines, "\n\n"), true } - return reply, true + return "Нашёл проверяемые данные:\n\n" + strings.Join(lines, "\n\n"), true } func isInternetStatusQuery(query string) bool { @@ -1967,9 +1975,17 @@ func humanResearchExcerpt(query, raw string) string { return text } -func containsPriceEvidence(value string) bool { - pricePattern := regexp.MustCompile(`(?i)(\d[\d\s.,]{2,})(?:\s*(?:₽|руб\.?|р\.?|usd|\$|€))`) - return pricePattern.MatchString(value) +func trainScheduleQuery(query string) bool { + lower := strings.ToLower(query) + return strings.Contains(lower, "поезд") && strings.Contains(lower, "время отправления") +} + +func containsScheduleTimeEvidence(value string) bool { + return regexp.MustCompile(`\b(?:[01]?\d|2[0-3]):[0-5]\d\b`).MatchString(value) +} + +func stableKnowledgeAnswer(answer string) bool { + return strings.TrimSpace(answer) != "" && strings.TrimSpace(answer) != "NEEDS_VERIFIED_RESEARCH" } func reviewPreservesResearchEvidence(reviewed, draft string) bool { diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index 5744fbe..de31027 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -149,12 +149,12 @@ func TestHumanMemoryInventoryUsesNewestPreference(t *testing.T) { } } -func TestContainsPriceEvidenceDistinguishesLinksFromPrices(t *testing.T) { - if containsPriceEvidence("RTX 4090 — market.yandex.ru") { - t.Fatal("a product link is not price evidence") +func TestContainsScheduleTimeEvidence(t *testing.T) { + if containsScheduleTimeEvidence("расписание поездов и цена билета") { + t.Fatal("a price or schedule label is not a departure time") } - if !containsPriceEvidence("Цена: 189 990 ₽") { - t.Fatal("expected a currency amount to count as price evidence") + if !containsScheduleTimeEvidence("Отправление в 07:45") { + t.Fatal("expected an explicit departure time to be recognized") } } diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index efb076f..c2c7836 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -226,6 +226,16 @@ func TestNearestPlaneUsesTravelContextAndCurrentDay(t *testing.T) { } } +func TestTravelResearchQueryUsesKnownOriginForTrainSchedule(t *testing.T) { + now := time.Date(2026, time.August, 26, 9, 0, 0, 0, time.UTC) + history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}} + got := TravelResearchQuery("найди поезд до Петербурга", history, now) + want := "расписание поездов из Москве в Петербурга время отправления сегодня 26.08.2026" + if got != want { + t.Fatalf("train schedule query lost route context or primary outcome: %q", got) + } +} + func TestIndependentRequestPartsKeepsCoordinatedEntitiesTogether(t *testing.T) { if got := IndependentRequestParts("какая разница во времени между Москвой и Шанхаем?"); len(got) != 1 { t.Fatalf("coordinated entities became independent requests: %#v", got) diff --git a/modules/interaction/domain/travel.go b/modules/interaction/domain/travel.go index 53d5765..f458331 100644 --- a/modules/interaction/domain/travel.go +++ b/modules/interaction/domain/travel.go @@ -46,6 +46,12 @@ func TravelResearchQuery(message string, history []interaction.HumanUtterance, n if origin != "" && !containsStemInTokens(tokens, "из", "откуда", "от") { query += " отправление из " + origin } + } else if trainScheduleRequest(tokens) { + origin := travelContextOrigin(history) + destination := travelContextDestinationFromTokens(tokens) + if origin != "" && destination != "" { + return trainScheduleResearchQuery(origin, destination, now, containsStemInTokens(tokens, "ближ")) + } } else if travelCue(tokens) && !containsStemInTokens(tokens, "из", "откуда", "от") { origin := travelContextOrigin(history) destination := travelContextDestinationFromTokens(tokens) @@ -61,6 +67,21 @@ func TravelResearchQuery(message string, history []interaction.HumanUtterance, n return query } +// trainScheduleResearchQuery formulates an observed schedule request from the +// user's route and remembered origin. It contains no city, provider or answer +// catalogue: actual departure data remains evidence from the selected source. +func trainScheduleResearchQuery(origin, destination string, now time.Time, nearest bool) string { + prefix := "расписание поездов" + if nearest { + prefix = "ближайшие поезда" + } + query := prefix + " из " + origin + " в " + destination + " время отправления" + if !now.IsZero() { + query += " сегодня " + now.Format("02.01.2006") + } + return query +} + // nearestAirTravelSearchQuery is a capability-local search formulation, not a // provider or place lookup. It removes conversational imperatives while // preserving the two user-derived route endpoints and the implicit current-day @@ -401,6 +422,11 @@ func travelCue(tokens []string) bool { return containsStemInTokens(tokens, "билет", "рейс", "самолет", "самолёт", "вылет", "поезд", "автобус", "перелет", "перелёт") } +func trainScheduleRequest(tokens []string) bool { + return containsStemInTokens(tokens, "поезд") && + (containsStemInTokens(tokens, "найд", "ближ", "расписан", "отправ", "прибуд", "едет", "едут") || len(tokens) > 1) +} + func nearestAirTravelRequest(tokens []string) bool { return containsStemInTokens(tokens, "ближ") && containsStemInTokens(tokens, "самолет", "самолёт", "авиарейс", "перелет", "перелёт") }