@with-logic/intent 0.1.0 → 0.1.1

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/dist/index.cjs ADDED
@@ -0,0 +1,1143 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ CONFIG: () => CONFIG,
34
+ DEFAULT_KEY_EXTRACTOR: () => DEFAULT_KEY_EXTRACTOR,
35
+ DEFAULT_SUMMARY_EXTRACTOR: () => DEFAULT_SUMMARY_EXTRACTOR,
36
+ Intent: () => Intent,
37
+ createDefaultGroqClient: () => createDefaultGroqClient
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/batches.ts
42
+ function sliceIntoFixedBatches(items, size) {
43
+ const out = [];
44
+ for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
45
+ return out;
46
+ }
47
+ function mergeTinyFinalBatch(batches, tinyThreshold) {
48
+ if (batches.length < 2) return batches;
49
+ const last = batches[batches.length - 1];
50
+ if (last.length === 0 || last.length > tinyThreshold) return batches;
51
+ const prev = batches[batches.length - 2];
52
+ batches[batches.length - 2] = prev.concat(last);
53
+ batches.pop();
54
+ return batches;
55
+ }
56
+ function createBatches(items, size, tinyFraction) {
57
+ const batches = sliceIntoFixedBatches(items, size);
58
+ const tinyThreshold = Math.ceil(tinyFraction * size);
59
+ return mergeTinyFinalBatch(batches, tinyThreshold);
60
+ }
61
+ async function batchProcess(items, size, tinyFraction, fn, logger, onError) {
62
+ const batches = createBatches(items, size, tinyFraction);
63
+ const results = await Promise.all(
64
+ batches.map(async (b) => {
65
+ try {
66
+ return await fn(b);
67
+ } catch (error) {
68
+ logger?.warn?.("intent reranker batch failed, preserving original order", {
69
+ error: error?.message
70
+ });
71
+ if (onError) {
72
+ return onError(b, error);
73
+ }
74
+ return b;
75
+ }
76
+ })
77
+ );
78
+ return results.flat();
79
+ }
80
+
81
+ // src/config.ts
82
+ var import_config = require("dotenv/config");
83
+
84
+ // src/lib/number.ts
85
+ function clamp(n, min, max) {
86
+ let out = n;
87
+ if (typeof min === "number" && out < min) {
88
+ out = min;
89
+ }
90
+ if (typeof max === "number" && out > max) {
91
+ out = max;
92
+ }
93
+ return out;
94
+ }
95
+
96
+ // src/lib/config.ts
97
+ function throwRequiredEnvVar(name) {
98
+ throw new Error(`${name} is required.`);
99
+ }
100
+ function string(name, opts) {
101
+ const value = process.env[name];
102
+ const exists = value != null && value.length > 0;
103
+ if (exists) {
104
+ return value;
105
+ }
106
+ if (opts?.default !== void 0) {
107
+ return opts.default;
108
+ }
109
+ return throwRequiredEnvVar(name);
110
+ }
111
+ function enumeration(name, opts) {
112
+ const value = opts.default !== void 0 ? string(name, { default: opts.default }) : string(name);
113
+ if (opts.values.includes(value)) {
114
+ return value;
115
+ }
116
+ throw new Error(`${name} must be one of: ${opts.values.join(", ")}`);
117
+ }
118
+ function int(name, opts) {
119
+ const value = process.env[name];
120
+ const exists = value != null && value.length > 0;
121
+ if (exists) {
122
+ const parsed = Number.parseInt(value, 10);
123
+ if (Number.isNaN(parsed)) {
124
+ throw new Error(`${name} must be a valid integer`);
125
+ }
126
+ return clamp(parsed, opts?.min, opts?.max);
127
+ }
128
+ if (opts?.default !== void 0) {
129
+ const def = Math.trunc(opts.default);
130
+ return clamp(def, opts?.min, opts?.max);
131
+ }
132
+ return throwRequiredEnvVar(name);
133
+ }
134
+ function number(name, opts) {
135
+ const value = process.env[name];
136
+ const exists = value != null && value.length > 0;
137
+ if (exists) {
138
+ const parsed = Number.parseFloat(value);
139
+ if (Number.isNaN(parsed)) {
140
+ throw new Error(`${name} must be a valid number`);
141
+ }
142
+ return clamp(parsed, opts?.min, opts?.max);
143
+ }
144
+ if (opts?.default !== void 0) {
145
+ return clamp(opts.default, opts?.min, opts?.max);
146
+ }
147
+ return throwRequiredEnvVar(name);
148
+ }
149
+
150
+ // src/config.ts
151
+ var CONFIG = {
152
+ GROQ: {
153
+ API_KEY: string("GROQ_API_KEY", { default: "" }),
154
+ DEFAULT_MODEL: string("GROQ_DEFAULT_MODEL", { default: "openai/gpt-oss-20b" }),
155
+ DEFAULT_REASONING_EFFORT: enumeration("GROQ_DEFAULT_REASONING_EFFORT", {
156
+ default: "medium",
157
+ values: ["low", "medium", "high"]
158
+ }),
159
+ JSON_REPAIR_ATTEMPTS: int("GROQ_JSON_REPAIR_ATTEMPTS", { default: 3, min: 0 })
160
+ },
161
+ INTENT: {
162
+ PROVIDER: enumeration("INTENT_PROVIDER", { default: "GROQ", values: ["GROQ"] }),
163
+ TIMEOUT_MS: int("INTENT_TIMEOUT_MS", { default: 3e3, min: 1 }),
164
+ MIN_SCORE: int("INTENT_MIN_SCORE", { default: 0 }),
165
+ MAX_SCORE: int("INTENT_MAX_SCORE", { default: 10, min: 1 }),
166
+ RELEVANCY_THRESHOLD: int("INTENT_RELEVANCY_THRESHOLD", { default: 0 }),
167
+ BATCH_SIZE: int("INTENT_BATCH_SIZE", { default: 20, min: 1 }),
168
+ TINY_BATCH_FRACTION: number("INTENT_TINY_BATCH_FRACTION", { default: 0.2, min: 0, max: 1 })
169
+ },
170
+ TEST: {
171
+ SCOPE: string("TEST_SCOPE", { default: "all" })
172
+ }
173
+ };
174
+
175
+ // src/extractors.ts
176
+ function hash32(str) {
177
+ let hash = 5381;
178
+ for (let i = 0; i < str.length; i++) {
179
+ const char = str.charCodeAt(i);
180
+ hash = (hash << 5) + hash + char | 0;
181
+ }
182
+ return hash >>> 0;
183
+ }
184
+ function jsonStringify(value) {
185
+ try {
186
+ const result = JSON.stringify(value, null, 2);
187
+ if (result === void 0) {
188
+ return String(value);
189
+ }
190
+ return result;
191
+ } catch {
192
+ return String(value);
193
+ }
194
+ }
195
+ function hashToString(value) {
196
+ const json = jsonStringify(value);
197
+ const hashValue = hash32(json);
198
+ return String(hashValue);
199
+ }
200
+ function DEFAULT_KEY_EXTRACTOR(item) {
201
+ return hashToString(item);
202
+ }
203
+ function DEFAULT_SUMMARY_EXTRACTOR(item) {
204
+ return jsonStringify(item);
205
+ }
206
+
207
+ // src/providers/groq.ts
208
+ var import_groq_sdk = __toESM(require("groq-sdk"), 1);
209
+ function getNestedErrorObject(err) {
210
+ if (err == null || typeof err !== "object") {
211
+ return void 0;
212
+ }
213
+ const record = err;
214
+ const error = record.error;
215
+ if (error == null || typeof error !== "object") {
216
+ return void 0;
217
+ }
218
+ return error;
219
+ }
220
+ function mapToGroqMessages(messages) {
221
+ return messages.map((m) => {
222
+ if (m.role === "system" || m.role === "user" || m.role === "assistant") {
223
+ return { role: m.role, content: m.content };
224
+ }
225
+ throw new Error(`intent: '${m.role}' role messages are not supported in provider calls`);
226
+ });
227
+ }
228
+ function buildGroqRequest(outputSchema, groqMessages, config, userId, defaults) {
229
+ return {
230
+ model: config?.model ?? defaults.model,
231
+ reasoning_effort: config?.reasoningEffort ?? defaults.reasoningEffort,
232
+ messages: groqMessages,
233
+ ...userId ? { user: userId } : {},
234
+ response_format: {
235
+ type: "json_schema",
236
+ json_schema: {
237
+ name: "intent_relevancy",
238
+ schema: outputSchema,
239
+ strict: true
240
+ }
241
+ }
242
+ };
243
+ }
244
+ async function executeCompletion(client, request, timeoutMs) {
245
+ const timeout = typeof timeoutMs === "number" ? { timeout: timeoutMs } : void 0;
246
+ return client.chat.completions.create(request, timeout);
247
+ }
248
+ function getResponseContent(response) {
249
+ const content = response?.choices?.[0]?.message?.content;
250
+ if (typeof content !== "string") {
251
+ throw new Error("Groq did not return content");
252
+ }
253
+ return content;
254
+ }
255
+ function parseJson(content) {
256
+ try {
257
+ const data = JSON.parse(content);
258
+ return { data };
259
+ } catch (error) {
260
+ const detail = error instanceof Error ? error.message : String(error);
261
+ const err = new Error(`Groq returned invalid JSON: ${detail}`);
262
+ err.rawOutput = content;
263
+ throw err;
264
+ }
265
+ }
266
+ function buildJsonRepairMessages(baseMessages, rawOutput, errorMessage) {
267
+ return [
268
+ ...baseMessages,
269
+ { role: "assistant", content: rawOutput },
270
+ {
271
+ role: "user",
272
+ content: `Your previous response was invalid JSON or did not match the required JSON schema. Please correct it and return ONLY valid JSON that matches the schema.
273
+
274
+ Error: ${errorMessage}`
275
+ }
276
+ ];
277
+ }
278
+ function buildRepairRetry(state, repair) {
279
+ if (state.remaining <= 1) {
280
+ return void 0;
281
+ }
282
+ const repairedRequest = {
283
+ ...state.request,
284
+ messages: buildJsonRepairMessages(
285
+ state.request.messages,
286
+ repair.rawOutput,
287
+ repair.errorMessage
288
+ )
289
+ };
290
+ return { remaining: state.remaining - 1, request: repairedRequest };
291
+ }
292
+ function parseErrorToRepairInput(rawOutput, parseError) {
293
+ return { rawOutput, errorMessage: String(parseError) };
294
+ }
295
+ function getRawOutputFromParseJsonError(error) {
296
+ if (!(error instanceof Error)) {
297
+ return void 0;
298
+ }
299
+ const record = error;
300
+ return typeof record.rawOutput === "string" ? record.rawOutput : void 0;
301
+ }
302
+ function extractJsonValidateFailedRepairInput(err) {
303
+ if (err instanceof Error) {
304
+ const match = err.message.match(/"failed_generation":"(\{.*?\})"/);
305
+ const failedGeneration2 = match?.[1] ? match[1].replace(/\\"/g, '"') : void 0;
306
+ if (err.message.includes('"code":"json_validate_failed"')) {
307
+ return {
308
+ rawOutput: failedGeneration2 ?? "(Groq did not include the rejected generation in the error payload)",
309
+ errorMessage: err.message
310
+ };
311
+ }
312
+ }
313
+ const errorObj = getNestedErrorObject(err);
314
+ const nested = errorObj && typeof errorObj.error === "object" ? errorObj.error : void 0;
315
+ const serverError = nested ?? errorObj;
316
+ if (!serverError) {
317
+ return void 0;
318
+ }
319
+ const code = typeof serverError.code === "string" ? serverError.code : void 0;
320
+ if (code !== "json_validate_failed") {
321
+ return void 0;
322
+ }
323
+ const message = typeof serverError.message === "string" ? serverError.message : void 0;
324
+ const generatedResponse = typeof serverError.generated_response === "string" ? serverError.generated_response : void 0;
325
+ const failedGeneration = typeof serverError.failed_generation === "string" ? serverError.failed_generation : void 0;
326
+ const rawOutput = generatedResponse ?? failedGeneration;
327
+ return {
328
+ rawOutput: rawOutput ?? "(Groq did not include the rejected generation in the error payload)",
329
+ errorMessage: message ?? String(err)
330
+ };
331
+ }
332
+ function createGroqSdkLike(apiKey) {
333
+ const sdk = new import_groq_sdk.default({ apiKey });
334
+ return {
335
+ chat: {
336
+ completions: {
337
+ create: async (req, opts) => {
338
+ return await sdk.chat.completions.create(
339
+ req,
340
+ opts
341
+ );
342
+ }
343
+ }
344
+ }
345
+ };
346
+ }
347
+ function createDefaultGroqClient(apiKey, options) {
348
+ const defaults = {
349
+ model: options?.defaults?.model ?? CONFIG.GROQ.DEFAULT_MODEL,
350
+ reasoningEffort: options?.defaults?.reasoningEffort ?? CONFIG.GROQ.DEFAULT_REASONING_EFFORT
351
+ };
352
+ const makeSdk = options?.makeSdk ?? createGroqSdkLike;
353
+ const jsonRepairAttempts = options?.jsonRepairAttempts ?? CONFIG.GROQ.JSON_REPAIR_ATTEMPTS;
354
+ return {
355
+ /**
356
+ * Call Groq with JSON schema enforced response and return parsed data.
357
+ *
358
+ * Implements the LlmClient interface with Groq-specific features:
359
+ * - Creates a new SDK client per call with the provided API key
360
+ * - Maps generic messages to Groq format
361
+ * - Builds request with strict JSON schema response format
362
+ * - Retries up to 3 times on json_validate_failed errors
363
+ * - Parses and returns the structured response
364
+ *
365
+ * @param messages - Chat messages to send to the model
366
+ * @param outputSchema - JSON schema defining expected response structure
367
+ * @param config - Optional model, reasoning effort, and timeout overrides
368
+ * @param userId - Optional user ID for Groq's abuse monitoring
369
+ * @returns Parsed response data wrapped in { data } object
370
+ * @throws {Error} If all retry attempts fail or response is invalid
371
+ */
372
+ async call(messages, outputSchema, config, userId) {
373
+ const client = makeSdk(apiKey);
374
+ const groqMessages = mapToGroqMessages(messages);
375
+ const baseRequest = buildGroqRequest(outputSchema, groqMessages, config, userId, defaults);
376
+ const createWithRetry = async (state) => {
377
+ try {
378
+ const response = await executeCompletion(client, state.request, config?.timeoutMs);
379
+ const content = getResponseContent(response);
380
+ const parsed = parseJson(content);
381
+ return parsed;
382
+ } catch (err) {
383
+ const parseRawOutput = getRawOutputFromParseJsonError(err);
384
+ if (parseRawOutput !== void 0) {
385
+ const retry = buildRepairRetry(state, parseErrorToRepairInput(parseRawOutput, err));
386
+ if (retry) {
387
+ return createWithRetry(retry);
388
+ }
389
+ throw err;
390
+ }
391
+ const validationRepairInput = extractJsonValidateFailedRepairInput(err);
392
+ if (validationRepairInput) {
393
+ const retry = buildRepairRetry(state, validationRepairInput);
394
+ if (retry) {
395
+ return createWithRetry(retry);
396
+ }
397
+ }
398
+ throw err;
399
+ }
400
+ };
401
+ const attempts = Math.max(1, jsonRepairAttempts);
402
+ return createWithRetry({ remaining: attempts, request: baseRequest });
403
+ }
404
+ };
405
+ }
406
+
407
+ // src/llm_client.ts
408
+ function selectLlmClient(ctx, config = CONFIG) {
409
+ if (ctx.llm) {
410
+ return ctx.llm;
411
+ }
412
+ const groqKey = config.GROQ.API_KEY;
413
+ if (groqKey && groqKey !== "") {
414
+ return createDefaultGroqClient(groqKey, {
415
+ defaults: {
416
+ model: config.GROQ.DEFAULT_MODEL,
417
+ reasoningEffort: config.GROQ.DEFAULT_REASONING_EFFORT
418
+ }
419
+ });
420
+ }
421
+ return void 0;
422
+ }
423
+
424
+ // src/messages.ts
425
+ function buildMessages(query, candidates, scoreRange) {
426
+ const system = `You will receive a JSON blob containing candidate_search_results (each candidate has a key and a short summary) plus a short user request.
427
+
428
+ Your task is to assess each candidate and return a JSON object that maps candidate keys to objects of the form {"explanation": string, "score": integer} avoiding ambiguity.
429
+
430
+ The score must be an integer from ${scoreRange.minScore} to ${scoreRange.maxScore}:
431
+ - ${scoreRange.minScore} means not relevant at all
432
+ - ${scoreRange.maxScore} means highly relevant
433
+
434
+ Sometimes none are relevant, sometimes all are relevant. Be decisive.
435
+
436
+ It is okay to return ${scoreRange.minScore} if the candidate is not relevant to the query. It is okay to return ${scoreRange.maxScore} if the candidate is highly relevant to the query. Use the full range of scores.
437
+
438
+ Every candidate MUST include an explanation. Write the explanation first, then the score. The explanation should be concise (1-3 sentences), concrete, and reference the query intent and the candidate summary.
439
+
440
+ Write explanations as end-user-facing justifications:
441
+ - Do NOT say "the query" or talk about prompt mechanics.
442
+ - Write in a direct, item-first voice (e.g., "gpt-5.2 is best here because it specializes in feature implementation and testing.").
443
+ - Avoid "I"/"we".
444
+
445
+ Every key in candidate_search_results must be present in your output mapping. Do not add any keys that are not present in candidate_search_results.
446
+ Every key in candidate_search_results must map to an object with:
447
+ - explanation: string
448
+ - score: integer from ${scoreRange.minScore} to ${scoreRange.maxScore}
449
+ Do not, in your generated JSON, include anything other than the \`"{key}": {"explanation": "...", "score": 7}\` mappings. Do not include any other text outside the JSON.
450
+
451
+ Return a JSON object that matches the enforced JSON schema for response formatting. Use the candidate.key as the property name in the output mapping.
452
+
453
+ The JSON you return should be of the form: {
454
+ "Key for document 1": { "explanation": "...", "score": ${scoreRange.minScore} },
455
+ "Key for document 2": { "explanation": "...", "score": ${scoreRange.maxScore} },
456
+ ...
457
+ }
458
+
459
+ Pretty-print the JSON for readability.`;
460
+ const payload = {
461
+ query,
462
+ candidate_search_results: candidates.map((c) => ({ key: c.key, summary: c.summary }))
463
+ };
464
+ return [
465
+ { role: "system", content: system },
466
+ { role: "user", content: jsonStringify(payload) }
467
+ ];
468
+ }
469
+ function buildFilterMessages(query, candidates) {
470
+ const system = `You will receive a JSON blob containing candidate_search_results (each candidate has a key and a short summary) plus a short user request.
471
+
472
+ Your task is to assess each candidate and return a JSON object that maps candidate keys to objects of the form {"explanation": string, "isRelevant": boolean}.
473
+
474
+ Return isRelevant=true only when the candidate clearly helps satisfy the query intent. Otherwise return isRelevant=false.
475
+
476
+ Every candidate MUST include an explanation. Write the explanation first, then the boolean. The explanation should be concise (1-3 sentences), concrete, and reference the query intent and the candidate summary.
477
+
478
+ Write explanations as end-user-facing justifications:
479
+ - Do NOT say "the query" or talk about prompt mechanics.
480
+ - Write in a direct, item-first voice.
481
+ - Avoid "I"/"we".
482
+
483
+ Every key in candidate_search_results must be present in your output mapping. Do not add any keys that are not present in candidate_search_results.
484
+ Every key in candidate_search_results must map to an object with:
485
+ - explanation: string
486
+ - isRelevant: boolean
487
+ Do not include anything other than the mapping JSON object. Return only JSON matching the enforced schema.
488
+
489
+ Pretty-print the JSON for readability.`;
490
+ const payload = {
491
+ query,
492
+ candidate_search_results: candidates.map((c) => ({ key: c.key, summary: c.summary }))
493
+ };
494
+ return [
495
+ { role: "system", content: system },
496
+ { role: "user", content: jsonStringify(payload) }
497
+ ];
498
+ }
499
+ function buildChoiceMessages(query, candidates) {
500
+ const system = `You will receive a JSON blob containing candidate_search_results (each candidate has a key and a short summary) plus a short user request.
501
+
502
+ Your task is to choose exactly one candidate as the best match for what the user wants.
503
+
504
+ You MUST choose one candidate key from the provided list. Do not choose multiple.
505
+
506
+ Return ONLY JSON of the form: {"explanation": string, "selectedKey": string} where selectedKey is exactly one of the candidate keys. The explanation should be concise (1-3 sentences), concrete, and reference the query intent and the candidate summary.
507
+
508
+ Write the explanation as an end-user-facing justification:
509
+ - Do NOT say "the query" or talk about prompt mechanics.
510
+ - Write in a direct, item-first voice.
511
+ - Avoid "I"/"we".
512
+
513
+ Do not include any other text outside the JSON. Return only JSON matching the enforced schema.
514
+
515
+ Pretty-print the JSON for readability.`;
516
+ const payload = {
517
+ query,
518
+ candidate_search_results: candidates.map((c) => ({ key: c.key, summary: c.summary }))
519
+ };
520
+ return [
521
+ { role: "system", content: system },
522
+ { role: "user", content: jsonStringify(payload) }
523
+ ];
524
+ }
525
+
526
+ // src/schema.ts
527
+ function buildCandidateEvaluationSchema() {
528
+ return {
529
+ type: "object",
530
+ properties: {
531
+ explanation: { type: "string" },
532
+ score: { type: "integer" }
533
+ },
534
+ required: ["explanation", "score"],
535
+ additionalProperties: false
536
+ };
537
+ }
538
+ function buildCandidateFilterSchema() {
539
+ return {
540
+ type: "object",
541
+ properties: {
542
+ explanation: { type: "string" },
543
+ isRelevant: { type: "boolean" }
544
+ },
545
+ required: ["explanation", "isRelevant"],
546
+ additionalProperties: false
547
+ };
548
+ }
549
+ function buildRelevancySchema(keys, minScore, maxScore) {
550
+ const evaluationSchema = buildCandidateEvaluationSchema();
551
+ const properties = {};
552
+ for (const k of keys) {
553
+ properties[k] = evaluationSchema;
554
+ }
555
+ return {
556
+ title: "Query / Candidate Relevancy Assessment",
557
+ description: `Map candidate results for a search query to relevancy scores (${minScore}-${maxScore}) with explanations.`,
558
+ type: "object",
559
+ properties,
560
+ required: keys,
561
+ additionalProperties: false
562
+ };
563
+ }
564
+ function buildFilterSchema(keys) {
565
+ const decisionSchema = buildCandidateFilterSchema();
566
+ const properties = {};
567
+ for (const k of keys) {
568
+ properties[k] = decisionSchema;
569
+ }
570
+ return {
571
+ title: "Query / Candidate Relevancy Filter",
572
+ description: "Map candidate results for a search query to boolean relevancy decisions with explanations.",
573
+ type: "object",
574
+ properties,
575
+ required: keys,
576
+ additionalProperties: false
577
+ };
578
+ }
579
+ function buildChoiceSchema(keys) {
580
+ return {
581
+ title: "Query / Candidate Single Choice",
582
+ description: "Choose exactly one candidate key for the query and explain why.",
583
+ type: "object",
584
+ properties: {
585
+ explanation: { type: "string" },
586
+ selectedKey: { type: "string", enum: keys }
587
+ },
588
+ required: ["explanation", "selectedKey"],
589
+ additionalProperties: false
590
+ };
591
+ }
592
+
593
+ // src/intent.ts
594
+ var Intent = class {
595
+ /**
596
+ * Resolve the model name to use for this Intent instance.
597
+ *
598
+ * Intent is provider-driven. Today only GROQ is supported; when using GROQ
599
+ * we always take the model from GROQ's config defaults.
600
+ *
601
+ * @returns Provider-specific model name
602
+ * @private
603
+ */
604
+ resolveModel() {
605
+ return this.env.GROQ.DEFAULT_MODEL;
606
+ }
607
+ /**
608
+ * Builds the context object from options.
609
+ *
610
+ * Constructs an IntentContext with only defined properties to satisfy
611
+ * TypeScript's exactOptionalPropertyTypes requirement.
612
+ *
613
+ * @param options - The options object containing llm, logger, and userId
614
+ * @returns IntentContext with only defined properties
615
+ * @private
616
+ */
617
+ buildContext(options) {
618
+ return {
619
+ ...options.llm !== void 0 && { llm: options.llm },
620
+ ...options.logger !== void 0 && { logger: options.logger },
621
+ ...options.userId !== void 0 && { userId: options.userId }
622
+ };
623
+ }
624
+ /**
625
+ * Builds the extractors object from options.
626
+ *
627
+ * Uses provided extractors or falls back to generic defaults that work
628
+ * for any type T via JSON stringification and hashing.
629
+ *
630
+ * @param options - The options object containing key and summary extractors
631
+ * @returns Required extractors with defaults applied
632
+ * @private
633
+ */
634
+ buildExtractors(options) {
635
+ return {
636
+ key: options.key ?? DEFAULT_KEY_EXTRACTOR,
637
+ summary: options.summary ?? DEFAULT_SUMMARY_EXTRACTOR
638
+ };
639
+ }
640
+ /**
641
+ * Builds the configuration object from options.
642
+ *
643
+ * Merges user-provided options with environment-based CONFIG defaults.
644
+ *
645
+ * @param options - The options object containing config overrides
646
+ * @returns Required config with all values populated
647
+ * @private
648
+ */
649
+ buildConfig(options) {
650
+ return {
651
+ provider: options.provider ?? this.env.INTENT.PROVIDER,
652
+ timeoutMs: options.timeoutMs ?? this.env.INTENT.TIMEOUT_MS,
653
+ relevancyThreshold: options.relevancyThreshold ?? this.env.INTENT.RELEVANCY_THRESHOLD,
654
+ batchSize: options.batchSize ?? this.env.INTENT.BATCH_SIZE,
655
+ tinyBatchFraction: options.tinyBatchFraction ?? this.env.INTENT.TINY_BATCH_FRACTION,
656
+ minScore: options.minScore ?? this.env.INTENT.MIN_SCORE,
657
+ maxScore: options.maxScore ?? this.env.INTENT.MAX_SCORE
658
+ };
659
+ }
660
+ /**
661
+ * Validates the configuration values.
662
+ *
663
+ * Ensures the configured score range is valid and the relevancyThreshold is in range.
664
+ *
665
+ * @throws {Error} If maxScore is below minScore
666
+ * @throws {Error} If relevancyThreshold is not within [minScore, maxScore]
667
+ * @private
668
+ */
669
+ validateConfig() {
670
+ if (this.cfg.maxScore < this.cfg.minScore) {
671
+ throw new Error(
672
+ `intent: maxScore must be >= minScore, got minScore=${this.cfg.minScore} maxScore=${this.cfg.maxScore}`
673
+ );
674
+ }
675
+ if (this.cfg.relevancyThreshold < this.cfg.minScore || this.cfg.relevancyThreshold > this.cfg.maxScore) {
676
+ throw new Error(
677
+ `intent: relevancyThreshold must be between ${this.cfg.minScore} and ${this.cfg.maxScore}, got ${this.cfg.relevancyThreshold}`
678
+ );
679
+ }
680
+ }
681
+ /**
682
+ * Selects and validates the LLM client.
683
+ *
684
+ * Uses the provided client from context or attempts to create a default
685
+ * Groq client if GROQ_API_KEY is available.
686
+ *
687
+ * @returns The selected LLM client
688
+ * @throws {Error} If no LLM client is provided and GROQ_API_KEY is not set
689
+ * @private
690
+ */
691
+ selectAndValidateLlmClient() {
692
+ const selectedClient = selectLlmClient(this.ctx, this.env);
693
+ if (!selectedClient) {
694
+ throw new Error(
695
+ "intent: No LLM client provided and GROQ_API_KEY not set. Provide options.llm or set GROQ_API_KEY."
696
+ );
697
+ }
698
+ return selectedClient;
699
+ }
700
+ /**
701
+ * Creates a new Intent instance.
702
+ *
703
+ * All options are optional with sensible defaults:
704
+ * - llm: Auto-detected from GROQ_API_KEY environment variable if available
705
+ * - key: Hash-based string from JSON representation of items
706
+ * - summary: Pretty-printed JSON of items (2-space indentation for LLM readability)
707
+ * - Config values: From INTENT_* environment variables or built-in defaults
708
+ *
709
+ * @param options - Optional configuration object
710
+ * @param options.llm - Optional LLM client. If omitted, uses Groq client when GROQ_API_KEY is set
711
+ * @param options.logger - Optional logger for warnings and errors
712
+ * @param options.userId - Optional user identifier for LLM provider abuse monitoring
713
+ * @param options.key - Optional function extracting a short human-readable key from items
714
+ * @param options.summary - Optional function extracting a short description for LLM reasoning
715
+ * @param options.provider - Optional provider override (default: INTENT_PROVIDER or "GROQ")
716
+ * @param options.timeoutMs - Optional timeout in milliseconds (default: INTENT_TIMEOUT_MS or 3000)
717
+ * @param options.relevancyThreshold - Optional minimum score to include results (default: INTENT_RELEVANCY_THRESHOLD)
718
+ * @param options.minScore - Optional minimum score value (default: INTENT_MIN_SCORE or 0)
719
+ * @param options.maxScore - Optional maximum score value (default: INTENT_MAX_SCORE or 10)
720
+ * @param options.batchSize - Optional number of candidates per LLM call (default: INTENT_BATCH_SIZE or 20)
721
+ * @param options.tinyBatchFraction - Optional threshold for merging small batches (default: INTENT_TINY_BATCH_FRACTION or 0.2)
722
+ * @throws {Error} If no LLM client is provided and GROQ_API_KEY is not set
723
+ * @throws {Error} If maxScore is below minScore
724
+ * @throws {Error} If relevancyThreshold is not within [minScore, maxScore]
725
+ *
726
+ * @example
727
+ * ```typescript
728
+ * // Minimal - uses all defaults
729
+ * const intent = new Intent();
730
+ *
731
+ * // With extractors
732
+ * const intent = new Intent<Doc>({
733
+ * key: doc => doc.title,
734
+ * summary: doc => doc.content
735
+ * });
736
+ *
737
+ * // Full configuration
738
+ * const intent = new Intent<Doc>({
739
+ * llm: myClient,
740
+ * userId: "org-123",
741
+ * key: doc => doc.title,
742
+ * summary: doc => doc.content,
743
+ * relevancyThreshold: 5,
744
+ * batchSize: 20
745
+ * });
746
+ * ```
747
+ */
748
+ constructor(options = {}) {
749
+ this.env = options.config ?? CONFIG;
750
+ this.ctx = this.buildContext(options);
751
+ this.extractors = this.buildExtractors(options);
752
+ this.cfg = this.buildConfig(options);
753
+ this.validateConfig();
754
+ this.llm = this.selectAndValidateLlmClient();
755
+ }
756
+ async rank(query, candidates, options) {
757
+ try {
758
+ if (candidates.length === 0) {
759
+ return [];
760
+ }
761
+ if (candidates.length === 1) {
762
+ if (options?.explain) {
763
+ const [firstCandidate] = candidates;
764
+ return [{ item: firstCandidate, explanation: "" }];
765
+ }
766
+ return candidates;
767
+ }
768
+ const prepared = this.prepareCandidates(candidates);
769
+ const rankedWithExplanations = await batchProcess(
770
+ prepared,
771
+ this.cfg.batchSize,
772
+ this.cfg.tinyBatchFraction,
773
+ async (batch) => await this.processBatch(
774
+ query,
775
+ batch,
776
+ options?.userId !== void 0 ? { userId: options.userId } : void 0
777
+ ),
778
+ this.ctx.logger,
779
+ (batch) => batch.map(({ item }) => ({ item, explanation: "" }))
780
+ );
781
+ if (options?.explain) {
782
+ return rankedWithExplanations;
783
+ }
784
+ return rankedWithExplanations.map(({ item }) => item);
785
+ } catch (error) {
786
+ this.ctx.logger?.warn?.("intent reranker failed, using fallback", {
787
+ error: error?.message
788
+ });
789
+ if (options?.explain) {
790
+ return candidates.map((item) => ({ item, explanation: "" }));
791
+ }
792
+ return candidates;
793
+ }
794
+ }
795
+ async filter(query, candidates, options) {
796
+ if (candidates.length === 0) {
797
+ return [];
798
+ }
799
+ if (candidates.length === 1) {
800
+ if (options?.explain) {
801
+ const [firstCandidate] = candidates;
802
+ return [{ item: firstCandidate, explanation: "" }];
803
+ }
804
+ return candidates;
805
+ }
806
+ const prepared = this.prepareCandidates(candidates);
807
+ const filteredWithExplanations = await batchProcess(
808
+ prepared,
809
+ this.cfg.batchSize,
810
+ this.cfg.tinyBatchFraction,
811
+ async (batch) => await this.processFilterBatch(
812
+ query,
813
+ batch,
814
+ options?.userId !== void 0 ? { userId: options.userId } : void 0
815
+ ),
816
+ this.ctx.logger,
817
+ (batch) => batch.map(({ item }) => ({ item, explanation: "" }))
818
+ );
819
+ if (options?.explain) {
820
+ return filteredWithExplanations;
821
+ }
822
+ return filteredWithExplanations.map(({ item }) => item);
823
+ }
824
+ async choice(query, candidates, options) {
825
+ if (candidates.length === 0) {
826
+ throw new Error("intent: choice requires at least one candidate");
827
+ }
828
+ if (candidates.length === 1) {
829
+ const [firstCandidate] = candidates;
830
+ if (options?.explain) {
831
+ return { item: firstCandidate, explanation: "" };
832
+ }
833
+ return firstCandidate;
834
+ }
835
+ const prepared = this.prepareCandidates(candidates);
836
+ const keyed = this.ensureUniqueKeys(prepared);
837
+ const winners = await batchProcess(
838
+ keyed,
839
+ this.cfg.batchSize,
840
+ this.cfg.tinyBatchFraction,
841
+ async (batch) => [await this.processChoiceBatch(query, batch, options?.userId)],
842
+ this.ctx.logger,
843
+ (batch) => [{ item: batch[0].item, explanation: "" }]
844
+ );
845
+ if (winners.length === 1) {
846
+ const [onlyWinner] = winners;
847
+ if (options?.explain) {
848
+ return onlyWinner;
849
+ }
850
+ return onlyWinner.item;
851
+ }
852
+ const preparedFinalists = this.prepareCandidates(winners.map((w) => w.item));
853
+ const keyedFinalists = this.ensureUniqueKeys(preparedFinalists);
854
+ const final = await this.processChoiceBatch(query, keyedFinalists, options?.userId);
855
+ if (options?.explain) {
856
+ return final;
857
+ }
858
+ return final.item;
859
+ }
860
+ /**
861
+ * Normalize incoming items into a consistent shape for downstream processing.
862
+ *
863
+ * Extracts the key and summary from each item using the configured extractors,
864
+ * and attaches the original input index for stable sorting later.
865
+ *
866
+ * @param candidates - Raw items to prepare
867
+ * @returns Array of prepared candidates with extracted metadata and original index
868
+ * @private
869
+ */
870
+ prepareCandidates(candidates) {
871
+ return candidates.map((item, idx) => ({
872
+ item,
873
+ idx,
874
+ baseKey: this.extractors.key(item),
875
+ summary: this.extractors.summary(item)
876
+ }));
877
+ }
878
+ /**
879
+ * Ensure keys are unique by suffixing duplicates with their input index.
880
+ *
881
+ * When multiple items share the same key, subsequent occurrences are renamed
882
+ * to "Key (idx)" where idx is the original input index. This prevents JSON
883
+ * schema validation errors and ensures the LLM can score each item independently.
884
+ *
885
+ * @param itemsBase - Prepared candidates with potentially duplicate keys
886
+ * @returns Candidates with guaranteed unique keys
887
+ * @private
888
+ */
889
+ ensureUniqueKeys(itemsBase) {
890
+ const counts = /* @__PURE__ */ new Map();
891
+ return itemsBase.map(({ item, baseKey, summary, idx }) => {
892
+ const n = (counts.get(baseKey) ?? 0) + 1;
893
+ counts.set(baseKey, n);
894
+ const key = n === 1 ? baseKey : `${baseKey} (${idx})`;
895
+ return { item, idx, key, summary };
896
+ });
897
+ }
898
+ /**
899
+ * Build the JSON schema and chat messages payload for the LLM.
900
+ *
901
+ * Creates a strict JSON schema requiring one integer property (minScore-maxScore) per candidate key,
902
+ * and constructs system + user messages instructing the LLM to score relevance.
903
+ *
904
+ * @param query - The search query to evaluate candidates against
905
+ * @param items - Candidates with unique keys and summaries
906
+ * @returns Object containing JSON schema and chat messages array
907
+ * @private
908
+ */
909
+ buildRequest(query, items) {
910
+ const keys = items.map((x) => x.key);
911
+ const schema = buildRelevancySchema(keys, this.cfg.minScore, this.cfg.maxScore);
912
+ const messages = buildMessages(query, items, {
913
+ minScore: this.cfg.minScore,
914
+ maxScore: this.cfg.maxScore
915
+ });
916
+ return { schema, messages };
917
+ }
918
+ /**
919
+ * Build the JSON schema and chat messages payload for the LLM filter call.
920
+ *
921
+ * @param query - The search query to evaluate candidates against
922
+ * @param items - Candidates with unique keys and summaries
923
+ * @returns Object containing JSON schema and chat messages array
924
+ * @private
925
+ */
926
+ buildFilterRequest(query, items) {
927
+ const keys = items.map((x) => x.key);
928
+ const schema = buildFilterSchema(keys);
929
+ const messages = buildFilterMessages(query, items);
930
+ return { schema, messages };
931
+ }
932
+ /**
933
+ * Build the JSON schema and chat messages payload for the LLM choice call.
934
+ *
935
+ * @param query - The search query to choose against
936
+ * @param items - Candidates with unique keys and summaries
937
+ * @returns Object containing JSON schema and chat messages array
938
+ * @private
939
+ */
940
+ buildChoiceRequest(query, items) {
941
+ const keys = items.map((x) => x.key);
942
+ const schema = buildChoiceSchema(keys);
943
+ const messages = buildChoiceMessages(query, items);
944
+ return { schema, messages };
945
+ }
946
+ /**
947
+ * Invoke the LLM and return the parsed map of candidate scores.
948
+ *
949
+ * Calls the configured LLM client with the messages, JSON schema, model config,
950
+ * and user ID. Returns null if the response is invalid or missing.
951
+ *
952
+ * @param messages - Chat messages (system + user) to send to LLM
953
+ * @param schema - Strict JSON schema defining expected response structure
954
+ * @param userId - Optional user identifier for provider abuse monitoring
955
+ * @returns Map of candidate keys to numeric scores, or null if response invalid
956
+ * @private
957
+ */
958
+ async fetchEvaluations(messages, schema, userId) {
959
+ const config = {
960
+ model: this.resolveModel(),
961
+ reasoningEffort: "medium",
962
+ timeoutMs: this.cfg.timeoutMs
963
+ };
964
+ const { data } = await this.llm.call(
965
+ messages,
966
+ schema,
967
+ config,
968
+ userId ?? this.ctx.userId
969
+ );
970
+ if (data == null || typeof data !== "object") return null;
971
+ return data;
972
+ }
973
+ /**
974
+ * Invoke the LLM and return boolean relevancy decisions.
975
+ *
976
+ * @param messages - Chat messages to send
977
+ * @param schema - Strict JSON schema defining expected response structure
978
+ * @param userId - Optional user id
979
+ * @returns Map of candidate keys to filter decisions, or null if invalid
980
+ * @private
981
+ */
982
+ async fetchFilterDecisions(messages, schema, userId) {
983
+ const config = {
984
+ model: this.resolveModel(),
985
+ reasoningEffort: "medium",
986
+ timeoutMs: this.cfg.timeoutMs
987
+ };
988
+ const { data } = await this.llm.call(messages, schema, config, userId ?? this.ctx.userId);
989
+ if (data == null || typeof data !== "object") return null;
990
+ return data;
991
+ }
992
+ /**
993
+ * Invoke the LLM and return a single selected key.
994
+ *
995
+ * @param messages - Chat messages to send
996
+ * @param schema - Strict JSON schema defining expected response structure
997
+ * @param userId - Optional user id
998
+ * @returns Choice result, or null if invalid
999
+ * @private
1000
+ */
1001
+ async fetchChoice(messages, schema, userId) {
1002
+ const config = {
1003
+ model: this.resolveModel(),
1004
+ reasoningEffort: "medium",
1005
+ timeoutMs: this.cfg.timeoutMs
1006
+ };
1007
+ const { data } = await this.llm.call(
1008
+ messages,
1009
+ schema,
1010
+ config,
1011
+ userId ?? this.ctx.userId
1012
+ );
1013
+ if (data == null || typeof data !== "object") return null;
1014
+ const record = data;
1015
+ const explanation = typeof record.explanation === "string" ? record.explanation : "";
1016
+ const selectedKey = typeof record.selectedKey === "string" ? record.selectedKey : "";
1017
+ if (selectedKey === "") return null;
1018
+ return { explanation, selectedKey };
1019
+ }
1020
+ /**
1021
+ * Apply boolean filter decisions while preserving input order.
1022
+ *
1023
+ * @param items - Candidates with unique keys
1024
+ * @param decisions - Map of candidate keys to decisions
1025
+ * @returns Filtered items with explanations
1026
+ * @private
1027
+ */
1028
+ applyFilter(items, decisions) {
1029
+ const kept = [];
1030
+ for (const it of items) {
1031
+ const decision = decisions[it.key];
1032
+ if (decision?.isRelevant === true) {
1033
+ kept.push({
1034
+ item: it.item,
1035
+ explanation: typeof decision.explanation === "string" ? decision.explanation : ""
1036
+ });
1037
+ }
1038
+ }
1039
+ return kept;
1040
+ }
1041
+ /**
1042
+ * Process a single batch of candidates through the LLM filter.
1043
+ *
1044
+ * @param query - Query to filter against
1045
+ * @param batch - Prepared candidates
1046
+ * @param options - Optional userId override
1047
+ * @returns Filtered items (stable order), with explanations
1048
+ * @private
1049
+ */
1050
+ async processFilterBatch(query, batch, options) {
1051
+ const keyed = this.ensureUniqueKeys(batch);
1052
+ const { schema, messages } = this.buildFilterRequest(query, keyed);
1053
+ const decisions = await this.fetchFilterDecisions(messages, schema, options?.userId);
1054
+ if (decisions == null) {
1055
+ return keyed.map(({ item }) => ({ item, explanation: "" }));
1056
+ }
1057
+ return this.applyFilter(keyed, decisions);
1058
+ }
1059
+ /**
1060
+ * Process a batch of candidates and choose a single winner.
1061
+ *
1062
+ * @param query - Query to choose against
1063
+ * @param batch - Candidates with unique keys
1064
+ * @param userId - Optional userId override
1065
+ * @returns Winner item with explanation
1066
+ * @private
1067
+ */
1068
+ async processChoiceBatch(query, batch, userId) {
1069
+ const { schema, messages } = this.buildChoiceRequest(query, batch);
1070
+ const choice = await this.fetchChoice(messages, schema, userId);
1071
+ if (choice == null) {
1072
+ return { item: batch[0].item, explanation: "" };
1073
+ }
1074
+ const winner = batch.find((x) => x.key === choice.selectedKey);
1075
+ if (!winner) {
1076
+ return { item: batch[0].item, explanation: choice.explanation };
1077
+ }
1078
+ return { item: winner.item, explanation: choice.explanation };
1079
+ }
1080
+ /**
1081
+ * Apply relevancy threshold filtering and stable sorting.
1082
+ *
1083
+ * Scores are clamped to the configured score range, then filtered to keep only items with
1084
+ * score > threshold. Results are sorted by score descending, with ties
1085
+ * preserving original input order for deterministic results.
1086
+ *
1087
+ * @param items - Candidates with unique keys
1088
+ * @param scores - Map of candidate keys to LLM-assigned scores
1089
+ * @returns Filtered and sorted array of original items
1090
+ * @private
1091
+ */
1092
+ rankAndFilter(items, evaluations) {
1093
+ const threshold = this.cfg.relevancyThreshold;
1094
+ const scored = items.map(({ item, idx, key }) => ({
1095
+ item,
1096
+ idx,
1097
+ explanation: typeof evaluations[key]?.explanation === "string" ? evaluations[key].explanation : "",
1098
+ score: clamp(
1099
+ evaluations[key]?.score ?? this.cfg.minScore,
1100
+ this.cfg.minScore,
1101
+ this.cfg.maxScore
1102
+ )
1103
+ }));
1104
+ const filtered = scored.filter(({ score }) => score > threshold);
1105
+ const sorted = filtered.sort((a, b) => {
1106
+ if (b.score !== a.score) return b.score - a.score;
1107
+ return a.idx - b.idx;
1108
+ });
1109
+ return sorted;
1110
+ }
1111
+ /**
1112
+ * Process a single batch of candidates through the LLM.
1113
+ *
1114
+ * Ensures unique keys, builds the request payload, fetches scores from the LLM,
1115
+ * and returns filtered and sorted results. On any error or null response from
1116
+ * the LLM, returns items in their original order as a fallback.
1117
+ *
1118
+ * @param query - The search query to evaluate candidates against
1119
+ * @param batch - Batch of prepared candidates to process
1120
+ * @param userId - Optional user identifier for provider abuse monitoring
1121
+ * @returns Ranked and filtered items, or original order on error
1122
+ * @private
1123
+ */
1124
+ async processBatch(query, batch, options) {
1125
+ const keyed = this.ensureUniqueKeys(batch);
1126
+ const { schema, messages } = this.buildRequest(query, keyed);
1127
+ const evaluations = await this.fetchEvaluations(messages, schema, options?.userId);
1128
+ if (evaluations == null) {
1129
+ return keyed.map(({ item }) => ({ item, explanation: "" }));
1130
+ }
1131
+ const ranked = this.rankAndFilter(keyed, evaluations);
1132
+ return ranked.map(({ item, explanation }) => ({ item, explanation }));
1133
+ }
1134
+ };
1135
+ // Annotate the CommonJS export names for ESM import in node:
1136
+ 0 && (module.exports = {
1137
+ CONFIG,
1138
+ DEFAULT_KEY_EXTRACTOR,
1139
+ DEFAULT_SUMMARY_EXTRACTOR,
1140
+ Intent,
1141
+ createDefaultGroqClient
1142
+ });
1143
+ //# sourceMappingURL=index.cjs.map