From 47c48abf836f5918120c0550c57d4eda32d3f10e Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Tue, 3 Dec 2024 17:52:52 -0500 Subject: Convert Biz/Storybook.py to Ludic This is basically a full rewrite. I ripped out Flask and rearchitected the whole thing to use fully RESTful resources and endpoints using Ludic. The UI was completely redone to use Ludic's components. I added tests for everything that I reasonably could. This is almost ready for an alpha launch. Before shipping it I still need to: 1. generate images using image n-1 applied to `openai.images.create_variation()` 2. write a nix service, get it on a VM somewhere, I'll probably provision a new VM for this 3. replace the `db` thing with a real sqlite database I only need the first one done to show it to Lia and see if she likes it, that should be completed in a day or two. Then the nix service and deployment won't take long at all. Setting up a sqlite database will be annoying, but that I can't see that actually taking more than 2 days. So max 5 days out from launching this to friends and family. --- Biz/Storybook.py | 521 +++++++++++++++++++++++++++++----------------- Omni/Bild/Deps/Python.nix | 14 +- Omni/Log.py | 6 +- 3 files changed, 351 insertions(+), 190 deletions(-) diff --git a/Biz/Storybook.py b/Biz/Storybook.py index 2f362c4..8727b57 100644 --- a/Biz/Storybook.py +++ b/Biz/Storybook.py @@ -5,28 +5,44 @@ This application generates a children's storybook using the OpenAI API. The user can select a theme, specify the main character's name, and choose a setting. The app generates a 10-page storybook with images. -The tech stack is: Python, Flask, HTMX, and bootstrap. All of the code is in +The tech stack is: Python, Ludic, and HTMX. All of the code is in this single file. """ # : out storybook -# : dep flask +# : dep ludic # : dep openai -import flask +# : dep uvicorn +# : dep starlette +# : dep sqids import json +import logging +import ludic +import ludic.catalog.buttons as buttons +import ludic.catalog.forms as forms +import ludic.catalog.headers as headers +import ludic.catalog.layouts as layouts +import ludic.catalog.pages as pages +import ludic.catalog.typography as typography +import ludic.web +import Omni.Log as Log import openai -import os -import pydantic +import sqids +import starlette.testclient import sys +import typing import unittest +import uvicorn -app = flask.Flask(__name__) -app.secret_key = os.urandom(24) +MOCK = True +DEBUG = False + +app = ludic.web.LudicApp(debug=DEBUG) def main() -> None: - """Run the Flask application.""" + """Run the Ludic application.""" if sys.argv[1] == "test": test() else: @@ -35,119 +51,88 @@ def main() -> None: def move() -> None: """Run the application.""" - app.run() + Log.setup(logging.DEBUG if DEBUG else logging.ERROR) + uvicorn.run(app, host="100.127.197.132") def test() -> None: """Run the unittest suite manually.""" + Log.setup(logging.DEBUG if DEBUG else logging.ERROR) suite = unittest.TestSuite() - tests = [StorybookTestCase, StoryTestCase] + tests = [StorybookTest, IndexTest, StoryTest] suite.addTests([ unittest.defaultTestLoader.loadTestsFromTestCase(tc) for tc in tests ]) unittest.TextTestRunner(verbosity=2).run(suite) -@app.route("/") -def index() -> str: - """Render the main page.""" - return flask.render_template_string(f""" - - - - - - Storybook Generator - - - - -
-

Storybook Generator

-
-
- - -
-
- - -
-
- - -
- -
-
-
- - -""") - - -class Page(pydantic.BaseModel): +def const(s: str) -> str: + """Just returns the input.""" + return s + + +class StoryPage(ludic.attrs.Attrs): """Represents a single page in the storybook.""" - model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) - text: str - image_prompt: str - image_url: str | None + text: typing.Annotated[str, const] + image_prompt: typing.Annotated[str, const] + image_url: typing.Annotated[str, const] + +def load_image(prompt: str) -> str: + """Load an image for a given page using the OpenAI API. -def load_image(page: Page) -> Page: - """Load an image for a given page using the OpenAI API.""" - if page.image_url is not None: - return page - prompt = page.image_prompt - client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + Raises: + ValueError: when OpenAI response is bad + """ + client = openai.OpenAI() image_response = client.images.generate( prompt=prompt, n=1, size="256x256", ) - page.image_url = image_response.data[0].url - # Handle if image is None - return page - - -class Story(pydantic.BaseModel): - """Represents a story with multiple pages.""" - - model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) - theme: str - character: str - setting: str - moral: str - pages: list[Page] | None = None - - -def system_prompt() -> str: - """Generate the system prompt for the OpenAI API.""" - return ( - "You are an author and illustrator of childrens books. " - "Each book is 10 pages long. " - "All output must be in valid JSON. " - "Don't add explanation or markdown formatting beyond the JSON. " - "In your output, include the text on the page and a description of the " - "image to be generated with an AI image generator." - ) + url = image_response.data[0].url + if url is not None: + return url + msg = "error with load_image" + raise ValueError(msg) + + +class StoryInputs(ludic.attrs.Attrs): + """Represents story inputs from the user.""" + + theme: typing.Annotated[str, const] + character: typing.Annotated[str, const] + setting: typing.Annotated[str, const] + moral: typing.Annotated[str, const] + +example_story: dict[str, str] = { + "theme": "Christian", + "character": "Lia and her pet bunny", + "setting": "A suburban park", + "moral": "Honor thy mother and father", +} -def user_prompt(story: Story) -> str: + +class Story(ludic.attrs.Attrs): + """Represents a full generated story.""" + + id: typing.Annotated[str, const] + pages: typing.Annotated[list[StoryPage], const] + + +system_prompt: str = ( + "You are an author and illustrator of childrens books. " + "Each book is 10 pages long. " + "All output must be in valid JSON. " + "Don't add explanation or markdown formatting beyond the JSON. " + "In your output, include the text on the page and a description of the " + "image to be generated with an AI image generator." +) + + +def user_prompt(story: StoryInputs) -> str: """Generate the user prompt based on the story details.""" return " ".join([ "Write a story with the following details.", @@ -155,23 +140,25 @@ def user_prompt(story: Story) -> str: "image like the following example:", """[{"text": "",""", """"image": ""}...],""", - f"Character: {story.character}\n", - f"Setting: {story.setting}\n", - f"Theme: {story.theme}\n", - f"Moral: {story.moral}\n", + f"Character: {story["character"]}\n", + f"Setting: {story["setting"]}\n", + f"Theme: {story["theme"]}\n", + f"Moral: {story["moral"]}\n", ]) -def _openai_generate_text(story: Story) -> openai.types.chat.ChatCompletion: +def _openai_generate_text( + story: StoryInputs, +) -> openai.types.chat.ChatCompletion: """Generate story text using the OpenAI API.""" messages: list[ openai.types.chat.ChatCompletionUserMessageParam | openai.types.chat.ChatCompletionSystemMessageParam ] = [ - {"role": "system", "content": system_prompt()}, + {"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt(story)}, ] - client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) + client = openai.OpenAI() return client.chat.completions.create( model="gpt-4o-mini", messages=messages, @@ -179,112 +166,274 @@ def _openai_generate_text(story: Story) -> openai.types.chat.ChatCompletion: ) -def generate_text(story: Story) -> Story: +def generate_pages(inputs: StoryInputs) -> list[StoryPage]: """Generate the text for a story and update its pages. Raises: - ValueError: If the content is None or JSON parsing fails. + ValueError: when openAI response is bad """ - response = _openai_generate_text(story) + # when developing, don't run up the OpenAI tab + if MOCK: + name = inputs["character"] + return [ + StoryPage( + text=f"A story about {name}...", + image_prompt="lorem ipsum", + image_url="//placehold.co/256x256", + ) + for _ in range(10) + ] + response = _openai_generate_text(inputs) content = response.choices[0].message.content if content is None: - error_message = "No content in response" - raise ValueError(error_message) - try: - response_messages = json.loads(content) - except (json.JSONDecodeError, ValueError) as e: - error_message = f"Failed to parse story JSON: {e}" - raise ValueError(error_message) from e - story.pages = [ - Page(text=msg["text"], image_prompt=msg["image"], image_url=None) + msg = "content is none" + raise ValueError(msg) + response_messages = json.loads(content) + return [ + StoryPage( + text=msg["text"], + image_prompt=msg["image"], + image_url=load_image(msg["image"]), + ) for msg in response_messages ] - return story -@app.route("/generate/story", methods=["POST"]) -def generate_story() -> str: - """Generate a story based on user input.""" - story = Story( - theme=flask.request.form["theme"], - character=flask.request.form["character"], - setting=flask.request.form["setting"], - moral="Honor thy mother and father", # request.form["moral"], - ) - story = generate_text(story) - if story.pages is None: - return "

error: no story pages found

" - flask.session["story"] = story.model_dump_json() - return "".join( - f"
" - f"Loading...""" - f"
" - f"

{page.text}

" - for i, page in enumerate(story.pages) +class StoryTest(unittest.TestCase): + """Unit test case for the Story class and related functions.""" + + def test_story_creation(self) -> None: + """Creates a story with 10 pages.""" + s = StoryInputs(example_story) # type: ignore[misc] + pages = generate_pages(s) + self.assertIsNotNone(pages) + self.assertEqual(len(pages), 10) + + +class AppPage( + ludic.components.Component[ludic.types.AnyChildren, ludic.attrs.NoAttrs], +): + """HTML wrapper for the app.""" + + @typing.override + def render(self) -> pages.HtmlPage: + return pages.HtmlPage( + pages.Head( + ludic.html.meta(charset="utf-8"), + ludic.html.meta( + name="viewport", + content="width=device-width, initial-scale=1", + ), + ludic.html.style.load(), + title="Storybook", + favicon="favicon.ico", + load_styles=True, + ), + pages.Body( + layouts.Center(layouts.Stack(*self.children)), + htmx_version="latest", + ), + ) + + +@app.get("/") +def index(_: ludic.web.Request) -> AppPage: + """Render the main page.""" + return AppPage( + headers.H1("Storybook Generator"), + StoriesForm(), + ludic.html.div(id="story"), ) -@app.route("/generate/image/", methods=["GET"]) -def generate_image(n: int) -> str: - """Generate an image for a specific page in the story.""" - story_data = flask.session.get("story") - if story_data is None: - return "

error: no story data found

" +class IndexTest(unittest.TestCase): + """Test the home page.""" - try: - story = Story.model_validate_json(story_data) - except pydantic.ValidationError as e: - return f"

error: story validation failed: {e}

" - if story.pages is not None and 0 <= n < len(story.pages): - page = load_image(story.pages[n]) - return f"" - return "

Image not found

" + def setUp(self) -> None: + """Create test client.""" + self.client = starlette.testclient.TestClient(app) + def test_index(self) -> None: + """The index page loads successfully.""" + response = self.client.get("/") + self.assertEqual(response.status_code, 200) + self.assertIn("Storybook Generator", response.text) -class StoryTestCase(unittest.TestCase): - """Unit test case for the Story class and related functions.""" - def test_story_creation(self) -> None: - """Test the creation of a story and its text generation.""" - s = Story( - theme="Christian", - character="Lia and her pet bunny", - setting="A suburban park", - moral="Honor thy mother and father", +db_last_id: str = "bM" # sqid.encode([0]) +db: dict[str, Story] = {} + + +@app.endpoint("/stories/{sqid:str}") +class Stories(ludic.web.Endpoint[Story]): + """Resource for accessing a Story.""" + + @classmethod + def get(cls, sqid: str) -> typing.Self: + """Get a single story. + + Raises: + NotFoundError: if the story doesn't exist. + """ + story = db.get(sqid) + if story is None: + msg = f"story {sqid} not found" + raise ludic.web.exceptions.NotFoundError(msg) + return cls(**story) + + @classmethod + def put(cls, sqid: str, data: list[StoryPage]) -> typing.Self: + """Upsert a new story.""" + pages = data # .validate() + + story = Story(id=sqid, pages=pages) + story_id = story["id"] + + # save to the 'database' + db[story_id] = story + return cls(**story) + + @typing.override + def render(self) -> ludic.base.BaseElement: + return layouts.Stack( + headers.H1(str(self.attrs["id"])), + *(Pages(**page) for page in self.attrs["pages"]), + ) + + +@app.endpoint("/stories/{sqid:str}/{page:int}") +class Pages(ludic.web.Endpoint[StoryPage]): + """Resource for retrieving individual pages in a story.""" + + @classmethod + def get(cls, sqid: str, page: int) -> typing.Self: + """Get a single page.""" + story = Stories.get(sqid) + story_page = StoryPage(**story.attrs["pages"][page]) + return cls(**story_page) + + @typing.override + def render(self) -> ludic.base.BaseElement: + """Render a single page as HTML.""" + return layouts.Box( + layouts.Stack( + ludic.html.img( + src=self.attrs["image_url"], + ), + typography.Paragraph(self.attrs["text"]), + ), ) - s = generate_text(s) - self.assertIsNotNone(s.pages) - self.assertEqual(len(s.pages), 10) # type: ignore[arg-type] -class StorybookTestCase(unittest.TestCase): +@app.endpoint("/stories") +class StoriesForm(ludic.web.Endpoint[StoryInputs]): + """Form for generating new stories.""" + + @classmethod + def post(cls, data: ludic.web.parsers.Parser[StoryInputs]) -> Stories: + """Upsert a new story.""" + inputs = StoryInputs(**data.validate()) + # generate story pages + pages = generate_pages(inputs) + # calculate sqid + sqid = sqids.Sqids() + next_id_num = 1 + sqid.decode(db_last_id)[0] + next_id = sqid.encode([next_id_num]) + return Stories.put(next_id, pages) + + @typing.override + def render(self) -> ludic.base.BaseElement: + """Render the story as HTML.""" + return layouts.Box( + forms.Form( + forms.SelectField( + forms.Option("Christian", value="Christian"), + forms.Option("Secular", value="Secular"), + id="theme", + name="theme", + label="Select Theme:", + for_="theme", + ), + forms.InputField( + label="Main Character's Name:", + for_="character", + type="text", + id="character", + name="character", + required=True, + value="Alice", + ), + forms.SelectField( + forms.Option("Rural", value="rural"), + forms.Option("Urban", value="urban"), + forms.Option("Beach", value="beach"), + forms.Option("Forest", value="forest"), + label="Select Setting:", + for_="setting", + id="setting", + name="setting", + ), + forms.InputField( + label="Moral:", + for_="moral", + type="text", + id="moral", + name="moral", + required=True, + value="Honor thy mother and father", + ), + buttons.ButtonSuccess( + "Generate Story", + type="submit", + classes=["large"], + ), + hx_post=self.url_for(StoriesForm), + hx_target="#story", + ), + ) + + +class StorybookTest(unittest.TestCase): """Unit test case for the Storybook application.""" def setUp(self) -> None: - """Set up the test client.""" - self.app = app.test_client() + """Set up the test client and seed database.""" + self.client = starlette.testclient.TestClient(app) + self.character = "Alice" + self.data = example_story | {"character": self.character} + self.client.post("/stories/", data=self.data) + self.story = next(iter(db.values())) + self.story_id = self.story["id"] + + def test_stories_post(self) -> None: + """User can create a story.""" + response = self.client.post("/stories/", data=self.data) + self.assertEqual(response.status_code, 200) + self.assertIn(self.character, response.text) + + def test_stories_post_invalid_data(self) -> None: + """Invalid POST data.""" + response = self.client.post("/stories/", data={"bad": "data"}) + self.assertNotEqual(response.status_code, 200) - def test_index_page(self) -> None: - """Test that the index page loads successfully.""" - response = self.app.get("/") + def test_stories_get(self) -> None: + """User can access the story directly.""" + response = self.client.get(f"/stories/{self.story_id}") self.assertEqual(response.status_code, 200) - self.assertIn(b"Storybook Generator", response.data) - - def test_generate_story(self) -> None: - """Test the story generation endpoint.""" - response = self.app.post( - "/generate/story", - data={ - "theme": "Christian", - "character": "Alice", - "setting": "forest", - }, - ) + self.assertIn(self.character, response.text) + + def test_stories_get_nonexistent(self) -> None: + """Returns 404 when a story is not found.""" + response = self.client.get("/stories/nonexistent") + self.assertEqual(response.status_code, 404) + + def test_pages_get(self) -> None: + """User can access one page at a time.""" + page_num = 1 + self.story["pages"][page_num] + response = self.client.get(f"/stories/{self.story_id}/{page_num}") self.assertEqual(response.status_code, 200) - self.assertIn(b"
", response.data) + self.assertIn(self.character, response.text) if __name__ == "__main__": diff --git a/Omni/Bild/Deps/Python.nix b/Omni/Bild/Deps/Python.nix index e36896a..9af4630 100644 --- a/Omni/Bild/Deps/Python.nix +++ b/Omni/Bild/Deps/Python.nix @@ -1 +1,13 @@ -[ "cryptography" "llm" "mypy" "nltk" "slixmpp" "flask" "openai" "ludic" ] +[ + "cryptography" + "flask" + "llm" + "ludic" + "mypy" + "nltk" + "openai" + "slixmpp" + "sqids" + "starlette" + "uvicorn" +] diff --git a/Omni/Log.py b/Omni/Log.py index 2fbd007..e644a1a 100644 --- a/Omni/Log.py +++ b/Omni/Log.py @@ -1,6 +1,5 @@ """Setup logging like Omni/Log.hs.""" -# noqa: builtin-attribute-shadowing import logging import typing @@ -14,10 +13,10 @@ class LowerFormatter(logging.Formatter): return super(logging.Formatter, self).format(record) # type: ignore[misc] -def setup() -> None: +def setup(level: int = logging.INFO) -> logging.Logger: """Run this in your __main__ function.""" logging.basicConfig( - level=logging.DEBUG, + level=level, format="%(levelname)s: %(name)s: %(message)s", ) logging.addLevelName(logging.DEBUG, "dbug") @@ -28,6 +27,7 @@ def setup() -> None: handler = logging.StreamHandler() handler.setFormatter(formatter) logger.addHandler(handler) + return logger if __name__ == "__main__": -- cgit v1.2.3