WIP - use AI assistant to create records in the system
This commit is contained in:
@@ -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)
|
||||
// ============================================
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user