From 2233d55e2548a6ad33c14cd330bc145d0720f8ad Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 31 Mar 2026 12:23:07 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20RecipeEngine=20Level=201-2=20=E2=80=94?= =?UTF-8?q?=20grocery=20links=20+=20affiliate=20deeplink=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add GroceryLink schema model and grocery_links field to RecipeResult. Introduce GroceryLinkBuilder service (Amazon Fresh, Walmart, Instacart) using env-var affiliate tags; no links emitted when tags are absent. Wire link builder into RecipeEngine.suggest() for levels 1-2. Add test_grocery_links_free_tier to verify structure contract. 35 tests passing. --- app/models/schemas/recipe.py | 7 ++ app/services/recipe/grocery_links.py | 73 +++++++++++++++++++++ app/services/recipe/recipe_engine.py | 14 +++- tests/services/recipe/test_recipe_engine.py | 13 ++++ 4 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 app/services/recipe/grocery_links.py diff --git a/app/models/schemas/recipe.py b/app/models/schemas/recipe.py index 7227d77..ff61b86 100644 --- a/app/models/schemas/recipe.py +++ b/app/models/schemas/recipe.py @@ -23,10 +23,17 @@ class RecipeSuggestion(BaseModel): is_wildcard: bool = False +class GroceryLink(BaseModel): + ingredient: str + retailer: str + url: str + + class RecipeResult(BaseModel): suggestions: list[RecipeSuggestion] element_gaps: list[str] grocery_list: list[str] = Field(default_factory=list) + grocery_links: list[GroceryLink] = Field(default_factory=list) rate_limited: bool = False rate_limit_count: int = 0 diff --git a/app/services/recipe/grocery_links.py b/app/services/recipe/grocery_links.py new file mode 100644 index 0000000..d289325 --- /dev/null +++ b/app/services/recipe/grocery_links.py @@ -0,0 +1,73 @@ +""" +GroceryLinkBuilder — affiliate deeplinks for missing ingredient grocery lists. + +Free tier: URL construction only (Amazon Fresh, Walmart, Instacart). +Paid+: live product search API (stubbed — future task). + +Config (env vars, all optional — missing = retailer disabled): + AMAZON_AFFILIATE_TAG — e.g. "circuitforge-20" + INSTACART_AFFILIATE_ID — e.g. "circuitforge" + WALMART_AFFILIATE_ID — e.g. "circuitforge" (Impact affiliate network) +""" +from __future__ import annotations + +import os +from urllib.parse import quote_plus + +from app.models.schemas.recipe import GroceryLink + + +def _amazon_link(ingredient: str, tag: str) -> GroceryLink: + q = quote_plus(ingredient) + url = f"https://www.amazon.com/s?k={q}&i=amazonfresh&tag={tag}" + return GroceryLink(ingredient=ingredient, retailer="Amazon Fresh", url=url) + + +def _walmart_link(ingredient: str, affiliate_id: str) -> GroceryLink: + q = quote_plus(ingredient) + # Walmart Impact affiliate deeplink pattern + url = f"https://goto.walmart.com/c/{affiliate_id}/walmart?u=https://www.walmart.com/search?q={q}" + return GroceryLink(ingredient=ingredient, retailer="Walmart Grocery", url=url) + + +def _instacart_link(ingredient: str, affiliate_id: str) -> GroceryLink: + q = quote_plus(ingredient) + url = f"https://www.instacart.com/store/s?k={q}&aff={affiliate_id}" + return GroceryLink(ingredient=ingredient, retailer="Instacart", url=url) + + +class GroceryLinkBuilder: + def __init__(self, tier: str = "free", has_byok: bool = False) -> None: + self._tier = tier + self._has_byok = has_byok + self._amazon_tag = os.environ.get("AMAZON_AFFILIATE_TAG", "") + self._instacart_id = os.environ.get("INSTACART_AFFILIATE_ID", "") + self._walmart_id = os.environ.get("WALMART_AFFILIATE_ID", "") + + def build_links(self, ingredient: str) -> list[GroceryLink]: + """Build affiliate deeplinks for a single ingredient. + + Free tier: URL construction only. + Paid+: would call live product search APIs (stubbed). + """ + links: list[GroceryLink] = [] + + if self._amazon_tag: + links.append(_amazon_link(ingredient, self._amazon_tag)) + if self._walmart_id: + links.append(_walmart_link(ingredient, self._walmart_id)) + if self._instacart_id: + links.append(_instacart_link(ingredient, self._instacart_id)) + + # Paid+: live API stub (future task) + # if self._tier in ("paid", "premium") and not self._has_byok: + # links.extend(self._search_kroger_api(ingredient)) + + return links + + def build_all(self, ingredients: list[str]) -> list[GroceryLink]: + """Build links for a list of ingredients.""" + links: list[GroceryLink] = [] + for ingredient in ingredients: + links.extend(self.build_links(ingredient)) + return links diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index 7d1a167..57b6fc4 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -20,8 +20,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from app.db.store import Store -from app.models.schemas.recipe import RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate +from app.models.schemas.recipe import GroceryLink, RecipeRequest, RecipeResult, RecipeSuggestion, SwapCandidate from app.services.recipe.element_classifier import ElementClassifier +from app.services.recipe.grocery_links import GroceryLinkBuilder from app.services.recipe.substitution_engine import SubstitutionEngine _LEFTOVER_DAILY_MAX_FREE = 5 @@ -163,4 +164,13 @@ class RecipeEngine: grocery_list.append(item) seen.add(item) - return RecipeResult(suggestions=suggestions, element_gaps=gaps, grocery_list=grocery_list) + # Build grocery links — affiliate deeplinks for each missing ingredient + link_builder = GroceryLinkBuilder(tier=req.tier, has_byok=req.has_byok) + grocery_links = link_builder.build_all(grocery_list) + + return RecipeResult( + suggestions=suggestions, + element_gaps=gaps, + grocery_list=grocery_list, + grocery_links=grocery_links, + ) diff --git a/tests/services/recipe/test_recipe_engine.py b/tests/services/recipe/test_recipe_engine.py index acd1fe8..2ca6aa3 100644 --- a/tests/services/recipe/test_recipe_engine.py +++ b/tests/services/recipe/test_recipe_engine.py @@ -106,3 +106,16 @@ def test_hard_day_mode_filters_complex_methods(store_with_recipes): result = engine.suggest(req_hard) titles = [s.title for s in result.suggestions] assert "Braised Short Ribs" not in titles + + +def test_grocery_links_free_tier(store_with_recipes): + from app.services.recipe.recipe_engine import RecipeEngine, RecipeRequest + engine = RecipeEngine(store_with_recipes) + req = RecipeRequest(pantry_items=["butter"], level=1, constraints=[], max_missing=5) + result = engine.suggest(req) + # Links may be empty if no retailer env vars set, but structure must be correct + assert isinstance(result.grocery_links, list) + for link in result.grocery_links: + assert hasattr(link, "ingredient") + assert hasattr(link, "retailer") + assert hasattr(link, "url")