191 lines
6.2 KiB
Go
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])
|
|
}
|