webctx 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTORS.md +1 -1
- package/README.md +23 -5
- package/internal/app/app.go +137 -0
- package/internal/app/data_client.go +220 -0
- package/internal/app/data_execute.go +424 -0
- package/internal/app/data_inspect.go +176 -0
- package/internal/app/data_research.go +323 -0
- package/internal/app/data_tools_test.go +336 -0
- package/internal/app/data_types.go +40 -0
- package/internal/app/firecrawl_fallback.go +4 -14
- package/internal/app/firecrawl_fallback_test.go +38 -0
- package/internal/app/native_markdown.go +76 -0
- package/internal/app/native_markdown_test.go +78 -0
- package/internal/app/research.go +122 -33
- package/internal/app/research_navigation.go +237 -0
- package/internal/app/research_navigation_test.go +72 -0
- package/internal/app/research_test.go +4 -0
- package/internal/app/retrieval_quality.go +106 -0
- package/internal/app/retrieval_quality_test.go +63 -0
- package/internal/app/scrape.go +4 -65
- package/internal/app/structured_source.go +10 -0
- package/internal/app/tools.go +16 -7
- package/package.json +2 -2
- package/pkg/origo/data_test.go +134 -0
- package/pkg/origo/server.go +46 -0
- package/pkg/origo/server_test.go +11 -6
- package/pkg/retrieval/data.go +13 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
// ResearchData discovers data sources and their available operations. Search
|
|
4
|
+
// and catalogue browsing are both free Firecrawl Alexandria operations.
|
|
5
|
+
type ResearchDataInput struct {
|
|
6
|
+
Query string `json:"query,omitempty" jsonschema:"Optional natural-language description of the information needed"`
|
|
7
|
+
Mode string `json:"mode,omitempty" jsonschema:"ranked semantic discovery or catalogue browsing with filters and pagination; defaults to ranked for a query"`
|
|
8
|
+
URLs []string `json:"urls,omitempty" jsonschema:"Filter by websites associated with a data source"`
|
|
9
|
+
Sources []string `json:"sources,omitempty" jsonschema:"Filter by source IDs returned by research"`
|
|
10
|
+
Categories []string `json:"categories,omitempty" jsonschema:"Filter by catalogue category IDs"`
|
|
11
|
+
Groups []string `json:"groups,omitempty" jsonschema:"Filter by catalogue group IDs"`
|
|
12
|
+
Operations []string `json:"operations,omitempty" jsonschema:"Filter by exact operation IDs"`
|
|
13
|
+
View string `json:"view,omitempty" jsonschema:"What to browse: sources, groups, or operations; default operations for a query, otherwise sources"`
|
|
14
|
+
Include []string `json:"include,omitempty" jsonschema:"Additional contract details: inputs, output, examples. Default is a concise overview"`
|
|
15
|
+
Limit int `json:"limit,omitempty" jsonschema:"Maximum results in this page, 1-100; default 10"`
|
|
16
|
+
Offset int `json:"offset,omitempty" jsonschema:"Catalogue pagination offset, zero-based"`
|
|
17
|
+
Raw bool `json:"raw,omitempty" jsonschema:"Also include the complete original Firecrawl response"`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// InspectDataInput returns the exact contract for a selected operation, or
|
|
21
|
+
// browses all operations of a source when passed just a source ID.
|
|
22
|
+
type InspectDataInput struct {
|
|
23
|
+
ID string `json:"id" jsonschema:"Source or operation ID returned by research, e.g. particle/podcasts/episodes/search"`
|
|
24
|
+
Include []string `json:"include,omitempty" jsonschema:"Contract sections: inputs, output, examples; all by default"`
|
|
25
|
+
Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original Firecrawl response"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type DataCall struct {
|
|
29
|
+
ID string `json:"id" jsonschema:"Exact operation ID returned by research or inspect"`
|
|
30
|
+
Inputs map[string]any `json:"inputs,omitempty" jsonschema:"Provider-specific input fields copied from the inspected contract"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ExecuteDataInput supports up to ten calls in a single provider request.
|
|
34
|
+
// It validates each call against the free catalogue before using credits.
|
|
35
|
+
type ExecuteDataInput struct {
|
|
36
|
+
Calls []DataCall `json:"calls" jsonschema:"One to ten operation calls; inspect each operation to discover exact input field names"`
|
|
37
|
+
MaxCredits int `json:"max_credits,omitempty" jsonschema:"Maximum predicted credits for this request; defaults to WEBCTX_ALEXANDRIA_MAX_CREDITS or 100"`
|
|
38
|
+
RequestID string `json:"request_id,omitempty" jsonschema:"Optional stable idempotency identifier for retrying a paid request; reuse unchanged if retrying"`
|
|
39
|
+
Raw bool `json:"raw,omitempty" jsonschema:"Include the complete original provider response"`
|
|
40
|
+
}
|
|
@@ -18,20 +18,7 @@ type scrapedPage struct {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
func usableScrapedMarkdown(markdown string) bool {
|
|
21
|
-
|
|
22
|
-
if len(text) < 10 {
|
|
23
|
-
return false
|
|
24
|
-
}
|
|
25
|
-
for _, marker := range []string{
|
|
26
|
-
"just a moment...", "checking your browser", "verify you are human",
|
|
27
|
-
"enable javascript and cookies", "access denied | cloudflare",
|
|
28
|
-
"attention required! | cloudflare", "no content extracted",
|
|
29
|
-
} {
|
|
30
|
-
if strings.Contains(text, marker) && len(text) < 2000 {
|
|
31
|
-
return false
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return true
|
|
21
|
+
return usableSourceContent(markdown)
|
|
35
22
|
}
|
|
36
23
|
|
|
37
24
|
func firecrawlScrape(rawURL, apiKey, proxy string) (scrapedPage, error) {
|
|
@@ -165,6 +152,9 @@ func firecrawlBrowserFallback(rawURL, apiKey string) (string, string, error) {
|
|
|
165
152
|
if parsed, err := strconv.Unquote(strings.TrimSpace(text)); err == nil {
|
|
166
153
|
text = parsed
|
|
167
154
|
}
|
|
155
|
+
if reason := challengeReason(text); reason != "" {
|
|
156
|
+
return "", "", fmt.Errorf("browser fallback returned %s rather than documentation", reason)
|
|
157
|
+
}
|
|
168
158
|
return "Browser extraction", text, nil
|
|
169
159
|
}
|
|
170
160
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package app
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
|
+
"encoding/json"
|
|
4
5
|
"io"
|
|
5
6
|
"net/http"
|
|
6
7
|
"strings"
|
|
@@ -18,6 +19,43 @@ func TestSourceURLSafety(t *testing.T) {
|
|
|
18
19
|
}
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
func TestLongBrowserCAPTCHAFallbackFailsClearly(t *testing.T) {
|
|
23
|
+
original := http.DefaultClient
|
|
24
|
+
t.Cleanup(func() { http.DefaultClient = original })
|
|
25
|
+
challenge := "hCaptcha Please verify you are human. " +
|
|
26
|
+
strings.Repeat("French Deutsch Español English 日本語 中文 ", 150)
|
|
27
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
28
|
+
switch req.Method + " " + req.URL.Path {
|
|
29
|
+
case "POST /v2/scrape":
|
|
30
|
+
content, _ := json.Marshal(map[string]any{
|
|
31
|
+
"success": true,
|
|
32
|
+
"data": map[string]any{"markdown": challenge},
|
|
33
|
+
})
|
|
34
|
+
return testHTTPResponse(req, http.StatusOK, string(content), nil), nil
|
|
35
|
+
case "POST /v2/interact":
|
|
36
|
+
return testHTTPResponse(req, http.StatusOK, `{"success":true,"id":"test-captcha"}`, nil), nil
|
|
37
|
+
case "POST /v2/interact/test-captcha/execute":
|
|
38
|
+
body, _ := json.Marshal(map[string]any{
|
|
39
|
+
"success": true, "stdout": challenge, "exitCode": 0,
|
|
40
|
+
})
|
|
41
|
+
return testHTTPResponse(req, http.StatusOK, string(body), nil), nil
|
|
42
|
+
case "DELETE /v2/interact/test-captcha":
|
|
43
|
+
return testHTTPResponse(req, http.StatusOK, `{"success":true}`, nil), nil
|
|
44
|
+
default:
|
|
45
|
+
t.Fatalf("unexpected request: %s %s", req.Method, req.URL)
|
|
46
|
+
return nil, nil
|
|
47
|
+
}
|
|
48
|
+
})}
|
|
49
|
+
t.Setenv("FIRECRAWL_API_KEY", "unit-test")
|
|
50
|
+
_, returned, err := scrapeLinkWithFirecrawl("https://docs.example.com/challenged")
|
|
51
|
+
if err == nil || returned != "" || !strings.Contains(err.Error(), "challenge") {
|
|
52
|
+
t.Fatalf("long browser challenge leaked into result or lacked clear failure: %q %v", returned, err)
|
|
53
|
+
}
|
|
54
|
+
if strings.Contains(err.Error(), "French Deutsch") {
|
|
55
|
+
t.Fatalf("challenge payload should not be logged verbatim in errors: %s", err)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
21
59
|
func TestScrapeEscalatesToEnhancedOnBlockedBody(t *testing.T) {
|
|
22
60
|
originalClient := http.DefaultClient
|
|
23
61
|
t.Cleanup(func() { http.DefaultClient = originalClient })
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"net/url"
|
|
6
|
+
"path"
|
|
7
|
+
"strings"
|
|
8
|
+
"time"
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
const maxNativeMarkdownBytes = 1024 * 1024
|
|
12
|
+
|
|
13
|
+
// Documentation sites frequently expose canonical HTML and an authored
|
|
14
|
+
// Markdown representation. Use bounded GET probes instead of HEAD:
|
|
15
|
+
// Stripe sends Content-Length: 0 on a successful Markdown HEAD response,
|
|
16
|
+
// and Cloudflare uses /index.md for directory URLs.
|
|
17
|
+
func markdownCandidateURLs(rawURL string) []string {
|
|
18
|
+
u, err := url.Parse(rawURL)
|
|
19
|
+
if err != nil {
|
|
20
|
+
return nil
|
|
21
|
+
}
|
|
22
|
+
p := u.Path
|
|
23
|
+
if strings.HasSuffix(strings.ToLower(p), ".md") {
|
|
24
|
+
return []string{rawURL}
|
|
25
|
+
}
|
|
26
|
+
var candidates []string
|
|
27
|
+
if strings.HasSuffix(p, "/") || p == "" {
|
|
28
|
+
candidates = append(candidates, strings.TrimSuffix(p, "/")+"/index.md")
|
|
29
|
+
} else if path.Ext(p) == "" {
|
|
30
|
+
candidates = append(candidates, p+".md", p+"/index.md")
|
|
31
|
+
} else {
|
|
32
|
+
return nil
|
|
33
|
+
}
|
|
34
|
+
seen := map[string]bool{}
|
|
35
|
+
var out []string
|
|
36
|
+
for _, candidate := range candidates {
|
|
37
|
+
next := *u
|
|
38
|
+
next.Path = candidate
|
|
39
|
+
next.RawPath = ""
|
|
40
|
+
next.Fragment = ""
|
|
41
|
+
next.RawFragment = ""
|
|
42
|
+
value := next.String()
|
|
43
|
+
if !seen[value] && validateSourceURL(value) == nil {
|
|
44
|
+
out = append(out, value)
|
|
45
|
+
seen[value] = true
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func usableNativeMarkdown(content string) bool {
|
|
52
|
+
text := strings.TrimSpace(content)
|
|
53
|
+
if len(text) < 18 || strings.IndexByte(text, 0) >= 0 || challengeReason(text) != "" {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
prefix := strings.ToLower(text[:min(len(text), 700)])
|
|
57
|
+
if strings.Contains(prefix, "<!doctype html") || strings.Contains(prefix, "<html") ||
|
|
58
|
+
strings.Contains(prefix, "<head") || strings.Contains(prefix, "<body") {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
return true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func readNativeMarkdown(rawURL string) (*MarkdownResult, error) {
|
|
65
|
+
for _, candidate := range markdownCandidateURLs(rawURL) {
|
|
66
|
+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
67
|
+
body, err := fetchPublicText(ctx, candidate, maxNativeMarkdownBytes)
|
|
68
|
+
cancel()
|
|
69
|
+
if err != nil || !usableNativeMarkdown(body) {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
title := firstHeadingOrFallback(body, path.Base(rawURL))
|
|
73
|
+
return &MarkdownResult{URL: rawURL, SourceURL: candidate, Title: title, Markdown: body}, nil
|
|
74
|
+
}
|
|
75
|
+
return nil, nil
|
|
76
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestCanonicalStripeGuidePrefersMarkdownWithZeroHeadLength(t *testing.T) {
|
|
10
|
+
previous := http.DefaultClient
|
|
11
|
+
t.Cleanup(func() { http.DefaultClient = previous })
|
|
12
|
+
calls := []string{}
|
|
13
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
14
|
+
calls = append(calls, req.Method+" "+req.URL.Path)
|
|
15
|
+
if req.Method == http.MethodHead {
|
|
16
|
+
t.Fatal("native Markdown detection must not require HEAD metadata")
|
|
17
|
+
}
|
|
18
|
+
if req.Method == http.MethodGet && req.URL.Path == "/payments/checkout.md" {
|
|
19
|
+
r := testHTTPResponse(req, http.StatusOK, "# Build a payments page\n\nStripe-hosted checkout is supported.", map[string]string{"Content-Type": "text/markdown"})
|
|
20
|
+
r.ContentLength = 0 // Publishers can return zero or unknown length metadata.
|
|
21
|
+
return r, nil
|
|
22
|
+
}
|
|
23
|
+
t.Fatalf("unexpected extra request: %s %s", req.Method, req.URL)
|
|
24
|
+
return nil, nil
|
|
25
|
+
})}
|
|
26
|
+
out, err := ReadLink("https://docs.stripe.com/payments/checkout")
|
|
27
|
+
if err != nil || !strings.Contains(out, "**Markdown source:** https://docs.stripe.com/payments/checkout.md") {
|
|
28
|
+
t.Fatalf("native source not selected: %v %s", err, out)
|
|
29
|
+
}
|
|
30
|
+
if len(calls) != 1 {
|
|
31
|
+
t.Fatalf("unexpected calls: %+v", calls)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func TestCanonicalCloudflareDirectoryReadsIndexMarkdown(t *testing.T) {
|
|
36
|
+
previous := http.DefaultClient
|
|
37
|
+
t.Cleanup(func() { http.DefaultClient = previous })
|
|
38
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
39
|
+
if req.Method != http.MethodGet || req.URL.Path != "/durable-objects/index.md" {
|
|
40
|
+
t.Fatalf("unexpected URL: %s %s", req.Method, req.URL)
|
|
41
|
+
}
|
|
42
|
+
return testHTTPResponse(req, http.StatusOK,
|
|
43
|
+
"# Cloudflare Durable Objects\n\n[Get started](https://developers.cloudflare.com/durable-objects/get-started/)",
|
|
44
|
+
map[string]string{"Content-Type": "text/markdown"}), nil
|
|
45
|
+
})}
|
|
46
|
+
out, err := ReadLink("https://developers.cloudflare.com/durable-objects/")
|
|
47
|
+
if err != nil || !strings.Contains(out, "**Markdown source:** https://developers.cloudflare.com/durable-objects/index.md") {
|
|
48
|
+
t.Fatalf("directory-index native Markdown failed: %v %s", err, out)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func TestCAPTCHADisguisedAsMarkdownIsRejected(t *testing.T) {
|
|
53
|
+
previous := http.DefaultClient
|
|
54
|
+
t.Cleanup(func() { http.DefaultClient = previous })
|
|
55
|
+
var scrapeCalled bool
|
|
56
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
57
|
+
if req.URL.Path == "/guide.md" {
|
|
58
|
+
return testHTTPResponse(req, http.StatusOK, "# hCaptcha\nPlease verify you are human. "+
|
|
59
|
+
strings.Repeat("English French Deutsch Español Chinese 日本語 ", 200), map[string]string{"Content-Type": "text/markdown"}), nil
|
|
60
|
+
}
|
|
61
|
+
if req.URL.Path == "/guide/index.md" {
|
|
62
|
+
return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
|
|
63
|
+
}
|
|
64
|
+
if req.Method == http.MethodPost && req.URL.Path == "/v2/scrape" {
|
|
65
|
+
scrapeCalled = true
|
|
66
|
+
return testHTTPResponse(req, http.StatusOK,
|
|
67
|
+
`{"success":true,"data":{"markdown":"# Legitimate guide\n\nFull instructions for a public API."}}`,
|
|
68
|
+
nil), nil
|
|
69
|
+
}
|
|
70
|
+
t.Fatalf("unexpected request: %s %s", req.Method, req.URL)
|
|
71
|
+
return nil, nil
|
|
72
|
+
})}
|
|
73
|
+
t.Setenv("FIRECRAWL_API_KEY", "test")
|
|
74
|
+
out, err := ReadLink("https://docs.example.com/guide")
|
|
75
|
+
if err != nil || !scrapeCalled || strings.Contains(out, "Deutsch Español") || !strings.Contains(out, "Full instructions") {
|
|
76
|
+
t.Fatalf("CAPTCHA disguised as Markdown leaked or fallback failed: %v %s", err, out)
|
|
77
|
+
}
|
|
78
|
+
}
|
package/internal/app/research.go
CHANGED
|
@@ -20,10 +20,19 @@ const (
|
|
|
20
20
|
focusedPrimaryBudget = 22000
|
|
21
21
|
focusedRelatedBudget = 6500
|
|
22
22
|
searchSourceBudget = 4000
|
|
23
|
-
maxRelatedSources =
|
|
23
|
+
maxRelatedSources = 5
|
|
24
|
+
maxFollowupSources = 2
|
|
24
25
|
maxSearchSources = 3
|
|
25
26
|
)
|
|
26
27
|
|
|
28
|
+
func relatedExcerptBudget(rawURL string) int {
|
|
29
|
+
lower := strings.ToLower(rawURL)
|
|
30
|
+
if strings.Contains(lower, "/get-started/") || strings.Contains(lower, "/quickstart") {
|
|
31
|
+
return 18000 // Preserve end-to-end runnable setup guides.
|
|
32
|
+
}
|
|
33
|
+
return focusedRelatedBudget
|
|
34
|
+
}
|
|
35
|
+
|
|
27
36
|
var (
|
|
28
37
|
mdLinkPattern = regexp.MustCompile(`\[([^\]\n]{2,120})\]\(([^\s)]+)(?:\s+"[^"]*")?\)`)
|
|
29
38
|
wordPattern = regexp.MustCompile(`[a-zA-Z][a-zA-Z0-9_-]{2,}`)
|
|
@@ -79,25 +88,17 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
79
88
|
return "", err
|
|
80
89
|
}
|
|
81
90
|
|
|
82
|
-
links
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
if len(links) == maxRelatedSources {
|
|
88
|
-
break
|
|
89
|
-
}
|
|
90
|
-
duplicate := candidate.url == rawURL
|
|
91
|
-
for _, chosen := range links {
|
|
92
|
-
duplicate = duplicate || chosen.url == candidate.url
|
|
93
|
-
}
|
|
94
|
-
if !duplicate {
|
|
95
|
-
links = append(links, candidate)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
91
|
+
// Combine on-page links and authoritative Markdown index links instead of
|
|
92
|
+
// filling the entire budget from whichever links happen to appear first.
|
|
93
|
+
// In particular, an implementation question should prefer get-started,
|
|
94
|
+
// SQLite, webhook, and API references over incidental alarm/concept pages.
|
|
95
|
+
links := researchCandidates(rawURL, primary, question, maxRelatedSources)
|
|
99
96
|
structuredDone := make(chan string, 1)
|
|
100
|
-
|
|
97
|
+
if strings.Contains(primary, "**Markdown source:**") {
|
|
98
|
+
structuredDone <- ""
|
|
99
|
+
} else {
|
|
100
|
+
go func() { structuredDone <- embeddedStructuredContext(rawURL, question) }()
|
|
101
|
+
}
|
|
101
102
|
more := readSources(links, reader)
|
|
102
103
|
parts := []string{
|
|
103
104
|
"# Source-grounded context",
|
|
@@ -107,27 +108,91 @@ func readLinkFocused(rawURL, question string, reader func(string) (string, error
|
|
|
107
108
|
"## Primary source",
|
|
108
109
|
"**Source:** " + rawURL,
|
|
109
110
|
"",
|
|
110
|
-
focusExcerpt(primary, question, focusedPrimaryBudget),
|
|
111
111
|
}
|
|
112
|
+
primaryExcerpt := focusExcerpt(primary, question, focusedPrimaryBudget)
|
|
113
|
+
parts = append(parts, primaryExcerpt)
|
|
112
114
|
successful := 1
|
|
115
|
+
relatedSuccessful := 0
|
|
116
|
+
allRetrieved := primary
|
|
117
|
+
outputEvidence := primaryExcerpt
|
|
118
|
+
seenURLs := map[string]bool{rawURL: true}
|
|
113
119
|
for _, source := range more {
|
|
114
120
|
if source.err != nil {
|
|
115
121
|
continue
|
|
116
122
|
}
|
|
117
123
|
successful++
|
|
118
|
-
|
|
124
|
+
relatedSuccessful++
|
|
125
|
+
seenURLs[source.url] = true
|
|
126
|
+
allRetrieved += "\n" + source.content
|
|
127
|
+
excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
|
|
128
|
+
outputEvidence += "\n" + excerpt
|
|
129
|
+
parts = append(parts, "", "## Related source", "**Source:** "+source.url, "", excerpt)
|
|
130
|
+
}
|
|
131
|
+
// One additional bounded expansion if required code/API evidence remains
|
|
132
|
+
// missing. This follows references from the implementation pages we actually
|
|
133
|
+
// found, not arbitrary link hops across the entire site.
|
|
134
|
+
missing := missingResearchEvidence(question, allRetrieved)
|
|
135
|
+
if len(missing) > 0 {
|
|
136
|
+
var next []sourceCandidate
|
|
137
|
+
for _, source := range more {
|
|
138
|
+
if source.err != nil {
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
if strings.Contains(strings.ToLower(source.content), "article has multiple variants") {
|
|
142
|
+
for _, candidate := range relatedSourceLinks(source.content, source.url, question, 8) {
|
|
143
|
+
if !seenURLs[candidate.url] {
|
|
144
|
+
seenURLs[candidate.url] = true
|
|
145
|
+
candidate.score += 50 // A published page variant is the actual documentation, not an index.
|
|
146
|
+
if strings.Contains(strings.ToLower(question), "hosted") &&
|
|
147
|
+
strings.Contains(strings.ToLower(candidate.url+" "+candidate.label), "hosted") {
|
|
148
|
+
candidate.score += 20
|
|
149
|
+
}
|
|
150
|
+
next = append(next, candidate)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
for _, candidate := range relatedSourceLinks(source.content, source.url, strings.Join(missing, " "), 8) {
|
|
155
|
+
if !seenURLs[candidate.url] {
|
|
156
|
+
seenURLs[candidate.url] = true
|
|
157
|
+
candidate.score += implementationLinkBonus(candidate, question)
|
|
158
|
+
next = append(next, candidate)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
sort.SliceStable(next, func(i, j int) bool { return next[i].score > next[j].score })
|
|
163
|
+
if len(next) > maxFollowupSources {
|
|
164
|
+
next = next[:maxFollowupSources]
|
|
165
|
+
}
|
|
166
|
+
followups := readSources(next, reader)
|
|
167
|
+
more = append(more, followups...)
|
|
168
|
+
for _, source := range followups {
|
|
169
|
+
if source.err != nil {
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
successful++
|
|
173
|
+
relatedSuccessful++
|
|
174
|
+
allRetrieved += "\n" + source.content
|
|
175
|
+
excerpt := focusExcerpt(source.content, question, relatedExcerptBudget(source.url))
|
|
176
|
+
outputEvidence += "\n" + excerpt
|
|
177
|
+
parts = append(parts, "", "## Supporting implementation reference", "**Source:** "+source.url, "", excerpt)
|
|
178
|
+
}
|
|
119
179
|
}
|
|
120
180
|
if openAPIDone != nil {
|
|
121
181
|
if contract := <-openAPIDone; contract.Content != "" {
|
|
122
182
|
successful++
|
|
183
|
+
outputEvidence += "\n" + contract.Content
|
|
123
184
|
parts = append(parts, "", "## REST API contract", "**Source:** "+contract.URL, "", contract.Content)
|
|
124
185
|
}
|
|
125
186
|
}
|
|
126
187
|
if structured := <-structuredDone; structured != "" {
|
|
188
|
+
outputEvidence += "\n" + structured
|
|
127
189
|
parts = append(parts, "", structured)
|
|
128
190
|
}
|
|
191
|
+
if unmet := missingResearchEvidence(question, outputEvidence); len(unmet) > 0 {
|
|
192
|
+
parts = append(parts, "", "**Requested details not fully evidenced in returned excerpts:** "+strings.Join(unmet, ", ")+". Follow the cited source pages rather than assuming these details.")
|
|
193
|
+
}
|
|
129
194
|
parts = append(parts, "", fmt.Sprintf("**Coverage:** %d source document(s) inspected. Extracts are source-grounded, not an independently verified synthesis. REST commands assembled from an OpenAPI operation and its published example are labeled as such.", successful))
|
|
130
|
-
if failed := len(more) -
|
|
195
|
+
if failed := len(more) - relatedSuccessful; failed > 0 {
|
|
131
196
|
parts = append(parts, fmt.Sprintf("%d related page(s) could not be retrieved; the primary source is preserved.", failed))
|
|
132
197
|
}
|
|
133
198
|
return strings.Join(parts, "\n"), nil
|
|
@@ -184,24 +249,48 @@ func enrichSearchWithReader(query string, ranked []SearchDoc, reader func(string
|
|
|
184
249
|
}
|
|
185
250
|
|
|
186
251
|
func linksFromDocsIndex(rawURL, question string, limit int) []sourceCandidate {
|
|
252
|
+
return linksFromDocsIndexWithPrimary(rawURL, "", question, limit)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
func linksFromDocsIndexWithPrimary(rawURL, primary, question string, limit int) []sourceCandidate {
|
|
187
256
|
u, err := url.Parse(rawURL)
|
|
188
257
|
if err != nil || u.Hostname() == "github.com" || directSourceLanguage(rawURL) != "" {
|
|
189
258
|
return nil
|
|
190
259
|
}
|
|
191
|
-
if !strings.HasPrefix(u.Hostname(), "docs.") && !strings.Contains(u.Path, "/docs/") &&
|
|
260
|
+
if !strings.HasPrefix(u.Hostname(), "docs.") && !strings.Contains(u.Path, "/docs/") &&
|
|
261
|
+
!strings.Contains(u.Path, "/reference/") && !strings.Contains(primary, "llms.txt") {
|
|
192
262
|
return nil
|
|
193
263
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
264
|
+
indexURLs := documentIndexURLs(u, primary)
|
|
265
|
+
var candidates []sourceCandidate
|
|
266
|
+
seen := map[string]bool{}
|
|
267
|
+
for _, indexURL := range indexURLs {
|
|
268
|
+
if rawURL == indexURL {
|
|
269
|
+
continue
|
|
270
|
+
}
|
|
271
|
+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
272
|
+
index, err := fetchPublicText(ctx, indexURL, 300000)
|
|
273
|
+
cancel()
|
|
274
|
+
if err != nil || !strings.Contains(index, "](") {
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
for _, item := range relatedSourceLinks(index, indexURL, question, 300) {
|
|
278
|
+
item.score += implementationLinkBonus(item, question)
|
|
279
|
+
item.score += sourceScopeBonus(item, rawURL, question)
|
|
280
|
+
if !seen[item.url] {
|
|
281
|
+
seen[item.url] = true
|
|
282
|
+
candidates = append(candidates, item)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if len(candidates) >= limit {
|
|
286
|
+
break
|
|
287
|
+
}
|
|
197
288
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if err != nil || !strings.Contains(index, "](") {
|
|
202
|
-
return nil
|
|
289
|
+
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
|
|
290
|
+
if len(candidates) > limit {
|
|
291
|
+
candidates = candidates[:limit]
|
|
203
292
|
}
|
|
204
|
-
return
|
|
293
|
+
return candidates
|
|
205
294
|
}
|
|
206
295
|
|
|
207
296
|
func readSources(candidates []sourceCandidate, reader func(string) (string, error)) []sourceRead {
|
|
@@ -279,7 +368,7 @@ func relatedSourceLinks(markdown, baseURL, question string, limit int) []sourceC
|
|
|
279
368
|
terms := queryTerms(question)
|
|
280
369
|
seen := map[string]bool{canonicalSourceURL(base): true}
|
|
281
370
|
var links []sourceCandidate
|
|
282
|
-
for i, match := range mdLinkPattern.FindAllStringSubmatch(markdown,
|
|
371
|
+
for i, match := range mdLinkPattern.FindAllStringSubmatch(markdown, 1000) {
|
|
283
372
|
destination := strings.TrimSpace(match[2])
|
|
284
373
|
if strings.Contains(destination, "\\") { // malformed Markdown-escaped URLs
|
|
285
374
|
continue
|