[object Object]

← 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

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
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 →