diff --git a/README.md b/README.md index 26cb9cc..64f8079 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ If you plan to run the app in a conda or virtual environment, make sure to set u 3. Run the application with the following command: ```bash - streamlit run recommender.py + streamlit run main.py ``` 4. Once running, the app will be accessible at `localhost:8501`. @@ -55,10 +55,30 @@ You're done! Access the app at `localhost:8501` in your browser. The recommender system works by loading a set of random video links. Once the user hits the Run button, a video will be shown, and the system will suggest whether it recommends the video or not. The user can then provide feedback using "pain" or "pleasure" signals to guide the recommendation process. Based on this feedback, the system adjusts its responses and suggests another video. This cycle continues, allowing for more accurate and personalized recommendations over time. +## Recipe Domain Demo + +This repository also includes a recipe and meal-planning recommender domain that demonstrates how the same continuously trainable pattern can work beyond YouTube videos. + +The demo uses a local recipe catalog with cuisine, meal slot, cook time, difficulty, dietary tags, and meal context labels. These features are converted into binary AO inputs in `recipe_domain.py`, using the architecture in `arch__RecipeRecommender.py`. + +Run the recipe demo with: + +```bash +streamlit run recipe_recommender.py +``` + +The recipe app supports a live AO Agent when `ao_core` and `ao_arch` are installed. If those private packages are unavailable during review, it falls back to a deterministic scorer so the workflow, dataset, and feature encoding can still be tested locally. + +Recipe-domain validation: + +```bash +python3 -m unittest discover -s tests +python3 -m py_compile recipe_domain.py recipe_recommender.py arch__RecipeRecommender.py tests/test_recipe_domain.py +``` + ## Contributing Fork the repository, make your changes, and submit a pull request for review. - diff --git a/arch__RecipeRecommender.py b/arch__RecipeRecommender.py new file mode 100644 index 0000000..3bfd8c6 --- /dev/null +++ b/arch__RecipeRecommender.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +""" +AO architecture for the recipe-domain recommender demo. +""" + +import ao_arch as ar + +description = "Recipe Recommender System" + +# recipe attributes plus requested meal slot, cook-time limit, dietary preference, and mood +arch_i = [3, 2, 2, 2, 4, 2, 2, 4, 2] +arch_z = [12] +arch_c = [] +connector_function = "full_conn" + +arch = ar.Arch(arch_i, arch_z, arch_c, connector_function, description) diff --git a/recipe_domain.py b/recipe_domain.py new file mode 100644 index 0000000..72a2885 --- /dev/null +++ b/recipe_domain.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from dataclasses import dataclass +from math import ceil, log2 + + +@dataclass(frozen=True) +class Recipe: + title: str + cuisine: str + meal_slot: str + cook_time_minutes: int + difficulty: str + dietary_tags: tuple[str, ...] + contexts: tuple[str, ...] + description: str + ingredients: tuple[str, ...] + + +@dataclass(frozen=True) +class MealContext: + meal_slot: str + mood: str + max_cook_time_minutes: int + dietary_preference: str | None = None + + +CUISINES = ( + "american", + "asian", + "latin", + "mediterranean", + "middle_eastern", + "indian", + "bakery", + "global", +) +MEAL_SLOTS = ("breakfast", "lunch", "dinner", "snack") +DIFFICULTIES = ("easy", "moderate", "project") +MOODS = ("quick", "comfort", "balanced", "adventurous") +DIETARY_FLAGS = ("vegetarian", "high_protein", "gluten_free", "dairy_free") + + +RECIPE_CATALOG = ( + Recipe( + title="Miso Mushroom Rice Bowl", + cuisine="asian", + meal_slot="dinner", + cook_time_minutes=28, + difficulty="easy", + dietary_tags=("vegetarian", "dairy_free"), + contexts=("quick", "balanced"), + description="Savory mushrooms, greens, and miso dressing over steamed rice.", + ingredients=("mushrooms", "rice", "miso", "spinach", "sesame"), + ), + Recipe( + title="Chickpea Shawarma Wraps", + cuisine="middle_eastern", + meal_slot="lunch", + cook_time_minutes=24, + difficulty="easy", + dietary_tags=("vegetarian", "dairy_free"), + contexts=("quick", "adventurous"), + description="Roasted chickpeas, cucumber, herbs, and tahini in warm flatbread.", + ingredients=("chickpeas", "flatbread", "tahini", "cucumber", "parsley"), + ), + Recipe( + title="Turkey Chili with Black Beans", + cuisine="american", + meal_slot="dinner", + cook_time_minutes=45, + difficulty="moderate", + dietary_tags=("high_protein", "gluten_free", "dairy_free"), + contexts=("comfort", "balanced"), + description="Weeknight chili with lean turkey, beans, tomatoes, and warm spices.", + ingredients=("ground turkey", "black beans", "tomatoes", "chili powder"), + ), + Recipe( + title="Lemon Herb Salmon Tray Bake", + cuisine="mediterranean", + meal_slot="dinner", + cook_time_minutes=32, + difficulty="easy", + dietary_tags=("high_protein", "gluten_free", "dairy_free"), + contexts=("balanced", "quick"), + description="Salmon, potatoes, and green beans roasted on one tray.", + ingredients=("salmon", "potatoes", "green beans", "lemon", "dill"), + ), + Recipe( + title="Sweet Potato Breakfast Hash", + cuisine="american", + meal_slot="breakfast", + cook_time_minutes=30, + difficulty="moderate", + dietary_tags=("gluten_free", "high_protein"), + contexts=("comfort", "balanced"), + description="Crisp sweet potatoes with peppers, onions, and eggs.", + ingredients=("sweet potato", "eggs", "bell pepper", "onion"), + ), + Recipe( + title="Mango Coconut Overnight Oats", + cuisine="global", + meal_slot="breakfast", + cook_time_minutes=10, + difficulty="easy", + dietary_tags=("vegetarian", "dairy_free"), + contexts=("quick", "balanced"), + description="No-cook oats with mango, coconut milk, chia, and lime.", + ingredients=("oats", "mango", "coconut milk", "chia", "lime"), + ), + Recipe( + title="Lentil Bolognese", + cuisine="mediterranean", + meal_slot="dinner", + cook_time_minutes=55, + difficulty="moderate", + dietary_tags=("vegetarian", "dairy_free", "high_protein"), + contexts=("comfort", "balanced"), + description="Rich tomato lentil sauce for pasta or roasted vegetables.", + ingredients=("lentils", "tomatoes", "carrot", "celery", "pasta"), + ), + Recipe( + title="Paneer Tikka Salad", + cuisine="indian", + meal_slot="lunch", + cook_time_minutes=35, + difficulty="moderate", + dietary_tags=("vegetarian", "high_protein", "gluten_free"), + contexts=("adventurous", "balanced"), + description="Spiced paneer over greens with cucumber, tomato, and mint yogurt.", + ingredients=("paneer", "yogurt", "greens", "cucumber", "garam masala"), + ), + Recipe( + title="Avocado Edamame Toast", + cuisine="global", + meal_slot="snack", + cook_time_minutes=12, + difficulty="easy", + dietary_tags=("vegetarian", "dairy_free", "high_protein"), + contexts=("quick", "balanced"), + description="Smashed avocado and edamame on toast with chili crisp.", + ingredients=("bread", "avocado", "edamame", "lime", "chili crisp"), + ), + Recipe( + title="Cocoa Tahini Energy Bites", + cuisine="bakery", + meal_slot="snack", + cook_time_minutes=18, + difficulty="easy", + dietary_tags=("vegetarian", "gluten_free", "dairy_free"), + contexts=("quick", "comfort"), + description="No-bake bites with oats, tahini, cocoa, dates, and seeds.", + ingredients=("oats", "tahini", "cocoa", "dates", "sunflower seeds"), + ), + Recipe( + title="Black Bean Taco Skillet", + cuisine="latin", + meal_slot="dinner", + cook_time_minutes=25, + difficulty="easy", + dietary_tags=("vegetarian", "gluten_free", "dairy_free"), + contexts=("quick", "comfort"), + description="A single-pan taco filling with black beans, corn, and salsa.", + ingredients=("black beans", "corn", "salsa", "tortillas", "cilantro"), + ), + Recipe( + title="Saffron Chickpea Pilaf", + cuisine="middle_eastern", + meal_slot="dinner", + cook_time_minutes=50, + difficulty="project", + dietary_tags=("vegetarian", "gluten_free", "dairy_free"), + contexts=("adventurous", "comfort"), + description="Fragrant rice with chickpeas, herbs, raisins, and toasted nuts.", + ingredients=("rice", "chickpeas", "saffron", "raisins", "almonds"), + ), +) + + +def _option_bits(value: str, options: tuple[str, ...]) -> list[int]: + normalized = value.lower() + if normalized not in options: + normalized = options[-1] + bit_width = ceil(log2(len(options))) + return [int(bit) for bit in format(options.index(normalized), f"0{bit_width}b")] + + +def _cook_time_bits(minutes: int) -> list[int]: + if minutes <= 20: + return [0, 0] + if minutes <= 35: + return [0, 1] + if minutes <= 55: + return [1, 0] + return [1, 1] + + +def _dietary_preference_bits(preference: str | None) -> list[int]: + return [int(preference == flag) for flag in DIETARY_FLAGS] + + +def encode_recipe(recipe: Recipe, context: MealContext) -> list[int]: + return ( + _option_bits(recipe.cuisine, CUISINES) + + _option_bits(recipe.meal_slot, MEAL_SLOTS) + + _cook_time_bits(recipe.cook_time_minutes) + + _option_bits(recipe.difficulty, DIFFICULTIES) + + [int(flag in recipe.dietary_tags) for flag in DIETARY_FLAGS] + + _option_bits(context.meal_slot, MEAL_SLOTS) + + _cook_time_bits(context.max_cook_time_minutes) + + _dietary_preference_bits(context.dietary_preference) + + _option_bits(context.mood, MOODS) + ) + + +def score_recipe(recipe: Recipe, context: MealContext) -> int: + score = 0 + if recipe.meal_slot == context.meal_slot: + score += 35 + if context.mood in recipe.contexts: + score += 25 + if recipe.cook_time_minutes <= context.max_cook_time_minutes: + score += 20 + else: + score -= min(25, recipe.cook_time_minutes - context.max_cook_time_minutes) + if context.dietary_preference: + if context.dietary_preference in recipe.dietary_tags: + score += 20 + else: + score -= 35 + if recipe.difficulty == "easy": + score += 8 + elif recipe.difficulty == "project" and context.mood != "adventurous": + score -= 10 + return max(0, min(100, score)) + + +def recommend_recipes( + context: MealContext, + catalog: tuple[Recipe, ...] = RECIPE_CATALOG, + limit: int = 5, +) -> list[tuple[Recipe, int, list[int]]]: + ranked = [ + (recipe, score_recipe(recipe, context), encode_recipe(recipe, context)) + for recipe in catalog + ] + ranked.sort(key=lambda item: (-item[1], item[0].cook_time_minutes, item[0].title)) + return ranked[:limit] + + +def feedback_label(user_liked_recipe: bool, output_size: int) -> list[int]: + return [1 if user_liked_recipe else 0] * output_size diff --git a/recipe_recommender.py b/recipe_recommender.py new file mode 100644 index 0000000..93c569e --- /dev/null +++ b/recipe_recommender.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import numpy as np +import pandas as pd +import streamlit as st + +from recipe_domain import ( + DIETARY_FLAGS, + MEAL_SLOTS, + MOODS, + MealContext, + feedback_label, + recommend_recipes, +) + +try: + import ao_core as ao + from arch__RecipeRecommender import arch +except ImportError: + ao = None + arch = None + + +def _ensure_state() -> None: + if "recipe_agent" not in st.session_state: + if ao and arch: + st.session_state.recipe_agent = ao.Agent(arch, notes="Recipe Agent") + for _ in range(4): + st.session_state.recipe_agent.reset_state() + st.session_state.recipe_agent.reset_state(training=True) + else: + st.session_state.recipe_agent = None + if "last_recipe_input" not in st.session_state: + st.session_state.last_recipe_input = None + + +def _agent_percentage(binary_input: list[int], fallback_score: int) -> int: + agent = st.session_state.recipe_agent + if agent is None: + return fallback_score + agent.reset_state() + response = None + for _ in range(5): + response = agent.next_state(INPUT=binary_input, print_result=False) + return round((sum(response) / len(response)) * 100) + + +def _train_agent(liked: bool) -> None: + agent = st.session_state.recipe_agent + binary_input = st.session_state.last_recipe_input + if agent is None or binary_input is None: + return + label = np.array(feedback_label(liked, agent.arch.Z__flat.shape[0]), dtype=np.int8) + for _ in range(6 if liked else 10): + agent.reset_state() + agent.next_state(INPUT=binary_input, LABEL=label, print_result=False, unsequenced=True) + + +st.set_page_config( + page_title="Recipe Recommender Demo by AO Labs", + page_icon="misc/ao_favicon.png", + layout="wide", +) + +_ensure_state() + +st.title("Real-Time Recipe Recommender") +st.write( + "### A new recipe and meal-planning domain for AO Labs' continuously trainable " + "recommender" +) + +with st.sidebar: + st.write("## Meal Context") + meal_slot = st.selectbox("Meal", MEAL_SLOTS, index=MEAL_SLOTS.index("dinner")) + mood = st.selectbox("Mood", MOODS, index=MOODS.index("balanced")) + max_cook_time = st.slider( + "Max cook time", + min_value=10, + max_value=75, + value=35, + step=5, + ) + dietary_options = ("none",) + DIETARY_FLAGS + dietary_choice = st.selectbox("Dietary preference", dietary_options) + dietary_preference = None if dietary_choice == "none" else dietary_choice + + st.write("---") + if st.session_state.recipe_agent is None: + st.info( + "AO packages are not installed, so this demo is using the deterministic " + "fallback scorer." + ) + else: + st.success("Live AO Agent loaded.") + + if st.button("Recommend More Like Last Recipe", type="primary"): + _train_agent(True) + if st.button("Less Like Last Recipe"): + _train_agent(False) + +context = MealContext( + meal_slot=meal_slot, + mood=mood, + max_cook_time_minutes=max_cook_time, + dietary_preference=dietary_preference, +) + +recommendations = recommend_recipes(context, limit=5) + +for rank, (recipe, fallback_score, binary_input) in enumerate(recommendations, start=1): + if rank == 1: + st.session_state.last_recipe_input = binary_input + agent_score = _agent_percentage(binary_input, fallback_score) + with st.container(border=True): + st.subheader(f"{rank}. {recipe.title}") + st.write(recipe.description) + st.write( + f"**Cuisine:** {recipe.cuisine} | **Meal:** {recipe.meal_slot} | " + f"**Time:** {recipe.cook_time_minutes} min | **Difficulty:** {recipe.difficulty}" + ) + st.write("**Tags:** " + ", ".join(recipe.dietary_tags)) + st.progress(agent_score / 100, text=f"Recommendation strength: {agent_score}%") + +st.write("---") +st.write("### Feature Encoding") +encoding_rows = [ + { + "recipe": recipe.title, + "fallback_score": score, + "binary_input": "".join(str(bit) for bit in binary_input), + } + for recipe, score, binary_input in recommendations +] +st.dataframe(pd.DataFrame(encoding_rows), width="stretch") + +st.write( + "This demo maps meal context and recipe attributes into binary AO inputs, then lets the " + "agent learn from lightweight positive or negative feedback. The fallback scorer keeps the " + "demo reviewable without private AO package access." +) diff --git a/tests/test_recipe_domain.py b/tests/test_recipe_domain.py new file mode 100644 index 0000000..ff29607 --- /dev/null +++ b/tests/test_recipe_domain.py @@ -0,0 +1,54 @@ +import unittest + +from recipe_domain import ( + DIETARY_FLAGS, + MealContext, + RECIPE_CATALOG, + encode_recipe, + recommend_recipes, + score_recipe, +) + + +class RecipeDomainTest(unittest.TestCase): + def test_encoder_has_expected_shape(self): + context = MealContext("dinner", "balanced", 35) + encoded = encode_recipe(RECIPE_CATALOG[0], context) + self.assertEqual(len(encoded), 23) + self.assertTrue(all(bit in (0, 1) for bit in encoded)) + + def test_context_changes_binary_input(self): + recipe = RECIPE_CATALOG[0] + balanced = MealContext("dinner", "balanced", 35) + adventurous = MealContext("dinner", "adventurous", 35) + self.assertNotEqual(encode_recipe(recipe, balanced), encode_recipe(recipe, adventurous)) + + def test_binary_input_includes_meal_context(self): + recipe = RECIPE_CATALOG[0] + dinner = MealContext("dinner", "balanced", 35) + lunch = MealContext("lunch", "balanced", 35, "vegetarian") + self.assertNotEqual(encode_recipe(recipe, dinner), encode_recipe(recipe, lunch)) + + def test_dietary_preference_rewards_matching_recipes(self): + vegetarian_context = MealContext("lunch", "quick", 30, "vegetarian") + vegetarian_recipe = next(recipe for recipe in RECIPE_CATALOG if "vegetarian" in recipe.dietary_tags) + non_matching_recipe = next(recipe for recipe in RECIPE_CATALOG if "vegetarian" not in recipe.dietary_tags) + self.assertGreater( + score_recipe(vegetarian_recipe, vegetarian_context), + score_recipe(non_matching_recipe, vegetarian_context), + ) + + def test_recommendations_prioritize_requested_meal_and_time(self): + context = MealContext("breakfast", "quick", 15, "vegetarian") + top_recipe, top_score, _ = recommend_recipes(context, limit=1)[0] + self.assertEqual(top_recipe.meal_slot, "breakfast") + self.assertLessEqual(top_recipe.cook_time_minutes, 15) + self.assertGreaterEqual(top_score, 70) + + def test_all_dietary_flags_are_representable(self): + represented = {tag for recipe in RECIPE_CATALOG for tag in recipe.dietary_tags} + self.assertTrue(set(DIETARY_FLAGS).issubset(represented)) + + +if __name__ == "__main__": + unittest.main()