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