Fix AI nutrition extraction: robust parser + better prompt
- NutritionParser now handles short key aliases (protein/carbs/fat instead of proteinGrams/carbsGrams/fatGrams), string-typed numbers, surrounding prose, and multi-line markdown fences reliably - safeInt/safeDouble helpers check multiple key names in priority order and handle Number, String, and missing values without throwing - Nutrition prompt now includes an explicit JSON example so all models return the expected format regardless of their default style - Same fixes applied to web app parseEstimate (aliases + fence stripping) - Extended NutritionParserTest to cover aliases, string numbers, surrounding text Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
b93e479be4
commit
a4e44201a2
4 changed files with 104 additions and 32 deletions
|
|
@ -347,9 +347,13 @@ private fun buildVisionBody(model: String, description: String, measure: String,
|
||||||
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject =
|
private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject =
|
||||||
JSONObject().put("model", model).put("temperature", 0.1)
|
JSONObject().put("model", model).put("temperature", 0.1)
|
||||||
.put("messages", JSONArray()
|
.put("messages", JSONArray()
|
||||||
.put(JSONObject().put("role", "system").put("content", "Return strict JSON only. Do not wrap in markdown."))
|
.put(JSONObject().put("role", "system").put("content",
|
||||||
|
"You are a nutrition estimator. Respond with ONLY a JSON object — no markdown, no explanation, no extra text.\n" +
|
||||||
|
"Required keys: mealName (string), calories (integer), proteinGrams (integer), carbsGrams (integer), fatGrams (integer), fruitServings (decimal), vegetableServings (decimal), foodGroups (string), notes (string).\n" +
|
||||||
|
"Example: {\"mealName\":\"Grilled chicken and rice\",\"calories\":520,\"proteinGrams\":45,\"carbsGrams\":48,\"fatGrams\":10,\"fruitServings\":0,\"vegetableServings\":1.5,\"foodGroups\":\"protein, grains\",\"notes\":\"standard portion\"}"
|
||||||
|
))
|
||||||
.put(JSONObject().put("role", "user").put("content",
|
.put(JSONObject().put("role", "user").put("content",
|
||||||
"Estimate nutrition for one meal. Return JSON with keys: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integers for grams and calories.\n\nDescription: $description\nPortion: $measure\nImage estimate: $visionEstimate"
|
"Estimate the nutrition for this meal:\nDescription: $description\nPortion: $measure\nAdditional context: $visionEstimate"
|
||||||
)))
|
)))
|
||||||
|
|
||||||
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
|
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
|
||||||
|
|
|
||||||
|
|
@ -6,29 +6,58 @@ import org.json.JSONObject
|
||||||
|
|
||||||
class NutritionParser {
|
class NutritionParser {
|
||||||
fun parse(content: String): NutritionEstimate {
|
fun parse(content: String): NutritionEstimate {
|
||||||
|
// Strip markdown code fences and extract the JSON object
|
||||||
var cleaned = content.trim()
|
var cleaned = content.trim()
|
||||||
.removePrefix("```json")
|
val fenceEnd = cleaned.indexOf('\n')
|
||||||
.removePrefix("```")
|
if (cleaned.startsWith("```") && fenceEnd >= 0) cleaned = cleaned.substring(fenceEnd + 1)
|
||||||
.removeSuffix("```")
|
if (cleaned.endsWith("```")) cleaned = cleaned.dropLast(3)
|
||||||
.trim()
|
cleaned = cleaned.trim()
|
||||||
|
|
||||||
val start = cleaned.indexOf('{')
|
val start = cleaned.indexOf('{')
|
||||||
val end = cleaned.lastIndexOf('}')
|
val end = cleaned.lastIndexOf('}')
|
||||||
if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1)
|
if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1)
|
||||||
|
|
||||||
val json = JSONObject(cleaned)
|
val json = JSONObject(cleaned)
|
||||||
return NutritionEstimate(
|
return NutritionEstimate(
|
||||||
mealName = json.optString("mealName", "Meal").ifBlank { "Meal" },
|
mealName = json.optString("mealName", "").ifBlank { "Meal" },
|
||||||
calories = json.optInt("calories").coerceAtLeast(0),
|
calories = safeInt(json, "calories", "kcal", "energy"),
|
||||||
proteinGrams = json.optInt("proteinGrams").coerceAtLeast(0),
|
proteinGrams = safeInt(json, "proteinGrams", "protein", "protein_g", "proteins"),
|
||||||
carbsGrams = json.optInt("carbsGrams").coerceAtLeast(0),
|
carbsGrams = safeInt(json, "carbsGrams", "carbs", "carbohydrates", "carbohydrateGrams", "carbs_g", "carbohydrate"),
|
||||||
fatGrams = json.optInt("fatGrams").coerceAtLeast(0),
|
fatGrams = safeInt(json, "fatGrams", "fat", "totalFat", "fat_g", "fats"),
|
||||||
fruitServings = json.optDouble("fruitServings", json.optDouble("fruitsServings", 0.0)).coerceAtLeast(0.0),
|
fruitServings = safeDouble(json, "fruitServings", "fruit", "fruitsServings", "fruits"),
|
||||||
vegetableServings = json.optDouble("vegetableServings", json.optDouble("vegetablesServings", 0.0)).coerceAtLeast(0.0),
|
vegetableServings = safeDouble(json, "vegetableServings", "vegetables", "vegetablesServings", "veggies"),
|
||||||
foodGroups = stringifyGroups(json.opt("foodGroups")),
|
foodGroups = stringifyGroups(json.opt("foodGroups")),
|
||||||
notes = json.optString("notes", ""),
|
notes = json.optString("notes", ""),
|
||||||
raw = content
|
raw = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun safeInt(json: JSONObject, vararg keys: String): Int {
|
||||||
|
for (key in keys) {
|
||||||
|
val raw = json.opt(key) ?: continue
|
||||||
|
val n = when (raw) {
|
||||||
|
is Number -> raw.toInt()
|
||||||
|
is String -> raw.toDoubleOrNull()?.toInt() ?: continue
|
||||||
|
else -> continue
|
||||||
|
}
|
||||||
|
if (n > 0) return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun safeDouble(json: JSONObject, vararg keys: String): Double {
|
||||||
|
for (key in keys) {
|
||||||
|
val raw = json.opt(key) ?: continue
|
||||||
|
val n = when (raw) {
|
||||||
|
is Number -> raw.toDouble()
|
||||||
|
is String -> raw.toDoubleOrNull() ?: continue
|
||||||
|
else -> continue
|
||||||
|
}
|
||||||
|
if (n > 0.0) return n
|
||||||
|
}
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
|
||||||
private fun stringifyGroups(value: Any?): String = when (value) {
|
private fun stringifyGroups(value: Any?): String = when (value) {
|
||||||
null, JSONObject.NULL -> ""
|
null, JSONObject.NULL -> ""
|
||||||
is JSONArray -> (0 until value.length()).joinToString(", ") { value.optString(it) }
|
is JSONArray -> (0 until value.length()).joinToString(", ") { value.optString(it) }
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,16 @@ import org.junit.Test
|
||||||
class NutritionParserTest {
|
class NutritionParserTest {
|
||||||
private val parser = NutritionParser()
|
private val parser = NutritionParser()
|
||||||
|
|
||||||
|
@Test fun parsesCleanJson() {
|
||||||
|
val estimate = parser.parse("{\"mealName\":\"Chicken salad\",\"calories\":450,\"proteinGrams\":38,\"carbsGrams\":12,\"fatGrams\":22,\"fruitServings\":0,\"vegetableServings\":2,\"foodGroups\":\"protein, vegetables\",\"notes\":\"\"}")
|
||||||
|
assertEquals("Chicken salad", estimate.mealName)
|
||||||
|
assertEquals(450, estimate.calories)
|
||||||
|
assertEquals(38, estimate.proteinGrams)
|
||||||
|
assertEquals(2.0, estimate.vegetableServings, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun parsesJsonInsideMarkdownFence() {
|
@Test fun parsesJsonInsideMarkdownFence() {
|
||||||
val estimate = parser.parse("```json\n{\"mealName\":\"Rice bowl\",\"calories\":640,\"proteinGrams\":42,\"carbsGrams\":70,\"fatGrams\":18,\"fruitServings\":0.5,\"vegetableServings\":1.5,\"foodGroups\":[\"grain\",\"vegetable\"]}\n```")
|
val estimate = parser.parse("```json\n{\"mealName\":\"Rice bowl\",\"calories\":640,\"proteinGrams\":42,\"carbsGrams\":70,\"fatGrams\":18,\"fruitServings\":0.5,\"vegetableServings\":1.5,\"foodGroups\":[\"grain\",\"vegetable\"]}\n```")
|
||||||
|
|
||||||
assertEquals("Rice bowl", estimate.mealName)
|
assertEquals("Rice bowl", estimate.mealName)
|
||||||
assertEquals(640, estimate.calories)
|
assertEquals(640, estimate.calories)
|
||||||
assertEquals(42, estimate.proteinGrams)
|
assertEquals(42, estimate.proteinGrams)
|
||||||
|
|
@ -16,11 +23,32 @@ class NutritionParserTest {
|
||||||
assertEquals(1.5, estimate.vegetableServings, 0.001)
|
assertEquals(1.5, estimate.vegetableServings, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun parsesShortKeyAliases() {
|
||||||
|
// Models often return "protein" instead of "proteinGrams" etc.
|
||||||
|
val estimate = parser.parse("{\"mealName\":\"Pasta\",\"calories\":580,\"protein\":28,\"carbs\":80,\"fat\":14}")
|
||||||
|
assertEquals("Pasta", estimate.mealName)
|
||||||
|
assertEquals(580, estimate.calories)
|
||||||
|
assertEquals(28, estimate.proteinGrams)
|
||||||
|
assertEquals(80, estimate.carbsGrams)
|
||||||
|
assertEquals(14, estimate.fatGrams)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun parsesJsonWithSurroundingText() {
|
||||||
|
val estimate = parser.parse("Here is the estimate:\n{\"mealName\":\"Toast\",\"calories\":200,\"proteinGrams\":6,\"carbsGrams\":35,\"fatGrams\":4,\"fruitServings\":0,\"vegetableServings\":0,\"foodGroups\":\"grains\",\"notes\":\"2 slices\"}\nThese are estimates only.")
|
||||||
|
assertEquals("Toast", estimate.mealName)
|
||||||
|
assertEquals(200, estimate.calories)
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun clampsNegativeNumbers() {
|
@Test fun clampsNegativeNumbers() {
|
||||||
val estimate = parser.parse("{\"calories\":-10,\"proteinGrams\":-3,\"fruitServings\":-1}")
|
val estimate = parser.parse("{\"calories\":-10,\"proteinGrams\":-3,\"fruitServings\":-1}")
|
||||||
|
|
||||||
assertEquals(0, estimate.calories)
|
assertEquals(0, estimate.calories)
|
||||||
assertEquals(0, estimate.proteinGrams)
|
assertEquals(0, estimate.proteinGrams)
|
||||||
assertEquals(0.0, estimate.fruitServings, 0.001)
|
assertEquals(0.0, estimate.fruitServings, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun handlesStringNumbers() {
|
||||||
|
val estimate = parser.parse("{\"mealName\":\"Oatmeal\",\"calories\":\"320\",\"protein\":\"12\",\"carbs\":\"55\",\"fat\":\"8\"}")
|
||||||
|
assertEquals(320, estimate.calories)
|
||||||
|
assertEquals(12, estimate.proteinGrams)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -528,32 +528,43 @@
|
||||||
model: settings.taskModel,
|
model: settings.taskModel,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' },
|
{ role: 'system', content: 'You are a nutrition estimator. Respond with ONLY a JSON object — no markdown, no explanation, no extra text.\nRequired keys: mealName (string), calories (integer), proteinGrams (integer), carbsGrams (integer), fatGrams (integer), fruitServings (decimal), vegetableServings (decimal), foodGroups (string), notes (string).\nExample: {"mealName":"Grilled chicken and rice","calories":520,"proteinGrams":45,"carbsGrams":48,"fatGrams":10,"fruitServings":0,"vegetableServings":1.5,"foodGroups":"protein, grains","notes":"standard portion"}' },
|
||||||
{ role: 'user', content: `Estimate nutrition for one meal. Return only JSON with keys mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes. Use integer grams and calories. fruitServings and vegetableServings can be decimal numbers. foodGroups should be a short comma-separated string.
|
{ role: 'user', content: `Estimate the nutrition for this meal:\nDescription: ${description}\nPortion: ${measure}\nAdditional context: ${imageEstimate}` },
|
||||||
|
|
||||||
Description: ${description}
|
|
||||||
Portion: ${measure}
|
|
||||||
Image estimate: ${imageEstimate}` },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEstimate(content) {
|
function parseEstimate(content) {
|
||||||
let cleaned = content.trim().replace(/^```json/i, '').replace(/^```/, '').replace(/```$/, '').trim();
|
let cleaned = content.trim();
|
||||||
|
// Strip markdown code fences
|
||||||
|
const fenceEnd = cleaned.indexOf('\n');
|
||||||
|
if (cleaned.startsWith('```') && fenceEnd >= 0) cleaned = cleaned.slice(fenceEnd + 1);
|
||||||
|
if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3);
|
||||||
|
cleaned = cleaned.trim();
|
||||||
const start = cleaned.indexOf('{');
|
const start = cleaned.indexOf('{');
|
||||||
const end = cleaned.lastIndexOf('}');
|
const end = cleaned.lastIndexOf('}');
|
||||||
if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1);
|
if (start >= 0 && end > start) cleaned = cleaned.slice(start, end + 1);
|
||||||
const parsed = JSON.parse(cleaned);
|
const p = JSON.parse(cleaned);
|
||||||
|
|
||||||
|
function safeInt(...keys) {
|
||||||
|
for (const k of keys) { const v = parseInt(p[k], 10); if (v > 0) return v; }
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
function safeFloat(...keys) {
|
||||||
|
for (const k of keys) { const v = parseFloat(p[k]); if (v > 0) return v; }
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mealName: parsed.mealName || 'Meal',
|
mealName: p.mealName || 'Meal',
|
||||||
calories: Math.max(0, Number.parseInt(parsed.calories || 0, 10)),
|
calories: safeInt('calories', 'kcal', 'energy'),
|
||||||
proteinGrams: Math.max(0, Number.parseInt(parsed.proteinGrams || 0, 10)),
|
proteinGrams: safeInt('proteinGrams', 'protein', 'protein_g'),
|
||||||
carbsGrams: Math.max(0, Number.parseInt(parsed.carbsGrams || 0, 10)),
|
carbsGrams: safeInt('carbsGrams', 'carbs', 'carbohydrates', 'carbohydrateGrams'),
|
||||||
fatGrams: Math.max(0, Number.parseInt(parsed.fatGrams || 0, 10)),
|
fatGrams: safeInt('fatGrams', 'fat', 'totalFat'),
|
||||||
fruitServings: Math.max(0, Number(parsed.fruitServings ?? parsed.fruitsServings ?? 0)),
|
fruitServings: safeFloat('fruitServings', 'fruit', 'fruitsServings'),
|
||||||
vegetableServings: Math.max(0, Number(parsed.vegetableServings ?? parsed.vegetablesServings ?? 0)),
|
vegetableServings: safeFloat('vegetableServings', 'vegetables', 'vegetablesServings'),
|
||||||
foodGroups: Array.isArray(parsed.foodGroups) ? parsed.foodGroups.join(', ') : String(parsed.foodGroups || ''),
|
foodGroups: Array.isArray(p.foodGroups) ? p.foodGroups.join(', ') : String(p.foodGroups || ''),
|
||||||
notes: parsed.notes || '',
|
notes: p.notes || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue