feat(add-meal): add a model-assisted meal-text interpreter (#633) - #636
Open
simonoppowa wants to merge 2 commits into
Open
feat(add-meal): add a model-assisted meal-text interpreter (#633)#636simonoppowa wants to merge 2 commits into
simonoppowa wants to merge 2 commits into
Conversation
Implements #633, the first part of tier 1b of #599: a model does the language work, the database still supplies every number. `MealTextInterpreter` returns the same `MealTextParseResult` the deterministic parser returns, so an implementation is a drop-in alternative to `parseMealText` -- the resolver, the review screen and the write path do not know or care which produced the items. The provenance guarantee is structural rather than a convention. The tool schema handed to the model has no macro fields, so it has nowhere to put a calorie count and cannot supply one; a test asserts the schema exposes only query, quantity and unit. Whatever does come back then passes through `validateParsedMealItems`, the bounds `parseMealText` already enforces, so a model can never write to the diary under looser rules than a regex. That is why this tier does not reopen #250: no macro is ever invented, and every number still comes from Open Food Facts / USDA / BLS. The prompt asks for a quantity only when the user stated one. Inferring that half an avocado is about 100 g is estimation rather than parsing, and estimating mass is the first step back toward estimating nutrition. With nothing stated the field is omitted and the review row's existing serving-size default fills it, exactly as for the deterministic parser. The tool call is forced, so a prose reply is not a shape any caller has to handle. Anything unexpected -- a missing tool call, a malformed body, a non-200 -- raises `MealTextInterpreterException` rather than guessing, and carries no response body, since provider payloads can echo the submitted text into logs. Auth failures are marked non-transient so a caller can tell "your key is wrong" from "try again later". No UI, no key storage and no wiring: the key is a parameter for now. Those are #634 and #635. Nothing in the app calls this yet.
Contributor
There was a problem hiding this comment.
Pull request overview
Adds the tier-1b “model-assisted meal-text interpreter” client-side building blocks for the Add Meal flow, ensuring model output is structurally unable to carry nutrition data and is validated to the same bounds as the deterministic parser.
Changes:
- Introduces a
MealTextInterpreterinterface (+ exception type) that returnsMealTextParseResultto remain drop-in compatible with the existing parser pipeline. - Adds an Anthropic Messages API implementation that forces a tool call and validates model output via shared parsing bounds.
- Adds
validateParsedMealItemsplus comprehensive unit tests for schema safety, validation behavior, and failure modes.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| lib/features/add_meal/util/meal_text_parser.dart | Adds validateParsedMealItems to enforce deterministic-parser bounds on externally produced ParsedMealItems. |
| lib/features/add_meal/domain/meal_text_interpreter.dart | Defines the interpreter interface and a non-leaky exception surface for callers to fall back safely. |
| lib/features/add_meal/data/anthropic_meal_text_interpreter.dart | Implements Anthropic-backed interpreter with forced tool schema and strict response parsing. |
| test/unit_test/meal_text_parser_test.dart | Adds tests for validateParsedMealItems behavior (bounds, indexing, unit handling). |
| test/unit_test/anthropic_meal_text_interpreter_test.dart | Adds tests covering request shape, schema constraints, response parsing, validation, and error handling. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
simonoppowa
added a commit
that referenced
this pull request
Aug 13, 2026
Three findings from review on #640, all confirmed. **A dismissed dialog lost the change.** The switch and the remove button write immediately, but `showDialog` defaults to barrier-dismissible, so a tap outside popped `null` and the caller was told nothing had changed -- leaving the settings tile describing state that no longer existed. The dialog is no longer barrier-dismissible, and the caller now refreshes unconditionally rather than trusting the answer, so neither half can reintroduce it alone. **The README linked to a file this branch does not contain.** `anthropic_meal_text_interpreter.dart` only exists on #636's branch, so merging this one first would have shipped a broken link on the front page. Points at #633 instead, which resolves whatever the merge order turns out to be. **A Row label could overflow.** AGENTS.md is explicit that a title in a Row has to survive a long localized string and a large system font; "Ключ збережено" plus the mask is materially wider than the English. Wrapped in Expanded with maxLines and ellipsis, with a test at 2x text scale on a narrow viewport. Removing the Expanded fails it.
…lies Three findings from review on #636, all confirmed. **A forced cast bypassed the exception surface.** `(block['input'] as Map?)` throws a TypeError if the provider ever returns something other than an object there, which is exactly the case the doc comment claims raises cleanly. #635 relies on `MealTextInterpreterException` to fall back to the deterministic parser; a TypeError would crash the caller instead. Checked rather than cast. **No timeout.** The other remote data sources in this repo use `.timeout(_timeoutDuration)`; this did not, so a stalled connection would hang until the OS gave up and the user would wait rather than falling back. 20 seconds, matching them. **A unit could survive with no quantity.** `ParsedMealItem` documents the two as stated together and `parseMealText` cannot produce one without the other, so downstream code was written against that. A model can produce it, and honouring a unit nobody attached a number to presents a guess as though the user typed it. Dropped, with the reasoning recorded where it happens. Three tests added, each mutation-checked: restoring the cast fails one, removing the timeout fails one, keeping the orphan unit fails one.
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
test/unit_test/anthropic_meal_text_interpreter_test.dart:341
- This test currently waits for the production 20s timeout to elapse (because FakeClient.hangs never completes and AnthropicMealTextInterpreter uses
.timeout(const Duration(seconds: 20))). That adds ~20 seconds to the unit test suite runtime. Usepackage:fake_asyncto elapse time instantly and assert the future completes with MealTextInterpreterException without real waiting.
test('a stalled connection does not hang forever', () async {
final client = FakeClient(hangs: true);
await expectLater(
interpreterWith(client).interpret('toast'),
throwsA(isA<MealTextInterpreterException>()),
);
});
Comment on lines
+1
to
+8
| import 'dart:async'; | ||
| import 'dart:convert'; | ||
|
|
||
| import 'package:flutter_test/flutter_test.dart'; | ||
| import 'package:http/http.dart' as http; | ||
| import 'package:opennutritracker/features/add_meal/data/anthropic_meal_text_interpreter.dart'; | ||
| import 'package:opennutritracker/features/add_meal/domain/meal_text_interpreter.dart'; | ||
| import 'package:opennutritracker/features/add_meal/util/meal_text_parser.dart'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #633. First of three for tier 1b of #599.
What tier 1b is
A model does the language work — segmenting a sentence, reading
two eggsor2个鸡蛋. The database still supplies every number. No macro is ever invented, so "every number is cited" holds and #250 does not need reopening.This PR is the client only. Nothing in the app calls it yet — no UI, no key storage, no wiring. The key is a constructor parameter. Those are #634 and #635.
The design that makes it safe
MealTextInterpreterreturns the sameMealTextParseResultthe deterministic parser returns. That makes an implementation a drop-in alternative toparseMealText: the resolver, review rows and write path are untouched and do not know which produced the items.Two guarantees, both enforced rather than documented:
1. The tool schema has no macro fields. The model has nowhere to put a calorie count, so it cannot supply one. A test asserts the schema exposes exactly
{query, quantity, unit}and nothing else — so the review question becomes "did someone add a field to this schema?" rather than "did the model behave?".2. Model output is held to the parser's bounds. Everything returned passes through the new
validateParsedMealItems, the sameFoodNameValidator/> 0/<= 10000/ known-unit rulesparseMealTextenforces. A model cannot write to the diary under looser rules than a regex.The boundary I held
The prompt asks for a quantity only when the user stated one. Inferring that half an avocado is about 100 g is estimation, not parsing — and estimating mass is the first step back toward estimating nutrition, which is what #250 was closed over. With nothing stated the field is omitted and the review row's existing serving-size default fills it, exactly as for the deterministic parser.
I flagged this as the one open question before starting and proceeded on that answer. Say if you want it drawn elsewhere — it is one line of the prompt and one test.
Failure behaviour
The tool call is forced, so a prose reply is not a shape any caller has to handle. Anything unexpected — no tool call, malformed body, non-200 — raises
MealTextInterpreterExceptionrather than guessing, so #635 can fall back to the deterministic parser instead of showing an error.The exception carries no response body: provider payloads can echo the submitted text, and this reaches logs. The network-error path deliberately does not log the underlying error for the same reason, and a test asserts the submitted text does not appear in the exception string.
Auth failures are marked non-transient (
isTransient == false) so a caller can distinguish "your key is wrong" from "try again later".Choices worth a second opinion
claude-haiku-4-5, not an alias. A silent model change would move behaviour the user never asked to change. Overridable per instance.2 cups flourkeeps the food and the2, and the row defaults the unit — refusing the row outright would lose a usable food over a unit the review row guesses better than we can.kg/lare not accepted byvalidateParsedMealItems.parseMealTextconverts them itself before validating; this entry point does no extraction, so accepting them would log 2 g for 2 kg. Covered by a test.Verification
26 new tests (19 for the interpreter, 7 for the validator).
flutter test1108/1108,flutter analyzeclean, formatter clean.No live API call has been made. Every test fakes
http.Client. The request shape — forcedtool_choice,x-api-keyandanthropic-versionheaders — is asserted against the documented contract, not against a real response. One live call before #635 ships would be worth it, and I have not made one.No new dependency
httpwas already a direct dependency.