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
|
// 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)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user