diff --git a/app/db/store.py b/app/db/store.py index 9a0e366..7f11efd 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -260,3 +260,89 @@ class Store: return self._fetch_one( "SELECT * FROM receipt_data WHERE receipt_id = ?", (receipt_id,) ) + + # ── recipes ─────────────────────────────────────────────────────────── + + def search_recipes_by_ingredients( + self, + ingredient_names: list[str], + limit: int = 20, + category: str | None = None, + ) -> list[dict]: + """Find recipes containing any of the given ingredient names. + Scores by match count and returns highest-scoring first.""" + if not ingredient_names: + return [] + like_params = [f'%"{n}"%' for n in ingredient_names] + like_clauses = " OR ".join( + "r.ingredient_names LIKE ?" for _ in ingredient_names + ) + match_score = " + ".join( + "CASE WHEN r.ingredient_names LIKE ? THEN 1 ELSE 0 END" + for _ in ingredient_names + ) + category_clause = "" + category_params: list = [] + if category: + category_clause = "AND r.category = ?" + category_params = [category] + sql = f""" + SELECT r.*, ({match_score}) AS match_count + FROM recipes r + WHERE ({like_clauses}) + {category_clause} + ORDER BY match_count DESC, r.id ASC + LIMIT ? + """ + all_params = like_params + like_params + category_params + [limit] + return self._fetch_all(sql, tuple(all_params)) + + def get_recipe(self, recipe_id: int) -> dict | None: + return self._fetch_one("SELECT * FROM recipes WHERE id = ?", (recipe_id,)) + + # ── rate limits ─────────────────────────────────────────────────────── + + def check_and_increment_rate_limit( + self, feature: str, daily_max: int + ) -> tuple[bool, int]: + """Check daily counter for feature; only increment if under the limit. + Returns (allowed, current_count). Rejected calls do not consume quota.""" + from datetime import date + today = date.today().isoformat() + row = self._fetch_one( + "SELECT count FROM rate_limits WHERE feature = ? AND window_date = ?", + (feature, today), + ) + current = row["count"] if row else 0 + if current >= daily_max: + return (False, current) + self.conn.execute(""" + INSERT INTO rate_limits (feature, window_date, count) + VALUES (?, ?, 1) + ON CONFLICT(feature, window_date) DO UPDATE SET count = count + 1 + """, (feature, today)) + self.conn.commit() + return (True, current + 1) + + # ── substitution feedback ───────────────────────────────────────────── + + def log_substitution_feedback( + self, + original: str, + substitute: str, + constraint: str | None, + compensation_used: list[str], + approved: bool, + opted_in: bool, + ) -> None: + self.conn.execute(""" + INSERT INTO substitution_feedback + (original_name, substitute_name, constraint_label, + compensation_used, approved, opted_in) + VALUES (?,?,?,?,?,?) + """, ( + original, substitute, constraint, + self._dump(compensation_used), + int(approved), int(opted_in), + )) + self.conn.commit() diff --git a/tests/db/__init__.py b/tests/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/db/test_store_recipes.py b/tests/db/test_store_recipes.py new file mode 100644 index 0000000..1e71446 --- /dev/null +++ b/tests/db/test_store_recipes.py @@ -0,0 +1,44 @@ +import json, pytest +from tests.services.recipe.test_element_classifier import store_with_profiles + + +@pytest.fixture +def store_with_recipes(store_with_profiles): + store_with_profiles.conn.executemany(""" + INSERT INTO recipes (external_id, title, ingredients, ingredient_names, + directions, category, keywords, element_coverage) + VALUES (?,?,?,?,?,?,?,?) + """, [ + ("1", "Butter Pasta", '["butter","pasta","parmesan"]', + '["butter","pasta","parmesan"]', '["boil pasta","toss with butter"]', + "Italian", '["quick","pasta"]', + '{"Richness":0.5,"Depth":0.3,"Structure":0.2}'), + ("2", "Lentil Soup", '["lentils","carrots","onion","broth"]', + '["lentils","carrots","onion","broth"]', '["simmer all"]', + "Soup", '["vegan","hearty"]', + '{"Depth":0.4,"Seasoning":0.3}'), + ]) + store_with_profiles.conn.commit() + return store_with_profiles + + +def test_search_recipes_by_ingredient_names(store_with_recipes): + results = store_with_recipes.search_recipes_by_ingredients(["butter", "parmesan"]) + assert len(results) >= 1 + assert any(r["title"] == "Butter Pasta" for r in results) + +def test_search_recipes_respects_limit(store_with_recipes): + results = store_with_recipes.search_recipes_by_ingredients(["butter"], limit=1) + assert len(results) <= 1 + +def test_check_rate_limit_first_call(store_with_recipes): + allowed, count = store_with_recipes.check_and_increment_rate_limit("leftover_mode", daily_max=5) + assert allowed is True + assert count == 1 + +def test_check_rate_limit_exceeded(store_with_recipes): + for _ in range(5): + store_with_recipes.check_and_increment_rate_limit("leftover_mode", daily_max=5) + allowed, count = store_with_recipes.check_and_increment_rate_limit("leftover_mode", daily_max=5) + assert allowed is False + assert count == 5