Building a Story Generation App with FastAPI, AWS Bedrock, and Langchain
Table of contents
- Introduction
- 1. Setting up the Development Environment
- 2. Defining Data Structures with Enums and Schemas
- 3. Creating the Story Model (models.py)
- 4. Generating Stories with Langchain, AWS Bedrock, and Claude (prompts.py)
- 5. Building the FastAPI App (main.py)
- HTML Templates
- 7. Connecting to the Database
- 8. Alembic for Database Migrations
- 9. Running the Application
- 10. Deployment
- Conclusion
Introduction
This tutorial provides a step-by-step guide to building a web application that generates creative stories. You'll use Python, FastAPI, and Langchain to create a user-friendly interface where users can submit story ideas, and the app will generate unique stories based on their input.
1. Setting up the Development Environment
Before you dive into coding, set up your development environment. This involves installing Python and creating a virtual environment to manage project dependencies.
1.1 Installing Python
Ensure you have Python 3.7 or higher installed on your system. You can download the latest version from the official Python website (python.org).
1.2 Creating a Virtual Environment
A virtual environment isolates your project's dependencies from other projects, preventing conflicts and ensuring a clean workspace.
Open your terminal or command prompt.
Create a project directory: Bash
mkdir story-generator cd story-generator
Create a virtual environment named 'env': Bash
python3 -m venv env
1.3 Activating the Virtual Environment
Activate the virtual environment to start working within its context.
Windows: Bash
env\Scripts\activate
Linux/macOS: Bash
source env/bin/activate
You'll notice your terminal prompt now includes the environment name (env), indicating you're working within the virtual environment.
1.4 Installing Dependencies
You need to install the required libraries for our project. Create a requirements.txt
file in your project directory and add the following lines:
fastapi
uvicorn
sqlmodel
langchain
langchain-aws
python-decouple
This file lists the necessary packages. Now, use pip
to install them:
Bash
pip install -r requirements.txt
This command will install all the listed packages and their dependencies.
2. Defining Data Structures with Enums and Schemas
Now, define the structure of your data using Enums and Pydantic Schemas.
2.1 Creating Enums (models.py
)
Enums allows you to define a set of named constants, which helps in representing different story attributes in a more organized way.
Python
from enum import Enum
from sqlmodel import Field, SQLModel
class Genre(str, Enum):
FANTASY = "Fantasy"
SCIENCE_FICTION = "Science Fiction"
MYSTERY = "Mystery"
THRILLER = "Thriller"
ROMANCE = "Romance"
HORROR = "Horror"
HISTORICAL_FICTION = "Historical Fiction"
CONTEMPORARY = "Contemporary"
ACTION_ADVENTURE = "Action/Adventure"
DYSTOPIAN = "Dystopian"
MAGICAL_REALISM = "Magical Realism"
COMEDY = "Comedy"
class Structure(str, Enum):
LINEAR = "Linear"
NONLINEAR = "Nonlinear"
EPISODIC = "Episodic"
THREE_ACT = "Three-Act Structure"
FIVE_ACT = "Five-Act Structure"
HEROS_JOURNEY = "Hero's Journey"
SAVE_THE_CAT = "Save the Cat!"
class PointOfView(str, Enum):
FIRST_PERSON = "First Person"
SECOND_PERSON = "Second Person"
THIRD_PERSON_LIMITED = "Third Person Limited"
THIRD_PERSON_OMNISCIENT = "Third Person Omniscient"
THIRD_PERSON_OBJECTIVE = "Third Person Objective"
2.2 Defining Schemas (schemas.py
)
Pydantic schemas help define the structure of the data your application will handle. You'll use these schemas for data validation and serialization.
Python
from typing import Optional
from pydantic import BaseModel, Field
class StorySchema(BaseModel):
story: str = Field(description='a story')
class StoryListResponse(BaseModel):
id: int
idea: str
genre: str
class StoryDetailResponse(BaseModel):
id: int
idea: str
genre: str
unique_insight: str
structure: str
number_of_characters: int
point_of_view: str
story: Optional[str] = None
3. Creating the Story Model (models.py
)
You'll use SQLModel, an Object-Relational Mapper (ORM), to define the database model for storing story ideas.
Python
# ... (Import Enums from models.py) ...
class Story(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
idea: str = Field(max_length=1000)
genre: Genre
unique_insight: str = Field(max_length=1000)
structure: Structure
number_of_characters: int
point_of_view: PointOfView
story: str = Field(max_length=1000000)
This Story
model defines the structure of our story data, including fields for the story idea, genre, unique insight, structure, number of characters, point of view, and the generated story itself.
4. Generating Stories with Langchain, AWS Bedrock, and Claude (prompts.py
)
Langchain is a powerful library that simplifies interaction with large language models. You'll use it to generate stories based on user input.
Python
import asyncio
from langchain_core.prompts import PromptTemplate
from langchain_aws import ChatBedrock
from langchain.output_parsers import PydanticOutputParser
from decouple import config
from .schemas import StorySchema
async def generate_story_content(idea: str, genre: str, unique_insight: str, structure: str, number_of_characters: int, point_of_view: str):
generated_story_output_parser = PydanticOutputParser(pydantic_object=StorySchema)
generated_story_format_instructions = generated_story_output_parser.get_format_instructions()
generate_story_template = """
Generate a story based on the following parameters:
Idea: {idea}
Genre: {genre}
Unique Insight: {unique_insight}
Structure: {structure}
Number of Characters: {number_of_characters}
Point of View: {point_of_view}
Please create a compelling and engaging story, paying attention to plot, character development, and overall narrative flow.
Format instructions: {format_instructions}
"""
generate_story_prompt = PromptTemplate(
template=generate_story_template,
input_variables=["idea", "genre", "unique_insight", "structure", "number_of_characters", "point_of_view"],
partial_variables={"format_instructions": generated_story_format_instructions}
)
# Initialize Bedrock client using boto3 (replace with your actual credentials)
bedrock_runtime = boto3.client(
service_name="bedrock-runtime",
region_name=config("AWS_REGION_NAME"),
aws_access_key_id=config("AWS_ACCESS_KEY_ID"),
aws_secret_access_key=config("AWS_SECRET_ACCESS_KEY"),
)
llm = ChatBedrock(
model_id="anthropic.claude-v2", client=bedrock_runtime, model_kwargs={"max_tokens_to_sample": 500}
)
generated_story = generate_story_prompt | llm | generated_story_output_parser
result = await generated_story.ainvoke({
"idea": idea,
"genre": genre,
"unique_insight": unique_insight,
"structure": structure,
"number_of_characters": number_of_characters,
"point_of_view": point_of_view
})
return result
This code defines a function generate_story_content
that takes story parameters as input, constructs a prompt using PromptTemplate
, and uses the ChatBedrock
language model to generate a story.
5. Building the FastAPI App (main.py
)
Now, build the FastAPI application that will handle user interactions and display the generated stories.
Python
from typing import Annotated, List
from datetime import timedelta
import os
from fastapi import FastAPI, Depends, status, HTTPException, Form, Request
from fastapi.security import OAuth2PasswordBearer
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlmodel import Session, select
from sqlalchemy.orm import selectinload
from starlette.templating import Jinja2Templates
from starlette.staticfiles import StaticFiles
from . import models
from .database import create_db_and_tables, get_session
from .prompts import generate_story_content
from .schemas import StoryDetailResponse, StoryListResponse
SessionDep = Annotated[Session, Depends(get_session)]
app = FastAPI()
# ... (OAuth2 configuration, if needed) ...
script_dir = os.path.dirname(__file__)
st_abs_file_path = os.path.join(script_dir, "static/")
app.mount("/static", StaticFiles(directory=st_abs_file_path), name="static")
templates = Jinja2Templates(directory="app/templates")
@app.on_event("startup")
def on_startup():
create_db_and_tables()
@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse("home.html", {"request": request})
@app.get("/create", response_class=HTMLResponse)
async def create_story_form(request: Request):
genre_choices = list(models.Genre)
structure_choices = list(models.Structure)
point_of_view_choices = list(models.PointOfView)
return templates.TemplateResponse(
"create.html",
{
"request": request,
"genre_choices": genre_choices,
"structure_choices": structure_choices,
"point_of_view_choices": point_of_view_choices
}
)
@app.post("/create")
async def create_story(
request: Request,
session: SessionDep,
idea: str = Form(...),
genre: str = Form(...),
unique_insight: str = Form(...),
structure: str = Form(...),
number_of_characters: int = Form(...),
point_of_view: str = Form(...),
):
generated_story = await generate_story_content(
idea=idea,
genre=models.Genre(genre),
unique_insight=unique_insight,
structure=models.Structure(structure),
number_of_characters=number_of_characters,
point_of_view=models.PointOfView(point_of_view)
)
story = models.Story(
idea=idea,
genre=models.Genre(genre),
unique_insight=unique_insight,
structure=models.Structure(structure),
number_of_characters=number_of_characters,
point_of_view=models.PointOfView(point_of_view),
story=generated_story.story
)
session.add(story)
session.commit()
session.refresh(story)
return RedirectResponse(url=f"/stories/{story.id}", status_code=status.HTTP_303_SEE_OTHER)
@app.get("/stories", response_class=HTMLResponse)
async def list_stories(request: Request, session: SessionDep):
stories = session.exec(select(models.Story)).all()
stories_response = [StoryListResponse(id=story.id, idea=story.idea, genre=story.genre) for story in stories]
return templates.TemplateResponse("list.html", {"request": request, "stories": stories_response})
@app.get("/stories/{story_id}", response_class=HTMLResponse)
async def detail_story(request: Request, session: SessionDep, story_id: int):
story = session.get(models.Story, story_id)
if not story:
raise HTTPException(status_code=404, detail="Story not found")
story_response = StoryDetailResponse(
id=story.id,
idea=story.idea,
genre=story.genre,
unique_insight=story.unique_insight,
structure=story.structure,
number_of_characters=story.number_of_characters,
point_of_view=story.point_of_view,
)
return templates.TemplateResponse("detail.html", {"request": request, "story": story_response})
@app.post("/stories/{story_id}/generate", response_class=HTMLResponse)
async def generate_story(request: Request, session: SessionDep, story_id: int):
story = session.get(models.Story, story_id)
if not story:
raise HTTPException(status_code=404, detail="Story not found")
story_response = StoryDetailResponse(
id=story.id,
idea=story.idea,
genre=story.genre,
unique_insight=story.unique_insight,
structure=story.structure,
number_of_characters=story.number_of_characters,
point_of_view=story.point_of_view,
story=story.story
)
return templates.TemplateResponse("detail.html", {"request": request, "story": story_response})
This code sets up the FastAPI app, mounts static files (HTML, CSS, etc.), configures Jinja2 templates for rendering HTML, and defines various routes to handle different user interactions.
Explanation of the routes:
/
: Displays the home page./create
: Shows a form for users to submit story ideas./create
(POST): Handles the submission of the story idea form, generates the story, and saves it to the database./stories
: Lists all submitted story ideas./stories/{story_id}
: Shows the details of a specific story idea./stories/{story_id}/generate
: Generates and displays the story for a specific story idea.
HTML Templates
You'll need to create HTML templates for the different pages (home.html, create.html, list.html, detail.html). These templates will use Jinja2 templating engine to dynamically display data. Here are basic examples:
home.html
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Story Generator</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
<div class="container mx-auto p-8">
<h1 class="text-3xl text-gray-800 mb-6">Welcome to the Story Generator!</h1>
<a href="/create" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded inline-block mr-2">Create a New Story</a>
<a href="/stories" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded inline-block">View Stories</a>
</div>
</body>
</html>
create.html
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Create Story</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
<div class="container mx-auto p-8">
<h1 class="text-3xl text-gray-800 mb-6">Create a New Story</h1>
<form method="POST" class="w-full max-w-lg">
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="idea">Idea:</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" type="text" id="idea" name="idea" required>
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="genre">Genre:</label>
<select class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="genre" name="genre" required>
{% for genre_choice in genre_choices %}
<option value="{{ genre_choice.value }}">{{ genre_choice.value }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="unique_insight">Unique Insight:</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" type="text" id="unique_insight" name="unique_insight" required>
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="structure">Structure:</label>
<select class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="structure" name="structure" required>
{% for structure_choice in structure_choices %}
<option value="{{ structure_choice.value }}">{{ structure_choice.value }}</option>
{% endfor %}
</select>
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="number_of_characters">Number of Characters:</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" type="number" id="number_of_characters" name="number_of_characters" required>
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="point_of_view">Point of View:</label>
<select class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="point_of_view" name="point_of_view" required>
{% for pov_choice in point_of_view_choices %}
<option value="{{ pov_choice.value }}">{{ pov_choice.value }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">Save Story Idea</button>
</form>
</div>
</body>
</html>
list.html
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Story List</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
<div class="container mx-auto p-8">
<h1 class="text-3xl text-gray-800 mb-6">Story List</h1>
<table class="table-auto w-full">
<thead>
<tr class="bg-gray-200">
<th class="px-4 py-2 text-left">ID</th>
<th class="px-4 py-2 text-left">Idea</th>
<th class="px-4 py-2 text-left">Genre</th>
<th class="px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>
{% for story in stories %}
<tr class="border-b border-gray-200 hover:bg-gray-100">
<td class="px-4 py-2">{{ story.id }}</td>
<td class="px-4 py-2">{{ story.idea }}</td>
<td class="px-4 py-2">{{ story.genre }}</td>
<td class="px-4 py-2">
<a href="/stories/{{ story.id }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-2 rounded text-xs">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="/create" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded inline-block mt-4">Create New Story</a>
</div>
</body>
</html>
detail.html
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Story Detail</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</head>
<body class="bg-gray-100 font-sans">
<div class="container mx-auto p-8">
<h1 class="text-3xl text-gray-800 mb-6">Story Detail</h1>
<div class="mb-4">
<p class="text-gray-700"><strong class="font-bold">ID:</strong> {{ story.id }}</p>
<p class="text-gray-700"><strong class="font-bold">Idea:</strong> {{ story.idea }}</p>
<p class="text-gray-700"><strong class="font-bold">Genre:</strong> {{ story.genre }}</p>
<p class="text-gray-700"><strong class="font-bold">Unique Insight:</strong> {{ story.unique_insight }}</p>
<p class="text-gray-700"><strong class="font-bold">Structure:</strong> {{ story.structure }}</p>
<p class="text-gray-700"><strong class="font-bold">Number of Characters:</strong> {{ story.number_of_characters }}</p>
<p class="text-gray-700"><strong class="font-bold">Point of View:</strong> {{ story.point_of_view }}</p>
</div>
{% if story.story %}
<div>
<p class="text-gray-700 font-bold mb-2">Generated Story:</p>
<p class="text-gray-700">{{ story.story }}</p>
</div>
{% else %}
<form action="/stories/{{ story.id }}/generate" method="post" class="mt-6">
<button type="submit" class="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded">Generate Story</button>
</form>
{% endif %}
<a href="/stories" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded inline-block mt-4">Back to List</a>
</div>
</body>
</html>
7. Connecting to the Database
The database.py
file handles the database connection. You'll need to configure it to connect to your specific database. Here's an example using SQLite:
Python
from sqlmodel import create_engine, Session, SQLModel
from decouple import config
sqlite_file_name = config("STORY_GENERATOR_SQLITE_FILE_NAME", default="story_generator.db")
sqlite_url = f"sqlite:///{sqlite_file_name}"
connect_args = {"check_same_thread": False}
engine = create_engine(sqlite_url, echo=True, connect_args=connect_args)
def create_db_and_tables():
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield session
This code uses python-decouple
to read the database file name from environment variables (or defaults to story_generator.db
). It then creates the database file if it doesn't exist and sets up a session for interacting with the database.
8. Alembic for Database Migrations
As your application evolves, you might need to make changes to your database schema (e.g., adding new fields to the Story
model). Alembic is a great tool for managing these database migrations.
To integrate Alembic:
Install Alembic:
pip install alembic
Initialize Alembic:
alembic init alembic
Configure
alembic.ini
file. Inside the file, scroll down until you seesqlalchemy.url
variable. Update the variable to point to your database.sqlalchemy.url = sqlite:///story_generator.db
Update the
alembic/ script.py.mako
fileimport sqlmodel
Update
alembic/
env.py
:Import your models:
from app.models import Story
Set the target metadata:
target_metadata = SQLModel.metadata
Generate migrations:
alembic revision --autogenerate -m "Initial migration"
Apply migrations:
alembic upgrade head
9. Running the Application
Once you have all the code in place and the database configured, you can run the application using Uvicorn:
Bash
uvicorn main:app --reload
This will start the development server, and you can access the app in your browser at http://127.0.0.1:8000/
.
10. Deployment
For deploying the application, you can check out the following link on how to use Pulumi to deploy the application to AWS Apprunner. It is like Heroku.
Conclusion
Building this story generation app is a great way to learn about FastAPI, Langchain, and database interactions. You can expand on this foundation by adding more features, exploring different language models, and refining the story generation process. Remember to keep learning and experimenting!