diff --git a/README.md b/README.md index 26cb9cc..bed9f06 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,23 @@ 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. +## Book Domain Demo + +This fork also includes a local book recommendation domain that can run without paid APIs: + +```bash +streamlit run book_recommender.py +``` + +The demo uses `book_domain.py` for a small local catalog and deterministic fallback ranking. If `ao_core` and `ao_arch` are installed, `book_recommender.py` will also initialize an AO agent using `arch__BookRecommender.py`; otherwise it remains runnable with the fallback path. + +Validation: + +```bash +python -m unittest discover -s tests +python -m py_compile book_domain.py book_recommender.py arch__BookRecommender.py tests/test_book_domain.py +``` + ## Contributing diff --git a/arch__BookRecommender.py b/arch__BookRecommender.py new file mode 100644 index 0000000..124db68 --- /dev/null +++ b/arch__BookRecommender.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +Architecture definition for the book-domain recommender demo. + +The input vector is built in book_domain.encode_book: +genre + pace + length + tone + format + current reader mood. +""" + +import ao_arch as ar +from book_domain import binary_input_size + +description = "Book recommender for local catalog and reader context" + +arch_i = [binary_input_size()] +arch_z = [10] +arch_c = [] +connector_function = "full_conn" + +arch = ar.Arch(arch_i, arch_z, arch_c, connector_function, description) diff --git a/book_domain.py b/book_domain.py new file mode 100644 index 0000000..5fd1bac --- /dev/null +++ b/book_domain.py @@ -0,0 +1,157 @@ +BOOK_CATALOG = [ + { + "id": "project-hail-mary", + "title": "Project Hail Mary", + "genre": "science_fiction", + "pace": "fast", + "length": "long", + "tone": "hopeful", + "format": "novel", + "context": ["curious", "adventurous"], + }, + { + "id": "atomic-habits", + "title": "Atomic Habits", + "genre": "self_improvement", + "pace": "medium", + "length": "medium", + "tone": "practical", + "format": "nonfiction", + "context": ["focused", "motivated"], + }, + { + "id": "the-house-in-the-cerulean-sea", + "title": "The House in the Cerulean Sea", + "genre": "fantasy", + "pace": "gentle", + "length": "medium", + "tone": "cozy", + "format": "novel", + "context": ["comfort", "relaxed"], + }, + { + "id": "educated", + "title": "Educated", + "genre": "memoir", + "pace": "medium", + "length": "medium", + "tone": "reflective", + "format": "nonfiction", + "context": ["thoughtful", "resilient"], + }, + { + "id": "the-murderbot-diaries", + "title": "All Systems Red", + "genre": "science_fiction", + "pace": "fast", + "length": "short", + "tone": "witty", + "format": "novella", + "context": ["fun", "adventurous"], + }, + { + "id": "braiding-sweetgrass", + "title": "Braiding Sweetgrass", + "genre": "nature", + "pace": "gentle", + "length": "long", + "tone": "reflective", + "format": "nonfiction", + "context": ["thoughtful", "calm"], + }, + { + "id": "the-way-of-kings", + "title": "The Way of Kings", + "genre": "fantasy", + "pace": "slow", + "length": "long", + "tone": "epic", + "format": "novel", + "context": ["immersive", "adventurous"], + }, + { + "id": "deep-work", + "title": "Deep Work", + "genre": "productivity", + "pace": "medium", + "length": "medium", + "tone": "practical", + "format": "nonfiction", + "context": ["focused", "motivated"], + }, + { + "id": "legends-and-lattes", + "title": "Legends & Lattes", + "genre": "fantasy", + "pace": "gentle", + "length": "short", + "tone": "cozy", + "format": "novel", + "context": ["comfort", "fun"], + }, + { + "id": "the-design-of-everyday-things", + "title": "The Design of Everyday Things", + "genre": "design", + "pace": "medium", + "length": "medium", + "tone": "practical", + "format": "nonfiction", + "context": ["curious", "focused"], + }, +] + +GENRES = [ + "science_fiction", + "fantasy", + "self_improvement", + "memoir", + "nature", + "productivity", + "design", +] +PACES = ["gentle", "medium", "fast", "slow"] +LENGTHS = ["short", "medium", "long"] +TONES = ["cozy", "hopeful", "practical", "reflective", "witty", "epic"] +FORMATS = ["novel", "novella", "nonfiction"] +MOODS = ["comfort", "curious", "focused", "fun", "adventurous", "thoughtful", "motivated", "relaxed", "calm", "immersive"] + + +def one_hot(value, choices): + return [1 if value == choice else 0 for choice in choices] + + +def encode_book(book, mood="curious"): + return ( + one_hot(book["genre"], GENRES) + + one_hot(book["pace"], PACES) + + one_hot(book["length"], LENGTHS) + + one_hot(book["tone"], TONES) + + one_hot(book["format"], FORMATS) + + one_hot(mood, MOODS) + ) + + +def score_book(book, preferences): + score = 0 + for field in ("genre", "pace", "length", "tone", "format"): + wanted = preferences.get(field) + if wanted and book[field] == wanted: + score += 2 + mood = preferences.get("mood") + if mood and mood in book["context"]: + score += 3 + return score + + +def recommend_books(preferences, catalog=None, limit=3): + catalog = catalog or BOOK_CATALOG + ranked = sorted( + catalog, + key=lambda book: (-score_book(book, preferences), book["title"]), + ) + return ranked[:limit] + + +def binary_input_size(): + return len(encode_book(BOOK_CATALOG[0])) diff --git a/book_recommender.py b/book_recommender.py new file mode 100644 index 0000000..a6cdf8c --- /dev/null +++ b/book_recommender.py @@ -0,0 +1,77 @@ +import streamlit as st + +from book_domain import BOOK_CATALOG, encode_book, recommend_books + +try: + import ao_core as ao + from arch__BookRecommender import arch +except Exception: + ao = None + arch = None + + +def get_agent(): + if ao is None or arch is None: + return None + if "book_agent" not in st.session_state: + st.session_state.book_agent = ao.Agent(arch, notes="Book Recommender Agent") + for _ in range(4): + st.session_state.book_agent.reset_state() + st.session_state.book_agent.reset_state(training=True) + return st.session_state.book_agent + + +def agent_vote(agent, book, mood): + if agent is None: + return None + agent.reset_state() + response = agent.next_state(encode_book(book, mood)) + if not response: + return 0 + return round(sum(response) / len(response) * 100, 1) + + +st.set_page_config(page_title="AO Book Recommender") +st.title("AO Book Recommender") + +st.caption( + "A local book-domain demo for applying the AO recommender pattern without paid APIs." +) + +genre = st.selectbox( + "Preferred genre", + ["science_fiction", "fantasy", "self_improvement", "memoir", "nature", "productivity", "design"], +) +pace = st.selectbox("Reading pace", ["gentle", "medium", "fast", "slow"]) +length = st.selectbox("Book length", ["short", "medium", "long"]) +tone = st.selectbox("Tone", ["cozy", "hopeful", "practical", "reflective", "witty", "epic"]) +book_format = st.selectbox("Format", ["novel", "novella", "nonfiction"]) +mood = st.selectbox( + "Current reading context", + ["comfort", "curious", "focused", "fun", "adventurous", "thoughtful", "motivated", "relaxed", "calm", "immersive"], +) + +preferences = { + "genre": genre, + "pace": pace, + "length": length, + "tone": tone, + "format": book_format, + "mood": mood, +} + +agent = get_agent() +if agent is None: + st.info("AO packages are not installed; using deterministic local fallback scoring.") + +for book in recommend_books(preferences, BOOK_CATALOG, limit=3): + vote = agent_vote(agent, book, mood) + with st.container(): + st.subheader(book["title"]) + st.write( + f"{book['genre']} · {book['pace']} pace · {book['length']} · " + f"{book['tone']} · {book['format']}" + ) + st.write("Reader contexts: " + ", ".join(book["context"])) + if vote is not None: + st.write(f"AO recommendation signal: {vote}%") diff --git a/tests/test_book_domain.py b/tests/test_book_domain.py new file mode 100644 index 0000000..1538df8 --- /dev/null +++ b/tests/test_book_domain.py @@ -0,0 +1,46 @@ +import unittest + +from book_domain import BOOK_CATALOG, binary_input_size, encode_book, recommend_books + + +class BookDomainTests(unittest.TestCase): + def test_encoding_has_stable_binary_shape(self): + encoded = encode_book(BOOK_CATALOG[0], mood="curious") + + self.assertEqual(len(encoded), binary_input_size()) + self.assertTrue(all(bit in (0, 1) for bit in encoded)) + + def test_recommendation_prioritizes_matching_context(self): + results = recommend_books( + { + "genre": "fantasy", + "pace": "gentle", + "length": "short", + "tone": "cozy", + "format": "novel", + "mood": "comfort", + }, + limit=1, + ) + + self.assertEqual(results[0]["id"], "legends-and-lattes") + + def test_recommendation_supports_nonfiction_focus(self): + results = recommend_books( + { + "genre": "productivity", + "pace": "medium", + "length": "medium", + "tone": "practical", + "format": "nonfiction", + "mood": "focused", + }, + limit=2, + ) + + self.assertEqual(results[0]["id"], "deep-work") + self.assertTrue(all(book["format"] == "nonfiction" for book in results)) + + +if __name__ == "__main__": + unittest.main()