diff --git a/app/src/main/java/com/danvics/calorieai/MainActivity.kt b/app/src/main/java/com/danvics/calorieai/MainActivity.kt index a76f83e..361dab6 100644 --- a/app/src/main/java/com/danvics/calorieai/MainActivity.kt +++ b/app/src/main/java/com/danvics/calorieai/MainActivity.kt @@ -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 = JSONObject().put("model", model).put("temperature", 0.1) .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", - "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 = diff --git a/app/src/main/java/com/danvics/calorieai/ai/NutritionParser.kt b/app/src/main/java/com/danvics/calorieai/ai/NutritionParser.kt index dd62aa2..2cb77e5 100644 --- a/app/src/main/java/com/danvics/calorieai/ai/NutritionParser.kt +++ b/app/src/main/java/com/danvics/calorieai/ai/NutritionParser.kt @@ -6,29 +6,58 @@ import org.json.JSONObject class NutritionParser { fun parse(content: String): NutritionEstimate { + // Strip markdown code fences and extract the JSON object var cleaned = content.trim() - .removePrefix("```json") - .removePrefix("```") - .removeSuffix("```") - .trim() + val fenceEnd = cleaned.indexOf('\n') + if (cleaned.startsWith("```") && fenceEnd >= 0) cleaned = cleaned.substring(fenceEnd + 1) + if (cleaned.endsWith("```")) cleaned = cleaned.dropLast(3) + cleaned = cleaned.trim() + val start = cleaned.indexOf('{') val end = cleaned.lastIndexOf('}') if (start >= 0 && end > start) cleaned = cleaned.substring(start, end + 1) + val json = JSONObject(cleaned) return NutritionEstimate( - mealName = json.optString("mealName", "Meal").ifBlank { "Meal" }, - calories = json.optInt("calories").coerceAtLeast(0), - proteinGrams = json.optInt("proteinGrams").coerceAtLeast(0), - carbsGrams = json.optInt("carbsGrams").coerceAtLeast(0), - fatGrams = json.optInt("fatGrams").coerceAtLeast(0), - fruitServings = json.optDouble("fruitServings", json.optDouble("fruitsServings", 0.0)).coerceAtLeast(0.0), - vegetableServings = json.optDouble("vegetableServings", json.optDouble("vegetablesServings", 0.0)).coerceAtLeast(0.0), + mealName = json.optString("mealName", "").ifBlank { "Meal" }, + calories = safeInt(json, "calories", "kcal", "energy"), + proteinGrams = safeInt(json, "proteinGrams", "protein", "protein_g", "proteins"), + carbsGrams = safeInt(json, "carbsGrams", "carbs", "carbohydrates", "carbohydrateGrams", "carbs_g", "carbohydrate"), + fatGrams = safeInt(json, "fatGrams", "fat", "totalFat", "fat_g", "fats"), + fruitServings = safeDouble(json, "fruitServings", "fruit", "fruitsServings", "fruits"), + vegetableServings = safeDouble(json, "vegetableServings", "vegetables", "vegetablesServings", "veggies"), foodGroups = stringifyGroups(json.opt("foodGroups")), notes = json.optString("notes", ""), 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) { null, JSONObject.NULL -> "" is JSONArray -> (0 until value.length()).joinToString(", ") { value.optString(it) } diff --git a/app/src/test/java/com/danvics/calorieai/ai/NutritionParserTest.kt b/app/src/test/java/com/danvics/calorieai/ai/NutritionParserTest.kt index 625be34..7ead83f 100644 --- a/app/src/test/java/com/danvics/calorieai/ai/NutritionParserTest.kt +++ b/app/src/test/java/com/danvics/calorieai/ai/NutritionParserTest.kt @@ -6,9 +6,16 @@ import org.junit.Test class NutritionParserTest { 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() { 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(640, estimate.calories) assertEquals(42, estimate.proteinGrams) @@ -16,11 +23,32 @@ class NutritionParserTest { 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() { val estimate = parser.parse("{\"calories\":-10,\"proteinGrams\":-3,\"fruitServings\":-1}") - assertEquals(0, estimate.calories) assertEquals(0, estimate.proteinGrams) 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) + } } diff --git a/web/src/App.svelte b/web/src/App.svelte index e7fced6..14efe72 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -528,32 +528,43 @@ model: settings.taskModel, temperature: 0.1, messages: [ - { role: 'system', content: 'Return strict JSON only. Do not wrap the response in markdown.' }, - { 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. - -Description: ${description} -Portion: ${measure} -Image estimate: ${imageEstimate}` }, + { 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 the nutrition for this meal:\nDescription: ${description}\nPortion: ${measure}\nAdditional context: ${imageEstimate}` }, ], }); } 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 end = cleaned.lastIndexOf('}'); 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 { - mealName: parsed.mealName || 'Meal', - calories: Math.max(0, Number.parseInt(parsed.calories || 0, 10)), - proteinGrams: Math.max(0, Number.parseInt(parsed.proteinGrams || 0, 10)), - carbsGrams: Math.max(0, Number.parseInt(parsed.carbsGrams || 0, 10)), - fatGrams: Math.max(0, Number.parseInt(parsed.fatGrams || 0, 10)), - fruitServings: Math.max(0, Number(parsed.fruitServings ?? parsed.fruitsServings ?? 0)), - vegetableServings: Math.max(0, Number(parsed.vegetableServings ?? parsed.vegetablesServings ?? 0)), - foodGroups: Array.isArray(parsed.foodGroups) ? parsed.foodGroups.join(', ') : String(parsed.foodGroups || ''), - notes: parsed.notes || '', + mealName: p.mealName || 'Meal', + calories: safeInt('calories', 'kcal', 'energy'), + proteinGrams: safeInt('proteinGrams', 'protein', 'protein_g'), + carbsGrams: safeInt('carbsGrams', 'carbs', 'carbohydrates', 'carbohydrateGrams'), + fatGrams: safeInt('fatGrams', 'fat', 'totalFat'), + fruitServings: safeFloat('fruitServings', 'fruit', 'fruitsServings'), + vegetableServings: safeFloat('vegetableServings', 'vegetables', 'vegetablesServings'), + foodGroups: Array.isArray(p.foodGroups) ? p.foodGroups.join(', ') : String(p.foodGroups || ''), + notes: p.notes || '', }; }