Route marketplace search and bear drawing #351
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user