Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,12 @@ nomnom setup
nomnom doctor --json
```

Run `nomnom setup` in an interactive terminal before the first food log. It probes Open Food
Facts, explains that OFF needs no key, shows the official USDA signup URL, validates the USDA key
with a minimal FoodData Central request, and only then stores it locally. Follow with
`nomnom doctor --json` to verify current provider reachability instead of assuming setup worked.
Run `nomnom setup` in an interactive terminal before the first food log. It independently probes
Open Food Facts product/barcode lookup and full-text resolution, explains that OFF needs no key,
shows the official USDA signup URL, validates the USDA key with a minimal FoodData Central request,
and only then stores it locally. Follow with `nomnom doctor --json` to verify current provider
readiness instead of assuming setup worked. OFF product reachability does not imply that full-text
resolution is ready.

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
Expand Down Expand Up @@ -73,13 +75,18 @@ Successful API results are cached in the user's database, so the same food can r
later. Existing v0.2 cache records, logs, and recipes are preserved when v0.3 opens the database.
`nomnom search QUERY` searches this user cache; it is not a packaged food catalog.

Open Food Facts search goes directly through its official REST endpoint,
`https://api.openfoodfacts.org/api/v2/search`, with a descriptive nomnomcli User-Agent. Direct
`requests` calls are intentional: the official REST contract is small and transparent, while a
third-party OFF SDK would add an unofficial dependency without adding an authenticated workflow or
schema capability. nomnom applies bounded backoff to HTTP 429 and 5xx responses, safely honors a
numeric `Retry-After`, and reports a typed retryable `openfoodfacts_unavailable` error after retries
are exhausted. It never silently falls back to the unstable `world` search host.
Open Food Facts free-text search goes directly through the official legacy v1 endpoint,
`https://world.openfoodfacts.org/cgi/search.pl`, with `search_terms`, `search_simple=1`,
`action=process`, `json=1`, `page_size`, the supported response `fields`, and a descriptive
nomnomcli User-Agent. API v2 search is structured/filter-only: nomnom never sends free-text
`search_terms` to v2 and never falls back to unfiltered v2 catalog rows. Direct `requests` calls are
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.

### Enable USDA fallback

Expand Down Expand Up @@ -194,8 +201,15 @@ an `error` object and exit with status 2. Important codes include:
- `alias_target_not_found`: add/resolve the exact cached target or remove the stale alias.
- `openfoodfacts_unavailable` / `usda_unavailable`: retry later or use a manual label.

Provider-unavailable errors include a `retryable` boolean. `nomnom doctor --json` always emits only
provider `configured`/`reachable` state and USDA `key_source`; it never includes credential values.
Provider-unavailable errors include a `retryable` boolean. In `nomnom doctor --json`, OFF reports:

- `product_lookup_reachable`: the v2 product-by-barcode endpoint answered after bounded retries;
this says nothing about free-text search.
- `full_text_search_ready`: the same v1 CGI capability used by runtime free-text resolution
answered with a valid product-list payload; when false, OFF free-text resolution is unavailable.

`configured` means OFF needs no credential. USDA retains `configured`, `reachable`, and
`key_source`. Doctor never includes credential values.

Successful logs are stored immediately. Agents should show the returned names, grams, confidence,
alternatives, and assumptions before treating the resolution as confirmed.
Expand Down
71 changes: 71 additions & 0 deletions docs/plans.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,76 @@
# Plans

## Issue #17 Source
- Task: Fix the Open Food Facts full-text provider contract without live-test traffic.
- Canonical input: GitHub issue #17 and the user's required behavior.
- Repo context: `nomnomcli/off.py`, provider onboarding/doctor output, focused tests, README, and agent skill.
- Last updated: 2026-07-21

## Issue #17 Assumptions
- OFF's legacy production full-text endpoint is `https://world.openfoodfacts.org/cgi/search.pl`.
- Product/barcode reachability and full-text readiness are independent capabilities and must be reported separately.
- Existing user-cache and USDA fallback behavior remains unchanged; OFF never substitutes v2 catalog rows for failed full-text search.

## Issue #17 Milestone Order
| ID | Title | Depends on | Status |
| --- | --- | --- | --- |
| M9 | Add failing OFF v1 and readiness contract tests | M8 | [x] |
| M10 | Implement v1-only full-text and split probes | M9 | [x] |
| M11 | Document status semantics and validate | M10 | [x] |
| M12 | Audit and commit scoped changes | M11 | [x] |

