@hashgraphonline/conversational-agent
Version:
Hashgraph Online conversational AI agent implementing HCS-10 communication, HCS-2 registries, and content inscription on Hedera. https://hol.org
932 lines (826 loc) • 26.6 kB
text/typescript
import { z, ZodError } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
import type {
FormConfig,
FormField,
ValidationError,
FormMessage,
FormFieldType,
} from './types';
import {
extractRenderConfigs,
generateFieldOrdering,
type RenderConfigSchema,
type ZodSchemaWithRender,
type ExtractedRenderConfig,
} from '@hashgraphonline/standards-agent-kit';
import { Logger } from '@hashgraphonline/standards-sdk';
import { fieldTypeRegistry } from './field-type-registry';
import { fieldGuidanceRegistry } from './field-guidance-registry';
import { FIELD_PRIORITIES } from '../constants';
interface ZodObjectSchema extends z.ZodSchema {
shape?: Record<string, z.ZodSchema>;
}
function isZodObjectSchema(schema: z.ZodSchema): schema is ZodObjectSchema {
return typeof schema === 'object' && schema !== null && 'shape' in schema;
}
type FieldPriority = 'essential' | 'common' | 'advanced' | 'expert';
/**
* Generates forms from Zod validation failures
*/
export class FormGenerator {
private logger: Logger;
constructor() {
this.logger = new Logger({ module: 'FormGenerator' });
}
/**
* Creates a form message from a Zod validation error
* @param error The Zod validation error
* @param schema The original Zod schema
* @param toolName Name of the tool that failed validation
* @param originalPrompt The user's original request
* @returns FormMessage to send to the chat UI
*/
generateFormFromError(
error: ZodError,
schema: z.ZodSchema,
toolName: string,
originalPrompt: string
): FormMessage {
const validationErrors = this.extractValidationErrors(error);
const missingFields = this.identifyMissingFields(validationErrors, schema);
const formConfig = this.createFormConfig(schema, missingFields, toolName);
return {
type: 'form',
id: `form_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
formConfig,
originalPrompt,
toolName,
validationErrors,
};
}
/**
* Generates a form from a schema and partial input
* @param schema The Zod schema to generate a form from
* @param partialInput Any partial input already provided
* @param context Additional context about the tool
* @param preCalculatedMissingFields Optional pre-calculated missing fields set. If undefined, includes all fields from schema.
* @returns FormMessage to send to the chat UI
*/
async generateFormFromSchema(
schema: z.ZodSchema,
partialInput: unknown,
context: { toolName: string; toolDescription?: string },
preCalculatedMissingFields?: Set<string>
): Promise<FormMessage> {
let missingFields: Set<string>;
this.logger.info(`🏁 FormGenerator.generateFormFromSchema starting`, {
toolName: context.toolName,
partialInput,
hasSchema: !!schema,
hasShape: !!(schema && isZodObjectSchema(schema)),
hasPreCalculatedFields: preCalculatedMissingFields !== undefined,
preCalculatedFieldsSize: preCalculatedMissingFields?.size || 0,
});
if (preCalculatedMissingFields !== undefined) {
missingFields = preCalculatedMissingFields;
this.logger.info(`📋 Using pre-calculated missing fields`, {
missingFieldsCount: missingFields.size,
missingFields: Array.from(missingFields),
});
} else {
missingFields = new Set<string>();
const zodObject = this.extractZodObject(schema);
if (zodObject) {
const shape = zodObject.shape;
for (const fieldName of Object.keys(shape)) {
missingFields.add(fieldName);
this.logger.info(
`⭐ Including all fields from focused schema: ${fieldName}`
);
}
}
this.logger.info(`📋 Using ALL fields from focused schema`, {
totalFields: zodObject ? Object.keys(zodObject.shape).length : 0,
missingFieldsCount: missingFields.size,
missingFields: Array.from(missingFields),
});
}
const formConfig = this.createFormConfig(
schema,
missingFields,
context.toolName,
preCalculatedMissingFields
);
return {
type: 'form',
id: `form_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
formConfig,
originalPrompt: context.toolDescription || `Complete ${context.toolName}`,
toolName: context.toolName,
validationErrors: [],
};
}
/**
* Extracts validation errors from ZodError
*/
private extractValidationErrors(error: ZodError): ValidationError[] {
return error.issues.map((issue) => ({
path: issue.path.map((p) => String(p)),
message: issue.message,
code: issue.code,
}));
}
/**
* Identifies which fields are missing or invalid from validation errors
*/
private identifyMissingFields(
errors: ValidationError[],
_schema: z.ZodSchema
): Set<string> {
const missingFields = new Set<string>();
errors.forEach((error) => {
const fieldPath = error.path.join('.');
if (fieldPath) {
missingFields.add(fieldPath);
}
});
return missingFields;
}
/**
* Creates form configuration from schema
*/
private createFormConfig(
schema: z.ZodSchema,
missingFields: Set<string>,
toolName: string,
preCalculatedMissingFields?: Set<string>
): FormConfig {
const extractedConfig = this.extractRenderConfigsSafely(schema);
const fieldOrdering = this.generateFieldOrderingSafely(schema);
const fields = this.generateFormFields(
schema,
extractedConfig,
missingFields,
fieldOrdering,
preCalculatedMissingFields,
toolName
);
const globalGuidance = fieldGuidanceRegistry.getGlobalGuidance(toolName);
let description = this.generateFormDescription(
toolName,
missingFields.size
);
if (globalGuidance?.qualityStandards) {
description +=
'\n\nQuality Guidelines:\n' +
globalGuidance.qualityStandards
.map((standard) => `• ${standard}`)
.join('\n');
}
return {
title: this.generateFormTitle(toolName),
description,
fields,
submitLabel: 'Continue',
cancelLabel: 'Cancel',
metadata: {
toolName,
missingFieldCount: missingFields.size,
globalGuidance,
},
};
}
/**
* Safely extracts render configs from schema
*/
private extractRenderConfigsSafely(
schema: z.ZodSchema
): ExtractedRenderConfig {
try {
if (typeof extractRenderConfigs === 'function') {
return extractRenderConfigs(schema as unknown as ZodSchemaWithRender);
}
} catch (error) {
this.logger.warn('Could not extract render configs:', error);
}
return {
fields: {},
groups: {},
order: [],
metadata: {},
};
}
/**
* Safely generates field ordering from schema
*/
private generateFieldOrderingSafely(schema: z.ZodSchema): {
sections: Array<{ fields: string[] }>;
} {
try {
if (typeof generateFieldOrdering === 'function') {
const ordering = generateFieldOrdering(
schema as unknown as ZodSchemaWithRender
);
const sections = Object.values(ordering.sections).map((section) => ({
fields: section.fields,
}));
return { sections };
}
} catch (error) {
this.logger.warn('Could not generate field ordering:', error);
}
return { sections: [] };
}
/**
* Determines field priority for progressive disclosure
*/
private getFieldPriority(
name: string,
renderConfig?: RenderConfigSchema,
isRequired?: boolean
): FieldPriority {
if (renderConfig?.ui?.priority) {
const priority = renderConfig.ui.priority as string;
if (Object.values(FIELD_PRIORITIES).includes(priority as FieldPriority)) {
return priority as FieldPriority;
}
}
if (isRequired === true) {
return FIELD_PRIORITIES.ESSENTIAL;
}
const ui = renderConfig?.ui as Record<string, unknown> | undefined;
if (ui?.advanced === true) {
return FIELD_PRIORITIES.ADVANCED;
}
if (ui?.expert === true) {
return FIELD_PRIORITIES.EXPERT;
}
return FIELD_PRIORITIES.COMMON;
}
/**
* Determines which fields should be included in the form
*/
private determineFieldsToInclude(
schema: z.ZodSchema,
missingFields: Set<string>,
preCalculatedMissingFields?: Set<string>
): Set<string> {
const fieldsToInclude = new Set<string>();
if (preCalculatedMissingFields === undefined) {
this.logger.info(
`⭐ Focused schema mode - including ALL fields from schema`
);
const allSchemaFields = this.extractFieldsFromSchema(schema);
allSchemaFields.forEach((fieldName) => {
fieldsToInclude.add(fieldName);
this.logger.info(`✅ Including focused schema field: ${fieldName}`);
});
} else if (preCalculatedMissingFields.size > 0) {
this.logger.info(
`📋 Using ONLY pre-calculated missing fields (${preCalculatedMissingFields.size} fields)`,
{ fields: Array.from(preCalculatedMissingFields) }
);
preCalculatedMissingFields.forEach((fieldName) => {
fieldsToInclude.add(fieldName);
this.logger.info(`✅ Including pre-calculated field: ${fieldName}`);
});
} else {
this.logger.info(
'⚠️ No pre-calculated fields, falling back to schema analysis'
);
this.includeRequiredMissingFields(schema, missingFields, fieldsToInclude);
}
return fieldsToInclude;
}
/**
* Includes required fields that are missing
*/
private includeRequiredMissingFields(
schema: z.ZodSchema,
missingFields: Set<string>,
fieldsToInclude: Set<string>
): void {
const allSchemaFields = this.extractFieldsFromSchema(schema);
allSchemaFields.forEach((fieldName) => {
const isRequired = this.isFieldRequired(schema, fieldName);
const isMissing = missingFields.has(fieldName);
const shouldInclude = isMissing && isRequired;
this.logger.info(`🔍 FormGenerator field inclusion check: ${fieldName}`, {
isRequired,
isMissing,
shouldInclude,
});
if (shouldInclude) {
fieldsToInclude.add(fieldName);
}
});
}
/**
* Creates form fields from ordered field names
*/
private createOrderedFields(
fieldsToInclude: Set<string>,
fieldOrdering: { sections: Array<{ fields: string[] }> },
extractedConfig: ExtractedRenderConfig,
schema: z.ZodSchema,
toolName?: string
): FormField[] {
const fields: FormField[] = [];
const processedFields = new Set<string>();
if (fieldOrdering.sections.length > 0) {
const orderedFieldNames = fieldOrdering.sections.flatMap((s) => s.fields);
orderedFieldNames.forEach((fieldName) => {
if (fieldsToInclude.has(fieldName) && !processedFields.has(fieldName)) {
const field = this.createFormField(
fieldName,
extractedConfig.fields[fieldName],
schema,
fieldName,
toolName
);
if (field) {
fields.push(field);
processedFields.add(fieldName);
}
}
});
}
fieldsToInclude.forEach((fieldName) => {
if (!processedFields.has(fieldName)) {
const field = this.createFormField(
fieldName,
extractedConfig.fields[fieldName],
schema,
fieldName,
toolName
);
if (field) {
fields.push(field);
}
}
});
return fields;
}
/**
* Generates form fields from schema and validation errors
*/
private generateFormFields(
schema: z.ZodSchema,
extractedConfig: ExtractedRenderConfig,
missingFields: Set<string>,
fieldOrdering: { sections: Array<{ fields: string[] }> },
preCalculatedMissingFields?: Set<string>,
toolName?: string
): FormField[] {
const fieldsToInclude = this.determineFieldsToInclude(
schema,
missingFields,
preCalculatedMissingFields
);
let fields = this.createOrderedFields(
fieldsToInclude,
fieldOrdering,
extractedConfig,
schema,
toolName
);
if (fields.length === 0 && missingFields.size > 0) {
fields = Array.from(missingFields)
.map((fieldName) =>
this.createFormField(
fieldName,
extractedConfig.fields[fieldName],
schema,
fieldName,
toolName
)
)
.filter(
(field): field is FormField => field !== null && field !== undefined
);
}
return fields;
}
/**
* Creates a single form field
*/
private createFormField(
fieldName: string,
renderConfig?: RenderConfigSchema,
schema?: z.ZodSchema,
fieldPath?: string,
toolName?: string
): FormField {
const type = this.mapFieldType(renderConfig?.fieldType, schema, fieldPath);
const isRequired = this.isFieldRequired(schema, fieldPath || fieldName);
const guidance = toolName
? fieldGuidanceRegistry.getFieldGuidance(toolName, fieldName)
: null;
const finalType = guidance?.fieldTypeOverride || type;
const field: FormField = {
name: fieldName,
label: renderConfig?.ui?.label || this.humanizeFieldName(fieldName),
type: finalType,
required: isRequired,
priority: this.getFieldPriority(fieldName, renderConfig, isRequired),
};
if (guidance) {
if (guidance.suggestions && guidance.suggestions.length > 0) {
field.suggestions = guidance.suggestions;
if (!field.placeholder) {
field.placeholder = `e.g., ${guidance.suggestions[0]}`;
}
}
if (guidance.predefinedOptions) {
field.options = [
...(field.options || []),
...guidance.predefinedOptions,
];
}
if (guidance.contextualHelpText) {
field.helpText = guidance.contextualHelpText;
}
if (guidance.warnings) {
field.warnings = guidance.warnings.map((w) => w.message);
}
if (guidance.validationRules) {
const { qualityChecks } = guidance.validationRules;
if (qualityChecks) {
field.contextualGuidance = {
qualityStandards: [],
examples: guidance.suggestions || [],
avoidPatterns: qualityChecks.forbidTechnicalTerms || [],
};
if (qualityChecks.minNonTechnicalWords) {
field.contextualGuidance.qualityStandards?.push(
`Use at least ${qualityChecks.minNonTechnicalWords} meaningful words`
);
}
if (qualityChecks.forbidTechnicalTerms) {
field.contextualGuidance.qualityStandards?.push(
`Avoid technical terms like: ${qualityChecks.forbidTechnicalTerms.join(
', '
)}`
);
}
}
}
}
if (renderConfig) {
field.renderConfig = renderConfig;
}
if (renderConfig?.ui?.placeholder) {
field.placeholder = renderConfig.ui.placeholder;
}
if (renderConfig?.ui?.helpText) {
field.helpText = renderConfig.ui.helpText;
}
if (renderConfig?.constraints) {
const validation: Record<string, unknown> = {};
if (renderConfig.constraints.min !== undefined)
validation.min = renderConfig.constraints.min;
if (renderConfig.constraints.max !== undefined)
validation.max = renderConfig.constraints.max;
if (renderConfig.constraints.minLength !== undefined)
validation.minLength = renderConfig.constraints.minLength;
if (renderConfig.constraints.maxLength !== undefined)
validation.maxLength = renderConfig.constraints.maxLength;
if (renderConfig.constraints.pattern !== undefined)
validation.pattern = renderConfig.constraints.pattern;
if (Object.keys(validation).length > 0) {
field.validation = validation;
}
}
if (renderConfig?.options) {
field.options = renderConfig.options.map((opt) => ({
value: String(opt.value),
label: opt.label,
...(opt.disabled !== undefined && { disabled: opt.disabled }),
}));
}
return field;
}
/**
* Maps render config field type to form field type with fallback inference
*/
private mapFieldType(
fieldType?: string,
schema?: z.ZodSchema,
fieldPath?: string
): FormFieldType {
if (!fieldType && schema && fieldPath) {
const inferredType = this.inferTypeFromSchema(schema, fieldPath);
if (inferredType) {
return inferredType;
}
}
if (!fieldType && fieldPath) {
const registryType = fieldTypeRegistry.detectType(fieldPath);
if (registryType) {
return registryType;
}
}
if (!fieldType) {
return 'text';
}
const normalizedType = fieldType.toLowerCase();
if (['text', 'string'].includes(normalizedType)) return 'text';
if (['number', 'integer', 'float', 'decimal'].includes(normalizedType))
return 'number';
if (['select', 'enum', 'dropdown'].includes(normalizedType))
return 'select';
if (['checkbox', 'boolean', 'bool'].includes(normalizedType))
return 'checkbox';
if (['textarea', 'longtext', 'multiline'].includes(normalizedType))
return 'textarea';
if (['file', 'upload', 'attachment'].includes(normalizedType))
return 'file';
if (['array', 'list'].includes(normalizedType)) return 'array';
if (['object', 'json'].includes(normalizedType)) return 'object';
if (['currency', 'money', 'price'].includes(normalizedType))
return 'currency';
if (['percentage', 'percent'].includes(normalizedType)) return 'percentage';
return 'text';
}
/**
* Converts field name to human-readable label
*/
private humanizeFieldName(fieldName: string): string {
return fieldName
.replace(/([A-Z])/g, ' $1')
.replace(/_/g, ' ')
.replace(/\./g, ' ')
.trim()
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
}
/**
* Generates a title for the form
*/
private generateFormTitle(toolName: string): string {
const cleanName = toolName
.replace(/Tool$/, '')
.replace(/Hedera/g, '')
.replace(/([A-Z])/g, ' $1')
.trim();
return `Complete ${cleanName} Information`;
}
/**
* Safely extracts ZodObject from a schema, returns null if not an object schema
*/
private extractZodObject(
schema: z.ZodSchema
): z.ZodObject<z.ZodRawShape> | null {
try {
const def = (schema as z.ZodType)._def as { typeName?: string };
if (def && def.typeName === 'ZodObject') {
return schema as z.ZodObject<z.ZodRawShape>;
}
} catch (error) {
this.logger.debug('Could not extract ZodObject from schema:', error);
}
return null;
}
/**
* Extracts field names from Zod schema structure
*/
private extractFieldsFromSchema(schema: z.ZodSchema): string[] {
const fields: string[] = [];
const zodObject = this.extractZodObject(schema);
if (zodObject) {
fields.push(...Object.keys(zodObject.shape));
return fields;
}
try {
const def = (schema as z.ZodType)._def as {
typeName?: string;
options?: z.ZodType[];
};
if (def && def.typeName === 'ZodUnion' && def.options) {
const firstOption = def.options[0];
const firstOptionObject = this.extractZodObject(firstOption);
if (firstOptionObject) {
fields.push(...Object.keys(firstOptionObject.shape));
}
}
} catch (error) {
this.logger.debug(
'Could not extract fields from schema structure:',
error
);
}
return fields;
}
/**
* Infers field type from Zod schema
*/
private inferTypeFromSchema(
schema: z.ZodSchema,
fieldPath: string
): FormFieldType | null {
try {
const zodObject = this.extractZodObject(schema);
if (!zodObject) return null;
const shape = zodObject.shape;
if (!shape) return null;
let fieldSchema = shape[fieldPath] as z.ZodType | undefined;
if (!fieldSchema) return null;
const fieldDef = fieldSchema._def as {
typeName?: string;
innerType?: z.ZodType;
};
if (
fieldDef &&
fieldDef.typeName === 'ZodOptional' &&
fieldDef.innerType
) {
fieldSchema = fieldDef.innerType;
}
if (!fieldSchema || !fieldSchema._def) return null;
const typeDef = fieldSchema._def as { typeName?: string };
const fieldTypeName = typeDef.typeName;
const lowerPath = fieldPath.toLowerCase();
switch (fieldTypeName) {
case 'ZodString':
if (lowerPath.includes('memo') || lowerPath.includes('description')) {
return 'textarea';
}
return 'text';
case 'ZodNumber':
if (lowerPath.includes('percent')) {
return 'percentage';
}
if (lowerPath.includes('price') || lowerPath.includes('cost')) {
return 'currency';
}
return 'number';
case 'ZodBoolean':
return 'checkbox';
case 'ZodEnum':
case 'ZodNativeEnum':
return 'select';
case 'ZodArray':
return 'array';
case 'ZodObject':
return 'object';
default:
return 'text';
}
} catch (error) {
this.logger.debug('Could not infer type from schema:', error);
}
return null;
}
/**
* Determines if a field is required based on the Zod schema
*/
private isFieldRequired(schema?: z.ZodSchema, fieldPath?: string): boolean {
if (!schema || !fieldPath) {
return false;
}
try {
const zodObject = this.extractZodObject(schema);
if (!zodObject) return false;
const shape = zodObject.shape;
if (!shape || !shape[fieldPath]) return false;
const fieldSchema = shape[fieldPath] as z.ZodType;
if (!fieldSchema || !fieldSchema._def) return true;
const fieldDef = fieldSchema._def as {
typeName?: string;
defaultValue?: unknown;
};
const typeName = fieldDef.typeName;
if (typeName === 'ZodOptional') {
return false;
}
if (typeName === 'ZodDefault') {
return false;
}
if (fieldDef.defaultValue !== undefined) {
return false;
}
return true;
} catch (error) {
this.logger.debug(
`Could not determine if field ${fieldPath} is required:`,
error
);
}
return false;
}
/**
* Generates a description for the form
*/
private generateFormDescription(
toolName: string,
fieldCount: number
): string {
if (fieldCount === 0) {
return 'Please provide the required information to continue with your request.';
}
return `Please provide the following ${fieldCount} required field${
fieldCount !== 1 ? 's' : ''
} to continue with your request.`;
}
/**
* Generates JSON Schema and uiSchema from a Zod schema for use with @rjsf/core
* @param zodSchema The Zod schema to convert
* @param partialInput Existing input data to filter out fields that already have values
* @param missingFields Set of fields that are missing and should be shown
* @returns Object containing jsonSchema and uiSchema
*/
public generateJsonSchemaForm(
zodSchema: z.ZodObject<z.ZodRawShape>,
partialInput?: Record<string, unknown>,
missingFields?: Set<string>
): {
jsonSchema: Record<string, unknown>;
uiSchema: Record<string, unknown>;
} {
const fullJsonSchema = zodToJsonSchema(zodSchema, {
target: 'jsonSchema7',
});
const uiSchema: Record<string, Record<string, unknown>> = {};
let jsonSchema = fullJsonSchema;
if (missingFields && missingFields.size > 0) {
const fullSchemaAsObject = fullJsonSchema as {
properties?: Record<string, unknown>;
required?: string[];
[key: string]: unknown;
};
if (
fullSchemaAsObject.properties &&
typeof fullSchemaAsObject.properties === 'object'
) {
const filteredSchema = {
...fullSchemaAsObject,
type: 'object' as const,
properties: {} as Record<string, unknown>,
required: [] as string[],
};
let fieldsAdded = 0;
missingFields.forEach((fieldName) => {
if (
fullSchemaAsObject.properties &&
fullSchemaAsObject.properties[fieldName]
) {
filteredSchema.properties[fieldName] =
fullSchemaAsObject.properties[fieldName];
fieldsAdded++;
}
});
if (Array.isArray(fullSchemaAsObject.required)) {
filteredSchema.required = fullSchemaAsObject.required.filter(
(field: string) => missingFields.has(field)
);
}
if (fieldsAdded > 0) {
jsonSchema = filteredSchema;
}
}
}
const fieldNames = this.extractFieldsFromSchema(zodSchema);
fieldNames.forEach((fieldName) => {
const isRequired = this.isFieldRequired(zodSchema, fieldName);
const priority = this.getFieldPriority(fieldName, undefined, isRequired);
const lower = fieldName.toLowerCase();
if (
lower === 'attributes' ||
lower === 'metadata' ||
lower === 'properties'
) {
uiSchema[fieldName] = {
'ui:options': {
collapsible: true,
collapsed: true,
},
};
}
switch (priority) {
case 'essential':
if (isRequired) {
uiSchema[fieldName] = {
...uiSchema[fieldName],
'ui:help': 'Required field',
};
}
break;
case 'advanced':
case 'expert':
uiSchema[fieldName] = {
...uiSchema[fieldName],
'ui:options': {
...(uiSchema[fieldName]?.['ui:options'] as
| Record<string, unknown>
| undefined),
collapsed: true,
},
};
break;
}
});
return { jsonSchema, uiSchema };
}
}