979 lines
40 KiB
Go
979 lines
40 KiB
Go
package domain
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.cin.su/m/Assistant/contracts/identity"
|
|
"git.cin.su/m/Assistant/contracts/interaction"
|
|
"git.cin.su/m/Assistant/platform/ids"
|
|
)
|
|
|
|
const MaxHumanUtteranceBytes = 16 << 10
|
|
|
|
var (
|
|
ErrEmptyMessage = errors.New("empty human message")
|
|
ErrMessageTooLarge = errors.New("human message too large")
|
|
ErrInvalidPrincipal = errors.New("invalid interaction principal")
|
|
)
|
|
|
|
type Clock func() time.Time
|
|
|
|
func NewConversation(actorID identity.ActorID, principalID identity.PrincipalID, now time.Time) (interaction.Conversation, error) {
|
|
if actorID == "" || principalID == "" {
|
|
return interaction.Conversation{}, ErrInvalidPrincipal
|
|
}
|
|
id, err := ids.New()
|
|
if err != nil {
|
|
return interaction.Conversation{}, err
|
|
}
|
|
millis := now.UnixMilli()
|
|
return interaction.Conversation{
|
|
ID: interaction.ConversationID(id),
|
|
ActorID: actorID,
|
|
PrincipalID: principalID,
|
|
Channel: interaction.ChannelWebChat,
|
|
State: interaction.ConversationOpen,
|
|
CreatedAtUnixMillis: millis,
|
|
UpdatedAtUnixMillis: millis,
|
|
}, nil
|
|
}
|
|
|
|
func NewHumanUtterance(conversation interaction.Conversation, request interaction.ChatMessageRequest, now time.Time) (interaction.HumanUtterance, interaction.OriginalHumanIntent, []interaction.InteractionRelation, error) {
|
|
message := strings.TrimSpace(request.Message)
|
|
if message == "" {
|
|
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, ErrEmptyMessage
|
|
}
|
|
if len(message) > MaxHumanUtteranceBytes || !utf8.ValidString(message) {
|
|
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, ErrMessageTooLarge
|
|
}
|
|
utteranceID, err := ids.New()
|
|
if err != nil {
|
|
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, err
|
|
}
|
|
intentID, err := ids.New()
|
|
if err != nil {
|
|
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, err
|
|
}
|
|
millis := now.UnixMilli()
|
|
candidate := BuildDeterministicCandidate(message, request.CorrectsUtteranceID, millis)
|
|
speechAct := candidate.PrimarySpeechAct
|
|
utterance := interaction.HumanUtterance{
|
|
ID: interaction.HumanUtteranceID(utteranceID),
|
|
ConversationID: conversation.ID,
|
|
ActorID: conversation.ActorID,
|
|
PrincipalID: conversation.PrincipalID,
|
|
Channel: interaction.ChannelWebChat,
|
|
ReceivedAtUnixMillis: millis,
|
|
CorrelationID: request.CorrelationID,
|
|
Content: message,
|
|
ContentRef: "interaction:utterance:" + string(utteranceID),
|
|
SpeechAct: speechAct,
|
|
InterpretationState: interaction.InterpretationInterpreted,
|
|
CorrectsUtteranceID: request.CorrectsUtteranceID,
|
|
}
|
|
intent := interaction.OriginalHumanIntent{
|
|
ID: interaction.OriginalHumanIntentID(intentID),
|
|
UtteranceID: utterance.ID,
|
|
RecordedAtUnixMillis: millis,
|
|
Text: message,
|
|
}
|
|
var relations []interaction.InteractionRelation
|
|
if request.CorrectsUtteranceID != "" || speechAct == interaction.SpeechActCorrection {
|
|
relationID, err := ids.New()
|
|
if err != nil {
|
|
return interaction.HumanUtterance{}, interaction.OriginalHumanIntent{}, nil, err
|
|
}
|
|
relations = append(relations, interaction.InteractionRelation{
|
|
ID: interaction.InteractionRelationID(relationID),
|
|
FromUtteranceID: utterance.ID,
|
|
ToUtteranceID: request.CorrectsUtteranceID,
|
|
Kind: interaction.RelationCorrects,
|
|
RecordedAtUnixMillis: millis,
|
|
})
|
|
}
|
|
return utterance, intent, relations, nil
|
|
}
|
|
|
|
func ClassifySpeechAct(message string, corrects interaction.HumanUtteranceID) interaction.SpeechAct {
|
|
return BuildDeterministicCandidate(message, corrects, time.Now().UnixMilli()).PrimarySpeechAct
|
|
}
|
|
|
|
func NeedsCognition(act interaction.SpeechAct) bool {
|
|
return act == interaction.SpeechActQuestion || act == interaction.SpeechActRequestToInvestigate
|
|
}
|
|
|
|
func FallbackText(act interaction.SpeechAct) string {
|
|
switch act {
|
|
case interaction.SpeechActDirective:
|
|
return "Я понял действие как просьбу, но без отдельного Authority/Commit шага не выполняю мутацию."
|
|
case interaction.SpeechActAssertion:
|
|
return "Я записал это как утверждение человека. Это не считается проверенным фактом."
|
|
case interaction.SpeechActCorrection:
|
|
return "Исправление записано отдельной репликой; история не переписана."
|
|
case interaction.SpeechActPreference:
|
|
return "Хорошо, учту это дальше."
|
|
case interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate:
|
|
return "Когнитивный маршрут сейчас недоступен, но сообщение сохранено и может быть обработано позже."
|
|
default:
|
|
return "Сообщение записано."
|
|
}
|
|
}
|
|
|
|
// PreferredAssistantName extracts a name the human explicitly assigns to the
|
|
// assistant. It is intentionally based on naming intent and nearby words, not
|
|
// on a whole-message comparison, so it remains useful with ordinary variations
|
|
// of the same request.
|
|
func PreferredAssistantName(message string) string {
|
|
tokens := semanticTokens(message)
|
|
if !hasAssistantNamingTarget(tokens) {
|
|
return ""
|
|
}
|
|
for index, token := range tokens {
|
|
if !containsStem(token, "называ", "назов", "зов", "имен") {
|
|
continue
|
|
}
|
|
for _, candidate := range tokens[index+1:] {
|
|
if candidate == "тебя" || candidate == "тебе" || candidate == "себя" || candidate == "меня" || candidate == "в" || candidate == "теперь" || candidate == "же" {
|
|
continue
|
|
}
|
|
if candidate == "буду" || candidate == "будет" || candidate == "запомни" || candidate == "помни" || candidate == "ты" || candidate == "это" {
|
|
continue
|
|
}
|
|
if len([]rune(candidate)) < 2 || containsStem(candidate, "тогда", "дальше", "пожалуйста") {
|
|
break
|
|
}
|
|
runes := []rune(candidate)
|
|
runes[0] = []rune(strings.ToUpper(string(runes[0])))[0]
|
|
return normalizeAssistantName(string(runes))
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func normalizeAssistantName(value string) string {
|
|
if strings.HasSuffix(value, "ю") {
|
|
return strings.TrimSuffix(value, "ю") + "я"
|
|
}
|
|
return value
|
|
}
|
|
|
|
// IsIdentityInquiry identifies a request about the assistant itself by its
|
|
// interrogative meaning. Punctuation is optional and wording may vary.
|
|
func IsIdentityInquiry(message string) bool {
|
|
tokens := semanticTokens(message)
|
|
if len(tokens) == 0 {
|
|
return false
|
|
}
|
|
if containsToken(tokens, "кто") && (containsToken(tokens, "ты") || containsToken(tokens, "вы") || containsToken(tokens, "тебя")) {
|
|
return true
|
|
}
|
|
return containsToken(tokens, "зовут") || containsToken(tokens, "имя")
|
|
}
|
|
|
|
// IsExplicitMemoryInstruction recognizes a direct request to retain the
|
|
// following human-provided context. It deliberately does not interpret the
|
|
// content as a verified fact: the conversation owner keeps the original
|
|
// utterance and its provenance.
|
|
func IsExplicitMemoryInstruction(message string) bool {
|
|
tokens := semanticTokens(message)
|
|
return len(tokens) > 0 && containsStem(tokens[0], "запомни", "помни")
|
|
}
|
|
|
|
func BuildDeterministicCandidate(message string, corrects interaction.HumanUtteranceID, millis int64) interaction.SemanticCandidate {
|
|
id, _ := ids.New()
|
|
tokens := semanticTokens(message)
|
|
hasQuestionMark := strings.Contains(message, "?")
|
|
scores := semanticScores(tokens, hasQuestionMark, corrects != "")
|
|
primary, primaryScore := topAct(scores)
|
|
secondary, secondaryScore := secondAct(scores, primary)
|
|
if (startsWithModalRequest(tokens) || startsWithExplanatoryRequest(tokens, "расска", "раска", "объяс", "опиш", "покаж", "подскаж")) &&
|
|
(primary == interaction.SpeechActAssertion || primary == interaction.SpeechActDirective) &&
|
|
(secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate) {
|
|
primary, secondary = secondary, primary
|
|
}
|
|
if primary == interaction.SpeechActQuestion && !hasQuestionMark && !containsMeaningfulInterrogative(tokens) &&
|
|
!startsWithModalRequest(tokens) && !startsWithExplanatoryRequest(tokens, "расска", "раска", "объяс", "опиш", "покаж", "подскаж") {
|
|
if len(tokens) > 0 && containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ", "вывед") {
|
|
primary, secondary = interaction.SpeechActDirective, interaction.SpeechActUnknown
|
|
} else {
|
|
primary, secondary = interaction.SpeechActAssertion, interaction.SpeechActUnknown
|
|
}
|
|
}
|
|
candidate := interaction.SemanticCandidate{
|
|
ID: interaction.SemanticCandidateID(id),
|
|
UtteranceID: "",
|
|
Source: interaction.CandidateSourceDeterministic,
|
|
SelectionState: interaction.CandidateObserved,
|
|
PrimarySpeechAct: primary,
|
|
SecondarySpeechAct: secondary,
|
|
ConfidenceMillis: boundedConfidence(primaryScore, secondaryScore, scores),
|
|
Applicability: applicabilityFor(primary, secondary, primaryScore, secondaryScore, tokens, corrects != ""),
|
|
RequestedOutcome: requestedOutcome(message, primary),
|
|
Clarification: interaction.ClarificationNotRequired,
|
|
NeedsCognition: needsCognitionForCandidate(primary, secondary, tokens),
|
|
SelectionReason: deterministicReason(primary, secondary, tokens),
|
|
CapabilityHints: capabilityHintsFor(primary, secondary, tokens),
|
|
RecordedAtUnixMillis: millis,
|
|
}
|
|
if candidate.PrimarySpeechAct == interaction.SpeechActUnknown {
|
|
candidate.PrimarySpeechAct = interaction.SpeechActAssertion
|
|
}
|
|
if startsWithDirectiveWhatNibud(tokens) {
|
|
candidate.NeedsCognition = false
|
|
candidate.CapabilityHints = removeCapabilityHint(candidate.CapabilityHints, "COGNITIVE_REASONING")
|
|
}
|
|
if candidate.Applicability == interaction.SemanticCandidateClarify {
|
|
candidate.Clarification = interaction.ClarificationRequired
|
|
candidate.NeedsCognition = false
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
func semanticTokens(message string) []string {
|
|
lower := strings.ToLower(strings.TrimSpace(message))
|
|
replacer := strings.NewReplacer(".", " ", ",", " ", "!", " ", "?", " ", ":", " ", ";", " ", "(", " ", ")", " ", "\"", " ", "'", " ", "«", " ", "»", " ", "+", " + ", "-", " - ", "/", " / ")
|
|
normalized := replacer.Replace(lower)
|
|
return strings.Fields(normalized)
|
|
}
|
|
|
|
func semanticScores(tokens []string, hasQuestionMark bool, explicitCorrection bool) map[interaction.SpeechAct]int {
|
|
scores := map[interaction.SpeechAct]int{
|
|
interaction.SpeechActAssertion: 1,
|
|
interaction.SpeechActDirective: 0,
|
|
interaction.SpeechActQuestion: 0,
|
|
interaction.SpeechActRequestToInvestigate: 0,
|
|
interaction.SpeechActCorrection: 0,
|
|
interaction.SpeechActDecision: 0,
|
|
interaction.SpeechActPreference: 0,
|
|
interaction.SpeechActHypothesis: 0,
|
|
interaction.SpeechActConfirmation: 0,
|
|
interaction.SpeechActRejection: 0,
|
|
interaction.SpeechActConversational: 0,
|
|
}
|
|
if hasQuestionMark {
|
|
scores[interaction.SpeechActQuestion]++
|
|
}
|
|
if containsMeaningfulInterrogative(tokens) {
|
|
scores[interaction.SpeechActQuestion] += 2
|
|
}
|
|
if explicitCorrection {
|
|
scores[interaction.SpeechActCorrection] += 6
|
|
}
|
|
if containsStemInTokens(tokens, "опечат", "ошибк", "исправлен") && containsStemInTokens(tokens, "исправ", "поправ") {
|
|
scores[interaction.SpeechActCorrection] += 6
|
|
}
|
|
first := ""
|
|
if len(tokens) > 0 {
|
|
first = tokens[0]
|
|
}
|
|
if first == "нет" || first == "не" {
|
|
scores[interaction.SpeechActCorrection] += 2
|
|
scores[interaction.SpeechActRejection]++
|
|
}
|
|
if first == "да" {
|
|
scores[interaction.SpeechActConfirmation] += 2
|
|
}
|
|
questionWords := map[string]struct{}{"кто": {}, "что": {}, "как": {}, "почему": {}, "зачем": {}, "где": {}, "когда": {}, "сколько": {}, "какой": {}, "какая": {}, "какие": {}, "чей": {}}
|
|
conversationWords := []string{"привет", "здравствуйте", "спасибо", "ку", "здравствуй"}
|
|
confirmationWords := []string{"хорошо", "ладно", "окей", "ок", "понял", "понятно"}
|
|
investigateStems := []string{"разбер", "проверь", "проанализ", "исслед", "выясн", "уточн", "подум", "понят", "посмотри", "попроб", "повтор", "снов"}
|
|
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "спрос", "найд", "посчитай", "рассчитай", "ответь", "вывед"}
|
|
explanatoryRequestStems := []string{"расска", "раска", "объяс", "опиш", "покаж", "подскаж", "сообщ"}
|
|
preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"}
|
|
hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"}
|
|
decisionStems := []string{"решил", "решение", "выбираю"}
|
|
arithmeticMarker := false
|
|
for index, token := range tokens {
|
|
if _, ok := questionWords[token]; ok && !(token == "что" && index+1 < len(tokens) && tokens[index+1] == "нибудь") {
|
|
scores[interaction.SpeechActQuestion] += 3
|
|
}
|
|
if containsStem(token, conversationWords...) {
|
|
scores[interaction.SpeechActConversational] += 3
|
|
}
|
|
if containsExactToken(token, confirmationWords...) {
|
|
scores[interaction.SpeechActConfirmation] += 3
|
|
}
|
|
if containsStem(token, investigateStems...) {
|
|
scores[interaction.SpeechActRequestToInvestigate] += 3
|
|
scores[interaction.SpeechActQuestion]++
|
|
}
|
|
if containsStem(token, directiveStems...) {
|
|
scores[interaction.SpeechActDirective] += 3
|
|
}
|
|
if containsStem(token, preferenceStems...) {
|
|
scores[interaction.SpeechActPreference] += 3
|
|
}
|
|
if containsStem(token, hypothesisStems...) {
|
|
scores[interaction.SpeechActHypothesis] += 3
|
|
}
|
|
if containsStem(token, decisionStems...) {
|
|
scores[interaction.SpeechActDecision] += 3
|
|
}
|
|
if token == "+" || token == "-" || token == "/" || token == "*" || token == "плюс" || token == "минус" {
|
|
arithmeticMarker = true
|
|
}
|
|
}
|
|
if preferredNameFromTokens(tokens) {
|
|
scores[interaction.SpeechActPreference] += 8
|
|
scores[interaction.SpeechActAssertion] = 0
|
|
}
|
|
if containsToken(tokens, "запомни") && containsStemInTokens(tokens, "нрав", "люб", "предпочит") {
|
|
scores[interaction.SpeechActPreference] += 6
|
|
scores[interaction.SpeechActAssertion] = 0
|
|
}
|
|
if len(tokens) >= 2 && tokens[0] == "я" && containsStemInTokens(tokens[1:], "настро", "сдела", "исправ", "удал", "созда", "перезапуст", "поменя", "добави") {
|
|
scores[interaction.SpeechActAssertion] += 5
|
|
}
|
|
if arithmeticMarker {
|
|
scores[interaction.SpeechActQuestion] += 2
|
|
scores[interaction.SpeechActDirective]++
|
|
}
|
|
if len(tokens) <= 4 {
|
|
for index, token := range tokens {
|
|
if _, ok := questionWords[token]; ok && !(token == "что" && index+1 < len(tokens) && tokens[index+1] == "нибудь") {
|
|
scores[interaction.SpeechActQuestion]++
|
|
}
|
|
}
|
|
}
|
|
if len(tokens) == 2 && tokens[0] == "ты" {
|
|
scores[interaction.SpeechActQuestion] += 2
|
|
}
|
|
if len(tokens) >= 2 && tokens[0] == "как" && containsToken(tokens, "зовут") {
|
|
scores[interaction.SpeechActQuestion] += 2
|
|
}
|
|
if startsWithDirectiveWhatNibud(tokens) {
|
|
scores[interaction.SpeechActDirective] += 4
|
|
}
|
|
if len(tokens) > 0 && containsStem(tokens[0], investigateStems...) {
|
|
scores[interaction.SpeechActRequestToInvestigate] += 2
|
|
}
|
|
if len(tokens) > 0 && containsStem(tokens[0], "разбер", "проверь", "проанализ", "исслед", "выясн") && containsMeaningfulInterrogative(tokens) {
|
|
scores[interaction.SpeechActRequestToInvestigate] += 2
|
|
}
|
|
if startsWithExplanatoryRequest(tokens, explanatoryRequestStems...) {
|
|
scores[interaction.SpeechActQuestion] += 2
|
|
scores[interaction.SpeechActRequestToInvestigate] += 3
|
|
}
|
|
if startsWithModalRequest(tokens) && containsStemInTokens(tokens, explanatoryRequestStems...) {
|
|
scores[interaction.SpeechActQuestion] += 3
|
|
scores[interaction.SpeechActRequestToInvestigate] += 3
|
|
}
|
|
if containsModelReference(tokens) {
|
|
if containsStemInTokens(tokens, "обрати", "спрос", "уточни") {
|
|
scores[interaction.SpeechActDirective] += 3
|
|
scores[interaction.SpeechActQuestion] += 2
|
|
}
|
|
}
|
|
if containsStemInTokens(tokens, "замени", "смени") {
|
|
scores[interaction.SpeechActDirective] += 3
|
|
}
|
|
if !hasQuestionMark && !containsInterrogative(tokens) && !startsWithModalRequest(tokens) && !startsWithExplanatoryRequest(tokens, explanatoryRequestStems...) &&
|
|
scores[interaction.SpeechActDirective] == 0 && scores[interaction.SpeechActRequestToInvestigate] == 0 && scores[interaction.SpeechActCorrection] == 0 {
|
|
scores[interaction.SpeechActAssertion] += 2
|
|
}
|
|
return scores
|
|
}
|
|
|
|
func preferredNameFromTokens(tokens []string) bool {
|
|
if !hasAssistantNamingTarget(tokens) {
|
|
return false
|
|
}
|
|
for index, token := range tokens {
|
|
if !containsStem(token, "называ", "назов", "зов", "имен") {
|
|
continue
|
|
}
|
|
for _, candidate := range tokens[index+1:] {
|
|
if candidate == "тебя" || candidate == "тебе" || candidate == "себя" || candidate == "меня" || candidate == "буду" || candidate == "запомни" || candidate == "помни" || candidate == "ты" || candidate == "это" {
|
|
continue
|
|
}
|
|
return len([]rune(candidate)) >= 2
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasAssistantNamingTarget(tokens []string) bool {
|
|
return containsToken(tokens, "тебя") || containsToken(tokens, "тебе") || containsToken(tokens, "ты")
|
|
}
|
|
|
|
// IsRetryRequest identifies a short continuation that asks Domovoy to repeat
|
|
// the previous task. It deliberately carries no subject-specific knowledge;
|
|
// the conversation owner supplies the previous task when available.
|
|
func IsRetryRequest(message string) bool {
|
|
tokens := semanticTokens(message)
|
|
if len(tokens) == 0 || len(tokens) > 8 {
|
|
return false
|
|
}
|
|
hasRetryCue := false
|
|
for _, token := range tokens {
|
|
if containsStem(token, "повтор", "снов", "попроб", "ещ") {
|
|
hasRetryCue = true
|
|
break
|
|
}
|
|
}
|
|
return hasRetryCue
|
|
}
|
|
|
|
func topAct(scores map[interaction.SpeechAct]int) (interaction.SpeechAct, int) {
|
|
bestAct, bestScore := interaction.SpeechActUnknown, -1
|
|
for _, act := range []interaction.SpeechAct{
|
|
interaction.SpeechActQuestion,
|
|
interaction.SpeechActRequestToInvestigate,
|
|
interaction.SpeechActDirective,
|
|
interaction.SpeechActCorrection,
|
|
interaction.SpeechActConversational,
|
|
interaction.SpeechActPreference,
|
|
interaction.SpeechActHypothesis,
|
|
interaction.SpeechActDecision,
|
|
interaction.SpeechActConfirmation,
|
|
interaction.SpeechActRejection,
|
|
interaction.SpeechActAssertion,
|
|
} {
|
|
if score := scores[act]; score > bestScore {
|
|
bestAct, bestScore = act, score
|
|
}
|
|
}
|
|
return bestAct, bestScore
|
|
}
|
|
|
|
func secondAct(scores map[interaction.SpeechAct]int, primary interaction.SpeechAct) (interaction.SpeechAct, int) {
|
|
second, score := interaction.SpeechActUnknown, -1
|
|
for act, value := range scores {
|
|
if act == primary {
|
|
continue
|
|
}
|
|
if value > score {
|
|
second, score = act, value
|
|
}
|
|
}
|
|
if score <= 0 {
|
|
return interaction.SpeechActUnknown, 0
|
|
}
|
|
return second, score
|
|
}
|
|
|
|
func applicabilityFor(primary, secondary interaction.SpeechAct, primaryScore, secondaryScore int, tokens []string, explicitCorrection bool) interaction.SemanticApplicability {
|
|
if primary == interaction.SpeechActAssertion && !containsInterrogative(tokens) && secondaryScore <= 1 {
|
|
return interaction.SemanticApplicable
|
|
}
|
|
if primaryScore < 2 {
|
|
return interaction.SemanticNeedsBroaderInterpret
|
|
}
|
|
if explicitCorrection && secondaryScore >= 3 && (secondary == interaction.SpeechActDirective || secondary == interaction.SpeechActRequestToInvestigate) {
|
|
return interaction.SemanticMixedIntent
|
|
}
|
|
if primary == interaction.SpeechActCorrection && secondaryScore >= 3 && (secondary == interaction.SpeechActDirective || secondary == interaction.SpeechActRequestToInvestigate) {
|
|
return interaction.SemanticMixedIntent
|
|
}
|
|
if primary == interaction.SpeechActAssertion && containsMeaningfulInterrogative(tokens) {
|
|
return interaction.SemanticNeedsBroaderInterpret
|
|
}
|
|
if primary == interaction.SpeechActDirective && likelyConsequential(tokens) {
|
|
return interaction.SemanticCandidateClarify
|
|
}
|
|
if primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "обрати", "спрос") && containsModelReference(tokens) {
|
|
return interaction.SemanticLowConfidence
|
|
}
|
|
if primaryScore-secondaryScore <= 1 && secondaryScore >= 2 {
|
|
return interaction.SemanticLowConfidence
|
|
}
|
|
if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate {
|
|
if primaryScore < 4 {
|
|
return interaction.SemanticNeedsBroaderInterpret
|
|
}
|
|
}
|
|
return interaction.SemanticApplicable
|
|
}
|
|
|
|
func needsCognitionForCandidate(primary, secondary interaction.SpeechAct, tokens []string) bool {
|
|
if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate {
|
|
return true
|
|
}
|
|
if secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate {
|
|
return true
|
|
}
|
|
if primary == interaction.SpeechActDirective && (containsInterrogative(tokens) || containsModelReference(tokens) || containsStemInTokens(tokens, "спрос", "обрати", "посчитай", "рассчитай")) {
|
|
return true
|
|
}
|
|
// A harmless imperative can ask for a textual or structural result even
|
|
// without a question mark. Treat it as a cognition-worthy request instead
|
|
// of silently recording it as an assertion. Consequential instructions are
|
|
// kept on their explicit confirmation/capability paths.
|
|
if primary == interaction.SpeechActDirective && !likelyConsequential(tokens) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func deterministicReason(primary, secondary interaction.SpeechAct, tokens []string) string {
|
|
reasons := []string{"deterministic candidate from token-shape, interrogative, imperative and context cues"}
|
|
if primary == interaction.SpeechActQuestion && containsInterrogative(tokens) {
|
|
reasons = append(reasons, "interrogative structure observed")
|
|
}
|
|
if secondary != interaction.SpeechActUnknown {
|
|
reasons = append(reasons, "secondary competing semantic signal observed")
|
|
}
|
|
return strings.Join(reasons, "; ")
|
|
}
|
|
|
|
func capabilityHintsFor(primary, secondary interaction.SpeechAct, tokens []string) []string {
|
|
seen := map[string]struct{}{}
|
|
add := func(kind string) {
|
|
if kind != "" {
|
|
seen[kind] = struct{}{}
|
|
}
|
|
}
|
|
if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate {
|
|
add("COGNITIVE_REASONING")
|
|
}
|
|
if sourceDependentQuestion(primary, secondary, tokens) {
|
|
add("WEB_RESEARCH")
|
|
}
|
|
if isCreativeRequestTokens(tokens) {
|
|
// A correction that quotes a creative phrase is still a correction;
|
|
// do not execute the quoted phrase as a side effect.
|
|
if isImageCreationRequestTokens(tokens) && primary != interaction.SpeechActCorrection && secondary != interaction.SpeechActCorrection {
|
|
add("IMAGE_GENERATION")
|
|
} else {
|
|
add("COGNITIVE_REASONING")
|
|
}
|
|
}
|
|
if containsStemInTokens(tokens, "голосов", "аудио", "послуш") {
|
|
add("SPEECH_TO_TEXT")
|
|
}
|
|
if containsStemInTokens(tokens, "фотограф", "фото", "картин", "изображ") && containsStemInTokens(tokens, "прочит", "текст", "надпис") {
|
|
add("OCR")
|
|
} else if containsStemInTokens(tokens, "фотограф", "фото", "картин", "изображ") {
|
|
add("IMAGE_UNDERSTANDING")
|
|
}
|
|
if containsStemInTokens(tokens, "видео", "ролик") {
|
|
add("VIDEO_UNDERSTANDING")
|
|
}
|
|
if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") {
|
|
add("SCHEDULE_REMINDER")
|
|
}
|
|
if containsStemInTokens(tokens, "поставь", "создай", "добавь") && containsStemInTokens(tokens, "цел", "задач", "план") {
|
|
add("COGNITIVE_REASONING")
|
|
}
|
|
if isExplicitEmailAction(tokens) {
|
|
add("SEND_EMAIL")
|
|
}
|
|
if containsToken(tokens, "код") || containsToken(tokens, "патч") || containsToken(tokens, "текст") || containsToken(tokens, "главной") || containsToken(tokens, "чате") || containsToken(tokens, "проект") || containsStemInTokens(tokens, "тест") {
|
|
if primary == interaction.SpeechActDirective || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActDirective {
|
|
add("MODIFY_CODE")
|
|
add("RUN_TESTS")
|
|
}
|
|
}
|
|
if containsToken(tokens, "assistant") && containsStemInTokens(tokens, "исправ", "добав", "измени", "помен", "патч") {
|
|
add("MODIFY_CODE")
|
|
add("RUN_TESTS")
|
|
}
|
|
if containsStemInTokens(tokens, "исправ", "поправ") && (containsToken(tokens, "чате") || containsToken(tokens, "текст") || containsToken(tokens, "assistant")) {
|
|
add("MODIFY_CODE")
|
|
add("RUN_TESTS")
|
|
}
|
|
if containsStemInTokens(tokens, "патч", "исправ", "поправ") && containsStemInTokens(tokens, "разбер", "проверь", "почему", "тормозит", "подум") {
|
|
add("MODIFY_CODE")
|
|
add("RUN_TESTS")
|
|
}
|
|
if containsInterrogative(tokens) && containsArithmetic(tokens) {
|
|
add("COGNITIVE_REASONING")
|
|
}
|
|
if isCreativeRequestTokens(tokens) && containsStemInTokens(tokens, "погод", "ресторан", "кафе", "билет", "рейс", "цен", "найд", "узнай", "проверь") {
|
|
add("WEB_RESEARCH")
|
|
}
|
|
if containsToken(tokens, "свободно") || containsToken(tokens, "гб") {
|
|
add("VERIFY_FACT")
|
|
}
|
|
modelIntent := containsStemInTokens(tokens, "настро", "список", "доступ", "подключ", "сравн", "выбер", "провер", "проверь", "перечисл")
|
|
if containsStemInTokens(tokens, "модел", "ии") && modelIntent && (primary == interaction.SpeechActQuestion || primary == interaction.SpeechActDirective || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActDirective || secondary == interaction.SpeechActRequestToInvestigate) {
|
|
add("VERIFY_FACT")
|
|
}
|
|
values := make([]string, 0, len(seen))
|
|
for kind := range seen {
|
|
values = append(values, kind)
|
|
}
|
|
return values
|
|
}
|
|
|
|
func isImageCreationRequestTokens(tokens []string) bool {
|
|
return containsStemInTokens(tokens, "нарис", "рису", "изобраз")
|
|
}
|
|
|
|
func isCreativeRequestTokens(tokens []string) bool {
|
|
if containsStemInTokens(tokens, "нарис", "рису", "изобраз") {
|
|
return true
|
|
}
|
|
return containsStemInTokens(tokens, "расска", "раска", "сочин", "придум") &&
|
|
containsStemInTokens(tokens, "сказк", "истор", "стих", "шутк", "песн", "басн")
|
|
}
|
|
|
|
// sourceDependentQuestion identifies semantic classes whose answer depends on
|
|
// a current or externally observed source. It intentionally uses no named
|
|
// place, person or one-off question branch; when the cue is absent the
|
|
// truth-first fallback remains in force.
|
|
func sourceDependentQuestion(primary, secondary interaction.SpeechAct, tokens []string) bool {
|
|
inquiry := primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate || secondary == interaction.SpeechActQuestion || secondary == interaction.SpeechActRequestToInvestigate || (primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "найд", "поищ", "подбер"))
|
|
if !inquiry {
|
|
return false
|
|
}
|
|
if focusIntrospectionQuestion(tokens) {
|
|
return false
|
|
}
|
|
currentCue := containsStemInTokens(tokens, "сейчас", "сегодня", "текущ", "актуаль", "последн", "прямо")
|
|
observedStateCue := containsStemInTokens(tokens, "погод", "температур", "осад", "ветер", "курс", "цен", "стоим", "новост", "распис", "событ", "статус", "доступен", "работает", "запущен", "срок") || approximateToken(tokens, "погод", 2)
|
|
placeSearchCue := containsStemInTokens(tokens, "ресторан", "кафе", "отел", "магаз", "мест", "ближ", "адрес", "организац", "бренд", "товар", "артикул", "продавец", "маркетплейс", "wildberries", "wb")
|
|
travelSearchCue := travelCue(tokens) || containsStemInTokens(tokens, "отправлен", "прибыт", "расстоян", "километр", "добрат", "маршрут")
|
|
timeDifferenceCue := containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час")
|
|
return currentCue || observedStateCue || placeSearchCue || travelSearchCue || timeDifferenceCue
|
|
}
|
|
|
|
// approximateToken catches ordinary keyboard slips without turning routing
|
|
// into a list of complete user questions. It is only used for broad
|
|
// source-dependent concepts such as weather.
|
|
func approximateToken(tokens []string, target string, maxDistance int) bool {
|
|
for _, token := range tokens {
|
|
if nearToken(token, target, maxDistance) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func nearToken(token, target string, maxDistance int) bool {
|
|
if editDistance([]rune(token), []rune(target)) <= maxDistance {
|
|
return true
|
|
}
|
|
if maxDistance < 1 {
|
|
return false
|
|
}
|
|
left, right := []rune(token), []rune(target)
|
|
if len(left) != len(right) || len(left) < 2 {
|
|
return false
|
|
}
|
|
for index := range left {
|
|
if left[index] == right[index] {
|
|
continue
|
|
}
|
|
return index+1 < len(left) && left[index] == right[index+1] && left[index+1] == right[index] && string(left[index+2:]) == string(right[index+2:])
|
|
}
|
|
return false
|
|
}
|
|
|
|
func editDistance(left, right []rune) int {
|
|
previous := make([]int, len(right)+1)
|
|
for index := range previous {
|
|
previous[index] = index
|
|
}
|
|
for i, leftRune := range left {
|
|
current := make([]int, len(right)+1)
|
|
current[0] = i + 1
|
|
for j, rightRune := range right {
|
|
cost := 0
|
|
if leftRune != rightRune {
|
|
cost = 1
|
|
}
|
|
current[j+1] = minInt(current[j]+1, previous[j+1]+1, previous[j]+cost)
|
|
}
|
|
previous = current
|
|
}
|
|
return previous[len(right)]
|
|
}
|
|
|
|
func minInt(values ...int) int {
|
|
result := values[0]
|
|
for _, value := range values[1:] {
|
|
if value < result {
|
|
result = value
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func informationRequestKind(message string, candidate interaction.SemanticCandidate) interaction.InformationRequestKind {
|
|
tokens := semanticTokens(message)
|
|
if len(tokens) == 0 {
|
|
return ""
|
|
}
|
|
if containsStemInTokens(tokens, "памят", "помнишь", "ячейк") && (containsMeaningfulInterrogative(tokens) || containsStemInTokens(tokens, "вывед", "покаж", "напиш", "сообщ")) {
|
|
return interaction.InformationRequestMemoryInventory
|
|
}
|
|
if containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час") {
|
|
return interaction.InformationRequestTimeDifference
|
|
}
|
|
if containsStemInTokens(tokens, "почему", "зачем") && containsStemInTokens(tokens, "смог", "неполуч", "нашел", "нашёл") && containsStemInTokens(tokens, "ответ", "данн", "найт") {
|
|
return interaction.InformationRequestResearchFailureExplanation
|
|
}
|
|
inquiry := candidate.PrimarySpeechAct == interaction.SpeechActQuestion || candidate.PrimarySpeechAct == interaction.SpeechActRequestToInvestigate || candidate.SecondarySpeechAct == interaction.SpeechActQuestion || candidate.SecondarySpeechAct == interaction.SpeechActRequestToInvestigate
|
|
cognitiveSubject := containsStemInTokens(tokens, "модел", "ии", "ai", "локальн", "когнитив", "канал")
|
|
if cognitiveSubject && inquiry && containsStemInTokens(tokens, "какая", "какой", "текущ", "сейчас", "отвеч") && containsStemInTokens(tokens, "модел") {
|
|
return interaction.InformationRequestCognitiveCurrentModel
|
|
}
|
|
inventoryCue := containsStemInTokens(tokens, "список", "доступ", "подключ", "вариант") || (containsMeaningfulInterrogative(tokens) && containsStemInTokens(tokens, "настро"))
|
|
inventoryRequest := inquiry || containsStemInTokens(tokens, "назов", "напиш", "покаж", "перечисл", "сообщ", "вывед", "предостав")
|
|
if cognitiveSubject && inventoryRequest && inventoryCue {
|
|
return interaction.InformationRequestCognitiveModelInventory
|
|
}
|
|
capabilitySubject := containsStemInTokens(tokens, "уме", "возможност", "способн", "доступн", "инструмент", "capabilit") || (containsMeaningfulInterrogative(tokens) && containsToken(tokens, "можешь"))
|
|
if inquiry && capabilitySubject {
|
|
if containsStemInTokens(tokens, "без", "внешн", "локальн") && containsStemInTokens(tokens, "модел", "ии", "ai") {
|
|
return interaction.InformationRequestLocalCapabilityInventory
|
|
}
|
|
return interaction.InformationRequestCapabilityInventory
|
|
}
|
|
recoveryCue := containsStemInTokens(tokens, "восстанов", "исправ", "почин", "подключ", "настро", "недоступ", "нет")
|
|
if cognitiveSubject && recoveryCue {
|
|
return interaction.InformationRequestCognitiveRecovery
|
|
}
|
|
if cognitiveSubject && inquiry {
|
|
return interaction.InformationRequestCognitiveChannelStatus
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func focusIntrospectionQuestion(tokens []string) bool {
|
|
if containsStemInTokens(tokens, "фокус", "focus", "контекст") && (containsStemInTokens(tokens, "сейчас", "текущ", "работ", "контекст") || containsMeaningfulInterrogative(tokens)) {
|
|
return true
|
|
}
|
|
return containsStemInTokens(tokens, "над", "делаем", "работаем") && containsStemInTokens(tokens, "сейчас", "текущ", "мы")
|
|
}
|
|
|
|
func LocationContextReply(message string) string {
|
|
if location := personalCurrentLocation(message); location != "" {
|
|
return "Запомнил: сейчас вы в " + location + "."
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// IsCreativeRequest recognizes generation requests rather than retrieval of
|
|
// externally verifiable facts. The cognitive route remains responsible for
|
|
// the requested style and content.
|
|
func IsCreativeRequest(message string) bool {
|
|
tokens := semanticTokens(message)
|
|
return isCreativeRequestTokens(tokens)
|
|
}
|
|
|
|
// TimeDifferenceResearchQuery makes the requested measurement explicit for a
|
|
// current-data capability. It preserves the user-provided entities and does
|
|
// not encode any particular place or time-zone relationship.
|
|
func TimeDifferenceResearchQuery(message string) string {
|
|
tokens := semanticTokens(StripAssistantVocative(message))
|
|
if containsStemInTokens(tokens, "разниц") && containsStemInTokens(tokens, "времен", "час") && containsToken(tokens, "между") {
|
|
return strings.TrimSpace(StripAssistantVocative(message)) + " сколько часов"
|
|
}
|
|
return strings.TrimSpace(message)
|
|
}
|
|
|
|
// CreativeDeterministicReply intentionally has no example-specific answer
|
|
// branches. Creative requests must retain their full meaning and go through
|
|
// cognition; canned object or color answers hide lost attributes.
|
|
func CreativeDeterministicReply(message string) string {
|
|
return ""
|
|
}
|
|
|
|
// CreativeFallbackReply intentionally has no canned creative content. The
|
|
// cognitive route must construct the requested result while preserving all
|
|
// user attributes; a degraded path must not pretend that a prepared answer
|
|
// was generated.
|
|
func CreativeFallbackReply(message string) string {
|
|
return ""
|
|
}
|
|
|
|
func boundedConfidence(primaryScore, secondaryScore int, scores map[interaction.SpeechAct]int) int64 {
|
|
total := 0
|
|
for _, score := range scores {
|
|
if score > 0 {
|
|
total += score
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return 200
|
|
}
|
|
confidence := int64(400 + (primaryScore * 100))
|
|
if secondaryScore > 0 {
|
|
confidence -= int64(secondaryScore * 40)
|
|
}
|
|
if confidence < 200 {
|
|
confidence = 200
|
|
}
|
|
if confidence > 980 {
|
|
confidence = 980
|
|
}
|
|
return int64(confidence)
|
|
}
|
|
|
|
func containsStem(token string, stems ...string) bool {
|
|
for _, stem := range stems {
|
|
if strings.Contains(token, stem) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsExactToken(token string, words ...string) bool {
|
|
for _, word := range words {
|
|
if token == word {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsStemInTokens(tokens []string, stems ...string) bool {
|
|
for _, token := range tokens {
|
|
if containsStem(token, stems...) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func requestedOutcome(message string, act interaction.SpeechAct) string {
|
|
if act == interaction.SpeechActDirective || act == interaction.SpeechActDecision || act == interaction.SpeechActRequestToInvestigate {
|
|
return strings.TrimSpace(message)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func containsToken(tokens []string, expected string) bool {
|
|
for _, token := range tokens {
|
|
if token == expected {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func startsWithModalRequest(tokens []string) bool {
|
|
if len(tokens) == 0 {
|
|
return false
|
|
}
|
|
switch tokens[0] {
|
|
case "можешь", "можете", "сможешь", "сможете":
|
|
return true
|
|
}
|
|
if len(tokens) >= 3 && tokens[0] == "не" && strings.HasPrefix(tokens[1], "мог") && tokens[2] == "бы" {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func startsWithExplanatoryRequest(tokens []string, stems ...string) bool {
|
|
if len(tokens) == 0 {
|
|
return false
|
|
}
|
|
if containsStem(tokens[0], stems...) {
|
|
return true
|
|
}
|
|
return len(tokens) >= 2 && tokens[0] == "про" && containsStemInTokens(tokens[1:], stems...)
|
|
}
|
|
|
|
func containsInterrogative(tokens []string) bool {
|
|
for _, token := range tokens {
|
|
if token == "-" {
|
|
continue
|
|
}
|
|
switch token {
|
|
case "кто", "что", "как", "почему", "зачем", "где", "когда", "сколько", "какой", "какая", "какие", "чей":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsMeaningfulInterrogative(tokens []string) bool {
|
|
for index, token := range tokens {
|
|
if token == "что" && followsNibud(tokens[index+1:]) {
|
|
continue
|
|
}
|
|
switch token {
|
|
case "кто", "что", "как", "почему", "зачем", "где", "когда", "сколько", "какой", "какая", "какие", "чей":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsArithmetic(tokens []string) bool {
|
|
digits := 0
|
|
for _, token := range tokens {
|
|
if token == "+" || token == "-" || token == "*" || token == "/" || token == "плюс" || token == "минус" {
|
|
return true
|
|
}
|
|
for _, r := range token {
|
|
if r >= '0' && r <= '9' {
|
|
digits++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return digits >= 2
|
|
}
|
|
|
|
func likelyConsequential(tokens []string) bool {
|
|
for _, token := range tokens {
|
|
if containsStem(token, "удал", "снес", "замен", "миграц", "парол", "опубли", "перезапуст", "смени", "купи") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func followsNibud(tokens []string) bool {
|
|
for _, token := range tokens {
|
|
if token == "-" || token == "ни" {
|
|
continue
|
|
}
|
|
return token == "нибудь"
|
|
}
|
|
return false
|
|
}
|
|
|
|
func startsWithDirectiveWhatNibud(tokens []string) bool {
|
|
if len(tokens) < 2 || !containsStem(tokens[0], "сдел", "исправ", "добав", "удал", "напом", "подключ", "обрати", "спрос", "посчитай", "рассчитай", "использ", "вывед") {
|
|
return false
|
|
}
|
|
for index, token := range tokens {
|
|
if token == "что" && followsNibud(tokens[index+1:]) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsModelReference(tokens []string) bool {
|
|
for _, token := range tokens {
|
|
if token == "модель" || token == "модели" || strings.Contains(token, "gemma") || strings.Contains(token, "гемма") || strings.Contains(token, "qwen") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isExplicitEmailAction(tokens []string) bool {
|
|
hasMailObject := containsToken(tokens, "письмо") || containsToken(tokens, "письма") || containsToken(tokens, "почту") || containsToken(tokens, "почта")
|
|
hasMailVerb := containsStemInTokens(tokens, "отправ", "ответ", "напиш")
|
|
return hasMailObject && hasMailVerb
|
|
}
|
|
|
|
func removeCapabilityHint(values []string, denied string) []string {
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
if value == denied {
|
|
continue
|
|
}
|
|
result = append(result, value)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// RemoveCapabilityHint lets orchestration boundaries suppress a route that
|
|
// contradicts an already understood semantic class.
|
|
func RemoveCapabilityHint(values []string, denied string) []string {
|
|
return removeCapabilityHint(values, denied)
|
|
}
|