Files
Assistant/modules/interaction/domain/semantics_test.go
T

404 lines
21 KiB
Go
Raw 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"
"git.cin.su/m/Assistant/platform/ids"
)
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 TestInterpretUtteranceDirectiveRecordsOutcomeWithoutExecution(t *testing.T) {
result := interpretForTest(t, "Поменяй базу на УПО1.", activeFocus("upo", "УПО"), "")
if result.Interpretation.SpeechAct != interaction.SpeechActDirective && result.Interpretation.SpeechAct != interaction.SpeechActRequestToInvestigate {
t.Fatalf("expected directive-style intent, 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 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 TestInterpretUtteranceNaturalRussianHelpflowUsesContextAwareCognitionInput(t *testing.T) {
cases := []struct {
message string
wantSpeech interaction.SpeechAct
wantCognition bool
allowNoMatch bool
}{
{message: "расскажи как это работает", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "Расскажи, как это работает?", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "Расскажи как это работает", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "можешь объяснить почему база растёт", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "ПОЧЕМУ postgres использует так много памяти", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "ПОЧЕМУ postgres использует столько памяти", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "разберись почему сервис упал", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "разберись почему сервис упал", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "посмотри что произошло", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "исправь ошибку если найдёшь", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "разберись почему падает и исправь если причина очевидна", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "нет не UPO а UPO2", wantSpeech: interaction.SpeechActCorrection, wantCognition: false},
{message: "нет не UPO а UPO2 и обнови конфиг", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "postgres сейчас работает", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "сейчас домовой не видит ии", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не видит ии", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "основная база называется UPO", wantSpeech: interaction.SpeechActAssertion, wantCognition: false},
{message: "привет", wantSpeech: interaction.SpeechActConversational, wantCognition: false},
{message: "спасибо", wantSpeech: interaction.SpeechActConversational, wantCognition: false},
{message: "ты кто", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "Ты кто?", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "как тебя зовут", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "кто ты вообще", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "что ты думаешь", wantSpeech: interaction.SpeechActQuestion, wantCognition: true},
{message: "расскажи подробнее", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "СЕЙЧАС ДОМОВОЙ НЕ ВИДИТ ИИ", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "не понимаю меня не понимает ии", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не вижит ии", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не отвечает мне", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не слушает меня", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "домовой не отвечает на команды", wantSpeech: "", wantCognition: true, allowNoMatch: true},
{message: "ПОЧЕМУ postgres использует так много памяти", wantSpeech: "", wantCognition: true, allowNoMatch: true},
}
for _, tc := range cases {
result := interpretForTest(t, tc.message, nil, "")
if tc.wantSpeech != "" && result.Interpretation.SpeechAct != tc.wantSpeech {
t.Fatalf("message %q expected speech=%s got %s", tc.message, tc.wantSpeech, result.Interpretation.SpeechAct)
}
if tc.wantSpeech == "" && !tc.allowNoMatch && result.Interpretation.SpeechAct == "" {
t.Fatalf("message %q got empty speech interpretation", tc.message)
}
if result.NeedsCognition != tc.wantCognition {
t.Fatalf("message %q expected needs_cognition=%t got %t, applicability=%s interpretation=%#v", tc.message, tc.wantCognition, result.NeedsCognition, result.Interpretation.Applicability, result.Interpretation)
}
if tc.message != "" && result.Interpretation.Clarification == interaction.ClarificationRequired && tc.wantCognition {
t.Fatalf("message %q unexpectedly required clarification while asking for safe conversational understanding", tc.message)
}
}
}
func TestInterpretUtteranceDiagnosticTraceLineage(t *testing.T) {
message := "ты кто"
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}, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
result, err := InterpretUtterance(utterance, projectRegistryForTest(), nil, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
candidateID, err := ids.New()
if err != nil {
t.Fatal(err)
}
cognitiveCandidate := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(candidateID),
UtteranceID: utterance.ID,
Source: interaction.CandidateSourceCognitive,
SelectionState: interaction.CandidateObserved,
PrimarySpeechAct: interaction.SpeechActQuestion,
SecondarySpeechAct: interaction.SpeechActUnknown,
ConfidenceMillis: 780,
Applicability: interaction.SemanticNeedsBroaderInterpret,
RequestedOutcome: "",
Clarification: interaction.ClarificationNotRequired,
NeedsCognition: true,
SelectionReason: "trace-oriented cognitive clarification of identity-style intent",
CapabilityHints: []string{"COGNITIVE_REASONING"},
RecordedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
}
selected := ApplyCognitiveCandidate(result, message, cognitiveCandidate, nil)
var deterministic, selectedCandidate *interaction.SemanticCandidate
for index := range selected.Candidates {
c := &selected.Candidates[index]
if c.Source == interaction.CandidateSourceDeterministic {
deterministic = c
}
if c.SelectionState == interaction.CandidateSelected {
selectedCandidate = c
}
}
if deterministic == nil {
t.Fatal("expected deterministic candidate in trace")
}
if selectedCandidate == nil {
t.Fatal("expected selected candidate in trace")
}
if selected.Interpretation.Source != string(interaction.CandidateSourceCognitive) {
t.Fatalf("selection source should switch to cognitive, got %s", selected.Interpretation.Source)
}
if selected.Interpretation.NeedsCognition != selectedCandidate.NeedsCognition {
t.Fatalf("expected selected interpretation cognition=%t got %t", selectedCandidate.NeedsCognition, selected.Interpretation.NeedsCognition)
}
if selected.Interpretation.Clarification != selectedCandidate.Clarification {
t.Fatalf("expected selected candidate clarification to match interpretation clarification")
}
if selectedCandidate.SelectionReason == "" {
t.Fatal("expected clarification/selection reason in selected trace")
}
t.Logf("TRACE deterministic_source=%s cognitive_source=%s selected_source=%s needs_cognition=%t clarification_reason=%s",
deterministic.Source, selectedCandidate.Source, selected.Interpretation.Source, selected.Interpretation.NeedsCognition, selected.Interpretation.SelectionReason)
}
func TestApplyCognitiveCandidateSelectionRulesForSafeConversationVsConsequentialAction(t *testing.T) {
conversation := interaction.Conversation{ID: "conversation-1", ActorID: "actor-1", PrincipalID: "principal-1", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen}
safeUtterance, _, _, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: "ты кто"}, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
safeSemantic, err := InterpretUtterance(safeUtterance, projectRegistryForTest(), nil, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
safeCandidateID, err := ids.New()
if err != nil {
t.Fatal(err)
}
safeCognitive := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(safeCandidateID),
UtteranceID: safeUtterance.ID,
Source: interaction.CandidateSourceCognitive,
SelectionState: interaction.CandidateObserved,
PrimarySpeechAct: interaction.SpeechActAssertion,
SecondarySpeechAct: interaction.SpeechActUnknown,
ConfidenceMillis: 770,
Applicability: interaction.SemanticNeedsBroaderInterpret,
RequestedOutcome: "",
Clarification: interaction.ClarificationNotRequired,
NeedsCognition: true,
SelectionReason: "identity-style trace test",
CapabilityHints: []string{"COGNITIVE_REASONING"},
RecordedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
}
safeSelected := ApplyCognitiveCandidate(safeSemantic, safeUtterance.Content, safeCognitive, nil)
if safeSelected.Interpretation.Source != string(interaction.CandidateSourceCognitive) {
t.Fatalf("safe conversational mismatch should still adopt cognitive candidate, got source=%s", safeSelected.Interpretation.Source)
}
if safeSelected.Interpretation.SpeechAct != interaction.SpeechActAssertion {
t.Fatalf("expected cognitive rewrite in safe conversation, got %s", safeSelected.Interpretation.SpeechAct)
}
riskyUtterance, _, _, err := NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: "удали УПО"}, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
riskySemantic, err := InterpretUtterance(riskyUtterance, projectRegistryForTest(), nil, time.Unix(100, 0))
if err != nil {
t.Fatal(err)
}
if riskySemantic.Interpretation.SpeechAct != interaction.SpeechActDirective {
t.Fatalf("expected directive baseline, got %s", riskySemantic.Interpretation.SpeechAct)
}
riskyCandidateID, err := ids.New()
if err != nil {
t.Fatal(err)
}
riskyCognitive := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(riskyCandidateID),
UtteranceID: riskyUtterance.ID,
Source: interaction.CandidateSourceCognitive,
SelectionState: interaction.CandidateObserved,
PrimarySpeechAct: interaction.SpeechActQuestion,
SecondarySpeechAct: interaction.SpeechActUnknown,
ConfidenceMillis: 780,
Applicability: interaction.SemanticNeedsBroaderInterpret,
RequestedOutcome: "",
Clarification: interaction.ClarificationNotRequired,
NeedsCognition: true,
SelectionReason: "destructive path mismatch guard",
CapabilityHints: []string{"COGNITIVE_REASONING"},
RecordedAtUnixMillis: time.Unix(100, 0).UnixMilli(),
}
riskySelected := ApplyCognitiveCandidate(riskySemantic, riskyUtterance.Content, riskyCognitive, nil)
if riskySelected.Interpretation.Source != string(interaction.CandidateSourceDeterministic) {
t.Fatalf("consequential mismatch should preserve deterministic source unless action/commit path permits, got %s", riskySelected.Interpretation.Source)
}
}
func TestInterpretUtteranceModalExplanationRequestStillRequestsCognition(t *testing.T) {
for _, message := range []string{"можешь расказать об утре", "расскажи об утре", "про утро расскажешь"} {
result := interpretForTest(t, message, nil, "")
if result.Interpretation.SpeechAct == interaction.SpeechActAssertion && result.Interpretation.Applicability == interaction.SemanticApplicable {
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 TestInterpretUtteranceSelectsDeterministicCandidateByDefault(t *testing.T) {
result := interpretForTest(t, "основная база называется UPO", nil, "")
if len(result.Candidates) != 1 {
t.Fatalf("expected one deterministic candidate, got %d", len(result.Candidates))
}
if result.Candidates[0].SelectionState != interaction.CandidateSelected {
t.Fatalf("deterministic candidate must be selected by default, got %s", result.Candidates[0].SelectionState)
}
}
func TestRejectedCognitiveCandidatePreservesSelectedDeterministicCandidate(t *testing.T) {
result := interpretForTest(t, "основная база называется UPO", nil, "")
candidateID, err := ids.New()
if err != nil {
t.Fatal(err)
}
rejected := interaction.SemanticCandidate{
ID: interaction.SemanticCandidateID(candidateID),
UtteranceID: result.Interpretation.UtteranceID,
Source: interaction.CandidateSourceCognitive,
PrimarySpeechAct: interaction.SpeechActQuestion,
SecondarySpeechAct: interaction.SpeechActUnknown,
ConfidenceMillis: 700,
Applicability: interaction.SemanticApplicable,
Clarification: interaction.ClarificationNotRequired,
NeedsCognition: false,
SelectionReason: "must not replace a safe assertion with an unrelated candidate",
}
result = ApplyCognitiveCandidate(result, "основная база называется UPO", rejected, nil)
selected := 0
for _, candidate := range result.Candidates {
if candidate.SelectionState == interaction.CandidateSelected {
selected++
if candidate.Source != interaction.CandidateSourceDeterministic {
t.Fatalf("rejected cognitive candidate replaced deterministic selection: %#v", candidate)
}
}
}
if selected != 1 {
t.Fatalf("expected exactly one selected candidate after rejection, got %d", selected)
}
}
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(),
}
}