410 lines
20 KiB
Go
410 lines
20 KiB
Go
package domain
|
||
|
||
import (
|
||
"testing"
|
||
"time"
|
||
|
||
"git.cin.su/m/Assistant/contracts/identity"
|
||
"git.cin.su/m/Assistant/contracts/interaction"
|
||
)
|
||
|
||
func TestNewHumanUtteranceClassifiesQuestionAndRequiresCognition(t *testing.T) {
|
||
conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
|
||
utterance, intent, relations, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: "Кто ты?"}, time.Unix(10, 0))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if utterance.SpeechAct != interaction.SpeechActQuestion || !NeedsCognition(utterance.SpeechAct) {
|
||
t.Fatalf("expected cognitive question, got %s", utterance.SpeechAct)
|
||
}
|
||
if intent.Text != "Кто ты?" || len(relations) != 0 {
|
||
t.Fatalf("unexpected intent or relations")
|
||
}
|
||
}
|
||
|
||
func TestNewHumanUtteranceAppendsCorrectionRelation(t *testing.T) {
|
||
conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
|
||
utterance, _, relations, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: "исправь прошлое", CorrectsUtteranceID: "utterance-0"}, time.Unix(10, 0))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if utterance.SpeechAct != interaction.SpeechActCorrection {
|
||
t.Fatalf("expected correction, got %s", utterance.SpeechAct)
|
||
}
|
||
if len(relations) != 1 || relations[0].Kind != interaction.RelationCorrects || relations[0].ToUtteranceID != "utterance-0" {
|
||
t.Fatalf("unexpected correction relation: %#v", relations)
|
||
}
|
||
}
|
||
|
||
func TestNewHumanUtteranceClassifiesReminderAndCapabilityConnectionAsDirective(t *testing.T) {
|
||
conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
|
||
for _, message := range []string{"Напомни завтра позвонить врачу.", "Подключи интеграцию с неизвестным сервисом."} {
|
||
utterance, _, _, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: message}, time.Unix(10, 0))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if utterance.SpeechAct != interaction.SpeechActDirective {
|
||
t.Fatalf("expected directive for %q, got %s", message, utterance.SpeechAct)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPlaneRequestReceivesTravelResearchCapability(t *testing.T) {
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDeterministicCandidateIsInvariantToMissingQuestionMarkForIdentityAndArithmetic(t *testing.T) {
|
||
cases := []struct {
|
||
message string
|
||
act interaction.SpeechAct
|
||
}{
|
||
{"ты кто", interaction.SpeechActQuestion},
|
||
{"Ты кто?", interaction.SpeechActQuestion},
|
||
{"а ты кто", interaction.SpeechActQuestion},
|
||
{"сколько будет 2 + 2", interaction.SpeechActQuestion},
|
||
{"сколько будет 2 + 2?", interaction.SpeechActQuestion},
|
||
{"2 плюс 2 сколько", interaction.SpeechActQuestion},
|
||
}
|
||
for _, tc := range cases {
|
||
candidate := BuildDeterministicCandidate(tc.message, "", time.Unix(10, 0).UnixMilli())
|
||
if candidate.PrimarySpeechAct != tc.act {
|
||
t.Fatalf("expected %s for %q, got %s (%+v)", tc.act, tc.message, candidate.PrimarySpeechAct, candidate)
|
||
}
|
||
if candidate.Applicability == interaction.SemanticApplicable && candidate.NeedsCognition {
|
||
continue
|
||
}
|
||
if !candidate.NeedsCognition {
|
||
t.Fatalf("expected cognition-worthy candidate for %q, got %+v", tc.message, candidate)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDeterministicCandidateTreatsModalExplanationRequestsAsCognitionWorthWithoutQuestionMark(t *testing.T) {
|
||
for _, message := range []string{
|
||
"можешь рассказать об утре",
|
||
"можешь расказать об утре",
|
||
"можете объяснить это проще",
|
||
"расскажи об утре",
|
||
"про утро расскажешь",
|
||
} {
|
||
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
|
||
if candidate.PrimarySpeechAct != interaction.SpeechActQuestion && candidate.PrimarySpeechAct != interaction.SpeechActRequestToInvestigate {
|
||
t.Fatalf("expected broader cognitive speech act for %q, got %+v", message, candidate)
|
||
}
|
||
if !candidate.NeedsCognition {
|
||
t.Fatalf("expected cognition for %q, got %+v", message, candidate)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDeterministicCandidateTreatsModelRequestAsDirectiveNeedingCognition(t *testing.T) {
|
||
candidate := BuildDeterministicCandidate("обратись к модели gemma4 и спроси сколько звезд на небе", "", time.Unix(10, 0).UnixMilli())
|
||
if candidate.PrimarySpeechAct != interaction.SpeechActDirective {
|
||
t.Fatalf("expected directive, got %+v", candidate)
|
||
}
|
||
if !candidate.NeedsCognition {
|
||
t.Fatalf("expected cognition-worthy model request, got %+v", candidate)
|
||
}
|
||
}
|
||
|
||
func TestDeterministicCandidateTreatsFormattingImperativeAsCognitionWorthyDirective(t *testing.T) {
|
||
candidate := BuildDeterministicCandidate("выведи пустую таблицу из трех колонок и пяти строк", "", time.Unix(10, 0).UnixMilli())
|
||
if candidate.PrimarySpeechAct != interaction.SpeechActDirective {
|
||
t.Fatalf("formatting request was not recognized as a directive: %+v", candidate)
|
||
}
|
||
if !candidate.NeedsCognition {
|
||
t.Fatalf("formatting request must reach cognitive reasoning: %+v", candidate)
|
||
}
|
||
}
|
||
|
||
func TestAWordModelDoesNotTriggerModelInventoryOrVerification(t *testing.T) {
|
||
candidate := BuildDeterministicCandidate("модель должна понимать смысл вопроса", "", time.Unix(10, 0).UnixMilli())
|
||
for _, hint := range candidate.CapabilityHints {
|
||
if hint == "VERIFY_FACT" {
|
||
t.Fatalf("an incidental model mention must not trigger fact verification: %+v", candidate)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestInformationRequestsAreClassifiedOnceByInteraction(t *testing.T) {
|
||
cases := []struct {
|
||
message string
|
||
kind interaction.InformationRequestKind
|
||
}{
|
||
{"какие модели доступны?", interaction.InformationRequestCognitiveModelInventory},
|
||
{"назови доступные модели", interaction.InformationRequestCognitiveModelInventory},
|
||
{"какая модель отвечает?", interaction.InformationRequestCognitiveCurrentModel},
|
||
{"почему ты не смог найти ответ?", interaction.InformationRequestResearchFailureExplanation},
|
||
{"что ты можешь сделать без внешних моделей?", interaction.InformationRequestLocalCapabilityInventory},
|
||
{"что ты умеешь?", interaction.InformationRequestCapabilityInventory},
|
||
{"что доступно в этом чате?", interaction.InformationRequestCapabilityInventory},
|
||
{"проверь состояние локального канала", interaction.InformationRequestCognitiveChannelStatus},
|
||
{"нет связи с ИИ, восстанови канал", interaction.InformationRequestCognitiveRecovery},
|
||
{"сообщии список моделей", interaction.InformationRequestCognitiveModelInventory},
|
||
{"сообщите список подключённых моделей", interaction.InformationRequestCognitiveModelInventory},
|
||
}
|
||
for _, test := range cases {
|
||
result, err := InterpretUtterance(interaction.HumanUtterance{Content: test.message}, nil, nil, time.Unix(10, 0))
|
||
if err != nil {
|
||
t.Fatalf("interpret %q: %v", test.message, err)
|
||
}
|
||
if result.InformationRequestKind != test.kind {
|
||
t.Fatalf("message=%q kind=%q want=%q", test.message, result.InformationRequestKind, test.kind)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSourceDependentQuestionRequestsWebResearchWithoutEntityBranch(t *testing.T) {
|
||
candidate := BuildDeterministicCandidate("какая погода в Киеве?", "", time.Unix(10, 0).UnixMilli())
|
||
seen := false
|
||
for _, hint := range candidate.CapabilityHints {
|
||
if hint == "WEB_RESEARCH" {
|
||
seen = true
|
||
}
|
||
}
|
||
if !seen {
|
||
t.Fatalf("source-dependent question was not routed to WEB_RESEARCH: %+v", candidate)
|
||
}
|
||
ordinary := BuildDeterministicCandidate("что такое гравитация?", "", time.Unix(10, 0).UnixMilli())
|
||
for _, hint := range ordinary.CapabilityHints {
|
||
if hint == "WEB_RESEARCH" {
|
||
t.Fatalf("ordinary conceptual question was incorrectly routed to WEB_RESEARCH: %+v", ordinary)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestSourceDependentQuestionToleratesTyposAndPlaceSearch(t *testing.T) {
|
||
for _, message := range []string{"какая пугода в Сочи", "найди ближайший ресторан в Сочи", "найди билеты до Ялты", "найди на WB бренд marka-wear"} {
|
||
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
|
||
seen := false
|
||
for _, hint := range candidate.CapabilityHints {
|
||
if hint == "WEB_RESEARCH" {
|
||
seen = true
|
||
}
|
||
}
|
||
if !seen {
|
||
t.Fatalf("message=%q was not routed to WEB_RESEARCH: %+v", message, candidate)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestDecomposeMarketplaceBrandRequestIntoExecutionAtoms(t *testing.T) {
|
||
lexicon := TaskLexicon{ObjectAliases: []TaskLexiconEntry{{Alias: "бренд", Value: "BRAND"}}, SourceAliases: []TaskLexiconEntry{{Alias: "wb", Value: "WILDBERRIES"}}}
|
||
got := DecomposeTaskWithLexicon("найди на WB бренд marka-wear", lexicon)
|
||
if got.Intent != TaskIntentSearch || got.Object != "BRAND" || got.Source != "WILDBERRIES" || got.Target != "marka-wear" || !got.NeedsResearch {
|
||
t.Fatalf("unexpected marketplace decomposition: %+v", got)
|
||
}
|
||
if len(got.Missing) != 0 {
|
||
t.Fatalf("complete marketplace request marked incomplete: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestDecomposeDoesNotInventMutableAliases(t *testing.T) {
|
||
got := DecomposeTask("найди на WB бренд marka-wear")
|
||
if got.Source != "" || got.Object != "" || got.NeedsResearch {
|
||
t.Fatalf("parser invented a mutable alias without memory: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestDecomposeUsesRememberedAliasesAcrossWordings(t *testing.T) {
|
||
lexicon := TaskLexicon{
|
||
ObjectAliases: []TaskLexiconEntry{{Alias: "бренд", Value: "BRAND"}, {Alias: "марку", Value: "BRAND"}},
|
||
SourceAliases: []TaskLexiconEntry{{Alias: "wb", Value: "WILDBERRIES"}, {Alias: "wildberries", Value: "WILDBERRIES"}},
|
||
}
|
||
for _, message := range []string{"найди на WB бренд marka-wear", "поищи марку marka-wear в Wildberries"} {
|
||
got := DecomposeTaskWithLexicon(message, lexicon)
|
||
if got.Intent != TaskIntentSearch || got.Object != "BRAND" || got.Source != "WILDBERRIES" || got.Target != "marka-wear" || !got.NeedsResearch {
|
||
t.Fatalf("remembered aliases failed for %q: %+v", message, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestTaskLexiconFromHistoryFeedsSearchQueryWithoutCoreAlias(t *testing.T) {
|
||
history := []interaction.HumanUtterance{{Content: "запомни: WB — это Wildberries, источник для поиска"}}
|
||
lexicon := TaskLexiconFromHistory(history)
|
||
if got := SearchQueryWithLexicon("найди на WB бренд marka-wear", lexicon); got != "на WB бренд marka-wear wildberries" {
|
||
t.Fatalf("learned source was not applied to search: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestSearchQueryWithoutLearnedSourceOmitsImperative(t *testing.T) {
|
||
if got := SearchQueryWithLexicon("найди на WB бренд marka-wear", TaskLexicon{}); got != "на WB бренд marka-wear" {
|
||
t.Fatalf("search imperative leaked into external query: %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDecomposeCreativeRequestDoesNotInventResearch(t *testing.T) {
|
||
lexicon := TaskLexicon{ObjectAliases: []TaskLexiconEntry{{Alias: "медвед", Value: "IMAGE"}}}
|
||
got := DecomposeTaskWithLexicon("нарисуй бурого медведя", lexicon)
|
||
if got.Intent != TaskIntentCreate || got.Object != "IMAGE" || got.Target != "бурого медведя" || got.NeedsResearch {
|
||
t.Fatalf("unexpected creative decomposition: %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestRequestToClarifyCurrentFactRequestsWebResearch(t *testing.T) {
|
||
candidate := BuildDeterministicCandidate("уточни погоду в питере", "", time.Unix(10, 0).UnixMilli())
|
||
for _, hint := range candidate.CapabilityHints {
|
||
if hint == "WEB_RESEARCH" {
|
||
return
|
||
}
|
||
}
|
||
t.Fatalf("clarification request for current fact was not routed to WEB_RESEARCH: %+v", candidate)
|
||
}
|
||
|
||
func TestRetryRequestIsAContinuationCue(t *testing.T) {
|
||
for _, message := range []string{"ещё раз попробуй", "повтори", "попробуй снова"} {
|
||
if !IsRetryRequest(message) {
|
||
t.Fatalf("expected retry cue for %q", message)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNamingPreferenceIsSemanticAndProducesNaturalAcceptance(t *testing.T) {
|
||
result := BuildDeterministicCandidate("я буду называть тебя Дед, запомни ты дед", "", time.Unix(10, 0).UnixMilli())
|
||
if result.PrimarySpeechAct != interaction.SpeechActPreference {
|
||
t.Fatalf("expected naming preference, got %+v", result)
|
||
}
|
||
if name := PreferredAssistantName("я буду называть тебя Дед, запомни ты дед"); name != "Дед" {
|
||
t.Fatalf("expected extracted assistant name, got %q", name)
|
||
}
|
||
if name := PreferredAssistantName("я назову тебя дед, запомни"); name != "Дед" {
|
||
t.Fatalf("expected conversational name form to be extracted, got %q", name)
|
||
}
|
||
if name := PreferredAssistantName("давай переименуем тебя в фею. теперь тебя зовут фея"); name != "Фея" {
|
||
t.Fatalf("rename phrase returned %q", name)
|
||
}
|
||
if name := PreferredAssistantName("назови доступные модели"); name != "" {
|
||
t.Fatalf("model inventory request was mistaken for a name: %q", name)
|
||
}
|
||
if result := BuildDeterministicCandidate("назови доступные модели", "", time.Unix(10, 0).UnixMilli()); result.PrimarySpeechAct == interaction.SpeechActPreference {
|
||
t.Fatalf("model inventory request was mistaken for a preference: %+v", result)
|
||
}
|
||
if !IsIdentityInquiry("ты кто?") || !IsIdentityInquiry("как тебя зовут") {
|
||
t.Fatal("identity variants should be recognized")
|
||
}
|
||
if !IsExplicitMemoryInstruction("запомни: сокращение — это источник для поиска") {
|
||
t.Fatal("explicit memory instruction was not recognized")
|
||
}
|
||
if IsExplicitMemoryInstruction("что ты помнишь обо мне?") {
|
||
t.Fatal("memory inventory question must not be treated as an instruction")
|
||
}
|
||
}
|
||
|
||
func TestMemoryInventoryRequestIsSemantic(t *testing.T) {
|
||
for _, message := range []string{"выведи свою память", "напиши что ты помнишь в виде таблицы"} {
|
||
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
|
||
if got := informationRequestKind(message, candidate); got != interaction.InformationRequestMemoryInventory {
|
||
t.Fatalf("%q: expected memory inventory, got %q", message, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestRememberedPreferenceIsNotRenderedAsIdentity(t *testing.T) {
|
||
result := BuildDeterministicCandidate("запомни: мне нравится красный цвет", "", time.Unix(10, 0).UnixMilli())
|
||
if result.PrimarySpeechAct != interaction.SpeechActPreference {
|
||
t.Fatalf("expected remembered preference, got %+v", result)
|
||
}
|
||
if got := semanticReply("запомни: мне нравится красный цвет", SemanticResult{Interpretation: interaction.UtteranceInterpretation{SpeechAct: result.PrimarySpeechAct}}, nil); got != "Хорошо, учту это дальше." {
|
||
t.Fatalf("unexpected preference reply %q", got)
|
||
}
|
||
}
|
||
|
||
func TestStableAcknowledgementsDoNotBecomeTravelRequests(t *testing.T) {
|
||
for _, message := range []string{"Хорошо", "Ладно", "Понял"} {
|
||
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
|
||
if candidate.PrimarySpeechAct != interaction.SpeechActConfirmation {
|
||
t.Fatalf("%q: expected confirmation, got %s", message, candidate.PrimarySpeechAct)
|
||
}
|
||
if reply, stable := StableConversationalReply(message, candidate.PrimarySpeechAct); !stable || reply != "Хорошо." {
|
||
t.Fatalf("%q: stable reply = %q, %t", message, reply, stable)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestCreativeRequestsDoNotRouteToWebResearch(t *testing.T) {
|
||
for _, message := range []string{"нарисуй круг", "расскажи сказку о колобке"} {
|
||
candidate := BuildDeterministicCandidate(message, "", time.Now().UnixMilli())
|
||
t.Logf("%q candidate=%+v", message, candidate)
|
||
for _, hint := range candidate.CapabilityHints {
|
||
if hint == "WEB_RESEARCH" {
|
||
t.Fatalf("creative request %q unexpectedly selected web research: %+v", message, candidate)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestInterpretCreativeRequestDoesNotRequireExternalEvidence(t *testing.T) {
|
||
utterance := interaction.HumanUtterance{Content: "нарисуй круг"}
|
||
result, err := InterpretUtterance(utterance, nil, nil, time.Now())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
t.Logf("semantic result=%+v", result)
|
||
if result.Interpretation.SpeechAct == interaction.SpeechActQuestion || containsString(result.CapabilityHints, "WEB_RESEARCH") {
|
||
t.Fatalf("creative request was routed as external research: %+v", result)
|
||
}
|
||
if got := CreativeDeterministicReply("нарисуй круг"); got != "" {
|
||
t.Fatalf("creative request received a canned reply = %q", got)
|
||
}
|
||
if !IsCreativeRequest("расскажи сказку о колобке") {
|
||
t.Fatal("story request was not recognized as creative")
|
||
}
|
||
if got := CreativeFallbackReply("расскажи сказку о колобке"); got != "" {
|
||
t.Fatalf("creative request received canned content = %q", got)
|
||
}
|
||
for _, message := range []string{"нарисуй птицу", "нарисуй зеленую птицу", "нарисуй птицу в HD", "нарисуй коня"} {
|
||
if got := CreativeDeterministicReply(message); got != "" {
|
||
t.Fatalf("creative request %q received a canned reply = %q", message, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
func containsString(values []string, expected string) bool {
|
||
for _, value := range values {
|
||
if value == expected {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func TestLocationContextReply(t *testing.T) {
|
||
if got := LocationContextReply("я сейчас в Москве"); got != "Запомнил: сейчас вы в Москве." {
|
||
t.Fatalf("unexpected location reply %q", got)
|
||
}
|
||
}
|
||
|
||
func TestTimeDifferenceRequestIsSemantic(t *testing.T) {
|
||
message := "какая разница во времени между Москвой и Шанхаем?"
|
||
candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli())
|
||
if got := informationRequestKind(message, candidate); got != interaction.InformationRequestTimeDifference {
|
||
t.Fatalf("expected time difference, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestTimeDifferenceResearchQueryKeepsEntitiesAndAsksForMeasurement(t *testing.T) {
|
||
got := TimeDifferenceResearchQuery("какая разница во времени между Москвой и Шанхаем?")
|
||
if got != "какая разница во времени между Москвой и Шанхаем? сколько часов" {
|
||
t.Fatalf("time difference query = %q", got)
|
||
}
|
||
}
|
||
|
||
func TestDeterministicCandidateKeepsFirstPersonCompletedWorkAsAssertion(t *testing.T) {
|
||
candidate := BuildDeterministicCandidate("Я всё уже настроил.", "", time.Unix(10, 0).UnixMilli())
|
||
if candidate.PrimarySpeechAct != interaction.SpeechActAssertion {
|
||
t.Fatalf("expected assertion, got %+v", candidate)
|
||
}
|
||
}
|
||
|
||
func TestNewConversationRequiresActorAndPrincipal(t *testing.T) {
|
||
_, err := NewConversation(identity.ActorID(""), identity.PrincipalID("principal-1"), time.Unix(10, 0))
|
||
if err == nil {
|
||
t.Fatal("expected invalid principal")
|
||
}
|
||
}
|