Files
2026-07-13 12:38:34 +08:00

434 lines
13 KiB
TypeScript

import {
buildBundledIndex,
buildIndex,
extractSections,
getBundleSearchIndex,
pageSearchKey,
readPageByUrl,
toCleanMarkdown,
tokenize,
type DocPage,
} from './docs';
/**
* search_docs — find the most relevant Composio docs pages for a query.
*
* This is a local, in-memory lexical retriever. It uses a BM25-style body score
* plus field boosts for title, description, headings, and URL. The top results
* include full page content (bounded per page), so the model gets rich context
* in the same fast tool call instead of doing a serial search -> read round trip.
*/
// Collection priority: docs first, then examples, then references and toolkits.
// (Curated knowledge ranks with docs.) A toolkit-name query still surfaces its
// toolkit page because nothing else matches it.
const PRIORITY: Record<DocPage['collection'], number> = {
docs: 1.3,
knowledge: 1.3,
examples: 1.1,
reference: 0.85,
toolkits: 0.9,
};
export const DEFAULT_SEARCH_LIMIT = 5;
const DEFAULT_LIMIT = DEFAULT_SEARCH_LIMIT;
export const DEFAULT_CONTENT_RESULT_COUNT = 4;
export const DEFAULT_MAX_CONTENT_CHARS = 10_000;
export const DEFAULT_MAX_SECTIONS = 16;
const BM25_K1 = 1.2;
const BM25_B = 0.75;
const PERF_LOG_ENABLED = process.env.DOCS_AGENT_SEARCH_PERF_LOG === '1';
const PERF_LOG_QUERY = process.env.DOCS_AGENT_SEARCH_LOG_QUERY === '1';
type CorpusEntry = {
page: DocPage;
termCounts: Map<string, number>;
length: number;
};
type Corpus = {
entries: CorpusEntry[];
documentFrequency: Map<string, number>;
averageLength: number;
};
type CorpusSource = 'precomputed' | 'runtime';
type PrecomputedCorpusResult =
| { corpus: Corpus; fallbackReason?: never }
| { corpus?: never; fallbackReason: string };
type CorpusLoad = {
corpus: Corpus;
source: CorpusSource;
cached: boolean;
loadMs: number;
fallbackReason?: string;
};
let corpusCache: Corpus | undefined;
let corpusCacheSource: CorpusSource | undefined;
let corpusCacheFallbackReason: string | undefined;
function roundMs(ms: number): number {
return Math.round(ms * 100) / 100;
}
function logPerf(payload: Record<string, unknown>) {
if (!PERF_LOG_ENABLED) return;
console.info(`[docs-agent:search_docs] ${JSON.stringify(payload)}`);
}
function termCountsFor(page: DocPage): Map<string, number> {
const counts = new Map<string, number>();
for (const token of tokenize(page.lowerText)) {
counts.set(token, (counts.get(token) ?? 0) + 1);
}
return counts;
}
function buildRuntimeCorpus(pages: DocPage[]): Corpus {
const entries = pages.map(page => {
const termCounts = termCountsFor(page);
return {
page,
termCounts,
length: [...termCounts.values()].reduce((sum, count) => sum + count, 0),
};
});
const documentFrequency = new Map<string, number>();
for (const entry of entries) {
for (const term of entry.termCounts.keys()) {
documentFrequency.set(term, (documentFrequency.get(term) ?? 0) + 1);
}
}
return {
entries,
documentFrequency,
averageLength:
entries.reduce((sum, entry) => sum + entry.length, 0) / Math.max(entries.length, 1),
};
}
function buildPrecomputedCorpus(pages: DocPage[]): PrecomputedCorpusResult {
const search = getBundleSearchIndex();
if (!search) return { fallbackReason: 'missing-precomputed-index' };
if (search.entries.length !== pages.length) {
return { fallbackReason: `entry-count-mismatch:${search.entries.length}:${pages.length}` };
}
const entries: CorpusEntry[] = [];
for (let index = 0; index < pages.length; index++) {
const page = pages[index];
const entry = search.entries[index];
if (!entry) return { fallbackReason: `missing-entry:${index}` };
if (entry.key !== pageSearchKey(page)) {
return { fallbackReason: `entry-key-mismatch:${index}:${page.url}` };
}
entries.push({
page,
termCounts: new Map(entry.terms),
length: entry.length,
});
}
return {
corpus: {
entries,
documentFrequency: new Map(search.documentFrequency),
averageLength: search.averageLength,
},
};
}
function getCorpus(): CorpusLoad {
if (corpusCache && corpusCacheSource) {
return {
corpus: corpusCache,
source: corpusCacheSource,
cached: true,
loadMs: 0,
fallbackReason: corpusCacheFallbackReason,
};
}
const started = performance.now();
const bundledPages = buildBundledIndex();
const bundledPrecomputed = buildPrecomputedCorpus(bundledPages);
if (bundledPrecomputed.corpus) {
corpusCache = bundledPrecomputed.corpus;
corpusCacheSource = 'precomputed';
} else {
const pages = buildIndex();
const livePrecomputed = buildPrecomputedCorpus(pages);
if (livePrecomputed.corpus) {
corpusCache = livePrecomputed.corpus;
corpusCacheSource = 'precomputed';
} else {
corpusCache = buildRuntimeCorpus(pages);
corpusCacheSource = 'runtime';
corpusCacheFallbackReason = `bundle:${bundledPrecomputed.fallbackReason};live:${livePrecomputed.fallbackReason}`;
}
}
return {
corpus: corpusCache,
source: corpusCacheSource,
cached: false,
loadMs: performance.now() - started,
fallbackReason: corpusCacheFallbackReason,
};
}
function idf(term: string, corpus: Corpus): number {
const n = corpus.entries.length;
const df = corpus.documentFrequency.get(term) ?? 0;
return Math.log(1 + (n - df + 0.5) / (df + 0.5));
}
function bm25(entry: CorpusEntry, terms: string[], corpus: Corpus): number {
let total = 0;
for (const term of terms) {
const tf = entry.termCounts.get(term) ?? 0;
if (tf === 0) continue;
const denominator =
tf + BM25_K1 * (1 - BM25_B + BM25_B * (entry.length / corpus.averageLength));
total += idf(term, corpus) * ((tf * (BM25_K1 + 1)) / denominator);
}
return total;
}
function fieldBoost(page: DocPage, terms: string[]): number {
const title = page.title.toLowerCase();
const description = page.description.toLowerCase();
const url = page.url.toLowerCase();
let total = 0;
for (const term of terms) {
if (title.includes(term)) total += 12;
if (description.includes(term)) total += 5;
for (const heading of page.headings) if (heading.includes(term)) total += 4;
if (url.includes(term)) total += 6;
}
return total;
}
function score(entry: CorpusEntry, terms: string[], corpus: Corpus): number {
let total = bm25(entry, terms, corpus) * 8 + fieldBoost(entry.page, terms);
const isMigrationIntent = terms.some(term =>
['migration', 'migrate', 'direct', 'legacy', 'v1', 'v2'].includes(term)
);
// Heavily downrank legacy (direct-execution) pages so they only surface when
// nothing in the session-based docs matches.
if (entry.page.legacy) total *= 0.12;
// Migration pages mention both old and current APIs a lot; keep them for
// migration/direct-execution questions, but don't let them beat canonical
// session docs for ordinary usage questions.
if (!isMigrationIntent && entry.page.url.includes('/migration-guide')) total *= 0.35;
return total * (PRIORITY[entry.page.collection] ?? 1);
}
function firstTermMatch(text: string, terms: string[]): number {
const lower = text.toLowerCase();
return (
terms
.map(term => lower.indexOf(term))
.filter(index => index >= 0)
.sort((a, b) => a - b)[0] ?? 0
);
}
function excerpt(
text: string,
terms: string[],
maxChars: number
): { value: string; truncated: boolean } {
const at = firstTermMatch(text, terms);
const start = Math.max(0, at - 180);
const end = Math.min(text.length, start + maxChars);
const slice = text.slice(start, end).trim();
const prefix = start > 0 ? '…' : '';
const suffix = end < text.length ? '…' : '';
return { value: `${prefix}${slice}${suffix}`, truncated: start > 0 || end < text.length };
}
function snippet(page: DocPage, terms: string[]): string {
return excerpt(page.text, terms, 360).value;
}
function dedupeByUrl(ranked: { page: DocPage; s: number }[]): { page: DocPage; s: number }[] {
const seen = new Set<string>();
const deduped: { page: DocPage; s: number }[] = [];
for (const item of ranked) {
if (seen.has(item.page.url)) continue;
seen.add(item.page.url);
deduped.push(item);
}
return deduped;
}
function contentFor(
page: DocPage,
terms: string[],
maxContentChars: number,
maxSections: number
) {
if (page.collection === 'knowledge') {
const evidence = excerpt(page.text, terms, maxContentChars);
return {
content: evidence.value,
contentTruncated: evidence.truncated,
};
}
const found = readPageByUrl(page.url);
if (found) {
const markdown = toCleanMarkdown(found.raw);
const evidence = excerpt(markdown, terms, maxContentChars);
return {
sections: extractSections(markdown).slice(0, maxSections),
content: evidence.value,
contentTruncated: evidence.truncated,
};
}
const evidence = excerpt(page.text, terms, maxContentChars);
return {
content: evidence.value,
contentTruncated: evidence.truncated,
};
}
export type SearchDocsInvocation = 'tool' | 'eager_context' | 'eager_preview' | (string & {});
export type SearchDocsOptions = {
limit?: number;
invocation?: SearchDocsInvocation;
contentResultCount?: number;
maxContentChars?: number;
maxSections?: number;
/** When false, skip page hydration and return metadata/snippets only. */
hydrateContent?: boolean;
};
export const EAGER_SEARCH_ENABLED = process.env.DOCS_AGENT_EAGER_SEARCH !== '0';
export function shouldRunEagerDocsSearch(text: string): boolean {
if (!EAGER_SEARCH_ENABLED) return false;
if (text.trim().length < 3) return false;
const normalized = text.toLowerCase();
const accountTerms =
/\b(account|billing|invoice|payment|refund|subscription|ticket|dashboard|workspace|organization|org|api key)\b/;
const personalTerms =
/\b(my|our|me|us|latest|current|status|paid|check|look up|lookup|change|cancel|delete|update)\b/;
return !(accountTerms.test(normalized) && personalTerms.test(normalized));
}
export type SearchDocsResult = {
retrieval: 'bm25-lexical-local';
results: Array<{
title: string;
url: string;
description: string;
snippet: string;
sections?: { title: string; anchor: string }[];
content?: string;
contentTruncated?: boolean;
}>;
};
export function searchDocs(query: string, options: SearchDocsOptions = {}): SearchDocsResult {
const limit = options.limit ?? DEFAULT_LIMIT;
const contentResultCount = options.contentResultCount ?? DEFAULT_CONTENT_RESULT_COUNT;
const maxContentChars = options.maxContentChars ?? DEFAULT_MAX_CONTENT_CHARS;
const maxSections = options.maxSections ?? DEFAULT_MAX_SECTIONS;
const hydrateContent = options.hydrateContent ?? true;
const totalStarted = performance.now();
const tokenizeStarted = performance.now();
const terms = tokenize(query);
// Fall back to raw terms if the query was all stopwords.
const effective =
terms.length > 0
? terms
: query
.toLowerCase()
.split(/\s+/)
.filter(t => t.length > 1);
const tokenizeMs = performance.now() - tokenizeStarted;
const corpusLoad = getCorpus();
const corpus = corpusLoad.corpus;
const rankStarted = performance.now();
const ranked = dedupeByUrl(
corpus.entries
.map(entry => ({ page: entry.page, s: score(entry, effective, corpus) }))
.filter(({ s }) => s > 0)
.sort((a, b) => b.s - a.s)
).slice(0, limit);
const rankMs = performance.now() - rankStarted;
const hydrateStarted = performance.now();
const results = ranked.map(({ page }, index) => ({
title: page.title,
url: page.url,
description: page.description,
snippet: snippet(page, effective),
...(hydrateContent && index < contentResultCount
? contentFor(page, effective, maxContentChars, maxSections)
: {}),
}));
const hydrateMs = performance.now() - hydrateStarted;
const totalMs = performance.now() - totalStarted;
logPerf({
event: 'search_docs',
invocation: options.invocation ?? 'tool',
retrieval: 'bm25-lexical-local',
totalMs: roundMs(totalMs),
tokenizeMs: roundMs(tokenizeMs),
corpusLoadMs: roundMs(corpusLoad.loadMs),
rankMs: roundMs(rankMs),
hydrateMs: roundMs(hydrateMs),
corpusSource: corpusLoad.source,
corpusCached: corpusLoad.cached,
corpusFallbackReason: corpusLoad.fallbackReason,
queryChars: query.length,
termCount: effective.length,
limit,
resultCount: results.length,
contentResultCount: results.filter(result => 'content' in result).length,
corpusPages: corpus.entries.length,
corpusTerms: corpus.documentFrequency.size,
topUrls: results.slice(0, 5).map(result => result.url),
...(PERF_LOG_QUERY ? { query, terms: effective } : {}),
});
return {
retrieval: 'bm25-lexical-local',
results,
};
}