Files
platform/frontend-v2/src/routes/assistant/fitness/+server.ts
Yusuf Suleman 8275f3a71b feat: brain service — self-contained second brain knowledge manager
Full backend service with:
- FastAPI REST API with CRUD, search, reprocess endpoints
- PostgreSQL + pgvector for items and semantic search
- Redis + RQ for background job processing
- Meilisearch for fast keyword/filter search
- Browserless/Chrome for JS rendering and screenshots
- OpenAI structured output for AI classification
- Local file storage with S3-ready abstraction
- Gateway auth via X-Gateway-User-Id header
- Own docker-compose stack (6 containers)

Classification: fixed folders (Home/Family/Work/Travel/Knowledge/Faith/Projects)
and fixed tags (28 predefined). AI assigns exactly 1 folder, 2-3 tags, title,
summary, and confidence score per item.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 11:48:29 -05:00

886 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown> | 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<keyof typeof draftPerBase>).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<string[]> {
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<string, unknown>, entryDate: string): Draft {
const parsed = (resolved?.parsed as Record<string, unknown> | undefined) || {};
const matchedFood = (resolved?.matched_food as Record<string, unknown> | undefined) || null;
const aiEstimate = (resolved?.ai_estimate as Record<string, unknown> | 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<string, unknown>) => 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<DraftBundle | null> {
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<string, unknown>)
})
)
: [];
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 couldnt 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 couldnt 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
});
};