3109 lines
109 KiB
TypeScript
3109 lines
109 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-5.4-mini';
|
||
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, history, 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,
|
||
filters: [],
|
||
sort: null,
|
||
};
|
||
}
|
||
|
||
const fallback = await this.objectService.searchRecordsByKeyword(
|
||
resolvedTenantId,
|
||
payload.objectApiName,
|
||
userId,
|
||
keyword,
|
||
{ page, pageSize },
|
||
);
|
||
return {
|
||
...fallback,
|
||
strategy: plan.strategy,
|
||
explanation: plan.explanation,
|
||
filters: [],
|
||
sort: null,
|
||
};
|
||
}
|
||
|
||
console.log('AI search plan (query):', plan);
|
||
|
||
// Resolve any LOOKUP filter values (user typed a name, we find the related record ID)
|
||
const resolvedFilters = await this.resolveRelatedFilters(
|
||
resolvedTenantId,
|
||
userId,
|
||
objectDefinition,
|
||
plan.filters || [],
|
||
);
|
||
|
||
console.log('Resolved filters for query strategy:', resolvedFilters);
|
||
|
||
const filtered = await this.objectService.searchRecordsWithFilters(
|
||
resolvedTenantId,
|
||
payload.objectApiName,
|
||
userId,
|
||
resolvedFilters,
|
||
{ page, pageSize },
|
||
plan.sort || undefined,
|
||
);
|
||
|
||
return {
|
||
...filtered,
|
||
strategy: plan.strategy,
|
||
explanation: plan.explanation,
|
||
filters: resolvedFilters,
|
||
sort: plan.sort || null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Asks the LLM to suggest a short, human-friendly name for a saved list view
|
||
* based on the resolved filters / explanation.
|
||
*/
|
||
async suggestViewName(
|
||
tenantId: string,
|
||
payload: { objectLabel: string; filters: AiSearchFilter[]; explanation?: string },
|
||
): Promise<{ suggestedName: string }> {
|
||
const openAiConfig = await this.getOpenAiConfig(tenantId);
|
||
if (!openAiConfig) {
|
||
return { suggestedName: `${payload.objectLabel} View` };
|
||
}
|
||
|
||
const model = new ChatOpenAI({
|
||
apiKey: openAiConfig.apiKey,
|
||
model: this.normalizeChatModel(openAiConfig.model),
|
||
temperature: 0.4,
|
||
});
|
||
|
||
const filterSummary = payload.explanation?.trim()
|
||
|| payload.filters.map(f => `${f.field} ${f.operator} ${f.value ?? ''}`).join(', ')
|
||
|| 'no filters';
|
||
|
||
try {
|
||
const response = await model.invoke([
|
||
new SystemMessage(
|
||
'You are a CRM assistant. Suggest a very short (2–5 words), descriptive, and human-friendly name for a saved list view. ' +
|
||
'Reply with ONLY the name, no quotes or punctuation.',
|
||
),
|
||
new HumanMessage(
|
||
`Object: ${payload.objectLabel}.\nFilter summary: ${filterSummary}`,
|
||
),
|
||
]);
|
||
|
||
const raw = typeof response.content === 'string' ? response.content.trim() : '';
|
||
const suggestedName = raw || `${payload.objectLabel} View`;
|
||
return { suggestedName };
|
||
} catch {
|
||
return { suggestedName: `${payload.objectLabel} View` };
|
||
}
|
||
}
|
||
|
||
// ============================================
|
||
// 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;
|
||
}
|
||
|
||
/**
|
||
* Resolves LOOKUP filter values from human-readable names to actual record IDs.
|
||
* When the AI produces a filter like { field: "familyId", operator: "eq", value: "Gaona Family" },
|
||
* this method looks up "Gaona Family" in the referenced object and replaces the value with its ID.
|
||
*/
|
||
private async resolveRelatedFilters(
|
||
tenantId: string,
|
||
userId: string,
|
||
objectDefinition: any,
|
||
filters: AiSearchFilter[],
|
||
): Promise<AiSearchFilter[]> {
|
||
if (!filters || filters.length === 0) return filters;
|
||
|
||
const lookupFieldMap = new Map<string, string>(); // apiName → referenceObject
|
||
for (const field of objectDefinition.fields || []) {
|
||
if (field.type === 'LOOKUP' && field.referenceObject) {
|
||
lookupFieldMap.set(field.apiName, field.referenceObject);
|
||
}
|
||
}
|
||
|
||
const resolved: AiSearchFilter[] = [];
|
||
for (const filter of filters) {
|
||
const referenceObject = lookupFieldMap.get(filter.field);
|
||
if (
|
||
referenceObject &&
|
||
filter.value &&
|
||
typeof filter.value === 'string' &&
|
||
!this.isUuid(filter.value)
|
||
) {
|
||
// Try to resolve the name to an ID in the referenced object
|
||
try {
|
||
console.log(`Resolving LOOKUP filter: ${filter.field} → searching "${filter.value}" in ${referenceObject}`);
|
||
const relatedRecord = await this.searchForExistingRecord(tenantId, userId, referenceObject, filter.value);
|
||
if (relatedRecord?.id) {
|
||
console.log(`Resolved "${filter.value}" → ID: ${relatedRecord.id}`);
|
||
resolved.push({ ...filter, operator: 'eq', value: relatedRecord.id });
|
||
} else {
|
||
// Could not resolve; keep as-is so we get 0 results rather than wrong ones
|
||
console.warn(`Could not resolve related record "${filter.value}" in ${referenceObject}; keeping original filter`);
|
||
resolved.push(filter);
|
||
}
|
||
} catch (err) {
|
||
this.logger.warn(`Failed to resolve lookup filter for ${filter.field}: ${err.message}`);
|
||
resolved.push(filter);
|
||
}
|
||
} else {
|
||
resolved.push(filter);
|
||
}
|
||
}
|
||
|
||
return resolved;
|
||
}
|
||
|
||
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,
|
||
...(field.referenceObject ? { referenceObject: field.referenceObject } : {}),
|
||
}));
|
||
|
||
const formatInstructions = parser.getFormatInstructions();
|
||
const today = new Date().toISOString();
|
||
|
||
const systemPromptLines = [
|
||
`You are a CRM search assistant. The user is browsing a list of "${objectDefinition.label || objectDefinition.apiName}" records.`,
|
||
``,
|
||
`Your task: decide whether the user input is a KEYWORD search or a STRUCTURED QUERY, then return the correct JSON plan.`,
|
||
``,
|
||
`=== STRATEGY DECISION RULES ===`,
|
||
`Use strategy="query" when the user:`,
|
||
` - Describes a record attribute or property value (e.g. "list all golden retrievers" → filter the race field)`,
|
||
` - Uses phrases like "list all X", "show all X", "find all X", "filter by X", "where X is Y"`,
|
||
` - Mentions a specific field value, status, category, type, breed, color, size, or any distinguishing characteristic`,
|
||
` - Requests records matching a condition (e.g. "created this week", "price > 100", "active only")`,
|
||
` - Asks for sorted or ordered results (e.g. "newest first", "alphabetically")`,
|
||
``,
|
||
`Use strategy="keyword" ONLY when the user types a bare search term with no clear field or attribute intent`,
|
||
` - e.g. a lone name or identifier: "rex", "john", "acme corp"`,
|
||
``,
|
||
`=== HOW TO MAP USER LANGUAGE TO FIELDS ===`,
|
||
`When the user describes a characteristic or property value, find the best-matching field from the Fields list`,
|
||
`and apply a "contains" filter (or "eq" for exact values). Do NOT fall back to keyword just because the`,
|
||
`field name is not explicitly mentioned — infer the intent from context.`,
|
||
``,
|
||
`EXAMPLES (Object = Dog, fields include: name, race, size, color, createdAt, familyId[LOOKUP→Family]):`,
|
||
` "list all cocker spaniels" → strategy=query, filter: race contains "cocker spaniel"`,
|
||
` "show golden retrievers" → strategy=query, filter: race contains "golden retriever"`,
|
||
` "large dogs" → strategy=query, filter: size contains "large"`,
|
||
` "dogs added this week" → strategy=query, filter: createdAt between <monday> <today>`,
|
||
` "dogs belonging to Gaona Family" → strategy=query, filter: familyId eq "Gaona Family"`,
|
||
` "rex" → strategy=keyword, keyword="rex"`,
|
||
``,
|
||
`=== LOOKUP FIELDS ===`,
|
||
`Fields with type LOOKUP store a reference (ID) to another object (referenceObject).`,
|
||
`When the user refers to a related record by its name (e.g. "belonging to Gaona Family"),`,
|
||
`output the human-readable name as the filter value — the system will resolve it to the correct ID.`,
|
||
`Use the LOOKUP field apiName (e.g. familyId) as the filter field.`,
|
||
``,
|
||
`=== OUTPUT FORMAT ===`,
|
||
`Return a JSON object with these keys:`,
|
||
` strategy : "keyword" or "query"`,
|
||
` explanation : short plain-language description of what the search does`,
|
||
` keyword : the search term when strategy is "keyword", otherwise null`,
|
||
` filters : array of filter objects for strategy="query" (empty array otherwise)`,
|
||
` sort : {field, direction} when sorting is requested, otherwise null`,
|
||
``,
|
||
`Each filter object: { field: <apiName>, operator: <op>, value?, values?, from?, to? }`,
|
||
`Valid operators: eq | neq | gt | gte | lt | lte | contains | startsWith | endsWith | in | notIn | isNull | notNull | between`,
|
||
`Use "between" with {from, to} for date ranges.`,
|
||
`Only use field apiName values exactly as they appear in the Fields list provided.`,
|
||
``,
|
||
formatInstructions,
|
||
].join('\n');
|
||
|
||
const response = await model.invoke([
|
||
new SystemMessage(systemPromptLines),
|
||
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);
|
||
const normalizedPlan = this.normalizeSearchPlan(parsed, message, objectDefinition);
|
||
|
||
console.log('AI search plan:', normalizedPlan);
|
||
|
||
if (normalizedPlan.strategy === 'query') {
|
||
const aiExplanation = await this.generateQueryExplanationWithAi(
|
||
model,
|
||
message,
|
||
objectDefinition,
|
||
normalizedPlan,
|
||
);
|
||
if (aiExplanation) {
|
||
normalizedPlan.explanation = aiExplanation;
|
||
}
|
||
}
|
||
|
||
return normalizedPlan;
|
||
}
|
||
|
||
private normalizeSearchPlan(
|
||
plan: AiSearchPlan,
|
||
message: string,
|
||
objectDefinition?: any,
|
||
): 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,
|
||
};
|
||
}
|
||
|
||
const queryExplanation = this.buildQueryExplanation(
|
||
message,
|
||
objectDefinition,
|
||
Array.isArray(plan.filters) ? plan.filters : [],
|
||
plan.sort || null,
|
||
);
|
||
|
||
return {
|
||
strategy,
|
||
explanation: queryExplanation || explanation,
|
||
keyword: null,
|
||
filters: Array.isArray(plan.filters) ? plan.filters : [],
|
||
sort: plan.sort || null,
|
||
};
|
||
}
|
||
|
||
private buildQueryExplanation(
|
||
message: string,
|
||
objectDefinition: any,
|
||
filters: AiSearchFilter[],
|
||
sort: { field: string; direction: 'asc' | 'desc' } | null,
|
||
): string {
|
||
const fieldLabelByApiName = new Map<string, string>(
|
||
(objectDefinition?.fields || []).map((field: any) => [field.apiName, field.label || field.apiName]),
|
||
);
|
||
const objectLabel = objectDefinition?.label || objectDefinition?.apiName || 'records';
|
||
|
||
const filterParts = filters
|
||
.map((filter) => this.describeFilter(filter, fieldLabelByApiName))
|
||
.filter(Boolean) as string[];
|
||
|
||
const sortLabel = sort?.field
|
||
? fieldLabelByApiName.get(sort.field) || sort.field
|
||
: null;
|
||
const sortPart =
|
||
sortLabel && sort?.direction
|
||
? `sorted by ${sortLabel} (${sort.direction === 'desc' ? 'newest/highest first' : 'oldest/lowest first'})`
|
||
: '';
|
||
|
||
if (filterParts.length > 0 && sortPart) {
|
||
return `Showing ${objectLabel} where ${filterParts.join(' and ')}, ${sortPart}.`;
|
||
}
|
||
|
||
if (filterParts.length > 0) {
|
||
return `Showing ${objectLabel} where ${filterParts.join(' and ')}.`;
|
||
}
|
||
|
||
if (sortPart) {
|
||
return `Showing ${objectLabel} ${sortPart}.`;
|
||
}
|
||
|
||
return `Applied filters based on: "${message.trim()}".`;
|
||
}
|
||
|
||
private describeFilter(filter: AiSearchFilter, fieldLabelByApiName: Map<string, string>): string {
|
||
const fieldLabel = fieldLabelByApiName.get(filter.field) || filter.field;
|
||
const formatValue = (value: any) =>
|
||
value === null || value === undefined || value === ''
|
||
? 'empty'
|
||
: typeof value === 'string'
|
||
? `"${value}"`
|
||
: String(value);
|
||
|
||
switch (filter.operator) {
|
||
case 'eq':
|
||
return `${fieldLabel} is ${formatValue(filter.value)}`;
|
||
case 'neq':
|
||
return `${fieldLabel} is not ${formatValue(filter.value)}`;
|
||
case 'gt':
|
||
return `${fieldLabel} is greater than ${formatValue(filter.value)}`;
|
||
case 'gte':
|
||
return `${fieldLabel} is at least ${formatValue(filter.value)}`;
|
||
case 'lt':
|
||
return `${fieldLabel} is less than ${formatValue(filter.value)}`;
|
||
case 'lte':
|
||
return `${fieldLabel} is at most ${formatValue(filter.value)}`;
|
||
case 'contains':
|
||
return `${fieldLabel} contains ${formatValue(filter.value)}`;
|
||
case 'startsWith':
|
||
return `${fieldLabel} starts with ${formatValue(filter.value)}`;
|
||
case 'endsWith':
|
||
return `${fieldLabel} ends with ${formatValue(filter.value)}`;
|
||
case 'in':
|
||
return `${fieldLabel} is one of ${(filter.values || []).map(formatValue).join(', ')}`;
|
||
case 'notIn':
|
||
return `${fieldLabel} is not one of ${(filter.values || []).map(formatValue).join(', ')}`;
|
||
case 'isNull':
|
||
return `${fieldLabel} is empty`;
|
||
case 'notNull':
|
||
return `${fieldLabel} is not empty`;
|
||
case 'between':
|
||
if (filter.from && filter.to) {
|
||
return `${fieldLabel} is between ${formatValue(filter.from)} and ${formatValue(filter.to)}`;
|
||
}
|
||
if (filter.from) {
|
||
return `${fieldLabel} is from ${formatValue(filter.from)} onward`;
|
||
}
|
||
if (filter.to) {
|
||
return `${fieldLabel} is up to ${formatValue(filter.to)}`;
|
||
}
|
||
return `${fieldLabel} uses a date range filter`;
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
private async generateQueryExplanationWithAi(
|
||
model: ChatOpenAI,
|
||
message: string,
|
||
objectDefinition: any,
|
||
plan: AiSearchPlan,
|
||
): Promise<string | null> {
|
||
try {
|
||
const fields = (objectDefinition?.fields || []).map((field: any) => ({
|
||
apiName: field.apiName,
|
||
label: field.label || field.apiName,
|
||
}));
|
||
|
||
const response = await model.invoke([
|
||
new SystemMessage(
|
||
`You explain CRM list query results in plain language for end users.` +
|
||
`\nWrite one short sentence (max 25 words).` +
|
||
`\nDescribe the resulting filters/sort in business language.` +
|
||
`\nDo NOT mention SQL, JSON, "strategy", "query mode", or AI decision process.` +
|
||
`\nIf values are present, mention the most important ones clearly.`,
|
||
),
|
||
new HumanMessage(
|
||
`Object: ${objectDefinition?.label || objectDefinition?.apiName || 'records'}\n` +
|
||
`User request: ${message}\n` +
|
||
`Available fields: ${JSON.stringify(fields)}\n` +
|
||
`Applied filters: ${JSON.stringify(plan.filters || [])}\n` +
|
||
`Applied sort: ${JSON.stringify(plan.sort || null)}\n` +
|
||
`Current explanation draft: ${plan.explanation}`,
|
||
),
|
||
]);
|
||
|
||
const content = Array.isArray(response.content)
|
||
? response.content
|
||
.map((part: any) => (typeof part === 'string' ? part : part?.text || ''))
|
||
.join(' ')
|
||
: String(response.content || '');
|
||
const cleaned = content.trim().replace(/\s+/g, ' ');
|
||
|
||
console.log('AI-generated query explanation:', cleaned);
|
||
|
||
return cleaned || null;
|
||
} catch (error) {
|
||
this.logger.warn(`AI query explanation refinement failed: ${error.message}`);
|
||
return 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;
|
||
}
|
||
}
|