[object Object]

← back to Wallco Ai

REFACTOR-2: extract lib/gemini.js wrapper for 6 Gemini call sites

550c260c8646aec6dc376bcc15ef4cc4f79b5464 · 2026-05-23 14:41:54 -0700 · Steve Abrams

Consolidates the duplicated fetch+key+logGemini boilerplate previously
copy-pasted across server.js — crop-fix, design-critique, ai-designer,
style-element-analysis, furnish, recolor.

lib/gemini.js exports:
  geminiCall({ model, parts, generationConfig, app, note }) → response JSON
  extractText(j)        → first text part
  extractImageData(j)   → first inline_data/inlineData base64 string
  extractJson(j)        → text-part parsed as JSON (strips ```json fences)

Errors throw with .code = NO_KEY | HTTP_ERROR (+.status) | NETWORK.
Each call site maps those to its own response shape (callers preserved
their existing status codes + error message wording).

Behavior unchanged. Local boot test: /health → 200, 26709 designs loaded.

Files touched

Diff

commit 550c260c8646aec6dc376bcc15ef4cc4f79b5464
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat May 23 14:41:54 2026 -0700

    REFACTOR-2: extract lib/gemini.js wrapper for 6 Gemini call sites
    
    Consolidates the duplicated fetch+key+logGemini boilerplate previously
    copy-pasted across server.js — crop-fix, design-critique, ai-designer,
    style-element-analysis, furnish, recolor.
    
    lib/gemini.js exports:
      geminiCall({ model, parts, generationConfig, app, note }) → response JSON
      extractText(j)        → first text part
      extractImageData(j)   → first inline_data/inlineData base64 string
      extractJson(j)        → text-part parsed as JSON (strips ```json fences)
    
    Errors throw with .code = NO_KEY | HTTP_ERROR (+.status) | NETWORK.
    Each call site maps those to its own response shape (callers preserved
    their existing status codes + error message wording).
    
    Behavior unchanged. Local boot test: /health → 200, 26709 designs loaded.
---
 lib/gemini.js |  73 +++++++++++++++++
 server.js     | 252 ++++++++++++++++++++++------------------------------------
 2 files changed, 170 insertions(+), 155 deletions(-)