## M9. Add failing OFF v1 and readiness contract tests `[x]`
### Goal
- Capture endpoint, parameter, semantic, retry, no-fallback, confidence, and doctor contracts before implementation.

### Validation
```sh
pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py
```

### Stop-and-Fix Rule
- Record the expected RED failures before modifying runtime code.

## M10. Implement v1-only full-text and split probes `[x]`
### Goal
- Free text uses only the capability-probed v1 CGI endpoint; v2 remains product/barcode-only.

### Validation
```sh
pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py
```

### Stop-and-Fix Rule
- Never add a v2 free-text fallback or live-network test to make a contract test pass.

## M11. Document status semantics and validate `[x]`
### Goal
- README, setup output, and `skill/SKILL.md` explain product reachability versus full-text resolution readiness exactly.

### Validation
```sh
pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py tests/test_install.py
pytest -q
ruff check .
```

### Stop-and-Fix Rule
- Repair any focused, full-suite, or lint regression before committing.

## M12. Audit and commit scoped changes `[x]`
### Goal
- Commit only coherent issue #17 code, tests, docs, and execution records before the separate push/PR step.

### Validation
```sh
git diff --check
git status --short
git diff --stat
```

### Stop-and-Fix Rule
- Do not commit until the diff is scoped and every acceptance gate passes.

## v0.2 Source
- Task: Ship nomnomcli v0.2 resolving GitHub issues #1, #2, and #3.
- Canonical input: User-provided release brief for size parsing, Open Food Facts, and data quality.
Expand Down
14 changes: 11 additions & 3 deletions docs/status.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
# Status

## Snapshot
- Current phase: v0.2 complete
- Current phase: issue #17 complete
- Plan file: `docs/plans.md`
- Status: green
- Last updated: 2026-07-19
- Last updated: 2026-07-21

## Done
- Fixed issue #17 with OFF v1-only full-text search, independent product/full-text probes, typed bounded 503 handling, deterministic contract coverage, and updated provider docs.
- Built the complete package, 431-food database, 258-entry Russian synonym layer, CLI, recipes, installer, skill, docs, CI, and tests.
- Passed 30 pytest tests and Ruff with no issues.
- Passed the exact mixed-Russian log/stats flow, fixture recipe import/log, and installer dry-run.
Expand All @@ -19,14 +20,17 @@
- None.

## Next
- No push or PR was requested; the local conventional release commit is the handoff point.
- Push `feat/off-fulltext-contract` and open the issue #17 pull request after independent verification.

## Decisions Made
- Route all OFF free text to legacy v1 `/cgi/search.pl`; never pass `search_terms` to v2.
- Report OFF product/barcode reachability separately from full-text resolution readiness.
- Use stdlib `argparse` — keeps runtime dependencies to `requests` only.
- Use SQLite for shipped foods and per-user mutable state — matches the offline-first contract.
- Reuse `scripts/build_mini_db.py --update-existing` for deterministic offline v0.2 data repair without shrinking the tracked USDA corpus.

## Assumptions In Force
- Issue #17 tests use only mocked/replay transports and never live OFF traffic.
- Agent confirmation is an operating pattern, not a pending database transaction in v0.1.
- Named brands are never resolved to bundled generic foods; manual cache entries are the offline escape hatch.
- Descriptor weights are estimates that must be surfaced as assumptions in JSON and text.
Expand All @@ -52,6 +56,8 @@ ruff check .
| 2026-07-19 | M6 | OFF client, food repository, cache migration, CLI add, tests | mocked OFF success/503/network/malformed/ambiguity/cache runs | pass | M7 |
| 2026-07-19 | M7 | data overrides, offline seed, bundled SQLite, builder, quality tests | `build_mini_db.py --update-existing`; deterministic hash audit | 431 rows; pass | M8 |
| 2026-07-19 | M8 | README, skill, version, planning docs | `pytest -q`; `ruff check .`; exact isolated smoke; diff/junk audit | 72 pass; clean | commit |
| 2026-07-21 | M9 | OFF, food confidence, doctor/setup contract tests | `PYTHONPATH=. pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py`; focused setup test | RED: missing product probe; RED: missing status explanation | M10 |
| 2026-07-21 | M10–M11 | OFF client, onboarding/CLI, README, skill, tests | focused pytest; full pytest; full Ruff | 62 pass; 126 pass; clean | M12 |

