ak-gemini 1.0.52 → 1.0.54

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.
Files changed (3) hide show
  1. package/index.cjs +231 -79
  2. package/index.js +341 -116
  3. package/package.json +1 -1
package/index.cjs CHANGED
@@ -29,8 +29,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
29
29
  // index.js
30
30
  var index_exports = {};
31
31
  __export(index_exports, {
32
- AITransformer: () => AITransformer,
33
- default: () => AITransformer,
32
+ default: () => index_default,
34
33
  log: () => logger_default
35
34
  });
36
35
  module.exports = __toCommonJS(index_exports);
@@ -76,7 +75,7 @@ When presented with new Source JSON, apply the learned transformation rules to p
76
75
 
77
76
  Always respond ONLY with a valid JSON object that strictly adheres to the expected output format.
78
77
 
79
- Do not include any additional text, explanations, or formatting before or after the JSON object.
78
+ Do not include any additional text, explanations, or formatting before or after the JSON object.
80
79
  `;
81
80
  var DEFAULT_CHAT_CONFIG = {
82
81
  responseMimeType: "application/json",
@@ -96,26 +95,34 @@ var AITransformer = class {
96
95
  this.promptKey = "";
97
96
  this.answerKey = "";
98
97
  this.contextKey = "";
98
+ this.explanationKey = "";
99
+ this.systemInstructionKey = "";
99
100
  this.maxRetries = 3;
100
101
  this.retryDelay = 1e3;
101
102
  this.systemInstructions = "";
102
103
  this.chatConfig = {};
103
104
  this.apiKey = GEMINI_API_KEY;
105
+ this.onlyJSON = true;
106
+ this.asyncValidator = null;
104
107
  AITransformFactory.call(this, options);
105
108
  this.init = initChat.bind(this);
106
109
  this.seed = seedWithExamples.bind(this);
107
- this.message = transformJSON.bind(this);
110
+ this.rawMessage = rawMessage.bind(this);
111
+ this.message = (payload, opts = {}, validatorFn = null) => {
112
+ return prepareAndValidateMessage.call(this, payload, opts, validatorFn || this.asyncValidator);
113
+ };
108
114
  this.rebuild = rebuildPayload.bind(this);
109
115
  this.reset = resetChat.bind(this);
110
116
  this.getHistory = getChatHistory.bind(this);
111
- this.transformWithValidation = transformWithValidation.bind(this);
117
+ this.messageAndValidate = prepareAndValidateMessage.bind(this);
112
118
  this.estimate = estimateTokenUsage.bind(this);
113
119
  }
114
120
  };
121
+ var index_default = AITransformer;
115
122
  function AITransformFactory(options = {}) {
116
123
  this.modelName = options.modelName || "gemini-2.0-flash";
117
124
  this.systemInstructions = options.systemInstructions || DEFAULT_SYSTEM_INSTRUCTIONS;
118
- this.apiKey = options.apiKey || GEMINI_API_KEY;
125
+ this.apiKey = options.apiKey !== void 0 && options.apiKey !== null ? options.apiKey : GEMINI_API_KEY;
119
126
  if (!this.apiKey) throw new Error("Missing Gemini API key. Provide via options.apiKey or GEMINI_API_KEY env var.");
120
127
  this.chatConfig = {
121
128
  ...DEFAULT_CHAT_CONFIG,
@@ -127,11 +134,15 @@ function AITransformFactory(options = {}) {
127
134
  }
128
135
  this.examplesFile = options.examplesFile || null;
129
136
  this.exampleData = options.exampleData || null;
130
- this.promptKey = options.sourceKey || "PROMPT";
131
- this.answerKey = options.targetKey || "ANSWER";
137
+ this.promptKey = options.promptKey || "PROMPT";
138
+ this.answerKey = options.answerKey || "ANSWER";
132
139
  this.contextKey = options.contextKey || "CONTEXT";
140
+ this.explanationKey = options.explanationKey || "EXPLANATION";
141
+ this.systemInstructionsKey = options.systemInstructionsKey || "SYSTEM";
133
142
  this.maxRetries = options.maxRetries || 3;
134
143
  this.retryDelay = options.retryDelay || 1e3;
144
+ this.asyncValidator = options.asyncValidator || null;
145
+ this.onlyJSON = options.onlyJSON !== void 0 ? options.onlyJSON : true;
135
146
  if (this.promptKey === this.answerKey) {
136
147
  throw new Error("Source and target keys cannot be the same. Please provide distinct keys.");
137
148
  }
@@ -141,8 +152,8 @@ function AITransformFactory(options = {}) {
141
152
  this.genAIClient = ai;
142
153
  this.chat = null;
143
154
  }
144
- async function initChat() {
145
- if (this.chat) return;
155
+ async function initChat(force = false) {
156
+ if (this.chat && !force) return;
146
157
  logger_default.debug(`Initializing Gemini chat session with model: ${this.modelName}...`);
147
158
  this.chat = await this.genAIClient.chats.create({
148
159
  model: this.modelName,
@@ -157,36 +168,49 @@ async function seedWithExamples(examples) {
157
168
  if (!examples || !Array.isArray(examples) || examples.length === 0) {
158
169
  if (this.examplesFile) {
159
170
  logger_default.debug(`No examples provided, loading from file: ${this.examplesFile}`);
160
- examples = await import_ak_tools.default.load(import_path.default.resolve(this.examplesFile), true);
171
+ try {
172
+ examples = await import_ak_tools.default.load(import_path.default.resolve(this.examplesFile), true);
173
+ } catch (err) {
174
+ throw new Error(`Could not load examples from file: ${this.examplesFile}. Please check the file path and format.`);
175
+ }
161
176
  } else {
162
177
  logger_default.debug("No examples provided and no examples file specified. Skipping seeding.");
163
178
  return;
164
179
  }
165
180
  }
181
+ const instructionExample = examples.find((ex) => ex[this.systemInstructionsKey]);
182
+ if (instructionExample) {
183
+ logger_default.debug(`Found system instructions in examples; reinitializing chat with new instructions.`);
184
+ this.systemInstructions = instructionExample[this.systemInstructionsKey];
185
+ this.chatConfig.systemInstruction = this.systemInstructions;
186
+ await this.init(true);
187
+ }
166
188
  logger_default.debug(`Seeding chat with ${examples.length} transformation examples...`);
167
189
  const historyToAdd = [];
168
190
  for (const example of examples) {
169
191
  const contextValue = example[this.contextKey] || "";
170
192
  const promptValue = example[this.promptKey] || "";
171
193
  const answerValue = example[this.answerKey] || "";
194
+ const explanationValue = example[this.explanationKey] || "";
195
+ let userText = "";
196
+ let modelResponse = {};
172
197
  if (contextValue) {
173
- let contextText = import_ak_tools.default.isJSON(contextValue) ? JSON.stringify(contextValue, null, 2) : contextValue;
174
- historyToAdd.push({
175
- role: "user",
176
- parts: [{ text: `Context: ${contextText}` }]
177
- });
178
- historyToAdd.push({
179
- role: "model",
180
- parts: [{ text: "I understand the context." }]
181
- });
198
+ let contextText = isJSON(contextValue) ? JSON.stringify(contextValue, null, 2) : contextValue;
199
+ userText += `CONTEXT:
200
+ ${contextText}
201
+
202
+ `;
182
203
  }
183
204
  if (promptValue) {
184
- let promptText = import_ak_tools.default.isJSON(promptValue) ? JSON.stringify(promptValue, null, 2) : promptValue;
185
- historyToAdd.push({ role: "user", parts: [{ text: promptText }] });
205
+ let promptText = isJSON(promptValue) ? JSON.stringify(promptValue, null, 2) : promptValue;
206
+ userText += promptText;
186
207
  }
187
- if (answerValue) {
188
- let answerText = import_ak_tools.default.isJSON(answerValue) ? JSON.stringify(answerValue, null, 2) : answerValue;
189
- historyToAdd.push({ role: "model", parts: [{ text: answerText }] });
208
+ if (answerValue) modelResponse.data = answerValue;
209
+ if (explanationValue) modelResponse.explanation = explanationValue;
210
+ const modelText = JSON.stringify(modelResponse, null, 2);
211
+ if (userText.trim().length && modelText.trim().length > 0) {
212
+ historyToAdd.push({ role: "user", parts: [{ text: userText.trim() }] });
213
+ historyToAdd.push({ role: "model", parts: [{ text: modelText.trim() }] });
190
214
  }
191
215
  }
192
216
  const currentHistory = this?.chat?.getHistory() || [];
@@ -197,77 +221,64 @@ async function seedWithExamples(examples) {
197
221
  history: [...currentHistory, ...historyToAdd]
198
222
  });
199
223
  logger_default.debug("Transformation examples seeded successfully.");
224
+ return this.chat.getHistory();
200
225
  }
201
- async function transformJSON(sourcePayload) {
226
+ async function rawMessage(sourcePayload) {
202
227
  if (!this.chat) {
203
- throw new Error("Chat session not initialized. Call initChat() or seedWithExamples() first.");
228
+ throw new Error("Chat session not initialized.");
204
229
  }
205
- let result;
206
- let actualPayload;
207
- if (sourcePayload && import_ak_tools.default.isJSON(sourcePayload)) actualPayload = JSON.stringify(sourcePayload, null, 2);
208
- else if (typeof sourcePayload === "string") actualPayload = sourcePayload;
209
- else throw new Error("Invalid source payload. Must be a JSON object or a valid JSON string.");
230
+ const actualPayload = typeof sourcePayload === "string" ? sourcePayload : JSON.stringify(sourcePayload, null, 2);
210
231
  try {
211
- result = await this.chat.sendMessage({ message: actualPayload });
232
+ const result = await this.chat.sendMessage({ message: actualPayload });
233
+ const modelResponse = result.text;
234
+ const extractedJSON = extractJSON(modelResponse);
235
+ if (extractedJSON?.data) {
236
+ return extractedJSON.data;
237
+ }
238
+ return extractedJSON;
212
239
  } catch (error) {
213
- logger_default.error("Error with Gemini API:", error);
240
+ if (this.onlyJSON && error.message.includes("Could not extract valid JSON")) {
241
+ throw new Error(`Invalid JSON response from Gemini: ${error.message}`);
242
+ }
214
243
  throw new Error(`Transformation failed: ${error.message}`);
215
244
  }
216
- try {
217
- const modelResponse = result.text;
218
- const parsedResponse = JSON.parse(modelResponse);
219
- return parsedResponse;
220
- } catch (parseError) {
221
- logger_default.error("Error parsing Gemini response:", parseError);
222
- throw new Error(`Invalid JSON response from Gemini: ${parseError.message}`);
223
- }
224
245
  }
225
- async function transformWithValidation(sourcePayload, validatorFn, options = {}) {
246
+ async function prepareAndValidateMessage(sourcePayload, options = {}, validatorFn = null) {
247
+ if (!this.chat) {
248
+ throw new Error("Chat session not initialized. Please call init() first.");
249
+ }
226
250
  const maxRetries = options.maxRetries ?? this.maxRetries;
227
251
  const retryDelay = options.retryDelay ?? this.retryDelay;
228
- let lastPayload = null;
229
252
  let lastError = null;
253
+ let lastPayload = null;
254
+ if (sourcePayload && isJSON(sourcePayload)) {
255
+ lastPayload = JSON.stringify(sourcePayload, null, 2);
256
+ } else if (typeof sourcePayload === "string") {
257
+ lastPayload = sourcePayload;
258
+ } else {
259
+ throw new Error("Invalid source payload. Must be a JSON object or string.");
260
+ }
230
261
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
231
262
  try {
232
- const transformedPayload = attempt === 0 ? await this.message(sourcePayload) : await this.rebuild(lastPayload, lastError.message);
233
- const validatedPayload = await validatorFn(transformedPayload);
234
- logger_default.debug(`Transformation and validation succeeded on attempt ${attempt + 1}`);
235
- return validatedPayload;
263
+ const transformedPayload = attempt === 0 ? await this.rawMessage(lastPayload) : await this.rebuild(lastPayload, lastError.message);
264
+ lastPayload = transformedPayload;
265
+ if (validatorFn) {
266
+ await validatorFn(transformedPayload);
267
+ }
268
+ logger_default.debug(`Transformation succeeded on attempt ${attempt + 1}`);
269
+ return transformedPayload;
236
270
  } catch (error) {
237
271
  lastError = error;
238
- if (attempt === 0) {
239
- lastPayload = await this.message(sourcePayload).catch(() => null);
240
- }
241
- if (attempt < maxRetries) {
242
- const delay = retryDelay * Math.pow(2, attempt);
243
- logger_default.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, error.message);
244
- await new Promise((res) => setTimeout(res, delay));
245
- } else {
246
- logger_default.error(`All ${maxRetries + 1} attempts failed`);
247
- throw new Error(`Transformation with validation failed after ${maxRetries + 1} attempts. Last error: ${error.message}`);
272
+ logger_default.warn(`Attempt ${attempt + 1} failed: ${error.message}`);
273
+ if (attempt >= maxRetries) {
274
+ logger_default.error(`All ${maxRetries + 1} attempts failed.`);
275
+ throw new Error(`Transformation failed after ${maxRetries + 1} attempts. Last error: ${error.message}`);
248
276
  }
277
+ const delay = retryDelay * Math.pow(2, attempt);
278
+ await new Promise((res) => setTimeout(res, delay));
249
279
  }
250
280
  }
251
281
  }
252
- async function estimateTokenUsage(nextPayload) {
253
- const contents = [];
254
- if (this.systemInstructions) {
255
- contents.push({ parts: [{ text: this.systemInstructions }] });
256
- }
257
- if (this.chat && typeof this.chat.getHistory === "function") {
258
- const history = this.chat.getHistory();
259
- if (Array.isArray(history) && history.length > 0) {
260
- contents.push(...history);
261
- }
262
- }
263
- const nextMessage = typeof nextPayload === "string" ? nextPayload : JSON.stringify(nextPayload, null, 2);
264
- contents.push({ parts: [{ text: nextMessage }] });
265
- const resp = await this.genAIClient.models.countTokens({
266
- model: this.modelName,
267
- contents
268
- });
269
- return resp;
270
- }
271
282
  async function rebuildPayload(lastPayload, serverError) {
272
283
  await this.init();
273
284
  const prompt = `
