← back to AbramsOS

lib/savings-advisor.js

142 lines

// savings-advisor.js — the "life optimizer" engine.
//
// Turns what Steve actually buys (reorder_item + purchase history) into concrete
// money-saving / quality-improving suggestions, using the LOCAL Ollama model only
// (Steve's rule: never the Anthropic API; local = $0). Nothing here buys anything —
// every suggestion is a lead for Steve to review on the /savings dashboard.
//
// Design lesson (2026-07-13): asking an LLM for a SPECIFIC cheaper product + exact
// price makes it hallucinate (qwen3:14b proposed "L'Oréal face cream" for coffee pods).
// So we ask for a grounded SAVINGS STRATEGY (generic swap / third-party-compatible /
// bulk / subscribe-save / seasonal timing) + a rough % range — never an invented SKU
// or a fabricated dollar price. gemma3:12b stays on-topic; qwen3:14b (a thinking model)
// returns empty under format:json and is unusable here.
//
// Honesty guards:
//   • est_savings is derived only when we KNOW the typical price (typical_price * pct);
//     otherwise it stays null and the card shows a "~N% less" range instead of fake $.
//   • source_url is NEVER model-generated (stays null) so nothing looks authoritative
//     that isn't. Real links only come from the coupon feed (merchant_coupon).
//   • confidence is normalized to 0..1 (some models answer 0..100).

const db = require('./db');
const ollama = require('./ollama');
const { id } = require('./ids');

const SYSTEM = `You are a frugal, practical household shopping advisor. Given ONE item a
person buys repeatedly, propose the single best way to spend less on THAT SAME item, or
a genuine quality upgrade for similar money. Stay strictly on the given item — never
switch product categories. Never invent a specific brand name or an exact price; speak
in strategies and rough percentage ranges. Reply as strict JSON only.`;

function prompt(item) {
  return `Item bought repeatedly:
- name: ${item.name}
- category: ${item.category || 'unknown'}
- unit/size: ${item.unit || 'unknown'}
- usual merchant: ${item.merchant || 'unknown'}
- price usually paid: ${item.typical_price != null ? '$' + item.typical_price : 'unknown'}

Return JSON with EXACTLY these keys:
{
  "strategy": "generic-swap" | "third-party-compatible" | "bulk-buy" | "subscribe-save" | "seasonal-timing" | "quality-upgrade",
  "label": "short action label, <= 6 words, about THIS item",
  "suggestion": "one concrete sentence about this exact item",
  "est_savings_pct": number,          // rough %, 0-70; 0 if it's a quality upgrade
  "quality_note": "one sentence: tradeoff or why it's better",
  "merchant": "generic where to look (e.g. Amazon, store brand, warehouse club)",
  "confidence": number                 // 0..1 (or 0..100), be conservative
}`;
}

function normConf(v) {
  const n = Number(v);
  if (!Number.isFinite(n)) return 0.4;
  const c = n > 1 ? n / 100 : n;
  return Math.max(0, Math.min(1, c));
}
function pct(v) {
  const n = Number(v);
  if (!Number.isFinite(n)) return null;
  return Math.max(0, Math.min(90, n));
}

async function suggestForItem(userId, item, opts = {}) {
  let j;
  try {
    j = await ollama.generateJson(prompt(item), {
      system: SYSTEM,
      timeoutMs: opts.timeoutMs || 60_000,
      model: opts.model,
    });
  } catch (err) {
    return { ok: false, error: err.message, item: item.name };
  }
  const label = j && (j.label || j.suggestion);
  if (!label) return { ok: false, error: 'empty model output', item: item.name };

  const strategy = String(j.strategy || 'generic-swap');
  const kind = strategy === 'quality-upgrade' ? 'upgrade' : 'substitute';
  const savingsPct = pct(j.est_savings_pct);
  const confidence = normConf(j.confidence);
  const est_savings =
    item.typical_price != null && savingsPct != null
      ? Math.round(Number(item.typical_price) * (savingsPct / 100) * 100) / 100
      : null;

  // Dedupe: skip if an open suggestion already exists for this item + same strategy.
  const dupe = await db.query(
    `SELECT 1 FROM savings_suggestion
      WHERE user_id = $1 AND reorder_item_id IS NOT DISTINCT FROM $2
        AND metadata_jsonb->>'strategy' = $3 AND status IN ('new','saved')`,
    [userId, item.id || null, strategy]
  );
  if (dupe.rows.length) return { ok: true, skipped: 'dupe', item: item.name };

  const sid = id('saving');
  await db.query(
    `INSERT INTO savings_suggestion
       (id, user_id, kind, title, current_item, current_price, suggested_item,
        suggested_price, est_savings, savings_basis, quality_note, merchant,
        source_url, rationale, confidence, reorder_item_id, metadata_jsonb)
     VALUES ($1,$2,$3,$4,$5,$6,$7,NULL,$8,$9,$10,$11,NULL,$12,$13,$14,$15)`,
    [
      sid, userId, kind,
      `Save on ${item.name}`.slice(0, 200),
      item.name, item.typical_price ?? null,
      String(j.label || strategy).slice(0, 120),
      est_savings,
      est_savings != null ? 'per order' : (savingsPct != null ? `~${savingsPct}% less` : 'varies'),
      (j.quality_note || '').slice(0, 500),
      (j.merchant || item.merchant || '').slice(0, 120),
      (j.suggestion || '').slice(0, 1000),
      confidence, item.id || null,
      JSON.stringify({ estimate: true, strategy, est_savings_pct: savingsPct, model: opts.model || ollama.DEFAULT_MODEL }),
    ]
  );
  return { ok: true, id: sid, item: item.name, strategy, suggested: j.label };
}

// Generate suggestions for every active reorder item that doesn't already have a fresh one.
async function generateSuggestions(userId, opts = {}) {
  const items = await db.query(
    `SELECT id, name, merchant, category, unit, typical_price, best_price
       FROM reorder_item WHERE user_id = $1 AND status = 'active'
       ORDER BY updated_at DESC LIMIT $2`,
    [userId, opts.limit || 50]
  );
  const results = [];
  for (const item of items.rows) {
    results.push(await suggestForItem(userId, item, opts));
  }
  return {
    scanned: items.rows.length,
    created: results.filter((r) => r.ok && r.id).length,
    skipped: results.filter((r) => r.skipped).length,
    errors: results.filter((r) => !r.ok),
    results,
  };
}

module.exports = { generateSuggestions, suggestForItem };