feat: cooking assistant can propose adding items to a shopping list

Second tool alongside createRecipe, same safety shape: addToShoppingList's
execute only validates/echoes input, no DB write. The proposal card lets
the user pick an existing list (fetched lazily) or name a new one, then
confirms through the exact two-call flow AddToShoppingListButton already
uses — POST /api/v1/shopping-lists (if new) then POST .../items — no new
persistence code path.

generateMealPlan intentionally not built as a tool: the existing
/api/v1/ai/meal-plan/generate endpoint generates and writes in one step
with a week/preferences input, not a specific plan payload, so it has no
"save this exact draft" entry point to confirm against without a larger
refactor. Documented as a known gap rather than forcing a weaker pattern.

v0.46.0
This commit is contained in:
Arnaud
2026-07-17 18:37:07 +02:00
parent 982d4e3264
commit 9eecdbac3c
11 changed files with 244 additions and 11 deletions
+5 -1
View File
@@ -320,7 +320,11 @@ export function generateOpenApiSpec(): object {
ingredients: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })),
steps: z.array(z.object({ instruction: z.string() })),
}));
registry.registerPath({ method: "post", path: "/api/v1/ai/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history. Pass conversationId (from POST /api/v1/ai/conversations) to group messages into a named conversation and auto-title it from the first question. If the user asks the assistant to create/save a recipe, the response may include proposedRecipe — a draft the assistant proposed but did NOT save; the caller must POST it to /api/v1/recipes itself to actually create it.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500), conversationId: z.string().uuid().optional() }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string(), proposedRecipe: ProposedRecipeRef.optional() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
const ProposedShoppingListRef = registry.register("ProposedShoppingList", z.object({
items: z.array(z.object({ rawName: z.string(), quantity: z.number().optional(), unit: z.string().optional() })),
suggestedListName: z.string().optional(),
}));
registry.registerPath({ method: "post", path: "/api/v1/ai/cooking-chat", summary: "Ask the general (not recipe-specific) cooking assistant a question", description: "Rate-limited: 30 req/min. Consumes AI quota. Returns a single JSON response (not streamed); persists the exchange to chat history. Pass conversationId (from POST /api/v1/ai/conversations) to group messages into a named conversation and auto-title it from the first question. If the user asks the assistant to create/save a recipe or add items to a shopping list, the response may include proposedRecipe / proposedShoppingList — drafts the assistant proposed but did NOT save; the caller must POST them itself (to /api/v1/recipes, or /api/v1/shopping-lists + /api/v1/shopping-lists/{id}/items) to actually create anything.", security, request: { body: { content: { "application/json": { schema: z.object({ question: z.string().min(1).max(500), conversationId: z.string().uuid().optional() }) } }, required: true } }, responses: { 200: { description: "Answer", content: { "application/json": { schema: z.object({ answer: z.string(), proposedRecipe: ProposedRecipeRef.optional(), proposedShoppingList: ProposedShoppingListRef.optional() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited or AI quota exhausted", content: { "application/json": { schema: ApiErrorRef } } } } });
const AiConversationRef = registry.register("AiConversation", z.object({
id: z.string(), title: z.string().nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime(),
}));