feat: store — recipe search, rate-limit check, substitution feedback logging
This commit is contained in:
parent
925d1eeacb
commit
97e8889d89
3 changed files with 130 additions and 0 deletions
|
|
@ -260,3 +260,89 @@ class Store:
|
||||||
return self._fetch_one(
|
return self._fetch_one(
|
||||||
"SELECT * FROM receipt_data WHERE receipt_id = ?", (receipt_id,)
|
"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()
|
||||||
|
|
|
||||||
0
tests/db/__init__.py
Normal file
0
tests/db/__init__.py
Normal file
44
tests/db/test_store_recipes.py
Normal file
44
tests/db/test_store_recipes.py
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue