f0632cce95
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>
74 lines
2.7 KiB
TypeScript
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;
|
|
}
|
|
}
|