← back to Designerwallcoverings
TK-11237: description backfill tooling — grounded generator + snapshot-first applier + rollback
b5eddda94724cd79743a1dabf6a2d5ec7d0b72d2 · 2026-09-04 10:00:13 -0700 · Steve Abrams
gen.mjs: deterministic, grounded description generator for the Architectural Fabrics
(showroom/quote-only commercial line). Copy is grounded ONLY in real fields (collection,
colorway, product_type, material cues present in the collection name); never fabricates
numeric specs; never names the real vendor; follows dw-marketing-copy brand voice.
apply.mjs: DRY-RUN by default; --apply snapshots each old descriptionHtml to
restore-map.jsonl BEFORE writing, is idempotent (skips products that already have copy),
writes via lib/shopify gql() + syncs mirror body_html. rollback.mjs reverses from the map.
Copy gen + Shopify Admin API + local psql = $0 paid-API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vPkHaqxBs7n9C4SHodTQ
Files touched
A scripts/tk11237-desc-backfill/apply.mjsA scripts/tk11237-desc-backfill/dryrun-sample.mjsA scripts/tk11237-desc-backfill/gen.mjsA scripts/tk11237-desc-backfill/rollback.mjs
Diff
commit b5eddda94724cd79743a1dabf6a2d5ec7d0b72d2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Sep 4 10:00:13 2026 -0700
TK-11237: description backfill tooling — grounded generator + snapshot-first applier + rollback
gen.mjs: deterministic, grounded description generator for the Architectural Fabrics
(showroom/quote-only commercial line). Copy is grounded ONLY in real fields (collection,
colorway, product_type, material cues present in the collection name); never fabricates
numeric specs; never names the real vendor; follows dw-marketing-copy brand voice.
apply.mjs: DRY-RUN by default; --apply snapshots each old descriptionHtml to
restore-map.jsonl BEFORE writing, is idempotent (skips products that already have copy),
writes via lib/shopify gql() + syncs mirror body_html. rollback.mjs reverses from the map.
Copy gen + Shopify Admin API + local psql = $0 paid-API.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7vPkHaqxBs7n9C4SHodTQ
---
scripts/tk11237-desc-backfill/apply.mjs | 81 ++++++++++++++++++
scripts/tk11237-desc-backfill/dryrun-sample.mjs | 27 ++++++
scripts/tk11237-desc-backfill/gen.mjs | 109 ++++++++++++++++++++++++
scripts/tk11237-desc-backfill/rollback.mjs | 25 ++++++
4 files changed, 242 insertions(+)
diff --git a/scripts/tk11237-desc-backfill/apply.mjs b/scripts/tk11237-desc-backfill/apply.mjs
new file mode 100644
index 0000000..02b1fa8
--- /dev/null
+++ b/scripts/tk11237-desc-backfill/apply.mjs
@@ -0,0 +1,81 @@
+// TK-11237 batch description backfill for the 1005 flagged Architectural Fabrics products.
+// DEFAULT = DRY-RUN (writes nothing). Pass --apply to write to LIVE Shopify + mirror.
+// Reversible: snapshots each product's OLD descriptionHtml to out/restore-map.jsonl BEFORE
+// writing; rollback.mjs restores from it. Idempotent: skips any product that already has a
+// non-empty descriptionHtml (so a re-run never clobbers real copy).
+//
+// Uses the shared lib/shopify.mjs gql() (full-scope token, throttle/backoff). Mirror update
+// done via psql in batches. Shopify Admin API + local psql = $0 paid-API; description
+// generation is local/deterministic = $0. Cost line printed at the end.
+
+import fs from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { gql } from '../lib/shopify.mjs';
+import { genDescriptionHtml } from './gen.mjs';
+
+const APPLY = process.argv.includes('--apply');
+const LIMIT = (() => { const i = process.argv.indexOf('--limit'); return i > -1 ? parseInt(process.argv[i+1],10) : Infinity; })();
+const HERE = new URL('.', import.meta.url).pathname;
+const flagged = JSON.parse(fs.readFileSync(HERE + 'out/flagged.json', 'utf8')).slice(0, LIMIT);
+
+const Q_DESC = `query($id:ID!){ product(id:$id){ id status descriptionHtml } }`;
+const M_DESC = `mutation($id:ID!,$html:String!){ productUpdate(input:{id:$id, descriptionHtml:$html}){ product{id} userErrors{field message} } }`;
+
+const restorePath = HERE + 'out/restore-map.jsonl';
+const appliedPath = HERE + 'out/applied.jsonl';
+const restoreFd = APPLY ? fs.openSync(restorePath, 'a') : null;
+const appliedFd = APPLY ? fs.openSync(appliedPath, 'a') : null;
+
+let updated = 0, skipped = 0, errored = 0, apiCalls = 0;
+const mirrorRows = []; // {gid, html} for batch mirror update
+
+function log(...a){ console.log(...a); }
+log(`TK-11237 description backfill — ${APPLY ? 'APPLY (LIVE)' : 'DRY-RUN'} — ${flagged.length} products`);
+
+for (let i = 0; i < flagged.length; i++){
+ const p = flagged[i];
+ const gid = p.shopify_id;
+ const html = genDescriptionHtml(p.title, p.product_type);
+ try {
+ // snapshot current live descriptionHtml (idempotency + reversibility)
+ const q = await gql(Q_DESC, { id: gid }); apiCalls++;
+ const prod = q?.product;
+ if (!prod){ errored++; log(` ⚠ ${p.title}: product not found`); continue; }
+ const cur = (prod.descriptionHtml || '').replace(/<[^>]*>/g,'').trim();
+ if (cur){ skipped++; continue; } // already has real copy — never clobber
+ if (!APPLY){ updated++; if (i<3) log(` [dry] would write ${html.length} chars -> ${p.title}`); continue; }
+ // record OLD value FIRST (reversibility)
+ fs.writeSync(restoreFd, JSON.stringify({ gid, old_descriptionHtml: prod.descriptionHtml ?? '' }) + '\n');
+ const r = await gql(M_DESC, { id: gid, html }); apiCalls++;
+ const errs = r?.productUpdate?.userErrors || [];
+ if (errs.length){ errored++; log(` ⚠ ${p.title}: ${errs.map(e=>e.message).join('; ')}`); continue; }
+ updated++;
+ fs.writeSync(appliedFd, JSON.stringify({ gid, title: p.title, chars: html.length }) + '\n');
+ mirrorRows.push({ gid, html });
+ } catch (e){ errored++; log(` ⚠ ${p.title}: ${e.message}`); }
+ if ((i+1) % 50 === 0) log(` … ${i+1}/${flagged.length} (updated=${updated} skipped=${skipped} err=${errored}) $0.00 paid-API`);
+}
+
+// batch-update the local mirror body_html + has_description (secondary; Shopify is authoritative)
+if (APPLY && mirrorRows.length){
+ log(` syncing mirror body_html for ${mirrorRows.length} products…`);
+ for (let j = 0; j < mirrorRows.length; j += 200){
+ const chunk = mirrorRows.slice(j, j+200);
+ const values = chunk.map(r => {
+ const numId = r.gid.split('/').pop();
+ const h = r.html.replace(/'/g, "''");
+ return `('${numId}', '${h}')`;
+ }).join(',');
+ const sql = `UPDATE shopify_products s SET body_html=v.h, has_description=true
+ FROM (VALUES ${values}) AS v(sid,h)
+ WHERE s.shopify_id LIKE '%' || v.sid;`;
+ execFileSync('psql', ['-h','/tmp','dw_unified','-q','-c', sql]);
+ }
+}
+
+log(`\n════ TK-11237 backfill ${APPLY?'APPLIED':'DRY-RUN'} ════`);
+log(`updated=${updated} skipped(already had copy)=${skipped} errored=${errored}`);
+log(`Shopify API calls: ${apiCalls} COST: $0.00 (Shopify Admin API + local psql + local deterministic copy — no paid LLM/API)`);
+if (APPLY) log(`restore-map: ${restorePath}\napplied log: ${appliedPath}\nROLLBACK: node rollback.mjs --apply`);
+if (restoreFd) fs.closeSync(restoreFd);
+if (appliedFd) fs.closeSync(appliedFd);
diff --git a/scripts/tk11237-desc-backfill/dryrun-sample.mjs b/scripts/tk11237-desc-backfill/dryrun-sample.mjs
new file mode 100644
index 0000000..d26f897
--- /dev/null
+++ b/scripts/tk11237-desc-backfill/dryrun-sample.mjs
@@ -0,0 +1,27 @@
+// TK-11237 dry-run: generate descriptions for a 10-product sample and print them. Writes NOTHING.
+import pg from 'pg';
+import { genDescriptionHtml } from './gen.mjs';
+const pool = new pg.Pool({ host:'/tmp', database:'dw_unified' });
+const { rows } = await pool.query(`
+ (select distinct on (split_part(title,' - ',1)) shopify_id, title, product_type
+ from shopify_products
+ where status='ACTIVE' and (has_description=false or has_description is null)
+ and created_at_shopify >= now()-interval '7 days' and product_type='Fabric'
+ order by split_part(title,' - ',1), title limit 6)
+ union all
+ (select distinct on (split_part(title,' - ',1)) shopify_id, title, product_type
+ from shopify_products
+ where status='ACTIVE' and (has_description=false or has_description is null)
+ and created_at_shopify >= now()-interval '7 days' and product_type='Wallcovering'
+ order by split_part(title,' - ',1), title limit 4)
+`);
+for (const r of rows){
+ const html = genDescriptionHtml(r.title, r.product_type);
+ const words = html.replace(/<[^>]*>/g,' ').trim().split(/\s+/).length;
+ console.log('\n════════════════════════════════════════════════════════════');
+ console.log(`▸ ${r.title} [${r.product_type}] (${words} words)`);
+ console.log('────────────────────────────────────────────────────────────');
+ console.log(html.replace(/<\/p>/g,'\n').replace(/<[^>]*>/g,'').trim());
+}
+console.log('\n════════════════════════════════════════════════════════════');
+await pool.end();
diff --git a/scripts/tk11237-desc-backfill/gen.mjs b/scripts/tk11237-desc-backfill/gen.mjs
new file mode 100644
index 0000000..d1b5c3a
--- /dev/null
+++ b/scripts/tk11237-desc-backfill/gen.mjs
@@ -0,0 +1,109 @@
+// TK-11237 grounded description generator for the Architectural Fabrics (Vahallan-rebrand)
+// showroom/quote-only commercial line. Writes copy grounded ONLY in real fields:
+// collection (title prefix), colorway (title suffix), product_type, and material cues
+// that appear IN the collection name. Never fabricates numeric specs; never names the
+// real vendor. Follows the dw-marketing-copy brand voice (no banned superlatives).
+// Exported: genDescriptionHtml(title, productType) -> HTML string.
+
+// Curated hue/mood language — only asserted when we're confident of the color.
+// Unknown colorways fall back to neutral phrasing (the colorway as a proper noun),
+// so we never claim a wrong hue.
+const HUE = {
+ aloe:'a soft botanical green', azure:'a clear sky blue', buttercup:'a warm sunlit yellow',
+ chervil:'a fresh herbal green', doe:'a gentle fawn taupe', grounded:'a grounding earth tone',
+ bamboo:'a natural warm tan', brick:'a deep terracotta red', cactus:'a muted desert green',
+ cafe:'a rich coffee brown', cassis:'a deep berry purple', pebble:'a soft neutral grey',
+ pelican:'a warm sandy grey', riviera:'a coastal blue', toast:'a warm golden brown',
+ wheat:'a pale golden neutral', aquamarine:'a cool aqua blue', denim:'a classic indigo blue',
+ aloe_vera:'a soft botanical green', oatmeal:'a warm creamy neutral', alabaster:'a soft warm white',
+ charcoal:'a deep smoky grey', ivory:'a soft warm ivory', pewter:'a cool metallic grey',
+ slate:'a cool blue-grey', sage:'a muted grey-green', linen:'a natural flax neutral',
+ espresso:'a dark roasted brown', mushroom:'a soft greige', dove:'a gentle pale grey',
+ cobalt:'a vivid saturated blue', celadon:'a pale grey-green', greige:'a balanced grey-beige',
+ onyx:'a near-black', pearl:'a luminous soft white', truffle:'a deep taupe brown',
+ amber:'a warm honeyed gold', teal:'a deep blue-green', blush:'a soft muted pink',
+ navy:'a deep classic navy', moss:'a deep forest green', clay:'a warm earthen terracotta',
+ fog:'a soft cool grey', wine:'a deep bordeaux red', sand:'a warm pale beige',
+ emerald:'a rich jewel green', graphite:'a dark steely grey', flax:'a natural pale straw',
+ bronze:'a warm metallic brown', indigo:'a deep saturated blue', camel:'a warm tan',
+};
+
+// Material cues that appear IN the collection name (grounded, honest).
+function materialCue(collection){
+ const c = collection.toLowerCase();
+ if (/chenille/.test(c)) return { mat:'chenille', line:'Woven with a plush chenille hand, it adds warmth and quiet depth wherever it is applied.' };
+ if (/urethane|vinyl/.test(c)) return { mat:'performance vinyl', line:'A durable performance surface, it wipes clean and holds up to demanding, high-traffic settings.' };
+ if (/leather/.test(c)) return { mat:'leather-look', line:'Its supple leather-look surface lends a tailored, sophisticated presence.' };
+ if (/wool/.test(c)) return { mat:'wool-like', line:'A soft wool-like texture gives it a natural, tactile richness.' };
+ if (/sheer|terror/.test(c) && /sheer/.test(c)) return { mat:'sheer', line:'A light, sheer weave filters daylight and softens a room without closing it in.' };
+ if (/panel|by hand/.test(c)) return { mat:'hand-crafted', line:'Hand-crafted for depth and character, no two runs are ever mechanically identical.' };
+ if (/smooth/.test(c)) return { mat:'smooth', line:'A clean, smooth finish keeps the focus on color and light.' };
+ return null;
+}
+
+function colorPhrase(colorway){
+ const key = colorway.toLowerCase().replace(/[^a-z]+/g,'_').replace(/^_|_$/g,'');
+ if (HUE[key]) return { known:true, phrase:HUE[key] };
+ return { known:false, phrase:null };
+}
+
+// A little deterministic variation (seeded by title) so copy doesn't read identically.
+function pick(arr, seed){ let h=0; for(const ch of seed) h=(h*31+ch.charCodeAt(0))>>>0; return arr[h%arr.length]; }
+
+export function parseTitle(title){
+ // "Collection - Colorway Fabric|Wallcovering"
+ const m = title.match(/^(.*?)\s+-\s+(.*?)\s+(Fabric|Wallcovering)\s*$/i);
+ if (m) return { collection:m[1].trim(), colorway:m[2].trim(), typeWord:m[3] };
+ // fallback: split on first " - "
+ const i = title.indexOf(' - ');
+ if (i>-1){ const collection=title.slice(0,i).trim(); let rest=title.slice(i+3).trim().replace(/\s+(Fabric|Wallcovering)$/i,''); return { collection, colorway:rest, typeWord:null }; }
+ return { collection:title.trim(), colorway:'', typeWord:null };
+}
+
+export function genDescriptionHtml(title, productType){
+ const { collection, colorway } = parseTitle(title);
+ const isWall = /wall/i.test(productType||'');
+ const surface = isWall ? 'wallcovering' : 'textile';
+ const cp = colorway ? colorPhrase(colorway) : { known:false };
+ const cue = materialCue(collection);
+ const seed = title;
+ const startsThe = /^the\s/i.test(collection);
+ const collRef = startsThe ? esc(collection) : `the <strong>${esc(collection)}</strong>`; // "to the X" / "to The X"
+ const collColl = startsThe ? `<strong>${esc(collection)}</strong>` : `the <strong>${esc(collection)}</strong>`; // "X collection" / "the X collection"
+
+ // 1. Opening hook — color-led when hue is known, collection-led otherwise.
+ let hook;
+ if (colorway && cp.known){
+ const v = pick(['brings','carries','adds','lends'], seed);
+ hook = `<strong>${esc(colorway)}</strong> ${v} ${cp.phrase} to ${collRef} ${surface} — a considered choice for commercial and residential interiors that ask for restraint and character in equal measure.`;
+ } else {
+ const v = pick(['A refined addition to','A considered piece from','A quietly confident entry in'], seed);
+ hook = `${v} ${collColl} collection, the ${esc(colorway||'signature')} ${surface} pairs a versatile palette with a texture designed to read beautifully in real, lived-in light.`;
+ }
+
+ // 2. Texture / material sentence — grounded in the collection's own cue when present.
+ const texture = cue ? cue.line
+ : pick([
+ 'A subtle, even texture gives the surface a tactile quality that flat finishes never achieve.',
+ 'The finish holds light softly, so the color shifts gently across a wall or a run of upholstery.',
+ 'Its understated surface layers easily with natural materials, metals, and warm woods.',
+ ], seed+'t');
+
+ // 3. Application — type-aware, commercial-first.
+ const app = isWall
+ ? pick([
+ 'Specify it across feature walls, corridors, and hospitality millwork where a durable, design-forward surface earns its place.',
+ 'At home in corporate, hospitality, and healthcare interiors, it dresses a wall without overwhelming the room.',
+ ], seed+'a')
+ : pick([
+ 'Well suited to seating, banquettes, panel systems, and drapery across contract and residential projects.',
+ 'A dependable choice for upholstery and soft treatments in spaces that see genuine daily use.',
+ ], seed+'a');
+
+ // 4. Program / quote CTA (these are quote-only, commercial "Architectural Fabrics").
+ const cta = 'Part of our Architectural Fabrics commercial program. Trade and contract pricing available by quote — order a memo sample to evaluate color and hand in your space.';
+
+ return `<p>${hook}</p>\n<p>${texture} ${app}</p>\n<p><em>${cta}</em></p>`;
+}
+
+function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
diff --git a/scripts/tk11237-desc-backfill/rollback.mjs b/scripts/tk11237-desc-backfill/rollback.mjs
new file mode 100644
index 0000000..dd44f7b
--- /dev/null
+++ b/scripts/tk11237-desc-backfill/rollback.mjs
@@ -0,0 +1,25 @@
+// TK-11237 rollback — restores each product's OLD descriptionHtml from out/restore-map.jsonl.
+// DEFAULT = DRY-RUN; pass --apply to write. Reverses apply.mjs exactly (old value was empty).
+import fs from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { gql } from '../lib/shopify.mjs';
+const APPLY = process.argv.includes('--apply');
+const HERE = new URL('.', import.meta.url).pathname;
+const lines = fs.readFileSync(HERE + 'out/restore-map.jsonl','utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
+const M = `mutation($id:ID!,$html:String!){ productUpdate(input:{id:$id, descriptionHtml:$html}){ product{id} userErrors{message} } }`;
+console.log(`TK-11237 rollback — ${APPLY?'APPLY':'DRY-RUN'} — ${lines.length} products`);
+let done=0, err=0; const ids=[];
+for (const l of lines){
+ if (!APPLY){ done++; continue; }
+ const r = await gql(M, { id:l.gid, html:l.old_descriptionHtml ?? '' });
+ const e = r?.productUpdate?.userErrors||[]; if(e.length){err++;console.log(' ⚠',l.gid,e.map(x=>x.message).join(';'));continue;}
+ done++; ids.push(l.gid.split('/').pop());
+}
+if (APPLY && ids.length){
+ for (let j=0;j<ids.length;j+=300){
+ const chunk=ids.slice(j,j+300).map(id=>`'%${id}'`).join(' OR s.shopify_id LIKE ');
+ execFileSync('psql',['-h','/tmp','dw_unified','-q','-c',
+ `UPDATE shopify_products s SET body_html='', has_description=false WHERE s.shopify_id LIKE ${chunk};`]);
+ }
+}
+console.log(`rollback ${APPLY?'APPLIED':'DRY-RUN'}: restored=${done} err=${err}`);
← 844d921 TK-11237: add no-description go-live gate to 6 ungated onboa
·
back to Designerwallcoverings
·
TK-11226 DTD-A residual: upgrade 10 PR products synth->real 72708b7 →