From da5d06895bbccfe219f2170bd5f3dbc4b813a734 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Wed, 26 Aug 2026 04:17:07 +0300 Subject: [PATCH 1/3] Require destination evidence for ticket results --- cmd/core-runtime/main.go | 17 +++++++++++++++++ cmd/core-runtime/main_test.go | 12 ++++++++++++ modules/interaction/domain/semantics_test.go | 9 +++++++++ modules/interaction/domain/travel.go | 10 ++++++++++ 4 files changed, 48 insertions(+) diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index d0d8879..f2bd2a7 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -1818,6 +1818,9 @@ func researchFindingMatchesIntent(query string, finding epistemics.ResearchFindi if !findingContainsRequestedIdentifier(query, finding) { return false } + if ticketSearchQuery(query) && !ticketFindingMatchesDestination(query, finding) { + return false + } if !nearestFlightQuery(query) { return true } @@ -1847,6 +1850,20 @@ func researchFindingMatchesIntent(query string, finding epistemics.ResearchFindi return true } +func ticketSearchQuery(query string) bool { + return interactiondomain.TicketSearchDestination(query) != "" +} + +func ticketFindingMatchesDestination(query string, finding epistemics.ResearchFinding) bool { + destination := strings.ToLower(interactiondomain.TicketSearchDestination(query)) + if destination == "" { + return true + } + // The source title or URL may describe a category; human-facing proof must + // mention the requested destination in the actual displayed excerpt. + return strings.Contains(strings.ToLower(finding.Excerpt.Text), destination) +} + // findingContainsRequestedIdentifier prevents a generic landing page from // being presented as a result for a concrete model, article or brand request. // Identifiers are detected by their syntax, not by a catalogue of providers or diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index ca43c70..7ce510a 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -58,6 +58,18 @@ func TestNearestFlightEvidenceRequiresBothRouteEndpointsWhenKnown(t *testing.T) } } +func TestTicketReplyRejectsGenericCategoryPageWithoutDestination(t *testing.T) { + query := "найди билеты до Ялты сегодня" + generic := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Купить билеты", Locator: "https://tickets.example"}, Excerpt: epistemics.SourceExcerpt{Text: "Поиск авиабилетов среди множества направлений."}} + if researchFindingMatchesIntent(query, generic) { + t.Fatal("generic ticket category was presented as destination result") + } + relevant := epistemics.ResearchFinding{Source: epistemics.Source{Title: "Билеты"}, Excerpt: epistemics.SourceExcerpt{Text: "Билеты до Ялты: варианты на сегодня."}} + if !researchFindingMatchesIntent(query, relevant) { + t.Fatal("destination-specific ticket result was rejected") + } +} + func TestResearchReplyRejectsGenericLandingPageForConcreteIdentifier(t *testing.T) { result := epistemics.ResearchResult{Findings: []epistemics.ResearchFinding{{ State: epistemics.FindingSupported, diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index d88f309..efb076f 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -85,6 +85,15 @@ func TestLocalSearchClarification(t *testing.T) { } } +func TestTicketSearchDestinationUsesRequestDestination(t *testing.T) { + if got := TicketSearchDestination("найди билеты до Ялты сегодня"); got != "Ялты" { + t.Fatalf("ticket destination = %q", got) + } + if got := TicketSearchDestination("найди билеты"); got != "" { + t.Fatalf("destination invented without one: %q", got) + } +} + func TestContinueLocalSearch(t *testing.T) { history := []interaction.HumanUtterance{{Content: "Найди ближайший ресторан"}} if got := ContinueLocalSearch("в Сочи", history); got != "Найди ближайший ресторан в Сочи" { diff --git a/modules/interaction/domain/travel.go b/modules/interaction/domain/travel.go index bd35504..53d5765 100644 --- a/modules/interaction/domain/travel.go +++ b/modules/interaction/domain/travel.go @@ -236,6 +236,16 @@ func ContinueTravelDate(message string, history []interaction.HumanUtterance) st return strings.TrimSpace(latest + " " + current) } +// TicketSearchDestination extracts the destination from a ticket request +// without assigning any city or travel-provider meaning in core code. +func TicketSearchDestination(message string) string { + tokens := semanticTokens(StripAssistantVocative(message)) + if !containsStemInTokens(tokens, "билет") { + return "" + } + return travelContextDestinationFromTokens(tokens) +} + // TravelSearchClarification keeps an incomplete travel request in dialogue // instead of reducing it to an unrelated generic web search. func TravelSearchClarification(message string, history []interaction.HumanUtterance) string { -- 2.54.0 From f316f7ec3f522c6aa27d494a9e5f45d50bebfced Mon Sep 17 00:00:00 2001 From: Mikhail Date: Wed, 26 Aug 2026 04:20:15 +0300 Subject: [PATCH 2/3] Exclude dates from concrete research identifiers --- cmd/core-runtime/main.go | 16 +++++++++++++--- cmd/core-runtime/main_test.go | 7 +++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index f2bd2a7..88124d9 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -1883,9 +1883,14 @@ func findingContainsRequestedIdentifier(query string, finding epistemics.Researc } func requestedResearchIdentifiers(query string) []string { - identifiers := regexp.MustCompile(`(?i)[a-z0-9][a-z0-9._-]{3,}`).FindAllString(query, -1) - exact := make([]string, 0, len(identifiers)) - for _, identifier := range identifiers { + rawIdentifiers := regexp.MustCompile(`(?i)[a-z0-9][a-z0-9._-]{3,}`).FindAllString(query, -1) + identifiers := make([]string, 0, len(rawIdentifiers)) + exact := make([]string, 0, len(rawIdentifiers)) + for _, identifier := range rawIdentifiers { + if researchDateIdentifier(identifier) { + continue + } + identifiers = append(identifiers, identifier) if strings.ContainsAny(identifier, "._-") || strings.IndexFunc(identifier, func(r rune) bool { return r >= '0' && r <= '9' }) >= 0 { exact = append(exact, identifier) } @@ -1896,6 +1901,11 @@ func requestedResearchIdentifiers(query string) []string { return identifiers } +func researchDateIdentifier(value string) bool { + return regexp.MustCompile(`^\d{1,2}[.-]\d{1,2}[.-]\d{2,4}$`).MatchString(value) || + regexp.MustCompile(`^\d{4}[.-]\d{1,2}[.-]\d{1,2}$`).MatchString(value) +} + // nearestFlightRouteTerms retains only the two endpoint-shaped terms from a // structured flight search. It has no city or provider vocabulary: endpoint // relevance is determined from the request itself. diff --git a/cmd/core-runtime/main_test.go b/cmd/core-runtime/main_test.go index 7ce510a..5744fbe 100644 --- a/cmd/core-runtime/main_test.go +++ b/cmd/core-runtime/main_test.go @@ -94,6 +94,13 @@ func TestResearchReplyKeepsFindingContainingConcreteIdentifier(t *testing.T) { } } +func TestResearchIdentifiersExcludeSearchDate(t *testing.T) { + identifiers := requestedResearchIdentifiers("ближайший поезд в Петербург сегодня 26.08.2026") + if len(identifiers) != 0 { + t.Fatalf("search date was treated as a concrete identifier: %#v", identifiers) + } +} + func TestResponseReviewPreservesAttemptedResearchOutcome(t *testing.T) { if reviewMayReplacePlanningReply(false, true, false, "Уточните аэропорт.", "Пока не смог получить проверяемый ответ.") { t.Fatal("review replaced an honest unbacked research outcome") -- 2.54.0 From 4e3d153557adcd1e008762bd624c30a6a6b8570a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Wed, 26 Aug 2026 04:31:18 +0300 Subject: [PATCH 3/3] 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, "самолет", "самолёт", "авиарейс", "перелет", "перелёт") } -- 2.54.0