@@ -277,6 +288,7 @@ The server's error message is quoted afterward.
277
288
  ---------------- BAD PAYLOAD ----------------
278
289
  ${JSON.stringify(lastPayload, null, 2)}
279
290
 
291
+
280
292
  ---------------- SERVER ERROR ----------------
281
293
  ${serverError}
282
294
 
@@ -296,6 +308,25 @@ Respond with JSON only \u2013 no comments or explanations.
296
308
  throw new Error(`Gemini returned non-JSON while repairing payload: ${parseErr.message}`);
297
309
  }
298
310
  }
311
+ async function estimateTokenUsage(nextPayload) {
312
+ const contents = [];
313
+ if (this.systemInstructions) {
314
+ contents.push({ parts: [{ text: this.systemInstructions }] });
315
+ }
316
+ if (this.chat && typeof this.chat.getHistory === "function") {
317
+ const history = this.chat.getHistory();
318
+ if (Array.isArray(history) && history.length > 0) {
319
+ contents.push(...history);
320
+ }
321
+ }
322
+ const nextMessage = typeof nextPayload === "string" ? nextPayload : JSON.stringify(nextPayload, null, 2);
323
+ contents.push({ parts: [{ text: nextMessage }] });
324
+ const resp = await this.genAIClient.models.countTokens({
325
+ model: this.modelName,
326
+ contents
327
+ });
328
+ return resp;
329
+ }
299
330
  async function resetChat() {
300
331
  if (this.chat) {
301
332
  logger_default.debug("Resetting Gemini chat session...");
@@ -317,6 +348,128 @@ function getChatHistory() {
317
348
  }
318
349
  return this.chat.getHistory();
319
350
  }
351
+ function isJSON(data) {
352
+ try {
353
+ const attempt = JSON.stringify(data);
354
+ if (attempt?.startsWith("{") || attempt?.startsWith("[")) {
355
+ if (attempt?.endsWith("}") || attempt?.endsWith("]")) {
356
+ return true;
357
+ }
358
+ }
359
+ return false;
360
+ } catch (e) {
361
+ return false;
362
+ }
363
+ }
364
+ function isJSONStr(string) {
365
+ if (typeof string !== "string") return false;
366
+ try {
367
+ const result = JSON.parse(string);
368
+ const type = Object.prototype.toString.call(result);
369
+ return type === "[object Object]" || type === "[object Array]";
370
+ } catch (err) {
371
+ return false;
372
+ }
373
+ }
374
+ function extractJSON(text) {
375
+ if (!text || typeof text !== "string") {
376
+ throw new Error("No text provided for JSON extraction");
377
+ }
378
+ if (isJSONStr(text.trim())) {
379
+ return JSON.parse(text.trim());
380
+ }
381
+ const codeBlockPatterns = [
382
+ /```json\s*\n?([\s\S]*?)\n?\s*```/gi,
383
+ /```\s*\n?([\s\S]*?)\n?\s*```/gi
384
+ ];
385
+ for (const pattern of codeBlockPatterns) {
386
+ const matches = text.match(pattern);
387
+ if (matches) {
388
+ for (const match of matches) {
389
+ const jsonContent = match.replace(/```json\s*\n?/gi, "").replace(/```\s*\n?/gi, "").trim();
390
+ if (isJSONStr(jsonContent)) {
391
+ return JSON.parse(jsonContent);
392
+ }
393
+ }
394
+ }
395
+ }
396
+ const jsonPatterns = [
397
+ // Match complete JSON objects
398
+ /\{[\s\S]*\}/g,
399
+ // Match complete JSON arrays
400
+ /\[[\s\S]*\]/g
401
+ ];
402
+ for (const pattern of jsonPatterns) {
403
+ const matches = text.match(pattern);
404
+ if (matches) {
405
+ for (const match of matches) {
406
+ const candidate = match.trim();
407
+ if (isJSONStr(candidate)) {
408
+ return JSON.parse(candidate);
409
+ }
410
+ }
411
+ }
412
+ }
413
+ const advancedExtract = findCompleteJSONStructures(text);
414
+ if (advancedExtract.length > 0) {
415
+ for (const candidate of advancedExtract) {
416
+ if (isJSONStr(candidate)) {
417
+ return JSON.parse(candidate);
418
+ }
419
+ }
420
+ }
421
+ const cleanedText = text.replace(/^\s*Sure,?\s*here\s+is\s+your?\s+.*?[:\n]/gi, "").replace(/^\s*Here\s+is\s+the\s+.*?[:\n]/gi, "").replace(/^\s*The\s+.*?is\s*[:\n]/gi, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "").trim();
422
+ if (isJSONStr(cleanedText)) {
423
+ return JSON.parse(cleanedText);
424
+ }
425
+ throw new Error(`Could not extract valid JSON from model response. Response preview: ${text.substring(0, 200)}...`);
426
+ }
427
+ function findCompleteJSONStructures(text) {
428
+ const results = [];
429
+ const startChars = ["{", "["];
430
+ for (let i = 0; i < text.length; i++) {
431
+ if (startChars.includes(text[i])) {
432
+ const extracted = extractCompleteStructure(text, i);
433
+ if (extracted) {
434
+ results.push(extracted);
435
+ }
436
+ }
437
+ }
438
+ return results;
439
+ }
440
+ function extractCompleteStructure(text, startPos) {
441
+ const startChar = text[startPos];
442
+ const endChar = startChar === "{" ? "}" : "]";
443
+ let depth = 0;
444
+ let inString = false;
445
+ let escaped = false;
446
+ for (let i = startPos; i < text.length; i++) {
447
+ const char = text[i];
448
+ if (escaped) {
449
+ escaped = false;
450
+ continue;
451
+ }
452
+ if (char === "\\" && inString) {
453
+ escaped = true;
454
+ continue;
455
+ }
456
+ if (char === '"' && !escaped) {
457
+ inString = !inString;
458
+ continue;
459
+ }
460
+ if (!inString) {
461
+ if (char === startChar) {
462
+ depth++;
463
+ } else if (char === endChar) {
464
+ depth--;
465
+ if (depth === 0) {
466
+ return text.substring(startPos, i + 1);
467
+ }
468
+ }
469
+ }
470
+ }
471
+ return null;
472
+ }
320
473
  if (import_meta.url === new URL(`file://${process.argv[1]}`).href) {
321
474
  logger_default.info("RUNNING AI Transformer as standalone script...");
322
475
  (async () => {
@@ -361,7 +514,7 @@ if (import_meta.url === new URL(`file://${process.argv[1]}`).href) {
361
514
  }
362
515
  return payload;
363
516
  };
364
- const validatedResponse = await transformer.transformWithValidation(
517
+ const validatedResponse = await transformer.messageAndValidate(
365
518
  { "name": "Lynn" },
366
519
  mockValidator
367
520
  );
@@ -375,6 +528,5 @@ if (import_meta.url === new URL(`file://${process.argv[1]}`).href) {
375
528
  }
376
529
  // Annotate the CommonJS export names for ESM import in node:
377
530
  0 && (module.exports = {
378
- AITransformer,
379
531
  log
380
532
  });
package/index.js CHANGED
@@ -52,7 +52,7 @@ When presented with new Source JSON, apply the learned transformation rules to p
52
52
 
53
53
  Always respond ONLY with a valid JSON object that strictly adheres to the expected output format.
54
54
 
55
- Do not include any additional text, explanations, or formatting before or after the JSON object.
55
+ Do not include any additional text, explanations, or formatting before or after the JSON object.
56
56
  `;
57
57
 
58
58
  const DEFAULT_CHAT_CONFIG = {
@@ -65,7 +65,7 @@ const DEFAULT_CHAT_CONFIG = {
65
65
  };
66
66
 
67
67
  /**
68
- * @typedef {import('./types').AITransformer}
68
+ * @typedef {import('./types').AITransformer} AITransformerUtility
69
69
  */
70
70
 
71
71
 
@@ -73,12 +73,11 @@ const DEFAULT_CHAT_CONFIG = {
73
73
  /**
74
74
  * main export class for AI Transformer
75
75
  * @class AITransformer
76
- * @type {AITransformer}
76
+ * @type {AITransformerUtility}
77
77
  * @description A class that provides methods to initialize, seed, transform, and manage AI-based transformations using Google Gemini API.
78
78
  * @implements {ExportedAPI}
79
79
  */
80
- // @ts-ignore
81
- export default class AITransformer {
80
+ class AITransformer {
82
81
  /**
83
82
  * @param {AITransformerOptions} [options={}] - Configuration options for the transformer
84
83
  *
@@ -88,25 +87,40 @@ export default class AITransformer {
88
87
  this.promptKey = "";
89
88
  this.answerKey = "";
90
89
  this.contextKey = "";
90
+ this.explanationKey = "";
91
+ this.systemInstructionKey = "";
91
92
  this.maxRetries = 3;
92
93
  this.retryDelay = 1000;
93
94
  this.systemInstructions = "";
94
95
  this.chatConfig = {};
95
96
  this.apiKey = GEMINI_API_KEY;
97
+ this.onlyJSON = true; // always return JSON
98
+ this.asyncValidator = null; // for transformWithValidation
96
99
  AITransformFactory.call(this, options);
97
100
 
98
101
  //external API
99
102
  this.init = initChat.bind(this);
100
103
  this.seed = seedWithExamples.bind(this);
101
- this.message = transformJSON.bind(this);
104
+
105
+ // Internal "raw" message sender
106
+ this.rawMessage = rawMessage.bind(this);
107
+
108
+ // The public `.message()` method uses the GLOBAL validator
109
+ this.message = (payload, opts = {}, validatorFn = null) => {
110
+
111
+ return prepareAndValidateMessage.call(this, payload, opts, validatorFn || this.asyncValidator);
112
+ };
113
+
102
114
  this.rebuild = rebuildPayload.bind(this);
103
115
  this.reset = resetChat.bind(this);
104
116
  this.getHistory = getChatHistory.bind(this);
105
- this.transformWithValidation = transformWithValidation.bind(this);
117
+ this.messageAndValidate = prepareAndValidateMessage.bind(this);
106
118
  this.estimate = estimateTokenUsage.bind(this);
107
119
  }
108
120
  }
109
- export { AITransformer };
121
+
122
+ export default AITransformer;
123
+
110
124
  /**
111
125
  * factory function to create an AI Transformer instance
112
126
  * @param {AITransformerOptions} [options={}] - Configuration options for the transformer
@@ -117,7 +131,7 @@ function AITransformFactory(options = {}) {
117
131
  this.modelName = options.modelName || 'gemini-2.0-flash';
118
132
  this.systemInstructions = options.systemInstructions || DEFAULT_SYSTEM_INSTRUCTIONS;
119
133
 
120
- this.apiKey = options.apiKey || GEMINI_API_KEY;
134
+ this.apiKey = options.apiKey !== undefined && options.apiKey !== null ? options.apiKey : GEMINI_API_KEY;
121
135
  if (!this.apiKey) throw new Error("Missing Gemini API key. Provide via options.apiKey or GEMINI_API_KEY env var.");
122
136
  // Build chat config, making sure systemInstruction uses the custom instructions
123
137
  this.chatConfig = {
@@ -136,14 +150,22 @@ function AITransformFactory(options = {}) {
136
150
  this.exampleData = options.exampleData || null; // can be used instead of examplesFile
137
151
 
138
152
  // Use configurable keys with fallbacks
139
- this.promptKey = options.sourceKey || 'PROMPT';
140
- this.answerKey = options.targetKey || 'ANSWER';
141
- this.contextKey = options.contextKey || 'CONTEXT'; // Now configurable
153
+ this.promptKey = options.promptKey || 'PROMPT';
154
+ this.answerKey = options.answerKey || 'ANSWER';
155
+ this.contextKey = options.contextKey || 'CONTEXT'; // Optional key for context
156
+ this.explanationKey = options.explanationKey || 'EXPLANATION'; // Optional key for explanations
157
+ this.systemInstructionsKey = options.systemInstructionsKey || 'SYSTEM'; // Optional key for system instructions
142
158
 
143
159
  // Retry configuration
144
160
  this.maxRetries = options.maxRetries || 3;
145
161
  this.retryDelay = options.retryDelay || 1000;
146
162
 
163
+ //allow async validation function
164
+ this.asyncValidator = options.asyncValidator || null; // Function to validate transformed payloads
165
+
166
+ //are we forcing json responses only?
167
+ this.onlyJSON = options.onlyJSON !== undefined ? options.onlyJSON : true; // If true, only return JSON responses
168
+
147
169
  if (this.promptKey === this.answerKey) {
148
170
  throw new Error("Source and target keys cannot be the same. Please provide distinct keys.");
149
171
  }
@@ -158,11 +180,12 @@ function AITransformFactory(options = {}) {
158
180
 
159
181
  /**
160
182
  * Initializes the chat session with the specified model and configurations.
183
+ * @param {boolean} [force=false] - If true, forces reinitialization of the chat session.
161
184
  * @this {ExportedAPI}
162
185
  * @returns {Promise<void>}
163
186
  */
164
- async function initChat() {
165
- if (this.chat) return;
187
+ async function initChat(force = false) {
188
+ if (this.chat && !force) return;
166
189
 
167
190
  log.debug(`Initializing Gemini chat session with model: ${this.modelName}...`);
168
191
 
@@ -180,6 +203,7 @@ async function initChat() {
180
203
  * Seeds the chat session with example transformations.
181
204
  * @this {ExportedAPI}
182
205
  * @param {TransformationExample[]} [examples] - An array of transformation examples.
206
+ * @this {ExportedAPI}
183
207
  * @returns {Promise<void>}
184
208
  */
185
209
  async function seedWithExamples(examples) {
@@ -188,13 +212,26 @@ async function seedWithExamples(examples) {
188
212
  if (!examples || !Array.isArray(examples) || examples.length === 0) {
189
213
  if (this.examplesFile) {
190
214
  log.debug(`No examples provided, loading from file: ${this.examplesFile}`);
191
- examples = await u.load(path.resolve(this.examplesFile), true);
215
+ try {
216
+ examples = await u.load(path.resolve(this.examplesFile), true);
217
+ }
218
+ catch (err) {
219
+ throw new Error(`Could not load examples from file: ${this.examplesFile}. Please check the file path and format.`);
220
+ }
192
221
  } else {
193
222
  log.debug("No examples provided and no examples file specified. Skipping seeding.");
194
223
  return;
195
224
  }
196
225
  }
197
226
 
227
+ const instructionExample = examples.find(ex => ex[this.systemInstructionsKey]);
228
+ if (instructionExample) {
229
+ log.debug(`Found system instructions in examples; reinitializing chat with new instructions.`);
230
+ this.systemInstructions = instructionExample[this.systemInstructionsKey];
231
+ this.chatConfig.systemInstruction = this.systemInstructions;
232
+ await this.init(true); // Reinitialize chat with new system instructions
233
+ }
234
+
198
235
  log.debug(`Seeding chat with ${examples.length} transformation examples...`);
199
236
  const historyToAdd = [];
200
237
 
@@ -203,31 +240,31 @@ async function seedWithExamples(examples) {
203
240
  const contextValue = example[this.contextKey] || "";
204
241
  const promptValue = example[this.promptKey] || "";
205
242
  const answerValue = example[this.answerKey] || "";
243
+ const explanationValue = example[this.explanationKey] || "";
244
+ let userText = "";
245
+ let modelResponse = {};
206
246
 
207
247
  // Add context as user message with special formatting to make it part of the example flow
208
248
  if (contextValue) {
209
- let contextText = u.isJSON(contextValue) ? JSON.stringify(contextValue, null, 2) : contextValue;
249
+ let contextText = isJSON(contextValue) ? JSON.stringify(contextValue, null, 2) : contextValue;
210
250
  // Prefix context to make it clear it's contextual information
211
- historyToAdd.push({
212
- role: 'user',
213
- parts: [{ text: `Context: ${contextText}` }]
214
- });
215
- // Add a brief model acknowledgment
216
- historyToAdd.push({
217
- role: 'model',
218
- parts: [{ text: "I understand the context." }]
219
- });
251
+ userText += `CONTEXT:\n${contextText}\n\n`;
220
252
  }
221
253
 
222
254
  if (promptValue) {
223
- let promptText = u.isJSON(promptValue) ? JSON.stringify(promptValue, null, 2) : promptValue;
224
- historyToAdd.push({ role: 'user', parts: [{ text: promptText }] });
255
+ let promptText = isJSON(promptValue) ? JSON.stringify(promptValue, null, 2) : promptValue;
256
+ userText += promptText;
225
257
  }
226
258
 
227
- if (answerValue) {
228
- let answerText = u.isJSON(answerValue) ? JSON.stringify(answerValue, null, 2) : answerValue;
229
- historyToAdd.push({ role: 'model', parts: [{ text: answerText }] });
259
+ if (answerValue) modelResponse.data = answerValue;
260
+ if (explanationValue) modelResponse.explanation = explanationValue;
261
+ const modelText = JSON.stringify(modelResponse, null, 2);
262
+
263
+ if (userText.trim().length && modelText.trim().length > 0) {
264
+ historyToAdd.push({ role: 'user', parts: [{ text: userText.trim() }] });
265
+ historyToAdd.push({ role: 'model', parts: [{ text: modelText.trim() }] });
230
266
  }
267
+
231
268
  }
232
269
 
233
270
  const currentHistory = this?.chat?.getHistory() || [];
@@ -240,6 +277,8 @@ async function seedWithExamples(examples) {
240
277
  });
241
278
 
242
279
  log.debug("Transformation examples seeded successfully.");
280
+
281
+ return this.chat.getHistory(); // Return the updated chat history for reference
243
282
  }
244
283
 
245
284
  /**
@@ -248,90 +287,150 @@ async function seedWithExamples(examples) {
248
287
  * @returns {Promise<Object>} - The transformed target payload (as a JavaScript object).
249
288
  * @throws {Error} If the transformation fails or returns invalid JSON.
250
289
  */
251
- async function transformJSON(sourcePayload) {
290
+ /**
291
+ * (Internal) Sends a single prompt to the model and parses the response.
292
+ * No validation or retry logic.
293
+ * @this {ExportedAPI}
294
+ * @param {Object|string} sourcePayload - The source payload.
295
+ * @returns {Promise<Object>} - The transformed payload.
296
+ */
297
+ async function rawMessage(sourcePayload) {
252
298
  if (!this.chat) {
253
- throw new Error("Chat session not initialized. Call initChat() or seedWithExamples() first.");
299
+ throw new Error("Chat session not initialized.");
254
300
  }
255
301
 
256
- let result;
257
- let actualPayload;
258
- if (sourcePayload && u.isJSON(sourcePayload)) actualPayload = JSON.stringify(sourcePayload, null, 2);
259
- else if (typeof sourcePayload === 'string') actualPayload = sourcePayload;
260
- else throw new Error("Invalid source payload. Must be a JSON object or a valid JSON string.");
302
+ const actualPayload = typeof sourcePayload === 'string'
303
+ ? sourcePayload
304
+ : JSON.stringify(sourcePayload, null, 2);
261
305
 
262
306
  try {
263
- result = await this.chat.sendMessage({ message: actualPayload });
307
+ const result = await this.chat.sendMessage({ message: actualPayload });
308
+ const modelResponse = result.text;
309
+ const extractedJSON = extractJSON(modelResponse); // Assuming extractJSON is defined
310
+
311
+ // Unwrap the 'data' property if it exists
312
+ if (extractedJSON?.data) {
313
+ return extractedJSON.data;
314
+ }
315
+ return extractedJSON;
316
+
264
317
  } catch (error) {
265
- log.error("Error with Gemini API:", error);
318
+ if (this.onlyJSON && error.message.includes("Could not extract valid JSON")) {
319
+ throw new Error(`Invalid JSON response from Gemini: ${error.message}`);
320
+ }
321
+ // For other API errors, just re-throw
266
322
  throw new Error(`Transformation failed: ${error.message}`);
267
323
  }
268
-
269
- try {
270
- const modelResponse = result.text;
271
- const parsedResponse = JSON.parse(modelResponse);
272
- return parsedResponse;
273
- } catch (parseError) {
274
- log.error("Error parsing Gemini response:", parseError);
275
- throw new Error(`Invalid JSON response from Gemini: ${parseError.message}`);
276
- }
277
324
  }
278
325
 
279
326
  /**
280
- * Transforms payload with automatic validation and retry logic
281
- * @param {Object} sourcePayload - The source payload to transform
282
- * @param {AsyncValidatorFunction} validatorFn - Async function that validates the transformed payload
283
- * @param {Object} [options] - Options for the validation process
284
- * @param {number} [options.maxRetries] - Override default max retries
285
- * @param {number} [options.retryDelay] - Override default retry delay
286
- * @returns {Promise<Object>} - The validated transformed payload
287
- * @throws {Error} If transformation or validation fails after all retries
327
+ * (Engine) Transforms a payload with validation and automatic retry logic.
328
+ * @this {ExportedAPI}
329
+ * @param {Object} sourcePayload - The source payload to transform.
330
+ * @param {Object} [options] - Options for the validation process.
331
+ * @param {AsyncValidatorFunction | null} validatorFn - The specific validator to use for this run.
332
+ * @returns {Promise<Object>} - The validated transformed payload.
288
333
  */
289
- async function transformWithValidation(sourcePayload, validatorFn, options = {}) {
334
+ async function prepareAndValidateMessage(sourcePayload, options = {}, validatorFn = null) {
335
+ if (!this.chat) {
336
+ throw new Error("Chat session not initialized. Please call init() first.");
337
+ }
290
338
  const maxRetries = options.maxRetries ?? this.maxRetries;
291
339
  const retryDelay = options.retryDelay ?? this.retryDelay;
292
340
 
293
- let lastPayload = null;
294
341
  let lastError = null;
342
+ let lastPayload = null; // Store the payload that caused the validation error
343
+
344
+ // Prepare the payload
345
+ if (sourcePayload && isJSON(sourcePayload)) {
346
+ lastPayload = JSON.stringify(sourcePayload, null, 2);
347
+ } else if (typeof sourcePayload === 'string') {
348
+ lastPayload = sourcePayload;
349
+ } else {
350
+ throw new Error("Invalid source payload. Must be a JSON object or string.");
351
+ }
295
352
 
296
353
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
297
354
  try {
298
- // First attempt uses normal transformation, subsequent attempts use rebuild
299
- const transformedPayload = attempt === 0
300
- ? await this.message(sourcePayload)
355
+ // Step 1: Get the transformed payload
356
+ const transformedPayload = (attempt === 0)
357
+ ? await this.rawMessage(lastPayload) // Use the new raw method
301
358
  : await this.rebuild(lastPayload, lastError.message);
302
359
 
303
- // Validate the transformed payload
304
- const validatedPayload = await validatorFn(transformedPayload);
360
+ lastPayload = transformedPayload; // Always update lastPayload *before* validation
361
+
362
+ // Step 2: Validate if a validator is provided
363
+ if (validatorFn) {
364
+ await validatorFn(transformedPayload); // Validator throws on failure
365
+ }
305
366
 
306
- log.debug(`Transformation and validation succeeded on attempt ${attempt + 1}`);
307
- return validatedPayload;
367
+ // Step 3: Success!
368
+ log.debug(`Transformation succeeded on attempt ${attempt + 1}`);
369
+ return transformedPayload;
308
370
 
309
371
  } catch (error) {
310
372
  lastError = error;
373
+ log.warn(`Attempt ${attempt + 1} failed: ${error.message}`);
311
374
 
312
- if (attempt === 0) {
313
- // First attempt failed - could be transformation or validation error
314
- lastPayload = await this.message(sourcePayload).catch(() => null);
375
+ if (attempt >= maxRetries) {
376
+ log.error(`All ${maxRetries + 1} attempts failed.`);
377
+ throw new Error(`Transformation failed after ${maxRetries + 1} attempts. Last error: ${error.message}`);
315
378
  }
316
379
 
317
- if (attempt < maxRetries) {
318
- const delay = retryDelay * Math.pow(2, attempt); // Exponential backoff
319
- log.warn(`Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, error.message);
320
- await new Promise(res => setTimeout(res, delay));
321
- } else {
322
- log.error(`All ${maxRetries + 1} attempts failed`);
323
- throw new Error(`Transformation with validation failed after ${maxRetries + 1} attempts. Last error: ${error.message}`);
324
- }
380
+ // Wait before retrying
381
+ const delay = retryDelay * Math.pow(2, attempt);
382
+ await new Promise(res => setTimeout(res, delay));
325
383
  }
326
384
  }
327
385
  }
328
386
 
387
+ /**
388
+ * Rebuilds a payload based on server error feedback
389
+ * @param {Object} lastPayload - The payload that failed validation
390
+ * @param {string} serverError - The error message from the server
391
+ * @returns {Promise<Object>} - A new corrected payload
392
+ * @throws {Error} If the rebuild process fails.
393
+ */
394
+ async function rebuildPayload(lastPayload, serverError) {
395
+ await this.init(); // Ensure chat is initialized
396
+ const prompt = `
397
+ The previous JSON payload (below) failed validation.
398
+ The server's error message is quoted afterward.
399
+
400
+ ---------------- BAD PAYLOAD ----------------
401
+ ${JSON.stringify(lastPayload, null, 2)}
402
+
403
+
404
+ ---------------- SERVER ERROR ----------------
405
+ ${serverError}
406
+
407
+ Please return a NEW JSON payload that corrects the issue.
408
+ Respond with JSON only – no comments or explanations.
409
+ `;
410
+
411
+ let result;
412
+ try {
413
+ result = await this.chat.sendMessage({ message: prompt });
414
+ } catch (err) {
415
+ throw new Error(`Gemini call failed while repairing payload: ${err.message}`);
416
+ }
417
+
418
+ try {
419
+ const text = result.text ?? result.response ?? '';
420
+ return typeof text === 'object' ? text : JSON.parse(text);
421
+ } catch (parseErr) {
422
+ throw new Error(`Gemini returned non-JSON while repairing payload: ${parseErr.message}`);
423
+ }
424
+ }
425
+
426
+
427
+
329
428
 
