Repository navigation
Expand file tree
/
Copy pathfoodResolutionService.ts
More file actions
665 lines (601 loc) · 27.9 KB
/
Copy pathfoodResolutionService.ts
File metadata and controls
665 lines (601 loc) · 27.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
/**
* CODE SAMPLE — core of the voice-logging pipeline (see the README's
* "Voice logging flow"). This is the step between transcription
* (groq-proxy/index.ts, also in this folder) and the confirm sheet UI
* (VoiceConfirmSheet.tsx): it turns a raw transcript into structured,
* matched, ready-to-confirm food items.
*
* Food resolution service, the brain behind voice + AI-text logging.
*
* raw speech / typed meal → Groq Llama (extract foods + grams)
* → Supabase ranked search (alias table + trigram)
* → Groq Llama (pick the best DB match; skipped
* entirely when a curated alias hit is returned)
* → scaled, ready-to-confirm ResolvedFoodItem[]
*
* Used ONLY by the voice pipeline and the AI-text pipeline (see MicButton). The
* manual Search tab is untouched, it keeps using foodSearch.ts.
*
* ──────────────────────────── SECURITY ────────────────────────────
* Mirrors voiceLog.ts: the Groq key lives ONLY in the groq-proxy Edge Function
* (Supabase secret), the device never sees it. Requests carry the user's
* session JWT; the proxy verifies it, caps daily usage per user and forces the
* model server-side. We NEVER log tokens, the request, or the raw response.
* Every name that comes back from Llama (untrusted model output) is run
* through `sanitizeQuery` before it reaches the search RPC, and grams are
* clamped to the validated [MIN_GRAMS, MAX_GRAMS] range. The ranked/ILIKE SQL
* lives in SECURITY INVOKER RPCs (migrations 0011/0013/0014) so the read-only
* `foods` RLS still applies, the client can only ever SELECT the shared
* catalog.
*/
import { env, isGroqConfigured } from '@/config/env';
import { supabase } from '@/lib/supabase';
import { MAX_GRAMS, MIN_GRAMS } from '@/utils/foodMath';
import { round1 } from '@/utils/units';
import { sanitizeQuery } from './foodApi';
const CHAT_URL = `${env.supabaseUrl}/functions/v1/groq-proxy/chat`;
const EXTRACT_TIMEOUT_MS = 20000;
const MATCH_TIMEOUT_MS = 15000;
const MAX_ITEMS = 20; // guard against a runaway model response
const CANDIDATE_LIMIT = 8; // top-N catalog rows handed to the match step
const MIN_SEARCH_LEN = 2;
const MAX_QUICK_ADD_KCAL = 99999; // typo guard for calorie-only quick adds
/** Exact extraction prompt (do not reword, tuned for messy multi-item meals). */
const EXTRACTION_SYSTEM_PROMPT = `You are a food extraction engine for a calorie tracking app. The user has spoken or typed a meal description in natural, conversational English. They often ramble, combine several foods in one sentence, mention how something was cooked, or correct themselves mid-sentence. Examples: "two chicken thighs", "a big bowl of rice", "235g chicken thigh, two nuggets I deep fried in butter, butter was about 10g", "some pasta, not too much".
Your job is to SPLIT the description into separate food items and resolve the final gram weight to log for each one.
SPLITTING RULES:
- Output exactly one object per distinct food. Always break compound or rambling sentences into individual items (e.g. "chicken, rice and a coke" → three separate items).
- Resolve each item's weight independently using the WEIGHT PRIORITY RULES below.
- If the user mentions the same food twice (a self-correction), merge it into one item using their latest / most specific amount.
WEIGHT PRIORITY RULES (apply per item):
1. If the user stated an explicit gram/kg amount for that item → use that weight exactly, ignore any piece count.
2. If the user stated pieces/units only with no weight → estimate total weight from your built-in food knowledge (e.g. 1 chicken thigh ≈ 116g, 1 chicken nugget ≈ 16g, 1 chicken breast ≈ 174g, 1 egg ≈ 50g, 1 banana ≈ 118g, 1 slice bread ≈ 30g, 1 cup cooked rice ≈ 186g, 1 cup milk ≈ 244g, 1 apple ≈ 182g, 1 orange ≈ 131g, 1 tbsp butter ≈ 14g, 1 tbsp oil ≈ 14g).
3. If the user is vague ("some", "a bit", "not much", "a little") → estimate a small/moderate amount and set uncertain to true.
4. If the user gives conflicting info for one item ("six thighs, 200g total") → explicit gram weight wins, set grams to 200.
COOKING METHODS (important):
- Cooking / prep words (fried, deep fried, grilled, baked, roasted, boiled, sautéed, pan-fried, etc.) describe preparation ONLY. They do NOT change a food's macros, never inflate calories or fat just because a food was fried.
- NEVER invent an oil, butter, or sauce item on your own, and NEVER bake assumed oil/fat absorption into another food's weight. Only output oil/butter/sauce as a SEPARATE item when the user explicitly mentions it, and give it its own weight (from an explicit amount, a count, or a small estimate with uncertain=true).
CALORIE-ONLY / QUICK ADD:
- If the user states ONLY a calorie amount with no identifiable food (e.g. "just log 1000 calories", "add 500 cal, not sure what it was", "log 2000 calories"), output a SINGLE quick-add item exactly like this: { "search_name": "quick add", "display_name": "Quick add", "grams": 0, "uncertain": false, "quick_add": true, "calories": <the number> }. Do NOT guess a food name, do NOT invent a gram weight, and do NOT split it into pieces.
- EVERY item must carry all six fields. Set "quick_add": false and "calories": null on every real food; only a quick-add item has quick_add true and a calories number.
For each detected food, output a clean search-friendly name (e.g. "chicken thigh" not "Chicken, broilers or fryers, thigh"). Never invent nutrition values. For real foods "calories" is always null, and never output macros. Only resolve names, weights and counts.
TIME EXTRACTION:
Also extract any time reference the user mentions.
- "at 1pm" → "13:00"
- "at 7am" → "07:00"
- "around noon" → "12:00"
- "this morning" → null (too vague, return null)
- "just now" → null
- No time mentioned → null
Return time as "HH:MM" 24-hour format string, or null.
Respond ONLY with a valid JSON object. No markdown, no explanation, no extra text.
Format:
{
"foods": [
{ "search_name": "chicken thigh", "display_name": "Chicken Thigh", "grams": 232, "uncertain": false, "quick_add": false, "calories": null }
],
"time": null
}
EXAMPLES:
User: "235g chicken thigh, two nuggets I deep fried in butter, butter was about 10g"
Output:
{
"foods": [
{ "search_name": "chicken thigh", "display_name": "Chicken Thigh", "grams": 235, "uncertain": false, "quick_add": false, "calories": null },
{ "search_name": "chicken nuggets", "display_name": "Chicken Nuggets", "grams": 32, "uncertain": false, "quick_add": false, "calories": null },
{ "search_name": "butter", "display_name": "Butter", "grams": 10, "uncertain": false, "quick_add": false, "calories": null }
],
"time": null
}
User: "I had two eggs and toast at 1pm"
Output:
{
"foods": [
{ "search_name": "egg", "display_name": "Eggs", "grams": 100, "uncertain": false, "quick_add": false, "calories": null },
{ "search_name": "bread", "display_name": "Toast", "grams": 60, "uncertain": false, "quick_add": false, "calories": null }
],
"time": "13:00"
}
User: "a grilled chicken breast with some white rice and a little olive oil"
Output:
{
"foods": [
{ "search_name": "chicken breast", "display_name": "Chicken Breast", "grams": 174, "uncertain": false, "quick_add": false, "calories": null },
{ "search_name": "white rice", "display_name": "White Rice", "grams": 150, "uncertain": true, "quick_add": false, "calories": null },
{ "search_name": "olive oil", "display_name": "Olive Oil", "grams": 10, "uncertain": true, "quick_add": false, "calories": null }
],
"time": null
}
User: "three scrambled eggs and two slices of toast"
Output:
{
"foods": [
{ "search_name": "egg", "display_name": "Eggs", "grams": 150, "uncertain": false, "quick_add": false, "calories": null },
{ "search_name": "bread", "display_name": "Toast", "grams": 60, "uncertain": false, "quick_add": false, "calories": null }
],
"time": null
}
User: "chicken breast and rice"
Output:
{
"foods": [
{ "search_name": "chicken breast", "display_name": "Chicken Breast", "grams": 174, "uncertain": false, "quick_add": false, "calories": null },
{ "search_name": "rice", "display_name": "Rice", "grams": 150, "uncertain": true, "quick_add": false, "calories": null }
],
"time": null
}
User: "just log 1000 calories, don't know what it was"
Output:
{
"foods": [
{ "search_name": "quick add", "display_name": "Quick add", "grams": 0, "uncertain": false, "quick_add": true, "calories": 1000 }
],
"time": null
}`;
/**
* A food resolved against the shared USDA catalog, scaled to the logged grams.
*
* The `*Per100g` fields are the catalog base values; they are carried so the
* confirm sheet can recompute calories/protein/carbs/fat live as the user
* adjusts grams, with no extra API/DB round-trip (see Part 4 of the confirm
* sheet). For `unresolved` items every nutrition field is 0 and food_id is null.
*/
export interface ResolvedFoodItem {
food_id: string | null;
display_name: string;
grams: number;
calories: number;
protein: number;
carbs: number;
fat: number;
icon_name: string | null;
uncertain: boolean;
/** True when no confident USDA match was found, confirm sheet asks the user. */
unresolved: boolean;
/**
* True for a calorie-only "Quick add" (the user gave calories but no food).
* Logged as a clean "Quick add" entry: `calories` only, no grams, no macros.
*/
quickAdd: boolean;
// Per-100g catalog base values, for live macro recompute on gram changes.
caloriesPer100g: number;
proteinPer100g: number;
carbsPer100g: number;
fatPer100g: number;
/** Spoken time reference for the whole meal ("HH:MM" 24hr), or null. */
time: string | null;
}
/**
* Why a resolution failed. Every cause used to collapse into one generic
* message before it reached the user OR any log, which made a quota rejection,
* a Groq outage, a timeout and an unparseable reply indistinguishable in
* production - and there is no analytics in this app, only console logs.
*
* `unparseable` matters most: it is the failure class that Structured Outputs
* (Phase 2) is meant to eliminate, so it is tagged separately and never folded
* into `server`, giving a real before/after signal rather than only the eval's
* 20 fixed cases.
*/
export type FoodResolutionCause =
| 'auth' // no signed-in session
| 'config' // voice/AI logging not configured
| 'quota' // proxy daily per-user cap (HTTP 429)
| 'busy' // Groq per-minute rate limit (HTTP 503 from the proxy)
| 'timeout' // our own AbortController fired
| 'network' // fetch rejected for any other reason
| 'server' // proxy/Groq returned a non-OK status
| 'unparseable' // reply arrived but was not usable JSON
| 'catalog'; // Supabase food-catalog query failed
/** Raised for any resolution failure so the UI can surface a retry path. */
export class FoodResolutionError extends Error {
/**
* Diagnostic only. Local logging - nothing leaves the device. Optional so the
* exported constructor signature stays backward compatible.
*/
readonly cause: FoodResolutionCause;
constructor(message: string, cause: FoodResolutionCause = 'server') {
super(message);
this.name = 'FoodResolutionError';
this.cause = cause;
}
}
/** A validated food extracted from speech, before catalog resolution. */
export interface ExtractedFood {
searchName: string;
displayName: string;
grams: number;
uncertain: boolean;
/** Calorie-only "Quick add" (no identifiable food); carries `calories`. */
quickAdd?: boolean;
/** Stated calories for a quick-add item. */
calories?: number;
/** Spoken time reference for the whole meal ("HH:MM" 24hr), or null. */
time: string | null;
}
/** The parsed extraction payload: the split foods plus an optional meal time. */
export interface FoodExtractionResult {
foods: ExtractedFood[];
time: string | null;
}
/** A ranked/ILIKE catalog candidate (per-100g), as returned by the RPCs. */
interface Candidate {
id: string;
name: string;
brand: string | null;
caloriesPer100g: number;
proteinPer100g: number;
carbsPer100g: number;
fatPer100g: number;
icon: string | null;
/** 'usda' (generic) or 'usda_branded'; null on rows from the ILIKE fallback. */
source: string | null;
/**
* True when the row came from the curated food_aliases table (an exact staple
* hit like "coffee" pinned to plain brewed coffee). Alias hits skip the Llama
* match step entirely, the answer is already deterministic.
*/
isAlias: boolean;
}
/** Coerce an unknown numeric (PostgREST may send numeric as a string) to a finite number. */
function num(v: unknown): number {
const n = typeof v === 'number' ? v : parseFloat(String(v ?? ''));
return Number.isFinite(n) ? n : 0;
}
/** fetch with an abort timeout; never leaks the key/response into the error. */
async function postWithTimeout(init: RequestInit, timeoutMs: number): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(CHAT_URL, { ...init, signal: controller.signal });
} catch {
// Our own abort vs the network genuinely failing: same user-facing copy,
// but they mean different things when reading a log during a model swap.
const timedOut = controller.signal.aborted;
throw new FoodResolutionError(
'Network error while contacting the AI service.',
timedOut ? 'timeout' : 'network',
);
} finally {
clearTimeout(timer);
}
}
/**
* One Llama chat completion via the groq-proxy Edge Function. Returns the
* assistant message text. The model is chosen server-side; the proxy passes
* Groq's OpenAI-compatible response through verbatim.
*/
async function chat(
messages: { role: 'system' | 'user'; content: string }[],
timeoutMs: number,
/**
* Names a server-side JSON schema for Structured Outputs. The schema itself
* lives in the proxy, so the client still controls nothing but the text, and
* an older build that omits this keeps working with prompt-guided JSON.
*/
mode?: 'extraction' | 'match',
): Promise<string> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (!token) throw new FoodResolutionError('Please sign in to use voice and AI logging.', 'auth');
const res = await postWithTimeout(
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
apikey: env.supabaseAnonKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({ temperature: 0, messages, ...(mode ? { mode } : {}) }),
},
timeoutMs,
);
if (!res.ok) {
// 429 is the proxy's own daily per-user cap; 503 is Groq's per-minute rate
// limit passed through. "Try again tomorrow" and "try again in a moment"
// are different answers, so they must not share a message.
if (res.status === 429) {
throw new FoodResolutionError('Daily AI limit reached. Try again tomorrow.', 'quota');
}
if (res.status === 503) {
throw new FoodResolutionError('The AI service is busy. Try again in a moment.', 'busy');
}
throw new FoodResolutionError('The AI service returned an error.', 'server');
}
const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
return json.choices?.[0]?.message?.content ?? '';
}
/** Pull the first JSON array out of a model response (tolerates ``` fences / prose). */
function parseJsonArray(content: string): unknown {
const match = content.match(/\[[\s\S]*\]/);
if (!match) return null;
try {
return JSON.parse(match[0]);
} catch {
return null;
}
}
/** Normalize a model time value to a valid "HH:MM" 24-hour string, or null. */
function normalizeTime(v: unknown): string | null {
if (v == null) return null;
const m = String(v).trim().match(/^(\d{1,2}):(\d{2})$/);
if (!m) return null;
const h = parseInt(m[1], 10);
const min = parseInt(m[2], 10);
if (h < 0 || h > 23 || min < 0 || min > 59) return null;
return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`;
}
/**
* Pull the extraction payload out of a model reply. Accepts the current
* `{ "foods": [...], "time": ... }` object, and still tolerates a bare `[...]`
* array (older format / mocked test responses) with no time. Returns null when
* neither shape is present.
*/
function parseModelPayload(content: string): { rows: unknown[]; time: string | null } | null {
const objMatch = content.match(/\{[\s\S]*\}/);
if (objMatch) {
try {
const obj = JSON.parse(objMatch[0]) as Record<string, unknown>;
if (obj && Array.isArray(obj.foods)) {
return { rows: obj.foods, time: normalizeTime(obj.time) };
}
} catch {
// Not a valid object (e.g. a bare array whose inner braces don't parse) →
// fall through to the bare-array path below.
}
}
const arr = parseJsonArray(content);
return Array.isArray(arr) ? { rows: arr, time: null } : null;
}
/** Trim, collapse whitespace and cap a human-facing display name. */
function cleanDisplay(raw: string): string {
return raw.replace(/\s+/g, ' ').trim().slice(0, 80);
}
/**
* Parse + validate the model's extraction JSON into foods + an optional meal
* time. Pure (no network) and exported so the splitting/validation can be
* unit-tested against a mocked Llama response. Throws FoodResolutionError when
* the reply isn't a usable foods payload (never swallowed silently); junk rows
* inside a valid list are dropped. The meal time is stamped onto every food so
* it threads through to the confirm sheet.
*/
export function parseExtractionResult(content: string): FoodExtractionResult {
const payload = parseModelPayload(content);
if (!payload) {
throw new FoodResolutionError('Could not understand the foods in that. Please try again.', 'unparseable');
}
const { rows, time } = payload;
const out: ExtractedFood[] = [];
for (const raw of (rows as Record<string, unknown>[]).slice(0, MAX_ITEMS)) {
if (!raw || typeof raw !== 'object') continue;
// Calorie-only "Quick add": log the calories with no food, no fabricated
// gram weight, no invented macros.
if (raw.quick_add === true) {
const calories = num(raw.calories);
if (calories > 0) {
out.push({
searchName: 'quick add',
displayName: 'Quick add',
grams: 0,
uncertain: false,
quickAdd: true,
calories: Math.min(Math.round(calories), MAX_QUICK_ADD_KCAL),
time,
});
}
continue;
}
const searchName = sanitizeQuery(String(raw.search_name ?? ''));
const displayName = cleanDisplay(String(raw.display_name ?? raw.search_name ?? ''));
if (searchName.length < MIN_SEARCH_LEN || !displayName) continue;
let grams = num(raw.grams);
if (grams <= 0) grams = 100; // sane default when the model omits a weight
grams = Math.min(Math.max(round1(grams), MIN_GRAMS), MAX_GRAMS);
out.push({ searchName, displayName, grams, uncertain: raw.uncertain === true, time });
}
return { foods: out, time };
}
/**
* Parse the model's extraction JSON into ExtractedFood[]. Thin wrapper over
* parseExtractionResult, kept for the existing unit tests.
*/
export function parseExtraction(content: string): ExtractedFood[] {
return parseExtractionResult(content).foods;
}
/**
* Step 1, extract each food + its final gram weight from the raw text.
* Throws FoodResolutionError on a parse failure (never swallowed silently).
*/
async function extractFoods(text: string): Promise<ExtractedFood[]> {
const content = await chat(
[
{ role: 'system', content: EXTRACTION_SYSTEM_PROMPT },
{ role: 'user', content: text },
],
EXTRACT_TIMEOUT_MS,
'extraction',
);
return parseExtraction(content);
}
/** Map a raw RPC row to a Candidate (coercing numeric strings). */
function toCandidate(r: Record<string, unknown>): Candidate {
return {
id: String(r.id),
name: String(r.name ?? ''),
brand: r.brand != null ? String(r.brand) : null,
caloriesPer100g: num(r.calories_per_100g),
proteinPer100g: num(r.protein_per_100g),
carbsPer100g: num(r.carbs_per_100g),
fatPer100g: num(r.fat_per_100g),
icon: r.icon != null ? String(r.icon) : null,
source: r.source != null ? String(r.source) : null,
isAlias: r.is_alias === true,
};
}
/**
* Step 2, search the shared `foods` catalog for the spoken term via the ranked
* RPC (migration 0013/0014): curated alias hit first, then a generic-first
* weighted trigram ranking. If that returns nothing, falls back to a substring
* ILIKE RPC. Throws on a Supabase error so the UI can offer a retry.
*/
async function searchCatalog(searchName: string): Promise<Candidate[]> {
const q = sanitizeQuery(searchName);
if (q.length < MIN_SEARCH_LEN) return [];
const ranked = await supabase.rpc('search_foods_ranked', { query: q, max_results: CANDIDATE_LIMIT });
if (ranked.error) throw new FoodResolutionError('Could not search the food database.', 'catalog');
if (ranked.data && ranked.data.length > 0) {
return (ranked.data as Record<string, unknown>[]).map(toCandidate);
}
// Fallback: ranked search matched nothing → widen to a substring search.
const ilike = await supabase.rpc('search_foods_ilike', { query: q, max_results: CANDIDATE_LIMIT });
if (ilike.error) throw new FoodResolutionError('Could not search the food database.', 'catalog');
return ((ilike.data as Record<string, unknown>[] | null) ?? []).map(toCandidate);
}
/**
* Match-step prompt, built ONLY from the real candidate list. Each line carries
* the kcal/100g and a [generic]/[branded] tag so the model can sanity-check its
* pick against plausible values (the values themselves still come only from the
* DB row, the model never generates nutrition). The old version embedded
* fabricated few-shot candidate lists ("Coffee, brewed from grounds") that were
* never in the real list, which made the model reject or hallucinate indexes.
*/
function buildMatchPrompt(food: ExtractedFood, candidates: Candidate[]): string {
const list = candidates
.map((c, i) => {
const brand = c.brand ? ` (${c.brand})` : '';
const tag = c.source ? (c.source === 'usda' ? '[generic]' : '[branded]') : c.brand ? '[branded]' : '[generic]';
return `${i + 1}. ${c.name}${brand} - ${Math.round(c.caloriesPer100g)} kcal/100g ${tag}`;
})
.join('\n');
return `You are matching a user's spoken food to one entry from a nutrition database.
User said: "${food.displayName}" (${food.grams}g)
Candidates ([generic] = standard USDA food, [branded] = packaged product):
${list}
RULES, in strict priority order:
1. If the user named a brand, pick that brand's entry.
2. Otherwise ALWAYS prefer a [generic] entry over any [branded] one, even if the branded name looks like a closer string match.
3. Prefer the plainest form of the food: no added flavors, coatings, or dish preparations the user did not mention ("chicken breast" is not "chicken breast poppers").
4. Use the kcal values as a sanity check: pick the entry whose calories are plausible for the plain food the user named (plain brewed coffee or tea is 0-5 kcal, raw fruit 30-100, plain cooked meat 100-280, cheese 250-450, nuts and oils 550-900). Reject implausible outliers.
5. If the user stated a preparation method (fried, boiled, grilled), prefer the entry matching it.
Database names are formal and comma-inverted ("Chicken, broilers or fryers, breast, meat only"). Match by meaning, not exact wording.
Respond with the single number of the best candidate. Nothing else. Respond 0 ONLY if no candidate is the same food at all; a plain [generic] version of the food the user named is always an acceptable match.`;
}
/**
* Step 3, ask Llama which candidate best matches the spoken food. Returns the
* 1-based index, or 0 when none fit / the reply can't be parsed. A failed match
* call degrades that single item to unresolved rather than failing the batch.
*/
async function pickBestMatch(food: ExtractedFood, candidates: Candidate[]): Promise<number> {
let content: string;
try {
content = await chat([{ role: 'user', content: buildMatchPrompt(food, candidates) }], MATCH_TIMEOUT_MS, 'match');
} catch {
return 0; // one flaky match shouldn't sink the whole meal, mark unresolved
}
// Under Structured Outputs the reply is { "index": n }. The bare-integer
// regex is KEPT as a fallback so a GROQ_STRUCTURED_OUTPUTS=off rollback, or
// an older proxy, still parses. Range-checked by the caller either way.
try {
const obj = JSON.parse(content) as { index?: unknown };
if (typeof obj?.index === 'number' && Number.isFinite(obj.index)) return obj.index;
} catch {
// not JSON: fall through to the legacy bare-integer path
}
const m = content.match(/-?\d+/);
if (!m) return 0;
const n = parseInt(m[0], 10);
return Number.isFinite(n) ? n : 0;
}
/** Build the unresolved placeholder for a food with no confident catalog match. */
function unresolvedItem(food: ExtractedFood): ResolvedFoodItem {
return {
food_id: null,
display_name: food.displayName,
grams: food.grams,
calories: 0,
protein: 0,
carbs: 0,
fat: 0,
icon_name: null,
uncertain: food.uncertain,
unresolved: true,
quickAdd: false,
caloriesPer100g: 0,
proteinPer100g: 0,
carbsPer100g: 0,
fatPer100g: 0,
time: food.time,
};
}
/** A calorie-only quick add: clean "Quick add" entry, no grams, no macros. */
function quickAddItem(food: ExtractedFood): ResolvedFoodItem {
return {
food_id: null,
display_name: 'Quick add',
grams: 0,
calories: Math.max(0, Math.round(food.calories ?? 0)),
protein: 0,
carbs: 0,
fat: 0,
icon_name: null,
uncertain: false,
unresolved: false,
quickAdd: true,
caloriesPer100g: 0,
proteinPer100g: 0,
carbsPer100g: 0,
fatPer100g: 0,
time: food.time,
};
}
/** Step 4, scale a matched candidate's per-100g macros to the logged grams. */
function resolvedItem(food: ExtractedFood, c: Candidate): ResolvedFoodItem {
const factor = food.grams / 100;
return {
food_id: c.id,
display_name: food.displayName,
grams: food.grams,
calories: round1(c.caloriesPer100g * factor),
protein: round1(c.proteinPer100g * factor),
carbs: round1(c.carbsPer100g * factor),
fat: round1(c.fatPer100g * factor),
icon_name: c.icon,
uncertain: food.uncertain,
unresolved: false,
quickAdd: false,
caloriesPer100g: c.caloriesPer100g,
proteinPer100g: c.proteinPer100g,
carbsPer100g: c.carbsPer100g,
fatPer100g: c.fatPer100g,
time: food.time,
};
}
/** Resolve one extracted food: search, match, scale (or flag unresolved / quick add). */
async function resolveOne(food: ExtractedFood): Promise<ResolvedFoodItem> {
if (food.quickAdd) return quickAddItem(food);
const candidates = await searchCatalog(food.searchName);
if (candidates.length === 0) return unresolvedItem(food);
// A curated alias hit ("coffee" pinned to plain brewed coffee) is
// deterministic: skip the Llama match round-trip entirely.
if (candidates[0].isAlias) return resolvedItem(food, candidates[0]);
const pick = await pickBestMatch(food, candidates);
if (pick < 1 || pick > candidates.length) return unresolvedItem(food);
return resolvedItem(food, candidates[pick - 1]);
}
/**
* Resolve a raw spoken / typed meal into confirm-ready food items.
*
* The caller passes the raw Whisper transcript or the AI-tab text verbatim, all
* extraction, catalog search, matching and scaling happen here. Returns [] when
* no foods were extracted; throws FoodResolutionError on a hard failure (Groq
* down, extraction unparseable, catalog query error) so the UI can show a retry.
*/
export async function resolveSpokenMeal(rawText: string): Promise<ResolvedFoodItem[]> {
if (!isGroqConfigured) {
throw new FoodResolutionError('Voice and AI logging are not configured.', 'config');
}
const text = rawText.trim();
if (!text) return [];
const foods = await extractFoods(text);
if (foods.length === 0) return [];
return Promise.all(foods.map(resolveOne));
}