[object Object]

← back to Dw Photo Capture

Save EVERY scan (Steve): identify-multi now persists the raw photo(s) + OCR candidates + engines + resolved identity to data/scan-log.jsonl (append-only, async best-effort — never breaks a scan). New /api/scans (newest-first, filter=unresolved) + /scans review page: photo ⇄ what OCR read ⇄ resolved SKU, so mis-reads are visible and the corpus improves the app. Verified: scan saved, image served, GCV+Gemini fusion recorded

3e6d84ffad19c6a93ec43befa303279b9bbe8eb9 · 2026-07-08 14:54:15 -0700 · Steve Abrams

Files touched

Diff

commit 3e6d84ffad19c6a93ec43befa303279b9bbe8eb9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 8 14:54:15 2026 -0700

    Save EVERY scan (Steve): identify-multi now persists the raw photo(s) + OCR candidates + engines + resolved identity to data/scan-log.jsonl (append-only, async best-effort — never breaks a scan). New /api/scans (newest-first, filter=unresolved) + /scans review page: photo ⇄ what OCR read ⇄ resolved SKU, so mis-reads are visible and the corpus improves the app. Verified: scan saved, image served, GCV+Gemini fusion recorded
---
 server.js | 86 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 86 insertions(+)

diff --git a/server.js b/server.js
index 2508358..d2204fe 100644
--- a/server.js
+++ b/server.js
@@ -89,6 +89,32 @@ if (!TOKEN) {
 }
 
 fs.mkdirSync(PHOTOS, { recursive: true });
+
+// ── EVERY scan is saved (Steve: "EVERY scan must be saved to make app better") ──────────────────
+// Persist the raw photo(s) + what the OCR read + engines + resolved identity to an append-only log,
+// so mis-reads are reviewable (raw image ⇄ OCR ⇄ resolved SKU) and the corpus can drive engine
+// tuning / vendor-spec learning. Best-effort + fully async — a save failure NEVER breaks a scan.
+const SCAN_LOG = path.join(DATA, 'scan-log.jsonl');
+try { fs.mkdirSync(DATA, { recursive: true }); } catch (e) {}
+let _scanN = 0;
+function saveScan(imgs, meta) {
+  try {
+    const stamp = new Date().toISOString();
+    const id = stamp.replace(/[:.]/g, '-') + '-' + ((++_scanN) % 100000) + Math.random().toString(36).slice(2, 5);
+    const strip = s => (s || '').replace(/^data:image\/\w+;base64,/, '');
+    const images = {};
+    for (const [k, v] of Object.entries(imgs || {})) {
+      if (!v) continue;
+      let b; try { b = Buffer.from(strip(v), 'base64'); } catch (e) { continue; }
+      if (b.length < 200 || b.length > 12 * 1024 * 1024) continue;    // skip empty / oversized
+      const fn = `scan-${id}-${k}.jpg`;
+      fs.writeFile(path.join(PHOTOS, fn), b, () => {});                 // async, fire-and-forget
+      images[k] = `/photos/${fn}`;
+    }
+    const rec = Object.assign({ id, at: stamp, images }, meta || {});
+    fs.appendFile(SCAN_LOG, JSON.stringify(rec) + '\n', () => {});
+  } catch (e) { /* never let logging break a scan */ }
+}
 const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return d; } };
 const saveJSON = (f, o) => {
   // ATOMIC write: serialize to a temp file then rename over the target. rename() is atomic on
@@ -1375,6 +1401,12 @@ const appHandler = (req, res) => {
           primary = { dw_sku: v0.dw_sku, mfr_sku: v0.mfr_sku, vendor: v0.vendor || backVendor, pattern: v0.pattern, image: v0.image };
           source = 'visual'; confidence = v0.score >= 0.92 ? 'high' : v0.score >= 0.85 ? 'medium' : 'low';
         }
+        // SAVE EVERY SCAN — raw photo(s) + what OCR read + engines + resolved identity (found or not).
+        saveScan({ back: p.back, front: p.front || (fronts[0] ? 'data:image/jpeg;base64,' + fronts[0] : null) }, {
+          found: !!primary, source, confidence, corroborated, code,
+          engines: backOcr && backOcr.engines, cost_usd: backOcr && backOcr.cost_usd,
+          candidates: backOcr && backOcr.candidates, vendor: backVendor,
+          resolved: primary && { dw_sku: primary.dw_sku, mfr_sku: primary.mfr_sku, mfr_code: primary.mfr_code, vendor: primary.vendor, pattern: primary.pattern } });
         send(res, 200, { ok: true, found: !!primary, primary, confidence, source, corroborated, qr_used: qrUsed, back_vendor: backVendor,
           code, code_identity: codeId, visual,
           back_read: backOcr && { top: backOcr.top, candidates: backOcr.candidates, vendor: backOcr.vendor, fields: backOcr.fields, engines: backOcr.engines, cost_usd: backOcr.cost_usd } });
@@ -1525,6 +1557,60 @@ const appHandler = (req, res) => {
     return;
   }
 
+  // Review the saved-scan corpus (newest first): raw photo ⇄ what OCR read ⇄ resolved SKU. Powers the
+  // /scans review page so mis-reads are visible and the app can be improved from real data.
+  if (u.pathname === '/api/scans' && req.method === 'GET') {
+    const limit = Math.min(300, Math.max(1, parseInt(u.searchParams.get('limit') || '60', 10) || 60));
+    const only = u.searchParams.get('filter');   // 'unresolved' → only scans that didn't identify
+    let lines = [];
+    try { lines = fs.readFileSync(SCAN_LOG, 'utf8').split('\n').filter(Boolean); } catch (e) {}
+    const total = lines.length, resolved = lines.reduce((n, l) => n + (/"found":true/.test(l) ? 1 : 0), 0);
+    let recs = lines.slice(-Math.min(lines.length, limit * 3)).reverse()
+      .map(l => { try { return JSON.parse(l); } catch (e) { return null; } }).filter(Boolean);
+    if (only === 'unresolved') recs = recs.filter(r => !r.found);
+    recs = recs.slice(0, limit);
+    return send(res, 200, { ok: true, total, resolved, unresolved: total - resolved, count: recs.length, scans: recs });
+  }
+
+  // Scan-review page: browse the saved corpus (photo ⇄ OCR read ⇄ resolved SKU) to spot mis-reads.
+  if (u.pathname === '/scans' && req.method === 'GET') {
+    const html = `<!doctype html><meta charset=utf8><meta name=viewport content="width=device-width,initial-scale=1">
+<title>Scan review · DW Photo</title><style>
+body{margin:0;background:#141310;color:#eadfc6;font:14px/1.4 -apple-system,system-ui,sans-serif}
+header{position:sticky;top:0;background:#1a1813;border-bottom:1px solid #2a2721;padding:12px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
+header b{font-size:16px}.stat{font-size:12px;color:#9a917d}.stat b{color:#eadfc6}
+button{padding:6px 12px;font-size:13px;border:1px solid #2a2721;border-radius:8px;background:#0f0e0b;color:#c8bfa8;cursor:pointer}button.on{border-color:#2bd06a;color:#2bd06a}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;padding:16px}
+.card{border:1px solid #2a2721;border-radius:12px;overflow:hidden;background:#0f0e0b}
+.imgs{display:flex;gap:2px;background:#000}.imgs img{width:100%;height:150px;object-fit:cover;flex:1}
+.body{padding:9px 11px}.read{font:12px ui-monospace,Menlo,monospace;color:#c8bfa8;word-break:break-word}
+.eng{font-size:10px;color:#8f887a}.res{margin-top:6px;font-size:12px}.ok{color:#2bd06a}.no{color:#e0894a}
+.when{font-size:10px;color:#6f6857;margin-top:5px}
+</style>
+<header><b>📸 Scan review</b><span class="stat" id=stat>…</span>
+<button id=all class=on onclick="setF('')">All</button><button id=un onclick="setF('unresolved')">Unresolved only</button>
+<button onclick="load()">↻ Refresh</button></header>
+<div class=grid id=grid></div>
+<script>
+let F='';
+function setF(f){F=f;document.getElementById('all').className=f?'':'on';document.getElementById('un').className=f?'on':'';load();}
+async function load(){
+  const d=await(await fetch('/api/scans?limit=120'+(F?'&filter='+F:''))).json();
+  document.getElementById('stat').innerHTML='<b>'+d.total+'</b> scans · <b>'+d.resolved+'</b> resolved · <b>'+d.unresolved+'</b> unresolved';
+  document.getElementById('grid').innerHTML=(d.scans||[]).map(s=>{
+    const imgs=Object.values(s.images||{}).map(u=>'<img src="'+u+'" loading=lazy>').join('')||'<div style="height:150px;display:flex;align-items:center;justify-content:center;color:#5a544a">no image</div>';
+    const cands=(s.candidates||[]).slice(0,6).join(' · ')||'—';
+    const eng=(s.engines||[]).join('+');
+    const res=s.found?('<span class=ok>✓ '+[(s.resolved&&(s.resolved.mfr_code||s.resolved.mfr_sku)),(s.resolved&&s.resolved.dw_sku)].filter(Boolean).join(' · ')+'</span> <span class=eng>('+s.source+'/'+s.confidence+')</span>'):'<span class=no>✗ not identified</span>';
+    const when=new Date(s.at).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
+    return '<div class=card><div class=imgs>'+imgs+'</div><div class=body><div class=read>📄 '+cands+'</div><div class=eng>'+(eng||'')+(s.cost_usd?' · $'+s.cost_usd:'')+'</div><div class=res>'+res+'</div><div class=when>🕓 '+when+'</div></div></div>';
+  }).join('')||'<p style="padding:16px;color:#8f887a">No scans yet — scan a label and it appears here.</p>';
+}
+load();
+</script>`;
+    return send(res, 200, html, { 'Content-Type': 'text/html; charset=utf-8' });
+  }
+
   // Print-sticker "send": STAMP the sample as sent (Date WP Sample Sent = today — Steve's "field to
   // fill in") and, if a flag field is configured, ALSO flag the record so the Mac's FileMaker Pro
   // poller runs the physical Zebra print. Write is dryRun unless FM_WRITE=1 (protects the live file).

← 51cf188 OCR: multi-engine fusion for the back label — backOcrFused()  ·  back to Dw Photo Capture  ·  visual-search /similar: also return storefront handle (LEFT 06f5839 →