Polish web research answers and travel context #328

Merged
m merged 1 commits from codex/chat-semantic-inventory into main 2026-08-25 03:56:32 +00:00
6 changed files with 125 additions and 6 deletions
+48 -4
View File
@@ -19,9 +19,9 @@ import (
var ErrSearchRejected = errors.New("web search request rejected")
var (
searchLinkPattern = regexp.MustCompile(`(?is)<a[^>]+class=["'][^"']*result__a[^"']*["'][^>]*>(.*?)</a>`)
searchLinkPattern = regexp.MustCompile(`(?is)(<a[^>]+class=["'][^"']*result__a[^"']*["'][^>]*>)(.*?)</a>`)
searchSnippetPattern = regexp.MustCompile(`(?is)<a[^>]+class=["'][^"']*result__snippet[^"']*["'][^>]*>(.*?)</a>`)
bingLinkPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<h2[^>]*><a[^>]*>(.*?)</a>`)
bingLinkPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<h2[^>]*>(<a[^>]*>)(.*?)</a>`)
bingSnippetPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<p[^>]*>(.*?)</p>`)
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
}
@@ -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)
}
}
+1 -1
View File
@@ -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{
+26 -1
View File
@@ -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 "Я не смогла прочитать реестр подключённых моделей. Встроенный канал Домового остаётся доступен; настройки можно проверить повторно после восстановления реестра. Ключи и пароли не выводятся в чат."
@@ -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) {
+43
View File
@@ -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, "билет", "рейс", "поезд", "автобус", "перелет", "перелёт")
}