Skip to content

Commit 6f0abd3

Browse files
schwaaampclaude
andcommitted
Wire canonical foods search into event logging pipeline
Connect the existing search-foods Edge Function to the product search flow so generic foods like "banana" surface canonical entries (with prep states and real serving sizes) instead of only branded catalog products. - Add searchCanonicalAndAdapt() to productSearch.ts that calls the Edge Function, batch-fetches product_servings, and maps to ProductSearchResult - Modify searchAllProducts() pipeline: canonical search as Priority 3, catalog fallback as Priority 4, merged results deduped by name - Pass canonical_food_id and preparation_state through confirm flow into saved event data and user_product_registry - Add canonicalFoodId parameter to updateUserProductRegistry() - Recreate ivfflat vector index dropped in earlier index cleanup migration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d13549b commit 6f0abd3

File tree

8 files changed

+693
-1212
lines changed

8 files changed

+693
-1212
lines changed

docs/planning/canonical-foods-strategy.md

Lines changed: 430 additions & 1146 deletions
Large diffs are not rendered by default.

mobile/__tests__/utils/voiceEventParser.test.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -376,7 +376,8 @@ describe('voiceEventParser', () => {
376376
'food',
377377
'Apple',
378378
'Organic',
379-
undefined // productCatalogId defaults to null, converted to undefined via ??
379+
undefined, // productCatalogId defaults to null, converted to undefined via ??
380+
null // canonicalFoodId
380381
);
381382
});
382383

@@ -402,7 +403,8 @@ describe('voiceEventParser', () => {
402403
'supplement',
403404
'Vitamin D',
404405
'NOW',
405-
undefined // productCatalogId defaults to null, converted to undefined via ??
406+
undefined, // productCatalogId defaults to null, converted to undefined via ??
407+
null // canonicalFoodId
406408
);
407409
});
408410

@@ -428,7 +430,8 @@ describe('voiceEventParser', () => {
428430
'medication',
429431
'Advil',
430432
null,
431-
undefined // productCatalogId defaults to null, converted to undefined via ??
433+
undefined, // productCatalogId defaults to null, converted to undefined via ??
434+
null // canonicalFoodId
432435
);
433436
});
434437

@@ -512,7 +515,8 @@ describe('voiceEventParser', () => {
512515
'food',
513516
'Protein Bar',
514517
null,
515-
'catalog-uuid-456'
518+
'catalog-uuid-456',
519+
null // canonicalFoodId
516520
);
517521
});
518522

@@ -547,7 +551,8 @@ describe('voiceEventParser', () => {
547551
'supplement',
548552
'Fish Oil',
549553
null,
550-
undefined // productCatalogId defaults to null, converted to undefined via ??
554+
undefined, // productCatalogId defaults to null, converted to undefined via ??
555+
null // canonicalFoodId
551556
);
552557
});
553558

mobile/src/components/confirm/flows/ProductSelectionFlow.tsx

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,9 @@ export function ProductSelectionFlow({
296296
if (product.nutrients?.carbs) updatedData.carbs = product.nutrients.carbs;
297297
if (product.nutrients?.fat) updatedData.fat = product.nutrients.fat;
298298
if (product.servingSize) updatedData.serving_size = product.servingSize;
299+
if (product.canonical_food_id) {
300+
updatedData.canonical_food_id = product.canonical_food_id;
301+
}
299302

300303
// Match user quantity/unit to serving options
301304
const userQuantity = parseFloat(parsedData.event_data?.value as string);
@@ -453,13 +456,42 @@ export function ProductSelectionFlow({
453456

454457
const eventTime = calculateEventTime(parsedData.time_info);
455458

459+
// Calculate final nutrients from selected serving/prep state
460+
const confirmedEventData = { ...finalEventData };
461+
if (selectedProduct !== null && selectedProduct !== 'manual') {
462+
const selectedProductData =
463+
typeof selectedProduct === 'number' ? productOptions[selectedProduct] : null;
464+
if (selectedProductData) {
465+
const calculated = calculateNutrients(selectedProductData, selectedProduct as number);
466+
if (calculated) {
467+
confirmedEventData.calories = calculated.calories;
468+
confirmedEventData.protein = calculated.protein;
469+
confirmedEventData.carbs = calculated.carbs;
470+
confirmedEventData.fat = calculated.fat;
471+
confirmedEventData.serving_grams = calculated.totalGrams;
472+
if (calculated.prepState) {
473+
confirmedEventData.preparation_state = calculated.prepState;
474+
}
475+
}
476+
}
477+
}
478+
479+
// For canonical sources, pass null as productCatalogId (canonicals aren't catalog entries)
480+
const selectedProductForCatalogId =
481+
typeof selectedProduct === 'number' ? productOptions[selectedProduct] : null;
482+
const productCatalogId =
483+
selectedProductForCatalogId?.source === 'canonical'
484+
? null
485+
: selectedProductForCatalogId?.id || null;
486+
456487
await createVoiceEvent(
457488
user.id,
458489
parsedData.event_type as EventType,
459-
finalEventData,
490+
confirmedEventData,
460491
eventTime,
461492
auditId,
462-
captureMethod
493+
captureMethod,
494+
productCatalogId
463495
);
464496

465497
if (auditId !== null) {
@@ -495,6 +527,7 @@ export function ProductSelectionFlow({
495527
confidence,
496528
auditId,
497529
captureMethod,
530+
calculateNutrients,
498531
onCheckPatterns,
499532
onComplete,
500533
]);

mobile/src/utils/productCatalog.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ interface SearchFoodsResult {
148148
error?: string;
149149
}
150150

151-
interface PreparationState {
151+
export interface PreparationState {
152152
state: string;
153153
is_default: boolean;
154154
calories_per_100g?: number | null;
@@ -158,7 +158,7 @@ interface PreparationState {
158158
[key: string]: unknown;
159159
}
160160

161-
interface CanonicalSearchResult {
161+
export interface CanonicalSearchResult {
162162
canonical: {
163163
id: string;
164164
display_name: string;

mobile/src/utils/productRegistry.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,8 @@ export async function updateUserProductRegistry(
284284
eventType: string,
285285
productName: string,
286286
brand: string | null | undefined = null,
287-
productCatalogId: string | null | undefined = null
287+
productCatalogId: string | null | undefined = null,
288+
canonicalFoodId: string | null | undefined = null
288289
): Promise<boolean> {
289290
if (!userId || !eventType || !productName) {
290291
await Logger.error('registry', 'Missing required parameters for registry update', {
@@ -323,9 +324,10 @@ export async function updateUserProductRegistry(
323324
.update({
324325
times_logged: existing.times_logged + 1,
325326
last_logged_at: new Date().toISOString(),
326-
// Update brand/catalog ID if provided
327+
// Update brand/catalog ID/canonical ID if provided
327328
...(brand && { brand }),
328-
...(productCatalogId && { product_catalog_id: productCatalogId })
329+
...(productCatalogId && { product_catalog_id: productCatalogId }),
330+
...(canonicalFoodId && { canonical_food_id: canonicalFoodId })
329331
})
330332
.eq('id', existing.id);
331333

@@ -354,6 +356,7 @@ export async function updateUserProductRegistry(
354356
product_name: productName,
355357
brand,
356358
product_catalog_id: productCatalogId,
359+
...(canonicalFoodId && { canonical_food_id: canonicalFoodId }),
357360
times_logged: 1
358361
});
359362

0 commit comments

Comments
 (0)