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>
886 lines
27 KiB
TypeScript
886 lines
27 KiB
TypeScript
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 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
|
||
});
|
||
};
|