From c822017ef16dcf8fe1ba4526050610dfc9f08888 Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Sun, 18 Jan 2026 05:24:41 +0100 Subject: [PATCH] WIP - some success creating related records --- .../src/ai-assistant/ai-assistant.service.ts | 149 +++++++++++++++++- 1 file changed, 141 insertions(+), 8 deletions(-) diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts index e5410d8..cd18082 100644 --- a/backend/src/ai-assistant/ai-assistant.service.ts +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -234,6 +234,13 @@ export class AiAssistantService { // 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', @@ -279,6 +286,18 @@ export class AiAssistantService { '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.', '', + '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 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', + '', 'Core Responsibilities:', '- Parse user requests to understand what records they want to create or find', '- Identify the record type (Account, Contact, ContactDetail, etc.)', @@ -289,20 +308,22 @@ export class AiAssistantService { '- 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 ensure Y account exists, (b) Then create X as a Contact under Y', + ' 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', '', 'Multi-Step Example:', '- User: "Create Chipi under Jeannete Staley account"', - ' Step 1: Invoke subagent to find/create Account "Jeannete Staley" (objectApiName: Account)', - ' Step 2: Once account exists, invoke subagent to create Contact "Chipi" linked to that Account (objectApiName: Contact)', + ' 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:', - '- Include the record type in parentheses: "Find Account Jeannete Staley (objectApiName: Account)"', - '- For child records: "Create Contact Chipi for Account (objectApiName: Contact)"', - '- Pass the user\'s full request with context', + '- 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', @@ -500,6 +521,9 @@ export class AiAssistantService { // 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) { @@ -514,7 +538,7 @@ export class AiAssistantService { extractedContext.objectApiName = 'Contact'; } console.log('Inferred child object type:', extractedContext.objectApiName); - } else if (lowerMsg.includes('account') && (lowerMsg.includes('create') || lowerMsg.includes('add'))) { + } 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'; @@ -541,6 +565,15 @@ export class AiAssistantService { } } + // 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, '') @@ -574,6 +607,10 @@ export class AiAssistantService { 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); @@ -607,6 +644,7 @@ export class AiAssistantService { record: current.record, missingFields: current.missingFields, extractedFields: current.extractedFields, + foundExisting: current.record?.wasFound || false, }, }); @@ -617,7 +655,11 @@ export class AiAssistantService { }) .addEdge(START, 'transformInput') .addEdge('transformInput', 'loadContext') - .addEdge('loadContext', 'extractFields') + .addEdge('loadContext', 'searchExisting') + .addConditionalEdges('searchExisting', (current: AiAssistantState) => { + // If record was found, skip to formatOutput + return current.record ? 'formatOutput' : 'extractFields'; + }) .addEdge('extractFields', 'decideNext') .addConditionalEdges('decideNext', (current: AiAssistantState) => { return current.action === 'create_record' ? 'createRecord' : 'respondMissing'; @@ -669,6 +711,97 @@ export class AiAssistantService { }; } + 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,