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()) {
|
val visionEstimate = if (selectedImage != null && appState.settings.visionModel.isNotBlank()) {
|
||||||
repo.chat(buildVisionBody(appState.settings.visionModel, draft.description, draft.measure, selectedImage!!))
|
repo.chat(buildVisionBody(appState.settings.visionModel, draft.description, draft.measure, selectedImage!!))
|
||||||
} else ""
|
} 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 estimate = parser.parse(nutritionJson)
|
||||||
val meal = draft.copy(
|
val meal = draft.copy(
|
||||||
id = editing?.id ?: UUID.randomUUID().toString(),
|
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)))
|
.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)
|
JSONObject().put("model", model).put("temperature", 0.1)
|
||||||
.put("messages", JSONArray()
|
.put("messages", JSONArray()
|
||||||
.put(JSONObject().put("role", "system").put("content",
|
.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" +
|
"You are a nutrition estimator. Output ONLY a raw JSON object — no markdown fences, no explanation, no text before or after.\n" +
|
||||||
"Required keys: mealName (string), calories (integer), proteinGrams (integer), carbsGrams (integer), fatGrams (integer), fruitServings (decimal), vegetableServings (decimal), foodGroups (string), notes (string).\n" +
|
"You MUST use these exact key names: mealName, calories, proteinGrams, carbsGrams, fatGrams, fruitServings, vegetableServings, foodGroups, notes.\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\"}"
|
"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",
|
.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 =
|
private fun buildPlanBody(model: String, settings: ServerSettings, recentMeals: String): JSONObject =
|
||||||
|
|
|
||||||
|
|
@ -6,58 +6,31 @@ 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()
|
||||||
|
// Strip leading markdown fence line (e.g. "```json\n")
|
||||||
val fenceEnd = cleaned.indexOf('\n')
|
val fenceEnd = cleaned.indexOf('\n')
|
||||||
if (cleaned.startsWith("```") && fenceEnd >= 0) cleaned = cleaned.substring(fenceEnd + 1)
|
if (cleaned.startsWith("```") && fenceEnd >= 0) cleaned = cleaned.substring(fenceEnd + 1)
|
||||||
if (cleaned.endsWith("```")) cleaned = cleaned.dropLast(3)
|
if (cleaned.endsWith("```")) cleaned = cleaned.dropLast(3)
|
||||||
cleaned = cleaned.trim()
|
cleaned = cleaned.trim()
|
||||||
|
// Extract the JSON object in case there's any surrounding prose
|
||||||
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", "").ifBlank { "Meal" },
|
mealName = json.optString("mealName", "Meal").ifBlank { "Meal" },
|
||||||
calories = safeInt(json, "calories", "kcal", "energy"),
|
calories = json.optInt("calories").coerceAtLeast(0),
|
||||||
proteinGrams = safeInt(json, "proteinGrams", "protein", "protein_g", "proteins"),
|
proteinGrams = json.optInt("proteinGrams").coerceAtLeast(0),
|
||||||
carbsGrams = safeInt(json, "carbsGrams", "carbs", "carbohydrates", "carbohydrateGrams", "carbs_g", "carbohydrate"),
|
carbsGrams = json.optInt("carbsGrams").coerceAtLeast(0),
|
||||||
fatGrams = safeInt(json, "fatGrams", "fat", "totalFat", "fat_g", "fats"),
|
fatGrams = json.optInt("fatGrams").coerceAtLeast(0),
|
||||||
fruitServings = safeDouble(json, "fruitServings", "fruit", "fruitsServings", "fruits"),
|
fruitServings = json.optDouble("fruitServings", 0.0).coerceAtLeast(0.0),
|
||||||
vegetableServings = safeDouble(json, "vegetableServings", "vegetables", "vegetablesServings", "veggies"),
|
vegetableServings = json.optDouble("vegetableServings", 0.0).coerceAtLeast(0.0),
|
||||||
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) }
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,8 @@ class NutritionParserTest {
|
||||||
assertEquals(1.5, estimate.vegetableServings, 0.001)
|
assertEquals(1.5, estimate.vegetableServings, 0.001)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun parsesShortKeyAliases() {
|
@Test fun parsesExactKeys() {
|
||||||
// Models often return "protein" instead of "proteinGrams" etc.
|
val estimate = parser.parse("{\"mealName\":\"Pasta\",\"calories\":580,\"proteinGrams\":28,\"carbsGrams\":80,\"fatGrams\":14,\"fruitServings\":0,\"vegetableServings\":1,\"foodGroups\":\"grain\",\"notes\":\"\"}")
|
||||||
val estimate = parser.parse("{\"mealName\":\"Pasta\",\"calories\":580,\"protein\":28,\"carbs\":80,\"fat\":14}")
|
|
||||||
assertEquals("Pasta", estimate.mealName)
|
assertEquals("Pasta", estimate.mealName)
|
||||||
assertEquals(580, estimate.calories)
|
assertEquals(580, estimate.calories)
|
||||||
assertEquals(28, estimate.proteinGrams)
|
assertEquals(28, estimate.proteinGrams)
|
||||||
|
|
@ -47,7 +46,7 @@ class NutritionParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun handlesStringNumbers() {
|
@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(320, estimate.calories)
|
||||||
assertEquals(12, estimate.proteinGrams)
|
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) {
|
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({
|
return chatCompletion({
|
||||||
model: settings.taskModel,
|
model: settings.taskModel,
|
||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
messages: [
|
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: '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: `Estimate the nutrition for this meal:\nDescription: ${description}\nPortion: ${measure}\nAdditional context: ${imageEstimate}` },
|
{ role: 'user', content: userContent },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue