ak-gemini 1.0.52 → 1.0.53

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 +230 -79
  2. package/index.js +340 -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,48 @@ 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
+ if (examples?.slice().pop()[this.systemInstructionsKey]) {
182
+ logger_default.debug(`Found system instructions in examples; reinitializing chat with new instructions.`);
183
+ this.systemInstructions = examples.slice().pop()[this.systemInstructionsKey];
184
+ this.chatConfig.systemInstruction = this.systemInstructions;
185
+ await this.init(true);
186
+ }
166
187
  logger_default.debug(`Seeding chat with ${examples.length} transformation examples...`);
167
188
  const historyToAdd = [];
168
189
  for (const example of examples) {
169
190
  const contextValue = example[this.contextKey] || "";
170
191
  const promptValue = example[this.promptKey] || "";
171
192
  const answerValue = example[this.answerKey] || "";
193
+ const explanationValue = example[this.explanationKey] || "";
194
+ let userText = "";
195
+ let modelResponse = {};
172
196
  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
- });
197
+ let contextText = isJSON(contextValue) ? JSON.stringify(contextValue, null, 2) : contextValue;
198
+ userText += `CONTEXT:
199
+ ${contextText}
200
+
201
+ `;
182
202
  }
183
203
  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 }] });
204
+ let promptText = isJSON(promptValue) ? JSON.stringify(promptValue, null, 2) : promptValue;
205
+ userText += promptText;
186
206
  }
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 }] });
207
+ if (answerValue) modelResponse.data = answerValue;
208
+ if (explanationValue) modelResponse.explanation = explanationValue;
209
+ const modelText = JSON.stringify(modelResponse, null, 2);
210
+ if (userText.trim().length && modelText.trim().length > 0) {
211
+ historyToAdd.push({ role: "user", parts: [{ text: userText.trim() }] });
212
+ historyToAdd.push({ role: "model", parts: [{ text: modelText.trim() }] });
190
213
  }
191
214
  }
192
215
  const currentHistory = this?.chat?.getHistory() || [];
@@ -197,77 +220,64 @@ async function seedWithExamples(examples) {
197
220
  history: [...currentHistory, ...historyToAdd]
198
221
  });
199
222
  logger_default.debug("Transformation examples seeded successfully.");
223
+ return this.chat.getHistory();
200
224
  }
