From d7ca5f7e68c5a293d8f3947c0f257e0b6f99039a Mon Sep 17 00:00:00 2001 From: Mikhail Date: Tue, 25 Aug 2026 06:51:07 +0300 Subject: [PATCH] Polish web research answers and travel context --- adapters/research/search_connector.go | 52 ++++++++++++++++++-- adapters/research/search_connector_test.go | 3 ++ cmd/core-runtime/cognitive_interpretation.go | 2 +- cmd/core-runtime/main.go | 27 +++++++++- modules/interaction/domain/semantics_test.go | 4 ++ modules/interaction/domain/travel.go | 43 ++++++++++++++++ 6 files changed, 125 insertions(+), 6 deletions(-) diff --git a/adapters/research/search_connector.go b/adapters/research/search_connector.go index ac21607..2205da9 100644 --- a/adapters/research/search_connector.go +++ b/adapters/research/search_connector.go @@ -19,9 +19,9 @@ import ( var ErrSearchRejected = errors.New("web search request rejected") var ( - searchLinkPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*result__a[^"']*["'][^>]*>(.*?)`) + searchLinkPattern = regexp.MustCompile(`(?is)(]+class=["'][^"']*result__a[^"']*["'][^>]*>)(.*?)`) searchSnippetPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*result__snippet[^"']*["'][^>]*>(.*?)`) - bingLinkPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?]*>]*>(.*?)`) + bingLinkPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?]*>(]*>)(.*?)`) bingSnippetPattern = regexp.MustCompile(`(?is)]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?]*>(.*?)

`) tagPattern = regexp.MustCompile(`(?s)<[^>]*>`) termPattern = regexp.MustCompile(`(?i)[\p{L}\p{N}]{2,}`) @@ -128,7 +128,8 @@ func (c SearchConnector) collectEndpoint(ctx context.Context, configured SearchE snapshotID := epistemics.SourceSnapshotID("snapshot:search:" + base) results := make([]epistemics.ResearchCandidate, 0, len(links)) for index, link := range links { - title := cleanHTML(string(link[1])) + linkURL := resolveSearchResultLocator(extractHref(string(link[1])), endpoint) + title := cleanHTML(string(link[2])) snippet := "" if index < len(snippets) { snippet = cleanHTML(string(snippets[index][1])) @@ -142,7 +143,7 @@ func (c SearchConnector) collectEndpoint(ctx context.Context, configured SearchE results = append(results, epistemics.ResearchCandidate{ PropositionKey: request.PropositionKey + ":result:" + ordinal, Statement: statement, - Source: epistemics.Source{ID: sourceID, Kind: "web-search", Locator: endpoint.String(), Title: title, Publisher: endpoint.Hostname()}, + Source: epistemics.Source{ID: sourceID, Kind: "web-search", Locator: linkURL, Title: title, Publisher: sourcePublisher(linkURL, endpoint.Hostname())}, Snapshot: epistemics.SourceSnapshot{ID: snapshotID, SourceID: sourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent}, Excerpt: epistemics.SourceExcerpt{ID: excerptID, SnapshotID: snapshotID, Text: statement, Start: 0, End: int64(len(statement))}, Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone, @@ -185,3 +186,46 @@ func (c SearchConnector) limit() int { func cleanHTML(value string) string { return strings.Join(strings.Fields(html.UnescapeString(tagPattern.ReplaceAllString(value, ""))), " ") } + +func extractHref(tag string) string { + match := regexp.MustCompile(`(?is)\bhref=["']([^"']+)["']`).FindStringSubmatch(tag) + if len(match) == 2 { + return match[1] + } + return "" +} + +func resolveSearchResultLocator(raw string, searchEndpoint *url.URL) string { + value := strings.TrimSpace(html.UnescapeString(raw)) + if value == "" { + return searchEndpoint.String() + } + parsed, err := url.Parse(value) + if err != nil { + return searchEndpoint.String() + } + if target := parsed.Query().Get("uddg"); target != "" { + value = target + parsed, err = url.Parse(target) + if err != nil { + return searchEndpoint.String() + } + } + if parsed.Scheme == "" { + parsed.Scheme = "https" + } + if parsed.Host == "" && strings.HasPrefix(value, "//") { + parsed, err = url.Parse("https:" + value) + } + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return searchEndpoint.String() + } + return parsed.String() +} + +func sourcePublisher(locator, fallback string) string { + if parsed, err := url.Parse(locator); err == nil && parsed.Hostname() != "" { + return parsed.Hostname() + } + return fallback +} diff --git a/adapters/research/search_connector_test.go b/adapters/research/search_connector_test.go index 1d5e098..3e5a9fd 100644 --- a/adapters/research/search_connector_test.go +++ b/adapters/research/search_connector_test.go @@ -42,4 +42,7 @@ func TestSearchConnectorFallsBackToAnotherAllowedSource(t *testing.T) { if len(results) != 1 || !strings.Contains(results[0].Statement, "18") { t.Fatalf("fallback evidence = %#v", results) } + if results[0].Source.Locator != "https://example.test" || results[0].Source.Publisher != "example.test" { + t.Fatalf("search result source = %#v", results[0].Source) + } } diff --git a/cmd/core-runtime/cognitive_interpretation.go b/cmd/core-runtime/cognitive_interpretation.go index e382362..56b807b 100644 --- a/cmd/core-runtime/cognitive_interpretation.go +++ b/cmd/core-runtime/cognitive_interpretation.go @@ -33,7 +33,7 @@ func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, sessi if err != nil { return "", err } - prompt := "Ты редактор ответа Домового. Проверь, отвечает ли черновик на сообщение пользователя по смыслу. Верни только готовый ответ пользователю, без разбора проверки, служебных слов и упоминания моделей. Не выдумывай факты, не утверждай невыполненные действия, сохрани важные имена, числа, источники и ограничения. Если черновик уже адекватен, верни его без лишних добавлений.\n\nСообщение пользователя:\n" + userMessage + "\n\nЧерновик ответа:\n" + draft + prompt := "Ты редактор ответа Домового. Проверь черновик по смыслу и подготовь естественный ответ человеку. Учитывай весь запрос, включая недостающие параметры для конкретного результата. Если данных не хватает, задай один короткий полезный уточняющий вопрос, например откуда и куда ехать. Не выдавай поисковые сниппеты как выполненное действие и не показывай служебные URL поисковой выдачи. Сохрани проверяемые факты, названия источников, прямые ссылки и ограничения. Не выдумывай факты и не утверждай невыполненные действия. Верни только готовый ответ без разбора проверки, служебных слов и упоминания моделей. Если черновик уже адекватен, верни его без лишних добавлений.\n\nСообщение пользователя:\n" + userMessage + "\n\nЧерновик ответа:\n" + draft target := *c.baseURL target.Path = "/v1/advisories" payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{ diff --git a/cmd/core-runtime/main.go b/cmd/core-runtime/main.go index 82bc073..35e4069 100644 --- a/cmd/core-runtime/main.go +++ b/cmd/core-runtime/main.go @@ -1622,7 +1622,7 @@ func renderResearchReply(result epistemics.ResearchResult, researchErr error) (s if len([]rune(excerpt)) > limit { excerpt = string([]rune(excerpt)[:limit]) + "…" } - lines = append(lines, excerpt+"\nИсточник: "+finding.Source.Locator) + lines = append(lines, excerpt+"\nИсточник: "+researchSourceLabel(finding)) if len(lines) == 2 { break } @@ -1633,6 +1633,31 @@ func renderResearchReply(result epistemics.ResearchResult, researchErr error) (s return strings.Join(lines, "\n"), true } +func researchSourceLabel(finding epistemics.ResearchFinding) string { + title := strings.TrimSpace(finding.Source.Title) + locator := strings.TrimSpace(finding.Source.Locator) + publisher := strings.TrimSpace(finding.Source.Publisher) + if strings.Contains(locator, "duckduckgo.com/html") || strings.Contains(locator, "bing.com/search") { + locator = "" + } + if title != "" && publisher != "" { + if locator != "" { + return title + " — " + publisher + " (" + locator + ")" + } + return title + " — " + publisher + } + if title != "" { + return title + } + if publisher != "" { + return publisher + } + if locator != "" { + return locator + } + return "подключённый источник" +} + func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string { if inventoryErr != nil { return "Я не смогла прочитать реестр подключённых моделей. Встроенный канал Домового остаётся доступен; настройки можно проверить повторно после восстановления реестра. Ключи и пароли не выводятся в чат." diff --git a/modules/interaction/domain/semantics_test.go b/modules/interaction/domain/semantics_test.go index f5c7167..d4ba2f0 100644 --- a/modules/interaction/domain/semantics_test.go +++ b/modules/interaction/domain/semantics_test.go @@ -109,6 +109,10 @@ func TestTravelSearchClarification(t *testing.T) { if got := TravelSearchClarification("какая погода в Сочи?", history); got != "" { t.Fatalf("unrelated question must not inherit travel goal, got %q", got) } + contextHistory := []interaction.HumanUtterance{{Content: "Как можно добраться до Сочи?"}} + if got := TravelSearchClarification("Найди билеты, какой ближайший самолёт", contextHistory); got != "Из какого города вылетать в Сочи и на какую дату?" { + t.Fatalf("travel destination should be inherited from context, got %q", got) + } } func TestInterpretUtteranceDirectiveRecordsOutcomeWithoutExecution(t *testing.T) { diff --git a/modules/interaction/domain/travel.go b/modules/interaction/domain/travel.go index db837b2..bacb247 100644 --- a/modules/interaction/domain/travel.go +++ b/modules/interaction/domain/travel.go @@ -122,9 +122,52 @@ func TravelSearchClarification(message string, history []interaction.HumanUttera if origin && destination && date { return "" } + if !destination { + if contextDestination := travelContextDestination(history); contextDestination != "" { + destination = true + if !origin && !date { + return "Из какого города вылетать в " + contextDestination + " и на какую дату?" + } + if !origin { + return "Из какого города вылетать в " + contextDestination + "?" + } + } + } + if destination && !origin && !date { + return "Из какого города вылетать и на какую дату?" + } + if destination && !origin { + return "Из какого города вылетать?" + } + if destination && !date { + return "На какую дату нужны билеты?" + } return "Уточните маршрут и дату: откуда, куда и на какой день нужны билеты?" } +func travelContextDestination(history []interaction.HumanUtterance) string { + for index := len(history) - 1; index >= 0; index-- { + tokens := semanticTokens(history[index].Content) + for tokenIndex, token := range tokens { + if token != "в" && token != "до" { + continue + } + if tokenIndex+1 < len(tokens) && len([]rune(tokens[tokenIndex+1])) >= 3 { + return capitalizeWord(tokens[tokenIndex+1]) + } + } + } + return "" +} + +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, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт") } -- 2.54.0