From d50d709d69772881a6a8794a1e9beefb9efc837a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Tue, 18 Aug 2026 17:46:14 +0300 Subject: [PATCH] refactor: prioritize cognitive interpretation over deterministic rules --- modules/interaction/domain/chat.go | 146 +++++++++++++++---- modules/interaction/domain/semantics_test.go | 2 + modules/work/domain/planning.go | 48 ++++-- modules/work/domain/planning_test.go | 48 +++++- 4 files changed, 198 insertions(+), 46 deletions(-) diff --git a/modules/interaction/domain/chat.go b/modules/interaction/domain/chat.go index 8a81e9b..5fee581 100644 --- a/modules/interaction/domain/chat.go +++ b/modules/interaction/domain/chat.go @@ -175,7 +175,8 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo interaction.SpeechActConversational: 0, } if hasQuestionMark { - scores[interaction.SpeechActQuestion] += 3 + scores[interaction.SpeechActQuestion] += 4 + scores[interaction.SpeechActRequestToInvestigate]++ } if explicitCorrection { scores[interaction.SpeechActCorrection] += 6 @@ -192,14 +193,17 @@ func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bo scores[interaction.SpeechActConfirmation] += 2 } questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}} - conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй", "добрый", "день", "добрый день"} - investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "посмотри", "узнай", "расскаж", "объясн", "поясн"} - directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "поменя", "измен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "поправ", "обнов", "ответь", "посмотри"} + conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй", "добрый"} + investigateStems := []string{"разбер", "проверь", "провери", "проанализ", "исслед", "выясн", "узнай"} + directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "поменя", "измен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "поправ", "обнов", "ответь", "посмотри", "проверь"} preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"} hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"} decisionStems := []string{"решил", "решение", "выбираю"} arithmeticMarker := false for index, token := range tokens { + if firstPersonPastReport(tokens, index) && containsStem(token, directiveStems...) { + continue + } if _, ok := questionWords[token]; ok && !(token == "что" && index+1 < len(tokens) && tokens[index+1] == "нибудь") { scores[interaction.SpeechActQuestion] += 4 } @@ -226,37 +230,24 @@ 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 looksLikeInstructionWithoutPunctuationAfterLead(tokens) { + scores[interaction.SpeechActQuestion] += 4 + scores[interaction.SpeechActRequestToInvestigate] += 2 } if arithmeticMarker { scores[interaction.SpeechActQuestion] += 2 } - 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 looksLikeInformationRequest(tokens) { + scores[interaction.SpeechActQuestion] += 4 + scores[interaction.SpeechActRequestToInvestigate] += 2 + } + if !hasQuestionMark && containsInterrogative(tokens) { + scores[interaction.SpeechActQuestion] += 4 + scores[interaction.SpeechActRequestToInvestigate] += 2 } if containsModelReference(tokens) { scores[interaction.SpeechActQuestion] += 2 @@ -353,6 +344,12 @@ func needsCognitionForCandidate(primary, secondary interaction.SpeechAct, tokens if primary == interaction.SpeechActConversational && secondary == interaction.SpeechActDirective { return true } + if primary == interaction.SpeechActQuestion && containsMeaningfulInterrogative(tokens) { + return true + } + if isAssistantStatusProbe(primary, secondary, tokens) { + return true + } if primary == interaction.SpeechActAssertion && containsModelReference(tokens) && containsStemInTokens(tokens, "работает", "падает", "растет", "растёет", "свободно", "виснет") { return true } @@ -362,9 +359,6 @@ func needsCognitionForCandidate(primary, secondary interaction.SpeechAct, tokens if secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate { return true } - if isAssistantStatusProbe(primary, secondary, tokens) { - return true - } if primary == interaction.SpeechActDirective && (containsInterrogative(tokens) || containsModelReference(tokens) || containsStemInTokens(tokens, "обрати", "посмотри", "разбер", "исправ", "обнов", "добав")) { return true } @@ -405,9 +399,23 @@ func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []strin add("SEND_EMAIL") add("READ_EMAIL") } + if primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "добав", "реализ", "поддержк") { + add("MODIFY_CODE") + } + if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") { + add("SCHEDULE_REMINDER") + } + if primary == interaction.SpeechActRequestToInvestigate || (primary == interaction.SpeechActQuestion && containsInterrogative(tokens) && containsStemInTokens(tokens, "почему", "как", "что")) { + add("COGNITIVE_REASONING") + } if containsInterrogative(tokens) && containsArithmetic(tokens) { add("COGNITIVE_REASONING") } + if containsStemInTokens(tokens, "тормоз", "медлен", "почему", "что", "сервис", "база", "падает") { + add("INSPECT_PROJECT") + add("READ_DATABASE") + add("QUERY_LOGS") + } if containsToken(tokens, "свободно") || containsToken(tokens, "гб") || containsToken(tokens, "модель") || containsToken(tokens, "postgres") || containsToken(tokens, "база") { add("VERIFY_FACT") } @@ -531,6 +539,66 @@ func containsArithmetic(tokens []string) bool { return digits >= 2 } +func looksLikeInstructionWithoutPunctuation(first string) bool { + if first == "" { + return false + } + switch { + case strings.HasSuffix(first, "й"), strings.HasSuffix(first, "и"), strings.HasSuffix(first, "ите"): + return utf8.RuneCountInString(first) > 3 + default: + return false + } +} + +func looksLikeInstructionWithoutPunctuationAfterLead(tokens []string) bool { + if len(tokens) < 2 { + return false + } + if utf8.RuneCountInString(tokens[0]) > 3 { + return false + } + switch tokens[0] { + case "про", "по", "на", "в", "за": + default: + return false + } + for _, token := range tokens[1:] { + if utf8.RuneCountInString(token) <= 3 { + continue + } + switch { + case strings.HasSuffix(token, "ешь"), strings.HasSuffix(token, "и"), strings.HasSuffix(token, "й"), strings.HasSuffix(token, "те"), strings.HasSuffix(token, "йте"): + return true + } + } + return false +} + +func looksLikeInformationRequest(tokens []string) bool { + if len(tokens) < 2 { + return false + } + if !containsSuffix(tokens[0], "й", "и", "те", "йте") { + return false + } + for _, token := range tokens[1:] { + if containsStem(token, "что", "как", "почему", "зачем", "когда", "кто", "какой", "какая", "какие", "об", "про", "подроб") { + return true + } + } + return false +} + +func containsSuffix(value string, suffixes ...string) bool { + for _, suffix := range suffixes { + if strings.HasSuffix(value, suffix) { + return true + } + } + return false +} + func likelyConsequential(tokens []string) bool { for _, token := range tokens { if containsStem(token, "удал", "снес", "замен", "миграц", "парол", "опубли", "перезапуст", "смени", "купи", "установ", "внедр") { @@ -550,6 +618,24 @@ func followsNibud(tokens []string) bool { return false } +func firstPersonPastReport(tokens []string, index int) bool { + if len(tokens) == 0 || index == 0 { + return false + } + if tokens[0] != "я" && tokens[0] != "мы" { + return false + } + if index < 2 { + return false + } + for _, token := range tokens[:index] { + if token == "всё" || token == "все" || token == "сейчас" || token == "уже" { + return true + } + } + return false +} + func containsModelReference(tokens []string) bool { for _, token := range tokens { if token == "модель" || token == "модели" || token == "postgres" || token == "postgresql" || strings.Contains(token, "gemma") || strings.Contains(token, "гемма") || strings.Contains(token, "qwen") { diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index 44bed1b..ade21f6 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -129,6 +129,8 @@ func TestInterpretUtteranceNaturalRussianHelpflowUsesContextAwareCognitionInput( {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}, diff --git a/modules/work/domain/planning.go b/modules/work/domain/planning.go index 3b11acb..98e148e 100644 --- a/modules/work/domain/planning.go +++ b/modules/work/domain/planning.go @@ -82,11 +82,16 @@ func DetermineHelpPath(input work.PlanningInput) work.HelpPath { hasQueryLogsHint := hasPlanningHint(input, capability.CapabilityKindQueryLogs) hasVerifyHint := hasPlanningHint(input, capability.CapabilityKindVerifyFact) rawTextFallbackAllowed := len(input.CapabilityHints) == 0 && !input.RequiresClarification && !input.NeedsCognition + if input.SpeechAct != work.SpeechActRef("") && input.SpeechAct != work.SpeechActRef("UNKNOWN") { + rawTextFallbackAllowed = false + } 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 topSpeech == work.SpeechActRef("REQUEST_TO_INVESTIGATE"): + return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "explicit request-to-investigate should materialize durable work"} case hasReminderHint || (rawTextFallbackAllowed && 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) && hasReadEmailHint: @@ -95,10 +100,10 @@ func DetermineHelpPath(input work.PlanningInput) work.HelpPath { 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 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 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 rawTextFallbackAllowed && requiresDurableWork(lower): return work.HelpPath{Kind: work.HelpPathDurableWork, NeedsDurableGoal: true, NeedsDurableWork: true, NeedsDurablePlan: true, Reason: "request needs durable coordination, planning, investigation, or outcome tracking"} - case rawTextFallbackAllowed && 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 rawTextFallbackAllowed && containsAny(lower, "объясни", "расскажи", "что делает"): return work.HelpPath{Kind: work.HelpPathCognition, Reason: "simple help can be answered without durable Goal/Work/Plan"} case input.NeedsCognition && (topSpeech == work.SpeechActRef("QUESTION") || topSpeech == work.SpeechActRef("REQUEST_TO_INVESTIGATE") || hasRecallHint): @@ -173,14 +178,32 @@ func ValidatePlanCandidate(candidate work.PlanCandidate) error { func addScenarioPlan(input work.PlanningInput, millis int64, candidate *work.PlanCandidate) { lower := strings.ToLower(input.Message) + 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) + requiresInvestigation := input.SpeechAct == work.SpeechActRef("REQUEST_TO_INVESTIGATE") if input.SecondarySpeechAct == work.SpeechActRef("CORRECTION") { addStep(candidate, work.PlanStepClarify, "Preserve the correction as part of the same human intent history before execution-oriented work.", len(candidate.Steps)+1, millis) } switch { - case isReminderRequest(lower): - addRequirement(candidate, capability.CapabilityKindScheduleReminder, "schedule a future reminder after temporal interpretation", epistemics.VerificationNotApplicable, millis) - addStep(candidate, work.PlanStepSchedule, "Interpret the requested reminder time and reminder subject.", 1, millis) + case hasReminderHint: + addRequirement(candidate, capability.CapabilityKindScheduleReminder, "respecting a structured reminder intent", epistemics.VerificationNotApplicable, millis) + addStep(candidate, work.PlanStepSchedule, "Interpret requested reminder horizon and context.", 1, millis) addStep(candidate, work.PlanStepNoExecution, "Do not register a reminder until scheduling capability/authority is available.", 2, millis) + case hasReadEmailHint: + addRequirement(candidate, capability.CapabilityKindReadEmail, "read incoming communications", epistemics.VerificationUnknown, millis) + addRequirement(candidate, capability.CapabilityKindCognitiveReasoning, "classify messages needing response", epistemics.VerificationUnknown, millis) + addStep(candidate, work.PlanStepObserve, "Read relevant email messages through a mail capability.", 1, millis) + addStep(candidate, work.PlanStepReason, "Classify which messages need a response and why.", 2, millis) + addStep(candidate, work.PlanStepReport, "Report recommended replies without claiming email was sent.", 3, millis) + case hasSendEmailHint: + addRequirement(candidate, capability.CapabilityKindSendEmail, "send email only after authority", epistemics.VerificationNotApplicable, millis) + addStep(candidate, work.PlanStepPrepare, "Resolve recipient and appropriate communication channel.", 1, millis) + addStep(candidate, work.PlanStepCommunicate, "Prepare the message for a communication adapter; do not send without authority.", 2, millis) case isEmailReviewRequest(lower): addRequirement(candidate, capability.CapabilityKindReadEmail, "read incoming communications", epistemics.VerificationUnknown, millis) addRequirement(candidate, capability.CapabilityKindCognitiveReasoning, "classify messages needing response", epistemics.VerificationUnknown, millis) @@ -191,25 +214,29 @@ func addScenarioPlan(input work.PlanningInput, millis int64, candidate *work.Pla addRequirement(candidate, capability.CapabilityKindSendEmail, "send email only after authority", epistemics.VerificationNotApplicable, millis) addStep(candidate, work.PlanStepPrepare, "Resolve recipient and appropriate communication channel.", 1, millis) addStep(candidate, work.PlanStepCommunicate, "Prepare the message for a communication adapter; do not send without authority.", 2, millis) - case containsAny(lower, "тормозит", "медлен", "производительн", "разберись", "почему"): + case isReminderRequest(lower): + addRequirement(candidate, capability.CapabilityKindScheduleReminder, "schedule a future reminder after temporal interpretation", epistemics.VerificationNotApplicable, millis) + addStep(candidate, work.PlanStepSchedule, "Interpret the requested reminder time and reminder subject.", 1, millis) + addStep(candidate, work.PlanStepNoExecution, "Do not register a reminder until scheduling capability/authority is available.", 2, millis) + case requiresInvestigation || hasInspectHint || hasQueryLogsHint || hasVerifyHint: addRequirement(candidate, capability.CapabilityKindInspectProject, "inspect project context", epistemics.VerificationNotApplicable, millis) addRequirement(candidate, capability.CapabilityKindReadDatabase, "collect database performance observations", epistemics.VerificationUnknown, millis) addRequirement(candidate, capability.CapabilityKindQueryLogs, "collect runtime/log observations", epistemics.VerificationUnknown, millis) addRequirement(candidate, capability.CapabilityKindCognitiveReasoning, "compare evidence and identify likely causes", epistemics.VerificationUnknown, millis) - if containsAny(lower, "патч", "исправ") { + if containsAny(lower, "патч", "исправ") || containsAny(lower, "обнов", "конфиг") { addRequirement(candidate, capability.CapabilityKindModifyCode, "modify software after investigation confirms the change", epistemics.VerificationUnknown, millis) addRequirement(candidate, capability.CapabilityKindRunTests, "validate the patch after code modification", epistemics.VerificationNotApplicable, millis) } addStep(candidate, work.PlanStepInvestigate, "Identify the relevant project/service scope and current symptoms.", 1, millis) addStep(candidate, work.PlanStepObserve, "Collect database, runtime, log and configuration observations.", 2, millis) addStep(candidate, work.PlanStepReason, "Compare evidence and determine likely performance causes.", 3, millis) - if containsAny(lower, "патч", "исправ") { + if containsAny(lower, "патч", "исправ") || containsAny(lower, "обнов", "конфиг") { addStep(candidate, work.PlanStepModify, "Prepare a validated patch only after the investigation narrows the likely cause.", 4, millis) addStep(candidate, work.PlanStepRunChecks, "Run relevant checks before claiming the patch is safe.", 5, millis) } else { addStep(candidate, work.PlanStepReport, "Report findings and what remains unverified.", 4, millis) } - case containsAny(lower, "добавь", "поддержк", "возможность", "реализ"): + case hasModifyHint: addRequirement(candidate, capability.CapabilityKindModifyCode, "modify software project", epistemics.VerificationNotApplicable, millis) addRequirement(candidate, capability.CapabilityKindRunTests, "validate software change", epistemics.VerificationNotApplicable, millis) addStep(candidate, work.PlanStepInvestigate, "Understand desired capability and affected project scope.", 1, millis) @@ -227,7 +254,6 @@ func addScenarioPlan(input work.PlanningInput, millis int64, candidate *work.Pla addDelegation(candidate, requirement, millis) } } - func requiresDurableWork(lower string) bool { return containsAny(lower, "тормозит", "медлен", "производительн", "разберись", "почему", "добавь", "поддержк", "возможность", "реализ", "патч", "исправь", "установи", "большую модель") } @@ -335,4 +361,4 @@ func isExplicitEmailGapRequest(lower string) bool { func isUnsupportedCapabilityRequest(lower string) bool { return containsAny(lower, "не умеешь", "подключи интеграцию", "подключи сервис", "интеграц", "неизвестн", "нет capability", "создай adapter", "создай адаптер", "сервис, которого сейчас нет") -} +}\r\n \ No newline at end of file diff --git a/modules/work/domain/planning_test.go b/modules/work/domain/planning_test.go index b08c239..597f569 100644 --- a/modules/work/domain/planning_test.go +++ b/modules/work/domain/planning_test.go @@ -25,7 +25,9 @@ func TestPlanningInvestigationIsNotCodexPrompt(t *testing.T) { } func TestPlanningCodeChangeRequiresModifyCodeWithoutExecutor(t *testing.T) { - result := planForTest(t, "В Assistant добавь поддержку X.", "DIRECTIVE", epistemics.VerificationNotApplicable) + input := planningInputForTest("В Assistant добавь поддержку X.", "DIRECTIVE", epistemics.VerificationNotApplicable) + input.CapabilityHints = []capability.CapabilityKind{capability.CapabilityKindModifyCode} + result := planForTestWithInput(t, input) requireKinds(t, result, capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests) for _, delegation := range result.Candidate.Delegations { if delegation.ExecutorHint != "" { @@ -41,7 +43,9 @@ func TestPlanningEmailReviewIsNotCodexSpecific(t *testing.T) { } func TestPlanningReminderUsesSchedulingRequirement(t *testing.T) { - result := planForTest(t, "Напомни завтра позвонить врачу.", "DIRECTIVE", epistemics.VerificationNotApplicable) + input := planningInputForTest("Напомни завтра позвонить врачу.", "DIRECTIVE", epistemics.VerificationNotApplicable) + input.CapabilityHints = []capability.CapabilityKind{capability.CapabilityKindScheduleReminder} + result := planForTestWithInput(t, input) requireKinds(t, result, capability.CapabilityKindScheduleReminder) requireNoKind(t, result, capability.CapabilityKindModifyCode) } @@ -79,14 +83,18 @@ func TestPlanningFallbackPersistsWithoutCognition(t *testing.T) { } func TestHelpPathSimpleAnswerDoesNotRequireDurableGoalWorkPlan(t *testing.T) { - path := DetermineHelpPath(planningInputForTest("Объясни, что делает этот код.", "QUESTION", epistemics.VerificationNotApplicable)) + input := planningInputForTest("Объясни, что делает этот код.", "QUESTION", epistemics.VerificationNotApplicable) + input.NeedsCognition = true + path := DetermineHelpPath(input) if path.Kind != work.HelpPathCognition || path.NeedsDurableGoal || path.NeedsDurableWork || path.NeedsDurablePlan { t.Fatalf("simple help must not require durable planning: %#v", path) } } func TestHelpPathReminderCanUseDirectCapabilityWithoutWorkflow(t *testing.T) { - path := DetermineHelpPath(planningInputForTest("Напомни завтра позвонить.", "DIRECTIVE", epistemics.VerificationNotApplicable)) + input := planningInputForTest("Напомни завтра позвонить.", "DIRECTIVE", epistemics.VerificationNotApplicable) + input.CapabilityHints = []capability.CapabilityKind{capability.CapabilityKindScheduleReminder} + path := DetermineHelpPath(input) if path.Kind != work.HelpPathDirectCapability || path.NeedsDurableWork || len(path.SuggestedCapabilityKinds) != 1 || path.SuggestedCapabilityKinds[0] != capability.CapabilityKindScheduleReminder { t.Fatalf("reminder should be direct scheduling capability path: %#v", path) } @@ -99,6 +107,31 @@ func TestHelpPathComplexWorkMaterializesDurablePlanning(t *testing.T) { } } +func TestHelpPathStructuredIntentNotOverriddenByRawRussianText(t *testing.T) { + path := DetermineHelpPath(work.PlanningInput{ + ConversationID: "conversation-1", + UtteranceID: "utterance-1", + IntentID: "intent-1", + ActorID: identity.ActorID("actor-1"), + PrincipalID: identity.PrincipalID("principal-1"), + ProjectID: work.ProjectRef("upo"), + ProjectDisplayName: "УПО", + ProjectProvenance: work.ProjectProvenanceRef("EXPLICIT"), + Message: "Объясни, как добавить поддержку отправки писем в этом проекте.", + SpeechAct: work.SpeechActRef("DIRECTIVE"), + NeedsCognition: false, + CapabilityHints: []capability.CapabilityKind{capability.CapabilityKindModifyCode}, + AssertionVerification: epistemics.VerificationNotApplicable, + ReceivedAtUnixMillis: time.Unix(100, 0).UnixMilli(), + }) + if path.Kind != work.HelpPathDurableWork || !path.NeedsDurableWork || !path.NeedsDurablePlan { + t.Fatalf("structured durable intent should be preserved over raw phrase heuristics: %#v", path) + } + if path.Kind == work.HelpPathCognition { + t.Fatalf("raw Russian phrase should not force cognition when selected interpretation is already directive: %#v", path) + } +} + func TestHelpPathCapabilityGapDoesNotBecomeCodexTask(t *testing.T) { path := DetermineHelpPath(planningInputForTest("Подключи интеграцию с неизвестным сервисом, которого сейчас нет.", "DIRECTIVE", epistemics.VerificationNotApplicable)) if path.Kind != work.HelpPathCapabilityGap || !path.CapabilityGap { @@ -121,7 +154,12 @@ func TestHelpPathStructuredHintsOverrideRawCognitionPhrase(t *testing.T) { func planForTest(t *testing.T, message string, act string, verification epistemics.VerificationState) work.PlanningResult { t.Helper() - result, err := BuildPlanningResult(planningInputForTest(message, act, verification), time.Unix(100, 0)) + return planForTestWithInput(t, planningInputForTest(message, act, verification)) +} + +func planForTestWithInput(t *testing.T, input work.PlanningInput) work.PlanningResult { + t.Helper() + result, err := BuildPlanningResult(input, time.Unix(100, 0)) if err != nil { t.Fatal(err) }