import { env } from '$env/dynamic/private'; import { json } from '@sveltejs/kit'; import { createHash } from 'node:crypto'; import type { RequestHandler } from './$types'; type ChatRole = 'user' | 'assistant'; type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack'; type Draft = { food_name?: string; meal_type?: MealType; entry_date?: string; quantity?: number; unit?: string; calories?: number; protein?: number; carbs?: number; fat?: number; sugar?: number; fiber?: number; note?: string; default_serving_label?: string; }; type DraftBundle = Draft[]; type ChatMessage = { role?: ChatRole; content?: string; }; type ResolvedFood = { id: string; name: string; status?: string; base_unit?: string; calories_per_base?: number; protein_per_base?: number; carbs_per_base?: number; fat_per_base?: number; sugar_per_base?: number; fiber_per_base?: number; servings?: Array<{ id?: string; name?: string; amount_in_base?: number; is_default?: boolean }>; }; function todayIso(): string { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } function toNumber(value: unknown, fallback = 0): number { if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { const parsed = Number(value); if (Number.isFinite(parsed)) return parsed; } return fallback; } function clampDraft(input: Record | null | undefined): Draft { const meal = input?.meal_type; const normalizedMeal: MealType = meal === 'breakfast' || meal === 'lunch' || meal === 'dinner' || meal === 'snack' ? meal : 'snack'; return { food_name: typeof input?.food_name === 'string' ? input.food_name.trim() : '', meal_type: normalizedMeal, entry_date: typeof input?.entry_date === 'string' && input.entry_date ? input.entry_date : todayIso(), quantity: Math.max(toNumber(input?.quantity, 1), 0), unit: typeof input?.unit === 'string' && input.unit ? input.unit.trim() : 'serving', calories: Math.max(toNumber(input?.calories), 0), protein: Math.max(toNumber(input?.protein), 0), carbs: Math.max(toNumber(input?.carbs), 0), fat: Math.max(toNumber(input?.fat), 0), sugar: Math.max(toNumber(input?.sugar), 0), fiber: Math.max(toNumber(input?.fiber), 0), note: typeof input?.note === 'string' ? input.note.trim() : '', default_serving_label: typeof input?.default_serving_label === 'string' ? input.default_serving_label.trim() : '' }; } function parseLeadingQuantity(name: string): { quantity: number | null; cleanedName: string } { const trimmed = name.trim(); const match = trimmed.match(/^(\d+(?:\.\d+)?)\s+(.+)$/); if (!match) { return { quantity: null, cleanedName: trimmed }; } return { quantity: Number(match[1]), cleanedName: match[2].trim() }; } function canonicalFoodName(name: string): string { const cleaned = name .trim() .replace(/^(?:a|an|the)\s+/i, '') .replace(/\s+/g, ' '); return cleaned .split(' ') .filter(Boolean) .map((part) => { if (/[A-Z]{2,}/.test(part)) return part; return part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(); }) .join(' '); } function normalizedFoodKey(name: string): string { return canonicalFoodName(name) .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .replace(/\b(\d+(?:\.\d+)?)\b/g, ' ') .replace(/\s+/g, ' ') .trim(); } function shouldReuseResolvedFood(canonicalName: string, resolved: ResolvedFood | null, confidence: number): boolean { if (!resolved?.name) return false; if (confidence < 0.9) return false; const draftKey = normalizedFoodKey(canonicalName); const resolvedKey = normalizedFoodKey(resolved.name); if (!draftKey || !resolvedKey) return false; return draftKey === resolvedKey; } function foodBaseUnit(draft: Draft): string { const unit = (draft.unit || 'serving').trim().toLowerCase(); if (unit === 'piece' || unit === 'slice' || unit === 'cup' || unit === 'scoop' || unit === 'serving') { return unit; } return 'serving'; } function defaultServingName(baseUnit: string): string { return `1 ${baseUnit}`; } function hasMaterialNutritionMismatch(draft: Draft, matchedFood: ResolvedFood, entryQuantity: number): boolean { const draftPerBase = { calories: Math.max((draft.calories || 0) / entryQuantity, 0), protein: Math.max((draft.protein || 0) / entryQuantity, 0), carbs: Math.max((draft.carbs || 0) / entryQuantity, 0), fat: Math.max((draft.fat || 0) / entryQuantity, 0), sugar: Math.max((draft.sugar || 0) / entryQuantity, 0), fiber: Math.max((draft.fiber || 0) / entryQuantity, 0) }; const currentPerBase = { calories: matchedFood.calories_per_base || 0, protein: matchedFood.protein_per_base || 0, carbs: matchedFood.carbs_per_base || 0, fat: matchedFood.fat_per_base || 0, sugar: matchedFood.sugar_per_base || 0, fiber: matchedFood.fiber_per_base || 0 }; return (Object.keys(draftPerBase) as Array).some((key) => { const next = draftPerBase[key]; const current = currentPerBase[key]; if (Math.abs(next - current) >= 5) return true; if (Math.max(next, current) <= 0) return false; return Math.abs(next - current) / Math.max(next, current) >= 0.12; }); } function entryIdempotencyKey(draft: Draft, index = 0): string { return createHash('sha256') .update( JSON.stringify({ index, food_name: draft.food_name || '', meal_type: draft.meal_type || 'snack', entry_date: draft.entry_date || todayIso(), quantity: draft.quantity || 1, unit: draft.unit || 'serving', calories: draft.calories || 0, protein: draft.protein || 0, carbs: draft.carbs || 0, fat: draft.fat || 0, sugar: draft.sugar || 0, fiber: draft.fiber || 0, note: draft.note || '' }) ) .digest('hex'); } function hasCompleteDraft(draft: Draft): boolean { return !!draft.food_name && !!draft.meal_type && Number.isFinite(draft.calories ?? NaN); } function hasCompleteDrafts(drafts: DraftBundle | null | undefined): drafts is DraftBundle { return Array.isArray(drafts) && drafts.length > 0 && drafts.every((draft) => hasCompleteDraft(draft)); } function isExplicitConfirmation(message: string): boolean { const text = message.trim().toLowerCase(); if (!text) return false; return [ /^add it[.!]?$/, /^log it[.!]?$/, /^save it[.!]?$/, /^looks good[.!]?$/, /^looks good add it[.!]?$/, /^that looks good[.!]?$/, /^that looks good add it[.!]?$/, /^confirm[.!]?$/, /^go ahead[.!]?$/, /^yes add it[.!]?$/, /^yes log it[.!]?$/, /^yes save it[.!]?$/ ].some((pattern) => pattern.test(text)); } function isRetryRequest(message: string): boolean { const text = message.trim().toLowerCase(); if (!text) return false; return [ "that's not right", "that is not right", 'wrong item', 'wrong food', 'wrong meal', 'try again', 'search again', 'guess again', 'redo that', 'start over', 'that is wrong', "that's wrong" ].some((phrase) => text.includes(phrase)); } function draftForRetry(draft: Draft): Draft { return { meal_type: draft.meal_type, entry_date: draft.entry_date || todayIso(), quantity: draft.quantity || 1, unit: draft.unit || 'serving', food_name: '', calories: 0, protein: 0, carbs: 0, fat: 0, sugar: 0, fiber: 0, note: draft.note || '' }; } async function applyDraft(fetchFn: typeof fetch, draft: Draft, index = 0) { const parsedName = parseLeadingQuantity(draft.food_name || ''); const entryQuantity = Math.max( draft.quantity && draft.quantity > 0 ? draft.quantity : parsedName.quantity || 1, 0.1 ); const canonicalName = canonicalFoodName(parsedName.cleanedName || draft.food_name || 'Quick add'); const baseUnit = foodBaseUnit(draft); const resolveResponse = await fetchFn('/api/fitness/foods/resolve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ raw_phrase: canonicalName, meal_type: draft.meal_type || 'snack', entry_date: draft.entry_date || todayIso(), source: 'assistant' }) }); const resolveBody = await resolveResponse.json().catch(() => ({})); let matchedFood: ResolvedFood | null = resolveResponse.ok && shouldReuseResolvedFood(canonicalName, resolveBody?.matched_food ?? null, Number(resolveBody?.confidence || 0)) ? (resolveBody?.matched_food ?? null) : null; if (!matchedFood) { const perBaseCalories = Math.max((draft.calories || 0) / entryQuantity, 0); const perBaseProtein = Math.max((draft.protein || 0) / entryQuantity, 0); const perBaseCarbs = Math.max((draft.carbs || 0) / entryQuantity, 0); const perBaseFat = Math.max((draft.fat || 0) / entryQuantity, 0); const perBaseSugar = Math.max((draft.sugar || 0) / entryQuantity, 0); const perBaseFiber = Math.max((draft.fiber || 0) / entryQuantity, 0); const createFoodResponse = await fetchFn('/api/fitness/foods', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: canonicalName, calories_per_base: perBaseCalories, protein_per_base: perBaseProtein, carbs_per_base: perBaseCarbs, fat_per_base: perBaseFat, sugar_per_base: perBaseSugar, fiber_per_base: perBaseFiber, base_unit: baseUnit, status: 'assistant_created', notes: `Assistant created from chat draft: ${draft.food_name || canonicalName}`, servings: [ { name: draft.default_serving_label?.trim() || defaultServingName(baseUnit), amount_in_base: 1.0, is_default: true } ] }) }); const createdFoodBody = await createFoodResponse.json().catch(() => ({})); if (!createFoodResponse.ok) { return { ok: false, status: createFoodResponse.status, body: createdFoodBody }; } matchedFood = createdFoodBody as ResolvedFood; } else if ( (matchedFood.status === 'assistant_created' || matchedFood.status === 'ai_created') && hasMaterialNutritionMismatch(draft, matchedFood, entryQuantity) ) { const updateFoodResponse = await fetchFn(`/api/fitness/foods/${matchedFood.id}`, { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ calories_per_base: Math.max((draft.calories || 0) / entryQuantity, 0), protein_per_base: Math.max((draft.protein || 0) / entryQuantity, 0), carbs_per_base: Math.max((draft.carbs || 0) / entryQuantity, 0), fat_per_base: Math.max((draft.fat || 0) / entryQuantity, 0), sugar_per_base: Math.max((draft.sugar || 0) / entryQuantity, 0), fiber_per_base: Math.max((draft.fiber || 0) / entryQuantity, 0) }) }); const updatedFoodBody = await updateFoodResponse.json().catch(() => ({})); if (updateFoodResponse.ok) { matchedFood = updatedFoodBody as ResolvedFood; } } const entryPayload = { food_id: matchedFood.id, quantity: entryQuantity, unit: baseUnit, serving_id: matchedFood.servings?.find((serving) => serving.is_default)?.id, meal_type: draft.meal_type || 'snack', entry_date: draft.entry_date || todayIso(), entry_method: 'assistant', source: 'assistant', note: draft.note || undefined, idempotency_key: entryIdempotencyKey(draft, index), snapshot_food_name_override: draft.food_name && draft.food_name.trim() !== canonicalName ? draft.food_name.trim() : undefined }; const response = await fetchFn('/api/fitness/entries', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(entryPayload) }); const body = await response.json().catch(() => ({})); return { ok: response.ok, status: response.status, body }; } async function splitInputItems(fetchFn: typeof fetch, phrase: string): Promise { try { const response = await fetchFn('/api/fitness/foods/split', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ phrase }) }); const body = await response.json().catch(() => ({})); if (response.ok && Array.isArray(body?.items) && body.items.length > 0) { return body.items.map((item: unknown) => String(item).trim()).filter(Boolean); } } catch { // fall back below } return phrase .split(/,/) .map((item) => item.trim()) .filter(Boolean); } function draftFromResolvedItem(resolved: Record, entryDate: string): Draft { const parsed = (resolved?.parsed as Record | undefined) || {}; const matchedFood = (resolved?.matched_food as Record | undefined) || null; const aiEstimate = (resolved?.ai_estimate as Record | undefined) || null; const quantity = Math.max(toNumber(parsed.quantity, 1), 0.1); const unit = typeof parsed.unit === 'string' && parsed.unit ? parsed.unit : 'serving'; const meal = parsed.meal_type; const mealType: MealType = meal === 'breakfast' || meal === 'lunch' || meal === 'dinner' || meal === 'snack' ? meal : 'snack'; let calories = 0; let protein = 0; let carbs = 0; let fat = 0; let sugar = 0; let fiber = 0; if (matchedFood) { calories = toNumber(matchedFood.calories_per_base) * quantity; protein = toNumber(matchedFood.protein_per_base) * quantity; carbs = toNumber(matchedFood.carbs_per_base) * quantity; fat = toNumber(matchedFood.fat_per_base) * quantity; sugar = toNumber(matchedFood.sugar_per_base) * quantity; fiber = toNumber(matchedFood.fiber_per_base) * quantity; } else if (aiEstimate) { calories = toNumber(aiEstimate.calories_per_base) * quantity; protein = toNumber(aiEstimate.protein_per_base) * quantity; carbs = toNumber(aiEstimate.carbs_per_base) * quantity; fat = toNumber(aiEstimate.fat_per_base) * quantity; sugar = toNumber(aiEstimate.sugar_per_base) * quantity; fiber = toNumber(aiEstimate.fiber_per_base) * quantity; } else if (resolved?.resolution_type === 'quick_add') { calories = toNumber(parsed.quantity); } const foodName = (typeof resolved?.snapshot_name_override === 'string' && resolved.snapshot_name_override) || (typeof matchedFood?.name === 'string' && matchedFood.name) || (typeof aiEstimate?.food_name === 'string' && aiEstimate.food_name) || (typeof resolved?.raw_text === 'string' && resolved.raw_text) || 'Quick add'; return clampDraft({ food_name: foodName, meal_type: mealType, entry_date: entryDate, quantity, unit, calories: Math.round(calories), protein: Math.round(protein), carbs: Math.round(carbs), fat: Math.round(fat), sugar: Math.round(sugar), fiber: Math.round(fiber), note: typeof resolved?.note === 'string' ? resolved.note : '', default_serving_label: (typeof aiEstimate?.serving_description === 'string' && aiEstimate.serving_description) || (quantity > 0 && unit ? `${quantity} ${unit}` : '') }); } async function reviseDraftBundle( fetchFn: typeof fetch, messages: Array<{ role: 'user' | 'assistant'; content: string }>, drafts: DraftBundle, imageDataUrl: string | null ): Promise<{ reply: string; drafts: DraftBundle } | null> { if (!env.OPENAI_API_KEY || drafts.length === 0) return null; const systemPrompt = `You are revising a bundled food draft inside a fitness app. Return ONLY JSON like: { "reply": "short assistant reply", "drafts": [ { "food_name": "string", "meal_type": "breakfast|lunch|dinner|snack", "entry_date": "YYYY-MM-DD", "quantity": 1, "unit": "serving", "calories": 0, "protein": 0, "carbs": 0, "fat": 0, "sugar": 0, "fiber": 0, "note": "", "default_serving_label": "" } ] } Rules: - Update only the item or items the user is correcting. - Keep untouched items unchanged. - If the user says one item is wrong, replace that item without collapsing the bundle into one merged food. - Preserve meal and entry date unless the user changes them. - Keep replies brief and natural. - If a photo is attached, you may use it again for corrections.`; const userMessages = messages.map((message) => ({ role: message.role, content: message.content })); if (imageDataUrl) { const latestUserText = [...messages].reverse().find((message) => message.role === 'user')?.content || 'Revise these items.'; userMessages.push({ role: 'user', content: [ { type: 'text', text: latestUserText }, { type: 'image_url', image_url: { url: imageDataUrl } } ] } as unknown as { role: ChatRole; content: string }); } const response = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${env.OPENAI_API_KEY}` }, body: JSON.stringify({ model: env.OPENAI_MODEL || 'gpt-5.2', response_format: { type: 'json_object' }, temperature: 0.2, max_completion_tokens: 1000, messages: [ { role: 'system', content: `${systemPrompt}\n\nCurrent bundle:\n${JSON.stringify(drafts, null, 2)}` }, ...userMessages ] }) }); if (!response.ok) return null; const raw = await response.json().catch(() => ({})); const content = raw?.choices?.[0]?.message?.content; if (typeof content !== 'string') return null; try { const parsed = JSON.parse(content); const nextDrafts = Array.isArray(parsed?.drafts) ? parsed.drafts.map((draft: Record) => clampDraft(draft)) : []; if (!hasCompleteDrafts(nextDrafts)) return null; return { reply: typeof parsed?.reply === 'string' ? parsed.reply : 'I updated those items.', drafts: nextDrafts }; } catch { return null; } } async function buildDraftBundle(fetchFn: typeof fetch, phrase: string, entryDate: string): Promise { const parts = await splitInputItems(fetchFn, phrase); if (parts.length < 2) return null; const results = await Promise.all( parts.map(async (part) => { const response = await fetchFn('/api/fitness/foods/resolve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ raw_phrase: part, entry_date: entryDate, source: 'assistant' }) }); if (!response.ok) { throw new Error(`Failed to resolve "${part}"`); } const body = await response.json().catch(() => ({})); return draftFromResolvedItem(body, entryDate); }) ); return results.filter((draft) => hasCompleteDraft(draft)); } export const POST: RequestHandler = async ({ request, fetch, cookies }) => { if (!cookies.get('platform_session')) { return json({ error: 'Unauthorized' }, { status: 401 }); } const { messages = [], draft = null, drafts = null, action = 'chat', imageDataUrl = null, entryDate = null } = await request .json() .catch(() => ({})); const requestedDate = typeof entryDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(entryDate) ? entryDate : todayIso(); const currentDraft = clampDraft({ entry_date: requestedDate, ...(draft && typeof draft === 'object' ? draft : {}) }); const currentDrafts = Array.isArray(drafts) ? drafts .filter((item) => !!item && typeof item === 'object') .map((item) => clampDraft({ entry_date: requestedDate, ...(item as Record) }) ) : []; if (action === 'apply') { if (hasCompleteDrafts(currentDrafts)) { const results = await Promise.all(currentDrafts.map((item, index) => applyDraft(fetch, item, index))); const failed = results.find((result) => !result.ok); if (failed) { return json( { reply: 'I couldn’t add all of those entries yet. Try again in a moment.', drafts: currentDrafts, applied: false, error: failed.body?.error || `Fitness API returned ${failed.status}` }, { status: 500 } ); } return json({ reply: `Added ${currentDrafts.length} items to ${currentDrafts[0]?.meal_type || 'your log'}.`, drafts: currentDrafts, applied: true, entries: results.map((result) => result.body) }); } if (!hasCompleteDraft(currentDraft)) { return json({ reply: 'I still need a food and calories before I can add it.', draft: currentDraft, drafts: currentDrafts, applied: false }); } const result = await applyDraft(fetch, currentDraft); if (!result.ok) { return json( { reply: 'I couldn’t add that entry yet. Try again in a moment.', draft: currentDraft, applied: false, error: result.body?.error || `Fitness API returned ${result.status}` }, { status: 500 } ); } return json({ reply: `Added ${currentDraft.food_name} to ${currentDraft.meal_type}.`, draft: currentDraft, drafts: [], applied: true, entry: result.body }); } const recentMessages = (Array.isArray(messages) ? messages .filter((m: unknown) => !!m && typeof m === 'object') .map((m: ChatMessage) => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: typeof m.content === 'string' ? m.content.slice(0, 2000) : '' })) .filter((m) => m.content.trim()) .slice(-10) : []) as Array<{ role: 'user' | 'assistant'; content: string }>; const lastUserMessage = [...recentMessages].reverse().find((message) => message.role === 'user')?.content || ''; const allowApply = isExplicitConfirmation(lastUserMessage); const retryRequested = isRetryRequest(lastUserMessage); const hasPhoto = typeof imageDataUrl === 'string' && imageDataUrl.startsWith('data:image/') && imageDataUrl.length < 8_000_000; if (!allowApply && currentDrafts.length > 1 && lastUserMessage.trim()) { const revisedBundle = await reviseDraftBundle( fetch, recentMessages, currentDrafts, hasPhoto && typeof imageDataUrl === 'string' ? imageDataUrl : null ); if (revisedBundle) { return json({ reply: revisedBundle.reply, drafts: revisedBundle.drafts, draft: null, applied: false }); } } if (!hasPhoto && !retryRequested && !allowApply && lastUserMessage.trim()) { const bundle = await buildDraftBundle(fetch, lastUserMessage, requestedDate); if (bundle && bundle.length > 1) { const meal = bundle[0]?.meal_type || 'snack'; return json({ reply: `I split that into ${bundle.length} items for ${meal}: ${bundle.map((item) => item.food_name).join(', ')}. Add them when this looks right.`, drafts: bundle, draft: null, applied: false }); } } const systemPrompt = `You are a conversational fitness logging assistant inside a personal app. Your job: - read the chat plus the current draft food entry - update the draft naturally - keep the reply short, plain, and useful - do not add an entry on the first food message - only set apply_now=true if the latest user message is a pure confirmation like "add it", "log it", "save it", or "looks good add it" - never say an item was added, logged, or saved unless apply_now=true for that response - if a food photo is attached, identify the likely food, portion, and meal context before drafting - if the user says the current guess is wrong, treat that as authoritative and replace the draft instead of defending the previous guess Return ONLY JSON with this shape: { "reply": "short assistant reply", "draft": { "food_name": "string", "meal_type": "breakfast|lunch|dinner|snack", "entry_date": "YYYY-MM-DD", "quantity": 1, "unit": "serving", "calories": 0, "protein": 0, "carbs": 0, "fat": 0, "sugar": 0, "fiber": 0, "note": "", "default_serving_label": "" }, "apply_now": false } Rules: - Preserve the current draft unless the user changes something. - If the latest user message says the guess is wrong, try again, or search again, do not cling to the old food guess. Replace the draft with a new best guess. - If the user says "make it 150 calories", update calories and keep the rest unless another field should obviously move with it. - If the user says a meal, move it to that meal. - Default meal_type to snack if not specified. - Default entry_date to today unless the user specifies another date. - Estimate realistic nutrition when needed. - Always include sugar and fiber estimates, even if rough. - Keep food_name human and concise, for example "2 boiled eggs". - If the photo is a nutrition label or package nutrition panel, extract the serving size from the label and put it in default_serving_label. - If the user gives a product name plus a nutrition label photo, use the label values instead of guessing from memory. - If the photo is ambiguous, briefly mention up to 2 likely alternatives instead of sounding overconfident. - After drafting or revising, summarize the draft with calories and key macros, then ask for confirmation. - If a photo is unclear, say what you think it is and mention the uncertainty briefly. - If retrying from a photo, use the image again and produce a different best guess or ask one short clarifying question. - If details are missing, ask one short follow-up instead of overexplaining. - When the user confirms, keep the reply brief because the app will add the entry next. Today is ${todayIso()}. Current draft: ${JSON.stringify(retryRequested ? draftForRetry(currentDraft) : currentDraft, null, 2)}`; if (!env.OPENAI_API_KEY) { return json( { reply: 'Assistant is not configured yet.', draft: currentDraft, drafts: currentDrafts, applied: false }, { status: 500 } ); } const userMessages = recentMessages.map((message) => ({ role: message.role, content: message.content })); if (hasPhoto) { const latestUserText = lastUserMessage || 'Analyze this food photo and draft a fitness entry.'; userMessages.push({ role: 'user', content: [ { type: 'text', text: latestUserText }, { type: 'image_url', image_url: { url: imageDataUrl } } ] } as unknown as { role: ChatRole; content: string }); } const openAiResponse = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${env.OPENAI_API_KEY}` }, body: JSON.stringify({ model: env.OPENAI_MODEL || 'gpt-5.2', response_format: { type: 'json_object' }, temperature: 0.2, max_completion_tokens: 900, messages: [ { role: 'system', content: systemPrompt }, ...userMessages ] }) }); if (!openAiResponse.ok) { const errorText = await openAiResponse.text(); return json( { reply: 'The assistant did not respond cleanly.', draft: currentDraft, drafts: currentDrafts, applied: false, error: errorText }, { status: 500 } ); } const raw = await openAiResponse.json(); const content = raw?.choices?.[0]?.message?.content; if (typeof content !== 'string') { return json( { reply: 'The assistant response was empty.', draft: currentDraft, drafts: currentDrafts, applied: false }, { status: 500 } ); } let parsed: { reply?: string; draft?: Draft; apply_now?: boolean }; try { parsed = JSON.parse(content); } catch { return json( { reply: 'The assistant response could not be parsed.', draft: currentDraft, drafts: currentDrafts, applied: false }, { status: 500 } ); } const nextDraft = clampDraft(parsed.draft || currentDraft); if (parsed.apply_now && allowApply && hasCompleteDraft(nextDraft)) { const result = await applyDraft(fetch, nextDraft); if (result.ok) { return json({ reply: `Added ${nextDraft.food_name} to ${nextDraft.meal_type}.`, draft: nextDraft, drafts: [], applied: true, entry: result.body }); } } return json({ reply: parsed.reply || (hasCompleteDraft(nextDraft) ? `${nextDraft.food_name} is staged at ${Math.round(nextDraft.calories || 0)} calories.` : 'I updated the draft.'), draft: nextDraft, drafts: [], applied: false }); };