From cf2d29ad27adddc27def54a97b2eb4524c2fa5cb Mon Sep 17 00:00:00 2001 From: max r Date: Tue, 21 Jul 2026 01:03:05 +0500 Subject: [PATCH] feat: add source-backed zero-friction capture --- README.md | 79 +++++++-- docs/plans.md | 71 ++++++++ docs/status.md | 15 +- docs/test-plan.md | 41 +++++ nomnomcli/__init__.py | 2 +- nomnomcli/cli.py | 65 +++++++ nomnomcli/config.py | 90 +++++++++- nomnomcli/db.py | 31 +++- nomnomcli/foods.py | 232 +++++++++++++++++++++++- nomnomcli/models.py | 19 +- nomnomcli/off.py | 77 ++++++++ nomnomcli/usda.py | 3 + pyproject.toml | 2 +- skill/SKILL.md | 47 +++-- tests/test_capture.py | 353 +++++++++++++++++++++++++++++++++++++ tests/test_config.py | 58 ++++++ tests/test_data_quality.py | 20 ++- tests/test_db.py | 128 +++++++++++++- tests/test_foods.py | 176 ++++++++++++++++++ tests/test_off.py | 70 +++++++- 20 files changed, 1525 insertions(+), 54 deletions(-) create mode 100644 tests/test_capture.py diff --git a/README.md b/README.md index 3ce7814..9b6373c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **nomnom stores nothing about food; it computes what you feed it.** -`nomnomcli` is an agent-first nutrition ledger. Version 0.3 ships zero food records: no food +`nomnomcli` is an agent-first nutrition ledger. Version 0.4 ships zero food records: no food database, synonym corpus, or piece-weight table is hidden in the package. It resolves food at runtime, performs nutrition arithmetic in code, and stores successful logs plus a user-owned cache in SQLite. There is no LLM in the program and no invented nutrition fallback. @@ -17,6 +17,7 @@ flowchart LR N --> C[(user food cache)] N -->|free-text search| O[Open Food Facts API] N -->|when key is configured| U[USDA FoodData Central API] + A -->|barcode or extracted label facts| N N -->|resolved JSON or structured error| A ``` @@ -63,7 +64,8 @@ Resolution is deterministic and ordered: 2. exact match in the user's `food_cache`; 3. token-overlap search in that cache; 4. Open Food Facts free-text search; -5. USDA FoodData Central, when a setup key or `NOMNOM_USDA_KEY` is configured; +5. a safe USDA FoodData Central generic proxy, when a setup key or + `NOMNOM_USDA_KEY` is configured; 6. actionable JSON error—never a guessed food. Open Food Facts candidates need at least 0.5 normalized token overlap between the query and @@ -72,7 +74,8 @@ values must be present, finite, and greater than zero. A rejected result returns `off_low_confidence` with the candidate and alternatives, and is neither cached nor logged. Successful API results are cached in the user's database, so the same food can resolve locally -later. Existing v0.2 cache records, logs, and recipes are preserved when v0.3 opens the database. +later. Existing cache records, logs, recipes, and aliases are preserved when v0.4 opens the +database. `nomnom search QUERY` searches this user cache; it is not a packaged food catalog. Open Food Facts free-text search goes directly through the official legacy v1 endpoint, @@ -109,15 +112,61 @@ Without a key, a food that OFF cannot resolve returns `usda_key_required`, the s the same signup URL. USDA search requires complete positive kcal/protein/fat/carbs, scores query token overlap together with data type and category, prefers Foundation and SR Legacy, and enforces a confidence floor. Weak matches return `usda_low_confidence` with candidate alternatives and are -never cached. Accepted matches cache `source=usda`, `fdc_id`, and any returned serving-field -provenance. +never cached. + +The default generic policy is `allow_for_unbranded`. A USDA result becomes a generic proxy only +when it is an unbranded Foundation, SR Legacy, or Survey (FNDDS) record with an FDC id and every +normalized query token is covered by its name. Brand/SKU-like or unmatched input and branded USDA +records return `exact_resolution_required` without cache or log writes. Accepted proxies expose +`resolution_mode=generic_proxy`, `source=usda`, the FDC `source_id`, `provenance=usda`, confidence, +and an explicit assumption in log JSON. + +Choose a stricter policy in the user config: + +```toml +[resolution] +generic_proxy_policy = "ask" # or "exact_only" +``` + +`NOMNOM_GENERIC_PROXY_POLICY` overrides the file. `ask` returns +`generic_proxy_confirmation_required` with the candidate but writes nothing; `exact_only` requires +barcode or package-label capture. Supported values are `allow_for_unbranded`, `ask`, and +`exact_only`. Set `NOMNOM_OFFLINE=1` to prevent all remote food lookup. Set `NOMNOM_DISABLE_OFF=1` to skip OFF while retaining USDA when its key is configured. -### Pin a label manually +## Capture an exact packaged product + +When an exact packaged product is needed, use its barcode or ask the user for a package photo. A +barcode capture calls only the Open Food Facts v2 product endpoint; it never sends free text: + +```sh +nomnom capture barcode "0123456789012" --json +``` + +If the barcode is absent or OFF lacks complete core nutrition, the agent reads the supplied label +photo and passes the extracted per-100 g facts to the CLI: + +```sh +nomnom capture label \ + --name "chicken pastrami" --brand "Example" \ + --kcal 110 --protein 20 --fat 2 --carbs 3 \ + --serving-grams 75 \ + --source-note "image:sha256:LOCAL_REFERENCE" --json +``` + +`--source-note` is required. Use a nonempty local or opaque image/barcode reference that lets the +user trace the facts without putting the image itself in SQLite. The CLI has no OCR or vision +dependency, never receives or stores the photo, never estimates missing macros, and rejects +non-finite/negative values or a non-positive serving weight without writing. Both capture paths +persist `resolution_mode=exact_product`, source identity, provenance, and the normalized nutrition +facts; the canonical name can then be used in an alias and logged offline. + +### Legacy manual pin -`nomnom add` remains a manual operation. Use only verified per-100 g label values: +`nomnom add` remains available for existing manual workflows. For a new packaged product, prefer +the source-backed capture commands above. Use only verified per-100 g label values: ```sh nomnom add \ @@ -197,6 +246,13 @@ an `error` object and exit with status 2. Important codes include: no near match was cached. - `usda_invalid_nutrition`: every USDA candidate lacked one or more complete positive core values. - `usda_key_required`: configure the free FDC key or pin verified values. +- `generic_proxy_confirmation_required`: show the named USDA candidate and ask before changing the + configured policy; nothing was cached or logged. +- `exact_resolution_required`: request the barcode or a package photo for source-backed capture. +- `invalid_barcode` / `barcode_not_found` / `barcode_nutrition_incomplete`: correct the barcode or + request a package photo; failed captures write nothing. +- `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. - `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. @@ -227,14 +283,15 @@ Recipe ingredients use the same runtime resolver. An unresolved ingredient fails instead of storing partial nutrition. User data defaults to `~/.local/share/nomnomcli/nomnom.sqlite3`. Override it with -`NOMNOM_DB_PATH`. Schema v3 upgrades preserve cached foods, logs, and recipes in place and add the -user-only alias table. +`NOMNOM_DB_PATH`. Schema v4 upgrades preserve cached foods, logs, recipes, and aliases in place and +add resolution mode, source identity/note, provenance, and assumption fields to the food cache. ## Agent skill and development The repository agent workflow is [`skill/SKILL.md`](skill/SKILL.md). It teaches agents to use -nomnom's JSON, follow OFF → USDA → manual add → structured error, and never estimate nutrition in -their own context. +nomnom's JSON, accept only safe generic proxies, request a barcode/package photo for exact products, +capture extracted label facts with a source note, and never estimate nutrition in their own +context. ```sh python -m pip install -e '.[dev]' diff --git a/docs/plans.md b/docs/plans.md index 1954287..98a8bb8 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -1,5 +1,76 @@ # Plans +## Issue #19 Source +- Task: Implement the v0.4 zero-friction source-backed capture slice. +- Canonical input: GitHub issue #19 plus the user's explicit default-policy override. +- Repo context: provider policy, runtime resolver, capture CLI, user SQLite schema, tests, README, and agent skill. +- Last updated: 2026-07-21 + +## Issue #19 Assumptions +- `allow_for_unbranded` is the default despite the issue body's older `ask` default. +- A USDA proxy is eligible only when the returned record passes existing nutrition/confidence checks, is a generic data type with no brand, has an FDC id, and covers every normalized query token; unmatched brand/SKU-like input therefore stays exact-only. +- Package-photo OCR/vision remains agent-side. The CLI accepts only extracted facts and a mandatory local source reference and never stores the image. +- Schema version 4 is the additive migration boundary already started on this branch; issue #19 completes that explicit v3-to-v4 migration rather than introducing a second version number. + +## Issue #19 Milestone Order +| ID | Title | Depends on | Status | +| --- | --- | --- | --- | +| M13 | Add failing policy, proxy, capture, migration, and smoke contracts | M12 | [x] | +| M14 | Implement v4 provenance and deterministic capture/resolution | M13 | [x] | +| M15 | Document v0.4 agent flow and privacy contract | M14 | [x] | +| M16 | Run full validation, smoke, audit, and local commit | M15 | [x] | + +## M13. Add failing issue #19 acceptance contracts `[x]` +### Goal +- Freeze the user-visible policy, JSON, endpoint, persistence, migration, and clean-install behavior before runtime implementation. + +### Validation +```sh +pytest -q tests/test_config.py tests/test_foods.py tests/test_off.py tests/test_cli.py tests/test_db.py +``` + +### Stop-and-Fix Rule +- Record expected RED failures before production changes; no live provider traffic or personal-image fixture may enter tests. + +## M14. Implement v4 provenance and deterministic capture/resolution `[x]` +### Goal +- Source-backed unbranded USDA proxies and exact OFF/package captures persist and replay with explicit mode and provenance. + +### Validation +```sh +pytest -q tests/test_config.py tests/test_foods.py tests/test_off.py tests/test_cli.py tests/test_db.py +``` + +### Stop-and-Fix Rule +- Reject incomplete nutrition, unsafe/branded generic substitution, blank provenance, and failed capture without cache or log writes. + +## M15. Document v0.4 agent flow and privacy contract `[x]` +### Goal +- README and agent skill give exact commands and direct agents to request a photo—not manual label lookup—when exact package facts are needed. + +### Validation +```sh +pytest -q tests/test_install.py tests/test_cli.py +ruff check . +``` + +### Stop-and-Fix Rule +- Keep docs aligned with executable syntax and stable JSON fields before release validation. + +## M16. Run full validation, smoke, audit, and local commit `[x]` +### Goal +- Produce one coherent, validated local conventional commit with no push or PR. + +### Validation +```sh +pytest -q +ruff check . +git diff --check +``` + +### Stop-and-Fix Rule +- Do not commit until full tests, Ruff, literal isolated-DB smoke, and diff audit all pass. + ## Issue #17 Source - Task: Fix the Open Food Facts full-text provider contract without live-test traffic. - Canonical input: GitHub issue #17 and the user's required behavior. diff --git a/docs/status.md b/docs/status.md index e55d3f8..95710a0 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,7 +1,7 @@ # Status ## Snapshot -- Current phase: issue #17 complete +- Current phase: issue #19 complete - Plan file: `docs/plans.md` - Status: green - Last updated: 2026-07-21 @@ -15,14 +15,19 @@ - Added mocked OFF v2 resolution with branded priority, alternatives, barcode/cache migration, clear failures, and `nomnom add`. - Removed placeholder profiles from the offline seed and bundled 431-food database; byte-deterministic updates and data-quality gates pass. - Passed 72 tests, Ruff, version 0.2.0, and the exact isolated v0.2 smoke; removed the `/tmp` smoke database. +- Completed v0.4 source-backed capture: safe default USDA generic proxies, exact OFF v2 barcode capture, agent-extracted label capture, durable provenance, and additive schema-v4 migration. +- Passed 155 tests, Ruff, diff audit, and the literal isolated schema-v4 capture/alias/offline-log/error smoke. ## In Progress - None. ## Next -- Push `feat/off-fulltext-contract` and open the issue #17 pull request after independent verification. +- None; issue #19 is complete and committed locally with no push or PR. ## Decisions Made +- Default generic policy is `allow_for_unbranded`; this explicit user decision supersedes issue #19's older `ask` default. +- Generic proxy safety requires a generic USDA type, no returned brand, an FDC id, complete validated core nutrition, accepted confidence, and full query-token coverage. +- Package photo extraction stays outside the dependency-free CLI; only extracted facts and a mandatory source note enter the user database. - Route all OFF free text to legacy v1 `/cgi/search.pl`; never pass `search_terms` to v2. - Report OFF product/barcode reachability separately from full-text resolution readiness. - Use stdlib `argparse` — keeps runtime dependencies to `requests` only. @@ -30,6 +35,7 @@ - Reuse `scripts/build_mini_db.py --update-existing` for deterministic offline v0.2 data repair without shrinking the tracked USDA corpus. ## Assumptions In Force +- Schema v4 is completed additively from the existing v3-to-v4 boundary and preserves every v3 table and row. - Issue #17 tests use only mocked/replay transports and never live OFF traffic. - Agent confirmation is an operating pattern, not a pending database transaction in v0.1. - Named brands are never resolved to bundled generic foods; manual cache entries are the offline escape hatch. @@ -58,8 +64,12 @@ ruff check . | 2026-07-19 | M8 | README, skill, version, planning docs | `pytest -q`; `ruff check .`; exact isolated smoke; diff/junk audit | 72 pass; clean | commit | | 2026-07-21 | M9 | OFF, food confidence, doctor/setup contract tests | `PYTHONPATH=. pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py`; focused setup test | RED: missing product probe; RED: missing status explanation | M10 | | 2026-07-21 | M10–M11 | OFF client, onboarding/CLI, README, skill, tests | focused pytest; full pytest; full Ruff | 62 pass; 126 pass; clean | M12 | +| 2026-07-21 | issue #19 preflight | issue, providers, resolver, schema, CLI, tests, docs, skill | `pytest -q`; repository inspection | 126 pass; clean baseline | M13 | +| 2026-07-21 | M13 | policy, proxy, capture, and migration acceptance tests | focused pytest; full local pytest | RED contracts and 4 partial-worktree failures recorded | M14 | +| 2026-07-21 | M14–M16 | config, resolver, OFF, schema, CLI, docs, skill, tests | `pytest -q`; `ruff check .`; `git diff --check`; literal temp-DB smoke | 155 pass; clean; smoke pass | local commit | ## Smoke / Demo Checklist +- [x] Fresh temp DB: help/version, capture label, alias, log, and invalid structured capture error. - [x] Russian mixed-item log works and persists. - [x] Today stats reproduce logged totals. - [x] Fixture recipe imports and logs. @@ -71,3 +81,4 @@ ruff check . - [x] Version and documentation report 0.2.0 behavior. - [x] OFF free text uses v1 with no unfiltered v2 fallback. - [x] Doctor and setup distinguish product/barcode reachability from full-text readiness. +- [x] Version and documentation report v0.4 generic-proxy and source-backed capture behavior. diff --git a/docs/test-plan.md b/docs/test-plan.md index 4db5204..2192423 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -1,5 +1,46 @@ # Test Plan +## Issue #19 Source +- Task: Validate the v0.4 zero-friction source-backed capture slice. +- Plan file: `docs/plans.md` +- Status file: `docs/status.md` +- Last updated: 2026-07-21 + +## Issue #19 Validation Scope +- In scope: default/config/env proxy policy, generic USDA eligibility and visible assumptions, branded/SKU denial, OFF v2 barcode lookup, package-label capture, aliases/log replay, v3-to-v4 preservation, docs/skill, and isolated fresh-DB CLI smoke. +- Out of scope: LLM/OCR/cloud-vision dependencies, live API traffic, real/personal photos, repository nutrition records, and macro estimation. + +## Issue #19 Fixtures and Network Rules +- Provider responses are mocked synthetic OFF/USDA payloads only; barcode assertions inspect the exact v2 product URL and absence of free-text parameters. +- Package-label tests pass synthetic agent-extracted numbers and opaque image/barcode reference tokens; no image is stored or committed. + +## Issue #19 Test Levels + +### Unit +- Policy precedence/default/invalid values; barcode syntax and complete nutrients; generic data type/brand/query-token safety; finite non-negative label values and positive serving grams. + +### Integration +- Automatic unbranded USDA proxy caches and logs `generic_proxy`, canonical name, `source=usda`, FDC `source_id`, confidence, and explicit assumption. +- `ask`, `exact_only`, and branded inputs return structured actions without cache/log writes. +- Exact OFF and package-label captures preserve source, source id/note, provenance, mode, and later alias/log behavior. +- Explicit v3-to-v4 migration preserves cache, logs, recipes, and aliases while legacy rows remain readable. + +### End-to-End / Smoke +- A clean temp database runs help/version, capture label, alias creation, offline log, and invalid structured capture input. + +## Issue #19 Negative / Edge Cases +- Invalid barcode and OFF missing/zero core nutrition are never cached. +- Blank/missing source note, negative/non-finite nutrition, and non-positive serving grams are structured failures without writes. +- Returned branded USDA records, generic records with unmatched query tokens, and any explicit SKU never become generic proxies. + +## Issue #19 Acceptance Gates +- [x] Focused tests witnessed RED before implementation. +- [x] `pytest -q` — 155 passed. +- [x] `ruff check .` — clean. +- [x] Literal isolated temp-DB smoke passes. +- [x] `git diff --check` and scoped diff audit pass. +- [x] One local conventional commit; no push or PR. + ## Issue #17 Source - Task: Validate the OFF full-text provider contract from GitHub issue #17. - Plan file: `docs/plans.md` diff --git a/nomnomcli/__init__.py b/nomnomcli/__init__.py index ad7ea02..9cd8808 100644 --- a/nomnomcli/__init__.py +++ b/nomnomcli/__init__.py @@ -1,3 +1,3 @@ """Deterministic nutrition tracking for humans and their agents.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/nomnomcli/cli.py b/nomnomcli/cli.py index 9224a52..f71a92d 100644 --- a/nomnomcli/cli.py +++ b/nomnomcli/cli.py @@ -27,6 +27,24 @@ def _nutrition_line(totals: dict) -> str: ) +def _capture_result(food) -> dict: + return { + "name": food.name, + "brand": food.brand, + "source": food.source, + "source_id": food.source_id, + "source_note": food.source_note, + "provenance": food.provenance, + "resolution_mode": food.resolution_mode, + "barcode": food.barcode, + "kcal_per_100g": round(food.kcal, 2), + "protein_per_100g": round(food.protein, 2), + "fat_per_100g": round(food.fat, 2), + "carbs_per_100g": round(food.carbs, 2), + "serving_grams": food.piece_grams, + } + + def _print_log(result: dict, as_json: bool) -> None: if as_json: print(_json_output(result)) @@ -92,6 +110,32 @@ def _build_parser() -> argparse.ArgumentParser: add.add_argument("--piece-grams", type=float) add.add_argument("--json", action="store_true", help="machine-readable JSON output") + capture = commands.add_parser( + "capture", help="capture exact package facts from a barcode or agent-extracted label" + ) + capture_commands = capture.add_subparsers(dest="capture_command", required=True) + capture_barcode = capture_commands.add_parser( + "barcode", help="fetch one exact Open Food Facts v2 product" + ) + capture_barcode.add_argument("code") + capture_barcode.add_argument( + "--json", action="store_true", help="machine-readable JSON output" + ) + capture_label = capture_commands.add_parser( + "label", help="persist facts extracted by an agent from a supplied package photo" + ) + capture_label.add_argument("--name", required=True) + capture_label.add_argument("--brand") + capture_label.add_argument("--kcal", type=float, required=True) + capture_label.add_argument("--protein", type=float, required=True) + capture_label.add_argument("--fat", type=float, required=True) + capture_label.add_argument("--carbs", type=float, required=True) + capture_label.add_argument("--serving-grams", type=float) + capture_label.add_argument("--source-note") + capture_label.add_argument( + "--json", action="store_true", help="machine-readable JSON output" + ) + alias = commands.add_parser("alias", help="manage user food aliases") alias_commands = alias.add_subparsers(dest="alias_command", required=True) alias_add = alias_commands.add_parser("add", help="map a phrase to a cached food") @@ -166,6 +210,27 @@ def _run(args: argparse.Namespace) -> int: with connect() as connection: repository = FoodRepository(connection) + if args.command == "capture": + if args.capture_command == "barcode": + food = repository.capture_barcode(args.code) + else: + food = repository.capture_label( + name=args.name, + brand=args.brand, + kcal=args.kcal, + protein=args.protein, + fat=args.fat, + carbs=args.carbs, + serving_grams=args.serving_grams, + source_note=args.source_note, + ) + result = _capture_result(food) + if args.json: + print(_json_output(result)) + else: + print(f"Captured: {food.name} ({food.kcal:.2f} kcal/100g)") + return 0 + if args.command == "alias": if args.alias_command == "add": result = repository.add_alias(args.phrase, args.canonical_food_name) diff --git a/nomnomcli/config.py b/nomnomcli/config.py index 98291b3..d2c4e0c 100644 --- a/nomnomcli/config.py +++ b/nomnomcli/config.py @@ -12,6 +12,13 @@ from nomnomcli.errors import NomnomError USDA_ENV_VAR = "NOMNOM_USDA_KEY" +GENERIC_PROXY_POLICY_ENV_VAR = "NOMNOM_GENERIC_PROXY_POLICY" +GENERIC_PROXY_POLICIES = ( + "allow_for_unbranded", + "ask", + "exact_only", +) +DEFAULT_GENERIC_PROXY_POLICY = "allow_for_unbranded" @dataclass(frozen=True, slots=True) @@ -47,18 +54,64 @@ def usda_credential(self) -> Credential | None: stored_key = self._stored_usda_key() return Credential(stored_key, "user_config") if stored_key else None - def _stored_usda_key(self) -> str | None: + def generic_proxy_policy(self) -> str: + environment_policy = self._environ.get(GENERIC_PROXY_POLICY_ENV_VAR, "").strip() + if environment_policy: + return self._validate_generic_proxy_policy(environment_policy, "environment") + payload = self._stored_payload() + try: + stored_policy = payload.get("resolution", {}).get("generic_proxy_policy") + except AttributeError as exc: + raise self._invalid_config_error() from exc + if stored_policy is None: + return DEFAULT_GENERIC_PROXY_POLICY + if not isinstance(stored_policy, str): + raise NomnomError( + "generic_proxy_policy_invalid", + "generic_proxy_policy in provider configuration must be a string", + details={"path": str(self.path), "allowed": list(GENERIC_PROXY_POLICIES)}, + ) + return self._validate_generic_proxy_policy(stored_policy, "user_config") + + def _validate_generic_proxy_policy(self, value: str, source: str) -> str: + policy = value.strip() + if policy not in GENERIC_PROXY_POLICIES: + raise NomnomError( + "generic_proxy_policy_invalid", + f"Unsupported generic proxy policy: {policy or '(empty)'}", + details={ + "source": source, + "allowed": list(GENERIC_PROXY_POLICIES), + "environment_variable": GENERIC_PROXY_POLICY_ENV_VAR, + "path": str(self.path), + }, + ) + return policy + + def _invalid_config_error(self) -> NomnomError: + return NomnomError( + "provider_config_invalid", + f"Provider configuration is invalid: {self.path}", + details={"path": str(self.path), "action": "Run nomnom setup again"}, + ) + + def _stored_payload(self) -> dict: if not self.path.exists(): - return None + return {} try: payload = tomllib.loads(self.path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise self._invalid_config_error() from exc + if not isinstance(payload, dict): + raise self._invalid_config_error() + return payload + + def _stored_usda_key(self) -> str | None: + try: + payload = self._stored_payload() value = payload.get("providers", {}).get("usda", {}).get("api_key") - except (OSError, tomllib.TOMLDecodeError, AttributeError) as exc: - raise NomnomError( - "provider_config_invalid", - f"Provider configuration is invalid: {self.path}", - details={"path": str(self.path), "action": "Run nomnom setup again"}, - ) from exc + except AttributeError as exc: + raise self._invalid_config_error() from exc if value is None: return None if not isinstance(value, str): @@ -75,10 +128,31 @@ def store_usda_key(self, api_key: str) -> Path: raise NomnomError("usda_key_invalid", "USDA API key must not be empty") destination = self.path + payload = self._stored_payload() + try: + stored_policy = payload.get("resolution", {}).get("generic_proxy_policy") + except AttributeError as exc: + raise self._invalid_config_error() from exc + if stored_policy is not None and not isinstance(stored_policy, str): + raise NomnomError( + "generic_proxy_policy_invalid", + "generic_proxy_policy in provider configuration must be a string", + details={"path": str(self.path), "allowed": list(GENERIC_PROXY_POLICIES)}, + ) + policy = ( + self._validate_generic_proxy_policy(stored_policy, "user_config") + if stored_policy is not None + else None + ) destination.parent.mkdir(parents=True, exist_ok=True, mode=0o700) with suppress(OSError): destination.parent.chmod(0o700) content = f"[providers.usda]\napi_key = {json.dumps(key, ensure_ascii=False)}\n" + if policy is not None: + content += ( + "\n[resolution]\n" + f"generic_proxy_policy = {json.dumps(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 9462b7d..ac54802 100644 --- a/nomnomcli/db.py +++ b/nomnomcli/db.py @@ -27,7 +27,12 @@ lookup_query TEXT, alternatives_json TEXT, piece_grams_source TEXT, - piece_grams_source_value TEXT + piece_grams_source_value TEXT, + resolution_mode TEXT NOT NULL DEFAULT 'legacy', + source_id TEXT, + source_note TEXT, + provenance TEXT, + assumption TEXT )""", """CREATE TABLE log_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -103,17 +108,37 @@ def _migrate_v2_to_v3(connection: sqlite3.Connection) -> None: ) -def _migrate_v3_to_v4(connection: sqlite3.Connection) -> None: +def _ensure_v4_food_cache(connection: sqlite3.Connection) -> None: columns = _column_names(connection, "food_cache") additions = { "piece_grams_source": "ALTER TABLE food_cache ADD COLUMN piece_grams_source TEXT", "piece_grams_source_value": ( "ALTER TABLE food_cache ADD COLUMN piece_grams_source_value TEXT" ), + "resolution_mode": ( + "ALTER TABLE food_cache ADD COLUMN resolution_mode " + "TEXT NOT NULL DEFAULT 'legacy'" + ), + "source_id": "ALTER TABLE food_cache ADD COLUMN source_id TEXT", + "source_note": "ALTER TABLE food_cache ADD COLUMN source_note TEXT", + "provenance": "ALTER TABLE food_cache ADD COLUMN provenance TEXT", + "assumption": "ALTER TABLE food_cache ADD COLUMN assumption TEXT", } for column, statement in additions.items(): if column not in columns: connection.execute(statement) + connection.execute( + """UPDATE food_cache + SET source_id = COALESCE(barcode, CAST(fdc_id AS TEXT)) + WHERE source_id IS NULL""" + ) + connection.execute( + "UPDATE food_cache SET provenance = source WHERE provenance IS NULL" + ) + + +def _migrate_v3_to_v4(connection: sqlite3.Connection) -> None: + _ensure_v4_food_cache(connection) MIGRATIONS = {1: _migrate_v1_to_v2, 2: _migrate_v2_to_v3, 3: _migrate_v3_to_v4} @@ -149,6 +174,8 @@ def _initialize_database(connection: sqlite3.Connection) -> None: table = statement.split("TABLE ", 1)[1].split(" ", 1)[0].strip().strip("(") if table in missing: connection.execute(statement) + if "food_cache" in _table_names(connection): + _ensure_v4_food_cache(connection) connection.commit() except Exception: connection.rollback() diff --git a/nomnomcli/foods.py b/nomnomcli/foods.py index b871718..3ed4348 100644 --- a/nomnomcli/foods.py +++ b/nomnomcli/foods.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math import os import re import sqlite3 @@ -18,6 +19,11 @@ "then run nomnom setup or set NOMNOM_USDA_KEY." ) USDA_KEY_SETUP = f"Run nomnom setup; signup: {USDA_SETUP_URL}" +GENERIC_USDA_DATA_TYPES = frozenset({"foundation", "sr legacy", "survey (fndds)"}) +EXACT_CAPTURE_ACTION = ( + "Provide the package barcode or photo so the agent can run nomnom capture " + "barcode or nomnom capture label" +) def normalize_name(value: str) -> str: @@ -79,6 +85,24 @@ def _brand_matches_query(food: Food, query: str) -> bool: ) +def _generic_proxy_query_is_safe(query: str, food: Food) -> bool: + query_tokens = _name_tokens(query) + candidate_tokens = _name_tokens(food.name) + return bool(query_tokens) and query_tokens <= candidate_tokens and not any( + token.isdigit() for token in query_tokens + ) + + +def _generic_proxy_candidate(food: Food, confidence: float) -> dict: + return { + "name": food.name, + "source": "usda", + "source_id": str(food.fdc_id), + "resolution_mode": "generic_proxy", + "confidence": round(confidence, 2), + } + + class FoodRepository: def __init__( self, @@ -124,8 +148,98 @@ def _row_to_food(self, row: sqlite3.Row) -> Food: barcode=(str(row["barcode"]) if "barcode" in columns and row["barcode"] else None), brand=(str(row["brand"]) if "brand" in columns and row["brand"] else None), alternatives=alternatives, + resolution_mode=( + str(row["resolution_mode"]) + if "resolution_mode" in columns and row["resolution_mode"] + else "legacy" + ), + source_id=( + str(row["source_id"]) + if "source_id" in columns and row["source_id"] + else None + ), + source_note=( + str(row["source_note"]) + if "source_note" in columns and row["source_note"] + else None + ), + provenance=( + str(row["provenance"]) + if "provenance" in columns and row["provenance"] + else str(row["source"]) + ), + assumption=( + str(row["assumption"]) + if "assumption" in columns and row["assumption"] + else None + ), ) + def _apply_generic_proxy_policy( + self, food: Food, confidence: float + ) -> tuple[Food, float]: + if food.resolution_mode != "generic_proxy": + return food, confidence + policy = self.provider_config.generic_proxy_policy() + if policy == "allow_for_unbranded": + return food, confidence + candidate = _generic_proxy_candidate(food, confidence) + if policy == "ask": + raise NomnomError( + "generic_proxy_confirmation_required", + f"Confirm the USDA generic proxy for: {food.name}", + details={ + "candidate": candidate, + "policy": policy, + "action": ( + "Confirm this named USDA proxy by setting the policy to " + "allow_for_unbranded, or provide a package barcode/photo" + ), + }, + ) + raise NomnomError( + "exact_resolution_required", + f"Exact product resolution is required for: {food.name}", + details={"candidate": candidate, "policy": policy, "action": EXACT_CAPTURE_ACTION}, + ) + + def _prepare_usda_generic_proxy( + self, query: str, food: Food, confidence: float + ) -> tuple[Food, float]: + generic_type = (food.provider_data_type or "").casefold() + eligible = ( + food.source == "usda" + and food.fdc_id is not None + and food.brand is None + and generic_type in GENERIC_USDA_DATA_TYPES + and _generic_proxy_query_is_safe(query, food) + ) + if not eligible: + raise NomnomError( + "exact_resolution_required", + f"Exact product resolution is required for: {query}", + details={ + "food": query, + "candidate": { + "name": food.name, + "source": food.source, + "source_id": str(food.fdc_id) if food.fdc_id is not None else None, + "data_type": food.provider_data_type, + "brand": food.brand, + "confidence": round(confidence, 2), + }, + "action": EXACT_CAPTURE_ACTION, + }, + ) + proxy = replace( + food, + resolution_mode="generic_proxy", + source_id=str(food.fdc_id), + provenance="usda", + assumption=f"Brand not specified; used USDA generic proxy: {food.name}.", + ) + return self._apply_generic_proxy_policy(proxy, confidence) + def _find_exact(self, name: str) -> Food | None: cached = self.user_connection.execute( """SELECT * FROM food_cache @@ -175,20 +289,24 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float normalized = normalize_name(query) alias = self._alias_target(normalized) if alias is not None: - return alias, 1.0 + return self._apply_generic_proxy_policy(alias, 1.0) exact = self._find_exact(normalized) if exact: - return exact, 1.0 + return self._apply_generic_proxy_policy(exact, 1.0) canonical = self._canonicalize_query(normalized) exact = self._find_exact(canonical) if exact: - return exact, 0.98 if canonical != normalized else 1.0 + return self._apply_generic_proxy_policy( + exact, 0.98 if canonical != normalized else 1.0 + ) ranked_cache_matches = self._ranked_user_cache_matches(canonical, limit=5) if ranked_cache_matches: - return self._row_to_food(ranked_cache_matches[0]), 0.85 + return self._apply_generic_proxy_policy( + self._row_to_food(ranked_cache_matches[0]), 0.85 + ) matches = self.search(canonical, limit=5) if len(matches) == 1: @@ -257,12 +375,19 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float } for alternative in off_matches[1:] ) - food = replace(off_matches[0], alternatives=alternatives) + food = replace( + off_matches[0], + alternatives=alternatives, + resolution_mode="exact_product", + source_id=off_matches[0].source_id or off_matches[0].barcode, + provenance=off_matches[0].provenance or "openfoodfacts", + ) self._cache_food(food, lookup_query=query) return food, confidence if remote_enabled and credential is not None: food, confidence = self.usda_client.resolve(query, credential.value) + food, confidence = self._prepare_usda_generic_proxy(query, food, confidence) self._cache_food(food, lookup_query=query) return food, confidence if low_confidence_error is not None: @@ -313,6 +438,10 @@ def search(self, query: str, limit: int = 10) -> list[Food]: unique: dict[str, Food] = {} for row in rows: food = self._row_to_food(row) + if food.resolution_mode == "generic_proxy" and not _generic_proxy_query_is_safe( + query, food + ): + continue unique.setdefault(normalize_name(food.name), food) return list(unique.values())[:limit] @@ -395,6 +524,10 @@ def _ranked_user_cache_matches(self, query: str, limit: int) -> list[sqlite3.Row ).fetchall() for row in rows: food = self._row_to_food(row) + if food.resolution_mode == "generic_proxy" and not _generic_proxy_query_is_safe( + query, food + ): + continue candidate_tokens = _name_tokens( " ".join( value @@ -425,8 +558,9 @@ def _cache_food(self, food: Food, *, lookup_query: str) -> None: """INSERT INTO food_cache (name, kcal, protein, fat, carbs, piece_grams, piece_grams_source, piece_grams_source_value, density_g_ml, source, fdc_id, barcode, brand, - lookup_query, alternatives_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + lookup_query, alternatives_json, resolution_mode, source_id, source_note, + provenance, assumption) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET kcal=excluded.kcal, protein=excluded.protein, @@ -441,7 +575,12 @@ def _cache_food(self, food: Food, *, lookup_query: str) -> None: barcode=excluded.barcode, brand=excluded.brand, lookup_query=excluded.lookup_query, - alternatives_json=excluded.alternatives_json""", + alternatives_json=excluded.alternatives_json, + resolution_mode=excluded.resolution_mode, + source_id=excluded.source_id, + source_note=excluded.source_note, + provenance=excluded.provenance, + assumption=excluded.assumption""", ( food.name, food.kcal, @@ -458,9 +597,84 @@ def _cache_food(self, food: Food, *, lookup_query: str) -> None: food.brand, normalize_name(lookup_query), json.dumps(food.alternatives, ensure_ascii=False, sort_keys=True), + food.resolution_mode, + food.source_id, + food.source_note, + food.provenance, + food.assumption, ), ) + def capture_barcode(self, barcode: str) -> Food: + food = self.off_client.product_by_barcode(barcode) + exact = replace( + food, + source="openfoodfacts", + barcode=barcode.strip(), + resolution_mode="exact_product", + source_id=barcode.strip(), + provenance="openfoodfacts", + assumption=None, + ) + self._cache_food(exact, lookup_query=" ".join(filter(None, (exact.name, exact.brand)))) + return exact + + def capture_label( + self, + *, + name: str, + brand: str | None, + kcal: float, + protein: float, + fat: float, + carbs: float, + serving_grams: float | None, + source_note: str | None, + ) -> Food: + clean_name = " ".join(name.strip().split()) + clean_brand = " ".join((brand or "").strip().split()) or None + note = " ".join((source_note or "").strip().split()) + if not clean_name: + raise NomnomError("invalid_product", "Package label name must not be empty") + if not note or any(ord(character) < 32 for character in note): + raise NomnomError( + "invalid_source_note", + "--source-note is required and must contain a nonempty image/barcode reference", + ) + nutrients = (kcal, protein, fat, carbs) + if any(not math.isfinite(value) or value < 0 for value in nutrients): + raise NomnomError( + "invalid_nutrition", "Nutrition values must be finite and non-negative" + ) + if serving_grams is not None and ( + not math.isfinite(serving_grams) or serving_grams <= 0 + ): + raise NomnomError( + "invalid_serving_grams", + "Serving grams must be finite and greater than zero", + ) + canonical_name = f"{clean_name} — {clean_brand}" if clean_brand else clean_name + food = Food( + name=canonical_name, + kcal=kcal, + protein=protein, + fat=fat, + carbs=carbs, + piece_grams=serving_grams, + piece_grams_source=("--serving-grams" if serving_grams is not None else None), + piece_grams_source_value=( + f"{serving_grams:g} g" if serving_grams is not None else None + ), + source="package_label", + brand=clean_brand, + resolution_mode="exact_product", + source_id=note, + source_note=note, + provenance="package_label", + ) + self._cache_food(food, lookup_query=" ".join(filter(None, (clean_name, clean_brand)))) + return food + def add_food( self, *, @@ -483,6 +697,8 @@ def add_food( piece_grams_source_value=(f"{piece_grams:g} g" if piece_grams is not None else None), source="user", brand=brand.strip(), + resolution_mode="exact_product", + provenance="legacy_manual", ) self._cache_food(food, lookup_query=f"{name} {brand}") return food diff --git a/nomnomcli/models.py b/nomnomcli/models.py index 8775918..8201870 100644 --- a/nomnomcli/models.py +++ b/nomnomcli/models.py @@ -20,6 +20,12 @@ class Food: brand: str | None = None categories: tuple[str, ...] = () alternatives: tuple[dict[str, str], ...] = () + resolution_mode: str = "legacy" + source_id: str | None = None + source_note: str | None = None + provenance: str | None = None + assumption: str | None = None + provider_data_type: str | None = None @dataclass(frozen=True, slots=True) @@ -38,6 +44,10 @@ class ResolvedItem: barcode: str | None = None brand: str | None = None alternatives: tuple[dict[str, str], ...] | None = None + resolution_mode: str | None = None + source_id: str | None = None + source_note: str | None = None + provenance: str | None = None def to_dict(self) -> dict[str, str | float | bool]: return {key: value for key, value in asdict(self).items() if value is not None} @@ -59,6 +69,7 @@ def scale_food( assumption: str | None = None, ) -> ResolvedItem: factor = grams / 100.0 + assumptions = [value for value in (food.assumption, assumption) if value] return ResolvedItem( name=food.name, grams=round_nutrition(grams), @@ -67,13 +78,17 @@ def scale_food( fat=round_nutrition(food.fat * factor), carbs=round_nutrition(food.carbs * factor), match_confidence=round(confidence, 2), - assumed=assumed, - assumption=assumption, + assumed=assumed if assumed is not None else (True if food.assumption else None), + assumption="; ".join(assumptions) if assumptions else None, source=food.source if food.source != "unknown" else None, fdc_id=food.fdc_id, barcode=food.barcode, brand=food.brand, alternatives=food.alternatives or None, + resolution_mode=food.resolution_mode, + source_id=food.source_id, + source_note=food.source_note, + provenance=food.provenance, ) diff --git a/nomnomcli/off.py b/nomnomcli/off.py index 7276e9a..d9a12ff 100644 --- a/nomnomcli/off.py +++ b/nomnomcli/off.py @@ -14,6 +14,7 @@ OFF_SEARCH_URL = "https://world.openfoodfacts.org/cgi/search.pl" OFF_PRODUCT_PROBE_URL = "https://api.openfoodfacts.org/api/v2/product/0" +OFF_PRODUCT_URL = "https://api.openfoodfacts.org/api/v2/product/{barcode}" OFF_FIELDS = "product_name,brands,nutriments,code,serving_size,categories,categories_tags" @@ -79,6 +80,9 @@ def _normalize_product(product: dict) -> Food | None: barcode=barcode, brand=brand, categories=tuple(value for value in categories if value), + resolution_mode="exact_product", + source_id=barcode, + provenance="openfoodfacts", ) @@ -181,6 +185,79 @@ def probe_product(self) -> bool: ) return True + def product_by_barcode(self, barcode: str) -> Food: + code = barcode.strip() + if not code.isdigit() or len(code) not in {8, 12, 13, 14}: + raise NomnomError( + "invalid_barcode", + "Barcode must contain 8, 12, 13, or 14 digits", + details={"barcode": code}, + ) + details = { + "barcode": code, + "action": "Check the barcode or send a package photo for exact label capture", + } + response = request_with_retry( + provider="openfoodfacts", + code="openfoodfacts_unavailable", + message="Open Food Facts barcode lookup is unavailable", + request_get=self._request_get or requests.get, + url=OFF_PRODUCT_URL.format(barcode=code), + request_kwargs={ + "params": {"fields": OFF_FIELDS}, + "timeout": 10, + "headers": self._headers(), + }, + details=details, + retry_policy=self.retry_policy, + sleep=self.sleep, + ) + try: + response.raise_for_status() + except requests.RequestException as exc: + raise ProviderUnavailableError( + "openfoodfacts", + "openfoodfacts_unavailable", + "Open Food Facts barcode lookup is unavailable", + retryable=False, + details={**details, "status": getattr(response, "status_code", None)}, + ) from exc + try: + payload = response.json() + except ValueError as exc: + raise NomnomError( + "openfoodfacts_invalid_response", + "Open Food Facts returned malformed JSON", + details=details, + ) from exc + if not isinstance(payload, dict): + raise NomnomError( + "openfoodfacts_invalid_response", + "Open Food Facts returned an invalid product payload", + details=details, + ) + product = payload.get("product") + if payload.get("status") != 1 or not isinstance(product, dict): + raise NomnomError( + "barcode_not_found", + f"Open Food Facts has no product for barcode: {code}", + details=details, + ) + food = _normalize_product(product) + if food is None: + raise NomnomError( + "barcode_nutrition_incomplete", + "Barcode product lacks complete positive core nutrition per 100 g", + details=details, + ) + if food.barcode != code: + raise NomnomError( + "barcode_product_mismatch", + "Open Food Facts product code does not match the requested barcode", + details={**details, "returned_barcode": food.barcode}, + ) + return food + def search(self, query: str, page_size: int = 5) -> list[Food]: payload = self._get_payload(query, page_size) return [ diff --git a/nomnomcli/usda.py b/nomnomcli/usda.py index 29a822a..d79d9f7 100644 --- a/nomnomcli/usda.py +++ b/nomnomcli/usda.py @@ -151,6 +151,9 @@ def _normalize_record(record: dict) -> tuple[Food | None, list[str]]: piece_grams_source_value=source_value, source="usda", fdc_id=int(record["fdcId"]) if record.get("fdcId") is not None else None, + source_id=(str(record["fdcId"]) if record.get("fdcId") is not None else None), + provenance="usda", + provider_data_type=str(record.get("dataType") or "").strip() or None, brand=str(record.get("brandOwner") or "").strip() or None, categories=(category,) if category else (), ) diff --git a/pyproject.toml b/pyproject.toml index 1483645..d6b34d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "nomnomcli" -version = "0.3.0" +version = "0.4.0" description = "Agent-first, deterministic calorie and nutrition tracking CLI" readme = "README.md" requires-python = ">=3.11" diff --git a/skill/SKILL.md b/skill/SKILL.md index 45798f1..d2959de 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -58,6 +58,33 @@ nomnom log --food "chickpeas, cooked" --grams 150 --json 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. + +```sh +nomnom capture barcode "BARCODE" --json +``` + +Barcode capture uses the OFF v2 product endpoint only. If it returns `invalid_barcode`, +`barcode_not_found`, or `barcode_nutrition_incomplete`, request a package photo. Read the product +name, brand, per-100 g kcal/protein/fat/carbs, and optional serving grams from the supplied image, +then run: + +```sh +nomnom capture label --name NAME --brand BRAND \ + --kcal KCAL --protein PROTEIN --fat FAT --carbs CARBS \ + --serving-grams GRAMS --source-note "image:LOCAL_REFERENCE" --json +``` + +Omit `--brand` or `--serving-grams` only when the label does not provide them. `--source-note` is +always required and must be a nonempty local/opaque reference to the supplied image or barcode. +Vision/OCR remains agent-side: never pass the photo to nomnom, and never store image content or +invent a missing value. A successful capture returns `resolution_mode=exact_product`, source id, +source note, and provenance. Use its exact returned `name` for an alias or later log. + ## User aliases When the user wants a durable phrase for an exact food already in their local @@ -71,7 +98,7 @@ Aliases are user-database records, never packaged translations. They resolve only to exact local cache names and must not invent, approximate, or remotely substitute a target. -## Unknown-food workflow: OFF → USDA → add → error +## Unknown-food workflow: OFF → safe USDA proxy → capture → error The CLI automatically checks exact user alias, exact cache, cache search, then Open Food Facts. @@ -79,18 +106,16 @@ For an unresolved food: 1. Let OFF run. For `off_low_confidence`, show its `candidate` and `alternatives`; do not accept one without the user's explicit choice. -2. Let USDA run only when setup or `NOMNOM_USDA_KEY` has configured it. For +2. Let USDA run only when setup or `NOMNOM_USDA_KEY` has configured it. The default + `allow_for_unbranded` policy accepts only unbranded generic records with an FDC id, complete + validated nutrition, sufficient confidence, and full query-token coverage. Always show returned + `assumptions`. Never treat a branded or SKU-like query as a generic proxy. For `usda_key_required`, offer `nomnom setup` and the free-key URL returned in `details`. Use the environment only for non-interactive/CI operation. -3. If the user has verified label values, manually pin them: - - ```sh - nomnom add --name NAME --brand BRAND --kcal KCAL \ - --protein PROTEIN --fat FAT --carbs CARBS --piece-grams GRAMS --json - ``` - - Omit `--piece-grams` when the label does not provide a serving weight. -4. If OFF, USDA, and a verified manual pin cannot resolve the food, report the +3. On `generic_proxy_confirmation_required`, show the candidate and ask; do not change policy or + write anything without the user's choice. On `exact_resolution_required`, ask for the package + barcode or photo and use the exact capture flow above. +4. If OFF, USDA, and source-backed capture cannot resolve the food, report the structured error. Never substitute a similar food or invent values. Other error handling: diff --git a/tests/test_capture.py b/tests/test_capture.py new file mode 100644 index 0000000..32bae2a --- /dev/null +++ b/tests/test_capture.py @@ -0,0 +1,353 @@ +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +from nomnomcli.cli import main +from nomnomcli.db import connect +from nomnomcli.models import Food +from nomnomcli.off import OpenFoodFactsClient +from nomnomcli.usda import USDAClient + + +def test_cli_logs_default_safe_usda_proxy_with_visible_provenance( + user_db, monkeypatch, capsys +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + monkeypatch.setenv("NOMNOM_USDA_KEY", "test-key") + monkeypatch.setenv("NOMNOM_DISABLE_OFF", "1") + monkeypatch.delenv("NOMNOM_GENERIC_PROXY_POLICY", raising=False) + monkeypatch.setattr( + USDAClient, + "resolve", + lambda self, query, api_key: ( + Food( + "chicken breast, roasted", + 165, + 31, + 3.6, + 1, + source="usda", + fdc_id=171477, + source_id="171477", + provenance="usda", + provider_data_type="Foundation", + ), + 0.95, + ), + ) + + assert ( + main( + [ + "log", + "--food", + "chicken breast roasted", + "--grams", + "100", + "--json", + ] + ) + == 0 + ) + result = json.loads(capsys.readouterr().out) + + assert result["assumptions"] == [ + "Brand not specified; used USDA generic proxy: chicken breast, roasted." + ] + assert result["items"][0]["resolution_mode"] == "generic_proxy" + assert result["items"][0]["source"] == "usda" + assert result["items"][0]["source_id"] == "171477" + assert result["items"][0]["provenance"] == "usda" + assert result["items"][0]["fdc_id"] == 171477 + with connect(user_db) as connection: + assert connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 1 + assert connection.execute("SELECT count(*) FROM log_entries").fetchone()[0] == 1 + + +def test_cli_capture_barcode_caches_exact_product_provenance( + user_db, monkeypatch, capsys +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + monkeypatch.setattr( + OpenFoodFactsClient, + "product_by_barcode", + lambda self, code: Food( + "Fixture Bar — Acme", + 250, + 9, + 4, + 45, + source="openfoodfacts", + barcode=code, + brand="Acme", + ), + ) + + assert main(["capture", "barcode", "0123456789012", "--json"]) == 0 + result = json.loads(capsys.readouterr().out) + + assert result["name"] == "Fixture Bar — Acme" + assert result["source"] == "openfoodfacts" + assert result["source_id"] == "0123456789012" + assert result["barcode"] == "0123456789012" + assert result["resolution_mode"] == "exact_product" + assert result["provenance"] == "openfoodfacts" + with connect(user_db) as connection: + row = connection.execute( + """SELECT source, source_id, barcode, resolution_mode, provenance + FROM food_cache""" + ).fetchone() + assert tuple(row) == ( + "openfoodfacts", + "0123456789012", + "0123456789012", + "exact_product", + "openfoodfacts", + ) + + +def test_cli_capture_barcode_incomplete_product_is_never_cached( + user_db, monkeypatch, capsys +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + + def incomplete(self, code): + from nomnomcli.errors import NomnomError + + raise NomnomError( + "barcode_nutrition_incomplete", + "Barcode product lacks complete core nutrition", + details={"barcode": code}, + ) + + monkeypatch.setattr(OpenFoodFactsClient, "product_by_barcode", incomplete) + + assert main(["capture", "barcode", "0123456789012", "--json"]) == 2 + error = json.loads(capsys.readouterr().err) + assert error["error"]["code"] == "barcode_nutrition_incomplete" + with connect(user_db) as connection: + assert connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + +def test_cli_capture_label_requires_source_note_and_writes_nothing( + user_db, monkeypatch, capsys +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + argv = [ + "capture", + "label", + "--name", + "Chicken pastrami", + "--brand", + "Acme", + "--kcal", + "110", + "--protein", + "20", + "--fat", + "2", + "--carbs", + "3", + "--json", + ] + + assert main(argv) == 2 + error = json.loads(capsys.readouterr().err) + assert error["error"]["code"] == "invalid_source_note" + with connect(user_db) as connection: + assert connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + +def test_cli_capture_label_persists_agent_extracted_facts_alias_and_log( + user_db, monkeypatch, capsys +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + assert ( + main( + [ + "capture", + "label", + "--name", + "Chicken pastrami", + "--brand", + "Acme", + "--kcal", + "110", + "--protein", + "20", + "--fat", + "2", + "--carbs", + "3", + "--serving-grams", + "75", + "--source-note", + "image:sha256:synthetic-fixture", + "--json", + ] + ) + == 0 + ) + captured = json.loads(capsys.readouterr().out) + assert captured == { + "barcode": None, + "brand": "Acme", + "carbs_per_100g": 3.0, + "fat_per_100g": 2.0, + "kcal_per_100g": 110.0, + "name": "Chicken pastrami — Acme", + "protein_per_100g": 20.0, + "provenance": "package_label", + "resolution_mode": "exact_product", + "serving_grams": 75.0, + "source": "package_label", + "source_id": "image:sha256:synthetic-fixture", + "source_note": "image:sha256:synthetic-fixture", + } + + assert ( + main( + [ + "alias", + "add", + "куриная пастрома", + captured["name"], + "--json", + ] + ) + == 0 + ) + capsys.readouterr() + monkeypatch.setenv("NOMNOM_OFFLINE", "1") + assert main(["log", "--parse", "куриная пастрома 150г", "--json"]) == 0 + logged = json.loads(capsys.readouterr().out) + assert logged["items"][0] == { + "brand": "Acme", + "carbs": 4.5, + "fat": 3.0, + "grams": 150.0, + "kcal": 165.0, + "match_confidence": 1.0, + "name": "Chicken pastrami — Acme", + "protein": 30.0, + "provenance": "package_label", + "resolution_mode": "exact_product", + "source": "package_label", + "source_id": "image:sha256:synthetic-fixture", + "source_note": "image:sha256:synthetic-fixture", + } + with connect(user_db) as connection: + assert connection.execute("SELECT count(*) FROM food_aliases").fetchone()[0] == 1 + assert connection.execute("SELECT count(*) FROM log_entries").fetchone()[0] == 1 + + +def test_cli_capture_label_rejects_invalid_values_without_cache( + user_db, monkeypatch, capsys +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) + assert ( + main( + [ + "capture", + "label", + "--name", + "Fixture", + "--kcal", + "-1", + "--protein", + "1", + "--fat", + "1", + "--carbs", + "1", + "--source-note", + "image:fixture", + "--json", + ] + ) + == 2 + ) + assert json.loads(capsys.readouterr().err)["error"]["code"] == "invalid_nutrition" + with connect(user_db) as connection: + assert connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + +def _run_cli(repo: Path, database: Path, *args: str) -> subprocess.CompletedProcess[str]: + environment = { + **os.environ, + "NOMNOM_DB_PATH": str(database), + "NOMNOM_OFFLINE": "1", + "PYTHONPATH": str(repo), + } + return subprocess.run( + [sys.executable, "-m", "nomnomcli", *args], + cwd=repo, + env=environment, + text=True, + capture_output=True, + check=False, + ) + + +def test_fresh_database_cli_capture_alias_log_and_bad_input_smoke(tmp_path): + repo = Path(__file__).parents[1] + database = tmp_path / "fresh-smoke.sqlite3" + + help_result = _run_cli(repo, database, "--help") + version_result = _run_cli(repo, database, "--version") + capture = _run_cli( + repo, + database, + "capture", + "label", + "--name", + "Synthetic bar", + "--brand", + "Fixture", + "--kcal", + "200", + "--protein", + "10", + "--fat", + "5", + "--carbs", + "30", + "--source-note", + "image:synthetic-smoke", + "--json", + ) + canonical = json.loads(capture.stdout)["name"] + alias = _run_cli(repo, database, "alias", "add", "my bar", canonical, "--json") + logged = _run_cli(repo, database, "log", "--parse", "my bar 50g", "--json") + invalid = _run_cli( + repo, + database, + "capture", + "label", + "--name", + "Bad", + "--kcal", + "-1", + "--protein", + "1", + "--fat", + "1", + "--carbs", + "1", + "--source-note", + "image:bad", + "--json", + ) + + assert help_result.returncode == 0 + assert "capture" in help_result.stdout + assert version_result.stdout.strip() == "nomnom 0.4.0" + assert capture.returncode == 0 + assert alias.returncode == 0 + assert json.loads(logged.stdout)["items"][0]["resolution_mode"] == "exact_product" + assert invalid.returncode == 2 + assert json.loads(invalid.stderr)["error"]["code"] == "invalid_nutrition" diff --git a/tests/test_config.py b/tests/test_config.py index 1928a42..7d33211 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -27,6 +27,51 @@ def probe(self, api_key): return True +def test_generic_proxy_policy_defaults_to_allow_for_unbranded(tmp_path): + config = ProviderConfig(environ={}, config_path=tmp_path / "missing.toml") + + assert config.generic_proxy_policy() == "allow_for_unbranded" + + +def test_generic_proxy_policy_environment_overrides_user_config(tmp_path): + path = tmp_path / "config.toml" + path.write_text( + '[resolution]\ngeneric_proxy_policy = "ask"\n', encoding="utf-8" + ) + config = ProviderConfig( + environ={"NOMNOM_GENERIC_PROXY_POLICY": "exact_only"}, config_path=path + ) + + assert config.generic_proxy_policy() == "exact_only" + + +@pytest.mark.parametrize("policy", ["allow_for_unbranded", "ask", "exact_only"]) +def test_generic_proxy_policy_accepts_every_documented_config_value(tmp_path, policy): + path = tmp_path / "config.toml" + path.write_text( + f'[resolution]\ngeneric_proxy_policy = "{policy}"\n', encoding="utf-8" + ) + + assert ProviderConfig(environ={}, config_path=path).generic_proxy_policy() == policy + + +def test_invalid_generic_proxy_policy_is_structured(tmp_path): + config = ProviderConfig( + environ={"NOMNOM_GENERIC_PROXY_POLICY": "sometimes"}, + config_path=tmp_path / "config.toml", + ) + + with pytest.raises(NomnomError) as caught: + config.generic_proxy_policy() + + assert caught.value.code == "generic_proxy_policy_invalid" + assert caught.value.details["allowed"] == [ + "allow_for_unbranded", + "ask", + "exact_only", + ] + + def test_environment_usda_key_overrides_user_config(tmp_path): path = tmp_path / "config.toml" stored = ProviderConfig(environ={}, config_path=path) @@ -51,6 +96,19 @@ def test_stored_config_is_xdg_and_owner_only(tmp_path): assert config.usda_credential().source == "user_config" +def test_storing_usda_key_preserves_generic_proxy_policy(tmp_path): + path = tmp_path / "config.toml" + path.write_text( + '[resolution]\ngeneric_proxy_policy = "exact_only"\n', encoding="utf-8" + ) + config = ProviderConfig(environ={}, config_path=path) + + config.store_usda_key("stored-placeholder") + + assert config.usda_credential().value == "stored-placeholder" + assert config.generic_proxy_policy() == "exact_only" + + def test_invalid_config_is_actionable(tmp_path): path = tmp_path / "config.toml" path.write_text("[providers.usda\napi_key = broken", encoding="utf-8") diff --git a/tests/test_data_quality.py b/tests/test_data_quality.py index 59b6430..342489d 100644 --- a/tests/test_data_quality.py +++ b/tests/test_data_quality.py @@ -46,10 +46,10 @@ def test_food_fixture_corpus_is_limited_to_ten_records(): assert record_count <= 10 -def test_package_version_is_030(): +def test_package_version_is_040(): metadata = (ROOT / "pyproject.toml").read_text(encoding="utf-8") - assert __version__ == "0.3.0" - assert 'version = "0.3.0"' in metadata + assert __version__ == "0.4.0" + assert 'version = "0.4.0"' in metadata def test_readme_documents_language_agnostic_agent_contract(): @@ -58,3 +58,17 @@ def test_readme_documents_language_agnostic_agent_contract(): assert "food name + quantity + unit + optional modifiers" in readme assert "### Adding a new language" in readme assert "parser code changes are not required for ordinary unit aliases" in readme + + +def test_v04_docs_define_safe_proxy_and_private_exact_capture_flow(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + skill = (ROOT / "skill" / "SKILL.md").read_text(encoding="utf-8") + + assert "The default generic policy is `allow_for_unbranded`" in readme + assert "nomnom capture barcode" in readme + assert "nomnom capture label" in readme + assert "`--source-note` is required" in readme + assert "never receives or stores the photo" in readme + assert "request a clear package photo" in skill + assert "Vision/OCR remains agent-side" in skill + assert len(skill.splitlines()) <= 200 diff --git a/tests/test_db.py b/tests/test_db.py index ec13547..c124eac 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -80,6 +80,72 @@ def v2_database(tmp_path): return database +@pytest.fixture +def v3_database(tmp_path): + database = tmp_path / "v3.sqlite3" + with sqlite3.connect(database) as legacy: + legacy.executescript( + """ + PRAGMA user_version = 3; + CREATE TABLE food_cache ( + name TEXT PRIMARY KEY COLLATE NOCASE, + kcal REAL NOT NULL, + protein REAL NOT NULL, + fat REAL NOT NULL, + carbs REAL NOT NULL, + piece_grams REAL, + density_g_ml REAL, + source TEXT NOT NULL, + fdc_id INTEGER, + barcode TEXT, + brand TEXT, + lookup_query TEXT, + alternatives_json TEXT + ); + CREATE TABLE log_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + logged_at TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'food', + label TEXT, + items_json TEXT NOT NULL, + kcal REAL NOT NULL, + protein REAL NOT NULL, + fat REAL NOT NULL, + carbs REAL NOT NULL + ); + CREATE TABLE recipes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + source_url TEXT NOT NULL, + servings REAL NOT NULL, + ingredients_json TEXT NOT NULL, + kcal_per_serving REAL NOT NULL, + protein_per_serving REAL NOT NULL, + fat_per_serving REAL NOT NULL, + carbs_per_serving REAL NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE food_aliases ( + phrase TEXT NOT NULL, + normalized_phrase TEXT PRIMARY KEY, + canonical_name TEXT NOT NULL COLLATE NOCASE + ); + INSERT INTO food_cache VALUES + ('v3 oats', 71, 2.54, 1.52, 12, NULL, NULL, 'usda', 173904, + NULL, NULL, 'v3 oats', '[]'); + INSERT INTO log_entries VALUES + (3, '2026-07-20T12:00:00+00:00', 'food', 'v3 meal', + '[{"name":"v3 oats"}]', 71, 2.54, 1.52, 12); + INSERT INTO recipes VALUES + (2, 'V3 oats', 'https://example.test/v3-oats', 1, + '[{"name":"v3 oats"}]', 71, 2.54, 1.52, 12, + '2026-07-20T11:00:00+00:00'); + INSERT INTO food_aliases VALUES ('my oats', 'my oats', 'v3 oats'); + """ + ) + return database + + def test_connect_migrates_v1_database_without_losing_data(tmp_path): database = tmp_path / "legacy.sqlite3" with sqlite3.connect(database) as legacy: @@ -138,7 +204,13 @@ def test_connect_migrates_v1_database_without_losing_data(tmp_path): } assert "barcode" in columns assert {"brand", "lookup_query", "alternatives_json"} <= columns - assert tuple(connection.execute("SELECT * FROM food_cache").fetchone()) == ( + row = connection.execute( + """SELECT name, kcal, protein, fat, carbs, piece_grams, density_g_ml, + source, fdc_id, barcode, brand, lookup_query, alternatives_json, + piece_grams_source, piece_grams_source_value, resolution_mode, source_id, + source_note, provenance, assumption FROM food_cache""" + ).fetchone() + assert tuple(row) == ( "legacy oats", 71.0, 2.54, @@ -154,6 +226,11 @@ def test_connect_migrates_v1_database_without_losing_data(tmp_path): None, None, None, + "legacy", + "173904", + None, + "legacy cache", + None, ) assert tuple(connection.execute("SELECT * FROM log_entries").fetchone()) == ( 7, @@ -196,12 +273,54 @@ def test_connect_creates_fresh_database_at_latest_schema(tmp_path): "SELECT name FROM sqlite_master WHERE type = 'table'" ).fetchall() } + columns = { + row[1] for row in connection.execute("PRAGMA table_info(food_cache)") + } + assert { + "resolution_mode", + "source_id", + "source_note", + "provenance", + "assumption", + } <= columns + + +def test_connect_migrates_v3_to_v4_preserving_all_user_records(v3_database): + with connect(v3_database) as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 4 + row = connection.execute( + """SELECT name, source, fdc_id, resolution_mode, source_id, + source_note, provenance, assumption FROM food_cache""" + ).fetchone() + assert tuple(row) == ( + "v3 oats", + "usda", + 173904, + "legacy", + "173904", + None, + "usda", + None, + ) + assert connection.execute("SELECT count(*) FROM log_entries").fetchone()[0] == 1 + assert connection.execute("SELECT count(*) FROM recipes").fetchone()[0] == 1 + assert tuple(connection.execute("SELECT * FROM food_aliases").fetchone()) == ( + "my oats", + "my oats", + "v3 oats", + ) def test_connect_migrates_v2_to_latest_without_losing_user_data(v2_database): with connect(v2_database) as connection: assert connection.execute("PRAGMA user_version").fetchone()[0] == LATEST_SCHEMA_VERSION - assert tuple(connection.execute("SELECT * FROM food_cache").fetchone()) == ( + row = connection.execute( + """SELECT name, kcal, protein, fat, carbs, piece_grams, density_g_ml, + source, fdc_id, barcode, brand, lookup_query, alternatives_json, + piece_grams_source, piece_grams_source_value, resolution_mode, source_id, + source_note, provenance, assumption FROM food_cache""" + ).fetchone() + assert tuple(row) == ( "v2 egg", 155.0, 12.58, @@ -217,6 +336,11 @@ def test_connect_migrates_v2_to_latest_without_losing_user_data(v2_database): "[]", None, None, + "legacy", + None, + None, + "user", + None, ) assert tuple(connection.execute("SELECT * FROM log_entries").fetchone()) == ( 9, diff --git a/tests/test_foods.py b/tests/test_foods.py index 44bf6aa..43f1110 100644 --- a/tests/test_foods.py +++ b/tests/test_foods.py @@ -3,12 +3,185 @@ import pytest import requests +from nomnomcli.config import ProviderConfig from nomnomcli.db import connect from nomnomcli.errors import NomnomError from nomnomcli.foods import FoodRepository from nomnomcli.models import Food +def _usda_generic_response(description="Chicken breast, roasted", *, branded=False): + class Response: + status_code = 200 + headers = {} + + def raise_for_status(self): + return None + + def json(self): + return { + "foods": [ + { + "fdcId": 171477, + "description": description, + "dataType": "Branded" if branded else "Foundation", + "brandOwner": "Acme" if branded else None, + "foodCategory": "Poultry Products", + "foodNutrients": [ + { + "nutrientId": 1008, + "nutrientName": "Energy", + "unitName": "KCAL", + "value": 165, + }, + { + "nutrientId": 1003, + "nutrientName": "Protein", + "unitName": "G", + "value": 31, + }, + { + "nutrientId": 1004, + "nutrientName": "Total lipid (fat)", + "unitName": "G", + "value": 3.6, + }, + { + "nutrientId": 1005, + "nutrientName": "Carbohydrate, by difference", + "unitName": "G", + "value": 1, + }, + ], + } + ] + } + + return Response() + + +def test_default_unbranded_usda_fallback_is_explicit_generic_proxy( + repository, monkeypatch +): + monkeypatch.setenv("NOMNOM_USDA_KEY", "test-key") + monkeypatch.setenv("NOMNOM_DISABLE_OFF", "1") + monkeypatch.delenv("NOMNOM_GENERIC_PROXY_POLICY", raising=False) + monkeypatch.setattr(requests, "get", lambda *args, **kwargs: _usda_generic_response()) + + food, confidence = repository.resolve("chicken breast roasted") + + assert confidence >= 0.8 + assert food.name == "chicken breast, roasted" + assert food.source == "usda" + assert food.source_id == "171477" + assert food.resolution_mode == "generic_proxy" + assert food.assumption == ( + "Brand not specified; used USDA generic proxy: chicken breast, roasted." + ) + row = repository.user_connection.execute( + """SELECT resolution_mode, source_id, provenance, assumption + FROM food_cache WHERE name = ?""", + (food.name,), + ).fetchone() + assert tuple(row) == ( + "generic_proxy", + "171477", + "usda", + "Brand not specified; used USDA generic proxy: chicken breast, roasted.", + ) + + +@pytest.mark.parametrize( + ("policy", "error_code"), + [ + ("ask", "generic_proxy_confirmation_required"), + ("exact_only", "exact_resolution_required"), + ], +) +def test_generic_proxy_policy_returns_structured_candidate_without_writes( + user_db, monkeypatch, tmp_path, policy, error_code +): + config_path = tmp_path / f"{policy}.toml" + config_path.write_text( + f'[resolution]\ngeneric_proxy_policy = "{policy}"\n', encoding="utf-8" + ) + monkeypatch.setenv("NOMNOM_USDA_KEY", "test-key") + monkeypatch.setenv("NOMNOM_DISABLE_OFF", "1") + monkeypatch.setattr(requests, "get", lambda *args, **kwargs: _usda_generic_response()) + + with connect(user_db) as connection: + repository = FoodRepository( + connection, + provider_config=ProviderConfig( + environ={"NOMNOM_USDA_KEY": "test-key"}, config_path=config_path + ), + ) + with pytest.raises(NomnomError) as caught: + repository.resolve("chicken breast roasted") + + assert caught.value.code == error_code + assert caught.value.details["candidate"] == { + "name": "chicken breast, roasted", + "source": "usda", + "source_id": "171477", + "resolution_mode": "generic_proxy", + "confidence": pytest.approx(caught.value.details["candidate"]["confidence"]), + } + assert caught.value.details["candidate"]["confidence"] >= 0.8 + assert connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + +@pytest.mark.parametrize( + "query", ["Acme chicken breast roasted", "chicken breast roasted 12345"] +) +def test_brand_or_sku_token_denies_usda_generic_fallback_even_when_policy_allows( + repository, monkeypatch, query +): + monkeypatch.setenv("NOMNOM_USDA_KEY", "test-key") + monkeypatch.setenv("NOMNOM_DISABLE_OFF", "1") + monkeypatch.setenv("NOMNOM_GENERIC_PROXY_POLICY", "allow_for_unbranded") + monkeypatch.setattr(requests, "get", lambda *args, **kwargs: _usda_generic_response()) + + with pytest.raises(NomnomError) as caught: + repository.resolve(query) + + assert caught.value.code == "exact_resolution_required" + assert "barcode" in caught.value.details["action"].casefold() + assert "photo" in caught.value.details["action"].casefold() + assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + +def test_usda_branded_record_is_never_a_generic_proxy(repository, monkeypatch): + monkeypatch.setenv("NOMNOM_USDA_KEY", "test-key") + monkeypatch.setenv("NOMNOM_DISABLE_OFF", "1") + monkeypatch.setattr( + requests, + "get", + lambda *args, **kwargs: _usda_generic_response(branded=True), + ) + + with pytest.raises(NomnomError) as caught: + repository.resolve("chicken breast roasted") + + assert caught.value.code == "exact_resolution_required" + assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + +def test_cached_generic_proxy_is_not_reused_for_later_branded_query( + repository, monkeypatch +): + monkeypatch.setenv("NOMNOM_USDA_KEY", "test-key") + monkeypatch.setenv("NOMNOM_DISABLE_OFF", "1") + monkeypatch.setattr(requests, "get", lambda *args, **kwargs: _usda_generic_response()) + repository.resolve("chicken breast roasted") + + with pytest.raises(NomnomError) as caught: + repository.resolve("Acme chicken breast roasted") + + assert caught.value.code == "exact_resolution_required" + assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 1 + + def test_repository_does_not_read_bundled_food_resources(user_db, monkeypatch): def fail(*args, **kwargs): pytest.fail("runtime repository must not read package food resources") @@ -313,6 +486,9 @@ def test_named_brand_never_substitutes_generic_food(repository, monkeypatch): source="openfoodfacts", barcode="42", brand="Acme", + resolution_mode="exact_product", + source_id="42", + provenance="openfoodfacts", ) monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [branded]) food, _ = repository.resolve("bread Acme") diff --git a/tests/test_off.py b/tests/test_off.py index 0669098..023ca18 100644 --- a/tests/test_off.py +++ b/tests/test_off.py @@ -4,7 +4,11 @@ import requests from nomnomcli.errors import NomnomError, ProviderUnavailableError -from nomnomcli.off import OFF_PRODUCT_PROBE_URL, OFF_SEARCH_URL, OpenFoodFactsClient +from nomnomcli.off import ( + OFF_PRODUCT_PROBE_URL, + OFF_SEARCH_URL, + OpenFoodFactsClient, +) from nomnomcli.providers import RetryPolicy @@ -65,7 +69,7 @@ def fake_get(url, **kwargs): "page_size": 3, }, "timeout": 10, - "headers": {"User-Agent": "nomnomcli/0.3.0 (+https://github.com/maxjustships/nomnomcli)"}, + "headers": {"User-Agent": "nomnomcli/0.4.0 (+https://github.com/maxjustships/nomnomcli)"}, } assert foods[0].name == "Whole Grain Bread — Acme" assert foods[0].source == "openfoodfacts" @@ -132,7 +136,7 @@ def get(url, **kwargs): "timeout": 10, "headers": { "User-Agent": ( - "nomnomcli/0.3.0 (+https://github.com/maxjustships/nomnomcli)" + "nomnomcli/0.4.0 (+https://github.com/maxjustships/nomnomcli)" ) }, }, @@ -141,6 +145,66 @@ def get(url, **kwargs): assert "search_terms" not in calls[0][1]["params"] +def test_off_barcode_capture_uses_exact_v2_product_endpoint_only(): + calls = [] + + def get(url, **kwargs): + calls.append((url, kwargs)) + return Response({"status": 1, "product": product(code="0123456789012")}) + + food = OpenFoodFactsClient(request_get=get).product_by_barcode("0123456789012") + + assert calls == [ + ( + "https://api.openfoodfacts.org/api/v2/product/0123456789012", + { + "params": {"fields": ( + "product_name,brands,nutriments,code,serving_size," + "categories,categories_tags" + )}, + "timeout": 10, + "headers": { + "User-Agent": ( + "nomnomcli/0.4.0 (+https://github.com/maxjustships/nomnomcli)" + ) + }, + }, + ) + ] + assert food.barcode == "0123456789012" + assert food.source == "openfoodfacts" + assert food.source_id == "0123456789012" + assert food.resolution_mode == "exact_product" + assert "search_terms" not in calls[0][1]["params"] + + +@pytest.mark.parametrize("barcode", ["", "abc", "1234567", "123456789012345"]) +def test_off_barcode_capture_rejects_invalid_code_without_request(barcode): + client = OpenFoodFactsClient( + request_get=lambda *args, **kwargs: pytest.fail("invalid barcode must not request") + ) + + with pytest.raises(NomnomError) as caught: + client.product_by_barcode(barcode) + + assert caught.value.code == "invalid_barcode" + + +def test_off_barcode_capture_rejects_incomplete_core_nutrition(): + incomplete = product() + del incomplete["nutriments"]["proteins_100g"] + client = OpenFoodFactsClient( + request_get=lambda *args, **kwargs: Response( + {"status": 1, "product": incomplete} + ) + ) + + with pytest.raises(NomnomError) as caught: + client.product_by_barcode("0123456789012") + + assert caught.value.code == "barcode_nutrition_incomplete" + + def test_off_rejects_candidate_with_nonpositive_core_nutrient(monkeypatch): candidate = product() candidate["nutriments"]["fat_100g"] = 0