Compare commits
2 Commits
7218e7cc3f
...
1dcb47cfc5
| Author | SHA1 | Date | |
|---|---|---|---|
| 1dcb47cfc5 | |||
| 167c13c572 |
@@ -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
|
||||||
@@ -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) => {
|
router.get('/by-type', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { type, company } = req.query;
|
const { type, company } = req.query;
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
throw new Error('Database pool not initialized');
|
throw new Error('Database pool not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate prompt type
|
// Validate type is provided
|
||||||
if (!type || !['general', 'system', 'company_specific'].includes(type)) {
|
if (!type || typeof type !== 'string' || type.trim().length === 0) {
|
||||||
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) {
|
|
||||||
return res.status(400).json({
|
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
|
// For company_specific types, company ID is required
|
||||||
if ((type === 'general' || type === 'system') && company) {
|
const isCompanySpecificType = type.endsWith('_company_specific');
|
||||||
|
if (isCompanySpecificType && !company) {
|
||||||
return res.status(400).json({
|
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;
|
let query, params;
|
||||||
if (type === 'company_specific') {
|
if (company) {
|
||||||
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1 AND company = $2';
|
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1 AND company = $2';
|
||||||
params = [type, company];
|
params = [type.trim(), company];
|
||||||
} else {
|
} else {
|
||||||
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1';
|
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1 AND company IS NULL';
|
||||||
params = [type];
|
params = [type.trim()];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute the query
|
// Execute the query
|
||||||
const result = await pool.query(query, params);
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
// Check if any prompt was found
|
// Check if any prompt was found
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
let errorMessage;
|
const errorMessage = company
|
||||||
if (type === 'company_specific') {
|
? `AI prompt '${type}' not found for company ${company}`
|
||||||
errorMessage = `AI prompt not found for company ${company}`;
|
: `AI prompt '${type}' not found`;
|
||||||
} else {
|
|
||||||
errorMessage = `${type.charAt(0).toUpperCase() + type.slice(1)} AI prompt not found`;
|
|
||||||
}
|
|
||||||
return res.status(404).json({ error: errorMessage });
|
return res.status(404).json({ error: errorMessage });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the first matching prompt
|
// Return the first matching prompt
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching AI prompt by type:', error);
|
console.error('Error fetching AI prompt by type:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Failed to fetch AI prompt',
|
error: 'Failed to fetch AI prompt',
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
});
|
});
|
||||||
@@ -130,27 +128,28 @@ router.post('/', async (req, res) => {
|
|||||||
if (!prompt_text || !prompt_type) {
|
if (!prompt_text || !prompt_type) {
|
||||||
return res.status(400).json({ error: 'Prompt text and type are required' });
|
return res.status(400).json({ error: 'Prompt text and type are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate prompt type
|
// Validate prompt_type is a non-empty string (no hardcoded list - extensible)
|
||||||
if (!['general', 'company_specific', 'system'].includes(prompt_type)) {
|
if (typeof prompt_type !== 'string' || prompt_type.trim().length === 0) {
|
||||||
return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' });
|
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' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate company is not provided for general or system prompts
|
// For company-specific types (ending with _company_specific), require company
|
||||||
if ((prompt_type === 'general' || prompt_type === 'system') && company) {
|
const isCompanySpecificType = prompt_type.endsWith('_company_specific');
|
||||||
return res.status(400).json({ error: 'Company should not be provided for general or system prompts' });
|
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;
|
const pool = req.app.locals.pool;
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
throw new Error('Database pool not initialized');
|
throw new Error('Database pool not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
INSERT INTO ai_prompts (
|
INSERT INTO ai_prompts (
|
||||||
prompt_text,
|
prompt_text,
|
||||||
@@ -160,35 +159,30 @@ router.post('/', async (req, res) => {
|
|||||||
RETURNING *
|
RETURNING *
|
||||||
`, [
|
`, [
|
||||||
prompt_text,
|
prompt_text,
|
||||||
prompt_type,
|
prompt_type.trim(),
|
||||||
company
|
company || null
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.status(201).json(result.rows[0]);
|
res.status(201).json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating AI prompt:', error);
|
console.error('Error creating AI prompt:', error);
|
||||||
|
|
||||||
// Check for unique constraint violations
|
// Check for unique constraint violations
|
||||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
if (error instanceof Error && error.message.includes('unique')) {
|
||||||
if (error.message.includes('unique_company_prompt')) {
|
if (error.message.includes('idx_singleton_with_company')) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'A prompt already exists for this company',
|
error: 'A prompt of this type already exists for this company',
|
||||||
details: error.message
|
details: error.message
|
||||||
});
|
});
|
||||||
} else if (error.message.includes('idx_unique_general_prompt')) {
|
} else if (error.message.includes('idx_singleton_no_company')) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'A general prompt already exists',
|
error: 'A prompt of this type 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',
|
|
||||||
details: error.message
|
details: error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Failed to create AI prompt',
|
error: 'Failed to create AI prompt',
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
});
|
});
|
||||||
@@ -209,73 +203,70 @@ router.put('/:id', async (req, res) => {
|
|||||||
if (!prompt_text || !prompt_type) {
|
if (!prompt_text || !prompt_type) {
|
||||||
return res.status(400).json({ error: 'Prompt text and type are required' });
|
return res.status(400).json({ error: 'Prompt text and type are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate prompt type
|
// Validate prompt_type is a non-empty string (no hardcoded list - extensible)
|
||||||
if (!['general', 'company_specific', 'system'].includes(prompt_type)) {
|
if (typeof prompt_type !== 'string' || prompt_type.trim().length === 0) {
|
||||||
return res.status(400).json({ error: 'Prompt type must be either "general", "company_specific", or "system"' });
|
return res.status(400).json({ error: 'Prompt type must be a non-empty string' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate company is provided for company-specific prompts
|
// For company-specific types, require company
|
||||||
if (prompt_type === 'company_specific' && !company) {
|
const isCompanySpecificType = prompt_type.endsWith('_company_specific');
|
||||||
return res.status(400).json({ error: 'Company is required for company-specific prompts' });
|
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
|
// For non-company-specific types, company should not be provided
|
||||||
if ((prompt_type === 'general' || prompt_type === 'system') && company) {
|
if (!isCompanySpecificType && company) {
|
||||||
return res.status(400).json({ error: 'Company should not be provided for general or system prompts' });
|
return res.status(400).json({ error: 'Company should not be provided for non-company-specific prompt types' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
throw new Error('Database pool not initialized');
|
throw new Error('Database pool not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the prompt exists
|
// Check if the prompt exists
|
||||||
const checkResult = await pool.query('SELECT * FROM ai_prompts WHERE id = $1', [id]);
|
const checkResult = await pool.query('SELECT * FROM ai_prompts WHERE id = $1', [id]);
|
||||||
if (checkResult.rows.length === 0) {
|
if (checkResult.rows.length === 0) {
|
||||||
return res.status(404).json({ error: 'AI prompt not found' });
|
return res.status(404).json({ error: 'AI prompt not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
UPDATE ai_prompts
|
UPDATE ai_prompts
|
||||||
SET
|
SET
|
||||||
prompt_text = $1,
|
prompt_text = $1,
|
||||||
prompt_type = $2,
|
prompt_type = $2,
|
||||||
company = $3
|
company = $3,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $4
|
WHERE id = $4
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`, [
|
`, [
|
||||||
prompt_text,
|
prompt_text,
|
||||||
prompt_type,
|
prompt_type.trim(),
|
||||||
company,
|
company || null,
|
||||||
id
|
id
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating AI prompt:', error);
|
console.error('Error updating AI prompt:', error);
|
||||||
|
|
||||||
// Check for unique constraint violations
|
// Check for unique constraint violations
|
||||||
if (error instanceof Error && error.message.includes('unique constraint')) {
|
if (error instanceof Error && error.message.includes('unique')) {
|
||||||
if (error.message.includes('unique_company_prompt')) {
|
if (error.message.includes('idx_singleton_with_company')) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'A prompt already exists for this company',
|
error: 'A prompt of this type already exists for this company',
|
||||||
details: error.message
|
details: error.message
|
||||||
});
|
});
|
||||||
} else if (error.message.includes('idx_unique_general_prompt')) {
|
} else if (error.message.includes('idx_singleton_no_company')) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
error: 'A general prompt already exists',
|
error: 'A prompt of this type 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',
|
|
||||||
details: error.message
|
details: error.message
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Failed to update AI prompt',
|
error: 'Failed to update AI prompt',
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -347,34 +347,34 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
throw new Error("Database connection not available");
|
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(`
|
const systemPromptResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'system'
|
WHERE prompt_type = 'bulk_validation_system' AND company IS NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (systemPromptResult.rows.length === 0) {
|
if (systemPromptResult.rows.length === 0) {
|
||||||
console.error("❌ No system prompt found in database");
|
console.error("❌ No bulk_validation_system prompt found in database");
|
||||||
throw new Error("No 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];
|
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(`
|
const generalPromptResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'general'
|
WHERE prompt_type = 'bulk_validation_general' AND company IS NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (generalPromptResult.rows.length === 0) {
|
if (generalPromptResult.rows.length === 0) {
|
||||||
console.error("❌ No general prompt found in database");
|
console.error("❌ No bulk_validation_general prompt found in database");
|
||||||
throw new Error("No 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
|
// Get the general prompt text and info
|
||||||
const generalPrompt = generalPromptResult.rows[0];
|
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
|
// Fetch company-specific prompts if we have products to validate
|
||||||
let companyPrompts = [];
|
let companyPrompts = [];
|
||||||
@@ -389,16 +389,16 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
|
|
||||||
if (companyIds.size > 0) {
|
if (companyIds.size > 0) {
|
||||||
console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds));
|
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(`
|
const companyPromptsResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'company_specific'
|
WHERE prompt_type = 'bulk_validation_company_specific'
|
||||||
AND company = ANY($1)
|
AND company = ANY($1)
|
||||||
`, [Array.from(companyIds)]);
|
`, [Array.from(companyIds)]);
|
||||||
|
|
||||||
companyPrompts = companyPromptsResult.rows;
|
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");
|
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(`
|
const systemPromptResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'system'
|
WHERE prompt_type = 'bulk_validation_system' AND company IS NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (systemPromptResult.rows.length === 0) {
|
if (systemPromptResult.rows.length === 0) {
|
||||||
console.error("❌ No system prompt found in database");
|
console.error("❌ No bulk_validation_system prompt found in database");
|
||||||
throw new Error("No 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;
|
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(`
|
const generalPromptResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'general'
|
WHERE prompt_type = 'bulk_validation_general' AND company IS NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (generalPromptResult.rows.length === 0) {
|
if (generalPromptResult.rows.length === 0) {
|
||||||
console.error("❌ No general prompt found in database");
|
console.error("❌ No bulk_validation_general prompt found in database");
|
||||||
throw new Error("No 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
|
// Get the general prompt text
|
||||||
const basePrompt = generalPromptResult.rows[0].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
|
// Fetch company-specific prompts if we have products to validate
|
||||||
let companyPrompts = [];
|
let companyPrompts = [];
|
||||||
@@ -730,16 +730,16 @@ async function loadPrompt(connection, productsToValidate = null, appPool = null)
|
|||||||
|
|
||||||
if (companyIds.size > 0) {
|
if (companyIds.size > 0) {
|
||||||
console.log(`🔍 Found ${companyIds.size} unique companies in products:`, Array.from(companyIds));
|
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(`
|
const companyPromptsResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'company_specific'
|
WHERE prompt_type = 'bulk_validation_company_specific'
|
||||||
AND company = ANY($1)
|
AND company = ANY($1)
|
||||||
`, [Array.from(companyIds)]);
|
`, [Array.from(companyIds)]);
|
||||||
|
|
||||||
companyPrompts = companyPromptsResult.rows;
|
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) {
|
if (!pool) {
|
||||||
console.warn("⚠️ Local database pool not available for prompt sources");
|
console.warn("⚠️ Local database pool not available for prompt sources");
|
||||||
} else {
|
} else {
|
||||||
// Get system prompt
|
// Get system prompt for bulk validation
|
||||||
const systemPromptResult = await pool.query(`
|
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(`
|
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
|
// Extract unique company IDs from products
|
||||||
@@ -1206,10 +1206,10 @@ router.post("/validate", async (req, res) => {
|
|||||||
|
|
||||||
let companyPrompts = [];
|
let companyPrompts = [];
|
||||||
if (companyIds.size > 0) {
|
if (companyIds.size > 0) {
|
||||||
// Fetch company-specific prompts
|
// Fetch company-specific prompts for bulk validation
|
||||||
const companyPromptsResult = await pool.query(`
|
const companyPromptsResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'company_specific'
|
WHERE prompt_type = 'bulk_validation_company_specific'
|
||||||
AND company = ANY($1)
|
AND company = ANY($1)
|
||||||
`, [Array.from(companyIds)]);
|
`, [Array.from(companyIds)]);
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ async function ensureInitialized() {
|
|||||||
|
|
||||||
const result = await aiService.initialize({
|
const result = await aiService.initialize({
|
||||||
openaiApiKey: process.env.OPENAI_API_KEY,
|
openaiApiKey: process.env.OPENAI_API_KEY,
|
||||||
|
groqApiKey: process.env.GROQ_API_KEY,
|
||||||
mysqlConnection: connection,
|
mysqlConnection: connection,
|
||||||
|
pool: null, // Will be set by setPool()
|
||||||
logger: console
|
logger: console
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,7 +47,10 @@ async function ensureInitialized() {
|
|||||||
return false;
|
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;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AI Routes] Failed to initialize AI service:', error);
|
console.error('[AI Routes] Failed to initialize AI service:', error);
|
||||||
@@ -278,4 +283,152 @@ router.post('/similar', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// INLINE AI VALIDATION ENDPOINTS (Groq-powered)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/validate/inline/name
|
||||||
|
* Validate a single product name for spelling, grammar, and naming conventions
|
||||||
|
*
|
||||||
|
* Body: { product: { name, company_name, company_id, line_name, description } }
|
||||||
|
* Returns: { isValid, suggestion?, issues[], latencyMs }
|
||||||
|
*/
|
||||||
|
router.post('/validate/inline/name', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ready = await ensureInitialized();
|
||||||
|
if (!ready) {
|
||||||
|
return res.status(503).json({ error: 'AI service not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aiService.hasChatCompletion()) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'Chat completion not available - GROQ_API_KEY not configured'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product } = req.body;
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(400).json({ error: 'Product is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pool from app.locals (set by server.js)
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const result = await aiService.runTask(aiService.TASK_IDS.VALIDATE_NAME, {
|
||||||
|
product,
|
||||||
|
pool
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: result.error || 'Validation failed',
|
||||||
|
code: result.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AI Routes] Name validation error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/validate/inline/description
|
||||||
|
* Validate a single product description for quality and guideline compliance
|
||||||
|
*
|
||||||
|
* Body: { product: { name, description, company_name, company_id, categories } }
|
||||||
|
* Returns: { isValid, suggestion?, issues[], latencyMs }
|
||||||
|
*/
|
||||||
|
router.post('/validate/inline/description', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ready = await ensureInitialized();
|
||||||
|
if (!ready) {
|
||||||
|
return res.status(503).json({ error: 'AI service not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aiService.hasChatCompletion()) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'Chat completion not available - GROQ_API_KEY not configured'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { product } = req.body;
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return res.status(400).json({ error: 'Product is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pool from app.locals (set by server.js)
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const result = await aiService.runTask(aiService.TASK_IDS.VALIDATE_DESCRIPTION, {
|
||||||
|
product,
|
||||||
|
pool
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: result.error || 'Validation failed',
|
||||||
|
code: result.code
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AI Routes] Description validation error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/ai/validate/sanity-check
|
||||||
|
* Run consistency/sanity check on a batch of products
|
||||||
|
*
|
||||||
|
* Body: { products: Array<product data> }
|
||||||
|
* Returns: { issues: Array<{ productIndex, field, issue, suggestion? }>, summary, latencyMs }
|
||||||
|
*/
|
||||||
|
router.post('/validate/sanity-check', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ready = await ensureInitialized();
|
||||||
|
if (!ready) {
|
||||||
|
return res.status(503).json({ error: 'AI service not available' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!aiService.hasChatCompletion()) {
|
||||||
|
return res.status(503).json({
|
||||||
|
error: 'Chat completion not available - GROQ_API_KEY not configured'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { products } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(products) || products.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Products array is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pool from app.locals (set by server.js)
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const result = await aiService.runTask(aiService.TASK_IDS.SANITY_CHECK, {
|
||||||
|
products,
|
||||||
|
pool
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,28 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* AI Service
|
* AI Service
|
||||||
*
|
*
|
||||||
* Main entry point for AI functionality including embeddings.
|
* Main entry point for AI functionality including:
|
||||||
* Provides embedding generation and similarity search for product validation.
|
* - Embeddings for taxonomy suggestions (OpenAI)
|
||||||
|
* - Chat completions for validation tasks (Groq)
|
||||||
|
* - Task registry for AI operations
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const { OpenAIProvider } = require('./providers/openaiProvider');
|
const { OpenAIProvider } = require('./providers/openaiProvider');
|
||||||
|
const { GroqProvider, MODELS: GROQ_MODELS } = require('./providers/groqProvider');
|
||||||
const { TaxonomyEmbeddings } = require('./embeddings/taxonomyEmbeddings');
|
const { TaxonomyEmbeddings } = require('./embeddings/taxonomyEmbeddings');
|
||||||
const { cosineSimilarity, findTopMatches } = require('./embeddings/similarity');
|
const { cosineSimilarity, findTopMatches } = require('./embeddings/similarity');
|
||||||
|
const { getRegistry, TASK_IDS, registerAllTasks } = require('./tasks');
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
let initializing = false;
|
let initializing = false;
|
||||||
let openaiProvider = null;
|
let openaiProvider = null;
|
||||||
|
let groqProvider = null;
|
||||||
let taxonomyEmbeddings = null;
|
let taxonomyEmbeddings = null;
|
||||||
let logger = console;
|
let logger = console;
|
||||||
|
|
||||||
|
// Store pool reference for task access
|
||||||
|
let appPool = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the AI service
|
* Initialize the AI service
|
||||||
* @param {Object} options
|
* @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.mysqlConnection - MySQL connection for taxonomy data
|
||||||
|
* @param {Object} [options.pool] - PostgreSQL pool for prompt loading
|
||||||
* @param {Object} [options.logger] - Logger instance
|
* @param {Object} [options.logger] - Logger instance
|
||||||
*/
|
*/
|
||||||
async function initialize({ openaiApiKey, mysqlConnection, logger: customLogger }) {
|
async function initialize({ openaiApiKey, groqApiKey, mysqlConnection, pool, logger: customLogger }) {
|
||||||
if (initialized) {
|
if (initialized) {
|
||||||
return { success: true, message: 'Already initialized' };
|
return { success: true, message: 'Already initialized' };
|
||||||
}
|
}
|
||||||
@@ -48,9 +58,22 @@ async function initialize({ openaiApiKey, mysqlConnection, logger: customLogger
|
|||||||
|
|
||||||
logger.info('[AI] Initializing AI service...');
|
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 });
|
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
|
// Create and initialize taxonomy embeddings
|
||||||
taxonomyEmbeddings = new TaxonomyEmbeddings({
|
taxonomyEmbeddings = new TaxonomyEmbeddings({
|
||||||
provider: openaiProvider,
|
provider: openaiProvider,
|
||||||
@@ -59,13 +82,23 @@ async function initialize({ openaiApiKey, mysqlConnection, logger: customLogger
|
|||||||
|
|
||||||
const stats = await taxonomyEmbeddings.initialize(mysqlConnection);
|
const stats = await taxonomyEmbeddings.initialize(mysqlConnection);
|
||||||
|
|
||||||
|
// Register validation tasks if Groq is available
|
||||||
|
if (groqProvider) {
|
||||||
|
registerValidationTasks();
|
||||||
|
}
|
||||||
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
logger.info('[AI] AI service initialized', stats);
|
logger.info('[AI] AI service initialized', {
|
||||||
|
...stats,
|
||||||
|
groqEnabled: !!groqProvider,
|
||||||
|
tasksRegistered: getRegistry().list()
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Initialized',
|
message: 'Initialized',
|
||||||
stats
|
stats,
|
||||||
|
groqEnabled: !!groqProvider
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[AI] Initialization failed:', 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
|
* Check if service is ready
|
||||||
*/
|
*/
|
||||||
@@ -245,28 +287,99 @@ function getTaxonomyData() {
|
|||||||
* Get service status
|
* Get service status
|
||||||
*/
|
*/
|
||||||
function getStatus() {
|
function getStatus() {
|
||||||
|
const registry = getRegistry();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initialized,
|
initialized,
|
||||||
ready: isReady(),
|
ready: isReady(),
|
||||||
hasProvider: !!openaiProvider,
|
hasOpenAI: !!openaiProvider,
|
||||||
|
hasGroq: !!groqProvider,
|
||||||
hasTaxonomy: !!taxonomyEmbeddings,
|
hasTaxonomy: !!taxonomyEmbeddings,
|
||||||
taxonomyStats: taxonomyEmbeddings ? {
|
taxonomyStats: taxonomyEmbeddings ? {
|
||||||
categories: taxonomyEmbeddings.categories?.length || 0,
|
categories: taxonomyEmbeddings.categories?.length || 0,
|
||||||
themes: taxonomyEmbeddings.themes?.length || 0,
|
themes: taxonomyEmbeddings.themes?.length || 0,
|
||||||
colors: taxonomyEmbeddings.colors?.length || 0
|
colors: taxonomyEmbeddings.colors?.length || 0
|
||||||
} : null
|
} : null,
|
||||||
|
tasks: {
|
||||||
|
registered: registry.list(),
|
||||||
|
count: registry.size()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an AI task by ID
|
||||||
|
* @param {string} taskId - Task identifier from TASK_IDS
|
||||||
|
* @param {Object} payload - Task-specific input
|
||||||
|
* @returns {Promise<Object>} Task result
|
||||||
|
*/
|
||||||
|
async function runTask(taskId, payload = {}) {
|
||||||
|
if (!initialized) {
|
||||||
|
throw new Error('AI service not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!groqProvider) {
|
||||||
|
throw new Error('Groq provider not available - chat completion tasks require GROQ_API_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = getRegistry();
|
||||||
|
return registry.runTask(taskId, {
|
||||||
|
...payload,
|
||||||
|
// Inject dependencies tasks may need
|
||||||
|
provider: groqProvider,
|
||||||
|
// Use pool from payload if provided (from route), fall back to stored appPool
|
||||||
|
pool: payload.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 = {
|
module.exports = {
|
||||||
|
// Initialization
|
||||||
initialize,
|
initialize,
|
||||||
isReady,
|
isReady,
|
||||||
|
getStatus,
|
||||||
|
|
||||||
|
// Embeddings (OpenAI)
|
||||||
getProductEmbedding,
|
getProductEmbedding,
|
||||||
getProductEmbeddings,
|
getProductEmbeddings,
|
||||||
findSimilarTaxonomy,
|
findSimilarTaxonomy,
|
||||||
getSuggestionsForProduct,
|
getSuggestionsForProduct,
|
||||||
getTaxonomyData,
|
getTaxonomyData,
|
||||||
getStatus,
|
|
||||||
|
// Chat completions (Groq)
|
||||||
|
runTask,
|
||||||
|
hasChatCompletion,
|
||||||
|
getGroqProvider,
|
||||||
|
getPool,
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
TASK_IDS,
|
||||||
|
GROQ_MODELS,
|
||||||
|
|
||||||
// Re-export utilities
|
// Re-export utilities
|
||||||
cosineSimilarity,
|
cosineSimilarity,
|
||||||
findTopMatches
|
findTopMatches
|
||||||
|
|||||||
177
inventory-server/src/services/ai/prompts/descriptionPrompts.js
Normal file
177
inventory-server/src/services/ai/prompts/descriptionPrompts.js
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Description Validation Prompts
|
||||||
|
*
|
||||||
|
* Functions for building and parsing description validation prompts.
|
||||||
|
* System and general prompts are loaded from the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize an issue string from AI response
|
||||||
|
* AI sometimes returns malformed strings with escape sequences
|
||||||
|
*
|
||||||
|
* @param {string} issue - Raw issue string
|
||||||
|
* @returns {string} Cleaned issue string
|
||||||
|
*/
|
||||||
|
function sanitizeIssue(issue) {
|
||||||
|
if (!issue || typeof issue !== 'string') return '';
|
||||||
|
|
||||||
|
let cleaned = issue
|
||||||
|
// Remove trailing backslashes (incomplete escapes)
|
||||||
|
.replace(/\\+$/, '')
|
||||||
|
// Fix malformed escaped quotes at end of string
|
||||||
|
.replace(/\\",?\)?$/, '')
|
||||||
|
// Clean up double-escaped quotes
|
||||||
|
.replace(/\\\\"/g, '"')
|
||||||
|
// Clean up single escaped quotes that aren't needed
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
|
// Remove any remaining trailing punctuation artifacts
|
||||||
|
.replace(/[,\s]+$/, '')
|
||||||
|
// Trim whitespace
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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('CRITICAL RULES:');
|
||||||
|
parts.push('- If isValid is false, you MUST provide a suggestion with the improved description');
|
||||||
|
parts.push('- If there are ANY issues, isValid MUST be false and suggestion MUST contain the corrected text');
|
||||||
|
parts.push('- If the description is empty or very short, write a complete description based on the product name');
|
||||||
|
parts.push('- Only set isValid to true if there are ZERO issues and the description needs no changes');
|
||||||
|
parts.push('');
|
||||||
|
parts.push('RESPOND WITH JSON:');
|
||||||
|
parts.push(JSON.stringify({
|
||||||
|
isValid: 'true if perfect, false if ANY changes needed',
|
||||||
|
suggestion: 'REQUIRED when isValid is false - the complete improved description',
|
||||||
|
issues: ['list each problem found (empty array only if isValid is true)']
|
||||||
|
}, 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') {
|
||||||
|
// Sanitize issues - AI sometimes returns malformed escape sequences
|
||||||
|
const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
|
||||||
|
const issues = rawIssues
|
||||||
|
.map(sanitizeIssue)
|
||||||
|
.filter(issue => issue.length > 0);
|
||||||
|
|
||||||
|
const suggestion = parsed.suggestion || null;
|
||||||
|
|
||||||
|
// IMPORTANT: LLMs sometimes return contradictory data (isValid: true with issues).
|
||||||
|
// If there are issues, treat as invalid regardless of what the AI said.
|
||||||
|
// Also if there's a suggestion, the AI thought something needed to change.
|
||||||
|
const isValid = parsed.isValid && issues.length === 0 && !suggestion;
|
||||||
|
|
||||||
|
return { isValid, suggestion, issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where isValid is a string "true"/"false" instead of boolean
|
||||||
|
if (parsed && typeof parsed.isValid === 'string') {
|
||||||
|
const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
|
||||||
|
const issues = rawIssues
|
||||||
|
.map(sanitizeIssue)
|
||||||
|
.filter(issue => issue.length > 0);
|
||||||
|
const suggestion = parsed.suggestion || null;
|
||||||
|
const rawIsValid = parsed.isValid.toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
// Same defensive logic: if there are issues, it's not valid
|
||||||
|
const isValid = rawIsValid && issues.length === 0 && !suggestion;
|
||||||
|
|
||||||
|
return { isValid, suggestion, 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 => sanitizeIssue(s.replace(/"/g, '')))
|
||||||
|
.filter(issue => issue.length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same logic: if there are issues, it's not valid
|
||||||
|
const finalIsValid = isValid && issues.length === 0 && !suggestion;
|
||||||
|
|
||||||
|
return { isValid: finalIsValid, suggestion, issues };
|
||||||
|
} catch {
|
||||||
|
// Default to valid if we can't parse anything
|
||||||
|
return { isValid: true, suggestion: null, issues: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDescriptionUserPrompt,
|
||||||
|
parseDescriptionResponse
|
||||||
|
};
|
||||||
201
inventory-server/src/services/ai/prompts/namePrompts.js
Normal file
201
inventory-server/src/services/ai/prompts/namePrompts.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* Name Validation Prompts
|
||||||
|
*
|
||||||
|
* Functions for building and parsing name validation prompts.
|
||||||
|
* System and general prompts are loaded from the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize an issue string from AI response
|
||||||
|
* AI sometimes returns malformed strings with escape sequences
|
||||||
|
*
|
||||||
|
* @param {string} issue - Raw issue string
|
||||||
|
* @returns {string} Cleaned issue string
|
||||||
|
*/
|
||||||
|
function sanitizeIssue(issue) {
|
||||||
|
if (!issue || typeof issue !== 'string') return '';
|
||||||
|
|
||||||
|
let cleaned = issue
|
||||||
|
// Remove trailing backslashes (incomplete escapes)
|
||||||
|
.replace(/\\+$/, '')
|
||||||
|
// Fix malformed escaped quotes at end of string
|
||||||
|
.replace(/\\",?\)?$/, '')
|
||||||
|
// Clean up double-escaped quotes
|
||||||
|
.replace(/\\\\"/g, '"')
|
||||||
|
// Clean up single escaped quotes that aren't needed
|
||||||
|
.replace(/\\"/g, '"')
|
||||||
|
// Remove any remaining trailing punctuation artifacts
|
||||||
|
.replace(/[,\s]+$/, '')
|
||||||
|
// Trim whitespace
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.subline_name] - Product subline name
|
||||||
|
* @param {string} [product.description] - Product description (for context)
|
||||||
|
* @param {string[]} [product.siblingNames] - Names of other products in the same line
|
||||||
|
* @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.subline_name) {
|
||||||
|
parts.push(`SUBLINE: ${product.subline_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (product.description) {
|
||||||
|
parts.push(`DESCRIPTION (for context): ${product.description.substring(0, 200)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sibling context for naming decisions
|
||||||
|
if (product.siblingNames && product.siblingNames.length > 0) {
|
||||||
|
parts.push('');
|
||||||
|
parts.push(`OTHER PRODUCTS IN THIS LINE (${product.siblingNames.length + 1} total including this one):`);
|
||||||
|
product.siblingNames.forEach(name => {
|
||||||
|
parts.push(`- ${name}`);
|
||||||
|
});
|
||||||
|
parts.push('');
|
||||||
|
parts.push('Use this context to determine:');
|
||||||
|
parts.push('- If this product needs a differentiator (multiple similar products exist)');
|
||||||
|
parts.push('- If naming is consistent with sibling products');
|
||||||
|
parts.push('- Which naming pattern is appropriate (single vs multiple products in line)');
|
||||||
|
} else if (product.line_name) {
|
||||||
|
parts.push('');
|
||||||
|
parts.push('This appears to be the ONLY product in this line (no siblings in current batch).');
|
||||||
|
parts.push('Use the single-product naming pattern: [Line Name] [Product Name] - [Company]');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Debug: Log what we're trying to parse
|
||||||
|
console.log('[parseNameResponse] Input:', {
|
||||||
|
hasParsed: !!parsed,
|
||||||
|
parsedIsValid: parsed?.isValid,
|
||||||
|
parsedType: typeof parsed?.isValid,
|
||||||
|
contentPreview: content?.substring(0, 3000)
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we got valid parsed JSON, use it
|
||||||
|
if (parsed && typeof parsed.isValid === 'boolean') {
|
||||||
|
// Sanitize issues - AI sometimes returns malformed escape sequences
|
||||||
|
const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
|
||||||
|
const issues = rawIssues
|
||||||
|
.map(sanitizeIssue)
|
||||||
|
.filter(issue => issue.length > 0);
|
||||||
|
const suggestion = parsed.suggestion || null;
|
||||||
|
|
||||||
|
// IMPORTANT: LLMs sometimes return contradictory data (isValid: true with issues).
|
||||||
|
// If there are issues, treat as invalid regardless of what the AI said.
|
||||||
|
const isValid = parsed.isValid && issues.length === 0 && !suggestion;
|
||||||
|
|
||||||
|
return { isValid, suggestion, issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle case where isValid is a string "true"/"false" instead of boolean
|
||||||
|
if (parsed && typeof parsed.isValid === 'string') {
|
||||||
|
const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
|
||||||
|
const issues = rawIssues
|
||||||
|
.map(sanitizeIssue)
|
||||||
|
.filter(issue => issue.length > 0);
|
||||||
|
const suggestion = parsed.suggestion || null;
|
||||||
|
const rawIsValid = parsed.isValid.toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
// Same defensive logic: if there are issues, it's not valid
|
||||||
|
const isValid = rawIsValid && issues.length === 0 && !suggestion;
|
||||||
|
|
||||||
|
console.log('[parseNameResponse] Parsed isValid as string:', parsed.isValid, '→', isValid);
|
||||||
|
return { isValid, suggestion, issues };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from content if parsing failed
|
||||||
|
try {
|
||||||
|
// Look for isValid pattern - handle both boolean and quoted string
|
||||||
|
// Matches: "isValid": true, "isValid": false, "isValid": "true", "isValid": "false"
|
||||||
|
const isValidMatch = content.match(/"isValid"\s*:\s*"?(true|false)"?/i);
|
||||||
|
const isValid = isValidMatch ? isValidMatch[1].toLowerCase() === 'true' : true;
|
||||||
|
|
||||||
|
console.log('[parseNameResponse] Regex extraction:', {
|
||||||
|
isValidMatch: isValidMatch?.[0],
|
||||||
|
isValidValue: isValidMatch?.[1],
|
||||||
|
resultIsValid: isValid
|
||||||
|
});
|
||||||
|
|
||||||
|
// Look for suggestion - handle escaped quotes and null
|
||||||
|
const suggestionMatch = content.match(/"suggestion"\s*:\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|null)/);
|
||||||
|
const suggestion = suggestionMatch ? (suggestionMatch[1] || null) : 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 => sanitizeIssue(s.replace(/"/g, '')))
|
||||||
|
.filter(issue => issue.length > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same defensive logic: if there are issues, it's not valid
|
||||||
|
const finalIsValid = isValid && issues.length === 0 && !suggestion;
|
||||||
|
|
||||||
|
return { isValid: finalIsValid, suggestion, issues };
|
||||||
|
} catch {
|
||||||
|
// Default to valid if we can't parse anything
|
||||||
|
return { isValid: true, suggestion: null, issues: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildNameUserPrompt,
|
||||||
|
parseNameResponse
|
||||||
|
};
|
||||||
194
inventory-server/src/services/ai/prompts/promptLoader.js
Normal file
194
inventory-server/src/services/ai/prompts/promptLoader.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Prompt Loader
|
||||||
|
*
|
||||||
|
* Utilities to load AI prompts from the ai_prompts PostgreSQL table.
|
||||||
|
* Supports loading prompts by base type (e.g., 'name_validation' loads
|
||||||
|
* name_validation_system, name_validation_general, and optionally
|
||||||
|
* name_validation_company_specific).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a single prompt by exact type
|
||||||
|
* @param {Object} pool - PostgreSQL pool
|
||||||
|
* @param {string} promptType - Exact prompt type (e.g., 'name_validation_system')
|
||||||
|
* @param {string} [company] - Company identifier (for company_specific types)
|
||||||
|
* @returns {Promise<string|null>} Prompt text or null if not found
|
||||||
|
*/
|
||||||
|
async function loadPromptByType(pool, promptType, company = null) {
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (company) {
|
||||||
|
result = await pool.query(
|
||||||
|
'SELECT prompt_text FROM ai_prompts WHERE prompt_type = $1 AND company = $2',
|
||||||
|
[promptType, company]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result = await pool.query(
|
||||||
|
'SELECT prompt_text FROM ai_prompts WHERE prompt_type = $1 AND company IS NULL',
|
||||||
|
[promptType]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.rows[0]?.prompt_text || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[PromptLoader] Error loading ${promptType} prompt:`, error.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all prompts for a task type (system, general, and optionally company-specific)
|
||||||
|
*
|
||||||
|
* @param {Object} pool - PostgreSQL pool
|
||||||
|
* @param {string} baseType - Base type name (e.g., 'name_validation', 'description_validation')
|
||||||
|
* @param {string|null} [company] - Optional company ID for company-specific prompts
|
||||||
|
* @returns {Promise<{system: string|null, general: string|null, companySpecific: string|null}>}
|
||||||
|
*/
|
||||||
|
async function loadPromptsByType(pool, baseType, company = null) {
|
||||||
|
const systemType = `${baseType}_system`;
|
||||||
|
const generalType = `${baseType}_general`;
|
||||||
|
const companyType = `${baseType}_company_specific`;
|
||||||
|
|
||||||
|
// Load system and general prompts in parallel
|
||||||
|
const [system, general] = await Promise.all([
|
||||||
|
loadPromptByType(pool, systemType),
|
||||||
|
loadPromptByType(pool, generalType)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Load company-specific prompt if company is provided
|
||||||
|
let companySpecific = null;
|
||||||
|
if (company) {
|
||||||
|
companySpecific = await loadPromptByType(pool, companyType, company);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
system,
|
||||||
|
general,
|
||||||
|
companySpecific
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load name validation prompts
|
||||||
|
* @param {Object} pool - PostgreSQL pool
|
||||||
|
* @param {string|null} [company] - Optional company ID
|
||||||
|
* @returns {Promise<{system: string|null, general: string|null, companySpecific: string|null}>}
|
||||||
|
*/
|
||||||
|
async function loadNameValidationPrompts(pool, company = null) {
|
||||||
|
return loadPromptsByType(pool, 'name_validation', company);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load description validation prompts
|
||||||
|
* @param {Object} pool - PostgreSQL pool
|
||||||
|
* @param {string|null} [company] - Optional company ID
|
||||||
|
* @returns {Promise<{system: string|null, general: string|null, companySpecific: string|null}>}
|
||||||
|
*/
|
||||||
|
async function loadDescriptionValidationPrompts(pool, company = null) {
|
||||||
|
return loadPromptsByType(pool, 'description_validation', company);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load sanity check prompts (no company-specific variant)
|
||||||
|
* @param {Object} pool - PostgreSQL pool
|
||||||
|
* @returns {Promise<{system: string|null, general: string|null, companySpecific: null}>}
|
||||||
|
*/
|
||||||
|
async function loadSanityCheckPrompts(pool) {
|
||||||
|
return loadPromptsByType(pool, 'sanity_check', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load bulk validation prompts (GPT-5 validation)
|
||||||
|
* @param {Object} pool - PostgreSQL pool
|
||||||
|
* @param {string|null} [company] - Optional company ID
|
||||||
|
* @returns {Promise<{system: string|null, general: string|null, companySpecific: string|null}>}
|
||||||
|
*/
|
||||||
|
async function loadBulkValidationPrompts(pool, company = null) {
|
||||||
|
return loadPromptsByType(pool, 'bulk_validation', company);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load bulk validation prompts for multiple companies at once
|
||||||
|
* @param {Object} pool - PostgreSQL pool
|
||||||
|
* @param {string[]} companyIds - Array of company IDs
|
||||||
|
* @returns {Promise<{system: string|null, general: string|null, companyPrompts: Map<string, string>}>}
|
||||||
|
*/
|
||||||
|
async function loadBulkValidationPromptsForCompanies(pool, companyIds = []) {
|
||||||
|
// Load system and general prompts
|
||||||
|
const [system, general] = await Promise.all([
|
||||||
|
loadPromptByType(pool, 'bulk_validation_system'),
|
||||||
|
loadPromptByType(pool, 'bulk_validation_general')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Load company-specific prompts for all provided companies
|
||||||
|
const companyPrompts = new Map();
|
||||||
|
|
||||||
|
if (companyIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT company, prompt_text FROM ai_prompts
|
||||||
|
WHERE prompt_type = 'bulk_validation_company_specific'
|
||||||
|
AND company = ANY($1)`,
|
||||||
|
[companyIds]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
companyPrompts.set(row.company, row.prompt_text);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PromptLoader] Error loading company-specific prompts:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
system,
|
||||||
|
general,
|
||||||
|
companyPrompts
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that required prompts exist, throw error if missing
|
||||||
|
* @param {Object} prompts - Prompts object from loadPromptsByType
|
||||||
|
* @param {string} baseType - Base type for error messages
|
||||||
|
* @param {Object} options - Validation options
|
||||||
|
* @param {boolean} [options.requireSystem=true] - Require system prompt
|
||||||
|
* @param {boolean} [options.requireGeneral=true] - Require general prompt
|
||||||
|
* @throws {Error} If required prompts are missing
|
||||||
|
*/
|
||||||
|
function validateRequiredPrompts(prompts, baseType, options = {}) {
|
||||||
|
const { requireSystem = true, requireGeneral = true } = options;
|
||||||
|
const missing = [];
|
||||||
|
|
||||||
|
if (requireSystem && !prompts.system) {
|
||||||
|
missing.push(`${baseType}_system`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireGeneral && !prompts.general) {
|
||||||
|
missing.push(`${baseType}_general`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing required AI prompts: ${missing.join(', ')}. ` +
|
||||||
|
`Please add these prompts in Settings > AI Validation Prompts.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Core loader
|
||||||
|
loadPromptByType,
|
||||||
|
loadPromptsByType,
|
||||||
|
|
||||||
|
// Task-specific loaders
|
||||||
|
loadNameValidationPrompts,
|
||||||
|
loadDescriptionValidationPrompts,
|
||||||
|
loadSanityCheckPrompts,
|
||||||
|
loadBulkValidationPrompts,
|
||||||
|
loadBulkValidationPromptsForCompanies,
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
validateRequiredPrompts
|
||||||
|
};
|
||||||
127
inventory-server/src/services/ai/prompts/sanityCheckPrompts.js
Normal file
127
inventory-server/src/services/ai/prompts/sanityCheckPrompts.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Sanity Check Prompts
|
||||||
|
*
|
||||||
|
* Functions for building and parsing batch product consistency validation prompts.
|
||||||
|
* System and general prompts are loaded from the database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the user prompt for sanity check
|
||||||
|
* Combines database prompts with product data
|
||||||
|
*
|
||||||
|
* @param {Object[]} products - Array of product data (limited fields for context)
|
||||||
|
* @param {Object} prompts - Prompts loaded from database
|
||||||
|
* @param {string} prompts.general - General sanity check rules
|
||||||
|
* @returns {string} Complete user prompt
|
||||||
|
*/
|
||||||
|
function buildSanityCheckUserPrompt(products, prompts) {
|
||||||
|
// Build a simplified product list for the prompt
|
||||||
|
const productSummaries = products.map((p, index) => ({
|
||||||
|
index,
|
||||||
|
name: p.name,
|
||||||
|
supplier: p.supplier_name || p.supplier,
|
||||||
|
company: p.company_name || p.company,
|
||||||
|
supplier_no: p.supplier_no,
|
||||||
|
msrp: p.msrp,
|
||||||
|
cost_each: p.cost_each,
|
||||||
|
qty_per_unit: p.qty_per_unit,
|
||||||
|
case_qty: p.case_qty,
|
||||||
|
tax_cat: p.tax_cat_name || p.tax_cat,
|
||||||
|
size_cat: p.size_cat_name || p.size_cat,
|
||||||
|
themes: p.theme_names || p.themes,
|
||||||
|
weight: p.weight,
|
||||||
|
length: p.length,
|
||||||
|
width: p.width,
|
||||||
|
height: p.height
|
||||||
|
}));
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Add general prompt/rules if provided
|
||||||
|
if (prompts.general) {
|
||||||
|
parts.push(prompts.general);
|
||||||
|
parts.push(''); // Empty line for separation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add products to review
|
||||||
|
parts.push(`PRODUCTS TO REVIEW (${products.length} items):`);
|
||||||
|
parts.push(JSON.stringify(productSummaries, null, 2));
|
||||||
|
|
||||||
|
// Add response format
|
||||||
|
parts.push('');
|
||||||
|
parts.push('RESPOND WITH JSON:');
|
||||||
|
parts.push(JSON.stringify({
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
productIndex: 0,
|
||||||
|
field: 'msrp',
|
||||||
|
issue: 'Description of the issue found',
|
||||||
|
suggestion: 'Suggested fix or verification (optional)'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
summary: 'Brief overall assessment of the batch quality'
|
||||||
|
}, null, 2));
|
||||||
|
|
||||||
|
parts.push('');
|
||||||
|
parts.push('If no issues are found, return empty issues array with positive summary.');
|
||||||
|
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the AI response for sanity check
|
||||||
|
*
|
||||||
|
* @param {Object|null} parsed - Parsed JSON from AI
|
||||||
|
* @param {string} content - Raw response content
|
||||||
|
* @returns {Object}
|
||||||
|
*/
|
||||||
|
function parseSanityCheckResponse(parsed, content) {
|
||||||
|
// If we got valid parsed JSON, use it
|
||||||
|
if (parsed && Array.isArray(parsed.issues)) {
|
||||||
|
return {
|
||||||
|
issues: parsed.issues.map(issue => ({
|
||||||
|
productIndex: issue.productIndex ?? issue.index ?? 0,
|
||||||
|
field: issue.field || 'unknown',
|
||||||
|
issue: issue.issue || issue.message || '',
|
||||||
|
suggestion: issue.suggestion || null
|
||||||
|
})),
|
||||||
|
summary: parsed.summary || 'Review complete'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to extract from content if parsing failed
|
||||||
|
try {
|
||||||
|
// Try to find issues array
|
||||||
|
const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/);
|
||||||
|
let issues = [];
|
||||||
|
|
||||||
|
if (issuesMatch) {
|
||||||
|
// Try to parse the array content
|
||||||
|
try {
|
||||||
|
const arrayContent = `[${issuesMatch[1]}]`;
|
||||||
|
const parsedIssues = JSON.parse(arrayContent);
|
||||||
|
issues = parsedIssues.map(issue => ({
|
||||||
|
productIndex: issue.productIndex ?? issue.index ?? 0,
|
||||||
|
field: issue.field || 'unknown',
|
||||||
|
issue: issue.issue || issue.message || '',
|
||||||
|
suggestion: issue.suggestion || null
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Couldn't parse the array
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find summary
|
||||||
|
const summaryMatch = content.match(/"summary"\s*:\s*"([^"]+)"/);
|
||||||
|
const summary = summaryMatch ? summaryMatch[1] : 'Review complete';
|
||||||
|
|
||||||
|
return { issues, summary };
|
||||||
|
} catch {
|
||||||
|
return { issues: [], summary: 'Could not parse review results' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildSanityCheckUserPrompt,
|
||||||
|
parseSanityCheckResponse
|
||||||
|
};
|
||||||
203
inventory-server/src/services/ai/providers/groqProvider.js
Normal file
203
inventory-server/src/services/ai/providers/groqProvider.js
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* 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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: Log request being sent
|
||||||
|
console.log('[Groq] Request:', {
|
||||||
|
model: body.model,
|
||||||
|
temperature: body.temperature,
|
||||||
|
maxTokens: body.max_completion_tokens,
|
||||||
|
hasResponseFormat: !!body.response_format,
|
||||||
|
messageCount: body.messages?.length,
|
||||||
|
systemPromptLength: body.messages?.[0]?.content?.length,
|
||||||
|
userPromptLength: body.messages?.[1]?.content?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await this._makeRequest('chat/completions', body, timeoutMs);
|
||||||
|
|
||||||
|
// Debug: Log raw response structure
|
||||||
|
console.log('[Groq] Raw response:', {
|
||||||
|
hasChoices: !!response.choices,
|
||||||
|
choicesLength: response.choices?.length,
|
||||||
|
firstChoice: response.choices?.[0] ? {
|
||||||
|
finishReason: response.choices[0].finish_reason,
|
||||||
|
hasMessage: !!response.choices[0].message,
|
||||||
|
contentLength: response.choices[0].message?.content?.length,
|
||||||
|
contentPreview: response.choices[0].message?.content?.substring(0, 200)
|
||||||
|
} : null,
|
||||||
|
usage: response.usage,
|
||||||
|
model: response.model
|
||||||
|
});
|
||||||
|
|
||||||
|
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 };
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Description Validation Task
|
||||||
|
*
|
||||||
|
* Validates a product description for quality, accuracy, and guideline compliance.
|
||||||
|
* Uses Groq with the larger model for better reasoning about content quality.
|
||||||
|
* Loads all prompts from the database (no hardcoded prompts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { MODELS } = require('../providers/groqProvider');
|
||||||
|
const {
|
||||||
|
loadDescriptionValidationPrompts,
|
||||||
|
validateRequiredPrompts
|
||||||
|
} = require('../prompts/promptLoader');
|
||||||
|
const {
|
||||||
|
buildDescriptionUserPrompt,
|
||||||
|
parseDescriptionResponse
|
||||||
|
} = require('../prompts/descriptionPrompts');
|
||||||
|
|
||||||
|
const TASK_ID = 'validate.description';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the description validation task
|
||||||
|
*
|
||||||
|
* @returns {Object} Task definition
|
||||||
|
*/
|
||||||
|
function createDescriptionValidationTask() {
|
||||||
|
return {
|
||||||
|
id: TASK_ID,
|
||||||
|
description: 'Validate product description for quality and guideline compliance',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the description validation
|
||||||
|
*
|
||||||
|
* @param {Object} payload
|
||||||
|
* @param {Object} payload.product - Product data
|
||||||
|
* @param {string} payload.product.name - Product name (for context)
|
||||||
|
* @param {string} payload.product.description - Description to validate
|
||||||
|
* @param {string} [payload.product.company_name] - Company name
|
||||||
|
* @param {string} [payload.product.company_id] - Company ID for loading specific rules
|
||||||
|
* @param {string} [payload.product.categories] - Product categories
|
||||||
|
* @param {Object} payload.provider - Groq provider instance
|
||||||
|
* @param {Object} payload.pool - PostgreSQL pool
|
||||||
|
* @param {Object} [payload.logger] - Logger instance
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async run(payload) {
|
||||||
|
const { product, provider, pool, logger } = payload;
|
||||||
|
const log = logger || console;
|
||||||
|
|
||||||
|
// Validate required input
|
||||||
|
if (!product?.name && !product?.description) {
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
suggestion: null,
|
||||||
|
issues: [],
|
||||||
|
skipped: true,
|
||||||
|
reason: 'No name or description provided'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Groq provider not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load prompts from database
|
||||||
|
const companyKey = product.company_id || product.company_name || product.company;
|
||||||
|
const prompts = await loadDescriptionValidationPrompts(pool, companyKey);
|
||||||
|
|
||||||
|
// Validate required prompts exist
|
||||||
|
validateRequiredPrompts(prompts, 'description_validation');
|
||||||
|
|
||||||
|
// Build the user prompt with database-loaded prompts
|
||||||
|
const userPrompt = buildDescriptionUserPrompt(product, prompts);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
let result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try with JSON mode first
|
||||||
|
response = await provider.chatCompletion({
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: prompts.system },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
],
|
||||||
|
model: MODELS.LARGE, // openai/gpt-oss-120b - better for content analysis
|
||||||
|
temperature: 0.3, // Slightly higher for creative suggestions
|
||||||
|
maxTokens: 2000, // Reasoning models need extra tokens for thinking
|
||||||
|
responseFormat: { type: 'json_object' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log full raw response for debugging
|
||||||
|
log.info('[DescriptionValidation] Raw AI response:', {
|
||||||
|
parsed: response.parsed,
|
||||||
|
content: response.content,
|
||||||
|
contentLength: response.content?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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:', {
|
||||||
|
failedGeneration: jsonError.failedGeneration
|
||||||
|
});
|
||||||
|
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: 2000 // Reasoning models need extra tokens for thinking
|
||||||
|
// No responseFormat - let the model respond freely
|
||||||
|
});
|
||||||
|
log.info('[DescriptionValidation] Raw AI response (no JSON mode):', {
|
||||||
|
parsed: response.parsed,
|
||||||
|
content: response.content,
|
||||||
|
contentLength: response.content?.length
|
||||||
|
});
|
||||||
|
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
|
||||||
|
};
|
||||||
186
inventory-server/src/services/ai/tasks/index.js
Normal file
186
inventory-server/src/services/ai/tasks/index.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* AI Task Registry
|
||||||
|
*
|
||||||
|
* Simple registry pattern for AI tasks. Each task has:
|
||||||
|
* - id: Unique identifier
|
||||||
|
* - run: Async function that executes the task
|
||||||
|
*
|
||||||
|
* This allows adding new AI capabilities without modifying core code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { createNameValidationTask, TASK_ID: NAME_TASK_ID } = require('./nameValidationTask');
|
||||||
|
const { createDescriptionValidationTask, TASK_ID: DESC_TASK_ID } = require('./descriptionValidationTask');
|
||||||
|
const { createSanityCheckTask, TASK_ID: SANITY_TASK_ID } = require('./sanityCheckTask');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task IDs - frozen constants for type safety
|
||||||
|
*/
|
||||||
|
const TASK_IDS = Object.freeze({
|
||||||
|
// Inline validation (triggered on field blur)
|
||||||
|
VALIDATE_NAME: NAME_TASK_ID,
|
||||||
|
VALIDATE_DESCRIPTION: DESC_TASK_ID,
|
||||||
|
|
||||||
|
// Batch operations (triggered on user action)
|
||||||
|
SANITY_CHECK: SANITY_TASK_ID
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task Registry
|
||||||
|
*/
|
||||||
|
class TaskRegistry {
|
||||||
|
constructor() {
|
||||||
|
this.tasks = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a task
|
||||||
|
* @param {Object} task
|
||||||
|
* @param {string} task.id - Unique task identifier
|
||||||
|
* @param {Function} task.run - Async function: (payload) => result
|
||||||
|
* @param {string} [task.description] - Human-readable description
|
||||||
|
*/
|
||||||
|
register(task) {
|
||||||
|
if (!task?.id) {
|
||||||
|
throw new Error('Task must have an id');
|
||||||
|
}
|
||||||
|
if (typeof task.run !== 'function') {
|
||||||
|
throw new Error(`Task ${task.id} must have a run function`);
|
||||||
|
}
|
||||||
|
if (this.tasks.has(task.id)) {
|
||||||
|
throw new Error(`Task ${task.id} is already registered`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.tasks.set(task.id, task);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a task by ID
|
||||||
|
* @param {string} taskId
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
get(taskId) {
|
||||||
|
return this.tasks.get(taskId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a task exists
|
||||||
|
* @param {string} taskId
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
has(taskId) {
|
||||||
|
return this.tasks.has(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a task by ID
|
||||||
|
* @param {string} taskId
|
||||||
|
* @param {Object} payload - Task-specific input
|
||||||
|
* @returns {Promise<Object>} Task result
|
||||||
|
*/
|
||||||
|
async runTask(taskId, payload = {}) {
|
||||||
|
const task = this.get(taskId);
|
||||||
|
if (!task) {
|
||||||
|
throw new Error(`Unknown task: ${taskId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await task.run(payload);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
taskId,
|
||||||
|
...result
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
taskId,
|
||||||
|
error: error.message,
|
||||||
|
code: error.code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all registered task IDs
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
list() {
|
||||||
|
return Array.from(this.tasks.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of registered tasks
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
size() {
|
||||||
|
return this.tasks.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
let registry = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create the task registry
|
||||||
|
* @returns {TaskRegistry}
|
||||||
|
*/
|
||||||
|
function getRegistry() {
|
||||||
|
if (!registry) {
|
||||||
|
registry = new TaskRegistry();
|
||||||
|
}
|
||||||
|
return registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset the registry (mainly for testing)
|
||||||
|
*/
|
||||||
|
function resetRegistry() {
|
||||||
|
registry = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all validation tasks with the registry
|
||||||
|
* Call this during initialization after the registry is created
|
||||||
|
*
|
||||||
|
* @param {Object} [logger] - Optional logger
|
||||||
|
*/
|
||||||
|
function registerAllTasks(logger = console) {
|
||||||
|
const reg = getRegistry();
|
||||||
|
|
||||||
|
// Register name validation
|
||||||
|
if (!reg.has(TASK_IDS.VALIDATE_NAME)) {
|
||||||
|
reg.register(createNameValidationTask());
|
||||||
|
logger.info(`[Tasks] Registered: ${TASK_IDS.VALIDATE_NAME}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register description validation
|
||||||
|
if (!reg.has(TASK_IDS.VALIDATE_DESCRIPTION)) {
|
||||||
|
reg.register(createDescriptionValidationTask());
|
||||||
|
logger.info(`[Tasks] Registered: ${TASK_IDS.VALIDATE_DESCRIPTION}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register sanity check
|
||||||
|
if (!reg.has(TASK_IDS.SANITY_CHECK)) {
|
||||||
|
reg.register(createSanityCheckTask());
|
||||||
|
logger.info(`[Tasks] Registered: ${TASK_IDS.SANITY_CHECK}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reg;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Constants
|
||||||
|
TASK_IDS,
|
||||||
|
|
||||||
|
// Registry
|
||||||
|
TaskRegistry,
|
||||||
|
getRegistry,
|
||||||
|
resetRegistry,
|
||||||
|
registerAllTasks,
|
||||||
|
|
||||||
|
// Task factories (for custom registration)
|
||||||
|
createNameValidationTask,
|
||||||
|
createDescriptionValidationTask,
|
||||||
|
createSanityCheckTask
|
||||||
|
};
|
||||||
172
inventory-server/src/services/ai/tasks/nameValidationTask.js
Normal file
172
inventory-server/src/services/ai/tasks/nameValidationTask.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* Name Validation Task
|
||||||
|
*
|
||||||
|
* Validates a product name for spelling, grammar, and naming conventions.
|
||||||
|
* Uses Groq with the smaller model for fast response times.
|
||||||
|
* Loads all prompts from the database (no hardcoded prompts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { MODELS } = require('../providers/groqProvider');
|
||||||
|
const {
|
||||||
|
loadNameValidationPrompts,
|
||||||
|
validateRequiredPrompts
|
||||||
|
} = require('../prompts/promptLoader');
|
||||||
|
const {
|
||||||
|
buildNameUserPrompt,
|
||||||
|
parseNameResponse
|
||||||
|
} = require('../prompts/namePrompts');
|
||||||
|
|
||||||
|
const TASK_ID = 'validate.name';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the name validation task
|
||||||
|
*
|
||||||
|
* @returns {Object} Task definition
|
||||||
|
*/
|
||||||
|
function createNameValidationTask() {
|
||||||
|
return {
|
||||||
|
id: TASK_ID,
|
||||||
|
description: 'Validate product name for spelling, grammar, and conventions',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the name validation
|
||||||
|
*
|
||||||
|
* @param {Object} payload
|
||||||
|
* @param {Object} payload.product - Product data
|
||||||
|
* @param {string} payload.product.name - Product name to validate
|
||||||
|
* @param {string} [payload.product.company_name] - Company name
|
||||||
|
* @param {string} [payload.product.company_id] - Company ID for loading specific rules
|
||||||
|
* @param {string} [payload.product.line_name] - Product line
|
||||||
|
* @param {string} [payload.product.description] - Description for context
|
||||||
|
* @param {Object} payload.provider - Groq provider instance
|
||||||
|
* @param {Object} payload.pool - PostgreSQL pool
|
||||||
|
* @param {Object} [payload.logger] - Logger instance
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async run(payload) {
|
||||||
|
const { product, provider, pool, logger } = payload;
|
||||||
|
const log = logger || console;
|
||||||
|
|
||||||
|
// Validate required input
|
||||||
|
if (!product?.name) {
|
||||||
|
return {
|
||||||
|
isValid: true,
|
||||||
|
suggestion: null,
|
||||||
|
issues: [],
|
||||||
|
skipped: true,
|
||||||
|
reason: 'No name provided'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Groq provider not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load prompts from database
|
||||||
|
const companyKey = product.company_id || product.company_name || product.company;
|
||||||
|
const prompts = await loadNameValidationPrompts(pool, companyKey);
|
||||||
|
|
||||||
|
// Debug: Log loaded prompts
|
||||||
|
log.info('[NameValidation] Loaded prompts:', {
|
||||||
|
hasSystem: !!prompts.system,
|
||||||
|
systemLength: prompts.system?.length || 0,
|
||||||
|
hasGeneral: !!prompts.general,
|
||||||
|
generalLength: prompts.general?.length || 0,
|
||||||
|
generalPreview: prompts.general?.substring(0, 100) || '(empty)',
|
||||||
|
hasCompanySpecific: !!prompts.companySpecific,
|
||||||
|
companyKey
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate required prompts exist
|
||||||
|
validateRequiredPrompts(prompts, 'name_validation');
|
||||||
|
|
||||||
|
// Build the user prompt with database-loaded prompts
|
||||||
|
const userPrompt = buildNameUserPrompt(product, prompts);
|
||||||
|
|
||||||
|
// Debug: Log the full user prompt being sent
|
||||||
|
log.info('[NameValidation] User prompt:', userPrompt.substring(0, 500));
|
||||||
|
|
||||||
|
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 - reasoning model
|
||||||
|
temperature: 0.2, // Low temperature for consistent results
|
||||||
|
maxTokens: 1500, // Reasoning models need extra tokens for thinking
|
||||||
|
responseFormat: { type: 'json_object' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log full raw response for debugging
|
||||||
|
log.info('[NameValidation] Raw AI response:', {
|
||||||
|
parsed: response.parsed,
|
||||||
|
content: response.content,
|
||||||
|
contentLength: response.content?.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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:', {
|
||||||
|
failedGeneration: jsonError.failedGeneration
|
||||||
|
});
|
||||||
|
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: 1500 // Reasoning models need extra tokens for thinking
|
||||||
|
// No responseFormat - let the model respond freely
|
||||||
|
});
|
||||||
|
log.info('[NameValidation] Raw AI response (no JSON mode):', {
|
||||||
|
parsed: response.parsed,
|
||||||
|
content: response.content,
|
||||||
|
contentLength: response.content?.length
|
||||||
|
});
|
||||||
|
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
|
||||||
|
};
|
||||||
182
inventory-server/src/services/ai/tasks/sanityCheckTask.js
Normal file
182
inventory-server/src/services/ai/tasks/sanityCheckTask.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* Sanity Check Task
|
||||||
|
*
|
||||||
|
* Reviews a batch of products for consistency and appropriateness.
|
||||||
|
* Uses Groq with the larger model for complex batch analysis.
|
||||||
|
* Loads all prompts from the database (no hardcoded prompts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { MODELS } = require('../providers/groqProvider');
|
||||||
|
const {
|
||||||
|
loadSanityCheckPrompts,
|
||||||
|
validateRequiredPrompts
|
||||||
|
} = require('../prompts/promptLoader');
|
||||||
|
const {
|
||||||
|
buildSanityCheckUserPrompt,
|
||||||
|
parseSanityCheckResponse
|
||||||
|
} = require('../prompts/sanityCheckPrompts');
|
||||||
|
|
||||||
|
const TASK_ID = 'sanity.check';
|
||||||
|
|
||||||
|
// Maximum products to send in a single request (to avoid token limits)
|
||||||
|
const MAX_PRODUCTS_PER_REQUEST = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the sanity check task
|
||||||
|
*
|
||||||
|
* @returns {Object} Task definition
|
||||||
|
*/
|
||||||
|
function createSanityCheckTask() {
|
||||||
|
return {
|
||||||
|
id: TASK_ID,
|
||||||
|
description: 'Review batch of products for consistency and appropriateness',
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the sanity check
|
||||||
|
*
|
||||||
|
* @param {Object} payload
|
||||||
|
* @param {Object[]} payload.products - Array of products to check
|
||||||
|
* @param {Object} payload.provider - Groq provider instance
|
||||||
|
* @param {Object} payload.pool - PostgreSQL pool
|
||||||
|
* @param {Object} [payload.logger] - Logger instance
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async run(payload) {
|
||||||
|
const { products, provider, pool, logger } = payload;
|
||||||
|
const log = logger || console;
|
||||||
|
|
||||||
|
// Validate required input
|
||||||
|
if (!Array.isArray(products) || products.length === 0) {
|
||||||
|
return {
|
||||||
|
issues: [],
|
||||||
|
summary: 'No products to check',
|
||||||
|
skipped: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error('Groq provider not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pool) {
|
||||||
|
throw new Error('Database pool not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load prompts from database
|
||||||
|
const prompts = await loadSanityCheckPrompts(pool);
|
||||||
|
|
||||||
|
// Validate required prompts exist
|
||||||
|
validateRequiredPrompts(prompts, 'sanity_check');
|
||||||
|
|
||||||
|
// If batch is small enough, process in one request
|
||||||
|
if (products.length <= MAX_PRODUCTS_PER_REQUEST) {
|
||||||
|
return await checkBatch(products, prompts, provider, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, process in chunks and combine results
|
||||||
|
log.info(`[SanityCheck] Processing ${products.length} products in chunks`);
|
||||||
|
const allIssues = [];
|
||||||
|
const summaries = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < products.length; i += MAX_PRODUCTS_PER_REQUEST) {
|
||||||
|
const chunk = products.slice(i, i + MAX_PRODUCTS_PER_REQUEST);
|
||||||
|
const chunkOffset = i; // To adjust product indices in results
|
||||||
|
|
||||||
|
const result = await checkBatch(chunk, prompts, provider, log);
|
||||||
|
|
||||||
|
// Adjust product indices to match original array
|
||||||
|
const adjustedIssues = result.issues.map(issue => ({
|
||||||
|
...issue,
|
||||||
|
productIndex: issue.productIndex + chunkOffset
|
||||||
|
}));
|
||||||
|
|
||||||
|
allIssues.push(...adjustedIssues);
|
||||||
|
summaries.push(result.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
issues: allIssues,
|
||||||
|
summary: summaries.length > 1
|
||||||
|
? `Reviewed ${products.length} products in ${summaries.length} batches. ${allIssues.length} issues found.`
|
||||||
|
: summaries[0],
|
||||||
|
totalProducts: products.length,
|
||||||
|
issueCount: allIssues.length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log.error('[SanityCheck] Error:', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check a single batch of products
|
||||||
|
*
|
||||||
|
* @param {Object[]} products - Products to check
|
||||||
|
* @param {Object} prompts - Loaded prompts from database
|
||||||
|
* @param {Object} provider - Groq provider
|
||||||
|
* @param {Object} log - Logger
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async function checkBatch(products, prompts, provider, log) {
|
||||||
|
const userPrompt = buildSanityCheckUserPrompt(products, prompts);
|
||||||
|
|
||||||
|
let response;
|
||||||
|
let result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try with JSON mode first
|
||||||
|
response = await provider.chatCompletion({
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: prompts.system },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
],
|
||||||
|
model: MODELS.LARGE, // openai/gpt-oss-120b - needed for complex batch analysis
|
||||||
|
temperature: 0.2, // Low temperature for consistent analysis
|
||||||
|
maxTokens: 2000, // More tokens for batch results
|
||||||
|
responseFormat: { type: 'json_object' }
|
||||||
|
});
|
||||||
|
|
||||||
|
result = parseSanityCheckResponse(response.parsed, response.content);
|
||||||
|
} catch (jsonError) {
|
||||||
|
// If JSON mode failed, check if we have failedGeneration to parse
|
||||||
|
if (jsonError.failedGeneration) {
|
||||||
|
log.warn('[SanityCheck] JSON mode failed, attempting to parse failed_generation');
|
||||||
|
result = parseSanityCheckResponse(null, jsonError.failedGeneration);
|
||||||
|
response = { latencyMs: 0, usage: {}, model: MODELS.LARGE };
|
||||||
|
} else {
|
||||||
|
// Retry without JSON mode
|
||||||
|
log.warn('[SanityCheck] JSON mode failed, retrying without JSON mode');
|
||||||
|
response = await provider.chatCompletion({
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: prompts.system },
|
||||||
|
{ role: 'user', content: userPrompt }
|
||||||
|
],
|
||||||
|
model: MODELS.LARGE,
|
||||||
|
temperature: 0.2,
|
||||||
|
maxTokens: 2000
|
||||||
|
// No responseFormat - let the model respond freely
|
||||||
|
});
|
||||||
|
result = parseSanityCheckResponse(response.parsed, response.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(`[SanityCheck] Checked ${products.length} products in ${response.latencyMs}ms`, {
|
||||||
|
issueCount: result.issues.length
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
latencyMs: response.latencyMs,
|
||||||
|
usage: response.usage,
|
||||||
|
model: response.model
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TASK_ID,
|
||||||
|
createSanityCheckTask,
|
||||||
|
MAX_PRODUCTS_PER_REQUEST
|
||||||
|
};
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* AiSuggestionBadge Component
|
||||||
|
*
|
||||||
|
* Displays an AI suggestion with accept/dismiss actions.
|
||||||
|
* Used for inline validation suggestions on Name and Description fields.
|
||||||
|
*
|
||||||
|
* For description fields, starts collapsed (just icon + count) and expands on click.
|
||||||
|
* For name fields, uses compact inline mode.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
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) - used for name field */
|
||||||
|
compact?: boolean;
|
||||||
|
/** Whether to start in collapsible mode (icon + count) - used for description field */
|
||||||
|
collapsible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiSuggestionBadge({
|
||||||
|
suggestion,
|
||||||
|
issues = [],
|
||||||
|
onAccept,
|
||||||
|
onDismiss,
|
||||||
|
className,
|
||||||
|
compact = false,
|
||||||
|
collapsible = false
|
||||||
|
}: AiSuggestionBadgeProps) {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
// Compact mode for name fields - inline suggestion with accept/dismiss
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-1.5 px-2 py-1 rounded-md text-xs',
|
||||||
|
'bg-purple-50 border border-purple-200',
|
||||||
|
'dark:bg-purple-950/30 dark:border-purple-800',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0 mt-0.5" />
|
||||||
|
|
||||||
|
<span className="text-purple-700 dark:text-purple-300">
|
||||||
|
{suggestion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAccept();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p>Accept suggestion</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p>Ignore</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
{/* Info icon with issues tooltip */}
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={200}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex-shrink-0 text-purple-400 hover:text-purple-600 transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Info className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent
|
||||||
|
side="top"
|
||||||
|
align="start"
|
||||||
|
className="max-w-[300px] p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="text-xs font-medium text-purple-300 mb-1">
|
||||||
|
Issues found:
|
||||||
|
</div>
|
||||||
|
{issues.map((issue, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-300" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsible mode for description fields
|
||||||
|
if (collapsible && !isExpanded) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExpanded(true);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
|
||||||
|
'bg-purple-50 border border-purple-200 hover:bg-purple-100',
|
||||||
|
'dark:bg-purple-950/30 dark:border-purple-800 dark:hover:bg-purple-900/40',
|
||||||
|
'transition-colors cursor-pointer',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
title="Click to see AI suggestion"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
|
<span className="text-purple-600 dark:text-purple-400 font-medium">
|
||||||
|
{issues.length} {issues.length === 1 ? 'issue' : 'issues'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 text-purple-400" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expanded view (default for non-compact, or when collapsible is expanded)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col gap-2 p-3 rounded-md',
|
||||||
|
'bg-purple-50 border border-purple-200',
|
||||||
|
'dark:bg-purple-950/30 dark:border-purple-800',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header with collapse button if collapsible */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
||||||
|
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||||
|
AI Suggestion
|
||||||
|
</span>
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<span className="text-xs text-purple-500 dark:text-purple-400">
|
||||||
|
({issues.length} {issues.length === 1 ? 'issue' : 'issues'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{collapsible && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5 p-0 text-purple-400 hover:text-purple-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsExpanded(false);
|
||||||
|
}}
|
||||||
|
title="Collapse"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issues list */}
|
||||||
|
{issues.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 text-xs">
|
||||||
|
{issues.map((issue, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-1.5 text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Suggested description */}
|
||||||
|
<div className="mt-1">
|
||||||
|
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
|
||||||
|
Suggested:
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed bg-white/50 dark:bg-black/20 rounded p-2 border border-purple-100 dark:border-purple-800">
|
||||||
|
{suggestion}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAccept();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Accept
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDismiss();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loading state for AI validation
|
||||||
|
*/
|
||||||
|
export function AiValidationLoading({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-2 py-1 rounded-md text-xs',
|
||||||
|
'bg-purple-50 border border-purple-200',
|
||||||
|
'dark:bg-purple-950/30 dark:border-purple-800',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="h-3 w-3 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-purple-600 dark:text-purple-400">
|
||||||
|
Validating with AI...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -242,7 +242,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn('w-full justify-between overflow-hidden', triggerClassName)}
|
className={cn('w-full justify-between overflow-hidden', triggerClassName)}
|
||||||
>
|
>
|
||||||
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span>
|
<span className="truncate overflow-hidden mr-1 text-sm font-normal">{getDisplayText()}</span>
|
||||||
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
|
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* Note: Initialization effects are in index.tsx so they run before this mounts.
|
* 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 { useValidationStore } from '../store/validationStore';
|
||||||
import {
|
import {
|
||||||
useTotalErrorCount,
|
useTotalErrorCount,
|
||||||
@@ -22,12 +22,15 @@ import { useAiValidationFlow } from '../hooks/useAiValidation';
|
|||||||
import { useFieldOptions } from '../hooks/useFieldOptions';
|
import { useFieldOptions } from '../hooks/useFieldOptions';
|
||||||
import { useTemplateManagement } from '../hooks/useTemplateManagement';
|
import { useTemplateManagement } from '../hooks/useTemplateManagement';
|
||||||
import { useCopyDownValidation } from '../hooks/useCopyDownValidation';
|
import { useCopyDownValidation } from '../hooks/useCopyDownValidation';
|
||||||
|
import { useSanityCheck } from '../hooks/useSanityCheck';
|
||||||
import { AiValidationProgressDialog } from '../dialogs/AiValidationProgress';
|
import { AiValidationProgressDialog } from '../dialogs/AiValidationProgress';
|
||||||
import { AiValidationResultsDialog } from '../dialogs/AiValidationResults';
|
import { AiValidationResultsDialog } from '../dialogs/AiValidationResults';
|
||||||
import { AiDebugDialog } from '../dialogs/AiDebugDialog';
|
import { AiDebugDialog } from '../dialogs/AiDebugDialog';
|
||||||
|
import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
|
||||||
import { TemplateForm } from '@/components/templates/TemplateForm';
|
import { TemplateForm } from '@/components/templates/TemplateForm';
|
||||||
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
|
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
|
||||||
import type { CleanRowData, RowData } from '../store/types';
|
import type { CleanRowData, RowData } from '../store/types';
|
||||||
|
import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
|
||||||
|
|
||||||
interface ValidationContainerProps {
|
interface ValidationContainerProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@@ -58,6 +61,12 @@ export const ValidationContainer = ({
|
|||||||
const aiValidation = useAiValidationFlow();
|
const aiValidation = useAiValidationFlow();
|
||||||
const { data: fieldOptionsData } = useFieldOptions();
|
const { data: fieldOptionsData } = useFieldOptions();
|
||||||
const { loadTemplates } = useTemplateManagement();
|
const { loadTemplates } = useTemplateManagement();
|
||||||
|
const sanityCheck = useSanityCheck();
|
||||||
|
|
||||||
|
// Sanity check dialog state
|
||||||
|
const [sanityCheckDialogOpen, setSanityCheckDialogOpen] = useState(false);
|
||||||
|
// Debug: skip sanity check toggle (admin:debug only)
|
||||||
|
const [skipSanityCheck, setSkipSanityCheck] = useState(false);
|
||||||
|
|
||||||
// Handle UPC validation after copy-down operations on supplier/upc fields
|
// Handle UPC validation after copy-down operations on supplier/upc fields
|
||||||
useCopyDownValidation();
|
useCopyDownValidation();
|
||||||
@@ -121,6 +130,105 @@ export const ValidationContainer = ({
|
|||||||
}
|
}
|
||||||
}, [onBack]);
|
}, [onBack]);
|
||||||
|
|
||||||
|
// Build products array for sanity check
|
||||||
|
const buildProductsForSanityCheck = useCallback((): ProductForSanityCheck[] => {
|
||||||
|
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
|
||||||
|
return 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,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle viewing cached sanity check results
|
||||||
|
const handleViewResults = useCallback(() => {
|
||||||
|
setSanityCheckDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle running a fresh sanity check
|
||||||
|
const handleRunCheck = useCallback(() => {
|
||||||
|
const products = buildProductsForSanityCheck();
|
||||||
|
setSanityCheckDialogOpen(true);
|
||||||
|
sanityCheck.runCheck(products);
|
||||||
|
}, [sanityCheck, buildProductsForSanityCheck]);
|
||||||
|
|
||||||
|
// Handle proceeding directly to next step (skipping sanity check)
|
||||||
|
const handleProceedDirect = useCallback(() => {
|
||||||
|
handleNext();
|
||||||
|
}, [handleNext]);
|
||||||
|
|
||||||
|
// Force a new sanity check (refresh button in dialog)
|
||||||
|
const handleRefreshSanityCheck = useCallback(() => {
|
||||||
|
const products = buildProductsForSanityCheck();
|
||||||
|
sanityCheck.runCheck(products);
|
||||||
|
}, [sanityCheck, buildProductsForSanityCheck]);
|
||||||
|
|
||||||
|
// Handle proceeding after sanity check
|
||||||
|
const handleSanityCheckProceed = useCallback(() => {
|
||||||
|
setSanityCheckDialogOpen(false);
|
||||||
|
sanityCheck.clearResults();
|
||||||
|
handleNext();
|
||||||
|
}, [handleNext, sanityCheck]);
|
||||||
|
|
||||||
|
// Handle going back from sanity check dialog (keeps results cached)
|
||||||
|
const handleSanityCheckGoBack = useCallback(() => {
|
||||||
|
setSanityCheckDialogOpen(false);
|
||||||
|
// Don't clear results - keep them cached for next time
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle scrolling to a specific product from sanity check issue
|
||||||
|
const handleScrollToProduct = useCallback((productIndex: number) => {
|
||||||
|
// Find the row element and scroll to it
|
||||||
|
const rowElement = document.querySelector(`[data-row-index="${productIndex}"]`);
|
||||||
|
if (rowElement) {
|
||||||
|
rowElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
// Briefly highlight the row
|
||||||
|
rowElement.classList.add('ring-2', 'ring-purple-500');
|
||||||
|
setTimeout(() => {
|
||||||
|
rowElement.classList.remove('ring-2', 'ring-purple-500');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build product names lookup for sanity check dialog
|
||||||
|
const productNames = useMemo(() => {
|
||||||
|
const rows = useValidationStore.getState().rows;
|
||||||
|
const names: Record<number, string> = {};
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
names[index] = (row.name as string) || `Product ${index + 1}`;
|
||||||
|
});
|
||||||
|
return names;
|
||||||
|
}, [rowCount]); // Depend on rowCount to update when rows change
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AiSuggestionsProvider
|
<AiSuggestionsProvider
|
||||||
getCompanyName={getCompanyName}
|
getCompanyName={getCompanyName}
|
||||||
@@ -144,14 +252,17 @@ export const ValidationContainer = ({
|
|||||||
{/* Footer with navigation */}
|
{/* Footer with navigation */}
|
||||||
<ValidationFooter
|
<ValidationFooter
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
onNext={handleNext}
|
onProceedDirect={handleProceedDirect}
|
||||||
|
onViewResults={handleViewResults}
|
||||||
|
onRunCheck={handleRunCheck}
|
||||||
canGoBack={!!onBack}
|
canGoBack={!!onBack}
|
||||||
canProceed={totalErrorCount === 0}
|
canProceed={totalErrorCount === 0}
|
||||||
errorCount={totalErrorCount}
|
errorCount={totalErrorCount}
|
||||||
rowCount={rowCount}
|
rowCount={rowCount}
|
||||||
onAiValidate={aiValidation.validate}
|
isSanityChecking={sanityCheck.isChecking}
|
||||||
isAiValidating={aiValidation.isValidating}
|
hasRunSanityCheck={sanityCheck.hasRun}
|
||||||
onShowDebug={aiValidation.showPromptPreview}
|
skipSanityCheck={skipSanityCheck}
|
||||||
|
onSkipSanityCheckChange={setSkipSanityCheck}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Floating selection bar - appears when rows selected */}
|
{/* Floating selection bar - appears when rows selected */}
|
||||||
@@ -182,6 +293,21 @@ export const ValidationContainer = ({
|
|||||||
debugData={aiValidation.debugPrompt}
|
debugData={aiValidation.debugPrompt}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Sanity Check Dialog - shows cached results or runs new check */}
|
||||||
|
<SanityCheckDialog
|
||||||
|
open={sanityCheckDialogOpen}
|
||||||
|
onOpenChange={setSanityCheckDialogOpen}
|
||||||
|
isChecking={sanityCheck.isChecking}
|
||||||
|
error={sanityCheck.error}
|
||||||
|
result={sanityCheck.result}
|
||||||
|
onProceed={handleSanityCheckProceed}
|
||||||
|
onGoBack={handleSanityCheckGoBack}
|
||||||
|
onRefresh={handleRefreshSanityCheck}
|
||||||
|
onScrollToProduct={handleScrollToProduct}
|
||||||
|
productNames={productNames}
|
||||||
|
validationErrorCount={totalErrorCount}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Template form dialog - for saving row as template */}
|
{/* Template form dialog - for saving row as template */}
|
||||||
<TemplateForm
|
<TemplateForm
|
||||||
isOpen={isTemplateFormOpen}
|
isOpen={isTemplateFormOpen}
|
||||||
|
|||||||
@@ -1,39 +1,61 @@
|
|||||||
/**
|
/**
|
||||||
* ValidationFooter Component
|
* ValidationFooter Component
|
||||||
*
|
*
|
||||||
* Navigation footer with back/next buttons, AI validate, and summary info.
|
* Navigation footer with back/next buttons and summary info.
|
||||||
|
* After first sanity check, shows options to view results, recheck, or proceed directly.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useContext } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { CheckCircle, Wand2, FileText } from 'lucide-react';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Protected } from '@/components/auth/Protected';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
import { CheckCircle, Loader2, Bug, Eye, RefreshCw, ChevronRight } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from '@/components/ui/tooltip';
|
||||||
|
import { AuthContext } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
interface ValidationFooterProps {
|
interface ValidationFooterProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onNext?: () => void;
|
/** Called to proceed directly to next step (no sanity check) */
|
||||||
|
onProceedDirect?: () => void;
|
||||||
|
/** Called to view cached sanity check results */
|
||||||
|
onViewResults?: () => void;
|
||||||
|
/** Called to run a fresh sanity check */
|
||||||
|
onRunCheck?: () => void;
|
||||||
canGoBack: boolean;
|
canGoBack: boolean;
|
||||||
canProceed: boolean;
|
canProceed: boolean;
|
||||||
errorCount: number;
|
errorCount: number;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
onAiValidate?: () => void;
|
/** Whether sanity check is currently running */
|
||||||
isAiValidating?: boolean;
|
isSanityChecking?: boolean;
|
||||||
onShowDebug?: () => void;
|
/** Whether sanity check has been run at least once */
|
||||||
|
hasRunSanityCheck?: boolean;
|
||||||
|
/** Whether to skip sanity check (debug mode) */
|
||||||
|
skipSanityCheck?: boolean;
|
||||||
|
/** Called when skip sanity check toggle changes */
|
||||||
|
onSkipSanityCheckChange?: (skip: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ValidationFooter = ({
|
export const ValidationFooter = ({
|
||||||
onBack,
|
onBack,
|
||||||
onNext,
|
onProceedDirect,
|
||||||
|
onViewResults,
|
||||||
|
onRunCheck,
|
||||||
canGoBack,
|
canGoBack,
|
||||||
canProceed,
|
canProceed,
|
||||||
errorCount,
|
errorCount,
|
||||||
rowCount,
|
rowCount,
|
||||||
onAiValidate,
|
isSanityChecking = false,
|
||||||
isAiValidating = false,
|
hasRunSanityCheck = false,
|
||||||
onShowDebug,
|
skipSanityCheck = false,
|
||||||
|
onSkipSanityCheckChange,
|
||||||
}: ValidationFooterProps) => {
|
}: ValidationFooterProps) => {
|
||||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
const { user } = useContext(AuthContext);
|
||||||
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes('admin:debug'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
|
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
|
||||||
@@ -60,81 +82,130 @@ export const ValidationFooter = ({
|
|||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Show Prompt Debug - Admin only */}
|
{/* Skip sanity check toggle - only for admin:debug users */}
|
||||||
{onShowDebug && (
|
{hasDebugPermission && onSkipSanityCheckChange && (
|
||||||
<Protected permission="admin:debug">
|
<TooltipProvider>
|
||||||
<Button
|
<Tooltip delayDuration={300}>
|
||||||
variant="outline"
|
<TooltipTrigger asChild>
|
||||||
onClick={onShowDebug}
|
<div className="flex items-center gap-2 mr-2">
|
||||||
disabled={isAiValidating}
|
<Switch
|
||||||
>
|
id="skip-sanity"
|
||||||
<FileText className="h-4 w-4 mr-1" />
|
checked={skipSanityCheck}
|
||||||
Show Prompt
|
onCheckedChange={onSkipSanityCheckChange}
|
||||||
</Button>
|
className="data-[state=checked]:bg-amber-500"
|
||||||
</Protected>
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="skip-sanity"
|
||||||
|
className="text-xs text-muted-foreground cursor-pointer flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Bug className="h-3 w-3" />
|
||||||
|
Skip
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p>Debug: Skip sanity check</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* AI Validate */}
|
{/* Before first sanity check: single "Continue" button that runs the check */}
|
||||||
{onAiValidate && (
|
{!hasRunSanityCheck && !skipSanityCheck && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
onClick={onRunCheck}
|
||||||
onClick={onAiValidate}
|
disabled={isSanityChecking || rowCount === 0}
|
||||||
disabled={isAiValidating || rowCount === 0}
|
title={
|
||||||
|
!canProceed
|
||||||
|
? `There are ${errorCount} validation errors`
|
||||||
|
: 'Continue to image upload'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Wand2 className="h-4 w-4 mr-1" />
|
{isSanityChecking ? (
|
||||||
{isAiValidating ? 'Validating...' : 'AI Validate'}
|
<>
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
Checking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Continue'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Next button */}
|
{/* After first sanity check: show all three options */}
|
||||||
{onNext && (
|
{hasRunSanityCheck && !skipSanityCheck && (
|
||||||
<>
|
<>
|
||||||
|
{/* View previous results */}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onViewResults}
|
||||||
|
disabled={isSanityChecking}
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
|
Results
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p>View previous sanity check results</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* Run fresh check */}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip delayDuration={300}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRunCheck}
|
||||||
|
disabled={isSanityChecking || rowCount === 0}
|
||||||
|
>
|
||||||
|
{isSanityChecking ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
)}
|
||||||
|
{isSanityChecking ? 'Checking...' : 'Recheck'}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top">
|
||||||
|
<p>Run a fresh sanity check</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
{/* Proceed directly */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={onProceedDirect}
|
||||||
if (canProceed) {
|
disabled={isSanityChecking}
|
||||||
onNext();
|
|
||||||
} else {
|
|
||||||
setShowErrorDialog(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={
|
title={
|
||||||
!canProceed
|
!canProceed
|
||||||
? `There are ${errorCount} validation errors`
|
? `There are ${errorCount} validation errors`
|
||||||
: 'Continue to image upload'
|
: 'Continue to image upload'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Next
|
Continue
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Dialog open={showErrorDialog} onOpenChange={setShowErrorDialog}>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="pb-3">Are you sure?</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
There are still {errorCount} validation error{errorCount !== 1 ? 's' : ''} in your data.
|
|
||||||
Are you sure you want to continue?
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setShowErrorDialog(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setShowErrorDialog(false);
|
|
||||||
onNext();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Continue Anyway
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Skip mode: just show Continue */}
|
||||||
|
{skipSanityCheck && (
|
||||||
|
<Button
|
||||||
|
onClick={onProceedDirect}
|
||||||
|
disabled={rowCount === 0}
|
||||||
|
title="Continue to image upload (sanity check skipped)"
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,9 +50,16 @@ import { MultilineInput } from './cells/MultilineInput';
|
|||||||
// AI Suggestions context
|
// AI Suggestions context
|
||||||
import { useAiSuggestionsContext } from '../contexts/AiSuggestionsContext';
|
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
|
// Fields that trigger AI suggestion refresh when changed
|
||||||
const AI_EMBEDDING_FIELDS = ['company', 'line', 'name', 'description'] as const;
|
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
|
// Threshold for switching to ComboboxCell (with search) instead of SelectCell
|
||||||
const COMBOBOX_OPTION_THRESHOLD = 50;
|
const COMBOBOX_OPTION_THRESHOLD = 50;
|
||||||
|
|
||||||
@@ -86,7 +93,7 @@ const getCellComponent = (field: Field<string>, optionCount: number = 0) => {
|
|||||||
/**
|
/**
|
||||||
* Row height for virtualization
|
* Row height for virtualization
|
||||||
*/
|
*/
|
||||||
const ROW_HEIGHT = 40;
|
const ROW_HEIGHT = 80; // Taller rows to show 2 lines of description + space for AI badges
|
||||||
const HEADER_HEIGHT = 40;
|
const HEADER_HEIGHT = 40;
|
||||||
|
|
||||||
// Stable empty references to avoid creating new objects in selectors
|
// Stable empty references to avoid creating new objects in selectors
|
||||||
@@ -120,6 +127,9 @@ interface CellWrapperProps {
|
|||||||
isInCopyDownRange: boolean;
|
isInCopyDownRange: boolean;
|
||||||
isCopyDownTarget: boolean;
|
isCopyDownTarget: boolean;
|
||||||
totalRowCount: number;
|
totalRowCount: number;
|
||||||
|
// Inline AI validation (Groq-powered)
|
||||||
|
inlineAiSuggestion?: InlineAiSuggestion;
|
||||||
|
isInlineAiValidating?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -145,6 +155,8 @@ const CellWrapper = memo(({
|
|||||||
isInCopyDownRange,
|
isInCopyDownRange,
|
||||||
isCopyDownTarget,
|
isCopyDownTarget,
|
||||||
totalRowCount,
|
totalRowCount,
|
||||||
|
inlineAiSuggestion,
|
||||||
|
isInlineAiValidating = false,
|
||||||
}: CellWrapperProps) => {
|
}: CellWrapperProps) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isGeneratingUpc, setIsGeneratingUpc] = useState(false);
|
const [isGeneratingUpc, setIsGeneratingUpc] = useState(false);
|
||||||
@@ -156,6 +168,27 @@ const CellWrapper = memo(({
|
|||||||
const aiSuggestions = useAiSuggestionsContext();
|
const aiSuggestions = useAiSuggestionsContext();
|
||||||
const isEmbeddingField = AI_EMBEDDING_FIELDS.includes(field.key as typeof AI_EMBEDDING_FIELDS[number]);
|
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;
|
||||||
|
|
||||||
|
// Debug: Log when we have a suggestion for name field
|
||||||
|
if (isInlineAiField && field.key === 'name' && inlineAiSuggestion) {
|
||||||
|
console.log('[CellWrapper] Name field suggestion:', {
|
||||||
|
productIndex,
|
||||||
|
inlineAiSuggestion,
|
||||||
|
fieldSuggestion,
|
||||||
|
isDismissed,
|
||||||
|
showSuggestion,
|
||||||
|
isValid: fieldSuggestion?.isValid,
|
||||||
|
suggestion: fieldSuggestion?.suggestion
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check if cell has a value (for showing copy-down button)
|
// Check if cell has a value (for showing copy-down button)
|
||||||
const hasValue = value !== undefined && value !== null && value !== '';
|
const hasValue = value !== undefined && value !== null && value !== '';
|
||||||
|
|
||||||
@@ -269,8 +302,12 @@ const CellWrapper = memo(({
|
|||||||
// Stable callback for onBlur - validates field and triggers UPC validation if needed
|
// Stable callback for onBlur - validates field and triggers UPC validation if needed
|
||||||
// Uses setTimeout(0) to defer validation AFTER browser paint
|
// Uses setTimeout(0) to defer validation AFTER browser paint
|
||||||
const handleBlur = useCallback((newValue: unknown) => {
|
const handleBlur = useCallback((newValue: unknown) => {
|
||||||
const { updateCell } = useValidationStore.getState();
|
const state = useValidationStore.getState();
|
||||||
|
const { updateCell } = state;
|
||||||
|
|
||||||
|
// Capture previous value BEFORE updating - needed to detect actual changes
|
||||||
|
const previousValue = state.rows[rowIndex]?.[field.key];
|
||||||
|
|
||||||
let valueToSave = newValue;
|
let valueToSave = newValue;
|
||||||
|
|
||||||
// Auto-correct UPC check digit if this is the UPC field
|
// Auto-correct UPC check digit if this is the UPC field
|
||||||
@@ -281,7 +318,10 @@ const CellWrapper = memo(({
|
|||||||
// We'll use the corrected value
|
// We'll use the corrected value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the value actually changed (for AI validation trigger)
|
||||||
|
const valueChanged = String(valueToSave ?? '') !== String(previousValue ?? '');
|
||||||
|
|
||||||
updateCell(rowIndex, field.key, valueToSave);
|
updateCell(rowIndex, field.key, valueToSave);
|
||||||
|
|
||||||
// Defer validation to after the browser paints
|
// Defer validation to after the browser paints
|
||||||
@@ -511,8 +551,102 @@ const CellWrapper = memo(({
|
|||||||
aiSuggestions.handleFieldBlur(currentRow, field.key);
|
aiSuggestions.handleFieldBlur(currentRow, field.key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger inline AI validation for name/description fields
|
||||||
|
// This validates spelling, grammar, and naming conventions using Groq
|
||||||
|
// Only trigger if value actually changed to avoid unnecessary API calls
|
||||||
|
if (isInlineAiField && valueChanged && 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute sibling products (same company + line + subline if set) for naming context
|
||||||
|
const rows = useValidationStore.getState().rows;
|
||||||
|
const siblingNames: string[] = [];
|
||||||
|
|
||||||
|
if (currentRow.company && currentRow.line) {
|
||||||
|
const companyId = String(currentRow.company);
|
||||||
|
const lineId = String(currentRow.line);
|
||||||
|
const sublineId = currentRow.subline ? String(currentRow.subline) : null;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
// Skip self
|
||||||
|
if (row.__index === productIndex) continue;
|
||||||
|
|
||||||
|
// Must match company and line
|
||||||
|
if (String(row.company) !== companyId) continue;
|
||||||
|
if (String(row.line) !== lineId) continue;
|
||||||
|
|
||||||
|
// If current product has subline, siblings must match subline too
|
||||||
|
if (sublineId && String(row.subline) !== sublineId) continue;
|
||||||
|
|
||||||
|
// Add name if it exists
|
||||||
|
if (row.name && typeof row.name === 'string' && row.name.trim()) {
|
||||||
|
siblingNames.push(row.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
line_id: currentRow.line ? String(currentRow.line) : undefined,
|
||||||
|
subline_name: currentRow.subline ? getFieldLabel('subline', currentRow.subline) : undefined,
|
||||||
|
subline_id: currentRow.subline ? String(currentRow.subline) : undefined,
|
||||||
|
categories: currentRow.categories as string | undefined,
|
||||||
|
siblingNames: siblingNames.length > 0 ? siblingNames : 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);
|
}, 0);
|
||||||
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions]);
|
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions, isInlineAiField, productIndex]);
|
||||||
|
|
||||||
// Stable callback for fetching options (for line/subline dropdowns)
|
// Stable callback for fetching options (for line/subline dropdowns)
|
||||||
const handleFetchOptions = useCallback(async () => {
|
const handleFetchOptions = useCallback(async () => {
|
||||||
@@ -609,6 +743,14 @@ const CellWrapper = memo(({
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
|
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
|
||||||
isLoadingOptions={isLoadingOptions}
|
isLoadingOptions={isLoadingOptions}
|
||||||
|
// Pass AI suggestion props for description field (MultilineInput handles it internally)
|
||||||
|
{...(field.key === 'description' && {
|
||||||
|
aiSuggestion: fieldSuggestion,
|
||||||
|
isAiValidating: isInlineAiValidating,
|
||||||
|
onDismissAiSuggestion: () => {
|
||||||
|
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
|
||||||
|
},
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -665,6 +807,30 @@ const CellWrapper = memo(({
|
|||||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline AI validation spinner - only for name field (description handles it internally) */}
|
||||||
|
{isInlineAiValidating && field.key === 'name' && (
|
||||||
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 z-10">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-purple-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Suggestion badge - only for name field (description handles it inside its popover) */}
|
||||||
|
{showSuggestion && fieldSuggestion && field.key === 'name' && (
|
||||||
|
<div className="absolute top-full left-0 right-0 z-20 mt-1">
|
||||||
|
<AiSuggestionBadge
|
||||||
|
suggestion={fieldSuggestion.suggestion!}
|
||||||
|
issues={fieldSuggestion.issues}
|
||||||
|
onAccept={() => {
|
||||||
|
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name');
|
||||||
|
}}
|
||||||
|
onDismiss={() => {
|
||||||
|
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
|
||||||
|
}}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -775,6 +941,113 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa
|
|||||||
|
|
||||||
toast.success('Template applied');
|
toast.success('Template applied');
|
||||||
|
|
||||||
|
// Trigger inline AI validation for name/description if template set those fields
|
||||||
|
const productIndex = currentRow?.__index;
|
||||||
|
if (productIndex) {
|
||||||
|
const { setInlineAiValidating, setInlineAiSuggestion } = state;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the updated row data (after template applied)
|
||||||
|
const updatedRow = { ...currentRow, ...updates };
|
||||||
|
|
||||||
|
// Compute sibling names for context
|
||||||
|
const rows = state.rows;
|
||||||
|
const siblingNames: string[] = [];
|
||||||
|
if (updatedRow.company && updatedRow.line) {
|
||||||
|
const companyId = String(updatedRow.company);
|
||||||
|
const lineId = String(updatedRow.line);
|
||||||
|
const sublineId = updatedRow.subline ? String(updatedRow.subline) : null;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.__index === productIndex) continue;
|
||||||
|
if (String(row.company) !== companyId) continue;
|
||||||
|
if (String(row.line) !== lineId) continue;
|
||||||
|
if (sublineId && String(row.subline) !== sublineId) continue;
|
||||||
|
if (row.name && typeof row.name === 'string' && row.name.trim()) {
|
||||||
|
siblingNames.push(row.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger name validation if template set name
|
||||||
|
if (templateFieldsSet.has('name') && updates.name && String(updates.name).trim()) {
|
||||||
|
setInlineAiValidating(`${productIndex}-name`, true);
|
||||||
|
|
||||||
|
const productPayload = {
|
||||||
|
name: String(updates.name),
|
||||||
|
description: updatedRow.description as string,
|
||||||
|
company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined,
|
||||||
|
company_id: updatedRow.company ? String(updatedRow.company) : undefined,
|
||||||
|
line_name: updatedRow.line ? getFieldLabel('line', updatedRow.line) : undefined,
|
||||||
|
line_id: updatedRow.line ? String(updatedRow.line) : undefined,
|
||||||
|
subline_name: updatedRow.subline ? getFieldLabel('subline', updatedRow.subline) : undefined,
|
||||||
|
subline_id: updatedRow.subline ? String(updatedRow.subline) : undefined,
|
||||||
|
categories: updatedRow.categories as string | undefined,
|
||||||
|
siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/ai/validate/inline/name', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product: productPayload }),
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success !== false) {
|
||||||
|
setInlineAiSuggestion(productIndex, 'name', {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[InlineAI] name validation error:', err))
|
||||||
|
.finally(() => setInlineAiValidating(`${productIndex}-name`, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger description validation if template set description
|
||||||
|
if (templateFieldsSet.has('description') && updates.description && String(updates.description).trim()) {
|
||||||
|
setInlineAiValidating(`${productIndex}-description`, true);
|
||||||
|
|
||||||
|
const productPayload = {
|
||||||
|
name: updatedRow.name as string,
|
||||||
|
description: String(updates.description),
|
||||||
|
company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined,
|
||||||
|
company_id: updatedRow.company ? String(updatedRow.company) : undefined,
|
||||||
|
categories: updatedRow.categories as string | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/ai/validate/inline/description', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product: productPayload }),
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success !== false) {
|
||||||
|
setInlineAiSuggestion(productIndex, 'description', {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[InlineAI] description validation error:', err))
|
||||||
|
.finally(() => setInlineAiValidating(`${productIndex}-description`, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger UPC validation if template set supplier or upc, and we have both values
|
// Trigger UPC validation if template set supplier or upc, and we have both values
|
||||||
const finalSupplier = updates.supplier ?? currentRow?.supplier;
|
const finalSupplier = updates.supplier ?? currentRow?.supplier;
|
||||||
const finalUpc = updates.upc ?? currentRow?.upc;
|
const finalUpc = updates.upc ?? currentRow?.upc;
|
||||||
@@ -880,6 +1153,8 @@ interface VirtualRowProps {
|
|||||||
columns: ColumnDef<RowData>[];
|
columns: ColumnDef<RowData>[];
|
||||||
fields: Field<string>[];
|
fields: Field<string>[];
|
||||||
totalRowCount: number;
|
totalRowCount: number;
|
||||||
|
/** Whether table is scrolled horizontally - used for sticky column shadow */
|
||||||
|
isScrolledHorizontally: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualRow = memo(({
|
const VirtualRow = memo(({
|
||||||
@@ -889,6 +1164,7 @@ const VirtualRow = memo(({
|
|||||||
columns,
|
columns,
|
||||||
fields,
|
fields,
|
||||||
totalRowCount,
|
totalRowCount,
|
||||||
|
isScrolledHorizontally,
|
||||||
}: VirtualRowProps) => {
|
}: VirtualRowProps) => {
|
||||||
// Subscribe to row data - this is THE subscription for all cell values in this row
|
// Subscribe to row data - this is THE subscription for all cell values in this row
|
||||||
const rowData = useValidationStore(
|
const rowData = useValidationStore(
|
||||||
@@ -936,6 +1212,26 @@ const VirtualRow = memo(({
|
|||||||
useCallback((state) => state.copyDownMode, [])
|
useCallback((state) => state.copyDownMode, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Subscribe to inline AI suggestions for this row (for name/description validation)
|
||||||
|
const inlineAiSuggestion = useValidationStore(
|
||||||
|
useCallback((state) => {
|
||||||
|
const suggestion = state.inlineAi.suggestions.get(rowId);
|
||||||
|
// Debug: Log when subscription returns a value
|
||||||
|
if (suggestion) {
|
||||||
|
console.log('[VirtualRow] Got suggestion for rowId:', rowId, suggestion);
|
||||||
|
}
|
||||||
|
return suggestion;
|
||||||
|
}, [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
|
// 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!
|
// 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
|
// Note: company and line are already declared above for loading state subscriptions
|
||||||
@@ -946,6 +1242,13 @@ const VirtualRow = memo(({
|
|||||||
|
|
||||||
const hasErrors = Object.keys(rowErrors).length > 0;
|
const hasErrors = Object.keys(rowErrors).length > 0;
|
||||||
|
|
||||||
|
// Check if this row has a visible AI suggestion badge (needs higher z-index to show above next row)
|
||||||
|
// Only name field shows a floating badge - description handles AI suggestions inside its popover
|
||||||
|
const hasVisibleAiSuggestion = inlineAiSuggestion?.name &&
|
||||||
|
!inlineAiSuggestion.name.isValid &&
|
||||||
|
inlineAiSuggestion.name.suggestion &&
|
||||||
|
!inlineAiSuggestion.dismissed?.name;
|
||||||
|
|
||||||
// Handle mouse enter for copy-down target selection
|
// Handle mouse enter for copy-down target selection
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
if (copyDownMode.isActive && copyDownMode.sourceRowIndex !== null && rowIndex > copyDownMode.sourceRowIndex) {
|
if (copyDownMode.isActive && copyDownMode.sourceRowIndex !== null && rowIndex > copyDownMode.sourceRowIndex) {
|
||||||
@@ -964,12 +1267,14 @@ const VirtualRow = memo(({
|
|||||||
style={{
|
style={{
|
||||||
height: ROW_HEIGHT,
|
height: ROW_HEIGHT,
|
||||||
transform: `translateY(${virtualStart}px)`,
|
transform: `translateY(${virtualStart}px)`,
|
||||||
|
// Elevate row when it has a visible AI suggestion so badge shows above next row
|
||||||
|
zIndex: hasVisibleAiSuggestion ? 10 : undefined,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
>
|
>
|
||||||
{/* Selection checkbox cell */}
|
{/* Selection checkbox cell */}
|
||||||
<div
|
<div
|
||||||
className="px-2 py-1 border-r flex items-center justify-center"
|
className="px-2 py-3 border-r flex items-start justify-center"
|
||||||
style={{
|
style={{
|
||||||
width: columns[0]?.size || 40,
|
width: columns[0]?.size || 40,
|
||||||
minWidth: columns[0]?.size || 40,
|
minWidth: columns[0]?.size || 40,
|
||||||
@@ -984,7 +1289,7 @@ const VirtualRow = memo(({
|
|||||||
|
|
||||||
{/* Template column */}
|
{/* Template column */}
|
||||||
<div
|
<div
|
||||||
className="px-2 py-1 border-r flex items-center overflow-hidden"
|
className="px-2 py-2 border-r flex items-start overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
width: TEMPLATE_COLUMN_WIDTH,
|
width: TEMPLATE_COLUMN_WIDTH,
|
||||||
minWidth: TEMPLATE_COLUMN_WIDTH,
|
minWidth: TEMPLATE_COLUMN_WIDTH,
|
||||||
@@ -1043,8 +1348,22 @@ const VirtualRow = memo(({
|
|||||||
key={field.key}
|
key={field.key}
|
||||||
data-cell-field={field.key}
|
data-cell-field={field.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1 border-r last:border-r-0 flex items-center overflow-hidden",
|
"px-2 py-2 border-r last:border-r-0 flex items-start",
|
||||||
isNameColumn && "lg:sticky lg:z-10 lg:bg-background lg:shadow-md"
|
// Name column needs overflow-visible for the floating AI suggestion badge
|
||||||
|
// Description handles AI suggestions inside its popover, so no overflow needed
|
||||||
|
isNameColumn ? "overflow-visible" : "overflow-hidden",
|
||||||
|
// Name column is sticky - needs SOLID (opaque) background that matches row state
|
||||||
|
// Uses gradient trick to composite semi-transparent tint onto solid background
|
||||||
|
// Shadow only shows when scrolled horizontally (column is actually overlaying content)
|
||||||
|
isNameColumn && "lg:sticky lg:z-10",
|
||||||
|
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
|
||||||
|
isNameColumn && (
|
||||||
|
hasErrors
|
||||||
|
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
|
||||||
|
: isSelected
|
||||||
|
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]"
|
||||||
|
: "lg:bg-background"
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: columnWidth,
|
width: columnWidth,
|
||||||
@@ -1069,6 +1388,11 @@ const VirtualRow = memo(({
|
|||||||
isInCopyDownRange={isInCopyDownRange}
|
isInCopyDownRange={isInCopyDownRange}
|
||||||
isCopyDownTarget={isCopyDownTarget}
|
isCopyDownTarget={isCopyDownTarget}
|
||||||
totalRowCount={totalRowCount}
|
totalRowCount={totalRowCount}
|
||||||
|
inlineAiSuggestion={inlineAiSuggestion}
|
||||||
|
isInlineAiValidating={
|
||||||
|
field.key === 'name' ? isInlineAiValidatingName :
|
||||||
|
field.key === 'description' ? isInlineAiValidatingDescription : false
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1128,12 +1452,29 @@ export const ValidationTable = () => {
|
|||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Sync header scroll with body scroll
|
// Calculate name column's natural left position (before it becomes sticky)
|
||||||
|
// Selection (40) + Template (200) + all field columns before 'name'
|
||||||
|
const nameColumnLeftOffset = useMemo(() => {
|
||||||
|
let offset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns
|
||||||
|
for (const field of fields) {
|
||||||
|
if (field.key === 'name') break;
|
||||||
|
offset += field.width || 150;
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Track horizontal scroll for sticky column shadow
|
||||||
|
const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false);
|
||||||
|
|
||||||
|
// Sync header scroll with body scroll + track horizontal scroll state
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (tableContainerRef.current && headerRef.current) {
|
if (tableContainerRef.current && headerRef.current) {
|
||||||
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft;
|
const scrollLeft = tableContainerRef.current.scrollLeft;
|
||||||
|
headerRef.current.scrollLeft = scrollLeft;
|
||||||
|
// Only show shadow when scrolled past the name column's natural position
|
||||||
|
setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [nameColumnLeftOffset]);
|
||||||
|
|
||||||
// Compute filtered indices AND row IDs in a single pass
|
// Compute filtered indices AND row IDs in a single pass
|
||||||
// This avoids calling getState() during render for each row
|
// This avoids calling getState() during render for each row
|
||||||
@@ -1249,7 +1590,9 @@ export const ValidationTable = () => {
|
|||||||
key={column.id || index}
|
key={column.id || index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
|
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
|
||||||
isNameColumn && "lg:sticky lg:z-20 lg:bg-muted lg:shadow-md"
|
// Sticky header needs solid background matching the row's bg-muted/50 appearance
|
||||||
|
isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
|
||||||
|
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: column.size || 150,
|
width: column.size || 150,
|
||||||
@@ -1292,6 +1635,7 @@ export const ValidationTable = () => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
totalRowCount={rowCount}
|
totalRowCount={rowCount}
|
||||||
|
isScrolledHorizontally={isScrolledHorizontally}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* MultilineInput Component
|
* MultilineInput Component
|
||||||
*
|
*
|
||||||
* Expandable textarea cell for long text content.
|
* Expandable textarea cell for long text content.
|
||||||
|
* Includes AI suggestion display when available.
|
||||||
* Memoized to prevent unnecessary re-renders when parent table updates.
|
* Memoized to prevent unnecessary re-renders when parent table updates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -15,21 +16,36 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from '@/components/ui/tooltip';
|
} from '@/components/ui/tooltip';
|
||||||
import { X, Loader2 } from 'lucide-react';
|
import { X, Loader2, Sparkles, AlertCircle, Check, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Field, SelectOption } from '../../../../types';
|
import type { Field, SelectOption } from '../../../../types';
|
||||||
import type { ValidationError } from '../../store/types';
|
import type { ValidationError } from '../../store/types';
|
||||||
|
|
||||||
|
/** AI suggestion data for a single field */
|
||||||
|
interface AiFieldSuggestion {
|
||||||
|
isValid: boolean;
|
||||||
|
suggestion?: string | null;
|
||||||
|
issues?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface MultilineInputProps {
|
interface MultilineInputProps {
|
||||||
value: unknown;
|
value: unknown;
|
||||||
field: Field<string>;
|
field: Field<string>;
|
||||||
options?: SelectOption[];
|
options?: SelectOption[];
|
||||||
rowIndex: number;
|
rowIndex: number;
|
||||||
|
productIndex: string;
|
||||||
isValidating: boolean;
|
isValidating: boolean;
|
||||||
errors: ValidationError[];
|
errors: ValidationError[];
|
||||||
onChange: (value: unknown) => void;
|
onChange: (value: unknown) => void;
|
||||||
onBlur: (value: unknown) => void;
|
onBlur: (value: unknown) => void;
|
||||||
onFetchOptions?: () => void;
|
onFetchOptions?: () => void;
|
||||||
|
isLoadingOptions?: boolean;
|
||||||
|
/** AI suggestion for this field */
|
||||||
|
aiSuggestion?: AiFieldSuggestion | null;
|
||||||
|
/** Whether AI is currently validating */
|
||||||
|
isAiValidating?: boolean;
|
||||||
|
/** Called when user dismisses/clears the AI suggestion (also called after applying) */
|
||||||
|
onDismissAiSuggestion?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MultilineInputComponent = ({
|
const MultilineInputComponent = ({
|
||||||
@@ -39,16 +55,38 @@ const MultilineInputComponent = ({
|
|||||||
errors,
|
errors,
|
||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
|
aiSuggestion,
|
||||||
|
isAiValidating,
|
||||||
|
onDismissAiSuggestion,
|
||||||
}: MultilineInputProps) => {
|
}: MultilineInputProps) => {
|
||||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||||
const [editValue, setEditValue] = useState('');
|
const [editValue, setEditValue] = useState('');
|
||||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||||
|
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
||||||
|
const [editedSuggestion, setEditedSuggestion] = useState('');
|
||||||
const cellRef = useRef<HTMLDivElement>(null);
|
const cellRef = useRef<HTMLDivElement>(null);
|
||||||
const preventReopenRef = useRef(false);
|
const preventReopenRef = useRef(false);
|
||||||
|
|
||||||
const hasError = errors.length > 0;
|
const hasError = errors.length > 0;
|
||||||
const errorMessage = errors[0]?.message;
|
const errorMessage = errors[0]?.message;
|
||||||
|
|
||||||
|
// Check if we have a displayable AI suggestion
|
||||||
|
const hasAiSuggestion = aiSuggestion && !aiSuggestion.isValid && aiSuggestion.suggestion;
|
||||||
|
const aiIssues = aiSuggestion?.issues || [];
|
||||||
|
|
||||||
|
// Handle wheel scroll in textarea - stop propagation to prevent table scroll
|
||||||
|
const handleTextareaWheel = useCallback((e: React.WheelEvent<HTMLTextAreaElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = target;
|
||||||
|
const atTop = scrollTop === 0;
|
||||||
|
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
|
||||||
|
|
||||||
|
// Only stop propagation if we can scroll in the direction of the wheel
|
||||||
|
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Initialize localDisplayValue on mount and when value changes externally
|
// Initialize localDisplayValue on mount and when value changes externally
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const strValue = String(value ?? '');
|
const strValue = String(value ?? '');
|
||||||
@@ -57,6 +95,13 @@ const MultilineInputComponent = ({
|
|||||||
}
|
}
|
||||||
}, [value, localDisplayValue]);
|
}, [value, localDisplayValue]);
|
||||||
|
|
||||||
|
// Initialize edited suggestion when AI suggestion changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiSuggestion?.suggestion) {
|
||||||
|
setEditedSuggestion(aiSuggestion.suggestion);
|
||||||
|
}
|
||||||
|
}, [aiSuggestion?.suggestion]);
|
||||||
|
|
||||||
// Handle trigger click to toggle the popover
|
// Handle trigger click to toggle the popover
|
||||||
const handleTriggerClick = useCallback(
|
const handleTriggerClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
@@ -91,6 +136,7 @@ const MultilineInputComponent = ({
|
|||||||
|
|
||||||
// Immediately close popover
|
// Immediately close popover
|
||||||
setPopoverOpen(false);
|
setPopoverOpen(false);
|
||||||
|
setAiSuggestionExpanded(false);
|
||||||
|
|
||||||
// Prevent reopening
|
// Prevent reopening
|
||||||
preventReopenRef.current = true;
|
preventReopenRef.current = true;
|
||||||
@@ -117,6 +163,23 @@ const MultilineInputComponent = ({
|
|||||||
setEditValue(e.target.value);
|
setEditValue(e.target.value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Handle accepting the AI suggestion (possibly edited)
|
||||||
|
const handleAcceptSuggestion = useCallback(() => {
|
||||||
|
// Use the edited suggestion
|
||||||
|
setEditValue(editedSuggestion);
|
||||||
|
setLocalDisplayValue(editedSuggestion);
|
||||||
|
onChange(editedSuggestion);
|
||||||
|
onBlur(editedSuggestion);
|
||||||
|
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
||||||
|
setAiSuggestionExpanded(false);
|
||||||
|
}, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]);
|
||||||
|
|
||||||
|
// Handle dismissing the AI suggestion
|
||||||
|
const handleDismissSuggestion = useCallback(() => {
|
||||||
|
onDismissAiSuggestion?.();
|
||||||
|
setAiSuggestionExpanded(false);
|
||||||
|
}, [onDismissAiSuggestion]);
|
||||||
|
|
||||||
// Calculate display value
|
// Calculate display value
|
||||||
const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? '');
|
const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? '');
|
||||||
|
|
||||||
@@ -134,14 +197,38 @@ const MultilineInputComponent = ({
|
|||||||
<div
|
<div
|
||||||
onClick={handleTriggerClick}
|
onClick={handleTriggerClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1 h-8 rounded-md text-sm w-full cursor-pointer',
|
'px-2 py-1 rounded-md text-sm w-full cursor-pointer relative',
|
||||||
'overflow-hidden whitespace-nowrap text-ellipsis',
|
'overflow-hidden leading-tight h-[65px]',
|
||||||
'border',
|
'border',
|
||||||
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
|
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
|
||||||
|
hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20',
|
||||||
isValidating && 'opacity-50'
|
isValidating && 'opacity-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{displayValue}
|
{displayValue}
|
||||||
|
{/* AI suggestion indicator - small badge in corner, clickable to open with AI expanded */}
|
||||||
|
{hasAiSuggestion && !popoverOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAiSuggestionExpanded(true);
|
||||||
|
setPopoverOpen(true);
|
||||||
|
setEditValue(localDisplayValue || String(value ?? ''));
|
||||||
|
}}
|
||||||
|
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
|
||||||
|
title="View AI suggestion"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
<span>{aiIssues.length}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* AI validating indicator */}
|
||||||
|
{isAiValidating && (
|
||||||
|
<div className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/50">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -158,14 +245,14 @@ const MultilineInputComponent = ({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="p-0 shadow-lg rounded-md"
|
className="p-0 shadow-lg rounded-md"
|
||||||
style={{ width: Math.max(cellRef.current?.offsetWidth || 300, 300) }}
|
style={{ width: Math.max(cellRef.current?.offsetWidth || 400, 400) }}
|
||||||
align="start"
|
align="start"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
sideOffset={4}
|
sideOffset={4}
|
||||||
onInteractOutside={handleClosePopover}
|
|
||||||
>
|
>
|
||||||
<div className="flex flex-col relative">
|
<div className="flex flex-col">
|
||||||
|
{/* Close button */}
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -175,13 +262,96 @@ const MultilineInputComponent = ({
|
|||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Main textarea */}
|
||||||
<Textarea
|
<Textarea
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="min-h-[150px] border-none focus-visible:ring-0 rounded-md p-2 pr-8"
|
onWheel={handleTextareaWheel}
|
||||||
|
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none p-2 pr-8 resize-none"
|
||||||
placeholder={`Enter ${field.label || 'text'}...`}
|
placeholder={`Enter ${field.label || 'text'}...`}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* AI Suggestion section */}
|
||||||
|
{hasAiSuggestion && (
|
||||||
|
<div className="border-t border-purple-200 dark:border-purple-800 bg-purple-50/80 dark:bg-purple-950/30">
|
||||||
|
{/* Collapsed header - always visible */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAiSuggestionExpanded(!aiSuggestionExpanded)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-purple-100/50 dark:hover:bg-purple-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
|
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||||
|
AI Suggestion
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-purple-500 dark:text-purple-400">
|
||||||
|
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{aiSuggestionExpanded ? (
|
||||||
|
<ChevronUp className="h-4 w-4 text-purple-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4 text-purple-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
{aiSuggestionExpanded && (
|
||||||
|
<div className="px-3 pb-3 space-y-3">
|
||||||
|
{/* Issues list */}
|
||||||
|
{aiIssues.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{aiIssues.map((issue, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
|
<span>{issue}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Editable suggestion */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
|
||||||
|
Suggested (editable):
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
value={editedSuggestion}
|
||||||
|
onChange={(e) => setEditedSuggestion(e.target.value)}
|
||||||
|
onWheel={handleTextareaWheel}
|
||||||
|
className="min-h-[80px] max-h-[150px] overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
||||||
|
onClick={handleAcceptSuggestion}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3 mr-1" />
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||||
|
onClick={handleDismissSuggestion}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
RefreshCw
|
||||||
|
} 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 refresh/re-run the sanity check */
|
||||||
|
onRefresh?: () => void;
|
||||||
|
/** Called to scroll to a specific product */
|
||||||
|
onScrollToProduct?: (productIndex: number) => void;
|
||||||
|
/** Product names for display (indexed by product index) */
|
||||||
|
productNames?: Record<number, string>;
|
||||||
|
/** Number of validation errors (required fields, etc.) */
|
||||||
|
validationErrorCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SanityCheckDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
isChecking,
|
||||||
|
error,
|
||||||
|
result,
|
||||||
|
onProceed,
|
||||||
|
onGoBack,
|
||||||
|
onRefresh,
|
||||||
|
onScrollToProduct,
|
||||||
|
productNames = {},
|
||||||
|
validationErrorCount = 0
|
||||||
|
}: SanityCheckDialogProps) {
|
||||||
|
const hasSanityIssues = result?.issues && result.issues.length > 0;
|
||||||
|
const hasValidationErrors = validationErrorCount > 0;
|
||||||
|
const hasAnyIssues = hasSanityIssues || hasValidationErrors;
|
||||||
|
const allClear = !isChecking && !error && !hasSanityIssues && result;
|
||||||
|
|
||||||
|
// Group issues by severity/field for better organization
|
||||||
|
const issuesByField = result?.issues?.reduce((acc, issue) => {
|
||||||
|
const field = issue.field;
|
||||||
|
if (!acc[field]) {
|
||||||
|
acc[field] = [];
|
||||||
|
}
|
||||||
|
acc[field].push(issue);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, SanityIssue[]>) || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[600px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{isChecking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-purple-500" />
|
||||||
|
Running Sanity Check...
|
||||||
|
</>
|
||||||
|
) : error ? (
|
||||||
|
<>
|
||||||
|
<XCircle className="h-5 w-5 text-red-500" />
|
||||||
|
Sanity Check Failed
|
||||||
|
</>
|
||||||
|
) : hasAnyIssues ? (
|
||||||
|
<>
|
||||||
|
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||||
|
{hasValidationErrors && hasSanityIssues
|
||||||
|
? 'Validation Errors & Issues Found'
|
||||||
|
: hasValidationErrors
|
||||||
|
? 'Validation Errors'
|
||||||
|
: 'Issues Found'}
|
||||||
|
</>
|
||||||
|
) : allClear ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
|
Ready to Continue
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Pre-flight Check'
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
{/* Refresh button - only show when not checking */}
|
||||||
|
{!isChecking && onRefresh && result && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onRefresh}
|
||||||
|
className="h-8 px-2 text-muted-foreground hover:text-foreground"
|
||||||
|
title="Run sanity check again"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogDescription>
|
||||||
|
{isChecking
|
||||||
|
? 'Reviewing products for consistency and appropriateness...'
|
||||||
|
: error
|
||||||
|
? 'An error occurred while checking your products.'
|
||||||
|
: allClear && !hasValidationErrors
|
||||||
|
? 'All products look good! No issues detected.'
|
||||||
|
: hasAnyIssues
|
||||||
|
? buildIssuesSummary(validationErrorCount, result?.issues?.length || 0)
|
||||||
|
: 'Checking your products...'}
|
||||||
|
{/* Show when results were cached */}
|
||||||
|
{!isChecking && result?.checkedAt && (
|
||||||
|
<span className="block text-xs text-muted-foreground mt-1">
|
||||||
|
Last checked {formatTimeAgo(result.checkedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="py-4">
|
||||||
|
{/* Loading state */}
|
||||||
|
{isChecking && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-purple-500" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Analyzing {result?.totalProducts || '...'} products...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state */}
|
||||||
|
{error && !isChecking && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200">
|
||||||
|
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-800">Error</p>
|
||||||
|
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success state - only show if no validation errors either */}
|
||||||
|
{allClear && !hasValidationErrors && !isChecking && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 border border-green-200">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-green-800">All Clear!</p>
|
||||||
|
<p className="text-sm text-green-600 mt-1">
|
||||||
|
{result?.summary || 'No consistency issues detected in your products.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Validation errors warning */}
|
||||||
|
{hasValidationErrors && !isChecking && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200 mb-4">
|
||||||
|
<XCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-red-800">Validation Errors</p>
|
||||||
|
<p className="text-sm text-red-600 mt-1">
|
||||||
|
There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields, invalid values, etc.) in your data.
|
||||||
|
These should be fixed before continuing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sanity check issues list */}
|
||||||
|
{hasSanityIssues && !isChecking && (
|
||||||
|
<ScrollArea className="max-h-[400px] pr-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary */}
|
||||||
|
{result?.summary && (
|
||||||
|
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
|
||||||
|
<p className="text-sm text-amber-800">{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Issues grouped by field */}
|
||||||
|
{Object.entries(issuesByField).map(([field, fieldIssues]) => (
|
||||||
|
<div key={field} className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{formatFieldName(field)}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{fieldIssues.length} issue{fieldIssues.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fieldIssues.map((issue, index) => (
|
||||||
|
<div
|
||||||
|
key={`${field}-${index}`}
|
||||||
|
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 border border-gray-200 hover:border-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{productNames[issue.productIndex] || `Product ${issue.productIndex + 1}`}
|
||||||
|
</span>
|
||||||
|
{onScrollToProduct && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1 text-xs text-blue-600 hover:text-blue-800"
|
||||||
|
onClick={() => {
|
||||||
|
onOpenChange(false);
|
||||||
|
onScrollToProduct(issue.productIndex);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Go to
|
||||||
|
<ChevronRight className="h-3 w-3 ml-0.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">{issue.issue}</p>
|
||||||
|
{issue.suggestion && (
|
||||||
|
<p className="text-xs text-blue-600 mt-1">
|
||||||
|
💡 {issue.suggestion}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<DialogFooter>
|
||||||
|
{isChecking ? (
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
) : error ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onGoBack}>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : allClear && !hasValidationErrors ? (
|
||||||
|
<Button onClick={onProceed}>
|
||||||
|
Continue to Next Step
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
) : hasAnyIssues ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={onGoBack}>
|
||||||
|
Go Back & Fix
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onProceed} variant={hasValidationErrors ? 'destructive' : 'default'}>
|
||||||
|
{hasValidationErrors ? 'Proceed Despite Errors' : 'Proceed Anyway'}
|
||||||
|
<ChevronRight className="h-4 w-4 ml-1" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a field key into a human-readable name
|
||||||
|
*/
|
||||||
|
function formatFieldName(field: string): string {
|
||||||
|
const fieldNames: Record<string, string> = {
|
||||||
|
supplier_no: 'Supplier #',
|
||||||
|
msrp: 'MSRP',
|
||||||
|
cost_each: 'Cost Each',
|
||||||
|
qty_per_unit: 'Min Qty',
|
||||||
|
case_qty: 'Case Pack',
|
||||||
|
tax_cat: 'Tax Category',
|
||||||
|
size_cat: 'Size Category',
|
||||||
|
name: 'Name',
|
||||||
|
themes: 'Themes',
|
||||||
|
weight: 'Weight',
|
||||||
|
length: 'Length',
|
||||||
|
width: 'Width',
|
||||||
|
height: 'Height'
|
||||||
|
};
|
||||||
|
|
||||||
|
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a timestamp as a relative time string
|
||||||
|
*/
|
||||||
|
function formatTimeAgo(timestamp: number): string {
|
||||||
|
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 5) return 'just now';
|
||||||
|
if (seconds < 60) return `${seconds} seconds ago`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes === 1) return '1 minute ago';
|
||||||
|
if (minutes < 60) return `${minutes} minutes ago`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours === 1) return '1 hour ago';
|
||||||
|
if (hours < 24) return `${hours} hours ago`;
|
||||||
|
|
||||||
|
return 'over a day ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a summary string describing both validation errors and sanity issues
|
||||||
|
*/
|
||||||
|
function buildIssuesSummary(validationErrorCount: number, sanityIssueCount: number): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
if (validationErrorCount > 0) {
|
||||||
|
parts.push(`${validationErrorCount} validation error${validationErrorCount === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sanityIssueCount > 0) {
|
||||||
|
parts.push(`${sanityIssueCount} consistency issue${sanityIssueCount === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return `Found ${parts[0]} and ${parts[1]} to review.`;
|
||||||
|
} else if (parts.length === 1) {
|
||||||
|
return `Found ${parts[0]} to review.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Review the issues below.';
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
line_id?: string | number;
|
||||||
|
subline_name?: string;
|
||||||
|
subline_id?: string | number;
|
||||||
|
categories?: string;
|
||||||
|
// Sibling context for naming decisions
|
||||||
|
siblingNames?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce delay in milliseconds
|
||||||
|
const DEBOUNCE_DELAY = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for inline AI validation of product fields
|
||||||
|
*/
|
||||||
|
export function useInlineAiValidation() {
|
||||||
|
const [state, setState] = useState<InlineAiValidationState>({
|
||||||
|
isValidating: false,
|
||||||
|
error: null,
|
||||||
|
nameResult: null,
|
||||||
|
descriptionResult: null
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track pending requests for cancellation
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a product name
|
||||||
|
*/
|
||||||
|
const validateName = useCallback(async (product: ProductForValidation): Promise<InlineAiResult | null> => {
|
||||||
|
if (!product.name?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending request
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, isValidating: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/validate/inline/name', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Validation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
const aiResult: InlineAiResult = {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion || null,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isValidating: false,
|
||||||
|
nameResult: aiResult,
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return aiResult;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).name === 'AbortError') {
|
||||||
|
// Request was cancelled, ignore
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (error as Error).message || 'Validation failed';
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isValidating: false,
|
||||||
|
error: message
|
||||||
|
}));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a product description
|
||||||
|
*/
|
||||||
|
const validateDescription = useCallback(async (product: ProductForValidation): Promise<InlineAiResult | null> => {
|
||||||
|
if (!product.name?.trim() && !product.description?.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending request
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, isValidating: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/validate/inline/description', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Validation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
const aiResult: InlineAiResult = {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion || null,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isValidating: false,
|
||||||
|
descriptionResult: aiResult,
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return aiResult;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).name === 'AbortError') {
|
||||||
|
// Request was cancelled, ignore
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (error as Error).message || 'Validation failed';
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isValidating: false,
|
||||||
|
error: message
|
||||||
|
}));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced name validation - call on blur or after typing stops
|
||||||
|
*/
|
||||||
|
const validateNameDebounced = useCallback((product: ProductForValidation) => {
|
||||||
|
// Clear existing timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
validateName(product);
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
}, [validateName]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounced description validation
|
||||||
|
*/
|
||||||
|
const validateDescriptionDebounced = useCallback((product: ProductForValidation) => {
|
||||||
|
// Clear existing timer
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimerRef.current = setTimeout(() => {
|
||||||
|
validateDescription(product);
|
||||||
|
}, DEBOUNCE_DELAY);
|
||||||
|
}, [validateDescription]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear validation results
|
||||||
|
*/
|
||||||
|
const clearResults = useCallback(() => {
|
||||||
|
// Cancel any pending requests
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debounceTimerRef.current) {
|
||||||
|
clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isValidating: false,
|
||||||
|
error: null,
|
||||||
|
nameResult: null,
|
||||||
|
descriptionResult: null
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear name result only
|
||||||
|
*/
|
||||||
|
const clearNameResult = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, nameResult: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear description result only
|
||||||
|
*/
|
||||||
|
const clearDescriptionResult = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, descriptionResult: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isValidating: state.isValidating,
|
||||||
|
error: state.error,
|
||||||
|
nameResult: state.nameResult,
|
||||||
|
descriptionResult: state.descriptionResult,
|
||||||
|
|
||||||
|
// Actions - immediate
|
||||||
|
validateName,
|
||||||
|
validateDescription,
|
||||||
|
|
||||||
|
// Actions - debounced
|
||||||
|
validateNameDebounced,
|
||||||
|
validateDescriptionDebounced,
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
clearResults,
|
||||||
|
clearNameResult,
|
||||||
|
clearDescriptionResult
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* useSanityCheck Hook
|
||||||
|
*
|
||||||
|
* Runs batch sanity check on products before proceeding to next step.
|
||||||
|
* Checks for consistency and appropriateness across products.
|
||||||
|
*
|
||||||
|
* Results are cached locally - clicking "Sanity Check" again shows cached
|
||||||
|
* results without making a new API call. Use "Refresh" to force a new check.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** Timestamp when check was run */
|
||||||
|
checkedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SanityCheckState {
|
||||||
|
isChecking: boolean;
|
||||||
|
error: string | null;
|
||||||
|
result: SanityCheckResult | null;
|
||||||
|
hasRun: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product data for sanity check (simplified structure)
|
||||||
|
export interface ProductForSanityCheck {
|
||||||
|
name?: string;
|
||||||
|
supplier?: string;
|
||||||
|
supplier_name?: string;
|
||||||
|
company?: string;
|
||||||
|
company_name?: string;
|
||||||
|
supplier_no?: string;
|
||||||
|
msrp?: string | number;
|
||||||
|
cost_each?: string | number;
|
||||||
|
qty_per_unit?: string | number;
|
||||||
|
case_qty?: string | number;
|
||||||
|
tax_cat?: string | number;
|
||||||
|
tax_cat_name?: string;
|
||||||
|
size_cat?: string | number;
|
||||||
|
size_cat_name?: string;
|
||||||
|
themes?: string;
|
||||||
|
theme_names?: string;
|
||||||
|
weight?: string | number;
|
||||||
|
length?: string | number;
|
||||||
|
width?: string | number;
|
||||||
|
height?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for batch sanity check of products
|
||||||
|
*/
|
||||||
|
export function useSanityCheck() {
|
||||||
|
const [state, setState] = useState<SanityCheckState>({
|
||||||
|
isChecking: false,
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
hasRun: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track pending request for cancellation
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run sanity check on products
|
||||||
|
*/
|
||||||
|
const runCheck = useCallback(async (products: ProductForSanityCheck[]): Promise<SanityCheckResult | null> => {
|
||||||
|
if (!products || products.length === 0) {
|
||||||
|
return {
|
||||||
|
issues: [],
|
||||||
|
summary: 'No products to check'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel any pending request
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortControllerRef.current = controller;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: true,
|
||||||
|
error: null,
|
||||||
|
hasRun: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/ai/validate/sanity-check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ products }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || `Sanity check failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
const result: SanityCheckResult = {
|
||||||
|
issues: data.issues || [],
|
||||||
|
summary: data.summary || 'Check complete',
|
||||||
|
latencyMs: data.latencyMs,
|
||||||
|
totalProducts: products.length,
|
||||||
|
issueCount: data.issues?.length || 0,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: false,
|
||||||
|
result,
|
||||||
|
error: null
|
||||||
|
}));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as Error).name === 'AbortError') {
|
||||||
|
// Request was cancelled
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = (error as Error).message || 'Sanity check failed';
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: false,
|
||||||
|
error: message
|
||||||
|
}));
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel the current check
|
||||||
|
*/
|
||||||
|
const cancelCheck = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isChecking: false
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear results and reset state
|
||||||
|
*/
|
||||||
|
const clearResults = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort();
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
isChecking: false,
|
||||||
|
error: null,
|
||||||
|
result: null,
|
||||||
|
hasRun: false
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get issues for a specific product index
|
||||||
|
*/
|
||||||
|
const getIssuesForProduct = useCallback((productIndex: number): SanityIssue[] => {
|
||||||
|
if (!state.result?.issues) return [];
|
||||||
|
return state.result.issues.filter(issue => issue.productIndex === productIndex);
|
||||||
|
}, [state.result]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get issues grouped by field
|
||||||
|
*/
|
||||||
|
const getIssuesByField = useCallback((): Record<string, SanityIssue[]> => {
|
||||||
|
if (!state.result?.issues) return {};
|
||||||
|
|
||||||
|
return state.result.issues.reduce((acc, issue) => {
|
||||||
|
const field = issue.field;
|
||||||
|
if (!acc[field]) {
|
||||||
|
acc[field] = [];
|
||||||
|
}
|
||||||
|
acc[field].push(issue);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, SanityIssue[]>);
|
||||||
|
}, [state.result]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if there are any issues
|
||||||
|
*/
|
||||||
|
const hasIssues = state.result?.issues && state.result.issues.length > 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the sanity check passed (ran with no issues)
|
||||||
|
*/
|
||||||
|
const passed = state.hasRun && !state.isChecking && !state.error && !hasIssues;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we have cached results that can be displayed
|
||||||
|
*/
|
||||||
|
const hasCachedResults = state.hasRun && state.result !== null && !state.isChecking;
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isChecking: state.isChecking,
|
||||||
|
error: state.error,
|
||||||
|
result: state.result,
|
||||||
|
hasRun: state.hasRun,
|
||||||
|
hasIssues,
|
||||||
|
passed,
|
||||||
|
hasCachedResults,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
issues: state.result?.issues || [],
|
||||||
|
summary: state.result?.summary || null,
|
||||||
|
issueCount: state.result?.issueCount || 0,
|
||||||
|
checkedAt: state.result?.checkedAt || null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
runCheck,
|
||||||
|
cancelCheck,
|
||||||
|
clearResults,
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
getIssuesForProduct,
|
||||||
|
getIssuesByField
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -258,6 +258,43 @@ export interface AiSuggestionsState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Inline AI Validation Types (Groq-powered)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result from inline AI validation (name or description)
|
||||||
|
*/
|
||||||
|
export interface InlineAiValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
suggestion?: string;
|
||||||
|
issues: string[];
|
||||||
|
latencyMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-product inline AI suggestions (keyed by product __index)
|
||||||
|
*/
|
||||||
|
export interface InlineAiSuggestion {
|
||||||
|
name?: InlineAiValidationResult;
|
||||||
|
description?: InlineAiValidationResult;
|
||||||
|
/** Whether the suggestion has been dismissed by the user */
|
||||||
|
dismissed?: {
|
||||||
|
name?: boolean;
|
||||||
|
description?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for inline AI validation
|
||||||
|
*/
|
||||||
|
export interface InlineAiState {
|
||||||
|
/** Map of product __index to their inline suggestions */
|
||||||
|
suggestions: Map<string, InlineAiSuggestion>;
|
||||||
|
/** Products currently being validated */
|
||||||
|
validating: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Initialization Types
|
// Initialization Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -343,6 +380,9 @@ export interface ValidationState {
|
|||||||
// === AI Validation ===
|
// === AI Validation ===
|
||||||
aiValidation: AiValidationState;
|
aiValidation: AiValidationState;
|
||||||
|
|
||||||
|
// === Inline AI Validation (Groq) ===
|
||||||
|
inlineAi: InlineAiState;
|
||||||
|
|
||||||
// === File (for output) ===
|
// === File (for output) ===
|
||||||
file: File | null;
|
file: File | null;
|
||||||
}
|
}
|
||||||
@@ -438,6 +478,13 @@ export interface ValidationActions {
|
|||||||
clearAiValidation: () => void;
|
clearAiValidation: () => void;
|
||||||
storeOriginalValues: () => 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 ===
|
// === Output ===
|
||||||
getCleanedData: () => CleanRowData[];
|
getCleanedData: () => CleanRowData[];
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import type {
|
|||||||
AiValidationResults,
|
AiValidationResults,
|
||||||
CopyDownState,
|
CopyDownState,
|
||||||
DialogState,
|
DialogState,
|
||||||
PendingCopyDownValidation,
|
InlineAiValidationResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
import type { Field, SelectOption } from '../../../types';
|
import type { Field, SelectOption } from '../../../types';
|
||||||
|
|
||||||
@@ -125,6 +125,12 @@ const getInitialState = (): ValidationState => ({
|
|||||||
revertedChanges: new Set(),
|
revertedChanges: new Set(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Inline AI Validation (Groq)
|
||||||
|
inlineAi: {
|
||||||
|
suggestions: new Map(),
|
||||||
|
validating: new Set(),
|
||||||
|
},
|
||||||
|
|
||||||
// File
|
// File
|
||||||
file: null,
|
file: null,
|
||||||
});
|
});
|
||||||
@@ -750,6 +756,103 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Inline AI Validation (Groq)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: InlineAiValidationResult) => {
|
||||||
|
// Debug: Log what we're setting
|
||||||
|
console.log('[Store] setInlineAiSuggestion called:', {
|
||||||
|
productIndex,
|
||||||
|
field,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const existing = state.inlineAi.suggestions.get(productIndex) || {};
|
||||||
|
const newSuggestion = {
|
||||||
|
...existing,
|
||||||
|
[field]: result,
|
||||||
|
dismissed: {
|
||||||
|
...existing.dismissed,
|
||||||
|
[field]: false, // Reset dismissed state when new suggestion arrives
|
||||||
|
},
|
||||||
|
};
|
||||||
|
state.inlineAi.suggestions.set(productIndex, newSuggestion);
|
||||||
|
state.inlineAi.validating.delete(`${productIndex}-${field}`);
|
||||||
|
|
||||||
|
// Debug: Log what's in the Map now
|
||||||
|
console.log('[Store] After set, suggestions Map has:', productIndex, state.inlineAi.suggestions.get(productIndex));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
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
|
// Output
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
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 { Label } from "@/components/ui/label";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ArrowUpDown, Pencil, Trash2, PlusCircle } from "lucide-react";
|
import { ArrowUpDown, Pencil, Trash2, PlusCircle } from "lucide-react";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import {
|
import {
|
||||||
@@ -48,18 +49,21 @@ interface FieldOption {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Form uses task + role, which gets composed into prompt_type on submit
|
||||||
interface PromptFormData {
|
interface PromptFormData {
|
||||||
id?: number;
|
id?: number;
|
||||||
prompt_text: string;
|
prompt_text: string;
|
||||||
prompt_type: 'general' | 'company_specific' | 'system';
|
task: string;
|
||||||
|
role: "system" | "general" | "company_specific";
|
||||||
company: string | null;
|
company: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AiPrompt {
|
interface AiPrompt {
|
||||||
id: number;
|
id: number;
|
||||||
prompt_text: string;
|
prompt_text: string;
|
||||||
prompt_type: 'general' | 'company_specific' | 'system';
|
prompt_type: string;
|
||||||
company: string | null;
|
company: string | null;
|
||||||
|
is_singleton: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -68,19 +72,74 @@ interface FieldOptions {
|
|||||||
companies: FieldOption[];
|
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() {
|
export function PromptManagement() {
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
const [promptToDelete, setPromptToDelete] = useState<AiPrompt | null>(null);
|
const [promptToDelete, setPromptToDelete] = useState<AiPrompt | null>(null);
|
||||||
const [editingPrompt, setEditingPrompt] = useState<AiPrompt | null>(null);
|
const [editingPrompt, setEditingPrompt] = useState<AiPrompt | null>(null);
|
||||||
const [sorting, setSorting] = useState<SortingState>([
|
const [sorting, setSorting] = useState<SortingState>([
|
||||||
{ id: "prompt_type", desc: true },
|
{ id: "prompt_type", desc: false },
|
||||||
{ id: "company", desc: false }
|
{ id: "company", desc: false }
|
||||||
]);
|
]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [useCustomTask, setUseCustomTask] = useState(false);
|
||||||
|
const [customTask, setCustomTask] = useState("");
|
||||||
const [formData, setFormData] = useState<PromptFormData>({
|
const [formData, setFormData] = useState<PromptFormData>({
|
||||||
prompt_text: "",
|
prompt_text: "",
|
||||||
prompt_type: "general",
|
task: "",
|
||||||
|
role: "system",
|
||||||
company: null,
|
company: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,34 +167,45 @@ export function PromptManagement() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if general and system prompts already exist
|
// Track which prompts exist for disabling options
|
||||||
const generalPromptExists = useMemo(() => {
|
const existingPrompts = useMemo(() => {
|
||||||
return prompts?.some(prompt => prompt.prompt_type === 'general');
|
if (!prompts) return { system: new Set<string>(), general: new Set<string>(), companySpecific: new Map<string, Set<string>>() };
|
||||||
}, [prompts]);
|
|
||||||
|
const system = new Set<string>();
|
||||||
const systemPromptExists = useMemo(() => {
|
const general = new Set<string>();
|
||||||
return prompts?.some(prompt => prompt.prompt_type === 'system');
|
const companySpecific = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
prompts.forEach(p => {
|
||||||
|
const { task, role } = parsePromptType(p.prompt_type);
|
||||||
|
if (role === "system") {
|
||||||
|
system.add(task);
|
||||||
|
} else if (role === "general") {
|
||||||
|
general.add(task);
|
||||||
|
} else if (role === "company_specific" && p.company) {
|
||||||
|
if (!companySpecific.has(task)) {
|
||||||
|
companySpecific.set(task, new Set());
|
||||||
|
}
|
||||||
|
companySpecific.get(task)!.add(p.company);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { system, general, companySpecific };
|
||||||
}, [prompts]);
|
}, [prompts]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
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`, {
|
const response = await fetch(`${config.apiUrl}/ai-prompts`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.message || error.error || "Failed to create prompt");
|
throw new Error(error.message || error.error || "Failed to create prompt");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
onSuccess: (newPrompt) => {
|
onSuccess: (newPrompt) => {
|
||||||
// Optimistically update the cache with the new prompt
|
|
||||||
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
||||||
if (!old) return [newPrompt];
|
if (!old) return [newPrompt];
|
||||||
return [...old, newPrompt];
|
return [...old, newPrompt];
|
||||||
@@ -149,29 +219,22 @@ export function PromptManagement() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: async (data: PromptFormData) => {
|
mutationFn: async (data: { id: number; prompt_text: string; prompt_type: string; company: string | null; is_singleton: boolean }) => {
|
||||||
if (!data.id) throw new Error("Prompt ID is required for update");
|
|
||||||
|
|
||||||
const response = await fetch(`${config.apiUrl}/ai-prompts/${data.id}`, {
|
const response = await fetch(`${config.apiUrl}/ai-prompts/${data.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.message || error.error || "Failed to update prompt");
|
throw new Error(error.message || error.error || "Failed to update prompt");
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
onSuccess: (updatedPrompt) => {
|
onSuccess: (updatedPrompt) => {
|
||||||
// Optimistically update the cache with the returned data
|
|
||||||
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
||||||
if (!old) return [updatedPrompt];
|
if (!old) return [updatedPrompt];
|
||||||
return old.map((prompt) =>
|
return old.map((prompt) =>
|
||||||
prompt.id === updatedPrompt.id ? updatedPrompt : prompt
|
prompt.id === updatedPrompt.id ? updatedPrompt : prompt
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -194,7 +257,6 @@ export function PromptManagement() {
|
|||||||
return id;
|
return id;
|
||||||
},
|
},
|
||||||
onSuccess: (deletedId) => {
|
onSuccess: (deletedId) => {
|
||||||
// Optimistically update the cache by removing the deleted prompt
|
|
||||||
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
||||||
if (!old) return [];
|
if (!old) return [];
|
||||||
return old.filter((prompt) => prompt.id !== deletedId);
|
return old.filter((prompt) => prompt.id !== deletedId);
|
||||||
@@ -208,10 +270,15 @@ export function PromptManagement() {
|
|||||||
|
|
||||||
const handleEdit = (prompt: AiPrompt) => {
|
const handleEdit = (prompt: AiPrompt) => {
|
||||||
setEditingPrompt(prompt);
|
setEditingPrompt(prompt);
|
||||||
|
const { task, role } = parsePromptType(prompt.prompt_type);
|
||||||
|
const isPredefinedTask = PREDEFINED_TASKS.some(t => t.value === task);
|
||||||
|
setUseCustomTask(!isPredefinedTask);
|
||||||
|
setCustomTask(isPredefinedTask ? "" : task);
|
||||||
setFormData({
|
setFormData({
|
||||||
id: prompt.id,
|
id: prompt.id,
|
||||||
prompt_text: prompt.prompt_text,
|
prompt_text: prompt.prompt_text,
|
||||||
prompt_type: prompt.prompt_type,
|
task: task,
|
||||||
|
role: role,
|
||||||
company: prompt.company,
|
company: prompt.company,
|
||||||
});
|
});
|
||||||
setIsFormOpen(true);
|
setIsFormOpen(true);
|
||||||
@@ -232,15 +299,35 @@ export function PromptManagement() {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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 = {
|
const submitData = {
|
||||||
...formData,
|
id: formData.id,
|
||||||
company: formData.prompt_type === 'company_specific' ? formData.company : null,
|
prompt_text: formData.prompt_text,
|
||||||
|
prompt_type: promptType,
|
||||||
|
company: formData.role === "company_specific" ? formData.company : null,
|
||||||
|
is_singleton: true, // Always singleton
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingPrompt) {
|
if (editingPrompt) {
|
||||||
updateMutation.mutate(submitData);
|
updateMutation.mutate(submitData as { id: number; prompt_text: string; prompt_type: string; company: string | null; is_singleton: boolean });
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(submitData);
|
createMutation.mutate(submitData);
|
||||||
}
|
}
|
||||||
@@ -249,39 +336,76 @@ export function PromptManagement() {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setFormData({
|
setFormData({
|
||||||
prompt_text: "",
|
prompt_text: "",
|
||||||
prompt_type: "general",
|
task: "",
|
||||||
|
role: "system",
|
||||||
company: null,
|
company: null,
|
||||||
});
|
});
|
||||||
setEditingPrompt(null);
|
setEditingPrompt(null);
|
||||||
|
setUseCustomTask(false);
|
||||||
|
setCustomTask("");
|
||||||
setIsFormOpen(false);
|
setIsFormOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateClick = () => {
|
const handleCreateClick = () => {
|
||||||
resetForm();
|
resetForm();
|
||||||
|
// Find first task + role combo that doesn't exist
|
||||||
// If general prompt and system prompt exist, default to company-specific
|
for (const task of PREDEFINED_TASKS) {
|
||||||
if (generalPromptExists && systemPromptExists) {
|
if (!existingPrompts.system.has(task.value)) {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({ ...prev, task: task.value, role: "system" }));
|
||||||
...prev,
|
setIsFormOpen(true);
|
||||||
prompt_type: 'company_specific'
|
return;
|
||||||
}));
|
}
|
||||||
} else if (generalPromptExists && !systemPromptExists) {
|
if (!existingPrompts.general.has(task.value)) {
|
||||||
// If general exists but system doesn't, suggest system prompt
|
setFormData(prev => ({ ...prev, task: task.value, role: "general" }));
|
||||||
setFormData(prev => ({
|
setIsFormOpen(true);
|
||||||
...prev,
|
return;
|
||||||
prompt_type: 'system'
|
}
|
||||||
}));
|
|
||||||
} else if (!generalPromptExists) {
|
|
||||||
// If no general prompt, suggest that first
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
prompt_type: 'general'
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
// All base prompts exist, default to first task with company-specific
|
||||||
|
setFormData(prev => ({ ...prev, task: PREDEFINED_TASKS[0].value, role: "company_specific" }));
|
||||||
setIsFormOpen(true);
|
setIsFormOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTaskChange = (value: string) => {
|
||||||
|
if (value === "__custom__") {
|
||||||
|
setUseCustomTask(true);
|
||||||
|
setFormData(prev => ({ ...prev, task: "" }));
|
||||||
|
} else {
|
||||||
|
setUseCustomTask(false);
|
||||||
|
setCustomTask("");
|
||||||
|
setFormData(prev => ({ ...prev, task: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the effective task for checking what exists
|
||||||
|
const effectiveTask = useCustomTask ? customTask.trim() : formData.task;
|
||||||
|
|
||||||
|
// Check if current selection would conflict
|
||||||
|
const wouldConflict = useMemo(() => {
|
||||||
|
if (!effectiveTask) return false;
|
||||||
|
|
||||||
|
// If editing the same prompt, no conflict
|
||||||
|
if (editingPrompt) {
|
||||||
|
const { task: editTask, role: editRole } = parsePromptType(editingPrompt.prompt_type);
|
||||||
|
if (editTask === effectiveTask && editRole === formData.role) {
|
||||||
|
if (formData.role !== "company_specific") return false;
|
||||||
|
if (editingPrompt.company === formData.company) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.role === "system") {
|
||||||
|
return existingPrompts.system.has(effectiveTask);
|
||||||
|
}
|
||||||
|
if (formData.role === "general") {
|
||||||
|
return existingPrompts.general.has(effectiveTask);
|
||||||
|
}
|
||||||
|
if (formData.role === "company_specific" && formData.company) {
|
||||||
|
const taskCompanies = existingPrompts.companySpecific.get(effectiveTask);
|
||||||
|
return taskCompanies?.has(formData.company) || false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [effectiveTask, formData.role, formData.company, existingPrompts, editingPrompt]);
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<AiPrompt>[]>(() => [
|
const columns = useMemo<ColumnDef<AiPrompt>[]>(() => [
|
||||||
{
|
{
|
||||||
accessorKey: "prompt_type",
|
accessorKey: "prompt_type",
|
||||||
@@ -290,15 +414,24 @@ export function PromptManagement() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||||
>
|
>
|
||||||
Type
|
Task
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const type = row.getValue("prompt_type") as string;
|
const { task } = parsePromptType(row.getValue("prompt_type") as string);
|
||||||
if (type === 'general') return 'General';
|
return (
|
||||||
if (type === 'system') return 'System';
|
<span className="font-medium">{getTaskDisplayName(task)}</span>
|
||||||
return 'Company Specific';
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "role",
|
||||||
|
header: "Role",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const { role } = parsePromptType(row.getValue("prompt_type") as string);
|
||||||
|
const variant = role === "system" ? "default" : role === "general" ? "secondary" : "outline";
|
||||||
|
return <Badge variant={variant}>{getRoleDisplayName(role)}</Badge>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -331,7 +464,7 @@ export function PromptManagement() {
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const companyId = row.getValue("company");
|
const companyId = row.getValue("company");
|
||||||
if (!companyId) return 'N/A';
|
if (!companyId) return <span className="text-muted-foreground">—</span>;
|
||||||
return fieldOptions?.companies.find(c => c.value === companyId)?.label || companyId;
|
return fieldOptions?.companies.find(c => c.value === companyId)?.label || companyId;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -352,10 +485,7 @@ export function PromptManagement() {
|
|||||||
id: "actions",
|
id: "actions",
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex gap-2 justify-end pr-4">
|
<div className="flex gap-2 justify-end pr-4">
|
||||||
<Button
|
<Button variant="ghost" onClick={() => handleEdit(row.original)}>
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleEdit(row.original)}
|
|
||||||
>
|
|
||||||
<Pencil className="h-4 w-4" />
|
<Pencil className="h-4 w-4" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -376,7 +506,12 @@ export function PromptManagement() {
|
|||||||
if (!prompts) return [];
|
if (!prompts) return [];
|
||||||
return prompts.filter((prompt) => {
|
return prompts.filter((prompt) => {
|
||||||
const searchString = searchQuery.toLowerCase();
|
const searchString = searchQuery.toLowerCase();
|
||||||
|
const { task, role } = parsePromptType(prompt.prompt_type);
|
||||||
|
const taskName = getTaskDisplayName(task).toLowerCase();
|
||||||
|
const roleName = getRoleDisplayName(role).toLowerCase();
|
||||||
return (
|
return (
|
||||||
|
taskName.includes(searchString) ||
|
||||||
|
roleName.includes(searchString) ||
|
||||||
prompt.prompt_type.toLowerCase().includes(searchString) ||
|
prompt.prompt_type.toLowerCase().includes(searchString) ||
|
||||||
(prompt.company && prompt.company.toLowerCase().includes(searchString))
|
(prompt.company && prompt.company.toLowerCase().includes(searchString))
|
||||||
);
|
);
|
||||||
@@ -386,9 +521,7 @@ export function PromptManagement() {
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
columns,
|
columns,
|
||||||
state: {
|
state: { sorting },
|
||||||
sorting,
|
|
||||||
},
|
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
@@ -425,10 +558,7 @@ export function PromptManagement() {
|
|||||||
<TableHead key={header.id}>
|
<TableHead key={header.id}>
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext()
|
|
||||||
)}
|
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -463,108 +593,174 @@ export function PromptManagement() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingPrompt ? "Edit Prompt" : "Create New Prompt"}</DialogTitle>
|
<DialogTitle>{editingPrompt ? "Edit Prompt" : "Create New Prompt"}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{editingPrompt
|
{editingPrompt
|
||||||
? "Update this AI validation prompt."
|
? "Update this AI validation prompt."
|
||||||
: "Create a new AI validation prompt that will be used during product validation."}
|
: "Create a new AI validation prompt. Select a task and role, then enter the prompt text."}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="grid gap-4 py-4">
|
<div className="grid gap-4 py-4">
|
||||||
|
{/* Task Selector */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="prompt_type">Prompt Type</Label>
|
<Label>Task</Label>
|
||||||
<Select
|
{!useCustomTask ? (
|
||||||
value={formData.prompt_type}
|
<Select value={formData.task} onValueChange={handleTaskChange}>
|
||||||
onValueChange={(value: 'general' | 'company_specific' | 'system') =>
|
<SelectTrigger>
|
||||||
setFormData({ ...formData, prompt_type: value })
|
<SelectValue placeholder="Select task" />
|
||||||
}
|
</SelectTrigger>
|
||||||
disabled={(generalPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id) ||
|
<SelectContent>
|
||||||
(systemPromptExists && formData.prompt_type !== 'system' && !editingPrompt?.id)}
|
<SelectGroup>
|
||||||
>
|
<SelectLabel className="text-xs text-muted-foreground">Predefined Tasks</SelectLabel>
|
||||||
<SelectTrigger>
|
{PREDEFINED_TASKS.map((task) => (
|
||||||
<SelectValue placeholder="Select prompt type" />
|
<SelectItem key={task.value} value={task.value}>
|
||||||
</SelectTrigger>
|
<span className="flex flex-col">
|
||||||
<SelectContent>
|
<span>{task.label}</span>
|
||||||
<SelectItem
|
<span className="text-xs text-muted-foreground">{task.description}</span>
|
||||||
value="general"
|
</span>
|
||||||
disabled={generalPromptExists && !editingPrompt?.prompt_type?.includes('general')}
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectLabel className="text-xs text-muted-foreground">Custom</SelectLabel>
|
||||||
|
<SelectItem value="__custom__">Custom Task...</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={customTask}
|
||||||
|
onChange={(e) => setCustomTask(e.target.value.toLowerCase().replace(/\s+/g, '_'))}
|
||||||
|
placeholder="e.g., my_custom_task"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setUseCustomTask(false);
|
||||||
|
setCustomTask("");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
General
|
Cancel
|
||||||
</SelectItem>
|
</Button>
|
||||||
<SelectItem
|
</div>
|
||||||
value="system"
|
|
||||||
disabled={systemPromptExists && !editingPrompt?.prompt_type?.includes('system')}
|
|
||||||
>
|
|
||||||
System
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="company_specific">Company Specific</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{generalPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id && systemPromptExists && formData.prompt_type !== 'system' && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
General and system prompts already exist. You can only create company-specific prompts.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{generalPromptExists && !systemPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
A general prompt already exists. You can create a system prompt or company-specific prompts.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{systemPromptExists && !generalPromptExists && formData.prompt_type !== 'system' && !editingPrompt?.id && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
A system prompt already exists. You can create a general prompt or company-specific prompts.
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formData.prompt_type === 'company_specific' && (
|
{/* Role Selector */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Role</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.role}
|
||||||
|
onValueChange={(value: "system" | "general" | "company_specific") =>
|
||||||
|
setFormData(prev => ({ ...prev, role: value, company: value !== "company_specific" ? null : prev.company }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ROLES.map((role) => {
|
||||||
|
// Check if this role is already taken for the current task
|
||||||
|
let isDisabled = false;
|
||||||
|
if (effectiveTask) {
|
||||||
|
if (role.value === "system") {
|
||||||
|
isDisabled = existingPrompts.system.has(effectiveTask);
|
||||||
|
} else if (role.value === "general") {
|
||||||
|
isDisabled = existingPrompts.general.has(effectiveTask);
|
||||||
|
}
|
||||||
|
// Company-specific is never disabled at the role level
|
||||||
|
}
|
||||||
|
// Allow if editing the same prompt
|
||||||
|
if (editingPrompt && effectiveTask) {
|
||||||
|
const { task: editTask, role: editRole } = parsePromptType(editingPrompt.prompt_type);
|
||||||
|
if (editTask === effectiveTask && editRole === role.value) {
|
||||||
|
isDisabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SelectItem key={role.value} value={role.value} disabled={isDisabled}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{role.label}
|
||||||
|
{isDisabled && <Badge variant="secondary" className="text-xs">exists</Badge>}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{ROLES.find(r => r.value === formData.role)?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Company Selector (only for company_specific role) */}
|
||||||
|
{formData.role === "company_specific" && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="company">Company</Label>
|
<Label>Company</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.company || ''}
|
value={formData.company || ''}
|
||||||
onValueChange={(value) => setFormData({ ...formData, company: value })}
|
onValueChange={(value) => setFormData(prev => ({ ...prev, company: value }))}
|
||||||
required={formData.prompt_type === 'company_specific'}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select company" />
|
<SelectValue placeholder="Select company" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{fieldOptions?.companies.map((company) => (
|
{fieldOptions?.companies.map((company) => {
|
||||||
<SelectItem key={company.value} value={company.value}>
|
const taskCompanies = existingPrompts.companySpecific.get(effectiveTask);
|
||||||
{company.label}
|
const isExisting = taskCompanies?.has(company.value);
|
||||||
</SelectItem>
|
const isCurrentEdit = editingPrompt?.company === company.value;
|
||||||
))}
|
return (
|
||||||
|
<SelectItem
|
||||||
|
key={company.value}
|
||||||
|
value={company.value}
|
||||||
|
disabled={isExisting && !isCurrentEdit}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{company.label}
|
||||||
|
{isExisting && !isCurrentEdit && (
|
||||||
|
<Badge variant="secondary" className="text-xs">exists</Badge>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Conflict Warning */}
|
||||||
|
{wouldConflict && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
A prompt for this task + role combination already exists.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Prompt Text */}
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="prompt_text">Prompt Text</Label>
|
<Label htmlFor="prompt_text">Prompt Text</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="prompt_text"
|
id="prompt_text"
|
||||||
value={formData.prompt_text}
|
value={formData.prompt_text}
|
||||||
onChange={(e) => setFormData({ ...formData, prompt_text: e.target.value })}
|
onChange={(e) => setFormData(prev => ({ ...prev, prompt_text: e.target.value }))}
|
||||||
placeholder={`Enter your ${formData.prompt_type === 'system' ? 'system instructions' : 'validation prompt'} text...`}
|
placeholder="Enter your prompt text..."
|
||||||
className="h-80 font-mono text-sm"
|
className="h-80 font-mono text-sm"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{formData.prompt_type === 'system' && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
System prompts provide the initial instructions to the AI. This sets the tone and approach for all validations.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={() => {
|
<Button type="button" variant="outline" onClick={() => { resetForm(); setIsFormOpen(false); }}>
|
||||||
resetForm();
|
|
||||||
setIsFormOpen(false);
|
|
||||||
}}>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={wouldConflict || !effectiveTask || !formData.role || (formData.role === "company_specific" && !formData.company)}
|
||||||
|
>
|
||||||
{editingPrompt ? "Update" : "Create"} Prompt
|
{editingPrompt ? "Update" : "Create"} Prompt
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -582,10 +778,7 @@ export function PromptManagement() {
|
|||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel onClick={() => {
|
<AlertDialogCancel onClick={() => { setIsDeleteOpen(false); setPromptToDelete(null); }}>
|
||||||
setIsDeleteOpen(false);
|
|
||||||
setPromptToDelete(null);
|
|
||||||
}}>
|
|
||||||
Cancel
|
Cancel
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleDeleteConfirm}>
|
<AlertDialogAction onClick={handleDeleteConfirm}>
|
||||||
@@ -596,4 +789,4 @@ export function PromptManagement() {
|
|||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user