387 lines
13 KiB
Markdown
387 lines
13 KiB
Markdown
---
|
|
authors:
|
|
- jxnl
|
|
- zilto
|
|
categories:
|
|
- Data Processing
|
|
comments: true
|
|
date: 2024-10-18
|
|
description: Flashcard generator application with Instructor + Burr
|
|
draft: false
|
|
slug: youtube-flashcards
|
|
tags:
|
|
- instructor
|
|
- Burr
|
|
- OpenAI
|
|
- LLM
|
|
- observability
|
|
---
|
|
|
|
# Flashcard generator with Instructor + Burr
|
|
|
|
Flashcards help break down complex topics and learn anything from biology to a new
|
|
language or lines for a play. This blog will show how to use LLMs to generate
|
|
flashcards and kickstart your learning!
|
|
|
|
**Instructor** lets us get structured outputs from LLMs reliably, and [Burr](https://github.com/dagworks-inc/burr) helps
|
|
create an LLM application that's easy to understand and debug. It comes with **Burr UI**,
|
|
a free, open-source, and local-first tool for observability, annotations, and more!
|
|
|
|
<!-- more -->
|
|
|
|
??? info
|
|
|
|
This post expands on an earlier one: [Analyzing Youtube Transcripts with Instructor](./youtube-transcripts.md/).
|
|
|
|
|
|
## Generate flashcards using LLMs with Instructor
|
|
|
|
```bash
|
|
pip install openai instructor pydantic youtube_transcript_api "burr[start]"
|
|
```
|
|
|
|
### 1. Define the LLM response model
|
|
|
|
With `instructor`, you define Pydantic models that will serve as template for the LLM to
|
|
fill.
|
|
|
|
Here, we define the `QuestionAnswer` model which will store the question, the answer, and
|
|
some metadata. Attributes without a default value will be generated by the LLM.
|
|
|
|
```python hl_lines="10-11 23 24-27"
|
|
import uuid
|
|
|
|
from pydantic import BaseModel, Field
|
|
from pydantic.json_schema import SkipJsonSchema
|
|
|
|
|
|
class QuestionAnswer(BaseModel):
|
|
question: str = Field(description="Question about the topic")
|
|
options: list[str] = Field(
|
|
description="Potential answers to the question.", min_items=3, max_items=5
|
|
)
|
|
answer_index: int = Field(
|
|
description="Index of the correct answer options (starting from 0).", ge=0, lt=5
|
|
)
|
|
difficulty: int = Field(
|
|
description="Difficulty of this question from 1 to 5, 5 being the most difficult.",
|
|
gt=0,
|
|
le=5,
|
|
)
|
|
youtube_url: SkipJsonSchema[str | None] = None
|
|
id: uuid.UUID = Field(description="Unique identifier", default_factory=uuid.uuid4)
|
|
```
|
|
|
|
This examples shows several `instructor` features:
|
|
|
|
- `Field` can have a `default` or `default_factory` value to prevent the LLM from
|
|
hallucinating the value
|
|
- `id` generates a unique id (`uuid`)
|
|
- The type annotation `SkipJsonSchema` also prevents the LLM from generating the value.
|
|
- `youtube_url` is set programmatically in the application. We don't want the LLM
|
|
to hallucinate it.
|
|
- `Field` can set constraints on what the LLM generates.
|
|
- `min_items=3, max_items=5` to limit the number of potential answers between 3 and 5
|
|
- `ge=0, lt=5` to limit the difficulty between 0 and 5 with 5 being the most difficult
|
|
|
|
|
|
### 2. Retrieve the YouTube transcript
|
|
|
|
We use `youtube-transcript-api` to get the full transcript of a video.
|
|
|
|
```python
|
|
from youtube_transcript_api import YouTubeTranscriptApi
|
|
|
|
youtube_url = "https://www.youtube.com/watch?v=hqutVJyd3TI"
|
|
_, _, video_id = youtube_url.partition("?v=")
|
|
segments = YouTubeTranscriptApi.get_transcript(video_id)
|
|
transcript = " ".join([s["text"] for s in segments])
|
|
```
|
|
|
|
### 3. Generate question-answer pairs
|
|
|
|
Now, to produce question-answer pairs:
|
|
|
|
1. Create an `instructor` client by wrapping the OpenAI client
|
|
2. Use `.create_iterable()` on the `instructor_client` to generate multiple outputs from
|
|
the input
|
|
3. Specify `response_model=QuestionAnswer` to ensure outputs are `QuestionAnswer` objects
|
|
4. Use the `messages` to pass the task instructos via the `system` message, and the input
|
|
transcript via `user` message.
|
|
|
|
```python hl_lines="4 10 12"
|
|
import instructor
|
|
|
|
instructor_client = instructor.from_provider("openai/gpt-5-nano")
|
|
|
|
system_prompt = """Analyze the given YouTube transcript and generate question-answer pairs
|
|
to help study and understand the topic better. Please rate all questions from 1 to 5
|
|
based on their difficulty."""
|
|
|
|
response = instructor_client.create_iterable(
|
|
model="gpt-4o-mini",
|
|
response_model=QuestionAnswer,
|
|
messages=[
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": transcript},
|
|
],
|
|
)
|
|
```
|
|
|
|
This will return an generator that you can iterate over to access individual
|
|
`QuestionAnswer` objects.
|
|
|
|
```python
|
|
print("Preview:\n")
|
|
count = 0
|
|
for qna in response:
|
|
if count > 2:
|
|
break
|
|
print(qna.question)
|
|
print(qna.options)
|
|
print()
|
|
count += 1
|
|
|
|
"""
|
|
Preview:
|
|
|
|
What is the primary purpose of the new OpenTelemetry instrumentation released with Burr?
|
|
['To reduce code complexity', 'To provide full instrumentation without changing code', 'To couple the project with OpenAI', 'To enhance customer support']
|
|
|
|
What do you need to install to use the OpenTelemetry instrumentation with Burr applications?
|
|
['Only OpenAI package', 'Specific OpenTelemetry instrumentation module', 'All available packages', 'No installation needed']
|
|
|
|
What advantage does OpenTelemetry provide in the context of instrumentation?
|
|
['It is vendor agnostic', 'It requires complex integration', 'It relies on specific vendors', 'It makes applications slower']
|
|
"""
|
|
```
|
|
|
|
|
|
## Create a flashcard application with Burr
|
|
|
|
Burr uses `actions` and `transitions` to define complex applications while
|
|
preserving the simplicity of a flowchart for understanding and debugging.
|
|
|
|
|
|
### 1. Define `actions`
|
|
|
|
Actions are what your application can do. The `@action` decorator specifies what values
|
|
can be read from or written to `State`. The decorated function takes a `State` as
|
|
first argument and return an updated `State` object.
|
|
|
|
Next, we define three actions:
|
|
|
|
- Process the user input to get the YouTube URL
|
|
- Get the YouTube transcript associated with the URL
|
|
- Generate question-answer pairs for the transcript
|
|
|
|
Note that this is only a light refactor from the previous code snippets.
|
|
|
|
```python
|
|
from burr.core import action, State
|
|
|
|
|
|
@action(reads=[], writes=["youtube_url"])
|
|
def process_user_input(state: State, user_input: str) -> State:
|
|
"""Process user input and update the YouTube URL."""
|
|
youtube_url = (
|
|
user_input # In practice, we would have more complex validation logic.
|
|
)
|
|
return state.update(youtube_url=youtube_url)
|
|
|
|
|
|
@action(reads=["youtube_url"], writes=["transcript"])
|
|
def get_youtube_transcript(state: State) -> State:
|
|
"""Get the official YouTube transcript for a video given it's URL"""
|
|
youtube_url = state["youtube_url"]
|
|
|
|
_, _, video_id = youtube_url.partition("?v=")
|
|
transcript = YouTubeTranscriptApi.get_transcript(video_id)
|
|
full_transcript = " ".join([entry["text"] for entry in transcript])
|
|
|
|
# store the transcript in state
|
|
return state.update(transcript=full_transcript, youtube_url=youtube_url)
|
|
|
|
|
|
@action(reads=["transcript", "youtube_url"], writes=["question_answers"])
|
|
def generate_question_and_answers(state: State) -> State:
|
|
"""Generate `QuestionAnswer` from a YouTube transcript using an LLM."""
|
|
# read the transcript from state
|
|
transcript = state["transcript"]
|
|
youtube_url = state["youtube_url"]
|
|
|
|
# create the instructor client
|
|
instructor_client = instructor.from_provider("openai/gpt-5-nano")
|
|
system_prompt = (
|
|
"Analyze the given YouTube transcript and generate question-answer pairs"
|
|
" to help study and understand the topic better. Please rate all questions from 1 to 5"
|
|
" based on their difficulty."
|
|
)
|
|
response = instructor_client.create_iterable(
|
|
model="gpt-4o-mini",
|
|
response_model=QuestionAnswer,
|
|
messages=[
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": transcript},
|
|
],
|
|
)
|
|
|
|
# iterate over QuestionAnswer, add the `youtube_url`, and append to state
|
|
for qna in response:
|
|
qna.youtube_url = youtube_url
|
|
# `State` is immutable, so `.append()` returns a new object with the appended value
|
|
state = state.append(question_answers=qna)
|
|
|
|
return state
|
|
```
|
|
|
|
### 2. Build the `Application`
|
|
|
|
To create a Burr `Application`, we use the `ApplicationBuilder` object.
|
|
|
|
Minimally, it needs to:
|
|
|
|
- Use `.with_actions()` to define all possible actions. Simply pass the functions
|
|
decorated with `@action`.
|
|
- Use `.with_transitions()` to define possible transitions between actions. This is
|
|
done via tuples `(from_action, to_action)`.
|
|
- Use `.with_entrypoint()` to specify which action to run first.
|
|
|
|
|
|
```python
|
|
from burr.core import ApplicationBuilder
|
|
|
|
app = (
|
|
ApplicationBuilder()
|
|
.with_actions(
|
|
process_user_input,
|
|
get_youtube_transcript,
|
|
generate_question_and_answers,
|
|
)
|
|
.with_transitions(
|
|
("process_user_input", "get_youtube_transcript"),
|
|
("get_youtube_transcript", "generate_question_and_answers"),
|
|
("generate_question_and_answers", "process_user_input"),
|
|
)
|
|
.with_entrypoint("process_user_input")
|
|
.build()
|
|
)
|
|
app.visualize()
|
|
```
|
|
|
|

|
|
|
|
> You can always visualize the application graph to understand the logic's flow.
|
|
|
|
|
|
### 3. Launch the application
|
|
|
|
Using `Application.run()` will make the application execute actions until a halt condition.
|
|
In this case, we halt before `process_user_input` to get the YouTube URL from the user.
|
|
|
|
The method `.run()` returns a tuple `(action_name, result, state)`. In this case, we only
|
|
use the state to inspect the generated question-answer pairs.
|
|
|
|
```python
|
|
action_name, result, state = app.run(
|
|
halt_before=["process_user_input"],
|
|
inputs={"user_input": "https://www.youtube.com/watch?v=hqutVJyd3TI"},
|
|
)
|
|
print(state["question_answers"][0])
|
|
```
|
|
|
|
You can create a simple local experience by using `.run()` in a `while` loop
|
|
|
|
```python
|
|
while True:
|
|
user_input = input("Enter a YouTube URL (q to quit): ")
|
|
if user_input.lower() == "q":
|
|
break
|
|
|
|
action_name, result, state = app.run(
|
|
halt_before=["process_user_input"],
|
|
inputs={"user_input": user_input},
|
|
)
|
|
print(f"{len(state['question_answers'])} question-answer pairs generated")
|
|
```
|
|
|
|
|
|
## Next steps
|
|
|
|
Now that you know how to use Instructor for reliable LLM outputs and Burr to
|
|
structure your application, many avenues open up depending on your goals!
|
|
|
|
|
|
### 1. Build complex agents
|
|
|
|
Instructor improves the LLM's reasoning by providing structure. Nesting models and adding
|
|
constraints allow to [get facts with citations](../../examples/exact_citations.md)
|
|
or [extract a knowledge graph](../../examples/knowledge_graph.md)
|
|
in a few lines of code. Also, [retries](../../concepts/retrying.md)
|
|
enable the LLM to self-correct.
|
|
|
|
Burr sets the boundaries between users, LLMs, and the rest of your system. You can add
|
|
`Condition` on transitions to create complex workflows that remain easy to reason about.
|
|
|
|
### 2. Add Burr to your product
|
|
|
|
Your Burr `Application` is a lightweight Python object. You can run it within a notebook,
|
|
via script, a web app (Streamlit, Gradio, etc.), or as a [web service](https://burr.dagworks.io/examples/deployment/web-server/)
|
|
(e.g., FastAPI).
|
|
|
|
The `ApplicationBuilder` provides many features to productionize your app:
|
|
|
|
- [Persistence](https://burr.dagworks.io../../concepts/state-persistence/.md): save and restore `State`
|
|
(e.g., store conversation history)
|
|
- [Observability](https://burr.dagworks.io../../concepts/additional-visibility/.md): log and monitor
|
|
application telemetry (e.g., LLM calls, number of tokens used, errors and retries)
|
|
- [Streaming and async](https://burr.dagworks.io../../concepts/streaming-actions/.md): create snappy
|
|
user interfaces by streaming LLM responses and running actions asynchronously.
|
|
|
|
For example, you can log telemetry into Burr UI in a few lines of code. First, instrument the
|
|
OpenAI library. Then, add `.with_tracker()` the `ApplicationBuilder` with a project name and
|
|
enabling `use_otel_tracing=True`.
|
|
|
|
```python hl_lines="5 19"
|
|
from burr.core import ApplicationBuilder
|
|
from opentelemetry.instrumentation.openai import OpenAIApiInstrumentor
|
|
|
|
# instrument before importing instructor or creating the OpenAI client
|
|
OpenAIApiInstrumentor().instrument()
|
|
|
|
app = (
|
|
ApplicationBuilder()
|
|
.with_actions(
|
|
process_user_input,
|
|
get_youtube_transcript,
|
|
generate_question_and_answers,
|
|
)
|
|
.with_transitions(
|
|
("process_user_input", "get_youtube_transcript"),
|
|
("get_youtube_transcript", "generate_question_and_answers"),
|
|
("generate_question_and_answers", "process_user_input"),
|
|
)
|
|
.with_tracker(project="youtube-qna", use_otel_tracing=True)
|
|
.with_entrypoint("process_user_input")
|
|
.build()
|
|
)
|
|
```
|
|
|
|

|
|
|
|
> Telemetry for our OpenAI API calls with Instructor. We see the prompt, the response model, and the response content.
|
|
|
|
### 3. Annotate application logs
|
|
|
|
Burr UI has a built-in annotation tool that allows you to label, rate, or comment on
|
|
logged data (e.g., user input, LLM response, content retrieved for RAG). This can be
|
|
useful to create test cases and evaluation datasets.
|
|
|
|

|
|
|
|
|
|
## Conclusion
|
|
|
|
We've shown how Instructor helps getting reliable outputs from LLMs and Burr provides
|
|
the right tools to build an application. Now it's your turn to start building!
|