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 }