Skip to content

feat(add-meal): add a model-assisted meal-text interpreter (#633) - #636

Open
simonoppowa wants to merge 2 commits into
feature/ai-assisted-meal-loggingfrom
feat/meal-text-interpreter
Open

feat(add-meal): add a model-assisted meal-text interpreter (#633)#636
simonoppowa wants to merge 2 commits into
feature/ai-assisted-meal-loggingfrom
feat/meal-text-interpreter

Conversation

@simonoppowa

Copy link
Copy Markdown
Owner

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 eggs or 2个鸡蛋. 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

MealTextInterpreter returns the same MealTextParseResult the deterministic parser returns. That makes an implementation a drop-in alternative to parseMealText: 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 same FoodNameValidator / > 0 / <= 10000 / known-unit rules parseMealText enforces. 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 MealTextInterpreterException rather 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

  • Model pinned to claude-haiku-4-5, not an alias. A silent model change would move behaviour the user never asked to change. Overridable per instance.
  • An unrecognised unit is dropped, not rejected. 2 cups flour keeps the food and the 2, 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/l are not accepted by validateParsedMealItems. parseMealText converts 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 test 1108/1108, flutter analyze clean, formatter clean.

No live API call has been made. Every test fakes http.Client. The request shape — forced tool_choice, x-api-key and anthropic-version headers — 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

http was already a direct dependency.

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.
Copilot AI lite review requested due to automatic review settings August 10, 2026 18:54

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 MealTextInterpreter interface (+ exception type) that returns MealTextParseResult to 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 validateParsedMealItems plus 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.

Comment thread lib/features/add_meal/data/anthropic_meal_text_interpreter.dart Outdated
Comment thread lib/features/add_meal/util/meal_text_parser.dart Outdated
Comment thread lib/features/add_meal/data/anthropic_meal_text_interpreter.dart Outdated
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.
Copilot AI review requested due to automatic review settings August 13, 2026 09:01

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. Use package:fake_async to 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';
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants