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>
This commit is contained in:
Yusuf Suleman
2026-04-01 11:48:29 -05:00
parent 51a8157fd4
commit 8275f3a71b
73 changed files with 24081 additions and 4209 deletions

View File

@@ -0,0 +1,885 @@
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
});
};