diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index 6489c2f..90956b8 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -91,7 +91,7 @@ func (c cognitiveInterpretationClient) AnswerStableKnowledge(ctx context.Context if err != nil { return "", err } - prompt := "Ты отвечаешь как Домовой на обычный вопрос пользователя. Сначала пойми цель вопроса и верни короткий естественный ответ по устойчивому общеизвестному знанию. Не называй модель или внутреннюю систему. Если истинность ответа зависит от текущего наблюдения, внешнего источника или инструмента, верни ровно NEEDS_VERIFIED_RESEARCH. Не придумывай факты.\n\nВопрос пользователя:\n" + userMessage + 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}) diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 7ecd8f4..02a829e 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -860,6 +860,17 @@ func main() { needsCognition = false } } + if planningReply == "" && needsCognition && semantic.Interpretation.SpeechAct == interaction.SpeechActQuestion && !hasChatRoute(routePlan, chatRouteResearch) && !creativeRequest { + answerCtx, cancelAnswer := context.WithTimeout(r.Context(), 12*time.Second) + answer, answerErr := cognitiveClient.AnswerStableKnowledge(answerCtx, session, effectiveTaskMessage, 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) { + planningReply = answer + needsCognition = false + } + } if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable { planningReply = humanCognitiveDegradedReply(utterance.Content) needsCognition = false diff --git a/modules/interaction/domain/chat.go b/modules/interaction/domain/chat.go index 9ddc2a7..6e33f44 100644 --- a/modules/interaction/domain/chat.go +++ b/modules/interaction/domain/chat.go @@ -612,7 +612,7 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens [] currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо") observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2) placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац", "бренд", "товар", "артикул", "продавец", "маркетплейс", "wildberries", "wb") - travelSearchCue := containsStemInTokens(tokens, "билет", "рейс", "самолет", "самолёт", "вылет", "поезд", "автобус", "перелет", "перелёт", "отправлен", "прибыт", "расстоян", "километр", "добрат", "маршрут") + travelSearchCue := travelCue(tokens) || containsStemInTokens(tokens, "отправлен", "прибыт", "расстоян", "километр", "добрат", "маршрут") timeDifferenceCue := containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час") return currentCue || observedStateCue || placeSearchCue || travelSearchCue || timeDifferenceCue } @@ -622,13 +622,33 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens [] // source-dependent concepts such as weather. func approximateToken(tokens []string, target string, maxDistance int) bool { for _, token := range tokens { - if editDistance([]rune(token), []rune(target)) <= maxDistance { + if nearToken(token, target, maxDistance) { return true } } return false } +func nearToken(token, target string, maxDistance int) bool { + if editDistance([]rune(token), []rune(target)) <= maxDistance { + return true + } + if maxDistance < 1 { + return false + } + left, right := []rune(token), []rune(target) + if len(left) != len(right) || len(left) < 2 { + return false + } + for index := range left { + if left[index] == right[index] { + continue + } + return index+1 < len(left) && left[index] == right[index+1] && left[index+1] == right[index] && string(left[index+2:]) == string(right[index+2:]) + } + return false +} + func editDistance(left, right []rune) int { previous := make([]int, len(right)+1) for index := range previous { diff --git a/modules/interaction/domain/chat_test.go b/modules/interaction/domain/chat_test.go index 30a41e0..05b5b82 100644 --- a/modules/interaction/domain/chat_test.go +++ b/modules/interaction/domain/chat_test.go @@ -50,9 +50,11 @@ func TestNewHumanUtteranceClassifiesReminderAndCapabilityConnectionAsDirective(t } func TestPlaneRequestReceivesTravelResearchCapability(t *testing.T) { - candidate := BuildDeterministicCandidate("найди ближайший самолет до Казани", "", 100) - if !containsToken(candidate.CapabilityHints, "WEB_RESEARCH") { - t.Fatalf("plane request did not select web research: %+v", candidate) + for _, message := range []string{"найди ближайший самолет до Казани", "найди сомалет до Казани"} { + candidate := BuildDeterministicCandidate(message, "", 100) + if !containsToken(candidate.CapabilityHints, "WEB_RESEARCH") { + t.Fatalf("plane request did not select web research for %q: %+v", message, candidate) + } } } diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index c2c7836..3be1840 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -236,6 +236,14 @@ func TestTravelResearchQueryUsesKnownOriginForTrainSchedule(t *testing.T) { } } +func TestTravelResearchQueryNormalizesNearUnambiguousTravelTypo(t *testing.T) { + history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}} + got := TravelResearchQuery("найди сомалет до Казани", history, time.Time{}) + if !strings.Contains(got, "самолёт") || !strings.Contains(got, "пункт отправления: Москве") { + t.Fatalf("travel typo was not normalized with remembered route context: %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 f458331..c57e7fb 100644 --- a/modules/interaction/domain/travel.go +++ b/modules/interaction/domain/travel.go @@ -10,7 +10,7 @@ import ( // TravelSearchQuery carries the implicit current-day meaning of a nearest // flight request into the research adapter. func TravelSearchQuery(message string, now time.Time) string { - cleaned := strings.TrimSpace(StripAssistantVocative(message)) + cleaned := normalizedTravelSearchText(message) tokens := semanticTokens(cleaned) if !travelCue(tokens) || !containsStemInTokens(tokens, "ближ") || isTravelDateSpecified(tokens) { return cleaned @@ -21,6 +21,25 @@ func TravelSearchQuery(message string, now time.Time) string { return cleaned + " сегодня " + now.Format("02.01.2006") } +// normalizedTravelSearchText corrects only a near-unambiguous typo in a +// capability word before an external query. The original utterance is kept by +// Conversation; this is a replaceable search-skill normalization, not a fact +// about a user, city or provider. +func normalizedTravelSearchText(message string) string { + tokens := semanticTokens(StripAssistantVocative(message)) + changed := false + for index, token := range tokens { + if nearToken(token, "самолет", 2) || nearToken(token, "самолёт", 2) { + tokens[index] = "самолёт" + changed = true + } + } + if !changed { + return strings.TrimSpace(StripAssistantVocative(message)) + } + return strings.Join(tokens, " ") +} + // TravelResearchQuery keeps route and distance questions tied to a known // origin before they reach a generic web search. The history is semantic // context, not a list of example-specific answers. @@ -419,7 +438,7 @@ func capitalizeWord(value string) string { } func travelCue(tokens []string) bool { - return containsStemInTokens(tokens, "билет", "рейс", "самолет", "самолёт", "вылет", "поезд", "автобус", "перелет", "перелёт") + return containsStemInTokens(tokens, "билет", "рейс", "самолет", "самолёт", "вылет", "поезд", "автобус", "перелет", "перелёт") || approximateToken(tokens, "самолет", 2) || approximateToken(tokens, "самолёт", 2) } func trainScheduleRequest(tokens []string) bool {