@adityanair98/api-oracle 0.5.0 → 0.6.0
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/README.md +78 -26
- package/dist/cli.js +24 -11
- package/dist/dashboard/routes/api.js +1 -1
- package/dist/dashboard/server.d.ts +7 -1
- package/dist/dashboard/server.js +48 -10
- package/dist/index.d.ts +1 -1
- package/dist/index.js +4 -5
- package/dist/knowledge/db.d.ts +10 -0
- package/dist/knowledge/db.js +38 -2
- package/dist/knowledge/loader.d.ts +56 -0
- package/dist/knowledge/loader.js +116 -0
- package/dist/knowledge/relevance.d.ts +66 -0
- package/dist/knowledge/relevance.js +267 -0
- package/dist/knowledge/scorer.d.ts +41 -15
- package/dist/knowledge/scorer.js +151 -156
- package/dist/knowledge/search.d.ts +37 -12
- package/dist/knowledge/search.js +74 -71
- package/dist/knowledge/synonyms.d.ts +9 -0
- package/dist/knowledge/synonyms.js +23 -2
- package/dist/server.d.ts +3 -1
- package/dist/server.js +8 -2
- package/dist/tools/check-freshness.js +1 -1
- package/dist/tools/find-api.d.ts +18 -1
- package/dist/tools/find-api.js +109 -63
- package/dist/utils/config.d.ts +1 -0
- package/dist/utils/config.js +4 -1
- package/dist/utils/logger.d.ts +8 -1
- package/dist/utils/logger.js +13 -1
- package/package.json +4 -3
- package/dist/dashboard/public/public/app.js +0 -1004
- package/dist/dashboard/public/public/index.html +0 -142
- package/dist/dashboard/public/public/styles.css +0 -1464
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query relevance model — how well does an entry cover what the user asked for?
|
|
3
|
+
*
|
|
4
|
+
* relevance(entry) = IDF-weighted share of the query's words ("concepts") that
|
|
5
|
+
* the entry covers, where a concept is covered by the best field it appears in:
|
|
6
|
+
*
|
|
7
|
+
* name / slug / subcategory / bestFor / "perfect" use cases 1.0
|
|
8
|
+
* "good" use cases 0.8
|
|
9
|
+
* description / category 0.6
|
|
10
|
+
* "partial" use cases ignored — in this
|
|
11
|
+
* knowledge base "partial" marks known poor fits (e.g. Anthropic:
|
|
12
|
+
* "Generate images or audio", Twilio: "Send transactional emails")
|
|
13
|
+
*
|
|
14
|
+
* A direct word match counts fully; a match only through a synonym group counts
|
|
15
|
+
* SYNONYM_CREDIT. Brand names inside synonym groups (stripe, twilio, mux…) are
|
|
16
|
+
* never used as synonyms — they only count when the user typed them — and
|
|
17
|
+
* product names never match by prefix stemming ("open" !~ "openai").
|
|
18
|
+
*
|
|
19
|
+
* IDF is computed over the entries being ranked, so rare, specific words
|
|
20
|
+
* ("transcription", "resize") outweigh generic ones ("users", "data").
|
|
21
|
+
* Query words that no entry matches at all still count (with a low weight) so
|
|
22
|
+
* mostly-unknown queries get low relevance everywhere.
|
|
23
|
+
*
|
|
24
|
+
* Exports: buildRelevanceModel, entryRelevance, RelevanceModel, tokensMatch,
|
|
25
|
+
* relevanceTokens
|
|
26
|
+
*/
|
|
27
|
+
import type { ApiEntry } from "./schema.js";
|
|
28
|
+
export declare const FIELD_WEIGHTS: {
|
|
29
|
+
readonly primary: 1;
|
|
30
|
+
readonly good: 0.8;
|
|
31
|
+
readonly secondary: 0.6;
|
|
32
|
+
};
|
|
33
|
+
/** Credit for a concept matched only through a synonym */
|
|
34
|
+
export declare const SYNONYM_CREDIT = 0.4;
|
|
35
|
+
/**
|
|
36
|
+
* Weight of an ordered word pair from the query (e.g. "speech text") relative
|
|
37
|
+
* to the mean IDF of its two words. Rewards entries that contain the phrase in
|
|
38
|
+
* the same order, so "speech to text" prefers STT over text-to-speech.
|
|
39
|
+
*/
|
|
40
|
+
export declare const BIGRAM_WEIGHT = 0.5;
|
|
41
|
+
/**
|
|
42
|
+
* Weight of a query word that no entry matches at all (≈ the lowest IDF a
|
|
43
|
+
* known word can have). It can never be covered, so a query made mostly of
|
|
44
|
+
* unknown words gets low relevance everywhere instead of a perfect score for
|
|
45
|
+
* the one word it shares with an entry.
|
|
46
|
+
*/
|
|
47
|
+
export declare const UNKNOWN_WORD_WEIGHT = 1;
|
|
48
|
+
export declare function relevanceTokens(text: string): string[];
|
|
49
|
+
/**
|
|
50
|
+
* Light stemming by prefix: "image"~"images", "upload"~"uploaded",
|
|
51
|
+
* "transcribe"~"transcription", "generate"~"generation", but not
|
|
52
|
+
* "store"~"storefront" or "pay"~"payment".
|
|
53
|
+
*/
|
|
54
|
+
export declare function tokensMatch(a: string, b: string, exactOnly?: ReadonlySet<string>): boolean;
|
|
55
|
+
export interface RelevanceModel {
|
|
56
|
+
/** Concept keys: query words, then ordered word pairs ("speech text") */
|
|
57
|
+
concepts: string[];
|
|
58
|
+
/** concept key → weight (pairs absent from the KB get 0) */
|
|
59
|
+
idf: Map<string, number>;
|
|
60
|
+
/** slug → per-concept match strength, aligned with `concepts` */
|
|
61
|
+
matches: Map<string, number[]>;
|
|
62
|
+
}
|
|
63
|
+
/** Build the relevance model for one query over the entries being ranked. */
|
|
64
|
+
export declare function buildRelevanceModel(query: string, entries: ApiEntry[]): RelevanceModel;
|
|
65
|
+
/** 0..1 — IDF-weighted share of the query's concepts the entry covers */
|
|
66
|
+
export declare function entryRelevance(model: RelevanceModel, slug: string): number;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query relevance model — how well does an entry cover what the user asked for?
|
|
3
|
+
*
|
|
4
|
+
* relevance(entry) = IDF-weighted share of the query's words ("concepts") that
|
|
5
|
+
* the entry covers, where a concept is covered by the best field it appears in:
|
|
6
|
+
*
|
|
7
|
+
* name / slug / subcategory / bestFor / "perfect" use cases 1.0
|
|
8
|
+
* "good" use cases 0.8
|
|
9
|
+
* description / category 0.6
|
|
10
|
+
* "partial" use cases ignored — in this
|
|
11
|
+
* knowledge base "partial" marks known poor fits (e.g. Anthropic:
|
|
12
|
+
* "Generate images or audio", Twilio: "Send transactional emails")
|
|
13
|
+
*
|
|
14
|
+
* A direct word match counts fully; a match only through a synonym group counts
|
|
15
|
+
* SYNONYM_CREDIT. Brand names inside synonym groups (stripe, twilio, mux…) are
|
|
16
|
+
* never used as synonyms — they only count when the user typed them — and
|
|
17
|
+
* product names never match by prefix stemming ("open" !~ "openai").
|
|
18
|
+
*
|
|
19
|
+
* IDF is computed over the entries being ranked, so rare, specific words
|
|
20
|
+
* ("transcription", "resize") outweigh generic ones ("users", "data").
|
|
21
|
+
* Query words that no entry matches at all still count (with a low weight) so
|
|
22
|
+
* mostly-unknown queries get low relevance everywhere.
|
|
23
|
+
*
|
|
24
|
+
* Exports: buildRelevanceModel, entryRelevance, RelevanceModel, tokensMatch,
|
|
25
|
+
* relevanceTokens
|
|
26
|
+
*/
|
|
27
|
+
import { matchSynonymGroups } from "./synonyms.js";
|
|
28
|
+
// ─── Tunables ─────────────────────────────────────────────────────────────────
|
|
29
|
+
export const FIELD_WEIGHTS = {
|
|
30
|
+
primary: 1.0, // name, slug, subcategory, bestFor, perfect use cases
|
|
31
|
+
good: 0.8, // good use cases
|
|
32
|
+
secondary: 0.6, // description, category
|
|
33
|
+
};
|
|
34
|
+
/** Credit for a concept matched only through a synonym */
|
|
35
|
+
export const SYNONYM_CREDIT = 0.4;
|
|
36
|
+
/**
|
|
37
|
+
* Weight of an ordered word pair from the query (e.g. "speech text") relative
|
|
38
|
+
* to the mean IDF of its two words. Rewards entries that contain the phrase in
|
|
39
|
+
* the same order, so "speech to text" prefers STT over text-to-speech.
|
|
40
|
+
*/
|
|
41
|
+
export const BIGRAM_WEIGHT = 0.5;
|
|
42
|
+
/**
|
|
43
|
+
* Weight of a query word that no entry matches at all (≈ the lowest IDF a
|
|
44
|
+
* known word can have). It can never be covered, so a query made mostly of
|
|
45
|
+
* unknown words gets low relevance everywhere instead of a perfect score for
|
|
46
|
+
* the one word it shares with an entry.
|
|
47
|
+
*/
|
|
48
|
+
export const UNKNOWN_WORD_WEIGHT = 1.0;
|
|
49
|
+
// Words that carry no task meaning in a developer's request
|
|
50
|
+
const STOP = new Set([
|
|
51
|
+
"a", "an", "the", "to", "in", "on", "of", "for", "by", "at", "as", "is", "be",
|
|
52
|
+
"it", "or", "and", "my", "me", "we", "us", "our", "i", "im", "do", "so", "up",
|
|
53
|
+
"if", "no", "but", "with", "from", "into", "via", "using", "use", "that", "this",
|
|
54
|
+
"these", "those", "them", "their", "they", "you", "your", "what", "which", "when",
|
|
55
|
+
"how", "who", "can", "will", "would", "should", "could", "want", "need", "needs",
|
|
56
|
+
"get", "let", "lets", "make", "add", "build", "just", "some", "any", "all", "also",
|
|
57
|
+
"app", "apps", "application", "way", "best", "good", "thing", "things", "something",
|
|
58
|
+
"anything", "everything", "like", "than", "then", "there", "where", "without",
|
|
59
|
+
"about", "have", "has", "are", "was", "been", "not", "one", "more", "very", "really",
|
|
60
|
+
"know", "someone", "somebody", "whenever", "every", "each",
|
|
61
|
+
// Preference modifiers: handled by constraints / phrase detection, not task words
|
|
62
|
+
"cheap", "cheaper", "cheapest", "affordable", "budget", "free", "reliable", "most",
|
|
63
|
+
"easy", "easiest", "simple", "simplest", "quick", "quickly", "popular", "recommended",
|
|
64
|
+
// Domain-generic: every entry is an API / SDK / service
|
|
65
|
+
"api", "apis", "sdk", "sdks", "service", "services", "tool", "tools",
|
|
66
|
+
]);
|
|
67
|
+
// Synonym-group terms that are product names. Kept out of synonym expansion.
|
|
68
|
+
const BRAND_TERMS = new Set([
|
|
69
|
+
"stripe", "sendgrid", "resend", "postmark", "twilio", "vonage", "algolia",
|
|
70
|
+
"openai", "anthropic", "claude", "gpt", "sentry", "posthog", "mixpanel",
|
|
71
|
+
"amplitude", "onesignal", "knock", "novu", "mux", "imgix", "vercel", "netlify",
|
|
72
|
+
"railway", "heroku", "cloudflare", "shopify", "contentful", "sanity", "strapi",
|
|
73
|
+
"typeform", "calendly", "cal", "snyk", "elasticsearch", "s3", "r2",
|
|
74
|
+
]);
|
|
75
|
+
// ─── Tokenisation and fuzzy word matching ─────────────────────────────────────
|
|
76
|
+
export function relevanceTokens(text) {
|
|
77
|
+
return text
|
|
78
|
+
.toLowerCase()
|
|
79
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
80
|
+
.split(/\s+/)
|
|
81
|
+
.filter((t) => t.length >= 2 && !STOP.has(t));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Light stemming by prefix: "image"~"images", "upload"~"uploaded",
|
|
85
|
+
* "transcribe"~"transcription", "generate"~"generation", but not
|
|
86
|
+
* "store"~"storefront" or "pay"~"payment".
|
|
87
|
+
*/
|
|
88
|
+
export function tokensMatch(a, b, exactOnly) {
|
|
89
|
+
if (a === b)
|
|
90
|
+
return true;
|
|
91
|
+
// Product names never match by prefix: "open" !~ "openai", "upload" !~ "uploadthing"
|
|
92
|
+
if (exactOnly && (exactOnly.has(a) || exactOnly.has(b)))
|
|
93
|
+
return false;
|
|
94
|
+
const [s, l] = a.length <= b.length ? [a, b] : [b, a];
|
|
95
|
+
if (s.length >= 3 && l.startsWith(s) && l.length - s.length <= 3)
|
|
96
|
+
return true;
|
|
97
|
+
let p = 0;
|
|
98
|
+
while (p < s.length && s[p] === l[p])
|
|
99
|
+
p++;
|
|
100
|
+
return p >= 6 && p >= 0.75 * s.length;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Words in entry names that are ordinary English and should keep stemming
|
|
104
|
+
* ("stream" ~ "streaming", "maps" ~ "map"). Every other name token is a
|
|
105
|
+
* product name and only matches exactly.
|
|
106
|
+
*/
|
|
107
|
+
const GENERIC_NAME_WORDS = new Set([
|
|
108
|
+
"ai", "replicate", "resemble", "stability", "sanity", "stream", "chat", "neon",
|
|
109
|
+
"fly", "io", "railway", "workers", "spaces", "google", "maps", "platform",
|
|
110
|
+
"channels", "knock", "lemon", "square", "temporal", "trigger", "dev", "paddle",
|
|
111
|
+
"medusa", "clerk", "resend", "com", "aws", "s3", "r2",
|
|
112
|
+
]);
|
|
113
|
+
/** Product-name tokens of the entries being ranked (+ BRAND_TERMS) */
|
|
114
|
+
function productNameTokens(entries) {
|
|
115
|
+
const names = new Set(BRAND_TERMS);
|
|
116
|
+
for (const e of entries) {
|
|
117
|
+
for (const t of relevanceTokens(e.name))
|
|
118
|
+
if (!GENERIC_NAME_WORDS.has(t))
|
|
119
|
+
names.add(t);
|
|
120
|
+
}
|
|
121
|
+
return names;
|
|
122
|
+
}
|
|
123
|
+
/** Never matches a word — separates texts so no bigram spans two of them */
|
|
124
|
+
const BOUNDARY = "|";
|
|
125
|
+
function field(texts, weight) {
|
|
126
|
+
const sequence = [];
|
|
127
|
+
for (const text of texts) {
|
|
128
|
+
if (sequence.length > 0)
|
|
129
|
+
sequence.push(BOUNDARY);
|
|
130
|
+
sequence.push(...relevanceTokens(text));
|
|
131
|
+
}
|
|
132
|
+
const tokens = [...new Set(sequence.filter((t) => t !== BOUNDARY))];
|
|
133
|
+
return { tokens, sequence, weight };
|
|
134
|
+
}
|
|
135
|
+
function indexEntry(entry) {
|
|
136
|
+
const primary = [
|
|
137
|
+
entry.name,
|
|
138
|
+
entry.slug.replace(/-/g, " "),
|
|
139
|
+
entry.subcategory.replace(/-/g, " "),
|
|
140
|
+
entry.bestFor,
|
|
141
|
+
...entry.useCases.filter((u) => u.fit === "perfect").map((u) => u.task),
|
|
142
|
+
];
|
|
143
|
+
const good = entry.useCases.filter((u) => u.fit === "good").map((u) => u.task);
|
|
144
|
+
const secondary = [entry.description, entry.category];
|
|
145
|
+
return [
|
|
146
|
+
field(primary, FIELD_WEIGHTS.primary),
|
|
147
|
+
field(good, FIELD_WEIGHTS.good),
|
|
148
|
+
field(secondary, FIELD_WEIGHTS.secondary),
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
function buildConcepts(query, match) {
|
|
152
|
+
const sequence = relevanceTokens(query);
|
|
153
|
+
const tokens = [...new Set(sequence)];
|
|
154
|
+
const groups = matchSynonymGroups(query);
|
|
155
|
+
const words = tokens.map((token) => {
|
|
156
|
+
const synonyms = new Set();
|
|
157
|
+
for (const { term, group } of groups) {
|
|
158
|
+
// Attach the group to the query word(s) that triggered it
|
|
159
|
+
if (!relevanceTokens(term).some((t) => match(t, token)))
|
|
160
|
+
continue;
|
|
161
|
+
for (const syn of group) {
|
|
162
|
+
for (const st of relevanceTokens(syn)) {
|
|
163
|
+
if (!BRAND_TERMS.has(st) && !match(st, token))
|
|
164
|
+
synonyms.add(st);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { kind: "word", key: token, token, synonyms: [...synonyms] };
|
|
169
|
+
});
|
|
170
|
+
const pairs = [];
|
|
171
|
+
const seenPairs = new Set();
|
|
172
|
+
for (let i = 0; i + 1 < sequence.length; i++) {
|
|
173
|
+
const key = `${sequence[i]} ${sequence[i + 1]}`;
|
|
174
|
+
if (sequence[i] === sequence[i + 1] || seenPairs.has(key))
|
|
175
|
+
continue;
|
|
176
|
+
seenPairs.add(key);
|
|
177
|
+
pairs.push({ kind: "pair", key, first: sequence[i], second: sequence[i + 1] });
|
|
178
|
+
}
|
|
179
|
+
return { words, pairs };
|
|
180
|
+
}
|
|
181
|
+
function fieldHas(field, word, match) {
|
|
182
|
+
return field.tokens.some((t) => match(t, word));
|
|
183
|
+
}
|
|
184
|
+
/** Max distance between the two words of a pair ("send … emails" = 2) */
|
|
185
|
+
const PAIR_WINDOW = 2;
|
|
186
|
+
function fieldHasPair(field, first, second, match) {
|
|
187
|
+
const seq = field.sequence;
|
|
188
|
+
for (let i = 0; i < seq.length; i++) {
|
|
189
|
+
if (!match(seq[i], first))
|
|
190
|
+
continue;
|
|
191
|
+
for (let j = i + 1; j <= i + PAIR_WINDOW && j < seq.length; j++) {
|
|
192
|
+
if (seq[j] === BOUNDARY)
|
|
193
|
+
break;
|
|
194
|
+
if (match(seq[j], second))
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
/** 0..1 — how strongly one entry covers one concept */
|
|
201
|
+
function conceptMatch(concept, fields, match) {
|
|
202
|
+
let best = 0;
|
|
203
|
+
for (const field of fields) {
|
|
204
|
+
if (field.weight <= best)
|
|
205
|
+
continue;
|
|
206
|
+
if (concept.kind === "pair") {
|
|
207
|
+
if (fieldHasPair(field, concept.first, concept.second, match))
|
|
208
|
+
best = field.weight;
|
|
209
|
+
}
|
|
210
|
+
else if (fieldHas(field, concept.token, match)) {
|
|
211
|
+
best = field.weight;
|
|
212
|
+
}
|
|
213
|
+
else if (field.weight * SYNONYM_CREDIT > best &&
|
|
214
|
+
concept.synonyms.some((s) => fieldHas(field, s, match))) {
|
|
215
|
+
best = field.weight * SYNONYM_CREDIT;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return best;
|
|
219
|
+
}
|
|
220
|
+
/** Build the relevance model for one query over the entries being ranked. */
|
|
221
|
+
export function buildRelevanceModel(query, entries) {
|
|
222
|
+
const productNames = productNameTokens(entries);
|
|
223
|
+
const match = (a, b) => tokensMatch(a, b, productNames);
|
|
224
|
+
const { words, pairs } = buildConcepts(query, match);
|
|
225
|
+
const concepts = [...words, ...pairs];
|
|
226
|
+
const indexes = entries.map((e) => ({ slug: e.slug, fields: indexEntry(e) }));
|
|
227
|
+
const matches = new Map();
|
|
228
|
+
for (const { slug, fields } of indexes) {
|
|
229
|
+
matches.set(slug, concepts.map((c) => conceptMatch(c, fields, match)));
|
|
230
|
+
}
|
|
231
|
+
const N = entries.length;
|
|
232
|
+
const idf = new Map();
|
|
233
|
+
words.forEach((c, i) => {
|
|
234
|
+
let df = 0; // entries that match this word at all (directly or via synonym)
|
|
235
|
+
let directDf = 0; // entries that contain the word itself
|
|
236
|
+
for (const { slug, fields } of indexes) {
|
|
237
|
+
if (matches.get(slug)[i] > 0)
|
|
238
|
+
df++;
|
|
239
|
+
if (fields.some((f) => fieldHas(f, c.token, match)))
|
|
240
|
+
directDf++;
|
|
241
|
+
}
|
|
242
|
+
idf.set(c.key, df === 0 ? UNKNOWN_WORD_WEIGHT : Math.log((N + 1) / (Math.max(directDf, 1) + 1)) + 1);
|
|
243
|
+
});
|
|
244
|
+
pairs.forEach((p, j) => {
|
|
245
|
+
const i = words.length + j;
|
|
246
|
+
const anyMatch = indexes.some(({ slug }) => matches.get(slug)[i] > 0);
|
|
247
|
+
const a = idf.get(p.first) ?? 0;
|
|
248
|
+
const b = idf.get(p.second) ?? 0;
|
|
249
|
+
// Only phrases that exist somewhere in the KB
|
|
250
|
+
idf.set(p.key, anyMatch ? (BIGRAM_WEIGHT * (a + b)) / 2 : 0);
|
|
251
|
+
});
|
|
252
|
+
return { concepts: concepts.map((c) => c.key), idf, matches };
|
|
253
|
+
}
|
|
254
|
+
/** 0..1 — IDF-weighted share of the query's concepts the entry covers */
|
|
255
|
+
export function entryRelevance(model, slug) {
|
|
256
|
+
const m = model.matches.get(slug);
|
|
257
|
+
if (!m)
|
|
258
|
+
return 0;
|
|
259
|
+
let num = 0;
|
|
260
|
+
let den = 0;
|
|
261
|
+
model.concepts.forEach((c, i) => {
|
|
262
|
+
const w = model.idf.get(c) ?? 0;
|
|
263
|
+
num += w * m[i];
|
|
264
|
+
den += w;
|
|
265
|
+
});
|
|
266
|
+
return den === 0 ? 0 : num / den;
|
|
267
|
+
}
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Scoring/ranking engine — ranks API entries by relevance to a task description.
|
|
3
|
-
* Uses weighted multi-factor scoring across 6 dimensions.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Relevance gates the score. Query-independent factors (quality, developer
|
|
5
|
+
* experience, pricing, recency) can only ADJUST an entry that is already
|
|
6
|
+
* relevant — they can never make an unrelated API rank:
|
|
7
|
+
*
|
|
8
|
+
* relevance = min(coverage + keyword bonus, 1) × categoryFit ∈ [0, 1]
|
|
9
|
+
* prior = weighted mean of quality, DX, pricing, recency ∈ [0, 1]
|
|
10
|
+
* score = relevance × (1 − PRIOR_INFLUENCE + PRIOR_INFLUENCE × prior)
|
|
11
|
+
* + keywordRelevance × KEYWORD_RANK_WEIGHT × categoryFit
|
|
12
|
+
*
|
|
13
|
+
* With PRIOR_INFLUENCE = 0.25 the prior scales a score between 0.75× and 1.0×
|
|
14
|
+
* of its relevance, so a high-quality but irrelevant API (relevance≈0) scores
|
|
15
|
+
* ≈0. The keyword term (the user named the API or its subcategory) sits
|
|
16
|
+
* outside the clamp so a named API wins ties against entries that merely
|
|
17
|
+
* mention it ("firebase" → Firebase, not "the open-source Firebase
|
|
18
|
+
* alternative"). search.ts additionally drops candidates below a relevance
|
|
19
|
+
* threshold and those in a clearly incompatible category.
|
|
20
|
+
*
|
|
21
|
+
* Exports: SCORING_WEIGHTS, DEFAULT_WEIGHTS, ScoreWeights, PRIOR_INFLUENCE, KEYWORD_RANK_WEIGHT,
|
|
22
|
+
* scoreEntry, rankEntries, tokenize, scoreDevExperience, categoryFit,
|
|
23
|
+
* computeConfidence, confidenceLabel, ScoringContext
|
|
7
24
|
*/
|
|
8
25
|
import type { ApiEntry } from "./schema.js";
|
|
9
26
|
import type { SearchConstraints, RankedResult } from "./search.js";
|
|
27
|
+
import { type RelevanceModel } from "./relevance.js";
|
|
10
28
|
export interface ScoreWeights {
|
|
11
29
|
useCaseFit: number;
|
|
12
30
|
qualityScore: number;
|
|
@@ -19,19 +37,23 @@ export interface ScoreWeights {
|
|
|
19
37
|
export declare const SCORING_WEIGHTS: ScoreWeights;
|
|
20
38
|
/** Alias for backwards compatibility */
|
|
21
39
|
export declare const DEFAULT_WEIGHTS: ScoreWeights;
|
|
40
|
+
/** How much the query-independent prior can move a relevant entry's score */
|
|
41
|
+
export declare const PRIOR_INFLUENCE = 0.25;
|
|
42
|
+
/**
|
|
43
|
+
* Ranking weight of keywordRelevance outside the relevance clamp
|
|
44
|
+
* (= SCORING_WEIGHTS.keywordRelevance by default; see scoreEntry).
|
|
45
|
+
*/
|
|
46
|
+
export declare const KEYWORD_RANK_WEIGHT: number;
|
|
22
47
|
/**
|
|
23
48
|
* Normalize text to a set of lowercase tokens.
|
|
24
49
|
* Allows 2-char tokens (important for "ai", "db", "ml", "go").
|
|
25
50
|
*/
|
|
26
51
|
export declare function tokenize(text: string): Set<string>;
|
|
27
52
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* - bestFor (25%) — concise purpose statement
|
|
31
|
-
* - Description (20%) — first 200 chars (signal-dense)
|
|
32
|
-
* - Category signals (15%) — broad category alignment
|
|
53
|
+
* Relevance multiplier from category alignment with the detected task category:
|
|
54
|
+
* same 1.0, related 0.9, unrelated 0.7. No detection → 1.0 (neutral).
|
|
33
55
|
*/
|
|
34
|
-
export declare function
|
|
56
|
+
export declare function categoryFit(entryCategory: string, detectedCategory: string | null, detectedConfidence: number): number;
|
|
35
57
|
/**
|
|
36
58
|
* DX sub-scoring with measurable, explicit factors:
|
|
37
59
|
* - TypeScript as primary SDK: 3 pts
|
|
@@ -45,19 +67,23 @@ export declare function scoreUseCaseFit(entry: ApiEntry, queryTokens: Set<string
|
|
|
45
67
|
*/
|
|
46
68
|
export declare function scoreDevExperience(entry: ApiEntry, constraints?: SearchConstraints): number;
|
|
47
69
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* High confidence (>0.8): strong category match + high useCaseFit score
|
|
51
|
-
* Medium confidence (0.5-0.8): partial signals
|
|
52
|
-
* Low confidence (<0.5): weak query, no clear category
|
|
70
|
+
* Confidence (0-1) for the top result: how relevant it is, how clear the
|
|
71
|
+
* category is, and how far it is ahead of the runner-up.
|
|
53
72
|
*/
|
|
54
73
|
export declare function computeConfidence(topResult: RankedResult, detectedCategory: string | null, detectedConfidence: number, allResults: RankedResult[]): number;
|
|
55
74
|
/** Human-readable confidence label */
|
|
56
75
|
export declare function confidenceLabel(confidence: number): "high" | "medium" | "low";
|
|
57
76
|
export interface ScoringContext {
|
|
58
|
-
|
|
77
|
+
/** Kept for compatibility; relevance uses the original query + synonym groups */
|
|
78
|
+
expandedQuery?: string;
|
|
59
79
|
detectedCategory: string | null;
|
|
60
80
|
detectedConfidence: number;
|
|
81
|
+
/** Prebuilt relevance model over the full candidate set (IDF needs the corpus) */
|
|
82
|
+
relevanceModel?: RelevanceModel;
|
|
61
83
|
}
|
|
62
84
|
export declare function scoreEntry(entry: ApiEntry, query: string, constraints?: SearchConstraints, weights?: ScoreWeights, context?: ScoringContext): RankedResult;
|
|
85
|
+
/**
|
|
86
|
+
* Score and sort entries (no filtering — see findApis for relevance gating).
|
|
87
|
+
* The relevance model is built over `entries`, so IDF reflects the corpus.
|
|
88
|
+
*/
|
|
63
89
|
export declare function rankEntries(entries: ApiEntry[], query: string, constraints?: SearchConstraints, topN?: number, weights?: ScoreWeights, context?: ScoringContext): RankedResult[];
|