Add Groq as AI provider + new inline AI tasks, extend database to support more prompt types

This commit is contained in:
2026-01-20 10:04:01 -05:00
parent 7218e7cc3f
commit 167c13c572
24 changed files with 3521 additions and 315 deletions

View File

@@ -0,0 +1,117 @@
/**
* Description Validation Prompts
*
* Functions for building and parsing description validation prompts.
* System and general prompts are loaded from the database.
*/
/**
* Build the user prompt for description validation
* Combines database prompts with product data
*
* @param {Object} product - Product data
* @param {string} product.name - Product name
* @param {string} product.description - Current description
* @param {string} [product.company_name] - Company name
* @param {string} [product.categories] - Product categories
* @param {Object} prompts - Prompts loaded from database
* @param {string} prompts.general - General description guidelines
* @param {string} [prompts.companySpecific] - Company-specific rules
* @returns {string} Complete user prompt
*/
function buildDescriptionUserPrompt(product, prompts) {
const parts = [];
// Add general prompt/guidelines if provided
if (prompts.general) {
parts.push(prompts.general);
parts.push(''); // Empty line for separation
}
// Add company-specific rules if provided
if (prompts.companySpecific) {
parts.push(`COMPANY-SPECIFIC RULES FOR ${product.company_name || 'THIS COMPANY'}:`);
parts.push(prompts.companySpecific);
parts.push(''); // Empty line for separation
}
// Add product information
parts.push('PRODUCT TO VALIDATE:');
parts.push(`NAME: "${product.name || ''}"`);
parts.push(`COMPANY: ${product.company_name || 'Unknown'}`);
if (product.categories) {
parts.push(`CATEGORIES: ${product.categories}`);
}
parts.push('');
parts.push('CURRENT DESCRIPTION:');
parts.push(`"${product.description || '(empty)'}"`);
// Add response format instructions
parts.push('');
parts.push('If the description is empty or very short, suggest a complete description based on the product name.');
parts.push('');
parts.push('RESPOND WITH JSON:');
parts.push(JSON.stringify({
isValid: 'true/false',
suggestion: 'improved description if changes needed, or null if valid',
issues: ['issue 1', 'issue 2 (empty array if valid)']
}, null, 2));
return parts.join('\n');
}
/**
* Parse the AI response for description validation
*
* @param {Object|null} parsed - Parsed JSON from AI
* @param {string} content - Raw response content
* @returns {Object}
*/
function parseDescriptionResponse(parsed, content) {
// If we got valid parsed JSON, use it
if (parsed && typeof parsed.isValid === 'boolean') {
return {
isValid: parsed.isValid,
suggestion: parsed.suggestion || null,
issues: Array.isArray(parsed.issues) ? parsed.issues : []
};
}
// Try to extract from content if parsing failed
try {
// Look for isValid pattern
const isValidMatch = content.match(/"isValid"\s*:\s*(true|false)/i);
const isValid = isValidMatch ? isValidMatch[1].toLowerCase() === 'true' : true;
// Look for suggestion (might be multiline)
const suggestionMatch = content.match(/"suggestion"\s*:\s*"((?:[^"\\]|\\.)*)"/s);
let suggestion = suggestionMatch ? suggestionMatch[1] : null;
if (suggestion) {
// Unescape common escapes
suggestion = suggestion.replace(/\\n/g, '\n').replace(/\\"/g, '"');
}
// Look for issues array
const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/);
let issues = [];
if (issuesMatch) {
const issuesContent = issuesMatch[1];
const issueStrings = issuesContent.match(/"([^"]+)"/g);
if (issueStrings) {
issues = issueStrings.map(s => s.replace(/"/g, ''));
}
}
return { isValid, suggestion, issues };
} catch {
// Default to valid if we can't parse anything
return { isValid: true, suggestion: null, issues: [] };
}
}
module.exports = {
buildDescriptionUserPrompt,
parseDescriptionResponse
};

View File

