From d7ff2f63d3eabc240161b542d3c23d57fe9c7061 Mon Sep 17 00:00:00 2001 From: max r Date: Mon, 20 Jul 2026 13:34:09 +0500 Subject: [PATCH] feat: harden provider onboarding and resolution --- README.md | 54 ++++++- install.sh | 3 +- nomnomcli/cli.py | 35 ++++ nomnomcli/config.py | 105 ++++++++++++ nomnomcli/db.py | 21 ++- nomnomcli/errors.py | 21 +++ nomnomcli/foods.py | 143 ++++++----------- nomnomcli/models.py | 7 +- nomnomcli/off.py | 69 +++++--- nomnomcli/onboarding.py | 101 ++++++++++++ nomnomcli/parser.py | 36 ++++- nomnomcli/providers.py | 83 ++++++++++ nomnomcli/usda.py | 326 ++++++++++++++++++++++++++++++++++++++ skill/SKILL.md | 34 +++- tests/conftest.py | 10 +- tests/fixtures/foods.json | 32 ++-- tests/test_cli.py | 83 ++++++++-- tests/test_config.py | 160 +++++++++++++++++++ tests/test_db.py | 10 +- tests/test_foods.py | 25 ++- tests/test_install.py | 19 +++ tests/test_off.py | 60 ++++++- tests/test_parser.py | 55 ++++++- tests/test_usda.py | 198 +++++++++++++++++++++++ 24 files changed, 1507 insertions(+), 183 deletions(-) create mode 100644 nomnomcli/config.py create mode 100644 nomnomcli/onboarding.py create mode 100644 nomnomcli/providers.py create mode 100644 nomnomcli/usda.py create mode 100644 tests/test_config.py create mode 100644 tests/test_install.py create mode 100644 tests/test_usda.py diff --git a/README.md b/README.md index eb6bb0a..057d85a 100644 --- a/README.md +++ b/README.md @@ -33,8 +33,21 @@ git clone https://github.com/maxjustships/nomnomcli cd nomnomcli python3 -m pip install -e . nomnom --version +nomnom setup +nomnom doctor --json ``` +Run `nomnom setup` in an interactive terminal before the first food log. It probes Open Food +Facts, explains that OFF needs no key, shows the official USDA signup URL, validates the USDA key +with a minimal FoodData Central request, and only then stores it locally. Follow with +`nomnom doctor --json` to verify current provider reachability instead of assuming setup worked. + +USDA credentials are local user configuration, never repository or database content. The default +path is `$XDG_CONFIG_HOME/nomnomcli/config.toml` or `~/.config/nomnomcli/config.toml`, written with +owner-only `0600` permissions. `NOMNOM_USDA_KEY` is the non-interactive/CI option and takes +precedence over the stored key. Avoid putting it in commands, logs, or checked-in environment +files. + ## Resolve and log food ```sh @@ -48,7 +61,7 @@ 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, only when `NOMNOM_USDA_KEY` is set; +5. USDA FoodData Central, 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 @@ -60,16 +73,37 @@ Successful API results are cached in the user's database, so the same food can r later. Existing v0.2 cache records, logs, and recipes are preserved when v0.3 opens the database. `nomnom search QUERY` searches this user cache; it is not a packaged food catalog. +Open Food Facts search goes directly through its official REST endpoint, +`https://api.openfoodfacts.org/api/v2/search`, with a descriptive nomnomcli User-Agent. Direct +`requests` calls are intentional: the official REST contract is small and transparent, while a +third-party OFF SDK would add an unofficial dependency without adding an authenticated workflow or +schema capability. nomnom applies bounded backoff to HTTP 429 and 5xx responses, safely honors a +numeric `Retry-After`, and reports a typed retryable `openfoodfacts_unavailable` error after retries +are exhausted. It never silently falls back to the unstable `world` search host. + ### Enable USDA fallback -Get a free key at , then set it in the environment: +Run the guided flow (recommended): + +```sh +nomnom setup +nomnom doctor --json +``` + +Get a free key at the official signup page +. For non-interactive/CI use only, set it in the +environment: ```sh export NOMNOM_USDA_KEY="your-key" ``` -Without the key, a food that OFF cannot resolve returns `usda_key_required`, the same setup URL, -and the environment variable name. USDA matches cache with `source=usda` and their `fdc_id`. +Without a key, a food that OFF cannot resolve returns `usda_key_required`, the setup command, and +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. 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. @@ -119,8 +153,10 @@ 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 the resolved cached/pinned/API food -record. When that field is absent, `piece_weight_unknown` asks for grams. +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`. ```sh nomnom log --parse "bread 2 pieces at 40g" --json @@ -150,11 +186,17 @@ an `error` object and exit with status 2. Important codes include: - `off_low_confidence`: inspect `candidate` and `alternatives`; retry more specifically or pin a verified label. +- `usda_low_confidence`: inspect the FDC candidate/data type/category and retry more specifically; + 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. - `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. +Provider-unavailable errors include a `retryable` boolean. `nomnom doctor --json` always emits only +provider `configured`/`reachable` state and USDA `key_source`; it never includes credential values. + Successful logs are stored immediately. Agents should show the returned names, grams, confidence, alternatives, and assumptions before treating the resolution as confirmed. diff --git a/install.sh b/install.sh index f6f12a1..4e87108 100755 --- a/install.sh +++ b/install.sh @@ -66,4 +66,5 @@ else fi say "nomnomcli installation complete." -say "Next: run 'nomnom --help', then 'nomnom search buckwheat'." +say "Next: run 'nomnom setup' in an interactive terminal before the first food log." +say "Then verify provider readiness with 'nomnom doctor --json'." diff --git a/nomnomcli/cli.py b/nomnomcli/cli.py index 9abf7c1..876b645 100644 --- a/nomnomcli/cli.py +++ b/nomnomcli/cli.py @@ -11,6 +11,7 @@ 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 from nomnomcli.parser import parse_free_text from nomnomcli.recipes import fetch_recipe, recipe_portion, save_recipe @@ -60,6 +61,11 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("setup", help="configure and validate nutrition providers") + + doctor = commands.add_parser("doctor", help="probe provider readiness") + doctor.add_argument("--json", action="store_true", help="machine-readable JSON output") + log = commands.add_parser("log", help="resolve and store food") form = log.add_mutually_exclusive_group(required=True) form.add_argument("--parse", metavar="TEXT", help="comma-separated food phrases") @@ -112,6 +118,35 @@ def _build_parser() -> argparse.ArgumentParser: def _run(args: argparse.Namespace) -> int: + if args.command == "setup": + print("Open Food Facts: free, no account or key; branded packaged foods.") + print("USDA FoodData Central: free API key; generic/raw foods and fallback.") + print("USDA signup: https://fdc.nal.usda.gov/api-key-signup.html") + result = setup_providers(interactive=sys.stdin.isatty()) + off_status = ( + "reachable" + if result["providers"]["openfoodfacts"]["reachable"] + else "unreachable" + ) + print(f"Open Food Facts (no key): {off_status}") + print( + "USDA: reachable; key source " + f"{result['providers']['usda']['key_source']}" + ) + if result.get("config_path"): + print(f"Saved secure provider config: {result['config_path']} (0600)") + return 0 + + if args.command == "doctor": + result = doctor_report() + if args.json: + print(_json_output(result)) + else: + for provider, status in result["providers"].items(): + state = "reachable" if status["reachable"] else "unreachable" + print(f"{provider}: {state}") + return 0 + with connect() as connection: repository = FoodRepository(connection) if args.command == "alias": diff --git a/nomnomcli/config.py b/nomnomcli/config.py new file mode 100644 index 0000000..98291b3 --- /dev/null +++ b/nomnomcli/config.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import json +import os +import tempfile +import tomllib +from collections.abc import Mapping +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path + +from nomnomcli.errors import NomnomError + +USDA_ENV_VAR = "NOMNOM_USDA_KEY" + + +@dataclass(frozen=True, slots=True) +class Credential: + value: str + source: str + + +class ProviderConfig: + """Read and persist provider settings outside the repository and user database.""" + + def __init__( + self, + *, + environ: Mapping[str, str] | None = None, + config_path: str | Path | None = None, + ) -> None: + self._environ = os.environ if environ is None else environ + self._config_path = Path(config_path).expanduser() if config_path else None + + @property + def path(self) -> Path: + if self._config_path is not None: + return self._config_path + root = self._environ.get("XDG_CONFIG_HOME") + base = Path(root).expanduser() if root else Path.home() / ".config" + return base / "nomnomcli" / "config.toml" + + def usda_credential(self) -> Credential | None: + environment_key = self._environ.get(USDA_ENV_VAR, "").strip() + if environment_key: + return Credential(environment_key, "environment") + stored_key = self._stored_usda_key() + return Credential(stored_key, "user_config") if stored_key else None + + def _stored_usda_key(self) -> str | None: + if not self.path.exists(): + return None + try: + payload = tomllib.loads(self.path.read_text(encoding="utf-8")) + 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 + if value is None: + return None + if not isinstance(value, str): + raise NomnomError( + "provider_config_invalid", + "USDA api_key in provider configuration must be a string", + details={"path": str(self.path), "action": "Run nomnom setup again"}, + ) + return value.strip() or None + + def store_usda_key(self, api_key: str) -> Path: + key = api_key.strip() + if not key or any(ord(character) < 32 for character in key): + raise NomnomError("usda_key_invalid", "USDA API key must not be empty") + + destination = self.path + 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" + temporary_name: str | None = None + try: + descriptor, temporary_name = tempfile.mkstemp( + prefix=".config.toml.", dir=destination.parent + ) + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w", encoding="utf-8") as temporary: + temporary.write(content) + temporary.flush() + os.fsync(temporary.fileno()) + os.replace(temporary_name, destination) + temporary_name = None + destination.chmod(0o600) + except OSError as exc: + raise NomnomError( + "provider_config_write_failed", + f"Could not securely write provider configuration: {destination}", + details={"path": str(destination)}, + ) from exc + finally: + if temporary_name is not None: + with suppress(FileNotFoundError): + Path(temporary_name).unlink() + return destination diff --git a/nomnomcli/db.py b/nomnomcli/db.py index b6ddd65..9462b7d 100644 --- a/nomnomcli/db.py +++ b/nomnomcli/db.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from pathlib import Path -LATEST_SCHEMA_VERSION = 3 +LATEST_SCHEMA_VERSION = 4 V1_TABLES = frozenset({"food_cache", "log_entries", "recipes"}) LATEST_SCHEMA = ( @@ -25,7 +25,9 @@ barcode TEXT, brand TEXT, lookup_query TEXT, - alternatives_json TEXT + alternatives_json TEXT, + piece_grams_source TEXT, + piece_grams_source_value TEXT )""", """CREATE TABLE log_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -101,7 +103,20 @@ def _migrate_v2_to_v3(connection: sqlite3.Connection) -> None: ) -MIGRATIONS = {1: _migrate_v1_to_v2, 2: _migrate_v2_to_v3} +def _migrate_v3_to_v4(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" + ), + } + for column, statement in additions.items(): + if column not in columns: + connection.execute(statement) + + +MIGRATIONS = {1: _migrate_v1_to_v2, 2: _migrate_v2_to_v3, 3: _migrate_v3_to_v4} def _initialize_database(connection: sqlite3.Connection) -> None: diff --git a/nomnomcli/errors.py b/nomnomcli/errors.py index 6fcd8a9..b3c0c06 100644 --- a/nomnomcli/errors.py +++ b/nomnomcli/errors.py @@ -17,3 +17,24 @@ def as_dict(self) -> dict: if self.details: error["details"] = self.details return {"error": error} + + +class ProviderUnavailableError(NomnomError): + """A provider failure whose retryability is explicit in the public contract.""" + + def __init__( + self, + provider: str, + code: str, + message: str, + *, + retryable: bool, + details: dict | None = None, + ) -> None: + super().__init__( + code, + message, + details={"provider": provider, "retryable": retryable, **(details or {})}, + ) + self.provider = provider + self.retryable = retryable diff --git a/nomnomcli/foods.py b/nomnomcli/foods.py index d341157..b871718 100644 --- a/nomnomcli/foods.py +++ b/nomnomcli/foods.py @@ -1,24 +1,23 @@ from __future__ import annotations import json -import math import os import re import sqlite3 from dataclasses import replace -import requests - +from nomnomcli.config import ProviderConfig from nomnomcli.errors import NomnomError from nomnomcli.models import Food from nomnomcli.off import OpenFoodFactsClient +from nomnomcli.usda import USDAClient USDA_SETUP_URL = "https://fdc.nal.usda.gov/api-key-signup.html" USDA_KEY_REQUIRED_MESSAGE = ( f"USDA FoodData Central API key required. Get a free key at {USDA_SETUP_URL}, " - "then set NOMNOM_USDA_KEY." + "then run nomnom setup or set NOMNOM_USDA_KEY." ) -USDA_KEY_SETUP = f"Get a free key at {USDA_SETUP_URL} then: export NOMNOM_USDA_KEY=..." +USDA_KEY_SETUP = f"Run nomnom setup; signup: {USDA_SETUP_URL}" def normalize_name(value: str) -> str: @@ -44,27 +43,10 @@ def _comparison_token(token: str) -> str: return token -_FOOD_TYPE_MARKERS = { - "cheese": {"cheese", "сыр", "сыры"}, - "eggs": {"egg", "яйц"}, - "nuts": {"nut", "орех", "орехи", "орехов"}, -} - - -def _food_types(tokens: set[str]) -> set[str]: - return { - food_type - for food_type, markers in _FOOD_TYPE_MARKERS.items() - if tokens & markers - } - - def _off_confidence(query: str, food: Food) -> float: query_tokens = _name_tokens(query) category_tokens = _name_tokens(" ".join(food.categories)) - query_types = _food_types(query_tokens) - category_types = _food_types(category_tokens) - if query_types and category_types and query_types.isdisjoint(category_types): + if category_tokens and query_tokens.isdisjoint(category_tokens): return 0.0 candidate_tokens = _name_tokens(" ".join((food.name, food.brand or ""))) if not query_tokens: @@ -85,16 +67,6 @@ def _candidate_details(food: Food, confidence: float) -> dict: } -def _usda_serving_grams(record: dict) -> float | None: - if str(record.get("servingSizeUnit") or "").casefold() not in {"g", "gram", "grams"}: - return None - try: - value = float(record.get("servingSize")) - except (TypeError, ValueError): - return None - return value if math.isfinite(value) and value > 0 else None - - def _brand_matches_query(food: Food, query: str) -> bool: if not food.brand: return False @@ -108,9 +80,18 @@ def _brand_matches_query(food: Food, query: str) -> bool: class FoodRepository: - def __init__(self, user_connection: sqlite3.Connection) -> None: + def __init__( + self, + user_connection: sqlite3.Connection, + *, + provider_config: ProviderConfig | None = None, + off_client: OpenFoodFactsClient | None = None, + usda_client: USDAClient | None = None, + ) -> None: self.user_connection = user_connection - self.off_client = OpenFoodFactsClient() + self.provider_config = provider_config or ProviderConfig() + self.off_client = off_client or OpenFoodFactsClient() + self.usda_client = usda_client or USDAClient() def _canonicalize_query(self, query: str) -> str: return normalize_name(query) @@ -127,6 +108,16 @@ def _row_to_food(self, row: sqlite3.Row) -> Food: fat=float(row["fat"]), carbs=float(row["carbs"]), piece_grams=float(row["piece_grams"]) if row["piece_grams"] is not None else None, + piece_grams_source=( + str(row["piece_grams_source"]) + if "piece_grams_source" in columns and row["piece_grams_source"] + else None + ), + piece_grams_source_value=( + str(row["piece_grams_source_value"]) + if "piece_grams_source_value" in columns and row["piece_grams_source_value"] + else None + ), density_g_ml=float(row["density_g_ml"]) if row["density_g_ml"] is not None else None, source=row["source"], fdc_id=int(row["fdc_id"]) if row["fdc_id"] is not None else None, @@ -209,13 +200,13 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float remote_enabled = allow_remote and not os.getenv("NOMNOM_OFFLINE") off_enabled = remote_enabled and not os.getenv("NOMNOM_DISABLE_OFF") - api_key = os.getenv("NOMNOM_USDA_KEY") + credential = self.provider_config.usda_credential() low_confidence_error: NomnomError | None = None if off_enabled: try: off_matches = self.off_client.search(query, page_size=5) except NomnomError: - if not api_key: + if credential is None: raise off_matches = [] if off_matches: @@ -242,8 +233,8 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float _candidate_details(food, score) for food, score in scored[1:] ], "action": ( - "Try a more specific name, configure NOMNOM_USDA_KEY, or pin " - "verified label values with nomnom add" + "Try a more specific name, run nomnom setup, or pin verified " + "label values with nomnom add" ), }, ) @@ -270,8 +261,10 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float self._cache_food(food, lookup_query=query) return food, confidence - if remote_enabled and api_key: - return self._fetch_usda(query, api_key), 0.72 + if remote_enabled and credential is not None: + food, confidence = self.usda_client.resolve(query, credential.value) + self._cache_food(food, lookup_query=query) + return food, confidence if low_confidence_error is not None: raise low_confidence_error suggestions = [food.name for food in matches[:3]] @@ -285,8 +278,9 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float "setup": USDA_KEY_SETUP, "environment_variable": "NOMNOM_USDA_KEY", "action": ( - "Set NOMNOM_USDA_KEY or pin verified nutrition with nomnom add " - "--name NAME --brand BRAND --kcal KCAL --protein P --fat F --carbs C" + "Run nomnom setup (or set NOMNOM_USDA_KEY in CI), or pin verified " + "nutrition with nomnom add --name NAME --brand BRAND --kcal KCAL " + "--protein P --fat F --carbs C" ), }, ) @@ -296,7 +290,7 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float details={ "food": query, "suggestions": suggestions, - "offline": not off_enabled and not bool(api_key), + "offline": not off_enabled and credential is None, "action": ( "Search a more specific product name or pin label values with nomnom add " "--name NAME --brand BRAND --kcal KCAL --protein P --fat F --carbs C" @@ -429,15 +423,18 @@ def _ranked_user_cache_matches(self, query: str, limit: int) -> list[sqlite3.Row def _cache_food(self, food: Food, *, lookup_query: str) -> None: self.user_connection.execute( """INSERT INTO food_cache - (name, kcal, protein, fat, carbs, piece_grams, density_g_ml, source, fdc_id, - barcode, brand, lookup_query, alternatives_json) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(name) DO UPDATE SET kcal=excluded.kcal, protein=excluded.protein, fat=excluded.fat, carbs=excluded.carbs, piece_grams=excluded.piece_grams, + piece_grams_source=excluded.piece_grams_source, + piece_grams_source_value=excluded.piece_grams_source_value, density_g_ml=excluded.density_g_ml, source=excluded.source, fdc_id=excluded.fdc_id, @@ -452,6 +449,8 @@ def _cache_food(self, food: Food, *, lookup_query: str) -> None: food.fat, food.carbs, food.piece_grams, + food.piece_grams_source, + food.piece_grams_source_value, food.density_g_ml, food.source, food.fdc_id, @@ -480,56 +479,10 @@ def add_food( fat=fat, carbs=carbs, piece_grams=piece_grams, + piece_grams_source="--piece-grams" if piece_grams is not None else None, + piece_grams_source_value=(f"{piece_grams:g} g" if piece_grams is not None else None), source="user", brand=brand.strip(), ) self._cache_food(food, lookup_query=f"{name} {brand}") return food - - def _fetch_usda(self, query: str, api_key: str) -> Food: - try: - response = requests.get( - "https://api.nal.usda.gov/fdc/v1/foods/search", - params={"api_key": api_key, "query": query, "pageSize": 1}, - timeout=15, - ) - response.raise_for_status() - foods = response.json().get("foods", []) - except (requests.RequestException, ValueError) as exc: - raise NomnomError( - "usda_unavailable", "USDA fallback request failed", details={"food": query} - ) from exc - if not foods: - raise NomnomError( - "food_not_found", - f"USDA could not resolve food: {query}", - details={ - "food": query, - "action": ( - "Try a more specific name or pin verified nutrition with nomnom add " - "--name NAME --brand BRAND --kcal KCAL --protein P --fat F --carbs C" - ), - }, - ) - record = foods[0] - nutrients = {} - for item in record.get("foodNutrients", []): - name = item.get("nutrientName", "").casefold() - if name == "energy" and ( - str(item.get("nutrientNumber", "")) != "208" - and str(item.get("unitName", "")).casefold() != "kcal" - ): - continue - nutrients[name] = float(item.get("value") or 0) - food = Food( - name=record["description"].casefold(), - kcal=nutrients.get("energy", 0.0), - protein=nutrients.get("protein", 0.0), - fat=nutrients.get("total lipid (fat)", 0.0), - carbs=nutrients.get("carbohydrate, by difference", 0.0), - piece_grams=_usda_serving_grams(record), - source="usda", - fdc_id=record.get("fdcId"), - ) - self._cache_food(food, lookup_query=query) - return food diff --git a/nomnomcli/models.py b/nomnomcli/models.py index 6aba3c3..8775918 100644 --- a/nomnomcli/models.py +++ b/nomnomcli/models.py @@ -11,6 +11,8 @@ class Food: fat: float carbs: float piece_grams: float | None = None + piece_grams_source: str | None = None + piece_grams_source_value: str | None = None density_g_ml: float | None = None source: str = "unknown" fdc_id: int | None = None @@ -32,6 +34,7 @@ class ResolvedItem: assumed: bool | None = None assumption: str | None = None source: str | None = None + fdc_id: int | None = None barcode: str | None = None brand: str | None = None alternatives: tuple[dict[str, str], ...] | None = None @@ -56,7 +59,6 @@ def scale_food( assumption: str | None = None, ) -> ResolvedItem: factor = grams / 100.0 - is_branded = food.source in {"openfoodfacts", "user"} return ResolvedItem( name=food.name, grams=round_nutrition(grams), @@ -67,7 +69,8 @@ def scale_food( match_confidence=round(confidence, 2), assumed=assumed, assumption=assumption, - source=food.source if is_branded 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, diff --git a/nomnomcli/off.py b/nomnomcli/off.py index 0d0c89a..e1ef9fa 100644 --- a/nomnomcli/off.py +++ b/nomnomcli/off.py @@ -2,14 +2,17 @@ import math import re +import time +from collections.abc import Callable import requests from nomnomcli import __version__ -from nomnomcli.errors import NomnomError +from nomnomcli.errors import NomnomError, ProviderUnavailableError from nomnomcli.models import Food +from nomnomcli.providers import RetryPolicy, request_with_retry -OFF_SEARCH_URL = "https://world.openfoodfacts.org/api/v2/search" +OFF_SEARCH_URL = "https://api.openfoodfacts.org/api/v2/search" OFF_FIELDS = "product_name,brands,nutriments,code,serving_size,categories,categories_tags" @@ -66,6 +69,10 @@ def _normalize_product(product: dict) -> Food | None: fat=fat, carbs=carbs, piece_grams=_serving_grams(product.get("serving_size")), + piece_grams_source="serving_size", + piece_grams_source_value=( + str(product["serving_size"]) if product.get("serving_size") else None + ), source="openfoodfacts", fdc_id=None, barcode=barcode, @@ -75,7 +82,18 @@ def _normalize_product(product: dict) -> Food | None: class OpenFoodFactsClient: - def search(self, query: str, page_size: int = 5) -> list[Food]: + def __init__( + self, + *, + request_get: Callable | None = None, + retry_policy: RetryPolicy | None = None, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self._request_get = request_get + self.retry_policy = retry_policy or RetryPolicy() + self.sleep = sleep + + def _get_payload(self, query: str, page_size: int) -> dict: details = { "food": query, "offline_escape": ( @@ -83,36 +101,41 @@ def search(self, query: str, page_size: int = 5) -> list[Food]: "--kcal KCAL --protein P --fat F --carbs C" ), } - try: - response = requests.get( - OFF_SEARCH_URL, - params={ + response = request_with_retry( + provider="openfoodfacts", + code="openfoodfacts_unavailable", + message="Open Food Facts lookup is unavailable", + request_get=self._request_get or requests.get, + url=OFF_SEARCH_URL, + request_kwargs={ + "params": { "search_terms": query, "fields": OFF_FIELDS, "page_size": page_size, }, - timeout=10, - headers={ + "timeout": 10, + "headers": { "User-Agent": ( f"nomnomcli/{__version__} " "(+https://github.com/maxjustships/nomnomcli)" ) }, - ) + }, + details=details, + retry_policy=self.retry_policy, + sleep=self.sleep, + ) + try: response.raise_for_status() except requests.RequestException as exc: - status = getattr(getattr(exc, "response", None), "status_code", None) - if status is not None: - details["status"] = status - details["reason"] = "http_error" - else: - details["reason"] = "network_error" - raise NomnomError( + status = getattr(response, "status_code", None) + raise ProviderUnavailableError( + "openfoodfacts", "openfoodfacts_unavailable", "Open Food Facts lookup is unavailable", - details=details, + retryable=False, + details={**details, "reason": "http_error", "status": status, "attempts": 1}, ) from exc - try: payload = response.json() except ValueError as exc: @@ -127,6 +150,14 @@ def search(self, query: str, page_size: int = 5) -> list[Food]: "Open Food Facts returned an invalid product payload", details=details, ) + return payload + + def probe(self) -> bool: + self._get_payload("a", 1) + return True + + def search(self, query: str, page_size: int = 5) -> list[Food]: + payload = self._get_payload(query, page_size) return [ food for product in payload["products"] diff --git a/nomnomcli/onboarding.py b/nomnomcli/onboarding.py new file mode 100644 index 0000000..3b0d7f7 --- /dev/null +++ b/nomnomcli/onboarding.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import getpass +from collections.abc import Callable + +from nomnomcli.config import ProviderConfig +from nomnomcli.errors import NomnomError +from nomnomcli.foods import USDA_SETUP_URL +from nomnomcli.off import OpenFoodFactsClient +from nomnomcli.usda import USDAClient + + +def _reachable(probe: Callable[[], bool]) -> bool: + try: + return bool(probe()) + except NomnomError: + return False + + +def doctor_report( + *, + config: ProviderConfig | None = None, + off_client: OpenFoodFactsClient | None = None, + usda_client: USDAClient | None = None, +) -> dict: + provider_config = config or ProviderConfig() + off = off_client or OpenFoodFactsClient() + usda = usda_client or USDAClient() + try: + credential = provider_config.usda_credential() + except NomnomError: + credential = None + return { + "providers": { + "openfoodfacts": {"configured": True, "reachable": _reachable(off.probe)}, + "usda": { + "configured": credential is not None, + "reachable": ( + _reachable(lambda: usda.probe(credential.value)) + if credential is not None + else False + ), + "key_source": credential.source if credential is not None else None, + }, + } + } + + +def setup_providers( + *, + interactive: bool, + config: ProviderConfig | None = None, + off_client: OpenFoodFactsClient | None = None, + usda_client: USDAClient | None = None, + prompt: Callable[[str], str] | None = None, +) -> dict: + provider_config = config or ProviderConfig() + off = off_client or OpenFoodFactsClient() + usda = usda_client or USDAClient() + off_reachable = _reachable(off.probe) + credential = provider_config.usda_credential() + if credential is not None: + usda.probe(credential.value) + return { + "providers": { + "openfoodfacts": {"configured": True, "reachable": off_reachable}, + "usda": { + "configured": True, + "reachable": True, + "key_source": credential.source, + }, + } + } + if not interactive: + raise NomnomError( + "setup_requires_interactive", + "USDA is not configured and setup cannot prompt on non-interactive stdin", + details={ + "signup_url": USDA_SETUP_URL, + "action": ( + "Run nomnom setup in an interactive terminal, or set NOMNOM_USDA_KEY " + "and run nomnom doctor --json" + ), + }, + ) + api_key = (prompt or getpass.getpass)("USDA API key (input hidden): ").strip() + if not api_key: + raise NomnomError( + "usda_key_invalid", + "USDA API key must not be empty; configuration was not changed", + details={"signup_url": USDA_SETUP_URL}, + ) + usda.probe(api_key) + path = provider_config.store_usda_key(api_key) + return { + "providers": { + "openfoodfacts": {"configured": True, "reachable": off_reachable}, + "usda": {"configured": True, "reachable": True, "key_source": "user_config"}, + }, + "config_path": str(path), + } diff --git a/nomnomcli/parser.py b/nomnomcli/parser.py index 93c5ae8..3bc6cea 100644 --- a/nomnomcli/parser.py +++ b/nomnomcli/parser.py @@ -68,6 +68,12 @@ def _alias_pattern(values) -> str: rf"(?P{MASS_UNIT})$", re.IGNORECASE, ) +LEADING_PER_PIECE_QUANTITY = re.compile( + rf"^(?P{NUMBER})\s*(?P{PIECE_UNIT})\s+" + rf"(?P.+?)\s+(?:{_alias_pattern(EACH_MARKERS)})\s+" + rf"(?P{NUMBER})\s*(?P{MASS_UNIT})$", + re.IGNORECASE, +) SIZE_ALIASES = { "small": { @@ -150,7 +156,13 @@ def _quantity_to_grams(amount: float, unit: str, food) -> float: raise NomnomError( "piece_weight_unknown", f"No deterministic piece weight for {food.name}; provide grams", - details={"food": food.name, "pieces": amount}, + details={ + "food": food.name, + "pieces": amount, + "provider": food.source, + "source_fields_checked": ["serving_size", "servingSize", "foodMeasures"], + "action": "Provide exact grams", + }, ) return amount * food.piece_grams return amount @@ -188,9 +200,25 @@ def _parse_descriptor_piece(cleaned: str, repository: FoodRepository) -> Resolve food_query = " ".join(match.group("food").split()) food, confidence = repository.resolve(food_query) + if food.piece_grams is not None and ( + not food.piece_grams_source or not food.piece_grams_source_value + ): + raise NomnomError( + "piece_weight_unknown", + f"Serving-weight provenance is missing for {food.name}; provide grams", + details={ + "food": food.name, + "pieces": amount, + "provider": food.source, + "reason": "serving_weight_provenance_missing", + "action": "Provide exact grams", + }, + ) grams = _quantity_to_grams(amount, "pieces", food) assumption = ( - f"{_format_amount(amount)} {size} {food_query} = {_format_grams(grams)}g" + f"{_format_amount(amount)} {size} {food_query} = {_format_grams(grams)}g " + f"(source: {food.source}.{food.piece_grams_source}=" + f"{food.piece_grams_source_value})" ) return scale_food( food, @@ -206,7 +234,9 @@ def parse_item_phrase(phrase: str, repository: FoodRepository) -> ResolvedItem: descriptor_item = _parse_descriptor_piece(cleaned, repository) if descriptor_item: return descriptor_item - per_piece = PER_PIECE_QUANTITY.match(cleaned) + per_piece = PER_PIECE_QUANTITY.match(cleaned) or LEADING_PER_PIECE_QUANTITY.match( + cleaned + ) if per_piece: food, confidence = repository.resolve(per_piece.group("food").strip(" -")) amount = parse_number(per_piece.group("amount")) diff --git a/nomnomcli/providers.py b/nomnomcli/providers.py new file mode 100644 index 0000000..81c5252 --- /dev/null +++ b/nomnomcli/providers.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import math +import time +from collections.abc import Callable +from dataclasses import dataclass + +import requests + +from nomnomcli.errors import ProviderUnavailableError + +RETRYABLE_STATUS_CODES = frozenset({429, *range(500, 600)}) + + +@dataclass(frozen=True, slots=True) +class RetryPolicy: + max_attempts: int = 3 + backoff_base: float = 0.25 + max_delay: float = 2.0 + max_retry_after: float = 5.0 + + def __post_init__(self) -> None: + if self.max_attempts < 1: + raise ValueError("max_attempts must be at least one") + if min(self.backoff_base, self.max_delay, self.max_retry_after) < 0: + raise ValueError("retry delays must be non-negative") + + def delay(self, failed_attempt: int, response) -> float: + retry_after = str(getattr(response, "headers", {}).get("Retry-After", "")).strip() + try: + numeric_retry_after = float(retry_after) + except ValueError: + numeric_retry_after = math.nan + if ( + math.isfinite(numeric_retry_after) + and 0 <= numeric_retry_after <= self.max_retry_after + ): + return numeric_retry_after + return min(self.backoff_base * (2 ** (failed_attempt - 1)), self.max_delay) + + +def request_with_retry( + *, + provider: str, + code: str, + message: str, + request_get: Callable, + url: str, + request_kwargs: dict, + details: dict, + retry_policy: RetryPolicy, + sleep: Callable[[float], None] = time.sleep, +): + for attempt in range(1, retry_policy.max_attempts + 1): + try: + response = request_get(url, **request_kwargs) + except requests.RequestException as exc: + raise ProviderUnavailableError( + provider, + code, + message, + retryable=True, + details={**details, "reason": "network_error", "attempts": attempt}, + ) from exc + + status = int(getattr(response, "status_code", 200)) + if status not in RETRYABLE_STATUS_CODES: + return response + if attempt == retry_policy.max_attempts: + raise ProviderUnavailableError( + provider, + code, + message, + retryable=True, + details={ + **details, + "reason": "http_error", + "status": status, + "attempts": attempt, + }, + ) + sleep(retry_policy.delay(attempt, response)) + raise AssertionError("retry loop exhausted unexpectedly") diff --git a/nomnomcli/usda.py b/nomnomcli/usda.py new file mode 100644 index 0000000..29a822a --- /dev/null +++ b/nomnomcli/usda.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import math +import re +import time +from collections.abc import Callable +from dataclasses import replace + +import requests + +from nomnomcli.errors import NomnomError, ProviderUnavailableError +from nomnomcli.models import Food +from nomnomcli.providers import RetryPolicy, request_with_retry + +USDA_SEARCH_URL = "https://api.nal.usda.gov/fdc/v1/foods/search" +USDA_CONFIDENCE_FLOOR = 0.8 + +_NUTRIENT_SPECS = { + "kcal": { + "ids": {1008}, + "numbers": {"208"}, + "names": {"energy"}, + "units": {"kcal"}, + }, + "protein": { + "ids": {1003}, + "numbers": {"203"}, + "names": {"protein"}, + "units": {"g", "gram", "grams"}, + }, + "fat": { + "ids": {1004}, + "numbers": {"204"}, + "names": {"total lipid (fat)", "total fat", "fat"}, + "units": {"g", "gram", "grams"}, + }, + "carbs": { + "ids": {1005}, + "numbers": {"205"}, + "names": {"carbohydrate, by difference", "carbohydrate"}, + "units": {"g", "gram", "grams"}, + }, +} +_DATA_TYPE_QUALITY = { + "foundation": 1.0, + "sr legacy": 0.95, + "survey (fndds)": 0.55, + "branded": 0.35, +} + + +def _tokens(value: str) -> set[str]: + tokens = set() + for raw in re.findall(r"\w+(?:['’]\w+)*", value.casefold()): + token = ( + raw[:-1] + if re.fullmatch(r"[a-z]+", raw) and len(raw) > 3 and raw.endswith("s") + else raw + ) + tokens.add(token) + return tokens + + +def _number(value) -> float | None: + if value is None or isinstance(value, bool): + return None + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) and number > 0 else None + + +def _nutrient_metadata(item: dict) -> tuple[int | None, str, str, str, float | None]: + nested = item.get("nutrient") if isinstance(item.get("nutrient"), dict) else {} + raw_id = item.get("nutrientId", nested.get("id")) + try: + nutrient_id = int(raw_id) + except (TypeError, ValueError): + nutrient_id = None + number = str(item.get("nutrientNumber", nested.get("number", ""))).strip() + name = str( + item.get("nutrientName") + or item.get("description") + or nested.get("name") + or nested.get("description") + or "" + ).casefold().strip() + unit = str(item.get("unitName") or nested.get("unitName") or "").casefold().strip() + value = _number(item.get("value", item.get("amount"))) + return nutrient_id, number, name, unit, value + + +def _core_nutrients(record: dict) -> tuple[dict[str, float], list[str]]: + nutrients: dict[str, float] = {} + raw_nutrients = record.get("foodNutrients") + if not isinstance(raw_nutrients, list): + return nutrients, list(_NUTRIENT_SPECS) + for item in raw_nutrients: + if not isinstance(item, dict): + continue + nutrient_id, number, name, unit, value = _nutrient_metadata(item) + if value is None: + continue + for key, spec in _NUTRIENT_SPECS.items(): + identity_match = ( + nutrient_id in spec["ids"] + or number in spec["numbers"] + or name in spec["names"] + ) + if identity_match and unit in spec["units"]: + nutrients.setdefault(key, value) + break + missing = [key for key in _NUTRIENT_SPECS if key not in nutrients] + return nutrients, missing + + +def _serving_weight(record: dict) -> tuple[float | None, str | None, str | None]: + unit = str(record.get("servingSizeUnit") or "").casefold().strip() + serving_size = _number(record.get("servingSize")) + if serving_size is not None and unit in {"g", "gram", "grams"}: + return serving_size, "servingSize", f"{serving_size:g} {unit}" + measures = record.get("foodMeasures") + if isinstance(measures, list): + for index, measure in enumerate(measures): + if not isinstance(measure, dict): + continue + gram_weight = _number(measure.get("gramWeight")) + if gram_weight is not None: + return gram_weight, f"foodMeasures[{index}].gramWeight", f"{gram_weight:g} g" + return None, None, None + + +def _normalize_record(record: dict) -> tuple[Food | None, list[str]]: + description = str(record.get("description") or "").strip() + if not description: + return None, list(_NUTRIENT_SPECS) + nutrients, missing = _core_nutrients(record) + if missing: + return None, missing + piece_grams, source_field, source_value = _serving_weight(record) + category = str(record.get("foodCategory") or "").strip() + food = Food( + name=description.casefold(), + kcal=nutrients["kcal"], + protein=nutrients["protein"], + fat=nutrients["fat"], + carbs=nutrients["carbs"], + piece_grams=piece_grams, + piece_grams_source=source_field, + piece_grams_source_value=source_value, + source="usda", + fdc_id=int(record["fdcId"]) if record.get("fdcId") is not None else None, + brand=str(record.get("brandOwner") or "").strip() or None, + categories=(category,) if category else (), + ) + return food, [] + + +def _confidence(query: str, food: Food, data_type: str) -> float: + query_tokens = _tokens(query) + candidate_tokens = _tokens(" ".join((food.name, food.brand or ""))) + if not query_tokens or not candidate_tokens: + return 0.0 + overlap = query_tokens & candidate_tokens + coverage = len(overlap) / len(query_tokens) + precision = len(overlap) / len(candidate_tokens) + data_quality = _DATA_TYPE_QUALITY.get(data_type.casefold(), 0.5) + category_tokens = _tokens(" ".join(food.categories)) + category_quality = ( + 1.0 if query_tokens & category_tokens else 0.5 if category_tokens else 0.0 + ) + return 0.5 * coverage + 0.3 * precision + 0.15 * data_quality + 0.05 * category_quality + + +def _candidate_details(food: Food, data_type: str, confidence: float) -> dict: + return { + key: value + for key, value in { + "name": food.name, + "fdc_id": food.fdc_id, + "data_type": data_type or None, + "category": food.categories[0] if food.categories else None, + "confidence": round(confidence, 2), + }.items() + if value is not None + } + + +class USDAClient: + def __init__( + self, + *, + request_get: Callable | None = None, + retry_policy: RetryPolicy | None = None, + sleep: Callable[[float], None] = time.sleep, + ) -> None: + self._request_get = request_get + self.retry_policy = retry_policy or RetryPolicy() + self.sleep = sleep + + def _payload(self, query: str, api_key: str, page_size: int) -> dict: + details = {"food": query} + response = request_with_retry( + provider="usda", + code="usda_unavailable", + message="USDA FoodData Central lookup is unavailable", + request_get=self._request_get or requests.get, + url=USDA_SEARCH_URL, + request_kwargs={ + "params": {"api_key": api_key, "query": query, "pageSize": page_size}, + "timeout": 15, + }, + details=details, + retry_policy=self.retry_policy, + sleep=self.sleep, + ) + if response.status_code in {401, 403}: + raise NomnomError( + "usda_key_invalid", + "USDA FoodData Central rejected the API key", + details={"setup_url": "https://fdc.nal.usda.gov/api-key-signup.html"}, + ) + try: + response.raise_for_status() + except requests.RequestException as exc: + raise ProviderUnavailableError( + "usda", + "usda_unavailable", + "USDA FoodData Central lookup is unavailable", + retryable=False, + details={**details, "status": response.status_code, "reason": "http_error"}, + ) from exc + try: + payload = response.json() + except ValueError as exc: + raise NomnomError( + "usda_invalid_response", + "USDA FoodData Central returned malformed JSON", + details=details, + ) from exc + if not isinstance(payload, dict) or not isinstance(payload.get("foods"), list): + raise NomnomError( + "usda_invalid_response", + "USDA FoodData Central returned an invalid search payload", + details=details, + ) + return payload + + def probe(self, api_key: str) -> bool: + self._payload("a", api_key, 1) + return True + + def resolve(self, query: str, api_key: str) -> tuple[Food, float]: + records = self._payload(query, api_key, 10)["foods"] + if not records: + raise NomnomError( + "food_not_found", + f"USDA could not resolve food: {query}", + details={ + "food": query, + "action": ( + "Try a more specific name or pin verified nutrition with nomnom add " + "--name NAME --brand BRAND --kcal KCAL --protein P --fat F --carbs C" + ), + }, + ) + + candidates: list[tuple[Food, str, float]] = [] + rejected = [] + for record in records: + if not isinstance(record, dict): + continue + food, missing = _normalize_record(record) + if food is None: + rejected.append( + { + "name": str(record.get("description") or ""), + "fdc_id": record.get("fdcId"), + "missing_or_nonpositive_core": missing, + } + ) + continue + data_type = str(record.get("dataType") or "").strip() + candidates.append((food, data_type, _confidence(query, food, data_type))) + + if not candidates: + raise NomnomError( + "usda_invalid_nutrition", + f"USDA candidates lack complete positive core nutrition for: {query}", + details={"food": query, "rejected_candidates": rejected}, + ) + + candidates.sort( + key=lambda item: ( + -item[2], + -_DATA_TYPE_QUALITY.get(item[1].casefold(), 0.5), + item[0].name, + item[0].fdc_id or 0, + ) + ) + accepted = [ + candidate for candidate in candidates if candidate[2] >= USDA_CONFIDENCE_FLOOR + ] + if not accepted: + candidate = candidates[0] + raise NomnomError( + "usda_low_confidence", + f"USDA candidate is too weak for: {query}", + details={ + "food": query, + "threshold": USDA_CONFIDENCE_FLOOR, + "candidate": _candidate_details(*candidate), + "alternatives": [_candidate_details(*item) for item in candidates[1:]], + "action": ( + "Try a more specific food name or pin verified nutrition " + "with nomnom add" + ), + }, + ) + + selected = accepted[0] + alternatives = tuple( + _candidate_details(*item) for item in candidates if item is not selected + ) + return replace(selected[0], alternatives=alternatives), selected[2] diff --git a/skill/SKILL.md b/skill/SKILL.md index f3cec25..5ab9c68 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -18,6 +18,19 @@ If `nomnom --help` is unavailable, ask permission and run: curl -sL https://raw.githubusercontent.com/maxjustships/nomnomcli/main/install.sh | sh ``` +Before the first food log, run setup in the user's interactive terminal, then verify readiness: + +```sh +nomnom setup +nomnom doctor --json +``` + +Explain only actionable results. Open Food Facts is keyless. USDA signup is at +; setup validates before writing the key to the +owner-only XDG user config (`0600`). Credentials stay local and must never enter the repository, +database, shell history, agent transcript, or logs. `NOMNOM_USDA_KEY` is the non-interactive option +and overrides stored config. + ## Log free text 1. Preserve every food and quantity the user supplied. @@ -66,9 +79,9 @@ 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 `NOMNOM_USDA_KEY` is configured. For - `usda_key_required`, offer the free-key setup URL returned in `details` and - ask the user to set the environment variable. +2. Let USDA run only when setup or `NOMNOM_USDA_KEY` has configured it. 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 @@ -84,9 +97,24 @@ 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`. +- `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. - `openfoodfacts_unavailable` / `usda_unavailable`: say that nothing was estimated; offer retry or a verified manual pin. +Size assumptions must show the returned provider serving field and value. Exact +human grams always override provider serving data, including per-piece input. + +## Provider contract + +OFF search intentionally uses direct `requests` calls to the official REST endpoint +`https://api.openfoodfacts.org/api/v2/search` with a descriptive User-Agent. No +third-party OFF SDK or alternate `world` search host is used. Retryable 429/5xx +failures use bounded backoff; never implement a silent provider/cache fallback. + +Use `nomnom doctor --json` when diagnosing resolution. Report only +`configured`, `reachable`, and USDA `key_source`; never expose a credential value. + ## Stats ```sh diff --git a/tests/conftest.py b/tests/conftest.py index d908ae5..6072527 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -26,8 +26,8 @@ def seed_food_cache(connection, foods: list[dict]) -> None: connection.execute( """INSERT INTO food_cache (name, kcal, protein, fat, carbs, piece_grams, density_g_ml, source, fdc_id, - lookup_query) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + lookup_query, piece_grams_source, piece_grams_source_value) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( food["name"], food["kcal"], @@ -39,6 +39,12 @@ def seed_food_cache(connection, foods: list[dict]) -> None: "fixture", None, food.get("lookup_query"), + "synthetic_serving" if food.get("piece_grams") is not None else None, + ( + f"{food['piece_grams']:g} g" + if food.get("piece_grams") is not None + else None + ), ), ) diff --git a/tests/fixtures/foods.json b/tests/fixtures/foods.json index 2dbce3e..b60b44f 100644 --- a/tests/fixtures/foods.json +++ b/tests/fixtures/foods.json @@ -35,21 +35,21 @@ "piece_grams": 50 }, { - "name": "tomato, raw", - "lookup_query": "томат томата помидор tomato raw", - "kcal": 18, - "protein": 0.88, - "fat": 0.2, - "carbs": 3.89, + "name": "fixture pod", + "lookup_query": "fixture pod", + "kcal": 80, + "protein": 4, + "fat": 2, + "carbs": 12, "piece_grams": 100 }, { - "name": "onion, raw", - "lookup_query": "лук луковица луковицы onion raw", - "kcal": 40, - "protein": 1.1, - "fat": 0.1, - "carbs": 9.34, + "name": "fixture bulb", + "lookup_query": "fixture bulb", + "kcal": 120, + "protein": 6, + "fat": 3, + "carbs": 18, "piece_grams": 80 }, { @@ -95,13 +95,15 @@ "usda": { "fdcId": 175200, "description": "Chickpeas, cooked", + "dataType": "Foundation", + "foodCategory": "Legumes and Legume Products", "servingSize": 130, "servingSizeUnit": "g", "foodNutrients": [ {"nutrientName": "Energy", "nutrientNumber": "208", "unitName": "KCAL", "value": 164}, - {"nutrientName": "Protein", "value": 8.86}, - {"nutrientName": "Total lipid (fat)", "value": 2.59}, - {"nutrientName": "Carbohydrate, by difference", "value": 27.42} + {"nutrientId": 1003, "nutrientName": "Protein", "unitName": "G", "value": 8.86}, + {"nutrientId": 1004, "nutrientName": "Total lipid (fat)", "unitName": "G", "value": 2.59}, + {"nutrientId": 1005, "nutrientName": "Carbohydrate, by difference", "unitName": "G", "value": 27.42} ] } } diff --git a/tests/test_cli.py b/tests/test_cli.py index 8734f04..feb561c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -82,8 +82,7 @@ def json(self): assert captured.out == "" assert error["error"]["code"] == "usda_key_required" assert error["error"]["setup"] == ( - "Get a free key at https://fdc.nal.usda.gov/api-key-signup.html then: " - "export NOMNOM_USDA_KEY=..." + "Run nomnom setup; signup: https://fdc.nal.usda.gov/api-key-signup.html" ) assert error["error"]["details"]["setup_url"] == ( "https://fdc.nal.usda.gov/api-key-signup.html" @@ -141,14 +140,14 @@ def test_cli_json_and_text_surface_size_assumptions( seeded_user_db, monkeypatch, capsys ): monkeypatch.setenv("NOMNOM_DB_PATH", str(seeded_user_db)) - phrase = "омлет из 2 small eggs, половины небольшого томата" + phrase = "омлет из 2 small eggs, half small fixture pod" code = main(["log", "--parse", phrase, "--json"]) result = json.loads(capsys.readouterr().out) assert code == 0 assert result["assumptions"] == [ - "2 small eggs = 100g", - "1/2 small томата = 50g", + "2 small eggs = 100g (source: fixture.synthetic_serving=50 g)", + "1/2 small fixture pod = 50g (source: fixture.synthetic_serving=100 g)", ] assert all(item["assumed"] is True for item in result["items"]) @@ -156,7 +155,7 @@ def test_cli_json_and_text_surface_size_assumptions( output = capsys.readouterr().out assert code == 0 assert "Assumptions:" in output - assert "2 small eggs = 100g" in output + assert "2 small eggs = 100g (source: fixture.synthetic_serving=50 g)" in output def test_cli_add_pins_branded_product_for_offline_piece_lookup( @@ -249,8 +248,8 @@ def test_exact_issue_phrase_with_pinned_brand(seeded_user_db, monkeypatch, capsy capsys.readouterr() monkeypatch.setenv("NOMNOM_OFFLINE", "1") phrase = ( - "яичница из 3 небольших яиц, половины небольшого томата и половины средней " - "луковицы, хлеб harry's 2 куска по 40г" + "яичница из 3 небольших яиц, half small fixture pod и half medium " + "fixture bulb, хлеб harry's 2 куска по 40г" ) assert main(["log", "--parse", phrase, "--json"]) == 0 result = json.loads(capsys.readouterr().out) @@ -262,12 +261,74 @@ def test_exact_issue_phrase_with_pinned_brand(seeded_user_db, monkeypatch, capsy for item in result["items"] ) assert result["assumptions"] == [ - "3 small яиц = 150g", - "1/2 small томата = 50g", - "1/2 medium луковицы = 40g", + "3 small яиц = 150g (source: fixture.synthetic_serving=50 g)", + "1/2 small fixture pod = 50g (source: fixture.synthetic_serving=100 g)", + "1/2 medium fixture bulb = 40g (source: fixture.synthetic_serving=80 g)", ] +def test_cli_leading_piece_count_with_explicit_grams_is_114g( + seeded_user_db, monkeypatch, capsys +): + monkeypatch.setenv("NOMNOM_DB_PATH", str(seeded_user_db)) + + assert ( + main( + [ + "log", + "--parse", + "3 pieces egg whole boiled at 38g", + "--json", + ] + ) + == 0 + ) + result = json.loads(capsys.readouterr().out) + assert result["items"][0]["grams"] == 114 + assert "assumptions" not in result + + +def test_cli_doctor_json_contract(monkeypatch, capsys): + expected = { + "providers": { + "openfoodfacts": {"configured": True, "reachable": True}, + "usda": { + "configured": False, + "reachable": False, + "key_source": None, + }, + } + } + monkeypatch.setattr("nomnomcli.cli.doctor_report", lambda: expected) + + assert main(["doctor", "--json"]) == 0 + assert json.loads(capsys.readouterr().out) == expected + + +def test_cli_setup_passes_noninteractive_state_and_is_actionable( + monkeypatch, capsys +): + class NonInteractive: + def isatty(self): + return False + + def setup(*, interactive): + assert interactive is False + raise NomnomError( + "setup_requires_interactive", + "interactive terminal required", + details={"action": "Run nomnom setup in an interactive terminal"}, + ) + + monkeypatch.setattr("nomnomcli.cli.sys.stdin", NonInteractive()) + monkeypatch.setattr("nomnomcli.cli.setup_providers", setup) + + assert main(["setup"]) == 2 + captured = capsys.readouterr() + assert "Open Food Facts: free, no account or key" in captured.out + assert json.loads(captured.err)["error"]["code"] == "setup_requires_interactive" + + def test_cli_off_alternatives_are_additive_json(user_db, monkeypatch, capsys): monkeypatch.setenv("NOMNOM_DB_PATH", str(user_db)) matches = [ diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..67f3d99 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import json +import stat + +import pytest + +from nomnomcli.config import ProviderConfig +from nomnomcli.errors import NomnomError +from nomnomcli.onboarding import doctor_report, setup_providers + + +class HealthyOFF: + def probe(self): + return True + + +class HealthyUSDA: + def __init__(self): + self.keys = [] + + def probe(self, api_key): + self.keys.append(api_key) + return True + + +def test_environment_usda_key_overrides_user_config(tmp_path): + path = tmp_path / "config.toml" + stored = ProviderConfig(environ={}, config_path=path) + stored.store_usda_key("stored-placeholder") + + credential = ProviderConfig( + environ={"NOMNOM_USDA_KEY": "environment-placeholder"}, config_path=path + ).usda_credential() + + assert credential is not None + assert credential.source == "environment" + assert credential.value == "environment-placeholder" + + +def test_stored_config_is_xdg_and_owner_only(tmp_path): + config = ProviderConfig(environ={"XDG_CONFIG_HOME": str(tmp_path)}) + path = config.store_usda_key("stored-placeholder") + + assert path == tmp_path / "nomnomcli" / "config.toml" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert stat.S_IMODE(path.parent.stat().st_mode) == 0o700 + assert config.usda_credential().source == "user_config" + + +def test_invalid_config_is_actionable(tmp_path): + path = tmp_path / "config.toml" + path.write_text("[providers.usda\napi_key = broken", encoding="utf-8") + + with pytest.raises(NomnomError) as caught: + ProviderConfig(environ={}, config_path=path).usda_credential() + + assert caught.value.code == "provider_config_invalid" + assert caught.value.details["path"] == str(path) + + +def test_interactive_setup_validates_before_secure_write(tmp_path): + path = tmp_path / "config.toml" + config = ProviderConfig(environ={}, config_path=path) + usda = HealthyUSDA() + + result = setup_providers( + interactive=True, + config=config, + off_client=HealthyOFF(), + usda_client=usda, + prompt=lambda _: "new-placeholder", + ) + + assert result["providers"]["usda"] == { + "configured": True, + "reachable": True, + "key_source": "user_config", + } + assert usda.keys == ["new-placeholder"] + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + +def test_invalid_key_writes_nothing(tmp_path): + path = tmp_path / "config.toml" + + class InvalidUSDA: + def probe(self, api_key): + raise NomnomError("usda_key_invalid", "invalid") + + with pytest.raises(NomnomError) as caught: + setup_providers( + interactive=True, + config=ProviderConfig(environ={}, config_path=path), + off_client=HealthyOFF(), + usda_client=InvalidUSDA(), + prompt=lambda _: "invalid-placeholder", + ) + + assert caught.value.code == "usda_key_invalid" + assert not path.exists() + + +def test_noninteractive_setup_never_prompts(tmp_path): + def unexpected_prompt(_): + pytest.fail("noninteractive setup must not prompt") + + with pytest.raises(NomnomError) as caught: + setup_providers( + interactive=False, + config=ProviderConfig(environ={}, config_path=tmp_path / "config.toml"), + off_client=HealthyOFF(), + usda_client=HealthyUSDA(), + prompt=unexpected_prompt, + ) + + assert caught.value.code == "setup_requires_interactive" + assert "nomnom doctor --json" in caught.value.details["action"] + + +def test_noninteractive_setup_uses_environment_key_without_prompt(tmp_path): + def unexpected_prompt(_): + pytest.fail("configured noninteractive setup must not prompt") + + usda = HealthyUSDA() + result = setup_providers( + interactive=False, + config=ProviderConfig( + environ={"NOMNOM_USDA_KEY": "environment-placeholder"}, + config_path=tmp_path / "config.toml", + ), + off_client=HealthyOFF(), + usda_client=usda, + prompt=unexpected_prompt, + ) + + assert result["providers"]["usda"]["key_source"] == "environment" + assert usda.keys == ["environment-placeholder"] + assert not (tmp_path / "config.toml").exists() + + +def test_doctor_report_is_deterministic_and_never_contains_key(tmp_path): + path = tmp_path / "config.toml" + config = ProviderConfig(environ={}, config_path=path) + config.store_usda_key("never-print-placeholder") + report = doctor_report( + config=config, off_client=HealthyOFF(), usda_client=HealthyUSDA() + ) + + assert report == { + "providers": { + "openfoodfacts": {"configured": True, "reachable": True}, + "usda": { + "configured": True, + "reachable": True, + "key_source": "user_config", + }, + } + } + assert "never-print-placeholder" not in json.dumps(report) diff --git a/tests/test_db.py b/tests/test_db.py index b1c7252..ec13547 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -152,6 +152,8 @@ def test_connect_migrates_v1_database_without_losing_data(tmp_path): None, None, None, + None, + None, ) assert tuple(connection.execute("SELECT * FROM log_entries").fetchone()) == ( 7, @@ -196,9 +198,9 @@ def test_connect_creates_fresh_database_at_latest_schema(tmp_path): } -def test_connect_migrates_v2_to_v3_without_losing_user_data(v2_database): +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] == 3 + assert connection.execute("PRAGMA user_version").fetchone()[0] == LATEST_SCHEMA_VERSION assert tuple(connection.execute("SELECT * FROM food_cache").fetchone()) == ( "v2 egg", 155.0, @@ -213,6 +215,8 @@ def test_connect_migrates_v2_to_v3_without_losing_user_data(v2_database): "Fixture", "v2 egg fixture", "[]", + None, + None, ) assert tuple(connection.execute("SELECT * FROM log_entries").fetchone()) == ( 9, @@ -240,7 +244,7 @@ def test_connect_migrates_v2_to_v3_without_losing_user_data(v2_database): assert connection.execute("SELECT count(*) FROM food_aliases").fetchone()[0] == 0 with connect(v2_database) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 3 + assert connection.execute("PRAGMA user_version").fetchone()[0] == LATEST_SCHEMA_VERSION assert connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 1 assert connection.execute("SELECT count(*) FROM log_entries").fetchone()[0] == 1 assert connection.execute("SELECT count(*) FROM recipes").fetchone()[0] == 1 diff --git a/tests/test_foods.py b/tests/test_foods.py index e99924d..1b6643f 100644 --- a/tests/test_foods.py +++ b/tests/test_foods.py @@ -154,7 +154,7 @@ def test_missing_usda_key_has_exact_actionable_setup_error(repository, monkeypat assert caught.value.code == "usda_key_required" assert caught.value.message == ( f"USDA FoodData Central API key required. Get a free key at {setup_url}, " - "then set NOMNOM_USDA_KEY." + "then run nomnom setup or set NOMNOM_USDA_KEY." ) assert caught.value.details["setup_url"] == setup_url @@ -171,6 +171,9 @@ def test_usda_fallback_is_cached_with_runtime_source_and_fdc_id( repository, monkeypatch, food_fixtures ): class Response: + status_code = 200 + headers = {} + def raise_for_status(self): return None @@ -181,24 +184,29 @@ def json(self): monkeypatch.setenv("NOMNOM_DISABLE_OFF", "1") monkeypatch.setattr(requests, "get", lambda *args, **kwargs: Response()) food, confidence = repository.resolve("chickpeas cooked") - assert (food.kcal, confidence) == (164, 0.72) + assert food.kcal == 164 + assert round(confidence, 2) == 0.98 assert food.fdc_id == 175200 assert food.piece_grams == 130 row = repository.user_connection.execute( - "SELECT source, fdc_id FROM food_cache WHERE name = 'chickpeas, cooked'" + """SELECT source, fdc_id, piece_grams_source, piece_grams_source_value + FROM food_cache WHERE name = 'chickpeas, cooked'""" ).fetchone() - assert tuple(row) == ("usda", 175200) + assert tuple(row) == ("usda", 175200, "servingSize", "130 g") def test_usda_fallback_replaces_low_confidence_off_candidate( repository, monkeypatch, food_fixtures ): class Response: + status_code = 200 + headers = {} + def raise_for_status(self): return None def json(self): - if "world.openfoodfacts.org" in self.url: + if "openfoodfacts.org" in self.url: return {"products": [food_fixtures["off"]["cheese"]]} return {"foods": [food_fixtures["usda"]]} @@ -210,15 +218,18 @@ def get(url, *args, **kwargs): monkeypatch.setenv("NOMNOM_USDA_KEY", "test-key") monkeypatch.setattr(requests, "get", get) - food, confidence = repository.resolve("кедровые орехи") + food, confidence = repository.resolve("chickpeas cooked") assert food.source == "usda" assert food.fdc_id == 175200 - assert confidence == 0.72 + assert round(confidence, 2) == 0.98 def test_usda_no_result_is_structured_and_actionable(repository, monkeypatch): class Response: + status_code = 200 + headers = {} + def raise_for_status(self): return None diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..e9d2aeb --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def test_installer_prompts_setup_and_doctor_before_first_log(): + root = Path(__file__).resolve().parents[1] + result = subprocess.run( + ["sh", "install.sh", "--dry-run"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + + assert "nomnom setup" in result.stdout + assert "nomnom doctor --json" in result.stdout + assert "before the first food log" in result.stdout diff --git a/tests/test_off.py b/tests/test_off.py index 8605e29..70338a3 100644 --- a/tests/test_off.py +++ b/tests/test_off.py @@ -3,15 +3,17 @@ import pytest import requests -from nomnomcli.errors import NomnomError +from nomnomcli.errors import NomnomError, ProviderUnavailableError from nomnomcli.off import OFF_SEARCH_URL, OpenFoodFactsClient +from nomnomcli.providers import RetryPolicy class Response: - def __init__(self, payload=None, *, status_code=200, json_error=None): + def __init__(self, payload=None, *, status_code=200, json_error=None, headers=None): self.payload = payload self.status_code = status_code self.json_error = json_error + self.headers = headers or {} def raise_for_status(self): if self.status_code >= 400: @@ -48,6 +50,8 @@ def fake_get(url, **kwargs): monkeypatch.setattr(requests, "get", fake_get) foods = OpenFoodFactsClient().search("Acme bread", page_size=3) + assert OFF_SEARCH_URL == "https://api.openfoodfacts.org/api/v2/search" + assert "world.openfoodfacts.org" not in captured["url"] assert captured == { "url": OFF_SEARCH_URL, "params": { @@ -83,10 +87,12 @@ def test_off_rejects_candidate_with_nonpositive_core_nutrient(monkeypatch): def test_off_http_503_is_clear(monkeypatch): monkeypatch.setattr(requests, "get", lambda *args, **kwargs: Response(status_code=503)) - with pytest.raises(NomnomError) as caught: - OpenFoodFactsClient().search("Acme bread") + with pytest.raises(ProviderUnavailableError) as caught: + OpenFoodFactsClient(sleep=lambda _: None).search("Acme bread") assert caught.value.code == "openfoodfacts_unavailable" assert caught.value.details["status"] == 503 + assert caught.value.details["attempts"] == 3 + assert caught.value.details["retryable"] is True assert "nomnom add" in caught.value.details["offline_escape"] @@ -95,10 +101,11 @@ def fail(*args, **kwargs): raise requests.ConnectionError("offline") monkeypatch.setattr(requests, "get", fail) - with pytest.raises(NomnomError) as caught: + with pytest.raises(ProviderUnavailableError) as caught: OpenFoodFactsClient().search("Acme bread") assert caught.value.code == "openfoodfacts_unavailable" assert caught.value.details["reason"] == "network_error" + assert caught.value.retryable is True def test_off_malformed_json_is_clear(monkeypatch): @@ -119,3 +126,46 @@ def test_off_ambiguous_results_keep_relevance_order(monkeypatch): monkeypatch.setattr(requests, "get", lambda *args, **kwargs: Response(payload)) foods = OpenFoodFactsClient().search("Acme bread") assert [food.barcode for food in foods] == ["1", "2"] + + +def test_off_retries_429_and_5xx_with_injected_safe_delays(): + responses = iter( + [ + Response(status_code=429, headers={"Retry-After": "1.5"}), + Response(status_code=503, headers={"Retry-After": "not-numeric"}), + Response({"products": [product()]}), + ] + ) + calls = [] + sleeps = [] + + def get(url, **kwargs): + calls.append((url, kwargs)) + return next(responses) + + client = OpenFoodFactsClient( + request_get=get, + retry_policy=RetryPolicy(max_attempts=3, backoff_base=0.5), + sleep=sleeps.append, + ) + assert client.search("Acme bread")[0].barcode == "0123456789012" + assert len(calls) == 3 + assert sleeps == [1.5, 1.0] + assert all(call[0] == OFF_SEARCH_URL for call in calls) + + +def test_off_oversized_retry_after_uses_bounded_backoff(): + responses = iter( + [ + Response(status_code=503, headers={"Retry-After": "999999"}), + Response({"products": []}), + ] + ) + sleeps = [] + client = OpenFoodFactsClient( + request_get=lambda *args, **kwargs: next(responses), + retry_policy=RetryPolicy(max_attempts=2, backoff_base=0.25, max_retry_after=5), + sleep=sleeps.append, + ) + assert client.search("Acme bread") == [] + assert sleeps == [0.25] diff --git a/tests/test_parser.py b/tests/test_parser.py index feb0a18..87d2370 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -13,12 +13,26 @@ def test_descriptor_weight_comes_from_resolved_food(): class Repository: def resolve(self, query): assert query == "egg" - return Food("runtime egg", 155, 12.6, 10.6, 1.1, piece_grams=52), 0.8 + return ( + Food( + "runtime egg", + 155, + 12.6, + 10.6, + 1.1, + piece_grams=52, + piece_grams_source="servingSize", + piece_grams_source_value="52 g", + source="usda", + ), + 0.8, + ) item = parse_item_phrase("small egg", Repository()) assert item.name == "runtime egg" assert item.grams == 52 + assert item.assumption == "1 small egg = 52g (source: usda.servingSize=52 g)" def test_descriptor_without_runtime_piece_weight_asks_for_grams(): @@ -33,6 +47,18 @@ def resolve(self, query): assert "provide grams" in caught.value.message +def test_descriptor_rejects_opaque_legacy_piece_weight(): + class Repository: + def resolve(self, query): + return Food("legacy cache record", 60, 3, 2, 8, piece_grams=64), 1.0 + + with pytest.raises(NomnomError) as caught: + parse_item_phrase("small legacy record", Repository()) + + assert caught.value.code == "piece_weight_unknown" + assert caught.value.details["reason"] == "serving_weight_provenance_missing" + + def test_parse_russian_grams(seeded_repository): item = parse_item_phrase("борщ 300г", seeded_repository) assert item.name == "borscht" @@ -120,8 +146,8 @@ def test_parse_size_descriptors(seeded_repository, phrase, expected_grams): ], ) def test_parse_piece_fractions(seeded_repository, fraction, factor): - item = parse_item_phrase(f"{fraction} small tomato", seeded_repository) - assert item.name == "tomato, raw" + item = parse_item_phrase(f"{fraction} small fixture pod", seeded_repository) + assert item.name == "fixture pod" assert item.grams == 100 * factor assert item.to_dict()["assumed"] is True @@ -139,16 +165,29 @@ def test_parse_explicit_each_grams_for_shtuk(seeded_repository): assert item.grams == 150 +def test_leading_piece_count_with_explicit_user_grams_wins(): + class Repository: + def resolve(self, query): + assert query == "egg whole cooked fried" + return Food("provider candidate", 155, 12.6, 10.6, 1.1, piece_grams=52), 0.9 + + item = parse_item_phrase("3 pieces egg whole cooked fried at 38g", Repository()) + + assert item.grams == 114 + assert item.kcal == 176.7 + assert item.assumption is None + + def test_decompose_dish_prefix_with_russian_inflection(seeded_repository): items = parse_free_text( - "яичница из 3 небольших яиц, половины небольшого томата " - "и половины средней луковицы, хлеб 2 куска по 40г", + "яичница из 3 небольших яиц, half small fixture pod " + "и half medium fixture bulb, хлеб 2 куска по 40г", seeded_repository, ) assert [item.name for item in items] == [ "egg, whole, boiled", - "tomato, raw", - "onion, raw", + "fixture pod", + "fixture bulb", "bread, wheat", ] assert [item.grams for item in items] == [150, 50, 40, 80] @@ -159,7 +198,7 @@ def test_decompose_dish_prefix_with_russian_inflection(seeded_repository): @pytest.mark.parametrize("prefix", ["яичница из", "омлет из", "салат из", "каша из"]) def test_all_dish_prefixes_split_conjunctions(seeded_repository, prefix): items = parse_free_text( - f"{prefix} small tomato и medium onion", seeded_repository + f"{prefix} small fixture pod и medium fixture bulb", seeded_repository ) assert [item.grams for item in items] == [100, 80] diff --git a/tests/test_usda.py b/tests/test_usda.py new file mode 100644 index 0000000..2588897 --- /dev/null +++ b/tests/test_usda.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import pytest +import requests + +from nomnomcli.errors import NomnomError +from nomnomcli.usda import USDA_SEARCH_URL, USDAClient + + +class Response: + def __init__(self, payload, status_code=200): + self.payload = payload + self.status_code = status_code + self.headers = {} + + def raise_for_status(self): + if self.status_code >= 400: + raise requests.HTTPError(response=self) + + def json(self): + return self.payload + + +def nutrients(*, kcal=180, protein=9, fat=4, carbs=28): + return [ + { + "nutrient": {"id": 1008, "name": "Energy", "unitName": "KCAL"}, + "amount": kcal, + }, + {"nutrientId": 1003, "nutrientName": "Protein", "unitName": "G", "value": protein}, + { + "nutrientNumber": "204", + "description": "Total lipid (fat)", + "unitName": "g", + "value": fat, + }, + { + "nutrientName": "Carbohydrate, by difference", + "unitName": "grams", + "value": carbs, + }, + ] + + +def record( + description="Sample legumes, cooked", + *, + fdc_id=100, + data_type="Foundation", + category="Legumes and legume products", + food_nutrients=None, + serving=True, +): + result = { + "fdcId": fdc_id, + "description": description, + "dataType": data_type, + "foodCategory": category, + "foodNutrients": nutrients() if food_nutrients is None else food_nutrients, + } + if serving: + result.update({"servingSize": 45, "servingSizeUnit": "g"}) + return result + + +def test_usda_uses_official_endpoint_and_parses_actual_nutrient_schema(): + captured = {} + + def get(url, **kwargs): + captured.update({"url": url, **kwargs}) + return Response({"foods": [record()]}) + + food, confidence = USDAClient(request_get=get).resolve( + "sample legumes cooked", "placeholder" + ) + + assert captured["url"] == USDA_SEARCH_URL + assert captured["params"] == { + "api_key": "placeholder", + "query": "sample legumes cooked", + "pageSize": 10, + } + assert (food.kcal, food.protein, food.fat, food.carbs) == (180, 9, 4, 28) + assert food.fdc_id == 100 + assert food.source == "usda" + assert confidence >= 0.8 + + +def test_usda_probe_is_minimal_and_invalid_key_is_structured(): + calls = [] + + def valid_get(url, **kwargs): + calls.append((url, kwargs)) + return Response({"foods": []}) + + assert USDAClient(request_get=valid_get).probe("placeholder") is True + assert calls == [ + ( + USDA_SEARCH_URL, + { + "params": {"api_key": "placeholder", "query": "a", "pageSize": 1}, + "timeout": 15, + }, + ) + ] + + invalid = USDAClient( + request_get=lambda *args, **kwargs: Response({}, status_code=403) + ) + with pytest.raises(NomnomError) as caught: + invalid.probe("invalid-placeholder") + assert caught.value.code == "usda_key_invalid" + + +@pytest.mark.parametrize("missing_key", ["kcal", "protein", "fat", "carbs"]) +def test_usda_rejects_each_zero_or_missing_core_nutrient(missing_key): + values = {"kcal": 180, "protein": 9, "fat": 4, "carbs": 28} + values[missing_key] = 0 + candidate = record( + food_nutrients=nutrients( + kcal=values["kcal"], + protein=values["protein"], + fat=values["fat"], + carbs=values["carbs"], + ) + ) + client = USDAClient(request_get=lambda *args, **kwargs: Response({"foods": [candidate]})) + + with pytest.raises(NomnomError) as caught: + client.resolve("sample legumes cooked", "placeholder") + + assert caught.value.code == "usda_invalid_nutrition" + assert missing_key in caught.value.details["rejected_candidates"][0][ + "missing_or_nonpositive_core" + ] + + +def test_usda_rejects_wrong_energy_unit(): + candidate = record() + candidate["foodNutrients"][0]["nutrient"]["unitName"] = "KJ" + client = USDAClient(request_get=lambda *args, **kwargs: Response({"foods": [candidate]})) + + with pytest.raises(NomnomError) as caught: + client.resolve("sample legumes cooked", "placeholder") + + assert caught.value.code == "usda_invalid_nutrition" + assert "kcal" in caught.value.details["rejected_candidates"][0][ + "missing_or_nonpositive_core" + ] + + +def test_usda_rejects_known_289_kcal_processed_onion_near_match(): + processed = record( + "Onions, dehydrated flakes", + data_type="SR Legacy", + category="Vegetables and vegetable products", + food_nutrients=nutrients(kcal=289, protein=10.9, fat=1, carbs=70), + ) + client = USDAClient(request_get=lambda *args, **kwargs: Response({"foods": [processed]})) + + with pytest.raises(NomnomError) as caught: + client.resolve("onion", "placeholder") + + assert caught.value.code == "usda_low_confidence" + assert caught.value.details["candidate"]["confidence"] < caught.value.details["threshold"] + + +def test_usda_prefers_foundation_over_branded_candidate(): + branded = record("Sample meal", fdc_id=1, data_type="Branded", category="Sample") + foundation = record("Sample meal", fdc_id=2, data_type="Foundation", category="Sample") + client = USDAClient( + request_get=lambda *args, **kwargs: Response({"foods": [branded, foundation]}) + ) + + food, confidence = client.resolve("sample meal", "placeholder") + + assert food.fdc_id == 2 + assert confidence > food.alternatives[0]["confidence"] + assert food.alternatives[0]["fdc_id"] == 1 + + +def test_usda_serving_weight_provenance_comes_only_from_returned_fields(): + client = USDAClient( + request_get=lambda *args, **kwargs: Response({"foods": [record(serving=True)]}) + ) + food, _ = client.resolve("sample legumes cooked", "placeholder") + assert food.piece_grams == 45 + assert food.piece_grams_source == "servingSize" + assert food.piece_grams_source_value == "45 g" + + without_serving = USDAClient( + request_get=lambda *args, **kwargs: Response({"foods": [record(serving=False)]}) + ) + food_without_serving, _ = without_serving.resolve( + "sample legumes cooked", "placeholder" + ) + assert food_without_serving.piece_grams is None + assert food_without_serving.piece_grams_source is None