WIP - add meilisearch for easier record find for AI assistant
This commit is contained in:
@@ -4,9 +4,10 @@ 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';
|
||||
import { MeilisearchModule } from '../search/meilisearch.module';
|
||||
|
||||
@Module({
|
||||
imports: [ObjectModule, PageLayoutModule, TenantModule],
|
||||
imports: [ObjectModule, PageLayoutModule, TenantModule, MeilisearchModule],
|
||||
controllers: [AiAssistantController],
|
||||
providers: [AiAssistantService],
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
import { MeilisearchService } from '../search/meilisearch.service';
|
||||
|
||||
@Injectable()
|
||||
export class AiAssistantService {
|
||||
@@ -24,6 +25,7 @@ export class AiAssistantService {
|
||||
private readonly objectService: ObjectService,
|
||||
private readonly pageLayoutService: PageLayoutService,
|
||||
private readonly tenantDbService: TenantDatabaseService,
|
||||
private readonly meilisearchService: MeilisearchService,
|
||||
) {}
|
||||
|
||||
async handleChat(
|
||||
@@ -174,12 +176,21 @@ export class AiAssistantService {
|
||||
}
|
||||
|
||||
const newExtraction = openAiConfig
|
||||
? await this.extractWithOpenAI(openAiConfig, state.message, state.objectDefinition.label, fieldDefinitions)
|
||||
? await this.extractWithOpenAI(
|
||||
openAiConfig,
|
||||
state.message,
|
||||
state.objectDefinition.label,
|
||||
fieldDefinitions,
|
||||
)
|
||||
: this.extractWithHeuristics(state.message, fieldDefinitions);
|
||||
const mergedExtraction = {
|
||||
...(state.extractedFields || {}),
|
||||
...(newExtraction || {}),
|
||||
};
|
||||
const mergedExtraction = this.enrichPolymorphicLookupFromMessage(
|
||||
state.message,
|
||||
state.objectDefinition,
|
||||
{
|
||||
...(state.extractedFields || {}),
|
||||
...(newExtraction || {}),
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -220,6 +231,37 @@ export class AiAssistantService {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -610,6 +652,23 @@ export class AiAssistantService {
|
||||
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();
|
||||
@@ -839,6 +898,19 @@ export class AiAssistantService {
|
||||
: this.toTableName(targetApiName);
|
||||
|
||||
const providedValue = typeof value === 'string' ? value.trim() : value;
|
||||
if (providedValue && typeof providedValue === 'string') {
|
||||
const meiliMatch = await this.meilisearchService.searchRecord(
|
||||
resolvedTenantId,
|
||||
targetDefinition?.apiName || targetApiName,
|
||||
providedValue,
|
||||
displayField,
|
||||
);
|
||||
if (meiliMatch?.id) {
|
||||
resolvedFields[field.apiName] = meiliMatch.id;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const record =
|
||||
providedValue && typeof providedValue === 'string'
|
||||
? await knex(tableName)
|
||||
|
||||
@@ -9,9 +9,10 @@ import { MigrationModule } from '../migration/migration.module';
|
||||
import { RbacModule } from '../rbac/rbac.module';
|
||||
import { ModelRegistry } from './models/model.registry';
|
||||
import { ModelService } from './models/model.service';
|
||||
import { MeilisearchModule } from '../search/meilisearch.module';
|
||||
|
||||
@Module({
|
||||
imports: [TenantModule, MigrationModule, RbacModule],
|
||||
imports: [TenantModule, MigrationModule, RbacModule, MeilisearchModule],
|
||||
providers: [
|
||||
ObjectService,
|
||||
SchemaManagementService,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, Logger, BadRequestException } from '@nestjs/common';
|
||||
import { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||
import { CustomMigrationService } from '../migration/custom-migration.service';
|
||||
import { ModelService } from './models/model.service';
|
||||
@@ -8,6 +8,7 @@ import { ObjectDefinition } from '../models/object-definition.model';
|
||||
import { FieldDefinition } from '../models/field-definition.model';
|
||||
import { User } from '../models/user.model';
|
||||
import { ObjectMetadata } from './models/dynamic-model.factory';
|
||||
import { MeilisearchService } from '../search/meilisearch.service';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectService {
|
||||
@@ -18,6 +19,7 @@ export class ObjectService {
|
||||
private customMigrationService: CustomMigrationService,
|
||||
private modelService: ModelService,
|
||||
private authService: AuthorizationService,
|
||||
private meilisearchService: MeilisearchService,
|
||||
) {}
|
||||
|
||||
// Setup endpoints - Object metadata management
|
||||
@@ -820,7 +822,13 @@ export class ObjectService {
|
||||
...editableData,
|
||||
...(hasOwnerField ? { ownerId: userId } : {}),
|
||||
};
|
||||
const record = await boundModel.query().insert(recordData);
|
||||
const normalizedRecordData = await this.normalizePolymorphicRelatedObject(
|
||||
resolvedTenantId,
|
||||
objectApiName,
|
||||
recordData,
|
||||
);
|
||||
const record = await boundModel.query().insert(normalizedRecordData);
|
||||
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
@@ -881,8 +889,15 @@ export class ObjectService {
|
||||
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
await boundModel.query().where({ id: recordId }).update(editableData);
|
||||
return boundModel.query().where({ id: recordId }).first();
|
||||
const normalizedEditableData = await this.normalizePolymorphicRelatedObject(
|
||||
resolvedTenantId,
|
||||
objectApiName,
|
||||
editableData,
|
||||
);
|
||||
await boundModel.query().where({ id: recordId }).update(normalizedEditableData);
|
||||
const record = await boundModel.query().where({ id: recordId }).first();
|
||||
await this.indexRecord(resolvedTenantId, objectApiName, objectDefModel.fields, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async deleteRecord(
|
||||
@@ -932,10 +947,163 @@ export class ObjectService {
|
||||
// Use Objection model
|
||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||
await boundModel.query().where({ id: recordId }).delete();
|
||||
await this.removeIndexedRecord(resolvedTenantId, objectApiName, recordId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async indexRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
fields: FieldDefinition[],
|
||||
record: Record<string, any>,
|
||||
) {
|
||||
if (!this.meilisearchService.isEnabled() || !record?.id) return;
|
||||
|
||||
const fieldsToIndex = (fields || [])
|
||||
.map((field: any) => field.apiName)
|
||||
.filter((apiName) => apiName && !this.isSystemField(apiName));
|
||||
|
||||
await this.meilisearchService.upsertRecord(
|
||||
tenantId,
|
||||
objectApiName,
|
||||
record,
|
||||
fieldsToIndex,
|
||||
);
|
||||
}
|
||||
|
||||
private async removeIndexedRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
recordId: string,
|
||||
) {
|
||||
if (!this.meilisearchService.isEnabled()) return;
|
||||
await this.meilisearchService.deleteRecord(tenantId, objectApiName, recordId);
|
||||
}
|
||||
|
||||
private isSystemField(apiName: string): boolean {
|
||||
return [
|
||||
'id',
|
||||
'ownerId',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'tenantId',
|
||||
].includes(apiName);
|
||||
}
|
||||
|
||||
private async normalizePolymorphicRelatedObject(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
data: any,
|
||||
): Promise<any> {
|
||||
if (!data || !this.isContactDetailApi(objectApiName)) return data;
|
||||
|
||||
const relatedObjectType = data.relatedObjectType;
|
||||
const relatedObjectId = data.relatedObjectId;
|
||||
if (!relatedObjectType || !relatedObjectId) return data;
|
||||
|
||||
const normalizedType = this.toPolymorphicApiName(relatedObjectType);
|
||||
if (!normalizedType) return data;
|
||||
|
||||
if (this.isUuid(String(relatedObjectId))) {
|
||||
return {
|
||||
...data,
|
||||
relatedObjectType: normalizedType,
|
||||
};
|
||||
}
|
||||
|
||||
let targetDefinition: any;
|
||||
try {
|
||||
targetDefinition = await this.getObjectDefinition(tenantId, normalizedType.toLowerCase());
|
||||
} catch (error) {
|
||||
this.logger.warn(`Failed to load definition for ${normalizedType}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!targetDefinition) {
|
||||
throw new BadRequestException(
|
||||
`Unable to resolve ${normalizedType} for "${relatedObjectId}". Please provide a valid record.`,
|
||||
);
|
||||
}
|
||||
|
||||
const displayField = this.getDisplayFieldForObjectDefinition(targetDefinition);
|
||||
const tableName = this.getTableName(
|
||||
targetDefinition.apiName,
|
||||
targetDefinition.label,
|
||||
targetDefinition.pluralLabel,
|
||||
);
|
||||
|
||||
let resolvedId: string | null = null;
|
||||
|
||||
if (this.meilisearchService.isEnabled()) {
|
||||
const match = await this.meilisearchService.searchRecord(
|
||||
tenantId,
|
||||
targetDefinition.apiName,
|
||||
String(relatedObjectId),
|
||||
displayField,
|
||||
);
|
||||
if (match?.id) {
|
||||
resolvedId = match.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedId) {
|
||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
const record = await knex(tableName)
|
||||
.whereRaw('LOWER(??) = ?', [displayField, String(relatedObjectId).toLowerCase()])
|
||||
.first();
|
||||
if (record?.id) {
|
||||
resolvedId = record.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedId) {
|
||||
throw new BadRequestException(
|
||||
`Could not find ${normalizedType} matching "${relatedObjectId}". Please use an existing record.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
relatedObjectId: resolvedId,
|
||||
relatedObjectType: normalizedType,
|
||||
};
|
||||
}
|
||||
|
||||
private isContactDetailApi(objectApiName: string): boolean {
|
||||
if (!objectApiName) return false;
|
||||
const normalized = objectApiName.toLowerCase();
|
||||
return ['contactdetail', 'contact_detail', 'contactdetails', 'contact_details'].includes(
|
||||
normalized,
|
||||
);
|
||||
}
|
||||
|
||||
private toPolymorphicApiName(raw: string): string | null {
|
||||
if (!raw) return null;
|
||||
const normalized = raw.toLowerCase();
|
||||
if (normalized === 'account' || normalized === 'accounts') return 'Account';
|
||||
if (normalized === 'contact' || normalized === 'contacts') return 'Contact';
|
||||
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 getDisplayFieldForObjectDefinition(objectDefinition: any): string {
|
||||
if (!objectDefinition?.fields) return 'id';
|
||||
const hasName = objectDefinition.fields.some((field: any) => field.apiName === 'name');
|
||||
if (hasName) return 'name';
|
||||
|
||||
const firstText = objectDefinition.fields.find((field: any) =>
|
||||
['STRING', 'TEXT', 'EMAIL'].includes(String(field.type || '').toUpperCase()),
|
||||
);
|
||||
return firstText?.apiName || 'id';
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a field definition
|
||||
* Can update metadata (label, description, placeholder, helpText, etc.) safely
|
||||
|
||||
8
backend/src/search/meilisearch.module.ts
Normal file
8
backend/src/search/meilisearch.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeilisearchService } from './meilisearch.service';
|
||||
|
||||
@Module({
|
||||
providers: [MeilisearchService],
|
||||
exports: [MeilisearchService],
|
||||
})
|
||||
export class MeilisearchModule {}
|
||||
200
backend/src/search/meilisearch.service.ts
Normal file
200
backend/src/search/meilisearch.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
type MeiliConfig = {
|
||||
host: string;
|
||||
apiKey?: string;
|
||||
indexPrefix: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MeilisearchService {
|
||||
private readonly logger = new Logger(MeilisearchService.name);
|
||||
|
||||
isEnabled(): boolean {
|
||||
return Boolean(this.getConfig());
|
||||
}
|
||||
|
||||
async searchRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
query: string,
|
||||
displayField?: string,
|
||||
): Promise<{ id: string; hit: any } | null> {
|
||||
const config = this.getConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/search`;
|
||||
|
||||
try {
|
||||
const response = await this.requestJson('POST', url, {
|
||||
q: query,
|
||||
limit: 5,
|
||||
}, this.buildHeaders(config));
|
||||
|
||||
if (!this.isSuccessStatus(response.status)) {
|
||||
this.logger.warn(
|
||||
`Meilisearch query failed for index ${indexName}: ${response.status}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hits = Array.isArray(response.body?.hits) ? response.body.hits : [];
|
||||
if (hits.length === 0) return null;
|
||||
|
||||
if (displayField) {
|
||||
const loweredQuery = query.toLowerCase();
|
||||
const exactMatch = hits.find((hit: any) => {
|
||||
const value = hit?.[displayField];
|
||||
return value && String(value).toLowerCase() === loweredQuery;
|
||||
});
|
||||
if (exactMatch?.id) {
|
||||
return { id: exactMatch.id, hit: exactMatch };
|
||||
}
|
||||
}
|
||||
|
||||
const match = hits[0];
|
||||
if (match?.id) {
|
||||
return { id: match.id, hit: match };
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Meilisearch lookup failed: ${error.message}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async upsertRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
record: Record<string, any>,
|
||||
fieldsToIndex: string[],
|
||||
): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
if (!config || !record?.id) return;
|
||||
|
||||
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents?primaryKey=id`;
|
||||
const document = this.pickRecordFields(record, fieldsToIndex);
|
||||
|
||||
try {
|
||||
const response = await this.requestJson('POST', url, [document], this.buildHeaders(config));
|
||||
if (!this.isSuccessStatus(response.status)) {
|
||||
this.logger.warn(
|
||||
`Meilisearch upsert failed for index ${indexName}: ${response.status}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Meilisearch upsert failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
recordId: string,
|
||||
): Promise<void> {
|
||||
const config = this.getConfig();
|
||||
if (!config || !recordId) return;
|
||||
|
||||
const indexName = this.buildIndexName(config, tenantId, objectApiName);
|
||||
const url = `${config.host}/indexes/${encodeURIComponent(indexName)}/documents/${encodeURIComponent(recordId)}`;
|
||||
|
||||
try {
|
||||
const response = await this.requestJson('DELETE', url, undefined, this.buildHeaders(config));
|
||||
if (!this.isSuccessStatus(response.status)) {
|
||||
this.logger.warn(
|
||||
`Meilisearch delete failed for index ${indexName}: ${response.status}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn(`Meilisearch delete failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getConfig(): MeiliConfig | null {
|
||||
const host = process.env.MEILI_HOST || process.env.MEILISEARCH_HOST;
|
||||
if (!host) return null;
|
||||
const trimmedHost = host.replace(/\/+$/, '');
|
||||
const apiKey = process.env.MEILI_API_KEY || process.env.MEILISEARCH_API_KEY;
|
||||
const indexPrefix = process.env.MEILI_INDEX_PREFIX || 'tenant_';
|
||||
return { host: trimmedHost, apiKey, indexPrefix };
|
||||
}
|
||||
|
||||
private buildIndexName(config: MeiliConfig, tenantId: string, objectApiName: string): string {
|
||||
return `${config.indexPrefix}${tenantId}_${objectApiName}`.toLowerCase();
|
||||
}
|
||||
|
||||
private buildHeaders(config: MeiliConfig): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
if (config.apiKey) {
|
||||
headers['X-Meili-API-Key'] = config.apiKey;
|
||||
headers.Authorization = `Bearer ${config.apiKey}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private pickRecordFields(record: Record<string, any>, fields: string[]): Record<string, any> {
|
||||
const document: Record<string, any> = { id: record.id };
|
||||
for (const field of fields) {
|
||||
if (record[field] !== undefined) {
|
||||
document[field] = record[field];
|
||||
}
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private isSuccessStatus(status: number): boolean {
|
||||
return status >= 200 && status < 300;
|
||||
}
|
||||
|
||||
private requestJson(
|
||||
method: 'POST' | 'DELETE',
|
||||
url: string,
|
||||
payload: any,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsedUrl = new URL(url);
|
||||
const client = parsedUrl.protocol === 'https:' ? https : http;
|
||||
const request = client.request(
|
||||
{
|
||||
method,
|
||||
hostname: parsedUrl.hostname,
|
||||
port: parsedUrl.port,
|
||||
path: `${parsedUrl.pathname}${parsedUrl.search}`,
|
||||
headers,
|
||||
},
|
||||
(response) => {
|
||||
let data = '';
|
||||
response.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
response.on('end', () => {
|
||||
if (!data) {
|
||||
resolve({ status: response.statusCode || 0, body: null });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const body = JSON.parse(data);
|
||||
resolve({ status: response.statusCode || 0, body });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
request.on('error', reject);
|
||||
if (payload !== undefined) {
|
||||
request.write(JSON.stringify(payload));
|
||||
}
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user