@@ -0,0 +1,108 @@
/**
* Name Validation Prompts
*
* Functions for building and parsing name validation prompts.
* System and general prompts are loaded from the database.
*/
/**
* Build the user prompt for name validation
* Combines database prompts with product data
*
* @param {Object} product - Product data
* @param {string} product.name - Current product name
* @param {string} [product.company_name] - Company name
* @param {string} [product.line_name] - Product line name
* @param {string} [product.description] - Product description (for context)
* @param {Object} prompts - Prompts loaded from database
* @param {string} prompts.general - General naming conventions
* @param {string} [prompts.companySpecific] - Company-specific rules
* @returns {string} Complete user prompt
*/
function buildNameUserPrompt(product, prompts) {
const parts = [];
// Add general prompt/conventions if provided
if (prompts.general) {
parts.push(prompts.general);
parts.push(''); // Empty line for separation
}
// Add company-specific rules if provided
if (prompts.companySpecific) {
parts.push(`COMPANY-SPECIFIC RULES FOR ${product.company_name || 'THIS COMPANY'}:`);
parts.push(prompts.companySpecific);
parts.push(''); // Empty line for separation
}
// Add product information
parts.push('PRODUCT TO VALIDATE:');
parts.push(`NAME: "${product.name || ''}"`);
parts.push(`COMPANY: ${product.company_name || 'Unknown'}`);
parts.push(`LINE: ${product.line_name || 'None'}`);
if (product.description) {
parts.push(`DESCRIPTION (for context): ${product.description.substring(0, 200)}`);
}
// Add response format instructions
parts.push('');
parts.push('RESPOND WITH JSON:');
parts.push(JSON.stringify({
isValid: 'true/false',
suggestion: 'corrected name if changes needed, or null if valid',
issues: ['issue 1', 'issue 2 (empty array if valid)']
}, null, 2));
return parts.join('\n');
}
/**
* Parse the AI response for name validation
*
* @param {Object|null} parsed - Parsed JSON from AI
* @param {string} content - Raw response content
* @returns {Object}
*/
function parseNameResponse(parsed, content) {
// If we got valid parsed JSON, use it
if (parsed && typeof parsed.isValid === 'boolean') {
return {
isValid: parsed.isValid,
suggestion: parsed.suggestion || null,
issues: Array.isArray(parsed.issues) ? parsed.issues : []
};
}
// Try to extract from content if parsing failed
try {
// Look for isValid pattern
const isValidMatch = content.match(/"isValid"\s*:\s*(true|false)/i);
const isValid = isValidMatch ? isValidMatch[1].toLowerCase() === 'true' : true;
// Look for suggestion
const suggestionMatch = content.match(/"suggestion"\s*:\s*"([^"]+)"/);
const suggestion = suggestionMatch ? suggestionMatch[1] : null;
// Look for issues array
const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/);
let issues = [];
if (issuesMatch) {
const issuesContent = issuesMatch[1];
const issueStrings = issuesContent.match(/"([^"]+)"/g);
if (issueStrings) {
issues = issueStrings.map(s => s.replace(/"/g, ''));
}
}
return { isValid, suggestion, issues };
} catch {
// Default to valid if we can't parse anything
return { isValid: true, suggestion: null, issues: [] };
}
}
module.exports = {
buildNameUserPrompt,
parseNameResponse
};

View File