diff --git a/lib/gemini.js b/lib/gemini.js
new file mode 100644
index 0000000..a6d05bb
--- /dev/null
+++ b/lib/gemini.js
@@ -0,0 +1,73 @@
+// Gemini API wrapper — consolidates the 3 scattered fetch+key+logGemini
+// call sites previously duplicated in server.js (crop-fix, design-critique,
+// ai-designer). Centralizes:
+//   - key resolution (GOOGLE_API_KEY → GEMINI_API_KEY)
+//   - URL build (one place to bump API version)
+//   - cost-tracker logGemini accounting (was try/catch-wrapped at every site)
+//   - response shape probing (handles both inlineData and inline_data)
+//
+// Errors throw with `.code` set: NO_KEY | HTTP_ERROR (+.status) | NETWORK.
+// Caller decides response status code from .code/.status.
+
+const path = require('path');
+const os   = require('os');
+
+function geminiKey() {
+  return (process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY || '').trim();
+}
+
+async function geminiCall({ model, parts, generationConfig, app = 'wallco-ai', note = '' }) {
+  const key = geminiKey();
+  if (!key) {
+    const err = new Error('GEMINI_API_KEY not configured');
+    err.code = 'NO_KEY';
+    throw err;
+  }
+  const url  = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${key}`;
+  const body = { contents: [{ parts }] };
+  if (generationConfig) body.generationConfig = generationConfig;
+
+  let r;
+  try {
+    r = await fetch(url, {
+      method:  'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body:    JSON.stringify(body),
+    });
+  } catch (e) {
+    const err = new Error('gemini network: ' + e.message);
+    err.code = 'NETWORK';
+    throw err;
+  }
+  if (!r.ok) {
+    const text = (await r.text()).slice(0, 240);
+    const err  = new Error(`gemini http ${r.status}: ${text}`);
+    err.code   = 'HTTP_ERROR';
+    err.status = r.status;
+    throw err;
+  }
+  const j = await r.json();
+  try {
+    const { logGemini } = require(path.join(os.homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
+    logGemini(j, { app, model, note });
+  } catch { /* non-fatal */ }
+  return j;
+}
+
+function extractText(j) {
+  return j?.candidates?.[0]?.content?.parts?.find(p => p.text)?.text || null;
+}
+
+function extractImageData(j) {
+  const part = j?.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+  return part?.inline_data?.data || part?.inlineData?.data || null;
+}
+
+function extractJson(j) {
+  const t = extractText(j);
+  if (!t) return null;
+  try { return JSON.parse(t.replace(/^```json\s*|\s*```$/g, '')); }
+  catch { return null; }
+}
+
+module.exports = { geminiKey, geminiCall, extractText, extractImageData, extractJson };
diff --git a/server.js b/server.js
index 54e4ff2..93ffce7 100644
--- a/server.js
+++ b/server.js
@@ -317,6 +317,7 @@ loadDesigns();
 // REFACTOR-1d: parseDesignId / parseIdLike available via lib/parse.js for new code.
 const { maybe304 } = require('./lib/http');
 const { parseDesignId, parseIdLike } = require('./lib/parse');
+const { geminiCall, extractText: geminiText, extractImageData: geminiImage, extractJson: geminiJson } = require('./lib/gemini');
 
 // ── Levenshtein distance (early-exit at cap). Used for did-you-mean suggestions.
 function _lev(a, b, cap) {
@@ -6681,9 +6682,6 @@ app.post('/api/design/:id/crop-fix', express.json({ limit: '16kb' }), async (req
   }
   const fixKind = String(req.body?.fix_kind || 'ghost-layer');
 
-  const GEMINI_KEY = process.env.GEMINI_API_KEY;
-  if (!GEMINI_KEY) return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
-
   try {
     // Load source row (PG → in-memory cache fallback)
     let d = null;
@@ -6748,30 +6746,25 @@ print('OK', left, top, right, bottom)
     );
 
     const imageB64 = fs.readFileSync(composedPath).toString('base64');
-    const body = {
-      contents: [{ parts: [
-        { inline_data: { mime_type: 'image/png', data: imageB64 } },
-        { text: editPrompt },
-      ]}],
-      generationConfig: { responseModalities: ['IMAGE'] },
-    };
-    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_KEY}`;
     const t0 = Date.now();
-    const r = await fetch(url, {
-      method:  'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body:    JSON.stringify(body),
-    });
-    if (!r.ok) return res.status(502).json({ error: 'gemini http ' + r.status + ': ' + (await r.text()).slice(0, 240) });
-    const j = await r.json();
+    let j;
     try {
-      const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
-      logGemini(j, { app: 'wallco-ai', note: 'crop-fix-' + fixKind + '-from-' + id, model: 'gemini-2.5-flash-image' });
-    } catch { /* non-fatal */ }
-    const part = j.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
-    const data = part?.inline_data?.data || part?.inlineData?.data;
+      j = await geminiCall({
+        model: 'gemini-2.5-flash-image',
+        parts: [
+          { inline_data: { mime_type: 'image/png', data: imageB64 } },
+          { text: editPrompt },
+        ],
+        generationConfig: { responseModalities: ['IMAGE'] },
+        note: 'crop-fix-' + fixKind + '-from-' + id,
+      });
+    } catch (e) {
+      if (e.code === 'NO_KEY') return res.status(500).json({ error: 'GEMINI_API_KEY not set' });
+      return res.status(e.status === 502 ? 502 : 502).json({ error: e.message });
+    }
+    const data = geminiImage(j);
     if (!data) {
-      const text = j.candidates?.[0]?.content?.parts?.find(p => p.text)?.text;
+      const text = geminiText(j);
       return res.status(502).json({ error: 'gemini returned no image: ' + (text || '').slice(0, 240) });
     }
 
@@ -14176,21 +14169,15 @@ app.post('/api/studio/interior-design-review', __reviewLimiter, async (req, res)
     if (!d.id) return res.status(404).json({ error: 'design not found' });
 
     // Gemini vision review (mandatory per /interior-designer guidance)
-    const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
     let critique = { strengths: [], concerns: [], recommendation: '' };
-    if (geminiKey && d.local_path && fs.existsSync(d.local_path)) {
+    if (d.local_path && fs.existsSync(d.local_path)) {
       try {
         const b64 = fs.readFileSync(d.local_path).toString('base64');
-        const r = await fetch(
-          `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
-          {
-            method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({
-              contents: [{
-                parts: [
-                  { inlineData: { mimeType: 'image/jpeg', data: b64 } },
-                  { text: `You are a luxury interior designer reviewing a new wallpaper pattern.
+        const j = await geminiCall({
+          model: 'gemini-2.0-flash',
+          parts: [
+            { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+            { text: `You are a luxury interior designer reviewing a new wallpaper pattern.
 Original prompt: "${(prompt || d.prompt || '').slice(0,400)}"
 
 Critique as a designer who places this in HIGH-END interiors (Hermès, Soho House, private hotel suites). STRICT JSON:
@@ -14201,23 +14188,16 @@ Critique as a designer who places this in HIGH-END interiors (Hermès, Soho Hous
   "room_fit": ["<best room: bedroom|living|dining|powder|library|...>", "<second-best>"],
   "lighting": "<best lighting: north light, candlelight, gallery, sunny>",
   "scale": "<small repeat / medium / large / mural>"
-}` }
-                ]
-              }],
-              generationConfig: { temperature: 0.2, maxOutputTokens: 600, responseMimeType: 'application/json' }
-            })
-          }
-        );
-        const j = await r.json();
-        try {
-          const { logGemini } = require(require('path').join(require('os').homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
-          logGemini(j, { app: 'wallco-ai', model: 'gemini-2.0-flash', note: 'design-critique ' + (d.id || '') });
-        } catch {}
-        const t = j.candidates?.[0]?.content?.parts?.[0]?.text;
-        if (t) {
-          try { critique = JSON.parse(t.replace(/^```json\s*|\s*```$/g, '')); } catch {}
-        }
-      } catch (e) { critique._err = e.message; }
+}` },
+          ],
+          generationConfig: { temperature: 0.2, maxOutputTokens: 600, responseMimeType: 'application/json' },
+          note: 'design-critique ' + (d.id || ''),
+        });
+        const parsed = geminiJson(j);
+        if (parsed) critique = parsed;
+      } catch (e) {
+        if (e.code !== 'NO_KEY') critique._err = e.message;
+      }
     }
     // SEC-5: scrub server-only fields before returning + persist to cache so
     // subsequent visitors on the same design hit disk, not Gemini.
@@ -14251,20 +14231,14 @@ app.post('/api/design/:id/ai-designer', requireAdmin, express.json({ limit: '16k
       return res.status(409).json({ error: 'image file missing on disk', local_path: d.local_path });
     }
 
-    const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
-    if (!geminiKey) return res.status(503).json({ error: 'GEMINI_API_KEY not configured' });
-
     const b64 = fs.readFileSync(d.local_path).toString('base64');
-    const r = await fetch(
-      `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
-      {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          contents: [{
-            parts: [
-              { inlineData: { mimeType: 'image/jpeg', data: b64 } },
-              { text: `You are a luxury interior designer cataloging a new wallpaper pattern for Designer Wallcoverings.
+    let j;
+    try {
+      j = await geminiCall({
+        model: 'gemini-2.0-flash',
+        parts: [
+          { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+          { text: `You are a luxury interior designer cataloging a new wallpaper pattern for Designer Wallcoverings.
 Look at the image and answer with STRICT JSON only. The current generation prompt is provided as a HINT only — your authority is what you actually SEE in the image, not what the prompt requested. If the image contains a motif the prompt tried to remove (e.g. leaves, plants, banana, monstera, palm leaves, palm fronds), say so — accuracy matters.
 
 Generation prompt hint: "${(d.prompt || '').slice(0, 500)}"
@@ -14279,25 +14253,20 @@ Return STRICT JSON:
   "leaf_kind": "<if contains_leaves true: 'banana'|'monstera'|'palm'|'tropical'|'generic'; else 'none'>",
   "seam_visible": <true|false — does the image show visible horizontal or vertical seam lines / tile cut-marks indicating bad tiling?>,
   "designer_notes": "<one sentence the catalog can quote>"
-}` }
-            ]
-          }],
-          generationConfig: { temperature: 0.2, maxOutputTokens: 500, responseMimeType: 'application/json' }
-        })
-      }
-    );
-    const j = await r.json();
-    try {
-      const { logGemini } = require(require('path').join(require('os').homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
-      logGemini(j, { app: 'wallco-ai', model: 'gemini-2.0-flash', note: 'ai-designer ' + id });
-    } catch {}
-    const t = j.candidates?.[0]?.content?.parts?.[0]?.text;
-    let suggested = null;
-    if (t) {
-      try { suggested = JSON.parse(t.replace(/^```json\s*|\s*```$/g, '')); }
-      catch { return res.status(502).json({ error: 'gemini JSON parse failed', raw: t.slice(0, 400) }); }
+}` },
+        ],
+        generationConfig: { temperature: 0.2, maxOutputTokens: 500, responseMimeType: 'application/json' },
+        note: 'ai-designer ' + id,
+      });
+    } catch (e) {
+      if (e.code === 'NO_KEY') return res.status(503).json({ error: 'GEMINI_API_KEY not configured' });
+      return res.status(502).json({ error: e.message });
+    }
+    const suggested = geminiJson(j);
+    if (!suggested) {
+      const t = geminiText(j);
+      return res.status(502).json({ error: t ? 'gemini JSON parse failed' : 'no candidate text from gemini', raw: (t || '').slice(0, 400) });
     }
-    if (!suggested) return res.status(502).json({ error: 'no candidate text from gemini' });
 
     // Hard settlement gate — if Gemini reports leaves/banana, refuse to persist
     if (suggested.contains_leaves === true && /^(banana|monstera|palm|tropical)$/i.test(suggested.leaf_kind || '')) {
@@ -14517,45 +14486,27 @@ print(f"#{r:02x}{g:02x}{b:02x}")
 
     // AI style + element analysis via Gemini (mandatory per dw standing rule)
     let ai = { style: null, colors: [], elements: [], summary: null };
-    const geminiKey = process.env.GOOGLE_API_KEY || process.env.GEMINI_API_KEY;
-    if (geminiKey) {
-      try {
-        const b64 = fs.readFileSync(target).toString('base64');
-        const r = await fetch(
-          `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${geminiKey}`,
-          {
-            method: 'POST',
-            headers: { 'Content-Type': 'application/json' },
-            body: JSON.stringify({
-              contents: [{
-                parts: [
-                  { inlineData: { mimeType: 'image/jpeg', data: b64 } },
-                  { text: `Analyze this design. Return STRICT JSON:
+    try {
+      const b64 = fs.readFileSync(target).toString('base64');
+      const j = await geminiCall({
+        model: 'gemini-2.0-flash',
+        parts: [
+          { inlineData: { mimeType: 'image/jpeg', data: b64 } },
+          { text: `Analyze this design. Return STRICT JSON:
 {
   "style": "<one of: Baroque, Rococo, Neoclassical, Art Nouveau, Art Deco, Arts and Crafts, Mid-century Modern, Bauhaus, Memphis, Maximalist, Minimalist, Chinoiserie, Toile, Damask, Ikat, Suzani, Moroccan, Japonisme, Folk Scandi, Tropical, Other>",
   "colors": ["#hex","#hex","#hex"],
   "elements": ["<element keywords like bird, peony, monogram, trellis, etc — max 8>"],
   "summary": "<one sentence describing the design>"
-}` }
-                ]
-              }],
-              generationConfig: { temperature: 0.1, maxOutputTokens: 400, responseMimeType: 'application/json' }
-            })
-          }
-        );
-        const j = await r.json();
-        try {
-          const { logGemini } = require(require('path').join(require('os').homedir(), '.claude/skills/cost-tracker/scripts/log-gemini.js'));
-          logGemini(j, { app: 'wallco-ai', model: 'gemini-2.0-flash', note: 'style-element-analysis' });
-        } catch {}
-        const t = j.candidates && j.candidates[0] && j.candidates[0].content
-                  && j.candidates[0].content.parts && j.candidates[0].content.parts[0]
-                  && j.candidates[0].content.parts[0].text;
-        if (t) {
-          const cleaned = t.replace(/^```json\s*|\s*```$/g, '');
-          try { ai = { ...ai, ...JSON.parse(cleaned) }; } catch {}
-        }
-      } catch (e) { console.error('gemini upload error:', e.message); }
+}` },
+        ],
+        generationConfig: { temperature: 0.1, maxOutputTokens: 400, responseMimeType: 'application/json' },
+        note: 'style-element-analysis',
+      });
+      const parsed = geminiJson(j);
+      if (parsed) ai = { ...ai, ...parsed };
+    } catch (e) {
+      if (e.code !== 'NO_KEY') console.error('gemini upload error:', e.message);
     }
 
     let stat = fs.statSync(target);
@@ -15695,9 +15646,6 @@ app.post('/api/design/:id/furnish', __furnishLimiter, async (req, res) => {
     const d = DESIGNS.find(x => x.id === id);
     if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
 
-    const KEY = process.env.GEMINI_API_KEY;
-    if (!KEY) return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
-
     const palette = Array.isArray(d.palette) ? d.palette.map(p => p.hex).slice(0, 5).join(', ') : '';
     const motifs = Array.isArray(d.motifs) ? d.motifs.slice(0, 6).join(', ') : '';
     const contextLabel = {
@@ -15717,23 +15665,23 @@ app.post('/api/design/:id/furnish', __furnishLimiter, async (req, res) => {
       'Categories to include: Seating, Lighting, Rugs, Art, Tables, Accessories. 2-3 items each. Detail is 1 short phrase (material + color cue).',
     ].join(' ');
 
-    const r = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${KEY}`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        contents: [{ parts: [{ text: prompt }] }],
-        generationConfig: { responseMimeType: 'application/json', temperature: 0.7 },
-      }),
-    });
-    if (!r.ok) return res.status(502).json({ ok: false, error: 'gemini ' + r.status, body: (await r.text()).slice(0,200) });
-    const j = await r.json();
+    let j;
     try {
-      const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
-      logGemini(j, { app: 'wallco-ai', note: 'furnish-' + ctx, model: 'gemini-2.5-flash' });
-    } catch {}
-    const text = j.candidates?.[0]?.content?.parts?.find(p => p.text)?.text || '{}';
-    let parsed = {};
-    try { parsed = JSON.parse(text); } catch (e) { return res.status(502).json({ ok: false, error: 'parse failed', text: text.slice(0,200) }); }
+      j = await geminiCall({
+        model: 'gemini-2.5-flash',
+        parts: [{ text: prompt }],
+        generationConfig: { responseMimeType: 'application/json', temperature: 0.7 },
+        note: 'furnish-' + ctx,
+      });
+    } catch (e) {
+      if (e.code === 'NO_KEY') return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
+      return res.status(502).json({ ok: false, error: e.message });
+    }
+    const parsed = geminiJson(j) || {};
+    if (!parsed.categories && !parsed.note) {
+      const text = geminiText(j) || '';
+      return res.status(502).json({ ok: false, error: 'parse failed', text: text.slice(0,200) });
+    }
     const payload = { ok: true, design_id: id, context: ctx, categories: parsed.categories || [], note: parsed.note || '' };
     // Write-through cache (best-effort; ignore disk errors)
     try {
@@ -15780,9 +15728,6 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
     const hue = RECOLOR_HUES[hueKey];
     if (!hue) return res.status(400).json({ ok: false, error: 'bad hue', valid: Object.keys(RECOLOR_HUES) });
 
-    const KEY = process.env.GEMINI_API_KEY;
-    if (!KEY) return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
-
     // Load source image bytes from local_path or by reading the file behind image_url
     const d = DESIGNS.find(x => x.id === id);
     if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
@@ -15798,22 +15743,19 @@ app.post('/api/design/:id/recolor', express.json(), async (req, res) => {
       + 'High detail, archival quality, fabric-pattern aesthetic.';
 
     const t0 = Date.now();
-    const gr = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${KEY}`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        contents: [{ parts: [{ inline_data: { mime_type: 'image/png', data: srcB64 } }, { text: prompt }] }],
-        generationConfig: { responseModalities: ['IMAGE'] },
-      }),
-    });
-    if (!gr.ok) return res.status(502).json({ ok: false, error: 'gemini ' + gr.status, body: (await gr.text()).slice(0, 200) });
-    const gj = await gr.json();
+    let gj;
     try {
-      const { logGemini } = require(require('os').homedir() + '/.claude/skills/cost-tracker/scripts/log-gemini.js');
-      logGemini(gj, { app: 'wallco-ai', note: 'recolor-' + hueKey + '-from-' + id, model: 'gemini-2.5-flash-image' });
-    } catch {}
-    const part = gj.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
-    const data = part?.inline_data?.data || part?.inlineData?.data;
+      gj = await geminiCall({
+        model: 'gemini-2.5-flash-image',
+        parts: [{ inline_data: { mime_type: 'image/png', data: srcB64 } }, { text: prompt }],
+        generationConfig: { responseModalities: ['IMAGE'] },
+        note: 'recolor-' + hueKey + '-from-' + id,
+      });
+    } catch (e) {
+      if (e.code === 'NO_KEY') return res.status(500).json({ ok: false, error: 'GEMINI_API_KEY missing' });
+      return res.status(502).json({ ok: false, error: e.message });
+    }
+    const data = geminiImage(gj);
     if (!data) return res.status(502).json({ ok: false, error: 'no image returned', finish: gj.candidates?.[0]?.finishReason });
 
     // Save PNG

← b940514 REFACTOR-1b/c/d: extract lib/color.js, lib/http.js, lib/pars  ·  back to Wallco Ai  ·  ghost-detector: 3x voting + maximal-determinism config 03cdc46 →