Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 19 additions & 12 deletions docs/dev/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ unaffected.
schemas (name, description, JSON Schema parameters). Schema only;
the browser never executes a recipe tool.
- `supabase/functions/venice/tools/recipe_save.ts`, `recipe_list.ts`,
`recipe_get.ts`, `recipe_update.ts`, `recipe_delete.ts`, and
`recipe_get.ts`, `recipe_delete.ts`, and
`recipe_photos.ts` (which carries all four photo verbs) — the
implementations, dispatched function-side against the admin client.
`_recipe_helpers.ts` holds `readRecipePhotoMeta`, the "newest
version's link set" read that `recipe_get` and `recipe_update`
version's link set" read that `recipe_get` and the save tool's edit form
both answer photo questions with. Mutating tools reach the UI
through the realtime relay, not `notifyCookbookChanged` - see the
relay gotcha below.
Expand Down Expand Up @@ -169,7 +169,7 @@ unaffected.
fetches from Supabase; subsequent switches are free). Clicking a
row opens the Cookbook modal on the detail pane for that id via
the `initialRecipeId` prop.
- **LLM tool calls** - `recipe_save / list / get / update / delete`,
- **LLM tool calls** - `recipe_save (create or edit) / list / get / delete`,
grouped into the `cooking` toolbox. Every tool is declared on
every request (no gating); the model calls them directly.

Expand All @@ -187,7 +187,14 @@ unaffected.
partial `recipes_user_upcoming_idx (user_id) where upcoming` and
`recipes_user_favorite_idx (user_id) where favorite` to keep the
"list upcoming" / "list favorites" paths cheap while most rows
aren't bookmarked.
aren't bookmarked. `recipes_user_title_unique` - a unique index
on `(user_id, lower(btrim(title)))`: duplicate titles read as
bugs (two cards that drift apart as one gets edited), and the
uniqueness key is what the `recipe_save` tool's natural-key
dedup probes against - an exact-title create routes to that
recipe's update, a near-match is refused with candidates named.
Enforced as an index because Postgres only accepts expressions in
index form.
- RLS: four self-* policies (select / insert / update / delete),
same shape as `memories`.
- **Bookmark flags** (`upcoming`, `favorite`): not in
Expand Down Expand Up @@ -308,7 +315,7 @@ unaffected.
- A mutating tool's response must describe state it actually read
back, not the shape the caller asked for. See the echoed-row gotcha
below for the two fields this went wrong on.
- **The rating is user-only.** `recipe_update` accepts no `rating`
- **The rating is user-only.** `recipe_save` accepts no `rating`
argument and always passes `p_set_rating: false`; a call that
carries one is rejected with an explanatory error. The rating is a
user evaluation of a cooked dish, so it moves only through the UI
Expand All @@ -326,7 +333,7 @@ Two writes per mutation, one transaction: both
plpgsql RPCs, so either both rows land or neither does.

`change_message` is required at the `SupabaseService.createRecipe` /
`updateRecipe` layer (and on `recipe_update`, where a meaningful delta
`updateRecipe` layer (and on the save tool's edit form, where a meaningful delta
description exists). The `recipe_save` tool is the one exception: a
save is always a recipe's first version, so an omitted message has no
delta to describe and the tool defaults it to `"Initial version"`
Expand All @@ -353,7 +360,7 @@ under a megabyte).
**Atomicity**: `recipe_update_with_version` takes a `for update`
lock on the parent row before snapshotting, so concurrent writers
(the user editing in the modal while the model also calls
`recipe_update`) serialize. The first writer commits its snapshot;
the save tool's edit form) serialize. The first writer commits its snapshot;
the second sees the post-first-commit state and snapshots that. No
gaps in the history chain, no surprise overwrites.

Expand All @@ -366,10 +373,10 @@ recoverable.

**No history LLM tools** (deliberately): the model has no
`recipe_versions_list` or `recipe_revert` tool, only the existing
`recipe_save` / `recipe_update` / etc. History viewing and revert
`recipe_save` / etc. History viewing and revert
are user-directed UX flows; letting the model revert without an
explicit user prompt is a footgun without a clear win. The model
can already author whatever content it wants via `recipe_update`,
can already author whatever content it wants via the save tool's edit form,
and the user has revert in the modal. Revisit if a need surfaces.

