Files
inventory/docs/ai-validation-redesign.md

2847 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AI Validation System Redesign
> **Status:** Design Document (not yet implemented)
> **Date:** January 2025
> **Goal:** Replace expensive batch AI validation with a multi-tier, real-time validation system
## Table of Contents
1. [Executive Summary](#executive-summary)
2. [Current State Analysis](#current-state-analysis)
3. [Proposed Architecture](#proposed-architecture)
4. [Backend Implementation](#backend-implementation)
- [File Structure](#file-structure)
- [Provider System](#provider-system)
- [Task Registry](#task-registry)
- [Embedding System](#embedding-system)
- [Micro-Prompt Tasks](#micro-prompt-tasks)
5. [Frontend Implementation](#frontend-implementation)
- [Suggestion Hooks](#suggestion-hooks)
- [UI Components](#ui-components)
- [Integration with ValidationStep](#integration-with-validationstep)
6. [Database Schema](#database-schema)
7. [Migration Strategy](#migration-strategy)
8. [Cost Analysis](#cost-analysis)
---
## Executive Summary
### Problem
The current AI validation system:
- Costs **$0.30-0.50 per run** (GPT-5.2 with reasoning)
- Takes **3-4 minutes** to process
- Sends a **giant prompt** (18KB+ instructions + full taxonomy + all products)
- Processes everything in **one batch at the end** of the workflow
### Solution
A multi-tier validation system that:
- Performs **deterministic validation in code** (no AI needed for formatting)
- Uses **Groq (Llama 3.3)** for real-time field suggestions (~200ms, ~$0.001/product)
- Pre-computes **category/theme embeddings** for fast similarity search
- Reserves **expensive models** only for complex description generation
- Validates **incrementally** as user works, not all at once
### Expected Outcomes
| Metric | Current | Proposed |
|--------|---------|----------|
| Cost per 50 products | $0.30-0.50 | $0.02-0.05 |
| Processing time | 3-4 minutes | Real-time + 10-30s batch |
| User experience | Wait at end | Inline suggestions |
---
## Current State Analysis
### What the Current System Does
Based on the general prompt in `ai_prompts` table, validation performs these tasks:
| Task | AI Required? | Proposed Approach |
|------|--------------|-------------------|
| Price formatting (`$5.00``5.00`) | ❌ No | JavaScript normalizer |
| UPC/SKU trimming | ❌ No | JavaScript normalizer |
| Country code conversion (`USA``US`) | ❌ No | Lookup table |
| Date formatting (ETA field) | ❌ No | Date parser |
| Category assignment | ⚠️ Partial | Embeddings + small model |
| Theme detection | ⚠️ Partial | Embeddings + small model |
| Color extraction | ⚠️ Partial | Small model |
| Name standardization | ✅ Yes | Medium model |
| Description enhancement | ✅ Yes | Medium model (streaming) |
| Weight/dimension consistency | ✅ Yes | Batch comparison |
| Tax code assignment | ⚠️ Partial | Rules + small model |
### Current Prompt Structure
```
[System Instructions] ~200 chars
[General Prompt] ~18,000 chars (field guidelines, naming rules, etc.)
[Company-Specific] ~variable
[Taxonomy Data] ~50,000-100,000 chars (categories, themes, colors, etc.)
[Product Data] ~variable (all products as JSON)
```
**Total prompt size:** Often 100,000+ characters = ~25,000+ tokens input
### Current Model Usage
- **Model:** GPT-5.2 (reasoning model)
- **Reasoning effort:** Medium
- **Max output tokens:** 50,000
- **Response format:** Strict JSON schema
---
## Proposed Architecture
### System Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ VALIDATION STEP UI │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Field Edit │ │ Row Navigation │ │ Batch Action │ │
│ │ (on blur) │ │ (on row change)│ │ (user clicks) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
└───────────┼─────────────────────┼─────────────────────┼─────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ TIER 1: CLIENT-SIDE │
│ (Deterministic - No AI) │
├───────────────────────────────────────────────────────────────────────────┤
│ • Price formatting • UPC trimming • Country codes │
│ • Numeric validation • Required fields • Date parsing │
└───────────────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ TIER 2: REAL-TIME AI SUGGESTIONS │
│ (Groq - Llama 3.3 70B) │
├───────────────────────────────────────────────────────────────────────────┤
│ │
│ POST /api/ai/suggest/:field │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Name │ │ Category │ │ Theme │ │ Color │ │ Tax Code │ │
│ │ Task │ │ Task │ │ Task │ │ Task │ │ Task │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │ │
│ │ ▼ │ │ │ │
│ │ ┌────────────────┐ │ │ │ │
│ │ │ Embedding │ │ │ │ │
│ │ │ Pre-Filter │ │ │ │ │
│ │ └────────────────┘ │ │ │ │
│ │ │ │ │ │ │
└───────┼──────────────┼─────────────┼─────────────┼─────────────┼──────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ TIER 3: BATCH AI ENHANCEMENT │
│ (Claude Haiku / GPT-4o-mini) │
├───────────────────────────────────────────────────────────────────────────┤
│ │
│ POST /api/ai/enhance/descriptions │
│ POST /api/ai/validate/consistency │
│ │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ Description │ │ Cross-Product │ │
│ │ Generation │ │ Consistency │ │
│ │ (10-20 at once) │ │ (weights/dims) │ │
│ └────────────────────┘ └────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────────┘
```
### Validation Flow
```
User enters/maps data
┌───────────────────┐
│ Tier 1: Code │ ◄── Instant (0ms)
│ - Format prices │
│ - Validate UPCs │
│ - Convert country │
└─────────┬─────────┘
┌───────────────────┐
│ Tier 2: Groq │ ◄── Fast (200-500ms per field)
│ - Suggest category│ Triggered on focus/blur
│ - Detect themes │
│ - Format name │
└─────────┬─────────┘
┌───────────────────┐
│ Tier 3: Batch │ ◄── On-demand (5-30s)
│ - Descriptions │ User clicks "Enhance"
│ - Consistency │
└───────────────────┘
```
---
## Backend Implementation
### File Structure
```
inventory-server/src/
├── services/
│ └── ai/
│ ├── index.js # Main entry, initialization, exports
│ ├── config.js # AI configuration management
│ ├── taskRegistry.js # Task registration system
│ ├── workQueue.js # Concurrency control
│ │
│ ├── providers/
│ │ ├── index.js # Provider factory
│ │ ├── groqProvider.js # Groq API client (chat)
│ │ ├── openaiProvider.js # OpenAI API client (embeddings + chat)
│ │ └── anthropicProvider.js # Claude API client (batch tasks)
│ │
│ ├── embeddings/
│ │ ├── index.js # Embedding service entry
│ │ ├── categoryEmbeddings.js # Category embedding management
│ │ ├── themeEmbeddings.js # Theme embedding management
│ │ ├── vectorStore.js # In-memory vector storage
│ │ └── similarity.js # Cosine similarity utilities
│ │
│ ├── tasks/
│ │ ├── index.js # Task exports
│ │ ├── nameSuggestionTask.js
│ │ ├── categorySuggestionTask.js
│ │ ├── themeSuggestionTask.js
│ │ ├── colorSuggestionTask.js
│ │ ├── taxCodeSuggestionTask.js
│ │ ├── descriptionEnhanceTask.js
│ │ ├── consistencyCheckTask.js
│ │ └── utils/
│ │ ├── productUtils.js # Product data helpers
│ │ └── responseParser.js # AI response parsing
│ │
│ ├── prompts/
│ │ ├── index.js # Prompt exports
│ │ ├── namePrompts.js
│ │ ├── categoryPrompts.js
│ │ ├── themePrompts.js
│ │ ├── colorPrompts.js
│ │ ├── descriptionPrompts.js
│ │ └── consistencyPrompts.js
│ │
│ └── normalizers/
│ ├── index.js # Normalizer exports
│ ├── priceNormalizer.js
│ ├── upcNormalizer.js
│ ├── countryCodeNormalizer.js
│ ├── dateNormalizer.js
│ └── numericNormalizer.js
└── routes/
└── ai.js # New AI routes (replaces ai-validation.js)
```
### Provider System
#### Provider Interface
```javascript
// services/ai/providers/index.js
/**
* All providers must implement this interface
*/
class AIProvider {
/**
* Chat completion
* @param {Object} params
* @param {Array<{role: string, content: string}>} params.messages
* @param {string} params.model
* @param {number} [params.temperature=0.3]
* @param {number} [params.maxTokens=500]
* @param {Object} [params.responseFormat] - JSON schema for structured output
* @param {number} [params.timeoutMs=30000]
* @returns {Promise<{content: string, parsed: Object|null, usage: Object, latencyMs: number, model: string}>}
*/
async chatCompletion(params) {
throw new Error('Not implemented');
}
/**
* Generate embeddings
* @param {string|string[]} input - Text or array of texts
* @param {Object} [options]
* @param {string} [options.model]
* @param {number} [options.dimensions]
* @returns {Promise<{embeddings: number[][], usage: Object, model: string, latencyMs: number}>}
*/
async embed(input, options) {
throw new Error('Not implemented');
}
}
/**
* Provider factory
*/
function createProvider(providerName, config) {
switch (providerName.toLowerCase()) {
case 'groq':
return new GroqProvider(config.providers.groq);
case 'openai':
return new OpenAIProvider(config.providers.openai);
case 'anthropic':
return new AnthropicProvider(config.providers.anthropic);
default:
throw new Error(`Unknown provider: ${providerName}`);
}
}
module.exports = { AIProvider, createProvider };
```
#### Groq Provider (Primary for Real-Time)
```javascript
// services/ai/providers/groqProvider.js
const Groq = require('groq-sdk');
class GroqProvider {
constructor({ apiKey, baseUrl, timeoutMs = 30000 }) {
if (!apiKey) {
throw new Error('Groq API key is required');
}
this.client = new Groq({ apiKey, baseURL: baseUrl });
this.timeoutMs = timeoutMs;
}
async chatCompletion({
messages,
model = 'llama-3.3-70b-versatile',
temperature = 0.3,
maxTokens = 500,
responseFormat = null,
timeoutMs = this.timeoutMs
}) {
const started = Date.now();
const params = {
messages,
model,
temperature,
max_tokens: maxTokens
};
// Add JSON mode if requested
if (responseFormat) {
params.response_format = { type: 'json_object' };
}
const response = await this.client.chat.completions.create(params, {
timeout: timeoutMs
});
const content = response.choices[0]?.message?.content || '';
const usage = response.usage || {};
// Try to parse JSON if response format was requested
let parsed = null;
if (responseFormat && content) {
try {
parsed = JSON.parse(content);
} catch {
// Will return raw 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
};
}
// Groq doesn't support embeddings, so this throws
async embed() {
throw new Error('Groq does not support embeddings. Use OpenAI provider.');
}
}
module.exports = { GroqProvider };
```
#### OpenAI Provider (Embeddings + Fallback Chat)
```javascript
// services/ai/providers/openaiProvider.js
const MAX_EMBEDDING_BATCH_SIZE = 2048;
class OpenAIProvider {
constructor({
apiKey,
baseUrl = 'https://api.openai.com/v1',
embeddingModel = 'text-embedding-3-small',
embeddingDimensions = 1536,
chatModel = 'gpt-4o-mini',
timeoutMs = 60000
}) {
if (!apiKey) {
throw new Error('OpenAI API key is required');
}
this.apiKey = apiKey;
this.baseUrl = baseUrl;
this.embeddingModel = embeddingModel;
this.embeddingDimensions = embeddingDimensions;
this.chatModel = chatModel;
this.timeoutMs = timeoutMs;
}
async chatCompletion({
messages,
model = this.chatModel,
temperature = 0.3,
maxTokens = 500,
responseFormat = null,
timeoutMs = this.timeoutMs
}) {
const started = Date.now();
const body = {
model,
messages,
temperature,
max_tokens: maxTokens
};
if (responseFormat) {
body.response_format = { type: 'json_object' };
}
const response = await this._makeRequest('chat/completions', body, timeoutMs);
const content = response.choices[0]?.message?.content || '';
const usage = response.usage || {};
let parsed = null;
if (responseFormat && content) {
try {
parsed = JSON.parse(content);
} catch {
// Will return raw 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
};
}
/**
* Generate embeddings for a single text or batch
*/
async embed(input, options = {}) {
const texts = Array.isArray(input) ? input : [input];
const model = options.model || this.embeddingModel;
const dimensions = options.dimensions || this.embeddingDimensions;
const timeoutMs = options.timeoutMs || this.timeoutMs;
if (texts.length > MAX_EMBEDDING_BATCH_SIZE) {
throw new Error(`Batch size ${texts.length} exceeds max of ${MAX_EMBEDDING_BATCH_SIZE}`);
}
const started = Date.now();
// Clean input texts
const cleanedTexts = texts.map(t =>
(t || '').replace(/\n+/g, ' ').trim().substring(0, 8000)
);
const body = {
input: cleanedTexts,
model,
encoding_format: 'float'
};
// Only embedding-3 models support dimensions parameter
if (model.includes('embedding-3')) {
body.dimensions = dimensions;
}
const response = await this._makeRequest('embeddings', body, timeoutMs);
// Sort by index to ensure order matches input
const sortedData = response.data.sort((a, b) => a.index - b.index);
const embeddings = sortedData.map(item => item.embedding);
return {
embeddings,
usage: {
promptTokens: response.usage?.prompt_tokens || 0,
totalTokens: response.usage?.total_tokens || 0
},
model: response.model || model,
latencyMs: Date.now() - started
};
}
/**
* Generator for processing large batches in chunks
*/
async *embedBatchChunked(texts, options = {}) {
const batchSize = Math.min(options.batchSize || 100, MAX_EMBEDDING_BATCH_SIZE);
for (let i = 0; i < texts.length; i += batchSize) {
const chunk = texts.slice(i, i + batchSize);
const result = await this.embed(chunk, options);
yield {
embeddings: result.embeddings,
startIndex: i,
endIndex: i + chunk.length,
usage: result.usage,
model: result.model,
latencyMs: result.latencyMs
};
}
}
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(() => ({}));
throw new Error(error.error?.message || `OpenAI API error: ${response.status}`);
}
return response.json();
} finally {
clearTimeout(timeout);
}
}
}
module.exports = { OpenAIProvider, MAX_EMBEDDING_BATCH_SIZE };
```
#### Anthropic Provider (Batch Enhancement)
```javascript
// services/ai/providers/anthropicProvider.js
const Anthropic = require('@anthropic-ai/sdk');
class AnthropicProvider {
constructor({
apiKey,
defaultModel = 'claude-3-5-haiku-20241022',
timeoutMs = 120000
}) {
if (!apiKey) {
throw new Error('Anthropic API key is required');
}
this.client = new Anthropic({ apiKey });
this.defaultModel = defaultModel;
this.timeoutMs = timeoutMs;
}
async chatCompletion({
messages,
model = this.defaultModel,
temperature = 0.3,
maxTokens = 1000,
system = null,
timeoutMs = this.timeoutMs
}) {
const started = Date.now();
// Anthropic uses separate system parameter
const params = {
model,
max_tokens: maxTokens,
temperature,
messages: messages.filter(m => m.role !== 'system')
};
// Extract system message if present
const systemMessage = system || messages.find(m => m.role === 'system')?.content;
if (systemMessage) {
params.system = systemMessage;
}
const response = await this.client.messages.create(params);
const content = response.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('');
let parsed = null;
try {
parsed = JSON.parse(content);
} catch {
// Not JSON
}
return {
content,
parsed,
usage: {
promptTokens: response.usage?.input_tokens || 0,
completionTokens: response.usage?.output_tokens || 0,
totalTokens: (response.usage?.input_tokens || 0) + (response.usage?.output_tokens || 0)
},
latencyMs: Date.now() - started,
model: response.model || model
};
}
async embed() {
throw new Error('Anthropic does not support embeddings. Use OpenAI provider.');
}
}
module.exports = { AnthropicProvider };
```
### Task Registry
```javascript
// services/ai/taskRegistry.js
/**
* Registry for AI tasks
* Manages task registration, lookup, and execution
*/
class AiTaskRegistry {
constructor() {
this.tasks = new Map();
}
/**
* Register a task
* @param {Object} taskDefinition
* @param {string} taskDefinition.id - Unique task identifier
* @param {string} taskDefinition.description - Human-readable description
* @param {Function} taskDefinition.run - Async function to execute
* @param {Object} [taskDefinition.config] - Task-specific configuration
*/
register(taskDefinition) {
if (!taskDefinition?.id) {
throw new Error('Task must have an id');
}
if (typeof taskDefinition.run !== 'function') {
throw new Error(`Task ${taskDefinition.id} must have a run function`);
}
if (this.tasks.has(taskDefinition.id)) {
throw new Error(`Task ${taskDefinition.id} is already registered`);
}
this.tasks.set(taskDefinition.id, taskDefinition);
return this;
}
/**
* Get a task by ID
*/
get(taskId) {
return this.tasks.get(taskId) || null;
}
/**
* Check if a task exists
*/
has(taskId) {
return this.tasks.has(taskId);
}
/**
* List all registered task IDs
*/
list() {
return Array.from(this.tasks.keys());
}
}
/**
* Task IDs as frozen constants
*/
const TASK_IDS = Object.freeze({
// Tier 2: Real-time suggestions
SUGGEST_NAME: 'suggest.name',
SUGGEST_CATEGORIES: 'suggest.categories',
SUGGEST_THEMES: 'suggest.themes',
SUGGEST_COLORS: 'suggest.colors',
SUGGEST_TAX_CODE: 'suggest.taxCode',
SUGGEST_SIZE_CATEGORY: 'suggest.sizeCategory',
// Tier 3: Batch enhancement
ENHANCE_DESCRIPTIONS: 'enhance.descriptions',
CHECK_CONSISTENCY: 'check.consistency',
// Utility tasks
COMPUTE_EMBEDDINGS: 'util.computeEmbeddings'
});
module.exports = { AiTaskRegistry, TASK_IDS };
```
### Work Queue (Concurrency Control)
```javascript
// services/ai/workQueue.js
/**
* Simple concurrent work queue
* Prevents overwhelming AI providers with too many parallel requests
*/
class AiWorkQueue {
constructor(concurrency = 3) {
this.concurrency = Math.max(1, concurrency);
this.active = 0;
this.queue = [];
}
/**
* Enqueue a task for execution
* @param {Function} taskFactory - Async function to execute
* @returns {Promise} - Resolves with task result
*/
enqueue(taskFactory) {
return new Promise((resolve, reject) => {
const execute = async () => {
this.active += 1;
try {
const result = await taskFactory();
resolve(result);
} catch (error) {
reject(error);
} finally {
this.active -= 1;
this._processNext();
}
};
if (this.active < this.concurrency) {
execute();
} else {
this.queue.push(execute);
}
});
}
_processNext() {
if (this.queue.length === 0 || this.active >= this.concurrency) {
return;
}
const next = this.queue.shift();
if (next) {
next();
}
}
/**
* Get current queue statistics
*/
getStats() {
return {
active: this.active,
queued: this.queue.length,
concurrency: this.concurrency
};
}
}
module.exports = { AiWorkQueue };
```
### Embedding System
#### Embedding Service Entry
```javascript
// services/ai/embeddings/index.js
const { CategoryEmbeddings } = require('./categoryEmbeddings');
const { ThemeEmbeddings } = require('./themeEmbeddings');
const { VectorStore } = require('./vectorStore');
let categoryEmbeddings = null;
let themeEmbeddings = null;
let initialized = false;
/**
* Initialize the embedding system
* Should be called once at server startup
*/
async function initializeEmbeddings({ openaiProvider, mysqlConnection, logger }) {
if (initialized) {
return { categoryEmbeddings, themeEmbeddings };
}
logger?.info('[Embeddings] Initializing embedding system...');
// Initialize category embeddings
categoryEmbeddings = new CategoryEmbeddings({
provider: openaiProvider,
connection: mysqlConnection,
logger
});
// Initialize theme embeddings
themeEmbeddings = new ThemeEmbeddings({
provider: openaiProvider,
connection: mysqlConnection,
logger
});
// Load or compute embeddings
await Promise.all([
categoryEmbeddings.initialize(),
themeEmbeddings.initialize()
]);
initialized = true;
logger?.info('[Embeddings] Embedding system initialized');
return { categoryEmbeddings, themeEmbeddings };
}
/**
* Get category suggestions for a product
*/
async function suggestCategories(productText, topK = 10) {
if (!categoryEmbeddings) {
throw new Error('Embeddings not initialized');
}
return categoryEmbeddings.findSimilar(productText, topK);
}
/**
* Get theme suggestions for a product
*/
async function suggestThemes(productText, topK = 5) {
if (!themeEmbeddings) {
throw new Error('Embeddings not initialized');
}
return themeEmbeddings.findSimilar(productText, topK);
}
module.exports = {
initializeEmbeddings,
suggestCategories,
suggestThemes
};
```
#### Category Embeddings
```javascript
// services/ai/embeddings/categoryEmbeddings.js
const { VectorStore } = require('./vectorStore');
const { cosineSimilarity } = require('./similarity');
class CategoryEmbeddings {
constructor({ provider, connection, logger }) {
this.provider = provider;
this.connection = connection;
this.logger = logger;
this.vectorStore = new VectorStore();
this.categories = []; // Raw category data
}
/**
* Initialize embeddings - load from cache or compute
*/
async initialize() {
this.logger?.info('[CategoryEmbeddings] Loading categories from database...');
// Fetch hierarchical categories
const [rows] = await this.connection.query(`
SELECT
cat_id,
name,
master_cat_id,
type
FROM product_categories
WHERE type IN (10, 11, 12, 13)
ORDER BY type, name
`);
// Build category paths (e.g., "Paper > Patterned Paper > 12x12 Single Sheets")
this.categories = this._buildCategoryPaths(rows);
this.logger?.info(`[CategoryEmbeddings] Built ${this.categories.length} category paths`);
// Check if we have cached embeddings
const cached = await this._loadCachedEmbeddings();
if (cached && cached.length === this.categories.length) {
this.logger?.info('[CategoryEmbeddings] Using cached embeddings');
this.vectorStore.load(cached);
return;
}
// Compute new embeddings
await this._computeAndCacheEmbeddings();
}
/**
* Find similar categories for a product
*/
async findSimilar(productText, topK = 10) {
// Get embedding for product
const { embeddings } = await this.provider.embed(productText);
const productEmbedding = embeddings[0];
// Find most similar categories
const results = this.vectorStore.search(productEmbedding, topK);
// Enrich with category data
return results.map(result => {
const category = this.categories.find(c => c.id === result.id);
return {
id: category.id,
name: category.name,
fullPath: category.fullPath,
parentId: category.parentId,
similarity: result.similarity
};
});
}
/**
* Build full paths for categories
*/
_buildCategoryPaths(rows) {
const byId = new Map(rows.map(r => [r.cat_id, r]));
const categories = [];
for (const row of rows) {
const path = [];
let current = row;
// Walk up the tree to build full path
while (current) {
path.unshift(current.name);
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
}
categories.push({
id: row.cat_id,
name: row.name,
parentId: row.master_cat_id,
type: row.type,
fullPath: path.join(' > '),
// Text for embedding includes path for context
embeddingText: path.join(' ')
});
}
return categories;
}
/**
* Compute embeddings for all categories
*/
async _computeAndCacheEmbeddings() {
this.logger?.info('[CategoryEmbeddings] Computing embeddings...');
const texts = this.categories.map(c => c.embeddingText);
const allEmbeddings = [];
// Process in chunks (OpenAI has batch limits)
for await (const chunk of this.provider.embedBatchChunked(texts, { batchSize: 100 })) {
for (let i = 0; i < chunk.embeddings.length; i++) {
const globalIndex = chunk.startIndex + i;
allEmbeddings.push({
id: this.categories[globalIndex].id,
embedding: chunk.embeddings[i]
});
}
this.logger?.info(`[CategoryEmbeddings] Processed ${chunk.endIndex}/${texts.length}`);
}
// Store in vector store
this.vectorStore.load(allEmbeddings);
// Cache to database for faster startup next time
await this._cacheEmbeddings(allEmbeddings);
this.logger?.info('[CategoryEmbeddings] Embeddings computed and cached');
}
async _loadCachedEmbeddings() {
// TODO: Load from ai_embeddings_cache table
return null;
}
async _cacheEmbeddings(embeddings) {
// TODO: Save to ai_embeddings_cache table
}
}
module.exports = { CategoryEmbeddings };
```
#### Vector Store
```javascript
// services/ai/embeddings/vectorStore.js
const { cosineSimilarity } = require('./similarity');
/**
* In-memory vector store for fast similarity search
*/
class VectorStore {
constructor() {
this.vectors = []; // Array of { id, embedding }
}
/**
* Load vectors into the store
*/
load(vectors) {
this.vectors = vectors;
}
/**
* Add a single vector
*/
add(id, embedding) {
this.vectors.push({ id, embedding });
}
/**
* Search for most similar vectors
*/
search(queryEmbedding, topK = 10) {
const scored = this.vectors.map(item => ({
id: item.id,
similarity: cosineSimilarity(queryEmbedding, item.embedding)
}));
// Sort by similarity descending
scored.sort((a, b) => b.similarity - a.similarity);
return scored.slice(0, topK);
}
/**
* Get store size
*/
size() {
return this.vectors.length;
}
/**
* Clear the store
*/
clear() {
this.vectors = [];
}
}
module.exports = { VectorStore };
```
#### Similarity Utilities
```javascript
// services/ai/embeddings/similarity.js
/**
* Compute cosine similarity between two vectors
*/
function cosineSimilarity(a, b) {
if (a.length !== b.length) {
throw new Error('Vectors must have same length');
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
if (denominator === 0) return 0;
return dotProduct / denominator;
}
/**
* Compute Euclidean distance between two vectors
*/
function euclideanDistance(a, b) {
if (a.length !== b.length) {
throw new Error('Vectors must have same length');
}
let sum = 0;
for (let i = 0; i < a.length; i++) {
const diff = a[i] - b[i];
sum += diff * diff;
}
return Math.sqrt(sum);
}
module.exports = { cosineSimilarity, euclideanDistance };
```
### Micro-Prompt Tasks
#### Name Suggestion Task
```javascript
// services/ai/tasks/nameSuggestionTask.js
const { buildNamePrompt } = require('../prompts/namePrompts');
function createNameSuggestionTask({ provider, logger, config }) {
const taskConfig = config.tasks?.nameSuggestion || {};
async function run({ product }) {
if (!product?.name && !product?.description) {
return { suggestion: null, reason: 'No name or description provided' };
}
const prompt = buildNamePrompt(product);
const response = await provider.chatCompletion({
messages: [{ role: 'user', content: prompt }],
model: taskConfig.model || 'llama-3.3-70b-versatile',
temperature: taskConfig.temperature || 0.2,
maxTokens: taskConfig.maxTokens || 150
});
const suggestion = response.content.trim();
// Only return if different from original
if (suggestion === product.name) {
return { suggestion: null, unchanged: true };
}
return {
suggestion,
original: product.name,
usage: response.usage,
latencyMs: response.latencyMs
};
}
return {
id: 'suggest.name',
description: 'Suggest formatted product name',
run
};
}
module.exports = { createNameSuggestionTask };
```
#### Category Suggestion Task
```javascript
// services/ai/tasks/categorySuggestionTask.js
const { suggestCategories } = require('../embeddings');
const { buildCategoryPrompt } = require('../prompts/categoryPrompts');
function createCategorySuggestionTask({ provider, logger, config }) {
const taskConfig = config.tasks?.categorySuggestion || {};
async function run({ product }) {
const productText = `${product.name || ''} ${product.description || ''}`.trim();
if (!productText) {
return { suggestions: [], reason: 'No product text provided' };
}
// Step 1: Get top candidates via embedding similarity
const embeddingMatches = await suggestCategories(productText, 10);
// Step 2: Use AI to pick best matches from candidates
const prompt = buildCategoryPrompt(product, embeddingMatches);
const response = await provider.chatCompletion({
messages: [{ role: 'user', content: prompt }],
model: taskConfig.model || 'llama-3.1-8b-instant',
temperature: taskConfig.temperature || 0.1,
maxTokens: taskConfig.maxTokens || 100,
responseFormat: { type: 'json_object' }
});
let selectedIds = [];
try {
const parsed = JSON.parse(response.content);
selectedIds = Array.isArray(parsed.categories) ? parsed.categories : parsed;
} catch {
logger?.warn('[CategorySuggestion] Failed to parse response', { content: response.content });
}
// Filter matches to only selected IDs
const suggestions = embeddingMatches
.filter(m => selectedIds.includes(m.id))
.map(m => ({
id: m.id,
name: m.name,
fullPath: m.fullPath,
similarity: m.similarity
}));
return {
suggestions,
allMatches: embeddingMatches, // Include all for fallback
usage: response.usage,
latencyMs: response.latencyMs
};
}
return {
id: 'suggest.categories',
description: 'Suggest product categories',
run
};
}
module.exports = { createCategorySuggestionTask };
```
#### Description Enhancement Task
```javascript
// services/ai/tasks/descriptionEnhanceTask.js
const { buildDescriptionPrompt } = require('../prompts/descriptionPrompts');
function createDescriptionEnhanceTask({ provider, logger, config }) {
const taskConfig = config.tasks?.descriptionEnhance || {};
/**
* Enhance descriptions for a batch of products
*/
async function run({ products, mode = 'enhance' }) {
if (!Array.isArray(products) || products.length === 0) {
return { results: [], reason: 'No products provided' };
}
// Process in smaller batches for reliability
const batchSize = taskConfig.batchSize || 10;
const results = [];
for (let i = 0; i < products.length; i += batchSize) {
const batch = products.slice(i, i + batchSize);
const batchResults = await processBatch(batch, mode);
results.push(...batchResults);
}
return { results };
}
async function processBatch(products, mode) {
const prompt = buildDescriptionPrompt(products, mode);
const response = await provider.chatCompletion({
messages: [
{ role: 'system', content: getSystemPrompt() },
{ role: 'user', content: prompt }
],
model: taskConfig.model || 'claude-3-5-haiku-20241022',
temperature: taskConfig.temperature || 0.7,
maxTokens: taskConfig.maxTokens || 2000,
responseFormat: { type: 'json_object' }
});
let parsed = [];
try {
const result = JSON.parse(response.content);
parsed = result.descriptions || result;
} catch (error) {
logger?.warn('[DescriptionEnhance] Failed to parse response', { error: error.message });
}
// Match results back to products
return products.map((product, index) => {
const enhanced = parsed[index] || {};
return {
productId: product._index || product.upc || index,
original: product.description,
enhanced: enhanced.description || null,
changed: enhanced.description && enhanced.description !== product.description
};
});
}
function getSystemPrompt() {
return `You are a product copywriter for a craft supplies ecommerce store.
Write SEO-friendly, accurate descriptions that help customers understand what they're buying.
Always state what's included. Never use "our" - use "this" or the company name.
Keep descriptions to 2-4 sentences unless the product is complex.`;
}
return {
id: 'enhance.descriptions',
description: 'Enhance product descriptions in batch',
run
};
}
module.exports = { createDescriptionEnhanceTask };
```
### Prompts
#### Name Prompts
```javascript
// services/ai/prompts/namePrompts.js
function buildNamePrompt(product) {
return `Format this product name for a craft supplies store.
CURRENT NAME: "${product.name || ''}"
COMPANY: ${product.company_name || product.company || 'Unknown'}
LINE: ${product.line_name || product.line || 'None'}
PRODUCT TYPE: ${inferProductType(product)}
NAMING RULES:
- Single product in line: [Line Name] [Product Name] - [Company]
- Multiple similar products: [Differentiator] [Product Type] - [Line Name] - [Company]
- Standalone products: [Product Name] - [Company]
- Always capitalize every word (including "The", "And", etc.)
- Paper sizes: Use "12x12", "6x6" (no spaces or units)
- All stamps → "Stamp Set" (not "Clear Stamps")
- All dies → "Dies" (not "Die Set")
SPECIAL RULES:
- Tim Holtz from Ranger: "[Color] [Product] - Tim Holtz Distress - Ranger"
- Tim Holtz from Sizzix: "[Product] by Tim Holtz - Sizzix"
- Dylusions from Ranger: "[Product] - Dylusions - Ranger"
Return ONLY the corrected name, nothing else.`;
}
function inferProductType(product) {
const name = (product.name || '').toLowerCase();
const desc = (product.description || '').toLowerCase();
const text = `${name} ${desc}`;
if (text.includes('stamp')) return 'Stamps';
if (text.includes('die') || text.includes('thinlit')) return 'Dies';
if (text.includes('paper') || text.includes('cardstock')) return 'Paper';
if (text.includes('ink')) return 'Ink';
if (text.includes('sticker')) return 'Stickers';
if (text.includes('washi')) return 'Washi Tape';
return 'Unknown';
}
module.exports = { buildNamePrompt };
```
#### Category Prompts
```javascript
// services/ai/prompts/categoryPrompts.js
function buildCategoryPrompt(product, categoryMatches) {
const matchList = categoryMatches
.map(c => `${c.id}: ${c.fullPath}`)
.join('\n');
return `Select the best categories for this craft product.
PRODUCT:
Name: "${product.name || ''}"
Description: "${(product.description || '').substring(0, 200)}"
Company: ${product.company_name || 'Unknown'}
CATEGORY OPTIONS:
${matchList}
RULES:
- Select 1-3 most specific categories
- Prefer deeper subcategories over parents
- If selecting a subcategory, don't also select its parent
- Never select "Deals" or "Black Friday" categories
Return JSON: {"categories": [id1, id2]}`;
}
module.exports = { buildCategoryPrompt };
```
#### Description Prompts
```javascript
// services/ai/prompts/descriptionPrompts.js
function buildDescriptionPrompt(products, mode = 'enhance') {
const productList = products.map((p, i) => {
return `[${i}]
Name: ${p.name || 'Unknown'}
Company: ${p.company_name || 'Unknown'}
Current Description: ${p.description || '(none)'}
Categories: ${p.category_names?.join(', ') || 'Unknown'}
Dimensions: ${p.length || '?'}x${p.width || '?'} inches
Weight: ${p.weight || '?'} oz`;
}).join('\n\n');
const instruction = mode === 'generate'
? 'Write new descriptions for these products.'
: 'Improve these product descriptions. Fix grammar, add missing details, make SEO-friendly.';
return `${instruction}
PRODUCTS:
${productList}
RULES:
- 2-4 sentences each, professional but friendly
- Always state what's included (quantity, size)
- Don't use "our" - use "this" or company name
- Don't add generic filler ("perfect for all your crafts")
- State facts: dimensions, compatibility, materials
- Don't make up information you're not sure about
Return JSON: {"descriptions": [{"description": "..."}, ...]}`;
}
module.exports = { buildDescriptionPrompt };
```
### Normalizers (Tier 1 - No AI)
```javascript
// services/ai/normalizers/index.js
const priceNormalizer = require('./priceNormalizer');
const upcNormalizer = require('./upcNormalizer');
const countryCodeNormalizer = require('./countryCodeNormalizer');
const dateNormalizer = require('./dateNormalizer');
const numericNormalizer = require('./numericNormalizer');
/**
* Apply all relevant normalizers to a product
*/
function normalizeProduct(product, fieldMappings) {
const normalized = { ...product };
const changes = [];
// Price fields
for (const field of ['msrp', 'cost_each', 'price']) {
if (normalized[field] !== undefined) {
const result = priceNormalizer.normalize(normalized[field]);
if (result.value !== normalized[field]) {
changes.push({ field, from: normalized[field], to: result.value });
normalized[field] = result.value;
}
}
}
// UPC/SKU fields
for (const field of ['upc', 'supplier_no', 'notions_no', 'item_number']) {
if (normalized[field] !== undefined) {
const result = upcNormalizer.normalize(normalized[field]);
if (result.value !== normalized[field]) {
changes.push({ field, from: normalized[field], to: result.value });
normalized[field] = result.value;
}
}
}
// Country of origin
if (normalized.coo !== undefined) {
const result = countryCodeNormalizer.normalize(normalized.coo);
if (result.value !== normalized.coo) {
changes.push({ field: 'coo', from: normalized.coo, to: result.value });
normalized.coo = result.value;
}
}
// ETA date
if (normalized.eta !== undefined) {
const result = dateNormalizer.normalizeEta(normalized.eta);
if (result.value !== normalized.eta) {
changes.push({ field: 'eta', from: normalized.eta, to: result.value });
normalized.eta = result.value;
}
}
// Numeric fields
for (const field of ['qty_per_unit', 'case_qty']) {
if (normalized[field] !== undefined) {
const result = numericNormalizer.normalize(normalized[field]);
if (result.value !== normalized[field]) {
changes.push({ field, from: normalized[field], to: result.value });
normalized[field] = result.value;
}
}
}
return { normalized, changes };
}
module.exports = {
normalizeProduct,
priceNormalizer,
upcNormalizer,
countryCodeNormalizer,
dateNormalizer,
numericNormalizer
};
```
```javascript
// services/ai/normalizers/priceNormalizer.js
/**
* Normalize price values
* Input: "$5.00", "5", "5.5", "$1,234.56"
* Output: "5.00", "5.00", "5.50", "1234.56"
*/
function normalize(value) {
if (value === null || value === undefined || value === '') {
return { value, changed: false };
}
const original = String(value);
// Remove currency symbols and commas
let cleaned = original.replace(/[$,]/g, '').trim();
// Try to parse as number
const num = parseFloat(cleaned);
if (isNaN(num)) {
return { value: original, changed: false, error: 'Not a valid number' };
}
// Format to 2 decimal places
const formatted = num.toFixed(2);
return {
value: formatted,
changed: formatted !== original
};
}
module.exports = { normalize };
```
```javascript
// services/ai/normalizers/countryCodeNormalizer.js
const COUNTRY_MAP = {
// Full names
'united states': 'US',
'united states of america': 'US',
'china': 'CN',
'peoples republic of china': 'CN',
"people's republic of china": 'CN',
'taiwan': 'TW',
'japan': 'JP',
'south korea': 'KR',
'korea': 'KR',
'india': 'IN',
'germany': 'DE',
'united kingdom': 'GB',
'great britain': 'GB',
'france': 'FR',
'italy': 'IT',
'spain': 'ES',
'canada': 'CA',
'mexico': 'MX',
'brazil': 'BR',
'australia': 'AU',
'vietnam': 'VN',
'thailand': 'TH',
'indonesia': 'ID',
'philippines': 'PH',
'malaysia': 'MY',
// Common abbreviations
'usa': 'US',
'u.s.a.': 'US',
'u.s.': 'US',
'prc': 'CN',
'uk': 'GB',
'u.k.': 'GB',
'rok': 'KR'
};
function normalize(value) {
if (value === null || value === undefined || value === '') {
return { value, changed: false };
}
const original = String(value).trim();
const lookup = original.toLowerCase();
// Check if it's already a valid 2-letter code
if (/^[A-Z]{2}$/.test(original)) {
return { value: original, changed: false };
}
// Look up in map
if (COUNTRY_MAP[lookup]) {
return {
value: COUNTRY_MAP[lookup],
changed: true
};
}
// If 2 chars, uppercase and return
if (original.length === 2) {
const upper = original.toUpperCase();
return {
value: upper,
changed: upper !== original
};
}
// Take first 2 chars as fallback
const fallback = original.substring(0, 2).toUpperCase();
return {
value: fallback,
changed: true,
warning: `Unknown country "${original}", using "${fallback}"`
};
}
module.exports = { normalize };
```
### Main AI Service Entry
```javascript
// services/ai/index.js
const { AiTaskRegistry, TASK_IDS } = require('./taskRegistry');
const { AiWorkQueue } = require('./workQueue');
const { createProvider } = require('./providers');
const { initializeEmbeddings } = require('./embeddings');
const { normalizeProduct } = require('./normalizers');
// Task factories
const { createNameSuggestionTask } = require('./tasks/nameSuggestionTask');
const { createCategorySuggestionTask } = require('./tasks/categorySuggestionTask');
const { createThemeSuggestionTask } = require('./tasks/themeSuggestionTask');
const { createColorSuggestionTask } = require('./tasks/colorSuggestionTask');
const { createDescriptionEnhanceTask } = require('./tasks/descriptionEnhanceTask');
const { createConsistencyCheckTask } = require('./tasks/consistencyCheckTask');
let initialized = false;
let aiEnabled = false;
let registry = null;
let workQueue = null;
let providers = {};
/**
* Initialize the AI system
*/
async function initialize({ config, mysqlConnection, logger }) {
if (initialized) {
return { enabled: aiEnabled };
}
if (!config?.ai?.enabled) {
logger?.info('[AI] AI features disabled by configuration');
initialized = true;
aiEnabled = false;
return { enabled: false };
}
try {
// Initialize providers
providers.groq = createProvider('groq', config.ai);
providers.openai = createProvider('openai', config.ai);
if (config.ai.providers?.anthropic?.apiKey) {
providers.anthropic = createProvider('anthropic', config.ai);
}
// Initialize embeddings (requires OpenAI for embedding generation)
await initializeEmbeddings({
openaiProvider: providers.openai,
mysqlConnection,
logger
});
// Initialize work queue
workQueue = new AiWorkQueue(config.ai.maxConcurrentTasks || 5);
// Initialize task registry
registry = new AiTaskRegistry();
// Register Tier 2 tasks (real-time, Groq)
registry.register(createNameSuggestionTask({
provider: providers.groq,
logger,
config: config.ai
}));
registry.register(createCategorySuggestionTask({
provider: providers.groq,
logger,
config: config.ai
}));
registry.register(createThemeSuggestionTask({
provider: providers.groq,
logger,
config: config.ai
}));
registry.register(createColorSuggestionTask({
provider: providers.groq,
logger,
config: config.ai
}));
// Register Tier 3 tasks (batch, Anthropic/OpenAI)
const batchProvider = providers.anthropic || providers.openai;
registry.register(createDescriptionEnhanceTask({
provider: batchProvider,
logger,
config: config.ai
}));
registry.register(createConsistencyCheckTask({
provider: batchProvider,
logger,
config: config.ai
}));
initialized = true;
aiEnabled = true;
logger?.info('[AI] AI system initialized successfully');
return { enabled: true };
} catch (error) {
logger?.error('[AI] Failed to initialize AI system', { error: error.message });
initialized = true;
aiEnabled = false;
return { enabled: false, error: error.message };
}
}
/**
* Run a task by ID
*/
async function runTask(taskId, payload = {}) {
if (!aiEnabled) {
throw new Error('AI features are disabled');
}
const task = registry?.get(taskId);
if (!task) {
throw new Error(`Unknown task: ${taskId}`);
}
// Execute through work queue for concurrency control
return workQueue.enqueue(() => task.run(payload));
}
/**
* Normalize a product using Tier 1 (code-based) rules
*/
function normalize(product, fieldMappings) {
return normalizeProduct(product, fieldMappings);
}
/**
* Get system status
*/
function getStatus() {
return {
enabled: aiEnabled,
initialized,
tasks: registry?.list() || [],
queue: workQueue?.getStats() || null
};
}
module.exports = {
initialize,
runTask,
normalize,
getStatus,
TASK_IDS
};
```
### API Routes
```javascript
// routes/ai.js
const express = require('express');
const router = express.Router();
const ai = require('../services/ai');
const { TASK_IDS } = require('../services/ai/taskRegistry');
/**
* Get AI system status
*/
router.get('/status', (req, res) => {
res.json(ai.getStatus());
});
/**
* Normalize a product (Tier 1 - no AI)
*/
router.post('/normalize', (req, res) => {
try {
const { product, fieldMappings } = req.body;
const result = ai.normalize(product, fieldMappings);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* Normalize multiple products (Tier 1 - no AI)
*/
router.post('/normalize/batch', (req, res) => {
try {
const { products, fieldMappings } = req.body;
const results = products.map(product => ai.normalize(product, fieldMappings));
res.json({ results });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* Get name suggestion (Tier 2 - Groq)
*/
router.post('/suggest/name', async (req, res) => {
try {
const { product } = req.body;
const result = await ai.runTask(TASK_IDS.SUGGEST_NAME, { product });
res.json(result);
} catch (error) {
console.error('[AI] Name suggestion error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Get category suggestions (Tier 2 - Embeddings + Groq)
*/
router.post('/suggest/categories', async (req, res) => {
try {
const { product } = req.body;
const result = await ai.runTask(TASK_IDS.SUGGEST_CATEGORIES, { product });
res.json(result);
} catch (error) {
console.error('[AI] Category suggestion error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Get theme suggestions (Tier 2 - Embeddings + Groq)
*/
router.post('/suggest/themes', async (req, res) => {
try {
const { product } = req.body;
const result = await ai.runTask(TASK_IDS.SUGGEST_THEMES, { product });
res.json(result);
} catch (error) {
console.error('[AI] Theme suggestion error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Get color suggestions (Tier 2 - Groq)
*/
router.post('/suggest/colors', async (req, res) => {
try {
const { product } = req.body;
const result = await ai.runTask(TASK_IDS.SUGGEST_COLORS, { product });
res.json(result);
} catch (error) {
console.error('[AI] Color suggestion error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Enhance descriptions (Tier 3 - Batch)
*/
router.post('/enhance/descriptions', async (req, res) => {
try {
const { products, mode = 'enhance' } = req.body;
const result = await ai.runTask(TASK_IDS.ENHANCE_DESCRIPTIONS, { products, mode });
res.json(result);
} catch (error) {
console.error('[AI] Description enhancement error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Check consistency across products (Tier 3 - Batch)
*/
router.post('/check/consistency', async (req, res) => {
try {
const { products } = req.body;
const result = await ai.runTask(TASK_IDS.CHECK_CONSISTENCY, { products });
res.json(result);
} catch (error) {
console.error('[AI] Consistency check error:', error);
res.status(500).json({ error: error.message });
}
});
/**
* Legacy endpoint - full validation (redirects to new system)
* Kept for backwards compatibility during migration
*/
router.post('/validate', async (req, res) => {
// TODO: Implement migration path
// For now, combine Tier 1 normalization with Tier 3 batch processing
res.status(501).json({
error: 'Legacy validation endpoint deprecated. Use /normalize, /suggest/*, and /enhance/* endpoints.'
});
});
module.exports = router;
```
---
## Frontend Implementation
### Suggestion Hooks
#### Base Hook
```typescript
// hooks/ai/useAiSuggestion.ts
import { useState, useCallback } from 'react';
import { useDebouncedCallback } from 'use-debounce';
interface SuggestionResult<T> {
suggestion: T | null;
isLoading: boolean;
error: string | null;
latencyMs: number | null;
}
interface UseAiSuggestionOptions {
debounceMs?: number;
enabled?: boolean;
}
export function useAiSuggestion<T>(
endpoint: string,
options: UseAiSuggestionOptions = {}
) {
const { debounceMs = 300, enabled = true } = options;
const [result, setResult] = useState<SuggestionResult<T>>({
suggestion: null,
isLoading: false,
error: null,
latencyMs: null
});
const fetchSuggestion = useCallback(async (payload: unknown) => {
if (!enabled) return;
setResult(prev => ({ ...prev, isLoading: true, error: null }));
try {
const response = await fetch(`/api/ai${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
setResult({
suggestion: data.suggestion ?? data.suggestions ?? data,
isLoading: false,
error: null,
latencyMs: data.latencyMs ?? null
});
} catch (error) {
setResult(prev => ({
...prev,
isLoading: false,
error: error instanceof Error ? error.message : 'Unknown error'
}));
}
}, [endpoint, enabled]);
const debouncedFetch = useDebouncedCallback(fetchSuggestion, debounceMs);
const clear = useCallback(() => {
setResult({
suggestion: null,
isLoading: false,
error: null,
latencyMs: null
});
}, []);
return {
...result,
fetch: fetchSuggestion,
fetchDebounced: debouncedFetch,
clear
};
}
```
#### Name Suggestion Hook
```typescript
// hooks/ai/useNameSuggestion.ts
import { useAiSuggestion } from './useAiSuggestion';
import { ProductRow } from '@/types/import';
interface NameSuggestionResult {
suggestion: string | null;
original: string;
unchanged?: boolean;
}
export function useNameSuggestion() {
const {
suggestion,
isLoading,
error,
fetch,
fetchDebounced,
clear
} = useAiSuggestion<NameSuggestionResult>('/suggest/name');
const suggest = (product: Partial<ProductRow>) => {
if (!product.name && !product.description) return;
fetchDebounced({ product });
};
const suggestImmediate = (product: Partial<ProductRow>) => {
if (!product.name && !product.description) return;
fetch({ product });
};
return {
suggestion: suggestion?.suggestion ?? null,
original: suggestion?.original ?? null,
unchanged: suggestion?.unchanged ?? false,
isLoading,
error,
suggest,
suggestImmediate,
clear
};
}
```
#### Category Suggestion Hook
```typescript
// hooks/ai/useCategorySuggestion.ts
import { useAiSuggestion } from './useAiSuggestion';
import { ProductRow } from '@/types/import';
interface CategoryMatch {
id: number;
name: string;
fullPath: string;
similarity: number;
}
interface CategorySuggestionResult {
suggestions: CategoryMatch[];
allMatches: CategoryMatch[];
}
export function useCategorySuggestion() {
const {
suggestion,
isLoading,
error,
fetch,
clear
} = useAiSuggestion<CategorySuggestionResult>('/suggest/categories', {
debounceMs: 500 // Longer debounce for embedding lookup
});
const suggest = (product: Partial<ProductRow>) => {
const hasText = product.name || product.description;
if (!hasText) return;
fetch({ product });
};
return {
suggestions: suggestion?.suggestions ?? [],
allMatches: suggestion?.allMatches ?? [],
isLoading,
error,
suggest,
clear
};
}
```
#### Batch Enhancement Hook
```typescript
// hooks/ai/useDescriptionEnhancement.ts
import { useState, useCallback } from 'react';
import { ProductRow } from '@/types/import';
interface EnhancementResult {
productId: string | number;
original: string | null;
enhanced: string | null;
changed: boolean;
}
interface UseDescriptionEnhancementResult {
results: EnhancementResult[];
isProcessing: boolean;
progress: { current: number; total: number };
error: string | null;
enhance: (products: ProductRow[], mode?: 'enhance' | 'generate') => Promise<void>;
cancel: () => void;
}
export function useDescriptionEnhancement(): UseDescriptionEnhancementResult {
const [results, setResults] = useState<EnhancementResult[]>([]);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState({ current: 0, total: 0 });
const [error, setError] = useState<string | null>(null);
const [abortController, setAbortController] = useState<AbortController | null>(null);
const enhance = useCallback(async (
products: ProductRow[],
mode: 'enhance' | 'generate' = 'enhance'
) => {
const controller = new AbortController();
setAbortController(controller);
setIsProcessing(true);
setProgress({ current: 0, total: products.length });
setResults([]);
setError(null);
try {
const response = await fetch('/api/ai/enhance/descriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ products, mode }),
signal: controller.signal
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
setResults(data.results || []);
setProgress({ current: products.length, total: products.length });
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
// Cancelled by user
return;
}
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsProcessing(false);
setAbortController(null);
}
}, []);
const cancel = useCallback(() => {
abortController?.abort();
}, [abortController]);
return {
results,
isProcessing,
progress,
error,
enhance,
cancel
};
}
```
### UI Components
#### Suggestion Badge
```tsx
// components/ai/SuggestionBadge.tsx
import { Check, X, Sparkles } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface SuggestionBadgeProps {
suggestion: string;
onAccept: () => void;
onDismiss: () => void;
className?: string;
}
export function SuggestionBadge({
suggestion,
onAccept,
onDismiss,
className
}: SuggestionBadgeProps) {
return (
<div className={cn(
'flex items-center gap-2 p-2 mt-1 rounded-md',
'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800',
className
)}>
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
<span className="text-sm text-purple-700 dark:text-purple-300 flex-1 truncate">
{suggestion}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-green-600 hover:text-green-700 hover:bg-green-100"
onClick={onAccept}
>
<Check className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
onClick={onDismiss}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</div>
);
}
```
#### Category Suggestion Dropdown
```tsx
// components/ai/CategorySuggestionDropdown.tsx
import { useState, useEffect } from 'react';
import { Sparkles, ChevronDown } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useCategorySuggestion } from '@/hooks/ai/useCategorySuggestion';
import { ProductRow } from '@/types/import';
interface CategorySuggestionDropdownProps {
product: ProductRow;
currentCategories: number[];
onSelect: (categoryId: number) => void;
allCategories: Array<{ id: number; name: string; fullPath: string }>;
}
export function CategorySuggestionDropdown({
product,
currentCategories,
onSelect,
allCategories
}: CategorySuggestionDropdownProps) {
const { suggestions, allMatches, isLoading, suggest, clear } = useCategorySuggestion();
const [isOpen, setIsOpen] = useState(false);
// Fetch suggestions when dropdown opens
useEffect(() => {
if (isOpen && suggestions.length === 0) {
suggest(product);
}
}, [isOpen, product, suggest, suggestions.length]);
// Clear when dropdown closes
useEffect(() => {
if (!isOpen) {
clear();
}
}, [isOpen, clear]);
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<span>Categories</span>
{currentCategories.length > 0 && (
<Badge variant="secondary" className="ml-1">
{currentCategories.length}
</Badge>
)}
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-80">
{/* AI Suggestions Section */}
{(isLoading || suggestions.length > 0) && (
<>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600">
<Sparkles className="h-3 w-3" />
AI Suggested
{isLoading && <span className="text-gray-400">(loading...)</span>}
</div>
{suggestions.map(cat => (
<DropdownMenuItem
key={cat.id}
onClick={() => onSelect(cat.id)}
className="flex items-center justify-between"
>
<span className="truncate">{cat.fullPath}</span>
<Badge variant="outline" className="ml-2 text-xs">
{Math.round(cat.similarity * 100)}%
</Badge>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</>
)}
{/* All Categories Section */}
<div className="px-2 py-1.5 text-xs font-medium text-gray-500">
All Categories
</div>
<div className="max-h-60 overflow-y-auto">
{allCategories.slice(0, 50).map(cat => (
<DropdownMenuItem
key={cat.id}
onClick={() => onSelect(cat.id)}
disabled={currentCategories.includes(cat.id)}
>
<span className="truncate">{cat.fullPath}</span>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
```
#### AI Validation Cell
```tsx
// components/ai/AiValidationCell.tsx
import { useState, useEffect } from 'react';
import { Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { SuggestionBadge } from './SuggestionBadge';
import { useNameSuggestion } from '@/hooks/ai/useNameSuggestion';
import { ProductRow } from '@/types/import';
interface AiValidationCellProps {
field: 'name' | 'description';
value: string;
product: ProductRow;
onChange: (value: string) => void;
onBlur?: () => void;
}
export function AiValidationCell({
field,
value,
product,
onChange,
onBlur
}: AiValidationCellProps) {
const {
suggestion,
isLoading,
suggest,
clear
} = useNameSuggestion();
const [localValue, setLocalValue] = useState(value);
const [showSuggestion, setShowSuggestion] = useState(false);
// Sync external value changes
useEffect(() => {
setLocalValue(value);
}, [value]);
// Show suggestion when it arrives and differs from current value
useEffect(() => {
if (suggestion && suggestion !== localValue) {
setShowSuggestion(true);
}
}, [suggestion, localValue]);
const handleBlur = () => {
// Trigger AI suggestion on blur
if (field === 'name') {
suggest({ ...product, name: localValue });
}
onBlur?.();
};
const handleAccept = () => {
if (suggestion) {
setLocalValue(suggestion);
onChange(suggestion);
setShowSuggestion(false);
clear();
}
};
const handleDismiss = () => {
setShowSuggestion(false);
clear();
};
return (
<div className="relative">
<div className="relative">
<Input
value={localValue}
onChange={(e) => {
setLocalValue(e.target.value);
onChange(e.target.value);
}}
onBlur={handleBlur}
className="pr-8"
/>
{isLoading && (
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<Loader2 className="h-4 w-4 animate-spin text-purple-500" />
</div>
)}
</div>
{showSuggestion && suggestion && (
<SuggestionBadge
suggestion={suggestion}
onAccept={handleAccept}
onDismiss={handleDismiss}
/>
)}
</div>
);
}
```
#### Batch Enhancement Button
```tsx
// components/ai/EnhanceDescriptionsButton.tsx
import { useState } from 'react';
import { Sparkles, Loader2, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress';
import { useDescriptionEnhancement } from '@/hooks/ai/useDescriptionEnhancement';
import { useValidationStore } from '../store/validationStore';
export function EnhanceDescriptionsButton() {
const [showDialog, setShowDialog] = useState(false);
const rows = useValidationStore(state => state.rows);
const updateRow = useValidationStore(state => state.updateRow);
const {
results,
isProcessing,
progress,
error,
enhance,
cancel
} = useDescriptionEnhancement();
const handleEnhance = async () => {
setShowDialog(true);
await enhance(rows.map(r => r.data));
};
const handleApply = () => {
// Apply enhanced descriptions to store
for (const result of results) {
if (result.changed && result.enhanced) {
const rowIndex = rows.findIndex(
r => r.data._index === result.productId || r.data.upc === result.productId
);
if (rowIndex !== -1) {
updateRow(rowIndex, { description: result.enhanced });
}
}
}
setShowDialog(false);
};
const changedCount = results.filter(r => r.changed).length;
const progressPercent = progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0;
return (
<>
<Button
variant="outline"
onClick={handleEnhance}
disabled={isProcessing || rows.length === 0}
>
<Sparkles className="h-4 w-4 mr-2" />
Enhance Descriptions
</Button>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Enhance Descriptions</DialogTitle>
<DialogDescription>
AI will improve product descriptions for SEO and clarity.
</DialogDescription>
</DialogHeader>
<div className="py-4">
{isProcessing ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-purple-500" />
<span>Processing {progress.current} of {progress.total} products...</span>
</div>
<Progress value={progressPercent} className="h-2" />
</div>
) : error ? (
<div className="text-red-500">{error}</div>
) : results.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-5 w-5" />
<span>Enhanced {changedCount} descriptions</span>
</div>
<p className="text-sm text-gray-500">
{results.length - changedCount} descriptions were already good or unchanged.
</p>
</div>
) : null}
</div>
<DialogFooter>
{isProcessing ? (
<Button variant="outline" onClick={cancel}>
Cancel
</Button>
) : results.length > 0 ? (
<>
<Button variant="outline" onClick={() => setShowDialog(false)}>
Discard
</Button>
<Button onClick={handleApply} disabled={changedCount === 0}>
Apply {changedCount} Changes
</Button>
</>
) : null}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
```
### Integration with ValidationStep
```tsx
// Example integration in ValidationContainer.tsx
import { AiValidationCell } from '@/components/ai/AiValidationCell';
import { CategorySuggestionDropdown } from '@/components/ai/CategorySuggestionDropdown';
import { EnhanceDescriptionsButton } from '@/components/ai/EnhanceDescriptionsButton';
// In the toolbar
<div className="flex items-center gap-2">
<EnhanceDescriptionsButton />
{/* Other toolbar items */}
</div>
// In the data grid column definitions
const columns = [
{
key: 'name',
name: 'Name',
renderCell: ({ row, onRowChange }) => (
<AiValidationCell
field="name"
value={row.name}
product={row}
onChange={(value) => onRowChange({ ...row, name: value })}
/>
)
},
{
key: 'categories',
name: 'Categories',
renderCell: ({ row, onRowChange }) => (
<CategorySuggestionDropdown
product={row}
currentCategories={parseCategories(row.categories)}
onSelect={(catId) => {
const current = parseCategories(row.categories);
onRowChange({
...row,
categories: [...current, catId].join(',')
});
}}
allCategories={allCategories}
/>
)
}
];
```
---
## Database Schema
### New Tables
```sql
-- Embedding cache for faster startup
CREATE TABLE IF NOT EXISTS ai_embedding_cache (
id SERIAL PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL, -- 'category', 'theme', 'color'
entity_id INTEGER NOT NULL,
embedding_model VARCHAR(100) NOT NULL,
embedding VECTOR(1536), -- Using pgvector extension
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(entity_type, entity_id, embedding_model)
);
-- Index for fast lookups
CREATE INDEX idx_embedding_cache_lookup
ON ai_embedding_cache(entity_type, embedding_model);
-- AI suggestion history (for analytics and improvement)
CREATE TABLE IF NOT EXISTS ai_suggestion_log (
id SERIAL PRIMARY KEY,
task_id VARCHAR(100) NOT NULL,
product_identifier VARCHAR(255),
suggestion JSONB,
accepted BOOLEAN DEFAULT NULL,
latency_ms INTEGER,
token_usage JSONB,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for analytics queries
CREATE INDEX idx_suggestion_log_task
ON ai_suggestion_log(task_id, created_at);
```
### Configuration Table Updates
```sql
-- Add AI configuration to existing config or create new
CREATE TABLE IF NOT EXISTS ai_config (
id SERIAL PRIMARY KEY,
key VARCHAR(100) UNIQUE NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert default config
INSERT INTO ai_config (key, value) VALUES
('providers', '{
"groq": {
"enabled": true,
"model": "llama-3.3-70b-versatile"
},
"openai": {
"enabled": true,
"embeddingModel": "text-embedding-3-small"
},
"anthropic": {
"enabled": true,
"model": "claude-3-5-haiku-20241022"
}
}'),
('tasks', '{
"nameSuggestion": {
"model": "llama-3.3-70b-versatile",
"temperature": 0.2,
"maxTokens": 150
},
"categorySuggestion": {
"model": "llama-3.1-8b-instant",
"temperature": 0.1,
"maxTokens": 100
},
"descriptionEnhance": {
"model": "claude-3-5-haiku-20241022",
"temperature": 0.7,
"maxTokens": 2000,
"batchSize": 10
}
}');
```
---
## Migration Strategy
### Phase 1: Add New System Alongside Old (Week 1-2)
1. Implement `services/ai/` structure
2. Add new `/api/ai/*` routes
3. Keep old `/api/ai-validation/*` routes working
4. Add feature flag to enable new system per-user
### Phase 2: Frontend Integration (Week 2-3)
1. Add suggestion hooks
2. Integrate `AiValidationCell` for name field first
3. Add `CategorySuggestionDropdown`
4. Add `EnhanceDescriptionsButton` for batch descriptions
### Phase 3: Replace Batch Validation (Week 3-4)
1. Update "AI Validate" button to use new tiered system
2. Remove old giant-prompt approach
3. Keep old endpoint as deprecated fallback
4. Monitor costs and latency
### Phase 4: Cleanup (Week 4+)
1. Remove old `ai-validation.js` route
2. Remove old frontend components
3. Archive old prompts
4. Document new system
---
## Cost Analysis
### Current Costs (GPT-5.2 Reasoning)
| Metric | Value |
|--------|-------|
| Input tokens (est.) | ~25,000 |
| Output tokens (est.) | ~5,000 |
| Cost per run | $0.30-0.50 |
| Runs per day (est.) | 20 |
| **Monthly cost** | **~$200-300** |
### Projected Costs (New System)
| Tier | Model | Cost per call | Calls per product | Cost per product |
|------|-------|---------------|-------------------|------------------|
| Tier 1 | Code | $0 | N/A | $0 |
| Tier 2 | Groq Llama 3.3 70B | ~$0.0003 | 3-5 | ~$0.001 |
| Tier 2 | OpenAI Embeddings | ~$0.00002 | 1 | ~$0.00002 |
| Tier 3 | Claude Haiku | ~$0.001 | 0.1 (batch) | ~$0.0001 |
**Per 50 products:**
- Tier 1: $0
- Tier 2: ~$0.05 (if all fields suggested)
- Tier 3: ~$0.01 (description batch)
- **Total: ~$0.06**
**Monthly projection (20 runs/day × 50 products):**
- Current: $200-300
- New: ~$36
- **Savings: 80-90%**
---
## Next Steps
1. **Review this document** - Confirm approach aligns with expectations
2. **Set up Groq account** - Get API key for real-time inference
3. **Implement providers** - Start with Groq + OpenAI
4. **Build embedding system** - Pre-compute category embeddings
5. **Create first task** - Start with name suggestion
6. **Integrate frontend** - Add suggestion badge to name field
7. **Iterate** - Add more tasks based on feedback
---
## Appendix: Key Files Reference
### Email App AI System (Reference)
- `/Users/matt/Dev/email/email-server/services/ai/index.js` - Main entry
- `/Users/matt/Dev/email/email-server/services/ai/providers/groqProvider.js` - Groq implementation
- `/Users/matt/Dev/email/email-server/services/ai/taskRegistry.js` - Task system
- `/Users/matt/Dev/email/email-server/services/ai/workQueue.js` - Concurrency
### Current Inventory AI System
- `/inventory-server/src/routes/ai-validation.js` - Current monolithic implementation
- `/inventory/src/components/product-import/steps/ValidationStep/hooks/useAiValidation/` - Current frontend hooks