Route marketplace search and bear drawing #351

Merged
m merged 4 commits from codex/household-scenario-audit into main 2026-08-25 11:55:31 +00:00
5 changed files with 167 additions and 4 deletions
@@ -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)
}
}
+4 -1
View File
@@ -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 ""
}
+23 -1
View File
@@ -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 {
@@ -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)
}
+2 -2
View File
@@ -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"]
},