diff --git a/README.md b/README.md index a79e500..6945c3f 100644 --- a/README.md +++ b/README.md @@ -94,16 +94,21 @@ Resolution is deterministic and ordered: 1. exact phrase in the user's alias table, pointing to an exact local cache name; 2. exact match in the user's `food_cache`; 3. token-overlap search in that cache; -4. strict Open Food Facts free-text search; -5. a safe USDA FoodData Central generic proxy, when a setup key or +4. an exact Open Food Facts product only when the input explicitly matches its brand/SKU; +5. a safe USDA FoodData Central Foundation/SR Legacy generic proxy, when a setup key or `NOMNOM_USDA_KEY` is configured; -6. actionable JSON error—never a guessed food. +6. a strictly matching Open Food Facts generic proxy, including a labelled branded candidate only + when its returned product name/type is safe for the unbranded input; +7. actionable JSON error—never a guessed food. Open Food Facts candidates need complete normalized query-token coverage from the returned product -name/brand, a matching food type/category, a source identity/barcode, and complete positive finite -kcal, protein, fat, and carbs. A branded source identity is cached as `exact_product`; an unbranded -source identity is truthfully cached as `generic_proxy` and remains subject to the configured proxy -policy. Rejected results are neither cached nor logged. +name, a matching food type/category, a source identity/barcode, and complete positive finite kcal, +protein, fat, and carbs. An exact product requires a user-supplied barcode, an explicitly matched +brand/SKU, or an exact local pin/alias. Candidate confidence never establishes exact identity. A +safe branded OFF candidate used for unbranded text is always a `generic_proxy`, retains its source, +brand, barcode, and explicit assumption, and remains subject to the configured proxy policy. An +unsafe candidate returns `food_needs_source`. Rejected results are neither cached nor logged, and a +generic-proxy cache entry cannot satisfy a later branded query. Successful API results are cached in the user's database, so the same food can resolve locally later. Existing cache records, logs, recipes, and aliases are preserved when v0.4 opens the @@ -154,7 +159,7 @@ confidence floor. Weak matches return `usda_low_confidence` with candidate alter 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 +when it is an unbranded Foundation or SR Legacy 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, @@ -241,7 +246,8 @@ required; modifiers may express a fraction, size, or explicit per-piece mass. An agent translates the user's language into this contract before invoking nomnom. Translation may choose a canonical food name already known to the user cache or an explicit user alias. Nutrition -resolution is a separate deterministic step: alias → local cache → Open Food Facts → USDA → error. +resolution is a separate deterministic step: alias → local cache → exact intent or generic +provider proxy → error. The agent must not translate by inventing nutrition values or silently substituting another food. ## Quantities, sizes, and dishes @@ -286,7 +292,7 @@ an `error` object and exit with status 2. Important codes include: - `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. -- `generic_proxy_confirmation_required`: show the named USDA candidate and ask before changing the +- `generic_proxy_confirmation_required`: show the named provider 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 diff --git a/docs/plans.md b/docs/plans.md index 2d02ff9..9907069 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -1,5 +1,61 @@ # Plans +## Issue #29 Source +- Task: Ensure unbranded foods resolve only to truthful generic proxies, never arbitrary branded exact products. +- Canonical input: GitHub issue #29 and the user's strict TDD, smoke, commit, and no-push requirements. +- Repo context: resolver intent, OFF/USDA selection, local cache/aliases, CLI/API JSON, docs, and agent skill. +- Last updated: 2026-07-21 + +## Issue #29 Assumptions +- Exact identity is established only by user barcode capture, an explicit brand/SKU match in the query, or an exact local pin/alias; provider confidence alone cannot establish exact identity. +- USDA generic lookup should request and rank Foundation/SR Legacy records ahead of any branded data, without embedding nutrition facts or food aliases in production. +- A branded OFF source may represent an unbranded query only as an explicitly assumed generic proxy after strict name/category/core-nutrient checks. + +## Issue #29 Milestone Order +| ID | Title | Depends on | Status | +| --- | --- | --- | --- | +| M25 | Freeze identity, provider, cache, policy, and literal contracts | M24 | [x] | +| M26 | Implement intent-aware generic proxy resolution | M25 | [x] | +| M27 | Document identity semantics and verify release gates | M26 | [x] | + +## M25. Freeze issue #29 contracts `[x]` +### Goal +- Focused tests reproduce arbitrary branded OFF exact matches, realistic USDA generic-versus-branded ranking, exact user intent, cache isolation, all policies, and the literal acceptance list. + +### Validation +```sh +PYTHONPATH=. pytest -q tests/test_foods.py tests/test_usda.py tests/test_off.py +``` + +### Stop-and-Fix Rule +- Record the expected RED failures before changing resolver/provider production behavior. + +## M26. Implement intent-aware generic proxy resolution `[x]` +### Goal +- Unbranded input returns only a clearly sourced safe generic proxy or structured `food_needs_source`; exact user intent remains exact-only and cache entries cannot cross identity boundaries. + +### Validation +```sh +PYTHONPATH=. pytest -q tests/test_foods.py tests/test_usda.py tests/test_off.py tests/test_cli.py +``` + +### Stop-and-Fix Rule +- Any arbitrary branded `exact_product`, generic fallback for branded/barcode intent, unsafe cache write, or missing provenance blocks documentation work. + +## M27. Document identity semantics and verify release gates `[x]` +### Goal +- README and agent skill distinguish exact products from generic proxies, and all tests, lint, literal isolated smoke, diff audit, and local commit requirements pass. + +### Validation +```sh +PYTHONPATH=. pytest -q +ruff check . +git diff --check +``` + +### Stop-and-Fix Rule +- Do not commit until the full suite, Ruff, clean temp-data smoke, and scoped diff audit pass. + ## Issue #27 Source - Task: Add safe backdated meal logging and local-date-scoped stats. - Canonical input: GitHub issue #27 and the user's strict TDD, smoke, commit, and no-push requirements. diff --git a/docs/status.md b/docs/status.md index 8738257..58cbeb4 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,12 +1,15 @@ # Status ## Snapshot -- Current phase: issue #27 implemented and independently verified +- Current phase: issue #29 implemented and independently verified - Plan file: `docs/plans.md` - Status: green - Last updated: 2026-07-21 ## Done +- Completed issue #29 with intent-aware exact identity, USDA Foundation/SR Legacy preference, strict OFF proxy type matching, branded-proxy provenance, and cache intent isolation. +- Recorded RED runs for 16 primary regressions plus two cache edges and missing-category evidence, then passed 114 focused tests, 204 full tests, and Ruff. +- Ran all six literal translated inputs against mocked providers in a fresh temporary data directory; all six were explicit `generic_proxy` rows with source, brand, barcode, and assumption. - Completed issue #27 with strict local-date parsing, deterministic local-noon backdating for both log forms, additive timestamp/date JSON, exact date stats, no-write failures, and preserved no-date/today/week behavior. - Documented `--date` for remembered meals and that humans/agents must never manipulate SQLite directly. - Passed 189 tests, Ruff, diff checks, checkout import proof, and the clean disposable installed-CLI literal smoke. @@ -24,12 +27,15 @@ - Passed 177 tests, Ruff, checkout-import guard, shell syntax, and the disposable checkout-built installer/provider-stub smoke. ## In Progress -- No implementation work pending; the feature is ready for the issue #27 pull request. +- No implementation work pending; issue #29 is verified and committed locally. ## Next -- Push the scoped commit and open the requested pull request. +- Do not push or open a pull request unless the user requests it separately. ## Decisions Made +- Issue #29: resolution mode follows user identity intent plus source identity, never candidate confidence alone. +- Issue #29: generic USDA searches send the documented `dataType` array for Foundation and SR Legacy and rank eligible non-branded provenance ahead of branded payload rows. +- Issue #29: a branded OFF candidate needs product-name token coverage plus category/type evidence to serve only as a labelled generic proxy. - Issue #27: reuse the existing offset-aware `logged_at` field; no migration is needed. - Issue #27: explicit dates become local noon and stats use `[local midnight, next local midnight)` boundaries. - Issue #23: a healthy no-key install is complete base coverage; USDA is only an optional enhancement for broader no-photo generic/raw-food resolution. @@ -81,6 +87,8 @@ ruff check . | 2026-07-21 | M21–M22 | CLI date tests and `nomnomcli/cli.py` | focused RED then GREEN pytest | 7 expected failures; then 7 pass | M23 | | 2026-07-21 | M23 | database/CLI date stats tests and query implementation | focused RED then GREEN pytest | 2 expected failures; then 8 pass | M24 | | 2026-07-21 | M24 | README, skill, changed code/tests, disposable installed checkout | focused/full pytest; Ruff; diff check; literal temp-DB smoke | 40/189 pass; clean; smoke 150 kcal | local commit | +| 2026-07-21 | M25 | resolver/provider regression tests | focused pytest | RED: 16 primary failures; 2 cache failures; 1 category-evidence failure | M26 | +| 2026-07-21 | M26–M27 | resolver, USDA, docs, skill, tests | `pytest -q`; `ruff check .`; temp-data mocked-provider smoke | 114 focused; 204 full; clean; 6/6 generic proxies | local commit | ## Smoke / Demo Checklist - [x] Fresh temp DB: help/version, capture label, alias, log, and invalid structured capture error. @@ -100,3 +108,4 @@ ruff check . - [x] Setup reports `base_ready/base` without USDA and `connected/enhanced` with the local USDA stub. - [x] Safe no-key miss returns `food_needs_source` with photo/barcode/label/cache options and optional USDA. - [x] Literal parsed/direct `2026-07-20` logs persist local noon and date stats return only that local day. +- [x] Issue #29 literal six-item temp-data smoke returns only explicit generic proxies with audited OFF candidate identity. diff --git a/docs/test-plan.md b/docs/test-plan.md index 3f1d876..7542446 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -1,5 +1,27 @@ # Test Plan +## Issue #29 Validation +- In scope: resolver intent, OFF safe generic proxy semantics, USDA request/ranking provenance, exact barcode/brand/pin/alias paths, cache isolation, generic policy writes, CLI/API JSON, and literal translated inputs. +- Fixtures: mocked OFF and USDA payloads only, temporary SQLite user databases, no live traffic or personal data. +- Critical negative cases: HSN soy isolate, arbitrary cream-cheese barcode, Menguy's peanuts, branded USDA outranking attempts, unsafe branded queries, and rejected-candidate no-write behavior. +- Literal smoke inputs: `milk 3% 625 ml`, `soy protein isolate 30 g`, `chicken pastrami 150 g`, `whole wheat bread 140 g`, `cream cheese 40 g`, and `peanuts 55 g`; every success must be `generic_proxy`, and unsafe resolution must be structured. + +### Issue #29 Acceptance Gates +- [x] Focused tests observed RED before production changes. +- [x] Focused resolver/provider/CLI/docs tests pass (114 tests). +- [x] Full `PYTHONPATH=. pytest -q` passes (204 tests). +- [x] `ruff check .` and `git diff --check` pass. +- [x] Fresh temp-data-dir literal mocked-provider smoke proves modes/provenance and no unsafe writes. +- [x] Scoped conventional commit exists; worktree is clean; nothing is pushed. + +### Issue #29 Command Matrix +```sh +PYTHONPATH=. pytest -q tests/test_foods.py tests/test_usda.py tests/test_off.py tests/test_cli.py +PYTHONPATH=. pytest -q +ruff check . +git diff --check +``` + ## Issue #27 Source - Task: Validate safe backdated logging and local-date-scoped stats. - Plan file: `docs/plans.md` diff --git a/nomnomcli/foods.py b/nomnomcli/foods.py index 206ef08..4bfe50f 100644 --- a/nomnomcli/foods.py +++ b/nomnomcli/foods.py @@ -14,7 +14,7 @@ from nomnomcli.usda import USDAClient USDA_SETUP_URL = "https://fdc.nal.usda.gov/api-key-signup.html" -GENERIC_USDA_DATA_TYPES = frozenset({"foundation", "sr legacy", "survey (fndds)"}) +GENERIC_USDA_DATA_TYPES = frozenset({"foundation", "sr legacy"}) EXACT_CAPTURE_ACTION = ( "Provide the package barcode or photo so the agent can run nomnom capture " "barcode or nomnom capture label" @@ -71,39 +71,113 @@ def _candidate_details(food: Food, confidence: float) -> dict: def _brand_matches_query(food: Food, query: str) -> bool: if not food.brand: return False - normalized_query = normalize_name(query) + query_tokens = _name_tokens(query) brand_parts = re.split(r"[,;/|]+", food.brand) return any( - normalized_brand and normalized_brand in normalized_query + brand_tokens + and brand_tokens <= query_tokens + and bool(query_tokens - brand_tokens) for part in brand_parts - if (normalized_brand := normalize_name(part)) + if (brand_tokens := _name_tokens(part)) ) +def _query_has_sku(query: str) -> bool: + return bool(re.search(r"(? set[str]: + if " — " in food.name: + product_name = food.name.split(" — ", 1)[0] + return _name_tokens(product_name) + tokens = _name_tokens(food.name) + return tokens - _name_tokens(food.brand or "") if food.brand else tokens + + 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 + candidate_tokens = ( + _off_product_tokens(food) if food.source == "openfoodfacts" else _name_tokens(food.name) + ) + return ( + bool(query_tokens) + and not _query_has_sku(query) + and not _brand_matches_query(food, query) + and query_tokens <= candidate_tokens ) -def _off_candidate_query_is_safe(query: str, food: Food) -> bool: - if food.brand is None: - return _generic_proxy_query_is_safe(query, food) +def _off_exact_candidate_query_is_safe(query: str, food: Food) -> bool: query_tokens = _name_tokens(query) - candidate_tokens = _name_tokens(" ".join((food.name, food.brand))) + candidate_tokens = _name_tokens( + " ".join(value for value in (food.name, food.brand, food.barcode) if value) + ) return bool(query_tokens) and query_tokens <= candidate_tokens +def _off_generic_candidate_query_is_safe(query: str, food: Food) -> bool: + query_tokens = _name_tokens(query) + category_tokens = _name_tokens(" ".join(food.categories)) + return ( + bool(category_tokens) + and bool(query_tokens & category_tokens) + and _generic_proxy_query_is_safe(query, food) + ) + + +def _exact_product_intent(query: str, food: Food) -> bool: + return ( + bool(food.barcode and normalize_name(query) == normalize_name(food.barcode)) + or _brand_matches_query(food, query) + or (_query_has_sku(query) and _off_exact_candidate_query_is_safe(query, food)) + ) + + +def _off_candidate_query_is_safe(query: str, food: Food, *, exact_intent: bool) -> bool: + if exact_intent: + return _exact_product_intent(query, food) and _off_exact_candidate_query_is_safe( + query, food + ) + return _off_generic_candidate_query_is_safe(query, food) + + def _generic_proxy_candidate(food: Food, confidence: float) -> dict: - return { + candidate = { "name": food.name, "source": food.source, "source_id": food.source_id or (str(food.fdc_id) if food.fdc_id is not None else None), "resolution_mode": "generic_proxy", "confidence": round(confidence, 2), } + if food.brand: + candidate.update( + { + "brand": food.brand, + "barcode": food.barcode, + "assumption": food.assumption, + } + ) + return {key: value for key, value in candidate.items() if value is not None} + + +def _off_proxy_assumption(food: Food) -> str: + if not food.brand: + return f"Brand not specified; used Open Food Facts generic proxy: {food.name}." + identity = f"brand: {food.brand}" + if food.barcode: + identity += f"; barcode: {food.barcode}" + return ( + "Brand not specified; used Open Food Facts generic proxy from candidate " + f"{food.name} ({identity})." + ) + + +def _cached_food_query_is_safe(query: str, food: Food, *, exact_name: bool = False) -> bool: + if food.resolution_mode == "generic_proxy": + return _generic_proxy_query_is_safe(query, food) + if food.resolution_mode == "exact_product" and food.source == "openfoodfacts": + return exact_name or _exact_product_intent(query, food) + return True def _food_needs_source_error( @@ -302,11 +376,19 @@ def _find_exact(self, name: str) -> Food | None: cached = self.user_connection.execute( """SELECT * FROM food_cache WHERE name = ? COLLATE NOCASE OR lookup_query = ? COLLATE NOCASE - ORDER BY CASE WHEN lookup_query = ? COLLATE NOCASE THEN 0 ELSE 1 END - LIMIT 1""", + ORDER BY CASE WHEN lookup_query = ? COLLATE NOCASE THEN 0 ELSE 1 END""", (name, normalize_name(name), normalize_name(name)), - ).fetchone() - return self._row_to_food(cached) if cached else None + ).fetchall() + for row in cached: + food = self._row_to_food(row) + if not _cached_food_query_is_safe( + name, + food, + exact_name=normalize_name(food.name) == normalize_name(name), + ): + continue + return food + return None def _find_name_exact(self, name: str) -> Food | None: cached = self.user_connection.execute( @@ -378,6 +460,9 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float off_enabled = remote_enabled and not os.getenv("NOMNOM_DISABLE_OFF") credential = self.provider_config.usda_credential() off_error: NomnomError | None = None + accepted: list[tuple[Food, float]] = [] + exact_intent = _query_has_sku(query) + off_matches: list[Food] = [] if off_enabled: try: off_matches = self.off_client.search(query, page_size=5) @@ -389,6 +474,7 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float (food for food in off_matches if _brand_matches_query(food, query)), None ) if matching_brand is not None: + exact_intent = True off_matches = [ matching_brand, *(food for food in off_matches if food is not matching_brand), @@ -399,13 +485,17 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float for match in scored if match[1] >= 0.5 and (match[0].source_id or match[0].barcode) - and _off_candidate_query_is_safe(query, match[0]) + and _off_candidate_query_is_safe( + query, match[0], exact_intent=exact_intent + ) ] if not accepted: candidate, confidence = scored[0] source_identity_missing = ( confidence >= 0.5 - and _off_candidate_query_is_safe(query, candidate) + and _off_candidate_query_is_safe( + query, candidate, exact_intent=exact_intent + ) and not (candidate.source_id or candidate.barcode) ) code = ( @@ -431,55 +521,60 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float "action": EXACT_CAPTURE_ACTION, }, ) - else: - accepted.sort(key=lambda match: -match[1]) - selected, confidence = accepted[0] - off_matches = [ - selected, - *(food for food in off_matches if food is not selected), - ] - alternatives = tuple( - { - key: value - for key, value in { - "name": alternative.name, - "brand": alternative.brand, - "barcode": alternative.barcode, - }.items() - if value is not None - } - for alternative in off_matches[1:] - ) - selected = off_matches[0] - if selected.brand is None: - food = replace( - selected, - alternatives=alternatives, - resolution_mode="generic_proxy", - source_id=selected.source_id or selected.barcode, - provenance=selected.provenance or "openfoodfacts", - assumption=( - "Brand not specified; used Open Food Facts generic proxy: " - f"{selected.name}." - ), - ) - food, confidence = self._apply_generic_proxy_policy(food, confidence) - else: - food = replace( - selected, - alternatives=alternatives, - resolution_mode="exact_product", - source_id=selected.source_id or selected.barcode, - provenance=selected.provenance or "openfoodfacts", - ) - self._cache_food(food, lookup_query=query) - return food, confidence + usda_error: NomnomError | None = None + if remote_enabled and credential is not None and not exact_intent: + try: + food, confidence = self.usda_client.resolve(query, credential.value) + except NomnomError as exc: + usda_error = exc + else: + food, confidence = self._prepare_usda_generic_proxy(query, food, confidence) + self._cache_food(food, lookup_query=query) + return food, confidence + + if accepted: + accepted.sort(key=lambda match: -match[1]) + selected, confidence = accepted[0] + alternatives = tuple( + { + key: value + for key, value in { + "name": alternative.name, + "brand": alternative.brand, + "barcode": alternative.barcode, + }.items() + if value is not None + } + for alternative in off_matches + if alternative is not selected + ) + if exact_intent: + food = replace( + selected, + alternatives=alternatives, + resolution_mode="exact_product", + source_id=selected.source_id or selected.barcode, + provenance=selected.provenance or "openfoodfacts", + assumption=None, + ) + else: + food = replace( + selected, + alternatives=alternatives, + resolution_mode="generic_proxy", + source_id=selected.source_id or selected.barcode, + provenance=selected.provenance or "openfoodfacts", + assumption=_off_proxy_assumption(selected), + ) + food, confidence = self._apply_generic_proxy_policy(food, confidence) + self._cache_food(food, lookup_query=query) + return food, confidence - if remote_enabled and credential is not None: + if usda_error is not None: + raise usda_error + if remote_enabled and credential is not None and exact_intent and not off_enabled: 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 raise _food_needs_source_error( query, provider_error=off_error, @@ -501,9 +596,7 @@ 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 - ): + if not _cached_food_query_is_safe(query, food): continue unique.setdefault(normalize_name(food.name), food) return list(unique.values())[:limit] @@ -587,9 +680,7 @@ 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 - ): + if not _cached_food_query_is_safe(query, food): continue candidate_tokens = _name_tokens( " ".join( diff --git a/nomnomcli/usda.py b/nomnomcli/usda.py index d79d9f7..24faf34 100644 --- a/nomnomcli/usda.py +++ b/nomnomcli/usda.py @@ -202,8 +202,18 @@ def __init__( self.retry_policy = retry_policy or RetryPolicy() self.sleep = sleep - def _payload(self, query: str, api_key: str, page_size: int) -> dict: + def _payload( + self, + query: str, + api_key: str, + page_size: int, + *, + data_types: list[str] | None = None, + ) -> dict: details = {"food": query} + params = {"api_key": api_key, "query": query, "pageSize": page_size} + if data_types is not None: + params["dataType"] = data_types response = request_with_retry( provider="usda", code="usda_unavailable", @@ -211,7 +221,7 @@ def _payload(self, query: str, api_key: str, page_size: int) -> dict: request_get=self._request_get or requests.get, url=USDA_SEARCH_URL, request_kwargs={ - "params": {"api_key": api_key, "query": query, "pageSize": page_size}, + "params": params, "timeout": 15, }, details=details, @@ -255,7 +265,12 @@ def probe(self, api_key: str) -> bool: return True def resolve(self, query: str, api_key: str) -> tuple[Food, float]: - records = self._payload(query, api_key, 10)["foods"] + records = self._payload( + query, + api_key, + 10, + data_types=["Foundation", "SR Legacy"], + )["foods"] if not records: raise NomnomError( "food_not_found", @@ -296,6 +311,10 @@ def resolve(self, query: str, api_key: str) -> tuple[Food, float]: candidates.sort( key=lambda item: ( + 0 + if item[0].brand is None + and item[1].casefold() in {"foundation", "sr legacy"} + else 1, -item[2], -_DATA_TYPE_QUALITY.get(item[1].casefold(), 0.5), item[0].name, diff --git a/skill/SKILL.md b/skill/SKILL.md index 68a468a..847f4ca 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -121,20 +121,18 @@ 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: cache/OFF → source request → optional USDA +## Unknown-food workflow: cache → exact intent / generic proxy → source request -The CLI automatically checks exact user alias, exact cache, cache search, then -Open Food Facts. +The CLI automatically checks exact user alias, exact cache, and safe cache search before providers. For an unresolved food: -1. Let strict OFF resolution run. A high-confidence unbranded source may be returned truthfully as - `generic_proxy`; an identified branded product remains `exact_product`. Always show source id, - provenance, assumptions, and alternatives. Never use a generic proxy for brand/SKU input. -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 +1. `exact_product` requires a user barcode, an explicitly matched/confirmed brand or SKU, or an + exact local pin/alias. Provider confidence never makes an arbitrary branded result `exact_product`. +2. For unbranded input, prefer USDA Foundation/SR Legacy when configured. The default + `allow_for_unbranded` policy accepts only non-branded 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. Use the environment - only for non-interactive/CI operation. + assumptions. A strictly name/type-matching OFF record may instead be a `generic_proxy`; if its + source candidate is branded, show that brand, barcode, source, and explicit assumption. 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. diff --git a/tests/test_data_quality.py b/tests/test_data_quality.py index 342489d..680410a 100644 --- a/tests/test_data_quality.py +++ b/tests/test_data_quality.py @@ -71,4 +71,7 @@ def test_v04_docs_define_safe_proxy_and_private_exact_capture_flow(): 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 "Candidate confidence never establishes exact identity." in readme + assert "unbranded text is always a `generic_proxy`" in readme + assert "never makes an arbitrary branded result `exact_product`" in skill assert len(skill.splitlines()) <= 200 diff --git a/tests/test_foods.py b/tests/test_foods.py index 96ae118..c6f0ee4 100644 --- a/tests/test_foods.py +++ b/tests/test_foods.py @@ -1,5 +1,7 @@ from __future__ import annotations +from dataclasses import replace + import pytest import requests @@ -8,6 +10,7 @@ from nomnomcli.errors import NomnomError from nomnomcli.foods import FoodRepository from nomnomcli.models import Food +from nomnomcli.parser import parse_free_text def _usda_generic_response(description="Chicken breast, roasted", *, branded=False): @@ -60,6 +63,113 @@ def json(self): return Response() +def _off_candidate( + name: str, + *, + brand: str | None, + barcode: str, + categories: tuple[str, ...], +) -> Food: + return Food( + name, + 200, + 20, + 5, + 10, + source="openfoodfacts", + barcode=barcode, + brand=brand, + categories=categories, + source_id=barcode, + provenance="openfoodfacts", + ) + + +@pytest.mark.parametrize( + ("query", "candidate"), + [ + ( + "soy protein isolate", + _off_candidate( + "Soy Protein Isolate 2.0 — HSN, HSN Essentials", + brand="HSN, HSN Essentials", + barcode="8435611324100", + categories=("en:soy-protein-isolates",), + ), + ), + ( + "cream cheese", + _off_candidate( + "cream-cheese — Cream cheese", + brand="Cream cheese", + barcode="8000000000024", + categories=("en:cream-cheeses",), + ), + ), + ( + "peanuts", + _off_candidate( + "Menguy's Peanut 100%", + brand="Menguy's", + barcode="3336970205050", + categories=("en:peanuts",), + ), + ), + ], +) +def test_unbranded_reported_off_matches_are_never_arbitrary_exact_products( + repository, monkeypatch, query, candidate +): + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + food, confidence = repository.resolve(query) + + assert confidence >= 0.5 + assert food.resolution_mode == "generic_proxy" + assert food.source == "openfoodfacts" + assert food.brand == candidate.brand + assert food.barcode == candidate.barcode + assert food.assumption is not None + assert candidate.brand in food.assumption + assert candidate.barcode in food.assumption + assert "Open Food Facts" in food.assumption + + +def test_unsafe_branded_off_match_needs_source_without_cache(repository, monkeypatch): + candidate = _off_candidate( + "Original spread — Cream Cheese", + brand="Cream Cheese", + barcode="8000000000093", + categories=("en:cream-cheeses",), + ) + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + with pytest.raises(NomnomError) as caught: + repository.resolve("cream cheese") + + assert caught.value.code == "food_needs_source" + assert caught.value.details["candidate"]["brand"] == "Cream Cheese" + assert caught.value.details["candidate"]["barcode"] == "8000000000093" + assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + +def test_branded_off_proxy_requires_category_type_evidence(repository, monkeypatch): + candidate = _off_candidate( + "Soy protein isolate — Example Sports", + brand="Example Sports", + barcode="8435611324100", + categories=(), + ) + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + with pytest.raises(NomnomError) as caught: + repository.resolve("soy protein isolate") + + assert caught.value.code == "food_needs_source" + assert caught.value.details["provider_error"]["code"] == "off_low_confidence" + assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + def test_default_unbranded_usda_fallback_is_explicit_generic_proxy( repository, monkeypatch ): @@ -182,6 +292,51 @@ def test_cached_generic_proxy_is_not_reused_for_later_branded_query( assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 1 +def test_cached_branded_generic_proxy_cannot_satisfy_later_brand_query( + repository, monkeypatch +): + candidate = _off_candidate( + "Menguy's Peanut 100%", + brand="Menguy's", + barcode="3336970205050", + categories=("en:peanuts",), + ) + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + proxy, _ = repository.resolve("peanuts") + assert proxy.resolution_mode == "generic_proxy" + + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: []) + with pytest.raises(NomnomError) as caught: + repository.resolve(candidate.name) + + assert caught.value.code == "food_needs_source" + assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 1 + + +def test_old_arbitrary_exact_off_cache_cannot_replay_for_unbranded_lookup( + repository, monkeypatch +): + candidate = _off_candidate( + "Menguy's Peanut 100%", + brand="Menguy's", + barcode="3336970205050", + categories=("en:peanuts",), + ) + repository._cache_food( + replace(candidate, resolution_mode="exact_product"), + lookup_query="peanuts", + ) + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + food, _ = repository.resolve("peanuts") + + assert food.resolution_mode == "generic_proxy" + row = repository.user_connection.execute( + "SELECT resolution_mode FROM food_cache WHERE barcode = ?", (candidate.barcode,) + ).fetchone() + assert row["resolution_mode"] == "generic_proxy" + + 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") @@ -431,17 +586,11 @@ def test_named_sku_can_resolve_only_to_matching_exact_off_product(repository, mo def test_unbranded_off_proxy_honors_policy_with_off_provenance( repository, monkeypatch, policy, error_code ): - candidate = Food( - "Chickpeas, cooked", - 164, - 8.9, - 2.6, - 27.4, - source="openfoodfacts", + candidate = _off_candidate( + "Chickpeas, cooked — Acme", + brand="Acme", barcode="12345678", categories=("en:chickpeas",), - source_id="12345678", - provenance="openfoodfacts", ) monkeypatch.setenv("NOMNOM_GENERIC_PROXY_POLICY", policy) monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) @@ -451,15 +600,116 @@ def test_unbranded_off_proxy_honors_policy_with_off_provenance( assert caught.value.code == error_code assert caught.value.details["candidate"] == { - "name": "Chickpeas, cooked", + "name": "Chickpeas, cooked — Acme", "source": "openfoodfacts", "source_id": "12345678", "resolution_mode": "generic_proxy", "confidence": 1.0, + "brand": "Acme", + "barcode": "12345678", + "assumption": ( + "Brand not specified; used Open Food Facts generic proxy from candidate " + "Chickpeas, cooked — Acme (brand: Acme; barcode: 12345678)." + ), } assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 +def test_configured_usda_generic_beats_safe_branded_off_proxy(repository, monkeypatch): + off_candidate = _off_candidate( + "Menguy's Peanut 100%", + brand="Menguy's", + barcode="3336970205050", + categories=("en:peanuts",), + ) + monkeypatch.setenv("NOMNOM_USDA_KEY", "test-key") + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [off_candidate]) + monkeypatch.setattr( + requests, + "get", + lambda *args, **kwargs: _usda_generic_response("Peanuts, raw"), + ) + + food, _ = repository.resolve("peanuts") + + assert food.source == "usda" + assert food.resolution_mode == "generic_proxy" + assert food.brand is None + + +@pytest.mark.parametrize( + ("literal", "candidate"), + [ + ( + "milk 3% 625 ml", + _off_candidate( + "Milk 3% — Example Dairy", + brand="Example Dairy", + barcode="10000001", + categories=("en:milks",), + ), + ), + ( + "soy protein isolate 30 g", + _off_candidate( + "Soy protein isolate — Example Sports", + brand="Example Sports", + barcode="10000002", + categories=("en:soy-protein-isolates",), + ), + ), + ( + "chicken pastrami 150 g", + _off_candidate( + "Chicken pastrami — Example Deli", + brand="Example Deli", + barcode="10000003", + categories=("en:chicken-pastrami",), + ), + ), + ( + "whole wheat bread 140 g", + _off_candidate( + "Whole wheat bread — Example Bakery", + brand="Example Bakery", + barcode="10000004", + categories=("en:whole-wheat-breads",), + ), + ), + ( + "cream cheese 40 g", + _off_candidate( + "Cream cheese — Example Dairy", + brand="Example Dairy", + barcode="10000005", + categories=("en:cream-cheeses",), + ), + ), + ( + "peanuts 55 g", + _off_candidate( + "Peanuts — Example Foods", + brand="Example Foods", + barcode="10000006", + categories=("en:peanuts",), + ), + ), + ], +) +def test_literal_translated_unbranded_components_are_explicit_generic_proxies( + repository, monkeypatch, literal, candidate +): + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + item = parse_free_text(literal, repository)[0].to_dict() + + assert item["resolution_mode"] == "generic_proxy" + assert item["source"] == "openfoodfacts" + assert item["brand"] == candidate.brand + assert item["barcode"] == candidate.barcode + assert "Open Food Facts" in item["assumption"] + + def test_off_category_does_not_replace_name_and_brand_token_overlap( repository, monkeypatch, food_fixtures ): diff --git a/tests/test_usda.py b/tests/test_usda.py index 2588897..3a8d692 100644 --- a/tests/test_usda.py +++ b/tests/test_usda.py @@ -79,6 +79,7 @@ def get(url, **kwargs): "api_key": "placeholder", "query": "sample legumes cooked", "pageSize": 10, + "dataType": ["Foundation", "SR Legacy"], } assert (food.kcal, food.protein, food.fat, food.carbs) == (180, 9, 4, 28) assert food.fdc_id == 100 @@ -179,6 +180,31 @@ def test_usda_prefers_foundation_over_branded_candidate(): assert food.alternatives[0]["fdc_id"] == 1 +def test_usda_generic_provenance_outranks_more_exact_branded_fdc_candidate(): + branded = record( + "Peanuts", fdc_id=9001, data_type="Branded", category="Peanuts" + ) + branded["brandOwner"] = "Example Foods" + foundation = record( + "Peanuts, raw", fdc_id=2346380, data_type="Foundation", category="Peanuts" + ) + captured = {} + + def get(url, **kwargs): + captured.update(kwargs) + return Response({"foods": [branded, foundation]}) + + food, confidence = USDAClient(request_get=get).resolve("peanuts", "placeholder") + + assert captured["params"]["dataType"] == ["Foundation", "SR Legacy"] + assert food.fdc_id == 2346380 + assert food.provider_data_type == "Foundation" + assert food.brand is None + assert food.source_id == "2346380" + assert food.provenance == "usda" + assert confidence >= 0.8 + + def test_usda_serving_weight_provenance_comes_only_from_returned_fields(): client = USDAClient( request_get=lambda *args, **kwargs: Response({"foods": [record(serving=True)]})