330
429
  /**
331
430
  * Estimate total token usage if you were to send a new payload as the next message.
332
431
  * Considers system instructions, current chat history (including examples), and the new message.
333
432
  * @param {object|string} nextPayload - The next user message to be sent (object or string)
334
- * @returns {Promise<{ totalTokens: number, ... }>} - The result of Gemini's countTokens API
433
+ * @returns {Promise<{ totalTokens: number }>} - The result of Gemini's countTokens API
335
434
  */
336
435
  async function estimateTokenUsage(nextPayload) {
337
436
  // Compose the conversation contents, Gemini-style
@@ -367,44 +466,6 @@ async function estimateTokenUsage(nextPayload) {
367
466
  return resp; // includes totalTokens, possibly breakdown
368
467
  }
369
468
 
370
- /**
371
- * Rebuilds a payload based on server error feedback
372
- * @param {Object} lastPayload - The payload that failed validation
373
- * @param {string} serverError - The error message from the server
374
- * @returns {Promise<Object>} - A new corrected payload
375
- * @throws {Error} If the rebuild process fails.
376
- */
377
- async function rebuildPayload(lastPayload, serverError) {
378
- await this.init();
379
-
380
- const prompt = `
381
- The previous JSON payload (below) failed validation.
382
- The server's error message is quoted afterward.
383
-
384
- ---------------- BAD PAYLOAD ----------------
385
- ${JSON.stringify(lastPayload, null, 2)}
386
-
387
- ---------------- SERVER ERROR ----------------
388
- ${serverError}
389
-
390
- Please return a NEW JSON payload that corrects the issue.
391
- Respond with JSON only – no comments or explanations.
392
- `;
393
-
394
- let result;
395
- try {
396
- result = await this.chat.sendMessage({ message: prompt });
397
- } catch (err) {
398
- throw new Error(`Gemini call failed while repairing payload: ${err.message}`);
399
- }
400
-
401
- try {
402
- const text = result.text ?? result.response ?? '';
403
- return typeof text === 'object' ? text : JSON.parse(text);
404
- } catch (parseErr) {
405
- throw new Error(`Gemini returned non-JSON while repairing payload: ${parseErr.message}`);
406
- }
407
- }
408
469
 
409
470
  /**
410
471
  * Resets the current chat session, clearing all history and examples
@@ -439,6 +500,170 @@ function getChatHistory() {
439
500
  }
440
501
 
441
502
 
503
+ /*
504
+ ----
505
+ HELPERS
506
+ ----
507
+ */
508
+
509
+ function isJSON(data) {
510
+ try {
511
+ const attempt = JSON.stringify(data);
512
+ if (attempt?.startsWith('{') || attempt?.startsWith('[')) {
513
+ if (attempt?.endsWith('}') || attempt?.endsWith(']')) {
514
+ return true;
515
+ }
516
+ }
517
+ return false;
518
+ } catch (e) {
519
+ return false;
520
+ }
521
+ }
522
+
523
+ function isJSONStr(string) {
524
+ if (typeof string !== 'string') return false;
525
+ try {
526
+ const result = JSON.parse(string);
527
+ const type = Object.prototype.toString.call(result);
528
+ return type === '[object Object]' || type === '[object Array]';
529
+ } catch (err) {
530
+ return false;
531
+ }
532
+ }
533
+
534
+ function extractJSON(text) {
535
+ if (!text || typeof text !== 'string') {
536
+ throw new Error('No text provided for JSON extraction');
537
+ }
538
+
539
+ // Strategy 1: Try parsing the entire response as JSON
540
+ if (isJSONStr(text.trim())) {
541
+ return JSON.parse(text.trim());
542
+ }
543
+
544
+ // Strategy 2: Look for JSON code blocks (```json...``` or ```...```)
545
+ const codeBlockPatterns = [
546
+ /```json\s*\n?([\s\S]*?)\n?\s*```/gi,
547
+ /```\s*\n?([\s\S]*?)\n?\s*```/gi
548
+ ];
549
+
550
+ for (const pattern of codeBlockPatterns) {
551
+ const matches = text.match(pattern);
552
+ if (matches) {
553
+ for (const match of matches) {
554
+ const jsonContent = match.replace(/```json\s*\n?/gi, '').replace(/```\s*\n?/gi, '').trim();
555
+ if (isJSONStr(jsonContent)) {
556
+ return JSON.parse(jsonContent);
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ // Strategy 3: Look for JSON objects/arrays using bracket matching
563
+ const jsonPatterns = [
564
+ // Match complete JSON objects
565
+ /\{[\s\S]*\}/g,
566
+ // Match complete JSON arrays
567
+ /\[[\s\S]*\]/g
568
+ ];
569
+
570
+ for (const pattern of jsonPatterns) {
571
+ const matches = text.match(pattern);
572
+ if (matches) {
573
+ for (const match of matches) {
574
+ const candidate = match.trim();
575
+ if (isJSONStr(candidate)) {
576
+ return JSON.parse(candidate);
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ // Strategy 4: Advanced bracket matching for nested structures
583
+ const advancedExtract = findCompleteJSONStructures(text);
584
+ if (advancedExtract.length > 0) {
585
+ // Return the first valid JSON structure found
586
+ for (const candidate of advancedExtract) {
587
+ if (isJSONStr(candidate)) {
588
+ return JSON.parse(candidate);
589
+ }
590
+ }
591
+ }
592
+
593
+ // Strategy 5: Clean up common formatting issues and retry
594
+ const cleanedText = text
595
+ .replace(/^\s*Sure,?\s*here\s+is\s+your?\s+.*?[:\n]/gi, '') // Remove conversational intros
596
+ .replace(/^\s*Here\s+is\s+the\s+.*?[:\n]/gi, '')
597
+ .replace(/^\s*The\s+.*?is\s*[:\n]/gi, '')
598
+ .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* comments */
599
+ .replace(/\/\/.*$/gm, '') // Remove // comments
600
+ .trim();
601
+
602
+ if (isJSONStr(cleanedText)) {
603
+ return JSON.parse(cleanedText);
604
+ }
605
+
606
+ // If all else fails, throw an error with helpful information
607
+ throw new Error(`Could not extract valid JSON from model response. Response preview: ${text.substring(0, 200)}...`);
608
+ }
609
+
610
+ function findCompleteJSONStructures(text) {
611
+ const results = [];
612
+ const startChars = ['{', '['];
613
+
614
+ for (let i = 0; i < text.length; i++) {
615
+ if (startChars.includes(text[i])) {
616
+ const extracted = extractCompleteStructure(text, i);
617
+ if (extracted) {
618
+ results.push(extracted);
619
+ }
620
+ }
621
+ }
622
+
623
+ return results;
624
+ }
625
+
626
+
627
+ function extractCompleteStructure(text, startPos) {
628
+ const startChar = text[startPos];
629
+ const endChar = startChar === '{' ? '}' : ']';
630
+ let depth = 0;
631
+ let inString = false;
632
+ let escaped = false;
633
+
634
+ for (let i = startPos; i < text.length; i++) {
635
+ const char = text[i];
636
+
637
+ if (escaped) {
638
+ escaped = false;
639
+ continue;
640
+ }
641
+
642
+ if (char === '\\' && inString) {
643
+ escaped = true;
644
+ continue;
645
+ }
646
+
647
+ if (char === '"' && !escaped) {
648
+ inString = !inString;
649
+ continue;
650
+ }
651
+
652
+ if (!inString) {
653
+ if (char === startChar) {
654
+ depth++;
655
+ } else if (char === endChar) {
656
+ depth--;
657
+ if (depth === 0) {
658
+ return text.substring(startPos, i + 1);
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ return null; // Incomplete structure
665
+ }
666
+
442
667
  if (import.meta.url === new URL(`file://${process.argv[1]}`).href) {
443
668
  log.info("RUNNING AI Transformer as standalone script...");
444
669
  (
@@ -490,7 +715,7 @@ if (import.meta.url === new URL(`file://${process.argv[1]}`).href) {
490
715
  return payload; // Return the payload if validation passes
491
716
  };
492
717
 
493
- const validatedResponse = await transformer.transformWithValidation(
718
+ const validatedResponse = await transformer.messageAndValidate(
494
719
  { "name": "Lynn" },
495
720
  mockValidator
496
721
  );
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "ak-gemini",
3
3
  "author": "ak@mixpanel.com",
4
4
  "description": "AK's Generative AI Helper for doing... transforms",
5
- "version": "1.0.52",
5
+ "version": "1.0.54",
6
6
  "main": "index.js",
7
7
  "files": [
8
8
  "index.js",