435 lines
12 KiB
JavaScript
435 lines
12 KiB
JavaScript
/**
|
|
* AI Routes
|
|
*
|
|
* API endpoints for AI-powered product validation features.
|
|
* Provides embedding generation and similarity-based suggestions.
|
|
*/
|
|
|
|
const express = require('express');
|
|
const router = express.Router();
|
|
const aiService = require('../services/ai');
|
|
const { getDbConnection, closeAllConnections } = require('../utils/dbConnection');
|
|
|
|
// Track initialization state
|
|
let initializationPromise = null;
|
|
|
|
/**
|
|
* Ensure AI service is initialized
|
|
* Uses lazy initialization on first request
|
|
*/
|
|
async function ensureInitialized() {
|
|
if (aiService.isReady()) {
|
|
return true;
|
|
}
|
|
|
|
if (initializationPromise) {
|
|
await initializationPromise;
|
|
return aiService.isReady();
|
|
}
|
|
|
|
initializationPromise = (async () => {
|
|
try {
|
|
console.log('[AI Routes] Initializing AI service...');
|
|
|
|
// Get database connection for taxonomy
|
|
const { connection } = await getDbConnection();
|
|
|
|
const result = await aiService.initialize({
|
|
openaiApiKey: process.env.OPENAI_API_KEY,
|
|
groqApiKey: process.env.GROQ_API_KEY,
|
|
mysqlConnection: connection,
|
|
pool: null, // Will be set by setPool()
|
|
logger: console
|
|
});
|
|
|
|
if (!result.success) {
|
|
console.error('[AI Routes] AI service initialization failed:', result.message);
|
|
return false;
|
|
}
|
|
|
|
console.log('[AI Routes] AI service initialized:', {
|
|
...result.stats,
|
|
groqEnabled: result.groqEnabled
|
|
});
|
|
return true;
|
|
} catch (error) {
|
|
console.error('[AI Routes] Failed to initialize AI service:', error);
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
await initializationPromise;
|
|
return aiService.isReady();
|
|
}
|
|
|
|
/**
|
|
* GET /api/ai/status
|
|
* Get AI service status
|
|
*/
|
|
router.get('/status', async (req, res) => {
|
|
try {
|
|
const status = aiService.getStatus();
|
|
res.json(status);
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/initialize
|
|
* Manually trigger initialization (also happens automatically on first use)
|
|
*/
|
|
router.post('/initialize', async (req, res) => {
|
|
try {
|
|
const ready = await ensureInitialized();
|
|
const status = aiService.getStatus();
|
|
|
|
res.json({
|
|
success: ready,
|
|
...status
|
|
});
|
|
} catch (error) {
|
|
console.error('[AI Routes] Initialize error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/ai/taxonomy
|
|
* Get all taxonomy data (categories, themes, colors) without embeddings
|
|
*/
|
|
router.get('/taxonomy', async (req, res) => {
|
|
try {
|
|
const ready = await ensureInitialized();
|
|
if (!ready) {
|
|
return res.status(503).json({ error: 'AI service not available' });
|
|
}
|
|
|
|
const taxonomy = aiService.getTaxonomyData();
|
|
res.json(taxonomy);
|
|
} catch (error) {
|
|
console.error('[AI Routes] Taxonomy error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/embedding
|
|
* Generate embedding for a single product
|
|
*
|
|
* Body: { product: { name, description, company_name, line_name } }
|
|
* Returns: { embedding: number[], latencyMs: number }
|
|
*/
|
|
router.post('/embedding', async (req, res) => {
|
|
try {
|
|
const ready = await ensureInitialized();
|
|
if (!ready) {
|
|
return res.status(503).json({ error: 'AI service not available' });
|
|
}
|
|
|
|
const { product } = req.body;
|
|
|
|
if (!product) {
|
|
return res.status(400).json({ error: 'Product is required' });
|
|
}
|
|
|
|
const result = await aiService.getProductEmbedding(product);
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error('[AI Routes] Embedding error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/embeddings
|
|
* Generate embeddings for multiple products
|
|
*
|
|
* Body: { products: Array<{ name, description, company_name, line_name }> }
|
|
* Returns: { embeddings: Array<{ index, embedding }>, latencyMs }
|
|
*/
|
|
router.post('/embeddings', async (req, res) => {
|
|
try {
|
|
const ready = await ensureInitialized();
|
|
if (!ready) {
|
|
return res.status(503).json({ error: 'AI service not available' });
|
|
}
|
|
|
|
const { products } = req.body;
|
|
|
|
if (!Array.isArray(products)) {
|
|
return res.status(400).json({ error: 'Products array is required' });
|
|
}
|
|
|
|
const result = await aiService.getProductEmbeddings(products);
|
|
res.json(result);
|
|
} catch (error) {
|
|
console.error('[AI Routes] Embeddings error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/suggestions
|
|
* Get category/theme/color suggestions for a single product
|
|
* Generates embedding and finds similar taxonomy items
|
|
*
|
|
* Body: { product: { name, description, company_name, line_name }, options?: { topCategories, topThemes, topColors } }
|
|
* Returns: { categories: Array, themes: Array, colors: Array, latencyMs }
|
|
*/
|
|
router.post('/suggestions', async (req, res) => {
|
|
try {
|
|
const ready = await ensureInitialized();
|
|
if (!ready) {
|
|
return res.status(503).json({ error: 'AI service not available' });
|
|
}
|
|
|
|
const { product, options } = req.body;
|
|
|
|
if (!product) {
|
|
return res.status(400).json({ error: 'Product is required' });
|
|
}
|
|
|
|
const suggestions = await aiService.getSuggestionsForProduct(product, options);
|
|
res.json(suggestions);
|
|
} catch (error) {
|
|
console.error('[AI Routes] Suggestions error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/suggestions/batch
|
|
* Get suggestions for multiple products
|
|
* More efficient than calling /suggestions multiple times
|
|
*
|
|
* Body: { products: Array, options?: { topCategories, topThemes, topColors } }
|
|
* Returns: { results: Array<{ index, categories, themes, colors }>, latencyMs }
|
|
*/
|
|
router.post('/suggestions/batch', async (req, res) => {
|
|
try {
|
|
const ready = await ensureInitialized();
|
|
if (!ready) {
|
|
return res.status(503).json({ error: 'AI service not available' });
|
|
}
|
|
|
|
const { products, options } = req.body;
|
|
|
|
if (!Array.isArray(products)) {
|
|
return res.status(400).json({ error: 'Products array is required' });
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Generate all embeddings at once
|
|
const { embeddings, latencyMs: embeddingLatency } = await aiService.getProductEmbeddings(products);
|
|
|
|
// Find suggestions for each embedding
|
|
const results = embeddings.map(({ index, embedding }) => {
|
|
const suggestions = aiService.findSimilarTaxonomy(embedding, options);
|
|
return {
|
|
index,
|
|
...suggestions
|
|
};
|
|
});
|
|
|
|
const totalLatency = Date.now() - startTime;
|
|
|
|
res.json({
|
|
results,
|
|
latencyMs: totalLatency,
|
|
embeddingLatencyMs: embeddingLatency,
|
|
searchLatencyMs: totalLatency - embeddingLatency,
|
|
productCount: products.length,
|
|
embeddingCount: embeddings.length
|
|
});
|
|
} catch (error) {
|
|
console.error('[AI Routes] Batch suggestions error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/similar
|
|
* Find similar taxonomy items given a pre-computed embedding
|
|
* Useful when frontend has cached the embedding
|
|
*
|
|
* Body: { embedding: number[], options?: { topCategories, topThemes, topColors } }
|
|
* Returns: { categories, themes, colors }
|
|
*/
|
|
router.post('/similar', async (req, res) => {
|
|
try {
|
|
const ready = await ensureInitialized();
|
|
if (!ready) {
|
|
return res.status(503).json({ error: 'AI service not available' });
|
|
}
|
|
|
|
const { embedding, options } = req.body;
|
|
|
|
if (!embedding || !Array.isArray(embedding)) {
|
|
return res.status(400).json({ error: 'Embedding array is required' });
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const suggestions = aiService.findSimilarTaxonomy(embedding, options);
|
|
|
|
res.json({
|
|
...suggestions,
|
|
latencyMs: Date.now() - startTime
|
|
});
|
|
} catch (error) {
|
|
console.error('[AI Routes] Similar error:', error);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// ============================================================================
|
|
// 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;
|