WIP - some success creating related records

This commit is contained in:
Francisco Gaona
2026-01-18 05:24:41 +01:00
parent 8b192ba7f5
commit c822017ef1

View File

@@ -234,6 +234,13 @@ export class AiAssistantService {
// If we found subagent results, use them; otherwise use defaults // If we found subagent results, use them; otherwise use defaults
if (subagentResult) { if (subagentResult) {
console.log('=== DEEP AGENT: Using subagent result ==='); 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 { return {
reply: replyText, reply: replyText,
action: subagentResult.action || 'clarify', 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.', '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.', '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:', 'Core Responsibilities:',
'- Parse user requests to understand what records they want to create or find', '- Parse user requests to understand what records they want to create or find',
'- Identify the record type (Account, Contact, ContactDetail, etc.)', '- Identify the record type (Account, Contact, ContactDetail, etc.)',
@@ -289,20 +308,22 @@ export class AiAssistantService {
'- When user says "Create X under Y account":', '- When user says "Create X under Y account":',
' 1. X is NOT an Account - it is a Contact or child record', ' 1. X is NOT an Account - it is a Contact or child record',
' 2. Y is the parent Account name', ' 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', '- Accounts are top-level organizations/companies',
'- Contacts are people/entities that belong to Accounts', '- Contacts are people/entities that belong to Accounts',
'- ContactDetails are phone/email/address records for Contacts or Accounts', '- ContactDetails are phone/email/address records for Contacts or Accounts',
'', '',
'Multi-Step Example:', 'Multi-Step Example:',
'- User: "Create Chipi under Jeannete Staley account"', '- User: "Create Chipi under Jeannete Staley account"',
' Step 1: Invoke subagent to find/create Account "Jeannete Staley" (objectApiName: Account)', ' Step 1: Invoke subagent "Find Account Jeannete Staley (objectApiName: Account)"',
' Step 2: Once account exists, invoke subagent to create Contact "Chipi" linked to that Account (objectApiName: Contact)', ' 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=<the ID from step 2> (objectApiName: Contact)"',
'', '',
'When invoking the subagent:', 'When invoking the subagent:',
'- Include the record type in parentheses: "Find Account Jeannete Staley (objectApiName: Account)"', '- To search: "Find <Type> <Name> (objectApiName: <Type>)"',
'- For child records: "Create Contact Chipi for Account <id> (objectApiName: Contact)"', '- To create: "Create <Type> <Name> (objectApiName: <Type>)"',
'- Pass the user\'s full request with context', '- Include any known IDs: "Create Contact Chipi accountId=abc123 (objectApiName: Contact)"',
'', '',
'DO NOT try to create records yourself - ALWAYS use the subagent for:', 'DO NOT try to create records yourself - ALWAYS use the subagent for:',
' * Finding existing records', ' * Finding existing records',
@@ -500,6 +521,9 @@ export class AiAssistantService {
// Try to infer from keywords and patterns // Try to infer from keywords and patterns
const lowerMsg = messageText.toLowerCase(); 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 // 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); const underAccountMatch = messageText.match(/create\s+([\w\s]+?)\s+(?:under|for)\s+([\w\s]+?)\s+account/i);
if (underAccountMatch) { if (underAccountMatch) {
@@ -514,7 +538,7 @@ export class AiAssistantService {
extractedContext.objectApiName = 'Contact'; extractedContext.objectApiName = 'Contact';
} }
console.log('Inferred child object type:', extractedContext.objectApiName); 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'; extractedContext.objectApiName = 'Account';
} else if (lowerMsg.includes('contact') && !lowerMsg.includes('contact detail')) { } else if (lowerMsg.includes('contact') && !lowerMsg.includes('contact detail')) {
extractedContext.objectApiName = 'Contact'; 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 // Clean the message text from system context annotations
const cleanMessage = messageText const cleanMessage = messageText
.replace(/\[System Context:[^\]]+\]/g, '') .replace(/\[System Context:[^\]]+\]/g, '')
@@ -574,6 +607,10 @@ export class AiAssistantService {
console.log('=== SUBAGENT: Load Context ==='); console.log('=== SUBAGENT: Load Context ===');
return this.loadContext(tenantId, current); 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) => { .addNode('extractFields', async (current: AiAssistantState) => {
console.log('=== SUBAGENT: Extract Fields ==='); console.log('=== SUBAGENT: Extract Fields ===');
return this.extractFields(tenantId, current); return this.extractFields(tenantId, current);
@@ -607,6 +644,7 @@ export class AiAssistantService {
record: current.record, record: current.record,
missingFields: current.missingFields, missingFields: current.missingFields,
extractedFields: current.extractedFields, extractedFields: current.extractedFields,
foundExisting: current.record?.wasFound || false,
}, },
}); });
@@ -617,7 +655,11 @@ export class AiAssistantService {
}) })
.addEdge(START, 'transformInput') .addEdge(START, 'transformInput')
.addEdge('transformInput', 'loadContext') .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') .addEdge('extractFields', 'decideNext')
.addConditionalEdges('decideNext', (current: AiAssistantState) => { .addConditionalEdges('decideNext', (current: AiAssistantState) => {
return current.action === 'create_record' ? 'createRecord' : 'respondMissing'; return current.action === 'create_record' ? 'createRecord' : 'respondMissing';
@@ -669,6 +711,97 @@ export class AiAssistantService {
}; };
} }
private async searchExistingRecord(
tenantId: string,
userId: string,
state: AiAssistantState,
): Promise<AiAssistantState> {
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( private async extractFields(
tenantId: string, tenantId: string,
state: AiAssistantState, state: AiAssistantState,