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
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)) {
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<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)
// ============================================

View File

@@ -65,10 +65,12 @@ export interface PlannedRecord {
entityApiName: string;
entityLabel: string;
fields: Record<string, any>;
resolvedFields?: Record<string, any>; // 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;
}