diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts index cd18082..3ded34d 100644 --- a/backend/src/ai-assistant/ai-assistant.service.ts +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -9,8 +9,18 @@ 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 } from './ai-assistant.types'; +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; @@ -45,6 +55,13 @@ export class AiAssistantService { { 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, @@ -53,6 +70,266 @@ export class AiAssistantService { 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, @@ -119,6 +396,9 @@ export class AiAssistantService { }; } + // Discover available entities for dynamic prompt generation + const systemEntities = await this.discoverEntities(tenantId); + // Build the compiled subagent const compiledSubagent = this.buildResolveOrCreateRecordGraph(tenantId, userId); @@ -129,7 +409,8 @@ export class AiAssistantService { temperature: 0.3, }); - const systemPrompt = this.buildDeepAgentSystemPrompt(context); + // Build dynamic system prompt based on discovered entities + const systemPrompt = this.buildDeepAgentSystemPrompt(systemEntities, context); const agent = createDeepAgent({ model: mainModel, @@ -137,25 +418,19 @@ export class AiAssistantService { tools: [], subagents: [ { - name: 'resolve-or-create-record', + name: 'record-planner', description: [ - 'ALWAYS use this subagent for ANY operation involving CRM records (create, find, or lookup).', + 'USE THIS FOR ALL RECORD OPERATIONS. This is the ONLY way to create, find, or modify CRM records.', '', - 'This subagent handles:', - '- Looking up existing records by name or other fields', - '- Creating new records with the provided field values', - '- Resolving related records (e.g., finding an Account to link a Contact to)', - '- Validating required fields before creating 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)', '', - 'IMPORTANT: When invoking this subagent, include in your message:', - '- The exact user request', - '- The object type (Account, Contact, etc.) if known from context', - '- Any previously collected information', - '', - 'Example invocations:', - '- "Create Account named Acme Corp" (objectApiName: Account)', - '- "Create Contact John Doe" (objectApiName: Contact)', - '- "Add phone number 555-1234 for Contact John" (objectApiName: ContactDetail)', + '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, }, @@ -251,11 +526,31 @@ export class AiAssistantService { } 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: replyText, - action: 'clarify', - missingFields: [], - record: undefined, + 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); @@ -277,83 +572,50 @@ export class AiAssistantService { } } - private buildDeepAgentSystemPrompt(context?: AiAssistantState['context']): string { + 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 through natural conversation.', - 'Your role is to understand user requests and coordinate with specialized subagents to fulfill them.', + 'You are an AI assistant helping users interact with a CRM system.', '', - 'CRITICAL RULE: ALWAYS SEARCH BEFORE CREATE', - '- Before creating ANY record, you MUST first search to see if it already exists', - '- Use the subagent to search: "Find Account Jeannete Staley (objectApiName: Account)"', - '- Only create if the search returns no results', - '- If a record is found, USE its ID - do not create a duplicate', + '*** 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.', '', - 'CRITICAL RULE: REMEMBER PREVIOUS RESULTS', - '- When the subagent returns a record (found or created), save its ID', - '- Use that exact ID in subsequent operations', - '- Example: After finding Account with id="123", use that ID when creating related Contact', - '- DO NOT ask the subagent to find the same record again - you already have it', + '=== AVAILABLE ENTITIES ===', + entitySummary, '', - 'Core Responsibilities:', - '- Parse user requests to understand what records they want to create or find', - '- Identify the record type (Account, Contact, ContactDetail, etc.)', - '- Break down complex multi-step requests into manageable tasks', - '- ALWAYS delegate to the "resolve-or-create-record" subagent for ANY record operation', + '=== 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)', '', - 'Understanding Record Relationships:', - '- When user says "Create X under Y account":', - ' 1. X is NOT an Account - it is a Contact or child record', - ' 2. Y is the parent Account name', - ' 3. You must: (a) First SEARCH for Y account, (b) If not found, CREATE it, (c) Then create X as a Contact under Y', - '- Accounts are top-level organizations/companies', - '- Contacts are people/entities that belong to Accounts', - '- ContactDetails are phone/email/address records for Contacts or Accounts', + '=== ENTITY RELATIONSHIPS ===', + relationshipExamples || ' (No relationships defined)', '', - 'Multi-Step Example:', - '- User: "Create Chipi under Jeannete Staley account"', - ' Step 1: Invoke subagent "Find Account Jeannete Staley (objectApiName: Account)"', - ' Step 2a: If found, note the account ID from response', - ' Step 2b: If not found, invoke subagent "Create Account Jeannete Staley (objectApiName: Account)" and note the ID', - ' Step 3: Invoke subagent "Create Contact Chipi for accountId= (objectApiName: Contact)"', - '', - 'When invoking the subagent:', - '- To search: "Find (objectApiName: )"', - '- To create: "Create (objectApiName: )"', - '- Include any known IDs: "Create Contact Chipi accountId=abc123 (objectApiName: Contact)"', - '', - 'DO NOT try to create records yourself - ALWAYS use the subagent for:', - ' * Finding existing records', - ' * Creating new records', - ' * Resolving related records', - '', - 'Important Patterns:', - '- When a user says "Create X under/for Y", this means:', - ' 1. You need to first find or verify record Y exists', - ' 2. Then create record X with a reference to Y', - ' Example: "Create Max under John Doe Account" means find the Account named "John Doe",', - ' then create a record named "Max" that references that Account.', - '', - '- For polymorphic relationships (records that can reference multiple types):', - ' * ContactDetail records can reference either Account or Contact', - ' * Infer the correct type from context clues in the user\'s message', - '', - 'CRITICAL - Checking Results:', - '- After the subagent responds, CHECK if it actually completed the action', - '- Look for phrases like "I still need" or "missing fields" which indicate incomplete work', - '- If the subagent asks for more information, relay that to the user - DO NOT claim success', - '- Only report success if the subagent explicitly confirms record creation', - '- DO NOT fabricate success messages if the subagent indicates it needs more data', - '', - 'Response Style:', - '- Be conversational and helpful', - '- Confirm what you\'re doing: "I\'ll create Max as a Contact under the John Doe Account"', - '- Ask for clarification when the request is ambiguous', - '- Report success clearly ONLY when confirmed: "Created Contact Max under John Doe Account"', - '- If the subagent needs more info, ask the user for that specific information', + '=== 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'); @@ -461,216 +723,788 @@ export class AiAssistantService { }; } + // ============================================ + // 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, ) { - const AssistantState = Annotation.Root({ + // 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(), }); - // Entry node to transform Deep Agent messages into our state format - const transformInput = async (state: any): Promise => { - console.log('=== SUBAGENT: Transform Input ==='); - console.log('Received state keys:', Object.keys(state)); - console.log('Has messages:', state.messages ? state.messages.length : 'no'); + // Node 1: Transform Deep Agent messages into state + const transformInput = async (state: any): Promise => { + console.log('=== PLAN GRAPH: Transform Input ==='); - // If invoked by Deep Agent, state will have messages array 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 from Deep Agent:', messageText); + console.log('Extracted message:', messageText); - // Try to extract context from message (Deep Agent should include it) + // 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: ([^\]]+))?\]/); - const priorFieldsMatch = messageText.match(/\[Previously collected field values: ({[^\]]+})\]/); - let extractedContext: AiAssistantState['context'] = {}; - let extractedFields: Record | undefined; if (contextMatch) { extractedContext.objectApiName = contextMatch[1]; if (contextMatch[2]) { extractedContext.recordId = contextMatch[2]; } - console.log('Extracted context from annotations:', extractedContext); - } else { - // Fallback: Try to infer object type from the message itself - console.log('No context annotation found, attempting to infer from message...'); - - // Check for explicit objectApiName mentions in parentheses - const explicitMatch = messageText.match(/\(objectApiName:\s*(\w+)\)/); - if (explicitMatch) { - extractedContext.objectApiName = explicitMatch[1]; - console.log('Found explicit objectApiName:', extractedContext.objectApiName); - } else { - // Try to infer from keywords and patterns - const lowerMsg = messageText.toLowerCase(); - - // Check if this is a search/find operation - const isFindOperation = lowerMsg.includes('find') || lowerMsg.includes('search') || lowerMsg.includes('look for'); - - // Pattern: "Create X under/for Y account" - X is NOT an account, it's a child record - const underAccountMatch = messageText.match(/create\s+([\w\s]+?)\s+(?:under|for)\s+([\w\s]+?)\s+account/i); - if (underAccountMatch) { - // The thing being created is likely a Contact or ContactDetail - console.log('Detected "under account" pattern - inferring child record type'); - - // Check if it's a contact detail (phone/email) - if (lowerMsg.includes('phone') || lowerMsg.includes('email') || lowerMsg.includes('address')) { - extractedContext.objectApiName = 'ContactDetail'; - } else { - // Default to Contact for things created under accounts - extractedContext.objectApiName = 'Contact'; - } - console.log('Inferred child object type:', extractedContext.objectApiName); - } else if (lowerMsg.includes('account') && (lowerMsg.includes('create') || lowerMsg.includes('add') || isFindOperation)) { - extractedContext.objectApiName = 'Account'; - } else if (lowerMsg.includes('contact') && !lowerMsg.includes('contact detail')) { - extractedContext.objectApiName = 'Contact'; - } else if (lowerMsg.includes('contact detail') || lowerMsg.includes('contactdetail')) { - extractedContext.objectApiName = 'ContactDetail'; - } else if (lowerMsg.includes('phone') || lowerMsg.includes('email')) { - extractedContext.objectApiName = 'ContactDetail'; - } - - if (extractedContext.objectApiName) { - console.log('Inferred objectApiName from keywords:', extractedContext.objectApiName); - } else { - console.warn('Could not infer objectApiName from message!'); - } - } } - if (priorFieldsMatch) { + // Extract any existing plan from the conversation + const planMatch = messageText.match(/\[Current Plan: (.*?)\]/); + let existingPlan: RecordCreationPlan | undefined; + if (planMatch) { try { - extractedFields = JSON.parse(priorFieldsMatch[1]); - console.log('Extracted prior fields:', extractedFields); + existingPlan = JSON.parse(planMatch[1]); } catch (e) { - console.warn('Failed to parse prior fields'); + console.warn('Failed to parse existing plan from message'); } } - // Check if there's a record ID being passed from previous operation - const recordIdMatch = messageText.match(/(?:accountId|contactId|recordId)=(\S+)/i); - if (recordIdMatch) { - if (!extractedFields) extractedFields = {}; - const idField = recordIdMatch[0].split('=')[0]; - extractedFields[idField] = recordIdMatch[1]; - console.log('Extracted ID from message:', { [idField]: recordIdMatch[1] }); - } - - // Clean the message text from system context annotations - const cleanMessage = messageText - .replace(/\[System Context:[^\]]+\]/g, '') - .replace(/\[Previously collected field values:[^\]]+\]/g, '') - .replace(/\(objectApiName:\s*\w+\)/g, '') - .trim(); - - console.log('Final transformed state:', { - message: cleanMessage, - context: extractedContext, - hasExtractedFields: !!extractedFields, - }); - return { message: cleanMessage, messages: state.messages, history: [], context: extractedContext, - extractedFields, - } as AiAssistantState; + plan: existingPlan, + }; } - // If invoked directly (fallback or testing), use the state as-is - console.log('Using direct state (not from Deep Agent)'); - return state as AiAssistantState; + return state; }; - const workflow = new StateGraph(AssistantState) - .addNode('transformInput', transformInput) - .addNode('loadContext', async (current: AiAssistantState) => { - console.log('=== SUBAGENT: Load Context ==='); - return this.loadContext(tenantId, current); - }) - .addNode('searchExisting', async (current: AiAssistantState) => { - console.log('=== SUBAGENT: Search Existing ==='); - return this.searchExistingRecord(tenantId, userId, current); - }) - .addNode('extractFields', async (current: AiAssistantState) => { - console.log('=== SUBAGENT: Extract Fields ==='); - return this.extractFields(tenantId, current); - }) - .addNode('decideNext', async (current: AiAssistantState) => { - console.log('=== SUBAGENT: Decide Next ==='); - return this.decideNextStep(current); - }) - .addNode('createRecord', async (current: AiAssistantState) => { - console.log('=== SUBAGENT: Create Record ==='); - return this.createRecord(tenantId, userId, current); - }) - .addNode('respondMissing', async (current: AiAssistantState) => { - console.log('=== SUBAGENT: Respond Missing ==='); - return this.respondWithMissingFields(current); - }) - .addNode('formatOutput', async (current: AiAssistantState) => { - console.log('=== SUBAGENT: Format Output ==='); - console.log('Final state before output:', { - action: current.action, - record: current.record, - reply: current.reply, - missingFields: current.missingFields, + // 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(); + + if (analyzedRecords && Array.isArray(analyzedRecords)) { + console.log(`Processing ${analyzedRecords.length} analyzed records...`); + + for (const analyzed of analyzedRecords) { + 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 + const 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 + const searchName = analyzed.fields?.name || analyzed.fields?.firstName; + const existingRecord = searchName ? await this.searchForExistingRecord( + tenantId, + userId, + entityInfo.apiName, + searchName + ) : null; + + if (existingRecord) { + console.log(`Found existing record in database: ${existingRecord.id}`); + // Record exists, add to plan as already created + const plannedRecord: PlannedRecord = { + id: `existing_${entityInfo.apiName.toLowerCase()}_${existingRecord.id}`, + entityApiName: entityInfo.apiName, + entityLabel: entityInfo.label, + fields: existingRecord, + missingRequiredFields: [], + dependsOn: [], + status: 'created', + createdRecordId: existingRecord.id, + }; + currentPlan.records.push(plannedRecord); + currentPlan.createdRecords.push(existingRecord); + } else { + console.log(`Adding new record to plan: ${entityInfo.apiName} with fields:`, analyzed.fields); + // Add new record to plan + this.addRecordToPlan( + currentPlan, + entityInfo, + analyzed.fields || {}, + analyzed.dependsOn || [] + ); + } + } + } + } + + // 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; + idMapping.set(plannedRecord.id, createdRecord.id); + + // Add to nameToRecord map for future lookups + const recordName = resolvedFields.name || resolvedFields.firstName || + `${resolvedFields.firstName || ''} ${resolvedFields.lastName || ''}`.trim(); + if (recordName) { + nameToRecord.set(recordName.toLowerCase(), { + id: createdRecord.id, + entityType: plannedRecord.entityApiName, + }); + } + + plan.createdRecords.push(createdRecord); + console.log(`Created: ${plannedRecord.entityLabel} ID=${createdRecord.id}, name="${recordName}"`); + } else { + throw new Error(`Failed to create ${plannedRecord.entityLabel}`); + } + } }); - // Format the output for Deep Agent to understand - const outputMessage = new AIMessage({ - content: current.reply || 'Completed.', - additional_kwargs: { - action: current.action, - record: current.record, - missingFields: current.missingFields, - extractedFields: current.extractedFields, - foundExisting: current.record?.wasFound || false, - }, - }); + plan.status = 'completed'; + + // Generate success message with meaningful names + const createdSummary = plan.records + .filter(r => r.status === 'created') + .map(r => { + // Get name from the fields we used to create the record + const name = r.fields.name || + (r.fields.firstName && r.fields.lastName + ? `${r.fields.firstName} ${r.fields.lastName}` + : r.fields.firstName) || + r.fields.value || + r.createdRecordId; + return `${r.entityLabel} "${name}"`; + }) + .join(', '); return { - ...current, - messages: [...(current.messages || []), outputMessage], - } as AiAssistantState; - }) + ...state, + plan, + action: 'create_record', + records: plan.createdRecords, + record: plan.createdRecords[plan.createdRecords.length - 1], // Last created for compatibility + reply: `Successfully created: ${createdSummary}`, + }; + + } 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', 'loadContext') - .addEdge('loadContext', 'searchExisting') - .addConditionalEdges('searchExisting', (current: AiAssistantState) => { - // If record was found, skip to formatOutput - return current.record ? 'formatOutput' : 'extractFields'; + .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('extractFields', 'decideNext') - .addConditionalEdges('decideNext', (current: AiAssistantState) => { - return current.action === 'create_record' ? 'createRecord' : 'respondMissing'; + .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('createRecord', 'formatOutput') - .addEdge('respondMissing', '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 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; + } + } + + // ============================================ + // Legacy Methods (kept for compatibility) + // ============================================ + private async loadContext( tenantId: string, state: AiAssistantState, diff --git a/backend/src/ai-assistant/ai-assistant.types.ts b/backend/src/ai-assistant/ai-assistant.types.ts index eb4634c..982a130 100644 --- a/backend/src/ai-assistant/ai-assistant.types.ts +++ b/backend/src/ai-assistant/ai-assistant.types.ts @@ -12,16 +12,92 @@ export interface AiChatContext { export interface AiAssistantReply { reply: string; - action?: 'create_record' | 'collect_fields' | 'clarify'; + action?: 'create_record' | 'collect_fields' | 'clarify' | 'plan_complete' | 'plan_pending'; missingFields?: string[]; record?: any; + records?: any[]; // Multiple records when plan execution completes + plan?: RecordCreationPlan; } +// ============================================ +// Entity Discovery Types +// ============================================ + +export interface EntityFieldInfo { + apiName: string; + label: string; + type: string; + isRequired: boolean; + isSystem: boolean; + referenceObject?: string; // For LOOKUP fields, the target entity + description?: string; +} + +export interface EntityRelationship { + fieldApiName: string; + fieldLabel: string; + targetEntity: string; + relationshipType: 'lookup' | 'master-detail' | 'polymorphic'; +} + +export interface EntityInfo { + apiName: string; + label: string; + pluralLabel?: string; + description?: string; + fields: EntityFieldInfo[]; + requiredFields: string[]; // Field apiNames that are required + relationships: EntityRelationship[]; +} + +export interface SystemEntities { + entities: EntityInfo[]; + entityByApiName: Record; // Changed from Map for state serialization + loadedAt: number; +} + +// ============================================ +// Planning Types +// ============================================ + +export interface PlannedRecord { + id: string; // Temporary ID for planning (e.g., "temp_account_1") + entityApiName: string; + entityLabel: string; + fields: Record; + missingRequiredFields: string[]; + dependsOn: string[]; // IDs of other planned records this depends on + status: 'pending' | 'ready' | 'created' | 'failed'; + createdRecordId?: string; // Actual ID after creation + error?: string; +} + +export interface RecordCreationPlan { + id: string; + records: PlannedRecord[]; + executionOrder: string[]; // Ordered list of planned record IDs + status: 'building' | 'incomplete' | 'ready' | 'executing' | 'completed' | 'failed'; + createdRecords: any[]; + errors: string[]; +} + +// ============================================ +// State Types +// ============================================ + export interface AiAssistantState { message: string; messages?: any[]; // BaseMessage[] from langchain - used when invoked by Deep Agent history?: AiChatMessage[]; context: AiChatContext; + + // Entity discovery + systemEntities?: SystemEntities; + + // Planning + plan?: RecordCreationPlan; + + // Legacy fields (kept for compatibility during transition) objectDefinition?: any; pageLayout?: any; extractedFields?: Record;