UNPKG

@allma/core-cdk

Version:

Core AWS CDK constructs for deploying the Allma serverless AI orchestration platform.

213 lines 10.6 kB
// packages/allma-app-logic/src/allma-core/template-service.ts import Handlebars from 'handlebars'; import { JSONPath } from 'jsonpath-plus'; import { MappingEventStatus, MappingEventType } from '@allma/core-types'; import { log_debug, log_warn } from '@allma/core-sdk'; import { getSmartValueByJsonPath } from './data-mapper.js'; // Import the smart resolver /** * A secure, sandboxed templating service for the ALLMA platform. * It's a pure renderer; context must be built by the caller. */ export class TemplateService { static instance; handlebars; constructor() { this.handlebars = Handlebars.create(); // Create an isolated instance this.registerHelpers(); } /** * Get the singleton instance of the TemplateService. */ static getInstance() { if (!TemplateService.instance) { TemplateService.instance = new TemplateService(); } return TemplateService.instance; } /** * Renders a template string with the provided context. * * @param template The Handlebars template string. * @param context A pre-built context object with data for the template. * @returns The rendered string. */ render(template, context) { const compiledTemplate = this.handlebars.compile(template, { noEscape: true, // We are not generating HTML, so we don't need escaping. strict: false, // Be lenient with missing properties, they'll just be empty. }); return compiledTemplate(context); } /** * Registers all custom ALLMA helpers to make Handlebars more powerful. */ registerHelpers() { // Helper to stringify an object/array into a JSON string. // Usage: {{json my_object}} this.handlebars.registerHelper('json', (context) => { if (context === undefined || context === null) return 'null'; if (typeof context === 'string') return context; // Using 0 for compact JSON in prompts, 2 for readable logs return JSON.stringify(context, null, 0); }); // Helper to slice an array. // Usage: {{#each (slice messages -5)}} -> gets last 5 messages this.handlebars.registerHelper('slice', (array, start, end) => { if (!Array.isArray(array)) return []; return array.slice(start, end); }); // Helper for basic conditional logic. // Usage: {{#if (eq status 'COMPLETED')}}...{{/if}} this.handlebars.registerHelper('eq', (a, b) => a === b); this.handlebars.registerHelper('neq', (a, b) => a !== b); this.handlebars.registerHelper('gt', (a, b) => a > b); this.handlebars.registerHelper('lt', (a, b) => a < b); // Helper to provide a default value if a variable is undefined or null. // Usage: {{default name "Guest"}} this.handlebars.registerHelper('default', (value, defaultValue) => { return (value !== null && value !== undefined) ? value : defaultValue; }); // Helper to encode a string in Base64 // Usage: {{base64 "user:pass"}} this.handlebars.registerHelper('base64', (str) => { return Buffer.from(str || '').toString('base64'); }); // Advanced: A block helper to expose a JSONPath result to a nested context. // Usage: {{#with_json_path "$.results.documents[*].content" as |doc_contents|}} {{#each doc_contents}}...{{/each}} {{/with_json_path}} this.handlebars.registerHelper('with_json_path', function (jsonPath, options) { const value = JSONPath({ path: jsonPath, json: this, wrap: false }); return options.fn(this, { data: options.data, blockParams: [value] }); }); } /** * A helper utility to build a context object for templating by evaluating declarative JSONPath mappings * against the flow's runtime state. This is used by step handlers before calling render(). * This method supports the advanced `TemplateContextMappingItem` structure and S3-aware data fetching. * * @param mappings A record mapping context variable names to `TemplateContextMappingItem` objects. * @param contextData The data context to source data from. * @returns An object with the built context and an array of mapping events. */ async buildContextFromMappings(mappings, contextData, correlationId) { const context = {}; const events = []; if (!mappings) { return { context, events }; } for (const [key, mapping] of Object.entries(mappings)) { let value; let resolutionEvents = []; try { // Use the smart, S3-aware path resolver const result = await getSmartValueByJsonPath(mapping.sourceJsonPath, contextData, correlationId); value = result.value; resolutionEvents = result.events; events.push(...resolutionEvents); } catch (e) { log_warn(`Error evaluating JSONPath for template key '${key}'`, { jsonPath: mapping.sourceJsonPath, error: e.message }, correlationId); events.push({ type: MappingEventType.TEMPLATE_CONTEXT_MAPPING, timestamp: new Date().toISOString(), status: MappingEventStatus.ERROR, message: `Error evaluating JSONPath for template key '${key}'.`, details: { sourceJsonPath: mapping.sourceJsonPath, targetKey: key, error: e.message } }); continue; // Skip this key } const baseEvent = { type: MappingEventType.TEMPLATE_CONTEXT_MAPPING, timestamp: new Date().toISOString(), details: { sourceJsonPath: mapping.sourceJsonPath, targetKey: key } }; if (value === undefined) { log_debug(`JSONPath for key '${key}' resulted in 'undefined'. It will be omitted from context.`, { path: mapping.sourceJsonPath }, correlationId); events.push({ ...baseEvent, status: MappingEventStatus.WARN, message: `Source path resolved to undefined. Key '${key}' was omitted from template context.`, }); continue; } // 1. Field Selection Logic let processedValue = value; if (mapping.selectFields && mapping.selectFields.length > 0) { let valueToProcess = processedValue; // NEW: Attempt to parse if the value is a JSON string before selecting fields. if (typeof valueToProcess === 'string') { const trimmed = valueToProcess.trim(); if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) { try { valueToProcess = JSON.parse(valueToProcess); } catch (e) { log_warn(`Value for key '${key}' could not be parsed as JSON for selectFields. Proceeding with raw string.`, { path: mapping.sourceJsonPath }, correlationId); // If parsing fails, valueToProcess remains the original string, and selectFields will likely do nothing, which is safe. } } } const fieldsToSelect = new Set(mapping.selectFields); if (Array.isArray(valueToProcess)) { processedValue = valueToProcess.map(item => { if (typeof item === 'object' && item !== null) { const newItem = {}; for (const field of fieldsToSelect) { if (item[field] !== undefined) { newItem[field] = item[field]; } } return newItem; } return item; }); } else if (typeof valueToProcess === 'object' && valueToProcess !== null) { const newItem = {}; for (const field of fieldsToSelect) { if (valueToProcess[field] !== undefined) { newItem[field] = valueToProcess[field]; } } processedValue = newItem; } } // 2. Formatting Logic switch (mapping.formatAs) { case 'JSON': context[key] = JSON.stringify(processedValue, null, 0); break; case 'CUSTOM_STRING': if (Array.isArray(processedValue)) { if (mapping.itemTemplate) { const formattedItems = processedValue.map(item => this.render(mapping.itemTemplate, item)); context[key] = formattedItems.join(mapping.joinSeparator); } else { log_warn(`formatAs is 'CUSTOM_STRING' for key '${key}' but itemTemplate is missing. Using raw value.`, { path: mapping.sourceJsonPath }, correlationId); context[key] = processedValue; } } else { log_warn(`formatAs is 'CUSTOM_STRING' but the data for key '${key}' is not an array. Using raw value.`, { path: mapping.sourceJsonPath, type: typeof processedValue }, correlationId); context[key] = processedValue; } break; case 'RAW': default: context[key] = processedValue; break; } events.push({ ...baseEvent, status: MappingEventStatus.SUCCESS, message: `Mapped '${mapping.sourceJsonPath}' to template key '${key}' with format '${mapping.formatAs || 'RAW'}'.`, details: { ...baseEvent.details, resolvedValuePreview: JSON.stringify(context[key])?.substring(0, 200) } }); } return { context, events }; } } //# sourceMappingURL=template-service.js.map