diff --git a/README.md b/README.md index 26cb9cc..41ee173 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,27 @@ 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. +## Travel Domain Demo + +This fork also includes a second runnable domain for the paid recommender expansion bounty: a real-time personal travel-experience recommender. + +It uses a local catalog of city experiences, encodes each recommendation into the same eight-bit input shape as the original AO architecture (`[3, 2, 1, 2]`), and trains a live AO Agent when `ao_core`/`ao_arch` are installed. Reviewers without the private AO packages still get a deterministic fallback ranker, so the demo remains runnable from a clean checkout. + +Run the travel demo: + +```bash +streamlit run travel_recommender.py +``` + +Run the domain tests: + +```bash +python -m unittest discover -s tests +``` + ## Contributing Fork the repository, make your changes, and submit a pull request for review. - diff --git a/tests/test_travel_domain.py b/tests/test_travel_domain.py new file mode 100644 index 0000000..85eae24 --- /dev/null +++ b/tests/test_travel_domain.py @@ -0,0 +1,50 @@ +import unittest + +from travel_domain import CATALOG, TravelPreferences, encode_experience, rank_experiences + + +class TravelDomainTest(unittest.TestCase): + def test_encoding_matches_existing_ao_architecture_width(self): + preferences = TravelPreferences(mood="active") + encoded = encode_experience(CATALOG[0], preferences) + + self.assertEqual(len(encoded), 8) + self.assertTrue(all(bit in (0, 1) for bit in encoded)) + + def test_budget_filter_penalizes_over_budget_experiences(self): + preferences = TravelPreferences(mood="active", max_budget="low", pace="energetic") + ranked = rank_experiences(CATALOG, preferences) + + top_five = [experience for experience, _, _ in ranked[:5]] + self.assertTrue(all(experience.budget in {"free", "low"} for experience in top_five)) + + def test_positive_feedback_promotes_activity_type(self): + preferences = TravelPreferences(mood="curious", max_budget="medium", pace="balanced") + baseline = rank_experiences(CATALOG, preferences) + promoted = rank_experiences(CATALOG, preferences, positive_feedback=["nightlife"]) + + baseline_best_nightlife = min( + index + for index, (experience, _, _) in enumerate(baseline) + if experience.activity_type == "nightlife" + ) + promoted_best_nightlife = min( + index + for index, (experience, _, _) in enumerate(promoted) + if experience.activity_type == "nightlife" + ) + self.assertLess(promoted_best_nightlife, baseline_best_nightlife) + + def test_negative_feedback_suppresses_specific_experience(self): + preferences = TravelPreferences(mood="relaxed", max_budget="medium", pace="calm") + target = "reykjavik-geothermal-dip" + ranked = rank_experiences(CATALOG, preferences, negative_feedback=[target]) + + target_rank = next( + index for index, (experience, _, _) in enumerate(ranked) if experience.id == target + ) + self.assertGreater(target_rank, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/travel_domain.py b/travel_domain.py new file mode 100644 index 0000000..f06c4e6 --- /dev/null +++ b/travel_domain.py @@ -0,0 +1,348 @@ +"""Travel-experience domain logic for the AO Labs recommender bounty. + +The Streamlit demo imports this module, and the tests exercise it without +requiring Streamlit or private AO packages. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Sequence + + +BUDGET_ORDER = {"free": 0, "low": 1, "medium": 2, "high": 3} +ACTIVITY_BITS = { + "culture": [0, 0, 0], + "food": [0, 0, 1], + "nature": [0, 1, 0], + "history": [0, 1, 1], + "nightlife": [1, 0, 0], + "wellness": [1, 0, 1], + "family": [1, 1, 0], + "adventure": [1, 1, 1], +} +BUDGET_BITS = { + "free": [0, 0], + "low": [0, 1], + "medium": [1, 0], + "high": [1, 1], +} +MOOD_BITS = { + "relaxed": [0, 0], + "curious": [0, 1], + "active": [1, 0], + "social": [1, 1], +} +MOOD_ACTIVITY_WEIGHTS = { + "relaxed": {"wellness": 3, "nature": 2, "culture": 1}, + "curious": {"culture": 3, "history": 3, "food": 1}, + "active": {"adventure": 3, "nature": 2, "family": 1}, + "social": {"nightlife": 3, "food": 2, "culture": 1}, +} +PACE_SCORE = {"calm": 0, "balanced": 1, "energetic": 2} + + +@dataclass(frozen=True) +class TravelExperience: + id: str + name: str + city: str + country: str + activity_type: str + budget: str + outdoor: bool + pace: str + party: tuple[str, ...] + duration_hours: float + description: str + + +@dataclass(frozen=True) +class TravelPreferences: + mood: str = "curious" + max_budget: str = "medium" + pace: str = "balanced" + party: str = "solo" + + +CATALOG: tuple[TravelExperience, ...] = ( + TravelExperience( + id="lisbon-azulejo-walk", + name="Azulejo street-art and tile walk", + city="Lisbon", + country="Portugal", + activity_type="culture", + budget="low", + outdoor=True, + pace="balanced", + party=("solo", "couple", "group"), + duration_hours=2.5, + description="A self-guided walk through Alfama and Mouraria tilework, murals, and viewpoints.", + ), + TravelExperience( + id="tokyo-depachika-dinner", + name="Depachika tasting loop", + city="Tokyo", + country="Japan", + activity_type="food", + budget="medium", + outdoor=False, + pace="calm", + party=("solo", "couple", "family"), + duration_hours=1.5, + description="Sample seasonal bento, wagashi, and regional snacks in a department-store food hall.", + ), + TravelExperience( + id="reykjavik-geothermal-dip", + name="Neighborhood geothermal pool reset", + city="Reykjavik", + country="Iceland", + activity_type="wellness", + budget="low", + outdoor=True, + pace="calm", + party=("solo", "couple", "family"), + duration_hours=2, + description="A low-key soak circuit at a local pool with hot pots and cold plunge options.", + ), + TravelExperience( + id="barcelona-gothic-legends", + name="Gothic Quarter legends route", + city="Barcelona", + country="Spain", + activity_type="history", + budget="free", + outdoor=True, + pace="balanced", + party=("solo", "couple", "group"), + duration_hours=2, + description="A compact route connecting Roman walls, medieval courtyards, and local legends.", + ), + TravelExperience( + id="vancouver-stanley-bike", + name="Stanley Park seawall ride", + city="Vancouver", + country="Canada", + activity_type="nature", + budget="medium", + outdoor=True, + pace="energetic", + party=("solo", "couple", "family", "group"), + duration_hours=3, + description="Cycle the seawall with forest detours, beach stops, and mountain views.", + ), + TravelExperience( + id="mexico-city-lucha-night", + name="Lucha libre and late taco crawl", + city="Mexico City", + country="Mexico", + activity_type="nightlife", + budget="medium", + outdoor=False, + pace="energetic", + party=("couple", "group"), + duration_hours=4, + description="A high-energy evening pairing an arena match with nearby al pastor stands.", + ), + TravelExperience( + id="copenhagen-harbor-swim", + name="Harbor swim and sauna hour", + city="Copenhagen", + country="Denmark", + activity_type="wellness", + budget="medium", + outdoor=True, + pace="calm", + party=("solo", "couple", "group"), + duration_hours=2, + description="A relaxed swim-sauna session near the harbor baths with cafe options nearby.", + ), + TravelExperience( + id="queenstown-canyon-swing", + name="Canyon swing adrenaline slot", + city="Queenstown", + country="New Zealand", + activity_type="adventure", + budget="high", + outdoor=True, + pace="energetic", + party=("solo", "couple", "group"), + duration_hours=3, + description="A guided canyon swing session for travelers chasing a memorable, high-intensity day.", + ), + TravelExperience( + id="london-museum-kids", + name="Hands-on museum morning", + city="London", + country="United Kingdom", + activity_type="family", + budget="free", + outdoor=False, + pace="calm", + party=("family",), + duration_hours=3, + description="A child-friendly museum route with interactive galleries and quiet reset spaces.", + ), + TravelExperience( + id="marrakesh-souk-cooking", + name="Souk pantry and tagine workshop", + city="Marrakesh", + country="Morocco", + activity_type="food", + budget="medium", + outdoor=True, + pace="balanced", + party=("solo", "couple", "family", "group"), + duration_hours=4, + description="Shop spices in the souk, then cook a tagine with a local host.", + ), + TravelExperience( + id="athens-acropolis-sunrise", + name="Acropolis sunrise history walk", + city="Athens", + country="Greece", + activity_type="history", + budget="low", + outdoor=True, + pace="balanced", + party=("solo", "couple", "family", "group"), + duration_hours=2.5, + description="Beat the heat with an early route around the Acropolis and surrounding viewpoints.", + ), + TravelExperience( + id="seoul-han-river-picnic", + name="Han River picnic and ramen stop", + city="Seoul", + country="South Korea", + activity_type="family", + budget="low", + outdoor=True, + pace="calm", + party=("solo", "couple", "family", "group"), + duration_hours=2, + description="A flexible riverside picnic with convenience-store ramen and skyline views.", + ), + TravelExperience( + id="berlin-techno-intro", + name="Low-pressure club culture intro", + city="Berlin", + country="Germany", + activity_type="nightlife", + budget="medium", + outdoor=False, + pace="energetic", + party=("solo", "couple", "group"), + duration_hours=5, + description="A beginner-friendly evening plan for sampling Berlin's electronic music scene.", + ), + TravelExperience( + id="banff-lake-hike", + name="Alpine lake early hike", + city="Banff", + country="Canada", + activity_type="nature", + budget="low", + outdoor=True, + pace="energetic", + party=("solo", "couple", "group"), + duration_hours=4, + description="An early-start trail plan for lake views before the crowds arrive.", + ), +) + + +def encode_experience(experience: TravelExperience, preferences: TravelPreferences) -> list[int]: + """Return the eight-bit AO input used by the travel demo. + + Bits are grouped as: activity type (3), budget (2), outdoor flag (1), mood (2). + That shape intentionally matches the original demo architecture: + ``arch_i = [3, 2, 1, 2]``. + """ + + return ( + ACTIVITY_BITS[experience.activity_type] + + BUDGET_BITS[experience.budget] + + [1 if experience.outdoor else 0] + + MOOD_BITS[preferences.mood] + ) + + +def budget_allows(experience: TravelExperience, max_budget: str) -> bool: + return BUDGET_ORDER[experience.budget] <= BUDGET_ORDER[max_budget] + + +def score_experience( + experience: TravelExperience, + preferences: TravelPreferences, + positive_feedback: Iterable[str] = (), + negative_feedback: Iterable[str] = (), +) -> tuple[int, list[str]]: + """Score a catalog item and explain the strongest matching reasons.""" + + positive = set(positive_feedback) + negative = set(negative_feedback) + score = 0 + reasons: list[str] = [] + + if budget_allows(experience, preferences.max_budget): + score += 5 + reasons.append(f"fits {preferences.max_budget} budget") + else: + score -= 6 + reasons.append(f"above {preferences.max_budget} budget") + + mood_weight = MOOD_ACTIVITY_WEIGHTS[preferences.mood].get(experience.activity_type, 0) + if mood_weight: + score += mood_weight + reasons.append(f"{experience.activity_type} works for a {preferences.mood} mood") + + pace_delta = abs(PACE_SCORE[experience.pace] - PACE_SCORE[preferences.pace]) + score += max(0, 3 - pace_delta) + if pace_delta == 0: + reasons.append(f"{experience.pace} pace match") + + if preferences.party in experience.party: + score += 3 + reasons.append(f"good for {preferences.party} travel") + + if experience.id in positive: + score += 6 + reasons.append("you asked for more like this") + if experience.activity_type in positive: + score += 3 + reasons.append(f"learned positive signal for {experience.activity_type}") + if experience.id in negative: + score -= 8 + reasons.append("you asked to see less like this") + if experience.activity_type in negative: + score -= 4 + reasons.append(f"learned negative signal for {experience.activity_type}") + + return score, reasons[:4] + + +def rank_experiences( + catalog: Sequence[TravelExperience], + preferences: TravelPreferences, + positive_feedback: Iterable[str] = (), + negative_feedback: Iterable[str] = (), +) -> list[tuple[TravelExperience, int, list[str]]]: + ranked = [ + ( + experience, + *score_experience( + experience, + preferences, + positive_feedback=positive_feedback, + negative_feedback=negative_feedback, + ), + ) + for experience in catalog + ] + return sorted(ranked, key=lambda item: (item[1], item[0].name), reverse=True) + + +def recommendation_percentage(score: int) -> int: + """Convert fallback score into a UI-friendly percentage.""" + + return max(0, min(100, 45 + score * 5)) diff --git a/travel_recommender.py b/travel_recommender.py new file mode 100644 index 0000000..e0102cb --- /dev/null +++ b/travel_recommender.py @@ -0,0 +1,204 @@ +"""Streamlit demo: AO-style travel experience recommender. + +Run with: + streamlit run travel_recommender.py +""" + +from __future__ import annotations + +import importlib + +import pandas as pd +import streamlit as st + +from travel_domain import ( + CATALOG, + TravelExperience, + TravelPreferences, + encode_experience, + rank_experiences, + recommendation_percentage, +) + + +def _optional_ao_agent(): + """Create an AO Agent when ao_core is installed, otherwise return None.""" + + try: + ao = importlib.import_module("ao_core") + arch_module = importlib.import_module("arch__Recommender") + except Exception: + return None + + try: + agent = ao.Agent(arch_module.arch, notes="Travel Domain Agent") + for _ in range(4): + agent.reset_state() + agent.reset_state(training=True) + return agent + except Exception: + return None + + +def _agent_percentage(agent, binary_input: list[int]) -> int | None: + if agent is None: + return None + + try: + agent.reset_state() + response = None + for _ in range(5): + response = agent.next_state(INPUT=binary_input, print_result=False) + if response is None: + return None + ones = sum(1 for value in response if value == 1) + return round((ones / len(response)) * 100) + except Exception: + return None + + +def _train_agent(agent, binary_input: list[int], positive: bool) -> None: + if agent is None: + return + + try: + import numpy as np + + label = ( + np.ones(agent.arch.Z__flat.shape, dtype=np.int8) + if positive + else np.zeros(agent.arch.Z__flat.shape, dtype=np.int8) + ) + repetitions = 5 if positive else 10 + for _ in range(repetitions): + agent.reset_state() + agent.next_state(INPUT=binary_input, LABEL=label, print_result=False, unsequenced=True) + except Exception as error: + st.warning(f"AO Agent training was skipped: {error}") + + +def _ensure_state() -> None: + defaults = { + "travel_positive": [], + "travel_negative": [], + "travel_history": [], + "travel_index": 0, + "travel_agent": None, + "travel_agent_checked": False, + } + for key, value in defaults.items(): + if key not in st.session_state: + st.session_state[key] = value + + if not st.session_state.travel_agent_checked: + st.session_state.travel_agent = _optional_ao_agent() + st.session_state.travel_agent_checked = True + + +def _current_recommendation( + preferences: TravelPreferences, +) -> tuple[TravelExperience, int, list[str], list[int], int | None]: + ranked = rank_experiences( + CATALOG, + preferences, + positive_feedback=st.session_state.travel_positive, + negative_feedback=st.session_state.travel_negative, + ) + selected = ranked[st.session_state.travel_index % len(ranked)] + experience, fallback_score, reasons = selected + binary_input = encode_experience(experience, preferences) + ao_percentage = _agent_percentage(st.session_state.travel_agent, binary_input) + fallback_percentage = recommendation_percentage(fallback_score) + return experience, fallback_percentage, reasons, binary_input, ao_percentage + + +def _record_feedback(experience: TravelExperience, binary_input: list[int], positive: bool) -> None: + target = "travel_positive" if positive else "travel_negative" + st.session_state[target].extend([experience.id, experience.activity_type]) + _train_agent(st.session_state.travel_agent, binary_input, positive=positive) + st.session_state.travel_history.append( + { + "experience": experience.name, + "city": experience.city, + "signal": "more" if positive else "less", + "activity": experience.activity_type, + } + ) + st.session_state.travel_index = 0 + + +def main() -> None: + st.set_page_config( + page_title="Travel Recommender by AO Labs", + page_icon="misc/ao_favicon.png", + layout="wide", + ) + _ensure_state() + + st.title("Real-Time Personal Travel Recommender") + st.write("A travel-domain AO recommender demo with a local fallback for reviewers.") + + with st.sidebar: + st.write("## Traveler Context") + mood = st.selectbox("Mood", ("curious", "relaxed", "active", "social"), index=0) + max_budget = st.selectbox("Max budget", ("free", "low", "medium", "high"), index=2) + pace = st.selectbox("Pace", ("calm", "balanced", "energetic"), index=1) + party = st.selectbox("Travel party", ("solo", "couple", "family", "group"), index=0) + + st.write("---") + if st.session_state.travel_agent is None: + st.info("Running deterministic fallback. Install ao_core/ao_arch to train a live AO Agent.") + else: + st.success("AO Agent loaded; feedback trains the live agent.") + + preferences = TravelPreferences( + mood=mood, + max_budget=max_budget, + pace=pace, + party=party, + ) + experience, fallback_percentage, reasons, binary_input, ao_percentage = _current_recommendation(preferences) + displayed_percentage = ao_percentage if ao_percentage is not None else fallback_percentage + + left, right = st.columns([0.62, 0.38], gap="large") + with left: + st.subheader(experience.name) + st.caption(f"{experience.city}, {experience.country}") + st.write(experience.description) + st.metric("Recommendation", f"{displayed_percentage}%") + + detail_rows = { + "Activity": experience.activity_type, + "Budget": experience.budget, + "Pace": experience.pace, + "Duration": f"{experience.duration_hours:g} hours", + "Outdoor": "yes" if experience.outdoor else "no", + "AO input bits": "".join(str(bit) for bit in binary_input), + } + st.dataframe(pd.DataFrame(detail_rows.items(), columns=["Field", "Value"]), hide_index=True) + + with right: + st.write("## Why this matched") + for reason in reasons: + st.write(f"- {reason}") + + more, less = st.columns(2) + if more.button("Recommend more like this", type="primary"): + _record_feedback(experience, binary_input, positive=True) + st.rerun() + if less.button("Show less like this"): + _record_feedback(experience, binary_input, positive=False) + st.rerun() + + if st.button("Next recommendation"): + st.session_state.travel_index += 1 + st.rerun() + + if st.session_state.travel_history: + st.write("---") + st.write("## Feedback History") + st.dataframe(pd.DataFrame(st.session_state.travel_history), hide_index=True) + + +if __name__ == "__main__": + main()