diff --git a/cmd/core-runtime/household_scenario_test.go b/cmd/core-runtime/household_scenario_test.go index dd0ed2a..923fa17 100644 --- a/cmd/core-runtime/household_scenario_test.go +++ b/cmd/core-runtime/household_scenario_test.go @@ -35,3 +35,31 @@ func TestHouseholdCreativeRouteDoesNotUseResearch(t *testing.T) { t.Fatal("creative request was not recognized") } } + +func TestHouseholdMarketplaceBrandSearchUsesResearchRoute(t *testing.T) { + candidate := interactiondomain.BuildDeterministicCandidate("найди на WB бренд marka-wear", "", time.Unix(10, 0).UnixMilli()) + decomposition := interactiondomain.DecomposeTaskWithLexicon("найди на WB бренд marka-wear", interactiondomain.TaskLexicon{ObjectAliases: []interactiondomain.TaskLexiconEntry{{Alias: "бренд", Value: "BRAND"}}, SourceAliases: []interactiondomain.TaskLexiconEntry{{Alias: "wb", Value: "WILDBERRIES"}}}) + if decomposition.Intent != interactiondomain.TaskIntentSearch || decomposition.Object != "BRAND" || decomposition.Source != "WILDBERRIES" || decomposition.Target != "marka-wear" || len(decomposition.Missing) != 0 { + t.Fatalf("marketplace request was not split into executable atoms: %+v", decomposition) + } + routes := buildChatRoutePlan(interactiondomain.SemanticResult{NeedsCognition: candidate.NeedsCognition, CapabilityHints: candidate.CapabilityHints}) + if !hasChatRoute(routes, chatRouteResearch) { + t.Fatalf("marketplace brand request did not select research route: candidate=%+v routes=%+v", candidate, routes) + } +} + +func TestHouseholdBearDrawingUsesCreativeLocalRoute(t *testing.T) { + candidate := interactiondomain.BuildDeterministicCandidate("нарисуй бурого медведя", "", time.Unix(10, 0).UnixMilli()) + decomposition := interactiondomain.DecomposeTaskWithLexicon("нарисуй бурого медведя", interactiondomain.TaskLexicon{ObjectAliases: []interactiondomain.TaskLexiconEntry{{Alias: "медвед", Value: "IMAGE"}}}) + if decomposition.Intent != interactiondomain.TaskIntentCreate || decomposition.Object != "IMAGE" || decomposition.Target != "бурого медведя" || decomposition.NeedsResearch { + t.Fatalf("creative request was not split into local execution atoms: %+v", decomposition) + } + for _, hint := range candidate.CapabilityHints { + if capability.CapabilityKind(hint) == capability.CapabilityKindWebResearch { + t.Fatalf("bear drawing selected web research: %+v", candidate) + } + } + if got := interactiondomain.CreativeDeterministicReply("нарисуй бурого медведя"); got != "🐻" { + t.Fatalf("bear drawing fallback=%q", got) + } +} diff --git a/modules/interaction/domain/chat.go b/modules/interaction/domain/chat.go index 1812ed1..d3694a4 100644 --- a/modules/interaction/domain/chat.go +++ b/modules/interaction/domain/chat.go @@ -558,7 +558,7 @@ func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens [] } currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо") observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2) - placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац") + placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац", "бренд", "товар", "артикул", "продавец", "маркетплейс", "wildberries", "wb") travelSearchCue := containsStemInTokens(tokens, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт", "отправлен", "прибыт") return currentCue || observedStateCue || placeSearchCue || travelSearchCue } @@ -665,6 +665,9 @@ func CreativeDeterministicReply(message string) string { if containsStemInTokens(tokens, "нарис", "рису", "изобраз") && containsStemInTokens(tokens, "круг", "окруж") { return "◯" } + if containsStemInTokens(tokens, "нарис", "рису", "изобраз") && containsStemInTokens(tokens, "медвед", "мишк") { + return "🐻" + } return "" } diff --git a/modules/interaction/domain/chat_test.go b/modules/interaction/domain/chat_test.go index dee93cc..ba8b914 100644 --- a/modules/interaction/domain/chat_test.go +++ b/modules/interaction/domain/chat_test.go @@ -156,7 +156,7 @@ func TestSourceDependentQuestionRequestsWebResearchWithoutEntityBranch(t *testin } func TestSourceDependentQuestionToleratesTyposAndPlaceSearch(t *testing.T) { - for _, message := range []string{"какая пугода в Сочи", "найди ближайший ресторан в Сочи", "найди билеты до Ялты"} { + for _, message := range []string{"какая пугода в Сочи", "найди ближайший ресторан в Сочи", "найди билеты до Ялты", "найди на WB бренд marka-wear"} { candidate := BuildDeterministicCandidate(message, "", time.Unix(10, 0).UnixMilli()) seen := false for _, hint := range candidate.CapabilityHints { @@ -170,6 +170,25 @@ func TestSourceDependentQuestionToleratesTyposAndPlaceSearch(t *testing.T) { } } +func TestDecomposeMarketplaceBrandRequestIntoExecutionAtoms(t *testing.T) { + lexicon := TaskLexicon{ObjectAliases: []TaskLexiconEntry{{Alias: "бренд", Value: "BRAND"}}, SourceAliases: []TaskLexiconEntry{{Alias: "wb", Value: "WILDBERRIES"}}} + got := DecomposeTaskWithLexicon("найди на WB бренд marka-wear", lexicon) + if got.Intent != TaskIntentSearch || got.Object != "BRAND" || got.Source != "WILDBERRIES" || got.Target != "marka-wear" || !got.NeedsResearch { + t.Fatalf("unexpected marketplace decomposition: %+v", got) + } + if len(got.Missing) != 0 { + t.Fatalf("complete marketplace request marked incomplete: %+v", got) + } +} + +func TestDecomposeCreativeRequestDoesNotInventResearch(t *testing.T) { + lexicon := TaskLexicon{ObjectAliases: []TaskLexiconEntry{{Alias: "медвед", Value: "IMAGE"}}} + got := DecomposeTaskWithLexicon("нарисуй бурого медведя", lexicon) + if got.Intent != TaskIntentCreate || got.Object != "IMAGE" || got.Target != "бурого медведя" || got.NeedsResearch { + t.Fatalf("unexpected creative decomposition: %+v", got) + } +} + func TestRequestToClarifyCurrentFactRequestsWebResearch(t *testing.T) { candidate := BuildDeterministicCandidate("уточни погоду в питере", "", time.Unix(10, 0).UnixMilli()) for _, hint := range candidate.CapabilityHints { @@ -247,6 +266,9 @@ func TestInterpretCreativeRequestDoesNotRequireExternalEvidence(t *testing.T) { if got := CreativeFallbackReply("расскажи сказку о колобке"); !strings.Contains(got, "Колоб") { t.Fatalf("story fallback = %q", got) } + if got := CreativeDeterministicReply("нарисуй бурого медведя"); got != "🐻" { + t.Fatalf("bear reply = %q", got) + } } func containsString(values []string, expected string) bool { diff --git a/modules/interaction/domain/task_decomposition.go b/modules/interaction/domain/task_decomposition.go new file mode 100644 index 0000000..594be6e --- /dev/null +++ b/modules/interaction/domain/task_decomposition.go @@ -0,0 +1,110 @@ +package domain + +import "strings" + +// TaskDecomposition is the small, typed intermediate representation used +// before a capability is called. It separates the user's goal from the +// object, source and target instead of treating the whole sentence as a +// command string. +type TaskDecomposition struct { + Intent string + Object string + Source string + Target string + Qualifiers []string + Missing []string + NeedsResearch bool +} + +type TaskLexiconEntry struct { + Alias string + Value string +} + +type TaskLexicon struct { + ObjectAliases []TaskLexiconEntry + SourceAliases []TaskLexiconEntry +} + +const ( + TaskIntentSearch = "SEARCH" + TaskIntentCreate = "CREATE" +) + +// DecomposeTask performs the structural pass without assuming a mutable +// world vocabulary. Learned names, aliases and source meanings are supplied +// by memory through DecomposeTaskWithLexicon. +func DecomposeTask(message string) TaskDecomposition { + return DecomposeTaskWithLexicon(message, TaskLexicon{}) +} + +// DecomposeTaskWithLexicon performs a cheap semantic pass. The lexicon is +// intentionally an input: a remembered alias may be revalidated or replaced +// without changing the parser or shipping a new hard-coded branch. +func DecomposeTaskWithLexicon(message string, lexicon TaskLexicon) TaskDecomposition { + lower := strings.ToLower(strings.TrimSpace(message)) + tokens := semanticTokens(message) + result := TaskDecomposition{} + + if containsStemInTokens(tokens, "найд", "поищ", "подбер", "проверь") { + result.Intent = TaskIntentSearch + } + if containsStemInTokens(tokens, "нарис", "рису", "изобраз", "создай") { + result.Intent = TaskIntentCreate + } + for _, entry := range lexicon.ObjectAliases { + if containsStemInTokens(tokens, entry.Alias) { + result.Object = entry.Value + break + } + } + for _, entry := range lexicon.SourceAliases { + if containsStemInTokens(tokens, entry.Alias) { + result.Source = entry.Value + break + } + } + if result.Source != "" { + result.NeedsResearch = result.Intent == TaskIntentSearch + } + if result.Intent == TaskIntentCreate { + result.Target = valueAfterAnyCue(lower, "нарисуй", "нарисовать", "изобрази", "создай") + } + if result.Target == "" && result.Object != "" { + for _, entry := range lexicon.ObjectAliases { + if entry.Value == result.Object { + result.Target = valueAfterCue(lower, entry.Alias) + break + } + } + } + if result.Intent == TaskIntentSearch && result.Source == "" { + result.Missing = append(result.Missing, "SOURCE_OR_SEARCH_SCOPE") + } + if result.Intent == TaskIntentSearch && result.Target == "" { + result.Missing = append(result.Missing, "TARGET") + } + return result +} + +func valueAfterAnyCue(message string, cues ...string) string { + for _, cue := range cues { + if value := valueAfterCue(message, cue); value != "" { + return value + } + } + return "" +} + +func valueAfterCue(message, cue string) string { + index := strings.Index(message, cue) + if index < 0 { + return "" + } + value := strings.TrimSpace(message[index+len(cue):]) + value = strings.Trim(value, " \t:,-") + if end := strings.IndexAny(value, "?!,.;"); end >= 0 { + value = value[:end] + } + return strings.TrimSpace(value) +} diff --git a/tests/household_scenarios/scenarios.json b/tests/household_scenarios/scenarios.json index 9ea5d80..741d2df 100644 --- a/tests/household_scenarios/scenarios.json +++ b/tests/household_scenarios/scenarios.json @@ -9,7 +9,7 @@ { "id": "SCN-HH-002", "category": "current_information", - "messages": ["я сейчас в Москве", "какая погода в Питере?", "проверь цену на RTX 4090"], + "messages": ["я сейчас в Москве", "какая погода в Питере?", "проверь цену на RTX 4090", "найди на WB бренд marka-wear"], "must": ["current facts use web research or an explicit source path", "location and time defaults are applied when confidence is high", "answer synthesizes evidence"], "must_not": ["raw search snippets", "search-engine query URLs as the answer", "unsupported certainty"] }, @@ -44,7 +44,7 @@ { "id": "SCN-HH-007", "category": "creative_conversation", - "messages": ["нарисуй круг", "расскажи сказку о Колобке", "придумай шутку"], + "messages": ["нарисуй круг", "нарисуй бурого медведя", "расскажи сказку о Колобке", "придумай шутку"], "must": ["creative requests avoid irrelevant web search", "simple safe output can use a local fast path", "rich output uses cognition when available"], "must_not": ["technical evidence refusal for a creative request", "provider name replacing Domovoy identity"] },