Route household knowledge and travel evidence through cognition #394
@@ -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 == "" {
|
||||
|
||||
@@ -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" {
|
||||
|
||||
+55
-12
@@ -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 {
|
||||
@@ -1818,6 +1826,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 +1858,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
|
||||
@@ -1866,9 +1891,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)
|
||||
}
|
||||
@@ -1879,6 +1909,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.
|
||||
@@ -1940,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 {
|
||||
|
||||
@@ -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,
|
||||
@@ -82,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")
|
||||
@@ -130,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "Найди ближайший ресторан в Сочи" {
|
||||
@@ -217,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)
|
||||
|
||||
@@ -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
|
||||
@@ -236,6 +257,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 {
|
||||
@@ -391,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, "самолет", "самолёт", "авиарейс", "перелет", "перелёт")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user