import { convert as convertHtmlToPlainText } from "html-to-text";
import { PDFParse } from "pdf-parse";
import { repository, version } from "../package.json" with { type: "json" };
import { scorePassages } from "./biEncoderService.ts";
import {
type PageReadOutcome,
recordPageRead,
} from "./pageReadsSinceLastRestart.ts";
import { resolvePublicUrl } from "./utils/publicUrl.ts";
import { readCappedBytes } from "./utils/streamUtils.ts";
const REQUEST_TIMEOUT_MS = 6000;
const MAX_REDIRECTS = 3;
const MAX_RESPONSE_BYTES = 1_500_000;
/** Extracted text kept per page, before the client applies its token budget. */
const MAX_PAGE_CHARS = 6000;
/** Below this, a page yielded a cookie wall or an empty shell, not an article. */
const MIN_USEFUL_CHARS = 200;
const MIN_PASSAGE_CHARS = 180;
const MAX_PASSAGE_CHARS = 1200;
const OVERLAP_CHARS = 200;
const appName = repository.url.slice(repository.url.lastIndexOf("/") + 1);
const REQUEST_HEADERS = {
Accept: "text/html,application/xhtml+xml;q=0.9,text/plain;q=0.8",
// No language preference: searches arrive in every language, and asking for
// English made a multilingual site serve its English edition for a query
// written in something else.
"Accept-Language": "*",
"User-Agent": `Mozilla/5.0 (compatible; ${appName}/${version}; +${repository.url})`,
} as const;
const READABLE_CONTENT_TYPES =
/^(text\/html|application\/xhtml\+xml|text\/plain)/i;
/**
* Elements that survive `baseElements` but still carry no article text:
* cookie banners, share bars, and inline widgets.
*/
const SKIPPED_SELECTORS = [
"aside",
"button",
"footer",
"form",
"header",
"iframe",
"img",
"input",
"nav",
"noscript",
"select",
"svg",
"textarea",
];
/**
* Word and sentence boundaries come from ICU rather than from an expression
* over character classes. `[^\p{L}\p{N}]+` cut Brahmic scripts and Thai at
* every combining vowel mark and discarded the marks, turning a Hindi query
* into one meaningless fragment, and no expression can find a boundary in
* Chinese, Japanese or Thai, which write none: a whole sentence arrived as a
* single token that matched nothing. Both segmenters are script-driven, so the
* locale is left unset and the result is the same on every host.
*/
const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
const sentenceSegmenter = new Intl.Segmenter(undefined, {
granularity: "sentence",
});
/**
* A query term and a word on the page count as the same word when one is a
* prefix of the other within these bounds. Suffix inflection is the common case
* wherever it happens at all ("gato"/"gatos", "sleep"/"sleeping",
* "बिल्ली"/"बिल्लियाँ"), so a shared prefix catches it without a suffix table per
* language, and scripts that do not inflect fall back to equality.
*
* Three characters is the shortest prefix worth trusting, and three more is
* about as long as an inflectional ending runs, which together keep "cat" on
* "cats" and off "catalogue".
*
* Measuring in characters is a compromise, since a character is worth a
* different amount per script: the floor rules out Chinese compounds like
* "睡"/"睡眠". Making the rule proportional instead admits those and also lets
* "do" match "dog", which cost more on the pages this was measured against
* than the compounds gained.
*
* A false positive costs more than the noise it adds: it raises the term's
* document frequency, which lowers the weight of a term that may have been the
* one worth ranking on. A query for "war" against a page repeating "warm" loses
* most of that term's weight.
*/
const MIN_PREFIX_MATCH_CHARS = 3;
const MAX_INFLECTION_CHARS = 3;
/** Extracted text for a single page, keyed by the URL it was read from. */
export interface PageContent {
url: string;
content: string;
}
interface RankedPassage {
url: string;
text: string;
score: number;
index: number;
tokens: Set;
}
interface FetchedPage {
url: string;
passages: string[];
durationMs: number;
bodyTruncated: boolean;
}
function isRedirect(status: number): boolean {
return (
status === 301 ||
status === 302 ||
status === 303 ||
status === 307 ||
status === 308
);
}
function findCharset(text: string, pattern: RegExp): string | null {
const match = pattern.exec(text);
return match ? match[1] : null;
}
/**
* Resolves the encoding of a document. The `Content-Type` header wins, but
* plenty of pages declare their encoding only in a `` tag, and decoding
* those as UTF-8 turns the whole excerpt into mojibake.
*/
function decodeDocument(bytes: Uint8Array, contentType: string): string {
try {
const declaredCharset =
findCharset(contentType, /charset\s*=\s*["']?([\w-]+)/i) ??
// Only a meta element counts, the way a browser's prescan reads it: a
// `charset=` inside a script or a link href is not a declaration. The
// element is ASCII-compatible in every encoding worth sniffing, so
// reading the head of the document as Latin-1 is enough to find it.
findCharset(
new TextDecoder("latin1").decode(bytes.slice(0, 4096)),
/]+charset\s*=\s*["']?([\w-]+)/i,
);
return new TextDecoder(declaredCharset ?? "utf-8").decode(bytes);
} catch {
return new TextDecoder("utf-8").decode(bytes);
}
}
/** `AbortSignal.timeout` rejects with this; a network failure is a `TypeError`. */
function isTimeout(error: unknown): boolean {
return error instanceof Error && error.name === "TimeoutError";
}
type DownloadResult =
| { outcome: "ok"; html?: string; text?: string; bodyTruncated: boolean }
| { outcome: Exclude };
/**
* Follows redirects by hand so that every hop is validated: `redirect:
* "follow"` would let a public URL bounce the server into a private address.
* The whole chain shares one deadline, so a page cannot buy extra time by
* redirecting.
*
* Returns why it gave up rather than throwing, because the caller counts the
* reasons and a thrown `Error` would have to be identified by its message.
*/
async function downloadDocument(rawUrl: string): Promise {
let target = rawUrl;
const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
let url: URL;
try {
url = await resolvePublicUrl(target);
} catch {
return { outcome: "blocked" };
}
let response: Response;
try {
response = await fetch(url, {
headers: REQUEST_HEADERS,
redirect: "manual",
signal: deadline,
});
} catch (error) {
return { outcome: isTimeout(error) ? "timedOut" : "failed" };
}
const location = response.headers.get("location");
if (isRedirect(response.status) && location) {
await response.body?.cancel().catch(() => {});
try {
target = new URL(location, url).toString();
} catch {
return { outcome: "failed" };
}
continue;
}
if (!response.ok) {
await response.body?.cancel().catch(() => {});
return { outcome: "httpError" };
}
const contentType = response.headers.get("content-type") ?? "";
if (!READABLE_CONTENT_TYPES.test(contentType.trim())) {
// PDF is not in READABLE_CONTENT_TYPES but we still want to read it.
if (!contentType.trim().toLowerCase().startsWith("application/pdf")) {
await response.body?.cancel().catch(() => {});
return { outcome: "notADocument" };
}
}
try {
const { bytes, truncated } = await readCappedBytes(
response,
MAX_RESPONSE_BYTES,
);
if (contentType.trim().toLowerCase().startsWith("application/pdf")) {
return {
outcome: "ok",
text: await extractPdfText(bytes),
bodyTruncated: truncated,
};
}
return {
outcome: "ok",
html: decodeDocument(bytes, contentType),
bodyTruncated: truncated,
};
} catch (error) {
return { outcome: isTimeout(error) ? "timedOut" : "failed" };
}
}
return { outcome: "redirectLimit" };
}
/**
* A parser holds an open pdf.js document behind it, so it has to be destroyed
* even when reading throws, or its worker outlives the request. `bytes` is
* transferred to that worker rather than copied, so it comes back empty and
* must not be read after this.
*/
async function extractPdfText(bytes: Uint8Array): Promise {
const parser = new PDFParse({ data: bytes });
try {
const { text } = await parser.getText();
return text;
} finally {
await parser.destroy();
}
}
/**
* Converts a document to plain text, preferring the innermost article-like
* container so that sidebars and site chrome never reach the prompt.
*/
export function extractReadableText(html: string): string {
return convertHtmlToPlainText(html, {
wordwrap: false,
baseElements: { selectors: ["article", "main", '[role="main"]', "body"] },
limits: { maxBaseElements: 1 },
selectors: [
{ selector: "a", options: { ignoreHref: true } },
...SKIPPED_SELECTORS.map((selector) => ({
selector,
format: "skip" as const,
})),
],
});
}
function sliceEvery(text: string, size: number): string[] {
if (text.length <= size) return [text];
const pieces: string[] = [];
for (let start = 0; start < text.length; start += size) {
pieces.push(text.slice(start, start + size));
}
return pieces;
}
function computeOverlap(text: string): string {
// Carry the tail of the last sentence into the next piece so a statement
// split across the cut is complete in at least one of them. Untrimmed so
// the seam preserves the original spacing.
const segments = [...sentenceSegmenter.segment(text)];
const last = segments[segments.length - 1]?.segment ?? text;
return last.length > OVERLAP_CHARS ? last.slice(-OVERLAP_CHARS) : last;
}
export function splitLongPassage(passage: string): string[] {
if (passage.length <= MAX_PASSAGE_CHARS) return [passage];
const chunks: string[] = [];
let current = "";
let overlap = "";
for (const { segment } of sentenceSegmenter.segment(passage)) {
for (const piece of sliceEvery(
segment,
MAX_PASSAGE_CHARS - OVERLAP_CHARS,
)) {
if (
current.length + piece.length > MAX_PASSAGE_CHARS &&
current.length > 0
) {
chunks.push(current.trim());
overlap = computeOverlap(current);
current = "";
}
current += overlap + piece;
overlap = "";
}
}
if (current.trim().length > 0) chunks.push(current.trim());
return chunks;
}
/**
* Splits extracted text into passages small enough to rank individually and
* large enough to stand on their own once quoted in a prompt.
*/
export function splitIntoPassages(text: string): string[] {
const blocks = text
.split(/\n{2,}/)
.map((block) => block.replace(/\s+/g, " ").trim())
.filter((block) => block.length > 0);
const passages: string[] = [];
for (const block of blocks) {
const previous = passages[passages.length - 1];
// A heading or a one-line list item only means something glued to the
// block that follows it.
if (previous !== undefined && previous.length < MIN_PASSAGE_CHARS) {
passages[passages.length - 1] = `${previous} ${block}`;
continue;
}
passages.push(block);
}
return passages.flatMap(splitLongPassage);
}
function tokenize(text: string): string[] {
const tokens: string[] = [];
for (const { segment, isWordLike } of wordSegmenter.segment(text)) {
// Single characters are kept: they are whole words in Chinese, Japanese and
// Korean. The weighting below is what discounts the ones that carry no
// signal, in any language.
if (isWordLike) tokens.push(segment.toLowerCase());
}
return tokens;
}
function recordPassage(
index: Map>,
key: string,
passage: number,
): void {
const passages = index.get(key);
if (passages === undefined) index.set(key, new Set([passage]));
else passages.add(passage);
}
/**
* Indexes the words of a page by the keys a query term can reach them under,
* applying the prefix rule once at build time: every word is stored whole, and
* again under each of its prefixes that a shorter term could match.
*
* Comparing each term against every word instead is quadratic in a product the
* page controls. 500,000 words of Han against a 2,000-character query is 7.5e8
* comparisons, measured at 8s for a single page, and the endpoint reads six of
* them concurrently on one event loop. Indexing is linear in the page and makes
* a lookup a handful of map reads.
*/
function buildWordIndex(passageWords: Set[]): {
whole: Map>;
prefixes: Map>;
} {
const whole = new Map>();
const prefixes = new Map>();
for (const [passage, words] of passageWords.entries()) {
for (const word of words) {
recordPassage(whole, word, passage);
for (
let length = Math.max(
MIN_PREFIX_MATCH_CHARS,
word.length - MAX_INFLECTION_CHARS,
);
length < word.length;
length++
) {
recordPassage(prefixes, word.slice(0, length), passage);
}
}
}
return { whole, prefixes };
}
/**
* The passages holding a word the prefix rule accepts for this term: the term
* itself, the longer words it is a prefix of, and the shorter words that are a
* prefix of it. A term below `MIN_PREFIX_MATCH_CHARS` reaches only the first,
* which is what keeps a single Han character an exact match.
*/
function matchingPassages(
term: string,
index: ReturnType,
): Set {
const matches = new Set();
for (const passage of index.whole.get(term) ?? []) matches.add(passage);
for (const passage of index.prefixes.get(term) ?? []) matches.add(passage);
for (
let length = Math.max(
MIN_PREFIX_MATCH_CHARS,
term.length - MAX_INFLECTION_CHARS,
);
length < term.length;
length++
) {
for (const passage of index.whole.get(term.slice(0, length)) ?? []) {
matches.add(passage);
}
}
return matches;
}
/**
* Weighs each query term by inverse document frequency across the passages of
* the current pool, and returns how much weight each passage matched along
* with the total available.
*
* This is what replaced a stop-word list, which could only ever cover the one
* language it was written in: a term found in most passages of a pool cannot
* say which passage to quote, and a term confined to a few of them can, and
* that holds whatever language the pool is in.
*/
function weighTerms(
queryTerms: string[],
passageWords: Set[],
): { matchedWeights: number[]; totalWeight: number } {
const index = buildWordIndex(passageWords);
const matchedWeights = passageWords.map(() => 0);
let totalWeight = 0;
for (const term of queryTerms) {
const matches = matchingPassages(term, index);
const weight = Math.log(
1 + (passageWords.length - matches.size + 0.5) / (matches.size + 0.5),
);
totalWeight += weight;
for (const passage of matches) matchedWeights[passage] += weight;
}
return { matchedWeights, totalWeight };
}
function scorePassage(
matchedWeight: number,
totalWeight: number,
termCount: number,
positionInPage: number,
): number {
const position = 1 / (1 + positionInPage);
if (termCount === 0) return position;
// Lead passages carry the definition or summary on most pages, so they get a
// prior worth half a matched term: enough to break ties and to float the
// intro of a page whose body never repeats the query, never enough to
// outrank a passage that covers more of it.
return matchedWeight / totalWeight + (0.5 * position) / termCount;
}
/**
* Ranks passages by how well they cover the query, using IDF weighting over
* the whole pool.
*
* @param passages - Candidate passages with their source URLs. The ranking is
* relative to this global pool, so term statistics are computed across all
* pages, not per page.
*/
function rankPassages(
query: string,
passages: { url: string; text: string; positionInPage: number }[],
): RankedPassage[] {
const passageWords = passages.map((p) => new Set(tokenize(p.text)));
const queryTerms = [...new Set(tokenize(query))];
const { matchedWeights, totalWeight } = weighTerms(queryTerms, passageWords);
return passages
.map((p, index) => ({
url: p.url,
text: p.text,
score: scorePassage(
matchedWeights[index],
totalWeight,
queryTerms.length,
p.positionInPage,
),
index,
tokens: passageWords[index],
}))
.sort((a, b) => b.score - a.score || a.index - b.index);
}
const JACCARD_THRESHOLD = 0.9;
/**
* Selects passages from a globally ranked pool, applying a per-URL character
* cap and suppressing near-duplicates via token-set Jaccard similarity.
*
* The same paragraph that appears on several pages reaches the prompt once.
* Passages that are near-duplicates of already-selected ones from a *different*
* URL are skipped, so a page whose passages all overlap with earlier picks
* keeps its snippet-only line with no empty excerpt block.
*/
function selectPassagesAcrossPages(
ranked: RankedPassage[],
maxChars: number,
maxCharsPerUrl: number,
): Map {
const selectedByUrl = new Map();
// Track every selected passage's tokens for cross-URL dedup.
// Passages from the same page are allowed to coexist; dedup is only for
// syndicated paragraphs that appear on different pages.
const selectedTokens: { url: string; tokens: Set }[] = [];
let usedChars = 0;
const usedPerUrl = new Map();
for (const passage of ranked) {
if (usedChars + passage.text.length > maxChars) continue;
if (
(usedPerUrl.get(passage.url) ?? 0) + passage.text.length >
maxCharsPerUrl
)
continue;
// Check against all selected passages from other URLs.
// Size prefilter: Jaccard ≥ 0.9 requires sizes within 10×, so skip
// comparisons where the ratio is too small.
let isDuplicate = false;
for (const { url: otherUrl, tokens: otherTokens } of selectedTokens) {
if (otherUrl === passage.url) continue;
const minSize = Math.min(passage.tokens.size, otherTokens.size);
const maxSize = Math.max(passage.tokens.size, otherTokens.size);
if (minSize * 10 < maxSize * 9) continue;
const intersection = [...passage.tokens].filter((t) =>
otherTokens.has(t),
).length;
// union = a.size + b.size - intersection; avoids allocating a Set.
const union = passage.tokens.size + otherTokens.size - intersection;
if (union > 0 && intersection / union >= JACCARD_THRESHOLD) {
isDuplicate = true;
break;
}
}
if (isDuplicate) continue;
const existing = selectedByUrl.get(passage.url);
if (existing === undefined) {
selectedByUrl.set(passage.url, [passage.text]);
} else {
existing.push(passage.text);
}
selectedTokens.push({ url: passage.url, tokens: passage.tokens });
usedChars += passage.text.length + 1;
usedPerUrl.set(
passage.url,
(usedPerUrl.get(passage.url) ?? 0) + passage.text.length + 1,
);
}
return selectedByUrl;
}
/**
* Picks the passages that best cover the query, within a character budget.
*
* The excerpt comes back best-first rather than in document order, because the
* client trims it again against the model's context and keeps a prefix. Under
* document order that prefix is whatever the page put at the top, which on an
* article is its navigation and its infobox, so the ranking below decided only
* what was transferred and never what the model read.
*
* Lexical and dense (bi-encoder) scores are fused with reciprocal rank fusion;
* when the model is unavailable the lexical score alone is used.
*
* @param passages - Candidate passages from a single page; the wrapper feeds
* them through the production selector so tests exercise the real code path.
*/
export async function selectPassages(
query: string,
passages: string[],
maxChars: number,
): Promise {
// Single-page wrapper over the production selector so tests exercise the
// real code path.
const input = passages.map((text, index) => ({
url: "",
text,
positionInPage: index,
}));
const lexicalRanked = rankPassages(query, input);
// Dense scores from the bi-encoder; empty array when the model is not loaded.
const denseScores = await scorePassages(query, passages);
// Reciprocal rank fusion: combine lexical and dense ranks.
const RRF_K = 60;
const fused = lexicalRanked.map((p, i) => {
const lexicalRank = i + 1;
const denseRank =
denseScores.length > 0
? [...denseScores]
.map((s, idx) => ({ s, idx }))
.sort((a, b) => b.s - a.s)
.findIndex((r) => r.idx === i) + 1
: lexicalRank;
return {
...p,
score:
1 / (RRF_K + lexicalRank) +
(denseScores.length > 0 ? 1 / (RRF_K + denseRank) : 0),
};
});
return selectPassagesAcrossPages(fused, maxChars, maxChars).get("") ?? [];
}
async function fetchPageContent(url: string): Promise<{
url: string;
passages: string[];
durationMs: number;
bodyTruncated: boolean;
} | null> {
const startedAt = performance.now();
const since = () => performance.now() - startedAt;
const download = await downloadDocument(url);
if (download.outcome !== "ok") {
recordPageRead({ outcome: download.outcome, durationMs: since() });
return null;
}
const { bodyTruncated } = download;
try {
const html = download.text ?? extractReadableText(download.html ?? "");
const passages = splitIntoPassages(html);
const content = passages.join("\n");
if (content.length < MIN_USEFUL_CHARS) {
recordPageRead({
outcome: "tooLittleText",
durationMs: since(),
bodyTruncated,
});
return null;
}
// Don't record passage stats here — selection happens globally in
// fetchPageContents, and we need to report how many passages actually
// survived the global budget, not how many the page yielded.
return { url, passages, durationMs: since(), bodyTruncated };
} catch {
recordPageRead({
outcome: "failed",
durationMs: performance.now() - startedAt,
bodyTruncated,
});
return null;
}
}
/**
* Reads the given pages and returns the passages most relevant to the query.
* Pages that fail, time out, or carry no readable text are left out instead of
* failing the batch: a partial set of excerpts still grounds the answer.
*
* Passages from all pages are ranked together, then selected with a per-URL
* character cap and near-duplicate suppression, so syndicated paragraphs that
* appear on several pages reach the prompt once.
*
* Nothing here is logged. Every read is counted instead, by outcome, in
* `pageReadsSinceLastRestart`, since a line naming the query or the URL would
* record what someone searched for.
*
* @param urls - Page URLs to read, already ranked by the search pipeline
* @returns One entry per page that yielded usable text
*/
export async function fetchPageContents(
query: string,
urls: string[],
): Promise {
const results = await Promise.all(urls.map((url) => fetchPageContent(url)));
const pages = results.filter((r): r is FetchedPage => r !== null);
if (pages.length === 0) return [];
const allPassages = pages.flatMap((p) =>
p.passages.map((text, passageIndex) => ({
url: p.url,
text,
positionInPage: passageIndex,
})),
);
const ranked = rankPassages(query, allPassages);
const selectedByUrl = selectPassagesAcrossPages(
ranked,
MAX_PAGE_CHARS * pages.length,
MAX_PAGE_CHARS,
);
// Record page-read stats now that we know how many passages were kept.
for (const page of pages) {
const selected = selectedByUrl.get(page.url) ?? [];
recordPageRead({
outcome: "read",
durationMs: page.durationMs,
bodyTruncated: page.bodyTruncated,
passagesKept: selected.length,
passagesAvailable: page.passages.length,
});
}
return pages
.map((p) => {
const selected = selectedByUrl.get(p.url) ?? [];
return { url: p.url, content: selected.join("\n") };
})
.filter((c) => c.content.length > 0);
}