**Backfill**: existing recipes that predate the rollout get one
Expand Down Expand Up @@ -578,7 +585,7 @@ keystrokes; the LLM tool path keeps using `listRecipes`.
their own form-row between the change-message field and the
cooklang+preview panes.
- **A tool's echoed row is a claim about live state - read it back.**
`recipe_update` answered with a hardcoded `photos: []` and echoed the
the edit form answered with a hardcoded `photos: []` and echoed the
`topics` column, and both read as data loss to the model, which
relayed "your photos and tags are gone" to the user after an edit
that had preserved every one of them. Photos: the RPC inherits the
Expand All @@ -593,7 +600,7 @@ keystrokes; the LLM tool path keeps using `listRecipes`.
only ever means "re-queued." `recipe_save`'s `photos: []` is a
different case and stays: a create passes `p_image_ids: null`, so
the recipe genuinely has no photos yet.
`tests/recipe_update.test.ts` guards both.
`tests/recipe_save_edit_form.test.ts` guards both.
- **Photo IDs are stable across versions.** A photo upserted into
`recipe_images` keeps the same id forever for that user;
reordering or appending changes the link rows, not the image
Expand Down Expand Up @@ -652,7 +659,7 @@ keystrokes; the LLM tool path keeps using `listRecipes`.
markdown exports append " (optional)" to the bullet, and step
prose shows just the name. Ingredients only - cooklang-rs also
allows `#?cookware`, but nak's flat cookware aside has nothing to
hang optionality off. The `recipe_save` / `recipe_update` tool
hang optionality off. The `recipe_save` tool
descriptions teach the model the syntax; keep them in sync if the
rendering changes.
- **Dash-only section reset.** A line whose non-whitespace content is
Expand Down
4 changes: 3 additions & 1 deletion docs/user/cookbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ Cooklang form.

If Nak tells you it can't save a recipe, check that the recipe text
actually made it into the chat - the tool needs the ingredients and
steps to come from somewhere.
steps to come from somewhere. Recipe titles are unique - asking Nak
to save "the same recipe" twice updates the existing card rather
than creating a second one.

## Jumping around a recipe

Expand Down
2 changes: 1 addition & 1 deletion src/lib/cooklang.ts
Original file line number Diff line number Diff line change
Expand Up @@ -711,7 +711,7 @@ export type { RecipeHtmlOptions, RecipeTocSection, RecipeTocEntry } from './cook

/**
* Catch the LLM-authoring quirks the parser tolerates but the renderer
* can't make readable. Called from `recipe_save` and `recipe_update`
* can't make readable. Called from the recipe_save tool (both forms)
* BEFORE the write hits the DB, so a malformed save fails at the tool
* surface and the LLM gets a corrective error it can act on — far
* cheaper than silently storing source that renders wrong and waiting
Expand Down
2 changes: 1 addition & 1 deletion src/lib/recipe-limits.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Recipe length limits. Lifted out of `cooklang.ts` so the always-on
* `recipe_save` / `recipe_update` tool schemas can reach them without
* `recipe_save` tool schema can reach them without
* pulling the 14 kB Cooklang parser into the main chunk - the parser
* is needed only on the Cookbook screen (lazy) and at recipe-save
* time (also reached via lazy tool impls).
Expand Down
2 changes: 1 addition & 1 deletion src/lib/supabase/cookbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ export async function createRecipe(

/**
* Partial update. Caller guarantees at least one field in `patch`
* is set - enforced by the recipe_update tool and the Cookbook
* is set - enforced by the recipe_save tool and the Cookbook
* Edit pane before this method runs. Goes through
* `recipe_update_with_version` so the prior state is snapshotted
* into `recipe_versions` in the same transaction. `changeMessage`
Expand Down
2 changes: 1 addition & 1 deletion src/lib/supabase/types/cookbook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export interface Recipe {
* the trail of past states the user can browse and revert to.
*
* `change_message` is required - the UI Edit form and the LLM
* `recipe_save` / `recipe_update` tools all force a non-empty value
* `recipe_save` tool (both forms) all force a non-empty value
* before the RPC is called.
*/
export interface RecipeVersion {
Expand Down
3 changes: 0 additions & 3 deletions src/lib/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ import { conversationGetSchema } from './conversation_get.schema';
import { recipeListSchema } from './recipe_list.schema';
import { recipeGetSchema } from './recipe_get.schema';
import { recipeSaveSchema } from './recipe_save.schema';
import { recipeUpdateSchema } from './recipe_update.schema';
import { recipeDeleteSchema } from './recipe_delete.schema';
import { recipePhotosAttachSchema } from './recipe_photos_attach.schema';
import { recipePhotosRemoveSchema } from './recipe_photos_remove.schema';
Expand Down Expand Up @@ -150,7 +149,6 @@ const conversationGet = serverSideTool(conversationGetSchema);
const recipeList = serverSideTool(recipeListSchema);
const recipeGet = serverSideTool(recipeGetSchema);
const recipeSave = serverSideTool(recipeSaveSchema);
const recipeUpdate = serverSideTool(recipeUpdateSchema);
const recipeDelete = serverSideTool(recipeDeleteSchema);
const recipePhotosAttach = serverSideTool(recipePhotosAttachSchema);
const recipePhotosRemove = serverSideTool(recipePhotosRemoveSchema);
Expand Down Expand Up @@ -310,7 +308,6 @@ export const cookingToolbox: Toolbox = {
'recipe_get) are always-on; this toolbox carries the writes.',
tools: [
recipeSave,
recipeUpdate,
recipeDelete,
recipePhotosAttach,
recipePhotosRemove,
Expand Down
19 changes: 6 additions & 13 deletions src/lib/tools/recipe_photo_label_set.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,9 @@ export const recipePhotoLabelSetSchema = {
"Set or clear captions on a recipe's existing photos. labels is " +
'an array of {photo_id, label} pairs; each sets the caption to ' +
'the given string, or clears it when label is null/empty. Every ' +
'photo_id must be on the recipe (use recipe_get to find ids). ' +
'Photos not named keep their existing captions. Max 200 chars per ' +
'caption. To add or remove photos use recipe_photos_attach or ' +
'recipe_photos_remove. change_message REQUIRED. Returns ' +
"{recipe_id, photos: [{id, position, label}, ...]} - the recipe's " +
'full ordered photo set with the new captions.',
'photo_id must be on the recipe (from recipe_get); photos not ' +
'named keep their captions. Returns the full ordered photo set ' +
'with the new captions.',
shortDescription: 'set or clear photo captions on a recipe',
parameters: {
type: 'object',
Expand All @@ -33,15 +30,12 @@ export const recipePhotoLabelSetSchema = {
photo_id: {
type: 'string',
minLength: 1,
description:
'Photo id to retitle. Must be on the recipe (use ' +
'recipe_get to find ids).',
description: 'Photo id to retitle (from recipe_get).',
},
label: {
type: ['string', 'null'],
maxLength: RECIPE_PHOTO_LABEL_MAX_CHARS,
description:
'New caption, or null/empty to clear. Max 200 chars.',
description: 'New caption, or null/empty to clear.',
},
},
required: ['photo_id'],
Expand All @@ -56,8 +50,7 @@ export const recipePhotoLabelSetSchema = {
minLength: 1,
maxLength: 500,
description:
'One-line history note; lands in the recipe changelog the user reviews. Examples: "Captioned the finished ' +
'plate", "Cleared the obsolete progress-shot caption".',
'One-line history note; lands in the recipe changelog the user reviews.',
},
},
required: ['recipe_id', 'labels', 'change_message'],
Expand Down
20 changes: 5 additions & 15 deletions src/lib/tools/recipe_photos_attach.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,9 @@ export const recipePhotosAttachSchema = {
'filenames lists conversation-attachment filenames in display ' +
'order (must match <thread_attachments> exactly, case-sensitive); ' +
'each must be live (not expired). Photos already on the recipe ' +
'are not duplicated; the array appends. Optional labels is ' +
'parallel-indexed with filenames (labels[i] captions ' +
'filenames[i]); pass empty/null for no caption, omit the array ' +
'entirely when no photo gets a caption. Re-attaching a filename ' +
'are not duplicated; the array appends. Re-attaching a filename ' +
'already on the recipe with a non-empty label updates that ' +
"photo's caption. Use recipe_photos_remove to drop, " +
'recipe_photos_reorder to reorder, recipe_photo_label_set to ' +
'recaption photos already on the recipe. change_message REQUIRED. ' +
'Returns {recipe_id, photos: [{id, position, label}, ...]} - ' +
'the post-attach full ordered set.',
"photo's caption. Returns the post-attach full ordered photo set.",
shortDescription: 'attach conversation images to a recipe',
parameters: {
type: 'object',
Expand All @@ -39,18 +32,15 @@ export const recipePhotosAttachSchema = {
type: 'array',
items: { type: ['string', 'null'], maxLength: 200 },
description:
'Optional captions parallel-indexed with filenames. Length ' +
'MUST match filenames when provided; pass empty/null per ' +
'photo for no caption. Omit the field entirely if none get ' +
'captions.',
'Optional captions, parallel-indexed with filenames (same ' +
'length). Omit if none get captions.',
},
change_message: {
type: 'string',
minLength: 1,
maxLength: 500,
description:
'One-line history note; lands in the recipe changelog the user reviews. Examples: "Added the finished plate ' +
'photo", "Saved the dough progress shot".',
'One-line history note; lands in the recipe changelog the user reviews.',
},
},
required: ['recipe_id', 'filenames', 'change_message'],
Expand Down
13 changes: 5 additions & 8 deletions src/lib/tools/recipe_photos_remove.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,10 @@
export const recipePhotosRemoveSchema = {
name: 'recipe_photos_remove',
description:
'Remove one or more photos from a recipe by photo id (the id ' +
"field on each entry of recipe_get's photos array). Every id " +
'must be on the recipe; an unknown id fails the call rather than ' +
'silently skipping. change_message REQUIRED. Returns ' +
'{recipe_id, photos: [{id, position, label}, ...]} - the ' +
'post-removal full ordered set with surviving captions preserved.',
'Remove one or more photos from a recipe by photo id (from ' +
"recipe_get's photos array). Every id must be on the recipe; an " +
'unknown id fails the call rather than silently skipping. Returns ' +
'the post-removal full ordered photo set.',
shortDescription: 'remove photos from a recipe by id',
parameters: {
type: 'object',
Expand All @@ -31,8 +29,7 @@ export const recipePhotosRemoveSchema = {
minLength: 1,
maxLength: 500,
description:
'One-line history note; lands in the recipe changelog the user reviews. Examples: "Removed the blurry first ' +
'attempt", "Dropped the redundant overhead shot".',
'One-line history note; lands in the recipe changelog the user reviews.',
},
},
required: ['recipe_id', 'photo_ids', 'change_message'],
Expand Down
14 changes: 5 additions & 9 deletions src/lib/tools/recipe_photos_reorder.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,11 @@
export const recipePhotosReorderSchema = {
name: 'recipe_photos_reorder',
description:
"Set a recipe's photo display order. photo_ids MUST be a " +
"Set a recipe's photo display order. photo_ids must be a " +
"permutation of the recipe's current photo set (every id present, " +
'no missing, no extras, no duplicates). Call recipe_get first to ' +
'read the current order. To add or remove use ' +
'recipe_photos_attach or recipe_photos_remove; to recaption use ' +
'recipe_photo_label_set. Captions travel with their photos. ' +
'change_message REQUIRED. Returns {recipe_id, photos: [{id, ' +
'position, label}, ...]} with positions renumbered from 0.',
'no extras, no duplicates; call recipe_get to read the current ' +
'order). Captions travel with their photos. Returns the reordered ' +
'set with positions renumbered from 0.',
shortDescription: 'reorder a recipe\'s photos',
parameters: {
type: 'object',
Expand All @@ -34,8 +31,7 @@ export const recipePhotosReorderSchema = {
minLength: 1,
maxLength: 500,
description:
'One-line history note; lands in the recipe changelog the user reviews. Examples: "Moved the finished plate ' +
'first", "Grouped prep shots before the served photo".',
'One-line history note; lands in the recipe changelog the user reviews.',
},
},
required: ['recipe_id', 'photo_ids', 'change_message'],
Expand Down
Loading