openvibers/openvibe.food Commits
Food: the recipe library — budget recipes OpenVibe.AI writes from the food list, costed by Food, labelled AI, indexed once a person checks them (#55)
Changes
@@@ -26,6 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) || Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route || Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |++| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one || Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown || The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services || Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |@@@ -51,6 +52,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written byrequests, p95 latency and event-loop-lag gauges; `/shared`, static assets, health, readiness and `/metrics` excluded;rows older than 14 days deleted daily ([server/app.js](server/app.js) mounts it, [server/index.js](server/index.js) flushes and prunes).++- `food_library_recipes`, `food_library_runs` (0007) — the recipe library: each kept recipe with its ingredients (food++ list slugs), steps, what to buy and its typical cost, the AI run, and who checked it; one row per theme run.++0001 creates no tables. The service is the authority for these rows and for nothing else.## Does not ownnnew file mode 100644@@@ -0,0 +1,37 @@++-- phase: expand++-- OpenVibe.Food's recipe library (server/food/library.js): budget recipes OpenVibe.AI wrote from Food's own food list++-- (workflow food.suggest_recipes with an empty cupboard and a theme), each costed by Food from its typical prices.++-- Labelled as AI; noindex until a member of staff marks it reviewed; staff may hide one.++++CREATE TABLE food_library_recipes (++ id text COLLATE "C" PRIMARY KEY, -- lib_<random>++ slug text COLLATE "C" NOT NULL UNIQUE,++ title text NOT NULL,++ theme text NOT NULL, -- what the AI was asked for++ serves integer,++ minutes integer,++ ingredients jsonb NOT NULL, -- [{ name, amount, food }] food = a food list slug++ steps jsonb NOT NULL, -- [text]++ notes text,++ to_buy jsonb NOT NULL, -- [{ slug, name, price }] from the food list, whole packages++ cost numeric(10,2), -- the sum of the typical prices of to_buy++ cost_partial boolean NOT NULL DEFAULT false, -- some food had no typical price++ ai_run_id text,++ status text NOT NULL DEFAULT 'published' CHECK (status IN ('published','hidden')),++ reviewed_by text, -- usr_… of the staff member who checked it++ reviewed_at text,++ hidden_reason text,++ created_at text COLLATE "C" NOT NULL -- ISO 8601, UTC++);++CREATE INDEX food_library_recipes_listing ON food_library_recipes (status, created_at DESC);++++-- One row per theme run, so a theme is not asked again before the others have had their turn.++CREATE TABLE food_library_runs (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ theme text NOT NULL,++ ai_run_id text,++ made integer NOT NULL DEFAULT 0,++ error text,++ created_at text COLLATE "C" NOT NULL++);++CREATE INDEX food_library_runs_theme ON food_library_runs (theme, created_at DESC);@@@ -10,7 +10,7 @@.recipe-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; border-bottom: 1px solid var(--border, #1f2d47); padding-bottom: 8px; }.recipe-head h3 { margin: 0; font-size: 1.15rem; }.recipe-meta { margin: 0; font-size: .85rem; color: var(--text-secondary, #96a7c2); }--.recipe h4 { margin: 12px 0 6px; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }++.recipe h4, .recipe .h4 { margin: 12px 0 6px; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }.recipe-body { display: grid; gap: 4px 24px; }@media (min-width: 760px) { .recipe-body { grid-template-columns: minmax(220px, 2fr) 3fr; } }.recipe-ingredients ul { list-style: none; padding: 0; margin: 0; }@@@ -24,3 +24,6 @@.recipe-total { margin: 6px 0 2px; border-top: 1px solid var(--border-light, #2c3d5c); padding-top: 6px; }.recipe-buy-none { color: var(--success, #22c55e); }.recipe-notes { margin-top: 10px; color: var(--text-secondary, #96a7c2); }++.lib-grid { list-style: none; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }++.lib-card h2 { margin: 0 0 6px; font-size: 1.1rem; }++.lib-recipe .recipe-head h1 { margin: 0; font-size: 1.6rem; }@@@ -86,6 +86,8 @@ async function createApp(opts = {}) {audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,}) : null;ctx.ai = opts.ai !== undefined ? opts.ai : createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl });++ // The recipe library (server/food/library.js): a theme every six hours where the process serves (server/index.js starts it).++ ctx.library = require('./food/library').createLibrary({ s, ai: ctx.ai, log });const app = express();app.disable('x-powered-by');nnew file mode 100644@@@ -0,0 +1,134 @@++'use strict';++/**++ * The recipe library (the owner's ask: "an AI recipe system" for OpenVibe.Food). Every few hours the next theme in THEMES++ * goes to OpenVibe.AI's food.suggest_recipes with an empty cupboard, so every ingredient has to be a food from Food's own++ * list (server/food/recipes.js validates that strictly; an ingredient the AI could not tie to the list drops the whole++ * recipe here, so a recipe never quietly misses something). Food prices what there is to buy from its typical prices,++ * never from anything the model said. Recipes are labelled as AI and stay noindex until a member of staff marks one++ * reviewed; staff may hide one.++ *++ * tick() the next theme (the one asked least recently), when AI is configured++ * list(), bySlug(), review(), setHidden()++ */++const crypto = require('crypto');++const { buildSources, validateSuggestions } = require('./recipes');++++const THEMES = [++ 'a one-pot dinner for four', 'a quick breakfast with eggs', 'a vegetarian chili', 'a packed lunch for work or school',++ 'a rice bowl', 'a pasta dinner', 'a soup for a cold day', 'a meal with no cooking', 'a dinner made from canned food',++ 'a high-protein meal on a budget', 'a sweet snack', 'something with potatoes', 'a bean dish', 'a breakfast without eggs',++ 'a weeknight stir-fry', 'a sandwich that fills you up', 'a dinner children will eat', 'a meal from frozen vegetables',++ 'tacos or burritos', 'a filling salad', 'something with bananas', 'a slow-simmered stew', 'a batch-cooked meal for the week',++ 'a dairy-free dinner', 'something with peanut butter', 'a cheap dinner for two', 'a one-pan camping meal',++ 'a microwave meal for a dorm room', 'something with oats', 'a tomato pasta sauce from scratch', 'fried rice',++ 'a breakfast burrito', 'a chicken dinner', 'a dinner from canned fish', 'a dessert from pantry staples',++];++const EVERY_MS = 6 * 3_600_000;++++const clean = (v, n) => (v == null ? null : String(v).replace(/\s+/g, ' ').trim().slice(0, n) || null);++const slugify = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70).replace(/-+$/, '');++const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };++const intIn = (v, lo, hi) => (Number.isInteger(v) && v >= lo && v <= hi ? v : null);++++function shape(r) {++ return r ? {++ ...r, ingredients: parse(r.ingredients, []), steps: parse(r.steps, []), to_buy: parse(r.to_buy, []),++ cost: r.cost == null ? null : Number(r.cost), reviewed: Boolean(r.reviewed_by),++ } : null;++}++++function createLibrary({ s, ai, log = console }) {++ const enabled = Boolean(ai && (ai.enabled === undefined || ai.enabled));++++ /** The theme asked least recently (never-asked first, in list order). */++ async function nextTheme() {++ const rows = await s.db.many('SELECT theme, max(created_at) AS last FROM food_library_runs GROUP BY theme');++ const last = new Map(rows.map((r) => [r.theme, r.last]));++ const never = THEMES.find((t) => !last.has(t));++ if (never) return never;++ return [...THEMES].sort((a, b) => (last.get(a) < last.get(b) ? -1 : 1))[0];++ }++++ async function save(theme, runId, r) {++ // Everything comes from the food list: a recipe the validator kept only through "from your cupboard" is dropped.++ if (r.ingredients.some((i) => i.from_pantry || !i.citations.length)) return null;++ const sources = buildSources();++ const title = clean(r.title, 120);++ const base = slugify(title);++ if (!title || !base) return null;++ if (await s.db.maybe('SELECT 1 FROM food_library_recipes WHERE slug = $1', [base])) return null; // the same dish again++ const ingredients = r.ingredients.map((i) => ({ name: clean(i.name, 120) || '', amount: clean(i.amount, 60), food: (sources[i.citations[0]] || {}).source_id || null }));++ const steps = r.steps.map((x) => clean(x, 600)).filter(Boolean).slice(0, 20);++ const row = {++ id: `lib_${crypto.randomBytes(8).toString('hex')}`, slug: base, title, theme, serves: intIn(r.serves, 1, 12), minutes: intIn(r.minutes, 1, 600),++ ingredients, steps, notes: clean(r.notes, 600), to_buy: r.to_buy, cost: r.to_buy_cost, cost_partial: Boolean(r.to_buy_cost_partial),++ };++ await s.db.query(`INSERT INTO food_library_recipes (id, slug, title, theme, serves, minutes, ingredients, steps, notes, to_buy, cost, cost_partial, ai_run_id, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10::jsonb, $11, $12, $13, $14) ON CONFLICT (slug) DO NOTHING`,++ [row.id, row.slug, row.title, theme, row.serves, row.minutes, JSON.stringify(ingredients), JSON.stringify(steps), row.notes, JSON.stringify(row.to_buy), row.cost, row.cost_partial, runId || null, s.iso()]);++ return row;++ }++++ /** One theme: ask, validate, cost, keep. Returns the recipes kept. */++ async function make(theme) {++ const input = { pantry: [], ask: `A budget recipe idea: ${theme}. Use only foods from the list.`, people: 4, sources: buildSources() };++ let run = null;++ try {++ run = await ai.run('food.suggest_recipes', input, { target: { service: 'food', type: 'library', id: slugify(theme) } });++ const out = validateSuggestions(run && run.output, { sources: input.sources, pantry: [] });++ const kept = [];++ for (const r of out.recipes) { const row = await save(theme, run && run.runId, r); if (row) kept.push(row); }++ await s.db.query('INSERT INTO food_library_runs (theme, ai_run_id, made, created_at) VALUES ($1, $2, $3, $4)', [theme, (run && run.runId) || null, kept.length, s.iso()]);++ return kept;++ } catch (err) {++ log.warn(`[OpenVibe.Food] recipe library (${theme}):`, err.message);++ await s.db.query('INSERT INTO food_library_runs (theme, ai_run_id, made, error, created_at) VALUES ($1, $2, 0, $3, $4)', [theme, (run && run.runId) || null, clean(err.message, 300), s.iso()]);++ return [];++ }++ }++++ let busy = false;++ /** The next theme, unless one was asked less than six hours ago (a restart does not ask again early). */++ async function tick({ force = false } = {}) {++ if (!enabled || busy) return [];++ busy = true;++ try {++ const last = await s.db.value('SELECT max(created_at) FROM food_library_runs');++ if (!force && last && s.now() - Date.parse(last) < EVERY_MS - 10 * 60_000) return [];++ return await make(await nextTheme());++ } finally { busy = false; }++ }++++ let timer = null;++ let first = null;++ return {++ enabled, THEMES, tick, make, nextTheme,++ async list({ limit = 24, offset = 0, staff = false } = {}) {++ const states = staff ? ['published', 'hidden'] : ['published'];++ const total = Number(await s.db.value('SELECT count(*) FROM food_library_recipes WHERE status = ANY($1::text[])', [states])) || 0;++ const rows = await s.db.many('SELECT * FROM food_library_recipes WHERE status = ANY($1::text[]) ORDER BY created_at DESC, id LIMIT $2 OFFSET $3', [states, limit, offset]);++ return { total, recipes: rows.map(shape) };++ },++ async reviewed() {++ return (await s.db.many("SELECT * FROM food_library_recipes WHERE status = 'published' AND reviewed_by IS NOT NULL ORDER BY created_at DESC")).map(shape);++ },++ async bySlug(slug) { return shape(await s.db.maybe('SELECT * FROM food_library_recipes WHERE slug = $1', [String(slug)])); },++ /** Staff: a person checked the recipe (it may now be indexed), or takes that back. */++ async review(recipe, by, yes = true) {++ await s.db.query('UPDATE food_library_recipes SET reviewed_by = $2, reviewed_at = $3 WHERE id = $1', [recipe.id, yes ? by : null, yes ? s.iso() : null]);++ },++ async setHidden(recipe, hidden, reason = null) {++ await s.db.query('UPDATE food_library_recipes SET status = $2, hidden_reason = $3 WHERE id = $1', [recipe.id, hidden ? 'hidden' : 'published', hidden ? clean(reason, 300) : null]);++ },++ start() {++ if (!enabled || timer) return;++ timer = setInterval(() => { tick().catch(() => {}); }, EVERY_MS);++ timer.unref();++ first = setTimeout(() => { tick().catch(() => {}); }, 5 * 60_000);++ first.unref();++ },++ stop() { clearInterval(timer); clearTimeout(first); timer = null; },++ };++}++++module.exports = { createLibrary, THEMES, slugify };@@@ -88,6 +88,7 @@ function createDiscoveryRoutes(ctx) {{ title: 'Food near you', url: abs('/near'), note: 'food banks and budget grocery stores, from OpenStreetMap' },{ title: 'Meal plan', url: abs('/plan'), note: 'a shopping list and an estimated cost' },{ title: 'The food list', url: abs('/foods'), note: 'nutrition and typical prices' },++ { title: 'Recipe library', url: abs('/recipes/library'), note: 'budget recipes written by OpenVibe.AI from the food list, each with what to buy and its typical cost; a recipe a person checked is marked so' },{ title: 'What shipped on OpenVibe.Food', url: abs('/updates') },] },{ title: 'Machine-readable', links: [@@@ -120,9 +121,11 @@ function createDiscoveryRoutes(ctx) {}));});-- r.get('/sitemap.xml', (_req, res) => {++ r.get('/sitemap.xml', async (_req, res) => {const lastmod = siteUpdated();const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));++ // Library recipes a person checked (the others are noindex until then).++ if (ctx.library) for (const x of await ctx.library.reviewed()) urls.push({ loc: abs(`/recipes/library/${x.slug}`), lastmod: String(x.reviewed_at || x.created_at).slice(0, 10), changefreq: 'monthly', priority: 0.6 });res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));});nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++/**++ * The recipe library's pages (server/food/library.js):++ *++ * GET /recipes/library every recipe, newest first (24 a page)++ * GET /recipes/library/:slug one recipe: ingredients from the food list, steps, what to buy and its cost++ * POST /recipes/library/:slug/review staff: a person checked it (indexed, with Recipe JSON-LD) or takes it back++ * POST /recipes/library/:slug/hide|show staff++ *++ * Every page says the recipe was written by OpenVibe.AI; an unreviewed one is noindex.++ */++const showcase = require('openvibe-shared/showcase');++const { html, notice, time } = require('../render/html');++const { sameOrigin } = require('./principal');++const { COST_NOTE } = require('../food/recipes');++++const PER_PAGE = 24;++const money = (n) => (n == null ? '—' : `$${Number(n).toFixed(2)}`);++const AI_LINE = 'Written by OpenVibe.AI from OpenVibe.Food\'s food list. Check a recipe before you cook it: cook meat, poultry and eggs through, and mind allergies.';++++function mountLibrary(r, { ctx, page, config, signedIn, express }) {++ const lib = ctx.library;++ const isStaff = (req) => Boolean(signedIn(req) && req.viewer.role === 'admin');++ const meta = (x) => [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `About ${x.minutes} min` : null, x.cost != null ? `${money(x.cost)} to buy${x.cost_partial ? '+' : ''}` : null].filter(Boolean).join(' · ');++++ r.get('/recipes/library', async (req, res) => {++ const n = parseInt(req.query.page, 10);++ const pageNo = Number.isInteger(n) && n > 0 ? Math.min(n, 500) : 1;++ const staff = isStaff(req);++ const { total, recipes } = await lib.list({ limit: PER_PAGE, offset: (pageNo - 1) * PER_PAGE, staff });++ const pages = Math.max(1, Math.ceil(total / PER_PAGE));++ page(req, res, {++ title: 'Recipe library', index: false, localStyles: ['css/recipes.css'], styles: [showcase.STYLESHEET],++ description: 'Budget recipes written by AI from OpenVibe.Food\'s food list, each with what to buy and its typical cost.',++ crumbs: [{ label: 'Recipe ideas', href: '/recipes' }, { label: 'Library' }],++ body: html`<h1>Recipe library</h1>++<p class="lede">Cheap, simple recipes that use only foods from <a href="/foods">the food list</a>, so OpenVibe.Food can tell you what to buy and what it typically costs. A new batch is added every few hours.</p>++<p class="notice">${AI_LINE}</p>++${recipes.length ? html`<ol class="lib-grid">${recipes.map((x) => html`<li class="recipe lib-card"><h2><a href="/recipes/library/${x.slug}">${x.title}</a></h2>++<p class="recipe-meta">${meta(x)}${x.reviewed ? ' · checked by a person' : ''}${x.status === 'hidden' ? ' · hidden' : ''}</p>++<p class="muted small">${x.ingredients.slice(0, 6).map((i) => i.name).join(', ')}${x.ingredients.length > 6 ? '…' : ''}</p></li>`)}</ol>`++ : html`<p class="muted">The first recipes are being written. Meanwhile, <a href="/recipes">get ideas from your own cupboard</a>.</p>`}++${pages > 1 ? html`<nav class="pager" aria-label="Pages">${pageNo > 1 ? html`<a href="/recipes/library${pageNo > 2 ? `?page=${pageNo - 1}` : ''}">← Newer</a>` : ''} <span>Page ${pageNo} of ${pages}</span> ${pageNo < pages ? html`<a href="/recipes/library?page=${pageNo + 1}">Older →</a>` : ''}</nav>` : ''}`,++ });++ });++++ r.get('/recipes/library/:slug', async (req, res) => {++ const x = await lib.bySlug(req.params.slug);++ const staff = isStaff(req);++ if (!x || (x.status === 'hidden' && !staff)) {++ return page(req, res, { title: 'No such recipe', crumbs: [{ label: 'Recipe library', href: '/recipes/library' }, { label: 'Not found' }],++ body: html`<h1>No such recipe</h1><p><a href="/recipes/library">See the library</a>.</p>` }, 404);++ }++ const url = `${config.baseUrl}/recipes/library/${x.slug}`;++ const jsonLd = x.reviewed && x.status === 'published' ? [{++ '@context': 'https://schema.org', '@type': 'Recipe', name: x.title, url,++ recipeIngredient: x.ingredients.map((i) => [i.amount, i.name].filter(Boolean).join(' ')),++ recipeInstructions: x.steps.map((t) => ({ '@type': 'HowToStep', text: t })),++ ...(x.serves ? { recipeYield: `${x.serves} servings` } : {}), ...(x.minutes ? { totalTime: `PT${x.minutes}M` } : {}),++ author: { '@type': 'Organization', name: 'OpenVibe.Food' }, datePublished: x.created_at,++ }] : [];++ page(req, res, {++ title: x.title, index: Boolean(x.reviewed && x.status === 'published'), localStyles: ['css/recipes.css'], jsonLd,++ description: `${x.title}: ${meta(x)}. A budget recipe from OpenVibe.Food's food list, written by AI.`,++ crumbs: [{ label: 'Recipe library', href: '/recipes/library' }, { label: x.title }],++ body: html`<article class="recipe lib-recipe">++<header class="recipe-head"><h1>${x.title}</h1><p class="recipe-meta">${meta(x)}</p></header>++<p class="notice">${AI_LINE}${x.reviewed ? ' A person at OpenVibe has checked this one.' : ''}</p>++<div class="recipe-body">++<section class="recipe-ingredients" aria-label="Ingredients"><h2 class="h4">Ingredients</h2>++<ul>${x.ingredients.map((i) => html`<li><span>${i.food ? html`<a href="/foods/${i.food}">${i.name}</a>` : i.name}${i.amount ? html` <span class="muted">${i.amount}</span>` : ''}</span></li>`)}</ul></section>++<section class="recipe-steps" aria-label="Steps"><h2 class="h4">Steps</h2><ol>${x.steps.map((t) => html`<li>${t}</li>`)}</ol></section>++</div>++${x.to_buy.length ? html`<div class="recipe-buy"><h2 class="h4">What to buy</h2>++<ul>${x.to_buy.map((b) => html`<li><span><a href="/foods/${b.slug}">${b.name}</a></span><span>${b.price == null ? 'no typical price' : money(b.price)}</span></li>`)}</ul>++<p class="recipe-total"><span>Typical total${x.cost_partial ? ' (items with a price)' : ''}</span><strong>${money(x.cost)}</strong></p>++<p class="muted small">Whole packs: most leave plenty over. ${COST_NOTE}</p></div>` : ''}++${x.notes ? html`<p class="recipe-notes small">${x.notes}</p>` : ''}++<p class="muted small">Asked for: ${x.theme} · ${time(x.created_at)} · <a href="/recipes/library">More recipes</a> · <a href="/plan">Plan a week</a></p>++${staff ? html`<form method="post" action="/recipes/library/${x.slug}/review" class="ov-form"><input type="hidden" name="yes" value="${x.reviewed ? '0' : '1'}"><button class="sc-btn" type="submit">${x.reviewed ? 'Take back the review' : 'I checked this recipe'}</button></form>++<form method="post" action="/recipes/library/${x.slug}/${x.status === 'hidden' ? 'show' : 'hide'}" class="ov-form"><button class="sc-btn" type="submit">${x.status === 'hidden' ? 'Show it again' : 'Hide this recipe'}</button></form>` : ''}++</article>`,++ });++ });++++ const form = express.urlencoded({ extended: false, limit: '4kb' });++ async function staffAction(req, res, fn) {++ if (!isStaff(req)) return res.status(403).type('text/plain').send('Staff only.');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Send this from openvibe.food itself.');++ const x = await lib.bySlug(req.params.slug);++ if (!x) return res.status(404).type('text/plain').send('Not found.');++ await fn(x);++ res.redirect(303, `/recipes/library/${x.slug}`);++ }++ r.post('/recipes/library/:slug/review', form, (req, res) => staffAction(req, res, (x) => lib.review(x, req.viewer.subject, req.body.yes !== '0')));++ r.post('/recipes/library/:slug/hide', form, (req, res) => staffAction(req, res, (x) => lib.setHidden(x, true, req.body.reason)));++ r.post('/recipes/library/:slug/show', form, (req, res) => staffAction(req, res, (x) => lib.setHidden(x, false)));++ return { notice };++}++++module.exports = { mountLibrary };@@@ -681,7 +681,7 @@ ${r.notes ? html`<div class="recipe-notes small">${raw(markdown(r.notes))}</div>title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],body: html`<h1>Recipe ideas</h1><p class="lede">Tell OpenVibe.Food what you feel like and how many people you are feeding. It suggests up to three recipes from the food list, marks what is already in your cupboard and lists what you would still need to buy, with a typical cost.</p>--<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Frecipes">Sign in to get recipe ideas</a></p>`,++<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Frecipes">Sign in to get recipe ideas</a> <a class="sc-btn" href="/recipes/library">Browse the recipe library</a></p>`,});}const rows = (await store.listRecipeSuggestions(s, ownerOf(req), { limit: 20 })).map(store.recipeSuggestionToWire);@@@ -704,7 +704,7 @@ ${error ? notice(problems[error] || 'That did not work. Try again.', 'warn') : '<input id="people" name="people" type="number" min="1" max="12" step="1" inputmode="numeric"></div><button class="sc-btn sc-primary" type="submit">Suggest recipes</button></form>--<p class="muted small">Ideas come from what is in <a href="/pantry">your cupboard</a>. Up to 10 requests a day.</p>++<p class="muted small">Ideas come from what is in <a href="/pantry">your cupboard</a>. Up to 10 requests a day. Or browse <a href="/recipes/library">the recipe library</a>.</p>${rows.length ? html`<h2>Recent ideas</h2><p class="notice">Suggested by OpenVibe.AI from your cupboard and the food list. Check a recipe before you cook it: cook meat, poultry and eggs through, and mind allergies.</p>${rows.map((row) => html`<section class="recipe-suggestion">@@@ -786,6 +786,8 @@ ${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──r.use(createDiscoveryRoutes(ctx));++ // The recipe library (server/food/library.js, http/library-routes.js).++ require('./library-routes').mountLibrary(r, { ctx, page, config, signedIn, express });return r;}@@@ -41,7 +41,9 @@ async function start() {// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without FOOD_EVENTS_URL and// FOOD_EVENTS_SECRET). The consumer itself is mounted in server/app.js.const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });-- const extra = [() => { if (subscriptions) subscriptions.stop(); }];++ // The recipe library writes a theme every six hours (server/food/library.js); off without OpenVibe.AI.++ ctx.library.start();++ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop()];createLifecycle({ server, ctx, extra });return { server, ctx };}nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++/**++ * The recipe library (server/food/library.js, server/http/library-routes.js):++ * - a theme goes to food.suggest_recipes with an empty cupboard and Food's food list; a recipe with an ingredient not++ * tied to the list is dropped, the rest are kept with Food's own costs; the same dish is not kept twice;++ * - themes take turns; a restart does not ask again within six hours; a failure is recorded, nothing is made;++ * - the pages say AI wrote it; unreviewed recipes are noindex, a reviewed one is indexed with Recipe JSON-LD;++ * - only staff review, hide and show, and only from openvibe.food itself.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { buildSources } = require('../server/food/recipes');++const { THEMES } = require('../server/food/library');++++const SAME = { 'sec-fetch-site': 'same-origin' };++const src = buildSources();++const at = (slug) => src.findIndex((x) => x.source_id === slug);++++function aiStub() {++ const calls = [];++ let fail = null;++ const ai = {++ calls,++ failNext(err) { fail = err; },++ async run(workflow, input, opts) {++ calls.push({ workflow, input, opts });++ if (fail) { const e = fail; fail = null; throw e; }++ const i = calls.length;++ return {++ runId: `run_lib_${i}`,++ output: {++ recipes: [++ { title: i === 2 ? 'Beans and Rice' : `Beans and rice ${i}`, serves: 4, minutes: 25,++ ingredients: [{ name: 'Rice', amount: '2 cups', from_pantry: false, citations: [at('white-rice')] }, { name: 'Beans', amount: '1 can', from_pantry: false, citations: [at('canned-beans')] }],++ steps: ['Cook the rice.', 'Warm the beans.', 'Serve together.'], notes: 'Add hot sauce.' },++ { title: 'Mystery stew', serves: 2, minutes: 40, ingredients: [{ name: 'Saffron', amount: 'a pinch', from_pantry: true, citations: [] }, { name: 'Rice', amount: '1 cup', from_pantry: false, citations: [at('white-rice')] }], steps: ['Stew it.'] },++ ],++ citations: [], gaps: [],++ },++ };++ },++ };++ return ai;++}++++(async () => {++ const ai = aiStub();++ const t = await boot({ env: { OV_AI_INTERNAL_URL: 'http://ai.test' }, ai });++ const kim = t.network.addUser('kim');++ const boss = t.network.addUser('boss', { role: 'admin' });++ try {++ await check('a theme becomes costed recipes from the food list; one leaning on "your cupboard" is dropped', async () => {++ assert.ok(at('white-rice') >= 0 && at('canned-beans') >= 0, 'the fixture foods are in the list');++ const kept = await t.ctx.library.tick({ force: true });++ assert.deepStrictEqual(kept.map((x) => x.title), ['Beans and rice 1']);++ const call = ai.calls[0];++ assert.strictEqual(call.workflow, 'food.suggest_recipes');++ assert.deepStrictEqual(call.input.pantry, []);++ assert.ok(call.input.ask.includes(THEMES[0]));++ assert.strictEqual(call.opts.target.type, 'library');++ const row = await t.ctx.library.bySlug('beans-and-rice-1');++ assert.deepStrictEqual(row.to_buy.map((b) => b.slug).sort(), ['canned-beans', 'white-rice']);++ assert.ok(row.cost > 0);++ assert.deepStrictEqual(row.ingredients.map((i) => i.food), ['white-rice', 'canned-beans']);++ });++++ await check('themes take turns; a restart does not ask again early; a failure makes nothing', async () => {++ assert.deepStrictEqual(await t.ctx.library.tick(), [], 'within six hours of the last run');++ assert.strictEqual(ai.calls.length, 1);++ assert.strictEqual(await t.ctx.library.nextTheme(), THEMES[1]);++ await t.ctx.library.tick({ force: true });++ assert.ok(ai.calls[1].input.ask.includes(THEMES[1]));++ ai.failNext(new Error('provider down'));++ assert.deepStrictEqual(await t.ctx.library.tick({ force: true }), []);++ const runs = await t.ctx.s.db.many('SELECT theme, made, error FROM food_library_runs ORDER BY id');++ assert.deepStrictEqual(runs.map((r) => [r.made, r.error]), [[1, null], [1, null], [0, 'provider down']]);++ });++++ await check('the pages: labelled AI, noindex until reviewed; staff review makes it indexable with Recipe JSON-LD', async () => {++ const list = await t.get('/recipes/library');++ assert.strictEqual(list.status, 200);++ assert.ok(list.text.includes('Written by OpenVibe.AI') && list.text.includes('href="/recipes/library/beans-and-rice-1"'));++ const page = await t.get('/recipes/library/beans-and-rice-1');++ assert.ok(page.text.includes('What to buy') && page.text.includes('href="/foods/white-rice"'));++ assert.match(page.text, /<meta name="robots" content="noindex/);++ assert.ok(!page.text.includes('"@type":"Recipe"'));++ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1/review', { method: 'POST', as: kim, form: { yes: '1' }, headers: SAME })).status, 403);++ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1/review', { method: 'POST', as: boss, form: { yes: '1' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);++ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1/review', { method: 'POST', as: boss, form: { yes: '1' }, headers: SAME })).status, 303);++ const reviewed = await t.get('/recipes/library/beans-and-rice-1');++ assert.match(reviewed.text, /<meta name="robots" content="index/);++ assert.ok(reviewed.text.includes('"@type":"Recipe"') && reviewed.text.includes('checked this one'));++ assert.ok((await t.get('/sitemap.xml')).text.includes('/recipes/library/beans-and-rice-1</loc>'), 'a reviewed recipe is in the sitemap');++ await t.get('/recipes/library/beans-and-rice-1/hide', { method: 'POST', as: boss, form: {}, headers: SAME });++ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1')).status, 404);++ assert.ok((await t.get('/recipes/library/beans-and-rice-1', { as: boss })).text.includes('Show it again'));++ await t.get('/recipes/library/beans-and-rice-1/show', { method: 'POST', as: boss, form: {}, headers: SAME });++ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1')).status, 200);++ assert.ok((await t.get('/recipes')).text.includes('href="/recipes/library"'));++ });++ } finally { await t.close(); }++ done();++})();
@@ -26,6 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
@@ -26,6 +26,7 @@ pantry with a deterministic "what can I make". Everything is server-rendered and
| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) |
| Pantry by barcode | [/pantry/barcode](server/http/pages.js), [server/food/barcode.js](server/food/barcode.js), [public/js/barcode-scan.js](public/js/barcode-scan.js) | Type a product's barcode, or scan it with the camera where the browser can read barcodes (the picture stays on the device). Open Food Facts names the product, and Food matches it to the item on its list it is (peanut butter is not butter, apple juice is not apples). The person confirms or picks another item before anything is added. Only the barcode is sent, with Food's User-Agent; answers are cached a day and capped at `FOOD_BARCODE_PER_MINUTE` (60) |
| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route |
| Pantry by photo or receipt | [/pantry/photo](server/http/pages.js), [public/js/pantry-photo.js](public/js/pantry-photo.js) | A photo of a cupboard, fridge or shelf, or a grocery receipt. The page makes it at most 1280 px on the device. OpenVibe.AI (`food.read_pantry_photo`) names the items from Food's list it clearly shows, and the person ticks what to add (unsure ones start unticked). AI keeps only the photo's hash; a receipt's prices, card and store details are never read out. Ten photos a person a day; nginx allows 4 MB on this one route |
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
| Recipe ideas | [/recipes](server/http/pages.js), [server/food/recipes.js](server/food/recipes.js) | Signed in, ask OpenVibe.AI's `food.suggest_recipes` workflow for ideas from your pantry; Food validates the answer against its own food list and prices the to-buy items from its typical prices. Clearly labelled AI-suggested |
+| Recipe library | [/recipes/library](server/http/library-routes.js), [server/food/library.js](server/food/library.js) | Every six hours the next of 35 themes goes to `food.suggest_recipes` with an empty cupboard, so every ingredient must be a food from the list (a recipe with anything else is dropped); Food prices what to buy from its typical prices. Labelled as written by AI, noindex until a member of staff marks one checked (then indexed with Recipe JSON-LD and in the sitemap); staff can hide one |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| Delivery, compared | [/delivery](server/http/pages.js), [server/food/delivery.js](server/food/delivery.js) | One query opens DoorDash, Uber Eats, Grubhub, Seamless, Caviar and Instacart's own searches in new tabs (URL forms checked in a real browser on 2026-10-15; Postmates is Uber Eats now, Gopuff, Slice and ezCater have no stable search link). Meal kits and prepared meals (EveryPlate, Dinnerly, HelloFresh, Home Chef, Marley Spoon, Blue Apron, Hungryroot, Green Chef, Purple Carrot, Factor) by tier, each with its plans page and an OpenVibe.Watch link for price changes. Food fetches none of them; published per-serving prices conflict between sources, so none are shown |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| The OpenStreetMap client | [server/food/upstream.js](server/food/upstream.js) | Paced, cached, identified, bounded. With the Open Food Facts barcode lookup (one product read per barcode, cached a day; data under the Open Database License, credited on the page), these are the only outbound calls besides OpenVibe's own services |
| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |
| Store | [server/food/store.js](server/food/store.js), [migrations/](migrations/) | `food_geo_cache`, `food_plans`, `food_pantry`, `food_recipe_suggestions` |
@@ -51,6 +52,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
@@ -51,6 +52,9 @@ Its own PostgreSQL tables, created by [migrations/](migrations/) and written by
requests, p95 latency and event-loop-lag gauges; `/shared`, static assets, health, readiness and `/metrics` excluded;
requests, p95 latency and event-loop-lag gauges; `/shared`, static assets, health, readiness and `/metrics` excluded;
rows older than 14 days deleted daily ([server/app.js](server/app.js) mounts it, [server/index.js](server/index.js) flushes and prunes).
rows older than 14 days deleted daily ([server/app.js](server/app.js) mounts it, [server/index.js](server/index.js) flushes and prunes).
+- `food_library_recipes`, `food_library_runs` (0007) — the recipe library: each kept recipe with its ingredients (food
+ list slugs), steps, what to buy and its typical cost, the AI run, and who checked it; one row per theme run.
+
0001 creates no tables. The service is the authority for these rows and for nothing else.
0001 creates no tables. The service is the authority for these rows and for nothing else.
## Does not own
## Does not own
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+-- phase: expand
+-- OpenVibe.Food's recipe library (server/food/library.js): budget recipes OpenVibe.AI wrote from Food's own food list
+-- (workflow food.suggest_recipes with an empty cupboard and a theme), each costed by Food from its typical prices.
+-- Labelled as AI; noindex until a member of staff marks it reviewed; staff may hide one.
+
+CREATE TABLE food_library_recipes (
+ id text COLLATE "C" PRIMARY KEY, -- lib_<random>
+ slug text COLLATE "C" NOT NULL UNIQUE,
+ title text NOT NULL,
+ theme text NOT NULL, -- what the AI was asked for
+ serves integer,
+ minutes integer,
+ ingredients jsonb NOT NULL, -- [{ name, amount, food }] food = a food list slug
+ steps jsonb NOT NULL, -- [text]
+ notes text,
+ to_buy jsonb NOT NULL, -- [{ slug, name, price }] from the food list, whole packages
+ cost numeric(10,2), -- the sum of the typical prices of to_buy
+ cost_partial boolean NOT NULL DEFAULT false, -- some food had no typical price
+ ai_run_id text,
+ status text NOT NULL DEFAULT 'published' CHECK (status IN ('published','hidden')),
+ reviewed_by text, -- usr_… of the staff member who checked it
+ reviewed_at text,
+ hidden_reason text,
+ created_at text COLLATE "C" NOT NULL -- ISO 8601, UTC
+);
+CREATE INDEX food_library_recipes_listing ON food_library_recipes (status, created_at DESC);
+
+-- One row per theme run, so a theme is not asked again before the others have had their turn.
+CREATE TABLE food_library_runs (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ theme text NOT NULL,
+ ai_run_id text,
+ made integer NOT NULL DEFAULT 0,
+ error text,
+ created_at text COLLATE "C" NOT NULL
+);
+CREATE INDEX food_library_runs_theme ON food_library_runs (theme, created_at DESC);
@@ -10,7 +10,7 @@
@@ -10,7 +10,7 @@
.recipe-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; border-bottom: 1px solid var(--border, #1f2d47); padding-bottom: 8px; }
.recipe-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 4px 12px; border-bottom: 1px solid var(--border, #1f2d47); padding-bottom: 8px; }
.recipe-head h3 { margin: 0; font-size: 1.15rem; }
.recipe-head h3 { margin: 0; font-size: 1.15rem; }
.recipe-meta { margin: 0; font-size: .85rem; color: var(--text-secondary, #96a7c2); }
.recipe-meta { margin: 0; font-size: .85rem; color: var(--text-secondary, #96a7c2); }
-.recipe h4 { margin: 12px 0 6px; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
+.recipe h4, .recipe .h4 { margin: 12px 0 6px; font-size: .8rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
.recipe-body { display: grid; gap: 4px 24px; }
.recipe-body { display: grid; gap: 4px 24px; }
@media (min-width: 760px) { .recipe-body { grid-template-columns: minmax(220px, 2fr) 3fr; } }
@media (min-width: 760px) { .recipe-body { grid-template-columns: minmax(220px, 2fr) 3fr; } }
.recipe-ingredients ul { list-style: none; padding: 0; margin: 0; }
.recipe-ingredients ul { list-style: none; padding: 0; margin: 0; }
@@ -24,3 +24,6 @@
@@ -24,3 +24,6 @@
.recipe-total { margin: 6px 0 2px; border-top: 1px solid var(--border-light, #2c3d5c); padding-top: 6px; }
.recipe-total { margin: 6px 0 2px; border-top: 1px solid var(--border-light, #2c3d5c); padding-top: 6px; }
.recipe-buy-none { color: var(--success, #22c55e); }
.recipe-buy-none { color: var(--success, #22c55e); }
.recipe-notes { margin-top: 10px; color: var(--text-secondary, #96a7c2); }
.recipe-notes { margin-top: 10px; color: var(--text-secondary, #96a7c2); }
+.lib-grid { list-style: none; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
+.lib-card h2 { margin: 0 0 6px; font-size: 1.1rem; }
+.lib-recipe .recipe-head h1 { margin: 0; font-size: 1.6rem; }
@@ -86,6 +86,8 @@ async function createApp(opts = {}) {
@@ -86,6 +86,8 @@ async function createApp(opts = {}) {
audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,
audience: 'openvibe.ai', scope: 'ai.run.create ai.run.read', fetchImpl,
}) : null;
}) : null;
ctx.ai = opts.ai !== undefined ? opts.ai : createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl });
ctx.ai = opts.ai !== undefined ? opts.ai : createAiClient({ baseUrl: config.aiUrl, tokenClient: aiTokenClient, fetch: fetchImpl });
+ // The recipe library (server/food/library.js): a theme every six hours where the process serves (server/index.js starts it).
+ ctx.library = require('./food/library').createLibrary({ s, ai: ctx.ai, log });
const app = express();
const app = express();
app.disable('x-powered-by');
app.disable('x-powered-by');
new file mode 100644
new file mode 100644
@@ -0,0 +1,134 @@
@@ -0,0 +1,134 @@
+'use strict';
+/**
+ * The recipe library (the owner's ask: "an AI recipe system" for OpenVibe.Food). Every few hours the next theme in THEMES
+ * goes to OpenVibe.AI's food.suggest_recipes with an empty cupboard, so every ingredient has to be a food from Food's own
+ * list (server/food/recipes.js validates that strictly; an ingredient the AI could not tie to the list drops the whole
+ * recipe here, so a recipe never quietly misses something). Food prices what there is to buy from its typical prices,
+ * never from anything the model said. Recipes are labelled as AI and stay noindex until a member of staff marks one
+ * reviewed; staff may hide one.
+ *
+ * tick() the next theme (the one asked least recently), when AI is configured
+ * list(), bySlug(), review(), setHidden()
+ */
+const crypto = require('crypto');
+const { buildSources, validateSuggestions } = require('./recipes');
+
+const THEMES = [
+ 'a one-pot dinner for four', 'a quick breakfast with eggs', 'a vegetarian chili', 'a packed lunch for work or school',
+ 'a rice bowl', 'a pasta dinner', 'a soup for a cold day', 'a meal with no cooking', 'a dinner made from canned food',
+ 'a high-protein meal on a budget', 'a sweet snack', 'something with potatoes', 'a bean dish', 'a breakfast without eggs',
+ 'a weeknight stir-fry', 'a sandwich that fills you up', 'a dinner children will eat', 'a meal from frozen vegetables',
+ 'tacos or burritos', 'a filling salad', 'something with bananas', 'a slow-simmered stew', 'a batch-cooked meal for the week',
+ 'a dairy-free dinner', 'something with peanut butter', 'a cheap dinner for two', 'a one-pan camping meal',
+ 'a microwave meal for a dorm room', 'something with oats', 'a tomato pasta sauce from scratch', 'fried rice',
+ 'a breakfast burrito', 'a chicken dinner', 'a dinner from canned fish', 'a dessert from pantry staples',
+];
+const EVERY_MS = 6 * 3_600_000;
+
+const clean = (v, n) => (v == null ? null : String(v).replace(/\s+/g, ' ').trim().slice(0, n) || null);
+const slugify = (s) => String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 70).replace(/-+$/, '');
+const parse = (v, d) => { if (v == null) return d; if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return d; } };
+const intIn = (v, lo, hi) => (Number.isInteger(v) && v >= lo && v <= hi ? v : null);
+
+function shape(r) {
+ return r ? {
+ ...r, ingredients: parse(r.ingredients, []), steps: parse(r.steps, []), to_buy: parse(r.to_buy, []),
+ cost: r.cost == null ? null : Number(r.cost), reviewed: Boolean(r.reviewed_by),
+ } : null;
+}
+
+function createLibrary({ s, ai, log = console }) {
+ const enabled = Boolean(ai && (ai.enabled === undefined || ai.enabled));
+
+ /** The theme asked least recently (never-asked first, in list order). */
+ async function nextTheme() {
+ const rows = await s.db.many('SELECT theme, max(created_at) AS last FROM food_library_runs GROUP BY theme');
+ const last = new Map(rows.map((r) => [r.theme, r.last]));
+ const never = THEMES.find((t) => !last.has(t));
+ if (never) return never;
+ return [...THEMES].sort((a, b) => (last.get(a) < last.get(b) ? -1 : 1))[0];
+ }
+
+ async function save(theme, runId, r) {
+ // Everything comes from the food list: a recipe the validator kept only through "from your cupboard" is dropped.
+ if (r.ingredients.some((i) => i.from_pantry || !i.citations.length)) return null;
+ const sources = buildSources();
+ const title = clean(r.title, 120);
+ const base = slugify(title);
+ if (!title || !base) return null;
+ if (await s.db.maybe('SELECT 1 FROM food_library_recipes WHERE slug = $1', [base])) return null; // the same dish again
+ const ingredients = r.ingredients.map((i) => ({ name: clean(i.name, 120) || '', amount: clean(i.amount, 60), food: (sources[i.citations[0]] || {}).source_id || null }));
+ const steps = r.steps.map((x) => clean(x, 600)).filter(Boolean).slice(0, 20);
+ const row = {
+ id: `lib_${crypto.randomBytes(8).toString('hex')}`, slug: base, title, theme, serves: intIn(r.serves, 1, 12), minutes: intIn(r.minutes, 1, 600),
+ ingredients, steps, notes: clean(r.notes, 600), to_buy: r.to_buy, cost: r.to_buy_cost, cost_partial: Boolean(r.to_buy_cost_partial),
+ };
+ await s.db.query(`INSERT INTO food_library_recipes (id, slug, title, theme, serves, minutes, ingredients, steps, notes, to_buy, cost, cost_partial, ai_run_id, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10::jsonb, $11, $12, $13, $14) ON CONFLICT (slug) DO NOTHING`,
+ [row.id, row.slug, row.title, theme, row.serves, row.minutes, JSON.stringify(ingredients), JSON.stringify(steps), row.notes, JSON.stringify(row.to_buy), row.cost, row.cost_partial, runId || null, s.iso()]);
+ return row;
+ }
+
+ /** One theme: ask, validate, cost, keep. Returns the recipes kept. */
+ async function make(theme) {
+ const input = { pantry: [], ask: `A budget recipe idea: ${theme}. Use only foods from the list.`, people: 4, sources: buildSources() };
+ let run = null;
+ try {
+ run = await ai.run('food.suggest_recipes', input, { target: { service: 'food', type: 'library', id: slugify(theme) } });
+ const out = validateSuggestions(run && run.output, { sources: input.sources, pantry: [] });
+ const kept = [];
+ for (const r of out.recipes) { const row = await save(theme, run && run.runId, r); if (row) kept.push(row); }
+ await s.db.query('INSERT INTO food_library_runs (theme, ai_run_id, made, created_at) VALUES ($1, $2, $3, $4)', [theme, (run && run.runId) || null, kept.length, s.iso()]);
+ return kept;
+ } catch (err) {
+ log.warn(`[OpenVibe.Food] recipe library (${theme}):`, err.message);
+ await s.db.query('INSERT INTO food_library_runs (theme, ai_run_id, made, error, created_at) VALUES ($1, $2, 0, $3, $4)', [theme, (run && run.runId) || null, clean(err.message, 300), s.iso()]);
+ return [];
+ }
+ }
+
+ let busy = false;
+ /** The next theme, unless one was asked less than six hours ago (a restart does not ask again early). */
+ async function tick({ force = false } = {}) {
+ if (!enabled || busy) return [];
+ busy = true;
+ try {
+ const last = await s.db.value('SELECT max(created_at) FROM food_library_runs');
+ if (!force && last && s.now() - Date.parse(last) < EVERY_MS - 10 * 60_000) return [];
+ return await make(await nextTheme());
+ } finally { busy = false; }
+ }
+
+ let timer = null;
+ let first = null;
+ return {
+ enabled, THEMES, tick, make, nextTheme,
+ async list({ limit = 24, offset = 0, staff = false } = {}) {
+ const states = staff ? ['published', 'hidden'] : ['published'];
+ const total = Number(await s.db.value('SELECT count(*) FROM food_library_recipes WHERE status = ANY($1::text[])', [states])) || 0;
+ const rows = await s.db.many('SELECT * FROM food_library_recipes WHERE status = ANY($1::text[]) ORDER BY created_at DESC, id LIMIT $2 OFFSET $3', [states, limit, offset]);
+ return { total, recipes: rows.map(shape) };
+ },
+ async reviewed() {
+ return (await s.db.many("SELECT * FROM food_library_recipes WHERE status = 'published' AND reviewed_by IS NOT NULL ORDER BY created_at DESC")).map(shape);
+ },
+ async bySlug(slug) { return shape(await s.db.maybe('SELECT * FROM food_library_recipes WHERE slug = $1', [String(slug)])); },
+ /** Staff: a person checked the recipe (it may now be indexed), or takes that back. */
+ async review(recipe, by, yes = true) {
+ await s.db.query('UPDATE food_library_recipes SET reviewed_by = $2, reviewed_at = $3 WHERE id = $1', [recipe.id, yes ? by : null, yes ? s.iso() : null]);
+ },
+ async setHidden(recipe, hidden, reason = null) {
+ await s.db.query('UPDATE food_library_recipes SET status = $2, hidden_reason = $3 WHERE id = $1', [recipe.id, hidden ? 'hidden' : 'published', hidden ? clean(reason, 300) : null]);
+ },
+ start() {
+ if (!enabled || timer) return;
+ timer = setInterval(() => { tick().catch(() => {}); }, EVERY_MS);
+ timer.unref();
+ first = setTimeout(() => { tick().catch(() => {}); }, 5 * 60_000);
+ first.unref();
+ },
+ stop() { clearInterval(timer); clearTimeout(first); timer = null; },
+ };
+}
+
+module.exports = { createLibrary, THEMES, slugify };
@@ -88,6 +88,7 @@ function createDiscoveryRoutes(ctx) {
@@ -88,6 +88,7 @@ function createDiscoveryRoutes(ctx) {
{ title: 'Food near you', url: abs('/near'), note: 'food banks and budget grocery stores, from OpenStreetMap' },
{ title: 'Food near you', url: abs('/near'), note: 'food banks and budget grocery stores, from OpenStreetMap' },
{ title: 'Meal plan', url: abs('/plan'), note: 'a shopping list and an estimated cost' },
{ title: 'Meal plan', url: abs('/plan'), note: 'a shopping list and an estimated cost' },
{ title: 'The food list', url: abs('/foods'), note: 'nutrition and typical prices' },
{ title: 'The food list', url: abs('/foods'), note: 'nutrition and typical prices' },
+ { title: 'Recipe library', url: abs('/recipes/library'), note: 'budget recipes written by OpenVibe.AI from the food list, each with what to buy and its typical cost; a recipe a person checked is marked so' },
{ title: 'What shipped on OpenVibe.Food', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Food', url: abs('/updates') },
] },
] },
{ title: 'Machine-readable', links: [
{ title: 'Machine-readable', links: [
@@ -120,9 +121,11 @@ function createDiscoveryRoutes(ctx) {
@@ -120,9 +121,11 @@ function createDiscoveryRoutes(ctx) {
}));
}));
});
});
- r.get('/sitemap.xml', (_req, res) => {
+ r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
const lastmod = siteUpdated();
const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
const urls = publicPages().map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ // Library recipes a person checked (the others are noindex until then).
+ if (ctx.library) for (const x of await ctx.library.reviewed()) urls.push({ loc: abs(`/recipes/library/${x.slug}`), lastmod: String(x.reviewed_at || x.created_at).slice(0, 10), changefreq: 'monthly', priority: 0.6 });
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+/**
+ * The recipe library's pages (server/food/library.js):
+ *
+ * GET /recipes/library every recipe, newest first (24 a page)
+ * GET /recipes/library/:slug one recipe: ingredients from the food list, steps, what to buy and its cost
+ * POST /recipes/library/:slug/review staff: a person checked it (indexed, with Recipe JSON-LD) or takes it back
+ * POST /recipes/library/:slug/hide|show staff
+ *
+ * Every page says the recipe was written by OpenVibe.AI; an unreviewed one is noindex.
+ */
+const showcase = require('openvibe-shared/showcase');
+const { html, notice, time } = require('../render/html');
+const { sameOrigin } = require('./principal');
+const { COST_NOTE } = require('../food/recipes');
+
+const PER_PAGE = 24;
+const money = (n) => (n == null ? '—' : `$${Number(n).toFixed(2)}`);
+const AI_LINE = 'Written by OpenVibe.AI from OpenVibe.Food\'s food list. Check a recipe before you cook it: cook meat, poultry and eggs through, and mind allergies.';
+
+function mountLibrary(r, { ctx, page, config, signedIn, express }) {
+ const lib = ctx.library;
+ const isStaff = (req) => Boolean(signedIn(req) && req.viewer.role === 'admin');
+ const meta = (x) => [x.serves ? `Serves ${x.serves}` : null, x.minutes ? `About ${x.minutes} min` : null, x.cost != null ? `${money(x.cost)} to buy${x.cost_partial ? '+' : ''}` : null].filter(Boolean).join(' · ');
+
+ r.get('/recipes/library', async (req, res) => {
+ const n = parseInt(req.query.page, 10);
+ const pageNo = Number.isInteger(n) && n > 0 ? Math.min(n, 500) : 1;
+ const staff = isStaff(req);
+ const { total, recipes } = await lib.list({ limit: PER_PAGE, offset: (pageNo - 1) * PER_PAGE, staff });
+ const pages = Math.max(1, Math.ceil(total / PER_PAGE));
+ page(req, res, {
+ title: 'Recipe library', index: false, localStyles: ['css/recipes.css'], styles: [showcase.STYLESHEET],
+ description: 'Budget recipes written by AI from OpenVibe.Food\'s food list, each with what to buy and its typical cost.',
+ crumbs: [{ label: 'Recipe ideas', href: '/recipes' }, { label: 'Library' }],
+ body: html`<h1>Recipe library</h1>
+<p class="lede">Cheap, simple recipes that use only foods from <a href="/foods">the food list</a>, so OpenVibe.Food can tell you what to buy and what it typically costs. A new batch is added every few hours.</p>
+<p class="notice">${AI_LINE}</p>
+${recipes.length ? html`<ol class="lib-grid">${recipes.map((x) => html`<li class="recipe lib-card"><h2><a href="/recipes/library/${x.slug}">${x.title}</a></h2>
+<p class="recipe-meta">${meta(x)}${x.reviewed ? ' · checked by a person' : ''}${x.status === 'hidden' ? ' · hidden' : ''}</p>
+<p class="muted small">${x.ingredients.slice(0, 6).map((i) => i.name).join(', ')}${x.ingredients.length > 6 ? '…' : ''}</p></li>`)}</ol>`
+ : html`<p class="muted">The first recipes are being written. Meanwhile, <a href="/recipes">get ideas from your own cupboard</a>.</p>`}
+${pages > 1 ? html`<nav class="pager" aria-label="Pages">${pageNo > 1 ? html`<a href="/recipes/library${pageNo > 2 ? `?page=${pageNo - 1}` : ''}">← Newer</a>` : ''} <span>Page ${pageNo} of ${pages}</span> ${pageNo < pages ? html`<a href="/recipes/library?page=${pageNo + 1}">Older →</a>` : ''}</nav>` : ''}`,
+ });
+ });
+
+ r.get('/recipes/library/:slug', async (req, res) => {
+ const x = await lib.bySlug(req.params.slug);
+ const staff = isStaff(req);
+ if (!x || (x.status === 'hidden' && !staff)) {
+ return page(req, res, { title: 'No such recipe', crumbs: [{ label: 'Recipe library', href: '/recipes/library' }, { label: 'Not found' }],
+ body: html`<h1>No such recipe</h1><p><a href="/recipes/library">See the library</a>.</p>` }, 404);
+ }
+ const url = `${config.baseUrl}/recipes/library/${x.slug}`;
+ const jsonLd = x.reviewed && x.status === 'published' ? [{
+ '@context': 'https://schema.org', '@type': 'Recipe', name: x.title, url,
+ recipeIngredient: x.ingredients.map((i) => [i.amount, i.name].filter(Boolean).join(' ')),
+ recipeInstructions: x.steps.map((t) => ({ '@type': 'HowToStep', text: t })),
+ ...(x.serves ? { recipeYield: `${x.serves} servings` } : {}), ...(x.minutes ? { totalTime: `PT${x.minutes}M` } : {}),
+ author: { '@type': 'Organization', name: 'OpenVibe.Food' }, datePublished: x.created_at,
+ }] : [];
+ page(req, res, {
+ title: x.title, index: Boolean(x.reviewed && x.status === 'published'), localStyles: ['css/recipes.css'], jsonLd,
+ description: `${x.title}: ${meta(x)}. A budget recipe from OpenVibe.Food's food list, written by AI.`,
+ crumbs: [{ label: 'Recipe library', href: '/recipes/library' }, { label: x.title }],
+ body: html`<article class="recipe lib-recipe">
+<header class="recipe-head"><h1>${x.title}</h1><p class="recipe-meta">${meta(x)}</p></header>
+<p class="notice">${AI_LINE}${x.reviewed ? ' A person at OpenVibe has checked this one.' : ''}</p>
+<div class="recipe-body">
+<section class="recipe-ingredients" aria-label="Ingredients"><h2 class="h4">Ingredients</h2>
+<ul>${x.ingredients.map((i) => html`<li><span>${i.food ? html`<a href="/foods/${i.food}">${i.name}</a>` : i.name}${i.amount ? html` <span class="muted">${i.amount}</span>` : ''}</span></li>`)}</ul></section>
+<section class="recipe-steps" aria-label="Steps"><h2 class="h4">Steps</h2><ol>${x.steps.map((t) => html`<li>${t}</li>`)}</ol></section>
+</div>
+${x.to_buy.length ? html`<div class="recipe-buy"><h2 class="h4">What to buy</h2>
+<ul>${x.to_buy.map((b) => html`<li><span><a href="/foods/${b.slug}">${b.name}</a></span><span>${b.price == null ? 'no typical price' : money(b.price)}</span></li>`)}</ul>
+<p class="recipe-total"><span>Typical total${x.cost_partial ? ' (items with a price)' : ''}</span><strong>${money(x.cost)}</strong></p>
+<p class="muted small">Whole packs: most leave plenty over. ${COST_NOTE}</p></div>` : ''}
+${x.notes ? html`<p class="recipe-notes small">${x.notes}</p>` : ''}
+<p class="muted small">Asked for: ${x.theme} · ${time(x.created_at)} · <a href="/recipes/library">More recipes</a> · <a href="/plan">Plan a week</a></p>
+${staff ? html`<form method="post" action="/recipes/library/${x.slug}/review" class="ov-form"><input type="hidden" name="yes" value="${x.reviewed ? '0' : '1'}"><button class="sc-btn" type="submit">${x.reviewed ? 'Take back the review' : 'I checked this recipe'}</button></form>
+<form method="post" action="/recipes/library/${x.slug}/${x.status === 'hidden' ? 'show' : 'hide'}" class="ov-form"><button class="sc-btn" type="submit">${x.status === 'hidden' ? 'Show it again' : 'Hide this recipe'}</button></form>` : ''}
+</article>`,
+ });
+ });
+
+ const form = express.urlencoded({ extended: false, limit: '4kb' });
+ async function staffAction(req, res, fn) {
+ if (!isStaff(req)) return res.status(403).type('text/plain').send('Staff only.');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Send this from openvibe.food itself.');
+ const x = await lib.bySlug(req.params.slug);
+ if (!x) return res.status(404).type('text/plain').send('Not found.');
+ await fn(x);
+ res.redirect(303, `/recipes/library/${x.slug}`);
+ }
+ r.post('/recipes/library/:slug/review', form, (req, res) => staffAction(req, res, (x) => lib.review(x, req.viewer.subject, req.body.yes !== '0')));
+ r.post('/recipes/library/:slug/hide', form, (req, res) => staffAction(req, res, (x) => lib.setHidden(x, true, req.body.reason)));
+ r.post('/recipes/library/:slug/show', form, (req, res) => staffAction(req, res, (x) => lib.setHidden(x, false)));
+ return { notice };
+}
+
+module.exports = { mountLibrary };
@@ -681,7 +681,7 @@ ${r.notes ? html`<div class="recipe-notes small">${raw(markdown(r.notes))}</div>
@@ -681,7 +681,7 @@ ${r.notes ? html`<div class="recipe-notes small">${raw(markdown(r.notes))}</div>
title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],
title: 'Recipe ideas', crumbs: [{ label: 'Recipe ideas' }], styles: [showcase.STYLESHEET], localStyles: ['css/recipes.css'],
body: html`<h1>Recipe ideas</h1>
body: html`<h1>Recipe ideas</h1>
<p class="lede">Tell OpenVibe.Food what you feel like and how many people you are feeding. It suggests up to three recipes from the food list, marks what is already in your cupboard and lists what you would still need to buy, with a typical cost.</p>
<p class="lede">Tell OpenVibe.Food what you feel like and how many people you are feeding. It suggests up to three recipes from the food list, marks what is already in your cupboard and lists what you would still need to buy, with a typical cost.</p>
-<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Frecipes">Sign in to get recipe ideas</a></p>`,
+<p><a class="sc-btn sc-primary" href="/auth/login?next=%2Frecipes">Sign in to get recipe ideas</a> <a class="sc-btn" href="/recipes/library">Browse the recipe library</a></p>`,
});
});
}
}
const rows = (await store.listRecipeSuggestions(s, ownerOf(req), { limit: 20 })).map(store.recipeSuggestionToWire);
const rows = (await store.listRecipeSuggestions(s, ownerOf(req), { limit: 20 })).map(store.recipeSuggestionToWire);
@@ -704,7 +704,7 @@ ${error ? notice(problems[error] || 'That did not work. Try again.', 'warn') : '
@@ -704,7 +704,7 @@ ${error ? notice(problems[error] || 'That did not work. Try again.', 'warn') : '
<input id="people" name="people" type="number" min="1" max="12" step="1" inputmode="numeric"></div>
<input id="people" name="people" type="number" min="1" max="12" step="1" inputmode="numeric"></div>
<button class="sc-btn sc-primary" type="submit">Suggest recipes</button>
<button class="sc-btn sc-primary" type="submit">Suggest recipes</button>
</form>
</form>
-<p class="muted small">Ideas come from what is in <a href="/pantry">your cupboard</a>. Up to 10 requests a day.</p>
+<p class="muted small">Ideas come from what is in <a href="/pantry">your cupboard</a>. Up to 10 requests a day. Or browse <a href="/recipes/library">the recipe library</a>.</p>
${rows.length ? html`<h2>Recent ideas</h2>
${rows.length ? html`<h2>Recent ideas</h2>
<p class="notice">Suggested by OpenVibe.AI from your cupboard and the food list. Check a recipe before you cook it: cook meat, poultry and eggs through, and mind allergies.</p>
<p class="notice">Suggested by OpenVibe.AI from your cupboard and the food list. Check a recipe before you cook it: cook meat, poultry and eggs through, and mind allergies.</p>
${rows.map((row) => html`<section class="recipe-suggestion">
${rows.map((row) => html`<section class="recipe-suggestion">
@@ -786,6 +786,8 @@ ${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-
@@ -786,6 +786,8 @@ ${links.length ? html`<ul class="dv-apps">${links.map((l) => html`<li class="dv-
// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt ──
r.use(createDiscoveryRoutes(ctx));
r.use(createDiscoveryRoutes(ctx));
+ // The recipe library (server/food/library.js, http/library-routes.js).
+ require('./library-routes').mountLibrary(r, { ctx, page, config, signedIn, express });
return r;
return r;
}
}
@@ -41,7 +41,9 @@ async function start() {
@@ -41,7 +41,9 @@ async function start() {
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without FOOD_EVENTS_URL and
// Subscribe to the two ADR-033 topics at OpenVibe.Events (idempotent; off without FOOD_EVENTS_URL and
// FOOD_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
// FOOD_EVENTS_SECRET). The consumer itself is mounted in server/app.js.
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
const subscriptions = require('./events-consumer').startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
- const extra = [() => { if (subscriptions) subscriptions.stop(); }];
+ // The recipe library writes a theme every six hours (server/food/library.js); off without OpenVibe.AI.
+ ctx.library.start();
+ const extra = [() => { if (subscriptions) subscriptions.stop(); }, () => ctx.library.stop()];
createLifecycle({ server, ctx, extra });
createLifecycle({ server, ctx, extra });
return { server, ctx };
return { server, ctx };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+/**
+ * The recipe library (server/food/library.js, server/http/library-routes.js):
+ * - a theme goes to food.suggest_recipes with an empty cupboard and Food's food list; a recipe with an ingredient not
+ * tied to the list is dropped, the rest are kept with Food's own costs; the same dish is not kept twice;
+ * - themes take turns; a restart does not ask again within six hours; a failure is recorded, nothing is made;
+ * - the pages say AI wrote it; unreviewed recipes are noindex, a reviewed one is indexed with Recipe JSON-LD;
+ * - only staff review, hide and show, and only from openvibe.food itself.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { buildSources } = require('../server/food/recipes');
+const { THEMES } = require('../server/food/library');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+const src = buildSources();
+const at = (slug) => src.findIndex((x) => x.source_id === slug);
+
+function aiStub() {
+ const calls = [];
+ let fail = null;
+ const ai = {
+ calls,
+ failNext(err) { fail = err; },
+ async run(workflow, input, opts) {
+ calls.push({ workflow, input, opts });
+ if (fail) { const e = fail; fail = null; throw e; }
+ const i = calls.length;
+ return {
+ runId: `run_lib_${i}`,
+ output: {
+ recipes: [
+ { title: i === 2 ? 'Beans and Rice' : `Beans and rice ${i}`, serves: 4, minutes: 25,
+ ingredients: [{ name: 'Rice', amount: '2 cups', from_pantry: false, citations: [at('white-rice')] }, { name: 'Beans', amount: '1 can', from_pantry: false, citations: [at('canned-beans')] }],
+ steps: ['Cook the rice.', 'Warm the beans.', 'Serve together.'], notes: 'Add hot sauce.' },
+ { title: 'Mystery stew', serves: 2, minutes: 40, ingredients: [{ name: 'Saffron', amount: 'a pinch', from_pantry: true, citations: [] }, { name: 'Rice', amount: '1 cup', from_pantry: false, citations: [at('white-rice')] }], steps: ['Stew it.'] },
+ ],
+ citations: [], gaps: [],
+ },
+ };
+ },
+ };
+ return ai;
+}
+
+(async () => {
+ const ai = aiStub();
+ const t = await boot({ env: { OV_AI_INTERNAL_URL: 'http://ai.test' }, ai });
+ const kim = t.network.addUser('kim');
+ const boss = t.network.addUser('boss', { role: 'admin' });
+ try {
+ await check('a theme becomes costed recipes from the food list; one leaning on "your cupboard" is dropped', async () => {
+ assert.ok(at('white-rice') >= 0 && at('canned-beans') >= 0, 'the fixture foods are in the list');
+ const kept = await t.ctx.library.tick({ force: true });
+ assert.deepStrictEqual(kept.map((x) => x.title), ['Beans and rice 1']);
+ const call = ai.calls[0];
+ assert.strictEqual(call.workflow, 'food.suggest_recipes');
+ assert.deepStrictEqual(call.input.pantry, []);
+ assert.ok(call.input.ask.includes(THEMES[0]));
+ assert.strictEqual(call.opts.target.type, 'library');
+ const row = await t.ctx.library.bySlug('beans-and-rice-1');
+ assert.deepStrictEqual(row.to_buy.map((b) => b.slug).sort(), ['canned-beans', 'white-rice']);
+ assert.ok(row.cost > 0);
+ assert.deepStrictEqual(row.ingredients.map((i) => i.food), ['white-rice', 'canned-beans']);
+ });
+
+ await check('themes take turns; a restart does not ask again early; a failure makes nothing', async () => {
+ assert.deepStrictEqual(await t.ctx.library.tick(), [], 'within six hours of the last run');
+ assert.strictEqual(ai.calls.length, 1);
+ assert.strictEqual(await t.ctx.library.nextTheme(), THEMES[1]);
+ await t.ctx.library.tick({ force: true });
+ assert.ok(ai.calls[1].input.ask.includes(THEMES[1]));
+ ai.failNext(new Error('provider down'));
+ assert.deepStrictEqual(await t.ctx.library.tick({ force: true }), []);
+ const runs = await t.ctx.s.db.many('SELECT theme, made, error FROM food_library_runs ORDER BY id');
+ assert.deepStrictEqual(runs.map((r) => [r.made, r.error]), [[1, null], [1, null], [0, 'provider down']]);
+ });
+
+ await check('the pages: labelled AI, noindex until reviewed; staff review makes it indexable with Recipe JSON-LD', async () => {
+ const list = await t.get('/recipes/library');
+ assert.strictEqual(list.status, 200);
+ assert.ok(list.text.includes('Written by OpenVibe.AI') && list.text.includes('href="/recipes/library/beans-and-rice-1"'));
+ const page = await t.get('/recipes/library/beans-and-rice-1');
+ assert.ok(page.text.includes('What to buy') && page.text.includes('href="/foods/white-rice"'));
+ assert.match(page.text, /<meta name="robots" content="noindex/);
+ assert.ok(!page.text.includes('"@type":"Recipe"'));
+ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1/review', { method: 'POST', as: kim, form: { yes: '1' }, headers: SAME })).status, 403);
+ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1/review', { method: 'POST', as: boss, form: { yes: '1' }, headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
+ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1/review', { method: 'POST', as: boss, form: { yes: '1' }, headers: SAME })).status, 303);
+ const reviewed = await t.get('/recipes/library/beans-and-rice-1');
+ assert.match(reviewed.text, /<meta name="robots" content="index/);
+ assert.ok(reviewed.text.includes('"@type":"Recipe"') && reviewed.text.includes('checked this one'));
+ assert.ok((await t.get('/sitemap.xml')).text.includes('/recipes/library/beans-and-rice-1</loc>'), 'a reviewed recipe is in the sitemap');
+ await t.get('/recipes/library/beans-and-rice-1/hide', { method: 'POST', as: boss, form: {}, headers: SAME });
+ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1')).status, 404);
+ assert.ok((await t.get('/recipes/library/beans-and-rice-1', { as: boss })).text.includes('Show it again'));
+ await t.get('/recipes/library/beans-and-rice-1/show', { method: 'POST', as: boss, form: {}, headers: SAME });
+ assert.strictEqual((await t.get('/recipes/library/beans-and-rice-1')).status, 200);
+ assert.ok((await t.get('/recipes')).text.includes('href="/recipes/library"'));
+ });
+ } finally { await t.close(); }
+ done();
+})();