Files
m 4e54ace76b
CI / test (pull_request) Successful in 1m19s
CI / test (push) Successful in 47s
Route local place searches to relevant evidence
2026-08-26 10:40:15 +03:00

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
}