From fe51355d29117cf8fe47e048b12b9be6b439851a Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Sun, 18 Jan 2026 09:15:06 +0100 Subject: [PATCH] WIP - use AI assistant to create records in the system --- .../src/ai-assistant/ai-assistant.service.ts | 254 +++++++++++++++--- .../src/ai-assistant/ai-assistant.types.ts | 2 + 2 files changed, 220 insertions(+), 36 deletions(-) diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts index 3ded34d..4dcf9d1 100644 --- a/backend/src/ai-assistant/ai-assistant.service.ts +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -890,10 +890,17 @@ export class AiAssistantService { // 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 (const analyzed of analyzedRecords) { + 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) { @@ -904,10 +911,19 @@ export class AiAssistantService { 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 - ); + // 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`); @@ -915,38 +931,115 @@ export class AiAssistantService { 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; + 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 record in database: ${existingRecord.id}`); + 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: `existing_${entityInfo.apiName.toLowerCase()}_${existingRecord.id}`, + id: recordPlanId, entityApiName: entityInfo.apiName, entityLabel: entityInfo.label, - fields: existingRecord, + 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); - currentPlan.createdRecords.push(existingRecord); + + // 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 - this.addRecordToPlan( + const newRecord = this.addRecordToPlan( currentPlan, entityInfo, - analyzed.fields || {}, - analyzed.dependsOn || [] + fieldsWithLookups, + resolvedDependsOn ); + + // Track the mapping + indexToRecordId.set(idx, newRecord.id); + console.log(`Mapped index ${idx} to new record ${newRecord.id}`); } } } @@ -1155,11 +1248,14 @@ export class AiAssistantService { 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(); + const recordName = resolvedFields.name || + (resolvedFields.firstName ? `${resolvedFields.firstName} ${resolvedFields.lastName || ''}`.trim() : '') || + resolvedFields.value || ''; if (recordName) { nameToRecord.set(recordName.toLowerCase(), { id: createdRecord.id, @@ -1167,7 +1263,7 @@ export class AiAssistantService { }); } - plan.createdRecords.push(createdRecord); + 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}`); @@ -1178,27 +1274,45 @@ export class AiAssistantService { 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}"`; - }) + 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: `Successfully created: ${createdSummary}`, + reply: replyMessage, }; } catch (error) { @@ -1477,7 +1591,8 @@ export class AiAssistantService { if (meiliMatch?.id) { console.log(`Found existing ${entityApiName} via Meilisearch: ${meiliMatch.id}`); - return meiliMatch; + // Return the full hit data, not just { id, hit } + return meiliMatch.hit || meiliMatch; } } @@ -1501,6 +1616,73 @@ export class AiAssistantService { } } + /** + * 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) // ============================================ diff --git a/backend/src/ai-assistant/ai-assistant.types.ts b/backend/src/ai-assistant/ai-assistant.types.ts index 982a130..4923c24 100644 --- a/backend/src/ai-assistant/ai-assistant.types.ts +++ b/backend/src/ai-assistant/ai-assistant.types.ts @@ -65,10 +65,12 @@ export interface PlannedRecord { entityApiName: string; entityLabel: string; fields: Record; + resolvedFields?: Record; // Fields after dependency resolution missingRequiredFields: string[]; dependsOn: string[]; // IDs of other planned records this depends on status: 'pending' | 'ready' | 'created' | 'failed'; createdRecordId?: string; // Actual ID after creation + wasExisting?: boolean; // True if record already existed in database error?: string; }