WIP - initial AI assistant chat working creating records
This commit is contained in:
27
backend/src/ai-assistant/ai-assistant.controller.ts
Normal file
27
backend/src/ai-assistant/ai-assistant.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { TenantId } from '../tenant/tenant.decorator';
|
||||
import { AiAssistantService } from './ai-assistant.service';
|
||||
import { AiChatRequestDto } from './dto/ai-chat.dto';
|
||||
|
||||
@Controller('ai')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class AiAssistantController {
|
||||
constructor(private readonly aiAssistantService: AiAssistantService) {}
|
||||
|
||||
@Post('chat')
|
||||
async chat(
|
||||
@TenantId() tenantId: string,
|
||||
@CurrentUser() user: any,
|
||||
@Body() payload: AiChatRequestDto,
|
||||
) {
|
||||
return this.aiAssistantService.handleChat(
|
||||
tenantId,
|
||||
user.userId,
|
||||
payload.message,
|
||||
payload.history,
|
||||
payload.context,
|
||||
);
|
||||
}
|
||||
}
|
||||
13
backend/src/ai-assistant/ai-assistant.module.ts
Normal file
13
backend/src/ai-assistant/ai-assistant.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AiAssistantController } from './ai-assistant.controller';
|
||||
import { AiAssistantService } from './ai-assistant.service';
|
||||
import { ObjectModule } from '../object/object.module';
|
||||
import { PageLayoutModule } from '../page-layout/page-layout.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
|
||||
@Module({
|
||||
imports: [ObjectModule, PageLayoutModule, TenantModule],
|
||||
controllers: [AiAssistantController],
|
||||
providers: [AiAssistantService],
|
||||
})
|
||||
export class AiAssistantModule {}
|
||||
928
backend/src/ai-assistant/ai-assistant.service.ts
Normal file
928
backend/src/ai-assistant/ai-assistant.service.ts
Normal file
@@ -0,0 +1,928 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JsonOutputParser } from '@langchain/core/output_parsers';
|
||||
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
|
||||
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 } from './ai-assistant.types';
|
||||
|
||||
@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
|
||||
|
||||
constructor(
|
||||
private readonly objectService: ObjectService,
|
||||
private readonly pageLayoutService: PageLayoutService,
|
||||
private readonly tenantDbService: TenantDatabaseService,
|
||||
) {}
|
||||
|
||||
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) : [];
|
||||
const initialState: AiAssistantState = {
|
||||
message: this.combineHistory(trimmedHistory, message),
|
||||
history: trimmedHistory,
|
||||
context: context || {},
|
||||
extractedFields: prior?.fields,
|
||||
};
|
||||
|
||||
const finalState = await this.runAssistantGraph(tenantId, userId, initialState);
|
||||
|
||||
if (finalState.record) {
|
||||
this.conversationState.delete(conversationKey);
|
||||
} else if (finalState.extractedFields && Object.keys(finalState.extractedFields).length > 0) {
|
||||
this.conversationState.set(conversationKey, {
|
||||
fields: finalState.extractedFields,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
reply: finalState.reply || 'How can I help?',
|
||||
action: finalState.action,
|
||||
missingFields: finalState.missingFields,
|
||||
record: finalState.record,
|
||||
};
|
||||
}
|
||||
|
||||
private async runAssistantGraph(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
state: AiAssistantState,
|
||||
): Promise<AiAssistantState> {
|
||||
const AssistantState = Annotation.Root({
|
||||
message: Annotation<string>(),
|
||||
history: Annotation<AiAssistantState['history']>(),
|
||||
context: Annotation<AiAssistantState['context']>(),
|
||||
objectDefinition: Annotation<any>(),
|
||||
pageLayout: Annotation<any>(),
|
||||
extractedFields: Annotation<Record<string, any>>(),
|
||||
requiredFields: Annotation<string[]>(),
|
||||
missingFields: Annotation<string[]>(),
|
||||
action: Annotation<AiAssistantState['action']>(),
|
||||
record: Annotation<any>(),
|
||||
reply: Annotation<string>(),
|
||||
});
|
||||
|
||||
const workflow = new StateGraph(AssistantState)
|
||||
.addNode('loadContext', async (current: AiAssistantState) => {
|
||||
return this.loadContext(tenantId, current);
|
||||
})
|
||||
.addNode('extractFields', async (current: AiAssistantState) => {
|
||||
return this.extractFields(tenantId, current);
|
||||
})
|
||||
.addNode('decideNext', async (current: AiAssistantState) => {
|
||||
return this.decideNextStep(current);
|
||||
})
|
||||
.addNode('createRecord', async (current: AiAssistantState) => {
|
||||
return this.createRecord(tenantId, userId, current);
|
||||
})
|
||||
.addNode('respondMissing', async (current: AiAssistantState) => {
|
||||
return this.respondWithMissingFields(current);
|
||||
})
|
||||
.addEdge(START, 'loadContext')
|
||||
.addEdge('loadContext', 'extractFields')
|
||||
.addEdge('extractFields', 'decideNext')
|
||||
.addConditionalEdges('decideNext', (current: AiAssistantState) => {
|
||||
return current.action === 'create_record' ? 'createRecord' : 'respondMissing';
|
||||
})
|
||||
.addEdge('createRecord', END)
|
||||
.addEdge('respondMissing', END);
|
||||
|
||||
const graph = workflow.compile();
|
||||
return graph.invoke(state);
|
||||
}
|
||||
|
||||
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 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 = {
|
||||
...(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 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,
|
||||
);
|
||||
|
||||
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'),
|
||||
);
|
||||
|
||||
// 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) {
|
||||
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();
|
||||
}
|
||||
|
||||
return extracted;
|
||||
}
|
||||
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
32
backend/src/ai-assistant/ai-assistant.types.ts
Normal file
32
backend/src/ai-assistant/ai-assistant.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface AiChatMessage {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface AiChatContext {
|
||||
objectApiName?: string;
|
||||
view?: string;
|
||||
recordId?: string;
|
||||
route?: string;
|
||||
}
|
||||
|
||||
export interface AiAssistantReply {
|
||||
reply: string;
|
||||
action?: 'create_record' | 'collect_fields' | 'clarify';
|
||||
missingFields?: string[];
|
||||
record?: any;
|
||||
}
|
||||
|
||||
export interface AiAssistantState {
|
||||
message: string;
|
||||
history?: AiChatMessage[];
|
||||
context: AiChatContext;
|
||||
objectDefinition?: any;
|
||||
pageLayout?: any;
|
||||
extractedFields?: Record<string, any>;
|
||||
requiredFields?: string[];
|
||||
missingFields?: string[];
|
||||
action?: AiAssistantReply['action'];
|
||||
record?: any;
|
||||
reply?: string;
|
||||
}
|
||||
36
backend/src/ai-assistant/dto/ai-chat.dto.ts
Normal file
36
backend/src/ai-assistant/dto/ai-chat.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||
import { AiChatMessageDto } from './ai-chat.message.dto';
|
||||
|
||||
export class AiChatContextDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
objectApiName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
view?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
recordId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
route?: string;
|
||||
}
|
||||
|
||||
export class AiChatRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
message: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
context?: AiChatContextDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AiChatMessageDto)
|
||||
history?: AiChatMessageDto[];
|
||||
}
|
||||
10
backend/src/ai-assistant/dto/ai-chat.message.dto.ts
Normal file
10
backend/src/ai-assistant/dto/ai-chat.message.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsIn, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class AiChatMessageDto {
|
||||
@IsIn(['user', 'assistant'])
|
||||
role: 'user' | 'assistant';
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
text: string;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { ObjectModule } from './object/object.module';
|
||||
import { AppBuilderModule } from './app-builder/app-builder.module';
|
||||
import { PageLayoutModule } from './page-layout/page-layout.module';
|
||||
import { VoiceModule } from './voice/voice.module';
|
||||
import { AiAssistantModule } from './ai-assistant/ai-assistant.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -22,6 +23,7 @@ import { VoiceModule } from './voice/voice.module';
|
||||
AppBuilderModule,
|
||||
PageLayoutModule,
|
||||
VoiceModule,
|
||||
AiAssistantModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
Reference in New Issue
Block a user