Files
Epicure/apps/web/lib/usda.ts
T
Arnaud f0632cce95 feat: USDA FoodData Central per-ingredient nutrition lookup (v0.70.0)
estimateNutrition() (lib/ai/features/estimate-nutrition.ts) is now a
hybrid: for each ingredient, estimateGrams() (lib/ingredient-grams.ts)
converts its quantity+unit to grams where possible (weight units
exactly; volume units -- cup/tbsp/tsp/ml/l/etc -- via a 1ml~=1g water-
density approximation, documented as a real simplification but far
better than skipping them). Convertible ingredients get looked up in
USDA FoodData Central (lib/usda.ts, SR Legacy + Survey (FNDDS)
datasets -- the two suited to generic/raw ingredients, not Branded
packaged products or narrower Foundation) and summed. Whatever's left
(count-based units like "2 cloves", no USDA match, or USDA_API_KEY
unset entirely) goes through one AI call asking for just that
subset's total contribution, which sums directly with the USDA
totals -- no whole-recipe AI estimate to reconcile against.

Same exported signature and return shape as before
(NutritionEstimate / {perServing: {...}}), so every existing caller
(the nutrition route, meal-plan generation) gets more accurate
results automatically once USDA_API_KEY is set in Admin -> Settings,
with zero behavior change when it isn't.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-22 00:41:12 +02:00

74 lines
2.7 KiB
TypeScript

import { getSiteSetting } from "@/lib/site-settings";
export type UsdaNutrientsPer100g = {
calories: number;
proteinG: number;
carbsG: number;
fatG: number;
fiberG: number;
sodiumMg: number;
};
// Standard USDA nutrient numbers (stable across FoodData Central datasets,
// unlike nutrientId which can vary) — see
// https://fdc.nal.usda.gov/portal-data/external/nutrientsList
const NUTRIENT_NUMBERS: Record<string, keyof UsdaNutrientsPer100g> = {
"208": "calories",
"203": "proteinG",
"205": "carbsG",
"204": "fatG",
"291": "fiberG",
"307": "sodiumMg",
};
/** Looks up an ingredient's nutrition facts per 100g via USDA FoodData
* Central. Restricted to the SR Legacy and Survey (FNDDS) datasets — the
* two best suited to generic/raw recipe ingredients ("flour", "olive
* oil"), unlike Branded (packaged products, already covered by the
* Open Food Facts barcode lookup in pantry scanning) or Foundation
* (narrower coverage). Returns null if unconfigured, no usable match, or
* the request fails — callers must treat this as best-effort and fall
* back to AI estimation. */
export async function lookupUsdaNutrients(query: string): Promise<UsdaNutrientsPer100g | null> {
const apiKey = await getSiteSetting("USDA_API_KEY");
if (!apiKey) return null;
try {
const url = new URL("https://api.nal.usda.gov/fdc/v1/foods/search");
url.searchParams.set("api_key", apiKey);
url.searchParams.set("query", query);
url.searchParams.set("pageSize", "3");
url.searchParams.set("dataType", "SR Legacy,Survey (FNDDS)");
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(8000) });
if (!res.ok) return null;
const data = (await res.json()) as {
foods?: { foodNutrients?: { nutrientNumber?: string; value?: number }[] }[];
};
const food = data.foods?.[0];
if (!food?.foodNutrients) return null;
const result: Partial<UsdaNutrientsPer100g> = {};
for (const n of food.foodNutrients) {
const key = n.nutrientNumber ? NUTRIENT_NUMBERS[n.nutrientNumber] : undefined;
if (key && typeof n.value === "number") result[key] = n.value;
}
// Energy is the load-bearing field — a "match" with no calorie value
// isn't usable, treat it the same as no match.
if (result.calories == null) return null;
return {
calories: result.calories,
proteinG: result.proteinG ?? 0,
carbsG: result.carbsG ?? 0,
fatG: result.fatG ?? 0,
fiberG: result.fiberG ?? 0,
sodiumMg: result.sodiumMg ?? 0,
};
} catch (err) {
console.error("[usda] lookupUsdaNutrients failed", { query, message: err instanceof Error ? err.message : err });
return null;
}
}