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:
parent
03d4178113
commit
40e65ca70c
4 changed files with 56 additions and 48 deletions
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue