Files
neo/backend/src/ai-assistant/ai-assistant.service.ts
2026-01-18 09:15:06 +01:00

2795 lines
96 KiB
TypeScript

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