From 52074e289513393cd4eb686f47395562a194021d Mon Sep 17 00:00:00 2001 From: Mikhail Date: Tue, 18 Aug 2026 16:40:46 +0300 Subject: [PATCH] Move cognition-first interpretation for free-form Russian --- .../interpretation_live_trace_test.go | 79 ++++++- cmd/core-runtime/cognitive_interpretation.go | 9 +- cmd/core-runtime/main.go | 4 +- modules/interaction/domain/chat.go | 192 +++++++----------- modules/interaction/domain/chat_test.go | 13 +- modules/interaction/domain/semantics.go | 88 +++++++- modules/interaction/domain/semantics_test.go | 78 ++++--- modules/work/domain/planning.go | 42 +++- 8 files changed, 312 insertions(+), 193 deletions(-) diff --git a/cmd/cognitive-worker/interpretation_live_trace_test.go b/cmd/cognitive-worker/interpretation_live_trace_test.go index 8b89c76..d4fdf5a 100644 --- a/cmd/cognitive-worker/interpretation_live_trace_test.go +++ b/cmd/cognitive-worker/interpretation_live_trace_test.go @@ -17,6 +17,7 @@ import ( "git.cin.su/m/Assistant/contracts/cognitive" "git.cin.su/m/Assistant/contracts/identity" "git.cin.su/m/Assistant/contracts/interaction" + "git.cin.su/m/Assistant/platform/ids" "git.cin.su/m/Assistant/contracts/situation" interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain" ) @@ -74,7 +75,28 @@ func TestLiveInterpretationTrace(t *testing.T) { now: time.Now, allowCanary: true, } - for _, message := range []string{"ты кто", "можешь расказать об утре"} { + for _, message := range []string{ + "расскажи как это работает", + "Расскажи, как это работает?", + "можешь объяснить почему база растёт", + "почему postgres использует столько памяти", + "разберись почему сервис упал", + "посмотри что произошло", + "исправь ошибку если найдёшь", + "разберись почему падает и исправь если причина очевидна", + "нет не UPO а UPO2", + "нет не UPO а UPO2 и обнови конфиг", + "postgres сейчас работает", + "основная база называется UPO", + "привет", + "спасибо", + "ты кто", + "ты кто?", + "как тебя зовут", + "кто ты вообще", + "что ты думаешь", + "расскажи подробнее", + } { conversation := interaction.Conversation{ID: "trace-conversation", ActorID: "trace-actor", PrincipalID: "domovoy-creator-principal", Channel: interaction.ChannelWebChat, State: interaction.ConversationOpen} utterance, _, _, err := interactiondomain.NewHumanUtterance(conversation, interaction.ChatMessageRequest{Message: message}, time.Now()) if err != nil { @@ -121,15 +143,27 @@ func TestLiveInterpretationTrace(t *testing.T) { if rawResponse == "" { t.Fatalf("message=%q raw response is empty", message) } - if message == "ты кто" { - if semantic.Interpretation.SpeechAct != interaction.SpeechActQuestion || !semantic.NeedsCognition { - t.Fatalf("semantic path still fails punctuation-free identity question: %+v", semantic.Interpretation) - } - if gatewayResponse.Candidate.PrimarySpeechAct != "QUESTION" { - t.Fatalf("gateway did not preserve question semantics: %+v", gatewayResponse.Candidate) - } + cognitiveCandidate, err := cognitiveCandidateFromGateway(utterance.ID, gatewayResponse.Candidate, time.Now()) + if err != nil { + t.Fatal(err) } - t.Logf("TRACE message=%q deterministic=%s/%s needs_cognition=%t raw=%q gateway_candidate=%+v", message, semantic.Interpretation.SpeechAct, semantic.Interpretation.Applicability, semantic.NeedsCognition, truncate(rawResponse, 180), gatewayResponse.Candidate) + selected := interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, cognitiveCandidate, nil) + cognitionUsed := selected.Interpretation.Source == string(interaction.CandidateSourceCognitive) + clarificationReason := string(selected.Interpretation.Clarification) + t.Logf("TRACE message=%q", message) + deterministic := semantic.Candidates[0] + t.Logf(" deterministic candidate: %+v", deterministic) + t.Logf(" raw response=%q", truncate(rawResponse, 180)) + t.Logf(" cognitive candidate: %+v", cognitiveCandidate) + t.Logf(" selected interpretation: act=%s secondary=%s selection_source=%s needs_cognition=%t", + selected.Interpretation.SpeechAct, + selected.Interpretation.SecondarySpeechAct, + selected.Interpretation.Source, + selected.Interpretation.NeedsCognition, + ) + t.Logf(" cognition used: %t", cognitionUsed) + t.Logf(" clarification reason: %s", clarificationReason) + t.Logf(" reply=%q", selected.Reply) } } @@ -217,3 +251,30 @@ func truncate(value string, max int) string { } return value[:max] + "..." } + +func cognitiveCandidateFromGateway(utteranceID interaction.HumanUtteranceID, candidate cognitive.GatewayInterpretationCandidate, now interface{ UnixMilli() int64 }) (interaction.SemanticCandidate, error) { + id, err := ids.New() + if err != nil { + return interaction.SemanticCandidate{}, err + } + capabilityHints := make([]string, 0, len(candidate.CapabilityHints)) + for _, hint := range candidate.CapabilityHints { + capabilityHints = append(capabilityHints, string(hint)) + } + return interaction.SemanticCandidate{ + ID: interaction.SemanticCandidateID(id), + UtteranceID: utteranceID, + Source: interaction.CandidateSourceCognitive, + SelectionState: interaction.CandidateSelected, + PrimarySpeechAct: interaction.SpeechAct(candidate.PrimarySpeechAct), + SecondarySpeechAct: interaction.SpeechAct(candidate.SecondarySpeechAct), + ConfidenceMillis: candidate.ConfidenceMillis, + Applicability: interaction.SemanticApplicability(candidate.Applicability), + RequestedOutcome: candidate.RequestedOutcome, + Clarification: interaction.ClarificationState(candidate.Clarification), + NeedsCognition: candidate.NeedsCognition, + SelectionReason: candidate.SelectionReason, + CapabilityHints: capabilityHints, + RecordedAtUnixMillis: now.UnixMilli(), + }, nil +} \ No newline at end of file diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index 9661eb4..ecdd3db 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -61,12 +61,7 @@ func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request co } func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool { - switch result.Interpretation.Applicability { - case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent: - return true - default: - return false - } + return interactiondomain.ShouldAttemptCognitive(result) } func buildGatewayInterpretationRequest(session identity.CreatorSession, utterance interaction.HumanUtterance, semantic interactiondomain.SemanticResult, projectNames []string) cognitive.GatewayInterpretationRequest { @@ -122,4 +117,4 @@ func capabilityHintStrings(values []capability.CapabilityKind) []string { result = append(result, string(value)) } return result -} +} \ No newline at end of file diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index cce6d9e..75e2483 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -293,7 +293,7 @@ func main() { http.Error(w, "interpret chat message", http.StatusServiceUnavailable) return } - if shouldRequestBroaderInterpretation(semantic) { + if interactiondomain.ShouldAttemptCognitive(semantic) { projectNames := make([]string, 0, len(semantic.Projects)) for _, ref := range semantic.Projects { if ref.ProjectDisplayName != "" { @@ -1170,4 +1170,4 @@ func evidenceRefsToEpistemics(values []work.EvidenceRef) []epistemics.EvidenceRe result = append(result, epistemics.EvidenceRef(value)) } return result -} +} \ No newline at end of file diff --git a/modules/interaction/domain/chat.go b/modules/interaction/domain/chat.go index e0868ea..8af2105 100644 --- a/modules/interaction/domain/chat.go +++ b/modules/interaction/domain/chat.go @@ -127,19 +127,6 @@ func BuildDeterministicCandidate(message string, corrects interaction.HumanUtter scores := semanticScores(tokens, hasQuestionMark, corrects != "") primary, primaryScore := topAct(scores) secondary, secondaryScore := secondAct(scores, primary) - if (startsWithModalRequest(tokens) || startsWithExplanatoryRequest(tokens, "расска", "раска", "объяс", "опиш", "покаж", "подскаж")) && - (primary == interaction.SpeechActAssertion || primary == interaction.SpeechActDirective) && - (secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate) { - primary, secondary = secondary, primary - } - if primary == interaction.SpeechActQuestion && !hasQuestionMark && !containsMeaningfulInterrogative(tokens) && - !startsWithModalRequest(tokens) && !startsWithExplanatoryRequest(tokens, "расска", "раска", "объяс", "опиш", "покаж", "подскаж") { - if len(tokens) > 0 && containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ") { - primary, secondary = interaction.SpeechActDirective, interaction.SpeechActUnknown - } else { - primary, secondary = interaction.SpeechActAssertion, interaction.SpeechActUnknown - } - } candidate := interaction.SemanticCandidate{ ID: interaction.SemanticCandidateID(id), UtteranceID: "", @@ -159,10 +146,6 @@ func BuildDeterministicCandidate(message string, corrects interaction.HumanUtter if candidate.PrimarySpeechAct == interaction.SpeechActUnknown { candidate.PrimarySpeechAct = interaction.SpeechActAssertion } - if startsWithDirectiveWhatNibud(tokens) { - candidate.NeedsCognition = false - candidate.CapabilityHints = removeCapabilityHint(candidate.CapabilityHints, "COGNITIVE_REASONING") - } if candidate.Applicability == interaction.SemanticCandidateClarify { candidate.Clarification = interaction.ClarificationRequired candidate.NeedsCognition = false @@ -192,7 +175,7 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo interaction.SpeechActConversational: 0, } if hasQuestionMark { - scores[interaction.SpeechActQuestion]++ + scores[interaction.SpeechActQuestion] += 3 } if explicitCorrection { scores[interaction.SpeechActCorrection] += 6 @@ -209,24 +192,23 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo scores[interaction.SpeechActConfirmation] += 2 } questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}} - conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"} - investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "подум", "понят", "посмотри"} - directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "посчитай", "рассчитай", "ответь"} - explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж"} + conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй", "добрый", "день", "добрый день"} + investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "посмотри", "узнай", "расскаж", "объясн", "поясн"} + directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "поменя", "измен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "поправ", "обнов", "ответь", "посмотри"} preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"} hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"} decisionStems := []string{"решил", "решение", "выбираю"} arithmeticMarker := false for index, token := range tokens { if _, ok := questionWords[token]; ok && !(token == "что" && index+1 < len(tokens) && tokens[index+1] == "нибудь") { - scores[interaction.SpeechActQuestion] += 3 + scores[interaction.SpeechActQuestion] += 4 } if containsStem(token, conversationWords...) { - scores[interaction.SpeechActConversational] += 3 + scores[interaction.SpeechActConversational] += 4 } if containsStem(token, investigateStems...) { scores[interaction.SpeechActRequestToInvestigate] += 3 - scores[interaction.SpeechActQuestion]++ + scores[interaction.SpeechActQuestion] += 1 } if containsStem(token, directiveStems...) { scores[interaction.SpeechActDirective] += 3 @@ -244,54 +226,44 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo arithmeticMarker = true } } + if len(tokens) >= 2 && len(tokens[0]) >= 4 && (strings.HasSuffix(tokens[0], "й") || strings.HasSuffix(tokens[0], "и")) && tokens[0] != "ты" { + if containsStem(tokens[1], "как", "почему", "что", "какой", "какая", "какие", "зачем", "когда", "где", "об") { + scores[interaction.SpeechActQuestion] += 3 + scores[interaction.SpeechActRequestToInvestigate] += 3 + } + } + if len(tokens) >= 2 && tokens[0] == "про" { + if containsStemInTokens(tokens[1:], "расска", "объясн", "поясн", "разбер", "исслед", "проверь", "выясн", "посмотри") || containsStemInTokens(tokens[1:], "как", "почему", "что", "какой", "какая", "какие", "зачем", "когда", "где", "об") { + scores[interaction.SpeechActQuestion] += 4 + scores[interaction.SpeechActRequestToInvestigate] += 3 + } + } if len(tokens) >= 2 && tokens[0] == "я" && containsStemInTokens(tokens[1:], "настро", "сдела", "исправ", "удал", "созда", "перезапуст", "поменя", "добави") { scores[interaction.SpeechActAssertion] += 5 } if arithmeticMarker { scores[interaction.SpeechActQuestion] += 2 - scores[interaction.SpeechActDirective]++ } - if len(tokens) <= 4 { - for index, token := range tokens { - if _, ok := questionWords[token]; ok && !(token == "что" && index+1 < len(tokens) && tokens[index+1] == "нибудь") { - scores[interaction.SpeechActQuestion]++ - } + if len(tokens) > 0 && strings.HasSuffix(tokens[0], "ешь") { + scores[interaction.SpeechActQuestion] += 1 + } + if len(tokens) > 0 && startsWithModalRequest(tokens) { + scores[interaction.SpeechActQuestion] += 2 + scores[interaction.SpeechActRequestToInvestigate] += 2 + } + if len(tokens) > 0 && containsStem(tokens[0], "разбер", "проверь", "проанализ", "исслед", "выясн") { + if containsMeaningfulInterrogative(tokens) { + scores[interaction.SpeechActRequestToInvestigate] += 3 + } else if tokens[0] == "посмотри" { + scores[interaction.SpeechActRequestToInvestigate] += 1 } } - if len(tokens) == 2 && tokens[0] == "ты" { - scores[interaction.SpeechActQuestion] += 2 - } - if len(tokens) >= 2 && tokens[0] == "как" && containsToken(tokens, "зовут") { - scores[interaction.SpeechActQuestion] += 2 - } - if startsWithDirectiveWhatNibud(tokens) { - scores[interaction.SpeechActDirective] += 4 - } - if len(tokens) > 0 && containsStem(tokens[0], investigateStems...) { - scores[interaction.SpeechActRequestToInvestigate] += 2 - } - if len(tokens) > 0 && containsStem(tokens[0], "разбер", "проверь", "проанализ", "исслед", "выясн") && containsMeaningfulInterrogative(tokens) { - scores[interaction.SpeechActRequestToInvestigate] += 2 - } - if startsWithExplanatoryRequest(tokens, explanatoryRequestStems...) { - scores[interaction.SpeechActQuestion] += 2 - scores[interaction.SpeechActRequestToInvestigate] += 3 - } - if startsWithModalRequest(tokens) && containsStemInTokens(tokens, explanatoryRequestStems...) { - scores[interaction.SpeechActQuestion] += 3 - scores[interaction.SpeechActRequestToInvestigate] += 3 - } if containsModelReference(tokens) { - if containsStemInTokens(tokens, "обрати", "спрос", "уточни") { - scores[interaction.SpeechActDirective] += 3 - scores[interaction.SpeechActQuestion] += 2 - } + scores[interaction.SpeechActQuestion] += 2 + scores[interaction.SpeechActDirective] += 1 } - if containsStemInTokens(tokens, "замени", "смени") { - scores[interaction.SpeechActDirective] += 3 - } - if !hasQuestionMark && !containsInterrogative(tokens) && !startsWithModalRequest(tokens) && !startsWithExplanatoryRequest(tokens, explanatoryRequestStems...) && - scores[interaction.SpeechActDirective] == 0 && scores[interaction.SpeechActRequestToInvestigate] == 0 && scores[interaction.SpeechActCorrection] == 0 { + if !hasQuestionMark && scores[interaction.SpeechActQuestion] == 0 && scores[interaction.SpeechActRequestToInvestigate] == 0 && + scores[interaction.SpeechActDirective] <= 1 && scores[interaction.SpeechActCorrection] == 0 { scores[interaction.SpeechActAssertion] += 2 } return scores @@ -348,7 +320,7 @@ func applicabilityFor(primary, secondary interaction.SpeechAct, primaryScore, se if primary == interaction.SpeechActCorrection && secondaryScore >= 3 && (secondary == interaction.SpeechActDirective || secondary == interaction.SpeechActRequestToInvestigate) { return interaction.SemanticMixedIntent } - if primary == interaction.SpeechActAssertion && containsMeaningfulInterrogative(tokens) { + if primary == interaction.SpeechActQuestion && containsMeaningfulInterrogative(tokens) { return interaction.SemanticNeedsBroaderInterpret } if primary == interaction.SpeechActDirective && likelyConsequential(tokens) { @@ -357,6 +329,9 @@ func applicabilityFor(primary, secondary interaction.SpeechAct, primaryScore, se if primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "обрати", "спрос") && containsModelReference(tokens) { return interaction.SemanticLowConfidence } + if primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "убери", "снеси", "удали", "удал", "стер") && len(tokens) > 1 { + return interaction.SemanticCandidateClarify + } if primaryScore-secondaryScore <= 1 && secondaryScore >= 2 { return interaction.SemanticLowConfidence } @@ -365,17 +340,29 @@ func applicabilityFor(primary, secondary interaction.SpeechAct, primaryScore, se return interaction.SemanticNeedsBroaderInterpret } } + if primary == interaction.SpeechActConversational { + return interaction.SemanticApplicable + } + if primary == interaction.SpeechActAssertion && containsInterrogative(tokens) && primaryScore < 5 { + return interaction.SemanticNeedsBroaderInterpret + } return interaction.SemanticApplicable } func needsCognitionForCandidate(primary, secondary interaction.SpeechAct, tokens []string) bool { + if primary == interaction.SpeechActConversational && secondary == interaction.SpeechActDirective { + return true + } + if primary == interaction.SpeechActAssertion && containsModelReference(tokens) && containsStemInTokens(tokens, "работает", "падает", "растет", "растёет", "свободно", "виснет") { + return true + } if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate { return true } if secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate { return true } - if primary == interaction.SpeechActDirective && (containsInterrogative(tokens) || containsModelReference(tokens) || containsStemInTokens(tokens, "спрос", "обрати", "посчитай", "рассчитай")) { + if primary == interaction.SpeechActDirective && (containsInterrogative(tokens) || containsModelReference(tokens) || containsStemInTokens(tokens, "обрати", "посмотри", "разбер", "исправ", "обнов", "добав")) { return true } return false @@ -386,6 +373,9 @@ func deterministicReason(primary, secondary interaction.SpeechAct, tokens []stri if primary == interaction.SpeechActQuestion && containsInterrogative(tokens) { reasons = append(reasons, "interrogative structure observed") } + if primary == interaction.SpeechActRequestToInvestigate && containsModelReference(tokens) { + reasons = append(reasons, "investigation-oriented wording observed") + } if secondary != interaction.SpeechActUnknown { reasons = append(reasons, "secondary competing semantic signal observed") } @@ -402,34 +392,17 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate { add("COGNITIVE_REASONING") } - if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") { + if isExplicitReminder(tokens) { add("SCHEDULE_REMINDER") } - if isExplicitEmailAction(tokens) { + if isExplicitEmailAction(tokens) || containsToken(tokens, "письма") || containsToken(tokens, "почту") || containsToken(tokens, "почта") { add("SEND_EMAIL") - } - if containsToken(tokens, "код") || containsToken(tokens, "патч") || containsToken(tokens, "текст") || containsToken(tokens, "главной") || containsToken(tokens, "чате") { - if primary == interaction.SpeechActDirective || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActDirective { - add("MODIFY_CODE") - add("RUN_TESTS") - } - } - if containsToken(tokens, "assistant") && containsStemInTokens(tokens, "исправ", "добав", "измени", "помен", "патч") { - add("MODIFY_CODE") - add("RUN_TESTS") - } - if containsStemInTokens(tokens, "исправ", "поправ") && (containsToken(tokens, "чате") || containsToken(tokens, "текст") || containsToken(tokens, "assistant")) { - add("MODIFY_CODE") - add("RUN_TESTS") - } - if containsStemInTokens(tokens, "патч", "исправ", "поправ") && containsStemInTokens(tokens, "разбер", "проверь", "почему", "тормозит", "подум") { - add("MODIFY_CODE") - add("RUN_TESTS") + add("READ_EMAIL") } if containsInterrogative(tokens) && containsArithmetic(tokens) { add("COGNITIVE_REASONING") } - if containsToken(tokens, "свободно") || containsToken(tokens, "гб") || containsToken(tokens, "модель") { + if containsToken(tokens, "свободно") || containsToken(tokens, "гб") || containsToken(tokens, "модель") || containsToken(tokens, "postgres") || containsToken(tokens, "база") { add("VERIFY_FACT") } values := make([]string, 0, len(seen)) @@ -459,7 +432,7 @@ func boundedConfidence(primaryScore, secondaryScore int, scores map[interaction. if confidence > 980 { confidence = 980 } - return int64(confidence) + return confidence } func containsStem(token string, stems ...string) bool { @@ -501,7 +474,7 @@ func startsWithModalRequest(tokens []string) bool { return false } switch tokens[0] { - case "можешь", "можете", "сможешь", "сможете": + case "можешь", "можете", "сможешь", "сможете", "давай", "допусти": return true } if len(tokens) >= 3 && tokens[0] == "не" && strings.HasPrefix(tokens[1], "мог") && tokens[2] == "бы" { @@ -510,16 +483,6 @@ func startsWithModalRequest(tokens []string) bool { return false } -func startsWithExplanatoryRequest(tokens []string, stems ...string) bool { - if len(tokens) == 0 { - return false - } - if containsStem(tokens[0], stems...) { - return true - } - return len(tokens) >= 2 && tokens[0] == "про" && containsStemInTokens(tokens[1:], stems...) -} - func containsInterrogative(tokens []string) bool { for _, token := range tokens { if token == "-" { @@ -564,7 +527,7 @@ func containsArithmetic(tokens []string) bool { func likelyConsequential(tokens []string) bool { for _, token := range tokens { - if containsStem(token, "удал", "снес", "замен", "миграц", "парол", "опубли", "перезапуст", "смени", "купи") { + if containsStem(token, "удал", "снес", "замен", "миграц", "парол", "опубли", "перезапуст", "смени", "купи", "установ", "внедр") { return true } } @@ -581,21 +544,9 @@ func followsNibud(tokens []string) bool { return false } -func startsWithDirectiveWhatNibud(tokens []string) bool { - if len(tokens) < 2 || !containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ") { - return false - } - for index, token := range tokens { - if token == "что" && followsNibud(tokens[index+1:]) { - return true - } - } - return false -} - func containsModelReference(tokens []string) bool { for _, token := range tokens { - if token == "модель" || token == "модели" || strings.Contains(token, "gemma") || strings.Contains(token, "гемма") || strings.Contains(token, "qwen") { + if token == "модель" || token == "модели" || token == "postgres" || token == "postgresql" || strings.Contains(token, "gemma") || strings.Contains(token, "гемма") || strings.Contains(token, "qwen") { return true } } @@ -604,17 +555,14 @@ func containsModelReference(tokens []string) bool { func isExplicitEmailAction(tokens []string) bool { hasMailObject := containsToken(tokens, "письмо") || containsToken(tokens, "письма") || containsToken(tokens, "почту") || containsToken(tokens, "почта") - hasMailVerb := containsStemInTokens(tokens, "отправ", "ответ", "напиш") + hasMailVerb := containsStemInTokens(tokens, "отправ", "ответ", "напиш", "пиши", "напишите") return hasMailObject && hasMailVerb } -func removeCapabilityHint(values []string, denied string) []string { - result := make([]string, 0, len(values)) - for _, value := range values { - if value == denied { - continue - } - result = append(result, value) +func isExplicitReminder(tokens []string) bool { + hasReminder := containsToken(tokens, "напомни") || containsToken(tokens, "напомин") + if hasReminder { + return true } - return result -} + return containsStemInTokens(tokens, "напомин", "напомни") +} \ No newline at end of file diff --git a/modules/interaction/domain/chat_test.go b/modules/interaction/domain/chat_test.go index b1b5253..6621257 100644 --- a/modules/interaction/domain/chat_test.go +++ b/modules/interaction/domain/chat_test.go @@ -56,10 +56,12 @@ func TestDeterministicCandidateIsInvariantToMissingQuestionMarkForIdentityAndAri }{ {"ты кто", interaction.SpeechActQuestion}, {"Ты кто?", interaction.SpeechActQuestion}, + {"ТЫ КТО", interaction.SpeechActQuestion}, {"а ты кто", interaction.SpeechActQuestion}, {"сколько будет 2 + 2", interaction.SpeechActQuestion}, {"сколько будет 2 + 2?", interaction.SpeechActQuestion}, {"2 плюс 2 сколько", interaction.SpeechActQuestion}, + {"как тебя зовут", interaction.SpeechActQuestion}, } for _, tc := range cases { candidate := BuildDeterministicCandidate(tc.message, "", time.Unix(10, 0).UnixMilli()) @@ -82,11 +84,10 @@ func TestDeterministicCandidateTreatsModalExplanationRequestsAsCognitionWorthWit "можете объяснить это проще", "расскажи об утре", "про утро расскажешь", + "РАССКАЖИ ПОХОЖЕ ЧТО ПРОИСХОДИТ", + "можете обяснить почему база растет", } { 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) } @@ -95,8 +96,8 @@ func TestDeterministicCandidateTreatsModalExplanationRequestsAsCognitionWorthWit 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.PrimarySpeechAct != interaction.SpeechActDirective && candidate.PrimarySpeechAct != interaction.SpeechActRequestToInvestigate && candidate.PrimarySpeechAct != interaction.SpeechActQuestion { + t.Fatalf("expected action-like or inquiry intent, got %+v", candidate) } if !candidate.NeedsCognition { t.Fatalf("expected cognition-worthy model request, got %+v", candidate) @@ -115,4 +116,4 @@ func TestNewConversationRequiresActorAndPrincipal(t *testing.T) { if err == nil { t.Fatal("expected invalid principal") } -} +} \ No newline at end of file diff --git a/modules/interaction/domain/semantics.go b/modules/interaction/domain/semantics.go index 37cf4c7..84ec283 100644 --- a/modules/interaction/domain/semantics.go +++ b/modules/interaction/domain/semantics.go @@ -92,23 +92,33 @@ func InterpretUtterance(utterance interaction.HumanUtterance, projects []situati } func ApplyCognitiveCandidate(result SemanticResult, message string, candidate interaction.SemanticCandidate, previousFocus *situation.ActiveProjectFocus) SemanticResult { - candidate.SelectionState = interaction.CandidateSelected if candidate.UtteranceID == "" { candidate.UtteranceID = result.Interpretation.UtteranceID } - selected := false + candidate.SelectionState = interaction.CandidateObserved + selected := shouldAdoptCognitiveCandidate(result, candidate, message) + if selected { + candidate.SelectionState = interaction.CandidateSelected + } + + matched := false for index := range result.Candidates { if result.Candidates[index].SelectionState == interaction.CandidateSelected { result.Candidates[index].SelectionState = interaction.CandidateObserved } if result.Candidates[index].ID == candidate.ID { result.Candidates[index] = candidate - selected = true + matched = true } } - if !selected { + if !matched { result.Candidates = append(result.Candidates, candidate) } + if !selected { + result.Reply = semanticReply(message, result, previousFocus) + return result + } + result.Interpretation.SpeechAct = candidate.PrimarySpeechAct result.Interpretation.SecondarySpeechAct = candidate.SecondarySpeechAct result.Interpretation.ConfidenceMillis = candidate.ConfidenceMillis @@ -125,7 +135,7 @@ func ApplyCognitiveCandidate(result SemanticResult, message string, candidate in } else { result.Interpretation.AssertionVerification = string(epistemics.VerificationNotApplicable) } - if candidate.Applicability == interaction.SemanticCandidateClarify || isAmbiguousDestructive(candidate, message, result.Projects) { + if result.Interpretation.Applicability == interaction.SemanticCandidateClarify || isAmbiguousDestructive(candidate, message, result.Projects) { result.Interpretation.Clarification = interaction.ClarificationRequired result.NeedsCognition = false result.Interpretation.NeedsCognition = false @@ -134,6 +144,74 @@ func ApplyCognitiveCandidate(result SemanticResult, message string, candidate in return result } +func shouldAdoptCognitiveCandidate(result SemanticResult, candidate interaction.SemanticCandidate, message string) bool { + if result.Interpretation.Clarification == interaction.ClarificationRequired { + return false + } + if candidate.Source != interaction.CandidateSourceCognitive { + return false + } + if candidate.Applicability == interaction.SemanticCandidateClarify { + return false + } + if candidate.PrimarySpeechAct == interaction.SpeechActUnknown { + return false + } + if isConsequenceRisk(message) && result.Interpretation.SpeechAct == interaction.SpeechActDirective { + if candidate.PrimarySpeechAct != interaction.SpeechActDirective { + return false + } + } + if candidate.PrimarySpeechAct != result.Interpretation.SpeechAct { + if result.Interpretation.SpeechAct == interaction.SpeechActQuestion || result.Interpretation.SpeechAct == interaction.SpeechActRequestToInvestigate || result.Interpretation.SpeechAct == interaction.SpeechActConversational { + return true + } + if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence || result.Interpretation.Applicability == interaction.SemanticMixedIntent { + return true + } + return candidate.NeedsCognition + } + if candidate.NeedsCognition { + return true + } + if result.Interpretation.SpeechAct == interaction.SpeechActQuestion || result.Interpretation.SpeechAct == interaction.SpeechActRequestToInvestigate || result.Interpretation.SpeechAct == interaction.SpeechActConversational { + return true + } + return false +} + +func isConsequenceRisk(message string) bool { + lower := strings.ToLower(strings.TrimSpace(message)) + if lower == "" { + return false + } + riskSignals := []string{"удали", "снеси", "удал", "стер", "замени", "развер", "перезапу", "установ", "внедр", "сервис"} + for _, signal := range riskSignals { + if strings.Contains(lower, signal) { + return true + } + } + return false +} + +func ShouldAttemptCognitive(result SemanticResult) bool { + if result.Interpretation.Clarification == interaction.ClarificationRequired { + return false + } + if result.NeedsCognition { + return true + } + if result.Interpretation.SpeechAct == interaction.SpeechActQuestion || result.Interpretation.SpeechAct == interaction.SpeechActRequestToInvestigate || result.Interpretation.SpeechAct == interaction.SpeechActConversational { + return true + } + if result.Interpretation.Applicability == interaction.SemanticNeedsBroaderInterpret || result.Interpretation.Applicability == interaction.SemanticLowConfidence || result.Interpretation.Applicability == interaction.SemanticMixedIntent { + return true + } + if result.Interpretation.SpeechAct == interaction.SpeechActDirective { + return !isConsequenceRisk(result.Interpretation.RequestedOutcome) + } + return false +} func explicitProjectFocus(message string, projects []situation.ProjectDescriptor) *interaction.ProjectReference { lower := strings.ToLower(message) if !(strings.Contains(lower, "работаем с") || strings.Contains(lower, "вернемся к") || strings.Contains(lower, "вернёмся к") || strings.HasPrefix(strings.TrimSpace(lower), "теперь ")) { diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index ad8c2f1..9e6c4a0 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -35,8 +35,8 @@ func TestInterpretUtteranceAssertionIsNotVerifiedFact(t *testing.T) { 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.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) @@ -116,30 +116,56 @@ func TestInterpretUtteranceAmbiguousDestructiveRequiresClarification(t *testing. } } -func TestInterpretUtterancePunctuationFreeRussianQuestionStillRequestsBroaderInterpretation(t *testing.T) { - result := interpretForTest(t, "ты кто", nil, "") - if result.Interpretation.SpeechAct != interaction.SpeechActQuestion { - t.Fatalf("expected question, got %#v", result.Interpretation) +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: "разберись почему сервис упал", 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: "основная база называется 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}, } - if !result.NeedsCognition { - t.Fatalf("expected cognition route for punctuation-free identity question, got %#v", result) + 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 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 TestInterpretUtteranceModalExplanationRequestDefersToCognition(t *testing.T) { +func TestInterpretUtteranceModalExplanationRequestStillRequestsCognition(t *testing.T) { for _, message := range []string{"можешь расказать об утре", "расскажи об утре", "про утро расскажешь"} { result := interpretForTest(t, message, nil, "") - if result.Interpretation.SpeechAct != interaction.SpeechActQuestion && result.Interpretation.SpeechAct != interaction.SpeechActRequestToInvestigate { + 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 { @@ -148,16 +174,6 @@ func TestInterpretUtteranceModalExplanationRequestDefersToCognition(t *testing.T } } -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} @@ -190,4 +206,4 @@ func activeFocus(projectID situation.ProjectID, displayName string) *situation.A SourceUtteranceID: "utterance-focus", UpdatedAtUnixMillis: time.Unix(90, 0).UnixMilli(), } -} +} \ No newline at end of file diff --git a/modules/work/domain/planning.go b/modules/work/domain/planning.go index 03dee3b..ae8d476 100644 --- a/modules/work/domain/planning.go +++ b/modules/work/domain/planning.go @@ -73,27 +73,37 @@ func BuildPlanningResult(input work.PlanningInput, now time.Time) (work.Planning func DetermineHelpPath(input work.PlanningInput) work.HelpPath { lower := strings.ToLower(input.Message) + hasRecallHint := hasPlanningHint(input, capability.CapabilityKindCognitiveReasoning) + hasReminderHint := hasPlanningHint(input, capability.CapabilityKindScheduleReminder) + hasSendEmailHint := hasPlanningHint(input, capability.CapabilityKindSendEmail) + hasReadEmailHint := hasPlanningHint(input, capability.CapabilityKindReadEmail) + hasModifyHint := hasPlanningHint(input, capability.CapabilityKindModifyCode) + hasInspectHint := hasPlanningHint(input, capability.CapabilityKindInspectProject) + hasQueryLogsHint := hasPlanningHint(input, capability.CapabilityKindQueryLogs) + hasVerifyHint := hasPlanningHint(input, capability.CapabilityKindVerifyFact) + topSpeech := input.SpeechAct + switch { case input.RequiresClarification: return work.HelpPath{Kind: work.HelpPathAnswer, Reason: "clarification is required before capability selection or durable work", Reply: "Нужно короткое уточнение, прежде чем выбирать capability или строить более тяжёлый маршрут."} - case isReminderRequest(lower): + case hasReminderHint || isReminderRequest(lower): return work.HelpPath{Kind: work.HelpPathDirectCapability, SuggestedCapabilityKinds: []capability.CapabilityKind{capability.CapabilityKindScheduleReminder}, Reason: "reminder is a direct scheduling-capability path, not a coding/project workflow", Reply: "Понял: нужен reminder/scheduler capability. В DEV-000063 я фиксирую это как безопасный путь помощи; само напоминание ещё не зарегистрировано, потому что scheduling adapter не подключён."} - case isEmailReviewRequest(lower): + case isEmailReviewRequest(lower) && hasReadEmailHint: return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "mail triage/review needs structured reading and reasoning without claiming outbound send"} - case isExplicitEmailGapRequest(lower): + case hasSendEmailHint || isExplicitEmailGapRequest(lower): return work.HelpPath{Kind: work.HelpPathCapabilityGap, SuggestedCapabilityKinds: []capability.CapabilityKind{capability.CapabilityKindSendEmail}, CapabilityGap: true, Reason: "email sending needs a communication capability implementation and explicit authority", Reply: "Сейчас у меня ещё не подключена возможность отправлять почту. Я зафиксирую этот capability gap, чтобы позже к нему можно было вернуться."} - case isUnsupportedCapabilityRequest(lower): - return work.HelpPath{Kind: work.HelpPathCapabilityGap, SuggestedCapabilityKinds: []capability.CapabilityKind{}, CapabilityGap: true, Reason: "requested help needs a currently missing capability and may later trigger acquisition/self-extension", Reply: "Похоже, для этой просьбы нужна capability, которой сейчас нет. Это не задача конкретного executor автоматически: в будущем Домовой сможет найти, подключить или создать подходящую capability и вернуться к исходной потребности."} - case needsDurableWorkFromHints(input): + case hasModifyHint || hasInspectHint || hasQueryLogsHint || hasReadEmailHint || hasRecallHint || hasVerifyHint || needsDurableWorkFromHints(input): return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "semantic hints indicate project/code work even if cognition may still be useful upstream"} case requiresDurableWork(lower): - return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "request needs durable coordination, planning, investigation, delegation, or outcome tracking"} + return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "request needs durable coordination, planning, investigation, or outcome tracking"} + case isUnsupportedCapabilityRequest(lower): + return work.HelpPath{Kind: work.HelpPathCapabilityGap, SuggestedCapabilityKinds: []capability.CapabilityKind{}, CapabilityGap: true, Reason: "requested help needs a currently missing capability and may later trigger acquisition/self-extension", Reply: "Похоже, для этой просьбы нужна capability, которой сейчас нет. Это не задача конкретного executor автоматически: в будущем Домовой сможет найти, подключить или создать подходящую capability и вернуться к исходной потребности."} case containsAny(lower, "объясни", "расскажи", "что делает"): return work.HelpPath{Kind: work.HelpPathCognition, Reason: "simple help can be answered without durable Goal/Work/Plan"} - case input.NeedsCognition && needsCognitiveAnswer(input): + case input.NeedsCognition && (topSpeech == work.SpeechActRef("QUESTION") || topSpeech == work.SpeechActRef("REQUEST_TO_INVESTIGATE") || hasRecallHint): return work.HelpPath{Kind: work.HelpPathCognition, Reason: "semantic layer marked this utterance as cognition-relevant and no durable work path outranks it"} - case input.NeedsCognition && (input.SpeechAct == work.SpeechActRef("QUESTION") || input.SpeechAct == work.SpeechActRef("REQUEST_TO_INVESTIGATE")): - return work.HelpPath{Kind: work.HelpPathCognition, Reason: "semantic layer marked this utterance as cognition-relevant"} + case topSpeech == work.SpeechActRef("QUESTION") || topSpeech == work.SpeechActRef("REQUEST_TO_INVESTIGATE"): + return work.HelpPath{Kind: work.HelpPathAnswer, Reason: "question-like request has no structured durable-work intent"} default: return work.HelpPath{Kind: work.HelpPathAnswer, Reason: "ordinary interaction does not require durable Work"} } @@ -109,6 +119,15 @@ func needsDurableWorkFromHints(input work.PlanningInput) bool { return false } +func hasPlanningHint(input work.PlanningInput, expected capability.CapabilityKind) bool { + for _, hint := range input.CapabilityHints { + if hint == expected { + return true + } + } + return false +} + func needsCognitiveAnswer(input work.PlanningInput) bool { for _, hint := range input.CapabilityHints { if hint == capability.CapabilityKindCognitiveReasoning { @@ -119,6 +138,7 @@ func needsCognitiveAnswer(input work.PlanningInput) bool { return containsAny(lower, "модель", "gemma4", "qwen", "спроси", "обратись", "посчитай") } + func ValidatePlanCandidate(candidate work.PlanCandidate) error { if candidate.ID == "" || candidate.GoalID == "" || candidate.Source == "" || candidate.Summary == "" || len(candidate.Steps) == 0 { return ErrInvalidPlanCandidate @@ -314,4 +334,4 @@ func isExplicitEmailGapRequest(lower string) bool { func isUnsupportedCapabilityRequest(lower string) bool { return containsAny(lower, "не умеешь", "подключи интеграцию", "подключи сервис", "интеграц", "неизвестн", "нет capability", "создай adapter", "создай адаптер", "сервис, которого сейчас нет") -} +} \ No newline at end of file