Files
Assistant/modules/interaction/domain/chat.go
T
2026-08-18 17:46:14 +03:00

679 lines
25 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.SpeechActQuestion, interaction.SpeechActRequestToInvestigate:
return "Когнитивный маршрут сейчас недоступен, но сообщение сохранено и может быть обработано позже."
default:
return "Сообщение записано."
}
}
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)
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 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] += 4
scores[interaction.SpeechActRequestToInvestigate]++
}
if explicitCorrection {
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{"привет", "здравствуйте", "спасибо", "ку", "здравствуй", "добрый"}
investigateStems := []string{"разбер", "проверь", "провери", "проанализ", "исслед", "выясн", "узнай"}
directiveStems := []string{"сдел", "запуст", "созд", "настро", "добав", "измени", "помен", "поменя", "измен", "использ", "удал", "напом", "подключ", "исправ", "обрати", "поправ", "обнов", "ответь", "посмотри", "проверь"}
preferenceStems := []string{"предпоч", "хочу", "лучше", "не используй"}
hypothesisStems := []string{"думаю", "кажется", "гипотез", "похоже"}
decisionStems := []string{"решил", "решение", "выбираю"}
arithmeticMarker := false
for index, token := range tokens {
if firstPersonPastReport(tokens, index) && containsStem(token, directiveStems...) {
continue
}
if _, ok := questionWords[token]; ok && !(token == "что" && index+1 < len(tokens) && tokens[index+1] == "нибудь") {
scores[interaction.SpeechActQuestion] += 4
}
if containsStem(token, conversationWords...) {
scores[interaction.SpeechActConversational] += 4
}
if containsStem(token, investigateStems...) {
scores[interaction.SpeechActRequestToInvestigate] += 3
scores[interaction.SpeechActQuestion] += 1
}
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 looksLikeInstructionWithoutPunctuationAfterLead(tokens) {
scores[interaction.SpeechActQuestion] += 4
scores[interaction.SpeechActRequestToInvestigate] += 2
}
if arithmeticMarker {
scores[interaction.SpeechActQuestion] += 2
}
if len(tokens) > 0 && startsWithModalRequest(tokens) {
scores[interaction.SpeechActQuestion] += 2
scores[interaction.SpeechActRequestToInvestigate] += 2
}
if looksLikeInformationRequest(tokens) {
scores[interaction.SpeechActQuestion] += 4
scores[interaction.SpeechActRequestToInvestigate] += 2
}
if !hasQuestionMark && containsInterrogative(tokens) {
scores[interaction.SpeechActQuestion] += 4
scores[interaction.SpeechActRequestToInvestigate] += 2
}
if containsModelReference(tokens) {
scores[interaction.SpeechActQuestion] += 2
scores[interaction.SpeechActDirective] += 1
}
if !hasQuestionMark && scores[interaction.SpeechActQuestion] == 0 && scores[interaction.SpeechActRequestToInvestigate] == 0 &&
scores[interaction.SpeechActDirective] <= 1 && scores[interaction.SpeechActCorrection] == 0 {
scores[interaction.SpeechActAssertion] += 2
}
return scores
}
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.SpeechActQuestion && 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 primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "убери", "снеси", "удали", "удал", "стер") && len(tokens) > 1 {
return interaction.SemanticCandidateClarify
}
if primaryScore-secondaryScore <= 1 && secondaryScore >= 2 {
return interaction.SemanticLowConfidence
}
if primary == interaction.SpeechActQuestion || primary == interaction.SpeechActRequestToInvestigate {
if primaryScore < 4 {
return interaction.SemanticNeedsBroaderInterpret
}
}
if primary == interaction.SpeechActConversational {
return interaction.SemanticApplicable
}
if primary == interaction.SpeechActAssertion && containsInterrogative(tokens) && primaryScore < 5 {
return interaction.SemanticNeedsBroaderInterpret
}
return interaction.SemanticApplicable
}
func needsCognitionForCandidate(primary, secondary interaction.SpeechAct, tokens []string) bool {
if primary == interaction.SpeechActConversational && secondary == interaction.SpeechActDirective {
return true
}
if primary == interaction.SpeechActQuestion && containsMeaningfulInterrogative(tokens) {
return true
}
if isAssistantStatusProbe(primary, secondary, tokens) {
return true
}
if primary == interaction.SpeechActAssertion && containsModelReference(tokens) && containsStemInTokens(tokens, "работает", "падает", "растет", "растёет", "свободно", "виснет") {
return true
}
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
}
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 primary == interaction.SpeechActRequestToInvestigate && containsModelReference(tokens) {
reasons = append(reasons, "investigation-oriented wording 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 isAssistantStatusProbe(primary, secondary, tokens) {
add("COGNITIVE_REASONING")
}
if isExplicitReminder(tokens) {
add("SCHEDULE_REMINDER")
}
if isExplicitEmailAction(tokens) || containsToken(tokens, "письма") || containsToken(tokens, "почту") || containsToken(tokens, "почта") {
add("SEND_EMAIL")
add("READ_EMAIL")
}
if primary == interaction.SpeechActDirective && containsStemInTokens(tokens, "добав", "реализ", "поддержк") {
add("MODIFY_CODE")
}
if containsToken(tokens, "напомни") || containsToken(tokens, "напомин") {
add("SCHEDULE_REMINDER")
}
if primary == interaction.SpeechActRequestToInvestigate || (primary == interaction.SpeechActQuestion && containsInterrogative(tokens) && containsStemInTokens(tokens, "почему", "как", "что")) {
add("COGNITIVE_REASONING")
}
if containsInterrogative(tokens) && containsArithmetic(tokens) {
add("COGNITIVE_REASONING")
}
if containsStemInTokens(tokens, "тормоз", "медлен", "почему", "что", "сервис", "база", "падает") {
add("INSPECT_PROJECT")
add("READ_DATABASE")
add("QUERY_LOGS")
}
if containsToken(tokens, "свободно") || containsToken(tokens, "гб") || containsToken(tokens, "модель") || containsToken(tokens, "postgres") || containsToken(tokens, "база") {
add("VERIFY_FACT")
}
values := make([]string, 0, len(seen))
for kind := range seen {
values = append(values, kind)
}
return values
}
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 confidence
}
func containsStem(token string, stems ...string) bool {
for _, stem := range stems {
if strings.Contains(token, stem) {
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 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 looksLikeInstructionWithoutPunctuation(first string) bool {
if first == "" {
return false
}
switch {
case strings.HasSuffix(first, "й"), strings.HasSuffix(first, "и"), strings.HasSuffix(first, "ите"):
return utf8.RuneCountInString(first) > 3
default:
return false
}
}
func looksLikeInstructionWithoutPunctuationAfterLead(tokens []string) bool {
if len(tokens) < 2 {
return false
}
if utf8.RuneCountInString(tokens[0]) > 3 {
return false
}
switch tokens[0] {
case "про", "по", "на", "в", "за":
default:
return false
}
for _, token := range tokens[1:] {
if utf8.RuneCountInString(token) <= 3 {
continue
}
switch {
case strings.HasSuffix(token, "ешь"), strings.HasSuffix(token, "и"), strings.HasSuffix(token, "й"), strings.HasSuffix(token, "те"), strings.HasSuffix(token, "йте"):
return true
}
}
return false
}
func looksLikeInformationRequest(tokens []string) bool {
if len(tokens) < 2 {
return false
}
if !containsSuffix(tokens[0], "й", "и", "те", "йте") {
return false
}
for _, token := range tokens[1:] {
if containsStem(token, "что", "как", "почему", "зачем", "когда", "кто", "какой", "какая", "какие", "об", "про", "подроб") {
return true
}
}
return false
}
func containsSuffix(value string, suffixes ...string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(value, suffix) {
return true
}
}
return false
}
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 firstPersonPastReport(tokens []string, index int) bool {
if len(tokens) == 0 || index == 0 {
return false
}
if tokens[0] != "я" && tokens[0] != "мы" {
return false
}
if index < 2 {
return false
}
for _, token := range tokens[:index] {
if token == "всё" || token == "все" || token == "сейчас" || token == "уже" {
return true
}
}
return false
}
func containsModelReference(tokens []string) bool {
for _, token := range tokens {
if token == "модель" || token == "модели" || token == "postgres" || token == "postgresql" || strings.Contains(token, "gemma") || strings.Contains(token, "гемма") || strings.Contains(token, "qwen") {
return true
}
}
return false
}
func isAssistantStatusProbe(primary, secondary interaction.SpeechAct, tokens []string) bool {
if primary != interaction.SpeechActAssertion && primary != interaction.SpeechActCorrection && primary != interaction.SpeechActQuestion && primary != interaction.SpeechActRequestToInvestigate && primary != interaction.SpeechActUnknown {
return false
}
if !(containsToken(tokens, "домовой") || containsToken(tokens, "ассистент") || containsToken(tokens, "бот") || containsToken(tokens, "ты") || containsToken(tokens, "ии") || containsToken(tokens, "ai") || containsToken(tokens, "gpt")) {
return false
}
if containsToken(tokens, "не") && containsStemInTokens(tokens, "вид", "виж", "понима", "ответ", "отвеч", "работа", "реагир", "слыш", "слуш", "ищ", "уведом", "чувств") {
return true
}
for index := 0; index < len(tokens)-1; index++ {
if tokens[index] == "не" && (containsStem(tokens[index+1], "вид", "виж", "понима", "ответ", "отвеч", "работа", "реагир", "слыш", "слуш", "ищ", "уведом", "чувств")) {
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 isExplicitReminder(tokens []string) bool {
hasReminder := containsToken(tokens, "напомни") || containsToken(tokens, "напомин")
if hasReminder {
return true
}
return containsStemInTokens(tokens, "напомин", "напомни")
}