2795 lines
96 KiB
TypeScript
2795 lines
96 KiB
TypeScript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
|
import { JsonOutputParser } from '@langchain/core/output_parsers';
|
|
import { AIMessage, BaseMessage, HumanMessage, SystemMessage } from '@langchain/core/messages';
|
|
import { ChatOpenAI } from '@langchain/openai';
|
|
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
|
|
import { createDeepAgent } from 'deepagents';
|
|
import { ObjectService } from '../object/object.service';
|
|
import { PageLayoutService } from '../page-layout/page-layout.service';
|
|
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
|
import { getCentralPrisma } from '../prisma/central-prisma.service';
|
|
import { OpenAIConfig } from '../voice/interfaces/integration-config.interface';
|
|
import {
|
|
AiAssistantReply,
|
|
AiAssistantState,
|
|
EntityInfo,
|
|
EntityFieldInfo,
|
|
EntityRelationship,
|
|
SystemEntities,
|
|
RecordCreationPlan,
|
|
PlannedRecord,
|
|
} from './ai-assistant.types';
|
|
import { MeilisearchService } from '../search/meilisearch.service';
|
|
import { randomUUID } from 'crypto';
|
|
|
|
type AiSearchFilter = {
|
|
field: string;
|
|
operator: string;
|
|
value?: any;
|
|
values?: any[];
|
|
from?: string;
|
|
to?: string;
|
|
};
|
|
|
|
type AiSearchPlan = {
|
|
strategy: 'keyword' | 'query';
|
|
explanation: string;
|
|
keyword?: string | null;
|
|
filters?: AiSearchFilter[];
|
|
sort?: { field: string; direction: 'asc' | 'desc' } | null;
|
|
};
|
|
|
|
type AiSearchPayload = {
|
|
objectApiName: string;
|
|
query: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
};
|
|
|
|
@Injectable()
|
|
export class AiAssistantService {
|
|
private readonly logger = new Logger(AiAssistantService.name);
|
|
private readonly defaultModel = process.env.OPENAI_MODEL || 'gpt-4o';
|
|
private readonly conversationState = new Map<
|
|
string,
|
|
{ fields: Record<string, any>; updatedAt: number }
|
|
>();
|
|
private readonly conversationTtlMs = 30 * 60 * 1000; // 30 minutes
|
|
|
|
// Entity discovery cache per tenant (refreshes every 5 minutes)
|
|
private readonly entityCache = new Map<string, SystemEntities>();
|
|
private readonly entityCacheTtlMs = 5 * 60 * 1000; // 5 minutes
|
|
|
|
// Plan cache per conversation
|
|
private readonly planCache = new Map<string, RecordCreationPlan>();
|
|
|
|
constructor(
|
|
private readonly objectService: ObjectService,
|
|
private readonly pageLayoutService: PageLayoutService,
|
|
private readonly tenantDbService: TenantDatabaseService,
|
|
private readonly meilisearchService: MeilisearchService,
|
|
) {}
|
|
|
|
// ============================================
|
|
// Entity Discovery Methods
|
|
// ============================================
|
|
|
|
/**
|
|
* Discovers all available entities in the system for a tenant.
|
|
* Results are cached for performance.
|
|
*/
|
|
async discoverEntities(tenantId: string): Promise<SystemEntities> {
|
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
|
|
// Check cache first
|
|
const cached = this.entityCache.get(resolvedTenantId);
|
|
if (cached && Date.now() - cached.loadedAt < this.entityCacheTtlMs) {
|
|
console.log('=== Using cached entity discovery ===');
|
|
return cached;
|
|
}
|
|
|
|
console.log('=== Discovering system entities ===');
|
|
|
|
const objectDefinitions = await this.objectService.getObjectDefinitions(resolvedTenantId);
|
|
const entities: EntityInfo[] = [];
|
|
const entityByApiName: Record<string, EntityInfo> = {}; // Use plain object instead of Map
|
|
|
|
for (const objDef of objectDefinitions) {
|
|
try {
|
|
// Get full object definition with fields
|
|
const fullDef = await this.objectService.getObjectDefinition(resolvedTenantId, objDef.apiName);
|
|
|
|
const fields: EntityFieldInfo[] = (fullDef.fields || []).map((f: any) => ({
|
|
apiName: f.apiName,
|
|
label: f.label || f.apiName,
|
|
type: f.type,
|
|
isRequired: f.isRequired || false,
|
|
isSystem: this.isSystemField(f.apiName),
|
|
referenceObject: f.referenceObject || undefined,
|
|
description: f.description,
|
|
}));
|
|
|
|
const relationships: EntityRelationship[] = fields
|
|
.filter(f => f.referenceObject && !f.isSystem)
|
|
.map(f => ({
|
|
fieldApiName: f.apiName,
|
|
fieldLabel: f.label,
|
|
targetEntity: f.referenceObject!,
|
|
relationshipType: f.type === 'LOOKUP' ? 'lookup' as const : 'master-detail' as const,
|
|
}));
|
|
|
|
const requiredFields = fields
|
|
.filter(f => f.isRequired && !f.isSystem)
|
|
.map(f => f.apiName);
|
|
|
|
const entityInfo: EntityInfo = {
|
|
apiName: fullDef.apiName,
|
|
label: fullDef.label || fullDef.apiName,
|
|
pluralLabel: fullDef.pluralLabel,
|
|
description: fullDef.description,
|
|
fields,
|
|
requiredFields,
|
|
relationships,
|
|
};
|
|
|
|
entities.push(entityInfo);
|
|
entityByApiName[fullDef.apiName.toLowerCase()] = entityInfo;
|
|
// Also map by label for easier lookup
|
|
entityByApiName[(fullDef.label || fullDef.apiName).toLowerCase()] = entityInfo;
|
|
} catch (error) {
|
|
this.logger.warn(`Failed to load entity ${objDef.apiName}: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
const systemEntities: SystemEntities = {
|
|
entities,
|
|
entityByApiName,
|
|
loadedAt: Date.now(),
|
|
};
|
|
|
|
this.entityCache.set(resolvedTenantId, systemEntities);
|
|
console.log(`Discovered ${entities.length} entities`);
|
|
|
|
return systemEntities;
|
|
}
|
|
|
|
/**
|
|
* Finds an entity by name (apiName or label, case-insensitive)
|
|
*/
|
|
findEntityByName(systemEntities: SystemEntities, name: string): EntityInfo | undefined {
|
|
if (!systemEntities?.entityByApiName) {
|
|
console.warn('findEntityByName: systemEntities or entityByApiName is undefined');
|
|
return undefined;
|
|
}
|
|
const result = systemEntities.entityByApiName[name.toLowerCase()];
|
|
if (!result) {
|
|
console.warn(`findEntityByName: Entity "${name}" not found. Available: ${Object.keys(systemEntities.entityByApiName).join(', ')}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Generates a summary of available entities for the AI prompt
|
|
*/
|
|
generateEntitySummaryForPrompt(systemEntities: SystemEntities): string {
|
|
const lines: string[] = ['Available Entities in the System:'];
|
|
|
|
for (const entity of systemEntities.entities) {
|
|
const requiredStr = entity.requiredFields.length > 0
|
|
? `Required fields: ${entity.requiredFields.join(', ')}`
|
|
: 'No required fields';
|
|
|
|
const relStr = entity.relationships.length > 0
|
|
? `Relationships: ${entity.relationships.map(r => `${r.fieldLabel} → ${r.targetEntity}`).join(', ')}`
|
|
: '';
|
|
|
|
lines.push(`- ${entity.label} (${entity.apiName}): ${requiredStr}${relStr ? '. ' + relStr : ''}`);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ============================================
|
|
// Planning Methods
|
|
// ============================================
|
|
|
|
/**
|
|
* Creates a new record creation plan
|
|
*/
|
|
createPlan(): RecordCreationPlan {
|
|
return {
|
|
id: randomUUID(),
|
|
records: [],
|
|
executionOrder: [],
|
|
status: 'building',
|
|
createdRecords: [],
|
|
errors: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Adds a record to the plan
|
|
*/
|
|
addRecordToPlan(
|
|
plan: RecordCreationPlan,
|
|
entityInfo: EntityInfo,
|
|
fields: Record<string, any>,
|
|
dependsOn: string[] = [],
|
|
): PlannedRecord {
|
|
const tempId = `temp_${entityInfo.apiName.toLowerCase()}_${plan.records.length + 1}`;
|
|
|
|
// Determine which required fields are missing
|
|
const missingRequiredFields = entityInfo.requiredFields.filter(
|
|
fieldApiName => !fields[fieldApiName] && fields[fieldApiName] !== 0 && fields[fieldApiName] !== false
|
|
);
|
|
|
|
const plannedRecord: PlannedRecord = {
|
|
id: tempId,
|
|
entityApiName: entityInfo.apiName,
|
|
entityLabel: entityInfo.label,
|
|
fields,
|
|
missingRequiredFields,
|
|
dependsOn,
|
|
status: missingRequiredFields.length === 0 ? 'ready' : 'pending',
|
|
};
|
|
|
|
plan.records.push(plannedRecord);
|
|
return plannedRecord;
|
|
}
|
|
|
|
/**
|
|
* Updates a planned record's fields
|
|
*/
|
|
updatePlannedRecordFields(
|
|
plan: RecordCreationPlan,
|
|
recordId: string,
|
|
newFields: Record<string, any>,
|
|
systemEntities: SystemEntities,
|
|
): PlannedRecord | undefined {
|
|
const record = plan.records.find(r => r.id === recordId);
|
|
if (!record) return undefined;
|
|
|
|
const entityInfo = this.findEntityByName(systemEntities, record.entityApiName);
|
|
if (!entityInfo) return undefined;
|
|
|
|
record.fields = { ...record.fields, ...newFields };
|
|
|
|
// Recalculate missing fields
|
|
record.missingRequiredFields = entityInfo.requiredFields.filter(
|
|
fieldApiName => !record.fields[fieldApiName] && record.fields[fieldApiName] !== 0 && record.fields[fieldApiName] !== false
|
|
);
|
|
|
|
record.status = record.missingRequiredFields.length === 0 ? 'ready' : 'pending';
|
|
|
|
return record;
|
|
}
|
|
|
|
/**
|
|
* Calculates the execution order based on dependencies
|
|
*/
|
|
calculateExecutionOrder(plan: RecordCreationPlan): string[] {
|
|
const order: string[] = [];
|
|
const processed = new Set<string>();
|
|
|
|
const process = (recordId: string) => {
|
|
if (processed.has(recordId)) return;
|
|
|
|
const record = plan.records.find(r => r.id === recordId);
|
|
if (!record) return;
|
|
|
|
// Process dependencies first
|
|
for (const depId of record.dependsOn) {
|
|
process(depId);
|
|
}
|
|
|
|
processed.add(recordId);
|
|
order.push(recordId);
|
|
};
|
|
|
|
for (const record of plan.records) {
|
|
process(record.id);
|
|
}
|
|
|
|
plan.executionOrder = order;
|
|
return order;
|
|
}
|
|
|
|
/**
|
|
* Checks if the plan is complete (all records have required data)
|
|
*/
|
|
isPlanComplete(plan: RecordCreationPlan): boolean {
|
|
return plan.records.every(r => r.status === 'ready' || r.status === 'created');
|
|
}
|
|
|
|
/**
|
|
* Gets all missing fields across the plan
|
|
*/
|
|
getAllMissingFields(plan: RecordCreationPlan): Array<{ record: PlannedRecord; missingFields: string[] }> {
|
|
return plan.records
|
|
.filter(r => r.missingRequiredFields.length > 0)
|
|
.map(r => ({ record: r, missingFields: r.missingRequiredFields }));
|
|
}
|
|
|
|
/**
|
|
* Generates a human-readable summary of missing fields
|
|
*/
|
|
generateMissingFieldsSummary(plan: RecordCreationPlan, systemEntities: SystemEntities): string {
|
|
const missing = this.getAllMissingFields(plan);
|
|
if (missing.length === 0) return '';
|
|
|
|
const parts: string[] = [];
|
|
for (const { record, missingFields } of missing) {
|
|
const entityInfo = this.findEntityByName(systemEntities, record.entityApiName);
|
|
const fieldLabels = missingFields.map(apiName => {
|
|
const field = entityInfo?.fields.find(f => f.apiName === apiName);
|
|
return field?.label || apiName;
|
|
});
|
|
parts.push(`${record.entityLabel}: ${fieldLabels.join(', ')}`);
|
|
}
|
|
|
|
return `I need more information to complete the plan:\n${parts.join('\n')}`;
|
|
}
|
|
|
|
async handleChat(
|
|
tenantId: string,
|
|
userId: string,
|
|
message: string,
|
|
history: AiAssistantState['history'],
|
|
context: AiAssistantState['context'],
|
|
): Promise<AiAssistantReply> {
|
|
this.pruneConversations();
|
|
const conversationKey = this.getConversationKey(
|
|
tenantId,
|
|
userId,
|
|
context?.objectApiName,
|
|
);
|
|
const prior = this.conversationState.get(conversationKey);
|
|
|
|
const trimmedHistory = Array.isArray(history) ? history.slice(-6) : [];
|
|
|
|
// Use Deep Agent as the main coordinator
|
|
const result = await this.runDeepAgent(tenantId, userId, message, trimmedHistory, context, prior);
|
|
|
|
// Update conversation state based on result
|
|
if (result.record) {
|
|
this.conversationState.delete(conversationKey);
|
|
} else if ('extractedFields' in result && result.extractedFields && Object.keys(result.extractedFields).length > 0) {
|
|
this.conversationState.set(conversationKey, {
|
|
fields: result.extractedFields,
|
|
updatedAt: Date.now(),
|
|
});
|
|
}
|
|
|
|
return {
|
|
reply: result.reply || 'How can I help?',
|
|
action: result.action,
|
|
missingFields: result.missingFields,
|
|
record: result.record,
|
|
};
|
|
}
|
|
|
|
private async runDeepAgent(
|
|
tenantId: string,
|
|
userId: string,
|
|
message: string,
|
|
history: AiAssistantState['history'],
|
|
context: AiAssistantState['context'],
|
|
prior?: { fields: Record<string, any>; updatedAt: number },
|
|
): Promise<AiAssistantReply & { extractedFields?: Record<string, any> }> {
|
|
const openAiConfig = await this.getOpenAiConfig(tenantId);
|
|
if (!openAiConfig) {
|
|
this.logger.warn('No OpenAI config found; using fallback graph execution.');
|
|
// Fallback to direct graph execution if no OpenAI config
|
|
const initialState: AiAssistantState = {
|
|
message: this.combineHistory(history, message),
|
|
history: history,
|
|
context: context || {},
|
|
extractedFields: prior?.fields,
|
|
};
|
|
const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId);
|
|
const result = await graph.invoke(initialState);
|
|
return {
|
|
reply: result.reply || 'How can I help?',
|
|
action: result.action,
|
|
missingFields: result.missingFields,
|
|
record: result.record,
|
|
};
|
|
}
|
|
|
|
// Discover available entities for dynamic prompt generation
|
|
const systemEntities = await this.discoverEntities(tenantId);
|
|
|
|
// Build the compiled subagent
|
|
const compiledSubagent = this.buildResolveOrCreateRecordGraph(tenantId, userId);
|
|
|
|
// Create Deep Agent with the subagent
|
|
const mainModel = new ChatOpenAI({
|
|
apiKey: openAiConfig.apiKey,
|
|
model: this.normalizeChatModel(openAiConfig.model),
|
|
temperature: 0.3,
|
|
});
|
|
|
|
// Build dynamic system prompt based on discovered entities
|
|
const systemPrompt = this.buildDeepAgentSystemPrompt(systemEntities, context);
|
|
|
|
const agent = createDeepAgent({
|
|
model: mainModel,
|
|
systemPrompt,
|
|
tools: [],
|
|
subagents: [
|
|
{
|
|
name: 'record-planner',
|
|
description: [
|
|
'USE THIS FOR ALL RECORD OPERATIONS. This is the ONLY way to create, find, or modify CRM records.',
|
|
'',
|
|
'Pass the user\'s request directly. The subagent handles:',
|
|
'- Finding existing records (prevents duplicates)',
|
|
'- Creating new records with all required fields',
|
|
'- Managing relationships between records',
|
|
'- Transaction handling (prevents orphaned records)',
|
|
'',
|
|
'Example: User says "Create contact John under Acme account"',
|
|
'Just pass: "Create contact John under Acme account"',
|
|
'The subagent will create both records with proper linking.',
|
|
].join('\n'),
|
|
runnable: compiledSubagent,
|
|
},
|
|
],
|
|
});
|
|
|
|
// Convert history to messages format
|
|
const messages: BaseMessage[] = [];
|
|
if (history && history.length > 0) {
|
|
for (const entry of history) {
|
|
if (entry.role === 'user') {
|
|
messages.push(new HumanMessage(entry.text));
|
|
} else if (entry.role === 'assistant') {
|
|
messages.push(new AIMessage(entry.text));
|
|
}
|
|
}
|
|
}
|
|
messages.push(new HumanMessage(message));
|
|
|
|
// Include context information in the first message if available
|
|
let contextInfo = '';
|
|
if (context?.objectApiName) {
|
|
contextInfo += `\n[System Context: User is working with ${context.objectApiName} object`;
|
|
if (context.recordId) {
|
|
contextInfo += `, record ID: ${context.recordId}`;
|
|
}
|
|
contextInfo += ']';
|
|
}
|
|
if (prior?.fields && Object.keys(prior.fields).length > 0) {
|
|
contextInfo += `\n[Previously collected field values: ${JSON.stringify(prior.fields)}]`;
|
|
}
|
|
|
|
if (contextInfo && messages.length > 0) {
|
|
const lastMessage = messages[messages.length - 1];
|
|
if (lastMessage instanceof HumanMessage) {
|
|
messages[messages.length - 1] = new HumanMessage(
|
|
lastMessage.content + contextInfo,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
console.log('=== DEEP AGENT: Starting invocation ===');
|
|
console.log('Messages:', messages.map(m => ({ role: m._getType(), content: m.content })));
|
|
|
|
const result = await agent.invoke({ messages });
|
|
|
|
console.log('=== DEEP AGENT: Result received ===');
|
|
console.log('Result messages count:', result.messages.length);
|
|
|
|
// Look for subagent results in the messages
|
|
let subagentResult: any = null;
|
|
for (let i = result.messages.length - 1; i >= 0; i--) {
|
|
const msg = result.messages[i];
|
|
console.log(`Message ${i}:`, {
|
|
type: msg._getType(),
|
|
content: typeof msg.content === 'string' ? msg.content.substring(0, 200) : msg.content,
|
|
additional_kwargs: msg.additional_kwargs,
|
|
});
|
|
|
|
// Check if this message has subagent output data
|
|
if (msg.additional_kwargs?.action || msg.additional_kwargs?.record) {
|
|
subagentResult = msg.additional_kwargs;
|
|
console.log('Found subagent result in message additional_kwargs:', subagentResult);
|
|
break;
|
|
}
|
|
}
|
|
|
|
const lastMsg = result.messages[result.messages.length - 1];
|
|
const replyText = typeof lastMsg.content === 'string'
|
|
? lastMsg.content
|
|
: 'How can I help?';
|
|
|
|
console.log('Final reply text:', replyText);
|
|
|
|
// If we found subagent results, use them; otherwise use defaults
|
|
if (subagentResult) {
|
|
console.log('=== DEEP AGENT: Using subagent result ===');
|
|
|
|
// If a record was found/created, log it prominently
|
|
if (subagentResult.record) {
|
|
const wasFound = subagentResult.foundExisting || subagentResult.record.wasFound;
|
|
console.log(`!!! Record ${wasFound ? 'FOUND' : 'CREATED'}: ID = ${subagentResult.record.id}, Name = ${subagentResult.record.name || 'N/A'}`);
|
|
}
|
|
|
|
return {
|
|
reply: replyText,
|
|
action: subagentResult.action || 'clarify',
|
|
missingFields: subagentResult.missingFields || [],
|
|
record: subagentResult.record,
|
|
extractedFields: subagentResult.extractedFields,
|
|
};
|
|
}
|
|
|
|
console.log('=== DEEP AGENT: No subagent result found, using defaults ===');
|
|
console.log('This usually means the Deep Agent did not invoke the subagent.');
|
|
console.log('Falling back to direct graph invocation...');
|
|
|
|
// Fallback: invoke the graph directly since Deep Agent didn't use the subagent
|
|
const initialState: AiAssistantState = {
|
|
message: this.combineHistory(history, message),
|
|
history: history,
|
|
context: context || {},
|
|
extractedFields: prior?.fields,
|
|
};
|
|
const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId);
|
|
const graphResult = await graph.invoke(initialState);
|
|
|
|
console.log('=== DIRECT GRAPH: Result ===', {
|
|
action: graphResult.action,
|
|
hasRecord: !!graphResult.record,
|
|
reply: graphResult.reply?.substring(0, 100),
|
|
});
|
|
|
|
return {
|
|
reply: graphResult.reply || replyText,
|
|
action: graphResult.action || 'clarify',
|
|
missingFields: graphResult.missingFields || [],
|
|
record: graphResult.record,
|
|
extractedFields: graphResult.extractedFields,
|
|
};
|
|
} catch (error) {
|
|
this.logger.error(`Deep Agent execution failed: ${error.message}`, error.stack);
|
|
// Fallback to direct graph execution
|
|
const initialState: AiAssistantState = {
|
|
message: this.combineHistory(history, message),
|
|
history: history,
|
|
context: context || {},
|
|
extractedFields: prior?.fields,
|
|
};
|
|
const graph = this.buildResolveOrCreateRecordGraph(tenantId, userId);
|
|
const result = await graph.invoke(initialState);
|
|
return {
|
|
reply: result.reply || 'How can I help?',
|
|
action: result.action,
|
|
missingFields: result.missingFields,
|
|
record: result.record,
|
|
};
|
|
}
|
|
}
|
|
|
|
private buildDeepAgentSystemPrompt(
|
|
systemEntities: SystemEntities,
|
|
context?: AiAssistantState['context'],
|
|
): string {
|
|
const contextInfo = context?.objectApiName
|
|
? ` The user is currently working with the ${context.objectApiName} object.`
|
|
: '';
|
|
|
|
// Generate dynamic entity information
|
|
const entitySummary = this.generateEntitySummaryForPrompt(systemEntities);
|
|
|
|
// Find entities with relationships for examples
|
|
const entitiesWithRelationships = systemEntities.entities.filter(e => e.relationships.length > 0);
|
|
const relationshipExamples = entitiesWithRelationships.slice(0, 3).map(e => {
|
|
const rel = e.relationships[0];
|
|
return ` - ${e.label} has a ${rel.fieldLabel} field that references ${rel.targetEntity}`;
|
|
}).join('\n');
|
|
|
|
return [
|
|
'You are an AI assistant helping users interact with a CRM system.',
|
|
'',
|
|
'*** CRITICAL: YOU MUST ALWAYS USE THE record-planner SUBAGENT ***',
|
|
'You CANNOT create, find, or modify records yourself.',
|
|
'For ANY request involving records, you MUST invoke the record-planner subagent.',
|
|
'Do NOT respond to record-related requests without using the subagent first.',
|
|
'',
|
|
'=== AVAILABLE ENTITIES ===',
|
|
entitySummary,
|
|
'',
|
|
'=== HOW TO USE THE SUBAGENT ===',
|
|
'Simply pass the user\'s request directly to the record-planner subagent.',
|
|
'The subagent will:',
|
|
'1. Analyze what records need to be created',
|
|
'2. Check if any already exist (no duplicates)',
|
|
'3. Verify all required data is present',
|
|
'4. Create records in a transaction (no orphans)',
|
|
'',
|
|
'=== ENTITY RELATIONSHIPS ===',
|
|
relationshipExamples || ' (No relationships defined)',
|
|
'',
|
|
'=== RULES ===',
|
|
'- INVOKE the subagent for ANY record operation',
|
|
'- If subagent needs more data, ask the user',
|
|
'- Report success only when subagent confirms',
|
|
'',
|
|
contextInfo,
|
|
].join('\n');
|
|
}
|
|
|
|
async searchRecords(
|
|
tenantId: string,
|
|
userId: string,
|
|
payload: AiSearchPayload,
|
|
) {
|
|
const queryText = payload?.query?.trim();
|
|
if (!payload?.objectApiName || !queryText) {
|
|
throw new BadRequestException('objectApiName and query are required');
|
|
}
|
|
|
|
// Normalize tenant ID so Meilisearch index names align with indexed records
|
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
|
|
const objectDefinition = await this.objectService.getObjectDefinition(
|
|
resolvedTenantId,
|
|
payload.objectApiName,
|
|
);
|
|
if (!objectDefinition) {
|
|
throw new BadRequestException(`Object ${payload.objectApiName} not found`);
|
|
}
|
|
|
|
const page = Number.isFinite(Number(payload.page)) ? Number(payload.page) : 1;
|
|
const pageSize = Number.isFinite(Number(payload.pageSize)) ? Number(payload.pageSize) : 20;
|
|
|
|
const plan = await this.buildSearchPlan(
|
|
resolvedTenantId,
|
|
queryText,
|
|
objectDefinition,
|
|
);
|
|
|
|
console.log('AI search plan:', plan);
|
|
|
|
if (plan.strategy === 'keyword') {
|
|
|
|
console.log('AI search plan (keyword):', plan);
|
|
|
|
const keyword = plan.keyword?.trim() || queryText;
|
|
if (this.meilisearchService.isEnabled()) {
|
|
const offset = (page - 1) * pageSize;
|
|
const meiliResults = await this.meilisearchService.searchRecords(
|
|
resolvedTenantId,
|
|
payload.objectApiName,
|
|
keyword,
|
|
{ limit: pageSize, offset },
|
|
);
|
|
|
|
console.log('Meilisearch results:', meiliResults);
|
|
|
|
const ids = meiliResults.hits
|
|
.map((hit: any) => hit?.id)
|
|
.filter(Boolean);
|
|
|
|
const records = ids.length
|
|
? await this.objectService.searchRecordsByIds(
|
|
resolvedTenantId,
|
|
payload.objectApiName,
|
|
userId,
|
|
ids,
|
|
{ page, pageSize },
|
|
)
|
|
: { data: [], totalCount: 0, page, pageSize };
|
|
|
|
return {
|
|
...records,
|
|
totalCount: meiliResults.total ?? records.totalCount ?? 0,
|
|
strategy: plan.strategy,
|
|
explanation: plan.explanation,
|
|
};
|
|
}
|
|
|
|
const fallback = await this.objectService.searchRecordsByKeyword(
|
|
resolvedTenantId,
|
|
payload.objectApiName,
|
|
userId,
|
|
keyword,
|
|
{ page, pageSize },
|
|
);
|
|
return {
|
|
...fallback,
|
|
strategy: plan.strategy,
|
|
explanation: plan.explanation,
|
|
};
|
|
}
|
|
|
|
console.log('AI search plan (query):', plan);
|
|
|
|
const filtered = await this.objectService.searchRecordsWithFilters(
|
|
resolvedTenantId,
|
|
payload.objectApiName,
|
|
userId,
|
|
plan.filters || [],
|
|
{ page, pageSize },
|
|
plan.sort || undefined,
|
|
);
|
|
|
|
return {
|
|
...filtered,
|
|
strategy: plan.strategy,
|
|
explanation: plan.explanation,
|
|
};
|
|
}
|
|
|
|
// ============================================
|
|
// Planning-Based LangGraph Workflow
|
|
// ============================================
|
|
|
|
/**
|
|
* Builds the planning-based record creation graph.
|
|
*
|
|
* Flow:
|
|
* 1. transformInput - Convert Deep Agent messages to state
|
|
* 2. discoverEntities - Load all available entities in the system
|
|
* 3. analyzeIntent - Use AI to determine what entities need to be created
|
|
* 4. buildPlan - Create a plan of records to be created with dependencies
|
|
* 5. verifyPlan - Check if all required data is present
|
|
* 6. (if incomplete) -> requestMissingData -> END
|
|
* 7. (if complete) -> executePlan -> verifyExecution -> END
|
|
*/
|
|
private buildResolveOrCreateRecordGraph(
|
|
tenantId: string,
|
|
userId: string,
|
|
) {
|
|
// Extended state for planning-based workflow
|
|
const PlanningState = Annotation.Root({
|
|
// Input fields
|
|
message: Annotation<string>(),
|
|
messages: Annotation<BaseMessage[]>(),
|
|
history: Annotation<AiAssistantState['history']>(),
|
|
context: Annotation<AiAssistantState['context']>(),
|
|
|
|
// Entity discovery
|
|
systemEntities: Annotation<SystemEntities>(),
|
|
|
|
// Intent analysis results
|
|
analyzedRecords: Annotation<any[]>(),
|
|
|
|
// Planning
|
|
plan: Annotation<RecordCreationPlan>(),
|
|
|
|
// Legacy compatibility
|
|
objectDefinition: Annotation<any>(),
|
|
pageLayout: Annotation<any>(),
|
|
extractedFields: Annotation<Record<string, any>>(),
|
|
requiredFields: Annotation<string[]>(),
|
|
missingFields: Annotation<string[]>(),
|
|
|
|
// Output
|
|
action: Annotation<AiAssistantState['action']>(),
|
|
record: Annotation<any>(),
|
|
records: Annotation<any[]>(),
|
|
reply: Annotation<string>(),
|
|
});
|
|
|
|
// Node 1: Transform Deep Agent messages into state
|
|
const transformInput = async (state: any): Promise<any> => {
|
|
console.log('=== PLAN GRAPH: Transform Input ===');
|
|
|
|
if (state.messages && Array.isArray(state.messages)) {
|
|
const lastMessage = state.messages[state.messages.length - 1];
|
|
const messageText = typeof lastMessage.content === 'string'
|
|
? lastMessage.content
|
|
: '';
|
|
|
|
console.log('Extracted message:', messageText);
|
|
|
|
// Clean annotations from message
|
|
const cleanMessage = messageText
|
|
.replace(/\[System Context:[^\]]+\]/g, '')
|
|
.replace(/\[Previously collected field values:[^\]]+\]/g, '')
|
|
.replace(/\[Available Entities:[^\]]+\]/g, '')
|
|
.replace(/\[Current Plan:[^\]]+\]/g, '')
|
|
.trim();
|
|
|
|
// Extract any context hints
|
|
const contextMatch = messageText.match(/\[System Context: User is working with (\w+) object(?:, record ID: ([^\]]+))?\]/);
|
|
let extractedContext: AiAssistantState['context'] = {};
|
|
|
|
if (contextMatch) {
|
|
extractedContext.objectApiName = contextMatch[1];
|
|
if (contextMatch[2]) {
|
|
extractedContext.recordId = contextMatch[2];
|
|
}
|
|
}
|
|
|
|
// Extract any existing plan from the conversation
|
|
const planMatch = messageText.match(/\[Current Plan: (.*?)\]/);
|
|
let existingPlan: RecordCreationPlan | undefined;
|
|
if (planMatch) {
|
|
try {
|
|
existingPlan = JSON.parse(planMatch[1]);
|
|
} catch (e) {
|
|
console.warn('Failed to parse existing plan from message');
|
|
}
|
|
}
|
|
|
|
return {
|
|
message: cleanMessage,
|
|
messages: state.messages,
|
|
history: [],
|
|
context: extractedContext,
|
|
plan: existingPlan,
|
|
};
|
|
}
|
|
|
|
return state;
|
|
};
|
|
|
|
// Node 2: Discover available entities
|
|
const discoverEntitiesNode = async (state: any): Promise<any> => {
|
|
console.log('=== PLAN GRAPH: Discover Entities ===');
|
|
|
|
const systemEntities = await this.discoverEntities(tenantId);
|
|
console.log(`Discovered ${systemEntities.entities.length} entities`);
|
|
|
|
return {
|
|
...state,
|
|
systemEntities,
|
|
};
|
|
};
|
|
|
|
// Node 3: Analyze user intent and determine what to create
|
|
const analyzeIntent = async (state: any): Promise<any> => {
|
|
console.log('=== PLAN GRAPH: Analyze Intent ===');
|
|
|
|
const { message, systemEntities } = state;
|
|
|
|
// First, check if this is a search/find operation
|
|
const lowerMessage = message.toLowerCase();
|
|
const isFindOperation = lowerMessage.includes('find') ||
|
|
lowerMessage.includes('search') ||
|
|
lowerMessage.includes('look for') ||
|
|
lowerMessage.includes('get');
|
|
|
|
if (isFindOperation) {
|
|
// Handle as search operation, not creation
|
|
return this.handleSearchOperation(tenantId, userId, state);
|
|
}
|
|
|
|
// Use AI to analyze what needs to be created
|
|
const openAiConfig = await this.getOpenAiConfig(tenantId);
|
|
if (!openAiConfig) {
|
|
// Fallback to heuristic analysis
|
|
return this.analyzeIntentWithHeuristics(state);
|
|
}
|
|
|
|
return this.analyzeIntentWithAI(openAiConfig, state);
|
|
};
|
|
|
|
// Node 4: Build or update the creation plan
|
|
const buildPlanNode = async (state: any): Promise<any> => {
|
|
console.log('=== PLAN GRAPH: Build Plan ===');
|
|
console.log('analyzedRecords:', state.analyzedRecords);
|
|
console.log('systemEntities available:', !!state.systemEntities);
|
|
console.log('systemEntities.entityByApiName keys:', state.systemEntities?.entityByApiName ? Object.keys(state.systemEntities.entityByApiName) : 'N/A');
|
|
|
|
const { systemEntities, plan, analyzedRecords } = state;
|
|
|
|
if (!systemEntities || !systemEntities.entityByApiName) {
|
|
console.error('systemEntities not available in buildPlanNode!');
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: 'System error: Entity definitions not loaded. Please try again.',
|
|
};
|
|
}
|
|
|
|
// 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 (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) {
|
|
console.warn(`Entity ${analyzed.entityName} not found in system - skipping`);
|
|
continue;
|
|
}
|
|
|
|
console.log(`Found entity: ${entityInfo.apiName} (${entityInfo.label})`);
|
|
|
|
// Check if this record already exists in the plan
|
|
// 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`);
|
|
// Update existing planned record
|
|
this.updatePlannedRecordFields(currentPlan, existingInPlan.id, analyzed.fields || {}, systemEntities);
|
|
} else {
|
|
// Check if record already exists in database
|
|
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 ${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: recordPlanId,
|
|
entityApiName: entityInfo.apiName,
|
|
entityLabel: entityInfo.label,
|
|
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);
|
|
|
|
// 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
|
|
const newRecord = this.addRecordToPlan(
|
|
currentPlan,
|
|
entityInfo,
|
|
fieldsWithLookups,
|
|
resolvedDependsOn
|
|
);
|
|
|
|
// Track the mapping
|
|
indexToRecordId.set(idx, newRecord.id);
|
|
console.log(`Mapped index ${idx} to new record ${newRecord.id}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate execution order
|
|
this.calculateExecutionOrder(currentPlan);
|
|
|
|
// Determine plan status
|
|
if (this.isPlanComplete(currentPlan)) {
|
|
currentPlan.status = 'ready';
|
|
} else {
|
|
currentPlan.status = 'incomplete';
|
|
}
|
|
|
|
console.log('Plan status:', currentPlan.status);
|
|
console.log('Plan records:', currentPlan.records.map(r => ({
|
|
id: r.id,
|
|
entity: r.entityApiName,
|
|
status: r.status,
|
|
missing: r.missingRequiredFields,
|
|
})));
|
|
|
|
return {
|
|
...state,
|
|
plan: currentPlan,
|
|
};
|
|
};
|
|
|
|
// Node 5: Verify plan completeness
|
|
const verifyPlan = async (state: any): Promise<any> => {
|
|
console.log('=== PLAN GRAPH: Verify Plan ===');
|
|
|
|
const { plan, systemEntities } = state;
|
|
|
|
if (!plan || plan.records.length === 0) {
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: 'I\'m not sure what you\'d like to create. Could you please be more specific?',
|
|
};
|
|
}
|
|
|
|
if (plan.status === 'ready') {
|
|
console.log('Plan is complete and ready for execution');
|
|
return {
|
|
...state,
|
|
action: 'plan_complete',
|
|
};
|
|
}
|
|
|
|
// Plan is incomplete, need more data
|
|
const missingFieldsSummary = this.generateMissingFieldsSummary(plan, systemEntities);
|
|
console.log('Plan incomplete:', missingFieldsSummary);
|
|
|
|
return {
|
|
...state,
|
|
action: 'plan_pending',
|
|
reply: missingFieldsSummary,
|
|
missingFields: this.getAllMissingFields(plan).flatMap(m => m.missingFields),
|
|
};
|
|
};
|
|
|
|
// Node 6: Execute the plan (with transaction)
|
|
const executePlan = async (state: any): Promise<any> => {
|
|
console.log('=== PLAN GRAPH: Execute Plan ===');
|
|
|
|
const { plan, systemEntities } = state;
|
|
|
|
if (!plan || plan.status !== 'ready') {
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: 'The plan is not ready for execution.',
|
|
};
|
|
}
|
|
|
|
plan.status = 'executing';
|
|
|
|
// Get tenant database connection for transaction
|
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
|
|
// Map of temp IDs to real IDs for dependency resolution
|
|
const idMapping = new Map<string, string>();
|
|
// Map of record names to their created IDs and entity types
|
|
const nameToRecord = new Map<string, { id: string; entityType: string }>();
|
|
|
|
// Populate with already created records
|
|
for (const record of plan.records) {
|
|
if (record.status === 'created' && record.createdRecordId) {
|
|
idMapping.set(record.id, record.createdRecordId);
|
|
const recordName = record.fields.name || record.fields.firstName;
|
|
if (recordName) {
|
|
nameToRecord.set(recordName.toLowerCase(), {
|
|
id: record.createdRecordId,
|
|
entityType: record.entityApiName,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Execute in transaction
|
|
await knex.transaction(async (trx) => {
|
|
for (const tempId of plan.executionOrder) {
|
|
const plannedRecord = plan.records.find(r => r.id === tempId);
|
|
if (!plannedRecord || plannedRecord.status === 'created') continue;
|
|
|
|
console.log(`Creating record: ${plannedRecord.entityLabel} (${plannedRecord.id})`);
|
|
console.log(`Original fields:`, plannedRecord.fields);
|
|
|
|
// Resolve any dependency references in fields
|
|
const resolvedFields = { ...plannedRecord.fields };
|
|
|
|
// Get entity info for this record type
|
|
const entityInfo = this.findEntityByName(systemEntities, plannedRecord.entityApiName);
|
|
|
|
// Resolve dependencies by temp ID
|
|
for (const depId of plannedRecord.dependsOn) {
|
|
const realId = idMapping.get(depId);
|
|
if (realId) {
|
|
const depRecord = plan.records.find(r => r.id === depId);
|
|
if (depRecord && entityInfo) {
|
|
// Find the lookup field that references this entity
|
|
const lookupField = entityInfo.relationships.find(
|
|
r => r.targetEntity.toLowerCase() === depRecord.entityApiName.toLowerCase()
|
|
);
|
|
if (lookupField) {
|
|
resolvedFields[lookupField.fieldApiName] = realId;
|
|
console.log(`Resolved ${lookupField.fieldApiName} = ${realId} (from dependency ${depId})`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle polymorphic fields (relatedObjectId/relatedObjectType for ContactDetail)
|
|
if (plannedRecord.entityApiName.toLowerCase() === 'contactdetail') {
|
|
// Check if relatedObjectId is a name rather than UUID
|
|
const relatedValue = resolvedFields.relatedObjectId;
|
|
if (relatedValue && typeof relatedValue === 'string' && !this.isUuid(relatedValue)) {
|
|
// Try to find the record by name in our created records
|
|
const foundRecord = nameToRecord.get(relatedValue.toLowerCase());
|
|
if (foundRecord) {
|
|
resolvedFields.relatedObjectId = foundRecord.id;
|
|
resolvedFields.relatedObjectType = foundRecord.entityType;
|
|
console.log(`Resolved polymorphic: relatedObjectId=${foundRecord.id}, relatedObjectType=${foundRecord.entityType}`);
|
|
} else {
|
|
// Try to search in database
|
|
for (const targetType of ['Contact', 'Account']) {
|
|
const existingRecord = await this.searchForExistingRecord(
|
|
tenantId, userId, targetType, relatedValue
|
|
);
|
|
if (existingRecord) {
|
|
resolvedFields.relatedObjectId = existingRecord.id;
|
|
resolvedFields.relatedObjectType = targetType;
|
|
console.log(`Found existing record for polymorphic: ${targetType} ${existingRecord.id}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Ensure relatedObjectType is set if we have relatedObjectId
|
|
if (resolvedFields.relatedObjectId && !resolvedFields.relatedObjectType) {
|
|
// Default to Contact if not specified
|
|
resolvedFields.relatedObjectType = 'Contact';
|
|
console.log(`Defaulting relatedObjectType to Contact`);
|
|
}
|
|
}
|
|
|
|
// Resolve any remaining lookup fields that have names instead of IDs
|
|
if (entityInfo) {
|
|
for (const rel of entityInfo.relationships) {
|
|
const fieldValue = resolvedFields[rel.fieldApiName];
|
|
if (fieldValue && typeof fieldValue === 'string' && !this.isUuid(fieldValue)) {
|
|
// This is a name, try to resolve it
|
|
const foundInPlan = nameToRecord.get(fieldValue.toLowerCase());
|
|
if (foundInPlan && foundInPlan.entityType.toLowerCase() === rel.targetEntity.toLowerCase()) {
|
|
resolvedFields[rel.fieldApiName] = foundInPlan.id;
|
|
console.log(`Resolved lookup ${rel.fieldApiName} from name "${fieldValue}" to ID ${foundInPlan.id}`);
|
|
} else {
|
|
// Search in database
|
|
const existingRecord = await this.searchForExistingRecord(
|
|
tenantId, userId, rel.targetEntity, fieldValue
|
|
);
|
|
if (existingRecord) {
|
|
resolvedFields[rel.fieldApiName] = existingRecord.id;
|
|
console.log(`Resolved lookup ${rel.fieldApiName} from database: ${existingRecord.id}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Resolved fields:`, resolvedFields);
|
|
|
|
// Create the record
|
|
const createdRecord = await this.objectService.createRecord(
|
|
tenantId,
|
|
plannedRecord.entityApiName,
|
|
resolvedFields,
|
|
userId,
|
|
);
|
|
|
|
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() : '') ||
|
|
resolvedFields.value || '';
|
|
if (recordName) {
|
|
nameToRecord.set(recordName.toLowerCase(), {
|
|
id: createdRecord.id,
|
|
entityType: plannedRecord.entityApiName,
|
|
});
|
|
}
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
plan.status = 'completed';
|
|
|
|
// Generate success message with meaningful names
|
|
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: replyMessage,
|
|
};
|
|
|
|
} catch (error) {
|
|
console.error('Plan execution failed:', error);
|
|
plan.status = 'failed';
|
|
plan.errors.push(error.message);
|
|
|
|
return {
|
|
...state,
|
|
plan,
|
|
action: 'clarify',
|
|
reply: `Failed to create records: ${error.message}. The transaction was rolled back.`,
|
|
};
|
|
}
|
|
};
|
|
|
|
// Node 7: Format output for Deep Agent
|
|
const formatOutput = async (state: any): Promise<any> => {
|
|
console.log('=== PLAN GRAPH: Format Output ===');
|
|
|
|
const outputMessage = new AIMessage({
|
|
content: state.reply || 'Completed.',
|
|
additional_kwargs: {
|
|
action: state.action,
|
|
record: state.record,
|
|
records: state.records,
|
|
plan: state.plan,
|
|
missingFields: state.missingFields,
|
|
foundExisting: state.record?.wasFound || false,
|
|
},
|
|
});
|
|
|
|
return {
|
|
...state,
|
|
messages: [...(state.messages || []), outputMessage],
|
|
};
|
|
};
|
|
|
|
// Build the workflow
|
|
const workflow = new StateGraph(PlanningState)
|
|
.addNode('transformInput', transformInput)
|
|
.addNode('discoverEntities', discoverEntitiesNode)
|
|
.addNode('analyzeIntent', analyzeIntent)
|
|
.addNode('buildPlan', buildPlanNode)
|
|
.addNode('verifyPlan', verifyPlan)
|
|
.addNode('executePlan', executePlan)
|
|
.addNode('formatOutput', formatOutput)
|
|
.addEdge(START, 'transformInput')
|
|
.addEdge('transformInput', 'discoverEntities')
|
|
.addEdge('discoverEntities', 'analyzeIntent')
|
|
.addConditionalEdges('analyzeIntent', (current: any) => {
|
|
// If it's a search result, go directly to format output
|
|
if (current.record || current.action === 'clarify') {
|
|
return 'formatOutput';
|
|
}
|
|
return 'buildPlan';
|
|
})
|
|
.addEdge('buildPlan', 'verifyPlan')
|
|
.addConditionalEdges('verifyPlan', (current: any) => {
|
|
// If plan is complete, execute it; otherwise format the missing fields response
|
|
if (current.action === 'plan_complete') {
|
|
return 'executePlan';
|
|
}
|
|
return 'formatOutput';
|
|
})
|
|
.addEdge('executePlan', 'formatOutput')
|
|
.addEdge('formatOutput', END);
|
|
|
|
return workflow.compile();
|
|
}
|
|
|
|
// ============================================
|
|
// Intent Analysis Helpers
|
|
// ============================================
|
|
|
|
private async handleSearchOperation(
|
|
tenantId: string,
|
|
userId: string,
|
|
state: any,
|
|
): Promise<any> {
|
|
const { message, systemEntities } = state;
|
|
|
|
// Try to extract entity type and search term
|
|
const lowerMessage = message.toLowerCase();
|
|
let entityName: string | undefined;
|
|
let searchTerm: string | undefined;
|
|
|
|
// Pattern: "find/search/get [entity] [name]"
|
|
for (const entity of systemEntities.entities) {
|
|
const label = entity.label.toLowerCase();
|
|
const apiName = entity.apiName.toLowerCase();
|
|
|
|
if (lowerMessage.includes(label) || lowerMessage.includes(apiName)) {
|
|
entityName = entity.apiName;
|
|
// Extract the search term after the entity name
|
|
const regex = new RegExp(`(?:find|search|get|look for)\\s+(?:${label}|${apiName})\\s+(.+)`, 'i');
|
|
const match = message.match(regex);
|
|
if (match) {
|
|
searchTerm = match[1].trim();
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!entityName) {
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: 'Which type of record would you like to find?',
|
|
};
|
|
}
|
|
|
|
if (!searchTerm) {
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: `What ${entityName} are you looking for?`,
|
|
};
|
|
}
|
|
|
|
// Search for the record
|
|
const record = await this.searchForExistingRecord(tenantId, userId, entityName, searchTerm);
|
|
|
|
if (record) {
|
|
return {
|
|
...state,
|
|
action: 'create_record', // Using create_record for compatibility
|
|
record: { ...record, wasFound: true },
|
|
reply: `Found ${entityName}: "${record.name || record.id}" (ID: ${record.id})`,
|
|
};
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: `No ${entityName} found matching "${searchTerm}". Would you like to create one?`,
|
|
};
|
|
}
|
|
|
|
private async analyzeIntentWithAI(
|
|
openAiConfig: any,
|
|
state: any,
|
|
): Promise<any> {
|
|
const { message, systemEntities } = state;
|
|
|
|
const model = new ChatOpenAI({
|
|
apiKey: openAiConfig.apiKey,
|
|
model: this.normalizeChatModel(openAiConfig.model),
|
|
temperature: 0.2,
|
|
});
|
|
|
|
const entitySummary = this.generateEntitySummaryForPrompt(systemEntities);
|
|
|
|
const parser = new JsonOutputParser<any>();
|
|
|
|
try {
|
|
const response = await model.invoke([
|
|
new SystemMessage(
|
|
`You analyze user requests to determine what CRM records need to be created.\n\n` +
|
|
`${entitySummary}\n\n` +
|
|
`Return JSON with:\n` +
|
|
`- "records": array of records to create, each with:\n` +
|
|
` - "entityName": the entity type (use apiName from the list)\n` +
|
|
` - "fields": object with field values mentioned by user\n` +
|
|
` - "dependsOn": array of indices of records this depends on (for relationships)\n\n` +
|
|
`Rules:\n` +
|
|
`- Only use entities from the list above\n` +
|
|
`- For "create X under/for Y", X depends on Y\n` +
|
|
`- Parent records (like Account) should come before children (like Contact)\n` +
|
|
`- Extract any field values mentioned in the request\n` +
|
|
`- For the "name" field, use the name mentioned by the user\n` +
|
|
`Example: "Create contact John under Acme account"\n` +
|
|
`Response: {"records":[{"entityName":"Account","fields":{"name":"Acme"},"dependsOn":[]},{"entityName":"Contact","fields":{"name":"John"},"dependsOn":[0]}]}`
|
|
),
|
|
new HumanMessage(message),
|
|
]);
|
|
|
|
const content = typeof response.content === 'string' ? response.content : '{}';
|
|
const parsed = await parser.parse(content);
|
|
|
|
// Transform the AI response to our format
|
|
const analyzedRecords = (parsed.records || []).map((r: any, idx: number) => ({
|
|
entityName: r.entityName,
|
|
fields: r.fields || {},
|
|
dependsOn: (r.dependsOn || []).map((depIdx: number) =>
|
|
`temp_${parsed.records[depIdx]?.entityName?.toLowerCase()}_${depIdx + 1}`
|
|
),
|
|
}));
|
|
|
|
console.log('AI analyzed records:', analyzedRecords);
|
|
|
|
return {
|
|
...state,
|
|
analyzedRecords,
|
|
};
|
|
} catch (error) {
|
|
console.error('AI intent analysis failed:', error);
|
|
return this.analyzeIntentWithHeuristics(state);
|
|
}
|
|
}
|
|
|
|
private analyzeIntentWithHeuristics(state: any): any {
|
|
const { message, systemEntities } = state;
|
|
const lowerMessage = message.toLowerCase();
|
|
const analyzedRecords: any[] = [];
|
|
|
|
// Pattern: "create X under/for Y account"
|
|
const underAccountMatch = message.match(/create\s+(\w+(?:\s+\w+)?)\s+(?:under|for)\s+(\w+(?:\s+\w+)?)\s+account/i);
|
|
if (underAccountMatch) {
|
|
const childName = underAccountMatch[1].trim();
|
|
const accountName = underAccountMatch[2].trim();
|
|
|
|
// Add parent account first
|
|
analyzedRecords.push({
|
|
entityName: 'Account',
|
|
fields: { name: accountName },
|
|
dependsOn: [],
|
|
});
|
|
|
|
// Add child - try to determine type
|
|
let childEntity = 'Contact'; // Default
|
|
if (lowerMessage.includes('phone') || lowerMessage.includes('email')) {
|
|
childEntity = 'ContactDetail';
|
|
}
|
|
|
|
analyzedRecords.push({
|
|
entityName: childEntity,
|
|
fields: { name: childName },
|
|
dependsOn: ['temp_account_1'],
|
|
});
|
|
} else {
|
|
// Simple pattern: "create/add [entity] [name]"
|
|
for (const entity of systemEntities.entities) {
|
|
const label = entity.label.toLowerCase();
|
|
const regex = new RegExp(`(?:create|add)\\s+(?:a\\s+)?${label}\\s+(.+?)(?:\\s+with|$)`, 'i');
|
|
const match = message.match(regex);
|
|
|
|
if (match) {
|
|
analyzedRecords.push({
|
|
entityName: entity.apiName,
|
|
fields: { name: match[1].trim() },
|
|
dependsOn: [],
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('Heuristic analyzed records:', analyzedRecords);
|
|
|
|
return {
|
|
...state,
|
|
analyzedRecords,
|
|
};
|
|
}
|
|
|
|
private async searchForExistingRecord(
|
|
tenantId: string,
|
|
userId: string,
|
|
entityApiName: string,
|
|
searchName: string,
|
|
): Promise<any | null> {
|
|
if (!searchName) return null;
|
|
|
|
try {
|
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
|
|
// Try Meilisearch first
|
|
if (this.meilisearchService.isEnabled()) {
|
|
const meiliMatch = await this.meilisearchService.searchRecord(
|
|
resolvedTenantId,
|
|
entityApiName,
|
|
searchName,
|
|
'name',
|
|
);
|
|
|
|
if (meiliMatch?.id) {
|
|
console.log(`Found existing ${entityApiName} via Meilisearch: ${meiliMatch.id}`);
|
|
// Return the full hit data, not just { id, hit }
|
|
return meiliMatch.hit || meiliMatch;
|
|
}
|
|
}
|
|
|
|
// Fallback to database
|
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
const tableName = this.toTableName(entityApiName);
|
|
|
|
const record = await knex(tableName)
|
|
.whereRaw('LOWER(name) = ?', [searchName.toLowerCase()])
|
|
.first();
|
|
|
|
if (record?.id) {
|
|
console.log(`Found existing ${entityApiName} via database: ${record.id}`);
|
|
return record;
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
console.error(`Error searching for ${entityApiName}:`, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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)
|
|
// ============================================
|
|
|
|
private async loadContext(
|
|
tenantId: string,
|
|
state: AiAssistantState,
|
|
): Promise<AiAssistantState> {
|
|
const objectApiName = state.context?.objectApiName;
|
|
console.log('Here:');
|
|
console.log(objectApiName);
|
|
if (!objectApiName) {
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: 'Tell me which object you want to work with, for example: "Add an account named Cloudflare."',
|
|
};
|
|
}
|
|
|
|
const objectDefinition = await this.objectService.getObjectDefinition(
|
|
tenantId,
|
|
objectApiName,
|
|
);
|
|
if (!objectDefinition) {
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: `I could not find an object named "${objectApiName}". Which object should I use?`,
|
|
};
|
|
}
|
|
|
|
const pageLayout = await this.pageLayoutService.findDefaultByObject(
|
|
tenantId,
|
|
objectDefinition.id,
|
|
);
|
|
|
|
return {
|
|
...state,
|
|
objectDefinition,
|
|
pageLayout,
|
|
history: state.history,
|
|
};
|
|
}
|
|
|
|
private async searchExistingRecord(
|
|
tenantId: string,
|
|
userId: string,
|
|
state: AiAssistantState,
|
|
): Promise<AiAssistantState> {
|
|
if (!state.objectDefinition || !state.message) {
|
|
return state;
|
|
}
|
|
|
|
// Check if this is a find/search operation or if we should check for existing
|
|
const lowerMessage = state.message.toLowerCase();
|
|
const isFindOperation = lowerMessage.includes('find') || lowerMessage.includes('search') || lowerMessage.includes('look for');
|
|
const isCreateOperation = lowerMessage.includes('create') || lowerMessage.includes('add');
|
|
|
|
// Extract the name to search for
|
|
let searchName: string | null = null;
|
|
|
|
// Pattern: "Find Account X"
|
|
const findMatch = state.message.match(/(?:find|search|look for)\s+(?:\w+\s+)?(.+?)(?:\(|$)/i);
|
|
if (findMatch) {
|
|
searchName = findMatch[1].trim();
|
|
} else if (isCreateOperation) {
|
|
// Pattern: "Create Account X" - check if X already exists
|
|
const createMatch = state.message.match(/(?:create|add)\s+(?:\w+\s+)?(.+?)(?:\s+(?:under|for|with)|\(|$)/i);
|
|
if (createMatch) {
|
|
searchName = createMatch[1].trim();
|
|
}
|
|
}
|
|
|
|
if (!searchName) {
|
|
console.log('No search name extracted, skipping search');
|
|
return state;
|
|
}
|
|
|
|
console.log(`Searching for existing ${state.objectDefinition.apiName}: "${searchName}"`);
|
|
|
|
try {
|
|
// Use Meilisearch if available
|
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
|
|
if (this.meilisearchService.isEnabled()) {
|
|
const displayField = this.getDisplayFieldForObject(state.objectDefinition);
|
|
const meiliMatch = await this.meilisearchService.searchRecord(
|
|
resolvedTenantId,
|
|
state.objectDefinition.apiName,
|
|
searchName,
|
|
displayField,
|
|
);
|
|
|
|
if (meiliMatch?.id) {
|
|
console.log('Found existing record via Meilisearch:', meiliMatch.id);
|
|
return {
|
|
...state,
|
|
record: { ...meiliMatch, wasFound: true },
|
|
action: 'create_record',
|
|
reply: `Found existing ${state.objectDefinition.label || state.objectDefinition.apiName} "${searchName}" (ID: ${meiliMatch.id}).`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Fallback to database search
|
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
const tableName = this.toTableName(
|
|
state.objectDefinition.apiName,
|
|
state.objectDefinition.label,
|
|
state.objectDefinition.pluralLabel,
|
|
);
|
|
const displayField = this.getDisplayFieldForObject(state.objectDefinition);
|
|
|
|
const record = await knex(tableName)
|
|
.whereRaw('LOWER(??) = ?', [displayField, searchName.toLowerCase()])
|
|
.first();
|
|
|
|
if (record?.id) {
|
|
console.log('Found existing record via database:', record.id);
|
|
return {
|
|
...state,
|
|
record: { ...record, wasFound: true },
|
|
action: 'create_record',
|
|
reply: `Found existing ${state.objectDefinition.label || state.objectDefinition.apiName} "${searchName}" (ID: ${record.id}).`,
|
|
};
|
|
}
|
|
|
|
console.log('No existing record found, will proceed to create');
|
|
return state;
|
|
} catch (error) {
|
|
console.error('Error searching for existing record:', error.message);
|
|
return state;
|
|
}
|
|
}
|
|
|
|
private async extractFields(
|
|
tenantId: string,
|
|
state: AiAssistantState,
|
|
): Promise<AiAssistantState> {
|
|
if (!state.objectDefinition) {
|
|
return state;
|
|
}
|
|
|
|
const openAiConfig = await this.getOpenAiConfig(tenantId);
|
|
const fieldDefinitions = (state.objectDefinition.fields || []).filter(
|
|
(field: any) => !this.isSystemField(field.apiName),
|
|
);
|
|
|
|
if (!openAiConfig) {
|
|
this.logger.warn('No OpenAI config found; using heuristic extraction.');
|
|
}
|
|
|
|
const newExtraction = openAiConfig
|
|
? await this.extractWithOpenAI(
|
|
openAiConfig,
|
|
state.message,
|
|
state.objectDefinition.label,
|
|
fieldDefinitions,
|
|
)
|
|
: this.extractWithHeuristics(state.message, fieldDefinitions);
|
|
const mergedExtraction = this.enrichPolymorphicLookupFromMessage(
|
|
state.message,
|
|
state.objectDefinition,
|
|
{
|
|
...(state.extractedFields || {}),
|
|
...(newExtraction || {}),
|
|
},
|
|
);
|
|
|
|
return {
|
|
...state,
|
|
extractedFields: mergedExtraction,
|
|
history: state.history,
|
|
};
|
|
}
|
|
|
|
private decideNextStep(state: AiAssistantState): AiAssistantState {
|
|
if (!state.objectDefinition) {
|
|
return state;
|
|
}
|
|
|
|
console.log('extracated:',state.extractedFields);
|
|
|
|
const fieldDefinitions = (state.objectDefinition.fields || []).filter(
|
|
(field: any) => !this.isSystemField(field.apiName),
|
|
);
|
|
const requiredFields = this.getRequiredFields(fieldDefinitions);
|
|
const missingFields = requiredFields.filter(
|
|
(fieldApiName) => !state.extractedFields?.[fieldApiName],
|
|
);
|
|
|
|
if (missingFields.length > 0) {
|
|
return {
|
|
...state,
|
|
requiredFields,
|
|
missingFields,
|
|
action: 'collect_fields',
|
|
};
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
requiredFields,
|
|
missingFields: [],
|
|
action: 'create_record',
|
|
};
|
|
}
|
|
|
|
private enrichPolymorphicLookupFromMessage(
|
|
message: string,
|
|
objectDefinition: any,
|
|
extracted: Record<string, any>,
|
|
): Record<string, any> {
|
|
if (!objectDefinition || !this.isContactDetail(objectDefinition.apiName)) {
|
|
return extracted;
|
|
}
|
|
|
|
if (extracted.relatedObjectId) return extracted;
|
|
|
|
const match = message.match(/(?:to|for)\s+([^.,;]+)$/i);
|
|
if (!match?.[1]) return extracted;
|
|
|
|
const candidateName = match[1].trim();
|
|
if (!candidateName) return extracted;
|
|
|
|
const lowerMessage = message.toLowerCase();
|
|
const preferredType = lowerMessage.includes('account')
|
|
? 'Account'
|
|
: lowerMessage.includes('contact')
|
|
? 'Contact'
|
|
: undefined;
|
|
|
|
return {
|
|
...extracted,
|
|
relatedObjectId: candidateName,
|
|
...(preferredType ? { relatedObjectType: preferredType } : {}),
|
|
};
|
|
}
|
|
|
|
private async createRecord(
|
|
tenantId: string,
|
|
userId: string,
|
|
state: AiAssistantState,
|
|
): Promise<AiAssistantState> {
|
|
if (!state.objectDefinition || !state.extractedFields) {
|
|
return {
|
|
...state,
|
|
action: 'clarify',
|
|
reply: 'I could not infer the record details. Can you provide the fields you want to set?'
|
|
};
|
|
}
|
|
|
|
const enrichedState = await this.resolvePolymorphicRelatedObject(
|
|
tenantId,
|
|
this.applyPolymorphicDefaults(state),
|
|
);
|
|
|
|
const {
|
|
resolvedFields,
|
|
unresolvedLookups,
|
|
} = await this.resolveLookupFields(
|
|
tenantId,
|
|
enrichedState.objectDefinition,
|
|
enrichedState.extractedFields,
|
|
);
|
|
|
|
if (unresolvedLookups.length > 0) {
|
|
const missingText = unresolvedLookups
|
|
.map(
|
|
(lookup) =>
|
|
`${lookup.fieldLabel || lookup.fieldApiName} (value "${lookup.providedValue}") for ${lookup.targetLabel || 'the related record'}`,
|
|
)
|
|
.join('; ');
|
|
|
|
return {
|
|
...state,
|
|
action: 'collect_fields',
|
|
reply: `I couldn't find these related records: ${missingText}. Please provide an existing record name or ID for each.`,
|
|
};
|
|
}
|
|
|
|
if (this.isContactDetail(enrichedState.objectDefinition.apiName)) {
|
|
const hasId = !!resolvedFields.relatedObjectId;
|
|
const hasType = !!resolvedFields.relatedObjectType;
|
|
if (!hasId || !hasType) {
|
|
return {
|
|
...enrichedState,
|
|
action: 'collect_fields',
|
|
reply:
|
|
'I need which record this contact detail belongs to. Please provide the related Contact or Account name/ID.',
|
|
history: state.history,
|
|
};
|
|
}
|
|
}
|
|
|
|
const record = await this.objectService.createRecord(
|
|
tenantId,
|
|
enrichedState.objectDefinition.apiName,
|
|
resolvedFields,
|
|
userId,
|
|
);
|
|
|
|
console.log('record',record);
|
|
|
|
const nameValue = enrichedState.extractedFields.name || record?.name || record?.id;
|
|
const label = enrichedState.objectDefinition.label || enrichedState.objectDefinition.apiName;
|
|
|
|
return {
|
|
...enrichedState,
|
|
record,
|
|
action: 'create_record',
|
|
reply: `Created ${label} ${nameValue ? `"${nameValue}"` : 'record'} successfully.`,
|
|
history: state.history,
|
|
};
|
|
}
|
|
|
|
private respondWithMissingFields(state: AiAssistantState): AiAssistantState {
|
|
if (!state.objectDefinition) {
|
|
return state;
|
|
}
|
|
|
|
const label = state.objectDefinition.label || state.objectDefinition.apiName;
|
|
const orderedMissing = this.orderMissingFields(state);
|
|
const missingLabels = orderedMissing.map(
|
|
(apiName) => this.getFieldLabel(state.objectDefinition.fields || [], apiName),
|
|
);
|
|
|
|
return {
|
|
...state,
|
|
action: 'collect_fields',
|
|
reply: `To create a ${label}, I still need: ${missingLabels.join(', ')}.`,
|
|
history: state.history,
|
|
};
|
|
}
|
|
|
|
private orderMissingFields(state: AiAssistantState): string[] {
|
|
if (!state.pageLayout || !state.missingFields) {
|
|
return state.missingFields || [];
|
|
}
|
|
|
|
const layoutConfig = this.parseLayoutConfig(state.pageLayout.layout_config);
|
|
const layoutFieldIds: string[] = layoutConfig?.fields?.map((field: any) => field.fieldId) || [];
|
|
const fieldIdToApiName = new Map(
|
|
(state.objectDefinition.fields || []).map((field: any) => [field.id, field.apiName]),
|
|
);
|
|
|
|
const ordered = layoutFieldIds
|
|
.map((fieldId) => fieldIdToApiName.get(fieldId))
|
|
.filter((apiName): apiName is string => Boolean(apiName))
|
|
.filter((apiName) => state.missingFields?.includes(apiName));
|
|
|
|
console.log('ordered:',ordered);
|
|
|
|
const remaining = (state.missingFields || []).filter(
|
|
(apiName) => !ordered.includes(apiName),
|
|
);
|
|
|
|
console.log('remaining:',remaining);
|
|
|
|
return [...ordered, ...remaining];
|
|
}
|
|
|
|
private getRequiredFields(fieldDefinitions: any[]): string[] {
|
|
const required = fieldDefinitions
|
|
.filter((field) => field.isRequired)
|
|
.map((field) => field.apiName);
|
|
|
|
const hasNameField = fieldDefinitions.some((field) => field.apiName === 'name');
|
|
if (hasNameField && !required.includes('name')) {
|
|
required.unshift('name');
|
|
}
|
|
|
|
return Array.from(new Set(required));
|
|
}
|
|
|
|
private async extractWithOpenAI(
|
|
openAiConfig: OpenAIConfig,
|
|
message: string,
|
|
objectLabel: string,
|
|
fieldDefinitions: any[],
|
|
didRetry = false,
|
|
): Promise<Record<string, any>> {
|
|
|
|
console.log('Using OpenAI extraction for message:', message);
|
|
|
|
try {
|
|
const model = new ChatOpenAI({
|
|
apiKey: openAiConfig.apiKey,
|
|
model: this.normalizeChatModel(openAiConfig.model),
|
|
temperature: 0.2,
|
|
});
|
|
const fieldDescriptions = fieldDefinitions.map((field) => {
|
|
return `${field.label} (${field.apiName}, type: ${field.type})`;
|
|
});
|
|
|
|
console.log('fieldDescriptions:',fieldDescriptions);
|
|
|
|
const parser = new JsonOutputParser<Record<string, any>>();
|
|
const response = await model.invoke([
|
|
new SystemMessage(
|
|
`You extract field values to create a ${objectLabel} record.` +
|
|
'\n- Return JSON only with keys: action, fields.' +
|
|
'\n- Use action "create_record" when the user wants to add or create.' +
|
|
'\n- Use ONLY apiName keys exactly as provided (case-sensitive). NEVER use labels or other keys.' +
|
|
'\n- Prefer values from the latest user turn, but keep earlier user-provided values in the same conversation for missing fields.' +
|
|
'\n- If a field value is provided, include it even if it looks custom; do not drop custom fields.' +
|
|
'\n- Avoid guessing fields that were not mentioned.' +
|
|
'\n- Example: {"action":"create_record","fields":{"apiName1":"value"}}',
|
|
),
|
|
new HumanMessage(
|
|
`Fields: ${fieldDescriptions.join('; ')}.\nUser message: ${message}`,
|
|
),
|
|
]);
|
|
|
|
console.log('respomse:', response);
|
|
|
|
const content = typeof response.content === 'string' ? response.content : '{}';
|
|
const parsed = await parser.parse(content);
|
|
const rawFields = parsed.fields || {};
|
|
const normalizedFields = this.normalizeExtractedFieldKeys(rawFields, fieldDefinitions);
|
|
const sanitizedFields = this.sanitizeUserOwnerFields(
|
|
normalizedFields,
|
|
fieldDefinitions,
|
|
message,
|
|
);
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(sanitizedFields).filter(([apiName]) =>
|
|
fieldDefinitions.some((field) => field.apiName === apiName),
|
|
),
|
|
);
|
|
} catch (error) {
|
|
const messageText = error?.message || '';
|
|
const shouldRetryWithDefault =
|
|
!didRetry &&
|
|
(messageText.includes('not a chat model') ||
|
|
messageText.includes('MODEL_NOT_FOUND') ||
|
|
messageText.includes('404'));
|
|
|
|
if (shouldRetryWithDefault) {
|
|
this.logger.warn(
|
|
`OpenAI extraction failed with model "${openAiConfig.model}". Retrying with gpt-4o-mini. Error: ${messageText}`,
|
|
);
|
|
return this.extractWithOpenAI(
|
|
{ ...openAiConfig, model: 'gpt-4o-mini' },
|
|
message,
|
|
objectLabel,
|
|
fieldDefinitions,
|
|
true,
|
|
);
|
|
}
|
|
|
|
this.logger.warn(`OpenAI extraction failed: ${messageText}`);
|
|
return this.extractWithHeuristics(message, fieldDefinitions);
|
|
}
|
|
}
|
|
|
|
private extractWithHeuristics(
|
|
message: string,
|
|
fieldDefinitions: any[],
|
|
): Record<string, any> {
|
|
const extracted: Record<string, any> = {};
|
|
const lowerMessage = message.toLowerCase();
|
|
|
|
console.log('Heuristic extraction for message:', message);
|
|
|
|
const nameField = fieldDefinitions.find(
|
|
(field) => field.apiName === 'name' || field.label.toLowerCase() === 'name',
|
|
);
|
|
const phoneField = fieldDefinitions.find((field) =>
|
|
field.apiName.toLowerCase().includes('phone') || field.label.toLowerCase().includes('phone'),
|
|
);
|
|
|
|
// Check for Account lookup field (for Contacts)
|
|
const accountField = fieldDefinitions.find((field) =>
|
|
field.apiName === 'accountId' || field.apiName.toLowerCase().includes('account'),
|
|
);
|
|
|
|
// Pattern: "Create X under/for Y account" - extract name and account reference
|
|
const underAccountMatch = message.match(/create\s+([^\s]+(?:\s+[^\s]+)?)\s+(?:under|for)\s+(.+?)\s+account/i);
|
|
if (underAccountMatch && nameField) {
|
|
const recordName = underAccountMatch[1].trim();
|
|
const accountName = underAccountMatch[2].trim();
|
|
|
|
extracted[nameField.apiName] = recordName;
|
|
|
|
if (accountField) {
|
|
// Store the account name for lookup
|
|
extracted[accountField.apiName] = accountName;
|
|
console.log('Extracted hierarchical pattern:', { name: recordName, account: accountName });
|
|
}
|
|
}
|
|
|
|
// Generic pattern matching for any field: "label: value" or "set label to value"
|
|
for (const field of fieldDefinitions) {
|
|
const value = this.extractValueForField(message, field);
|
|
if (value) {
|
|
extracted[field.apiName] = value;
|
|
}
|
|
}
|
|
|
|
if (nameField && !extracted[nameField.apiName]) {
|
|
const nameMatch = message.match(/add\s+([^\n]+?)(?:\s+with\s+|$)/i);
|
|
if (nameMatch?.[1]) {
|
|
extracted[nameField.apiName] = nameMatch[1].trim();
|
|
}
|
|
}
|
|
|
|
if (phoneField) {
|
|
const phoneMatch = message.match(/phone\s+([\d+().\s-]+)/i);
|
|
if (phoneMatch?.[1]) {
|
|
extracted[phoneField.apiName] = phoneMatch[1].trim();
|
|
}
|
|
}
|
|
|
|
if (Object.keys(extracted).length === 0 && lowerMessage.startsWith('add ') && nameField) {
|
|
extracted[nameField.apiName] = message.replace(/^add\s+/i, '').trim();
|
|
}
|
|
|
|
console.log('Heuristic extraction result:', extracted);
|
|
|
|
return extracted;
|
|
}
|
|
|
|
private async buildSearchPlan(
|
|
tenantId: string,
|
|
message: string,
|
|
objectDefinition: any,
|
|
): Promise<AiSearchPlan> {
|
|
const openAiConfig = await this.getOpenAiConfig(tenantId);
|
|
if (!openAiConfig) {
|
|
return this.buildSearchPlanFallback(message);
|
|
}
|
|
|
|
try {
|
|
return await this.buildSearchPlanWithAi(openAiConfig, message, objectDefinition);
|
|
} catch (error) {
|
|
this.logger.warn(`AI search planning failed: ${error.message}`);
|
|
return this.buildSearchPlanFallback(message);
|
|
}
|
|
}
|
|
|
|
private buildSearchPlanFallback(message: string): AiSearchPlan {
|
|
const trimmed = message.trim();
|
|
return {
|
|
strategy: 'keyword',
|
|
keyword: trimmed,
|
|
explanation: `Searched records that matches the word: "${trimmed}"`,
|
|
};
|
|
}
|
|
|
|
private async buildSearchPlanWithAi(
|
|
openAiConfig: OpenAIConfig,
|
|
message: string,
|
|
objectDefinition: any,
|
|
): Promise<AiSearchPlan> {
|
|
const model = new ChatOpenAI({
|
|
apiKey: openAiConfig.apiKey,
|
|
model: this.normalizeChatModel(openAiConfig.model),
|
|
temperature: 0.2,
|
|
});
|
|
|
|
const parser = new JsonOutputParser<AiSearchPlan>();
|
|
const fields = (objectDefinition.fields || []).map((field: any) => ({
|
|
apiName: field.apiName,
|
|
label: field.label,
|
|
type: field.type,
|
|
}));
|
|
|
|
const formatInstructions = parser.getFormatInstructions();
|
|
const today = new Date().toISOString();
|
|
|
|
const response = await model.invoke([
|
|
new SystemMessage(
|
|
`You are a CRM search assistant. Decide whether the user input is a keyword search or a structured query.` +
|
|
`\nReturn a JSON object with keys: strategy, explanation, keyword, filters, sort.` +
|
|
`\n- strategy must be "keyword" or "query".` +
|
|
`\n- explanation must be a short sentence explaining the approach.` +
|
|
`\n- keyword should be the search term when strategy is "keyword", otherwise null.` +
|
|
`\n- filters is an array of {field, operator, value, values, from, to}.` +
|
|
`\n- operators must be one of eq, neq, gt, gte, lt, lte, contains, startsWith, endsWith, in, notIn, isNull, notNull, between.` +
|
|
`\n- Use between with from/to when the user gives date ranges like "yesterday" or "last week".` +
|
|
`\n- sort should be {field, direction} when sorting is requested.` +
|
|
`\n- Only use field apiName values exactly as provided.` +
|
|
`\n${formatInstructions}`,
|
|
),
|
|
new HumanMessage(
|
|
`Object: ${objectDefinition.label || objectDefinition.apiName}.\n` +
|
|
`Fields: ${JSON.stringify(fields)}.\n` +
|
|
`Today is ${today}.\n` +
|
|
`User query: ${message}`,
|
|
),
|
|
]);
|
|
|
|
const content = typeof response.content === 'string' ? response.content : '{}';
|
|
const parsed = await parser.parse(content);
|
|
return this.normalizeSearchPlan(parsed, message);
|
|
}
|
|
|
|
private normalizeSearchPlan(plan: AiSearchPlan, message: string): AiSearchPlan {
|
|
if (!plan || typeof plan !== 'object') {
|
|
return this.buildSearchPlanFallback(message);
|
|
}
|
|
|
|
const strategy = plan.strategy === 'query' ? 'query' : 'keyword';
|
|
const explanation = plan.explanation?.trim()
|
|
? plan.explanation.trim()
|
|
: strategy === 'keyword'
|
|
? `Searched records that matches the word: "${message.trim()}"`
|
|
: `Applied filters based on: "${message.trim()}"`;
|
|
|
|
if (strategy === 'keyword') {
|
|
return {
|
|
strategy,
|
|
keyword: plan.keyword?.trim() || message.trim(),
|
|
explanation,
|
|
};
|
|
}
|
|
|
|
return {
|
|
strategy,
|
|
explanation,
|
|
keyword: null,
|
|
filters: Array.isArray(plan.filters) ? plan.filters : [],
|
|
sort: plan.sort || null,
|
|
};
|
|
}
|
|
|
|
private sanitizeUserOwnerFields(
|
|
fields: Record<string, any>,
|
|
fieldDefinitions: any[],
|
|
message: string,
|
|
): Record<string, any> {
|
|
const mentionsAssignment = /\b(user|owner|assign|assigned)\b/i.test(message);
|
|
const defsByApi = new Map(fieldDefinitions.map((f: any) => [f.apiName, f]));
|
|
|
|
const result: Record<string, any> = {};
|
|
for (const [apiName, value] of Object.entries(fields || {})) {
|
|
const def = defsByApi.get(apiName);
|
|
const label = def?.label || apiName;
|
|
const isUserish = /\b(user|owner)\b/i.test(label);
|
|
|
|
if (isUserish && !mentionsAssignment) {
|
|
// Skip auto-assigned "User"/"Owner" when the user didn't mention assignment
|
|
continue;
|
|
}
|
|
|
|
result[apiName] = value;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private normalizeExtractedFieldKeys(
|
|
fields: Record<string, any>,
|
|
fieldDefinitions: any[],
|
|
): Record<string, any> {
|
|
if (!fields) return {};
|
|
const apiNames = new Map(
|
|
(fieldDefinitions || []).map((f: any) => [f.apiName.toLowerCase(), f.apiName]),
|
|
);
|
|
|
|
const result: Record<string, any> = {};
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
const canonical = apiNames.get(key.toLowerCase());
|
|
if (canonical) {
|
|
result[canonical] = value;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private applyPolymorphicDefaults(state: AiAssistantState): AiAssistantState {
|
|
if (!state.objectDefinition || !state.extractedFields) return state;
|
|
|
|
const apiName = String(state.objectDefinition.apiName || '').toLowerCase();
|
|
if (!this.isContactDetail(apiName)) {
|
|
return state;
|
|
}
|
|
|
|
const updatedFields = { ...(state.extractedFields || {}) };
|
|
|
|
if (!updatedFields.relatedObjectId && state.context?.recordId) {
|
|
updatedFields.relatedObjectId = state.context.recordId;
|
|
}
|
|
|
|
if (!updatedFields.relatedObjectType && state.context?.objectApiName) {
|
|
const type = this.toPolymorphicType(state.context.objectApiName);
|
|
if (type) {
|
|
updatedFields.relatedObjectType = type;
|
|
}
|
|
}
|
|
|
|
return {
|
|
...state,
|
|
extractedFields: updatedFields,
|
|
};
|
|
}
|
|
|
|
private toPolymorphicType(objectApiName: string): string | null {
|
|
const normalized = objectApiName.toLowerCase();
|
|
if (normalized === 'account' || normalized === 'accounts') return 'Account';
|
|
if (normalized === 'contact' || normalized === 'contacts') return 'Contact';
|
|
return null;
|
|
}
|
|
|
|
private isContactDetail(objectApiName: string | undefined): boolean {
|
|
if (!objectApiName) return false;
|
|
const normalized = objectApiName.toLowerCase();
|
|
return ['contactdetail', 'contact_detail', 'contactdetails', 'contact_details'].includes(
|
|
normalized,
|
|
);
|
|
}
|
|
|
|
private async resolvePolymorphicRelatedObject(
|
|
tenantId: string,
|
|
state: AiAssistantState,
|
|
): Promise<AiAssistantState> {
|
|
if (!state.objectDefinition || !state.extractedFields) return state;
|
|
|
|
const apiName = String(state.objectDefinition.apiName || '').toLowerCase();
|
|
if (!this.isContactDetail(apiName)) {
|
|
return state;
|
|
}
|
|
|
|
const provided = state.extractedFields.relatedObjectId;
|
|
if (!provided || typeof provided !== 'string' || this.isUuid(provided)) {
|
|
return state;
|
|
}
|
|
|
|
const preferredType =
|
|
state.extractedFields.relatedObjectType ||
|
|
this.toPolymorphicType(state.context?.objectApiName || '');
|
|
|
|
const candidateTypes = preferredType
|
|
? [preferredType, ...['Account', 'Contact'].filter((t) => t !== preferredType)]
|
|
: ['Account', 'Contact'];
|
|
|
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
|
|
for (const type of candidateTypes) {
|
|
const objectApi = type.toLowerCase();
|
|
let targetDefinition: any;
|
|
try {
|
|
targetDefinition = await this.objectService.getObjectDefinition(tenantId, objectApi);
|
|
} catch (error) {
|
|
continue;
|
|
}
|
|
|
|
const displayField = this.getDisplayFieldForObject(targetDefinition);
|
|
const tableName = this.toTableName(
|
|
targetDefinition.apiName,
|
|
targetDefinition.label,
|
|
targetDefinition.pluralLabel,
|
|
);
|
|
|
|
const meiliMatch = await this.meilisearchService.searchRecord(
|
|
resolvedTenantId,
|
|
targetDefinition.apiName,
|
|
provided,
|
|
displayField,
|
|
);
|
|
if (meiliMatch?.id) {
|
|
return {
|
|
...state,
|
|
extractedFields: {
|
|
...state.extractedFields,
|
|
relatedObjectId: meiliMatch.id,
|
|
relatedObjectType: type,
|
|
},
|
|
};
|
|
}
|
|
|
|
const record = await knex(tableName)
|
|
.whereRaw('LOWER(??) = ?', [displayField, provided.toLowerCase()])
|
|
.first();
|
|
|
|
if (record?.id) {
|
|
return {
|
|
...state,
|
|
extractedFields: {
|
|
...state.extractedFields,
|
|
relatedObjectId: record.id,
|
|
relatedObjectType: type,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
private getDisplayFieldForObject(objectDefinition: any): string {
|
|
if (!objectDefinition?.fields) return 'name';
|
|
const hasName = objectDefinition.fields.some(
|
|
(candidate: any) => candidate.apiName === 'name',
|
|
);
|
|
if (hasName) return 'name';
|
|
|
|
const firstText = objectDefinition.fields.find((candidate: any) =>
|
|
['STRING', 'TEXT', 'EMAIL'].includes((candidate.type || '').toUpperCase()),
|
|
);
|
|
return firstText?.apiName || 'id';
|
|
}
|
|
|
|
private extractValueForField(message: string, field: any): string | null {
|
|
const label = field.label || field.apiName;
|
|
const apiName = field.apiName;
|
|
const patterns = [
|
|
new RegExp(`${this.escapeRegex(label)}\\s*:\\s*([^\\n;,]+)`, 'i'),
|
|
new RegExp(`${this.escapeRegex(apiName)}\\s*:\\s*([^\\n;,]+)`, 'i'),
|
|
new RegExp(`set\\s+${this.escapeRegex(label)}\\s+to\\s+([^\\n;,]+)`, 'i'),
|
|
new RegExp(`set\\s+${this.escapeRegex(apiName)}\\s+to\\s+([^\\n;,]+)`, 'i'),
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
const match = message.match(pattern);
|
|
if (match?.[1]) {
|
|
return match[1].trim();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private escapeRegex(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
private getFieldLabel(fields: any[], apiName: string): string {
|
|
const field = fields.find((candidate) => candidate.apiName === apiName);
|
|
return field?.label || apiName;
|
|
}
|
|
|
|
private parseLayoutConfig(layoutConfig: any) {
|
|
if (!layoutConfig) return null;
|
|
if (typeof layoutConfig === 'string') {
|
|
try {
|
|
return JSON.parse(layoutConfig);
|
|
} catch (error) {
|
|
this.logger.warn(`Failed to parse layout config: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return layoutConfig;
|
|
}
|
|
|
|
private isSystemField(apiName: string): boolean {
|
|
return [
|
|
'id',
|
|
'ownerId',
|
|
'created_at',
|
|
'updated_at',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'tenantId',
|
|
].includes(apiName);
|
|
}
|
|
|
|
private async getOpenAiConfig(tenantId: string): Promise<OpenAIConfig | null> {
|
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
const centralPrisma = getCentralPrisma();
|
|
const tenant = await centralPrisma.tenant.findUnique({
|
|
where: { id: resolvedTenantId },
|
|
select: { integrationsConfig: true },
|
|
});
|
|
|
|
let config = tenant?.integrationsConfig
|
|
? typeof tenant.integrationsConfig === 'string'
|
|
? this.tenantDbService.decryptIntegrationsConfig(tenant.integrationsConfig)
|
|
: tenant.integrationsConfig
|
|
: null;
|
|
|
|
// Fallback to environment if tenant config is missing
|
|
if (!config?.openai && process.env.OPENAI_API_KEY) {
|
|
this.logger.log('Using OPENAI_API_KEY fallback for AI assistant.');
|
|
config = {
|
|
...(config || {}),
|
|
openai: {
|
|
apiKey: process.env.OPENAI_API_KEY,
|
|
model: process.env.OPENAI_MODEL || this.defaultModel,
|
|
},
|
|
};
|
|
}
|
|
|
|
if (config?.openai?.apiKey) {
|
|
return {
|
|
apiKey: config.openai.apiKey,
|
|
model: this.defaultModel,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private normalizeChatModel(model?: string): string {
|
|
if (!model) return this.defaultModel;
|
|
|
|
const lower = model.toLowerCase();
|
|
if (
|
|
lower.includes('instruct') ||
|
|
lower.startsWith('text-') ||
|
|
lower.startsWith('davinci') ||
|
|
lower.startsWith('curie') ||
|
|
lower.includes('realtime')
|
|
) {
|
|
return this.defaultModel;
|
|
}
|
|
|
|
return model;
|
|
}
|
|
|
|
private combineHistory(history: AiAssistantState['history'], message: string): string {
|
|
if (!history || history.length === 0) return message;
|
|
|
|
const recent = history.slice(-6);
|
|
const serialized = recent
|
|
.map((entry) => `[${entry.role}] ${entry.text}`)
|
|
.join('\n');
|
|
return `${serialized}\n[user] ${message}`;
|
|
}
|
|
|
|
private getConversationKey(
|
|
tenantId: string,
|
|
userId: string,
|
|
objectApiName?: string,
|
|
): string {
|
|
return `${tenantId}:${userId}:${objectApiName || 'global'}`;
|
|
}
|
|
|
|
private pruneConversations() {
|
|
const now = Date.now();
|
|
for (const [key, value] of this.conversationState.entries()) {
|
|
if (now - value.updatedAt > this.conversationTtlMs) {
|
|
this.conversationState.delete(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async resolveLookupFields(
|
|
tenantId: string,
|
|
objectDefinition: any,
|
|
extractedFields: Record<string, any>,
|
|
): Promise<{
|
|
resolvedFields: Record<string, any>;
|
|
unresolvedLookups: Array<{
|
|
fieldApiName: string;
|
|
fieldLabel?: string;
|
|
targetLabel?: string;
|
|
providedValue: any;
|
|
}>;
|
|
}> {
|
|
if (!extractedFields) {
|
|
return { resolvedFields: {}, unresolvedLookups: [] };
|
|
}
|
|
|
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
|
const resolvedFields = { ...extractedFields };
|
|
const unresolvedLookups: Array<{
|
|
fieldApiName: string;
|
|
fieldLabel?: string;
|
|
targetLabel?: string;
|
|
providedValue: any;
|
|
}> = [];
|
|
|
|
const lookupFields = (objectDefinition.fields || []).filter(
|
|
(field: any) => field.type === 'LOOKUP' && field.referenceObject,
|
|
);
|
|
|
|
for (const field of lookupFields) {
|
|
const value = extractedFields[field.apiName];
|
|
if (value === undefined || value === null) continue;
|
|
|
|
// Already an ID or object with ID
|
|
if (typeof value === 'object' && value.id) {
|
|
resolvedFields[field.apiName] = value.id;
|
|
continue;
|
|
}
|
|
if (typeof value === 'string' && this.isUuid(value)) {
|
|
resolvedFields[field.apiName] = value;
|
|
continue;
|
|
}
|
|
|
|
// Resolve by display field (e.g., name)
|
|
const targetApiName = String(field.referenceObject);
|
|
let targetDefinition: any = null;
|
|
try {
|
|
targetDefinition = await this.objectService.getObjectDefinition(tenantId, targetApiName);
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
`Failed to load reference object ${targetApiName} for field ${field.apiName}: ${error.message}`,
|
|
);
|
|
}
|
|
|
|
const displayField = targetDefinition ? this.getLookupDisplayField(field, targetDefinition) : 'name';
|
|
const tableName = targetDefinition
|
|
? this.toTableName(targetDefinition.apiName, targetDefinition.label, targetDefinition.pluralLabel)
|
|
: this.toTableName(targetApiName);
|
|
|
|
const providedValue = typeof value === 'string' ? value.trim() : value;
|
|
if (providedValue && typeof providedValue === 'string') {
|
|
|
|
console.log('providedValue:', providedValue);
|
|
|
|
const meiliMatch = await this.meilisearchService.searchRecord(
|
|
resolvedTenantId,
|
|
targetDefinition?.apiName || targetApiName,
|
|
providedValue,
|
|
displayField,
|
|
);
|
|
|
|
console.log('MeiliSearch lookup for', meiliMatch);
|
|
|
|
if (meiliMatch?.id) {
|
|
resolvedFields[field.apiName] = meiliMatch.id;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const record =
|
|
providedValue && typeof providedValue === 'string'
|
|
? await knex(tableName)
|
|
.whereRaw('LOWER(??) = ?', [displayField, providedValue.toLowerCase()])
|
|
.first()
|
|
: null;
|
|
|
|
if (record?.id) {
|
|
resolvedFields[field.apiName] = record.id;
|
|
} else {
|
|
unresolvedLookups.push({
|
|
fieldApiName: field.apiName,
|
|
fieldLabel: field.label,
|
|
targetLabel: targetDefinition?.label || targetApiName,
|
|
providedValue: value,
|
|
});
|
|
}
|
|
}
|
|
|
|
return { resolvedFields, unresolvedLookups };
|
|
}
|
|
|
|
private getLookupDisplayField(field: any, targetDefinition: any): string {
|
|
const uiMetadata = this.parseUiMetadata(field.uiMetadata || field.ui_metadata);
|
|
if (uiMetadata?.relationDisplayField) {
|
|
return uiMetadata.relationDisplayField;
|
|
}
|
|
|
|
const hasName = (targetDefinition.fields || []).some(
|
|
(candidate: any) => candidate.apiName === 'name',
|
|
);
|
|
if (hasName) return 'name';
|
|
|
|
// Fallback to first string-like field
|
|
const firstTextField = (targetDefinition.fields || []).find((candidate: any) =>
|
|
['STRING', 'TEXT', 'EMAIL'].includes((candidate.type || '').toUpperCase()),
|
|
);
|
|
return firstTextField?.apiName || 'id';
|
|
}
|
|
|
|
private parseUiMetadata(uiMetadata: any): any {
|
|
if (!uiMetadata) return null;
|
|
if (typeof uiMetadata === 'object') return uiMetadata;
|
|
if (typeof uiMetadata === 'string') {
|
|
try {
|
|
return JSON.parse(uiMetadata);
|
|
} catch (error) {
|
|
this.logger.warn(`Failed to parse UI metadata: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private isUuid(value: string): boolean {
|
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
}
|
|
|
|
private toTableName(objectApiName: string, objectLabel?: string, pluralLabel?: string): string {
|
|
const toSnakePlural = (source: string): string => {
|
|
const cleaned = source.replace(/[\s-]+/g, '_');
|
|
const snake = cleaned
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
.replace(/__+/g, '_')
|
|
.toLowerCase()
|
|
.replace(/^_/, '');
|
|
|
|
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
|
|
if (snake.endsWith('s')) return snake;
|
|
return `${snake}s`;
|
|
};
|
|
|
|
const fromApi = toSnakePlural(objectApiName);
|
|
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
|
|
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
|
|
|
|
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
|
|
return fromLabel;
|
|
}
|
|
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
|
|
return fromPlural;
|
|
}
|
|
if (fromLabel && fromLabel !== fromApi) return fromLabel;
|
|
if (fromPlural && fromPlural !== fromApi) return fromPlural;
|
|
return fromApi;
|
|
}
|
|
}
|