import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { JsonOutputParser } from '@langchain/core/output_parsers'; import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages'; import { ChatOpenAI } from '@langchain/openai'; import { Annotation, END, START, StateGraph } from '@langchain/langgraph'; import { createDeepAgent } from 'deepagents'; import { ObjectService } from '../object/object.service'; import { PageLayoutService } from '../page-layout/page-layout.service'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { getCentralPrisma } from '../prisma/central-prisma.service'; import { OpenAIConfig } from '../voice/interfaces/integration-config.interface'; import { AiAssistantReply, AiAssistantState, EntityInfo, EntityFieldInfo, EntityRelationship, SystemEntities, RecordCreationPlan, PlannedRecord, } from './ai-assistant.types'; import { MeilisearchService } from '../search/meilisearch.service'; import { randomUUID } from 'crypto'; type AiSearchFilter = { field: string; operator: string; value?: any; values?: any[]; from?: string; to?: string; }; type AiSearchPlan = { strategy: 'keyword' | 'query'; explanation: string; keyword?: string | null; filters?: AiSearchFilter[]; sort?: { field: string; direction: 'asc' | 'desc' } | null; }; type AiSearchPayload = { objectApiName: string; query: string; page?: number; pageSize?: number; }; @Injectable() export class AiAssistantService { private readonly logger = new Logger(AiAssistantService.name); private readonly defaultModel = process.env.OPENAI_MODEL || 'gpt-4o'; private readonly conversationState = new Map< string, { fields: Record; updatedAt: number } >(); private readonly conversationTtlMs = 30 * 60 * 1000; // 30 minutes // Entity discovery cache per tenant (refreshes every 5 minutes) private readonly entityCache = new Map(); private readonly entityCacheTtlMs = 5 * 60 * 1000; // 5 minutes // Plan cache per conversation private readonly planCache = new Map(); constructor( private readonly objectService: ObjectService, private readonly pageLayoutService: PageLayoutService, private readonly tenantDbService: TenantDatabaseService, private readonly meilisearchService: MeilisearchService, ) {} // ============================================ // Entity Discovery Methods // ============================================ /** * Discovers all available entities in the system for a tenant. * Results are cached for performance. */ async discoverEntities(tenantId: string): Promise { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); // Check cache first const cached = this.entityCache.get(resolvedTenantId); if (cached && Date.now() - cached.loadedAt < this.entityCacheTtlMs) { console.log('=== Using cached entity discovery ==='); return cached; } console.log('=== Discovering system entities ==='); const objectDefinitions = await this.objectService.getObjectDefinitions(resolvedTenantId); const entities: EntityInfo[] = []; const entityByApiName: Record = {}; // Use plain object instead of Map for (const objDef of objectDefinitions) { try { // Get full object definition with fields const fullDef = await this.objectService.getObjectDefinition(resolvedTenantId, objDef.apiName); const fields: EntityFieldInfo[] = (fullDef.fields || []).map((f: any) => ({ apiName: f.apiName, label: f.label || f.apiName, type: f.type, isRequired: f.isRequired || false, isSystem: this.isSystemField(f.apiName), referenceObject: f.referenceObject || undefined, description: f.description, })); const relationships: EntityRelationship[] = fields .filter(f => f.referenceObject && !f.isSystem) .map(f => ({ fieldApiName: f.apiName, fieldLabel: f.label, targetEntity: f.referenceObject!, relationshipType: f.type === 'LOOKUP' ? 'lookup' as const : 'master-detail' as const, })); const requiredFields = fields .filter(f => f.isRequired && !f.isSystem) .map(f => f.apiName); const entityInfo: EntityInfo = { apiName: fullDef.apiName, label: fullDef.label || fullDef.apiName, pluralLabel: fullDef.pluralLabel, description: fullDef.description, fields, requiredFields, relationships, }; entities.push(entityInfo); entityByApiName[fullDef.apiName.toLowerCase()] = entityInfo; // Also map by label for easier lookup entityByApiName[(fullDef.label || fullDef.apiName).toLowerCase()] = entityInfo; } catch (error) { this.logger.warn(`Failed to load entity ${objDef.apiName}: ${error.message}`); } } const systemEntities: SystemEntities = { entities, entityByApiName, loadedAt: Date.now(), }; this.entityCache.set(resolvedTenantId, systemEntities); console.log(`Discovered ${entities.length} entities`); return systemEntities; } /** * Finds an entity by name (apiName or label, case-insensitive) */ findEntityByName(systemEntities: SystemEntities, name: string): EntityInfo | undefined { if (!systemEntities?.entityByApiName) { console.warn('findEntityByName: systemEntities or entityByApiName is undefined'); return undefined; } const result = systemEntities.entityByApiName[name.toLowerCase()]; if (!result) { console.warn(`findEntityByName: Entity "${name}" not found. Available: ${Object.keys(systemEntities.entityByApiName).join(', ')}`); } return result; } /** * Generates a summary of available entities for the AI prompt */ generateEntitySummaryForPrompt(systemEntities: SystemEntities): string { const lines: string[] = ['Available Entities in the System:']; for (const entity of systemEntities.entities) { const requiredStr = entity.requiredFields.length > 0 ? `Required fields: ${entity.requiredFields.join(', ')}` : 'No required fields'; const relStr = entity.relationships.length > 0 ? `Relationships: ${entity.relationships.map(r => `${r.fieldLabel} → ${r.targetEntity}`).join(', ')}` : ''; lines.push(`- ${entity.label} (${entity.apiName}): ${requiredStr}${relStr ? '. ' + relStr : ''}`); } return lines.join('\n'); } // ============================================ // Planning Methods // ============================================ /** * Creates a new record creation plan */ createPlan(): RecordCreationPlan { return { id: randomUUID(), records: [], executionOrder: [], status: 'building', createdRecords: [], errors: [], }; } /** * Adds a record to the plan */ addRecordToPlan( plan: RecordCreationPlan, entityInfo: EntityInfo, fields: Record, dependsOn: string[] = [], ): PlannedRecord { const tempId = `temp_${entityInfo.apiName.toLowerCase()}_${plan.records.length + 1}`; // Determine which required fields are missing const missingRequiredFields = entityInfo.requiredFields.filter( fieldApiName => !fields[fieldApiName] && fields[fieldApiName] !== 0 && fields[fieldApiName] !== false ); const plannedRecord: PlannedRecord = { id: tempId, entityApiName: entityInfo.apiName, entityLabel: entityInfo.label, fields, missingRequiredFields, dependsOn, status: missingRequiredFields.length === 0 ? 'ready' : 'pending', }; plan.records.push(plannedRecord); return plannedRecord; } /** * Updates a planned record's fields */ updatePlannedRecordFields( plan: RecordCreationPlan, recordId: string, newFields: Record, systemEntities: SystemEntities, ): PlannedRecord | undefined { const record = plan.records.find(r => r.id === recordId); if (!record) return undefined; const entityInfo = this.findEntityByName(systemEntities, record.entityApiName); if (!entityInfo) return undefined; record.fields = { ...record.fields, ...newFields }; // Recalculate missing fields record.missingRequiredFields = entityInfo.requiredFields.filter( fieldApiName => !record.fields[fieldApiName] && record.fields[fieldApiName] !== 0 && record.fields[fieldApiName] !== false ); record.status = record.missingRequiredFields.length === 0 ? 'ready' : 'pending'; return record; } /** * Calculates the execution order based on dependencies */ calculateExecutionOrder(plan: RecordCreationPlan): string[] { const order: string[] = []; const processed = new Set(); const process = (recordId: string) => { if (processed.has(recordId)) return; const record = plan.records.find(r => r.id === recordId); if (!record) return; // Process dependencies first for (const depId of record.dependsOn) { process(depId); } processed.add(recordId); order.push(recordId); }; for (const record of plan.records) { process(record.id); } plan.executionOrder = order; return order; } /** * Checks if the plan is complete (all records have required data) */ isPlanComplete(plan: RecordCreationPlan): boolean { return plan.records.every(r => r.status === 'ready' || r.status === 'created'); } /** * Gets all missing fields across the plan */ getAllMissingFields(plan: RecordCreationPlan): Array<{ record: PlannedRecord; missingFields: string[] }> { return plan.records .filter(r => r.missingRequiredFields.length > 0) .map(r => ({ record: r, missingFields: r.missingRequiredFields })); } /** * Generates a human-readable summary of missing fields */ generateMissingFieldsSummary(plan: RecordCreationPlan, systemEntities: SystemEntities): string { const missing = this.getAllMissingFields(plan); if (missing.length === 0) return ''; const parts: string[] = []; for (const { record, missingFields } of missing) { const entityInfo = this.findEntityByName(systemEntities, record.entityApiName); const fieldLabels = missingFields.map(apiName => { const field = entityInfo?.fields.find(f => f.apiName === apiName); return field?.label || apiName; }); parts.push(`${record.entityLabel}: ${fieldLabels.join(', ')}`); } return `I need more information to complete the plan:\n${parts.join('\n')}`; } async handleChat( tenantId: string, userId: string, message: string, history: AiAssistantState['history'], context: AiAssistantState['context'], ): Promise { this.pruneConversations(); const conversationKey = this.getConversationKey( tenantId, userId, context?.objectApiName, ); const prior = this.conversationState.get(conversationKey); const trimmedHistory = Array.isArray(history) ? history.slice(-6) : []; // Use Deep Agent as the main coordinator const result = await this.runDeepAgent(tenantId, userId, message, trimmedHistory, context, prior); // Update conversation state based on result if (result.record) { this.conversationState.delete(conversationKey); } else if ('extractedFields' in result && result.extractedFields && Object.keys(result.extractedFields).length > 0) { this.conversationState.set(conversationKey, { fields: result.extractedFields, updatedAt: Date.now(), }); } return { reply: result.reply || 'How can I help?', action: result.action, missingFields: result.missingFields, record: result.record, }; } private async runDeepAgent( tenantId: string, userId: string, message: string, history: AiAssistantState['history'], context: AiAssistantState['context'], prior?: { fields: Record; updatedAt: number }, ): Promise }> { const openAiConfig = await this.getOpenAiConfig(tenantId); if (!openAiConfig) { this.logger.warn('No OpenAI config found; using fallback graph execution.'); // Fallback to direct graph execution if no OpenAI config const initialState: AiAssistantState = { message: this.combineHistory(history, message), history: history, context: context || {}, extractedFields: prior?.fields, }; const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId); const result = await graph.invoke(initialState); return { reply: result.reply || 'How can I help?', action: result.action, missingFields: result.missingFields, record: result.record, }; } // Discover available entities for dynamic prompt generation const systemEntities = await this.discoverEntities(tenantId); // Build the compiled subagent const compiledSubagent = this.buildResolveOrCreateRecordGraph(tenantId, userId); // Create Deep Agent with the subagent const mainModel = new ChatOpenAI({ apiKey: openAiConfig.apiKey, model: this.normalizeChatModel(openAiConfig.model), temperature: 0.3, }); // Build dynamic system prompt based on discovered entities const systemPrompt = this.buildDeepAgentSystemPrompt(systemEntities, context); const agent = createDeepAgent({ model: mainModel, systemPrompt, tools: [], subagents: [ { name: 'record-planner', description: [ 'USE THIS FOR ALL RECORD OPERATIONS. This is the ONLY way to create, find, or modify CRM records.', '', 'Pass the user\'s request directly. The subagent handles:', '- Finding existing records (prevents duplicates)', '- Creating new records with all required fields', '- Managing relationships between records', '- Transaction handling (prevents orphaned records)', '', 'Example: User says "Create contact John under Acme account"', 'Just pass: "Create contact John under Acme account"', 'The subagent will create both records with proper linking.', ].join('\n'), runnable: compiledSubagent, }, ], }); // Convert history to messages format const messages: BaseMessage[] = []; if (history && history.length > 0) { for (const entry of history) { if (entry.role === 'user') { messages.push(new HumanMessage(entry.text)); } else if (entry.role === 'assistant') { messages.push(new AIMessage(entry.text)); } } } messages.push(new HumanMessage(message)); // Include context information in the first message if available let contextInfo = ''; if (context?.objectApiName) { contextInfo += `\n[System Context: User is working with ${context.objectApiName} object`; if (context.recordId) { contextInfo += `, record ID: ${context.recordId}`; } contextInfo += ']'; } if (prior?.fields && Object.keys(prior.fields).length > 0) { contextInfo += `\n[Previously collected field values: ${JSON.stringify(prior.fields)}]`; } if (contextInfo && messages.length > 0) { const lastMessage = messages[messages.length - 1]; if (lastMessage instanceof HumanMessage) { messages[messages.length - 1] = new HumanMessage( lastMessage.content + contextInfo, ); } } try { console.log('=== DEEP AGENT: Starting invocation ==='); console.log('Messages:', messages.map(m => ({ role: m._getType(), content: m.content }))); const result = await agent.invoke({ messages }); console.log('=== DEEP AGENT: Result received ==='); console.log('Result messages count:', result.messages.length); // Look for subagent results in the messages let subagentResult: any = null; for (let i = result.messages.length - 1; i >= 0; i--) { const msg = result.messages[i]; console.log(`Message ${i}:`, { type: msg._getType(), content: typeof msg.content === 'string' ? msg.content.substring(0, 200) : msg.content, additional_kwargs: msg.additional_kwargs, }); // Check if this message has subagent output data if (msg.additional_kwargs?.action || msg.additional_kwargs?.record) { subagentResult = msg.additional_kwargs; console.log('Found subagent result in message additional_kwargs:', subagentResult); break; } } const lastMsg = result.messages[result.messages.length - 1]; const replyText = typeof lastMsg.content === 'string' ? lastMsg.content : 'How can I help?'; console.log('Final reply text:', replyText); // If we found subagent results, use them; otherwise use defaults if (subagentResult) { console.log('=== DEEP AGENT: Using subagent result ==='); // If a record was found/created, log it prominently if (subagentResult.record) { const wasFound = subagentResult.foundExisting || subagentResult.record.wasFound; console.log(`!!! Record ${wasFound ? 'FOUND' : 'CREATED'}: ID = ${subagentResult.record.id}, Name = ${subagentResult.record.name || 'N/A'}`); } return { reply: replyText, action: subagentResult.action || 'clarify', missingFields: subagentResult.missingFields || [], record: subagentResult.record, extractedFields: subagentResult.extractedFields, }; } console.log('=== DEEP AGENT: No subagent result found, using defaults ==='); console.log('This usually means the Deep Agent did not invoke the subagent.'); console.log('Falling back to direct graph invocation...'); // Fallback: invoke the graph directly since Deep Agent didn't use the subagent const initialState: AiAssistantState = { message: this.combineHistory(history, message), history: history, context: context || {}, extractedFields: prior?.fields, }; const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId); const graphResult = await graph.invoke(initialState); console.log('=== DIRECT GRAPH: Result ===', { action: graphResult.action, hasRecord: !!graphResult.record, reply: graphResult.reply?.substring(0, 100), }); return { reply: graphResult.reply || replyText, action: graphResult.action || 'clarify', missingFields: graphResult.missingFields || [], record: graphResult.record, extractedFields: graphResult.extractedFields, }; } catch (error) { this.logger.error(`Deep Agent execution failed: ${error.message}`, error.stack); // Fallback to direct graph execution const initialState: AiAssistantState = { message: this.combineHistory(history, message), history: history, context: context || {}, extractedFields: prior?.fields, }; const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId); const result = await graph.invoke(initialState); return { reply: result.reply || 'How can I help?', action: result.action, missingFields: result.missingFields, record: result.record, }; } } private buildDeepAgentSystemPrompt( systemEntities: SystemEntities, context?: AiAssistantState['context'], ): string { const contextInfo = context?.objectApiName ? ` The user is currently working with the ${context.objectApiName} object.` : ''; // Generate dynamic entity information const entitySummary = this.generateEntitySummaryForPrompt(systemEntities); // Find entities with relationships for examples const entitiesWithRelationships = systemEntities.entities.filter(e => e.relationships.length > 0); const relationshipExamples = entitiesWithRelationships.slice(0, 3).map(e => { const rel = e.relationships[0]; return ` - ${e.label} has a ${rel.fieldLabel} field that references ${rel.targetEntity}`; }).join('\n'); return [ 'You are an AI assistant helping users interact with a CRM system.', '', '*** CRITICAL: YOU MUST ALWAYS USE THE record-planner SUBAGENT ***', 'You CANNOT create, find, or modify records yourself.', 'For ANY request involving records, you MUST invoke the record-planner subagent.', 'Do NOT respond to record-related requests without using the subagent first.', '', '=== AVAILABLE ENTITIES ===', entitySummary, '', '=== HOW TO USE THE SUBAGENT ===', 'Simply pass the user\'s request directly to the record-planner subagent.', 'The subagent will:', '1. Analyze what records need to be created', '2. Check if any already exist (no duplicates)', '3. Verify all required data is present', '4. Create records in a transaction (no orphans)', '', '=== ENTITY RELATIONSHIPS ===', relationshipExamples || ' (No relationships defined)', '', '=== RULES ===', '- INVOKE the subagent for ANY record operation', '- If subagent needs more data, ask the user', '- Report success only when subagent confirms', '', contextInfo, ].join('\n'); } async searchRecords( tenantId: string, userId: string, payload: AiSearchPayload, ) { const queryText = payload?.query?.trim(); if (!payload?.objectApiName || !queryText) { throw new BadRequestException('objectApiName and query are required'); } // Normalize tenant ID so Meilisearch index names align with indexed records const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const objectDefinition = await this.objectService.getObjectDefinition( resolvedTenantId, payload.objectApiName, ); if (!objectDefinition) { throw new BadRequestException(`Object ${payload.objectApiName} not found`); } const page = Number.isFinite(Number(payload.page)) ? Number(payload.page) : 1; const pageSize = Number.isFinite(Number(payload.pageSize)) ? Number(payload.pageSize) : 20; const plan = await this.buildSearchPlan( resolvedTenantId, queryText, objectDefinition, ); console.log('AI search plan:', plan); if (plan.strategy === 'keyword') { console.log('AI search plan (keyword):', plan); const keyword = plan.keyword?.trim() || queryText; if (this.meilisearchService.isEnabled()) { const offset = (page - 1) * pageSize; const meiliResults = await this.meilisearchService.searchRecords( resolvedTenantId, payload.objectApiName, keyword, { limit: pageSize, offset }, ); console.log('Meilisearch results:', meiliResults); const ids = meiliResults.hits .map((hit: any) => hit?.id) .filter(Boolean); const records = ids.length ? await this.objectService.searchRecordsByIds( resolvedTenantId, payload.objectApiName, userId, ids, { page, pageSize }, ) : { data: [], totalCount: 0, page, pageSize }; return { ...records, totalCount: meiliResults.total ?? records.totalCount ?? 0, strategy: plan.strategy, explanation: plan.explanation, }; } const fallback = await this.objectService.searchRecordsByKeyword( resolvedTenantId, payload.objectApiName, userId, keyword, { page, pageSize }, ); return { ...fallback, strategy: plan.strategy, explanation: plan.explanation, }; } console.log('AI search plan (query):', plan); const filtered = await this.objectService.searchRecordsWithFilters( resolvedTenantId, payload.objectApiName, userId, plan.filters || [], { page, pageSize }, plan.sort || undefined, ); return { ...filtered, strategy: plan.strategy, explanation: plan.explanation, }; } // ============================================ // Planning-Based LangGraph Workflow // ============================================ /** * Builds the planning-based record creation graph. * * Flow: * 1. transformInput - Convert Deep Agent messages to state * 2. discoverEntities - Load all available entities in the system * 3. analyzeIntent - Use AI to determine what entities need to be created * 4. buildPlan - Create a plan of records to be created with dependencies * 5. verifyPlan - Check if all required data is present * 6. (if incomplete) -> requestMissingData -> END * 7. (if complete) -> executePlan -> verifyExecution -> END */ private buildResolveOrCreateRecordGraph( tenantId: string, userId: string, ) { // Extended state for planning-based workflow const PlanningState = Annotation.Root({ // Input fields message: Annotation(), messages: Annotation(), history: Annotation(), context: Annotation(), // Entity discovery systemEntities: Annotation(), // Intent analysis results analyzedRecords: Annotation(), // Planning plan: Annotation(), // Legacy compatibility objectDefinition: Annotation(), pageLayout: Annotation(), extractedFields: Annotation>(), requiredFields: Annotation(), missingFields: Annotation(), // Output action: Annotation(), record: Annotation(), records: Annotation(), reply: Annotation(), }); // Node 1: Transform Deep Agent messages into state const transformInput = async (state: any): Promise => { console.log('=== PLAN GRAPH: Transform Input ==='); if (state.messages && Array.isArray(state.messages)) { const lastMessage = state.messages[state.messages.length - 1]; const messageText = typeof lastMessage.content === 'string' ? lastMessage.content : ''; console.log('Extracted message:', messageText); // Clean annotations from message const cleanMessage = messageText .replace(/\[System Context:[^\]]+\]/g, '') .replace(/\[Previously collected field values:[^\]]+\]/g, '') .replace(/\[Available Entities:[^\]]+\]/g, '') .replace(/\[Current Plan:[^\]]+\]/g, '') .trim(); // Extract any context hints const contextMatch = messageText.match(/\[System Context: User is working with (\w+) object(?:, record ID: ([^\]]+))?\]/); let extractedContext: AiAssistantState['context'] = {}; if (contextMatch) { extractedContext.objectApiName = contextMatch[1]; if (contextMatch[2]) { extractedContext.recordId = contextMatch[2]; } } // Extract any existing plan from the conversation const planMatch = messageText.match(/\[Current Plan: (.*?)\]/); let existingPlan: RecordCreationPlan | undefined; if (planMatch) { try { existingPlan = JSON.parse(planMatch[1]); } catch (e) { console.warn('Failed to parse existing plan from message'); } } return { message: cleanMessage, messages: state.messages, history: [], context: extractedContext, plan: existingPlan, }; } return state; }; // Node 2: Discover available entities const discoverEntitiesNode = async (state: any): Promise => { console.log('=== PLAN GRAPH: Discover Entities ==='); const systemEntities = await this.discoverEntities(tenantId); console.log(`Discovered ${systemEntities.entities.length} entities`); return { ...state, systemEntities, }; }; // Node 3: Analyze user intent and determine what to create const analyzeIntent = async (state: any): Promise => { console.log('=== PLAN GRAPH: Analyze Intent ==='); const { message, systemEntities } = state; // First, check if this is a search/find operation const lowerMessage = message.toLowerCase(); const isFindOperation = lowerMessage.includes('find') || lowerMessage.includes('search') || lowerMessage.includes('look for') || lowerMessage.includes('get'); if (isFindOperation) { // Handle as search operation, not creation return this.handleSearchOperation(tenantId, userId, state); } // Use AI to analyze what needs to be created const openAiConfig = await this.getOpenAiConfig(tenantId); if (!openAiConfig) { // Fallback to heuristic analysis return this.analyzeIntentWithHeuristics(state); } return this.analyzeIntentWithAI(openAiConfig, state); }; // Node 4: Build or update the creation plan const buildPlanNode = async (state: any): Promise => { console.log('=== PLAN GRAPH: Build Plan ==='); console.log('analyzedRecords:', state.analyzedRecords); console.log('systemEntities available:', !!state.systemEntities); console.log('systemEntities.entityByApiName keys:', state.systemEntities?.entityByApiName ? Object.keys(state.systemEntities.entityByApiName) : 'N/A'); const { systemEntities, plan, analyzedRecords } = state; if (!systemEntities || !systemEntities.entityByApiName) { console.error('systemEntities not available in buildPlanNode!'); return { ...state, action: 'clarify', reply: 'System error: Entity definitions not loaded. Please try again.', }; } // If we already have a plan, update it; otherwise create new let currentPlan = plan || this.createPlan(); // Track mapping from original index to actual plan record ID // This is needed because existing records get different IDs than temp IDs const indexToRecordId = new Map(); // Also track name to record mapping for resolving lookup fields const nameToExistingRecord = new Map(); if (analyzedRecords && Array.isArray(analyzedRecords)) { console.log(`Processing ${analyzedRecords.length} analyzed records...`); for (let idx = 0; idx < analyzedRecords.length; idx++) { const analyzed = analyzedRecords[idx]; console.log(`Looking up entity: "${analyzed.entityName}"`); const entityInfo = this.findEntityByName(systemEntities, analyzed.entityName); if (!entityInfo) { console.warn(`Entity ${analyzed.entityName} not found in system - skipping`); continue; } console.log(`Found entity: ${entityInfo.apiName} (${entityInfo.label})`); // Check if this record already exists in the plan // For ContactDetail, match by value; for others, match by name let existingInPlan: PlannedRecord | undefined; if (entityInfo.apiName === 'ContactDetail') { existingInPlan = currentPlan.records.find( r => r.entityApiName === 'ContactDetail' && r.fields.value === analyzed.fields?.value ); } else { existingInPlan = currentPlan.records.find( r => r.entityApiName === entityInfo.apiName && r.fields.name === analyzed.fields?.name ); } if (existingInPlan) { console.log(`Record already in plan, updating fields`); // Update existing planned record this.updatePlannedRecordFields(currentPlan, existingInPlan.id, analyzed.fields || {}, systemEntities); } else { // Check if record already exists in database let existingRecord: any = null; // For ContactDetail, search by value instead of name if (entityInfo.apiName === 'ContactDetail') { const searchValue = analyzed.fields?.value; if (searchValue) { existingRecord = await this.searchForExistingContactDetail( tenantId, userId, searchValue, analyzed.fields?.relatedObjectId ); } } else { // Standard search by name for other entities const searchName = analyzed.fields?.name || analyzed.fields?.firstName; existingRecord = searchName ? await this.searchForExistingRecord( tenantId, userId, entityInfo.apiName, searchName ) : null; } if (existingRecord) { console.log(`Found existing ${entityInfo.apiName} in database: ${existingRecord.id}`); // Record exists, add to plan as already created const recordPlanId = `existing_${entityInfo.apiName.toLowerCase()}_${existingRecord.id}`; const plannedRecord: PlannedRecord = { id: recordPlanId, entityApiName: entityInfo.apiName, entityLabel: entityInfo.label, fields: existingRecord, // Use full existing record for accurate display resolvedFields: existingRecord, // Also set resolvedFields missingRequiredFields: [], dependsOn: [], status: 'created', createdRecordId: existingRecord.id, wasExisting: true, // Mark as pre-existing }; currentPlan.records.push(plannedRecord); // Track the mapping from original index to actual plan record ID indexToRecordId.set(idx, recordPlanId); // Track by name for lookup field resolution const recordName = existingRecord.name || existingRecord.firstName || existingRecord.value; if (recordName) { nameToExistingRecord.set(recordName.toLowerCase(), { id: existingRecord.id, recordId: recordPlanId, entityType: entityInfo.apiName, }); } console.log(`Mapped index ${idx} to existing record ${recordPlanId}, name="${recordName}"`); } else { console.log(`Adding new record to plan: ${entityInfo.apiName} with fields:`, analyzed.fields); // Resolve dependsOn references - convert original indices to actual plan record IDs let resolvedDependsOn = analyzed.dependsOn || []; if (resolvedDependsOn.length > 0) { resolvedDependsOn = resolvedDependsOn.map((dep: string) => { // Check if this is a temp_xxx reference that maps to an existing record const tempMatch = dep.match(/temp_(\w+)_(\d+)/); if (tempMatch) { const depIndex = parseInt(tempMatch[2], 10) - 1; // temp IDs are 1-based const actualId = indexToRecordId.get(depIndex); if (actualId) { console.log(`Resolved dependency ${dep} to existing record ${actualId}`); return actualId; } } return dep; }); } // Also populate lookup fields with parent names if dependencies exist const fieldsWithLookups = { ...analyzed.fields }; for (const dep of resolvedDependsOn) { // Find the parent record in the plan const parentRecord = currentPlan.records.find(r => r.id === dep); if (parentRecord && parentRecord.wasExisting) { // Find the lookup field that should reference this entity const lookupRel = entityInfo.relationships.find( rel => rel.targetEntity.toLowerCase() === parentRecord.entityApiName.toLowerCase() ); if (lookupRel && !fieldsWithLookups[lookupRel.fieldApiName]) { // Set the lookup field to the parent's name so it can be resolved const parentName = parentRecord.fields.name || parentRecord.fields.firstName; if (parentName) { fieldsWithLookups[lookupRel.fieldApiName] = parentName; console.log(`Set lookup field ${lookupRel.fieldApiName} = "${parentName}" for relationship to ${parentRecord.entityApiName}`); } } } } // Add new record to plan const newRecord = this.addRecordToPlan( currentPlan, entityInfo, fieldsWithLookups, resolvedDependsOn ); // Track the mapping indexToRecordId.set(idx, newRecord.id); console.log(`Mapped index ${idx} to new record ${newRecord.id}`); } } } } // Calculate execution order this.calculateExecutionOrder(currentPlan); // Determine plan status if (this.isPlanComplete(currentPlan)) { currentPlan.status = 'ready'; } else { currentPlan.status = 'incomplete'; } console.log('Plan status:', currentPlan.status); console.log('Plan records:', currentPlan.records.map(r => ({ id: r.id, entity: r.entityApiName, status: r.status, missing: r.missingRequiredFields, }))); return { ...state, plan: currentPlan, }; }; // Node 5: Verify plan completeness const verifyPlan = async (state: any): Promise => { console.log('=== PLAN GRAPH: Verify Plan ==='); const { plan, systemEntities } = state; if (!plan || plan.records.length === 0) { return { ...state, action: 'clarify', reply: 'I\'m not sure what you\'d like to create. Could you please be more specific?', }; } if (plan.status === 'ready') { console.log('Plan is complete and ready for execution'); return { ...state, action: 'plan_complete', }; } // Plan is incomplete, need more data const missingFieldsSummary = this.generateMissingFieldsSummary(plan, systemEntities); console.log('Plan incomplete:', missingFieldsSummary); return { ...state, action: 'plan_pending', reply: missingFieldsSummary, missingFields: this.getAllMissingFields(plan).flatMap(m => m.missingFields), }; }; // Node 6: Execute the plan (with transaction) const executePlan = async (state: any): Promise => { console.log('=== PLAN GRAPH: Execute Plan ==='); const { plan, systemEntities } = state; if (!plan || plan.status !== 'ready') { return { ...state, action: 'clarify', reply: 'The plan is not ready for execution.', }; } plan.status = 'executing'; // Get tenant database connection for transaction const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); // Map of temp IDs to real IDs for dependency resolution const idMapping = new Map(); // Map of record names to their created IDs and entity types const nameToRecord = new Map(); // Populate with already created records for (const record of plan.records) { if (record.status === 'created' && record.createdRecordId) { idMapping.set(record.id, record.createdRecordId); const recordName = record.fields.name || record.fields.firstName; if (recordName) { nameToRecord.set(recordName.toLowerCase(), { id: record.createdRecordId, entityType: record.entityApiName, }); } } } try { // Execute in transaction await knex.transaction(async (trx) => { for (const tempId of plan.executionOrder) { const plannedRecord = plan.records.find(r => r.id === tempId); if (!plannedRecord || plannedRecord.status === 'created') continue; console.log(`Creating record: ${plannedRecord.entityLabel} (${plannedRecord.id})`); console.log(`Original fields:`, plannedRecord.fields); // Resolve any dependency references in fields const resolvedFields = { ...plannedRecord.fields }; // Get entity info for this record type const entityInfo = this.findEntityByName(systemEntities, plannedRecord.entityApiName); // Resolve dependencies by temp ID for (const depId of plannedRecord.dependsOn) { const realId = idMapping.get(depId); if (realId) { const depRecord = plan.records.find(r => r.id === depId); if (depRecord && entityInfo) { // Find the lookup field that references this entity const lookupField = entityInfo.relationships.find( r => r.targetEntity.toLowerCase() === depRecord.entityApiName.toLowerCase() ); if (lookupField) { resolvedFields[lookupField.fieldApiName] = realId; console.log(`Resolved ${lookupField.fieldApiName} = ${realId} (from dependency ${depId})`); } } } } // Handle polymorphic fields (relatedObjectId/relatedObjectType for ContactDetail) if (plannedRecord.entityApiName.toLowerCase() === 'contactdetail') { // Check if relatedObjectId is a name rather than UUID const relatedValue = resolvedFields.relatedObjectId; if (relatedValue && typeof relatedValue === 'string' && !this.isUuid(relatedValue)) { // Try to find the record by name in our created records const foundRecord = nameToRecord.get(relatedValue.toLowerCase()); if (foundRecord) { resolvedFields.relatedObjectId = foundRecord.id; resolvedFields.relatedObjectType = foundRecord.entityType; console.log(`Resolved polymorphic: relatedObjectId=${foundRecord.id}, relatedObjectType=${foundRecord.entityType}`); } else { // Try to search in database for (const targetType of ['Contact', 'Account']) { const existingRecord = await this.searchForExistingRecord( tenantId, userId, targetType, relatedValue ); if (existingRecord) { resolvedFields.relatedObjectId = existingRecord.id; resolvedFields.relatedObjectType = targetType; console.log(`Found existing record for polymorphic: ${targetType} ${existingRecord.id}`); break; } } } } // Ensure relatedObjectType is set if we have relatedObjectId if (resolvedFields.relatedObjectId && !resolvedFields.relatedObjectType) { // Default to Contact if not specified resolvedFields.relatedObjectType = 'Contact'; console.log(`Defaulting relatedObjectType to Contact`); } } // Resolve any remaining lookup fields that have names instead of IDs if (entityInfo) { for (const rel of entityInfo.relationships) { const fieldValue = resolvedFields[rel.fieldApiName]; if (fieldValue && typeof fieldValue === 'string' && !this.isUuid(fieldValue)) { // This is a name, try to resolve it const foundInPlan = nameToRecord.get(fieldValue.toLowerCase()); if (foundInPlan && foundInPlan.entityType.toLowerCase() === rel.targetEntity.toLowerCase()) { resolvedFields[rel.fieldApiName] = foundInPlan.id; console.log(`Resolved lookup ${rel.fieldApiName} from name "${fieldValue}" to ID ${foundInPlan.id}`); } else { // Search in database const existingRecord = await this.searchForExistingRecord( tenantId, userId, rel.targetEntity, fieldValue ); if (existingRecord) { resolvedFields[rel.fieldApiName] = existingRecord.id; console.log(`Resolved lookup ${rel.fieldApiName} from database: ${existingRecord.id}`); } } } } } console.log(`Resolved fields:`, resolvedFields); // Create the record const createdRecord = await this.objectService.createRecord( tenantId, plannedRecord.entityApiName, resolvedFields, userId, ); if (createdRecord?.id) { plannedRecord.status = 'created'; plannedRecord.createdRecordId = createdRecord.id; // Store the resolved fields for accurate success message plannedRecord.resolvedFields = resolvedFields; idMapping.set(plannedRecord.id, createdRecord.id); // Add to nameToRecord map for future lookups const recordName = resolvedFields.name || (resolvedFields.firstName ? `${resolvedFields.firstName} ${resolvedFields.lastName || ''}`.trim() : '') || resolvedFields.value || ''; if (recordName) { nameToRecord.set(recordName.toLowerCase(), { id: createdRecord.id, entityType: plannedRecord.entityApiName, }); } plan.createdRecords.push({ ...createdRecord, _displayName: recordName }); console.log(`Created: ${plannedRecord.entityLabel} ID=${createdRecord.id}, name="${recordName}"`); } else { throw new Error(`Failed to create ${plannedRecord.entityLabel}`); } } }); plan.status = 'completed'; // Generate success message with meaningful names const newlyCreated = plan.records.filter(r => r.status === 'created' && !r.wasExisting); const existingUsed = plan.records.filter(r => r.wasExisting); const getRecordDisplayName = (r: any) => { // Use resolvedFields if available (for newly created), otherwise original fields const fields = r.resolvedFields || r.fields; return fields.name || (fields.firstName ? `${fields.firstName} ${fields.lastName || ''}`.trim() : '') || fields.value || 'record'; }; const createdSummary = newlyCreated .map(r => `${r.entityLabel} "${getRecordDisplayName(r)}"`) .join(', '); const existingSummary = existingUsed.length > 0 ? ` (using existing: ${existingUsed.map(r => `${r.entityLabel} "${getRecordDisplayName(r)}"`).join(', ')})` : ''; // Build appropriate reply based on what was created vs found let replyMessage: string; if (newlyCreated.length > 0 && existingUsed.length > 0) { replyMessage = `Successfully created: ${createdSummary}${existingSummary}`; } else if (newlyCreated.length > 0) { replyMessage = `Successfully created: ${createdSummary}`; } else if (existingUsed.length > 0) { replyMessage = `Found existing records: ${existingUsed.map(r => `${r.entityLabel} "${getRecordDisplayName(r)}"`).join(', ')}. No new records needed.`; } else { replyMessage = 'No records were created.'; } return { ...state, plan, action: 'create_record', records: plan.createdRecords, record: plan.createdRecords[plan.createdRecords.length - 1], // Last created for compatibility reply: replyMessage, }; } catch (error) { console.error('Plan execution failed:', error); plan.status = 'failed'; plan.errors.push(error.message); return { ...state, plan, action: 'clarify', reply: `Failed to create records: ${error.message}. The transaction was rolled back.`, }; } }; // Node 7: Format output for Deep Agent const formatOutput = async (state: any): Promise => { console.log('=== PLAN GRAPH: Format Output ==='); const outputMessage = new AIMessage({ content: state.reply || 'Completed.', additional_kwargs: { action: state.action, record: state.record, records: state.records, plan: state.plan, missingFields: state.missingFields, foundExisting: state.record?.wasFound || false, }, }); return { ...state, messages: [...(state.messages || []), outputMessage], }; }; // Build the workflow const workflow = new StateGraph(PlanningState) .addNode('transformInput', transformInput) .addNode('discoverEntities', discoverEntitiesNode) .addNode('analyzeIntent', analyzeIntent) .addNode('buildPlan', buildPlanNode) .addNode('verifyPlan', verifyPlan) .addNode('executePlan', executePlan) .addNode('formatOutput', formatOutput) .addEdge(START, 'transformInput') .addEdge('transformInput', 'discoverEntities') .addEdge('discoverEntities', 'analyzeIntent') .addConditionalEdges('analyzeIntent', (current: any) => { // If it's a search result, go directly to format output if (current.record || current.action === 'clarify') { return 'formatOutput'; } return 'buildPlan'; }) .addEdge('buildPlan', 'verifyPlan') .addConditionalEdges('verifyPlan', (current: any) => { // If plan is complete, execute it; otherwise format the missing fields response if (current.action === 'plan_complete') { return 'executePlan'; } return 'formatOutput'; }) .addEdge('executePlan', 'formatOutput') .addEdge('formatOutput', END); return workflow.compile(); } // ============================================ // Intent Analysis Helpers // ============================================ private async handleSearchOperation( tenantId: string, userId: string, state: any, ): Promise { const { message, systemEntities } = state; // Try to extract entity type and search term const lowerMessage = message.toLowerCase(); let entityName: string | undefined; let searchTerm: string | undefined; // Pattern: "find/search/get [entity] [name]" for (const entity of systemEntities.entities) { const label = entity.label.toLowerCase(); const apiName = entity.apiName.toLowerCase(); if (lowerMessage.includes(label) || lowerMessage.includes(apiName)) { entityName = entity.apiName; // Extract the search term after the entity name const regex = new RegExp(`(?:find|search|get|look for)\\s+(?:${label}|${apiName})\\s+(.+)`, 'i'); const match = message.match(regex); if (match) { searchTerm = match[1].trim(); } break; } } if (!entityName) { return { ...state, action: 'clarify', reply: 'Which type of record would you like to find?', }; } if (!searchTerm) { return { ...state, action: 'clarify', reply: `What ${entityName} are you looking for?`, }; } // Search for the record const record = await this.searchForExistingRecord(tenantId, userId, entityName, searchTerm); if (record) { return { ...state, action: 'create_record', // Using create_record for compatibility record: { ...record, wasFound: true }, reply: `Found ${entityName}: "${record.name || record.id}" (ID: ${record.id})`, }; } return { ...state, action: 'clarify', reply: `No ${entityName} found matching "${searchTerm}". Would you like to create one?`, }; } private async analyzeIntentWithAI( openAiConfig: any, state: any, ): Promise { const { message, systemEntities } = state; const model = new ChatOpenAI({ apiKey: openAiConfig.apiKey, model: this.normalizeChatModel(openAiConfig.model), temperature: 0.2, }); const entitySummary = this.generateEntitySummaryForPrompt(systemEntities); const parser = new JsonOutputParser(); try { const response = await model.invoke([ new SystemMessage( `You analyze user requests to determine what CRM records need to be created.\n\n` + `${entitySummary}\n\n` + `Return JSON with:\n` + `- "records": array of records to create, each with:\n` + ` - "entityName": the entity type (use apiName from the list)\n` + ` - "fields": object with field values mentioned by user\n` + ` - "dependsOn": array of indices of records this depends on (for relationships)\n\n` + `Rules:\n` + `- Only use entities from the list above\n` + `- For "create X under/for Y", X depends on Y\n` + `- Parent records (like Account) should come before children (like Contact)\n` + `- Extract any field values mentioned in the request\n` + `- For the "name" field, use the name mentioned by the user\n` + `Example: "Create contact John under Acme account"\n` + `Response: {"records":[{"entityName":"Account","fields":{"name":"Acme"},"dependsOn":[]},{"entityName":"Contact","fields":{"name":"John"},"dependsOn":[0]}]}` ), new HumanMessage(message), ]); const content = typeof response.content === 'string' ? response.content : '{}'; const parsed = await parser.parse(content); // Transform the AI response to our format const analyzedRecords = (parsed.records || []).map((r: any, idx: number) => ({ entityName: r.entityName, fields: r.fields || {}, dependsOn: (r.dependsOn || []).map((depIdx: number) => `temp_${parsed.records[depIdx]?.entityName?.toLowerCase()}_${depIdx + 1}` ), })); console.log('AI analyzed records:', analyzedRecords); return { ...state, analyzedRecords, }; } catch (error) { console.error('AI intent analysis failed:', error); return this.analyzeIntentWithHeuristics(state); } } private analyzeIntentWithHeuristics(state: any): any { const { message, systemEntities } = state; const lowerMessage = message.toLowerCase(); const analyzedRecords: any[] = []; // Pattern: "create X under/for Y account" const underAccountMatch = message.match(/create\s+(\w+(?:\s+\w+)?)\s+(?:under|for)\s+(\w+(?:\s+\w+)?)\s+account/i); if (underAccountMatch) { const childName = underAccountMatch[1].trim(); const accountName = underAccountMatch[2].trim(); // Add parent account first analyzedRecords.push({ entityName: 'Account', fields: { name: accountName }, dependsOn: [], }); // Add child - try to determine type let childEntity = 'Contact'; // Default if (lowerMessage.includes('phone') || lowerMessage.includes('email')) { childEntity = 'ContactDetail'; } analyzedRecords.push({ entityName: childEntity, fields: { name: childName }, dependsOn: ['temp_account_1'], }); } else { // Simple pattern: "create/add [entity] [name]" for (const entity of systemEntities.entities) { const label = entity.label.toLowerCase(); const regex = new RegExp(`(?:create|add)\\s+(?:a\\s+)?${label}\\s+(.+?)(?:\\s+with|$)`, 'i'); const match = message.match(regex); if (match) { analyzedRecords.push({ entityName: entity.apiName, fields: { name: match[1].trim() }, dependsOn: [], }); break; } } } console.log('Heuristic analyzed records:', analyzedRecords); return { ...state, analyzedRecords, }; } private async searchForExistingRecord( tenantId: string, userId: string, entityApiName: string, searchName: string, ): Promise { if (!searchName) return null; try { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); // Try Meilisearch first if (this.meilisearchService.isEnabled()) { const meiliMatch = await this.meilisearchService.searchRecord( resolvedTenantId, entityApiName, searchName, 'name', ); if (meiliMatch?.id) { console.log(`Found existing ${entityApiName} via Meilisearch: ${meiliMatch.id}`); // Return the full hit data, not just { id, hit } return meiliMatch.hit || meiliMatch; } } // Fallback to database const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const tableName = this.toTableName(entityApiName); const record = await knex(tableName) .whereRaw('LOWER(name) = ?', [searchName.toLowerCase()]) .first(); if (record?.id) { console.log(`Found existing ${entityApiName} via database: ${record.id}`); return record; } return null; } catch (error) { console.error(`Error searching for ${entityApiName}:`, error.message); return null; } } /** * Search for existing ContactDetail by value and optionally by relatedObjectId * ContactDetail records are identified by their value (phone number, email, etc.) * and optionally their parent record (Contact or Account) */ private async searchForExistingContactDetail( tenantId: string, userId: string, value: string, relatedObjectId?: string, ): Promise { if (!value) return null; try { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); // Try Meilisearch first - search by value field if (this.meilisearchService.isEnabled()) { const meiliMatch = await this.meilisearchService.searchRecord( resolvedTenantId, 'ContactDetail', value, 'value', ); if (meiliMatch?.id) { // Access the full record data from hit const hitData = meiliMatch.hit || meiliMatch; // If we have a relatedObjectId, verify it matches (or skip if not resolved yet) if (!relatedObjectId || this.isUuid(relatedObjectId)) { if (!relatedObjectId || hitData.relatedObjectId === relatedObjectId) { console.log(`Found existing ContactDetail via Meilisearch: ${meiliMatch.id}`); return hitData; } } else { // relatedObjectId is a name, not UUID - just match by value for now console.log(`Found existing ContactDetail via Meilisearch (value match): ${meiliMatch.id}`); return hitData; } } } // Fallback to database const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const tableName = this.toTableName('ContactDetail'); let query = knex(tableName).whereRaw('LOWER(value) = ?', [value.toLowerCase()]); // If we have a UUID relatedObjectId, include it in the search if (relatedObjectId && this.isUuid(relatedObjectId)) { query = query.where('relatedObjectId', relatedObjectId); } const record = await query.first(); if (record?.id) { console.log(`Found existing ContactDetail via database: ${record.id} (value="${value}")`); return record; } return null; } catch (error) { console.error(`Error searching for ContactDetail:`, error.message); return null; } } // ============================================ // Legacy Methods (kept for compatibility) // ============================================ private async loadContext( tenantId: string, state: AiAssistantState, ): Promise { const objectApiName = state.context?.objectApiName; console.log('Here:'); console.log(objectApiName); if (!objectApiName) { return { ...state, action: 'clarify', reply: 'Tell me which object you want to work with, for example: "Add an account named Cloudflare."', }; } const objectDefinition = await this.objectService.getObjectDefinition( tenantId, objectApiName, ); if (!objectDefinition) { return { ...state, action: 'clarify', reply: `I could not find an object named "${objectApiName}". Which object should I use?`, }; } const pageLayout = await this.pageLayoutService.findDefaultByObject( tenantId, objectDefinition.id, ); return { ...state, objectDefinition, pageLayout, history: state.history, }; } private async searchExistingRecord( tenantId: string, userId: string, state: AiAssistantState, ): Promise { if (!state.objectDefinition || !state.message) { return state; } // Check if this is a find/search operation or if we should check for existing const lowerMessage = state.message.toLowerCase(); const isFindOperation = lowerMessage.includes('find') || lowerMessage.includes('search') || lowerMessage.includes('look for'); const isCreateOperation = lowerMessage.includes('create') || lowerMessage.includes('add'); // Extract the name to search for let searchName: string | null = null; // Pattern: "Find Account X" const findMatch = state.message.match(/(?:find|search|look for)\s+(?:\w+\s+)?(.+?)(?:\(|$)/i); if (findMatch) { searchName = findMatch[1].trim(); } else if (isCreateOperation) { // Pattern: "Create Account X" - check if X already exists const createMatch = state.message.match(/(?:create|add)\s+(?:\w+\s+)?(.+?)(?:\s+(?:under|for|with)|\(|$)/i); if (createMatch) { searchName = createMatch[1].trim(); } } if (!searchName) { console.log('No search name extracted, skipping search'); return state; } console.log(`Searching for existing ${state.objectDefinition.apiName}: "${searchName}"`); try { // Use Meilisearch if available const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); if (this.meilisearchService.isEnabled()) { const displayField = this.getDisplayFieldForObject(state.objectDefinition); const meiliMatch = await this.meilisearchService.searchRecord( resolvedTenantId, state.objectDefinition.apiName, searchName, displayField, ); if (meiliMatch?.id) { console.log('Found existing record via Meilisearch:', meiliMatch.id); return { ...state, record: { ...meiliMatch, wasFound: true }, action: 'create_record', reply: `Found existing ${state.objectDefinition.label || state.objectDefinition.apiName} "${searchName}" (ID: ${meiliMatch.id}).`, }; } } // Fallback to database search const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const tableName = this.toTableName( state.objectDefinition.apiName, state.objectDefinition.label, state.objectDefinition.pluralLabel, ); const displayField = this.getDisplayFieldForObject(state.objectDefinition); const record = await knex(tableName) .whereRaw('LOWER(??) = ?', [displayField, searchName.toLowerCase()]) .first(); if (record?.id) { console.log('Found existing record via database:', record.id); return { ...state, record: { ...record, wasFound: true }, action: 'create_record', reply: `Found existing ${state.objectDefinition.label || state.objectDefinition.apiName} "${searchName}" (ID: ${record.id}).`, }; } console.log('No existing record found, will proceed to create'); return state; } catch (error) { console.error('Error searching for existing record:', error.message); return state; } } private async extractFields( tenantId: string, state: AiAssistantState, ): Promise { if (!state.objectDefinition) { return state; } const openAiConfig = await this.getOpenAiConfig(tenantId); const fieldDefinitions = (state.objectDefinition.fields || []).filter( (field: any) => !this.isSystemField(field.apiName), ); if (!openAiConfig) { this.logger.warn('No OpenAI config found; using heuristic extraction.'); } const newExtraction = openAiConfig ? await this.extractWithOpenAI( openAiConfig, state.message, state.objectDefinition.label, fieldDefinitions, ) : this.extractWithHeuristics(state.message, fieldDefinitions); const mergedExtraction = this.enrichPolymorphicLookupFromMessage( state.message, state.objectDefinition, { ...(state.extractedFields || {}), ...(newExtraction || {}), }, ); return { ...state, extractedFields: mergedExtraction, history: state.history, }; } private decideNextStep(state: AiAssistantState): AiAssistantState { if (!state.objectDefinition) { return state; } console.log('extracated:',state.extractedFields); const fieldDefinitions = (state.objectDefinition.fields || []).filter( (field: any) => !this.isSystemField(field.apiName), ); const requiredFields = this.getRequiredFields(fieldDefinitions); const missingFields = requiredFields.filter( (fieldApiName) => !state.extractedFields?.[fieldApiName], ); if (missingFields.length > 0) { return { ...state, requiredFields, missingFields, action: 'collect_fields', }; } return { ...state, requiredFields, missingFields: [], action: 'create_record', }; } private enrichPolymorphicLookupFromMessage( message: string, objectDefinition: any, extracted: Record, ): Record { if (!objectDefinition || !this.isContactDetail(objectDefinition.apiName)) { return extracted; } if (extracted.relatedObjectId) return extracted; const match = message.match(/(?:to|for)\s+([^.,;]+)$/i); if (!match?.[1]) return extracted; const candidateName = match[1].trim(); if (!candidateName) return extracted; const lowerMessage = message.toLowerCase(); const preferredType = lowerMessage.includes('account') ? 'Account' : lowerMessage.includes('contact') ? 'Contact' : undefined; return { ...extracted, relatedObjectId: candidateName, ...(preferredType ? { relatedObjectType: preferredType } : {}), }; } private async createRecord( tenantId: string, userId: string, state: AiAssistantState, ): Promise { if (!state.objectDefinition || !state.extractedFields) { return { ...state, action: 'clarify', reply: 'I could not infer the record details. Can you provide the fields you want to set?' }; } const enrichedState = await this.resolvePolymorphicRelatedObject( tenantId, this.applyPolymorphicDefaults(state), ); const { resolvedFields, unresolvedLookups, } = await this.resolveLookupFields( tenantId, enrichedState.objectDefinition, enrichedState.extractedFields, ); if (unresolvedLookups.length > 0) { const missingText = unresolvedLookups .map( (lookup) => `${lookup.fieldLabel || lookup.fieldApiName} (value "${lookup.providedValue}") for ${lookup.targetLabel || 'the related record'}`, ) .join('; '); return { ...state, action: 'collect_fields', reply: `I couldn't find these related records: ${missingText}. Please provide an existing record name or ID for each.`, }; } if (this.isContactDetail(enrichedState.objectDefinition.apiName)) { const hasId = !!resolvedFields.relatedObjectId; const hasType = !!resolvedFields.relatedObjectType; if (!hasId || !hasType) { return { ...enrichedState, action: 'collect_fields', reply: 'I need which record this contact detail belongs to. Please provide the related Contact or Account name/ID.', history: state.history, }; } } const record = await this.objectService.createRecord( tenantId, enrichedState.objectDefinition.apiName, resolvedFields, userId, ); console.log('record',record); const nameValue = enrichedState.extractedFields.name || record?.name || record?.id; const label = enrichedState.objectDefinition.label || enrichedState.objectDefinition.apiName; return { ...enrichedState, record, action: 'create_record', reply: `Created ${label} ${nameValue ? `"${nameValue}"` : 'record'} successfully.`, history: state.history, }; } private respondWithMissingFields(state: AiAssistantState): AiAssistantState { if (!state.objectDefinition) { return state; } const label = state.objectDefinition.label || state.objectDefinition.apiName; const orderedMissing = this.orderMissingFields(state); const missingLabels = orderedMissing.map( (apiName) => this.getFieldLabel(state.objectDefinition.fields || [], apiName), ); return { ...state, action: 'collect_fields', reply: `To create a ${label}, I still need: ${missingLabels.join(', ')}.`, history: state.history, }; } private orderMissingFields(state: AiAssistantState): string[] { if (!state.pageLayout || !state.missingFields) { return state.missingFields || []; } const layoutConfig = this.parseLayoutConfig(state.pageLayout.layout_config); const layoutFieldIds: string[] = layoutConfig?.fields?.map((field: any) => field.fieldId) || []; const fieldIdToApiName = new Map( (state.objectDefinition.fields || []).map((field: any) => [field.id, field.apiName]), ); const ordered = layoutFieldIds .map((fieldId) => fieldIdToApiName.get(fieldId)) .filter((apiName): apiName is string => Boolean(apiName)) .filter((apiName) => state.missingFields?.includes(apiName)); console.log('ordered:',ordered); const remaining = (state.missingFields || []).filter( (apiName) => !ordered.includes(apiName), ); console.log('remaining:',remaining); return [...ordered, ...remaining]; } private getRequiredFields(fieldDefinitions: any[]): string[] { const required = fieldDefinitions .filter((field) => field.isRequired) .map((field) => field.apiName); const hasNameField = fieldDefinitions.some((field) => field.apiName === 'name'); if (hasNameField && !required.includes('name')) { required.unshift('name'); } return Array.from(new Set(required)); } private async extractWithOpenAI( openAiConfig: OpenAIConfig, message: string, objectLabel: string, fieldDefinitions: any[], didRetry = false, ): Promise> { console.log('Using OpenAI extraction for message:', message); try { const model = new ChatOpenAI({ apiKey: openAiConfig.apiKey, model: this.normalizeChatModel(openAiConfig.model), temperature: 0.2, }); const fieldDescriptions = fieldDefinitions.map((field) => { return `${field.label} (${field.apiName}, type: ${field.type})`; }); console.log('fieldDescriptions:',fieldDescriptions); const parser = new JsonOutputParser>(); const response = await model.invoke([ new SystemMessage( `You extract field values to create a ${objectLabel} record.` + '\n- Return JSON only with keys: action, fields.' + '\n- Use action "create_record" when the user wants to add or create.' + '\n- Use ONLY apiName keys exactly as provided (case-sensitive). NEVER use labels or other keys.' + '\n- Prefer values from the latest user turn, but keep earlier user-provided values in the same conversation for missing fields.' + '\n- If a field value is provided, include it even if it looks custom; do not drop custom fields.' + '\n- Avoid guessing fields that were not mentioned.' + '\n- Example: {"action":"create_record","fields":{"apiName1":"value"}}', ), new HumanMessage( `Fields: ${fieldDescriptions.join('; ')}.\nUser message: ${message}`, ), ]); console.log('respomse:', response); const content = typeof response.content === 'string' ? response.content : '{}'; const parsed = await parser.parse(content); const rawFields = parsed.fields || {}; const normalizedFields = this.normalizeExtractedFieldKeys(rawFields, fieldDefinitions); const sanitizedFields = this.sanitizeUserOwnerFields( normalizedFields, fieldDefinitions, message, ); return Object.fromEntries( Object.entries(sanitizedFields).filter(([apiName]) => fieldDefinitions.some((field) => field.apiName === apiName), ), ); } catch (error) { const messageText = error?.message || ''; const shouldRetryWithDefault = !didRetry && (messageText.includes('not a chat model') || messageText.includes('MODEL_NOT_FOUND') || messageText.includes('404')); if (shouldRetryWithDefault) { this.logger.warn( `OpenAI extraction failed with model "${openAiConfig.model}". Retrying with gpt-4o-mini. Error: ${messageText}`, ); return this.extractWithOpenAI( { ...openAiConfig, model: 'gpt-4o-mini' }, message, objectLabel, fieldDefinitions, true, ); } this.logger.warn(`OpenAI extraction failed: ${messageText}`); return this.extractWithHeuristics(message, fieldDefinitions); } } private extractWithHeuristics( message: string, fieldDefinitions: any[], ): Record { const extracted: Record = {}; const lowerMessage = message.toLowerCase(); console.log('Heuristic extraction for message:', message); const nameField = fieldDefinitions.find( (field) => field.apiName === 'name' || field.label.toLowerCase() === 'name', ); const phoneField = fieldDefinitions.find((field) => field.apiName.toLowerCase().includes('phone') || field.label.toLowerCase().includes('phone'), ); // Check for Account lookup field (for Contacts) const accountField = fieldDefinitions.find((field) => field.apiName === 'accountId' || field.apiName.toLowerCase().includes('account'), ); // Pattern: "Create X under/for Y account" - extract name and account reference const underAccountMatch = message.match(/create\s+([^\s]+(?:\s+[^\s]+)?)\s+(?:under|for)\s+(.+?)\s+account/i); if (underAccountMatch && nameField) { const recordName = underAccountMatch[1].trim(); const accountName = underAccountMatch[2].trim(); extracted[nameField.apiName] = recordName; if (accountField) { // Store the account name for lookup extracted[accountField.apiName] = accountName; console.log('Extracted hierarchical pattern:', { name: recordName, account: accountName }); } } // Generic pattern matching for any field: "label: value" or "set label to value" for (const field of fieldDefinitions) { const value = this.extractValueForField(message, field); if (value) { extracted[field.apiName] = value; } } if (nameField && !extracted[nameField.apiName]) { const nameMatch = message.match(/add\s+([^\n]+?)(?:\s+with\s+|$)/i); if (nameMatch?.[1]) { extracted[nameField.apiName] = nameMatch[1].trim(); } } if (phoneField) { const phoneMatch = message.match(/phone\s+([\d+().\s-]+)/i); if (phoneMatch?.[1]) { extracted[phoneField.apiName] = phoneMatch[1].trim(); } } if (Object.keys(extracted).length === 0 && lowerMessage.startsWith('add ') && nameField) { extracted[nameField.apiName] = message.replace(/^add\s+/i, '').trim(); } console.log('Heuristic extraction result:', extracted); return extracted; } private async buildSearchPlan( tenantId: string, message: string, objectDefinition: any, ): Promise { const openAiConfig = await this.getOpenAiConfig(tenantId); if (!openAiConfig) { return this.buildSearchPlanFallback(message); } try { return await this.buildSearchPlanWithAi(openAiConfig, message, objectDefinition); } catch (error) { this.logger.warn(`AI search planning failed: ${error.message}`); return this.buildSearchPlanFallback(message); } } private buildSearchPlanFallback(message: string): AiSearchPlan { const trimmed = message.trim(); return { strategy: 'keyword', keyword: trimmed, explanation: `Searched records that matches the word: "${trimmed}"`, }; } private async buildSearchPlanWithAi( openAiConfig: OpenAIConfig, message: string, objectDefinition: any, ): Promise { const model = new ChatOpenAI({ apiKey: openAiConfig.apiKey, model: this.normalizeChatModel(openAiConfig.model), temperature: 0.2, }); const parser = new JsonOutputParser(); const fields = (objectDefinition.fields || []).map((field: any) => ({ apiName: field.apiName, label: field.label, type: field.type, })); const formatInstructions = parser.getFormatInstructions(); const today = new Date().toISOString(); const response = await model.invoke([ new SystemMessage( `You are a CRM search assistant. Decide whether the user input is a keyword search or a structured query.` + `\nReturn a JSON object with keys: strategy, explanation, keyword, filters, sort.` + `\n- strategy must be "keyword" or "query".` + `\n- explanation must be a short sentence explaining the approach.` + `\n- keyword should be the search term when strategy is "keyword", otherwise null.` + `\n- filters is an array of {field, operator, value, values, from, to}.` + `\n- operators must be one of eq, neq, gt, gte, lt, lte, contains, startsWith, endsWith, in, notIn, isNull, notNull, between.` + `\n- Use between with from/to when the user gives date ranges like "yesterday" or "last week".` + `\n- sort should be {field, direction} when sorting is requested.` + `\n- Only use field apiName values exactly as provided.` + `\n${formatInstructions}`, ), new HumanMessage( `Object: ${objectDefinition.label || objectDefinition.apiName}.\n` + `Fields: ${JSON.stringify(fields)}.\n` + `Today is ${today}.\n` + `User query: ${message}`, ), ]); const content = typeof response.content === 'string' ? response.content : '{}'; const parsed = await parser.parse(content); return this.normalizeSearchPlan(parsed, message); } private normalizeSearchPlan(plan: AiSearchPlan, message: string): AiSearchPlan { if (!plan || typeof plan !== 'object') { return this.buildSearchPlanFallback(message); } const strategy = plan.strategy === 'query' ? 'query' : 'keyword'; const explanation = plan.explanation?.trim() ? plan.explanation.trim() : strategy === 'keyword' ? `Searched records that matches the word: "${message.trim()}"` : `Applied filters based on: "${message.trim()}"`; if (strategy === 'keyword') { return { strategy, keyword: plan.keyword?.trim() || message.trim(), explanation, }; } return { strategy, explanation, keyword: null, filters: Array.isArray(plan.filters) ? plan.filters : [], sort: plan.sort || null, }; } private sanitizeUserOwnerFields( fields: Record, fieldDefinitions: any[], message: string, ): Record { const mentionsAssignment = /\b(user|owner|assign|assigned)\b/i.test(message); const defsByApi = new Map(fieldDefinitions.map((f: any) => [f.apiName, f])); const result: Record = {}; for (const [apiName, value] of Object.entries(fields || {})) { const def = defsByApi.get(apiName); const label = def?.label || apiName; const isUserish = /\b(user|owner)\b/i.test(label); if (isUserish && !mentionsAssignment) { // Skip auto-assigned "User"/"Owner" when the user didn't mention assignment continue; } result[apiName] = value; } return result; } private normalizeExtractedFieldKeys( fields: Record, fieldDefinitions: any[], ): Record { if (!fields) return {}; const apiNames = new Map( (fieldDefinitions || []).map((f: any) => [f.apiName.toLowerCase(), f.apiName]), ); const result: Record = {}; for (const [key, value] of Object.entries(fields)) { const canonical = apiNames.get(key.toLowerCase()); if (canonical) { result[canonical] = value; } } return result; } private applyPolymorphicDefaults(state: AiAssistantState): AiAssistantState { if (!state.objectDefinition || !state.extractedFields) return state; const apiName = String(state.objectDefinition.apiName || '').toLowerCase(); if (!this.isContactDetail(apiName)) { return state; } const updatedFields = { ...(state.extractedFields || {}) }; if (!updatedFields.relatedObjectId && state.context?.recordId) { updatedFields.relatedObjectId = state.context.recordId; } if (!updatedFields.relatedObjectType && state.context?.objectApiName) { const type = this.toPolymorphicType(state.context.objectApiName); if (type) { updatedFields.relatedObjectType = type; } } return { ...state, extractedFields: updatedFields, }; } private toPolymorphicType(objectApiName: string): string | null { const normalized = objectApiName.toLowerCase(); if (normalized === 'account' || normalized === 'accounts') return 'Account'; if (normalized === 'contact' || normalized === 'contacts') return 'Contact'; return null; } private isContactDetail(objectApiName: string | undefined): boolean { if (!objectApiName) return false; const normalized = objectApiName.toLowerCase(); return ['contactdetail', 'contact_detail', 'contactdetails', 'contact_details'].includes( normalized, ); } private async resolvePolymorphicRelatedObject( tenantId: string, state: AiAssistantState, ): Promise { if (!state.objectDefinition || !state.extractedFields) return state; const apiName = String(state.objectDefinition.apiName || '').toLowerCase(); if (!this.isContactDetail(apiName)) { return state; } const provided = state.extractedFields.relatedObjectId; if (!provided || typeof provided !== 'string' || this.isUuid(provided)) { return state; } const preferredType = state.extractedFields.relatedObjectType || this.toPolymorphicType(state.context?.objectApiName || ''); const candidateTypes = preferredType ? [preferredType, ...['Account', 'Contact'].filter((t) => t !== preferredType)] : ['Account', 'Contact']; const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); for (const type of candidateTypes) { const objectApi = type.toLowerCase(); let targetDefinition: any; try { targetDefinition = await this.objectService.getObjectDefinition(tenantId, objectApi); } catch (error) { continue; } const displayField = this.getDisplayFieldForObject(targetDefinition); const tableName = this.toTableName( targetDefinition.apiName, targetDefinition.label, targetDefinition.pluralLabel, ); const meiliMatch = await this.meilisearchService.searchRecord( resolvedTenantId, targetDefinition.apiName, provided, displayField, ); if (meiliMatch?.id) { return { ...state, extractedFields: { ...state.extractedFields, relatedObjectId: meiliMatch.id, relatedObjectType: type, }, }; } const record = await knex(tableName) .whereRaw('LOWER(??) = ?', [displayField, provided.toLowerCase()]) .first(); if (record?.id) { return { ...state, extractedFields: { ...state.extractedFields, relatedObjectId: record.id, relatedObjectType: type, }, }; } } return state; } private getDisplayFieldForObject(objectDefinition: any): string { if (!objectDefinition?.fields) return 'name'; const hasName = objectDefinition.fields.some( (candidate: any) => candidate.apiName === 'name', ); if (hasName) return 'name'; const firstText = objectDefinition.fields.find((candidate: any) => ['STRING', 'TEXT', 'EMAIL'].includes((candidate.type || '').toUpperCase()), ); return firstText?.apiName || 'id'; } private extractValueForField(message: string, field: any): string | null { const label = field.label || field.apiName; const apiName = field.apiName; const patterns = [ new RegExp(`${this.escapeRegex(label)}\\s*:\\s*([^\\n;,]+)`, 'i'), new RegExp(`${this.escapeRegex(apiName)}\\s*:\\s*([^\\n;,]+)`, 'i'), new RegExp(`set\\s+${this.escapeRegex(label)}\\s+to\\s+([^\\n;,]+)`, 'i'), new RegExp(`set\\s+${this.escapeRegex(apiName)}\\s+to\\s+([^\\n;,]+)`, 'i'), ]; for (const pattern of patterns) { const match = message.match(pattern); if (match?.[1]) { return match[1].trim(); } } return null; } private escapeRegex(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } private getFieldLabel(fields: any[], apiName: string): string { const field = fields.find((candidate) => candidate.apiName === apiName); return field?.label || apiName; } private parseLayoutConfig(layoutConfig: any) { if (!layoutConfig) return null; if (typeof layoutConfig === 'string') { try { return JSON.parse(layoutConfig); } catch (error) { this.logger.warn(`Failed to parse layout config: ${error.message}`); return null; } } return layoutConfig; } private isSystemField(apiName: string): boolean { return [ 'id', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt', 'tenantId', ].includes(apiName); } private async getOpenAiConfig(tenantId: string): Promise { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const centralPrisma = getCentralPrisma(); const tenant = await centralPrisma.tenant.findUnique({ where: { id: resolvedTenantId }, select: { integrationsConfig: true }, }); let config = tenant?.integrationsConfig ? typeof tenant.integrationsConfig === 'string' ? this.tenantDbService.decryptIntegrationsConfig(tenant.integrationsConfig) : tenant.integrationsConfig : null; // Fallback to environment if tenant config is missing if (!config?.openai && process.env.OPENAI_API_KEY) { this.logger.log('Using OPENAI_API_KEY fallback for AI assistant.'); config = { ...(config || {}), openai: { apiKey: process.env.OPENAI_API_KEY, model: process.env.OPENAI_MODEL || this.defaultModel, }, }; } if (config?.openai?.apiKey) { return { apiKey: config.openai.apiKey, model: this.defaultModel, }; } return null; } private normalizeChatModel(model?: string): string { if (!model) return this.defaultModel; const lower = model.toLowerCase(); if ( lower.includes('instruct') || lower.startsWith('text-') || lower.startsWith('davinci') || lower.startsWith('curie') || lower.includes('realtime') ) { return this.defaultModel; } return model; } private combineHistory(history: AiAssistantState['history'], message: string): string { if (!history || history.length === 0) return message; const recent = history.slice(-6); const serialized = recent .map((entry) => `[${entry.role}] ${entry.text}`) .join('\n'); return `${serialized}\n[user] ${message}`; } private getConversationKey( tenantId: string, userId: string, objectApiName?: string, ): string { return `${tenantId}:${userId}:${objectApiName || 'global'}`; } private pruneConversations() { const now = Date.now(); for (const [key, value] of this.conversationState.entries()) { if (now - value.updatedAt > this.conversationTtlMs) { this.conversationState.delete(key); } } } private async resolveLookupFields( tenantId: string, objectDefinition: any, extractedFields: Record, ): Promise<{ resolvedFields: Record; unresolvedLookups: Array<{ fieldApiName: string; fieldLabel?: string; targetLabel?: string; providedValue: any; }>; }> { if (!extractedFields) { return { resolvedFields: {}, unresolvedLookups: [] }; } const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const resolvedFields = { ...extractedFields }; const unresolvedLookups: Array<{ fieldApiName: string; fieldLabel?: string; targetLabel?: string; providedValue: any; }> = []; const lookupFields = (objectDefinition.fields || []).filter( (field: any) => field.type === 'LOOKUP' && field.referenceObject, ); for (const field of lookupFields) { const value = extractedFields[field.apiName]; if (value === undefined || value === null) continue; // Already an ID or object with ID if (typeof value === 'object' && value.id) { resolvedFields[field.apiName] = value.id; continue; } if (typeof value === 'string' && this.isUuid(value)) { resolvedFields[field.apiName] = value; continue; } // Resolve by display field (e.g., name) const targetApiName = String(field.referenceObject); let targetDefinition: any = null; try { targetDefinition = await this.objectService.getObjectDefinition(tenantId, targetApiName); } catch (error) { this.logger.warn( `Failed to load reference object ${targetApiName} for field ${field.apiName}: ${error.message}`, ); } const displayField = targetDefinition ? this.getLookupDisplayField(field, targetDefinition) : 'name'; const tableName = targetDefinition ? this.toTableName(targetDefinition.apiName, targetDefinition.label, targetDefinition.pluralLabel) : this.toTableName(targetApiName); const providedValue = typeof value === 'string' ? value.trim() : value; if (providedValue && typeof providedValue === 'string') { console.log('providedValue:', providedValue); const meiliMatch = await this.meilisearchService.searchRecord( resolvedTenantId, targetDefinition?.apiName || targetApiName, providedValue, displayField, ); console.log('MeiliSearch lookup for', meiliMatch); if (meiliMatch?.id) { resolvedFields[field.apiName] = meiliMatch.id; continue; } } const record = providedValue && typeof providedValue === 'string' ? await knex(tableName) .whereRaw('LOWER(??) = ?', [displayField, providedValue.toLowerCase()]) .first() : null; if (record?.id) { resolvedFields[field.apiName] = record.id; } else { unresolvedLookups.push({ fieldApiName: field.apiName, fieldLabel: field.label, targetLabel: targetDefinition?.label || targetApiName, providedValue: value, }); } } return { resolvedFields, unresolvedLookups }; } private getLookupDisplayField(field: any, targetDefinition: any): string { const uiMetadata = this.parseUiMetadata(field.uiMetadata || field.ui_metadata); if (uiMetadata?.relationDisplayField) { return uiMetadata.relationDisplayField; } const hasName = (targetDefinition.fields || []).some( (candidate: any) => candidate.apiName === 'name', ); if (hasName) return 'name'; // Fallback to first string-like field const firstTextField = (targetDefinition.fields || []).find((candidate: any) => ['STRING', 'TEXT', 'EMAIL'].includes((candidate.type || '').toUpperCase()), ); return firstTextField?.apiName || 'id'; } private parseUiMetadata(uiMetadata: any): any { if (!uiMetadata) return null; if (typeof uiMetadata === 'object') return uiMetadata; if (typeof uiMetadata === 'string') { try { return JSON.parse(uiMetadata); } catch (error) { this.logger.warn(`Failed to parse UI metadata: ${error.message}`); return null; } } return null; } private isUuid(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } private toTableName(objectApiName: string, objectLabel?: string, pluralLabel?: string): string { const toSnakePlural = (source: string): string => { const cleaned = source.replace(/[\s-]+/g, '_'); const snake = cleaned .replace(/([a-z0-9])([A-Z])/g, '$1_$2') .replace(/__+/g, '_') .toLowerCase() .replace(/^_/, ''); if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`; if (snake.endsWith('s')) return snake; return `${snake}s`; }; const fromApi = toSnakePlural(objectApiName); const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null; const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null; if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) { return fromLabel; } if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) { return fromPlural; } if (fromLabel && fromLabel !== fromApi) return fromLabel; if (fromPlural && fromPlural !== fromApi) return fromPlural; return fromApi; } }