diff --git a/app/src/main/java/com/danvics/calorieai/MainActivity.kt b/app/src/main/java/com/danvics/calorieai/MainActivity.kt index 361dab6..c87a4d2 100644 --- a/app/src/main/java/com/danvics/calorieai/MainActivity.kt +++ b/app/src/main/java/com/danvics/calorieai/MainActivity.kt @@ -179,7 +179,8 @@ private fun AppRoot(repo: ApiRepository) { val visionEstimate = if (selectedImage != null && appState.settings.visionModel.isNotBlank()) { repo.chat(buildVisionBody(appState.settings.visionModel, draft.description, draft.measure, selectedImage!!)) } else "" - val nutritionJson = repo.chat(buildNutritionBody(appState.settings.taskModel, draft.description, draft.measure, visionEstimate)) + val todayHistory = buildTodayHistory(appState.entries, draft.date.ifBlank { LocalDate.now().toString() }) + val nutritionJson = repo.chat(buildNutritionBody(appState.settings.taskModel, draft.description, draft.measure, visionEstimate, todayHistory)) val estimate = parser.parse(nutritionJson) val meal = draft.copy( id = editing?.id ?: UUID.randomUUID().toString(), @@ -344,16 +345,31 @@ private fun buildVisionBody(model: String, description: String, measure: String, .put(JSONObject().put("type", "image_url").put("image_url", JSONObject().put("url", image.dataUri))) ))) -private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String): JSONObject = +private fun buildTodayHistory(entries: List, date: String): String { + val todayEntries = entries.filter { it.date == date && it.trashedAt.isBlank() } + if (todayEntries.isEmpty()) return "No meals logged yet today." + val rows = todayEntries.joinToString("\n") { + "- ${it.estimate.mealName}: ${it.estimate.calories} kcal, P${it.estimate.proteinGrams}g C${it.estimate.carbsGrams}g F${it.estimate.fatGrams}g" + } + val totals = todayEntries.fold(intArrayOf(0, 0, 0, 0)) { acc, e -> + acc[0] += e.estimate.calories; acc[1] += e.estimate.proteinGrams + acc[2] += e.estimate.carbsGrams; acc[3] += e.estimate.fatGrams; acc + } + return "$rows\nTotal so far: ${totals[0]} kcal, P${totals[1]}g C${totals[2]}g F${totals[3]}g" +} + +private fun buildNutritionBody(model: String, description: String, measure: String, visionEstimate: String, todayHistory: String): JSONObject = JSONObject().put("model", model).put("temperature", 0.1) .put("messages", JSONArray() .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\"}" + "You are a nutrition estimator. Output ONLY a raw JSON object — no markdown fences, no explanation, no text before or after.\n" + + "You MUST use these exact key names: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\n" + + "calories, proteinGrams, carbsGrams, fatGrams must be integers. fruitServings and vegetableServings must be numbers. foodGroups must be a short comma-separated string.\n" + + "Use the notes field for a brief personalised observation based on the user's diet history (e.g. macro balance, produce intake, daily total context).\n" + + "Output format: {\"mealName\":\"...\",\"calories\":0,\"proteinGrams\":0,\"carbsGrams\":0,\"fatGrams\":0,\"fruitServings\":0,\"vegetableServings\":0,\"foodGroups\":\"...\",\"notes\":\"...\"}" )) .put(JSONObject().put("role", "user").put("content", - "Estimate the nutrition for this meal:\nDescription: $description\nPortion: $measure\nAdditional context: $visionEstimate" + "Estimate nutrition for this meal.\nDescription: $description\nPortion: $measure\nImage analysis: $visionEstimate\n\nUser's meals logged today:\n$todayHistory" ))) 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 2cb77e5..8491504 100644 --- a/app/src/main/java/com/danvics/calorieai/ai/NutritionParser.kt +++ b/app/src/main/java/com/danvics/calorieai/ai/NutritionParser.kt @@ -6,58 +6,31 @@ import org.json.JSONObject class NutritionParser { fun parse(content: String): NutritionEstimate { - // Strip markdown code fences and extract the JSON object var cleaned = content.trim() + // Strip leading markdown fence line (e.g. "```json\n") 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() - + // Extract the JSON object in case there's any surrounding prose 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", "").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"), + 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", 0.0).coerceAtLeast(0.0), + vegetableServings = json.optDouble("vegetableServings", 0.0).coerceAtLeast(0.0), 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 7ead83f..99c671a 100644 --- a/app/src/test/java/com/danvics/calorieai/ai/NutritionParserTest.kt +++ b/app/src/test/java/com/danvics/calorieai/ai/NutritionParserTest.kt @@ -23,9 +23,8 @@ 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}") + @Test fun parsesExactKeys() { + val estimate = parser.parse("{\"mealName\":\"Pasta\",\"calories\":580,\"proteinGrams\":28,\"carbsGrams\":80,\"fatGrams\":14,\"fruitServings\":0,\"vegetableServings\":1,\"foodGroups\":\"grain\",\"notes\":\"\"}") assertEquals("Pasta", estimate.mealName) assertEquals(580, estimate.calories) assertEquals(28, estimate.proteinGrams) @@ -47,7 +46,7 @@ class NutritionParserTest { } @Test fun handlesStringNumbers() { - val estimate = parser.parse("{\"mealName\":\"Oatmeal\",\"calories\":\"320\",\"protein\":\"12\",\"carbs\":\"55\",\"fat\":\"8\"}") + val estimate = parser.parse("{\"mealName\":\"Oatmeal\",\"calories\":\"320\",\"proteinGrams\":\"12\",\"carbsGrams\":\"55\",\"fatGrams\":\"8\"}") assertEquals(320, estimate.calories) assertEquals(12, estimate.proteinGrams) } diff --git a/web/src/App.svelte b/web/src/App.svelte index 14efe72..6216845 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -523,13 +523,33 @@ }); } + function buildTodayHistory() { + const todayMeals = entries.filter(e => e.date === todayKey()); + if (!todayMeals.length) return ''; + let total = 0; + const lines = todayMeals.map(e => { + total += e.calories || 0; + return `- ${e.mealName || e.description}: ${e.calories || 0} kcal, P${e.proteinGrams || 0}g C${e.carbsGrams || 0}g F${e.fatGrams || 0}g`; + }); + lines.push(`Total so far: ${total} kcal`); + return lines.join('\n'); + } + async function requestMealEstimate(imageEstimate) { + const todayHistory = buildTodayHistory(); + const userContent = [ + `Estimate nutrition for this meal.`, + `Description: ${description}`, + `Portion: ${measure}`, + `Image analysis: ${imageEstimate}`, + todayHistory ? `\nUser's meals logged today:\n${todayHistory}` : '', + ].filter(Boolean).join('\n'); return chatCompletion({ model: settings.taskModel, temperature: 0.1, messages: [ - { 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}` }, + { role: 'system', content: 'You are a nutrition estimator. Output ONLY a raw JSON object — no markdown fences, no explanation, no text before or after.\nYou MUST use these exact key names: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\ncalories, proteinGrams, carbsGrams, fatGrams must be integers. fruitServings and vegetableServings must be numbers. foodGroups must be a short comma-separated string.\nUse the notes field for personalized dietary observations based on the user\'s meals logged today (e.g. macro balance, variety, progress toward goals).\nOutput format: {"mealName":"...","calories":0,"proteinGrams":0,"carbsGrams":0,"fatGrams":0,"fruitServings":0,"vegetableServings":0,"foodGroups":"...","notes":"..."}' }, + { role: 'user', content: userContent }, ], }); }