feat: RecipeEngine Level 1-2 — grocery links + affiliate deeplink builder
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.
This commit is contained in:
parent
37737b06de
commit
e8fb57f6a2
4 changed files with 105 additions and 2 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
73
app/services/recipe/grocery_links.py
Normal file
73
app/services/recipe/grocery_links.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue