← back to Interiordesignershowroom

lib/hotspot-providers/openai.js

101 lines

// HOTSPOT PROVIDER: openai — FALLBACK for when the Gemini prepay is depleted
// (TK-11260, follow-on to the TK-10402 SCENE_PROVIDER fallback pattern). Same
// contract as hotspot-providers/gemini.js: vision-locate each product inside a
// generated room-setting image and return a normalized %-box per product so the
// frontend can render a shoppable hotspot ON the actual piece. Uses gpt-5.2 (vision)
// via the Responses API — cost ~$0.003-0.006/room (image + short JSON out), a
// little pricier than Gemini Flash's ~$0.001 but funded when Gemini isn't.
// Selected via HOTSPOT_PROVIDER=openai in lib/hotspots.js.
const fs = require('fs');

const MODEL = 'gpt-5.2';
const COST_PER_CALL = 0.005; // conservative estimate for one image + small JSON out; shown to Steve

function buildPrompt(products) {
  const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
  return [
    'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
    'For EACH product you can clearly locate, return its bounding box.',
    'Respond with ONLY a compact JSON array, no prose, no code fence, no markdown:',
    '[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
    'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right), relative to the full image.',
    'Omit any product you cannot confidently locate. Products:',
    list,
  ].join('\n');
}

function iou(a, b) {
  const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
  const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
  const inter = ix * iy;
  return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
}

function toHotspots(raw, products) {
  const seen = new Set();
  const hotspots = [];
  for (const d of raw) {
    const idx = Number(d.i) - 1;
    const p = products[idx];
    if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
    let [ymin, xmin, ymax, xmax] = d.box.map(Number);
    const cl = (n) => Math.max(0, Math.min(1000, n || 0));
    ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
    if (xmax <= xmin || ymax <= ymin) continue;
    const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
    if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue;
    seen.add(p.id);
    hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
  }
  return hotspots;
}

function extractJsonArray(text) {
  if (!text) return [];
  // Strip a stray code fence if the model ignores the "no code fence" instruction.
  const cleaned = text.trim().replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
  try {
    const parsed = JSON.parse(cleaned);
    return Array.isArray(parsed) ? parsed : [];
  } catch { return []; }
}

async function locateProducts(imagePath, products = []) {
  const key = process.env.OPENAI_API_KEY;
  if (!key || !products.length) return { hotspots: [], cost: 0 };
  let b64;
  try { b64 = fs.readFileSync(imagePath).toString('base64'); }
  catch { return { hotspots: [], cost: 0 }; }

  const prompt = buildPrompt(products);
  let j;
  try {
    const res = await fetch('https://api.openai.com/v1/responses', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
      body: JSON.stringify({
        model: MODEL,
        input: [{
          role: 'user',
          content: [
            { type: 'input_text', text: prompt },
            { type: 'input_image', image_url: `data:image/png;base64,${b64}` },
          ],
        }],
      }),
    });
    j = await res.json();
  } catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
  if (j.error) return { hotspots: [], cost: 0, error: j.error.message };

  const text = (j.output || [])
    .flatMap((o) => (o.content || []))
    .map((c) => c.text || '')
    .join('');
  const raw = extractJsonArray(text);

  return { hotspots: toHotspots(raw, products), cost: COST_PER_CALL };
}

module.exports = { locateProducts, COST_PER_CALL };