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 lexiconAliasMatches(tokens, entry.Alias) { result.Object = entry.Value break } } for _, entry := range lexicon.SourceAliases { if lexiconAliasMatches(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 && lexiconAliasMatches(tokens, entry.Alias) { 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 lexiconAliasMatches(tokens []string, alias string) bool { alias = strings.TrimSpace(strings.ToLower(alias)) if len([]rune(alias)) < 2 { return false } return containsStemInTokens(tokens, alias) } 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:,-") end := len(value) if punctuation := strings.IndexAny(value, "?!,.;"); punctuation >= 0 && punctuation < end { end = punctuation } for _, boundary := range []string{" в ", " на ", " из ", " для ", " около "} { if index := strings.Index(value, boundary); index >= 0 && index < end { end = index } } return strings.TrimSpace(value[:end]) }