diff --git a/inventory-server/migrations/002_ai_prompts_extensible_types.sql b/inventory-server/migrations/002_ai_prompts_extensible_types.sql new file mode 100644 index 0000000..10cb664 --- /dev/null +++ b/inventory-server/migrations/002_ai_prompts_extensible_types.sql @@ -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 diff --git a/inventory-server/src/routes/ai-prompts.js b/inventory-server/src/routes/ai-prompts.js index 98136ca..1ec6a1c 100644 --- a/inventory-server/src/routes/ai-prompts.js +++ b/inventory-server/src/routes/ai-prompts.js @@ -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' }); diff --git a/inventory-server/src/routes/ai-validation.js b/inventory-server/src/routes/ai-validation.js index 2e7ce46..54fd8cb 100644 --- a/inventory-server/src/routes/ai-validation.js +++ b/inventory-server/src/routes/ai-validation.js @@ -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)]); diff --git a/inventory-server/src/routes/ai.js b/inventory-server/src/routes/ai.js index f3a5ebe..4d41f52 100644 --- a/inventory-server/src/routes/ai.js +++ b/inventory-server/src/routes/ai.js @@ -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 } + * 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; diff --git a/inventory-server/src/services/ai/index.js b/inventory-server/src/services/ai/index.js index 628d599..a89b21e 100644 --- a/inventory-server/src/services/ai/index.js +++ b/inventory-server/src/services/ai/index.js @@ -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} 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 diff --git a/inventory-server/src/services/ai/prompts/descriptionPrompts.js b/inventory-server/src/services/ai/prompts/descriptionPrompts.js new file mode 100644 index 0000000..f6dfef4 --- /dev/null +++ b/inventory-server/src/services/ai/prompts/descriptionPrompts.js @@ -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 +}; diff --git a/inventory-server/src/services/ai/prompts/namePrompts.js b/inventory-server/src/services/ai/prompts/namePrompts.js new file mode 100644 index 0000000..93ae459 --- /dev/null +++ b/inventory-server/src/services/ai/prompts/namePrompts.js @@ -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 +}; diff --git a/inventory-server/src/services/ai/prompts/promptLoader.js b/inventory-server/src/services/ai/prompts/promptLoader.js new file mode 100644 index 0000000..44282dc --- /dev/null +++ b/inventory-server/src/services/ai/prompts/promptLoader.js @@ -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} 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}>} + */ +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 +}; diff --git a/inventory-server/src/services/ai/prompts/sanityCheckPrompts.js b/inventory-server/src/services/ai/prompts/sanityCheckPrompts.js new file mode 100644 index 0000000..bfc778e --- /dev/null +++ b/inventory-server/src/services/ai/prompts/sanityCheckPrompts.js @@ -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 +}; diff --git a/inventory-server/src/services/ai/providers/groqProvider.js b/inventory-server/src/services/ai/providers/groqProvider.js new file mode 100644 index 0000000..a4972a7 --- /dev/null +++ b/inventory-server/src/services/ai/providers/groqProvider.js @@ -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 }; diff --git a/inventory-server/src/services/ai/tasks/descriptionValidationTask.js b/inventory-server/src/services/ai/tasks/descriptionValidationTask.js new file mode 100644 index 0000000..a9d6031 --- /dev/null +++ b/inventory-server/src/services/ai/tasks/descriptionValidationTask.js @@ -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} + */ + 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 +}; diff --git a/inventory-server/src/services/ai/tasks/index.js b/inventory-server/src/services/ai/tasks/index.js new file mode 100644 index 0000000..199d69b --- /dev/null +++ b/inventory-server/src/services/ai/tasks/index.js @@ -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} 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 +}; diff --git a/inventory-server/src/services/ai/tasks/nameValidationTask.js b/inventory-server/src/services/ai/tasks/nameValidationTask.js new file mode 100644 index 0000000..0b13b6e --- /dev/null +++ b/inventory-server/src/services/ai/tasks/nameValidationTask.js @@ -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} + */ + 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 +}; diff --git a/inventory-server/src/services/ai/tasks/sanityCheckTask.js b/inventory-server/src/services/ai/tasks/sanityCheckTask.js new file mode 100644 index 0000000..c715976 --- /dev/null +++ b/inventory-server/src/services/ai/tasks/sanityCheckTask.js @@ -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} + */ + 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} + */ +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 +}; diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/AiSuggestionBadge.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/AiSuggestionBadge.tsx new file mode 100644 index 0000000..2640823 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/components/AiSuggestionBadge.tsx @@ -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 ( +
+ + + {suggestion} + +
+ + +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + + AI Suggestion + +
+ + {/* Suggestion content */} +
+ {suggestion} +
+ + {/* Issues list (if any) */} + {issues.length > 0 && ( +
+ {issues.map((issue, index) => ( +
+ + {issue} +
+ ))} +
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} + +/** + * Loading state for AI validation + */ +export function AiValidationLoading({ className }: { className?: string }) { + return ( +
+
+ + Validating with AI... + +
+ ); +} diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx index ce48c01..ec9a3ea 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationContainer.tsx @@ -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 = {}; + 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 ( {/* Floating selection bar - appears when rows selected */} @@ -182,6 +272,19 @@ export const ValidationContainer = ({ debugData={aiValidation.debugPrompt} /> + {/* Sanity Check Dialog - auto-triggered on Continue */} + + {/* Template form dialog - for saving row as template */} 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 (
{/* Back button */} @@ -90,20 +113,19 @@ export const ValidationFooter = ({ {onNext && ( <> diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx index b88c093..2ae9e35 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -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(({
)} + + {/* Inline AI validation spinner */} + {isInlineAiValidating && isInlineAiField && ( +
+ +
+ )} + + {/* AI Suggestion badge - shows when AI has a suggestion for this field */} + {showSuggestion && fieldSuggestion && ( +
+ { + useValidationStore.getState().acceptInlineAiSuggestion(productIndex, field.key as 'name' | 'description'); + }} + onDismiss={() => { + useValidationStore.getState().dismissInlineAiSuggestion(productIndex, field.key as 'name' | 'description'); + }} + compact + /> +
+ )}
); }); @@ -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 + } /> ); diff --git a/inventory/src/components/product-import/steps/ValidationStep/dialogs/SanityCheckDialog.tsx b/inventory/src/components/product-import/steps/ValidationStep/dialogs/SanityCheckDialog.tsx new file mode 100644 index 0000000..d4f14c9 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/dialogs/SanityCheckDialog.tsx @@ -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; +} + +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) || {}; + + return ( + + + + + {isChecking ? ( + <> + + Running Sanity Check... + + ) : error ? ( + <> + + Sanity Check Failed + + ) : passed ? ( + <> + + Sanity Check Passed + + ) : hasIssues ? ( + <> + + Issues Found + + ) : ( + 'Sanity Check' + )} + + + {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...'} + + + + {/* Content */} +
+ {/* Loading state */} + {isChecking && ( +
+ +

+ Analyzing {result?.totalProducts || '...'} products... +

+
+ )} + + {/* Error state */} + {error && !isChecking && ( +
+ +
+

Error

+

{error}

+
+
+ )} + + {/* Success state */} + {passed && !isChecking && ( +
+ +
+

All Clear!

+

+ {result?.summary || 'No consistency issues detected in your products.'} +

+
+
+ )} + + {/* Issues list */} + {hasIssues && !isChecking && ( + +
+ {/* Summary */} + {result?.summary && ( +
+

{result.summary}

+
+ )} + + {/* Issues grouped by field */} + {Object.entries(issuesByField).map(([field, fieldIssues]) => ( +
+
+ + {formatFieldName(field)} + + + {fieldIssues.length} issue{fieldIssues.length === 1 ? '' : 's'} + +
+ + {fieldIssues.map((issue, index) => ( +
+ +
+
+ + {productNames[issue.productIndex] || `Product ${issue.productIndex + 1}`} + + {onScrollToProduct && ( + + )} +
+

{issue.issue}

+ {issue.suggestion && ( +

+ 💡 {issue.suggestion} +

+ )} +
+
+ ))} +
+ ))} +
+
+ )} +
+ + {/* Footer */} + + {isChecking ? ( + + ) : error ? ( + <> + + + + ) : passed ? ( + + ) : hasIssues ? ( + <> + + + + ) : null} + +
+
+ ); +} + +/** + * Format a field key into a human-readable name + */ +function formatFieldName(field: string): string { + const fieldNames: Record = { + 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()); +} diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation.ts new file mode 100644 index 0000000..51f884b --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation.ts @@ -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({ + isValidating: false, + error: null, + nameResult: null, + descriptionResult: null + }); + + // Track pending requests for cancellation + const abortControllerRef = useRef(null); + const debounceTimerRef = useRef(null); + + /** + * Validate a product name + */ + const validateName = useCallback(async (product: ProductForValidation): Promise => { + 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 => { + 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 + }; +} diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useSanityCheck.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useSanityCheck.ts new file mode 100644 index 0000000..1db91aa --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useSanityCheck.ts @@ -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({ + isChecking: false, + error: null, + result: null, + hasRun: false + }); + + // Track pending request for cancellation + const abortControllerRef = useRef(null); + + /** + * Run sanity check on products + */ + const runCheck = useCallback(async (products: ProductForSanityCheck[]): Promise => { + 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 => { + 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); + }, [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 + }; +} diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts index 6187b9f..3765a11 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts @@ -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; + /** Products currently being validated */ + validating: Set; +} + // ============================================================================= // 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[]; diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts index a8336c3..0bedf16 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts @@ -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()( }); }, + // ========================================================================= + // 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 // ========================================================================= diff --git a/inventory/src/components/settings/PromptManagement.tsx b/inventory/src/components/settings/PromptManagement.tsx index 9893561..cde6f61 100644 --- a/inventory/src/components/settings/PromptManagement.tsx +++ b/inventory/src/components/settings/PromptManagement.tsx @@ -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(null); const [editingPrompt, setEditingPrompt] = useState(null); const [sorting, setSorting] = useState([ - { 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({ 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(), general: new Set(), companySpecific: new Map>() }; + + const system = new Set(); + const general = new Set(); + const companySpecific = new Map>(); + + 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(["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(["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(["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[]>(() => [ { accessorKey: "prompt_type", @@ -290,15 +414,24 @@ export function PromptManagement() { variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} > - Type + Task ), 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 ( + {getTaskDisplayName(task)} + ); + }, + }, + { + 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 {getRoleDisplayName(role)}; }, }, { @@ -331,7 +464,7 @@ export function PromptManagement() { ), cell: ({ row }) => { const companyId = row.getValue("company"); - if (!companyId) return 'N/A'; + if (!companyId) return ; return fieldOptions?.companies.find(c => c.value === companyId)?.label || companyId; }, }, @@ -352,10 +485,7 @@ export function PromptManagement() { id: "actions", cell: ({ row }) => (
- @@ -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() { {header.isPlaceholder ? null - : flexRender( - header.column.columnDef.header, - header.getContext() - )} + : flexRender(header.column.columnDef.header, header.getContext())} ))} @@ -463,108 +593,174 @@ export function PromptManagement() { {editingPrompt ? "Edit Prompt" : "Create New Prompt"} - {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."} - +
+ {/* Task Selector */}
- - + + + + + + Predefined Tasks + {PREDEFINED_TASKS.map((task) => ( + + + {task.label} + {task.description} + + + ))} + + + Custom + Custom Task... + + + + ) : ( +
+ setCustomTask(e.target.value.toLowerCase().replace(/\s+/g, '_'))} + placeholder="e.g., my_custom_task" + className="font-mono" + /> + +
)}
- - {formData.prompt_type === 'company_specific' && ( + + {/* Role Selector */} +
+ + +

+ {ROLES.find(r => r.value === formData.role)?.description} +

+
+ + {/* Company Selector (only for company_specific role) */} + {formData.role === "company_specific" && (
- +
)} - + + {/* Conflict Warning */} + {wouldConflict && ( +

+ A prompt for this task + role combination already exists. +

+ )} + + {/* Prompt Text */}