## Smoke / Demo Checklist
- [x] Russian mixed-item log works and persists.
Expand All @@ -63,3 +69,5 @@ ruff check .
- [x] OFF success/failure paths are covered without live traffic.
- [x] Bundled database passes v0.2 quality tests.
- [x] Version and documentation report 0.2.0 behavior.
- [x] OFF free text uses v1 with no unfiltered v2 fallback.
- [x] Doctor and setup distinguish product/barcode reachability from full-text readiness.
27 changes: 27 additions & 0 deletions docs/test-plan.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
# Test Plan

## Issue #17 Source
- Task: Validate the OFF full-text provider contract from GitHub issue #17.
- Plan file: `docs/plans.md`
- Status file: `docs/status.md`
- Last updated: 2026-07-21

## Issue #17 Validation Scope
- In scope: v1 CGI URL and parameters, query-specific mocked results, no v2 free-text fallback, bounded 503/429 retry including `Retry-After`, split OFF doctor readiness, existing confidence filtering, README, setup output, and agent skill.
- Out of scope: live OFF/USDA traffic, bundled food data, aliases, nutrition facts, or unrelated resolver behavior.

## Issue #17 Fixtures and Network Rules
- Every HTTP response is injected or monkeypatched; tests must be deterministic and replayable offline.
- Distinct terms map to distinct mocked v1 payloads so forwarding semantics are observable.

## Issue #17 Acceptance Gates
- [x] Focused contract tests observed RED before runtime implementation.
- [x] `pytest -q tests/test_off.py tests/test_foods.py tests/test_config.py tests/test_cli.py tests/test_install.py` — 62 passed.
- [x] `pytest -q` — 126 passed.
- [x] `ruff check .` — clean.
- [x] `git diff --check` — clean.
- [x] Scoped conventional commit created locally; no push or PR.

## Issue #17 Negative / Edge Cases
- v1 503 exhausts bounded retries and raises retryable `openfoodfacts_unavailable` without any v2 request.
- A valid but unrelated v1 candidate remains rejected as `off_low_confidence` and is not cached.
- Product/barcode HTTP reachability cannot make doctor report full-text resolution ready.

