@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.
@@ -1,10 +1,28 @@
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
  */
25
+ import { buildRelevanceModel, entryRelevance } from "./relevance.js";
8
26
  /** Default weights — must sum to exactly 1.0 */
9
27
  export const SCORING_WEIGHTS = {
10
28
  useCaseFit: 0.35,
@@ -16,7 +34,14 @@ export const SCORING_WEIGHTS = {
16
34
  };
17
35
  /** Alias for backwards compatibility */
18
36
  export const DEFAULT_WEIGHTS = SCORING_WEIGHTS;
19
- // ─── Tokenizer ────────────────────────────────────────────────────────────────
37
+ /** How much the query-independent prior can move a relevant entry's score */
38
+ export const PRIOR_INFLUENCE = 0.25;
39
+ /**
40
+ * Ranking weight of keywordRelevance outside the relevance clamp
41
+ * (= SCORING_WEIGHTS.keywordRelevance by default; see scoreEntry).
42
+ */
43
+ export const KEYWORD_RANK_WEIGHT = SCORING_WEIGHTS.keywordRelevance;
44
+ // ─── Tokenizer (used by the TF-IDF index) ─────────────────────────────────────
20
45
  // Stop words that add noise without semantic value in the scoring context
21
46
  const STOP_WORDS = new Set([
22
47
  "the", "and", "for", "are", "but", "not", "you", "all",
@@ -38,7 +63,7 @@ export function tokenize(text) {
38
63
  .filter((t) => t.length >= 2 && !STOP_WORDS.has(t));
39
64
  return new Set(tokens);
40
65
  }
41
- /** Compute Jaccard-like overlap score between two token sets */
66
+ /** Overlap score between two token sets, normalised by the larger set */
42
67
  function tokenOverlap(a, b) {
43
68
  if (a.size === 0 || b.size === 0)
44
69
  return 0;
@@ -49,75 +74,53 @@ function tokenOverlap(a, b) {
49
74
  }
50
75
  return overlap / Math.max(a.size, b.size);
51
76
  }
52
- // ─── Category Signals ─────────────────────────────────────────────────────────
53
- const CATEGORY_SIGNALS = {
54
- email: ["email", "mail", "smtp", "send", "transactional", "newsletter", "inbox", "bounce", "mailer"],
55
- payments: ["payment", "pay", "checkout", "charge", "billing", "invoice", "subscription", "card", "upi", "merchant", "transaction"],
56
- ai: ["ai", "ml", "llm", "gpt", "claude", "openai", "anthropic", "model", "completion", "embedding", "chatbot", "generate", "inference", "generative"],
57
- storage: ["upload", "file", "image", "video", "media", "storage", "cdn", "asset", "photo", "bucket", "picture", "avatar"],
58
- search: ["search", "query", "index", "algolia", "fulltext", "autocomplete", "facet", "filter", "typeahead"],
59
- auth: ["auth", "authentication", "login", "signin", "signup", "oauth", "sso", "identity", "session", "token", "jwt", "credential"],
60
- messaging: ["sms", "text", "message", "messaging", "phone", "call", "voice", "telephony", "twilio", "vonage"],
61
- analytics: ["analytics", "tracking", "metrics", "event", "crash", "exception", "error", "monitoring", "observability", "posthog", "sentry"],
62
- database: ["database", "db", "sql", "nosql", "postgres", "mysql", "query", "datastore", "backend", "persist"],
63
- media: ["audio", "video", "stream", "transcribe", "transcode", "speech", "mux", "deepgram"],
64
- notifications: ["notification", "push", "alert", "bell", "notify", "apns", "fcm"],
65
- maps: ["map", "maps", "geo", "location", "address", "geocode", "navigation", "direction", "coordinate"],
66
- communication: ["chat", "conversation", "channel", "room", "thread", "realtime", "websocket", "pusher", "ably"],
67
- };
68
- function scoreCategoryMatch(queryTokens, category) {
69
- const signals = CATEGORY_SIGNALS[category] ?? [];
70
- if (signals.length === 0)
71
- return 0;
72
- const signalSet = new Set(signals);
73
- let matched = 0;
74
- for (const token of queryTokens) {
75
- if (signalSet.has(token))
76
- matched++;
77
- }
78
- return Math.min(matched / 3, 1.0);
79
- }
80
- // ─── 1. Use Case Fit (multi-field) ────────────────────────────────────────────
81
- const FIT_WEIGHTS = {
82
- perfect: 1.0,
83
- good: 0.7,
84
- partial: 0.4,
77
+ // ─── Category fit ─────────────────────────────────────────────────────────────
78
+ /**
79
+ * Categories that plausibly serve the same task. A query detected as "storage"
80
+ * can be answered by infrastructure (S3/R2) or media (imgix) entries, but not
81
+ * by payments.
82
+ */
83
+ const CATEGORY_AFFINITY = {
84
+ email: ["notifications"],
85
+ payments: ["commerce"],
86
+ commerce: ["payments", "cms"],
87
+ ai: ["media"],
88
+ media: ["ai", "storage", "infrastructure"],
89
+ storage: ["infrastructure", "media", "database"],
90
+ infrastructure: ["storage", "devops", "media"],
91
+ devops: ["infrastructure", "database"],
92
+ database: ["storage", "auth", "infrastructure", "devops"],
93
+ auth: ["database", "security"],
94
+ search: ["database"],
95
+ messaging: ["communication", "notifications"],
96
+ communication: ["messaging", "notifications"],
97
+ notifications: ["messaging", "communication", "email"],
98
+ analytics: ["testing"],
99
+ testing: ["analytics"],
100
+ security: ["auth", "database", "infrastructure"],
101
+ workflow: ["devops", "integration", "infrastructure"],
102
+ integration: ["workflow", "forms"],
103
+ forms: ["integration"],
104
+ cms: ["commerce"],
105
+ scheduling: [],
106
+ maps: [],
85
107
  };
108
+ /** Minimum detection confidence before category is used at all */
109
+ const CATEGORY_MIN_CONFIDENCE = 0.4;
86
110
  /**
87
- * Score against multiple fields with weighted relevance:
88
- * - Use case tasks (40%) — with fit multiplier, best match wins
89
- * - bestFor (25%) — concise purpose statement
90
- * - Description (20%) — first 200 chars (signal-dense)
91
- * - Category signals (15%) — broad category alignment
111
+ * Relevance multiplier from category alignment with the detected task category:
112
+ * same 1.0, related 0.9, unrelated 0.7. No detection → 1.0 (neutral).
92
113
  */
93
- export function scoreUseCaseFit(entry, queryTokens) {
94
- if (queryTokens.size === 0)
95
- return 0;
96
- // Use case task matching
97
- let bestUseCaseScore = 0;
98
- for (const useCase of entry.useCases) {
99
- const useCaseTokens = tokenize(useCase.task);
100
- const overlap = tokenOverlap(queryTokens, useCaseTokens);
101
- const score = overlap * FIT_WEIGHTS[useCase.fit];
102
- if (score > bestUseCaseScore)
103
- bestUseCaseScore = score;
104
- }
105
- // bestFor
106
- const bestForScore = tokenOverlap(queryTokens, tokenize(entry.bestFor));
107
- // Description (first 200 chars)
108
- const descScore = tokenOverlap(queryTokens, tokenize(entry.description.slice(0, 200)));
109
- // Category match
110
- const categoryScore = scoreCategoryMatch(queryTokens, entry.category);
111
- return Math.min(bestUseCaseScore * 0.40 +
112
- bestForScore * 0.25 +
113
- descScore * 0.20 +
114
- categoryScore * 0.15, 1.0);
114
+ export function categoryFit(entryCategory, detectedCategory, detectedConfidence) {
115
+ if (!detectedCategory || detectedConfidence < CATEGORY_MIN_CONFIDENCE)
116
+ return 1.0;
117
+ if (entryCategory === detectedCategory)
118
+ return 1.0;
119
+ if (CATEGORY_AFFINITY[detectedCategory]?.includes(entryCategory))
120
+ return 0.9;
121
+ return 0.7;
115
122
  }
116
- // ─── 2. Quality Score ─────────────────────────────────────────────────────────
117
- function scoreQuality(entry) {
118
- return (entry.qualityScore - 1) / 9;
119
- }
120
- // ─── 3. Developer Experience (sub-scored) ─────────────────────────────────────
123
+ // ─── Developer Experience (sub-scored) ────────────────────────────────────────
121
124
  /**
122
125
  * DX sub-scoring with measurable, explicit factors:
123
126
  * - TypeScript as primary SDK: 3 pts
@@ -132,33 +135,27 @@ function scoreQuality(entry) {
132
135
  export function scoreDevExperience(entry, constraints) {
133
136
  let score = 0;
134
137
  let maxScore = 8;
135
- // TypeScript primary SDK (+3)
136
138
  if (entry.sdk.primaryLanguage.toLowerCase() === "typescript") {
137
139
  score += 3;
138
140
  }
139
141
  else if (entry.sdk.otherLanguages.some((l) => l.toLowerCase() === "typescript")) {
140
142
  score += 1.5;
141
143
  }
142
- // 2+ code examples (+2)
143
144
  if (entry.codeExamples.length >= 2) {
144
145
  score += 2;
145
146
  }
146
147
  else if (entry.codeExamples.length === 1) {
147
148
  score += 1;
148
149
  }
149
- // envVarName defined — clear auth setup (+1)
150
150
  if (entry.auth.envVarName && entry.auth.envVarName.length > 0) {
151
151
  score += 1;
152
152
  }
153
- // Single npm install command (+1)
154
153
  if (entry.sdk.installCommand.includes("npm install")) {
155
154
  score += 1;
156
155
  }
157
- // Multiple language SDKs (3+) (+1)
158
156
  if (entry.sdk.otherLanguages.length >= 3) {
159
157
  score += 1;
160
158
  }
161
- // Required language match (only when constraint specified; +2)
162
159
  if (constraints?.requiredLanguage) {
163
160
  maxScore += 2;
164
161
  const required = constraints.requiredLanguage.toLowerCase();
@@ -171,10 +168,12 @@ export function scoreDevExperience(entry, constraints) {
171
168
  }
172
169
  return score / maxScore;
173
170
  }
174
- // ─── 4. Pricing Fit ───────────────────────────────────────────────────────────
171
+ // ─── Quality / Pricing / Recency (prior components) ───────────────────────────
172
+ function scoreQuality(entry) {
173
+ return (entry.qualityScore - 1) / 9;
174
+ }
175
175
  function scorePricingFit(entry, constraints) {
176
176
  if (constraints?.preferFree) {
177
- // preferFree: generous free tier or open source scores highest
178
177
  if (entry.pricing.model === "free" || entry.pricing.model === "open_source") {
179
178
  return 1.0;
180
179
  }
@@ -198,74 +197,47 @@ function scorePricingFit(entry, constraints) {
198
197
  }
199
198
  return 0.50; // Paid — neutral
200
199
  }
201
- // ─── 5. Keyword Relevance ─────────────────────────────────────────────────────
202
- /**
203
- * Direct keyword relevance: checks if query tokens appear in name/slug/subcategory.
204
- * This rewards exact API name matches ("algolia" for search queries, "stripe" for payments).
205
- */
206
- function scoreKeywordRelevance(entry, queryTokens) {
207
- const nameTokens = tokenize(entry.name);
208
- const slugTokens = tokenize(entry.slug.replace(/-/g, " "));
209
- const subCatTokens = tokenize(entry.subcategory.replace(/-/g, " "));
210
- // Boost when API name or slug appears in query (user named the tool directly)
211
- const nameMatch = tokenOverlap(queryTokens, nameTokens);
212
- const slugMatch = tokenOverlap(queryTokens, slugTokens);
213
- const subCatMatch = tokenOverlap(queryTokens, subCatTokens);
214
- // Name match is strongest signal (user asked for "stripe" or "algolia" by name)
215
- return Math.min(nameMatch * 0.5 + slugMatch * 0.3 + subCatMatch * 0.2, 1.0);
216
- }
217
- // ─── 6. Recency Bonus ─────────────────────────────────────────────────────────
218
- const NOW_MS = Date.now();
219
200
  const MS_PER_DAY = 86_400_000;
220
201
  /**
221
- * Recency bonus: entries verified within 30 days score 1.0.
222
- * Scales linearly to 0 at 365 days. Older entries score 0.
202
+ * Recency: verified within 30 days → 1.0, linearly to 0 at 365 days.
203
+ * Computed at call time (not module load) so long-running servers stay correct.
223
204
  */
224
- function scoreRecency(entry) {
225
- try {
226
- const verifiedMs = new Date(entry.lastVerified).getTime();
227
- const daysOld = (NOW_MS - verifiedMs) / MS_PER_DAY;
228
- if (daysOld <= 30)
229
- return 1.0;
230
- if (daysOld <= 365)
231
- return 1.0 - (daysOld - 30) / 335;
232
- return 0;
233
- }
234
- catch {
205
+ function scoreRecency(entry, nowMs = Date.now()) {
206
+ const verifiedMs = new Date(entry.lastVerified).getTime();
207
+ if (Number.isNaN(verifiedMs))
235
208
  return 0;
236
- }
209
+ const daysOld = (nowMs - verifiedMs) / MS_PER_DAY;
210
+ if (daysOld <= 30)
211
+ return 1.0;
212
+ if (daysOld <= 365)
213
+ return 1.0 - (daysOld - 30) / 335;
214
+ return 0;
237
215
  }
238
- // ─── Category Boost ───────────────────────────────────────────────────────────
239
- function applyCategoryBoost(score, entry, detectedCategory, detectedConfidence) {
240
- if (!detectedCategory || detectedConfidence < 0.4)
241
- return score;
242
- if (entry.category === detectedCategory) {
243
- const boost = detectedConfidence * 0.12; // Up to 12% boost
244
- return Math.min(score * (1 + boost), 1.0);
245
- }
246
- return score;
216
+ // ─── Keyword Relevance ────────────────────────────────────────────────────────
217
+ /**
218
+ * Direct keyword relevance on the ORIGINAL query (not the synonym-expanded
219
+ * one): rewards the user naming the API ("algolia", "stripe") or its subcategory.
220
+ */
221
+ function scoreKeywordRelevance(entry, queryTokens) {
222
+ const nameMatch = tokenOverlap(queryTokens, tokenize(entry.name));
223
+ const slugMatch = tokenOverlap(queryTokens, tokenize(entry.slug.replace(/-/g, " ")));
224
+ const subCatMatch = tokenOverlap(queryTokens, tokenize(entry.subcategory.replace(/-/g, " ")));
225
+ return Math.min(nameMatch * 0.5 + slugMatch * 0.3 + subCatMatch * 0.2, 1.0);
247
226
  }
248
227
  // ─── Confidence Score ─────────────────────────────────────────────────────────
249
228
  /**
250
- * Compute a confidence value (0-1) for how certain we are about the top result.
251
- *
252
- * High confidence (>0.8): strong category match + high useCaseFit score
253
- * Medium confidence (0.5-0.8): partial signals
254
- * Low confidence (<0.5): weak query, no clear category
229
+ * Confidence (0-1) for the top result: how relevant it is, how clear the
230
+ * category is, and how far it is ahead of the runner-up.
255
231
  */
256
232
  export function computeConfidence(topResult, detectedCategory, detectedConfidence, allResults) {
257
- const useCaseFit = topResult.scoreBreakdown["useCaseFit"] ?? 0;
233
+ const relevance = topResult.scoreBreakdown["relevance"] ?? 0;
258
234
  const totalScore = topResult.score;
259
- // Factor 1: useCaseFit raw score quality
260
- const fitSignal = Math.min(useCaseFit / 0.5, 1.0); // 0.5+ useCaseFit → full signal
261
- // Factor 2: category detection confidence
235
+ const fitSignal = Math.min(relevance / 0.7, 1.0);
262
236
  const catSignal = detectedCategory ? detectedConfidence : 0;
263
- // Factor 3: score separation (top result vs runner-up)
264
237
  const separation = allResults.length >= 2
265
- ? Math.min((totalScore - allResults[1].score) * 5, 1.0)
238
+ ? Math.min(((totalScore - allResults[1].score) / Math.max(totalScore, 0.01)) * 4, 1.0)
266
239
  : 0.5;
267
- // Weighted combination
268
- const raw = fitSignal * 0.40 + catSignal * 0.35 + separation * 0.25;
240
+ const raw = fitSignal * 0.5 + catSignal * 0.25 + separation * 0.25;
269
241
  return Math.round(Math.min(raw, 1.0) * 100) / 100;
270
242
  }
271
243
  /** Human-readable confidence label */
@@ -277,38 +249,61 @@ export function confidenceLabel(confidence) {
277
249
  return "low";
278
250
  }
279
251
  // ─── Main Scoring Function ────────────────────────────────────────────────────
252
+ function round2(n) {
253
+ return Math.round(n * 100) / 100;
254
+ }
280
255
  export function scoreEntry(entry, query, constraints, weights = SCORING_WEIGHTS, context) {
281
- const effectiveQuery = context?.expandedQuery ?? query;
282
- const queryTokens = tokenize(effectiveQuery);
283
- const useCaseFitRaw = scoreUseCaseFit(entry, queryTokens);
284
- const qualityRaw = scoreQuality(entry);
285
- const devExperienceRaw = scoreDevExperience(entry, constraints);
286
- const pricingFitRaw = scorePricingFit(entry, constraints);
287
- const keywordRelevanceRaw = scoreKeywordRelevance(entry, queryTokens);
288
- const recencyRaw = scoreRecency(entry);
289
- const baseScore = useCaseFitRaw * weights.useCaseFit +
290
- qualityRaw * weights.qualityScore +
291
- devExperienceRaw * weights.developerExperience +
292
- pricingFitRaw * weights.pricingFit +
293
- keywordRelevanceRaw * weights.keywordRelevance +
294
- recencyRaw * weights.recencyBonus;
295
- const totalScore = applyCategoryBoost(baseScore, entry, context?.detectedCategory ?? null, context?.detectedConfidence ?? 0);
256
+ const model = context?.relevanceModel ?? buildRelevanceModel(query, [entry]);
257
+ // Relevance (query-dependent)
258
+ const coverage = entryRelevance(model, entry.slug);
259
+ const keyword = scoreKeywordRelevance(entry, tokenize(query));
260
+ const keywordBonus = weights.useCaseFit > 0 ? weights.keywordRelevance / weights.useCaseFit : 0;
261
+ const catFit = categoryFit(entry.category, context?.detectedCategory ?? null, context?.detectedConfidence ?? 0);
262
+ const relevance = Math.min(coverage + keyword * keywordBonus, 1.0) * catFit;
263
+ // Prior (query-independent) — can only adjust a relevant entry
264
+ const quality = scoreQuality(entry);
265
+ const dx = scoreDevExperience(entry, constraints);
266
+ const pricing = scorePricingFit(entry, constraints);
267
+ const recency = scoreRecency(entry);
268
+ const priorWeight = weights.qualityScore + weights.developerExperience + weights.pricingFit + weights.recencyBonus;
269
+ const prior = priorWeight > 0
270
+ ? (quality * weights.qualityScore +
271
+ dx * weights.developerExperience +
272
+ pricing * weights.pricingFit +
273
+ recency * weights.recencyBonus) /
274
+ priorWeight
275
+ : 0;
276
+ const totalScore = relevance * (1 - PRIOR_INFLUENCE + PRIOR_INFLUENCE * prior) +
277
+ keyword * weights.keywordRelevance * catFit;
296
278
  const scoreBreakdown = {
297
- useCaseFit: Math.round(useCaseFitRaw * 100) / 100,
298
- qualityScore: Math.round(qualityRaw * 100) / 100,
299
- developerExperience: Math.round(devExperienceRaw * 100) / 100,
300
- pricingFit: Math.round(pricingFitRaw * 100) / 100,
301
- keywordRelevance: Math.round(keywordRelevanceRaw * 100) / 100,
302
- recencyBonus: Math.round(recencyRaw * 100) / 100,
303
- total: Math.round(totalScore * 100) / 100,
279
+ useCaseFit: round2(coverage),
280
+ keywordRelevance: round2(keyword),
281
+ categoryFit: round2(catFit),
282
+ relevance: round2(relevance),
283
+ qualityScore: round2(quality),
284
+ developerExperience: round2(dx),
285
+ pricingFit: round2(pricing),
286
+ recencyBonus: round2(recency),
287
+ prior: round2(prior),
288
+ total: round2(totalScore),
304
289
  };
305
- return { entry, score: totalScore, scoreBreakdown };
290
+ return { entry, score: totalScore, relevance, scoreBreakdown };
306
291
  }
307
292
  // ─── Ranking Function ─────────────────────────────────────────────────────────
293
+ /**
294
+ * Score and sort entries (no filtering — see findApis for relevance gating).
295
+ * The relevance model is built over `entries`, so IDF reflects the corpus.
296
+ */
308
297
  export function rankEntries(entries, query, constraints, topN = 3, weights = SCORING_WEIGHTS, context) {
309
298
  if (entries.length === 0)
310
299
  return [];
311
- const scored = entries.map((entry) => scoreEntry(entry, query, constraints, weights, context));
300
+ const fullContext = {
301
+ detectedCategory: context?.detectedCategory ?? null,
302
+ detectedConfidence: context?.detectedConfidence ?? 0,
303
+ expandedQuery: context?.expandedQuery,
304
+ relevanceModel: context?.relevanceModel ?? buildRelevanceModel(query, entries),
305
+ };
306
+ const scored = entries.map((entry) => scoreEntry(entry, query, constraints, weights, fullContext));
312
307
  scored.sort((a, b) => b.score - a.score);
313
308
  return scored.slice(0, topN);
314
309
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Search layer — combines SQLite text search with the scoring engine.
3
- * Uses synonym expansion and category detection to improve result quality.
2
+ * Search layer — scores the knowledge base against a task and keeps only
3
+ * candidates that are actually relevant to it.
4
4
  *
5
- * Exports: SearchConstraints, RankedResult, findApis, getApiBySlug, getApisByCategory
5
+ * Exports: SearchConstraints, RankedResult, SearchOutcome, RELEVANCE_GATE,
6
+ * findApis, findApisDetailed, getApiBySlug, getApisByCategory
6
7
  */
7
8
  import type { ApiEntry } from "./schema.js";
8
9
  export interface SearchConstraints {
@@ -13,23 +14,47 @@ export interface SearchConstraints {
13
14
  export interface RankedResult {
14
15
  entry: ApiEntry;
15
16
  score: number;
17
+ /** Query relevance in [0,1] before the quality/DX/pricing/recency prior */
18
+ relevance?: number;
16
19
  scoreBreakdown: Record<string, number>;
17
20
  /** Confidence in this recommendation (0-1); only set on the top result */
18
21
  confidence?: number;
19
22
  /** Human-readable confidence label: "high" | "medium" | "low" */
20
23
  confidenceLabel?: "high" | "medium" | "low";
21
24
  }
25
+ export interface SearchOutcome {
26
+ /** Top `topN` relevant results, best first */
27
+ results: RankedResult[];
28
+ /** All candidates that passed the relevance gate, best first */
29
+ relevant: RankedResult[];
30
+ /** How many entries were scored */
31
+ evaluated: number;
32
+ detectedCategory: string | null;
33
+ }
22
34
  /**
23
- * Find and rank APIs for a given task description.
24
- *
25
- * Pipeline:
26
- * 1. Detect phrases (preferFree, open source, etc.) — merge with constraints
27
- * 2. Expand query with synonyms
28
- * 3. SQLite text search on the expanded query (broader candidate pool)
29
- * 4. Also text-search on original query (catches exact matches)
30
- * 5. Detect category for scoring boost
31
- * 6. Rank all candidates with scorer using expanded query + context
35
+ * Relevance gate. A candidate is kept only if:
36
+ * - relevance ≥ minAbsolute, and
37
+ * - relevance ≥ minRelativeToBest × the best candidate's relevance, and
38
+ * - it is not in a clearly unrelated category (categoryFit at its floor while
39
+ * the category was detected with ≥ strongCategoryConfidence) unless its own
40
+ * relevance is ≥ categoryOverride.
41
+ */
42
+ export declare const RELEVANCE_GATE: {
43
+ readonly minAbsolute: 0.2;
44
+ readonly minRelativeToBest: 0.6;
45
+ readonly strongCategoryConfidence: 0.5;
46
+ readonly categoryOverride: 0.6;
47
+ };
48
+ /**
49
+ * Find and rank APIs for a task, with stats. Pipeline:
50
+ * 1. Phrase detection (e.g. "free" → preferFree) merged with constraints
51
+ * 2. Category detection
52
+ * 3. Candidate pool: every entry (small KB) or TF-IDF + detected category
53
+ * 4. Score: relevance × bounded quality/DX/pricing/recency prior
54
+ * 5. Gate: drop candidates that are not relevant enough (RELEVANCE_GATE)
32
55
  */
56
+ export declare function findApisDetailed(query: string, constraints?: SearchConstraints, topN?: number): SearchOutcome;
57
+ /** Find and rank APIs for a task — relevant results only, best first. */
33
58
  export declare function findApis(query: string, constraints?: SearchConstraints, topN?: number): RankedResult[];
34
59
  /** Get a single API entry by its slug */
35
60
  export declare function getApiBySlug(slug: string): ApiEntry | null;