484 lines
18 KiB
Go
484 lines
18 KiB
Go
package domain
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"git.cin.su/m/Assistant/contracts/interaction"
|
|
)
|
|
|
|
// TravelSearchQuery carries the implicit current-day meaning of a nearest
|
|
// flight request into the research adapter.
|
|
func TravelSearchQuery(message string, now time.Time) string {
|
|
cleaned := normalizedTravelSearchText(message)
|
|
tokens := semanticTokens(cleaned)
|
|
if !travelCue(tokens) || !containsStemInTokens(tokens, "ближ") || isTravelDateSpecified(tokens) {
|
|
return cleaned
|
|
}
|
|
if now.IsZero() {
|
|
return cleaned + " сегодня"
|
|
}
|
|
return cleaned + " сегодня " + now.Format("02.01.2006")
|
|
}
|
|
|
|
// normalizedTravelSearchText corrects only a near-unambiguous typo in a
|
|
// capability word before an external query. The original utterance is kept by
|
|
// Conversation; this is a replaceable search-skill normalization, not a fact
|
|
// about a user, city or provider.
|
|
func normalizedTravelSearchText(message string) string {
|
|
tokens := semanticTokens(StripAssistantVocative(message))
|
|
changed := false
|
|
for index, token := range tokens {
|
|
if nearToken(token, "самолет", 2) || nearToken(token, "самолёт", 2) {
|
|
tokens[index] = "самолёт"
|
|
changed = true
|
|
}
|
|
}
|
|
if !changed {
|
|
return strings.TrimSpace(StripAssistantVocative(message))
|
|
}
|
|
return strings.Join(tokens, " ")
|
|
}
|
|
|
|
// TravelResearchQuery keeps route and distance questions tied to a known
|
|
// origin before they reach a generic web search. The history is semantic
|
|
// context, not a list of example-specific answers.
|
|
func TravelResearchQuery(message string, history []interaction.HumanUtterance, now time.Time) string {
|
|
query := TravelSearchQuery(message, now)
|
|
tokens := semanticTokens(StripAssistantVocative(message))
|
|
if localPlaceSearchRequest(tokens) {
|
|
return localPlaceResearchQuery(tokens)
|
|
} else if containsStemInTokens(tokens, "расстоян", "километр") {
|
|
origin := travelContextOrigin(history)
|
|
destination := travelContextDestinationFromTokens(tokens)
|
|
if origin != "" && destination != "" {
|
|
return "расстояние между " + origin + " и " + destination + " в километрах по прямой и по дороге"
|
|
}
|
|
if origin != "" && !containsStemInTokens(tokens, "из", "откуда", "от") {
|
|
query += " точка отправления: " + origin
|
|
}
|
|
query += " расстояние по прямой и по дороге"
|
|
} else if containsStemInTokens(tokens, "добрат", "маршрут") {
|
|
origin := travelContextOrigin(history)
|
|
destination := travelContextDestinationFromTokens(tokens)
|
|
if origin != "" && destination != "" {
|
|
return "способы добраться из " + origin + " в " + destination + ": самолет поезд автобус автомобиль"
|
|
}
|
|
if origin != "" && !containsStemInTokens(tokens, "из", "откуда", "от") {
|
|
query += " отправление из " + origin
|
|
}
|
|
} else if trainScheduleRequest(tokens) {
|
|
origin := travelContextOrigin(history)
|
|
destination := travelContextDestinationFromTokens(tokens)
|
|
if origin != "" && destination != "" {
|
|
return trainScheduleResearchQuery(origin, destination, now, containsStemInTokens(tokens, "ближ"))
|
|
}
|
|
} else if travelCue(tokens) && !containsStemInTokens(tokens, "из", "откуда", "от") {
|
|
origin := travelContextOrigin(history)
|
|
destination := travelContextDestinationFromTokens(tokens)
|
|
if nearestAirTravelRequest(tokens) && origin != "" && destination != "" {
|
|
return nearestAirTravelSearchQuery(origin, destination, now)
|
|
}
|
|
if origin != "" {
|
|
// Preserve the remembered location as an explicit search parameter
|
|
// instead of guessing its grammatical form in the user's language.
|
|
query += " пункт отправления: " + origin
|
|
}
|
|
}
|
|
return query
|
|
}
|
|
|
|
// localPlaceSearchRequest recognizes a proximity search as a local-search
|
|
// capability, keeping it separate from travel-route compilation.
|
|
func localPlaceSearchRequest(tokens []string) bool {
|
|
return containsStemInTokens(tokens, "ресторан", "кафе") && containsStemInTokens(tokens, "ближ", "рядом")
|
|
}
|
|
|
|
func localPlaceResearchQuery(tokens []string) string {
|
|
parts := make([]string, 0, len(tokens))
|
|
for _, token := range tokens {
|
|
if containsStem(token, "найд", "покаж", "ближ", "рядом") {
|
|
continue
|
|
}
|
|
parts = append(parts, token)
|
|
}
|
|
return "места " + strings.Join(parts, " ") + " адрес отзывы меню"
|
|
}
|
|
|
|
// trainScheduleResearchQuery formulates an observed schedule request from the
|
|
// user's route and remembered origin. It contains no city, provider or answer
|
|
// catalogue: actual departure data remains evidence from the selected source.
|
|
func trainScheduleResearchQuery(origin, destination string, now time.Time, nearest bool) string {
|
|
prefix := "расписание поездов"
|
|
if nearest {
|
|
prefix = "ближайшие поезда"
|
|
}
|
|
query := prefix + " из " + origin + " в " + destination + " время отправления"
|
|
if !now.IsZero() {
|
|
query += " сегодня " + now.Format("02.01.2006")
|
|
}
|
|
return query
|
|
}
|
|
|
|
// nearestAirTravelSearchQuery is a capability-local search formulation, not a
|
|
// provider or place lookup. It removes conversational imperatives while
|
|
// preserving the two user-derived route endpoints and the implicit current-day
|
|
// meaning of "ближайший".
|
|
func nearestAirTravelSearchQuery(origin, destination string, now time.Time) string {
|
|
query := "ближайшие авиарейсы " + origin + " " + destination + " сегодня"
|
|
if !now.IsZero() {
|
|
query += " " + now.Format("02.01.2006")
|
|
}
|
|
return query
|
|
}
|
|
|
|
// StripAssistantVocative removes a leading assistant name used as an address.
|
|
// The original utterance remains the historical source; this value is only for
|
|
// interpretation, cognitive prompts and external queries.
|
|
func StripAssistantVocative(message string) string {
|
|
trimmed := strings.TrimSpace(message)
|
|
lower := strings.ToLower(trimmed)
|
|
for _, name := range []string{"дед", "домовой"} {
|
|
if !strings.HasPrefix(lower, name) {
|
|
continue
|
|
}
|
|
rest := trimmed[len(name):]
|
|
if rest == "" || !strings.ContainsRune(" \t,;:!?—-", []rune(rest)[0]) {
|
|
continue
|
|
}
|
|
return strings.TrimSpace(strings.TrimLeft(rest, " \t,;:!?—-"))
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
// IndependentRequestParts keeps separate user actions visible to the
|
|
// orchestrator. It is intentionally limited to the ordinary coordinating
|
|
// conjunction; the cognitive route remains responsible for ambiguous prose.
|
|
func IndependentRequestParts(message string) []string {
|
|
cleaned := StripAssistantVocative(message)
|
|
if containsToken(semanticTokens(cleaned), "между") {
|
|
return []string{cleaned}
|
|
}
|
|
parts := strings.Split(cleaned, " и ")
|
|
if len(parts) < 2 {
|
|
return []string{cleaned}
|
|
}
|
|
result := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
result = append(result, part)
|
|
}
|
|
}
|
|
if len(result) < 2 {
|
|
return []string{cleaned}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// LocalSearchClarification prevents a proximity search from silently using a
|
|
// default city when the user did not provide a place.
|
|
func LocalSearchClarification(message string) string {
|
|
tokens := semanticTokens(StripAssistantVocative(message))
|
|
if !containsStemInTokens(tokens, "ресторан", "кафе") || !containsStemInTokens(tokens, "ближ", "рядом") {
|
|
return ""
|
|
}
|
|
for index, token := range tokens {
|
|
if token != "в" && token != "на" && token != "около" && !containsStem(token, "район", "город") {
|
|
continue
|
|
}
|
|
if index+1 < len(tokens) || containsStem(token, "район", "город") {
|
|
return ""
|
|
}
|
|
}
|
|
return "В каком городе или районе искать ресторан?"
|
|
}
|
|
|
|
// ContinueLocalSearch turns a location-only follow-up into the previous local
|
|
// search request, but only when that request is present in this conversation.
|
|
func ContinueLocalSearch(message string, history []interaction.HumanUtterance) string {
|
|
current := strings.TrimSpace(StripAssistantVocative(message))
|
|
tokens := semanticTokens(current)
|
|
if len(tokens) == 0 || len(tokens) > 3 || (tokens[0] != "в" && tokens[0] != "на" && tokens[0] != "около") {
|
|
return ""
|
|
}
|
|
var latest *interaction.HumanUtterance
|
|
for index := range history {
|
|
candidate := history[index]
|
|
candidateText := strings.TrimSpace(StripAssistantVocative(candidate.Content))
|
|
candidateTokens := semanticTokens(candidateText)
|
|
if !containsStemInTokens(candidateTokens, "ресторан", "кафе") || !containsStemInTokens(candidateTokens, "ближ", "рядом") {
|
|
continue
|
|
}
|
|
if latest == nil || candidate.ReceivedAtUnixMillis > latest.ReceivedAtUnixMillis {
|
|
latest = &candidate
|
|
}
|
|
}
|
|
if latest != nil {
|
|
previous := strings.TrimSpace(StripAssistantVocative(latest.Content))
|
|
return strings.TrimSpace(previous + " " + current)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// ContinueTravelOrigin turns a short location answer into the previous travel
|
|
// task. The original utterance remains history; only the effective task is
|
|
// completed for routing and research.
|
|
func ContinueTravelOrigin(message string, history []interaction.HumanUtterance) string {
|
|
current := semanticTokens(StripAssistantVocative(message))
|
|
if len(current) == 0 || len(current) > 6 {
|
|
return ""
|
|
}
|
|
location := ""
|
|
if current[0] == "я" {
|
|
if len(current) < 3 {
|
|
return ""
|
|
}
|
|
for index, token := range current {
|
|
if (token == "в" || token == "из") && index+1 < len(current) {
|
|
location = capitalizeWord(current[index+1])
|
|
break
|
|
}
|
|
}
|
|
} else if len(current) <= 2 && !isTravelDateWord(current[0]) && len([]rune(current[0])) >= 3 {
|
|
location = capitalizeWord(current[0])
|
|
}
|
|
if location == "" {
|
|
return ""
|
|
}
|
|
var latest *interaction.HumanUtterance
|
|
for index := range history {
|
|
candidate := history[index]
|
|
if !travelContextCue(semanticTokens(candidate.Content)) {
|
|
continue
|
|
}
|
|
if latest == nil || candidate.ReceivedAtUnixMillis > latest.ReceivedAtUnixMillis {
|
|
latest = &candidate
|
|
}
|
|
}
|
|
if latest == nil {
|
|
return ""
|
|
}
|
|
previous := strings.TrimRight(strings.TrimSpace(StripAssistantVocative(latest.Content)), "?!.,;:")
|
|
preposition := " из "
|
|
if current[0] != "я" {
|
|
preposition = " из города "
|
|
}
|
|
return strings.TrimSpace(previous + preposition + location)
|
|
}
|
|
|
|
// ContinueTravelDate completes the most recent unfinished ticket search with
|
|
// a short date answer. The date is still the human's original wording, so the
|
|
// later route and research stages retain its provenance and can interpret it
|
|
// relative to the current time.
|
|
func ContinueTravelDate(message string, history []interaction.HumanUtterance) string {
|
|
current := strings.TrimSpace(StripAssistantVocative(message))
|
|
currentTokens := semanticTokens(current)
|
|
if len(currentTokens) == 0 || len(currentTokens) > 6 || !isTravelDateSpecified(currentTokens) {
|
|
return ""
|
|
}
|
|
latestAt := int64(-1)
|
|
latest := ""
|
|
for _, utterance := range history {
|
|
tokens := semanticTokens(utterance.Content)
|
|
if !travelCue(tokens) || isTravelDateSpecified(tokens) || containsStemInTokens(tokens, "ближ") {
|
|
continue
|
|
}
|
|
if utterance.ReceivedAtUnixMillis >= latestAt {
|
|
latestAt = utterance.ReceivedAtUnixMillis
|
|
latest = strings.TrimSpace(StripAssistantVocative(utterance.Content))
|
|
}
|
|
}
|
|
if latest == "" {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(latest + " " + current)
|
|
}
|
|
|
|
// TicketSearchDestination extracts the destination from a ticket request
|
|
// without assigning any city or travel-provider meaning in core code.
|
|
func TicketSearchDestination(message string) string {
|
|
tokens := semanticTokens(StripAssistantVocative(message))
|
|
if !containsStemInTokens(tokens, "билет") {
|
|
return ""
|
|
}
|
|
return travelContextDestinationFromTokens(tokens)
|
|
}
|
|
|
|
// TravelSearchClarification keeps an incomplete travel request in dialogue
|
|
// instead of reducing it to an unrelated generic web search.
|
|
func TravelSearchClarification(message string, history []interaction.HumanUtterance) string {
|
|
current := semanticTokens(StripAssistantVocative(message))
|
|
travel := travelCue(current)
|
|
if !travel {
|
|
travel = containsStemInTokens(current, "добрат", "передвиг", "поех", "дорог")
|
|
}
|
|
if !travel && travelFollowUpCue(current) {
|
|
for index := len(history) - 1; index >= 0; index-- {
|
|
if travelCue(semanticTokens(history[index].Content)) {
|
|
travel = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !travel {
|
|
return ""
|
|
}
|
|
origin := containsStemInTokens(current, "из", "откуда", "от")
|
|
destination := containsStemInTokens(current, "в", "куда", "до")
|
|
date := isTravelDateSpecified(current)
|
|
if containsStemInTokens(current, "ближ") {
|
|
date = true
|
|
}
|
|
if !date {
|
|
for _, token := range current {
|
|
if hasDigit(token) {
|
|
date = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
ticketRequest := containsStemInTokens(current, "билет", "рейс", "самолет", "самолёт", "вылет")
|
|
if !destination {
|
|
if contextDestination := travelContextDestination(history); contextDestination != "" {
|
|
destination = true
|
|
if !origin && !date {
|
|
return "Из какого города вылетать в " + contextDestination + " и на какую дату?"
|
|
}
|
|
if !origin {
|
|
return "Из какого города вылетать в " + contextDestination + "?"
|
|
}
|
|
}
|
|
}
|
|
if !origin {
|
|
if contextOrigin := travelContextOrigin(history); contextOrigin != "" {
|
|
origin = true
|
|
}
|
|
}
|
|
if origin && destination && (date || !ticketRequest) {
|
|
return ""
|
|
}
|
|
if destination && !origin && !date {
|
|
if !ticketRequest {
|
|
return "Из какого города выезжать в " + destinationLabel(current, history) + "? Подберу самолёт, поезд, автобус или машину."
|
|
}
|
|
return "Из какого города вылетать и на какую дату?"
|
|
}
|
|
if destination && !origin {
|
|
return "Из какого города вылетать?"
|
|
}
|
|
if destination && !date {
|
|
if !ticketRequest {
|
|
return ""
|
|
}
|
|
return "На какую дату нужны билеты?"
|
|
}
|
|
return "Уточните маршрут и дату: откуда, куда и на какой день нужны билеты?"
|
|
}
|
|
|
|
func isTravelDateSpecified(tokens []string) bool {
|
|
if containsStemInTokens(tokens, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр") {
|
|
return true
|
|
}
|
|
for _, token := range tokens {
|
|
if hasDigit(token) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isTravelDateWord(value string) bool {
|
|
return containsStem(value, "сегодня", "завтра", "послезавтр", "январ", "феврал", "март", "апрел", "мая", "июн", "июл", "август", "сентябр", "октябр", "ноябр", "декабр")
|
|
}
|
|
|
|
func destinationLabel(current []string, history []interaction.HumanUtterance) string {
|
|
for index, token := range current {
|
|
if (token == "в" || token == "до") && index+1 < len(current) {
|
|
return capitalizeWord(current[index+1])
|
|
}
|
|
}
|
|
if destination := travelContextDestination(history); destination != "" {
|
|
return destination
|
|
}
|
|
return "пункт назначения"
|
|
}
|
|
|
|
func travelContextDestinationFromTokens(tokens []string) string {
|
|
for index, token := range tokens {
|
|
if token != "до" && token != "в" && token != "к" {
|
|
continue
|
|
}
|
|
if index+1 < len(tokens) && len([]rune(tokens[index+1])) >= 3 {
|
|
return capitalizeWord(tokens[index+1])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func travelContextDestination(history []interaction.HumanUtterance) string {
|
|
latestAt := int64(-1)
|
|
latest := ""
|
|
for _, utterance := range history {
|
|
tokens := semanticTokens(utterance.Content)
|
|
if !travelContextCue(tokens) {
|
|
continue
|
|
}
|
|
for tokenIndex, token := range tokens {
|
|
if token != "в" && token != "до" {
|
|
continue
|
|
}
|
|
if tokenIndex+1 < len(tokens) && len([]rune(tokens[tokenIndex+1])) >= 3 {
|
|
if utterance.ReceivedAtUnixMillis >= latestAt {
|
|
latestAt = utterance.ReceivedAtUnixMillis
|
|
latest = capitalizeWord(tokens[tokenIndex+1])
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return latest
|
|
}
|
|
|
|
func travelContextOrigin(history []interaction.HumanUtterance) string {
|
|
return CompilePersonalMemory(history).CurrentLocation()
|
|
}
|
|
|
|
func travelContextCue(tokens []string) bool {
|
|
return travelCue(tokens) || containsStemInTokens(tokens, "добрат", "передвиг", "ехать", "едем", "лететь", "поездк")
|
|
}
|
|
|
|
func capitalizeWord(value string) string {
|
|
runes := []rune(value)
|
|
if len(runes) == 0 {
|
|
return value
|
|
}
|
|
return strings.ToUpper(string(runes[0])) + string(runes[1:])
|
|
}
|
|
|
|
func travelCue(tokens []string) bool {
|
|
return containsStemInTokens(tokens, "билет", "рейс", "самолет", "самолёт", "вылет", "поезд", "автобус", "перелет", "перелёт") || approximateToken(tokens, "самолет", 2) || approximateToken(tokens, "самолёт", 2)
|
|
}
|
|
|
|
func trainScheduleRequest(tokens []string) bool {
|
|
return containsStemInTokens(tokens, "поезд") &&
|
|
(containsStemInTokens(tokens, "найд", "ближ", "расписан", "отправ", "прибуд", "едет", "едут") || len(tokens) > 1)
|
|
}
|
|
|
|
func nearestAirTravelRequest(tokens []string) bool {
|
|
return containsStemInTokens(tokens, "ближ") && containsStemInTokens(tokens, "самолет", "самолёт", "авиарейс", "перелет", "перелёт")
|
|
}
|
|
|
|
func travelFollowUpCue(tokens []string) bool {
|
|
return containsStemInTokens(tokens, "куда", "откуда", "маршрут", "летим", "дата", "число")
|
|
}
|
|
|
|
func hasDigit(value string) bool {
|
|
for _, runeValue := range value {
|
|
if runeValue >= '0' && runeValue <= '9' {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|