Add category suggestions to product editor, deal with taxonomy embeddings better, fix category badge overflow
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -84,4 +84,6 @@ chat-migration*/**
|
|||||||
venv/
|
venv/
|
||||||
venv/**
|
venv/**
|
||||||
**/venv/*
|
**/venv/*
|
||||||
**/venv/**
|
**/venv/**
|
||||||
|
|
||||||
|
inventory-server/data/taxonomy-embeddings.json
|
||||||
@@ -51,6 +51,10 @@ async function ensureInitialized() {
|
|||||||
...result.stats,
|
...result.stats,
|
||||||
groqEnabled: result.groqEnabled
|
groqEnabled: result.groqEnabled
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Watch for taxonomy changes in the background (checks every hour)
|
||||||
|
aiService.startBackgroundCheck(getDbConnection);
|
||||||
|
|
||||||
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);
|
||||||
@@ -431,4 +435,16 @@ router.post('/validate/sanity-check', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off AI initialization in the background (no-op if already initialized).
|
||||||
|
* Call once from server startup so the taxonomy embeddings are ready before
|
||||||
|
* the first user request hits a taxonomy dropdown.
|
||||||
|
*/
|
||||||
|
function initInBackground() {
|
||||||
|
ensureInitialized().catch(err =>
|
||||||
|
console.error('[AI Routes] Background initialization failed:', err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
module.exports.initInBackground = initInBackground;
|
||||||
|
|||||||
@@ -162,6 +162,8 @@ async function startServer() {
|
|||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
console.log(`[Server] Running in ${process.env.NODE_ENV || 'development'} mode on port ${PORT}`);
|
||||||
|
// Pre-warm AI service so taxonomy embeddings are ready before first user request
|
||||||
|
aiRouter.initInBackground();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start server:', error);
|
console.error('Failed to start server:', error);
|
||||||
|
|||||||
@@ -3,13 +3,26 @@
|
|||||||
*
|
*
|
||||||
* Generates and caches embeddings for categories, themes, and colors.
|
* Generates and caches embeddings for categories, themes, and colors.
|
||||||
* Excludes "Black Friday", "Gifts", "Deals" categories and their children.
|
* Excludes "Black Friday", "Gifts", "Deals" categories and their children.
|
||||||
|
*
|
||||||
|
* Disk cache: embeddings are saved to data/taxonomy-embeddings.json and reused
|
||||||
|
* across server restarts. Cache is invalidated by content hash — if the taxonomy
|
||||||
|
* rows in MySQL change, the next check will detect it and regenerate automatically.
|
||||||
|
*
|
||||||
|
* Background check: after initialization, call startBackgroundCheck(getConnectionFn)
|
||||||
|
* to poll for taxonomy changes on a configurable interval (default 1h).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { findTopMatches } = require('./similarity');
|
const { findTopMatches } = require('./similarity');
|
||||||
|
|
||||||
// Categories to exclude (and all their children)
|
// Categories to exclude (and all their children)
|
||||||
const EXCLUDED_CATEGORY_NAMES = ['black friday', 'gifts', 'deals'];
|
const EXCLUDED_CATEGORY_NAMES = ['black friday', 'gifts', 'deals'];
|
||||||
|
|
||||||
|
// Disk cache config
|
||||||
|
const CACHE_PATH = path.join(__dirname, '..', '..', '..', '..', 'data', 'taxonomy-embeddings.json');
|
||||||
|
|
||||||
class TaxonomyEmbeddings {
|
class TaxonomyEmbeddings {
|
||||||
constructor({ provider, logger }) {
|
constructor({ provider, logger }) {
|
||||||
this.provider = provider;
|
this.provider = provider;
|
||||||
@@ -25,12 +38,18 @@ class TaxonomyEmbeddings {
|
|||||||
this.themeMap = new Map();
|
this.themeMap = new Map();
|
||||||
this.colorMap = new Map();
|
this.colorMap = new Map();
|
||||||
|
|
||||||
|
// Content hash of the last successfully built taxonomy (from DB rows)
|
||||||
|
this.contentHash = null;
|
||||||
|
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
this.initializing = false;
|
this.initializing = false;
|
||||||
|
this._checkInterval = null;
|
||||||
|
this._regenerating = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize embeddings - fetch taxonomy and generate embeddings
|
* Initialize embeddings — fetches raw taxonomy rows to compute a content hash,
|
||||||
|
* then either loads the matching disk cache or generates fresh embeddings.
|
||||||
*/
|
*/
|
||||||
async initialize(connection) {
|
async initialize(connection) {
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
@@ -48,42 +67,36 @@ class TaxonomyEmbeddings {
|
|||||||
this.initializing = true;
|
this.initializing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.logger.info('[TaxonomyEmbeddings] Starting initialization...');
|
// Always fetch raw rows first — cheap (~10ms), no OpenAI calls.
|
||||||
|
// Used to compute a content hash for cache validation.
|
||||||
|
const rawRows = await this._fetchRawRows(connection);
|
||||||
|
const freshHash = this._computeContentHash(rawRows);
|
||||||
|
|
||||||
// Fetch raw taxonomy data
|
const cached = this._loadCache();
|
||||||
const [categories, themes, colors] = await Promise.all([
|
if (cached && cached.contentHash === freshHash) {
|
||||||
this._fetchCategories(connection),
|
this.categories = cached.categories;
|
||||||
this._fetchThemes(connection),
|
this.themes = cached.themes;
|
||||||
this._fetchColors(connection)
|
this.colors = cached.colors;
|
||||||
]);
|
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||||
|
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||||
|
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||||
|
this.contentHash = freshHash;
|
||||||
|
this.initialized = true;
|
||||||
|
this.logger.info(`[TaxonomyEmbeddings] Loaded from cache: ${this.categories.length} categories, ${this.themes.length} themes, ${this.colors.length} colors`);
|
||||||
|
return { categories: this.categories.length, themes: this.themes.length, colors: this.colors.length };
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.info(`[TaxonomyEmbeddings] Fetched ${categories.length} categories, ${themes.length} themes, ${colors.length} colors`);
|
if (cached) {
|
||||||
|
this.logger.info('[TaxonomyEmbeddings] Taxonomy changed since cache was built, regenerating...');
|
||||||
// Generate embeddings in parallel
|
} else {
|
||||||
const [catEmbeddings, themeEmbeddings, colorEmbeddings] = await Promise.all([
|
this.logger.info('[TaxonomyEmbeddings] No cache — fetching taxonomy and generating embeddings...');
|
||||||
this._generateEmbeddings(categories, 'categories'),
|
}
|
||||||
this._generateEmbeddings(themes, 'themes'),
|
|
||||||
this._generateEmbeddings(colors, 'colors')
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Store with embeddings
|
|
||||||
this.categories = catEmbeddings;
|
|
||||||
this.themes = themeEmbeddings;
|
|
||||||
this.colors = colorEmbeddings;
|
|
||||||
|
|
||||||
// Build lookup maps
|
|
||||||
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
|
||||||
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
|
||||||
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
|
||||||
|
|
||||||
|
await this._buildAndEmbed(rawRows, freshHash);
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
this.logger.info('[TaxonomyEmbeddings] Initialization complete');
|
this.logger.info('[TaxonomyEmbeddings] Initialization complete');
|
||||||
|
|
||||||
return {
|
return { categories: this.categories.length, themes: this.themes.length, colors: this.colors.length };
|
||||||
categories: this.categories.length,
|
|
||||||
themes: this.themes.length,
|
|
||||||
colors: this.colors.length
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error('[TaxonomyEmbeddings] Initialization failed:', error);
|
this.logger.error('[TaxonomyEmbeddings] Initialization failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -92,6 +105,47 @@ class TaxonomyEmbeddings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a background interval that checks for taxonomy changes and regenerates
|
||||||
|
* embeddings automatically if the content hash differs.
|
||||||
|
*
|
||||||
|
* @param {Function} getConnectionFn - async function returning { connection }
|
||||||
|
* @param {number} intervalMs - check interval, default 1 hour
|
||||||
|
*/
|
||||||
|
startBackgroundCheck(getConnectionFn, intervalMs = 60 * 60 * 1000) {
|
||||||
|
if (this._checkInterval) return;
|
||||||
|
|
||||||
|
this.logger.info(`[TaxonomyEmbeddings] Background taxonomy check started (every ${intervalMs / 60000} min)`);
|
||||||
|
|
||||||
|
this._checkInterval = setInterval(async () => {
|
||||||
|
if (this._regenerating) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connection } = await getConnectionFn();
|
||||||
|
const rawRows = await this._fetchRawRows(connection);
|
||||||
|
const freshHash = this._computeContentHash(rawRows);
|
||||||
|
|
||||||
|
if (freshHash === this.contentHash) return;
|
||||||
|
|
||||||
|
this.logger.info('[TaxonomyEmbeddings] Taxonomy changed, regenerating embeddings in background...');
|
||||||
|
this._regenerating = true;
|
||||||
|
await this._buildAndEmbed(rawRows, freshHash);
|
||||||
|
this.logger.info('[TaxonomyEmbeddings] Background regeneration complete');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('[TaxonomyEmbeddings] Background taxonomy check failed:', err.message);
|
||||||
|
} finally {
|
||||||
|
this._regenerating = false;
|
||||||
|
}
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopBackgroundCheck() {
|
||||||
|
if (this._checkInterval) {
|
||||||
|
clearInterval(this._checkInterval);
|
||||||
|
this._checkInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find similar categories for a product embedding
|
* Find similar categories for a product embedding
|
||||||
*/
|
*/
|
||||||
@@ -176,29 +230,74 @@ class TaxonomyEmbeddings {
|
|||||||
// Private Methods
|
// Private Methods
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async _fetchCategories(connection) {
|
/**
|
||||||
// Fetch hierarchical categories (types 10-13)
|
* Fetch minimal raw rows from MySQL — used for content hash computation.
|
||||||
const [rows] = await connection.query(`
|
* This is the cheap path: no path-building, no embeddings, just the raw data.
|
||||||
SELECT cat_id, name, master_cat_id, type
|
*/
|
||||||
FROM product_categories
|
async _fetchRawRows(connection) {
|
||||||
WHERE type IN (10, 11, 12, 13)
|
const [[catRows], [themeRows], [colorRows]] = await Promise.all([
|
||||||
ORDER BY type, name
|
connection.query('SELECT cat_id, name, master_cat_id, type FROM product_categories WHERE type IN (10, 11, 12, 13) ORDER BY cat_id'),
|
||||||
`);
|
connection.query('SELECT cat_id, name, master_cat_id, type FROM product_categories WHERE type IN (20, 21) ORDER BY cat_id'),
|
||||||
|
connection.query('SELECT color, name, hex_color FROM product_color_list ORDER BY `order`')
|
||||||
|
]);
|
||||||
|
return { catRows, themeRows, colorRows };
|
||||||
|
}
|
||||||
|
|
||||||
// Build lookup for hierarchy
|
/**
|
||||||
|
* Compute a stable SHA-256 hash of the taxonomy row content.
|
||||||
|
* Any change to IDs, names, or parent relationships will produce a different hash.
|
||||||
|
*/
|
||||||
|
_computeContentHash({ catRows, themeRows, colorRows }) {
|
||||||
|
const content = JSON.stringify({
|
||||||
|
cats: catRows.map(r => [r.cat_id, r.name, r.master_cat_id]).sort((a, b) => a[0] - b[0]),
|
||||||
|
themes: themeRows.map(r => [r.cat_id, r.name, r.master_cat_id]).sort((a, b) => a[0] - b[0]),
|
||||||
|
colors: colorRows.map(r => [r.color, r.name]).sort()
|
||||||
|
});
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full taxonomy objects and generate embeddings, then atomically swap
|
||||||
|
* the in-memory state. Called on cache miss and on background change detection.
|
||||||
|
*/
|
||||||
|
async _buildAndEmbed(rawRows, contentHash) {
|
||||||
|
const { catRows, themeRows, colorRows } = rawRows;
|
||||||
|
|
||||||
|
const categories = this._buildCategories(catRows);
|
||||||
|
const themes = this._buildThemes(themeRows);
|
||||||
|
const colors = this._buildColors(colorRows);
|
||||||
|
|
||||||
|
this.logger.info(`[TaxonomyEmbeddings] Generating embeddings for ${categories.length} categories, ${themes.length} themes, ${colors.length} colors`);
|
||||||
|
|
||||||
|
const [catEmbeddings, themeEmbeddings, colorEmbeddings] = await Promise.all([
|
||||||
|
this._generateEmbeddings(categories, 'categories'),
|
||||||
|
this._generateEmbeddings(themes, 'themes'),
|
||||||
|
this._generateEmbeddings(colors, 'colors')
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Atomic in-memory swap (single-threaded JS — readers always see a consistent state)
|
||||||
|
this.categories = catEmbeddings;
|
||||||
|
this.themes = themeEmbeddings;
|
||||||
|
this.colors = colorEmbeddings;
|
||||||
|
this.categoryMap = new Map(this.categories.map(c => [c.id, c]));
|
||||||
|
this.themeMap = new Map(this.themes.map(t => [t.id, t]));
|
||||||
|
this.colorMap = new Map(this.colors.map(c => [c.id, c]));
|
||||||
|
this.contentHash = contentHash;
|
||||||
|
|
||||||
|
this._saveCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
_buildCategories(rows) {
|
||||||
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||||
|
|
||||||
// Find IDs of excluded top-level categories and all their descendants
|
|
||||||
const excludedIds = new Set();
|
const excludedIds = new Set();
|
||||||
|
|
||||||
// First pass: find excluded top-level categories
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (row.type === 10 && EXCLUDED_CATEGORY_NAMES.includes(row.name.toLowerCase())) {
|
if (row.type === 10 && EXCLUDED_CATEGORY_NAMES.includes(row.name.toLowerCase())) {
|
||||||
excludedIds.add(row.cat_id);
|
excludedIds.add(row.cat_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multiple passes to find all descendants
|
// Multiple passes to find all descendants of excluded categories
|
||||||
let foundNew = true;
|
let foundNew = true;
|
||||||
while (foundNew) {
|
while (foundNew) {
|
||||||
foundNew = false;
|
foundNew = false;
|
||||||
@@ -212,20 +311,14 @@ class TaxonomyEmbeddings {
|
|||||||
|
|
||||||
this.logger.info(`[TaxonomyEmbeddings] Excluding ${excludedIds.size} categories (Black Friday, Gifts, Deals and children)`);
|
this.logger.info(`[TaxonomyEmbeddings] Excluding ${excludedIds.size} categories (Black Friday, Gifts, Deals and children)`);
|
||||||
|
|
||||||
// Build category objects with full paths, excluding filtered ones
|
|
||||||
const categories = [];
|
const categories = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (excludedIds.has(row.cat_id)) {
|
if (excludedIds.has(row.cat_id)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = [];
|
const pathParts = [];
|
||||||
let current = row;
|
let current = row;
|
||||||
|
|
||||||
// Walk up the tree to build full path
|
|
||||||
while (current) {
|
while (current) {
|
||||||
path.unshift(current.name);
|
pathParts.unshift(current.name);
|
||||||
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,55 +327,37 @@ class TaxonomyEmbeddings {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
parentId: row.master_cat_id,
|
parentId: row.master_cat_id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
fullPath: path.join(' > '),
|
fullPath: pathParts.join(' > '),
|
||||||
embeddingText: path.join(' ')
|
embeddingText: pathParts.join(' ')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _fetchThemes(connection) {
|
_buildThemes(rows) {
|
||||||
// Fetch themes (types 20-21)
|
|
||||||
const [rows] = await connection.query(`
|
|
||||||
SELECT cat_id, name, master_cat_id, type
|
|
||||||
FROM product_categories
|
|
||||||
WHERE type IN (20, 21)
|
|
||||||
ORDER BY type, name
|
|
||||||
`);
|
|
||||||
|
|
||||||
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
const byId = new Map(rows.map(r => [r.cat_id, r]));
|
||||||
const themes = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
return rows.map(row => {
|
||||||
const path = [];
|
const pathParts = [];
|
||||||
let current = row;
|
let current = row;
|
||||||
|
|
||||||
while (current) {
|
while (current) {
|
||||||
path.unshift(current.name);
|
pathParts.unshift(current.name);
|
||||||
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
current = current.master_cat_id ? byId.get(current.master_cat_id) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
themes.push({
|
return {
|
||||||
id: row.cat_id,
|
id: row.cat_id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
parentId: row.master_cat_id,
|
parentId: row.master_cat_id,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
fullPath: path.join(' > '),
|
fullPath: pathParts.join(' > '),
|
||||||
embeddingText: path.join(' ')
|
embeddingText: pathParts.join(' ')
|
||||||
});
|
};
|
||||||
}
|
});
|
||||||
|
|
||||||
return themes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _fetchColors(connection) {
|
_buildColors(rows) {
|
||||||
const [rows] = await connection.query(`
|
|
||||||
SELECT color, name, hex_color
|
|
||||||
FROM product_color_list
|
|
||||||
ORDER BY \`order\`
|
|
||||||
`);
|
|
||||||
|
|
||||||
return rows.map(row => ({
|
return rows.map(row => ({
|
||||||
id: row.color,
|
id: row.color,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -301,9 +376,7 @@ class TaxonomyEmbeddings {
|
|||||||
const results = [...items];
|
const results = [...items];
|
||||||
|
|
||||||
// Process in batches
|
// Process in batches
|
||||||
let batchNum = 0;
|
|
||||||
for await (const chunk of this.provider.embedBatchChunked(texts, { batchSize: 100 })) {
|
for await (const chunk of this.provider.embedBatchChunked(texts, { batchSize: 100 })) {
|
||||||
batchNum++;
|
|
||||||
for (let i = 0; i < chunk.embeddings.length; i++) {
|
for (let i = 0; i < chunk.embeddings.length; i++) {
|
||||||
const globalIndex = chunk.startIndex + i;
|
const globalIndex = chunk.startIndex + i;
|
||||||
results[globalIndex] = {
|
results[globalIndex] = {
|
||||||
@@ -318,6 +391,43 @@ class TaxonomyEmbeddings {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Disk Cache Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
_loadCache() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(CACHE_PATH)) return null;
|
||||||
|
|
||||||
|
const data = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8'));
|
||||||
|
if (!data.contentHash || !data.categories?.length || !data.themes?.length || !data.colors?.length) {
|
||||||
|
this.logger.warn('[TaxonomyEmbeddings] Disk cache malformed or missing content hash, will regenerate');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('[TaxonomyEmbeddings] Failed to load disk cache:', err.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_saveCache() {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true });
|
||||||
|
fs.writeFileSync(CACHE_PATH, JSON.stringify({
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
contentHash: this.contentHash,
|
||||||
|
categories: this.categories,
|
||||||
|
themes: this.themes,
|
||||||
|
colors: this.colors,
|
||||||
|
}));
|
||||||
|
this.logger.info(`[TaxonomyEmbeddings] Disk cache saved to ${CACHE_PATH}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn('[TaxonomyEmbeddings] Failed to save disk cache:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { TaxonomyEmbeddings };
|
module.exports = { TaxonomyEmbeddings };
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ function isReady() {
|
|||||||
return initialized && taxonomyEmbeddings?.isReady();
|
return initialized && taxonomyEmbeddings?.isReady();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start background taxonomy change detection.
|
||||||
|
* Call once after initialization, passing a function that returns { connection }.
|
||||||
|
* @param {Function} getConnectionFn
|
||||||
|
* @param {number} [intervalMs] - default 1 hour
|
||||||
|
*/
|
||||||
|
function startBackgroundCheck(getConnectionFn, intervalMs) {
|
||||||
|
if (!initialized || !taxonomyEmbeddings) return;
|
||||||
|
taxonomyEmbeddings.startBackgroundCheck(getConnectionFn, intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build weighted product text for embedding.
|
* Build weighted product text for embedding.
|
||||||
* Weights the product name heavily by repeating it, and truncates long descriptions
|
* Weights the product name heavily by repeating it, and truncates long descriptions
|
||||||
@@ -362,6 +373,7 @@ module.exports = {
|
|||||||
initialize,
|
initialize,
|
||||||
isReady,
|
isReady,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
startBackgroundCheck,
|
||||||
|
|
||||||
// Embeddings (OpenAI)
|
// Embeddings (OpenAI)
|
||||||
getProductEmbedding,
|
getProductEmbedding,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useMemo, useCallback } from "react";
|
import { useState, useMemo, useCallback, useLayoutEffect, useRef } from "react";
|
||||||
import { Check, ChevronsUpDown } from "lucide-react";
|
import { Check, ChevronsUpDown, Sparkles, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { badgeVariants } from "@/components/ui/badge";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { FieldOption } from "./types";
|
import type { FieldOption } from "./types";
|
||||||
|
import type { TaxonomySuggestion } from "@/components/product-import/steps/ValidationStep/store/types";
|
||||||
|
|
||||||
interface ColorOption extends FieldOption {
|
interface ColorOption extends FieldOption {
|
||||||
hex?: string;
|
hex?: string;
|
||||||
@@ -34,6 +36,39 @@ function isWhite(hex: string) {
|
|||||||
return /^#?f{3,6}$/i.test(hex);
|
return /^#?f{3,6}$/i.test(hex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TruncatedBadge({ label, hex }: { label: string; hex?: string }) {
|
||||||
|
const textRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [isTruncated, setIsTruncated] = useState(false);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = textRef.current;
|
||||||
|
if (el) setIsTruncated(el.scrollWidth > el.clientWidth);
|
||||||
|
}, [label]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip open={isTruncated ? undefined : false}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className={cn(badgeVariants({ variant: "secondary" }), "text-[11px] py-0 px-1.5 gap-1 font-normal max-w-full")}>
|
||||||
|
{hex && (
|
||||||
|
<span
|
||||||
|
className={cn("inline-block h-2.5 w-2.5 rounded-full shrink-0", isWhite(hex) && "border border-black")}
|
||||||
|
style={{ backgroundColor: hex }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
ref={textRef}
|
||||||
|
className="overflow-hidden whitespace-nowrap"
|
||||||
|
style={{ direction: "rtl", textOverflow: "ellipsis" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EditableMultiSelect({
|
export function EditableMultiSelect({
|
||||||
options,
|
options,
|
||||||
value,
|
value,
|
||||||
@@ -42,6 +77,9 @@ export function EditableMultiSelect({
|
|||||||
placeholder,
|
placeholder,
|
||||||
searchPlaceholder,
|
searchPlaceholder,
|
||||||
showColors,
|
showColors,
|
||||||
|
suggestions,
|
||||||
|
isLoadingSuggestions,
|
||||||
|
onOpen,
|
||||||
}: {
|
}: {
|
||||||
options: FieldOption[];
|
options: FieldOption[];
|
||||||
value: string[];
|
value: string[];
|
||||||
@@ -50,9 +88,17 @@ export function EditableMultiSelect({
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
showColors?: boolean;
|
showColors?: boolean;
|
||||||
|
suggestions?: TaxonomySuggestion[];
|
||||||
|
isLoadingSuggestions?: boolean;
|
||||||
|
onOpen?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback((isOpen: boolean) => {
|
||||||
|
setOpen(isOpen);
|
||||||
|
if (isOpen) onOpen?.();
|
||||||
|
}, [onOpen]);
|
||||||
|
|
||||||
const selectedLabels = useMemo(() => {
|
const selectedLabels = useMemo(() => {
|
||||||
return value.map((v) => {
|
return value.map((v) => {
|
||||||
const opt = options.find((o) => String(o.value) === String(v));
|
const opt = options.find((o) => String(o.value) === String(v));
|
||||||
@@ -82,7 +128,7 @@ export function EditableMultiSelect({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => handleOpenChange(true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
"flex flex-col h-auto w-full rounded-md border border-muted-foreground/50 bg-transparent px-3 py-1 text-sm text-left",
|
||||||
"hover:border-input hover:bg-muted/50 transition-colors",
|
"hover:border-input hover:bg-muted/50 transition-colors",
|
||||||
@@ -98,22 +144,7 @@ export function EditableMultiSelect({
|
|||||||
) : (
|
) : (
|
||||||
<span className="flex flex-wrap gap-1 w-full">
|
<span className="flex flex-wrap gap-1 w-full">
|
||||||
{selectedLabels.map((s) => (
|
{selectedLabels.map((s) => (
|
||||||
<Badge
|
<TruncatedBadge key={s.value} label={s.label} hex={s.hex} />
|
||||||
key={s.value}
|
|
||||||
variant="secondary"
|
|
||||||
className="text-[11px] py-0 px-1.5 gap-1 shrink-0 font-normal"
|
|
||||||
>
|
|
||||||
{s.hex && (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"inline-block h-2.5 w-2.5 rounded-full shrink-0",
|
|
||||||
isWhite(s.hex) && "border border-black"
|
|
||||||
)}
|
|
||||||
style={{ backgroundColor: s.hex }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{s.label}
|
|
||||||
</Badge>
|
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -126,7 +157,7 @@ export function EditableMultiSelect({
|
|||||||
{label && (
|
{label && (
|
||||||
<span className="text-xs text-muted-foreground mb-1 block">{label}</span>
|
<span className="text-xs text-muted-foreground mb-1 block">{label}</span>
|
||||||
)}
|
)}
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -192,9 +223,54 @@ export function EditableMultiSelect({
|
|||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* AI Suggestions section */}
|
||||||
|
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
|
||||||
|
<CommandGroup>
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
<span>Suggested</span>
|
||||||
|
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
|
</div>
|
||||||
|
{suggestions?.slice(0, 5).map((suggestion) => {
|
||||||
|
const isSelected = value.includes(String(suggestion.id));
|
||||||
|
if (isSelected) return null;
|
||||||
|
const similarityPercent = Math.round(suggestion.similarity * 100);
|
||||||
|
const opt = options.find((o) => String(o.value) === String(suggestion.id)) as ColorOption | undefined;
|
||||||
|
const hex = showColors && opt ? getHex(opt) : undefined;
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={`suggestion-${suggestion.id}`}
|
||||||
|
value={`suggestion-${suggestion.name}`}
|
||||||
|
onSelect={() => handleSelect(String(suggestion.id))}
|
||||||
|
className="bg-purple-50/30 dark:bg-purple-950/20"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
||||||
|
{hex && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-block h-3.5 w-3.5 rounded-full mr-2 shrink-0",
|
||||||
|
isWhite(hex) && "border border-black"
|
||||||
|
)}
|
||||||
|
style={{ backgroundColor: hex }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span title={suggestion.fullPath || suggestion.name}>
|
||||||
|
{showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0">
|
||||||
|
{similarityPercent}%
|
||||||
|
</span>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* All options (excluding already-selected) */}
|
{/* All options (excluding already-selected) */}
|
||||||
<CommandGroup
|
<CommandGroup
|
||||||
heading={value.length > 0 ? "All Options" : undefined}
|
heading={value.length > 0 || (suggestions && suggestions.length > 0) ? "All Options" : undefined}
|
||||||
>
|
>
|
||||||
{options
|
{options
|
||||||
.filter((o) => !value.includes(String(o.value)))
|
.filter((o) => !value.includes(String(o.value)))
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { submitProductEdit, submitImageChanges, submitTaxonomySet, type ImageCha
|
|||||||
import { EditableComboboxField } from "./EditableComboboxField";
|
import { EditableComboboxField } from "./EditableComboboxField";
|
||||||
import { EditableInput } from "./EditableInput";
|
import { EditableInput } from "./EditableInput";
|
||||||
import { EditableMultiSelect } from "./EditableMultiSelect";
|
import { EditableMultiSelect } from "./EditableMultiSelect";
|
||||||
|
import { useProductSuggestions } from "./useProductSuggestions";
|
||||||
import { ImageManager, MiniImagePreview } from "./ImageManager";
|
import { ImageManager, MiniImagePreview } from "./ImageManager";
|
||||||
import type {
|
import type {
|
||||||
SearchProduct,
|
SearchProduct,
|
||||||
@@ -503,6 +504,20 @@ export function ProductEditForm({
|
|||||||
}
|
}
|
||||||
}, [getValues, fieldOptions, validateDescription, clearDescriptionResult]);
|
}, [getValues, fieldOptions, validateDescription, clearDescriptionResult]);
|
||||||
|
|
||||||
|
// --- Embedding-based taxonomy suggestions ---
|
||||||
|
const {
|
||||||
|
categories: categorySuggestions,
|
||||||
|
themes: themeSuggestions,
|
||||||
|
colors: colorSuggestions,
|
||||||
|
isLoading: isSuggestionsLoading,
|
||||||
|
triggerFetch: triggerSuggestions,
|
||||||
|
} = useProductSuggestions({
|
||||||
|
name: product.title,
|
||||||
|
description: product.description,
|
||||||
|
company_name: product.brand,
|
||||||
|
line_name: product.line,
|
||||||
|
});
|
||||||
|
|
||||||
const hasImageChanges = computeImageChanges() !== null;
|
const hasImageChanges = computeImageChanges() !== null;
|
||||||
const changedCount = Object.keys(dirtyFields).length;
|
const changedCount = Object.keys(dirtyFields).length;
|
||||||
|
|
||||||
@@ -560,6 +575,11 @@ export function ProductEditForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (fc.type === "multiselect") {
|
if (fc.type === "multiselect") {
|
||||||
|
const fieldSuggestions =
|
||||||
|
fc.key === "categories" ? categorySuggestions :
|
||||||
|
fc.key === "themes" ? themeSuggestions :
|
||||||
|
fc.key === "colors" ? colorSuggestions :
|
||||||
|
undefined;
|
||||||
return wrapSpan(
|
return wrapSpan(
|
||||||
<Controller
|
<Controller
|
||||||
key={fc.key}
|
key={fc.key}
|
||||||
@@ -574,6 +594,9 @@ export function ProductEditForm({
|
|||||||
placeholder="—"
|
placeholder="—"
|
||||||
searchPlaceholder={fc.searchPlaceholder}
|
searchPlaceholder={fc.searchPlaceholder}
|
||||||
showColors={fc.showColors}
|
showColors={fc.showColors}
|
||||||
|
suggestions={fieldSuggestions}
|
||||||
|
isLoadingSuggestions={isSuggestionsLoading}
|
||||||
|
onOpen={triggerSuggestions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
107
inventory/src/components/product-editor/useProductSuggestions.ts
Normal file
107
inventory/src/components/product-editor/useProductSuggestions.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* useProductSuggestions Hook
|
||||||
|
*
|
||||||
|
* Lazily fetches embedding-based taxonomy suggestions (categories, themes, colors)
|
||||||
|
* for a product in the product editor.
|
||||||
|
*
|
||||||
|
* Mirrors the logic in AiSuggestionsContext but simplified for single-product use:
|
||||||
|
* - Fetches once on first triggerFetch() call (no eager batch loading)
|
||||||
|
* - Caches results in local state for the lifetime of the component
|
||||||
|
* - Module-level init promise shared across all instances
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
|
import type { TaxonomySuggestion, ProductSuggestions } from '@/components/product-import/steps/ValidationStep/store/types';
|
||||||
|
|
||||||
|
const API_BASE = '/api/ai';
|
||||||
|
|
||||||
|
// Module-level init promise — shared so we only call /initialize once
|
||||||
|
let initPromise: Promise<boolean> | null = null;
|
||||||
|
|
||||||
|
async function ensureInitialized(): Promise<boolean> {
|
||||||
|
if (!initPromise) {
|
||||||
|
initPromise = fetch(`${API_BASE}/initialize`, { method: 'POST' })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => Boolean(d.success))
|
||||||
|
.catch(() => {
|
||||||
|
initPromise = null; // allow retry on next call
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return initPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductInput {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
company_name?: string;
|
||||||
|
line_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductSuggestionResults {
|
||||||
|
categories: TaxonomySuggestion[];
|
||||||
|
themes: TaxonomySuggestion[];
|
||||||
|
colors: TaxonomySuggestion[];
|
||||||
|
isLoading: boolean;
|
||||||
|
/** Call when a taxonomy dropdown opens to trigger a lazy fetch */
|
||||||
|
triggerFetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProductSuggestions(product: ProductInput): ProductSuggestionResults {
|
||||||
|
const [categories, setCategories] = useState<TaxonomySuggestion[]>([]);
|
||||||
|
const [themes, setThemes] = useState<TaxonomySuggestion[]>([]);
|
||||||
|
const [colors, setColors] = useState<TaxonomySuggestion[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Store current product in a ref so triggerFetch can read it without being re-created
|
||||||
|
const productRef = useRef(product);
|
||||||
|
productRef.current = product;
|
||||||
|
|
||||||
|
// Pre-warm: start initialization as soon as the form mounts so it's ready before
|
||||||
|
// the first dropdown opens. With the disk cache this completes in < 1 second.
|
||||||
|
useEffect(() => {
|
||||||
|
ensureInitialized();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Prevent duplicate fetches
|
||||||
|
const hasFetchedRef = useRef(false);
|
||||||
|
|
||||||
|
const triggerFetch = useCallback(async () => {
|
||||||
|
if (hasFetchedRef.current) return;
|
||||||
|
const p = productRef.current;
|
||||||
|
if (!p.name && !p.company_name) return;
|
||||||
|
|
||||||
|
hasFetchedRef.current = true;
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ready = await ensureInitialized();
|
||||||
|
if (!ready) {
|
||||||
|
hasFetchedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/suggestions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product: p }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
hasFetchedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ProductSuggestions = await response.json();
|
||||||
|
setCategories(data.categories ?? []);
|
||||||
|
setThemes(data.themes ?? []);
|
||||||
|
setColors(data.colors ?? []);
|
||||||
|
} catch {
|
||||||
|
hasFetchedRef.current = false;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { categories, themes, colors, isLoading, triggerFetch };
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user