package domain import ( "strings" "testing" "time" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/interaction" "git.cin.su/m/Assistant/contracts/situation" ) func TestInterpretUtteranceRecordsExplicitProjectFocus(t *testing.T) { result := interpretForTest(t, "Сейчас работаем с УПО.", nil, "") if result.ActiveFocus == nil || result.ActiveFocus.ProjectID != "upo" { t.Fatalf("expected explicit UPO focus, got %#v", result.ActiveFocus) } if result.Interpretation.SpeechAct != interaction.SpeechActAssertion { t.Fatalf("expected assertion, got %s", result.Interpretation.SpeechAct) } if result.Projects[0].Provenance != situation.ProjectResolutionExplicit { t.Fatalf("expected explicit provenance, got %#v", result.Projects) } } func TestInterpretUtteranceAssertionIsNotVerifiedFact(t *testing.T) { result := interpretForTest(t, "На сервере сейчас свободно 300 ГБ.", nil, "") if result.Interpretation.SpeechAct != interaction.SpeechActAssertion { t.Fatalf("expected assertion, got %s", result.Interpretation.SpeechAct) } if result.Interpretation.AssertionVerification != "UNVERIFIED" || !strings.Contains(result.Reply, "не проверенный факт") { t.Fatalf("expected unverified human assertion, got %#v reply=%q", result.Interpretation, result.Reply) } } func TestInterpretUtteranceNamingPreferenceIsAccepted(t *testing.T) { result := interpretForTest(t, "я буду называть тебя Дед, запомни ты дед", nil, "") if result.Interpretation.SpeechAct != interaction.SpeechActPreference { t.Fatalf("expected preference, got %s", result.Interpretation.SpeechAct) } if result.Reply != "Хорошо, зови меня Дед." { t.Fatalf("unexpected preference reply: %q", result.Reply) } } func TestInterpretUtteranceThanksHasNaturalConversationalReply(t *testing.T) { result := interpretForTest(t, "спасибо", nil, "") if result.Interpretation.SpeechAct != interaction.SpeechActConversational { t.Fatalf("expected conversational act, got %s", result.Interpretation.SpeechAct) } if result.Reply != "Пожалуйста." { t.Fatalf("unexpected thanks reply: %q", result.Reply) } } func TestStableConversationalReplySurvivesCognitiveRewrite(t *testing.T) { reply, stable := StableConversationalReply("спасибо", interaction.SpeechActConversational) if !stable || reply != "Пожалуйста." { t.Fatalf("unexpected stable conversational reply: stable=%t reply=%q", stable, reply) } } func TestStripAssistantVocative(t *testing.T) { if got := StripAssistantVocative("Дед, какая погода в Сочи?"); got != "какая погода в Сочи?" { t.Fatalf("unexpected cleaned query: %q", got) } if got := StripAssistantVocative("домовой: найди ресторан"); got != "найди ресторан" { t.Fatalf("unexpected cleaned query: %q", got) } } func TestIndependentRequestParts(t *testing.T) { parts := IndependentRequestParts("Дед, узнай погоду в Сочи и найди ресторан") if len(parts) != 2 || parts[0] != "узнай погоду в Сочи" || parts[1] != "найди ресторан" { t.Fatalf("unexpected request parts: %#v", parts) } } func TestLocalSearchClarification(t *testing.T) { if got := LocalSearchClarification("найди ближайший ресторан"); got == "" { t.Fatal("proximity search without place must ask for location") } if got := LocalSearchClarification("найди ближайший ресторан в Сочи"); got != "" { t.Fatalf("explicit place must proceed, got %q", got) } } 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 != "Найди ближайший ресторан в Сочи" { t.Fatalf("unexpected continuation: %q", got) } if got := ContinueLocalSearch("в Сочи", nil); got != "" { t.Fatalf("location must not create a goal without conversation context: %q", got) } } func TestContinueTravelOrigin(t *testing.T) { history := []interaction.HumanUtterance{{Content: "Как можно добраться до Сочи?"}} if got := ContinueTravelOrigin("я в Казани", history); got != "Как можно добраться до Сочи из Казани" { t.Fatalf("unexpected travel continuation: %q", got) } if got := ContinueTravelOrigin("я в Казани", nil); got != "" { t.Fatalf("location must not create a travel task without context: %q", got) } if got := ContinueTravelOrigin("Москва", history); got != "Как можно добраться до Сочи из города Москва" { t.Fatalf("bare city should complete travel origin: %q", got) } } func TestContinueTravelDate(t *testing.T) { history := []interaction.HumanUtterance{{Content: "Найди билеты до Ялты", ReceivedAtUnixMillis: 100}} if got := ContinueTravelDate("сегодня", history); got != "Найди билеты до Ялты сегодня" { t.Fatalf("date answer did not complete ticket search: %q", got) } if got := ContinueTravelDate("завтра", []interaction.HumanUtterance{{Content: "Найди билеты до Ялты сегодня", ReceivedAtUnixMillis: 100}}); got != "" { t.Fatalf("dated ticket search must not be continued again: %q", got) } if got := ContinueTravelDate("сегодня", []interaction.HumanUtterance{{Content: "Какая погода в Ялте?", ReceivedAtUnixMillis: 100}}); got != "" { t.Fatalf("date answer must not create travel work without travel context: %q", got) } } func TestTravelSearchClarification(t *testing.T) { if got := TravelSearchClarification("Найди билеты", nil); got == "" { t.Fatal("underspecified travel request must ask for missing route and date") } if got := TravelSearchClarification("Покажи ближайшие рейсы до Сочи", nil); got != "Из какого города вылетать?" { t.Fatalf("nearest-flight request should default date to now, got %q", got) } if got := TravelSearchClarification("Найди билеты из Москвы в Сочи на 12 сентября", nil); got != "" { t.Fatalf("complete travel request must proceed, got %q", got) } history := []interaction.HumanUtterance{{Content: "Найди билеты"}} if got := TravelSearchClarification("а куда летим?", history); got == "" { t.Fatal("follow-up must remain attached to the travel goal") } if got := TravelSearchClarification("какая погода в Сочи?", history); got != "" { t.Fatalf("unrelated question must not inherit travel goal, got %q", got) } for _, message := range []string{"нарисуй птицу", "расскажи сказку", "ты кто?", "выведи свою память"} { if got := TravelSearchClarification(message, history); got != "" { t.Fatalf("non-travel request %q inherited travel goal, got %q", message, got) } } if got := TravelSearchClarification("Как можно добраться до Сочи?", nil); got != "Из какого города выезжать в Сочи? Подберу самолёт, поезд, автобус или машину." { t.Fatalf("general travel question should ask departure city, got %q", got) } if got := TravelSearchClarification("Как можно добраться до Сочи из Казани", nil); got != "" { t.Fatalf("complete general travel route must proceed, got %q", got) } contextHistory := []interaction.HumanUtterance{{Content: "Как можно добраться до Сочи?"}} if got := TravelSearchClarification("Найди билеты, какой ближайший самолёт", contextHistory); got != "Из какого города вылетать в Сочи?" { t.Fatalf("travel destination should be inherited from context, got %q", got) } noisyContext := []interaction.HumanUtterance{ {Content: "Как можно добраться до Сочи?"}, {Content: "Какая погода в Питере?"}, } if got := TravelSearchClarification("Найди билеты", noisyContext); got != "Из какого города вылетать в Сочи и на какую дату?" { t.Fatalf("unrelated weather history must not replace travel destination, got %q", got) } } func TestTravelSearchQueryCarriesNearestFlightDate(t *testing.T) { now := time.Date(2026, time.August, 25, 18, 0, 0, 0, time.UTC) if got := TravelSearchQuery("какой ближайший рейс из Москвы в Казань", now); got != "какой ближайший рейс из Москвы в Казань сегодня 25.08.2026" { t.Fatalf("nearest flight query=%q", got) } if got := TravelSearchQuery("рейс из Москвы в Казань завтра", now); got != "рейс из Москвы в Казань завтра" { t.Fatalf("explicit date was changed: %q", got) } } func TestTravelResearchQueryUsesKnownOriginForDistance(t *testing.T) { history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}} got := TravelResearchQuery("сколько километров до Сочи?", history, time.Time{}) if got != "расстояние между Москве и Сочи в километрах по прямой и по дороге" { t.Fatalf("distance query lost route context: %q", got) } } func TestTravelResearchQueryCompilesLocalPlaceSearchWithoutSearchImperative(t *testing.T) { query := TravelResearchQuery("найди ближайший ресторан в Сочи", nil, time.Time{}) if strings.Contains(query, "найди") || strings.Contains(query, "ближайший") || !strings.Contains(query, "сочи") || !strings.Contains(query, "адрес") { t.Fatalf("local place query was not compiled semantically: %q", query) } } func TestTravelRouteWithKnownOriginDoesNotAskForTicketDate(t *testing.T) { history := []interaction.HumanUtterance{{Content: "я сейчас в Москве"}} if got := TravelSearchClarification("как можно добраться до Сочи?", history); got != "" { t.Fatalf("general route was incorrectly reduced to ticket clarification: %q", got) } got := TravelResearchQuery("как можно добраться до Сочи?", history, time.Time{}) if got != "способы добраться из Москве в Сочи: самолет поезд автобус автомобиль" { t.Fatalf("general route query lost known route context: %q", got) } } func TestTravelContextUsesNewestLocationRegardlessOfRepositoryOrder(t *testing.T) { history := []interaction.HumanUtterance{ {Content: "я сейчас в Санкт-Петербурге", ReceivedAtUnixMillis: 200}, {Content: "я сейчас в Москве", ReceivedAtUnixMillis: 300}, {Content: "я сейчас в Казани", ReceivedAtUnixMillis: 100}, } if got := TravelSearchClarification("какой ближайший рейс до Сочи?", history); got != "" { t.Fatalf("newest remembered origin was not applied: %q", got) } if got := TravelResearchQuery("какой ближайший рейс до Сочи?", history, time.Time{}); !strings.Contains(got, "пункт отправления: Москве") { t.Fatalf("newest origin missing from travel research query: %q", got) } } func TestNearestPlaneUsesTravelContextAndCurrentDay(t *testing.T) { now := time.Date(2026, time.August, 25, 18, 0, 0, 0, time.UTC) history := []interaction.HumanUtterance{{Content: "я сейчас в Москве", ReceivedAtUnixMillis: 100}} query := TravelResearchQuery("найди ближайший самолет до Казани", history, now) if query != "ближайшие авиарейсы Москве Казани сегодня 25.08.2026" { t.Fatalf("nearest plane search formulation lost current context: %q", query) } if got := TravelSearchClarification("найди ближайший самолет до Казани", history); got != "" { t.Fatalf("known origin and nearest-time meaning should proceed, got %q", got) } } 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 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) } if got := IndependentRequestParts("проверь погоду в Казани и найди кафе рядом"); len(got) != 2 { t.Fatalf("independent actions were not decomposed: %#v", got) } } func TestInterpretUtteranceDirectiveRecordsOutcomeWithoutExecution(t *testing.T) { result := interpretForTest(t, "Поменяй базу на УПО1.", activeFocus("upo", "УПО"), "") if result.Interpretation.SpeechAct != interaction.SpeechActDirective { t.Fatalf("expected directive, got %s", result.Interpretation.SpeechAct) } if result.Interpretation.RequestedOutcome == "" || !strings.Contains(result.Reply, "Ничего не выполнено") { t.Fatalf("expected non-executed requested outcome, got %#v reply=%q", result.Interpretation, result.Reply) } } func TestInterpretUtteranceCorrectionIsAppendOnlySupersedes(t *testing.T) { result := interpretForTest(t, "Нет, используй УПО2.", activeFocus("upo", "УПО"), "utterance-previous") if result.Interpretation.SpeechAct != interaction.SpeechActCorrection { t.Fatalf("expected correction, got %s", result.Interpretation.SpeechAct) } if len(result.Relations) != 1 || result.Relations[0].Kind != interaction.RelationSupersedes || result.Relations[0].ToUtteranceID != "utterance-previous" { t.Fatalf("expected supersedes relation, got %#v", result.Relations) } } func TestInterpretUtteranceQuestionAboutCurrentWorkUsesFocus(t *testing.T) { result := interpretForTest(t, "Что мы сейчас делаем?", activeFocus("assistant", "Assistant"), "") if result.Interpretation.SpeechAct != interaction.SpeechActQuestion { t.Fatalf("expected question, got %s", result.Interpretation.SpeechAct) } if !strings.Contains(result.Reply, "Assistant") { t.Fatalf("expected focus-aware reply, got %q", result.Reply) } if result.NeedsCognition { t.Fatalf("focus question should be answered from canonical focus without cognition") } } func TestInterpretUtteranceEnglishFocusIntrospectionStaysLocal(t *testing.T) { result := interpretForTest(t, "Какой сейчас Focus разговора?", activeFocus("assistant", "Assistant"), "") if result.Reply != "Сейчас в фокусе: Assistant." || result.NeedsCognition || len(result.CapabilityHints) != 0 { t.Fatalf("focus introspection escaped local route: %+v", result) } } func TestInterpretUtteranceInheritedFocusForThere(t *testing.T) { result := interpretForTest(t, "Добавь туда новую настройку.", activeFocus("assistant", "Assistant"), "") if len(result.Projects) != 1 || result.Projects[0].ProjectID != "assistant" || result.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus { t.Fatalf("expected inherited Assistant focus, got %#v", result.Projects) } } func TestProjectFocusIsContextualEvidenceNotGlobalMode(t *testing.T) { explicit := interpretForTest(t, "Сейчас работаем с УПО.", nil, "") if explicit.ActiveFocus == nil || explicit.ActiveFocus.ProjectID != "upo" { t.Fatalf("expected explicit UPO focus, got %#v", explicit.ActiveFocus) } focusedQuestion := interpretForTest(t, "Почему тормозит база?", activeFocus("upo", "УПО"), "") if len(focusedQuestion.Projects) != 1 || focusedQuestion.Projects[0].ProjectID != "upo" || focusedQuestion.Projects[0].Provenance != situation.ProjectResolutionInheritedFocus { t.Fatalf("database follow-up can use previous project as contextual evidence, got %#v", focusedQuestion.Projects) } unrelated := interpretForTest(t, "Помоги ответить на письмо бухгалтеру.", activeFocus("upo", "УПО"), "") if len(unrelated.Projects) != 0 { t.Fatalf("unrelated mail intent must not inherit UPO focus, got %#v", unrelated.Projects) } returned := interpretForTest(t, "Вернемся к УПО.", activeFocus("assistant", "Assistant"), "") if returned.ActiveFocus == nil || returned.ActiveFocus.ProjectID != "upo" { t.Fatalf("expected explicit return to UPO, got %#v", returned.ActiveFocus) } } func TestInterpretUtteranceMultiProjectReferenceRoles(t *testing.T) { result := interpretForTest(t, "Посмотри, как это сделано в Assistant, и сделай аналогично в УПО.", nil, "") if len(result.Projects) != 2 { t.Fatalf("expected two project references, got %#v", result.Projects) } roles := map[situation.ProjectID]interaction.ProjectReferenceRole{} for _, ref := range result.Projects { roles[ref.ProjectID] = ref.Role } if roles["assistant"] != interaction.ProjectRoleReferenceSource || roles["upo"] != interaction.ProjectRolePrimary { t.Fatalf("unexpected roles: %#v", roles) } } func TestInterpretUtteranceAmbiguousDestructiveRequiresClarification(t *testing.T) { result := interpretForTest(t, "Удали туда старую базу.", activeFocus("assistant", "Assistant"), "") if result.Interpretation.Clarification != interaction.ClarificationRequired { t.Fatalf("expected clarification, got %#v", result.Interpretation) } if result.NeedsCognition { t.Fatalf("ambiguous destructive directive must not require cognition") } } func TestInterpretUtterancePunctuationFreeRussianQuestionStillRequestsBroaderInterpretation(t *testing.T) { result := interpretForTest(t, "ты кто", nil, "") if result.Interpretation.SpeechAct != interaction.SpeechActQuestion { t.Fatalf("expected question, got %#v", result.Interpretation) } if !result.NeedsCognition { t.Fatalf("expected cognition route for punctuation-free identity question, got %#v", result) } } func TestInterpretUtterancePunctuationFreeArithmeticStillRequestsCognition(t *testing.T) { result := interpretForTest(t, "сколько будет 2 + 2", nil, "") if result.Interpretation.SpeechAct != interaction.SpeechActQuestion { t.Fatalf("expected question, got %#v", result.Interpretation) } if !result.NeedsCognition { t.Fatalf("expected cognition route for punctuation-free arithmetic, got %#v", result) } } func TestCarryForwardPreviousTaskPreservesRoutingForShortRetry(t *testing.T) { current := SemanticResult{ Interpretation: interaction.UtteranceInterpretation{UtteranceID: "retry", SpeechAct: interaction.SpeechActAssertion}, Candidates: []interaction.SemanticCandidate{{ID: "current", UtteranceID: "retry"}}, } previous := SemanticResult{ Interpretation: interaction.UtteranceInterpretation{ UtteranceID: "previous", SpeechAct: interaction.SpeechActRequestToInvestigate, RequestedOutcome: "узнать текущую погоду", NeedsCognition: false, Applicability: interaction.SemanticApplicable, Clarification: interaction.ClarificationNotRequired, RecordedAtUnixMillis: 100, }, Candidates: []interaction.SemanticCandidate{{ID: "previous-candidate", PrimarySpeechAct: interaction.SpeechActRequestToInvestigate, CapabilityHints: []string{"WEB_RESEARCH"}}}, CapabilityHints: []string{"WEB_RESEARCH"}, } result := CarryForwardPreviousTask(current, previous) if result.Interpretation.SpeechAct != interaction.SpeechActRequestToInvestigate || result.CapabilityHints[0] != "WEB_RESEARCH" || result.Candidates[0].UtteranceID != "retry" { t.Fatalf("previous routing was not carried forward: %#v", result) } } func TestInterpretUtteranceModalExplanationRequestDefersToCognition(t *testing.T) { for _, message := range []string{"можешь расказать об утре", "расскажи об утре", "про утро расскажешь"} { result := interpretForTest(t, message, nil, "") if result.Interpretation.SpeechAct != interaction.SpeechActQuestion && result.Interpretation.SpeechAct != interaction.SpeechActRequestToInvestigate { t.Fatalf("expected cognition-worthy interpretation for %q, got %#v", message, result.Interpretation) } if !result.NeedsCognition { t.Fatalf("expected cognition route for %q, got %#v", message, result) } } } func TestInterpretUtteranceNamedModelRequestIsDirectiveNeedingCognition(t *testing.T) { result := interpretForTest(t, "обратись к модели gemma4 и спроси сколько звезд на небе", nil, "") if result.Interpretation.SpeechAct != interaction.SpeechActDirective { t.Fatalf("expected directive, got %#v", result.Interpretation) } if !result.NeedsCognition { t.Fatalf("expected cognition route for named-model request, got %#v", result) } } func interpretForTest(t *testing.T, message string, previous *situation.ActiveProjectFocus, corrects interaction.HumanUtteranceID) SemanticResult { t.Helper() conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen} utterance, _, _, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: message, CorrectsUtteranceID: corrects}, time.Unix(100, 0)) if err != nil { t.Fatal(err) } result, err := InterpretUtterance(utterance, projectRegistryForTest(), previous, time.Unix(100, 0)) if err != nil { t.Fatal(err) } return result } func projectRegistryForTest() []situation.ProjectDescriptor { return []situation.ProjectDescriptor{ {ID: "assistant", DisplayName: "Assistant", Aliases: []situation.ProjectAlias{"assistant", "ассистент", "домовой"}}, {ID: "upo", DisplayName: "УПО", Aliases: []situation.ProjectAlias{"упо", "upo"}}, } } func activeFocus(projectID situation.ProjectID, displayName string) *situation.ActiveProjectFocus { return &situation.ActiveProjectFocus{ ActorID: identity.ActorID("actor-1"), PrincipalID: identity.PrincipalID("principal-1"), ProjectID: projectID, ProjectDisplayName: displayName, Provenance: situation.ProjectResolutionExplicit, ConfidenceMillis: 980, SourceUtteranceID: "utterance-focus", UpdatedAtUnixMillis: time.Unix(90, 0).UnixMilli(), } }