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,57 @@
-- Migration: Make AI prompts extensible with is_singleton column
-- Date: 2024-01-19
-- Description: Removes hardcoded prompt_type CHECK constraint, adds is_singleton column
-- for dynamic uniqueness enforcement, and creates appropriate indexes.
-- 1. Drop the old CHECK constraints on prompt_type (allows any string value now)
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS ai_prompts_prompt_type_check;
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS company_required_for_specific;
-- 2. Add is_singleton column (defaults to true for backwards compatibility)
ALTER TABLE ai_prompts ADD COLUMN IF NOT EXISTS is_singleton BOOLEAN NOT NULL DEFAULT true;
-- 3. Drop ALL old unique constraints and indexes (cleanup)
-- Some were created as CONSTRAINTS (via ADD CONSTRAINT), others as standalone indexes
-- Must drop constraints first, then remaining standalone indexes
-- Drop constraints (these also remove their backing indexes)
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS unique_company_prompt;
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS idx_unique_general_prompt;
ALTER TABLE ai_prompts DROP CONSTRAINT IF EXISTS idx_unique_system_prompt;
-- Drop standalone indexes (IF EXISTS handles cases where they don't exist)
DROP INDEX IF EXISTS idx_unique_general_prompt;
DROP INDEX IF EXISTS idx_unique_system_prompt;
DROP INDEX IF EXISTS idx_unique_name_validation_system;
DROP INDEX IF EXISTS idx_unique_name_validation_general;
DROP INDEX IF EXISTS idx_unique_description_validation_system;
DROP INDEX IF EXISTS idx_unique_description_validation_general;
DROP INDEX IF EXISTS idx_unique_sanity_check_system;
DROP INDEX IF EXISTS idx_unique_sanity_check_general;
DROP INDEX IF EXISTS idx_unique_bulk_validation_system;
DROP INDEX IF EXISTS idx_unique_bulk_validation_general;
DROP INDEX IF EXISTS idx_unique_name_validation_company;
DROP INDEX IF EXISTS idx_unique_description_validation_company;
DROP INDEX IF EXISTS idx_unique_bulk_validation_company;
-- 4. Create new partial unique indexes based on is_singleton
-- For singleton types WITHOUT company (only one per prompt_type)
CREATE UNIQUE INDEX IF NOT EXISTS idx_singleton_no_company
ON ai_prompts (prompt_type)
WHERE is_singleton = true AND company IS NULL;
-- For singleton types WITH company (only one per prompt_type + company combination)
CREATE UNIQUE INDEX IF NOT EXISTS idx_singleton_with_company
ON ai_prompts (prompt_type, company)
WHERE is_singleton = true AND company IS NOT NULL;
-- 5. Add index for fast lookups by type
CREATE INDEX IF NOT EXISTS idx_prompt_type ON ai_prompts (prompt_type);
-- NOTE: After running this migration, you should:
-- 1. Delete existing prompts with old types (general, system, company_specific)
-- 2. Create new prompts with the new type naming convention:
-- - name_validation_system, name_validation_general, name_validation_company_specific
-- - description_validation_system, description_validation_general, description_validation_company_specific
-- - sanity_check_system, sanity_check_general
-- - bulk_validation_system, bulk_validation_general, bulk_validation_company_specific

View File

@@ -51,66 +51,64 @@ router.get('/:id', async (req, res) => {
}
});
// Get prompt by type (general, system, company_specific)
// Get prompt by type (any prompt_type value - extensible)
router.get('/by-type', async (req, res) => {
try {
const { type, company } = req.query;
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
// Validate prompt type
if (!type || !['general', 'system', 'company_specific'].includes(type)) {
return res.status(400).json({
error: 'Valid type query parameter is required (general, system, or company_specific)'
});
}
// For company_specific type, company ID is required
if (type === 'company_specific' && !company) {
// Validate type is provided
if (!type || typeof type !== 'string' || type.trim().length === 0) {
return res.status(400).json({
error: 'Company ID is required for company_specific prompt type'
error: 'Valid type query parameter is required'
});
}
// For general and system types, company should not be provided
if ((type === 'general' || type === 'system') && company) {
// For company_specific types, company ID is required
const isCompanySpecificType = type.endsWith('_company_specific');
if (isCompanySpecificType && !company) {
return res.status(400).json({
error: 'Company ID should not be provided for general or system prompt types'
error: 'Company ID is required for company_specific prompt types'
});
}
// Build the query based on the type
// For non-company-specific types, company should not be provided
if (!isCompanySpecificType && company) {
return res.status(400).json({
error: 'Company ID should not be provided for non-company-specific prompt types'
});
}
// Build the query based on whether company is provided
let query, params;
if (type === 'company_specific') {
if (company) {
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1 AND company = $2';
params = [type, company];
params = [type.trim(), company];
} else {
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1';
params = [type];
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1 AND company IS NULL';
params = [type.trim()];
}
// Execute the query
const result = await pool.query(query, params);
// Check if any prompt was found
if (result.rows.length === 0) {
let errorMessage;
if (type === 'company_specific') {
errorMessage = `AI prompt not found for company ${company}`;
} else {
errorMessage = `${type.charAt(0).toUpperCase() + type.slice(1)} AI prompt not found`;
}
const errorMessage = company
? `AI prompt '${type}' not found for company ${company}`
: `AI prompt '${type}' not found`;
return res.status(404).json({ error: errorMessage });
}
// Return the first matching prompt
res.json(result.rows[0]);
} catch (error) {
console.error('Error fetching AI prompt by type:', error);
res.status(500).json({
res.status(500).json({
error: 'Failed to fetch AI prompt',
details: error instanceof Error ? error.message : 'Unknown error'
});
@@ -130,27 +128,28 @@ router.post('/', async (req, res) => {
if (!prompt_text || !prompt_type) {
return res.status(400).json({ error: 'Prompt text and type are required' });
}
// Validate prompt type
if (!['general', 'company_specific', 'system'].includes(prompt_type)) {
return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' });
}
// Validate company is provided for company-specific prompts
if (prompt_type === 'company_specific' && !company) {
return res.status(400).json({ error: 'Company is required for company-specific prompts' });
// Validate prompt_type is a non-empty string (no hardcoded list - extensible)
if (typeof prompt_type !== 'string' || prompt_type.trim().length === 0) {
return res.status(400).json({ error: 'Prompt type must be a non-empty string' });
}
// Validate company is not provided for general or system prompts
if ((prompt_type === 'general' || prompt_type === 'system') && company) {
return res.status(400).json({ error: 'Company should not be provided for general or system prompts' });
// For company-specific types (ending with _company_specific), require company
const isCompanySpecificType = prompt_type.endsWith('_company_specific');
if (isCompanySpecificType && !company) {
return res.status(400).json({ error: 'Company is required for company-specific prompt types' });
}
// For non-company-specific types, company should not be provided
if (!isCompanySpecificType && company) {
return res.status(400).json({ error: 'Company should not be provided for non-company-specific prompt types' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
const result = await pool.query(`
INSERT INTO ai_prompts (
prompt_text,
@@ -160,35 +159,30 @@ router.post('/', async (req, res) => {
RETURNING *
`, [
prompt_text,
prompt_type,
company
prompt_type.trim(),
company || null
]);
res.status(201).json(result.rows[0]);
} catch (error) {
console.error('Error creating AI prompt:', error);
// Check for unique constraint violations
if (error instanceof Error && error.message.includes('unique constraint')) {
if (error.message.includes('unique_company_prompt')) {
return res.status(409).json({
error: 'A prompt already exists for this company',
if (error instanceof Error && error.message.includes('unique')) {
if (error.message.includes('idx_singleton_with_company')) {
return res.status(409).json({
error: 'A prompt of this type already exists for this company',
details: error.message
});
} else if (error.message.includes('idx_unique_general_prompt')) {
return res.status(409).json({
error: 'A general prompt already exists',
details: error.message
});
} else if (error.message.includes('idx_unique_system_prompt')) {
return res.status(409).json({
error: 'A system prompt already exists',
} else if (error.message.includes('idx_singleton_no_company')) {
return res.status(409).json({
error: 'A prompt of this type already exists',
details: error.message
});
}
}
res.status(500).json({
res.status(500).json({
error: 'Failed to create AI prompt',
details: error instanceof Error ? error.message : 'Unknown error'
});
@@ -209,73 +203,70 @@ router.put('/:id', async (req, res) => {
if (!prompt_text || !prompt_type) {
return res.status(400).json({ error: 'Prompt text and type are required' });
}
// Validate prompt type
if (!['general', 'company_specific', 'system'].includes(prompt_type)) {
return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' });
// Validate prompt_type is a non-empty string (no hardcoded list - extensible)
if (typeof prompt_type !== 'string' || prompt_type.trim().length === 0) {
return res.status(400).json({ error: 'Prompt type must be a non-empty string' });
}
// Validate company is provided for company-specific prompts
if (prompt_type === 'company_specific' && !company) {
return res.status(400).json({ error: 'Company is required for company-specific prompts' });
// For company-specific types, require company
const isCompanySpecificType = prompt_type.endsWith('_company_specific');
if (isCompanySpecificType && !company) {
return res.status(400).json({ error: 'Company is required for company-specific prompt types' });
}
// Validate company is not provided for general or system prompts
if ((prompt_type === 'general' || prompt_type === 'system') && company) {
return res.status(400).json({ error: 'Company should not be provided for general or system prompts' });
// For non-company-specific types, company should not be provided
if (!isCompanySpecificType && company) {
return res.status(400).json({ error: 'Company should not be provided for non-company-specific prompt types' });
}
const pool = req.app.locals.pool;
if (!pool) {
throw new Error('Database pool not initialized');
}
// Check if the prompt exists
const checkResult = await pool.query('SELECT * FROM ai_prompts WHERE id = $1', [id]);
if (checkResult.rows.length === 0) {
return res.status(404).json({ error: 'AI prompt not found' });
}
const result = await pool.query(`
UPDATE ai_prompts
SET
UPDATE ai_prompts
SET
prompt_text = $1,
prompt_type = $2,
company = $3
company = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $4
RETURNING *
`, [
prompt_text,
prompt_type,
company,
prompt_type.trim(),
company || null,
id
]);
res.json(result.rows[0]);
} catch (error) {
console.error('Error updating AI prompt:', error);
// Check for unique constraint violations
if (error instanceof Error && error.message.includes('unique constraint')) {
if (error.message.includes('unique_company_prompt')) {
return res.status(409).json({
error: 'A prompt already exists for this company',
if (error instanceof Error && error.message.includes('unique')) {
if (error.message.includes('idx_singleton_with_company')) {
return res.status(409).json({
error: 'A prompt of this type already exists for this company',
details: error.message
});
} else if (error.message.includes('idx_unique_general_prompt')) {
return res.status(409).json({
error: 'A general prompt already exists',
details: error.message
});
} else if (error.message.includes('idx_unique_system_prompt')) {
return res.status(409).json({
error: 'A system prompt already exists',
} else if (error.message.includes('idx_singleton_no_company')) {
return res.status(409).json({
error: 'A prompt of this type already exists',
details: error.message
});
}
}
res.status(500).json({
res.status(500).json({
error: 'Failed to update AI prompt',
details: error instanceof Error ? error.message : 'Unknown error'
});

View File

@@ -347,34 +347,34 @@ async function generateDebugResponse(productsToUse, res) {
throw new Error("Database connection not available");
}
// First, fetch the system prompt using the consolidated endpoint approach
// First, fetch the system prompt for bulk validation
const systemPromptResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'system'
SELECT * FROM ai_prompts
WHERE prompt_type = 'bulk_validation_system' AND company IS NULL
`);
if (systemPromptResult.rows.length === 0) {
console.error("❌ No system prompt found in database");
throw new Error("No system prompt found in database");
console.error("❌ No bulk_validation_system prompt found in database");
throw new Error("Missing required AI prompt: bulk_validation_system. Please add it in Settings > AI Validation Prompts.");
}
const systemPrompt = systemPromptResult.rows[0];
console.log("📝 Loaded system prompt from database, ID:", systemPrompt.id);
console.log("📝 Loaded bulk_validation_system prompt from database, ID:", systemPrompt.id);
// Then, fetch the general prompt using the consolidated endpoint approach
// Then, fetch the general prompt for bulk validation
const generalPromptResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'general'
SELECT * FROM ai_prompts
WHERE prompt_type = 'bulk_validation_general' AND company IS NULL
`);
if (generalPromptResult.rows.length === 0) {
console.error("❌ No general prompt found in database");
throw new Error("No general prompt found in database");
console.error("❌ No bulk_validation_general prompt found in database");
throw new Error("Missing required AI prompt: bulk_validation_general. Please add it in Settings > AI Validation Prompts.");
}
// Get the general prompt text and info
const generalPrompt = generalPromptResult.rows[0];
console.log("📝 Loaded general prompt from database, ID:", generalPrompt.id);
console.log("📝 Loaded bulk_validation_general prompt from database, ID:", generalPrompt.id);
// Fetch company-specific prompts if we have products to validate
let companyPrompts = [];
@@ -389,16 +389,16 @@ async function generateDebugResponse(productsToUse, res) {
if (companyIds.size > 0) {
console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds));
// Fetch company-specific prompts
// Fetch company-specific prompts for bulk validation
const companyPromptsResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'company_specific'
SELECT * FROM ai_prompts
WHERE prompt_type = 'bulk_validation_company_specific'
AND company = ANY($1)
`, [Array.from(companyIds)]);
companyPrompts = companyPromptsResult.rows;
console.log(`📝 Loaded ${companyPrompts.length} company-specific prompts`);
console.log(`📝 Loaded ${companyPrompts.length} bulk_validation_company_specific prompts`);
}
}
@@ -688,34 +688,34 @@ async function loadPrompt(connection, productsToValidate = null, appPool = null)
throw new Error("Database connection not available");
}
// Fetch the system prompt using the consolidated endpoint approach
// Fetch the system prompt for bulk validation
const systemPromptResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'system'
SELECT * FROM ai_prompts
WHERE prompt_type = 'bulk_validation_system' AND company IS NULL
`);
if (systemPromptResult.rows.length === 0) {
console.error("❌ No system prompt found in database");
throw new Error("No system prompt found in database");
console.error("❌ No bulk_validation_system prompt found in database");
throw new Error("Missing required AI prompt: bulk_validation_system. Please add it in Settings > AI Validation Prompts.");
}
const systemInstructions = systemPromptResult.rows[0].prompt_text;
console.log("📝 Loaded system prompt from database");
console.log("📝 Loaded bulk_validation_system prompt from database");
// Fetch the general prompt using the consolidated endpoint approach
// Fetch the general prompt for bulk validation
const generalPromptResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'general'
SELECT * FROM ai_prompts
WHERE prompt_type = 'bulk_validation_general' AND company IS NULL
`);
if (generalPromptResult.rows.length === 0) {
console.error("❌ No general prompt found in database");
throw new Error("No general prompt found in database");
console.error("❌ No bulk_validation_general prompt found in database");
throw new Error("Missing required AI prompt: bulk_validation_general. Please add it in Settings > AI Validation Prompts.");
}
// Get the general prompt text
const basePrompt = generalPromptResult.rows[0].prompt_text;
console.log("📝 Loaded general prompt from database");
console.log("📝 Loaded bulk_validation_general prompt from database");
// Fetch company-specific prompts if we have products to validate
let companyPrompts = [];
@@ -730,16 +730,16 @@ async function loadPrompt(connection, productsToValidate = null, appPool = null)
if (companyIds.size > 0) {
console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds));
// Fetch company-specific prompts
// Fetch company-specific prompts for bulk validation
const companyPromptsResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'company_specific'
SELECT * FROM ai_prompts
WHERE prompt_type = 'bulk_validation_company_specific'
AND company = ANY($1)
`, [Array.from(companyIds)]);
companyPrompts = companyPromptsResult.rows;
console.log(`📝 Loaded ${companyPrompts.length} company-specific prompts`);
console.log(`📝 Loaded ${companyPrompts.length} bulk_validation_company_specific prompts`);
}
}
@@ -1186,14 +1186,14 @@ router.post("/validate", async (req, res) => {
if (!pool) {
console.warn("⚠️ Local database pool not available for prompt sources");
} else {
// Get system prompt
// Get system prompt for bulk validation
const systemPromptResult = await pool.query(`
SELECT * FROM ai_prompts WHERE prompt_type = 'system'
SELECT * FROM ai_prompts WHERE prompt_type = 'bulk_validation_system' AND company IS NULL
`);
// Get general prompt
// Get general prompt for bulk validation
const generalPromptResult = await pool.query(`
SELECT * FROM ai_prompts WHERE prompt_type = 'general'
SELECT * FROM ai_prompts WHERE prompt_type = 'bulk_validation_general' AND company IS NULL
`);
// Extract unique company IDs from products
@@ -1206,10 +1206,10 @@ router.post("/validate", async (req, res) => {
let companyPrompts = [];
if (companyIds.size > 0) {
// Fetch company-specific prompts
// Fetch company-specific prompts for bulk validation
const companyPromptsResult = await pool.query(`
SELECT * FROM ai_prompts
WHERE prompt_type = 'company_specific'
WHERE prompt_type = 'bulk_validation_company_specific'
AND company = ANY($1)
`, [Array.from(companyIds)]);

View File

@@ -36,7 +36,9 @@ async function ensureInitialized() {
const result = await aiService.initialize({
openaiApiKey: process.env.OPENAI_API_KEY,
groqApiKey: process.env.GROQ_API_KEY,
mysqlConnection: connection,
pool: null, // Will be set by setPool()
logger: console
});
@@ -45,7 +47,10 @@ async function ensureInitialized() {
return false;
}
console.log('[AI Routes] AI service initialized:', result.stats);
console.log('[AI Routes] AI service initialized:', {
...result.stats,
groqEnabled: result.groqEnabled
});
return true;
} catch (error) {
console.error('[AI Routes] Failed to initialize AI service:', error);
@@ -278,4 +283,148 @@ router.post('/similar', async (req, res) => {
}
});
// ============================================================================
// INLINE AI VALIDATION ENDPOINTS (Groq-powered)
// ============================================================================
/**
* POST /api/ai/validate/inline/name
* Validate a single product name for spelling, grammar, and naming conventions
*
* Body: { product: { name, company_name, company_id, line_name, description } }
* Returns: { isValid, suggestion?, issues[], latencyMs }
*/
router.post('/validate/inline/name', async (req, res) => {
try {
const ready = await ensureInitialized();
if (!ready) {
return res.status(503).json({ error: 'AI service not available' });
}
if (!aiService.hasChatCompletion()) {
return res.status(503).json({
error: 'Chat completion not available - GROQ_API_KEY not configured'
});
}
const { product } = req.body;
if (!product) {
return res.status(400).json({ error: 'Product is required' });
}
// Get pool from app.locals (set by server.js)
const pool = req.app.locals.pool;
const result = await aiService.runTask(aiService.TASK_IDS.VALIDATE_NAME, {
product,
pool
});
if (!result.success) {
return res.status(500).json({
error: result.error || 'Validation failed',
code: result.code
});
}
res.json(result);
} catch (error) {
console.error('[AI Routes] Name validation error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/ai/validate/inline/description
* Validate a single product description for quality and guideline compliance
*
* Body: { product: { name, description, company_name, company_id, categories } }
* Returns: { isValid, suggestion?, issues[], latencyMs }
*/
router.post('/validate/inline/description', async (req, res) => {
try {
const ready = await ensureInitialized();
if (!ready) {
return res.status(503).json({ error: 'AI service not available' });
}
if (!aiService.hasChatCompletion()) {
return res.status(503).json({
error: 'Chat completion not available - GROQ_API_KEY not configured'
});
}
const { product } = req.body;
if (!product) {
return res.status(400).json({ error: 'Product is required' });
}
// Get pool from app.locals (set by server.js)
const pool = req.app.locals.pool;
const result = await aiService.runTask(aiService.TASK_IDS.VALIDATE_DESCRIPTION, {
product,
pool
});
if (!result.success) {
return res.status(500).json({
error: result.error || 'Validation failed',
code: result.code
});
}
res.json(result);
} catch (error) {
console.error('[AI Routes] Description validation error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* POST /api/ai/validate/sanity-check
* Run consistency/sanity check on a batch of products
*
* Body: { products: Array<product data> }
* Returns: { issues: Array<{ productIndex, field, issue, suggestion? }>, summary, latencyMs }
*/
router.post('/validate/sanity-check', async (req, res) => {
try {
const ready = await ensureInitialized();
if (!ready) {
return res.status(503).json({ error: 'AI service not available' });
}
if (!aiService.hasChatCompletion()) {
return res.status(503).json({
error: 'Chat completion not available - GROQ_API_KEY not configured'
});
}
const { products } = req.body;
if (!Array.isArray(products) || products.length === 0) {
return res.status(400).json({ error: 'Products array is required' });
}
const result = await aiService.runTask(aiService.TASK_IDS.SANITY_CHECK, {
products
});
if (!result.success) {
return res.status(500).json({
error: result.error || 'Sanity check failed',
code: result.code
});
}
res.json(result);
} catch (error) {
console.error('[AI Routes] Sanity check error:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;

View File

@@ -1,28 +1,38 @@
/**
* AI Service
*
* Main entry point for AI functionality including embeddings.
* Provides embedding generation and similarity search for product validation.
* Main entry point for AI functionality including:
* - Embeddings for taxonomy suggestions (OpenAI)
* - Chat completions for validation tasks (Groq)
* - Task registry for AI operations
*/
const { OpenAIProvider } = require('./providers/openaiProvider');
const { GroqProvider, MODELS: GROQ_MODELS } = require('./providers/groqProvider');
const { TaxonomyEmbeddings } = require('./embeddings/taxonomyEmbeddings');
const { cosineSimilarity, findTopMatches } = require('./embeddings/similarity');
const { getRegistry, TASK_IDS, registerAllTasks } = require('./tasks');
let initialized = false;
let initializing = false;
let openaiProvider = null;
let groqProvider = null;
let taxonomyEmbeddings = null;
let logger = console;
// Store pool reference for task access
let appPool = null;
/**
* Initialize the AI service
* @param {Object} options
* @param {string} options.openaiApiKey - OpenAI API key
* @param {string} options.openaiApiKey - OpenAI API key (for embeddings)
* @param {string} [options.groqApiKey] - Groq API key (for chat completions)
* @param {Object} options.mysqlConnection - MySQL connection for taxonomy data
* @param {Object} [options.pool] - PostgreSQL pool for prompt loading
* @param {Object} [options.logger] - Logger instance
*/
async function initialize({ openaiApiKey, mysqlConnection, logger: customLogger }) {
async function initialize({ openaiApiKey, groqApiKey, mysqlConnection, pool, logger: customLogger }) {
if (initialized) {
return { success: true, message: 'Already initialized' };
}
@@ -48,9 +58,22 @@ async function initialize({ openaiApiKey, mysqlConnection, logger: customLogger
logger.info('[AI] Initializing AI service...');
// Create OpenAI provider
// Store pool reference for tasks
if (pool) {
appPool = pool;
}
// Create OpenAI provider (for embeddings)
openaiProvider = new OpenAIProvider({ apiKey: openaiApiKey });
// Create Groq provider (for chat completions) if API key provided
if (groqApiKey) {
groqProvider = new GroqProvider({ apiKey: groqApiKey });
logger.info('[AI] Groq provider initialized for chat completions');
} else {
logger.warn('[AI] No Groq API key provided - chat completion tasks will not be available');
}
// Create and initialize taxonomy embeddings
taxonomyEmbeddings = new TaxonomyEmbeddings({
provider: openaiProvider,
@@ -59,13 +82,23 @@ async function initialize({ openaiApiKey, mysqlConnection, logger: customLogger
const stats = await taxonomyEmbeddings.initialize(mysqlConnection);
// Register validation tasks if Groq is available
if (groqProvider) {
registerValidationTasks();
}
initialized = true;
logger.info('[AI] AI service initialized', stats);
logger.info('[AI] AI service initialized', {
...stats,
groqEnabled: !!groqProvider,
tasksRegistered: getRegistry().list()
});
return {
success: true,
message: 'Initialized',
stats
stats,
groqEnabled: !!groqProvider
};
} catch (error) {
logger.error('[AI] Initialization failed:', error);
@@ -75,6 +108,15 @@ async function initialize({ openaiApiKey, mysqlConnection, logger: customLogger
}
}
/**
* Register validation tasks with the task registry
* Called during initialization if Groq is available
*/
function registerValidationTasks() {
registerAllTasks(logger);
logger.info('[AI] Validation tasks registered');
}
/**
* Check if service is ready
*/
@@ -245,28 +287,98 @@ function getTaxonomyData() {
* Get service status
*/
function getStatus() {
const registry = getRegistry();
return {
initialized,
ready: isReady(),
hasProvider: !!openaiProvider,
hasOpenAI: !!openaiProvider,
hasGroq: !!groqProvider,
hasTaxonomy: !!taxonomyEmbeddings,
taxonomyStats: taxonomyEmbeddings ? {
categories: taxonomyEmbeddings.categories?.length || 0,
themes: taxonomyEmbeddings.themes?.length || 0,
colors: taxonomyEmbeddings.colors?.length || 0
} : null
} : null,
tasks: {
registered: registry.list(),
count: registry.size()
}
};
}
/**
* Run an AI task by ID
* @param {string} taskId - Task identifier from TASK_IDS
* @param {Object} payload - Task-specific input
* @returns {Promise<Object>} Task result
*/
async function runTask(taskId, payload = {}) {
if (!initialized) {
throw new Error('AI service not initialized');
}
if (!groqProvider) {
throw new Error('Groq provider not available - chat completion tasks require GROQ_API_KEY');
}
const registry = getRegistry();
return registry.runTask(taskId, {
...payload,
// Inject dependencies tasks may need
provider: groqProvider,
pool: appPool,
logger
});
}
/**
* Get the Groq provider instance (for direct use if needed)
* @returns {GroqProvider|null}
*/
function getGroqProvider() {
return groqProvider;
}
/**
* Get the PostgreSQL pool (for tasks that need DB access)
* @returns {Object|null}
*/
function getPool() {
return appPool;
}
/**
* Check if chat completion tasks are available
* @returns {boolean}
*/
function hasChatCompletion() {
return !!groqProvider;
}
module.exports = {
// Initialization
initialize,
isReady,
getStatus,
// Embeddings (OpenAI)
getProductEmbedding,
getProductEmbeddings,
findSimilarTaxonomy,
getSuggestionsForProduct,
getTaxonomyData,
getStatus,
// Chat completions (Groq)
runTask,
hasChatCompletion,
getGroqProvider,
getPool,
// Constants
TASK_IDS,
GROQ_MODELS,
// Re-export utilities
cosineSimilarity,
findTopMatches

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
};

View File

@@ -0,0 +1,178 @@
/**
* Groq Provider - Handles chat completions via Groq's OpenAI-compatible API
*
* Uses Groq's fast inference for real-time AI validation tasks.
* Supports models like openai/gpt-oss-120b (complex) and openai/gpt-oss-20b (simple).
*/
const GROQ_BASE_URL = 'https://api.groq.com/openai/v1';
// Default models
const MODELS = {
LARGE: 'openai/gpt-oss-120b', // For complex tasks (descriptions, sanity checks)
SMALL: 'openai/gpt-oss-20b' // For simple tasks (name validation)
};
class GroqProvider {
/**
* @param {Object} options
* @param {string} options.apiKey - Groq API key
* @param {string} [options.baseUrl] - Override base URL
* @param {number} [options.timeoutMs=30000] - Default timeout
*/
constructor({ apiKey, baseUrl = GROQ_BASE_URL, timeoutMs = 30000 }) {
if (!apiKey) {
throw new Error('Groq API key is required');
}
this.apiKey = apiKey;
this.baseUrl = baseUrl;
this.timeoutMs = timeoutMs;
}
/**
* Send a chat completion request
*
* @param {Object} params
* @param {Array<{role: string, content: string}>} params.messages - Conversation messages
* @param {string} [params.model] - Model to use (defaults to LARGE)
* @param {number} [params.temperature=0.3] - Response randomness (0-2)
* @param {number} [params.maxTokens=500] - Max tokens in response
* @param {Object} [params.responseFormat] - For JSON mode: { type: 'json_object' }
* @param {number} [params.timeoutMs] - Request timeout override
* @returns {Promise<{content: string, parsed: Object|null, usage: Object, latencyMs: number, model: string}>}
*/
async chatCompletion({
messages,
model = MODELS.LARGE,
temperature = 0.3,
maxTokens = 500,
responseFormat = null,
timeoutMs = this.timeoutMs
}) {
const started = Date.now();
const body = {
model,
messages,
temperature,
max_completion_tokens: maxTokens
};
// Enable JSON mode if requested
if (responseFormat?.type === 'json_object') {
body.response_format = { type: 'json_object' };
}
const response = await this._makeRequest('chat/completions', body, timeoutMs);
const content = response.choices?.[0]?.message?.content || '';
const usage = response.usage || {};
// Attempt to parse JSON if response format was requested
let parsed = null;
if (responseFormat && content) {
try {
parsed = JSON.parse(content);
} catch {
// Content isn't valid JSON - try to extract JSON from markdown
parsed = this._extractJson(content);
}
}
return {
content,
parsed,
usage: {
promptTokens: usage.prompt_tokens || 0,
completionTokens: usage.completion_tokens || 0,
totalTokens: usage.total_tokens || 0
},
latencyMs: Date.now() - started,
model: response.model || model
};
}
/**
* Extract JSON from content that might be wrapped in markdown code blocks
* @private
*/
_extractJson(content) {
// Try to find JSON in code blocks
const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
try {
return JSON.parse(codeBlockMatch[1].trim());
} catch {
// Fall through
}
}
// Try to find JSON object/array directly
const jsonMatch = content.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
if (jsonMatch) {
try {
return JSON.parse(jsonMatch[1]);
} catch {
// Fall through
}
}
return null;
}
/**
* Make an HTTP request to Groq API
* @private
*/
async _makeRequest(endpoint, body, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${this.baseUrl}/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify(body),
signal: controller.signal
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
const message = error.error?.message || `Groq API error: ${response.status}`;
const err = new Error(message);
err.status = response.status;
err.code = error.error?.code;
// Include failed_generation if available (for JSON mode failures)
if (error.error?.failed_generation) {
err.failedGeneration = error.error.failed_generation;
console.error('[Groq] JSON validation failed. Model output:', error.error.failed_generation);
}
throw err;
}
return response.json();
} catch (error) {
if (error.name === 'AbortError') {
const err = new Error(`Groq request timed out after ${timeoutMs}ms`);
err.code = 'TIMEOUT';
throw err;
}
throw error;
} finally {
clearTimeout(timeout);
}
}
/**
* Check if the provider is properly configured
* @returns {boolean}
*/
isConfigured() {
return !!this.apiKey;
}
}
module.exports = { GroqProvider, MODELS, GROQ_BASE_URL };

View File

@@ -0,0 +1,144 @@
/**
* Description Validation Task
*
* Validates a product description for quality, accuracy, and guideline compliance.
* Uses Groq with the larger model for better reasoning about content quality.
* Loads all prompts from the database (no hardcoded prompts).
*/
const { MODELS } = require('../providers/groqProvider');
const {
loadDescriptionValidationPrompts,
validateRequiredPrompts
} = require('../prompts/promptLoader');
const {
buildDescriptionUserPrompt,
parseDescriptionResponse
} = require('../prompts/descriptionPrompts');
const TASK_ID = 'validate.description';
/**
* Create the description validation task
*
* @returns {Object} Task definition
*/
function createDescriptionValidationTask() {
return {
id: TASK_ID,
description: 'Validate product description for quality and guideline compliance',
/**
* Run the description validation
*
* @param {Object} payload
* @param {Object} payload.product - Product data
* @param {string} payload.product.name - Product name (for context)
* @param {string} payload.product.description - Description to validate
* @param {string} [payload.product.company_name] - Company name
* @param {string} [payload.product.company_id] - Company ID for loading specific rules
* @param {string} [payload.product.categories] - Product categories
* @param {Object} payload.provider - Groq provider instance
* @param {Object} payload.pool - PostgreSQL pool
* @param {Object} [payload.logger] - Logger instance
* @returns {Promise<Object>}
*/
async run(payload) {
const { product, provider, pool, logger } = payload;
const log = logger || console;
// Validate required input
if (!product?.name && !product?.description) {
return {
isValid: true,
suggestion: null,
issues: [],
skipped: true,
reason: 'No name or description provided'
};
}
if (!provider) {
throw new Error('Groq provider not available');
}
if (!pool) {
throw new Error('Database pool not available');
}
try {
// Load prompts from database
const companyKey = product.company_id || product.company_name || product.company;
const prompts = await loadDescriptionValidationPrompts(pool, companyKey);
// Validate required prompts exist
validateRequiredPrompts(prompts, 'description_validation');
// Build the user prompt with database-loaded prompts
const userPrompt = buildDescriptionUserPrompt(product, prompts);
let response;
let result;
try {
// Try with JSON mode first
response = await provider.chatCompletion({
messages: [
{ role: 'system', content: prompts.system },
{ role: 'user', content: userPrompt }
],
model: MODELS.LARGE, // openai/gpt-oss-120b - better for content analysis
temperature: 0.3, // Slightly higher for creative suggestions
maxTokens: 500, // More tokens for description suggestions
responseFormat: { type: 'json_object' }
});
// Parse the response
result = parseDescriptionResponse(response.parsed, response.content);
} catch (jsonError) {
// If JSON mode failed, check if we have failedGeneration to parse
if (jsonError.failedGeneration) {
log.warn('[DescriptionValidation] JSON mode failed, attempting to parse failed_generation');
result = parseDescriptionResponse(null, jsonError.failedGeneration);
response = { latencyMs: 0, usage: {}, model: MODELS.LARGE };
} else {
// Retry without JSON mode
log.warn('[DescriptionValidation] JSON mode failed, retrying without JSON mode');
response = await provider.chatCompletion({
messages: [
{ role: 'system', content: prompts.system },
{ role: 'user', content: userPrompt }
],
model: MODELS.LARGE,
temperature: 0.3,
maxTokens: 500
// No responseFormat - let the model respond freely
});
result = parseDescriptionResponse(response.parsed, response.content);
}
}
log.info(`[DescriptionValidation] Validated description for "${product.name}" in ${response.latencyMs}ms`, {
isValid: result.isValid,
hasSuggestion: !!result.suggestion,
issueCount: result.issues.length
});
return {
...result,
latencyMs: response.latencyMs,
usage: response.usage,
model: response.model
};
} catch (error) {
log.error('[DescriptionValidation] Error:', error.message);
throw error;
}
}
};
}
module.exports = {
TASK_ID,
createDescriptionValidationTask
};

View File

@@ -0,0 +1,186 @@
/**
* AI Task Registry
*
* Simple registry pattern for AI tasks. Each task has:
* - id: Unique identifier
* - run: Async function that executes the task
*
* This allows adding new AI capabilities without modifying core code.
*/
const { createNameValidationTask, TASK_ID: NAME_TASK_ID } = require('./nameValidationTask');
const { createDescriptionValidationTask, TASK_ID: DESC_TASK_ID } = require('./descriptionValidationTask');
const { createSanityCheckTask, TASK_ID: SANITY_TASK_ID } = require('./sanityCheckTask');
/**
* Task IDs - frozen constants for type safety
*/
const TASK_IDS = Object.freeze({
// Inline validation (triggered on field blur)
VALIDATE_NAME: NAME_TASK_ID,
VALIDATE_DESCRIPTION: DESC_TASK_ID,
// Batch operations (triggered on user action)
SANITY_CHECK: SANITY_TASK_ID
});
/**
* Task Registry
*/
class TaskRegistry {
constructor() {
this.tasks = new Map();
}
/**
* Register a task
* @param {Object} task
* @param {string} task.id - Unique task identifier
* @param {Function} task.run - Async function: (payload) => result
* @param {string} [task.description] - Human-readable description
*/
register(task) {
if (!task?.id) {
throw new Error('Task must have an id');
}
if (typeof task.run !== 'function') {
throw new Error(`Task ${task.id} must have a run function`);
}
if (this.tasks.has(task.id)) {
throw new Error(`Task ${task.id} is already registered`);
}
this.tasks.set(task.id, task);
return this;
}
/**
* Get a task by ID
* @param {string} taskId
* @returns {Object|null}
*/
get(taskId) {
return this.tasks.get(taskId) || null;
}
/**
* Check if a task exists
* @param {string} taskId
* @returns {boolean}
*/
has(taskId) {
return this.tasks.has(taskId);
}
/**
* Run a task by ID
* @param {string} taskId
* @param {Object} payload - Task-specific input
* @returns {Promise<Object>} Task result
*/
async runTask(taskId, payload = {}) {
const task = this.get(taskId);
if (!task) {
throw new Error(`Unknown task: ${taskId}`);
}
try {
const result = await task.run(payload);
return {
success: true,
taskId,
...result
};
} catch (error) {
return {
success: false,
taskId,
error: error.message,
code: error.code
};
}
}
/**
* List all registered task IDs
* @returns {string[]}
*/
list() {
return Array.from(this.tasks.keys());
}
/**
* Get count of registered tasks
* @returns {number}
*/
size() {
return this.tasks.size;
}
}
// Singleton instance
let registry = null;
/**
* Get or create the task registry
* @returns {TaskRegistry}
*/
function getRegistry() {
if (!registry) {
registry = new TaskRegistry();
}
return registry;
}
/**
* Reset the registry (mainly for testing)
*/
function resetRegistry() {
registry = null;
}
/**
* Register all validation tasks with the registry
* Call this during initialization after the registry is created
*
* @param {Object} [logger] - Optional logger
*/
function registerAllTasks(logger = console) {
const reg = getRegistry();
// Register name validation
if (!reg.has(TASK_IDS.VALIDATE_NAME)) {
reg.register(createNameValidationTask());
logger.info(`[Tasks] Registered: ${TASK_IDS.VALIDATE_NAME}`);
}
// Register description validation
if (!reg.has(TASK_IDS.VALIDATE_DESCRIPTION)) {
reg.register(createDescriptionValidationTask());
logger.info(`[Tasks] Registered: ${TASK_IDS.VALIDATE_DESCRIPTION}`);
}
// Register sanity check
if (!reg.has(TASK_IDS.SANITY_CHECK)) {
reg.register(createSanityCheckTask());
logger.info(`[Tasks] Registered: ${TASK_IDS.SANITY_CHECK}`);
}
return reg;
}
module.exports = {
// Constants
TASK_IDS,
// Registry
TaskRegistry,
getRegistry,
resetRegistry,
registerAllTasks,
// Task factories (for custom registration)
createNameValidationTask,
createDescriptionValidationTask,
createSanityCheckTask
};

View File

@@ -0,0 +1,144 @@
/**
* Name Validation Task
*
* Validates a product name for spelling, grammar, and naming conventions.
* Uses Groq with the smaller model for fast response times.
* Loads all prompts from the database (no hardcoded prompts).
*/
const { MODELS } = require('../providers/groqProvider');
const {
loadNameValidationPrompts,
validateRequiredPrompts
} = require('../prompts/promptLoader');
const {
buildNameUserPrompt,
parseNameResponse
} = require('../prompts/namePrompts');
const TASK_ID = 'validate.name';
/**
* Create the name validation task
*
* @returns {Object} Task definition
*/
function createNameValidationTask() {
return {
id: TASK_ID,
description: 'Validate product name for spelling, grammar, and conventions',
/**
* Run the name validation
*
* @param {Object} payload
* @param {Object} payload.product - Product data
* @param {string} payload.product.name - Product name to validate
* @param {string} [payload.product.company_name] - Company name
* @param {string} [payload.product.company_id] - Company ID for loading specific rules
* @param {string} [payload.product.line_name] - Product line
* @param {string} [payload.product.description] - Description for context
* @param {Object} payload.provider - Groq provider instance
* @param {Object} payload.pool - PostgreSQL pool
* @param {Object} [payload.logger] - Logger instance
* @returns {Promise<Object>}
*/
async run(payload) {
const { product, provider, pool, logger } = payload;
const log = logger || console;
// Validate required input
if (!product?.name) {
return {
isValid: true,
suggestion: null,
issues: [],
skipped: true,
reason: 'No name provided'
};
}
if (!provider) {
throw new Error('Groq provider not available');
}
if (!pool) {
throw new Error('Database pool not available');
}
try {
// Load prompts from database
const companyKey = product.company_id || product.company_name || product.company;
const prompts = await loadNameValidationPrompts(pool, companyKey);
// Validate required prompts exist
validateRequiredPrompts(prompts, 'name_validation');
// Build the user prompt with database-loaded prompts
const userPrompt = buildNameUserPrompt(product, prompts);
let response;
let result;
try {
// Try with JSON mode first
response = await provider.chatCompletion({
messages: [
{ role: 'system', content: prompts.system },
{ role: 'user', content: userPrompt }
],
model: MODELS.SMALL, // openai/gpt-oss-20b - fast for simple tasks
temperature: 0.2, // Low temperature for consistent results
maxTokens: 300,
responseFormat: { type: 'json_object' }
});
// Parse the response
result = parseNameResponse(response.parsed, response.content);
} catch (jsonError) {
// If JSON mode failed, check if we have failedGeneration to parse
if (jsonError.failedGeneration) {
log.warn('[NameValidation] JSON mode failed, attempting to parse failed_generation');
result = parseNameResponse(null, jsonError.failedGeneration);
response = { latencyMs: 0, usage: {}, model: MODELS.SMALL };
} else {
// Retry without JSON mode
log.warn('[NameValidation] JSON mode failed, retrying without JSON mode');
response = await provider.chatCompletion({
messages: [
{ role: 'system', content: prompts.system },
{ role: 'user', content: userPrompt }
],
model: MODELS.SMALL,
temperature: 0.2,
maxTokens: 300
// No responseFormat - let the model respond freely
});
result = parseNameResponse(response.parsed, response.content);
}
}
log.info(`[NameValidation] Validated "${product.name}" in ${response.latencyMs}ms`, {
isValid: result.isValid,
hassuggestion: !!result.suggestion,
issueCount: result.issues.length
});
return {
...result,
latencyMs: response.latencyMs,
usage: response.usage,
model: response.model
};
} catch (error) {
log.error('[NameValidation] Error:', error.message);
throw error;
}
}
};
}
module.exports = {
TASK_ID,
createNameValidationTask
};

View File

@@ -0,0 +1,182 @@
/**
* Sanity Check Task
*
* Reviews a batch of products for consistency and appropriateness.
* Uses Groq with the larger model for complex batch analysis.
* Loads all prompts from the database (no hardcoded prompts).
*/
const { MODELS } = require('../providers/groqProvider');
const {
loadSanityCheckPrompts,
validateRequiredPrompts
} = require('../prompts/promptLoader');
const {
buildSanityCheckUserPrompt,
parseSanityCheckResponse
} = require('../prompts/sanityCheckPrompts');
const TASK_ID = 'sanity.check';
// Maximum products to send in a single request (to avoid token limits)
const MAX_PRODUCTS_PER_REQUEST = 50;
/**
* Create the sanity check task
*
* @returns {Object} Task definition
*/
function createSanityCheckTask() {
return {
id: TASK_ID,
description: 'Review batch of products for consistency and appropriateness',
/**
* Run the sanity check
*
* @param {Object} payload
* @param {Object[]} payload.products - Array of products to check
* @param {Object} payload.provider - Groq provider instance
* @param {Object} payload.pool - PostgreSQL pool
* @param {Object} [payload.logger] - Logger instance
* @returns {Promise<Object>}
*/
async run(payload) {
const { products, provider, pool, logger } = payload;
const log = logger || console;
// Validate required input
if (!Array.isArray(products) || products.length === 0) {
return {
issues: [],
summary: 'No products to check',
skipped: true
};
}
if (!provider) {
throw new Error('Groq provider not available');
}
if (!pool) {
throw new Error('Database pool not available');
}
try {
// Load prompts from database
const prompts = await loadSanityCheckPrompts(pool);
// Validate required prompts exist
validateRequiredPrompts(prompts, 'sanity_check');
// If batch is small enough, process in one request
if (products.length <= MAX_PRODUCTS_PER_REQUEST) {
return await checkBatch(products, prompts, provider, log);
}
// Otherwise, process in chunks and combine results
log.info(`[SanityCheck] Processing ${products.length} products in chunks`);
const allIssues = [];
const summaries = [];
for (let i = 0; i < products.length; i += MAX_PRODUCTS_PER_REQUEST) {
const chunk = products.slice(i, i + MAX_PRODUCTS_PER_REQUEST);
const chunkOffset = i; // To adjust product indices in results
const result = await checkBatch(chunk, prompts, provider, log);
// Adjust product indices to match original array
const adjustedIssues = result.issues.map(issue => ({
...issue,
productIndex: issue.productIndex + chunkOffset
}));
allIssues.push(...adjustedIssues);
summaries.push(result.summary);
}
return {
issues: allIssues,
summary: summaries.length > 1
? `Reviewed ${products.length} products in ${summaries.length} batches. ${allIssues.length} issues found.`
: summaries[0],
totalProducts: products.length,
issueCount: allIssues.length
};
} catch (error) {
log.error('[SanityCheck] Error:', error.message);
throw error;
}
}
};
}
/**
* Check a single batch of products
*
* @param {Object[]} products - Products to check
* @param {Object} prompts - Loaded prompts from database
* @param {Object} provider - Groq provider
* @param {Object} log - Logger
* @returns {Promise<Object>}
*/
async function checkBatch(products, prompts, provider, log) {
const userPrompt = buildSanityCheckUserPrompt(products, prompts);
let response;
let result;
try {
// Try with JSON mode first
response = await provider.chatCompletion({
messages: [
{ role: 'system', content: prompts.system },
{ role: 'user', content: userPrompt }
],
model: MODELS.LARGE, // openai/gpt-oss-120b - needed for complex batch analysis
temperature: 0.2, // Low temperature for consistent analysis
maxTokens: 2000, // More tokens for batch results
responseFormat: { type: 'json_object' }
});
result = parseSanityCheckResponse(response.parsed, response.content);
} catch (jsonError) {
// If JSON mode failed, check if we have failedGeneration to parse
if (jsonError.failedGeneration) {
log.warn('[SanityCheck] JSON mode failed, attempting to parse failed_generation');
result = parseSanityCheckResponse(null, jsonError.failedGeneration);
response = { latencyMs: 0, usage: {}, model: MODELS.LARGE };
} else {
// Retry without JSON mode
log.warn('[SanityCheck] JSON mode failed, retrying without JSON mode');
response = await provider.chatCompletion({
messages: [
{ role: 'system', content: prompts.system },
{ role: 'user', content: userPrompt }
],
model: MODELS.LARGE,
temperature: 0.2,
maxTokens: 2000
// No responseFormat - let the model respond freely
});
result = parseSanityCheckResponse(response.parsed, response.content);
}
}
log.info(`[SanityCheck] Checked ${products.length} products in ${response.latencyMs}ms`, {
issueCount: result.issues.length
});
return {
...result,
latencyMs: response.latencyMs,
usage: response.usage,
model: response.model
};
}
module.exports = {
TASK_ID,
createSanityCheckTask,
MAX_PRODUCTS_PER_REQUEST
};

View File

@@ -0,0 +1,159 @@
/**
* AiSuggestionBadge Component
*
* Displays an AI suggestion with accept/dismiss actions.
* Used for inline validation suggestions on Name and Description fields.
*/
import { Check, X, Sparkles, AlertCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface AiSuggestionBadgeProps {
/** The suggested value */
suggestion: string;
/** List of issues found (optional) */
issues?: string[];
/** Called when user accepts the suggestion */
onAccept: () => void;
/** Called when user dismisses the suggestion */
onDismiss: () => void;
/** Additional CSS classes */
className?: string;
/** Whether to show the suggestion as compact (inline) or expanded */
compact?: boolean;
}
export function AiSuggestionBadge({
suggestion,
issues = [],
onAccept,
onDismiss,
className,
compact = false
}: AiSuggestionBadgeProps) {
if (compact) {
return (
<div
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800',
className
)}
>
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0" />
<span className="text-purple-700 dark:text-purple-300 truncate max-w-[200px]">
{suggestion}
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100"
onClick={(e) => {
e.stopPropagation();
onAccept();
}}
title="Accept suggestion"
>
<Check className="h-3 w-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
title="Dismiss"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
);
}
return (
<div
className={cn(
'flex flex-col gap-2 p-2 mt-1 rounded-md',
'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800',
className
)}
>
{/* Header */}
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
AI Suggestion
</span>
</div>
{/* Suggestion content */}
<div className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed">
{suggestion}
</div>
{/* Issues list (if any) */}
{issues.length > 0 && (
<div className="flex flex-col gap-1 mt-1">
{issues.map((issue, index) => (
<div
key={index}
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
>
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0" />
<span>{issue}</span>
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 mt-1">
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
onClick={onAccept}
>
<Check className="h-3 w-3 mr-1" />
Accept
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700"
onClick={onDismiss}
>
Dismiss
</Button>
</div>
</div>
);
}
/**
* Loading state for AI validation
*/
export function AiValidationLoading({ className }: { className?: string }) {
return (
<div
className={cn(
'flex items-center gap-2 px-2 py-1 rounded-md text-xs',
'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800',
className
)}
>
<div className="h-3 w-3 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
<span className="text-purple-600 dark:text-purple-400">
Validating with AI...
</span>
</div>
);
}

View File

@@ -6,7 +6,7 @@
* Note: Initialization effects are in index.tsx so they run before this mounts.
*/
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import { useValidationStore } from '../store/validationStore';
import {
useTotalErrorCount,
@@ -22,12 +22,15 @@ import { useAiValidationFlow } from '../hooks/useAiValidation';
import { useFieldOptions } from '../hooks/useFieldOptions';
import { useTemplateManagement } from '../hooks/useTemplateManagement';
import { useCopyDownValidation } from '../hooks/useCopyDownValidation';
import { useSanityCheck } from '../hooks/useSanityCheck';
import { AiValidationProgressDialog } from '../dialogs/AiValidationProgress';
import { AiValidationResultsDialog } from '../dialogs/AiValidationResults';
import { AiDebugDialog } from '../dialogs/AiDebugDialog';
import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
import { TemplateForm } from '@/components/templates/TemplateForm';
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
import type { CleanRowData, RowData } from '../store/types';
import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
interface ValidationContainerProps {
onBack?: () => void;
@@ -58,6 +61,10 @@ export const ValidationContainer = ({
const aiValidation = useAiValidationFlow();
const { data: fieldOptionsData } = useFieldOptions();
const { loadTemplates } = useTemplateManagement();
const sanityCheck = useSanityCheck();
// Sanity check dialog state
const [sanityCheckDialogOpen, setSanityCheckDialogOpen] = useState(false);
// Handle UPC validation after copy-down operations on supplier/upc fields
useCopyDownValidation();
@@ -121,6 +128,87 @@ export const ValidationContainer = ({
}
}, [onBack]);
// Trigger sanity check when user clicks Continue
const handleTriggerSanityCheck = useCallback(() => {
// Get current rows and prepare for sanity check
const rows = useValidationStore.getState().rows;
const fields = useValidationStore.getState().fields;
// Build lookup for field options (for display names)
const getFieldLabel = (fieldKey: string, value: unknown): string | undefined => {
const field = fields.find(f => f.key === fieldKey);
if (field && field.fieldType.type === 'select' && 'options' in field.fieldType) {
const option = field.fieldType.options?.find(o => o.value === String(value));
return option?.label;
}
return undefined;
};
// Convert rows to sanity check format
const products: ProductForSanityCheck[] = rows.map((row) => ({
name: row.name as string | undefined,
supplier: row.supplier as string | undefined,
supplier_name: getFieldLabel('supplier', row.supplier),
company: row.company as string | undefined,
company_name: getFieldLabel('company', row.company),
supplier_no: row.supplier_no as string | undefined,
msrp: row.msrp as string | number | undefined,
cost_each: row.cost_each as string | number | undefined,
qty_per_unit: row.qty_per_unit as string | number | undefined,
case_qty: row.case_qty as string | number | undefined,
tax_cat: row.tax_cat as string | number | undefined,
tax_cat_name: getFieldLabel('tax_cat', row.tax_cat),
size_cat: row.size_cat as string | number | undefined,
size_cat_name: getFieldLabel('size_cat', row.size_cat),
themes: row.themes as string | undefined,
weight: row.weight as string | number | undefined,
length: row.length as string | number | undefined,
width: row.width as string | number | undefined,
height: row.height as string | number | undefined,
}));
// Open dialog and run check
setSanityCheckDialogOpen(true);
sanityCheck.runCheck(products);
}, [sanityCheck]);
// Handle proceeding after sanity check
const handleSanityCheckProceed = useCallback(() => {
setSanityCheckDialogOpen(false);
sanityCheck.clearResults();
handleNext();
}, [handleNext, sanityCheck]);
// Handle going back from sanity check dialog
const handleSanityCheckGoBack = useCallback(() => {
setSanityCheckDialogOpen(false);
sanityCheck.clearResults();
}, [sanityCheck]);
// Handle scrolling to a specific product from sanity check issue
const handleScrollToProduct = useCallback((productIndex: number) => {
// Find the row element and scroll to it
const rowElement = document.querySelector(`[data-row-index="${productIndex}"]`);
if (rowElement) {
rowElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Briefly highlight the row
rowElement.classList.add('ring-2', 'ring-purple-500');
setTimeout(() => {
rowElement.classList.remove('ring-2', 'ring-purple-500');
}, 2000);
}
}, []);
// Build product names lookup for sanity check dialog
const productNames = useMemo(() => {
const rows = useValidationStore.getState().rows;
const names: Record<number, string> = {};
rows.forEach((row, index) => {
names[index] = (row.name as string) || `Product ${index + 1}`;
});
return names;
}, [rowCount]); // Depend on rowCount to update when rows change
return (
<AiSuggestionsProvider
getCompanyName={getCompanyName}
@@ -152,6 +240,8 @@ export const ValidationContainer = ({
onAiValidate={aiValidation.validate}
isAiValidating={aiValidation.isValidating}
onShowDebug={aiValidation.showPromptPreview}
onTriggerSanityCheck={handleTriggerSanityCheck}
sanityCheckAvailable={true}
/>
{/* Floating selection bar - appears when rows selected */}
@@ -182,6 +272,19 @@ export const ValidationContainer = ({
debugData={aiValidation.debugPrompt}
/>
{/* Sanity Check Dialog - auto-triggered on Continue */}
<SanityCheckDialog
open={sanityCheckDialogOpen}
onOpenChange={setSanityCheckDialogOpen}
isChecking={sanityCheck.isChecking}
error={sanityCheck.error}
result={sanityCheck.result}
onProceed={handleSanityCheckProceed}
onGoBack={handleSanityCheckGoBack}
onScrollToProduct={handleScrollToProduct}
productNames={productNames}
/>
{/* Template form dialog - for saving row as template */}
<TemplateForm
isOpen={isTemplateFormOpen}

View File

@@ -2,11 +2,12 @@
* ValidationFooter Component
*
* Navigation footer with back/next buttons, AI validate, and summary info.
* Triggers sanity check automatically when user clicks "Continue".
*/
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { CheckCircle, Wand2, FileText } from 'lucide-react';
import { CheckCircle, Wand2, FileText, Sparkles } from 'lucide-react';
import { Protected } from '@/components/auth/Protected';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
@@ -20,6 +21,10 @@ interface ValidationFooterProps {
onAiValidate?: () => void;
isAiValidating?: boolean;
onShowDebug?: () => void;
/** Called when user clicks Continue - triggers sanity check */
onTriggerSanityCheck?: () => void;
/** Whether sanity check is available (Groq enabled) */
sanityCheckAvailable?: boolean;
}
export const ValidationFooter = ({
@@ -32,9 +37,27 @@ export const ValidationFooter = ({
onAiValidate,
isAiValidating = false,
onShowDebug,
onTriggerSanityCheck,
sanityCheckAvailable = false,
}: ValidationFooterProps) => {
const [showErrorDialog, setShowErrorDialog] = useState(false);
// Handle Continue click - either trigger sanity check or proceed directly
const handleContinueClick = () => {
if (canProceed) {
// If sanity check is available, trigger it first
if (sanityCheckAvailable && onTriggerSanityCheck) {
onTriggerSanityCheck();
} else if (onNext) {
// No sanity check available, proceed directly
onNext();
}
} else {
// Show error dialog if there are validation errors
setShowErrorDialog(true);
}
};
return (
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
{/* Back button */}
@@ -90,20 +113,19 @@ export const ValidationFooter = ({
{onNext && (
<>
<Button
onClick={() => {
if (canProceed) {
onNext();
} else {
setShowErrorDialog(true);
}
}}
onClick={handleContinueClick}
title={
!canProceed
? `There are ${errorCount} validation errors`
: 'Continue to image upload'
: sanityCheckAvailable
? 'Run sanity check and continue to image upload'
: 'Continue to image upload'
}
>
Next
{sanityCheckAvailable && canProceed && (
<Sparkles className="h-4 w-4 mr-1" />
)}
{canProceed ? 'Continue' : 'Next'}
</Button>
<Dialog open={showErrorDialog} onOpenChange={setShowErrorDialog}>

View File

@@ -50,9 +50,16 @@ import { MultilineInput } from './cells/MultilineInput';
// AI Suggestions context
import { useAiSuggestionsContext } from '../contexts/AiSuggestionsContext';
// AI Suggestion Badge for inline validation
import { AiSuggestionBadge } from './AiSuggestionBadge';
import type { InlineAiSuggestion } from '../store/types';
// Fields that trigger AI suggestion refresh when changed
const AI_EMBEDDING_FIELDS = ['company', 'line', 'name', 'description'] as const;
// Fields that trigger inline AI validation (Groq)
const INLINE_AI_VALIDATION_FIELDS = ['name', 'description'] as const;
// Threshold for switching to ComboboxCell (with search) instead of SelectCell
const COMBOBOX_OPTION_THRESHOLD = 50;
@@ -120,6 +127,9 @@ interface CellWrapperProps {
isInCopyDownRange: boolean;
isCopyDownTarget: boolean;
totalRowCount: number;
// Inline AI validation (Groq-powered)
inlineAiSuggestion?: InlineAiSuggestion;
isInlineAiValidating?: boolean;
}
/**
@@ -145,6 +155,8 @@ const CellWrapper = memo(({
isInCopyDownRange,
isCopyDownTarget,
totalRowCount,
inlineAiSuggestion,
isInlineAiValidating = false,
}: CellWrapperProps) => {
const [isHovered, setIsHovered] = useState(false);
const [isGeneratingUpc, setIsGeneratingUpc] = useState(false);
@@ -156,6 +168,14 @@ const CellWrapper = memo(({
const aiSuggestions = useAiSuggestionsContext();
const isEmbeddingField = AI_EMBEDDING_FIELDS.includes(field.key as typeof AI_EMBEDDING_FIELDS[number]);
// Check if this field supports inline AI validation
const isInlineAiField = INLINE_AI_VALIDATION_FIELDS.includes(field.key as typeof INLINE_AI_VALIDATION_FIELDS[number]);
// Get the suggestion for this specific field
const fieldSuggestion = isInlineAiField ? inlineAiSuggestion?.[field.key as 'name' | 'description'] : undefined;
const isDismissed = isInlineAiField ? inlineAiSuggestion?.dismissed?.[field.key as 'name' | 'description'] : false;
const showSuggestion = fieldSuggestion && !fieldSuggestion.isValid && fieldSuggestion.suggestion && !isDismissed;
// Check if cell has a value (for showing copy-down button)
const hasValue = value !== undefined && value !== null && value !== '';
@@ -511,8 +531,70 @@ const CellWrapper = memo(({
aiSuggestions.handleFieldBlur(currentRow, field.key);
}
}
// Trigger inline AI validation for name/description fields
// This validates spelling, grammar, and naming conventions using Groq
if (isInlineAiField && valueToSave && String(valueToSave).trim()) {
const currentRow = useValidationStore.getState().rows[rowIndex];
const fields = useValidationStore.getState().fields;
if (currentRow) {
const { setInlineAiValidating, setInlineAiSuggestion } = useValidationStore.getState();
const fieldKey = field.key as 'name' | 'description';
// Mark as validating
setInlineAiValidating(`${productIndex}-${fieldKey}`, true);
// Helper to look up field option label
const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
const fieldDef = fields.find(f => f.key === fieldKey);
if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) {
const option = fieldDef.fieldType.options?.find(o => o.value === String(val));
return option?.label;
}
return undefined;
};
// Build product payload for API
const productPayload = {
name: fieldKey === 'name' ? String(valueToSave) : (currentRow.name as string),
description: fieldKey === 'description' ? String(valueToSave) : (currentRow.description as string),
company_name: currentRow.company ? getFieldLabel('company', currentRow.company) : undefined,
company_id: currentRow.company ? String(currentRow.company) : undefined,
line_name: currentRow.line ? getFieldLabel('line', currentRow.line) : undefined,
categories: currentRow.categories as string | undefined,
};
// Call the appropriate API endpoint
const endpoint = fieldKey === 'name'
? '/api/ai/validate/inline/name'
: '/api/ai/validate/inline/description';
fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: productPayload }),
})
.then(res => res.json())
.then(result => {
if (result.success !== false) {
setInlineAiSuggestion(productIndex, fieldKey, {
isValid: result.isValid ?? true,
suggestion: result.suggestion,
issues: result.issues || [],
latencyMs: result.latencyMs,
});
}
})
.catch(err => {
console.error(`[InlineAI] ${fieldKey} validation error:`, err);
})
.finally(() => {
setInlineAiValidating(`${productIndex}-${fieldKey}`, false);
});
}
}
}, 0);
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions]);
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions, isInlineAiField, productIndex]);
// Stable callback for fetching options (for line/subline dropdowns)
const handleFetchOptions = useCallback(async () => {
@@ -665,6 +747,30 @@ const CellWrapper = memo(({
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
{/* Inline AI validation spinner */}
{isInlineAiValidating && isInlineAiField && (
<div className="absolute right-1 top-1/2 -translate-y-1/2 z-10">
<Loader2 className="h-4 w-4 animate-spin text-purple-500" />
</div>
)}
{/* AI Suggestion badge - shows when AI has a suggestion for this field */}
{showSuggestion && fieldSuggestion && (
<div className="absolute top-full left-0 right-0 z-20 mt-1">
<AiSuggestionBadge
suggestion={fieldSuggestion.suggestion!}
issues={fieldSuggestion.issues}
onAccept={() => {
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, field.key as 'name' | 'description');
}}
onDismiss={() => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, field.key as 'name' | 'description');
}}
compact
/>
</div>
)}
</div>
);
});
@@ -936,6 +1042,19 @@ const VirtualRow = memo(({
useCallback((state) => state.copyDownMode, [])
);
// Subscribe to inline AI suggestions for this row (for name/description validation)
const inlineAiSuggestion = useValidationStore(
useCallback((state) => state.inlineAi.suggestions.get(rowId), [rowId])
);
// Check if inline AI validation is running for this row
const isInlineAiValidatingName = useValidationStore(
useCallback((state) => state.inlineAi.validating.has(`${rowId}-name`), [rowId])
);
const isInlineAiValidatingDescription = useValidationStore(
useCallback((state) => state.inlineAi.validating.has(`${rowId}-description`), [rowId])
);
// DON'T subscribe to caches - read via getState() when needed
// Subscribing to caches causes ALL rows with same company to re-render when cache updates!
// Note: company and line are already declared above for loading state subscriptions
@@ -1069,6 +1188,11 @@ const VirtualRow = memo(({
isInCopyDownRange={isInCopyDownRange}
isCopyDownTarget={isCopyDownTarget}
totalRowCount={totalRowCount}
inlineAiSuggestion={inlineAiSuggestion}
isInlineAiValidating={
field.key === 'name' ? isInlineAiValidatingName :
field.key === 'description' ? isInlineAiValidatingDescription : false
}
/>
</div>
);

View File

@@ -0,0 +1,275 @@
/**
* SanityCheckDialog Component
*
* Modal dialog that shows sanity check progress and results.
* Automatically triggered when user clicks Continue to next step.
*/
import {
AlertCircle,
CheckCircle,
Loader2,
AlertTriangle,
ChevronRight,
XCircle
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { SanityIssue, SanityCheckResult } from '../hooks/useSanityCheck';
interface SanityCheckDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Called when dialog should close */
onOpenChange: (open: boolean) => void;
/** Whether the check is currently running */
isChecking: boolean;
/** Error message if check failed */
error: string | null;
/** Results of the sanity check */
result: SanityCheckResult | null;
/** Called when user wants to proceed despite issues */
onProceed: () => void;
/** Called when user wants to go back and fix issues */
onGoBack: () => void;
/** Called to scroll to a specific product */
onScrollToProduct?: (productIndex: number) => void;
/** Product names for display (indexed by product index) */
productNames?: Record<number, string>;
}
export function SanityCheckDialog({
open,
onOpenChange,
isChecking,
error,
result,
onProceed,
onGoBack,
onScrollToProduct,
productNames = {}
}: SanityCheckDialogProps) {
const hasIssues = result?.issues && result.issues.length > 0;
const passed = !isChecking && !error && !hasIssues && result;
// Group issues by severity/field for better organization
const issuesByField = result?.issues?.reduce((acc, issue) => {
const field = issue.field;
if (!acc[field]) {
acc[field] = [];
}
acc[field].push(issue);
return acc;
}, {} as Record<string, SanityIssue[]>) || {};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{isChecking ? (
<>
<Loader2 className="h-5 w-5 animate-spin text-purple-500" />
Running Sanity Check...
</>
) : error ? (
<>
<XCircle className="h-5 w-5 text-red-500" />
Sanity Check Failed
</>
) : passed ? (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
Sanity Check Passed
</>
) : hasIssues ? (
<>
<AlertTriangle className="h-5 w-5 text-amber-500" />
Issues Found
</>
) : (
'Sanity Check'
)}
</DialogTitle>
<DialogDescription>
{isChecking
? 'Reviewing products for consistency and appropriateness...'
: error
? 'An error occurred while checking your products.'
: passed
? 'All products look good! No consistency issues detected.'
: hasIssues
? `Found ${result?.issues.length} potential issue${result?.issues.length === 1 ? '' : 's'} to review.`
: 'Checking your products...'}
</DialogDescription>
</DialogHeader>
{/* Content */}
<div className="py-4">
{/* Loading state */}
{isChecking && (
<div className="flex flex-col items-center justify-center py-8 gap-4">
<Loader2 className="h-8 w-8 animate-spin text-purple-500" />
<p className="text-sm text-muted-foreground">
Analyzing {result?.totalProducts || '...'} products...
</p>
</div>
)}
{/* Error state */}
{error && !isChecking && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200">
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-800">Error</p>
<p className="text-sm text-red-600 mt-1">{error}</p>
</div>
</div>
)}
{/* Success state */}
{passed && !isChecking && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 border border-green-200">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-green-800">All Clear!</p>
<p className="text-sm text-green-600 mt-1">
{result?.summary || 'No consistency issues detected in your products.'}
</p>
</div>
</div>
)}
{/* Issues list */}
{hasIssues && !isChecking && (
<ScrollArea className="max-h-[400px] pr-4">
<div className="space-y-4">
{/* Summary */}
{result?.summary && (
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
<p className="text-sm text-amber-800">{result.summary}</p>
</div>
)}
{/* Issues grouped by field */}
{Object.entries(issuesByField).map(([field, fieldIssues]) => (
<div key={field} className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{formatFieldName(field)}
</Badge>
<span className="text-xs text-muted-foreground">
{fieldIssues.length} issue{fieldIssues.length === 1 ? '' : 's'}
</span>
</div>
{fieldIssues.map((issue, index) => (
<div
key={`${field}-${index}`}
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 border border-gray-200 hover:border-gray-300 transition-colors"
>
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium">
{productNames[issue.productIndex] || `Product ${issue.productIndex + 1}`}
</span>
{onScrollToProduct && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1 text-xs text-blue-600 hover:text-blue-800"
onClick={() => {
onOpenChange(false);
onScrollToProduct(issue.productIndex);
}}
>
Go to
<ChevronRight className="h-3 w-3 ml-0.5" />
</Button>
)}
</div>
<p className="text-sm text-gray-600">{issue.issue}</p>
{issue.suggestion && (
<p className="text-xs text-blue-600 mt-1">
💡 {issue.suggestion}
</p>
)}
</div>
</div>
))}
</div>
))}
</div>
</ScrollArea>
)}
</div>
{/* Footer */}
<DialogFooter>
{isChecking ? (
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
) : error ? (
<>
<Button variant="outline" onClick={onGoBack}>
Go Back
</Button>
<Button onClick={() => onOpenChange(false)}>
Close
</Button>
</>
) : passed ? (
<Button onClick={onProceed}>
Continue to Next Step
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : hasIssues ? (
<>
<Button variant="outline" onClick={onGoBack}>
Review Issues
</Button>
<Button onClick={onProceed}>
Proceed Anyway
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Format a field key into a human-readable name
*/
function formatFieldName(field: string): string {
const fieldNames: Record<string, string> = {
supplier_no: 'Supplier #',
msrp: 'MSRP',
cost_each: 'Cost Each',
qty_per_unit: 'Min Qty',
case_qty: 'Case Pack',
tax_cat: 'Tax Category',
size_cat: 'Size Category',
name: 'Name',
themes: 'Themes',
weight: 'Weight',
length: 'Length',
width: 'Width',
height: 'Height'
};
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}

View File

@@ -0,0 +1,268 @@
/**
* useInlineAiValidation Hook
*
* Provides inline AI validation for product names and descriptions.
* Calls the backend Groq-powered validation endpoints.
*/
import { useState, useCallback, useRef } from 'react';
// Types for the validation results
export interface InlineAiResult {
isValid: boolean;
suggestion: string | null;
issues: string[];
latencyMs?: number;
}
export interface InlineAiValidationState {
isValidating: boolean;
error: string | null;
nameResult: InlineAiResult | null;
descriptionResult: InlineAiResult | null;
}
// Product data structure for validation
export interface ProductForValidation {
name?: string;
description?: string;
company_name?: string;
company_id?: string | number;
line_name?: string;
categories?: string;
}
// Debounce delay in milliseconds
const DEBOUNCE_DELAY = 500;
/**
* Hook for inline AI validation of product fields
*/
export function useInlineAiValidation() {
const [state, setState] = useState<InlineAiValidationState>({
isValidating: false,
error: null,
nameResult: null,
descriptionResult: null
});
// Track pending requests for cancellation
const abortControllerRef = useRef<AbortController | null>(null);
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
/**
* Validate a product name
*/
const validateName = useCallback(async (product: ProductForValidation): Promise<InlineAiResult | null> => {
if (!product.name?.trim()) {
return null;
}
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setState(prev => ({ ...prev, isValidating: true, error: null }));
try {
const response = await fetch('/api/ai/validate/inline/name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product }),
signal: controller.signal
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Validation failed: ${response.status}`);
}
const result = await response.json();
const aiResult: InlineAiResult = {
isValid: result.isValid ?? true,
suggestion: result.suggestion || null,
issues: result.issues || [],
latencyMs: result.latencyMs
};
setState(prev => ({
...prev,
isValidating: false,
nameResult: aiResult,
error: null
}));
return aiResult;
} catch (error) {
if ((error as Error).name === 'AbortError') {
// Request was cancelled, ignore
return null;
}
const message = (error as Error).message || 'Validation failed';
setState(prev => ({
...prev,
isValidating: false,
error: message
}));
return null;
}
}, []);
/**
* Validate a product description
*/
const validateDescription = useCallback(async (product: ProductForValidation): Promise<InlineAiResult | null> => {
if (!product.name?.trim() && !product.description?.trim()) {
return null;
}
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setState(prev => ({ ...prev, isValidating: true, error: null }));
try {
const response = await fetch('/api/ai/validate/inline/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product }),
signal: controller.signal
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Validation failed: ${response.status}`);
}
const result = await response.json();
const aiResult: InlineAiResult = {
isValid: result.isValid ?? true,
suggestion: result.suggestion || null,
issues: result.issues || [],
latencyMs: result.latencyMs
};
setState(prev => ({
...prev,
isValidating: false,
descriptionResult: aiResult,
error: null
}));
return aiResult;
} catch (error) {
if ((error as Error).name === 'AbortError') {
// Request was cancelled, ignore
return null;
}
const message = (error as Error).message || 'Validation failed';
setState(prev => ({
...prev,
isValidating: false,
error: message
}));
return null;
}
}, []);
/**
* Debounced name validation - call on blur or after typing stops
*/
const validateNameDebounced = useCallback((product: ProductForValidation) => {
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
validateName(product);
}, DEBOUNCE_DELAY);
}, [validateName]);
/**
* Debounced description validation
*/
const validateDescriptionDebounced = useCallback((product: ProductForValidation) => {
// Clear existing timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
debounceTimerRef.current = setTimeout(() => {
validateDescription(product);
}, DEBOUNCE_DELAY);
}, [validateDescription]);
/**
* Clear validation results
*/
const clearResults = useCallback(() => {
// Cancel any pending requests
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = null;
}
setState({
isValidating: false,
error: null,
nameResult: null,
descriptionResult: null
});
}, []);
/**
* Clear name result only
*/
const clearNameResult = useCallback(() => {
setState(prev => ({ ...prev, nameResult: null }));
}, []);
/**
* Clear description result only
*/
const clearDescriptionResult = useCallback(() => {
setState(prev => ({ ...prev, descriptionResult: null }));
}, []);
return {
// State
isValidating: state.isValidating,
error: state.error,
nameResult: state.nameResult,
descriptionResult: state.descriptionResult,
// Actions - immediate
validateName,
validateDescription,
// Actions - debounced
validateNameDebounced,
validateDescriptionDebounced,
// Clear
clearResults,
clearNameResult,
clearDescriptionResult
};
}

View File

@@ -0,0 +1,234 @@
/**
* useSanityCheck Hook
*
* Runs batch sanity check on products before proceeding to next step.
* Checks for consistency and appropriateness across products.
*/
import { useState, useCallback, useRef } from 'react';
// Types for sanity check results
export interface SanityIssue {
productIndex: number;
field: string;
issue: string;
suggestion?: string;
}
export interface SanityCheckResult {
issues: SanityIssue[];
summary: string;
latencyMs?: number;
totalProducts?: number;
issueCount?: number;
}
export interface SanityCheckState {
isChecking: boolean;
error: string | null;
result: SanityCheckResult | null;
hasRun: boolean;
}
// Product data for sanity check (simplified structure)
export interface ProductForSanityCheck {
name?: string;
supplier?: string;
supplier_name?: string;
company?: string;
company_name?: string;
supplier_no?: string;
msrp?: string | number;
cost_each?: string | number;
qty_per_unit?: string | number;
case_qty?: string | number;
tax_cat?: string | number;
tax_cat_name?: string;
size_cat?: string | number;
size_cat_name?: string;
themes?: string;
theme_names?: string;
weight?: string | number;
length?: string | number;
width?: string | number;
height?: string | number;
}
/**
* Hook for batch sanity check of products
*/
export function useSanityCheck() {
const [state, setState] = useState<SanityCheckState>({
isChecking: false,
error: null,
result: null,
hasRun: false
});
// Track pending request for cancellation
const abortControllerRef = useRef<AbortController | null>(null);
/**
* Run sanity check on products
*/
const runCheck = useCallback(async (products: ProductForSanityCheck[]): Promise<SanityCheckResult | null> => {
if (!products || products.length === 0) {
return {
issues: [],
summary: 'No products to check'
};
}
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
const controller = new AbortController();
abortControllerRef.current = controller;
setState(prev => ({
...prev,
isChecking: true,
error: null,
hasRun: true
}));
try {
const response = await fetch('/api/ai/validate/sanity-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ products }),
signal: controller.signal
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Sanity check failed: ${response.status}`);
}
const data = await response.json();
const result: SanityCheckResult = {
issues: data.issues || [],
summary: data.summary || 'Check complete',
latencyMs: data.latencyMs,
totalProducts: products.length,
issueCount: data.issues?.length || 0
};
setState(prev => ({
...prev,
isChecking: false,
result,
error: null
}));
return result;
} catch (error) {
if ((error as Error).name === 'AbortError') {
// Request was cancelled
return null;
}
const message = (error as Error).message || 'Sanity check failed';
setState(prev => ({
...prev,
isChecking: false,
error: message
}));
return null;
}
}, []);
/**
* Cancel the current check
*/
const cancelCheck = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setState(prev => ({
...prev,
isChecking: false
}));
}, []);
/**
* Clear results and reset state
*/
const clearResults = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setState({
isChecking: false,
error: null,
result: null,
hasRun: false
});
}, []);
/**
* Get issues for a specific product index
*/
const getIssuesForProduct = useCallback((productIndex: number): SanityIssue[] => {
if (!state.result?.issues) return [];
return state.result.issues.filter(issue => issue.productIndex === productIndex);
}, [state.result]);
/**
* Get issues grouped by field
*/
const getIssuesByField = useCallback((): Record<string, SanityIssue[]> => {
if (!state.result?.issues) return {};
return state.result.issues.reduce((acc, issue) => {
const field = issue.field;
if (!acc[field]) {
acc[field] = [];
}
acc[field].push(issue);
return acc;
}, {} as Record<string, SanityIssue[]>);
}, [state.result]);
/**
* Check if there are any issues
*/
const hasIssues = state.result?.issues && state.result.issues.length > 0;
/**
* Check if the sanity check passed (ran with no issues)
*/
const passed = state.hasRun && !state.isChecking && !state.error && !hasIssues;
return {
// State
isChecking: state.isChecking,
error: state.error,
result: state.result,
hasRun: state.hasRun,
hasIssues,
passed,
// Computed
issues: state.result?.issues || [],
summary: state.result?.summary || null,
issueCount: state.result?.issueCount || 0,
// Actions
runCheck,
cancelCheck,
clearResults,
// Helpers
getIssuesForProduct,
getIssuesByField
};
}

View File

@@ -258,6 +258,43 @@ export interface AiSuggestionsState {
error: string | null;
}
// =============================================================================
// Inline AI Validation Types (Groq-powered)
// =============================================================================
/**
* Result from inline AI validation (name or description)
*/
export interface InlineAiValidationResult {
isValid: boolean;
suggestion?: string;
issues: string[];
latencyMs?: number;
}
/**
* Per-product inline AI suggestions (keyed by product __index)
*/
export interface InlineAiSuggestion {
name?: InlineAiValidationResult;
description?: InlineAiValidationResult;
/** Whether the suggestion has been dismissed by the user */
dismissed?: {
name?: boolean;
description?: boolean;
};
}
/**
* State for inline AI validation
*/
export interface InlineAiState {
/** Map of product __index to their inline suggestions */
suggestions: Map<string, InlineAiSuggestion>;
/** Products currently being validated */
validating: Set<string>;
}
// =============================================================================
// Initialization Types
// =============================================================================
@@ -343,6 +380,9 @@ export interface ValidationState {
// === AI Validation ===
aiValidation: AiValidationState;
// === Inline AI Validation (Groq) ===
inlineAi: InlineAiState;
// === File (for output) ===
file: File | null;
}
@@ -438,6 +478,13 @@ export interface ValidationActions {
clearAiValidation: () => void;
storeOriginalValues: () => void;
// === Inline AI Validation ===
setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: InlineAiValidationResult) => void;
dismissInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void;
acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void;
clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => void;
setInlineAiValidating: (productIndex: string, validating: boolean) => void;
// === Output ===
getCleanedData: () => CleanRowData[];

View File

@@ -29,7 +29,7 @@ import type {
AiValidationResults,
CopyDownState,
DialogState,
PendingCopyDownValidation,
InlineAiValidationResult,
} from './types';
import type { Field, SelectOption } from '../../../types';
@@ -125,6 +125,12 @@ const getInitialState = (): ValidationState => ({
revertedChanges: new Set(),
},
// Inline AI Validation (Groq)
inlineAi: {
suggestions: new Map(),
validating: new Set(),
},
// File
file: null,
});
@@ -750,6 +756,92 @@ export const useValidationStore = create<ValidationStore>()(
});
},
// =========================================================================
// Inline AI Validation (Groq)
// =========================================================================
setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: InlineAiValidationResult) => {
set((state) => {
const existing = state.inlineAi.suggestions.get(productIndex) || {};
state.inlineAi.suggestions.set(productIndex, {
...existing,
[field]: result,
dismissed: {
...existing.dismissed,
[field]: false, // Reset dismissed state when new suggestion arrives
},
});
state.inlineAi.validating.delete(`${productIndex}-${field}`);
});
},
dismissInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => {
set((state) => {
const existing = state.inlineAi.suggestions.get(productIndex);
if (existing) {
state.inlineAi.suggestions.set(productIndex, {
...existing,
dismissed: {
...existing.dismissed,
[field]: true,
},
});
}
});
},
acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => {
set((state) => {
const suggestion = state.inlineAi.suggestions.get(productIndex)?.[field];
if (suggestion?.suggestion) {
// Find the row by __index and update the field
const rowIndex = state.rows.findIndex((row: RowData) => row.__index === productIndex);
if (rowIndex >= 0) {
state.rows[rowIndex][field] = suggestion.suggestion;
}
// Mark as dismissed after accepting
const existing = state.inlineAi.suggestions.get(productIndex);
if (existing) {
state.inlineAi.suggestions.set(productIndex, {
...existing,
dismissed: {
...existing.dismissed,
[field]: true,
},
});
}
}
});
},
clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => {
set((state) => {
if (field) {
const existing = state.inlineAi.suggestions.get(productIndex);
if (existing) {
const { [field]: _, ...rest } = existing;
if (Object.keys(rest).length === 0 || (Object.keys(rest).length === 1 && 'dismissed' in rest)) {
state.inlineAi.suggestions.delete(productIndex);
} else {
state.inlineAi.suggestions.set(productIndex, rest);
}
}
} else {
state.inlineAi.suggestions.delete(productIndex);
}
});
},
setInlineAiValidating: (productIndex: string, validating: boolean) => {
set((state) => {
if (validating) {
state.inlineAi.validating.add(productIndex);
} else {
state.inlineAi.validating.delete(productIndex);
}
});
},
// =========================================================================
// Output
// =========================================================================

View File

@@ -11,8 +11,9 @@ import {
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { ArrowUpDown, Pencil, Trash2, PlusCircle } from "lucide-react";
import config from "@/config";
import {
@@ -48,18 +49,21 @@ interface FieldOption {
value: string;
}
// Form uses task + role, which gets composed into prompt_type on submit
interface PromptFormData {
id?: number;
prompt_text: string;
prompt_type: 'general' | 'company_specific' | 'system';
task: string;
role: "system" | "general" | "company_specific";
company: string | null;
}
interface AiPrompt {
id: number;
prompt_text: string;
prompt_type: 'general' | 'company_specific' | 'system';
prompt_type: string;
company: string | null;
is_singleton: boolean;
created_at: string;
updated_at: string;
}
@@ -68,19 +72,74 @@ interface FieldOptions {
companies: FieldOption[];
}
// Predefined tasks (can also enter custom)
const PREDEFINED_TASKS = [
{ value: "name_validation", label: "Name Validation", description: "Inline validation of product names (Groq)" },
{ value: "description_validation", label: "Description Validation", description: "Inline validation of product descriptions (Groq)" },
{ value: "sanity_check", label: "Sanity Check", description: "Batch product consistency review (Groq)" },
{ value: "bulk_validation", label: "Bulk Validation", description: "Full product validation during import (GPT-5)" },
];
// Role options
const ROLES = [
{ value: "system", label: "System", description: "Initial instructions that set the AI's behavior" },
{ value: "general", label: "General", description: "Rules that apply to all products" },
{ value: "company_specific", label: "Company-Specific", description: "Rules unique to a specific company" },
];
// Parse prompt_type into task and role
function parsePromptType(promptType: string): { task: string; role: "system" | "general" | "company_specific" } {
if (promptType.endsWith("_company_specific")) {
return { task: promptType.replace("_company_specific", ""), role: "company_specific" };
}
if (promptType.endsWith("_general")) {
return { task: promptType.replace("_general", ""), role: "general" };
}
if (promptType.endsWith("_system")) {
return { task: promptType.replace("_system", ""), role: "system" };
}
// Fallback - assume it's a system prompt
return { task: promptType, role: "system" };
}
// Compose task + role into prompt_type
function composePromptType(task: string, role: string): string {
return `${task}_${role}`;
}
// Get human-readable task name
function getTaskDisplayName(task: string): string {
const predefined = PREDEFINED_TASKS.find(t => t.value === task);
if (predefined) return predefined.label;
// Format custom task nicely
return task
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
// Get human-readable role name
function getRoleDisplayName(role: string): string {
const roleInfo = ROLES.find(r => r.value === role);
return roleInfo?.label || role;
}
export function PromptManagement() {
const [isFormOpen, setIsFormOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [promptToDelete, setPromptToDelete] = useState<AiPrompt | null>(null);
const [editingPrompt, setEditingPrompt] = useState<AiPrompt | null>(null);
const [sorting, setSorting] = useState<SortingState>([
{ id: "prompt_type", desc: true },
{ id: "prompt_type", desc: false },
{ id: "company", desc: false }
]);
const [searchQuery, setSearchQuery] = useState("");
const [useCustomTask, setUseCustomTask] = useState(false);
const [customTask, setCustomTask] = useState("");
const [formData, setFormData] = useState<PromptFormData>({
prompt_text: "",
prompt_type: "general",
task: "",
role: "system",
company: null,
});
@@ -108,34 +167,45 @@ export function PromptManagement() {
},
});
// Check if general and system prompts already exist
const generalPromptExists = useMemo(() => {
return prompts?.some(prompt => prompt.prompt_type === 'general');
}, [prompts]);
const systemPromptExists = useMemo(() => {
return prompts?.some(prompt => prompt.prompt_type === 'system');
// Track which prompts exist for disabling options
const existingPrompts = useMemo(() => {
if (!prompts) return { system: new Set<string>(), general: new Set<string>(), companySpecific: new Map<string, Set<string>>() };
const system = new Set<string>();
const general = new Set<string>();
const companySpecific = new Map<string, Set<string>>();
prompts.forEach(p => {
const { task, role } = parsePromptType(p.prompt_type);
if (role === "system") {
system.add(task);
} else if (role === "general") {
general.add(task);
} else if (role === "company_specific" && p.company) {
if (!companySpecific.has(task)) {
companySpecific.set(task, new Set());
}
companySpecific.get(task)!.add(p.company);
}
});
return { system, general, companySpecific };
}, [prompts]);
const createMutation = useMutation({
mutationFn: async (data: PromptFormData) => {
mutationFn: async (data: { prompt_text: string; prompt_type: string; company: string | null; is_singleton: boolean }) => {
const response = await fetch(`${config.apiUrl}/ai-prompts`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || error.error || "Failed to create prompt");
}
return response.json();
},
onSuccess: (newPrompt) => {
// Optimistically update the cache with the new prompt
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
if (!old) return [newPrompt];
return [...old, newPrompt];
@@ -149,29 +219,22 @@ export function PromptManagement() {
});
const updateMutation = useMutation({
mutationFn: async (data: PromptFormData) => {
if (!data.id) throw new Error("Prompt ID is required for update");
mutationFn: async (data: { id: number; prompt_text: string; prompt_type: string; company: string | null; is_singleton: boolean }) => {
const response = await fetch(`${config.apiUrl}/ai-prompts/${data.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || error.error || "Failed to update prompt");
}
return response.json();
},
onSuccess: (updatedPrompt) => {
// Optimistically update the cache with the returned data
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
if (!old) return [updatedPrompt];
return old.map((prompt) =>
return old.map((prompt) =>
prompt.id === updatedPrompt.id ? updatedPrompt : prompt
);
});
@@ -194,7 +257,6 @@ export function PromptManagement() {
return id;
},
onSuccess: (deletedId) => {
// Optimistically update the cache by removing the deleted prompt
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
if (!old) return [];
return old.filter((prompt) => prompt.id !== deletedId);
@@ -208,10 +270,15 @@ export function PromptManagement() {
const handleEdit = (prompt: AiPrompt) => {
setEditingPrompt(prompt);
const { task, role } = parsePromptType(prompt.prompt_type);
const isPredefinedTask = PREDEFINED_TASKS.some(t => t.value === task);
setUseCustomTask(!isPredefinedTask);
setCustomTask(isPredefinedTask ? "" : task);
setFormData({
id: prompt.id,
prompt_text: prompt.prompt_text,
prompt_type: prompt.prompt_type,
task: task,
role: role,
company: prompt.company,
});
setIsFormOpen(true);
@@ -232,15 +299,35 @@ export function PromptManagement() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// If prompt_type is general or system, ensure company is null
const actualTask = useCustomTask ? customTask.trim() : formData.task;
if (!actualTask) {
toast.error("Please select or enter a task");
return;
}
if (!formData.role) {
toast.error("Please select a role");
return;
}
if (formData.role === "company_specific" && !formData.company) {
toast.error("Please select a company");
return;
}
const promptType = composePromptType(actualTask, formData.role);
const submitData = {
...formData,
company: formData.prompt_type === 'company_specific' ? formData.company : null,
id: formData.id,
prompt_text: formData.prompt_text,
prompt_type: promptType,
company: formData.role === "company_specific" ? formData.company : null,
is_singleton: true, // Always singleton
};
if (editingPrompt) {
updateMutation.mutate(submitData);
updateMutation.mutate(submitData as { id: number; prompt_text: string; prompt_type: string; company: string | null; is_singleton: boolean });
} else {
createMutation.mutate(submitData);
}
@@ -249,39 +336,76 @@ export function PromptManagement() {
const resetForm = () => {
setFormData({
prompt_text: "",
prompt_type: "general",
task: "",
role: "system",
company: null,
});
setEditingPrompt(null);
setUseCustomTask(false);
setCustomTask("");
setIsFormOpen(false);
};
const handleCreateClick = () => {
resetForm();
// If general prompt and system prompt exist, default to company-specific
if (generalPromptExists && systemPromptExists) {
setFormData(prev => ({
...prev,
prompt_type: 'company_specific'
}));
} else if (generalPromptExists && !systemPromptExists) {
// If general exists but system doesn't, suggest system prompt
setFormData(prev => ({
...prev,
prompt_type: 'system'
}));
} else if (!generalPromptExists) {
// If no general prompt, suggest that first
setFormData(prev => ({
...prev,
prompt_type: 'general'
}));
// Find first task + role combo that doesn't exist
for (const task of PREDEFINED_TASKS) {
if (!existingPrompts.system.has(task.value)) {
setFormData(prev => ({ ...prev, task: task.value, role: "system" }));
setIsFormOpen(true);
return;
}
if (!existingPrompts.general.has(task.value)) {
setFormData(prev => ({ ...prev, task: task.value, role: "general" }));
setIsFormOpen(true);
return;
}
}
// All base prompts exist, default to first task with company-specific
setFormData(prev => ({ ...prev, task: PREDEFINED_TASKS[0].value, role: "company_specific" }));
setIsFormOpen(true);
};
const handleTaskChange = (value: string) => {
if (value === "__custom__") {
setUseCustomTask(true);
setFormData(prev => ({ ...prev, task: "" }));
} else {
setUseCustomTask(false);
setCustomTask("");
setFormData(prev => ({ ...prev, task: value }));
}
};
// Get the effective task for checking what exists
const effectiveTask = useCustomTask ? customTask.trim() : formData.task;
// Check if current selection would conflict
const wouldConflict = useMemo(() => {
if (!effectiveTask) return false;
// If editing the same prompt, no conflict
if (editingPrompt) {
const { task: editTask, role: editRole } = parsePromptType(editingPrompt.prompt_type);
if (editTask === effectiveTask && editRole === formData.role) {
if (formData.role !== "company_specific") return false;
if (editingPrompt.company === formData.company) return false;
}
}
if (formData.role === "system") {
return existingPrompts.system.has(effectiveTask);
}
if (formData.role === "general") {
return existingPrompts.general.has(effectiveTask);
}
if (formData.role === "company_specific" && formData.company) {
const taskCompanies = existingPrompts.companySpecific.get(effectiveTask);
return taskCompanies?.has(formData.company) || false;
}
return false;
}, [effectiveTask, formData.role, formData.company, existingPrompts, editingPrompt]);
const columns = useMemo<ColumnDef<AiPrompt>[]>(() => [
{
accessorKey: "prompt_type",
@@ -290,15 +414,24 @@ export function PromptManagement() {
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Type
Task
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => {
const type = row.getValue("prompt_type") as string;
if (type === 'general') return 'General';
if (type === 'system') return 'System';
return 'Company Specific';
const { task } = parsePromptType(row.getValue("prompt_type") as string);
return (
<span className="font-medium">{getTaskDisplayName(task)}</span>
);
},
},
{
id: "role",
header: "Role",
cell: ({ row }) => {
const { role } = parsePromptType(row.getValue("prompt_type") as string);
const variant = role === "system" ? "default" : role === "general" ? "secondary" : "outline";
return <Badge variant={variant}>{getRoleDisplayName(role)}</Badge>;
},
},
{
@@ -331,7 +464,7 @@ export function PromptManagement() {
),
cell: ({ row }) => {
const companyId = row.getValue("company");
if (!companyId) return 'N/A';
if (!companyId) return <span className="text-muted-foreground"></span>;
return fieldOptions?.companies.find(c => c.value === companyId)?.label || companyId;
},
},
@@ -352,10 +485,7 @@ export function PromptManagement() {
id: "actions",
cell: ({ row }) => (
<div className="flex gap-2 justify-end pr-4">
<Button
variant="ghost"
onClick={() => handleEdit(row.original)}
>
<Button variant="ghost" onClick={() => handleEdit(row.original)}>
<Pencil className="h-4 w-4" />
Edit
</Button>
@@ -376,7 +506,12 @@ export function PromptManagement() {
if (!prompts) return [];
return prompts.filter((prompt) => {
const searchString = searchQuery.toLowerCase();
const { task, role } = parsePromptType(prompt.prompt_type);
const taskName = getTaskDisplayName(task).toLowerCase();
const roleName = getRoleDisplayName(role).toLowerCase();
return (
taskName.includes(searchString) ||
roleName.includes(searchString) ||
prompt.prompt_type.toLowerCase().includes(searchString) ||
(prompt.company && prompt.company.toLowerCase().includes(searchString))
);
@@ -386,9 +521,7 @@ export function PromptManagement() {
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
},
state: { sorting },
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
getCoreRowModel: getCoreRowModel(),
@@ -425,10 +558,7 @@ export function PromptManagement() {
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</TableRow>
@@ -463,108 +593,174 @@ export function PromptManagement() {
<DialogHeader>
<DialogTitle>{editingPrompt ? "Edit Prompt" : "Create New Prompt"}</DialogTitle>
<DialogDescription>
{editingPrompt
{editingPrompt
? "Update this AI validation prompt."
: "Create a new AI validation prompt that will be used during product validation."}
: "Create a new AI validation prompt. Select a task and role, then enter the prompt text."}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
{/* Task Selector */}
<div className="grid gap-2">
<Label htmlFor="prompt_type">Prompt Type</Label>
<Select
value={formData.prompt_type}
onValueChange={(value: 'general' | 'company_specific' | 'system') =>
setFormData({ ...formData, prompt_type: value })
}
disabled={(generalPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id) ||
(systemPromptExists && formData.prompt_type !== 'system' && !editingPrompt?.id)}
>
<SelectTrigger>
<SelectValue placeholder="Select prompt type" />
</SelectTrigger>
<SelectContent>
<SelectItem
value="general"
disabled={generalPromptExists && !editingPrompt?.prompt_type?.includes('general')}
<Label>Task</Label>
{!useCustomTask ? (
<Select value={formData.task} onValueChange={handleTaskChange}>
<SelectTrigger>
<SelectValue placeholder="Select task" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">Predefined Tasks</SelectLabel>
{PREDEFINED_TASKS.map((task) => (
<SelectItem key={task.value} value={task.value}>
<span className="flex flex-col">
<span>{task.label}</span>
<span className="text-xs text-muted-foreground">{task.description}</span>
</span>
</SelectItem>
))}
</SelectGroup>
<SelectGroup>
<SelectLabel className="text-xs text-muted-foreground">Custom</SelectLabel>
<SelectItem value="__custom__">Custom Task...</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
) : (
<div className="flex gap-2">
<Input
value={customTask}
onChange={(e) => setCustomTask(e.target.value.toLowerCase().replace(/\s+/g, '_'))}
placeholder="e.g., my_custom_task"
className="font-mono"
/>
<Button
type="button"
variant="outline"
onClick={() => {
setUseCustomTask(false);
setCustomTask("");
}}
>
General
</SelectItem>
<SelectItem
value="system"
disabled={systemPromptExists && !editingPrompt?.prompt_type?.includes('system')}
>
System
</SelectItem>
<SelectItem value="company_specific">Company Specific</SelectItem>
</SelectContent>
</Select>
{generalPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id && systemPromptExists && formData.prompt_type !== 'system' && (
<p className="text-xs text-muted-foreground">
General and system prompts already exist. You can only create company-specific prompts.
</p>
)}
{generalPromptExists && !systemPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id && (
<p className="text-xs text-muted-foreground">
A general prompt already exists. You can create a system prompt or company-specific prompts.
</p>
)}
{systemPromptExists && !generalPromptExists && formData.prompt_type !== 'system' && !editingPrompt?.id && (
<p className="text-xs text-muted-foreground">
A system prompt already exists. You can create a general prompt or company-specific prompts.
</p>
Cancel
</Button>
</div>
)}
</div>
{formData.prompt_type === 'company_specific' && (
{/* Role Selector */}
<div className="grid gap-2">
<Label>Role</Label>
<Select
value={formData.role}
onValueChange={(value: "system" | "general" | "company_specific") =>
setFormData(prev => ({ ...prev, role: value, company: value !== "company_specific" ? null : prev.company }))
}
>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{ROLES.map((role) => {
// Check if this role is already taken for the current task
let isDisabled = false;
if (effectiveTask) {
if (role.value === "system") {
isDisabled = existingPrompts.system.has(effectiveTask);
} else if (role.value === "general") {
isDisabled = existingPrompts.general.has(effectiveTask);
}
// Company-specific is never disabled at the role level
}
// Allow if editing the same prompt
if (editingPrompt && effectiveTask) {
const { task: editTask, role: editRole } = parsePromptType(editingPrompt.prompt_type);
if (editTask === effectiveTask && editRole === role.value) {
isDisabled = false;
}
}
return (
<SelectItem key={role.value} value={role.value} disabled={isDisabled}>
<span className="flex items-center gap-2">
{role.label}
{isDisabled && <Badge variant="secondary" className="text-xs">exists</Badge>}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{ROLES.find(r => r.value === formData.role)?.description}
</p>
</div>
{/* Company Selector (only for company_specific role) */}
{formData.role === "company_specific" && (
<div className="grid gap-2">
<Label htmlFor="company">Company</Label>
<Label>Company</Label>
<Select
value={formData.company || ''}
onValueChange={(value) => setFormData({ ...formData, company: value })}
required={formData.prompt_type === 'company_specific'}
onValueChange={(value) => setFormData(prev => ({ ...prev, company: value }))}
>
<SelectTrigger>
<SelectValue placeholder="Select company" />
</SelectTrigger>
<SelectContent>
{fieldOptions?.companies.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
{fieldOptions?.companies.map((company) => {
const taskCompanies = existingPrompts.companySpecific.get(effectiveTask);
const isExisting = taskCompanies?.has(company.value);
const isCurrentEdit = editingPrompt?.company === company.value;
return (
<SelectItem
key={company.value}
value={company.value}
disabled={isExisting && !isCurrentEdit}
>
<span className="flex items-center gap-2">
{company.label}
{isExisting && !isCurrentEdit && (
<Badge variant="secondary" className="text-xs">exists</Badge>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
{/* Conflict Warning */}
{wouldConflict && (
<p className="text-sm text-destructive">
A prompt for this task + role combination already exists.
</p>
)}
{/* Prompt Text */}
<div className="grid gap-2">
<Label htmlFor="prompt_text">Prompt Text</Label>
<Textarea
id="prompt_text"
value={formData.prompt_text}
onChange={(e) => setFormData({ ...formData, prompt_text: e.target.value })}
placeholder={`Enter your ${formData.prompt_type === 'system' ? 'system instructions' : 'validation prompt'} text...`}
onChange={(e) => setFormData(prev => ({ ...prev, prompt_text: e.target.value }))}
placeholder="Enter your prompt text..."
className="h-80 font-mono text-sm"
required
/>
{formData.prompt_type === 'system' && (
<p className="text-xs text-muted-foreground mt-1">
System prompts provide the initial instructions to the AI. This sets the tone and approach for all validations.
</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => {
resetForm();
setIsFormOpen(false);
}}>
<Button type="button" variant="outline" onClick={() => { resetForm(); setIsFormOpen(false); }}>
Cancel
</Button>
<Button type="submit">
<Button
type="submit"
disabled={wouldConflict || !effectiveTask || !formData.role || (formData.role === "company_specific" && !formData.company)}
>
{editingPrompt ? "Update" : "Create"} Prompt
</Button>
</DialogFooter>
@@ -582,10 +778,7 @@ export function PromptManagement() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => {
setIsDeleteOpen(false);
setPromptToDelete(null);
}}>
<AlertDialogCancel onClick={() => { setIsDeleteOpen(false); setPromptToDelete(null); }}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}>
@@ -596,4 +789,4 @@ export function PromptManagement() {
</AlertDialog>
</div>
);
}
}