webctx 0.2.3 → 0.2.4
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/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 +1 -1
|
@@ -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
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/url"
|
|
5
|
+
"regexp"
|
|
6
|
+
"sort"
|
|
7
|
+
"strings"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
var documentationIndexPattern = regexp.MustCompile("https?://[^\\s<>\"')\\]]+/llms\\.txt")
|
|
11
|
+
|
|
12
|
+
func documentIndexURLs(base *url.URL, primary string) []string {
|
|
13
|
+
var urls []string
|
|
14
|
+
seen := map[string]bool{}
|
|
15
|
+
for _, raw := range documentationIndexPattern.FindAllString(primary, 5) {
|
|
16
|
+
target, err := url.Parse(raw)
|
|
17
|
+
if err != nil || validateSourceURL(raw) != nil ||
|
|
18
|
+
!strings.EqualFold(base.Hostname(), target.Hostname()) || target.Port() != base.Port() {
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
if !seen[raw] {
|
|
22
|
+
urls = append(urls, raw)
|
|
23
|
+
seen[raw] = true
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
fallback := base.Scheme + "://" + base.Host + "/llms.txt"
|
|
27
|
+
if !seen[fallback] {
|
|
28
|
+
urls = append(urls, fallback)
|
|
29
|
+
}
|
|
30
|
+
return urls
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func implementationQuestion(question string) bool {
|
|
34
|
+
text := strings.ToLower(question)
|
|
35
|
+
for _, signal := range []string{
|
|
36
|
+
"implement", "integrat", "runnable", "configuration",
|
|
37
|
+
"code example", "complete", "how to", "set up", "using a", "creating",
|
|
38
|
+
} {
|
|
39
|
+
if strings.Contains(text, signal) {
|
|
40
|
+
return true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func implementationLinkBonus(candidate sourceCandidate, question string) int {
|
|
47
|
+
lower := strings.ToLower(candidate.url + " " + candidate.label)
|
|
48
|
+
q := strings.ToLower(question)
|
|
49
|
+
score := 0
|
|
50
|
+
if implementationQuestion(question) {
|
|
51
|
+
switch {
|
|
52
|
+
case strings.Contains(lower, "/get-started/"), strings.Contains(lower, "/quickstart"),
|
|
53
|
+
strings.Contains(lower, "getting started"):
|
|
54
|
+
score += 64 // Publisher's end-to-end guide, not isolated snippets.
|
|
55
|
+
case strings.Contains(lower, "tutorial"), strings.Contains(lower, "integration"),
|
|
56
|
+
strings.Contains(lower, "how-to"):
|
|
57
|
+
score += 16
|
|
58
|
+
case strings.Contains(lower, "example"):
|
|
59
|
+
score += 6
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if strings.Contains(q, "sql") || strings.Contains(q, "sqlite") {
|
|
63
|
+
if strings.Contains(lower, "sqlite") || strings.Contains(lower, "storage") {
|
|
64
|
+
score += 26
|
|
65
|
+
} else if strings.Contains(lower, "/examples/") {
|
|
66
|
+
score -= 16
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if strings.Contains(q, "webhook") || strings.Contains(q, "fulfill") || strings.Contains(q, "fulfil") {
|
|
70
|
+
if strings.Contains(lower, "webhook") || strings.Contains(lower, "fulfill") || strings.Contains(lower, "fulfil") {
|
|
71
|
+
score += 30
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if strings.Contains(q, "checkout") && (strings.Contains(lower, "checkout") || strings.Contains(lower, "session")) {
|
|
75
|
+
score += 6
|
|
76
|
+
}
|
|
77
|
+
if strings.Contains(q, "signature") && strings.Contains(lower, "signature") {
|
|
78
|
+
score += 24
|
|
79
|
+
}
|
|
80
|
+
if strings.Contains(q, "curl") || strings.Contains(q, "rest") || strings.Contains(q, "endpoint") {
|
|
81
|
+
if strings.Contains(lower, "/api/") || strings.Contains(lower, "api-reference") {
|
|
82
|
+
score += 10
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if strings.Contains(q, "session") && strings.Contains(q, "creat") &&
|
|
86
|
+
(strings.Contains(lower, "/sessions/create") || strings.Contains(lower, "create a checkout session")) {
|
|
87
|
+
score += 38
|
|
88
|
+
}
|
|
89
|
+
if strings.Contains(q, "checkout") && strings.Contains(lower, "/checkout/quickstart") {
|
|
90
|
+
score += 20
|
|
91
|
+
}
|
|
92
|
+
if strings.Contains(q, "fulfill") || strings.Contains(q, "fulfil") {
|
|
93
|
+
if strings.Contains(lower, "/checkout/fulfillment") {
|
|
94
|
+
score += 24
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if strings.Contains(lower, "/alarms/") && !strings.Contains(q, "alarm") {
|
|
98
|
+
score -= 24
|
|
99
|
+
}
|
|
100
|
+
if strings.Contains(lower, "/concepts/") && implementationQuestion(question) {
|
|
101
|
+
score -= 12
|
|
102
|
+
}
|
|
103
|
+
if strings.Contains(lower, "legacy") && (strings.Contains(q, "new ") || strings.Contains(q, "current")) && !strings.Contains(q, "legacy") {
|
|
104
|
+
score -= 25
|
|
105
|
+
}
|
|
106
|
+
return score
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Search indices on a large documentation host often contain links for many
|
|
110
|
+
// unrelated products. Prefer links in the original section or with a product
|
|
111
|
+
// named in the question. This prevents "webhooks for Climate Orders" from
|
|
112
|
+
// outranking generic Checkout webhooks solely because both contain "webhook".
|
|
113
|
+
func sourceScopeBonus(candidate sourceCandidate, rawURL, question string) int {
|
|
114
|
+
base, err1 := url.Parse(rawURL)
|
|
115
|
+
target, err2 := url.Parse(candidate.url)
|
|
116
|
+
if err1 != nil || err2 != nil {
|
|
117
|
+
return 0
|
|
118
|
+
}
|
|
119
|
+
q := strings.ToLower(question)
|
|
120
|
+
sourceSegments := strings.Split(strings.Trim(base.Path, "/"), "/")
|
|
121
|
+
targetSegments := strings.Split(strings.Trim(target.Path, "/"), "/")
|
|
122
|
+
if len(targetSegments) == 0 {
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
score := 0
|
|
126
|
+
if len(sourceSegments) > 0 && targetSegments[0] == sourceSegments[0] {
|
|
127
|
+
score += 8
|
|
128
|
+
}
|
|
129
|
+
if len(targetSegments) > 0 && targetSegments[0] != "" &&
|
|
130
|
+
!strings.Contains(q, strings.TrimSuffix(targetSegments[0], "s")) &&
|
|
131
|
+
!strings.Contains(q, targetSegments[0]) &&
|
|
132
|
+
targetSegments[0] != "api" && targetSegments[0] != "webhooks" &&
|
|
133
|
+
targetSegments[0] != "events" &&
|
|
134
|
+
(len(sourceSegments) == 0 || targetSegments[0] != sourceSegments[0]) {
|
|
135
|
+
score -= 24
|
|
136
|
+
}
|
|
137
|
+
// A general Worker overview is less relevant than Durable Objects'
|
|
138
|
+
// focused setup instructions even if both mention "Worker".
|
|
139
|
+
if strings.Contains(q, "durable object") && strings.Contains(target.Path, "/workers/") &&
|
|
140
|
+
!strings.Contains(target.Path, "/durable-objects/") {
|
|
141
|
+
score -= 14
|
|
142
|
+
}
|
|
143
|
+
return score
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func normalizedDocumentationURL(raw string) string {
|
|
147
|
+
u, err := url.Parse(raw)
|
|
148
|
+
if err != nil {
|
|
149
|
+
return raw
|
|
150
|
+
}
|
|
151
|
+
p := strings.TrimSuffix(u.Path, "/index.md")
|
|
152
|
+
p = strings.TrimSuffix(p, ".md")
|
|
153
|
+
p = strings.TrimSuffix(p, "/")
|
|
154
|
+
u.Path = p
|
|
155
|
+
u.Fragment = ""
|
|
156
|
+
u.RawFragment = ""
|
|
157
|
+
u.RawPath = ""
|
|
158
|
+
return u.String()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func researchCandidates(rawURL, primary, question string, limit int) []sourceCandidate {
|
|
162
|
+
baseCandidates := relatedSourceLinks(primary, rawURL, question, limit*4)
|
|
163
|
+
indexCandidates := linksFromDocsIndexWithPrimary(rawURL, primary, question, limit*5)
|
|
164
|
+
seen := map[string]bool{normalizedDocumentationURL(rawURL): true}
|
|
165
|
+
var candidates []sourceCandidate
|
|
166
|
+
for _, c := range append(baseCandidates, indexCandidates...) {
|
|
167
|
+
key := normalizedDocumentationURL(c.url)
|
|
168
|
+
if seen[key] {
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
seen[key] = true
|
|
172
|
+
c.score += implementationLinkBonus(c, question)
|
|
173
|
+
c.score += sourceScopeBonus(c, rawURL, question)
|
|
174
|
+
candidates = append(candidates, c)
|
|
175
|
+
}
|
|
176
|
+
sort.SliceStable(candidates, func(i, j int) bool {
|
|
177
|
+
if candidates[i].score == candidates[j].score {
|
|
178
|
+
return candidates[i].order < candidates[j].order
|
|
179
|
+
}
|
|
180
|
+
return candidates[i].score > candidates[j].score
|
|
181
|
+
})
|
|
182
|
+
if len(candidates) > limit {
|
|
183
|
+
return candidates[:limit]
|
|
184
|
+
}
|
|
185
|
+
return candidates
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
type evidenceNeed struct {
|
|
189
|
+
name string
|
|
190
|
+
alternatives []string
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check for explicit source evidence; do not claim that a plausible guess
|
|
194
|
+
// supplies missing request fields or code.
|
|
195
|
+
func evidenceNeeds(question string) []evidenceNeed {
|
|
196
|
+
q := strings.ToLower(question)
|
|
197
|
+
var needs []evidenceNeed
|
|
198
|
+
for _, literal := range []string{"line_items", "success_url", "cancel_url", "sql.exec", "wrangler", "sqlite"} {
|
|
199
|
+
if strings.Contains(q, literal) {
|
|
200
|
+
needs = append(needs, evidenceNeed{literal, []string{literal}})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if strings.Contains(q, "webhook") {
|
|
204
|
+
needs = append(needs, evidenceNeed{"webhook events", []string{"webhook", "checkout.session.completed"}})
|
|
205
|
+
if strings.Contains(q, "signature") || strings.Contains(q, "secure") {
|
|
206
|
+
needs = append(needs, evidenceNeed{"webhook signature verification", []string{"stripe-signature", "constructevent", "signature verification", "signatureverification"}})
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if strings.Contains(q, "sql table") || strings.Contains(q, "creating a sql") {
|
|
210
|
+
needs = append(needs, evidenceNeed{"SQL table creation", []string{"create table"}})
|
|
211
|
+
}
|
|
212
|
+
if strings.Contains(q, "accesses the binding") || strings.Contains(q, "access the binding") {
|
|
213
|
+
needs = append(needs, evidenceNeed{"Worker binding access", []string{"getbyname", "env.my_durable_object", "env.durable_object"}})
|
|
214
|
+
}
|
|
215
|
+
if strings.Contains(q, "checkout") && (strings.Contains(q, "fulfill") || strings.Contains(q, "fulfil")) {
|
|
216
|
+
needs = append(needs, evidenceNeed{"Checkout fulfillment event", []string{"checkout.session.completed"}})
|
|
217
|
+
}
|
|
218
|
+
return needs
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
func missingResearchEvidence(question, text string) []string {
|
|
222
|
+
lower := strings.ToLower(text)
|
|
223
|
+
var missing []string
|
|
224
|
+
for _, need := range evidenceNeeds(question) {
|
|
225
|
+
found := false
|
|
226
|
+
for _, candidate := range need.alternatives {
|
|
227
|
+
if strings.Contains(lower, candidate) {
|
|
228
|
+
found = true
|
|
229
|
+
break
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if !found {
|
|
233
|
+
missing = append(missing, need.name)
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return missing
|
|
237
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"net/http"
|
|
5
|
+
"strings"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestStripeFocusedLinksPreferCheckoutAndWebhookDocs(t *testing.T) {
|
|
10
|
+
old := http.DefaultClient
|
|
11
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
12
|
+
index := "# Documentation\n\n" +
|
|
13
|
+
"- [Climate order webhooks](https://docs.stripe.com/climate/orders/webhooks.md)\n" +
|
|
14
|
+
"- [Create a Checkout Session](https://docs.stripe.com/api/checkout/sessions/create.md)\n" +
|
|
15
|
+
"- [Build a Stripe-hosted checkout page](https://docs.stripe.com/checkout/quickstart.md)\n" +
|
|
16
|
+
"- [Set up and deploy a webhook](https://docs.stripe.com/webhooks/quickstart.md)\n" +
|
|
17
|
+
"- [Fulfill orders](https://docs.stripe.com/checkout/fulfillment.md)\n" +
|
|
18
|
+
"- [Verify webhook signatures](https://docs.stripe.com/webhooks.md#verify-signature)\n"
|
|
19
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
20
|
+
if req.URL.Path == "/llms.txt" {
|
|
21
|
+
return testHTTPResponse(req, http.StatusOK, index, nil), nil
|
|
22
|
+
}
|
|
23
|
+
return testHTTPResponse(req, http.StatusNotFound, "", nil), nil
|
|
24
|
+
})}
|
|
25
|
+
question := "Implement Stripe-hosted Checkout: create a Checkout Session REST request and securely fulfill orders using webhooks, including signature verification"
|
|
26
|
+
primary := "# Stripe Checkout\n\n[Start building your checkout integration](https://docs.stripe.com/checkout/quickstart.md)"
|
|
27
|
+
got := researchCandidates("https://docs.stripe.com/payments/checkout", primary, question, 5)
|
|
28
|
+
var links []string
|
|
29
|
+
for _, c := range got {
|
|
30
|
+
links = append(links, c.url)
|
|
31
|
+
}
|
|
32
|
+
output := strings.Join(links, "\n")
|
|
33
|
+
for _, want := range []string{
|
|
34
|
+
"/checkout/quickstart.md",
|
|
35
|
+
"/api/checkout/sessions/create.md",
|
|
36
|
+
"/webhooks/quickstart.md",
|
|
37
|
+
"/checkout/fulfillment.md",
|
|
38
|
+
} {
|
|
39
|
+
if !strings.Contains(output, want) {
|
|
40
|
+
t.Errorf("expected %q among selected source links: %s", want, output)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if strings.Contains(output, "/climate/") {
|
|
44
|
+
t.Errorf("irrelevant Climate-specific webhook outranked Checkout sources: %s", output)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func TestCloudflareNewSQLiteQuestionPrefersGettingStarted(t *testing.T) {
|
|
49
|
+
old := http.DefaultClient
|
|
50
|
+
t.Cleanup(func() { http.DefaultClient = old })
|
|
51
|
+
http.DefaultClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
52
|
+
return testHTTPResponse(req, http.StatusForbidden, "", nil), nil
|
|
53
|
+
})}
|
|
54
|
+
primary := "# Cloudflare Durable Objects\n" +
|
|
55
|
+
"[Durable Object alarms](https://developers.cloudflare.com/durable-objects/api/alarms/)\n" +
|
|
56
|
+
"[SQLite storage](https://developers.cloudflare.com/durable-objects/api/sqlite-storage-api/)\n" +
|
|
57
|
+
"[Get started](https://developers.cloudflare.com/durable-objects/get-started/)\n" +
|
|
58
|
+
"> Documentation Index https://developers.cloudflare.com/durable-objects/llms.txt\n"
|
|
59
|
+
question := "For a new Cloudflare Worker using a SQLite-backed Durable Object, show complete wrangler configuration, Worker binding, and SQL table code."
|
|
60
|
+
got := researchCandidates("https://developers.cloudflare.com/durable-objects/", primary, question, 2)
|
|
61
|
+
if len(got) != 2 {
|
|
62
|
+
t.Fatalf("unexpected number of selected references: %+v", got)
|
|
63
|
+
}
|
|
64
|
+
var links []string
|
|
65
|
+
for _, c := range got {
|
|
66
|
+
links = append(links, c.url)
|
|
67
|
+
}
|
|
68
|
+
output := strings.Join(links, "\n")
|
|
69
|
+
if !strings.Contains(output, "/get-started/") || strings.Contains(output, "/alarms/") {
|
|
70
|
+
t.Fatalf("unrelated alarm material displaced getting-started guide: %s", output)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -5,6 +5,7 @@ import (
|
|
|
5
5
|
"fmt"
|
|
6
6
|
"net/http"
|
|
7
7
|
"strings"
|
|
8
|
+
"sync"
|
|
8
9
|
"testing"
|
|
9
10
|
)
|
|
10
11
|
|
|
@@ -35,8 +36,11 @@ func TestFocusedReadExpandsRelevantSameHostLinks(t *testing.T) {
|
|
|
35
36
|
"https://docs.example.com/pagination": "# Pagination\nPass next_cursor to read the next page.",
|
|
36
37
|
}
|
|
37
38
|
requested := make(map[string]int)
|
|
39
|
+
var requestedMu sync.Mutex
|
|
38
40
|
reader := func(url string) (string, error) {
|
|
41
|
+
requestedMu.Lock()
|
|
39
42
|
requested[url]++
|
|
43
|
+
requestedMu.Unlock()
|
|
40
44
|
value, ok := urls[url]
|
|
41
45
|
if !ok {
|
|
42
46
|
return "", errors.New("missing URL")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"log"
|
|
6
|
+
"net/url"
|
|
7
|
+
"os"
|
|
8
|
+
"strings"
|
|
9
|
+
"time"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
// CAPTCHA pages can contain thousands of bytes of translated prompts. Page
|
|
13
|
+
// length is not evidence of useful content, so challenge detection must not
|
|
14
|
+
// be gated on the size of the extracted Markdown.
|
|
15
|
+
func challengeReason(markdown string) string {
|
|
16
|
+
text := strings.ToLower(strings.TrimSpace(markdown))
|
|
17
|
+
if text == "" {
|
|
18
|
+
return "empty source"
|
|
19
|
+
}
|
|
20
|
+
prefix := text[:min(len(text), 4000)]
|
|
21
|
+
for _, marker := range []string{
|
|
22
|
+
"just a moment...",
|
|
23
|
+
"checking your browser",
|
|
24
|
+
"access denied | cloudflare",
|
|
25
|
+
"attention required! | cloudflare",
|
|
26
|
+
"enable javascript and cookies",
|
|
27
|
+
"no content extracted",
|
|
28
|
+
"please complete the security check",
|
|
29
|
+
"verify you are human",
|
|
30
|
+
"verifying you are human",
|
|
31
|
+
"verify that you are human",
|
|
32
|
+
"please verify you are a human",
|
|
33
|
+
"we need to verify your browser",
|
|
34
|
+
"challenge-platform",
|
|
35
|
+
} {
|
|
36
|
+
if strings.Contains(prefix, marker) {
|
|
37
|
+
return "browser verification challenge"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if strings.Contains(prefix, "hcaptcha") || strings.Contains(prefix, "h-captcha") {
|
|
41
|
+
for _, secondary := range []string{
|
|
42
|
+
"select all", "please verify", "i am human",
|
|
43
|
+
"privacy - terms", "please complete", "security check",
|
|
44
|
+
"captcha challenge", "verify your", "please solve",
|
|
45
|
+
} {
|
|
46
|
+
if strings.Contains(text, secondary) {
|
|
47
|
+
return "hCaptcha challenge"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (strings.Count(text, "captcha") > 2 || strings.Count(text, "verify you are human") > 0) &&
|
|
52
|
+
(strings.Contains(text, "try again") || strings.Contains(text, "challenge") || strings.Contains(text, "i am human")) {
|
|
53
|
+
return "CAPTCHA challenge"
|
|
54
|
+
}
|
|
55
|
+
return ""
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
func usableSourceContent(markdown string) bool {
|
|
59
|
+
text := strings.TrimSpace(markdown)
|
|
60
|
+
if len(text) < 10 || challengeReason(text) != "" {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
// Guard against a 200 status containing an HTML block/error page.
|
|
64
|
+
prefix := strings.ToLower(text[:min(len(text), 350)])
|
|
65
|
+
return !strings.Contains(prefix, "<!doctype html") &&
|
|
66
|
+
!strings.Contains(prefix, "<html")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// WEBCTX_DEBUG=1 enables stage traces. Only host + URL path are included;
|
|
70
|
+
// query parameters and credentials are intentionally not logged.
|
|
71
|
+
func traceRetrieval(rawURL, stage, outcome string, start time.Time, err error) {
|
|
72
|
+
if os.Getenv("WEBCTX_DEBUG") != "1" {
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
source := "invalid-url"
|
|
76
|
+
if u, parseErr := url.Parse(rawURL); parseErr == nil && u.Hostname() != "" {
|
|
77
|
+
source = u.Hostname() + u.EscapedPath()
|
|
78
|
+
}
|
|
79
|
+
detail := ""
|
|
80
|
+
if err != nil {
|
|
81
|
+
detail = fmt.Sprintf(" error=%q", sanitizedRetrievalError(err))
|
|
82
|
+
}
|
|
83
|
+
log.Printf("webctx retrieval source=%q stage=%q outcome=%q duration_ms=%d%s",
|
|
84
|
+
source, stage, outcome, time.Since(start).Milliseconds(), detail)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
func sanitizedRetrievalError(err error) string {
|
|
88
|
+
if err == nil {
|
|
89
|
+
return ""
|
|
90
|
+
}
|
|
91
|
+
message := strings.ToLower(err.Error())
|
|
92
|
+
switch {
|
|
93
|
+
case strings.Contains(message, "403"), strings.Contains(message, "forbidden"):
|
|
94
|
+
return "forbidden"
|
|
95
|
+
case strings.Contains(message, "429"), strings.Contains(message, "rate limit"):
|
|
96
|
+
return "rate-limited"
|
|
97
|
+
case strings.Contains(message, "timeout"), strings.Contains(message, "deadline"):
|
|
98
|
+
return "timeout"
|
|
99
|
+
case strings.Contains(message, "captcha"), strings.Contains(message, "challenge"):
|
|
100
|
+
return "challenge"
|
|
101
|
+
case strings.Contains(message, "404"), strings.Contains(message, "not found"):
|
|
102
|
+
return "not-found"
|
|
103
|
+
default:
|
|
104
|
+
return "source-unavailable"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
package app
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"errors"
|
|
6
|
+
"log"
|
|
7
|
+
"strings"
|
|
8
|
+
"testing"
|
|
9
|
+
"time"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestLongCAPTCHAPayloadCannotPassAsDocumentation(t *testing.T) {
|
|
13
|
+
longChallenge := "hCaptcha - Please verify you are human. " +
|
|
14
|
+
strings.Repeat("Deutsch Français 日本語 中文 Hindi Español English ", 1000)
|
|
15
|
+
if usableScrapedMarkdown(longChallenge) || usableNativeMarkdown(longChallenge) {
|
|
16
|
+
t.Fatal("large CAPTCHA payload must be rejected even when >2KB")
|
|
17
|
+
}
|
|
18
|
+
if challengeReason(longChallenge) != "browser verification challenge" {
|
|
19
|
+
t.Fatalf("missing browser challenge reason: %q", challengeReason(longChallenge))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func TestNormalDocumentationAboutCAPTCHAsIsNotAutomaticallyBlocked(t *testing.T) {
|
|
24
|
+
doc := "# hCaptcha integration guide\n\nUse the hCaptcha SDK to add a verification widget to your website.\n" +
|
|
25
|
+
strings.Repeat("Install the client library and validate tokens on your server.\n", 50)
|
|
26
|
+
if !usableScrapedMarkdown(doc) {
|
|
27
|
+
t.Fatal("ordinary documentation about captcha should not be rejected")
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func TestExplicitEvidenceGapReportsMissingFields(t *testing.T) {
|
|
32
|
+
q := "Create a Checkout Session with line_items, mode, success_url and cancel_url and explain webhook signature verification"
|
|
33
|
+
excerpts := "Use line_items and success_url in a session. Verify webhooks using Stripe-Signature."
|
|
34
|
+
gaps := missingResearchEvidence(q, excerpts)
|
|
35
|
+
if !strings.Contains(strings.Join(gaps, ","), "cancel_url") {
|
|
36
|
+
t.Fatalf("missing required request field was not reported: %+v", gaps)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
func TestEmbeddedJSONExampleDoesNotBypassExcerptBudget(t *testing.T) {
|
|
41
|
+
doc := "**URL:** https://example.com/guide\n\n# Guide\n" +
|
|
42
|
+
strings.Repeat("This guide describes a public API and its fields.\n", 1000) +
|
|
43
|
+
"\n" + backtickFence + "json\n" + `{"example":{"id":"ok"}}` + "\n" + backtickFence
|
|
44
|
+
out := focusExcerpt(doc, "public API fields", 3500)
|
|
45
|
+
if len(out) > 4000 {
|
|
46
|
+
t.Fatalf("returned source bypassed excerpt budget: size=%d", len(out))
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestRetrievalDebugTraceDoesNotLogCredentials(t *testing.T) {
|
|
51
|
+
original := log.Writer()
|
|
52
|
+
t.Cleanup(func() { log.SetOutput(original) })
|
|
53
|
+
var output bytes.Buffer
|
|
54
|
+
log.SetOutput(&output)
|
|
55
|
+
t.Setenv("WEBCTX_DEBUG", "1")
|
|
56
|
+
traceRetrieval("https://docs.example.com/guide?api_key=secret-value&token=sensitive",
|
|
57
|
+
"browser", "blocked", time.Now(), errors.New("unauthorized token sensitive"))
|
|
58
|
+
result := output.String()
|
|
59
|
+
if !strings.Contains(result, `stage="browser"`) || !strings.Contains(result, `error="source-unavailable"`) ||
|
|
60
|
+
strings.Contains(result, "secret-value") || strings.Contains(result, "sensitive") {
|
|
61
|
+
t.Fatalf("debug trace leaked query parameters or credentials: %s", result)
|
|
62
|
+
}
|
|
63
|
+
}
|
package/internal/app/scrape.go
CHANGED
|
@@ -2,9 +2,6 @@ package app
|
|
|
2
2
|
|
|
3
3
|
import (
|
|
4
4
|
"context"
|
|
5
|
-
"fmt"
|
|
6
|
-
"io"
|
|
7
|
-
"net/http"
|
|
8
5
|
"os"
|
|
9
6
|
"os/exec"
|
|
10
7
|
"path/filepath"
|
|
@@ -15,9 +12,10 @@ import (
|
|
|
15
12
|
)
|
|
16
13
|
|
|
17
14
|
type MarkdownResult struct {
|
|
18
|
-
URL
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
URL string
|
|
16
|
+
SourceURL string
|
|
17
|
+
Title string
|
|
18
|
+
Markdown string
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
const keychainServiceName = "webctx"
|
|
@@ -29,65 +27,6 @@ var (
|
|
|
29
27
|
keychainLookup = lookupKeychainSecret
|
|
30
28
|
)
|
|
31
29
|
|
|
32
|
-
func checkMarkdownAvailable(raw string) (bool, error) {
|
|
33
|
-
mdURL := raw
|
|
34
|
-
if !strings.HasSuffix(strings.ToLower(mdURL), ".md") {
|
|
35
|
-
mdURL += ".md"
|
|
36
|
-
}
|
|
37
|
-
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
38
|
-
defer cancel()
|
|
39
|
-
req, err := http.NewRequestWithContext(ctx, http.MethodHead, mdURL, nil)
|
|
40
|
-
if err != nil {
|
|
41
|
-
return false, err
|
|
42
|
-
}
|
|
43
|
-
resp, err := http.DefaultClient.Do(req)
|
|
44
|
-
if err != nil {
|
|
45
|
-
return false, err
|
|
46
|
-
}
|
|
47
|
-
defer resp.Body.Close()
|
|
48
|
-
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
49
|
-
return false, nil
|
|
50
|
-
}
|
|
51
|
-
contentType := strings.ToLower(resp.Header.Get("Content-Type"))
|
|
52
|
-
contentLength := resp.ContentLength
|
|
53
|
-
return (strings.Contains(contentType, "markdown") || strings.Contains(contentType, "text/plain")) && contentLength > 50, nil
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
func fetchMarkdownContent(raw string) (*MarkdownResult, error) {
|
|
57
|
-
mdURL := raw
|
|
58
|
-
if !strings.HasSuffix(strings.ToLower(mdURL), ".md") {
|
|
59
|
-
mdURL += ".md"
|
|
60
|
-
}
|
|
61
|
-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
62
|
-
defer cancel()
|
|
63
|
-
content, status, err := fetchText(ctx, mdURL)
|
|
64
|
-
if err != nil {
|
|
65
|
-
return nil, err
|
|
66
|
-
}
|
|
67
|
-
if status < 200 || status >= 300 {
|
|
68
|
-
return nil, fmt.Errorf("Failed to fetch markdown: %d", status)
|
|
69
|
-
}
|
|
70
|
-
title := firstHeadingOrFallback(content, filepath.Base(raw))
|
|
71
|
-
return &MarkdownResult{URL: raw, Title: title, Markdown: content}, nil
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
func fetchText(ctx context.Context, rawURL string) (string, int, error) {
|
|
75
|
-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
76
|
-
if err != nil {
|
|
77
|
-
return "", 0, err
|
|
78
|
-
}
|
|
79
|
-
resp, err := http.DefaultClient.Do(req)
|
|
80
|
-
if err != nil {
|
|
81
|
-
return "", 0, err
|
|
82
|
-
}
|
|
83
|
-
defer resp.Body.Close()
|
|
84
|
-
body, err := io.ReadAll(resp.Body)
|
|
85
|
-
if err != nil {
|
|
86
|
-
return "", resp.StatusCode, err
|
|
87
|
-
}
|
|
88
|
-
return string(body), resp.StatusCode, nil
|
|
89
|
-
}
|
|
90
|
-
|
|
91
30
|
func firstHeadingOrFallback(markdown, fallback string) string {
|
|
92
31
|
for _, line := range strings.Split(markdown, "\n") {
|
|
93
32
|
trimmed := strings.TrimSpace(line)
|
|
@@ -228,10 +228,20 @@ func selectedJSONFields(raw any, question string, budget int) string {
|
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
func focusedRawJSON(markdown, question string, budget int) string {
|
|
231
|
+
// Raw JSON documents have one JSON fence just after the URL. Looking for
|
|
232
|
+
// JSON examples in a long Markdown guide previously allowed the entire
|
|
233
|
+
// preceding guide to escape the excerpt budget.
|
|
234
|
+
if !strings.HasPrefix(strings.TrimSpace(markdown), "**URL:**") {
|
|
235
|
+
return ""
|
|
236
|
+
}
|
|
231
237
|
begin := strings.Index(markdown, "```json\n")
|
|
232
238
|
if begin < 0 {
|
|
233
239
|
return ""
|
|
234
240
|
}
|
|
241
|
+
prefix := strings.TrimSpace(markdown[:begin])
|
|
242
|
+
if len(prefix) > 400 || strings.Contains(prefix, "\n#") {
|
|
243
|
+
return ""
|
|
244
|
+
}
|
|
235
245
|
jsonStart := begin + len("```json\n")
|
|
236
246
|
end := strings.LastIndex(markdown, "\n```")
|
|
237
247
|
if end <= jsonStart {
|
package/internal/app/tools.go
CHANGED
|
@@ -171,12 +171,12 @@ func ReadLink(rawURL string) (string, error) {
|
|
|
171
171
|
return direct, nil
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
174
|
+
nativeStarted := time.Now()
|
|
175
|
+
if result, err := readNativeMarkdown(rawURL); err == nil && result != nil {
|
|
176
|
+
traceRetrieval(rawURL, "native-markdown", "success", nativeStarted, nil)
|
|
177
|
+
return formatReadLink(result.Title, result.URL, "**Markdown source:** "+result.SourceURL+"\n\n"+result.Markdown), nil
|
|
179
178
|
}
|
|
179
|
+
traceRetrieval(rawURL, "native-markdown", "miss", nativeStarted, nil)
|
|
180
180
|
|
|
181
181
|
title, markdown, err := scrapeLinkWithFirecrawl(rawURL)
|
|
182
182
|
if err != nil {
|
|
@@ -217,28 +217,37 @@ func scrapeLinkWithFirecrawl(rawURL string) (string, string, error) {
|
|
|
217
217
|
// Firecrawl's default auto proxy already tries basic then enhanced when the
|
|
218
218
|
// transport fails. A second explicit enhanced attempt is only useful when a
|
|
219
219
|
// scrape responds successfully but returns a challenge/empty document.
|
|
220
|
+
started := time.Now()
|
|
220
221
|
first, err := firecrawlScrape(rawURL, apiKey, "")
|
|
221
222
|
if err == nil && usableScrapedMarkdown(first.Markdown) {
|
|
223
|
+
traceRetrieval(rawURL, "firecrawl-auto", "success", started, nil)
|
|
222
224
|
return first.Title, withSourceTools(first.Markdown, first.ToolSummary), nil
|
|
223
225
|
}
|
|
224
226
|
lastError := err
|
|
227
|
+
traceRetrieval(rawURL, "firecrawl-auto", "escalate", started, err)
|
|
225
228
|
if err == nil {
|
|
226
|
-
lastError = fmt.Errorf("Firecrawl returned empty or blocked page content")
|
|
229
|
+
lastError = fmt.Errorf("Firecrawl returned empty or blocked page content: %s", challengeReason(first.Markdown))
|
|
230
|
+
started = time.Now()
|
|
227
231
|
second, enhancedErr := firecrawlScrape(rawURL, apiKey, "enhanced")
|
|
228
232
|
if enhancedErr == nil && usableScrapedMarkdown(second.Markdown) {
|
|
233
|
+
traceRetrieval(rawURL, "firecrawl-enhanced", "success", started, nil)
|
|
229
234
|
return second.Title, withSourceTools(second.Markdown, second.ToolSummary), nil
|
|
230
235
|
}
|
|
236
|
+
traceRetrieval(rawURL, "firecrawl-enhanced", "escalate", started, enhancedErr)
|
|
231
237
|
if enhancedErr != nil {
|
|
232
238
|
lastError = enhancedErr
|
|
233
239
|
}
|
|
234
240
|
}
|
|
241
|
+
started = time.Now()
|
|
235
242
|
title, markdown, browserErr := firecrawlBrowserFallback(rawURL, apiKey)
|
|
236
243
|
if browserErr == nil && usableScrapedMarkdown(markdown) {
|
|
244
|
+
traceRetrieval(rawURL, "browser", "success", started, nil)
|
|
237
245
|
return title, markdown, nil
|
|
238
246
|
}
|
|
239
247
|
if browserErr == nil {
|
|
240
|
-
browserErr = fmt.Errorf("browser produced no usable content")
|
|
248
|
+
browserErr = fmt.Errorf("browser produced no usable content: %s", challengeReason(markdown))
|
|
241
249
|
}
|
|
250
|
+
traceRetrieval(rawURL, "browser", "blocked", started, browserErr)
|
|
242
251
|
return "", "", fmt.Errorf("Error reading web page: scrape: %v; browser fallback: %w", lastError, browserErr)
|
|
243
252
|
}
|
|
244
253
|
|