UNPKG

@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
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 }; } }