openvibers/openvibe.ai Commits

AI: food.suggest_recipes workflow (recipes from a pantry, ingredients cited to Food's food list) (#57)

OP OpenVibers committed 12 Oct 2026 · db67f1be03 · 3 files, +20 −4

Adds the `food.suggest_recipes` workflow (namespace `food`, route `food.suggest`), for OpenVibe.Food's recipes pillar.

- **Input:** the person's pantry (up to 60 items), an optional count of people and request, and at least one numbered source: rows from Food's food list.
- **Output:** up to three recipes, each with a title, servings, minutes, ingredients and steps.
  - Each ingredient is either `from_pantry`, or cites the food row it uses.
  - A recipe that needs an unlisted food is left out, and the missing food is named in `gaps`.
- **No invented numbers:** the prompt forbids prices, calories, nutrition and health claims; Food computes costs from its own data.
- The `food.suggest` route joins the historical product routes, as an alias of `default.json`.
- `test/workflows.test.js` includes the new workflow, its route and an example; it passes 10/10.

Food calls this workflow with its service token, which needs a Network grant for namespace `food`.

Changes

ddiff --git a/server/workflows/products.js b/server/workflows/products.js
iindex f0145c1..4b204d1 100644
---- a/server/workflows/products.js
++++ b/server/workflows/products.js
@@@ -3,7 +3,7 @@
* Seed workflows the publication products consume (plan §15.13). Every one returns a DRAFT /
* EVIDENCE package — never publication truth — with citations that index into the numbered
* input.sources, plus `gaps` for what the sources do not support. The owning product (Wiki,
-- * Blog, News, Reviews, Deals, Coupons, Trade, Codes, Games, Tools) decides what to publish.
++ * Blog, News, Reviews, Deals, Coupons, Trade, Codes, Games, Tools, Food) decides what to publish.
*
* Hard rules carried into the prompts and the schemas:
* - no invented facts, prices, ratings, dates, quotes (Reviews has no rating field at all;
@@@ -55,6 +55,8 @@ const templates = [
'World: {{world}}\nRequest: {{prompt}}\n\n'),
productTemplate('tools.describe', 'Tools: describe a tool', 'Describe one OpenVibe tool for the people who use it, from its descriptor (the numbered sources: its registry entry, inputs, outputs and limits): a one-line summary, what it does, how to use it step by step, what each input and output means, its limits, and a short FAQ. Cite the descriptor fields each statement rests on. Do not claim formats, sizes or features the descriptor does not list; say what is unknown in gaps.', 'tools.describe',
'Tool: {{tool}}{{#audience}}\nAudience: {{audience}}{{/audience}}\n\n'),
++ productTemplate('food.suggest_recipes', 'Food: suggest recipes', 'Suggest up to three simple, low-cost recipes the person can cook, built first from their pantry. An ingredient from the pantry has from_pantry true and needs no citation. Every other ingredient must be one of the foods in the numbered sources (OpenVibe.Food\'s food list) and cite it; if a recipe needs something the sources do not list, leave that recipe out and name the missing food in gaps. State amounts in everyday units. Never state prices, calories, nutrition or health claims: the app works out costs from its own data. Keep steps short and safe (cook meat, poultry and eggs through).', 'food.suggest',
++ 'Pantry: {{json pantry}}{{#people}}\nCooking for: {{people}}{{/people}}{{#ask}}\nRequest: {{ask}}{{/ask}}\n\n'),
productTemplate('moderation.classify', 'Moderation: classify content', 'Classify the content under OpenVibe policy. Profanity, insults, crude jokes and trash talk are ALLOWED and are not violations. Flag only: credible threats of violence, any sexual content involving minors, doxxing (sharing private personal information), targeted harassment campaigns, spam/scams, sexual content where not allowed, and self-harm encouragement. Quote the exact spans you flag. When unsure, choose review, not block.', 'moderation.classify',
'{{#context}}Context: {{context}}\n\n{{/context}}Content to classify:\n"""\n{{text}}\n"""\n\n'),
];
@@@ -156,6 +158,19 @@ const workflows = [
}),
default_route: 'tools.describe',
},
++ {
++ key: 'food.suggest_recipes', name: 'Suggest recipes from a pantry', namespace: 'food',
++ input_schema: OBJ({ pantry: ARR(STR(120), 60), people: { type: 'integer', minimum: 1, maximum: 12 }, ask: STR(500), sources: SOURCES(1) }, ['pantry', 'sources']),
++ output_schema: OBJ({
++ recipes: ARR(OBJ({
++ title: STR(160), serves: { type: 'integer', minimum: 1, maximum: 12 }, minutes: { type: 'integer', minimum: 1, maximum: 600 },
++ ingredients: ARR(OBJ({ name: STR(120), amount: STR(80), from_pantry: { type: 'boolean' }, citations: CITES }), 25),
++ steps: ARR(STR(600), 20), notes: STR(600),
++ }), 3),
++ citations: CITES, gaps: GAPS,
++ }),
++ default_route: 'food.suggest',
++ },
{
key: 'moderation.classify', name: 'Classify content for moderation', namespace: 'moderation',
input_schema: OBJ({ text: STR(8000), context: STR(1000), sources: SOURCES(0) }, ['text']),
ddiff --git a/server/workflows/seed.js b/server/workflows/seed.js
iindex cbb0f1e..e40d993 100644
---- a/server/workflows/seed.js
++++ b/server/workflows/seed.js
@@@ -21,7 +21,7 @@ const products = require('./products');
const { resolveSecret } = require('../util');
const ROLE_TIMEOUT = { chat: 20000, vision: 30000, director: 25000, summary: 30000, legacy: 30000 };
--const HISTORICAL_ROUTES = ['wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe'];
++const HISTORICAL_ROUTES = ['wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest'];
function sharedProviderRecord(config, env) {
const s = config.shared;
ddiff --git a/test/workflows.test.js b/test/workflows.test.js
iindex cfb4027..fd838b6 100644
---- a/test/workflows.test.js
++++ b/test/workflows.test.js
@@@ -44,6 +44,7 @@ const EXAMPLES = {
'codes.generate_docs': { project: 'demo', sources: [{ source_type: 'code.file', title: 'index.js', content: 'module.exports = 1;' }] },
'games.generate_lore': { world: 'Hobo Quest', prompt: 'The origin of the tin-can knights', sources: SRC(1) },
'moderation.classify': { text: 'you absolute potato, gg', context: 'arena chat' },
++ 'food.suggest_recipes': { pantry: ['rice', 'onions', 'canned tomatoes'], people: 2, sources: [{ source_type: 'food.food', source_id: 'lentils', title: 'Lentils, dried', content: '{"id":"lentils","unit":"kg"}' }] },
'tools.describe': { tool: 'img.convert', sources: [{ source_type: 'tools.tool', title: 'img.convert descriptor', content: '{"id":"img.convert","inputs":["file","format"]}' }] },
};
for (const k of new Set(Object.values(KIND_TO_WORKFLOW).concat('live.complete'))) EXAMPLES[k] = PASS;
@@@ -87,11 +88,11 @@ t.test('boot seeds every required workflow key', async () => {
h = await boot({ env: { OV_MEDIA_INTERNAL_URL: mediaBase } });
const keys = (await h.registry.listWorkflows()).map(w => w.key);
for (const k of ['wiki.generate_space', 'wiki.generate_page', 'blog.draft_post', 'news.summarize_story', 'news.compare_perspectives', 'reviews.summarize_entity', 'deals.enrich_deal',
-- 'coupons.extract_coupon', 'trade.summarize_market_context', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe',
++ 'coupons.extract_coupon', 'trade.summarize_market_context', 'codes.generate_docs', 'games.generate_lore', 'moderation.classify', 'tools.describe', 'food.suggest_recipes',
'live.translate', 'live.stream.describe_frame', 'live.stream.summarize', 'live.viewers.reply', 'live.chat.insight', 'live.hero.slogans', 'live.stream.recap', 'network.site_copy']) {
assert.ok(keys.includes(k), `missing ${k}`);
}
-- for (const k of ['default.chat', 'default.json', 'default.embedding', 'wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'tools.describe', 'moderation.classify']) {
++ for (const k of ['default.chat', 'default.json', 'default.embedding', 'wiki.generate', 'blog.draft', 'news.summarize', 'reviews.summarize', 'deals.enrich', 'coupons.extract', 'trade.summarize', 'codes.generate_docs', 'games.generate_lore', 'tools.describe', 'moderation.classify', 'food.suggest']) {
assert.ok(await h.registry.getRoute(k), `missing historical route ${k}`);
}
});