webcrawl 1.2.4 → 1.2.7
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/dist/cli.js +3 -2
- package/dist/core/engine/awswaf.d.ts +37 -0
- package/dist/core/engine/awswaf.js +189 -0
- package/dist/core/engine/cache.d.ts +70 -0
- package/dist/core/engine/cache.js +297 -0
- package/dist/core/engine/crawl-policy.d.ts +36 -0
- package/dist/core/engine/crawl-policy.js +134 -0
- package/dist/core/engine/crawler.d.ts +151 -0
- package/dist/core/engine/crawler.js +885 -0
- package/dist/core/engine/fetcher.d.ts +66 -0
- package/dist/core/engine/fetcher.js +185 -0
- package/dist/core/engine/index.d.ts +4 -0
- package/dist/core/engine/index.js +4 -0
- package/dist/core/engine/markdown.d.ts +33 -0
- package/dist/core/engine/markdown.js +334 -0
- package/dist/core/engine/path-scope.d.ts +21 -0
- package/dist/core/engine/path-scope.js +88 -0
- package/dist/core/engine/safe-url.d.ts +23 -0
- package/dist/core/engine/safe-url.js +92 -0
- package/dist/core/engine/stealth.d.ts +79 -0
- package/dist/core/engine/stealth.js +155 -0
- package/dist/core/engine.d.ts +1 -0
- package/dist/core/engine.js +1 -0
- package/dist/core/extract/business.d.ts +28 -0
- package/dist/core/extract/business.js +344 -0
- package/dist/core/extract/currency.d.ts +15 -0
- package/dist/core/extract/currency.js +259 -0
- package/dist/core/extract/cve-cache.d.ts +62 -0
- package/dist/core/extract/cve-cache.js +277 -0
- package/dist/core/extract/cve.d.ts +57 -0
- package/dist/core/extract/cve.js +397 -0
- package/dist/core/extract/dedupe-key.d.ts +12 -0
- package/dist/core/extract/dedupe-key.js +36 -0
- package/dist/core/extract/flights.d.ts +37 -0
- package/dist/core/extract/flights.js +373 -0
- package/dist/core/extract/hotels.d.ts +35 -0
- package/dist/core/extract/hotels.js +473 -0
- package/dist/core/extract/index.d.ts +8 -0
- package/dist/core/extract/index.js +8 -0
- package/dist/core/extract/real-estate.d.ts +53 -0
- package/dist/core/extract/real-estate.js +835 -0
- package/dist/core/extract/scaledown.d.ts +14 -0
- package/dist/core/extract/scaledown.js +484 -0
- package/dist/core/extract/shopping.d.ts +44 -0
- package/dist/core/extract/shopping.js +393 -0
- package/dist/core/image-url.d.ts +5 -0
- package/dist/core/image-url.js +13 -0
- package/dist/core/interact/driver.d.ts +49 -0
- package/dist/core/interact/driver.js +264 -0
- package/dist/core/interact/remote.d.ts +41 -0
- package/dist/core/interact/remote.js +133 -0
- package/dist/core/interact/spans.d.ts +46 -0
- package/dist/core/interact/spans.js +32 -0
- package/dist/core/interact/validate.d.ts +34 -0
- package/dist/core/interact/validate.js +135 -0
- package/dist/core/interact/vm.d.ts +42 -0
- package/dist/core/interact/vm.js +296 -0
- package/dist/core/local-engine.d.ts +35 -0
- package/dist/core/local-engine.js +367 -0
- package/dist/core/parse/detect.d.ts +29 -0
- package/dist/core/parse/detect.js +152 -0
- package/dist/core/parse/fetch.d.ts +20 -0
- package/dist/core/parse/fetch.js +39 -0
- package/dist/core/parse/image.d.ts +13 -0
- package/dist/core/parse/image.js +100 -0
- package/dist/core/parse/index.d.ts +53 -0
- package/dist/core/parse/index.js +153 -0
- package/dist/core/parse/limits.d.ts +12 -0
- package/dist/core/parse/limits.js +29 -0
- package/dist/core/parse/media.d.ts +42 -0
- package/dist/core/parse/media.js +238 -0
- package/dist/core/parse/merge.d.ts +19 -0
- package/dist/core/parse/merge.js +48 -0
- package/dist/core/parse/ocr.d.ts +25 -0
- package/dist/core/parse/ocr.js +121 -0
- package/dist/core/parse/office.d.ts +28 -0
- package/dist/core/parse/office.js +83 -0
- package/dist/core/parse/pdf.d.ts +27 -0
- package/dist/core/parse/pdf.js +156 -0
- package/dist/core/parse/types.d.ts +38 -0
- package/dist/core/parse/types.js +1 -0
- package/dist/core/parse/youtube.d.ts +30 -0
- package/dist/core/parse/youtube.js +264 -0
- package/dist/core/ranker/decision.d.ts +28 -0
- package/dist/core/ranker/decision.js +151 -0
- package/dist/core/ranker/index.d.ts +5 -0
- package/dist/core/ranker/index.js +361 -0
- package/dist/core/ranker/lexical.d.ts +82 -0
- package/dist/core/ranker/lexical.js +642 -0
- package/dist/core/ranker/types.d.ts +90 -0
- package/dist/core/ranker/types.js +1 -0
- package/dist/core/ranker.d.ts +1 -0
- package/dist/core/ranker.js +1 -0
- package/dist/core/sandbox.d.ts +29 -0
- package/dist/core/sandbox.js +182 -0
- package/dist/core/search/catalog.d.ts +174 -0
- package/dist/core/search/catalog.js +488 -0
- package/dist/core/search/index.d.ts +3 -0
- package/dist/core/search/index.js +3 -0
- package/dist/core/search/pipeline.d.ts +7 -0
- package/dist/core/search/pipeline.js +704 -0
- package/dist/core/search/providers.d.ts +62 -0
- package/dist/core/search/providers.js +1175 -0
- package/dist/core/search.d.ts +1 -0
- package/dist/core/search.js +1 -0
- package/dist/core/targets/developer.d.ts +44 -0
- package/dist/core/targets/developer.js +338 -0
- package/dist/core/targets/index.d.ts +3 -0
- package/dist/core/targets/index.js +3 -0
- package/dist/core/targets/registry.d.ts +32 -0
- package/dist/core/targets/registry.js +411 -0
- package/dist/core/targets/types.d.ts +72 -0
- package/dist/core/targets/types.js +8 -0
- package/dist/core/targets.d.ts +1 -0
- package/dist/core/targets.js +1 -0
- package/package.json +82 -4
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { transcriptMarkdown } from "./media.js";
|
|
2
|
+
import { getCachedPageAsync, setCachedPage } from "../engine/cache.js";
|
|
3
|
+
const PLAYER_URL = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false";
|
|
4
|
+
const MAX_ATTEMPTS = 3;
|
|
5
|
+
const UA = "com.google.android.youtube/20.10.38 (Linux; U; Android 11) gzip";
|
|
6
|
+
/** Tried in order; YouTube blocks some clients from datacenter IPs, so we fall through. */
|
|
7
|
+
const CLIENTS = [
|
|
8
|
+
{
|
|
9
|
+
context: {
|
|
10
|
+
client: {
|
|
11
|
+
clientName: "ANDROID",
|
|
12
|
+
clientVersion: "20.10.38",
|
|
13
|
+
androidSdkVersion: 30,
|
|
14
|
+
hl: "en",
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
ua: UA,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
context: {
|
|
21
|
+
client: {
|
|
22
|
+
clientName: "TVHTML5_SIMPLY_EMBEDDED_PLAYER",
|
|
23
|
+
clientVersion: "2.0",
|
|
24
|
+
hl: "en",
|
|
25
|
+
},
|
|
26
|
+
thirdParty: { embedUrl: "https://www.youtube.com/" },
|
|
27
|
+
},
|
|
28
|
+
ua: "Mozilla/5.0 (SMART-TV; Linux; Tizen 6.5) AppleWebKit/537.36 (KHTML, like Gecko) 85.0.4183.93/6.5 TV Safari/537.36",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
context: {
|
|
32
|
+
client: {
|
|
33
|
+
clientName: "WEB_EMBEDDED_PLAYER",
|
|
34
|
+
clientVersion: "1.20250310.01.00",
|
|
35
|
+
hl: "en",
|
|
36
|
+
},
|
|
37
|
+
thirdParty: { embedUrl: "https://www.google.com/" },
|
|
38
|
+
},
|
|
39
|
+
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0 Safari/537.36",
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
context: {
|
|
43
|
+
client: {
|
|
44
|
+
clientName: "IOS",
|
|
45
|
+
clientVersion: "20.10.4",
|
|
46
|
+
deviceMake: "Apple",
|
|
47
|
+
deviceModel: "iPhone16,2",
|
|
48
|
+
osName: "iPhone",
|
|
49
|
+
osVersion: "18.3.2.22D82",
|
|
50
|
+
hl: "en",
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
ua: "com.google.ios.youtube/20.10.4 (iPhone16,2; U; CPU iOS 18_3_2 like Mac OS X;)",
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* YouTube refuses most cloud-hosting IP ranges. When YOUTUBE_RELAY_URL and YOUTUBE_RELAY_TOKEN are set,
|
|
58
|
+
* requests go through a relay on a network YouTube accepts; otherwise they go straight out.
|
|
59
|
+
*/
|
|
60
|
+
function youtubeFetch(url, init) {
|
|
61
|
+
const relay = process.env.YOUTUBE_RELAY_URL;
|
|
62
|
+
const token = process.env.YOUTUBE_RELAY_TOKEN;
|
|
63
|
+
if (!relay || !token)
|
|
64
|
+
return fetch(url, init);
|
|
65
|
+
return fetch(relay, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: {
|
|
68
|
+
Authorization: `Bearer ${token}`,
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
},
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
url,
|
|
73
|
+
method: init.method,
|
|
74
|
+
headers: init.headers,
|
|
75
|
+
body: init.body,
|
|
76
|
+
}),
|
|
77
|
+
signal: init.signal,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export class YouTubeError extends Error {
|
|
81
|
+
status;
|
|
82
|
+
constructor(message, status = 422) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.status = status;
|
|
85
|
+
this.name = "YouTubeError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/** Returns the 11-character video id for watch, short, embed, shorts, live and youtu.be links. */
|
|
89
|
+
export function youtubeVideoId(input) {
|
|
90
|
+
let u;
|
|
91
|
+
try {
|
|
92
|
+
u = new URL(input);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const host = u.hostname.replace(/^(www|m|music)\./, "");
|
|
98
|
+
const ok = (s) => s && /^[A-Za-z0-9_-]{11}$/.test(s) ? s : undefined;
|
|
99
|
+
if (host === "youtu.be")
|
|
100
|
+
return ok(u.pathname.slice(1).split("/")[0]);
|
|
101
|
+
if (host !== "youtube.com" && host !== "youtube-nocookie.com")
|
|
102
|
+
return undefined;
|
|
103
|
+
if (u.pathname === "/watch")
|
|
104
|
+
return ok(u.searchParams.get("v"));
|
|
105
|
+
const m = /^\/(?:embed|shorts|live|v)\/([^/?#]+)/.exec(u.pathname);
|
|
106
|
+
return m ? ok(m[1]) : undefined;
|
|
107
|
+
}
|
|
108
|
+
function decodeEntities(s) {
|
|
109
|
+
return s
|
|
110
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
111
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
|
112
|
+
.replace(/"/g, '"')
|
|
113
|
+
.replace(/'/g, "'")
|
|
114
|
+
.replace(/</g, "<")
|
|
115
|
+
.replace(/>/g, ">")
|
|
116
|
+
.replace(/&/g, "&");
|
|
117
|
+
}
|
|
118
|
+
/** Parses YouTube's timedtext XML (`<p t= d=>` in ms, optional `<s>` children). */
|
|
119
|
+
export function parseTimedText(xml) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const m of xml.matchAll(/<p\s([^>]*)>([\s\S]*?)<\/p>/g)) {
|
|
122
|
+
const t = /\bt="(\d+)"/.exec(m[1]);
|
|
123
|
+
if (!t)
|
|
124
|
+
continue;
|
|
125
|
+
const d = /\bd="(\d+)"/.exec(m[1]);
|
|
126
|
+
const text = decodeEntities(m[2].replace(/<[^>]+>/g, ""))
|
|
127
|
+
.replace(/\s+/g, " ")
|
|
128
|
+
.trim();
|
|
129
|
+
if (!text)
|
|
130
|
+
continue;
|
|
131
|
+
const start = Number(t[1]) / 1000;
|
|
132
|
+
out.push({
|
|
133
|
+
start,
|
|
134
|
+
end: Math.round((start + (d ? Number(d[1]) / 1000 : 0)) * 1000) / 1000,
|
|
135
|
+
text,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
function pickTrack(tracks, language) {
|
|
141
|
+
const want = (language || "en").toLowerCase().split("-")[0];
|
|
142
|
+
const byLang = tracks.filter((t) => t.languageCode.toLowerCase().split("-")[0] === want);
|
|
143
|
+
const pool = byLang.length ? byLang : tracks;
|
|
144
|
+
// Human-written captions beat auto-generated ones.
|
|
145
|
+
return pool.find((t) => t.kind !== "asr") ?? pool[0];
|
|
146
|
+
}
|
|
147
|
+
export async function parseYouTube(videoId, options = {}) {
|
|
148
|
+
const started = performance.now();
|
|
149
|
+
const signal = options.signal ?? AbortSignal.timeout(25_000);
|
|
150
|
+
let player;
|
|
151
|
+
let ua = UA;
|
|
152
|
+
let lastReason = "";
|
|
153
|
+
// The relay's addresses are shared, so YouTube occasionally flags one with a bot check.
|
|
154
|
+
// A retry usually leaves from a different address, so retry only that case.
|
|
155
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS && !player; attempt++) {
|
|
156
|
+
if (attempt > 0 && !/not a bot/i.test(lastReason))
|
|
157
|
+
break;
|
|
158
|
+
if (attempt > 0)
|
|
159
|
+
await new Promise((r) => setTimeout(r, 400 * attempt));
|
|
160
|
+
for (const c of CLIENTS) {
|
|
161
|
+
try {
|
|
162
|
+
const res = await youtubeFetch(PLAYER_URL, {
|
|
163
|
+
method: "POST",
|
|
164
|
+
headers: { "Content-Type": "application/json", "User-Agent": c.ua },
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
context: c.context,
|
|
167
|
+
videoId,
|
|
168
|
+
contentCheckOk: true,
|
|
169
|
+
racyCheckOk: true,
|
|
170
|
+
}),
|
|
171
|
+
signal,
|
|
172
|
+
});
|
|
173
|
+
if (!res.ok) {
|
|
174
|
+
lastReason = `HTTP ${res.status}`;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const p = (await res.json());
|
|
178
|
+
if (p.playabilityStatus?.status === "OK") {
|
|
179
|
+
player = p;
|
|
180
|
+
ua = c.ua;
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
lastReason =
|
|
184
|
+
p.playabilityStatus?.reason ||
|
|
185
|
+
p.playabilityStatus?.status?.toLowerCase() ||
|
|
186
|
+
"unavailable";
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
lastReason = err instanceof Error ? err.message : String(err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!player)
|
|
194
|
+
throw new YouTubeError(`This video can't be read (${lastReason})`);
|
|
195
|
+
const tracks = player.captions?.playerCaptionsTracklistRenderer?.captionTracks ?? [];
|
|
196
|
+
if (!tracks.length)
|
|
197
|
+
throw new YouTubeError("This video has no captions, so there is no transcript to read");
|
|
198
|
+
const track = pickTrack(tracks, options.language);
|
|
199
|
+
const cap = await youtubeFetch(track.baseUrl, {
|
|
200
|
+
headers: { "User-Agent": ua },
|
|
201
|
+
signal,
|
|
202
|
+
});
|
|
203
|
+
if (!cap.ok)
|
|
204
|
+
throw new YouTubeError(`Could not load the captions (HTTP ${cap.status})`, 502);
|
|
205
|
+
const segments = parseTimedText(await cap.text());
|
|
206
|
+
if (!segments.length)
|
|
207
|
+
throw new YouTubeError("The captions for this video were empty");
|
|
208
|
+
const lengthSeconds = Number(player.videoDetails?.lengthSeconds);
|
|
209
|
+
const duration = Number.isFinite(lengthSeconds) && lengthSeconds > 0
|
|
210
|
+
? lengthSeconds
|
|
211
|
+
: segments[segments.length - 1].end;
|
|
212
|
+
const warnings = [];
|
|
213
|
+
if (track.kind === "asr")
|
|
214
|
+
warnings.push("Captions are auto-generated by YouTube and may contain errors");
|
|
215
|
+
return {
|
|
216
|
+
title: player.videoDetails?.title || "",
|
|
217
|
+
markdown: `## Transcript\n\n${transcriptMarkdown(segments)}`,
|
|
218
|
+
meta: {
|
|
219
|
+
kind: "video",
|
|
220
|
+
mime: "video/youtube",
|
|
221
|
+
method: "transcript",
|
|
222
|
+
bytes: 0,
|
|
223
|
+
durationSeconds: duration,
|
|
224
|
+
transcriptProvider: "captions",
|
|
225
|
+
warnings,
|
|
226
|
+
processingMs: Math.round(performance.now() - started),
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** Same transcript for the same video and language, so successful reads are kept for 24 hours. Failures are never cached. */
|
|
231
|
+
export async function parseYouTubeCached(videoId, options = {}) {
|
|
232
|
+
const key = `wc-parse://youtube:${videoId}/lang=${options.language ?? "default"}`;
|
|
233
|
+
if (!options.bypassCache) {
|
|
234
|
+
const hit = await getCachedPageAsync(key, "parse");
|
|
235
|
+
const meta = hit?.structuredData?.parse;
|
|
236
|
+
if (hit && meta) {
|
|
237
|
+
return {
|
|
238
|
+
title: hit.title,
|
|
239
|
+
markdown: hit.markdown,
|
|
240
|
+
meta,
|
|
241
|
+
contentHash: `youtube:${videoId}`,
|
|
242
|
+
cached: true,
|
|
243
|
+
cacheLayer: hit.cacheLayer || "postgres",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const parsed = await parseYouTube(videoId, options);
|
|
248
|
+
setCachedPage(key, "parse", {
|
|
249
|
+
title: parsed.title,
|
|
250
|
+
markdown: parsed.markdown,
|
|
251
|
+
links: [],
|
|
252
|
+
structuredData: {
|
|
253
|
+
parse: parsed.meta,
|
|
254
|
+
},
|
|
255
|
+
statusCode: 200,
|
|
256
|
+
contentHash: `youtube:${videoId}`,
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
...parsed,
|
|
260
|
+
contentHash: `youtube:${videoId}`,
|
|
261
|
+
cached: false,
|
|
262
|
+
cacheLayer: "miss",
|
|
263
|
+
};
|
|
264
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { DecisionModelId, SearchFilterContext, UserLocationContext } from "./types.js";
|
|
2
|
+
/** What `decisionModel: "auto"` resolves to. Set WEBCRAWL_DECISION_MODEL to change it in prod. */
|
|
3
|
+
export declare function defaultDecisionModel(): DecisionModelId;
|
|
4
|
+
/**
|
|
5
|
+
* Stage 5: Vercel AI Gateway Decision Model Reranker (`baseten/mammoth-1` & `convaiinnovations/laya`).
|
|
6
|
+
* Scores all top-N candidates in ONE shared-prefix round-trip via `experimental_decide`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runDecisionModelReranker(query: string, candidates: Array<{
|
|
9
|
+
title: string;
|
|
10
|
+
url: string;
|
|
11
|
+
description: string;
|
|
12
|
+
highlights: string[];
|
|
13
|
+
}>, modelOption: DecisionModelId, timeoutMs: number, userLocation?: UserLocationContext, filterContext?: SearchFilterContext): Promise<{
|
|
14
|
+
scores: Map<number, {
|
|
15
|
+
normalizedScore: number;
|
|
16
|
+
rawScore: number;
|
|
17
|
+
confidence: number;
|
|
18
|
+
}>;
|
|
19
|
+
modelUsed: string | null;
|
|
20
|
+
} | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Full 5-Stage Document & Passage Ranking Pipeline:
|
|
23
|
+
* 1. Canonical URL Deduplication
|
|
24
|
+
* 2. Weighted Reciprocal Rank Fusion (W-RRF, k=60) + Multi-Engine Consensus
|
|
25
|
+
* 3. Fielded Okapi BM25F+ (title 3.5x, headings 2.2x, url 2.0x, description 1.5x, body 1.0x)
|
|
26
|
+
* 4. ColBERT-inspired Subword + Token MaxSim Passage Scoring & Highlight Extraction
|
|
27
|
+
* 5. Vercel AI Gateway Decision Model Reranking (`baseten/mammoth-1` / `convaiinnovations/laya`)
|
|
28
|
+
*/
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { createGateway } from "@ai-sdk/gateway";
|
|
2
|
+
import { experimental_decide as decide } from "ai";
|
|
3
|
+
import { resolveCountryDisplayName } from "./lexical.js";
|
|
4
|
+
const DECISION_MODELS = [
|
|
5
|
+
"baseten/mammoth-1",
|
|
6
|
+
"baseten/elephant-1",
|
|
7
|
+
"convaiinnovations/laya",
|
|
8
|
+
"none",
|
|
9
|
+
];
|
|
10
|
+
/** What `decisionModel: "auto"` resolves to. Set WEBCRAWL_DECISION_MODEL to change it in prod. */
|
|
11
|
+
export function defaultDecisionModel() {
|
|
12
|
+
const env = process.env.WEBCRAWL_DECISION_MODEL?.trim();
|
|
13
|
+
return env && DECISION_MODELS.includes(env) ? env : "baseten/mammoth-1";
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Stage 5: Vercel AI Gateway Decision Model Reranker (`baseten/mammoth-1` & `convaiinnovations/laya`).
|
|
17
|
+
* Scores all top-N candidates in ONE shared-prefix round-trip via `experimental_decide`.
|
|
18
|
+
*/
|
|
19
|
+
export async function runDecisionModelReranker(query, candidates, modelOption, timeoutMs, userLocation, filterContext) {
|
|
20
|
+
// Operator switch: WEBCRAWL_DECISION_MODEL picks what "auto" means (or "none" for lexical-only),
|
|
21
|
+
// so a model price change can be absorbed without a deploy of new code.
|
|
22
|
+
if (modelOption === "auto")
|
|
23
|
+
modelOption = defaultDecisionModel();
|
|
24
|
+
if (modelOption === "none" || candidates.length === 0) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const apiKey = process.env.AI_GATEWAY_API_KEY || process.env.VERCEL_OIDC_TOKEN;
|
|
28
|
+
if (!apiKey) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// Primary model selection: `baseten/mammoth-1` (#1 on Jev Decision Index 71.24, ~350ms)
|
|
32
|
+
// or user-specified `convaiinnovations/laya` / `baseten/elephant-1`.
|
|
33
|
+
const primaryModel = modelOption === "auto" ? "baseten/mammoth-1" : modelOption;
|
|
34
|
+
const fallbackModels = primaryModel === "baseten/mammoth-1"
|
|
35
|
+
? ["baseten/elephant-1", "convaiinnovations/laya"]
|
|
36
|
+
: ["baseten/mammoth-1", "baseten/elephant-1"];
|
|
37
|
+
const gw = createGateway({ apiKey });
|
|
38
|
+
const stateCandidates = candidates.map((c, idx) => ({
|
|
39
|
+
id: `doc_${idx}`,
|
|
40
|
+
title: c.title.slice(0, 180),
|
|
41
|
+
url: c.url.slice(0, 200),
|
|
42
|
+
snippet: (c.highlights[0] || c.description || "").slice(0, 360),
|
|
43
|
+
}));
|
|
44
|
+
const contextClauses = [];
|
|
45
|
+
if (filterContext?.category) {
|
|
46
|
+
contextClauses.push(`category="${filterContext.category}"`);
|
|
47
|
+
}
|
|
48
|
+
if (filterContext?.subcategory) {
|
|
49
|
+
const subStr = Array.isArray(filterContext.subcategory)
|
|
50
|
+
? filterContext.subcategory.join(", ")
|
|
51
|
+
: filterContext.subcategory;
|
|
52
|
+
contextClauses.push(`subcategory="${subStr}"`);
|
|
53
|
+
}
|
|
54
|
+
if (userLocation?.city || userLocation?.region || userLocation?.country) {
|
|
55
|
+
const locParts = [
|
|
56
|
+
userLocation.city,
|
|
57
|
+
userLocation.region,
|
|
58
|
+
userLocation.countryName ||
|
|
59
|
+
resolveCountryDisplayName(userLocation.country),
|
|
60
|
+
].filter(Boolean);
|
|
61
|
+
contextClauses.push(`userLocation="${locParts.join(", ")}"`);
|
|
62
|
+
}
|
|
63
|
+
if (filterContext?.customInstruction) {
|
|
64
|
+
contextClauses.push(`agentFilter="${filterContext.customInstruction}"`);
|
|
65
|
+
}
|
|
66
|
+
const contextSuffix = contextClauses.length > 0 ? ` (${contextClauses.join("; ")})` : "";
|
|
67
|
+
const questions = {};
|
|
68
|
+
for (let i = 0; i < stateCandidates.length; i++) {
|
|
69
|
+
questions[`doc_${i}`] = {
|
|
70
|
+
type: "score",
|
|
71
|
+
instructions: `Evaluate how relevant, authoritative, and aligned candidate doc_${i} is for the search query${contextSuffix}. Penalize entity homonym mismatches or wrong-region pages when userLocation or category is specified.`,
|
|
72
|
+
criteria: [
|
|
73
|
+
"Irrelevant, off-topic, wrong entity, wrong region, or spam",
|
|
74
|
+
"Tangentially related or generic syndication with weak match to the requested category/subcategory/location",
|
|
75
|
+
"Relevant and addresses a substantial part of the query and category/subcategory context",
|
|
76
|
+
"Highly relevant, authoritative, and directly satisfies the query, category, subcategory, and user location",
|
|
77
|
+
],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
const decisionPromise = decide({
|
|
82
|
+
model: gw.decisionModel(primaryModel),
|
|
83
|
+
state: JSON.stringify({
|
|
84
|
+
query,
|
|
85
|
+
...(filterContext?.category
|
|
86
|
+
? { category: filterContext.category }
|
|
87
|
+
: {}),
|
|
88
|
+
...(filterContext?.subcategory
|
|
89
|
+
? { subcategory: filterContext.subcategory }
|
|
90
|
+
: {}),
|
|
91
|
+
...(userLocation ? { userLocation } : {}),
|
|
92
|
+
candidates: stateCandidates,
|
|
93
|
+
}),
|
|
94
|
+
questions,
|
|
95
|
+
providerOptions: {
|
|
96
|
+
gateway: {
|
|
97
|
+
models: fallbackModels,
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
abortSignal: AbortSignal.timeout(timeoutMs),
|
|
101
|
+
});
|
|
102
|
+
const result = await decisionPromise;
|
|
103
|
+
const scores = new Map();
|
|
104
|
+
for (let i = 0; i < stateCandidates.length; i++) {
|
|
105
|
+
const ans = result.answers?.[`doc_${i}`];
|
|
106
|
+
if (!ans) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
let rawScore = typeof ans.score === "number" ? ans.score : 0;
|
|
110
|
+
let maxProb = typeof ans.confidence === "number" ? ans.confidence : 0;
|
|
111
|
+
if (ans.probabilities) {
|
|
112
|
+
const probs = Array.isArray(ans.probabilities)
|
|
113
|
+
? ans.probabilities
|
|
114
|
+
: [
|
|
115
|
+
Number(ans.probabilities["0"] ?? 0),
|
|
116
|
+
Number(ans.probabilities["1"] ?? 0),
|
|
117
|
+
Number(ans.probabilities["2"] ?? 0),
|
|
118
|
+
Number(ans.probabilities["3"] ?? 0),
|
|
119
|
+
];
|
|
120
|
+
const expected = 0 * (probs[0] || 0) +
|
|
121
|
+
1 * (probs[1] || 0) +
|
|
122
|
+
2 * (probs[2] || 0) +
|
|
123
|
+
3 * (probs[3] || 0);
|
|
124
|
+
if (expected > 0 || rawScore === 0) {
|
|
125
|
+
rawScore = expected;
|
|
126
|
+
}
|
|
127
|
+
maxProb = Math.max(maxProb, ...probs.filter((p) => Number.isFinite(p)));
|
|
128
|
+
}
|
|
129
|
+
const normalizedScore = Math.max(0, Math.min(1, rawScore / 3));
|
|
130
|
+
scores.set(i, {
|
|
131
|
+
normalizedScore,
|
|
132
|
+
rawScore: Number(rawScore.toFixed(4)),
|
|
133
|
+
confidence: Number((maxProb || 0.85).toFixed(4)),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const modelUsed = result.response?.modelId ||
|
|
137
|
+
primaryModel;
|
|
138
|
+
return { scores, modelUsed };
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Full 5-Stage Document & Passage Ranking Pipeline:
|
|
146
|
+
* 1. Canonical URL Deduplication
|
|
147
|
+
* 2. Weighted Reciprocal Rank Fusion (W-RRF, k=60) + Multi-Engine Consensus
|
|
148
|
+
* 3. Fielded Okapi BM25F+ (title 3.5x, headings 2.2x, url 2.0x, description 1.5x, body 1.0x)
|
|
149
|
+
* 4. ColBERT-inspired Subword + Token MaxSim Passage Scoring & Highlight Extraction
|
|
150
|
+
* 5. Vercel AI Gateway Decision Model Reranking (`baseten/mammoth-1` / `convaiinnovations/laya`)
|
|
151
|
+
*/
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export * from "./types.js";
|
|
2
|
+
export * from "./lexical.js";
|
|
3
|
+
export * from "./decision.js";
|
|
4
|
+
import type { RawCandidateDocument, RerankOptions, RerankResult } from "./types.js";
|
|
5
|
+
export declare function rankDocuments(query: string, rawCandidates: RawCandidateDocument[], options?: RerankOptions): Promise<RerankResult>;
|