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 (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=<the ID from step 2> (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 <id> (objectApiName: Contact)"',
'- Pass the user\'s full request with context',
'- To search: "Find <Type> <Name> (objectApiName: <Type>)"',
'- To create: "Create <Type> <Name> (objectApiName: <Type>)"',
'- 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<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(
tenantId: string,
state: AiAssistantState,