@@ -0,0 +1,194 @@
/**
* Prompt Loader
*
* Utilities to load AI prompts from the ai_prompts PostgreSQL table.
* Supports loading prompts by base type (e.g., 'name_validation' loads
* name_validation_system, name_validation_general, and optionally
* name_validation_company_specific).
*/
/**
* Load a single prompt by exact type
* @param {Object} pool - PostgreSQL pool
* @param {string} promptType - Exact prompt type (e.g., 'name_validation_system')
* @param {string} [company] - Company identifier (for company_specific types)
* @returns {Promise<string|null>} Prompt text or null if not found
*/
async function loadPromptByType(pool, promptType, company = null) {
try {
let result;
if (company) {
result = await pool.query(
'SELECT prompt_text FROM ai_prompts WHERE prompt_type = $1 AND company = $2',
[promptType, company]
);
} else {
result = await pool.query(
'SELECT prompt_text FROM ai_prompts WHERE prompt_type = $1 AND company IS NULL',
[promptType]
);
}
return result.rows[0]?.prompt_text || null;
} catch (error) {
console.error(`[PromptLoader] Error loading ${promptType} prompt:`, error.message);
return null;
}
}
/**
* Load all prompts for a task type (system, general, and optionally company-specific)
*
* @param {Object} pool - PostgreSQL pool
* @param {string} baseType - Base type name (e.g., 'name_validation', 'description_validation')
* @param {string|null} [company] - Optional company ID for company-specific prompts
* @returns {Promise<{system: string|null, general: string|null, companySpecific: string|null}>}
*/
async function loadPromptsByType(pool, baseType, company = null) {
const systemType = `${baseType}_system`;
const generalType = `${baseType}_general`;
const companyType = `${baseType}_company_specific`;
// Load system and general prompts in parallel
const [system, general] = await Promise.all([
loadPromptByType(pool, systemType),
loadPromptByType(pool, generalType)
]);
// Load company-specific prompt if company is provided
let companySpecific = null;
if (company) {
companySpecific = await loadPromptByType(pool, companyType, company);
}
return {
system,
general,
companySpecific
};
}
/**
* Load name validation prompts
* @param {Object} pool - PostgreSQL pool
* @param {string|null} [company] - Optional company ID
* @returns {Promise<{system: string|null, general: string|null, companySpecific: string|null}>}
*/
async function loadNameValidationPrompts(pool, company = null) {
return loadPromptsByType(pool, 'name_validation', company);
}
/**
* Load description validation prompts
* @param {Object} pool - PostgreSQL pool
* @param {string|null} [company] - Optional company ID
* @returns {Promise<{system: string|null, general: string|null, companySpecific: string|null}>}
*/
async function loadDescriptionValidationPrompts(pool, company = null) {
return loadPromptsByType(pool, 'description_validation', company);
}
/**
* Load sanity check prompts (no company-specific variant)
* @param {Object} pool - PostgreSQL pool
* @returns {Promise<{system: string|null, general: string|null, companySpecific: null}>}
*/
async function loadSanityCheckPrompts(pool) {
return loadPromptsByType(pool, 'sanity_check', null);
}
/**
* Load bulk validation prompts (GPT-5 validation)
* @param {Object} pool - PostgreSQL pool
* @param {string|null} [company] - Optional company ID
* @returns {Promise<{system: string|null, general: string|null, companySpecific: string|null}>}
*/
async function loadBulkValidationPrompts(pool, company = null) {
return loadPromptsByType(pool, 'bulk_validation', company);
}
/**
* Load bulk validation prompts for multiple companies at once
* @param {Object} pool - PostgreSQL pool
* @param {string[]} companyIds - Array of company IDs
* @returns {Promise<{system: string|null, general: string|null, companyPrompts: Map<string, string>}>}
*/
async function loadBulkValidationPromptsForCompanies(pool, companyIds = []) {
// Load system and general prompts
const [system, general] = await Promise.all([
loadPromptByType(pool, 'bulk_validation_system'),
loadPromptByType(pool, 'bulk_validation_general')
]);
// Load company-specific prompts for all provided companies
const companyPrompts = new Map();
if (companyIds.length > 0) {
try {
const result = await pool.query(
`SELECT company, prompt_text FROM ai_prompts
WHERE prompt_type = 'bulk_validation_company_specific'
AND company = ANY($1)`,
[companyIds]
);
for (const row of result.rows) {
companyPrompts.set(row.company, row.prompt_text);
}
} catch (error) {
console.error('[PromptLoader] Error loading company-specific prompts:', error.message);
}
}
return {
system,
general,
companyPrompts
};
}
/**
* Validate that required prompts exist, throw error if missing
* @param {Object} prompts - Prompts object from loadPromptsByType
* @param {string} baseType - Base type for error messages
* @param {Object} options - Validation options
* @param {boolean} [options.requireSystem=true] - Require system prompt
* @param {boolean} [options.requireGeneral=true] - Require general prompt
* @throws {Error} If required prompts are missing
*/
function validateRequiredPrompts(prompts, baseType, options = {}) {
const { requireSystem = true, requireGeneral = true } = options;
const missing = [];
if (requireSystem && !prompts.system) {
missing.push(`${baseType}_system`);
}
if (requireGeneral && !prompts.general) {
missing.push(`${baseType}_general`);
}
if (missing.length > 0) {
throw new Error(
`Missing required AI prompts: ${missing.join(', ')}. ` +
`Please add these prompts in Settings > AI Validation Prompts.`
);
}
}
module.exports = {
// Core loader
loadPromptByType,
loadPromptsByType,
// Task-specific loaders
loadNameValidationPrompts,
loadDescriptionValidationPrompts,
loadSanityCheckPrompts,
loadBulkValidationPrompts,
loadBulkValidationPromptsForCompanies,
// Validation
validateRequiredPrompts
};

