Files
m 369588ada7
CI / test (push) Successful in 1m25s
CI / test (pull_request) Failing after 55s
Clean search imperatives before research
2026-08-26 00:24:45 +03:00

191 lines
6.2 KiB
Go

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:
// "запомни: <alias> — это <source>, источник для поиска".
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])
}