diff --git a/core/http/endpoints/openai/evidence.go b/core/http/endpoints/openai/evidence.go index 5f7c782e10..2fd9ecd870 100644 --- a/core/http/endpoints/openai/evidence.go +++ b/core/http/endpoints/openai/evidence.go @@ -1,6 +1,7 @@ package openai import ( + "encoding/json" "regexp" "strings" @@ -12,9 +13,9 @@ import ( var numberToken = regexp.MustCompile(`\b\d+(?:[.,]\d+)?\b`) // urlToken matches an http(s) URL up to the first character that cannot be -// part of one in tool output: whitespace, a quote, a closing bracket of -// Markdown or JSON, or the backslash of a JSON escape. -var urlToken = regexp.MustCompile(`https?://[^\s"'<>()\[\]{}\\]+`) +// part of one in tool output. Parentheses are allowed inside URL paths; +// sourceURLs removes unmatched closing parentheses from Markdown links. +var urlToken = regexp.MustCompile(`https?://[^\s"'<>\[\]{}\\]+`) // maxSourceURLs caps the appended source line; a search tool can return // dozens of links, and the first ones are the ones it ranked highest. @@ -27,8 +28,20 @@ func sourceURLs(texts []string) []string { var urls []string seen := map[string]struct{}{} for _, text := range texts { + // ExecuteMCPToolCall JSON-encodes multiple TextContent results. + // Decode that envelope so escaped query separators remain intact. + var parts []string + var single string + if json.Unmarshal([]byte(text), &parts) == nil { + text = strings.Join(parts, "\n") + } else if json.Unmarshal([]byte(text), &single) == nil { + text = single + } for _, token := range urlToken.FindAllString(text, -1) { token = strings.TrimRight(token, ".,;:!?") + for strings.HasSuffix(token, ")") && strings.Count(token, ")") > strings.Count(token, "(") { + token = strings.TrimRight(strings.TrimSuffix(token, ")"), ".,;:!?") + } if _, ok := seen[token]; ok || strings.HasSuffix(token, "://") { continue } diff --git a/core/http/endpoints/openai/evidence_sources_test.go b/core/http/endpoints/openai/evidence_sources_test.go index 9b66178422..6921c9f0df 100644 --- a/core/http/endpoints/openai/evidence_sources_test.go +++ b/core/http/endpoints/openai/evidence_sources_test.go @@ -1,11 +1,25 @@ package openai import ( + "encoding/json" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("sourceURLs", func() { + It("preserves parentheses inside URLs and removes surrounding Markdown punctuation", func() { + url := "https://en.wikipedia.org/wiki/Mercury_(planet)" + Expect(sourceURLs([]string{"[Mercury](" + url + "). (" + url + ")"})).To(Equal([]string{url})) + }) + + It("decodes multiple MCP text results before extracting URLs", func() { + url := "https://example.com/search?a=1&b=2" + encoded, err := json.Marshal([]string{"First " + url + "\n", "Second https://example.com/other"}) + Expect(err).NotTo(HaveOccurred()) + Expect(sourceURLs([]string{string(encoded)})).To(Equal([]string{url, "https://example.com/other"})) + }) + It("returns unique URLs in order of appearance, at most five", func() { text := "x https://1.example/a, y (https://2.example/b) z https://1.example/a " + "[l](https://3.example/c). \"https://4.example/d\" http://5.example/e; https://6.example/f" diff --git a/docs/content/features/model-aliases.md b/docs/content/features/model-aliases.md index 0363f26b8d..bcf343c7ee 100644 --- a/docs/content/features/model-aliases.md +++ b/docs/content/features/model-aliases.md @@ -160,7 +160,8 @@ deterministic and applies to each request on its own: 3. Otherwise the distinct URLs of those tool results are collected in the order they first appear, at most five, and appended after a blank line as one line: `Quellen: https://a.example/test, https://b.example/oil`. Punctuation that - ends a sentence is not taken as part of a URL. + ends a sentence is not taken as part of a URL. Parentheses inside URL paths + and query parameters in JSON-encoded MCP text results are preserved. 4. If no tool result contains a URL, nothing is appended. No URL is ever made up: every link on the line was returned by a tool.