Files
neo/backend/src/ai-assistant/ai-assistant.service.ts
2026-04-10 10:37:11 +02:00

3109 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (25 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;
}
}