Add diet history context to AI meal analysis on both platforms

- Web: buildTodayHistory() computes today's logged meals and passes them
  to requestMealEstimate so the AI can give personalized notes
- Web: system prompt updated to instruct AI to use notes field for
  dietary observations (macro balance, variety, goal progress)
- Android: same diet history + notes guidance already applied to MainActivity
- Tests: fix NutritionParserTest to use exact key names (proteinGrams,
  carbsGrams, fatGrams) matching the standardised AI prompt format

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-05-20 16:12:25 +02:00
parent 03d4178113
commit 40e65ca70c
4 changed files with 56 additions and 48 deletions

View file

@ -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<MealEntry>, 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 =

View file

@ -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) }

View file

@ -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)
}

View file

@ -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 },
],
});
}