package domain import "strings" import "git.cin.su/m/Assistant/contracts/interaction" // 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{}) } // TaskLexiconFromHistory rebuilds learned search-source aliases from ordinary // human utterances. The fact remains user-owned conversational memory; core // code never embeds mutable marketplace or provider names. // // A source fact has the deliberately explicit form: // "запомни: — это , источник для поиска". func TaskLexiconFromHistory(history []interaction.HumanUtterance) TaskLexicon { lexicon := TaskLexicon{} for _, utterance := range history { message := strings.TrimSpace(utterance.Content) lower := strings.ToLower(message) if !strings.Contains(lower, "источник") || !strings.Contains(lower, "поиск") || !strings.Contains(lower, "это") { continue } before, after, found := strings.Cut(lower, "это") if !found { continue } alias := strings.TrimSpace(strings.TrimPrefix(before, "запомни:")) alias = strings.Trim(alias, " \t—-:;,") value := strings.TrimSpace(after) if index := strings.IndexAny(value, ",;."); index >= 0 { value = strings.TrimSpace(value[:index]) } if len([]rune(alias)) < 2 || len([]rune(value)) < 2 { continue } lexicon.SourceAliases = append(lexicon.SourceAliases, TaskLexiconEntry{Alias: alias, Value: value}) } return lexicon } // SearchQueryWithLexicon prepares a search-engine query without turning a // learned source alias into core vocabulary. The imperative that expresses the // user's intent is deliberately omitted: search engines must receive the // subject of the request, not mistake a Russian command such as "найди" for a // brand or site name. func SearchQueryWithLexicon(message string, lexicon TaskLexicon) string { decomposition := DecomposeTaskWithLexicon(message, lexicon) if decomposition.Intent == TaskIntentSearch && decomposition.Source != "" { if decomposition.Target != "" { return decomposition.Target + " " + decomposition.Source } return searchQueryWithoutImperative(message) + " " + decomposition.Source } if decomposition.Intent == TaskIntentSearch { return searchQueryWithoutImperative(message) } return strings.TrimSpace(message) } func searchQueryWithoutImperative(message string) string { cleaned := strings.TrimSpace(message) fields := strings.Fields(cleaned) if len(fields) < 2 { return cleaned } first := strings.Trim(strings.ToLower(fields[0]), ".,!?:;") if !containsStem(first, "найд", "поищ", "подбер", "проверь") { return cleaned } return strings.TrimSpace(strings.TrimPrefix(cleaned, fields[0])) } // 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]) }