From b25ad467656a5ffc9d84c167fa0ba1caa37ac209 Mon Sep 17 00:00:00 2001 From: max r Date: Tue, 21 Jul 2026 14:57:17 +0500 Subject: [PATCH] feat: support external fuzzy portion estimates --- README.md | 64 +++++- docs/plans.md | 57 +++++ docs/status.md | 16 +- docs/test-plan.md | 28 +++ nomnomcli/cli.py | 81 ++++++- nomnomcli/config.py | 37 ++++ nomnomcli/db.py | 5 +- nomnomcli/models.py | 11 +- nomnomcli/parser.py | 99 ++++++++- nomnomcli/portions.py | 237 ++++++++++++++++++++ skill/SKILL.md | 49 +++-- tests/test_portions.py | 480 +++++++++++++++++++++++++++++++++++++++++ 12 files changed, 1121 insertions(+), 43 deletions(-) create mode 100644 nomnomcli/portions.py create mode 100644 tests/test_portions.py diff --git a/README.md b/README.md index 6945c3f..bc4a7bd 100644 --- a/README.md +++ b/README.md @@ -254,10 +254,11 @@ The agent must not translate by inventing nutrition values or silently substitut The parser accepts kilograms, grams, millilitres, pieces, fractions, and explicit per-piece grams. English and Russian size words such as `small` and `небольшой` remain valid syntax, but a size word -does not supply a packaged estimate. Piece grams come only from explicit user input or serving data -on the resolved cached/pinned/API food record. Assumptions identify the provider, source field, and -returned value. When serving data is absent, structured `piece_weight_unknown` asks for exact -grams. Explicit grams always win, including `3 pieces FOOD at 38g` → `114g`. +does not make nomnom guess a weight. By default, piece grams come only from explicit user input or +serving data on the resolved cached/pinned/API food record. Assumptions identify the provider, +source field, and returned value. When serving data is absent, the default `strict` portion policy +keeps the existing `piece_weight_unknown` response and writes no log. Explicit grams always win, +including `3 pieces FOOD at 38g` → `114g`. ```sh nomnom log --parse "bread 2 pieces at 40g" --json @@ -268,6 +269,57 @@ Supported dish prefixes split only the ingredients the user stated. nomnom never or another ingredient. Millilitres use a resolved density when available and otherwise retain the documented 1 g/ml conversion. +### Opt-in external portion estimates + +An external agent may supply masses for unresolved counts, fractions, and size descriptions. This +is opt-in: use `--portion-policy estimate` together with inline `--portion-estimates` JSON. nomnom +does not contain an LLM, generate a mass, bundle portion weights, or treat the supplied mass as +measured or source-backed. It uses each supplied central `grams` value in the existing deterministic +nutrition calculator; the lower/upper range is provenance only and is not used to calculate a +nutrition interval. + +The payload has exactly one top-level `items` array. Every entry has exactly these fields: + +- `item_index`: zero-based position after nomnom splits the original `--parse` text; +- `input`: the exact trimmed item phrase from that text, matched byte-for-byte; +- `grams`, `lower_grams`, `upper_grams`: finite nonnegative numbers satisfying + `lower_grams <= grams <= upper_grams`; +- `confidence`: finite number from 0 through 1; +- `method`: the literal string `agent_estimate`; +- `assumption`: a nonempty human-readable explanation. + +For example: + +```sh +nomnom log --parse \ + "3 small fried eggs, half small tomato, half small onion, whole wheat bread 180 g, milk 110 g, 15 dates" \ + --portion-policy estimate \ + --portion-estimates '{"items":[ + {"item_index":0,"input":"3 small fried eggs","grams":135,"lower_grams":120,"upper_grams":150,"confidence":0.72,"method":"agent_estimate","assumption":"Three small fried eggs estimated at 45 g each."}, + {"item_index":1,"input":"half small tomato","grams":35,"lower_grams":25,"upper_grams":45,"confidence":0.65,"method":"agent_estimate","assumption":"Half of a small tomato estimated at 35 g."}, + {"item_index":2,"input":"half small onion","grams":30,"lower_grams":20,"upper_grams":40,"confidence":0.63,"method":"agent_estimate","assumption":"Half of a small onion estimated at 30 g."}, + {"item_index":5,"input":"15 dates","grams":120,"lower_grams":90,"upper_grams":150,"confidence":0.58,"method":"agent_estimate","assumption":"Fifteen dates estimated at 8 g each."} + ]}' --json +``` + +Every unresolved fuzzy item must have exactly one matching entry. Missing, extra, duplicate, or +non-exact mappings and malformed/invalid values reject the complete meal without a log write; +entries for explicit gram items are also rejected. Only inline JSON is supported. `ask` returns a +structured `portion_estimate_required` response with the exact index/input and writes nothing. +`strict` remains the default. Set a persistent default in user config or override it in the +environment: + +```toml +[resolution] +portion_policy = "ask" # or "estimate"; default is "strict" +``` + +`NOMNOM_PORTION_POLICY` accepts `strict`, `ask`, or `estimate`; the CLI flag has highest precedence. +Estimated log items persist `approximate=true`, `portion_provenance=agent_estimate`, and the full +`portion_estimate` object next to food-resolution provenance. Log JSON and date stats expose these +fields. Human output gives one correction prompt toward scale grams, a photo, or a barcode. Existing +logs without these additive fields remain readable. + ### Adding a new language Provide an agent-side translator that emits the canonical contract and, when desired, create @@ -300,6 +352,10 @@ an `error` object and exit with status 2. Important codes include: - `invalid_source_note` / `invalid_nutrition`: correct the extracted label facts; failed captures write nothing. - `piece_weight_unknown`: ask for grams or add a verified `--piece-grams` value. +- `portion_estimate_required` / `portion_estimate_missing`: supply exactly matched external estimate + metadata or use explicit grams; nothing is partially logged. +- `portion_estimates_malformed` / `portion_estimate_invalid` / + `portion_estimate_mismatch`: correct the complete inline payload and retry. - `alias_target_not_found`: add/resolve the exact cached target or remove the stale alias. - `openfoodfacts_unavailable` / `usda_unavailable`: retry later or use a manual label. diff --git a/docs/plans.md b/docs/plans.md index 9907069..8d8aba4 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -1,5 +1,62 @@ # Plans +## Issue #31 Source +- Task: Add opt-in externally agent-estimated fuzzy portions with explicit provenance. +- Canonical input: GitHub issue #31 latest product decision and the user's strict TDD, atomicity, smoke, commit, and no-push requirements. +- Repo context: free-text parsing, provider resolution, nutrition scaling, log JSON persistence, stats, config/CLI policy, README, and agent skill. +- Last updated: 2026-07-21 + +## Issue #31 Assumptions +- The estimate payload is inline JSON with an `items` array; each entry identifies one fuzzy phrase by both zero-based `item_index` and exact parser `input`, preventing similarity matching. +- Every estimate carries finite nonnegative `grams`, `lower_grams`, and `upper_grams`, confidence in `0..1`, the literal method `agent_estimate`, and a nonempty human-readable assumption; `lower_grams <= grams <= upper_grams`. +- Portion policy defaults to `strict`; `ask` returns an actionable structured request without writing, and `estimate` is the only policy that accepts a complete valid external payload. +- Existing `items_json` can carry additive portion provenance without a schema migration; nutrition continues to use only central `grams` through `scale_food`. + +## Issue #31 Milestone Order +| ID | Title | Depends on | Status | +| --- | --- | --- | --- | +| M28 | Freeze fuzzy estimate, mapping, policy, and atomicity contracts | M27 | [x] | +| M29 | Implement external estimate parsing and additive provenance | M28 | [x] | +| M30 | Document, validate, smoke, audit, and commit | M29 | [x] | + +## M28. Freeze issue #31 contracts `[x]` +### Goal +- Focused tests specify strict compatibility, exact all-or-nothing estimate mapping, full breakfast persistence/stats, explicit-gram precedence, config policy, and legacy-log readability before production changes. + +### Validation +```sh +PYTHONPATH=. pytest -q tests/test_portions.py tests/test_cli.py tests/test_config.py tests/test_db.py +``` + +### Stop-and-Fix Rule +- Record the expected RED failures before changing production parser, model, config, CLI, or persistence behavior. + +## M29. Implement external estimate parsing and additive provenance `[x]` +### Goal +- `strict|ask|estimate` policy and exact structured payload validation allow only complete agent-supplied fuzzy masses, persist explicit approximate provenance, and preserve central deterministic nutrition calculation. + +### Validation +```sh +PYTHONPATH=. pytest -q tests/test_portions.py tests/test_parser.py tests/test_cli.py tests/test_config.py tests/test_db.py +``` + +### Stop-and-Fix Rule +- Any partial write/application, imprecise match, locally generated weight, explicit-gram regression, or issue #29 identity regression blocks documentation work. + +## M30. Document, validate, smoke, audit, and commit `[x]` +### Goal +- README and agent guidance publish the exact schema, correction route, and non-measured semantics; all focused/full tests, Ruff, disposable mocked-provider CLI smoke, diff audit, and one scoped conventional commit pass. + +### Validation +```sh +PYTHONPATH=. pytest -q +ruff check . +git diff --check +``` + +### Stop-and-Fix Rule +- Do not commit until full validation passes, the exact breakfast smoke uses only a temporary database and mocked providers, and the diff is scoped to issue #31. + ## Issue #29 Source - Task: Ensure unbranded foods resolve only to truthful generic proxies, never arbitrary branded exact products. - Canonical input: GitHub issue #29 and the user's strict TDD, smoke, commit, and no-push requirements. diff --git a/docs/status.md b/docs/status.md index 58cbeb4..59e1b8f 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,12 +1,15 @@ # Status ## Snapshot -- Current phase: issue #29 implemented and independently verified +- Current phase: issue #31 implemented and verified - Plan file: `docs/plans.md` - Status: green - Last updated: 2026-07-21 ## Done +- Completed issue #31 with strict-by-default external portion policy, exact index-plus-input estimate mapping, full range/confidence/assumption validation, central-grams nutrition, and additive approximate provenance. +- Recorded the required RED run (16 expected failures, 1 strict-path pass), then passed 114 focused tests, 224 full tests, Ruff, diff checks, and the exact six-item checkout CLI smoke. +- Verified four breakfast items persist as `agent_estimate`, bread 180 g and milk 110 g remain non-approximate, date stats expose all portion fields, and the temporary smoke database is removed. - Completed issue #29 with intent-aware exact identity, USDA Foundation/SR Legacy preference, strict OFF proxy type matching, branded-proxy provenance, and cache intent isolation. - Recorded RED runs for 16 primary regressions plus two cache edges and missing-category evidence, then passed 114 focused tests, 204 full tests, and Ruff. - Ran all six literal translated inputs against mocked providers in a fresh temporary data directory; all six were explicit `generic_proxy` rows with source, brand, barcode, and assumption. @@ -27,12 +30,15 @@ - Passed 177 tests, Ruff, checkout-import guard, shell syntax, and the disposable checkout-built installer/provider-stub smoke. ## In Progress -- No implementation work pending; issue #29 is verified and committed locally. +- No implementation work pending; issue #31 is ready for the scoped local commit. ## Next -- Do not push or open a pull request unless the user requests it separately. +- Do not push, merge, or open a pull request unless the user requests it separately. ## Decisions Made +- Issue #31: require zero-based `item_index` plus exact `input` in every estimate entry; never fuzzy-match estimate metadata to parsed foods. +- Issue #31: require central/lower/upper grams, confidence, literal `agent_estimate`, and a nonempty assumption for every unresolved fuzzy portion. +- Issue #31: keep log provenance inside additive item JSON so schema v4 and old log readability remain unchanged. - Issue #29: resolution mode follows user identity intent plus source identity, never candidate confidence alone. - Issue #29: generic USDA searches send the documented `dataType` array for Foundation and SR Legacy and rank eligible non-branded provenance ahead of branded payload rows. - Issue #29: a branded OFF candidate needs product-name token coverage plus category/type evidence to serve only as a labelled generic proxy. @@ -89,6 +95,8 @@ ruff check . | 2026-07-21 | M24 | README, skill, changed code/tests, disposable installed checkout | focused/full pytest; Ruff; diff check; literal temp-DB smoke | 40/189 pass; clean; smoke 150 kcal | local commit | | 2026-07-21 | M25 | resolver/provider regression tests | focused pytest | RED: 16 primary failures; 2 cache failures; 1 category-evidence failure | M26 | | 2026-07-21 | M26–M27 | resolver, USDA, docs, skill, tests | `pytest -q`; `ruff check .`; temp-data mocked-provider smoke | 114 focused; 204 full; clean; 6/6 generic proxies | local commit | +| 2026-07-21 | M28 | issue #31 tests and planning records | targeted pytest before production edits | RED: 16 expected failures; 1 strict-path pass | M29 | +| 2026-07-21 | M29–M30 | portion validation/parser/model/CLI/stats, docs, skill, tests | focused/full pytest; Ruff; diff check; exact temp-DB checkout smoke | 114 focused; 224 full; clean; 4 estimates + 2 explicit grams | local commit | ## Smoke / Demo Checklist - [x] Fresh temp DB: help/version, capture label, alias, log, and invalid structured capture error. @@ -109,3 +117,5 @@ ruff check . - [x] Safe no-key miss returns `food_needs_source` with photo/barcode/label/cache options and optional USDA. - [x] Literal parsed/direct `2026-07-20` logs persist local noon and date stats return only that local day. - [x] Issue #29 literal six-item temp-data smoke returns only explicit generic proxies with audited OFF candidate identity. +- [x] Issue #31 exact breakfast logs atomically with four explicit agent estimates and two unflagged explicit-gram items. +- [x] Issue #31 date stats preserve the four portion provenance objects and one concise correction route. diff --git a/docs/test-plan.md b/docs/test-plan.md index 7542446..5076e0e 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -1,5 +1,33 @@ # Test Plan +## Issue #31 Validation +- In scope: `strict|ask|estimate` policy precedence, exact inline-JSON schema/mapping, fuzzy descriptor/fraction/bare-count parsing, all-or-nothing validation and writes, portion provenance in log/stats/text, explicit grams, old logs, docs, and agent guidance. +- Fixtures: temporary SQLite databases and monkeypatched deterministic generic provider foods only; no live traffic, user database, bundled weights, or repository food data. +- Exact acceptance literal: `3 small fried eggs, half small tomato, half small onion, whole wheat bread 180 g, milk 110 g, 15 dates`. +- Every fuzzy estimate entry must contain exact `item_index` and `input`, finite nonnegative `grams`, `lower_grams`, `upper_grams`, confidence in `0..1`, `method: agent_estimate`, and a nonempty assumption. + +### Issue #31 Negative / Edge Cases +- Default strict behavior remains `piece_weight_unknown` and produces no log write. +- Malformed JSON, missing/extra/duplicate/mismatched entries, fuzzy estimate attached to explicit grams, invalid method/assumption, non-finite/negative values, invalid range ordering, and confidence outside `0..1` all fail atomically. +- `ask` exposes exact unresolved phrase identifiers and correction routes without applying estimates. +- Explicit grams and per-piece grams stay non-fuzzy; legacy item JSON without portion fields remains readable. + +### Issue #31 Acceptance Gates +- [x] Focused tests observed RED before production changes — 16 expected failures, 1 pass. +- [x] Focused parser/config/CLI/database tests pass — 114 passed. +- [x] Full `PYTHONPATH=. pytest -q` passes — 224 passed. +- [x] `ruff check .` and `git diff --check` pass. +- [x] Disposable exact-breakfast CLI smoke passes with four estimated items and two explicit-gram items. +- [x] One scoped conventional commit exists; base remains `055e3d2`; nothing is pushed. + +### Issue #31 Command Matrix +```sh +PYTHONPATH=. pytest -q tests/test_portions.py tests/test_parser.py tests/test_cli.py tests/test_config.py tests/test_db.py +PYTHONPATH=. pytest -q +ruff check . +git diff --check +``` + ## Issue #29 Validation - In scope: resolver intent, OFF safe generic proxy semantics, USDA request/ranking provenance, exact barcode/brand/pin/alias paths, cache isolation, generic policy writes, CLI/API JSON, and literal translated inputs. - Fixtures: mocked OFF and USDA payloads only, temporary SQLite user databases, no live traffic or personal data. diff --git a/nomnomcli/cli.py b/nomnomcli/cli.py index fa0ca0d..f759151 100644 --- a/nomnomcli/cli.py +++ b/nomnomcli/cli.py @@ -9,12 +9,18 @@ from datetime import date, datetime from nomnomcli import __version__ +from nomnomcli.config import ProviderConfig from nomnomcli.db import connect, get_stats, store_log from nomnomcli.errors import NomnomError from nomnomcli.foods import FoodRepository from nomnomcli.models import scale_food, total_items from nomnomcli.onboarding import doctor_report, setup_providers, setup_status_report from nomnomcli.parser import parse_free_text +from nomnomcli.portions import ( + PORTION_CORRECTION, + parse_portion_estimates, + validate_portion_policy, +) from nomnomcli.recipes import fetch_recipe, recipe_portion, save_recipe @@ -100,12 +106,25 @@ def _print_log(result: dict, as_json: bool) -> None: return print("Logged:") for item in result["items"]: - print(f" {item['name']:<38} {item['grams']:>8.2f} g {item['kcal']:>8.2f} kcal") + marker = " (approx.)" if item.get("approximate") else "" + print( + f" {item['name'] + marker:<38} " + f"{item['grams']:>8.2f} g {item['kcal']:>8.2f} kcal" + ) + estimate = item.get("portion_estimate") + if estimate: + print( + f" portion: {item['portion_provenance']} | " + f"range {estimate['lower_grams']:.2f}-{estimate['upper_grams']:.2f} g | " + f"confidence {estimate['confidence']:.2f}" + ) if result.get("assumptions"): print("Assumptions:") for assumption in result["assumptions"]: print(f" - {assumption}") print(f"Total: {_nutrition_line(result['totals'])}") + if result.get("approximate"): + print(PORTION_CORRECTION) def _print_stats(result: dict, as_json: bool) -> None: @@ -117,8 +136,14 @@ def _print_stats(result: dict, as_json: bool) -> None: print(" No meals logged.") for meal in result["meals"]: label = meal["label"] or ", ".join(item["name"] for item in meal["items"]) - print(f" {meal['logged_at']} {label} {_nutrition_line(meal['totals'])}") + marker = " (approx.)" if meal.get("approximate") else "" + print( + f" {meal['logged_at']} {label}{marker} " + f"{_nutrition_line(meal['totals'])}" + ) print(f"Total: {_nutrition_line(result['totals'])}") + if result.get("approximate"): + print(PORTION_CORRECTION) def _build_parser() -> argparse.ArgumentParser: @@ -144,6 +169,15 @@ def _build_parser() -> argparse.ArgumentParser: form.add_argument("--parse", metavar="TEXT", help="comma-separated food phrases") form.add_argument("--food", metavar="NAME", help="food name for direct logging") log.add_argument("--grams", type=float, help="grams for --food") + log.add_argument( + "--portion-policy", + help="fuzzy portion policy: strict, ask, or estimate (default: config or strict)", + ) + log.add_argument( + "--portion-estimates", + metavar="JSON", + help="inline JSON estimates exactly matched by item_index and input", + ) log.add_argument("--date", help="local calendar date in YYYY-MM-DD; stored at local noon") log.add_argument("--json", action="store_true", help="machine-readable JSON output") @@ -219,6 +253,32 @@ def _build_parser() -> argparse.ArgumentParser: def _run(args: argparse.Namespace) -> int: + portion_policy = None + portion_estimates = None + if args.command == "log": + if args.food and (args.portion_policy is not None or args.portion_estimates is not None): + raise NomnomError( + "invalid_arguments", + "Portion policy and estimates are available only with --parse", + details={"action": "Use --food --grams for direct measured input."}, + ) + if args.parse: + portion_policy = ( + validate_portion_policy(args.portion_policy, source="command_line") + if args.portion_policy is not None + else ProviderConfig().portion_policy() + ) + if args.portion_estimates is not None: + if portion_policy != "estimate": + raise NomnomError( + "portion_policy_required", + "External portion estimates require the explicit estimate policy", + details={ + "policy": portion_policy, + "action": "Use --portion-policy estimate or omit --portion-estimates.", + }, + ) + portion_estimates = parse_portion_estimates(args.portion_estimates) log_time = _effective_log_time(args.date) if args.command == "log" else None stats_date = None if args.command == "stats": @@ -413,7 +473,12 @@ def _run(args: argparse.Namespace) -> int: else: if args.grams is not None: raise NomnomError("invalid_arguments", "--grams can only be used with --food") - resolved = parse_free_text(args.parse, repository) + resolved = parse_free_text( + args.parse, + repository, + portion_policy=portion_policy, + portion_estimates=portion_estimates, + ) items = [item.to_dict() for item in resolved] totals = total_items(resolved) log_id = store_log(connection, items, totals, logged_at=log_time) @@ -428,13 +493,17 @@ def _run(args: argparse.Namespace) -> int: assumptions = [item["assumption"] for item in items if item.get("assumption")] if assumptions: result["assumptions"] = assumptions + if any(item.get("approximate") for item in items): + result["approximate"] = True + result["portion_correction"] = PORTION_CORRECTION _print_log(result, args.json) return 0 if args.command == "stats": - _print_stats( - get_stats(connection, args.period, local_date=stats_date), args.json - ) + result = get_stats(connection, args.period, local_date=stats_date) + if result.get("approximate"): + result["portion_correction"] = PORTION_CORRECTION + _print_stats(result, args.json) return 0 if args.command == "search": diff --git a/nomnomcli/config.py b/nomnomcli/config.py index d2c4e0c..3f4af1b 100644 --- a/nomnomcli/config.py +++ b/nomnomcli/config.py @@ -10,6 +10,7 @@ from pathlib import Path from nomnomcli.errors import NomnomError +from nomnomcli.portions import DEFAULT_PORTION_POLICY, validate_portion_policy USDA_ENV_VAR = "NOMNOM_USDA_KEY" GENERIC_PROXY_POLICY_ENV_VAR = "NOMNOM_GENERIC_PROXY_POLICY" @@ -19,6 +20,7 @@ "exact_only", ) DEFAULT_GENERIC_PROXY_POLICY = "allow_for_unbranded" +PORTION_POLICY_ENV_VAR = "NOMNOM_PORTION_POLICY" @dataclass(frozen=True, slots=True) @@ -73,6 +75,25 @@ def generic_proxy_policy(self) -> str: ) return self._validate_generic_proxy_policy(stored_policy, "user_config") + def portion_policy(self) -> str: + environment_policy = self._environ.get(PORTION_POLICY_ENV_VAR, "").strip() + if environment_policy: + return validate_portion_policy(environment_policy, source="environment") + payload = self._stored_payload() + try: + stored_policy = payload.get("resolution", {}).get("portion_policy") + except AttributeError as exc: + raise self._invalid_config_error() from exc + if stored_policy is None: + return DEFAULT_PORTION_POLICY + if not isinstance(stored_policy, str): + raise NomnomError( + "portion_policy_invalid", + "portion_policy in provider configuration must be a string", + details={"path": str(self.path)}, + ) + return validate_portion_policy(stored_policy, source="user_config") + def _validate_generic_proxy_policy(self, value: str, source: str) -> str: policy = value.strip() if policy not in GENERIC_PROXY_POLICIES: @@ -131,6 +152,7 @@ def store_usda_key(self, api_key: str) -> Path: payload = self._stored_payload() try: stored_policy = payload.get("resolution", {}).get("generic_proxy_policy") + stored_portion_policy = payload.get("resolution", {}).get("portion_policy") except AttributeError as exc: raise self._invalid_config_error() from exc if stored_policy is not None and not isinstance(stored_policy, str): @@ -144,6 +166,17 @@ def store_usda_key(self, api_key: str) -> Path: if stored_policy is not None else None ) + if stored_portion_policy is not None and not isinstance(stored_portion_policy, str): + raise NomnomError( + "portion_policy_invalid", + "portion_policy in provider configuration must be a string", + details={"path": str(self.path)}, + ) + portion_policy = ( + validate_portion_policy(stored_portion_policy, source="user_config") + if stored_portion_policy is not None + else None + ) destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) with suppress(OSError): destination.parent.chmod(0o700) @@ -153,6 +186,10 @@ def store_usda_key(self, api_key: str) -> Path: "\n[resolution]\n" f"generic_proxy_policy = {json.dumps(policy, ensure_ascii=False)}\n" ) + if portion_policy is not None: + if policy is None: + content += "\n[resolution]\n" + content += f"portion_policy = {json.dumps(portion_policy, ensure_ascii=False)}\n" temporary_name: str | None = None try: descriptor, temporary_name = tempfile.mkstemp( diff --git a/nomnomcli/db.py b/nomnomcli/db.py index 156a20c..4a07194 100644 --- a/nomnomcli/db.py +++ b/nomnomcli/db.py @@ -279,14 +279,16 @@ def get_stats( meal_totals = {key: round(float(row[key]), 2) for key in totals} for key, value in meal_totals.items(): totals[key] += value + items = json.loads(row["items_json"]) meals.append( { "id": row["id"], "logged_at": row["logged_at"], "kind": row["kind"], "label": row["label"], - "items": json.loads(row["items_json"]), + "items": items, "totals": meal_totals, + "approximate": any(item.get("approximate") is True for item in items), } ) result = { @@ -294,6 +296,7 @@ def get_stats( "from": start.isoformat(timespec="seconds"), "totals": {key: round(value, 2) for key, value in totals.items()}, "meals": meals, + "approximate": any(meal["approximate"] for meal in meals), } if end is not None: result["to"] = end.isoformat(timespec="seconds") diff --git a/nomnomcli/models.py b/nomnomcli/models.py index 8201870..84225c1 100644 --- a/nomnomcli/models.py +++ b/nomnomcli/models.py @@ -48,8 +48,11 @@ class ResolvedItem: source_id: str | None = None source_note: str | None = None provenance: str | None = None + approximate: bool | None = None + portion_provenance: str | None = None + portion_estimate: dict | None = None - def to_dict(self) -> dict[str, str | float | bool]: + def to_dict(self) -> dict: return {key: value for key, value in asdict(self).items() if value is not None} @@ -67,6 +70,7 @@ def scale_food( *, assumed: bool | None = None, assumption: str | None = None, + portion_estimate: dict | None = None, ) -> ResolvedItem: factor = grams / 100.0 assumptions = [value for value in (food.assumption, assumption) if value] @@ -89,6 +93,11 @@ def scale_food( source_id=food.source_id, source_note=food.source_note, provenance=food.provenance, + approximate=True if portion_estimate is not None else None, + portion_provenance=( + str(portion_estimate["method"]) if portion_estimate is not None else None + ), + portion_estimate=portion_estimate, ) diff --git a/nomnomcli/parser.py b/nomnomcli/parser.py index 3bc6cea..84c623b 100644 --- a/nomnomcli/parser.py +++ b/nomnomcli/parser.py @@ -6,6 +6,11 @@ from nomnomcli.errors import NomnomError from nomnomcli.foods import FoodRepository from nomnomcli.models import ResolvedItem, scale_food +from nomnomcli.portions import ( + PortionEstimate, + PortionEstimateSet, + estimate_required_error, +) NUMBER = r"(?:\d+(?:[.,]\d+)?|\d+\s+\d+/\d+|\d+/\d+)" @@ -74,6 +79,9 @@ def _alias_pattern(values) -> str: rf"(?P{NUMBER})\s*(?P{MASS_UNIT})$", re.IGNORECASE, ) +BARE_PIECE_COUNT = re.compile( + rf"^(?P{NUMBER})\s+(?P.+)$", re.IGNORECASE +) SIZE_ALIASES = { "small": { @@ -180,7 +188,26 @@ def _format_grams(value: float) -> str: return str(int(value)) if value.is_integer() else str(round(value, 2)) -def _parse_descriptor_piece(cleaned: str, repository: FoodRepository) -> ResolvedItem | None: +def _estimate_item(food, confidence: float, estimate: PortionEstimate) -> ResolvedItem: + return scale_food( + food, + estimate.grams, + confidence, + assumed=True, + assumption=estimate.assumption, + portion_estimate=estimate.portion_dict(), + ) + + +def _parse_descriptor_piece( + cleaned: str, + repository: FoodRepository, + *, + portion_policy: str, + portion_estimate: PortionEstimate | None, + item_index: int, + input_phrase: str, +) -> ResolvedItem | None: match = DESCRIPTOR_PIECE.match(cleaned) size = None if not match: @@ -200,9 +227,13 @@ def _parse_descriptor_piece(cleaned: str, repository: FoodRepository) -> Resolve food_query = " ".join(match.group("food").split()) food, confidence = repository.resolve(food_query) + if portion_estimate is not None: + return _estimate_item(food, confidence, portion_estimate) if food.piece_grams is not None and ( not food.piece_grams_source or not food.piece_grams_source_value ): + if portion_policy != "strict": + raise estimate_required_error(portion_policy, item_index, input_phrase) raise NomnomError( "piece_weight_unknown", f"Serving-weight provenance is missing for {food.name}; provide grams", @@ -214,6 +245,8 @@ def _parse_descriptor_piece(cleaned: str, repository: FoodRepository) -> Resolve "action": "Provide exact grams", }, ) + if food.piece_grams is None and portion_policy != "strict": + raise estimate_required_error(portion_policy, item_index, input_phrase) grams = _quantity_to_grams(amount, "pieces", food) assumption = ( f"{_format_amount(amount)} {size} {food_query} = {_format_grams(grams)}g " @@ -229,9 +262,25 @@ def _parse_descriptor_piece(cleaned: str, repository: FoodRepository) -> Resolve ) -def parse_item_phrase(phrase: str, repository: FoodRepository) -> ResolvedItem: +def parse_item_phrase( + phrase: str, + repository: FoodRepository, + *, + portion_policy: str = "strict", + portion_estimate: PortionEstimate | None = None, + item_index: int = 0, + input_phrase: str | None = None, +) -> ResolvedItem: cleaned = " ".join(phrase.strip().split()) - descriptor_item = _parse_descriptor_piece(cleaned, repository) + exact_input = phrase.strip() if input_phrase is None else input_phrase + descriptor_item = _parse_descriptor_piece( + cleaned, + repository, + portion_policy=portion_policy, + portion_estimate=portion_estimate, + item_index=item_index, + input_phrase=exact_input, + ) if descriptor_item: return descriptor_item per_piece = PER_PIECE_QUANTITY.match(cleaned) or LEADING_PER_PIECE_QUANTITY.match( @@ -246,6 +295,18 @@ def parse_item_phrase(phrase: str, repository: FoodRepository) -> ResolvedItem: return scale_food(food, amount * each, confidence) match = TRAILING_QUANTITY.match(cleaned) if not match: + bare_count = BARE_PIECE_COUNT.match(cleaned) + if bare_count: + amount = parse_number(bare_count.group("amount")) + if amount <= 0: + raise NomnomError("invalid_quantity", "Quantity must be greater than zero") + food, confidence = repository.resolve(bare_count.group("food").strip(" -")) + if portion_estimate is not None: + return _estimate_item(food, confidence, portion_estimate) + if food.piece_grams is None and portion_policy != "strict": + raise estimate_required_error(portion_policy, item_index, exact_input) + grams = _quantity_to_grams(amount, "pieces", food) + return scale_food(food, grams, confidence) raise NomnomError( "quantity_required", f"A quantity with unit is required: {cleaned}", @@ -255,11 +316,22 @@ def parse_item_phrase(phrase: str, repository: FoodRepository) -> ResolvedItem: amount = parse_number(match.group("amount")) if amount <= 0: raise NomnomError("invalid_quantity", "Quantity must be greater than zero") + unit_kind = UNIT_BY_ALIAS[match.group("unit").casefold().replace("ё", "е")] + if portion_estimate is not None and unit_kind == "piece": + return _estimate_item(food, confidence, portion_estimate) + if unit_kind == "piece" and food.piece_grams is None and portion_policy != "strict": + raise estimate_required_error(portion_policy, item_index, exact_input) grams = _quantity_to_grams(amount, match.group("unit"), food) return scale_food(food, grams, confidence) -def parse_free_text(text: str, repository: FoodRepository) -> list[ResolvedItem]: +def parse_free_text( + text: str, + repository: FoodRepository, + *, + portion_policy: str = "strict", + portion_estimates: PortionEstimateSet | None = None, +) -> list[ResolvedItem]: cleaned_text = text.strip() dish = DISH_PREFIX.match(cleaned_text) if dish: @@ -273,11 +345,28 @@ def parse_free_text(text: str, repository: FoodRepository) -> list[ResolvedItem] items = [] for index, phrase in enumerate(phrases): try: - items.append(parse_item_phrase(phrase, repository)) + estimate = ( + portion_estimates.entry_for(index, phrase) + if portion_estimates is not None + else None + ) + item = parse_item_phrase( + phrase, + repository, + portion_policy=portion_policy, + portion_estimate=estimate, + item_index=index, + input_phrase=phrase, + ) + items.append(item) + if estimate is not None and item.portion_provenance is not None: + portion_estimates.mark_used(index) except NomnomError as exc: exc.details.setdefault("item_index", index) exc.details.setdefault("input", phrase) raise + if portion_estimates is not None: + portion_estimates.ensure_all_used() return items diff --git a/nomnomcli/portions.py b/nomnomcli/portions.py new file mode 100644 index 0000000..2c048c3 --- /dev/null +++ b/nomnomcli/portions.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import json +import math +from dataclasses import asdict, dataclass + +from nomnomcli.errors import NomnomError + +PORTION_POLICIES = ("strict", "ask", "estimate") +DEFAULT_PORTION_POLICY = "strict" +PORTION_METHOD = "agent_estimate" +PORTION_CORRECTION = "Correct approximate portions with scale grams, photo, or barcode." +ESTIMATE_FIELDS = frozenset( + { + "item_index", + "input", + "grams", + "lower_grams", + "upper_grams", + "confidence", + "method", + "assumption", + } +) + + +@dataclass(frozen=True, slots=True) +class PortionEstimate: + item_index: int + input: str + grams: float + lower_grams: float + upper_grams: float + confidence: float + method: str + assumption: str + + def portion_dict(self) -> dict: + return asdict(self) + + +class PortionEstimateSet: + def __init__(self, estimates: list[PortionEstimate]) -> None: + self._by_index = {estimate.item_index: estimate for estimate in estimates} + self._used: set[int] = set() + + def entry_for(self, item_index: int, input_phrase: str) -> PortionEstimate | None: + estimate = self._by_index.get(item_index) + if estimate is None: + return None + if estimate.input != input_phrase: + raise NomnomError( + "portion_estimate_mismatch", + "Portion estimate input does not exactly match the parsed item", + details={ + "item_index": item_index, + "expected_input": input_phrase, + "estimate_input": estimate.input, + "action": "Use the exact item_index and input returned from the original text.", + }, + ) + return estimate + + def mark_used(self, item_index: int) -> None: + self._used.add(item_index) + + def ensure_all_used(self) -> None: + unused = [ + { + "item_index": estimate.item_index, + "input": estimate.input, + } + for index, estimate in sorted(self._by_index.items()) + if index not in self._used + ] + if unused: + raise NomnomError( + "portion_estimate_mismatch", + "Portion estimates were supplied for items that do not need an estimate", + details={ + "unused_estimates": unused, + "action": "Supply estimates only for fuzzy portions without explicit grams.", + }, + ) + + +def validate_portion_policy(value: str, *, source: str) -> str: + policy = value.strip() + if policy not in PORTION_POLICIES: + raise NomnomError( + "portion_policy_invalid", + f"Unsupported portion policy: {policy or '(empty)'}", + details={"source": source, "allowed": list(PORTION_POLICIES)}, + ) + return policy + + +def _invalid_estimate(message: str, *, item_index=None, field=None) -> NomnomError: + details = {"required_fields": sorted(ESTIMATE_FIELDS)} + if item_index is not None: + details["item_index"] = item_index + if field is not None: + details["field"] = field + return NomnomError("portion_estimate_invalid", message, details=details) + + +def _number(entry: dict, field: str, item_index: int) -> float: + value = entry.get(field) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise _invalid_estimate( + f"{field} must be a finite nonnegative number", + item_index=item_index, + field=field, + ) + result = float(value) + if not math.isfinite(result) or result < 0: + raise _invalid_estimate( + f"{field} must be a finite nonnegative number", + item_index=item_index, + field=field, + ) + return result + + +def parse_portion_estimates(value: str) -> PortionEstimateSet: + try: + payload = json.loads(value) + except (json.JSONDecodeError, TypeError) as exc: + raise NomnomError( + "portion_estimates_malformed", + "--portion-estimates must be valid inline JSON", + details={"action": "Pass one JSON object with an items array."}, + ) from exc + if not isinstance(payload, dict) or set(payload) != {"items"}: + raise NomnomError( + "portion_estimates_malformed", + "Portion estimate JSON must contain exactly one items array", + details={"schema": {"items": [sorted(ESTIMATE_FIELDS)]}}, + ) + entries = payload["items"] + if not isinstance(entries, list): + raise NomnomError( + "portion_estimates_malformed", + "Portion estimate items must be an array", + ) + + estimates = [] + indexes: set[int] = set() + for position, entry in enumerate(entries): + if not isinstance(entry, dict) or set(entry) != ESTIMATE_FIELDS: + raise _invalid_estimate( + "Each portion estimate must contain exactly the documented fields", + item_index=position, + ) + item_index = entry["item_index"] + if isinstance(item_index, bool) or not isinstance(item_index, int) or item_index < 0: + raise _invalid_estimate( + "item_index must be a nonnegative integer", + item_index=position, + field="item_index", + ) + if item_index in indexes: + raise NomnomError( + "portion_estimate_duplicate", + f"Duplicate portion estimate for item_index {item_index}", + details={"item_index": item_index}, + ) + indexes.add(item_index) + + input_phrase = entry["input"] + if not isinstance(input_phrase, str) or not input_phrase.strip(): + raise _invalid_estimate( + "input must be a nonempty exact item phrase", + item_index=item_index, + field="input", + ) + method = entry["method"] + if method != PORTION_METHOD: + raise _invalid_estimate( + "method must be agent_estimate", + item_index=item_index, + field="method", + ) + assumption = entry["assumption"] + if not isinstance(assumption, str) or not assumption.strip(): + raise _invalid_estimate( + "assumption must be a nonempty human-readable string", + item_index=item_index, + field="assumption", + ) + grams = _number(entry, "grams", item_index) + lower = _number(entry, "lower_grams", item_index) + upper = _number(entry, "upper_grams", item_index) + confidence = _number(entry, "confidence", item_index) + if not lower <= grams <= upper: + raise _invalid_estimate( + "Portion range must satisfy lower_grams <= grams <= upper_grams", + item_index=item_index, + ) + if confidence > 1: + raise _invalid_estimate( + "confidence must be between 0 and 1", + item_index=item_index, + field="confidence", + ) + estimates.append( + PortionEstimate( + item_index=item_index, + input=input_phrase, + grams=grams, + lower_grams=lower, + upper_grams=upper, + confidence=confidence, + method=method, + assumption=assumption.strip(), + ) + ) + return PortionEstimateSet(estimates) + + +def estimate_required_error(policy: str, item_index: int, input_phrase: str) -> NomnomError: + code = "portion_estimate_missing" if policy == "estimate" else "portion_estimate_required" + return NomnomError( + code, + "This fuzzy portion needs one explicit external agent estimate", + details={ + "policy": policy, + "item_index": item_index, + "input": input_phrase, + "required_method": PORTION_METHOD, + "action": ( + "Supply exact grams or use --portion-policy estimate with one exactly matched " + "--portion-estimates entry." + ), + "correction": PORTION_CORRECTION, + }, + ) diff --git a/skill/SKILL.md b/skill/SKILL.md index 847f4ca..669ba3c 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -7,8 +7,8 @@ description: >- # nomnomcli -Use `nomnom` as the only source of nutrition numbers. Never estimate calories, -macros, weights, or serving conversions in the agent context. +Use `nomnom` as the only source of nutrition numbers. Never estimate calories, macros, or food +composition. Portion mass may be externally estimated only through the contract below. ## Mandatory install protocol @@ -55,21 +55,30 @@ Follow this exact sequence: ## Log free text 1. Preserve every food and quantity the user supplied. -2. Translate each item to the language-agnostic contract: food name + quantity + - unit + optional modifiers. Translate language, not nutrition. +2. Translate each item to food name + quantity + unit + optional modifiers; translate language, + not nutrition. 3. Run `nomnom log --parse "FOOD QUANTITY UNIT, FOOD QUANTITY UNIT" --json`. For a remembered prior local day, append `--date YYYY-MM-DD`; never infer a date or time. -4. Show returned canonical names, grams, confidence, totals, `alternatives`, and - every `assumptions` entry, plus `logged_at` and `local_date`. +4. Show returned names, grams, confidence, totals, alternatives, assumptions, and date fields. 5. Ask the user to confirm the resolution before relying on it. Successful logs are stored immediately. Do not silently correct or rerun one; tell the user before creating a replacement entry. Never read, edit, or manipulate SQLite directly; use `nomnom log --date` for remembered meals and the CLI for all user data operations. -Supported dish prefixes split only named ingredients. Never add oil or another -missing ingredient. Size words are parser syntax; piece grams must come from the -resolved food record. If the record has no piece weight, ask for grams. +Supported dish prefixes split only named ingredients; never add a missing ingredient. Size words +are parser syntax. Default `strict` behavior asks for grams when no piece weight exists. + +## External fuzzy portions + +Prefer human scale grams, photo, or barcode. Otherwise an agent may explicitly estimate a diary +count/fraction/size, but never call it measured, exact, or source-backed. Run `nomnom log --parse +TEXT --portion-policy estimate --portion-estimates JSON --json`. JSON is `{"items":[...]}` with one +entry per unresolved fuzzy item and none for explicit grams. Each requires zero-based `item_index`, +exact trimmed `input`, finite nonnegative central/lower/upper grams (lower <= central <= upper), +confidence 0..1, `method:"agent_estimate"`, and nonempty `assumption`. Never fuzzy-match or generate +nutrition ranges. Invalid or incomplete input rejects the whole log. Show returned portion fields +and correction prompt. `ask` writes nothing; `strict` is default. ## Direct food flow @@ -83,9 +92,8 @@ Always use the human's weight. ## Exact package capture -When an exact packaged food is needed, do not substitute a generic food or ask the human to look up -label numbers manually. Ask for the barcode first, or request a clear package photo when the barcode -is unavailable or incomplete in Open Food Facts. +For an exact packaged food, do not substitute or ask the human to look up label numbers. Ask for the +barcode first, or request a clear package photo when it is unavailable/incomplete in OFF. ```sh nomnom capture barcode "BARCODE" --json @@ -143,7 +151,7 @@ For an unresolved food: Other error handling: - `quantity_required`: ask for grams, millilitres, or a supported piece count. -- `piece_weight_unknown`: ask for grams, then retry with `--food --grams`. +- `piece_weight_unknown`: ask for grams, or use the explicit external fuzzy-portion flow. - `usda_low_confidence` / `usda_invalid_nutrition`: show the structured details, ask for a more specific name or verified label, and never accept/cache the weak result. - Nested `openfoodfacts_unavailable` or direct `usda_unavailable`: say that nothing was estimated; @@ -154,14 +162,10 @@ human grams always override provider serving data, including per-piece input. ## Provider contract -OFF free-text search intentionally uses direct `requests` calls to the official -legacy v1 endpoint `https://world.openfoodfacts.org/cgi/search.pl` with -`search_terms`, `search_simple=1`, `action=process`, `json=1`, `page_size`, -supported response fields, and a descriptive User-Agent. API v2 is only for -structured filters or product/barcode data: never send it free-text -`search_terms`, and never use its unfiltered rows as fallback results. Retryable -429/5xx failures use bounded backoff, including bounded numeric `Retry-After`; -exhausted v1 failures are retryable `openfoodfacts_unavailable` errors. +OFF free-text uses the official legacy v1 endpoint with `search_terms`, `search_simple=1`, +`action=process`, JSON, page size, supported fields, and a descriptive User-Agent. API v2 is only +for structured/product data: never send free text or use unfiltered fallback rows. Retryable +429/5xx failures use bounded backoff; exhausted v1 raises `openfoodfacts_unavailable`. Use `nomnom doctor --json` when diagnosing resolution. OFF `product_lookup_reachable` means only that the v2 product-by-barcode endpoint @@ -193,5 +197,4 @@ clarification; never complete recipe math yourself. ## Hard rule -If `nomnom` cannot produce a nutrition value, say it is unresolved. Never -estimate nutrition in the agent context. +If `nomnom` cannot produce nutrition, say it is unresolved; never estimate it in agent context. diff --git a/tests/test_portions.py b/tests/test_portions.py new file mode 100644 index 0000000..f8b92e3 --- /dev/null +++ b/tests/test_portions.py @@ -0,0 +1,480 @@ +from __future__ import annotations + +import copy +import json +from datetime import datetime + +import pytest + +from nomnomcli.cli import main +from nomnomcli.config import ProviderConfig +from nomnomcli.db import connect, store_log +from nomnomcli.foods import FoodRepository +from nomnomcli.models import Food + +BREAKFAST = ( + "3 small fried eggs, half small tomato, half small onion, " + "whole wheat bread 180 g, milk 110 g, 15 dates" +) +ESTIMATES = { + "items": [ + { + "item_index": 0, + "input": "3 small fried eggs", + "grams": 135, + "lower_grams": 120, + "upper_grams": 150, + "confidence": 0.72, + "method": "agent_estimate", + "assumption": "Three small fried eggs estimated at 45 g each.", + }, + { + "item_index": 1, + "input": "half small tomato", + "grams": 35, + "lower_grams": 25, + "upper_grams": 45, + "confidence": 0.65, + "method": "agent_estimate", + "assumption": "Half of a small tomato estimated at 35 g.", + }, + { + "item_index": 2, + "input": "half small onion", + "grams": 30, + "lower_grams": 20, + "upper_grams": 40, + "confidence": 0.63, + "method": "agent_estimate", + "assumption": "Half of a small onion estimated at 30 g.", + }, + { + "item_index": 5, + "input": "15 dates", + "grams": 120, + "lower_grams": 90, + "upper_grams": 150, + "confidence": 0.58, + "method": "agent_estimate", + "assumption": "Fifteen dates estimated at 8 g each.", + }, + ] +} + + +@pytest.fixture +def mocked_generic_breakfast(monkeypatch): + foods = { + "fried eggs": Food("egg, fried", 196, 13.6, 14.8, 0.8), + "tomato": Food("tomato, raw", 18, 0.9, 0.2, 3.9), + "onion": Food("onion, raw", 40, 1.1, 0.1, 9.3), + "whole wheat bread": Food("bread, whole wheat", 252, 12, 3.5, 43), + "milk": Food("milk", 61, 3.2, 3.3, 4.8), + "dates": Food("dates", 277, 1.8, 0.2, 75), + } + + def resolve(self, query, *, allow_remote=True): + del allow_remote + food = foods[query] + source_id = str(9000 + list(foods).index(query)) + resolved = Food( + food.name, + food.kcal, + food.protein, + food.fat, + food.carbs, + source="usda", + fdc_id=int(source_id), + resolution_mode="generic_proxy", + source_id=source_id, + provenance="usda", + assumption=f"Used mocked USDA generic proxy: {food.name}.", + ) + self.user_connection.execute( + """INSERT OR IGNORE INTO food_cache + (name, kcal, protein, fat, carbs, source, fdc_id, lookup_query, + resolution_mode, source_id, provenance, assumption) + VALUES (?, ?, ?, ?, ?, 'usda', ?, ?, 'generic_proxy', ?, 'usda', ?)""", + ( + resolved.name, + resolved.kcal, + resolved.protein, + resolved.fat, + resolved.carbs, + resolved.fdc_id, + query, + source_id, + resolved.assumption, + ), + ) + return resolved, 0.91 + + monkeypatch.setattr(FoodRepository, "resolve", resolve) + + +def _log_count(path) -> int: + if not path.exists(): + return 0 + with connect(path) as connection: + return int(connection.execute("SELECT count(*) FROM log_entries").fetchone()[0]) + + +def _cache_count(path) -> int: + if not path.exists(): + return 0 + with connect(path) as connection: + return int(connection.execute("SELECT count(*) FROM food_cache").fetchone()[0]) + + +def _run_breakfast(user_db, monkeypatch, capsys, estimates=ESTIMATES, *, policy="estimate"): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + arguments = [ + "log", + "--parse", + BREAKFAST, + "--portion-policy", + policy, + ] + if estimates is not None: + arguments.extend(["--portion-estimates", json.dumps(estimates)]) + arguments.append("--json") + code = main(arguments) + return code, capsys.readouterr() + + +def test_default_strict_breakfast_keeps_piece_weight_unknown_and_writes_no_log( + user_db, monkeypatch, capsys, mocked_generic_breakfast +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + + code = main(["log", "--parse", BREAKFAST, "--json"]) + captured = capsys.readouterr() + + assert code == 2 + assert captured.out == "" + assert json.loads(captured.err)["error"]["code"] == "piece_weight_unknown" + assert _log_count(user_db) == 0 + assert _cache_count(user_db) == 0 + + +def test_valid_agent_estimates_log_full_breakfast_and_date_stats_atomically( + user_db, + monkeypatch, + capsys, + almaty_timezone, + mocked_generic_breakfast, +): + monkeypatch.setattr( + "nomnomcli.cli._local_now", + lambda: datetime.fromisoformat("2026-07-21T08:30:00+05:00"), + ) + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + + code = main( + [ + "log", + "--parse", + BREAKFAST, + "--portion-policy", + "estimate", + "--portion-estimates", + json.dumps(ESTIMATES), + "--date", + "2026-07-20", + "--json", + ] + ) + logged = json.loads(capsys.readouterr().out) + + assert code == 0 + assert len(logged["items"]) == 6 + assert logged["approximate"] is True + assert "scale" in logged["portion_correction"] + estimated = [ + item for item in logged["items"] if item.get("portion_provenance") == "agent_estimate" + ] + assert [item["name"] for item in estimated] == [ + "egg, fried", + "tomato, raw", + "onion, raw", + "dates", + ] + assert all(item["approximate"] is True for item in estimated) + assert all(item["portion_estimate"]["method"] == "agent_estimate" for item in estimated) + assert all(item["portion_estimate"]["grams"] == item["grams"] for item in estimated) + assert [item["portion_estimate"]["item_index"] for item in estimated] == [0, 1, 2, 5] + assert [item["portion_estimate"]["input"] for item in estimated] == [ + "3 small fried eggs", + "half small tomato", + "half small onion", + "15 dates", + ] + explicit = [logged["items"][3], logged["items"][4]] + assert [item["grams"] for item in explicit] == [180, 110] + assert all("portion_provenance" not in item for item in explicit) + assert all("approximate" not in item for item in explicit) + + assert main(["stats", "date", "2026-07-20", "--json"]) == 0 + stats = json.loads(capsys.readouterr().out) + assert stats["approximate"] is True + assert "scale" in stats["portion_correction"] + assert stats["meals"][0]["approximate"] is True + assert sum( + item.get("portion_provenance") == "agent_estimate" + for item in stats["meals"][0]["items"] + ) == 4 + assert _log_count(user_db) == 1 + + +def test_text_log_has_one_concise_estimate_correction_prompt( + user_db, monkeypatch, capsys, mocked_generic_breakfast +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + + code = main( + [ + "log", + "--parse", + BREAKFAST, + "--portion-policy", + "estimate", + "--portion-estimates", + json.dumps(ESTIMATES), + ] + ) + output = capsys.readouterr().out + + assert code == 0 + assert output.count("Correct approximate portions with scale grams, photo, or barcode.") == 1 + assert "portion: agent_estimate | range 120.00-150.00 g | confidence 0.72" in output + assert "measured" not in output.casefold() + assert "exact" not in output.casefold() + + +def test_ask_policy_requests_external_estimate_without_writing( + user_db, monkeypatch, capsys, mocked_generic_breakfast +): + code, captured = _run_breakfast( + user_db, monkeypatch, capsys, estimates=None, policy="ask" + ) + + error = json.loads(captured.err)["error"] + assert code == 2 + assert captured.out == "" + assert error["code"] == "portion_estimate_required" + assert error["details"]["policy"] == "ask" + assert error["details"]["item_index"] == 0 + assert error["details"]["input"] == "3 small fried eggs" + assert _log_count(user_db) == 0 + assert _cache_count(user_db) == 0 + + +def test_estimates_require_explicit_estimate_policy( + user_db, monkeypatch, capsys, mocked_generic_breakfast +): + code, captured = _run_breakfast(user_db, monkeypatch, capsys, policy="strict") + + assert code == 2 + assert json.loads(captured.err)["error"]["code"] == "portion_policy_required" + assert _log_count(user_db) == 0 + assert _cache_count(user_db) == 0 + + +@pytest.mark.parametrize( + ("mutation", "error_code"), + [ + (lambda payload: payload["items"].pop(), "portion_estimate_missing"), + ( + lambda payload: payload["items"][0].update({"input": "3 medium fried eggs"}), + "portion_estimate_mismatch", + ), + ( + lambda payload: payload["items"].append(copy.deepcopy(payload["items"][0])), + "portion_estimate_duplicate", + ), + ( + lambda payload: payload["items"][0].update( + {"lower_grams": 140, "grams": 135} + ), + "portion_estimate_invalid", + ), + ( + lambda payload: payload["items"][0].update({"confidence": 1.01}), + "portion_estimate_invalid", + ), + ( + lambda payload: payload["items"][0].update({"grams": -1}), + "portion_estimate_invalid", + ), + ( + lambda payload: payload["items"][0].update({"method": "visual_guess"}), + "portion_estimate_invalid", + ), + ( + lambda payload: payload["items"][0].update({"assumption": " "}), + "portion_estimate_invalid", + ), + ( + lambda payload: payload["items"].append( + { + "item_index": 3, + "input": "whole wheat bread 180 g", + "grams": 180, + "lower_grams": 180, + "upper_grams": 180, + "confidence": 1, + "method": "agent_estimate", + "assumption": "Should be rejected because grams were explicit.", + } + ), + "portion_estimate_mismatch", + ), + ], + ids=( + "missing", + "input-mismatch", + "duplicate", + "range", + "confidence", + "negative", + "method", + "assumption", + "estimate-for-explicit-grams", + ), +) +def test_invalid_or_mismatched_estimate_payload_writes_nothing( + user_db, + monkeypatch, + capsys, + mocked_generic_breakfast, + mutation, + error_code, +): + payload = copy.deepcopy(ESTIMATES) + mutation(payload) + + code, captured = _run_breakfast(user_db, monkeypatch, capsys, payload) + + assert code == 2 + assert captured.out == "" + assert json.loads(captured.err)["error"]["code"] == error_code + assert _log_count(user_db) == 0 + assert _cache_count(user_db) == 0 + + +def test_malformed_estimate_json_is_rejected_before_database_creation( + user_db, monkeypatch, capsys +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + + code = main( + [ + "log", + "--parse", + BREAKFAST, + "--portion-policy", + "estimate", + "--portion-estimates", + '{"items": [', + "--json", + ] + ) + captured = capsys.readouterr() + + assert code == 2 + assert json.loads(captured.err)["error"]["code"] == "portion_estimates_malformed" + assert not user_db.exists() + + +def test_explicit_per_piece_grams_remain_authoritative_under_estimate_policy( + user_db, monkeypatch, capsys, mocked_generic_breakfast +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + + code = main( + [ + "log", + "--parse", + "3 pieces fried eggs at 38g", + "--portion-policy", + "estimate", + "--json", + ] + ) + result = json.loads(capsys.readouterr().out) + + assert code == 0 + assert result["items"][0]["grams"] == 114 + assert "portion_provenance" not in result["items"][0] + assert result.get("approximate") is not True + + +def test_direct_explicit_grams_do_not_read_fuzzy_portion_policy( + user_db, monkeypatch, capsys, mocked_generic_breakfast +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + monkeypatch.setenv("NOMNOM_PORTION_POLICY", "invalid-for-parse") + + code = main(["log", "--food", "milk", "--grams", "110", "--json"]) + result = json.loads(capsys.readouterr().out) + + assert code == 0 + assert result["items"][0]["grams"] == 110 + assert "portion_provenance" not in result["items"][0] + + +def test_legacy_log_items_without_portion_fields_remain_readable( + user_db, monkeypatch, capsys, almaty_timezone +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + monkeypatch.setattr( + "nomnomcli.cli._local_now", + lambda: datetime.fromisoformat("2026-07-21T08:30:00+05:00"), + ) + old_item = { + "name": "legacy food", + "grams": 100, + "kcal": 50, + "protein": 2, + "fat": 1, + "carbs": 8, + "match_confidence": 1, + } + with connect(user_db) as connection: + store_log( + connection, + [old_item], + {"kcal": 50, "protein": 2, "fat": 1, "carbs": 8}, + logged_at=datetime.fromisoformat("2026-07-20T12:00:00+05:00"), + ) + + assert main(["stats", "date", "2026-07-20", "--json"]) == 0 + stats = json.loads(capsys.readouterr().out) + + assert stats["meals"][0]["items"] == [old_item] + assert stats["meals"][0]["approximate"] is False + assert stats["approximate"] is False + + +def test_portion_policy_defaults_to_strict_and_supports_config_and_env(tmp_path): + path = tmp_path / "config.toml" + assert ProviderConfig(environ={}, config_path=path).portion_policy() == "strict" + + path.write_text('[resolution]\nportion_policy = "ask"\n', encoding="utf-8") + assert ProviderConfig(environ={}, config_path=path).portion_policy() == "ask" + assert ( + ProviderConfig( + environ={"NOMNOM_PORTION_POLICY": "estimate"}, config_path=path + ).portion_policy() + == "estimate" + ) + + +def test_storing_usda_key_preserves_portion_policy(tmp_path): + path = tmp_path / "config.toml" + path.write_text('[resolution]\nportion_policy = "estimate"\n', encoding="utf-8") + config = ProviderConfig(environ={}, config_path=path) + + config.store_usda_key("stored-placeholder") + + assert config.portion_policy() == "estimate"