WIP - add meilisearch for easier record find for AI assistant

This commit is contained in:
Francisco Gaona
2026-01-13 07:44:47 +01:00
parent a62f68fc10
commit 730fddd181
8 changed files with 482 additions and 11 deletions

View File

@@ -5,6 +5,11 @@ DATABASE_URL="mysql://platform:platform@db:3306/platform"
CENTRAL_DATABASE_URL="mysql://root:asjdnfqTash37faggT@db:3306/central_platform"
REDIS_URL="redis://redis:6379"
# Meilisearch (optional)
MEILI_HOST="http://meilisearch:7700"
MEILI_API_KEY="dev-meili-master-key"
MEILI_INDEX_PREFIX="tenant_"
# JWT, multi-tenant hints, etc.
JWT_SECRET="devsecret"
TENANCY_STRATEGY="single-db"

View File

@@ -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],
})

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MeilisearchService } from './meilisearch.service';
@Module({
providers: [MeilisearchService],
exports: [MeilisearchService],
})
export class MeilisearchModule {}

View 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();
});
}
}

View File

@@ -17,6 +17,7 @@ services:
depends_on:
- db
- redis
- meilisearch
networks:
- platform-network
@@ -66,9 +67,24 @@ services:
networks:
- platform-network
meilisearch:
image: getmeili/meilisearch:v1.7
container_name: platform-meilisearch
restart: unless-stopped
environment:
MEILI_ENV: development
MEILI_MASTER_KEY: ${MEILI_MASTER_KEY:-dev-meili-master-key}
ports:
- "7700:7700"
volumes:
- meili-data:/meili_data
networks:
- platform-network
volumes:
percona-data:
redis-data:
meili-data:
networks:
platform-network: