Compare commits
7 Commits
codex/fix-
...
copilot-wo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fc235636 | ||
|
|
c7282ee2a0 | ||
|
|
ce65817670 | ||
|
|
fe51355d29 | ||
|
|
4f466d7992 | ||
|
|
c822017ef1 | ||
|
|
8b192ba7f5 |
@@ -2,94 +2,24 @@
|
|||||||
* @param { import("knex").Knex } knex
|
* @param { import("knex").Knex } knex
|
||||||
* @returns { Promise<void> }
|
* @returns { Promise<void> }
|
||||||
*/
|
*/
|
||||||
exports.up = async function(knex) {
|
exports.up = function(knex) {
|
||||||
// Check if layout_type column already exists (in case of partial migration)
|
return knex.schema.alterTable('page_layouts', (table) => {
|
||||||
const hasLayoutType = await knex.schema.hasColumn('page_layouts', 'layout_type');
|
// Add layout_type column to distinguish between detail/edit layouts and list view layouts
|
||||||
|
// Default to 'detail' for existing layouts
|
||||||
// Check if the old index exists
|
table.enum('layout_type', ['detail', 'list']).notNullable().defaultTo('detail').after('name');
|
||||||
const [indexes] = await knex.raw(`SHOW INDEX FROM page_layouts WHERE Key_name = 'page_layouts_object_id_is_default_index'`);
|
|
||||||
const hasOldIndex = indexes.length > 0;
|
// Update the unique index to include layout_type so we can have both a default detail and default list layout
|
||||||
|
table.dropIndex(['object_id', 'is_default']);
|
||||||
// Check if foreign key exists
|
});
|
||||||
const [fks] = await knex.raw(`
|
|
||||||
SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
|
||||||
AND TABLE_NAME = 'page_layouts'
|
|
||||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
|
||||||
AND CONSTRAINT_NAME = 'page_layouts_object_id_foreign'
|
|
||||||
`);
|
|
||||||
const hasForeignKey = fks.length > 0;
|
|
||||||
|
|
||||||
if (hasOldIndex) {
|
|
||||||
// First, drop the foreign key constraint that depends on the index (if it exists)
|
|
||||||
if (hasForeignKey) {
|
|
||||||
await knex.schema.alterTable('page_layouts', (table) => {
|
|
||||||
table.dropForeign(['object_id']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now we can safely drop the old index
|
|
||||||
await knex.schema.alterTable('page_layouts', (table) => {
|
|
||||||
table.dropIndex(['object_id', 'is_default']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add layout_type column if it doesn't exist
|
|
||||||
if (!hasLayoutType) {
|
|
||||||
await knex.schema.alterTable('page_layouts', (table) => {
|
|
||||||
// Add layout_type column to distinguish between detail/edit layouts and list view layouts
|
|
||||||
// Default to 'detail' for existing layouts
|
|
||||||
table.enum('layout_type', ['detail', 'list']).notNullable().defaultTo('detail').after('name');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if new index exists
|
|
||||||
const [newIndexes] = await knex.raw(`SHOW INDEX FROM page_layouts WHERE Key_name = 'page_layouts_object_id_layout_type_is_default_index'`);
|
|
||||||
const hasNewIndex = newIndexes.length > 0;
|
|
||||||
|
|
||||||
if (!hasNewIndex) {
|
|
||||||
// Create new index including layout_type
|
|
||||||
await knex.schema.alterTable('page_layouts', (table) => {
|
|
||||||
table.index(['object_id', 'layout_type', 'is_default']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-check if foreign key exists (may have been dropped above or in previous attempt)
|
|
||||||
const [fksAfter] = await knex.raw(`
|
|
||||||
SELECT CONSTRAINT_NAME FROM information_schema.TABLE_CONSTRAINTS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE()
|
|
||||||
AND TABLE_NAME = 'page_layouts'
|
|
||||||
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
|
|
||||||
AND CONSTRAINT_NAME = 'page_layouts_object_id_foreign'
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (fksAfter.length === 0) {
|
|
||||||
// Re-add the foreign key constraint
|
|
||||||
await knex.schema.alterTable('page_layouts', (table) => {
|
|
||||||
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param { import("knex").Knex } knex
|
* @param { import("knex").Knex } knex
|
||||||
* @returns { Promise<void> }
|
* @returns { Promise<void> }
|
||||||
*/
|
*/
|
||||||
exports.down = async function(knex) {
|
exports.down = function(knex) {
|
||||||
// Drop the foreign key first
|
return knex.schema.alterTable('page_layouts', (table) => {
|
||||||
await knex.schema.alterTable('page_layouts', (table) => {
|
|
||||||
table.dropForeign(['object_id']);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drop the new index and column, restore old index
|
|
||||||
await knex.schema.alterTable('page_layouts', (table) => {
|
|
||||||
table.dropIndex(['object_id', 'layout_type', 'is_default']);
|
|
||||||
table.dropColumn('layout_type');
|
table.dropColumn('layout_type');
|
||||||
table.index(['object_id', 'is_default']);
|
table.index(['object_id', 'is_default']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-add the foreign key constraint
|
|
||||||
await knex.schema.alterTable('page_layouts', (table) => {
|
|
||||||
table.foreign('object_id').references('id').inTable('object_definitions').onDelete('CASCADE');
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2279,28 +2279,10 @@ export class AiAssistantService {
|
|||||||
|
|
||||||
const content = typeof response.content === 'string' ? response.content : '{}';
|
const content = typeof response.content === 'string' ? response.content : '{}';
|
||||||
const parsed = await parser.parse(content);
|
const parsed = await parser.parse(content);
|
||||||
const normalizedPlan = this.normalizeSearchPlan(parsed, message, objectDefinition);
|
return this.normalizeSearchPlan(parsed, message);
|
||||||
|
|
||||||
if (normalizedPlan.strategy === 'query') {
|
|
||||||
const aiExplanation = await this.generateQueryExplanationWithAi(
|
|
||||||
model,
|
|
||||||
message,
|
|
||||||
objectDefinition,
|
|
||||||
normalizedPlan,
|
|
||||||
);
|
|
||||||
if (aiExplanation) {
|
|
||||||
normalizedPlan.explanation = aiExplanation;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizedPlan;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeSearchPlan(
|
private normalizeSearchPlan(plan: AiSearchPlan, message: string): AiSearchPlan {
|
||||||
plan: AiSearchPlan,
|
|
||||||
message: string,
|
|
||||||
objectDefinition?: any,
|
|
||||||
): AiSearchPlan {
|
|
||||||
if (!plan || typeof plan !== 'object') {
|
if (!plan || typeof plan !== 'object') {
|
||||||
return this.buildSearchPlanFallback(message);
|
return this.buildSearchPlanFallback(message);
|
||||||
}
|
}
|
||||||
@@ -2320,155 +2302,15 @@ export class AiAssistantService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryExplanation = this.buildQueryExplanation(
|
|
||||||
message,
|
|
||||||
objectDefinition,
|
|
||||||
Array.isArray(plan.filters) ? plan.filters : [],
|
|
||||||
plan.sort || null,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
strategy,
|
strategy,
|
||||||
explanation: queryExplanation || explanation,
|
explanation,
|
||||||
keyword: null,
|
keyword: null,
|
||||||
filters: Array.isArray(plan.filters) ? plan.filters : [],
|
filters: Array.isArray(plan.filters) ? plan.filters : [],
|
||||||
sort: plan.sort || null,
|
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(
|
private sanitizeUserOwnerFields(
|
||||||
fields: Record<string, any>,
|
fields: Record<string, any>,
|
||||||
fieldDefinitions: any[],
|
fieldDefinitions: any[],
|
||||||
|
|||||||
Reference in New Issue
Block a user