Files
2026-08-29 23:28:10 +03:00

452 lines
24 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
}
}