diff --git a/CHANGELOG.md b/CHANGELOG.md index 4269637..d41c8f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.57.1 — 2026-07-20 20:00 + +### Fixed +- Chatbot sometimes answered "create a recipe" requests with the recipe spelled out as prose instead of the actual draft card (weaker/free-tier models occasionally ignore the tool-calling instruction). Now detects that shape and forces one retry with the tool call required, so the draft card still appears. + ## 0.57.0 — 2026-07-20 09:20 ### Added diff --git a/apps/web/app/api/v1/ai/cooking-chat/route.ts b/apps/web/app/api/v1/ai/cooking-chat/route.ts index 570a7e3..fddfff1 100644 --- a/apps/web/app/api/v1/ai/cooking-chat/route.ts +++ b/apps/web/app/api/v1/ai/cooking-chat/route.ts @@ -18,6 +18,25 @@ const Schema = z.object({ const LANG: Record = { en: "English", fr: "French" }; +// Forcing toolChoice to a specific tool makes some providers return the tool +// call with no accompanying text at all (their "forced function calling" is +// content-free by design) — the chat needs some reply, so fall back to this +// rather than showing an empty assistant bubble. +const DRAFT_FALLBACK_TEXT: Record = { + en: "Here's a draft — check it below and confirm if it looks right.", + fr: "Voici un brouillon — vérifie-le ci-dessous et confirme si ça te convient.", +}; + +/** Weaker/free-tier models sometimes ignore the system prompt's "call the + * tool, don't write it out" rule and answer with the recipe spelled out as + * a numbered/bulleted list instead. That's the exact shape the tool call + * itself would have captured structurally — a reliable enough signal to + * self-correct with a forced-tool retry rather than surfacing prose. */ +function looksLikeUnstructuredRecipe(text: string): boolean { + const listLines = text.split("\n").filter((line) => /^\s*(\d+[.)]|[-*•])\s+\S/.test(line)); + return listLines.length >= 3; +} + export async function POST(req: NextRequest) { const { session, response } = await requireSessionOrApiKey(req); if (response) return response; @@ -42,10 +61,12 @@ export async function POST(req: NextRequest) { const locale = (session!.user as { locale?: string }).locale ?? "en"; const lang = LANG[locale] ?? "English"; + const system = `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.\n\nYou have two tools. Using one only drafts something for the user to review — it never saves by itself.\n- createRecipe: the user is asking you to create, save, or write down a recipe (e.g. "make me a recipe for X", "give me a recipe for Y", "write that down"). This includes any request for a full recipe, not only ones that say the word "create" or "save".\n- addToShoppingList: the user is asking to add ingredients/items to a shopping list.\n\nIMPORTANT: when the user's request matches createRecipe, you MUST call that tool instead of writing the recipe's ingredients or steps directly in your text reply. Never output a full ingredient list or numbered steps as plain text — that content belongs in the tool call, not the message. Your text reply in that case should just be a short line like "Here's a draft — check it below and confirm if it looks right." Only skip the tool if the user is asking a general question (no specific recipe requested) or explicitly wants prose, not a structured recipe.${bioContext}`; + const result = await withAiQuota(session!.user.id, session!.user.tier as "free" | "pro" | "family", () => generateText({ model, - system: `You are Epicure, a helpful culinary assistant answering general cooking questions — not tied to any specific recipe (techniques, substitutions, timing, equipment, food safety, etc). If asked who you are or what model/AI you're built on, say you're Epicure — never name the underlying model or provider. If a question has nothing to do with cooking or food, politely redirect. Keep answers under 200 words. Respond in ${lang}.\n\nYou have two tools. Using one only drafts something for the user to review — it never saves by itself.\n- createRecipe: the user is asking you to create, save, or write down a recipe (e.g. "make me a recipe for X", "give me a recipe for Y", "write that down"). This includes any request for a full recipe, not only ones that say the word "create" or "save".\n- addToShoppingList: the user is asking to add ingredients/items to a shopping list.\n\nIMPORTANT: when the user's request matches createRecipe, you MUST call that tool instead of writing the recipe's ingredients or steps directly in your text reply. Never output a full ingredient list or numbered steps as plain text — that content belongs in the tool call, not the message. Your text reply in that case should just be a short line like "Here's a draft — check it below and confirm if it looks right." Only skip the tool if the user is asking a general question (no specific recipe requested) or explicitly wants prose, not a structured recipe.${bioContext}`, + system, prompt: parsed.data.question, tools: { createRecipe: createRecipeTool, addToShoppingList: addToShoppingListTool }, stopWhen: stepCountIs(3), @@ -53,13 +74,38 @@ export async function POST(req: NextRequest) { ); if (!result.ok) return result.response; + let final = result.data; + + // Weaker/free-tier models sometimes ignore the MUST-call-the-tool rule + // above and just write the recipe out in prose instead. Rather than + // surface that (or fail the request), force the tool call on one extra + // pass — same system+prompt, just no longer letting the model opt out. + if (final.toolCalls.length === 0 && looksLikeUnstructuredRecipe(final.text)) { + try { + const forced = await generateText({ + model, + system, + prompt: parsed.data.question, + // Same tool set as the first pass (just forcing createRecipe via + // toolChoice below) — keeps the result type identical to `result.data` + // so `final` can hold either without a type mismatch. + tools: { createRecipe: createRecipeTool, addToShoppingList: addToShoppingListTool }, + toolChoice: { type: "tool", toolName: "createRecipe" }, + stopWhen: stepCountIs(1), + }); + final = { ...forced, text: forced.text || (DRAFT_FALLBACK_TEXT[locale] ?? DRAFT_FALLBACK_TEXT["en"]!) }; + } catch (err) { + console.error("[cooking-chat] forced-tool retry failed, keeping prose answer", err); + } + } + const { conversationId } = parsed.data; - const proposedRecipe = result.data.toolCalls.find((c) => c.toolName === "createRecipe")?.input; - const proposedShoppingList = result.data.toolCalls.find((c) => c.toolName === "addToShoppingList")?.input; + const proposedRecipe = final.toolCalls.find((c) => c.toolName === "createRecipe")?.input; + const proposedShoppingList = final.toolCalls.find((c) => c.toolName === "addToShoppingList")?.input; void db.insert(chatMessages).values([ { id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "user", content: parsed.data.question }, - { id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "assistant", content: result.data.text }, + { id: crypto.randomUUID(), userId: session!.user.id, recipeId: null, conversationId, role: "assistant", content: final.text }, ]).catch((err) => console.error("[cooking-chat] failed to persist chat history", err)); if (conversationId) { @@ -74,5 +120,5 @@ export async function POST(req: NextRequest) { `).catch((err) => console.error("[cooking-chat] failed to touch conversation", err)); } - return NextResponse.json({ answer: result.data.text, proposedRecipe, proposedShoppingList }); + return NextResponse.json({ answer: final.text, proposedRecipe, proposedShoppingList }); } diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index 490b498..12ceb4a 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.57.0"; +export const APP_VERSION = "0.57.1"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.57.1", + date: "2026-07-20 20:00", + fixed: [ + "Chatbot sometimes answered \"create a recipe\" requests with the recipe spelled out as prose instead of the actual draft card (weaker/free-tier models occasionally ignore the tool-calling instruction). Now detects that shape (a numbered/bulleted list with no tool call) and forces one retry with the tool call required, so the draft card still appears.", + ], + }, { version: "0.57.0", date: "2026-07-20 09:20", diff --git a/apps/web/package.json b/apps/web/package.json index 2144c3e..bd42f74 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.57.0", + "version": "0.57.1", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index c4e4173..e8a5f20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.57.0", + "version": "0.57.1", "private": true, "scripts": { "dev": "pnpm --filter web dev",