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:
885
frontend-v2/src/routes/assistant/fitness/+server.ts
Normal file
885
frontend-v2/src/routes/assistant/fitness/+server.ts
Normal 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 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
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user