From 9df215b2ee62e2d87ac2be4950bc95508965805e Mon Sep 17 00:00:00 2001 From: max r Date: Tue, 21 Jul 2026 03:41:53 +0500 Subject: [PATCH] feat: add safe no-key base mode Verification: - pytest -q: 177 passed - ruff check .: no issues - sh -n install.sh: passed - disposable checkout install: installed_base_ready, generic_coverage=base - stubbed setup: base_ready/base and connected/enhanced - no-key unresolved flow: exit 2, food_needs_source --- README.md | 60 +++++++------ docs/plans.md | 114 ++++++++++++++++++++++++ docs/status.md | 15 +++- docs/test-plan.md | 43 ++++++++++ install.sh | 32 +++++-- nomnomcli/cli.py | 5 +- nomnomcli/foods.py | 177 ++++++++++++++++++++++++++------------ nomnomcli/onboarding.py | 17 +++- pyproject.toml | 1 + skill/SKILL.md | 40 +++++---- tests/conftest.py | 5 ++ tests/test_cli.py | 35 +++++--- tests/test_config.py | 35 +++++++- tests/test_foods.py | 186 +++++++++++++++++++++++++++++++++++++--- tests/test_install.py | 63 ++++++++++---- tests/test_parser.py | 2 +- 16 files changed, 671 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index bc53c22..336782a 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,12 @@ curl -fsSL https://raw.githubusercontent.com/maxjustships/nomnomcli/main/install | sh -s -- --status-json ``` -The structured `status` is `installed_and_ready`, `installed_needs_provider_setup`, -`installed_path_repair_needed`, or `error`, with the executable, version, and a concrete repair or -error action when needed. No credential value is emitted. `--dry-run` remains available. +The structured `status` is `installed_base_ready`, `installed_and_ready`, +`installed_path_repair_needed`, or `error`. A healthy no-token install is +`installed_base_ready`; `installed_and_ready` is reserved for configured, reachable USDA enhanced +coverage. JSON also reports `generic_coverage` as `base` or `enhanced` and an +`optional_usda_setup` action when relevant. PATH repair remains the top-level status while retaining +those capability fields. No credential value is emitted. `--dry-run` remains available. Check the optional generic-food connection without a prompt: @@ -50,10 +53,11 @@ Check the optional generic-food connection without a prompt: nomnom setup --status --json ``` -If it reports `setup_required`, run `nomnom setup` once in your own interactive terminal. Base -product/barcode and existing local-cache capture still work when this is deferred. Setup explains -why no-label generic-food lookup needs USDA, links to the official free signup page, validates the -key before saving, and prints a final `Connected` receipt. It never opens a browser by itself. +Without a key it reports `status: base_ready` and `generic_coverage: base`; base cache, aliases, +OFF barcode/full-text, and source-backed label capture are ready. USDA provides optional broader no-photo generic/raw-food coverage. A configured and reachable key reports `status: connected` and +`generic_coverage: enhanced`. Run `nomnom setup` only when that broader coverage is wanted or a +specific food cannot be safely resolved. Setup validates the key before saving and never opens a +browser by itself. 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 @@ -73,15 +77,16 @@ 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. Open Food Facts free-text search; +4. strict Open Food Facts free-text search; 5. a safe USDA FoodData Central generic proxy, when a setup key or `NOMNOM_USDA_KEY` is configured; 6. actionable JSON error—never a guessed food. -Open Food Facts candidates need at least 0.5 normalized token overlap between the query and -product name plus brands. A category/type conflict is rejected. All kcal, protein, fat, and carbs -values must be present, finite, and greater than zero. A rejected result returns -`off_low_confidence` with the candidate and alternatives, and is neither cached nor logged. +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. 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 @@ -96,12 +101,13 @@ nomnomcli User-Agent. API v2 search is structured/filter-only: nomnom never send intentional because this small provider contract remains explicit and replay-testable. Both OFF capabilities use bounded backoff for HTTP 429 and 5xx responses and safely honor a -numeric, bounded `Retry-After`. If v1 remains unavailable after retries, free-text resolution raises -the typed retryable error `openfoodfacts_unavailable`; it does not silently change endpoint or -return unrelated products. Product normalization and the token/category confidence checks still -apply to every v1 candidate. +numeric, bounded `Retry-After`. If v1 remains unavailable after retries, the OFF client raises the +typed retryable error `openfoodfacts_unavailable`; the no-key resolver preserves it inside +`food_needs_source.provider_error`. It never silently changes endpoint or returns unrelated +products. Product normalization and the token/category confidence checks still apply to every v1 +candidate. -### Enable USDA fallback +### Optional USDA enhancement Check first, then run the one-time guided connection only if needed: @@ -119,10 +125,15 @@ environment: export NOMNOM_USDA_KEY="your-key" ``` -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 +Without a key, a food that OFF cannot safely resolve returns `food_needs_source`, not a provider-key +failure. Its first-screen actions offer a package photo, barcode, source-backed `capture label`, and +the user's local cache/aliases. USDA appears separately as an optional enhancement for broader +no-photo generic/raw-food coverage. The nested `provider_error` preserves OFF candidate, +alternatives, retryability, and other technical diagnostics. Nothing unsafe is cached or logged. + +When enabled, 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. The default generic policy is `allow_for_unbranded`. A USDA result becomes a generic proxy only @@ -251,12 +262,13 @@ food names. Add `--json` for stable machine-readable output. User-correctable failures are written to stderr as 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. +- `food_needs_source`: use the returned photo, barcode, label-capture, or local-cache path; inspect + nested `provider_error` for OFF diagnostics. USDA is an explicitly optional enhancement. +- `off_low_confidence`: retained as nested technical detail when OFF candidates fail strict safety + checks. - `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. - `generic_proxy_confirmation_required`: show the named USDA candidate and ask before changing the configured policy; nothing was cached or logged. - `exact_resolution_required`: request the barcode or a package photo for source-backed capture. diff --git a/docs/plans.md b/docs/plans.md index 98a8bb8..e9e347c 100644 --- a/docs/plans.md +++ b/docs/plans.md @@ -1,5 +1,119 @@ # Plans +## Issue #23 Source +- Task: Make no-key base mode a successful safe product and USDA an optional coverage enhancement. +- Canonical input: GitHub issue #23 and the user's strict TDD, smoke, commit, and no-push requirements. +- Repo context: onboarding/status, installer, resolver errors and OFF provenance, tests, README, and agent skill. +- Last updated: 2026-07-21 + +## Issue #23 Assumptions +- OFF barcode lookup, source-backed label capture, user cache, and aliases define useful base coverage even when OFF full-text or USDA is unavailable. +- `installed_and_ready` requires both a configured and reachable USDA provider; every other verified install is `installed_base_ready` unless login PATH repair takes priority. +- Unbranded high-confidence OFF results may retain `exact_product` when source identity is a real named product, or become `generic_proxy` when the source product identity is generic; brand/SKU queries never receive a generic proxy. +- Tests and installer smoke use only checkout-local artifacts and local network stubs; no public install or live provider request is allowed. + +## Issue #23 Milestone Order +| ID | Title | Depends on | Status | +| --- | --- | --- | --- | +| M17 | Freeze base/enhanced setup and doctor contracts | M16 | [x] | +| M18 | Make fresh installer completion base-ready | M17 | [x] | +| M19 | Implement safe no-key OFF resolution and actionable source errors | M18 | [x] | +| M20 | Update installation/agent UX and run release verification | M19 | [x] | + +## M17. Freeze base/enhanced setup and doctor contracts `[x]` +### Goal +- No-key status is `base_ready` with base generic coverage and explicitly optional USDA enhancement; reachable configured USDA is connected/enhanced. + +### Tasks +- [x] Add and witness RED setup/status capability tests. +- [x] Implement the minimal additive capability model and interactive optional-enhancement copy. +- [x] Preserve credential redaction and independent OFF capability reporting. + +### Definition of Done +- Setup JSON and text treat the no-key product as ready and disclose enhanced coverage without portraying base as failed. + +### Validation +```sh +PYTHONPATH=. pytest -q tests/test_config.py tests/test_cli.py +``` + +### Known Risks +- Doctor JSON is consumed by the shell installer, so fields must remain additive and parseable. + +### Stop-and-Fix Rule +- Record RED before onboarding implementation; repair focused regressions before M18. + +## M18. Make fresh installer completion base-ready `[x]` +### Goal +- A verified no-token install succeeds as `installed_base_ready`, while reachable USDA is `installed_and_ready` and PATH repair remains highest priority. + +### Tasks +- [x] Add and witness RED installer JSON/human and agent-isolation contracts. +- [x] Add base/enhanced coverage and optional setup information without weakening sanitized verification. +- [x] Keep genuine installation/verification failures as `error`. + +### Definition of Done +- Installer tests cover no-key, enhanced, PATH repair, malformed doctor JSON, and inherited-agent isolation. + +### Validation +```sh +PYTHONPATH=. pytest -q tests/test_install.py +``` + +### Known Risks +- POSIX shell JSON construction must remain valid for multiline doctor output and constrained utility PATHs. + +### Stop-and-Fix Rule +- Do not change installer behavior until its new status contract is observed RED. + +## M19. Implement safe no-key OFF resolution and actionable source errors `[x]` +### Goal +- Strict high-confidence OFF results resolve with truthful provenance; unsafe or unavailable results return one no-key-first `food_needs_source` action contract. + +### Tasks +- [x] Add and witness RED tests for exact/generic OFF identity, weak/wrong-type, unavailable OFF, and brand/SKU denial. +- [x] Preserve category/token/core-nutrient thresholds and prevent rejected candidates from cache/log writes. +- [x] Replace first-screen raw USDA-key failures with photo, barcode, label capture, cache, and optional-USDA guidance. + +### Definition of Done +- No-key users either receive a safe source-backed food or a structured actionable source request; no generic brand substitution occurs. + +### Validation +```sh +PYTHONPATH=. pytest -q tests/test_off.py tests/test_foods.py tests/test_cli.py tests/test_capture.py +``` + +### Known Risks +- Existing tests intentionally preserve the pine-nuts/cheese incident and may not be weakened. + +### Stop-and-Fix Rule +- Any unsafe candidate acceptance, failed-write side effect, or provenance ambiguity blocks M20. + +## M20. Update installation/agent UX and run release verification `[x]` +### Goal +- Documentation makes no-token installation a successful completion and the checkout passes every requested gate and disposable installer smoke. + +### Tasks +- [x] Update README and `skill/SKILL.md` installation/limitation language. +- [x] Run full pytest, Ruff, diff audit, and checkout-import verification. +- [x] Run a disposable user-level installer against this PR source with temp HOME/tool bins and local provider stubs, then create one scoped conventional commit. + +### Definition of Done +- Exact outputs are recorded in status/final notes; one local commit exists; nothing is pushed and no PR is opened. + +### Validation +```sh +PYTHONPATH=. pytest -q +ruff check . +git diff --check +``` + +### Known Risks +- Installer source selection must be overridden locally without changing the production public source contract. + +### Stop-and-Fix Rule +- Do not commit until all gates and the literal fresh no-key smoke pass. + ## Issue #19 Source - Task: Implement the v0.4 zero-friction source-backed capture slice. - Canonical input: GitHub issue #19 plus the user's explicit default-policy override. diff --git a/docs/status.md b/docs/status.md index 95710a0..ae50296 100644 --- a/docs/status.md +++ b/docs/status.md @@ -1,7 +1,7 @@ # Status ## Snapshot -- Current phase: issue #19 complete +- Current phase: issue #23 complete - Plan file: `docs/plans.md` - Status: green - Last updated: 2026-07-21 @@ -17,14 +17,18 @@ - Passed 72 tests, Ruff, version 0.2.0, and the exact isolated v0.2 smoke; removed the `/tmp` smoke database. - Completed v0.4 source-backed capture: safe default USDA generic proxies, exact OFF v2 barcode capture, agent-extracted label capture, durable provenance, and additive schema-v4 migration. - Passed 155 tests, Ruff, diff audit, and the literal isolated schema-v4 capture/alias/offline-log/error smoke. +- Completed issue #23 no-key base mode: base/enhanced capability states, successful no-token installer status, strict truthful OFF exact/generic provenance, and actionable `food_needs_source` fallbacks. +- Passed 177 tests, Ruff, checkout-import guard, shell syntax, and the disposable checkout-built installer/provider-stub smoke. ## In Progress - None. ## Next -- None; issue #19 is complete and committed locally with no push or PR. +- None; issue #23 is complete and ready for the requested local commit. ## Decisions Made +- 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. +- Issue #23: PATH repair outranks base/enhanced installer completion status, but output must still describe the available coverage. - Default generic policy is `allow_for_unbranded`; this explicit user decision supersedes issue #19's older `ask` default. - Generic proxy safety requires a generic USDA type, no returned brand, an FDC id, complete validated core nutrition, accepted confidence, and full query-token coverage. - Package photo extraction stays outside the dependency-free CLI; only extracted facts and a mandatory source note enter the user database. @@ -67,6 +71,10 @@ ruff check . | 2026-07-21 | issue #19 preflight | issue, providers, resolver, schema, CLI, tests, docs, skill | `pytest -q`; repository inspection | 126 pass; clean baseline | M13 | | 2026-07-21 | M13 | policy, proxy, capture, and migration acceptance tests | focused pytest; full local pytest | RED contracts and 4 partial-worktree failures recorded | M14 | | 2026-07-21 | M14–M16 | config, resolver, OFF, schema, CLI, docs, skill, tests | `pytest -q`; `ruff check .`; `git diff --check`; literal temp-DB smoke | 155 pass; clean; smoke pass | local commit | +| 2026-07-21 | M17 | setup/status tests and onboarding/CLI | focused RED/GREEN pytest | RED captured; 9 setup tests pass | M18 | +| 2026-07-21 | M18 | installer statuses, capability JSON/human output, issue #22 isolation | focused RED/GREEN pytest | 10 installer tests pass before docs additions | M19 | +| 2026-07-21 | M19 | OFF identity/provenance and no-key source error | focused RED/GREEN pytest | 79 resolver/OFF/CLI/capture tests pass | M20 | +| 2026-07-21 | M20 | docs, import isolation, full suite, lint, disposable install | `pytest -q`; `ruff check .`; local offline installer smoke | 177 pass; clean; smoke pass | local commit | ## Smoke / Demo Checklist - [x] Fresh temp DB: help/version, capture label, alias, log, and invalid structured capture error. @@ -82,3 +90,6 @@ ruff check . - [x] OFF free text uses v1 with no unfiltered v2 fallback. - [x] Doctor and setup distinguish product/barcode reachability from full-text readiness. - [x] Version and documentation report v0.4 generic-proxy and source-backed capture behavior. +- [x] Fresh no-token checkout install reports `installed_base_ready` with base coverage. +- [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. diff --git a/docs/test-plan.md b/docs/test-plan.md index 2192423..827d28b 100644 --- a/docs/test-plan.md +++ b/docs/test-plan.md @@ -1,5 +1,48 @@ # Test Plan +## Issue #23 Source +- Task: Validate safe no-key base mode and optional USDA enhancement UX. +- Plan file: `docs/plans.md` +- Status file: `docs/status.md` +- Last updated: 2026-07-21 + +## Issue #23 Validation Scope +- In scope: setup/doctor capability JSON and text, installer status/human output, issue #22 agent isolation, strict OFF exact/generic identity and provenance, no-key source-needed errors, README, and agent skill. +- Out of scope: live provider traffic, public installs, bundled food data, model-derived nutrition, paid proxies, telemetry, or secret collection. + +## Issue #23 Fixtures and Network Rules +- OFF and USDA behavior is injected or monkeypatched with synthetic local responses only. +- The fresh installer smoke uses a temporary HOME, temporary tool bins, the checkout under review, and local provider stubs; it must not fetch a public package. + +## Issue #23 Test Levels + +### Unit +- Setup capability state transitions and optional next action. +- OFF identity classification plus existing nutrient, token, type/category, and confidence guardrails. + +### Integration +- No-key high-confidence OFF resolution caches truthful mode/source identity/provenance. +- Weak/wrong/unavailable OFF without USDA returns actionable `food_needs_source` without cache/log writes. +- Installer preserves sanitized environment isolation while reporting base/enhanced capability. + +### End-to-End / Smoke +- Fresh temp user install resolves checkout code and reports `installed_base_ready`, setup `base_ready`, and one safe no-key unresolved-food flow. +- Configured/reachable local USDA stub reports connected/enhanced and installer `installed_and_ready`. + +## Issue #23 Negative / Edge Cases +- Brand/SKU input never accepts a generic proxy. +- Pine nuts never resolve to an unrelated cheese result. +- OFF unavailability never surfaces raw `usda_key_required` as the first-screen user error. +- Login PATH repair remains the installer status even when base or enhanced capabilities are otherwise healthy. + +## Issue #23 Acceptance Gates +- [x] Every relevant production behavior has a focused test witnessed RED before implementation. +- [x] `pytest -q` imports the checkout and passes. +- [x] `ruff check .` +- [x] Fresh disposable checkout-local installer smoke passes without public network/install. +- [x] `git diff --check` and scoped diff audit pass. +- [x] One local conventional commit; no push or PR. + ## Issue #19 Source - Task: Validate the v0.4 zero-friction source-backed capture slice. - Plan file: `docs/plans.md` diff --git a/install.sh b/install.sh index 1ba3ba4..b8d1dbd 100644 --- a/install.sh +++ b/install.sh @@ -12,6 +12,8 @@ ERROR_CODE="" ERROR_MESSAGE="" ERROR_ACTION="" PATH_REPAIR="" +GENERIC_COVERAGE="" +OPTIONAL_USDA_SETUP=0 usage() { printf '%s\n' "Usage: sh install.sh [--dry-run] [--json|--status-json]" @@ -90,6 +92,16 @@ emit_status() { fi printf ',"path_repair":' json_value_or_null "$PATH_REPAIR" + printf ',"generic_coverage":' + json_value_or_null "$GENERIC_COVERAGE" + printf ',"optional_usda_setup":' + if [ "$OPTIONAL_USDA_SETUP" -eq 1 ]; then + printf '{"command":"nomnom setup","purpose":' + json_quote "broader no-photo raw/generic food coverage" + printf '}' + else + printf 'null' + fi printf '}\n' fi } @@ -121,10 +133,12 @@ if [ "$DRY_RUN" -eq 1 ]; then say "[dry-run] discover the user tool executable directory and normal login-shell PATH" say "[dry-run] verify nomnom --version with a sanitized user/system PATH" say "[dry-run] verify and parse nomnom doctor --json before the first food log" - say "Base product/barcode capture works; to enable no-label generic-food lookup, one free USDA setup remains." - say "Voluntary one-time action: run 'nomnom setup' in your own terminal." + say "Base product/barcode/cache/label capture is ready without a key." + say "Optional USDA enhancement: run 'nomnom setup' for broader no-photo raw/generic food coverage." if [ "$JSON_OUTPUT" -eq 1 ]; then STATUS="dry_run" + GENERIC_COVERAGE="base" + OPTIONAL_USDA_SETUP=1 emit_status fi exit 0 @@ -412,13 +426,20 @@ if [ -z "$USDA_CONFIGURED" ] || [ -z "$USDA_REACHABLE" ]; then fi LOGIN_EXECUTABLE=$(PATH="$SANITIZED_LOGIN_PATH" command -v nomnom 2>/dev/null || true) +if [ "$USDA_CONFIGURED" = "true" ] && [ "$USDA_REACHABLE" = "true" ]; then + GENERIC_COVERAGE="enhanced" + OPTIONAL_USDA_SETUP=0 +else + GENERIC_COVERAGE="base" + OPTIONAL_USDA_SETUP=1 +fi if [ "$LOGIN_EXECUTABLE" != "$EXECUTABLE" ]; then STATUS="installed_path_repair_needed" PATH_REPAIR="export PATH=\"$INSTALL_BIN_DIR:\$PATH\"" elif [ "$USDA_CONFIGURED" = "true" ] && [ "$USDA_REACHABLE" = "true" ]; then STATUS="installed_and_ready" else - STATUS="installed_needs_provider_setup" + STATUS="installed_base_ready" fi if [ -d "$HOME/.hermes" ]; then @@ -440,15 +461,16 @@ if [ "$JSON_OUTPUT" -eq 0 ]; then say "Installed: $EXECUTABLE" say "Version: $VERSION" say "Status: $STATUS" + say "Generic/raw coverage: $GENERIC_COVERAGE" if [ -n "$PATH_REPAIR" ]; then say "One-time PATH repair: $PATH_REPAIR" fi if [ "$USDA_CONFIGURED" != "true" ] || [ "$USDA_REACHABLE" != "true" ]; then - say "Base product/barcode capture works; to enable no-label generic-food lookup, one free USDA setup remains." + say "Base product/barcode/cache/label capture is ready without a key." if [ -t 1 ]; then say "That connection is voluntary; do it when you are ready." fi - say "One action: run 'nomnom setup' in your own terminal." + say "Optional USDA enhancement: run 'nomnom setup' for broader no-photo raw/generic food coverage." fi fi diff --git a/nomnomcli/cli.py b/nomnomcli/cli.py index 19fb247..fd50b99 100644 --- a/nomnomcli/cli.py +++ b/nomnomcli/cli.py @@ -191,10 +191,7 @@ def _run(args: argparse.Namespace) -> int: details={"action": "Run nomnom setup --status --json"}, ) print("One-time connection (optional)") - print( - "Base product/barcode capture works; to enable no-label generic-food lookup, " - "one free USDA setup remains." - ) + print("Optional: connect USDA for broader no-photo generic/raw-food coverage.") 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("Official free USDA signup: https://fdc.nal.usda.gov/api-key-signup.html") diff --git a/nomnomcli/foods.py b/nomnomcli/foods.py index 3ed4348..206ef08 100644 --- a/nomnomcli/foods.py +++ b/nomnomcli/foods.py @@ -14,11 +14,6 @@ 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 run nomnom setup or set NOMNOM_USDA_KEY." -) -USDA_KEY_SETUP = f"Run nomnom setup; signup: {USDA_SETUP_URL}" GENERIC_USDA_DATA_TYPES = frozenset({"foundation", "sr legacy", "survey (fndds)"}) EXACT_CAPTURE_ACTION = ( "Provide the package barcode or photo so the agent can run nomnom capture " @@ -93,16 +88,79 @@ def _generic_proxy_query_is_safe(query: str, food: Food) -> bool: ) +def _off_candidate_query_is_safe(query: str, food: Food) -> bool: + if food.brand is None: + return _generic_proxy_query_is_safe(query, food) + query_tokens = _name_tokens(query) + candidate_tokens = _name_tokens(" ".join((food.name, food.brand))) + return bool(query_tokens) and query_tokens <= candidate_tokens + + def _generic_proxy_candidate(food: Food, confidence: float) -> dict: return { "name": food.name, - "source": "usda", - "source_id": str(food.fdc_id), + "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), } +def _food_needs_source_error( + query: str, + *, + provider_error: NomnomError | None = None, + offline: bool = False, +) -> NomnomError: + technical = None + if provider_error is not None: + technical = { + "code": provider_error.code, + "message": provider_error.message, + "details": provider_error.details, + } + details = { + "food": query, + "offline": offline, + "source_options": { + "photo": { + "message": "Send a clear package photo so the agent can extract label facts." + }, + "barcode": {"command": "nomnom capture barcode BARCODE --json"}, + "capture_label": { + "command": ( + "nomnom capture label --name NAME --kcal KCAL --protein P " + "--fat F --carbs C --source-note SOURCE --json" + ) + }, + "local_cache": { + "command": "nomnom search QUERY --json", + "aliases": "nomnom alias list --json", + }, + }, + "optional_usda_enhancement": { + "optional": True, + "command": "nomnom setup", + "purpose": "broader no-photo raw/generic food coverage", + "signup_url": USDA_SETUP_URL, + }, + "provider_error": technical, + "action": ( + "Use a package photo, barcode, verified label capture, or exact local cache entry; " + "USDA setup is optional for broader generic/raw coverage" + ), + } + if provider_error is not None: + for key in ("candidate", "alternatives"): + if key in provider_error.details: + details[key] = provider_error.details[key] + return NomnomError( + "food_needs_source", + f"Food needs a trusted source: {query}", + details=details, + ) + + class FoodRepository: def __init__( self, @@ -319,13 +377,12 @@ 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") credential = self.provider_config.usda_credential() - low_confidence_error: NomnomError | None = None + off_error: NomnomError | None = None if off_enabled: try: off_matches = self.off_client.search(query, page_size=5) - except NomnomError: - if credential is None: - raise + except NomnomError as exc: + off_error = exc off_matches = [] if off_matches: matching_brand = next( @@ -337,12 +394,33 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float *(food for food in off_matches if food is not matching_brand), ] scored = [(food, _off_confidence(query, food)) for food in off_matches] - accepted = [match for match in scored if match[1] >= 0.5] + accepted = [ + match + 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]) + ] if not accepted: candidate, confidence = scored[0] - low_confidence_error = NomnomError( - "off_low_confidence", - f"Open Food Facts candidate is too weak for: {query}", + source_identity_missing = ( + confidence >= 0.5 + and _off_candidate_query_is_safe(query, candidate) + and not (candidate.source_id or candidate.barcode) + ) + code = ( + "off_source_identity_missing" + if source_identity_missing + else "off_low_confidence" + ) + message = ( + f"Open Food Facts candidate has no source identity for: {query}" + if source_identity_missing + else f"Open Food Facts candidate is too weak for: {query}" + ) + off_error = NomnomError( + code, + message, details={ "food": query, "threshold": 0.5, @@ -350,10 +428,7 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float "alternatives": [ _candidate_details(food, score) for food, score in scored[1:] ], - "action": ( - "Try a more specific name, run nomnom setup, or pin verified " - "label values with nomnom add" - ), + "action": EXACT_CAPTURE_ACTION, }, ) else: @@ -375,13 +450,28 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float } for alternative in off_matches[1:] ) - food = replace( - off_matches[0], - alternatives=alternatives, - resolution_mode="exact_product", - source_id=off_matches[0].source_id or off_matches[0].barcode, - provenance=off_matches[0].provenance or "openfoodfacts", - ) + 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 @@ -390,37 +480,10 @@ def resolve(self, query: str, *, allow_remote: bool = True) -> tuple[Food, float food, confidence = self._prepare_usda_generic_proxy(query, food, confidence) self._cache_food(food, lookup_query=query) return food, confidence - if low_confidence_error is not None: - raise low_confidence_error - suggestions = [food.name for food in matches[:3]] - if remote_enabled: - raise NomnomError( - "usda_key_required", - USDA_KEY_REQUIRED_MESSAGE, - details={ - "food": query, - "setup_url": USDA_SETUP_URL, - "setup": USDA_KEY_SETUP, - "environment_variable": "NOMNOM_USDA_KEY", - "action": ( - "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" - ), - }, - ) - raise NomnomError( - "food_not_found", - f"Could not resolve food: {query}", - details={ - "food": query, - "suggestions": suggestions, - "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" - ), - }, + raise _food_needs_source_error( + query, + provider_error=off_error, + offline=not remote_enabled, ) def search(self, query: str, limit: int = 10) -> list[Food]: diff --git a/nomnomcli/onboarding.py b/nomnomcli/onboarding.py index a1dec14..7ad128c 100644 --- a/nomnomcli/onboarding.py +++ b/nomnomcli/onboarding.py @@ -75,18 +75,27 @@ def setup_status_report( ) if usda["configured"] and usda["reachable"]: report["status"] = "connected" + report["generic_coverage"] = "enhanced" usda["next_action"] = None elif usda["configured"]: - report["status"] = "connection_unreachable" + report["status"] = "base_ready" + report["generic_coverage"] = "base" usda["next_action"] = { "command": "nomnom setup", - "message": "Run the one-time connection again to validate or replace the key.", + "optional": True, + "message": ( + "Optional: reconnect USDA to restore broader no-photo raw/generic food coverage." + ), } else: - report["status"] = "setup_required" + report["status"] = "base_ready" + report["generic_coverage"] = "base" usda["next_action"] = { "command": "nomnom setup", - "message": "Run the one-time connection in your terminal.", + "optional": True, + "message": ( + "Optional: connect USDA for broader no-photo raw/generic food coverage." + ), } return {"status": report.pop("status"), **report} diff --git a/pyproject.toml b/pyproject.toml index d6b34d3..6c68da9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ include = ["nomnomcli*"] [tool.pytest.ini_options] testpaths = ["tests"] addopts = "-ra" +pythonpath = ["."] [tool.ruff] target-version = "py311" diff --git a/skill/SKILL.md b/skill/SKILL.md index 1b33008..4ba1b35 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -26,8 +26,9 @@ user's command. The bootstrap must select uv tool, pipx, or a non-venv Python 3. Follow this exact sequence: -1. Parse the installer `status`, `executable`, `version`, `error`, and `path_repair` fields. Accept - only `installed_and_ready` or `installed_needs_provider_setup` as a completed shell install. For +1. Parse the installer `status`, `executable`, `version`, `generic_coverage`, + `optional_usda_setup`, `error`, and `path_repair` fields. Accept `installed_base_ready` and + `installed_and_ready` as completed shell installs. Base tracking is ready without a USDA key. For `installed_path_repair_needed`, give the returned one-time repair command and do not claim the install is complete. For `error`, explain its returned action. 2. Derive the executable directory from the returned path. Verify `nomnom --version` and run @@ -38,18 +39,18 @@ Follow this exact sequence: inherit agent XDG roots, credentials, database paths, or status settings. Exclude Hermes, Codex, the current project, temporary directories, and every virtualenv path. Parse the doctor JSON; do not infer readiness from exit status or human text. -3. Run `nomnom setup --status --json` and parse its prompt-free result. If USDA is not configured - and reachable, say exactly once: "Base product/barcode capture works; to enable no-label generic-food lookup, one free USDA setup remains." Offer exactly one voluntary action: - `nomnom setup` in the user's own interactive terminal. Do not open a browser or run interactive +3. Treat `installed_base_ready` as installation success and continue with the user's tracking task. + Say: "Base tracking is ready without a USDA key." Do not ask for setup after a successful base install. + Offer `nomnom setup` only when the user wants broader no-photo generic/raw-food coverage + or after a specific item returns `food_needs_source`. Never open a browser or run interactive setup automatically. 4. Agents must never type, receive, echo, or persist a USDA key or any other user secret. Do not ask for it in chat. Secret entry belongs only in the user's terminal through `nomnom setup`, which links to , validates the key, and stores it in the owner-only XDG config (`0600`). -5. Before every first meal after install (and before retrying after deferred setup), run and parse - `nomnom doctor --json` plus `nomnom setup --status --json`. If USDA setup remains deferred, use a - friendly barcode, package-photo, or exact local-cache flow. Never let a raw - `usda_key_required` error become the user's onboarding experience. +5. For base mode, use aliases/cache, strict OFF full text, exact OFF barcode, and package-photo label + capture. `nomnom setup --status --json` is available when capability status is actually needed: + `base_ready` means base mode works, and `connected` means USDA-enhanced coverage works. ## Log free text @@ -118,25 +119,26 @@ Aliases are user-database records, never packaged translations. They resolve only to exact local cache names and must not invent, approximate, or remotely substitute a target. -## Unknown-food workflow: OFF → safe USDA proxy → capture → error +## Unknown-food workflow: cache/OFF → source request → optional USDA The CLI automatically checks exact user alias, exact cache, cache search, then Open Food Facts. 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. +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 validated nutrition, sufficient confidence, and full query-token coverage. Always show returned - `assumptions`. Never treat a branded or SKU-like query as a generic proxy. For - `usda_key_required`, offer `nomnom setup` and the free-key URL returned in - `details`. Use the environment only for non-interactive/CI operation. + `assumptions`. Never treat a branded or SKU-like query as a generic proxy. Use the environment + only for non-interactive/CI operation. 3. On `generic_proxy_confirmation_required`, show the candidate and ask; do not change policy or write anything without the user's choice. On `exact_resolution_required`, ask for the package barcode or photo and use the exact capture flow above. -4. If OFF, USDA, and source-backed capture cannot resolve the food, report the - structured error. Never substitute a similar food or invent values. +4. On `food_needs_source`, preserve nested provider diagnostics and offer the returned package-photo, + barcode, `capture label`, and exact local-cache paths first. Offer the returned USDA action only + as an optional broader-coverage enhancement. Never substitute a similar food or invent values. Other error handling: @@ -144,8 +146,8 @@ Other error handling: - `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. +- Nested `openfoodfacts_unavailable` or direct `usda_unavailable`: say that nothing was estimated; + use the returned safe source actions or offer retry. Size assumptions must show the returned provider serving field and value. Exact human grams always override provider serving data, including per-piece input. diff --git a/tests/conftest.py b/tests/conftest.py index 6072527..78a5c67 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,11 @@ FIXTURE_PATH = Path(__file__).parent / "fixtures" / "foods.json" +@pytest.fixture(autouse=True) +def isolate_user_provider_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + + @pytest.fixture def user_db(tmp_path: Path) -> Path: return tmp_path / "user.sqlite3" diff --git a/tests/test_cli.py b/tests/test_cli.py index adb5673..d022e8b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -52,9 +52,10 @@ def json(self): assert code == 2 assert captured.out == "" - assert error["error"]["code"] == "off_low_confidence" + assert error["error"]["code"] == "food_needs_source" assert error["error"]["candidate"]["name"] == "Cheese — Wrong Match" assert error["error"]["alternatives"] == [] + assert error["error"]["details"]["provider_error"]["code"] == "off_low_confidence" assert error["error"]["details"]["candidate"]["name"] == "Cheese — Wrong Match" assert "alternatives" in error["error"]["details"] with connect(user_db) as connection: @@ -80,13 +81,12 @@ def json(self): assert code == 2 assert captured.out == "" - assert error["error"]["code"] == "usda_key_required" - assert error["error"]["setup"] == ( - "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" - ) + assert error["error"]["code"] == "food_needs_source" + details = error["error"]["details"] + assert "photo" in details["source_options"] + assert "barcode" in details["source_options"] + assert "capture_label" in details["source_options"] + assert details["optional_usda_enhancement"]["optional"] is True def test_cli_log_and_stats_json(seeded_user_db, monkeypatch, capsys): @@ -111,7 +111,7 @@ def test_cli_unknown_food_json_error(user_db, monkeypatch, capsys): error = json.loads(captured.err) assert code == 2 assert captured.out == "" - assert error["error"]["code"] == "food_not_found" + assert error["error"]["code"] == "food_needs_source" def test_cli_direct_requires_grams(user_db, monkeypatch, capsys): @@ -329,6 +329,7 @@ def setup(*, interactive): assert main(["setup"]) == 2 captured = capsys.readouterr() + assert "Optional: connect USDA for broader no-photo generic/raw-food coverage." in captured.out assert "Open Food Facts: free, no account or key" in captured.out assert json.loads(captured.err)["error"]["code"] == "setup_requires_interactive" @@ -371,7 +372,8 @@ def isatty(self): def test_cli_setup_status_json_is_prompt_free_and_actionable(monkeypatch, capsys): expected = { - "status": "setup_required", + "status": "base_ready", + "generic_coverage": "base", "providers": { "openfoodfacts": { "configured": True, @@ -386,7 +388,10 @@ def test_cli_setup_status_json_is_prompt_free_and_actionable(monkeypatch, capsys "signup_url": "https://fdc.nal.usda.gov/api-key-signup.html", "next_action": { "command": "nomnom setup", - "message": "Run the one-time connection in your terminal.", + "optional": True, + "message": ( + "Optional: connect USDA for broader no-photo raw/generic food coverage." + ), }, }, }, @@ -450,9 +455,11 @@ def unavailable(*args, **kwargs): monkeypatch.setattr(OpenFoodFactsClient, "search", unavailable) assert main(["log", "--parse", "Acme missing 100 г", "--json"]) == 2 error = json.loads(capsys.readouterr().err) - assert error["error"]["code"] == "openfoodfacts_unavailable" - assert error["error"]["details"]["status"] == 503 - assert "nomnom add" in error["error"]["details"]["offline_escape"] + assert error["error"]["code"] == "food_needs_source" + provider_error = error["error"]["details"]["provider_error"] + assert provider_error["code"] == "openfoodfacts_unavailable" + assert provider_error["details"]["status"] == 503 + assert "nomnom add" in provider_error["details"]["offline_escape"] def test_cli_alias_crud_json(user_db, monkeypatch, capsys): diff --git a/tests/test_config.py b/tests/test_config.py index 63140fc..5a3fbab 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -266,6 +266,7 @@ def test_setup_status_is_actionable_and_never_contains_key(tmp_path): ) assert report["status"] == "connected" + assert report["generic_coverage"] == "enhanced" assert report["providers"]["usda"] == { "configured": True, "reachable": True, @@ -277,17 +278,45 @@ def test_setup_status_is_actionable_and_never_contains_key(tmp_path): assert "never-emit-this-placeholder" not in json.dumps(report) -def test_setup_status_unconfigured_has_one_safe_next_action(tmp_path): +def test_setup_status_unconfigured_is_base_ready_with_optional_enhancement(tmp_path): report = setup_status_report( config=ProviderConfig(environ={}, config_path=tmp_path / "missing.toml"), off_client=HealthyOFF(), usda_client=HealthyUSDA(), ) - assert report["status"] == "setup_required" + assert report["status"] == "base_ready" + assert report["generic_coverage"] == "base" assert report["providers"]["usda"]["configured"] is False assert report["providers"]["usda"]["reachable"] is False assert report["providers"]["usda"]["next_action"] == { "command": "nomnom setup", - "message": "Run the one-time connection in your terminal.", + "optional": True, + "message": ( + "Optional: connect USDA for broader no-photo raw/generic food coverage." + ), + } + + +def test_setup_status_unreachable_usda_preserves_base_ready_coverage(tmp_path): + class UnreachableUSDA: + def probe(self, api_key): + raise NomnomError("usda_unavailable", "USDA is unavailable") + + path = tmp_path / "config.toml" + config = ProviderConfig(environ={}, config_path=path) + config.store_usda_key("unreachable-placeholder") + + report = setup_status_report( + config=config, off_client=HealthyOFF(), usda_client=UnreachableUSDA() + ) + + assert report["status"] == "base_ready" + assert report["generic_coverage"] == "base" + assert report["providers"]["usda"]["next_action"] == { + "command": "nomnom setup", + "optional": True, + "message": ( + "Optional: reconnect USDA to restore broader no-photo raw/generic food coverage." + ), } diff --git a/tests/test_foods.py b/tests/test_foods.py index 43f1110..96ae118 100644 --- a/tests/test_foods.py +++ b/tests/test_foods.py @@ -253,9 +253,22 @@ def get(url, *args, **kwargs): with pytest.raises(NomnomError) as caught: repository.resolve("кедровые орехи") - assert caught.value.code == "off_low_confidence" + assert caught.value.code == "food_needs_source" + assert caught.value.details["provider_error"]["code"] == "off_low_confidence" assert caught.value.details["candidate"]["name"] == "Cheese — Wrong Match" assert caught.value.details["alternatives"] == [] + assert set(caught.value.details["source_options"]) == { + "photo", + "barcode", + "capture_label", + "local_cache", + } + assert caught.value.details["optional_usda_enhancement"] == { + "optional": True, + "command": "nomnom setup", + "purpose": "broader no-photo raw/generic food coverage", + "signup_url": "https://fdc.nal.usda.gov/api-key-signup.html", + } assert urls == ["https://world.openfoodfacts.org/cgi/search.pl"] assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 @@ -277,7 +290,8 @@ def json(self): with pytest.raises(NomnomError) as caught: repository.resolve("кедровые орехи") - assert caught.value.code == "off_low_confidence" + assert caught.value.code == "food_needs_source" + assert caught.value.details["provider_error"]["code"] == "off_low_confidence" assert caught.value.details["candidate"]["confidence"] < 0.5 @@ -300,6 +314,152 @@ def json(self): assert confidence >= 0.5 +def test_unbranded_high_confidence_off_type_is_cached_as_truthful_generic_proxy( + repository, monkeypatch +): + candidate = Food( + "Chickpeas, cooked", + 164, + 8.9, + 2.6, + 27.4, + source="openfoodfacts", + barcode="12345678", + categories=("en:chickpeas",), + source_id="12345678", + provenance="openfoodfacts", + ) + monkeypatch.setenv("NOMNOM_GENERIC_PROXY_POLICY", "allow_for_unbranded") + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + food, confidence = repository.resolve("chickpeas cooked") + + assert confidence == 1.0 + assert food.name == "Chickpeas, cooked" + assert food.brand is None + assert food.resolution_mode == "generic_proxy" + assert food.source_id == "12345678" + assert food.provenance == "openfoodfacts" + assert food.assumption == ( + "Brand not specified; used Open Food Facts generic proxy: Chickpeas, cooked." + ) + row = repository.user_connection.execute( + """SELECT resolution_mode, source_id, provenance, assumption + FROM food_cache WHERE name = ?""", + (food.name,), + ).fetchone() + assert tuple(row) == ( + "generic_proxy", + "12345678", + "openfoodfacts", + "Brand not specified; used Open Food Facts generic proxy: Chickpeas, cooked.", + ) + + +def test_high_confidence_off_without_source_identity_is_not_cached(repository, monkeypatch): + candidate = Food( + "Chickpeas, cooked", + 164, + 8.9, + 2.6, + 27.4, + source="openfoodfacts", + categories=("en:chickpeas",), + ) + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + with pytest.raises(NomnomError) as caught: + repository.resolve("chickpeas cooked") + + assert caught.value.code == "food_needs_source" + assert caught.value.details["provider_error"]["code"] == "off_source_identity_missing" + assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + +@pytest.mark.parametrize("query", ["Acme chickpeas", "chickpeas 12345"]) +def test_brand_or_sku_query_never_uses_unbranded_off_proxy(repository, monkeypatch, query): + candidate = Food( + "Chickpeas", + 164, + 8.9, + 2.6, + 27.4, + source="openfoodfacts", + barcode="12345678", + categories=("en:chickpeas",), + source_id="12345678", + provenance="openfoodfacts", + ) + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + with pytest.raises(NomnomError) as caught: + repository.resolve(query) + + 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_named_sku_can_resolve_only_to_matching_exact_off_product(repository, monkeypatch): + candidate = Food( + "Acme Chickpeas 12345", + 164, + 8.9, + 2.6, + 27.4, + source="openfoodfacts", + barcode="12345678", + brand="Acme", + categories=("en:chickpeas",), + source_id="12345678", + provenance="openfoodfacts", + ) + monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: [candidate]) + + food, confidence = repository.resolve("Acme chickpeas 12345") + + assert confidence == 1.0 + assert food.name == "Acme Chickpeas 12345" + assert food.resolution_mode == "exact_product" + assert food.source_id == "12345678" + + +@pytest.mark.parametrize( + ("policy", "error_code"), + [("ask", "generic_proxy_confirmation_required"), ("exact_only", "exact_resolution_required")], +) +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", + 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]) + + with pytest.raises(NomnomError) as caught: + repository.resolve("chickpeas cooked") + + assert caught.value.code == error_code + assert caught.value.details["candidate"] == { + "name": "Chickpeas, cooked", + "source": "openfoodfacts", + "source_id": "12345678", + "resolution_mode": "generic_proxy", + "confidence": 1.0, + } + assert repository.user_connection.execute("SELECT count(*) FROM food_cache").fetchone()[0] == 0 + + def test_off_category_does_not_replace_name_and_brand_token_overlap( repository, monkeypatch, food_fixtures ): @@ -318,12 +478,14 @@ def json(self): with pytest.raises(NomnomError) as caught: repository.resolve("eggs") - assert caught.value.code == "off_low_confidence" + assert caught.value.code == "food_needs_source" + assert caught.value.details["provider_error"]["code"] == "off_low_confidence" assert caught.value.details["candidate"]["confidence"] == 0 -def test_missing_usda_key_has_exact_actionable_setup_error(repository, monkeypatch): - setup_url = "https://fdc.nal.usda.gov/api-key-signup.html" +def test_missing_usda_key_returns_safe_source_options_with_optional_enhancement( + repository, monkeypatch +): monkeypatch.delenv("NOMNOM_USDA_KEY", raising=False) monkeypatch.delenv("NOMNOM_OFFLINE", raising=False) monkeypatch.setattr(repository.off_client, "search", lambda *args, **kwargs: []) @@ -331,12 +493,13 @@ def test_missing_usda_key_has_exact_actionable_setup_error(repository, monkeypat with pytest.raises(NomnomError) as caught: repository.resolve("chickpeas cooked") - 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 run nomnom setup or set NOMNOM_USDA_KEY." - ) - assert caught.value.details["setup_url"] == setup_url + assert caught.value.code == "food_needs_source" + assert "USDA" not in caught.value.message + assert caught.value.details["provider_error"] is None + assert "photo" in caught.value.details["source_options"] + assert "barcode" in caught.value.details["source_options"] + assert "capture_label" in caught.value.details["source_options"] + assert caught.value.details["optional_usda_enhancement"]["optional"] is True def test_offline_not_found(repository, monkeypatch): @@ -344,6 +507,7 @@ def test_offline_not_found(repository, monkeypatch): monkeypatch.setenv("NOMNOM_OFFLINE", "1") with pytest.raises(NomnomError) as caught: repository.resolve("definitely not a food") + assert caught.value.code == "food_needs_source" assert caught.value.details["offline"] is True diff --git a/tests/test_install.py b/tests/test_install.py index e2cbe79..4d6824c 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -11,6 +11,12 @@ REPO_URL = "git+https://github.com/maxjustships/nomnomcli" +def test_tests_import_checkout_under_review(): + import nomnomcli + + assert Path(nomnomcli.__file__).resolve().is_relative_to(ROOT) + + def _executable(path: Path, content: str) -> Path: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") @@ -92,7 +98,7 @@ def _run_installer(environment: dict[str, str], *arguments: str) -> subprocess.C @pytest.mark.parametrize( ("usda_ready", "expected_status"), [ - (False, "installed_needs_provider_setup"), + (False, "installed_base_ready"), (True, "installed_and_ready"), ], ) @@ -143,6 +149,15 @@ def test_installer_prefers_uv_and_reports_provider_state_without_network( "version": "nomnom 0.4.0", "error": None, "path_repair": None, + "generic_coverage": "enhanced" if usda_ready else "base", + "optional_usda_setup": ( + None + if usda_ready + else { + "command": "nomnom setup", + "purpose": "broader no-photo raw/generic food coverage", + } + ), } trace = Path(environment["TRACE"]).read_text(encoding="utf-8") assert f"uv tool install --force {REPO_URL}" in trace @@ -215,7 +230,9 @@ def test_installer_reads_only_top_level_usda_provider_booleans(tmp_path, doctor_ assert result.returncode == 0, result.stderr payload = json.loads(result.stdout) - assert payload["status"] == "installed_needs_provider_setup" + assert payload["status"] == "installed_base_ready" + assert payload["generic_coverage"] == "base" + assert payload["optional_usda_setup"]["command"] == "nomnom setup" assert payload["path_repair"] is None @@ -251,6 +268,11 @@ def test_installer_uses_pipx_when_uv_is_unavailable(tmp_path): assert payload["version"] == "nomnom 0.4.0" assert f'export PATH="{tool_bin}:$PATH"' in payload["path_repair"] assert payload["error"] is None + assert payload["generic_coverage"] == "base" + assert payload["optional_usda_setup"] == { + "command": "nomnom setup", + "purpose": "broader no-photo raw/generic food coverage", + } assert f"pipx install --force {REPO_URL}" in Path(environment["TRACE"]).read_text() human_result = _run_installer(environment) @@ -258,8 +280,8 @@ def test_installer_uses_pipx_when_uv_is_unavailable(tmp_path): assert human_result.returncode == 0, human_result.stderr assert "Status: installed_path_repair_needed" in human_result.stdout assert "One-time PATH repair:" in human_result.stdout - assert "Base product/barcode capture works" in human_result.stdout - assert "One action: run 'nomnom setup' in your own terminal." in human_result.stdout + assert "Generic/raw coverage: base" in human_result.stdout + assert "Optional USDA enhancement: run 'nomnom setup'" in human_result.stdout def test_installer_user_site_fallback_skips_agent_venv_python(tmp_path): @@ -410,7 +432,9 @@ def test_installer_verification_ignores_agent_xdg_and_nomnom_environment(tmp_pat result = _run_installer(environment, "--status-json") assert result.returncode == 0, result.stderr - assert json.loads(result.stdout)["status"] == "installed_needs_provider_setup" + payload = json.loads(result.stdout) + assert payload["status"] == "installed_base_ready" + assert payload["generic_coverage"] == "base" invocation_environments = [ json.loads(line) for line in Path(environment["TRACE"]).read_text().splitlines() @@ -442,17 +466,13 @@ def test_installer_dry_run_surfaces_one_voluntary_setup_action(): assert "uv tool install --force" in result.stdout assert result.stdout.count("nomnom setup") == 1 - assert "one free USDA setup remains" in result.stdout + assert "Optional USDA enhancement" in result.stdout assert "nomnom doctor --json" in result.stdout assert "before the first food log" in result.stdout -def test_agent_skill_contains_the_mandatory_issue_21_protocol(): +def test_agent_skill_treats_no_token_install_as_successful_base_mode(): skill = (ROOT / "skill" / "SKILL.md").read_text(encoding="utf-8") - required_sentence = ( - "Base product/barcode capture works; to enable no-label generic-food lookup, " - "one free USDA setup remains." - ) assert "Mandatory install protocol" in skill assert "--status-json" in skill @@ -461,10 +481,23 @@ def test_agent_skill_contains_the_mandatory_issue_21_protocol(): assert "clear every `NOMNOM_*` override" in skill assert "nomnom --version" in skill assert "nomnom doctor --json" in skill - assert "nomnom setup --status --json" in skill - assert required_sentence in skill - assert "exactly one voluntary action" in skill + assert "installed_base_ready" in skill + assert "Base tracking is ready without a USDA key." in skill + assert "Do not ask for setup after a successful base install." in skill + assert "broader no-photo generic/raw-food coverage" in skill + assert "food_needs_source" in skill assert "must never type, receive, echo, or persist" in skill - assert "Before every first meal" in skill assert "local-cache" in skill assert "Never run `pip install -e`" in skill + + +def test_readme_documents_base_ready_install_and_optional_usda_contract(): + readme = (ROOT / "README.md").read_text(encoding="utf-8") + + assert "installed_base_ready" in readme + assert "generic_coverage" in readme + assert "status: base_ready" in readme + assert "food_needs_source" in readme + assert "optional broader no-photo generic/raw-food coverage" in readme + assert "installed_needs_provider_setup" not in readme + assert "returns `usda_key_required`" not in readme diff --git a/tests/test_parser.py b/tests/test_parser.py index 87d2370..831f793 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -109,7 +109,7 @@ def test_unknown_food_error_has_item_context(seeded_repository, monkeypatch): monkeypatch.setenv("NOMNOM_OFFLINE", "1") with pytest.raises(NomnomError) as caught: parse_free_text("несуществующая еда 100 г", seeded_repository) - assert caught.value.code == "food_not_found" + assert caught.value.code == "food_needs_source" assert caught.value.details["item_index"] == 0