← 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 };