← back to Sanderson Onboard
scripts/build_manifest.mjs
139 lines
// build_manifest.mjs — SDG go-live: enrich → assign dw_sku → 5-field/6-gate → settlement-scope tag → emit manifest.
// $0: descriptions/tags via LOCAL Ollama (mac1 qwen2.5:7b). NEVER fabricates price/color/dimension.
// Reads the 4 *_feed_pricing tables; writes pilot/manifest-<brand>.json + a HELD report.
// Idempotent dw_sku assignment: numbering continues from the max already on Shopify per prefix.
//
// Gate fields (must all pass to be BUILD-eligible; else HELD with reason):
// 1 sellable variant (implicit) 2 sample variant (implicit) 3 complete price (retail_usd>0)
// 4 description (enriched) 5 >=2 tags 6 featured image (feed image -> absolute URL)
// color falls back to pattern label when blank (Morris); width/length omitted-not-fabricated when absent.
import fs from 'node:fs';
import { execSync } from 'node:child_process';
const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
const MODEL = 'qwen2.5:7b';
const IMG_BASE = 'https://www.sanderson.design/static/media/catalog';
const PILOT = new URL('../pilot/', import.meta.url).pathname;
// brand -> { table, vendor (customer-facing), prefix (registered), startNum }
const BRANDS = {
sanderson: { table: 'sanderson_feed_pricing', vendor: 'Sanderson', prefix: 'DWXH', start: 600000 },
harlequin: { table: 'harlequin_feed_pricing', vendor: 'Harlequin', prefix: 'DWHF', start: 70608 },
zoffany: { table: 'zoffany_feed_pricing', vendor: 'Zoffany', prefix: 'DWZF', start: 600000 },
morris: { table: 'morris_feed_pricing', vendor: 'Morris & Co.', prefix: 'DWWM', start: 600000 },
};
function psql(sql) {
return execSync(`psql -h /tmp -d dw_unified -tA -F $'\\t' -c "${sql.replace(/"/g, '\\"')}"`, { encoding: 'utf8', maxBuffer: 1 << 28 });
}
// absolute image URL from feed relative path (or empty)
const absImg = (rel) => {
const s = String(rel || '').trim();
if (!s) return '';
if (/^https?:\/\//i.test(s)) return s;
return IMG_BASE + (s.startsWith('/') ? s : '/' + s);
};
// derive tags: product_type, collection, brand, pattern (all real feed values — never AI)
function deriveTags(r, vendor) {
const t = new Set();
if (r.product_type) t.add(r.product_type === 'fabric' ? 'Fabric' : 'Wallcovering');
t.add(vendor);
if (r.collection) t.add(r.collection.slice(0, 60));
if (r.pattern) t.add(r.pattern.split('/')[0].trim().slice(0, 40));
if (r.color) t.add(r.color.split('/')[0].trim().slice(0, 30));
return [...t].filter(Boolean);
}
async function ollamaDesc(r, vendor, timeoutMs = 20000) {
const isFabric = r.product_type === 'fabric';
const kind = isFabric ? 'fabric' : 'wallcovering';
const unit = isFabric ? 'yard' : 'roll';
const nounRule = isFabric
? `This product is a FABRIC — call it "fabric" or "textile". Do NOT call it a wallcovering or wallpaper.`
: `This product is a WALLCOVERING — call it "wallcovering". NEVER use the word "wallpaper".`;
const prompt = `Write ONE concise, elegant 2-sentence product description for a luxury ${kind} for an interior-design storefront. Pattern: "${r.pattern}". Colorway: "${r.color || r.pattern}". Brand: ${vendor}. Collection: "${r.collection || ''}". Sold per ${unit}. RULES: ${nounRule} Do not invent dimensions, prices, or materials not given; no markdown, no lists, plain prose only. Output only the description text.`;
try {
const ctrl = new AbortController();
const to = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(`${OLLAMA}/api/generate`, {
method: 'POST', signal: ctrl.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: MODEL, prompt, stream: false, options: { temperature: 0.6, num_predict: 120 } }),
});
clearTimeout(to);
const j = await res.json();
let d = String(j.response || '').trim().replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
// "wallpaper" is ALWAYS banned. For fabric rows, "wallcovering" is factually wrong -> "fabric".
d = d.replace(/\bwallpapers?\b/gi, (m) => (m[0] === m[0].toUpperCase() ? 'Wallcovering' : 'wallcovering'));
if (isFabric) d = d.replace(/\bwall\s*coverings?\b/gi, (m) => (m[0] === m[0].toUpperCase() ? 'Fabric' : 'fabric'));
return d.split('\n').filter(Boolean).join(' ').slice(0, 600);
} catch { return ''; }
}
async function main() {
const only = (process.argv.find(a => a.startsWith('--brand=')) || '').split('=')[1];
const limit = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
const brands = only ? { [only]: BRANDS[only] } : BRANDS;
fs.mkdirSync(PILOT, { recursive: true });
for (const [bk, b] of Object.entries(brands)) {
const cols = 'base_code,mfr_sku,product_type,pattern,color,collection,width,length,ssp_usd,trade_usd,retail_usd,image';
const rows = psql(`SELECT ${cols} FROM ${b.table} ORDER BY base_code`).trim().split('\n').filter(Boolean).map(line => {
const [base_code, mfr_sku, product_type, pattern, color, collection, width, length, ssp_usd, trade_usd, retail_usd, image] = line.split('\t');
return { base_code, mfr_sku, product_type, pattern, color, collection, width, length, ssp_usd, trade_usd, retail_usd, image };
});
// existing shopify mfr_skus for this vendor -> dedup HOLD
const existing = new Set(
psql(`SELECT upper(mfr_sku) FROM shopify_products WHERE mfr_sku IS NOT NULL AND vendor ILIKE '%${b.vendor.replace(/[^a-z ]/gi, '')}%'`)
.trim().split('\n').filter(Boolean)
);
const manifest = [];
const held = [];
let num = b.start;
const slice = limit > 0 ? rows.slice(0, limit) : rows;
for (const r of slice) {
const color = (r.color && r.color.trim()) ? r.color.trim() : (r.pattern ? r.pattern.trim() : '');
const img = absImg(r.image);
const price = parseFloat(r.retail_usd);
const reasons = [];
// GATE 3: price
if (!(price > 0)) reasons.push('5field:no-price');
// GATE 6: image
if (!img) reasons.push('image-missing');
// color fallback -> if STILL empty, cannot title -> HOLD (never "Unknown")
if (!r.pattern && !color) reasons.push('5field:no-title-source');
// dedup: already on shopify by mfr_sku
if (r.mfr_sku && existing.has(String(r.mfr_sku).toUpperCase())) reasons.push('dedup:already-on-shopify');
if (reasons.length) { held.push({ base_code: r.base_code, mfr_sku: r.mfr_sku, reasons, img: !!img, price: price || null }); continue; }
const dw_sku = `${b.prefix}-${num++}`;
const tags = deriveTags({ ...r, color }, b.vendor);
if (tags.length < 2) tags.push(b.vendor + (r.product_type === 'fabric' ? ' Fabric' : ' Wallcovering'));
const description = await ollamaDesc({ ...r, color }, b.vendor);
// GATE 4: description — if ollama failed, deterministic non-AI fallback (still a real description)
const desc = description || `${r.pattern}${color ? ' in ' + color : ''} from the ${r.collection || b.vendor} collection by ${b.vendor}, sold per ${r.product_type === 'fabric' ? 'yard' : 'roll'}.`;
manifest.push({
dw_sku, mfr_sku: r.mfr_sku, base_code: r.base_code,
vendor: b.vendor, product_type: r.product_type === 'fabric' ? 'Fabric' : 'Wallcovering',
pattern: r.pattern, color, collection: r.collection || '',
width: r.width || '', length: r.length || '',
retail_usd: price.toFixed(2), sample_price: '4.25',
image: img, description: desc, tags,
unit: r.product_type === 'fabric' ? 'Yard' : 'Roll',
});
}
fs.writeFileSync(`${PILOT}manifest-${bk}.json`, JSON.stringify(manifest, null, 2));
fs.writeFileSync(`${PILOT}held-${bk}.json`, JSON.stringify(held, null, 2));
const holdBy = held.reduce((a, h) => { const k = h.reasons[0]; a[k] = (a[k] || 0) + 1; return a; }, {});
console.log(`${bk}: BUILD-eligible=${manifest.length} HELD=${held.length} ${JSON.stringify(holdBy)}`);
}
}
main();