201
- async function transformJSON(sourcePayload) {
225
+ async function rawMessage(sourcePayload) {
202
226
  if (!this.chat) {
203
- throw new Error("Chat session not initialized. Call initChat() or seedWithExamples() first.");
227
+ throw new Error("Chat session not initialized.");
204
228
  }
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.");
229
+ const actualPayload = typeof sourcePayload === "string" ? sourcePayload : JSON.stringify(sourcePayload, null, 2);
210
230
  try {
211
- result = await this.chat.sendMessage({ message: actualPayload });
231
+ const result = await this.chat.sendMessage({ message: actualPayload });
232
+ const modelResponse = result.text;
233
+ const extractedJSON = extractJSON(modelResponse);
234
+ if (extractedJSON?.data) {
235
+ return extractedJSON.data;
236
+ }
237
+ return extractedJSON;
212
238
  } catch (error) {
213
- logger_default.error("Error with Gemini API:", error);
239
+ if (this.onlyJSON && error.message.includes("Could not extract valid JSON")) {
240
+ throw new Error(`Invalid JSON response from Gemini: ${error.message}`);
241
+ }
214
242
  throw new Error(`Transformation failed: ${error.message}`);
215
243
  }
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
244
  }
225
- async function transformWithValidation(sourcePayload, validatorFn, options = {}) {
245
+ async function prepareAndValidateMessage(sourcePayload, options = {}, validatorFn = null) {
246
+ if (!this.chat) {
247
+ throw new Error("Chat session not initialized. Please call init() first.");
248
+ }
226
249
  const maxRetries = options.maxRetries ?? this.maxRetries;
227
250
  const retryDelay = options.retryDelay ?? this.retryDelay;
228
- let lastPayload = null;
229
251
  let lastError = null;
252
+ let lastPayload = null;
253
+ if (sourcePayload && isJSON(sourcePayload)) {
254
+ lastPayload = JSON.stringify(sourcePayload, null, 2);
255
+ } else if (typeof sourcePayload === "string") {
256
+ lastPayload = sourcePayload;
257
+ } else {
258
+ throw new Error("Invalid source payload. Must be a JSON object or string.");
259
+ }
230
260
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
231
261
  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;
262
+ const transformedPayload = attempt === 0 ? await this.rawMessage(lastPayload) : await this.rebuild(lastPayload, lastError.message);
263
+ lastPayload = transformedPayload;
264
+ if (validatorFn) {
265
+ await validatorFn(transformedPayload);
266
+ }
267
+ logger_default.debug(`Transformation succeeded on attempt ${attempt + 1}`);
268
+ return transformedPayload;
236
269
  } catch (error) {
237
270
  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}`);
271
+ logger_default.warn(`Attempt ${attempt + 1} failed: ${error.message}`);
272
+ if (attempt >= maxRetries) {
273
+ logger_default.error(`All ${maxRetries + 1} attempts failed.`);
274
+ throw new Error(`Transformation failed after ${maxRetries + 1} attempts. Last error: ${error.message}`);
248
275
  }
276
+ const delay = retryDelay * Math.pow(2, attempt);
277
+ await new Promise((res) => setTimeout(res, delay));
249
278
  }
250
279
  }
251
280
  }
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
281
  async function rebuildPayload(lastPayload, serverError) {
272
282
  await this.init();
273
283
  const prompt = `
@@ -277,6 +287,7 @@ The server's error message is quoted afterward.
277
287
  ---------------- BAD PAYLOAD ----------------
278
288
  ${JSON.stringify(lastPayload, null, 2)}
279
289
 
290
+
280
291
  ---------------- SERVER ERROR ----------------
281
292
  ${serverError}
282
293
 
@@ -296,6 +307,25 @@ Respond with JSON only \u2013 no comments or explanations.
296
307
  throw new Error(`Gemini returned non-JSON while repairing payload: ${parseErr.message}`);
297
308
  }
298
309
  }
310
+ async function estimateTokenUsage(nextPayload) {
311
+ const contents = [];
312
+ if (this.systemInstructions) {
313
+ contents.push({ parts: [{ text: this.systemInstructions }] });
314
+ }
315
+ if (this.chat && typeof this.chat.getHistory === "function") {
316
+ const history = this.chat.getHistory();
317
+ if (Array.isArray(history) && history.length > 0) {
318
+ contents.push(...history);
319
+ }
320
+ }
321
+ const nextMessage = typeof nextPayload === "string" ? nextPayload : JSON.stringify(nextPayload, null, 2);
322
+ contents.push({ parts: [{ text: nextMessage }] });
323
+ const resp = await this.genAIClient.models.countTokens({
324
+ model: this.modelName,
325
+ contents
326
+ });
327
+ return resp;
328
+ }
299
329
  async function resetChat() {
300
330
  if (this.chat) {
301
331
  logger_default.debug("Resetting Gemini chat session...");
@@ -317,6 +347,128 @@ function getChatHistory() {
317
347
  }
318
348
  return this.chat.getHistory();
319
349
  }
350
+ function isJSON(data) {
351
+ try {
352
+ const attempt = JSON.stringify(data);
353
+ if (attempt?.startsWith("{") || attempt?.startsWith("[")) {
354
+ if (attempt?.endsWith("}") || attempt?.endsWith("]")) {
355
+ return true;
356
+ }
357
+ }
358
+ return false;
359
+ } catch (e) {
360
+ return false;
361
+ }
362
+ }
363
+ function isJSONStr(string) {
364
+ if (typeof string !== "string") return false;
365
+ try {
366
+ const result = JSON.parse(string);
367
+ const type = Object.prototype.toString.call(result);
368
+ return type === "[object Object]" || type === "[object Array]";
369
+ } catch (err) {
370
+ return false;
371
+ }
372
+ }
373
+ function extractJSON(text) {
374
+ if (!text || typeof text !== "string") {
375
+ throw new Error("No text provided for JSON extraction");
376
+ }
377
+ if (isJSONStr(text.trim())) {
378
+ return JSON.parse(text.trim());
379
+ }
380
+ const codeBlockPatterns = [
381
+ /```json\s*\n?([\s\S]*?)\n?\s*```/gi,
382
+ /```\s*\n?([\s\S]*?)\n?\s*```/gi
383
+ ];
384
+ for (const pattern of codeBlockPatterns) {
385
+ const matches = text.match(pattern);
386
+ if (matches) {
387
+ for (const match of matches) {
388
+ const jsonContent = match.replace(/```json\s*\n?/gi, "").replace(/```\s*\n?/gi, "").trim();
389
+ if (isJSONStr(jsonContent)) {
390
+ return JSON.parse(jsonContent);
391
+ }
392
+ }
393
+ }
394
+ }
395
+ const jsonPatterns = [
396
+ // Match complete JSON objects
397
+ /\{[\s\S]*\}/g,
398
+ // Match complete JSON arrays
399
+ /\[[\s\S]*\]/g
400
+ ];
401
+ for (const pattern of jsonPatterns) {
402
+ const matches = text.match(pattern);
403
+ if (matches) {
404
+ for (const match of matches) {
405
+ const candidate = match.trim();
406
+ if (isJSONStr(candidate)) {
407
+ return JSON.parse(candidate);
408
+ }
409
+ }
410
+ }
411
+ }
412
+ const advancedExtract = findCompleteJSONStructures(text);
413
+ if (advancedExtract.length > 0) {
414
+ for (const candidate of advancedExtract) {
415
+ if (isJSONStr(candidate)) {
416
+ return JSON.parse(candidate);
417
+ }
418
+ }
419
+ }
420
+ 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();
421
+ if (isJSONStr(cleanedText)) {
422
+ return JSON.parse(cleanedText);
423
+ }
424
+ throw new Error(`Could not extract valid JSON from model response. Response preview: ${text.substring(0, 200)}...`);
425
+ }
426
+ function findCompleteJSONStructures(text) {
427
+ const results = [];
428
+ const startChars = ["{", "["];
429
+ for (let i = 0; i < text.length; i++) {
430
+ if (startChars.includes(text[i])) {
431
+ const extracted = extractCompleteStructure(text, i);
432
+ if (extracted) {
433
+ results.push(extracted);
434
+ }
435
+ }
436
+ }
437
+ return results;
438
+ }
439
+ function extractCompleteStructure(text, startPos) {
440
+ const startChar = text[startPos];
441
+ const endChar = startChar === "{" ? "}" : "]";
442
+ let depth = 0;
443
+ let inString = false;
444
+ let escaped = false;
445
+ for (let i = startPos; i < text.length; i++) {
446
+ const char = text[i];
447
+ if (escaped) {
448
+ escaped = false;
449
+ continue;
450
+ }
451
+ if (char === "\\" && inString) {
452
+ escaped = true;
453
+ continue;
454
+ }
455
+ if (char === '"' && !escaped) {
456
+ inString = !inString;
457
+ continue;
458
+ }
459
+ if (!inString) {
460
+ if (char === startChar) {
461
+ depth++;
462
+ } else if (char === endChar) {
463
+ depth--;
464
+ if (depth === 0) {
465
+ return text.substring(startPos, i + 1);
466
+ }
467
+ }
468
+ }
469
+ }
470
+ return null;
471
+ }
320
472
  if (import_meta.url === new URL(`file://${process.argv[1]}`).href) {
321
473
  logger_default.info("RUNNING AI Transformer as standalone script...");
322
474
  (async () => {
@@ -361,7 +513,7 @@ if (import_meta.url === new URL(`file://${process.argv[1]}`).href) {
361
513
  }
362
514
  return payload;
363
515
  };
364
- const validatedResponse = await transformer.transformWithValidation(
516
+ const validatedResponse = await transformer.messageAndValidate(
365
517
  { "name": "Lynn" },
366
518
  mockValidator
367
519
  );
@@ -375,6 +527,5 @@ if (import_meta.url === new URL(`file://${process.argv[1]}`).href) {
375
527
  }
376
528
  // Annotate the CommonJS export names for ESM import in node:
377
529
  0 && (module.exports = {
378
- AITransformer,
379
530
  log
380
531
  });
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,25 @@ 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
+ if (examples?.slice().pop()[this.systemInstructionsKey]) {
228
+ log.debug(`Found system instructions in examples; reinitializing chat with new instructions.`);
229
+ this.systemInstructions = examples.slice().pop()[this.systemInstructionsKey];
230
+ this.chatConfig.systemInstruction = this.systemInstructions;
231
+ await this.init(true); // Reinitialize chat with new system instructions
232
+ }
233
+
198
234
  log.debug(`Seeding chat with ${examples.length} transformation examples...`);
199
235
  const historyToAdd = [];
200
236
 
@@ -203,31 +239,31 @@ async function seedWithExamples(examples) {
203
239
  const contextValue = example[this.contextKey] || "";
204
240
  const promptValue = example[this.promptKey] || "";
205
241
  const answerValue = example[this.answerKey] || "";
242
+ const explanationValue = example[this.explanationKey] || "";
243
+ let userText = "";
244
+ let modelResponse = {};
206
245
 
207
246
  // Add context as user message with special formatting to make it part of the example flow
208
247
  if (contextValue) {
209
- let contextText = u.isJSON(contextValue) ? JSON.stringify(contextValue, null, 2) : contextValue;
248
+ let contextText = isJSON(contextValue) ? JSON.stringify(contextValue, null, 2) : contextValue;
210
249
  // 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
- });
250
+ userText += `CONTEXT:\n${contextText}\n\n`;
220
251
  }
221
252
 
222
253
  if (promptValue) {
223
- let promptText = u.isJSON(promptValue) ? JSON.stringify(promptValue, null, 2) : promptValue;
224
- historyToAdd.push({ role: 'user', parts: [{ text: promptText }] });
254
+ let promptText = isJSON(promptValue) ? JSON.stringify(promptValue, null, 2) : promptValue;
255
+ userText += promptText;
225
256
  }
226
257
 
227
- if (answerValue) {
228
- let answerText = u.isJSON(answerValue) ? JSON.stringify(answerValue, null, 2) : answerValue;
229
- historyToAdd.push({ role: 'model', parts: [{ text: answerText }] });
258
+ if (answerValue) modelResponse.data = answerValue;
259
+ if (explanationValue) modelResponse.explanation = explanationValue;
260
+ const modelText = JSON.stringify(modelResponse, null, 2);
261
+
262
+ if (userText.trim().length && modelText.trim().length > 0) {
263
+ historyToAdd.push({ role: 'user', parts: [{ text: userText.trim() }] });
264
+ historyToAdd.push({ role: 'model', parts: [{ text: modelText.trim() }] });
230
265
  }
266
+
231
267
  }
232
268
 
233
269
  const currentHistory = this?.chat?.getHistory() || [];
@@ -240,6 +276,8 @@ async function seedWithExamples(examples) {
240
276
  });
241
277
 
242
278
  log.debug("Transformation examples seeded successfully.");
279
+
280
+ return this.chat.getHistory(); // Return the updated chat history for reference
243
281
  }
244
282
 
245
283
  /**
@@ -248,90 +286,150 @@ async function seedWithExamples(examples) {
248
286
  * @returns {Promise<Object>} - The transformed target payload (as a JavaScript object).
249
287
  * @throws {Error} If the transformation fails or returns invalid JSON.
250
288
  */
251
- async function transformJSON(sourcePayload) {
289
+ /**
290
+ * (Internal) Sends a single prompt to the model and parses the response.
291
+ * No validation or retry logic.
292
+ * @this {ExportedAPI}
293
+ * @param {Object|string} sourcePayload - The source payload.
294
+ * @returns {Promise<Object>} - The transformed payload.
295
+ */
296
+ async function rawMessage(sourcePayload) {
252
297
  if (!this.chat) {
253
- throw new Error("Chat session not initialized. Call initChat() or seedWithExamples() first.");
298
+ throw new Error("Chat session not initialized.");
254
299
  }
255
300
 
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.");
301
+ const actualPayload = typeof sourcePayload === 'string'
302
+ ? sourcePayload
303
+ : JSON.stringify(sourcePayload, null, 2);
261
304
 
262
305
  try {
263
- result = await this.chat.sendMessage({ message: actualPayload });
306
+ const result = await this.chat.sendMessage({ message: actualPayload });
307
+ const modelResponse = result.text;
308
+ const extractedJSON = extractJSON(modelResponse); // Assuming extractJSON is defined
309
+
310
+ // Unwrap the 'data' property if it exists
311
+ if (extractedJSON?.data) {
312
+ return extractedJSON.data;
313
+ }
314
+ return extractedJSON;
315
+
264
316
  } catch (error) {
265
- log.error("Error with Gemini API:", error);
317
+ if (this.onlyJSON && error.message.includes("Could not extract valid JSON")) {
318
+ throw new Error(`Invalid JSON response from Gemini: ${error.message}`);
319
+ }
320
+ // For other API errors, just re-throw
266
321
  throw new Error(`Transformation failed: ${error.message}`);
267
322
  }
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
323
  }
278
324
 
279
325
  /**
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
326
+ * (Engine) Transforms a payload with validation and automatic retry logic.
327
+ * @this {ExportedAPI}
328
+ * @param {Object} sourcePayload - The source payload to transform.
329
+ * @param {Object} [options] - Options for the validation process.
330
+ * @param {AsyncValidatorFunction | null} validatorFn - The specific validator to use for this run.
331
+ * @returns {Promise<Object>} - The validated transformed payload.
288
332
  */
289
- async function transformWithValidation(sourcePayload, validatorFn, options = {}) {
333
+ async function prepareAndValidateMessage(sourcePayload, options = {}, validatorFn = null) {
334
+ if (!this.chat) {
335
+ throw new Error("Chat session not initialized. Please call init() first.");
336
+ }
290
337
  const maxRetries = options.maxRetries ?? this.maxRetries;
291
338
  const retryDelay = options.retryDelay ?? this.retryDelay;
292
339
 
293
- let lastPayload = null;
294
340
  let lastError = null;
341
+ let lastPayload = null; // Store the payload that caused the validation error
342
+
343
+ // Prepare the payload
344
+ if (sourcePayload && isJSON(sourcePayload)) {
345
+ lastPayload = JSON.stringify(sourcePayload, null, 2);
346
+ } else if (typeof sourcePayload === 'string') {
347
+ lastPayload = sourcePayload;
348
+ } else {
349
+ throw new Error("Invalid source payload. Must be a JSON object or string.");
350
+ }
295
351
 
296
352
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
297
353
  try {
298
- // First attempt uses normal transformation, subsequent attempts use rebuild
299
- const transformedPayload = attempt === 0
300
- ? await this.message(sourcePayload)
354
+ // Step 1: Get the transformed payload
355
+ const transformedPayload = (attempt === 0)
356
+ ? await this.rawMessage(lastPayload) // Use the new raw method
301
357
  : await this.rebuild(lastPayload, lastError.message);
302
358
 
303
- // Validate the transformed payload
304
- const validatedPayload = await validatorFn(transformedPayload);
359
+ lastPayload = transformedPayload; // Always update lastPayload *before* validation
360
+
361
+ // Step 2: Validate if a validator is provided
362
+ if (validatorFn) {
363
+ await validatorFn(transformedPayload); // Validator throws on failure
364
+ }
305
365
 
306
- log.debug(`Transformation and validation succeeded on attempt ${attempt + 1}`);
307
- return validatedPayload;
366
+ // Step 3: Success!
367
+ log.debug(`Transformation succeeded on attempt ${attempt + 1}`);
368
+ return transformedPayload;
308
369
 
309
370
  } catch (error) {
310
371
  lastError = error;
372
+ log.warn(`Attempt ${attempt + 1} failed: ${error.message}`);
311
373
 
312
- if (attempt === 0) {
313
- // First attempt failed - could be transformation or validation error
314
- lastPayload = await this.message(sourcePayload).catch(() => null);
374
+ if (attempt >= maxRetries) {
375
+ log.error(`All ${maxRetries + 1} attempts failed.`);
376
+ throw new Error(`Transformation failed after ${maxRetries + 1} attempts. Last error: ${error.message}`);
315
377
  }
316
378
 
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
- }
379
+ // Wait before retrying
380
+ const delay = retryDelay * Math.pow(2, attempt);
381
+ await new Promise(res => setTimeout(res, delay));
325
382
  }
326
383
  }
327
384
  }
328
385
 
386
+ /**
387
+ * Rebuilds a payload based on server error feedback
388
+ * @param {Object} lastPayload - The payload that failed validation
389
+ * @param {string} serverError - The error message from the server
390
+ * @returns {Promise<Object>} - A new corrected payload
391
+ * @throws {Error} If the rebuild process fails.
392
+ */
393
+ async function rebuildPayload(lastPayload, serverError) {
394
+ await this.init(); // Ensure chat is initialized
395
+ const prompt = `
396
+ The previous JSON payload (below) failed validation.
397
+ The server's error message is quoted afterward.
398
+
399
+ ---------------- BAD PAYLOAD ----------------
400
+ ${JSON.stringify(lastPayload, null, 2)}
401
+
402
+
403
+ ---------------- SERVER ERROR ----------------
404
+ ${serverError}
405
+
406
+ Please return a NEW JSON payload that corrects the issue.
407
+ Respond with JSON only – no comments or explanations.
408
+ `;
409
+
410
+ let result;
411
+ try {
412
+ result = await this.chat.sendMessage({ message: prompt });
413
+ } catch (err) {
414
+ throw new Error(`Gemini call failed while repairing payload: ${err.message}`);
415
+ }
416
+
417
+ try {
418
+ const text = result.text ?? result.response ?? '';
419
+ return typeof text === 'object' ? text : JSON.parse(text);
420
+ } catch (parseErr) {
421
+ throw new Error(`Gemini returned non-JSON while repairing payload: ${parseErr.message}`);
422
+ }
423
+ }
424
+
425
+
426
+
329
427
 
330
428
  /**
331
429
  * Estimate total token usage if you were to send a new payload as the next message.
332
430
  * Considers system instructions, current chat history (including examples), and the new message.
333
431
  * @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
432
+ * @returns {Promise<{ totalTokens: number }>} - The result of Gemini's countTokens API
335
433
  */
336
434
  async function estimateTokenUsage(nextPayload) {
337
435
  // Compose the conversation contents, Gemini-style
@@ -367,44 +465,6 @@ async function estimateTokenUsage(nextPayload) {
367
465
  return resp; // includes totalTokens, possibly breakdown
368
466
  }
369
467
 
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
468
 
409
469
  /**
410
470
  * Resets the current chat session, clearing all history and examples
@@ -439,6 +499,170 @@ function getChatHistory() {
439
499
  }
440
500
 
441
501
 
502
+ /*
503
+ ----
504
+ HELPERS
505
+ ----
506
+ */
507
+
508
+ function isJSON(data) {
509
+ try {
510
+ const attempt = JSON.stringify(data);
511
+ if (attempt?.startsWith('{') || attempt?.startsWith('[')) {
512
+ if (attempt?.endsWith('}') || attempt?.endsWith(']')) {
513
+ return true;
514
+ }
515
+ }
516
+ return false;
517
+ } catch (e) {
518
+ return false;
519
+ }
520
+ }
521
+
522
+ function isJSONStr(string) {
523
+ if (typeof string !== 'string') return false;
524
+ try {
525
+ const result = JSON.parse(string);
526
+ const type = Object.prototype.toString.call(result);
527
+ return type === '[object Object]' || type === '[object Array]';
528
+ } catch (err) {
529
+ return false;
530
+ }
531
+ }
532
+
533
+ function extractJSON(text) {
534
+ if (!text || typeof text !== 'string') {
535
+ throw new Error('No text provided for JSON extraction');
536
+ }
537
+
538
+ // Strategy 1: Try parsing the entire response as JSON
539
+ if (isJSONStr(text.trim())) {
540
+ return JSON.parse(text.trim());
541
+ }
542
+
543
+ // Strategy 2: Look for JSON code blocks (```json...``` or ```...```)
544
+ const codeBlockPatterns = [
545
+ /```json\s*\n?([\s\S]*?)\n?\s*```/gi,
546
+ /```\s*\n?([\s\S]*?)\n?\s*```/gi
547
+ ];
548
+
549
+ for (const pattern of codeBlockPatterns) {
550
+ const matches = text.match(pattern);
551
+ if (matches) {
552
+ for (const match of matches) {
553
+ const jsonContent = match.replace(/```json\s*\n?/gi, '').replace(/```\s*\n?/gi, '').trim();
554
+ if (isJSONStr(jsonContent)) {
555
+ return JSON.parse(jsonContent);
556
+ }
557
+ }
558
+ }
559
+ }
560
+
561
+ // Strategy 3: Look for JSON objects/arrays using bracket matching
562
+ const jsonPatterns = [
563
+ // Match complete JSON objects
564
+ /\{[\s\S]*\}/g,
565
+ // Match complete JSON arrays
566
+ /\[[\s\S]*\]/g
567
+ ];
568
+
569
+ for (const pattern of jsonPatterns) {
570
+ const matches = text.match(pattern);
571
+ if (matches) {
572
+ for (const match of matches) {
573
+ const candidate = match.trim();
574
+ if (isJSONStr(candidate)) {
575
+ return JSON.parse(candidate);
576
+ }
577
+ }
578
+ }
579
+ }
580
+
581
+ // Strategy 4: Advanced bracket matching for nested structures
582
+ const advancedExtract = findCompleteJSONStructures(text);
583
+ if (advancedExtract.length > 0) {
584
+ // Return the first valid JSON structure found
585
+ for (const candidate of advancedExtract) {
586
+ if (isJSONStr(candidate)) {
587
+ return JSON.parse(candidate);
588
+ }
589
+ }
590
+ }
591
+
592
+ // Strategy 5: Clean up common formatting issues and retry
593
+ const cleanedText = text
594
+ .replace(/^\s*Sure,?\s*here\s+is\s+your?\s+.*?[:\n]/gi, '') // Remove conversational intros
595
+ .replace(/^\s*Here\s+is\s+the\s+.*?[:\n]/gi, '')
596
+ .replace(/^\s*The\s+.*?is\s*[:\n]/gi, '')
597
+ .replace(/\/\*[\s\S]*?\*\//g, '') // Remove /* comments */
598
+ .replace(/\/\/.*$/gm, '') // Remove // comments
599
+ .trim();
600
+
601
+ if (isJSONStr(cleanedText)) {
602
+ return JSON.parse(cleanedText);
603
+ }
604
+
605
+ // If all else fails, throw an error with helpful information
606
+ throw new Error(`Could not extract valid JSON from model response. Response preview: ${text.substring(0, 200)}...`);
607
+ }
608
+
609
+ function findCompleteJSONStructures(text) {
610
+ const results = [];
611
+ const startChars = ['{', '['];
612
+
613
+ for (let i = 0; i < text.length; i++) {
614
+ if (startChars.includes(text[i])) {
615
+ const extracted = extractCompleteStructure(text, i);
616
+ if (extracted) {
617
+ results.push(extracted);
618
+ }
619
+ }
620
+ }
621
+
622
+ return results;
623
+ }
624
+
625
+
626
+ function extractCompleteStructure(text, startPos) {
627
+ const startChar = text[startPos];
628
+ const endChar = startChar === '{' ? '}' : ']';
629
+ let depth = 0;
630
+ let inString = false;
631
+ let escaped = false;
632
+
633
+ for (let i = startPos; i < text.length; i++) {
634
+ const char = text[i];
635
+
636
+ if (escaped) {
637
+ escaped = false;
638
+ continue;
639
+ }
640
+
641
+ if (char === '\\' && inString) {
642
+ escaped = true;
643
+ continue;
644
+ }
645
+
646
+ if (char === '"' && !escaped) {
647
+ inString = !inString;
648
+ continue;
649
+ }
650
+
651
+ if (!inString) {
652
+ if (char === startChar) {
653
+ depth++;
654
+ } else if (char === endChar) {
655
+ depth--;
656
+ if (depth === 0) {
657
+ return text.substring(startPos, i + 1);
658
+ }
659
+ }
660
+ }
661
+ }
662
+
663
+ return null; // Incomplete structure
664
+ }
665
+
442
666
  if (import.meta.url === new URL(`file://${process.argv[1]}`).href) {
443
667
  log.info("RUNNING AI Transformer as standalone script...");
444
668
  (
@@ -490,7 +714,7 @@ if (import.meta.url === new URL(`file://${process.argv[1]}`).href) {
490
714
  return payload; // Return the payload if validation passes
491
715
  };
492
716
 
493
- const validatedResponse = await transformer.transformWithValidation(
717
+ const validatedResponse = await transformer.messageAndValidate(
494
718
  { "name": "Lynn" },
495
719
  mockValidator
496
720
  );
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.53",
6
6
  "main": "index.js",
7
7
  "files": [
8
8
  "index.js",