@allma/core-cdk
Version:
Core AWS CDK constructs for deploying the Allma serverless AI orchestration platform.
213 lines • 10.6 kB
JavaScript
// 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