summaryrefslogtreecommitdiff
path: root/Biz
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2024-12-03 17:52:52 -0500
committerBen Sima <ben@bsima.me>2024-12-21 10:08:04 -0500
commit47c48abf836f5918120c0550c57d4eda32d3f10e (patch)
treebff3572fc00c9f10f0e7758a24ff5d1fd01e78b1 /Biz
parentb3e113d2e1351ea1d48170a3433c2228ac2ae137 (diff)
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.
Diffstat (limited to 'Biz')
-rw-r--r--Biz/Storybook.py521
1 files changed, 335 insertions, 186 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"""
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>Storybook Generator</title>
- <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
- rel="stylesheet">
- <script src="https://unpkg.com/htmx.org@1.3.3"></script>
-</head>
-<body>
- <div class="container mt-5">
- <h1 class="text-center">Storybook Generator</h1>
- <form hx-post="{flask.url_for("generate_story")}" hx-target="#story"
- class="mt-4">
- <div class="form-group">
- <label for="theme">Select Theme:</label>
- <select class="form-control" id="theme" name="theme">
- <option value="Christian">Christian</option>
- <option value="Secular">Secular</option>
- </select>
- </div>
- <div class="form-group">
- <label for="character">Main Character's Name:</label>
- <input type="text" class="form-control" id="character"
- name="character" required>
- </div>
- <div class="form-group">
- <label for="setting">Select Setting:</label>
- <select class="form-control" id="setting" name="setting">
- <option value="rural">Rural</option>
- <option value="urban">Urban</option>
- <option value="beach">Beach</option>
- <option value="forest">Forest</option>
- </select>
- </div>
- <button type="submit" class="btn btn-primary">
- Generate Story
- </button>
- </form>
- <div id="story" class="mt-5"></div>
- </div>
-</body>
-</html>
-""")
-
-
-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": "<text of the story>",""",
""""image": "<description of illustration>"}...],""",
- 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 "<p>error: no story pages found</p>"
- flask.session["story"] = story.model_dump_json()
- return "".join(
- f"<div class='card mb-3'>"
- f"<img src='/static/placeholder.png' data-src='"
- f"{flask.url_for('generate_image', n=i)}'"
- f"class='card-img-top' hx-trigger='load' hx-swap='outerHTML' "
- f"""hx-get='{flask.url_for("generate_image", n=i)}' alt='Loading...'>"""
- f"<div class='card-body'>"
- f"<p class='card-text'>{page.text}</p></div></div>"
- 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/<int:n>", 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 "<p>error: no story data found</p>"
+class IndexTest(unittest.TestCase):
+ """Test the home page."""
- try:
- story = Story.model_validate_json(story_data)
- except pydantic.ValidationError as e:
- return f"<p>error: story validation failed: {e}</p>"
- if story.pages is not None and 0 <= n < len(story.pages):
- page = load_image(story.pages[n])
- return f"<img src='{page.image_url}' class='card-img-top'>"
- return "<p>Image not found</p>"
+ 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"<div class='card mb-3'>", response.data)
+ self.assertIn(self.character, response.text)
if __name__ == "__main__":