Use LLM refinement for query explanation text

This commit is contained in:
phyroslam
2026-04-09 08:58:09 -07:00
parent f68321c802
commit ed48623f27

View File

@@ -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<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, ' ');
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[],