@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.
@@ -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
- * Exports: SCORING_WEIGHTS, DEFAULT_WEIGHTS, ScoreWeights, scoreEntry, rankEntries,
6
- * tokenize, scoreUseCaseFit, scoreDevExperience
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
- * Score against multiple fields with weighted relevance:
29
- * - Use case tasks (40%) — with fit multiplier, best match wins
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 scoreUseCaseFit(entry: ApiEntry, queryTokens: Set<string>): number;
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
- * Compute a confidence value (0-1) for how certain we are about the top result.
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
- expandedQuery: string;
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[];