From ed48623f27304b84250921066ea3fdbbc5811660 Mon Sep 17 00:00:00 2001 From: phyroslam Date: Thu, 9 Apr 2026 08:58:09 -0700 Subject: [PATCH] Use LLM refinement for query explanation text --- .../src/ai-assistant/ai-assistant.service.ts | 164 +++++++++++++++++- 1 file changed, 161 insertions(+), 3 deletions(-) diff --git a/backend/src/ai-assistant/ai-assistant.service.ts b/backend/src/ai-assistant/ai-assistant.service.ts index d7c0624..1e3670b 100644 --- a/backend/src/ai-assistant/ai-assistant.service.ts +++ b/backend/src/ai-assistant/ai-assistant.service.ts @@ -2279,10 +2279,28 @@ export class AiAssistantService { const content = typeof response.content === 'string' ? response.content : '{}'; const parsed = await parser.parse(content); - return this.normalizeSearchPlan(parsed, message); + const normalizedPlan = this.normalizeSearchPlan(parsed, message, objectDefinition); + + 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): AiSearchPlan { + private normalizeSearchPlan( + plan: AiSearchPlan, + message: string, + objectDefinition?: any, + ): AiSearchPlan { if (!plan || typeof plan !== 'object') { return this.buildSearchPlanFallback(message); } @@ -2302,15 +2320,155 @@ export class AiAssistantService { }; } + const queryExplanation = this.buildQueryExplanation( + message, + objectDefinition, + Array.isArray(plan.filters) ? plan.filters : [], + plan.sort || null, + ); + return { strategy, - explanation, + 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( + (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 { + 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 { + 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, ' '); + return cleaned || null; + } catch (error) { + this.logger.warn(`AI query explanation refinement failed: ${error.message}`); + return null; + } + } + private sanitizeUserOwnerFields( fields: Record, fieldDefinitions: any[],