View File

@@ -0,0 +1,127 @@
/**
* Sanity Check Prompts
*
* Functions for building and parsing batch product consistency validation prompts.
* System and general prompts are loaded from the database.
*/
/**
* Build the user prompt for sanity check
* Combines database prompts with product data
*
* @param {Object[]} products - Array of product data (limited fields for context)
* @param {Object} prompts - Prompts loaded from database
* @param {string} prompts.general - General sanity check rules
* @returns {string} Complete user prompt
*/
function buildSanityCheckUserPrompt(products, prompts) {
// Build a simplified product list for the prompt
const productSummaries = products.map((p, index) => ({
index,
name: p.name,
supplier: p.supplier_name || p.supplier,
company: p.company_name || p.company,
supplier_no: p.supplier_no,
msrp: p.msrp,
cost_each: p.cost_each,
qty_per_unit: p.qty_per_unit,
case_qty: p.case_qty,
tax_cat: p.tax_cat_name || p.tax_cat,
size_cat: p.size_cat_name || p.size_cat,
themes: p.theme_names || p.themes,
weight: p.weight,
length: p.length,
width: p.width,
height: p.height
}));
const parts = [];
// Add general prompt/rules if provided
if (prompts.general) {
parts.push(prompts.general);
parts.push(''); // Empty line for separation
}
// Add products to review
parts.push(`PRODUCTS TO REVIEW (${products.length} items):`);
parts.push(JSON.stringify(productSummaries, null, 2));
// Add response format
parts.push('');
parts.push('RESPOND WITH JSON:');
parts.push(JSON.stringify({
issues: [
{
productIndex: 0,
field: 'msrp',
issue: 'Description of the issue found',
suggestion: 'Suggested fix or verification (optional)'
}
],
summary: 'Brief overall assessment of the batch quality'
}, null, 2));
parts.push('');
parts.push('If no issues are found, return empty issues array with positive summary.');
return parts.join('\n');
}
/**
* Parse the AI response for sanity check
*
* @param {Object|null} parsed - Parsed JSON from AI
* @param {string} content - Raw response content
* @returns {Object}
*/
function parseSanityCheckResponse(parsed, content) {
// If we got valid parsed JSON, use it
if (parsed && Array.isArray(parsed.issues)) {
return {
issues: parsed.issues.map(issue => ({
productIndex: issue.productIndex ?? issue.index ?? 0,
field: issue.field || 'unknown',
issue: issue.issue || issue.message || '',
suggestion: issue.suggestion || null
})),
summary: parsed.summary || 'Review complete'
};
}
// Try to extract from content if parsing failed
try {
// Try to find issues array
const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/);
let issues = [];
if (issuesMatch) {
// Try to parse the array content
try {
const arrayContent = `[${issuesMatch[1]}]`;
const parsedIssues = JSON.parse(arrayContent);
issues = parsedIssues.map(issue => ({
productIndex: issue.productIndex ?? issue.index ?? 0,
field: issue.field || 'unknown',
issue: issue.issue || issue.message || '',
suggestion: issue.suggestion || null
}));
} catch {
// Couldn't parse the array
}
}
// Try to find summary
const summaryMatch = content.match(/"summary"\s*:\s*"([^"]+)"/);
const summary = summaryMatch ? summaryMatch[1] : 'Review complete';
return { issues, summary };
} catch {
return { issues: [], summary: 'Could not parse review results' };
}
}
module.exports = {
buildSanityCheckUserPrompt,
parseSanityCheckResponse
};