WIP - use AI assistant to create records in the system

This commit is contained in:
Francisco Gaona
2026-01-18 09:15:06 +01:00
parent 4f466d7992
commit fe51355d29
2 changed files with 220 additions and 36 deletions

View File

@@ -890,10 +890,17 @@ export class AiAssistantService {
// If we already have a plan, update it; otherwise create new // If we already have a plan, update it; otherwise create new
let currentPlan = plan || this.createPlan(); 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<number, string>();
// Also track name to record mapping for resolving lookup fields
const nameToExistingRecord = new Map<string, { id: string; recordId: string; entityType: string }>();
if (analyzedRecords && Array.isArray(analyzedRecords)) { if (analyzedRecords && Array.isArray(analyzedRecords)) {
console.log(`Processing ${analyzedRecords.length} analyzed records...`); 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}"`); console.log(`Looking up entity: "${analyzed.entityName}"`);
const entityInfo = this.findEntityByName(systemEntities, analyzed.entityName); const entityInfo = this.findEntityByName(systemEntities, analyzed.entityName);
if (!entityInfo) { if (!entityInfo) {
@@ -904,10 +911,19 @@ export class AiAssistantService {
console.log(`Found entity: ${entityInfo.apiName} (${entityInfo.label})`); console.log(`Found entity: ${entityInfo.apiName} (${entityInfo.label})`);
// Check if this record already exists in the plan // Check if this record already exists in the plan
const existingInPlan = currentPlan.records.find( // For ContactDetail, match by value; for others, match by name
r => r.entityApiName === entityInfo.apiName && let existingInPlan: PlannedRecord | undefined;
r.fields.name === analyzed.fields?.name 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) { if (existingInPlan) {
console.log(`Record already in plan, updating fields`); console.log(`Record already in plan, updating fields`);
@@ -915,38 +931,115 @@ export class AiAssistantService {
this.updatePlannedRecordFields(currentPlan, existingInPlan.id, analyzed.fields || {}, systemEntities); this.updatePlannedRecordFields(currentPlan, existingInPlan.id, analyzed.fields || {}, systemEntities);
} else { } else {
// Check if record already exists in database // Check if record already exists in database
const searchName = analyzed.fields?.name || analyzed.fields?.firstName; let existingRecord: any = null;
const existingRecord = searchName ? await this.searchForExistingRecord(
tenantId, // For ContactDetail, search by value instead of name
userId, if (entityInfo.apiName === 'ContactDetail') {
entityInfo.apiName, const searchValue = analyzed.fields?.value;
searchName if (searchValue) {
) : null; 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) { 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 // Record exists, add to plan as already created
const recordPlanId = `existing_${entityInfo.apiName.toLowerCase()}_${existingRecord.id}`;
const plannedRecord: PlannedRecord = { const plannedRecord: PlannedRecord = {
id: `existing_${entityInfo.apiName.toLowerCase()}_${existingRecord.id}`, id: recordPlanId,
entityApiName: entityInfo.apiName, entityApiName: entityInfo.apiName,
entityLabel: entityInfo.label, entityLabel: entityInfo.label,
fields: existingRecord, fields: existingRecord, // Use full existing record for accurate display
resolvedFields: existingRecord, // Also set resolvedFields
missingRequiredFields: [], missingRequiredFields: [],
dependsOn: [], dependsOn: [],
status: 'created', status: 'created',
createdRecordId: existingRecord.id, createdRecordId: existingRecord.id,
wasExisting: true, // Mark as pre-existing
}; };
currentPlan.records.push(plannedRecord); 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 { } else {
console.log(`Adding new record to plan: ${entityInfo.apiName} with fields:`, analyzed.fields); 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 // Add new record to plan
this.addRecordToPlan( const newRecord = this.addRecordToPlan(
currentPlan, currentPlan,
entityInfo, entityInfo,
analyzed.fields || {}, fieldsWithLookups,
analyzed.dependsOn || [] 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) { if (createdRecord?.id) {
plannedRecord.status = 'created'; plannedRecord.status = 'created';
plannedRecord.createdRecordId = createdRecord.id; plannedRecord.createdRecordId = createdRecord.id;
// Store the resolved fields for accurate success message
plannedRecord.resolvedFields = resolvedFields;
idMapping.set(plannedRecord.id, createdRecord.id); idMapping.set(plannedRecord.id, createdRecord.id);
// Add to nameToRecord map for future lookups // Add to nameToRecord map for future lookups
const recordName = resolvedFields.name || resolvedFields.firstName || const recordName = resolvedFields.name ||
`${resolvedFields.firstName || ''} ${resolvedFields.lastName || ''}`.trim(); (resolvedFields.firstName ? `${resolvedFields.firstName} ${resolvedFields.lastName || ''}`.trim() : '') ||
resolvedFields.value || '';
if (recordName) { if (recordName) {
nameToRecord.set(recordName.toLowerCase(), { nameToRecord.set(recordName.toLowerCase(), {
id: createdRecord.id, 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}"`); console.log(`Created: ${plannedRecord.entityLabel} ID=${createdRecord.id}, name="${recordName}"`);
} else { } else {
throw new Error(`Failed to create ${plannedRecord.entityLabel}`); throw new Error(`Failed to create ${plannedRecord.entityLabel}`);
@@ -1178,27 +1274,45 @@ export class AiAssistantService {
plan.status = 'completed'; plan.status = 'completed';
// Generate success message with meaningful names // Generate success message with meaningful names
const createdSummary = plan.records const newlyCreated = plan.records.filter(r => r.status === 'created' && !r.wasExisting);
.filter(r => r.status === 'created') const existingUsed = plan.records.filter(r => r.wasExisting);
.map(r => {
// Get name from the fields we used to create the record const getRecordDisplayName = (r: any) => {
const name = r.fields.name || // Use resolvedFields if available (for newly created), otherwise original fields
(r.fields.firstName && r.fields.lastName const fields = r.resolvedFields || r.fields;
? `${r.fields.firstName} ${r.fields.lastName}` return fields.name ||
: r.fields.firstName) || (fields.firstName ? `${fields.firstName} ${fields.lastName || ''}`.trim() : '') ||
r.fields.value || fields.value ||
r.createdRecordId; 'record';
return `${r.entityLabel} "${name}"`; };
})
const createdSummary = newlyCreated
.map(r => `${r.entityLabel} "${getRecordDisplayName(r)}"`)
.join(', '); .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 { return {
...state, ...state,
plan, plan,
action: 'create_record', action: 'create_record',
records: plan.createdRecords, records: plan.createdRecords,
record: plan.createdRecords[plan.createdRecords.length - 1], // Last created for compatibility record: plan.createdRecords[plan.createdRecords.length - 1], // Last created for compatibility
reply: `Successfully created: ${createdSummary}`, reply: replyMessage,
}; };
} catch (error) { } catch (error) {
@@ -1477,7 +1591,8 @@ export class AiAssistantService {
if (meiliMatch?.id) { if (meiliMatch?.id) {
console.log(`Found existing ${entityApiName} via Meilisearch: ${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<any | null> {
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) // Legacy Methods (kept for compatibility)
// ============================================ // ============================================

View File

@@ -65,10 +65,12 @@ export interface PlannedRecord {
entityApiName: string; entityApiName: string;
entityLabel: string; entityLabel: string;
fields: Record<string, any>; fields: Record<string, any>;
resolvedFields?: Record<string, any>; // Fields after dependency resolution
missingRequiredFields: string[]; missingRequiredFields: string[];
dependsOn: string[]; // IDs of other planned records this depends on dependsOn: string[]; // IDs of other planned records this depends on
status: 'pending' | 'ready' | 'created' | 'failed'; status: 'pending' | 'ready' | 'created' | 'failed';
createdRecordId?: string; // Actual ID after creation createdRecordId?: string; // Actual ID after creation
wasExisting?: boolean; // True if record already existed in database
error?: string; error?: string;
} }