## v0.2 Source
- Task: Validate nomnomcli v0.2 issues #1, #2, and #3.
- Plan file: `docs/plans.md`
Expand Down
23 changes: 20 additions & 3 deletions nomnomcli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,19 @@ def _run(args: argparse.Namespace) -> int:
print("USDA FoodData Central: free API key; generic/raw foods and fallback.")
print("USDA signup: https://fdc.nal.usda.gov/api-key-signup.html")
result = setup_providers(interactive=sys.stdin.isatty())
off_status = (
off_product_status = (
"reachable"
if result["providers"]["openfoodfacts"]["reachable"]
if result["providers"]["openfoodfacts"]["product_lookup_reachable"]
else "unreachable"
)
print(f"Open Food Facts (no key): {off_status}")
off_search_status = (
"ready"
if result["providers"]["openfoodfacts"]["full_text_search_ready"]
else "unavailable"
)
print(f"Open Food Facts product/barcode lookup (no key): {off_product_status}")
print(f"Open Food Facts full-text resolution: {off_search_status}")
print("Product reachability does not imply full-text readiness.")
print(
"USDA: reachable; key source "
f"{result['providers']['usda']['key_source']}"
Expand All @@ -143,6 +150,16 @@ def _run(args: argparse.Namespace) -> int:
print(_json_output(result))
else:
for provider, status in result["providers"].items():
if provider == "openfoodfacts":
product_state = (
"reachable" if status["product_lookup_reachable"] else "unreachable"
)
search_state = (
"ready" if status["full_text_search_ready"] else "unavailable"
)
print(f"openfoodfacts product/barcode lookup: {product_state}")
print(f"openfoodfacts full-text resolution: {search_state}")
continue
state = "reachable" if status["reachable"] else "unreachable"
print(f"{provider}: {state}")
return 0
Expand Down
45 changes: 35 additions & 10 deletions nomnomcli/off.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
from nomnomcli.models import Food
from nomnomcli.providers import RetryPolicy, request_with_retry

OFF_SEARCH_URL = "https://api.openfoodfacts.org/api/v2/search"
OFF_SEARCH_URL = "https://world.openfoodfacts.org/cgi/search.pl"
OFF_PRODUCT_PROBE_URL = "https://api.openfoodfacts.org/api/v2/product/0"
OFF_FIELDS = "product_name,brands,nutriments,code,serving_size,categories,categories_tags"


Expand Down Expand Up @@ -93,6 +94,14 @@ def __init__(
self.retry_policy = retry_policy or RetryPolicy()
self.sleep = sleep

def _headers(self) -> dict[str, str]:
return {
"User-Agent": (
f"nomnomcli/{__version__} "
"(+https://github.com/maxjustships/nomnomcli)"
)
}

def _get_payload(self, query: str, page_size: int) -> dict:
details = {
"food": query,
Expand All @@ -104,22 +113,20 @@ def _get_payload(self, query: str, page_size: int) -> dict:
response = request_with_retry(
provider="openfoodfacts",
code="openfoodfacts_unavailable",
message="Open Food Facts lookup is unavailable",
message="Open Food Facts full-text search is unavailable",
request_get=self._request_get or requests.get,
url=OFF_SEARCH_URL,
request_kwargs={
"params": {
"search_terms": query,
"search_simple": 1,
"action": "process",
"json": 1,
"fields": OFF_FIELDS,
"page_size": page_size,
},
"timeout": 10,
"headers": {
"User-Agent": (
f"nomnomcli/{__version__} "
"(+https://github.com/maxjustships/nomnomcli)"
)
},
"headers": self._headers(),
},
details=details,
retry_policy=self.retry_policy,
Expand All @@ -132,7 +139,7 @@ def _get_payload(self, query: str, page_size: int) -> dict:
raise ProviderUnavailableError(
"openfoodfacts",
"openfoodfacts_unavailable",
"Open Food Facts lookup is unavailable",
"Open Food Facts full-text search is unavailable",
retryable=False,
details={**details, "reason": "http_error", "status": status, "attempts": 1},
) from exc
Expand All @@ -153,7 +160,25 @@ def _get_payload(self, query: str, page_size: int) -> dict:
return payload

def probe(self) -> bool:
self._get_payload("a", 1)
self._get_payload("nomnom", 1)
return True

def probe_product(self) -> bool:
request_with_retry(
provider="openfoodfacts",
code="openfoodfacts_unavailable",
message="Open Food Facts product lookup is unavailable",
request_get=self._request_get or requests.get,
url=OFF_PRODUCT_PROBE_URL,
request_kwargs={
"params": {"fields": "code"},
"timeout": 10,
"headers": self._headers(),
},
details={"capability": "product_lookup"},
retry_policy=self.retry_policy,
sleep=self.sleep,
)
return True

def search(self, query: str, page_size: int = 5) -> list[Food]:
Expand Down
16 changes: 12 additions & 4 deletions nomnomcli/onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ def _reachable(probe: Callable[[], bool]) -> bool:
return False


def _off_status(off: OpenFoodFactsClient) -> dict:
return {
"configured": True,
"product_lookup_reachable": _reachable(off.probe_product),
"full_text_search_ready": _reachable(off.probe),
}


def doctor_report(
*,
config: ProviderConfig | None = None,
Expand All @@ -32,7 +40,7 @@ def doctor_report(
credential = None
return {
"providers": {
"openfoodfacts": {"configured": True, "reachable": _reachable(off.probe)},
"openfoodfacts": _off_status(off),
"usda": {
"configured": credential is not None,
"reachable": (
Expand All @@ -57,13 +65,13 @@ def setup_providers(
provider_config = config or ProviderConfig()
off = off_client or OpenFoodFactsClient()
usda = usda_client or USDAClient()
off_reachable = _reachable(off.probe)
off_status = _off_status(off)
credential = provider_config.usda_credential()
if credential is not None:
usda.probe(credential.value)
return {
"providers": {
"openfoodfacts": {"configured": True, "reachable": off_reachable},
"openfoodfacts": off_status,
"usda": {
"configured": True,
"reachable": True,
Expand Down Expand Up @@ -94,7 +102,7 @@ def setup_providers(
path = provider_config.store_usda_key(api_key)
return {
"providers": {
"openfoodfacts": {"configured": True, "reachable": off_reachable},
"openfoodfacts": off_status,
"usda": {"configured": True, "reachable": True, "key_source": "user_config"},
},
"config_path": str(path),
Expand Down
Loading
Loading