> ## Documentation Index
> Fetch the complete documentation index at: https://docs.expectedparrot.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Scenarios

> Use text, tables, PDFs, images, video, and audio as inputs to EDSL questions.

A `Scenario` holds the inputs to a question: text, numbers, experimental conditions, or files such as PDFs, images, video, and audio. Give each input a name and refer to it in the question with `{{ scenario.name }}`. A `ScenarioList` supplies multiple sets of inputs so you can ask the same questions about each one.

Put the material being evaluated in a scenario; put simulated respondent characteristics in an [Agent](https://docs.expectedparrot.com/en/latest/agents.md).

## Keep the question fixed and vary the data

The example asks the same sentiment question about three feedback messages. Each scenario provides the message and a record identifier; the question supplies the shared Positive, Neutral, and Negative categories. It prepares the tasks without generating answers:

```python theme={null}
from edsl import QuestionMultipleChoice, Scenario, ScenarioList, Survey

question = QuestionMultipleChoice(
    question_name="sentiment",
    question_text="Classify the sentiment of this feedback: {{ scenario.feedback }}",
    question_options=["Positive", "Neutral", "Negative"],
)
feedback = ScenarioList([
    Scenario({"record_id": "f1", "feedback": "The setup was easy."}),
    Scenario({"record_id": "f2", "feedback": "I couldn't find the export button."}),
    Scenario({"record_id": "f3", "feedback": "It worked as expected."}),
])
job = question.by(feedback)
# by(): one interview per input, with the same answer.sentiment column.
# loop(): three distinct questions in one survey, with the text substituted now.
variants = question.loop(feedback)
variant_survey = Survey(variants)
```

`record_id` is retained in results even though it is not inserted into the question text. Use stable identifiers to join predictions with source data and human annotations.

## Crossing inputs versus creating question variants

`question.by(feedback)` asks the same question in separate interviews for each scenario. The answer column remains `answer.sentiment`, and scenario values identify the input row.

`question.loop(feedback)` returns separate question objects with scenario values substituted into their text. Placing those questions in a survey creates multiple answer columns in each interview. Choose this when respondents should see several fixed versions within one survey. It is not a substitute for assigning treatments between respondents.

## Controlled experiments

Cross scenario conditions with agent profiles and models using successive `by()` calls. EDSL runs every combination: two treatments, four profiles, and three models produce 24 interviews per iteration. Human participant assignment is configured separately when publishing the survey.

Use [Experimental conditions](https://docs.expectedparrot.com/en/latest/guides/experimental_conditions.md) to retain condition labels and [Human studies](https://docs.expectedparrot.com/en/latest/guides/human_studies.md) for scenario assignment in hosted surveys.

## Data and files

A scenario can hold extracted text or a complete file. For a complete file, EDSL uses a [FileStore](https://docs.expectedparrot.com/en/latest/filestore.md) to carry its contents. Reference the file's scenario field in every question that should receive it.

| Input | Create scenarios with | Example below |
| - | - | - |
| Images | `Scenario.from_image(path, image_name="image")` | [Images](#images) |
| Whole PDF | `Scenario.from_file(path, field_name="document")` | [PDF attachments](#pdf-attachments) |
| PDF text, one scenario per page | `ScenarioList.from_source("pdf", path)` | [PDF text](#pdf-text) |
| PDF pages as images | `ScenarioList.from_source("pdf_to_image", path)` | [PDF page images](#pdf-page-images) |
| Video or audio | `Scenario.from_file(path, field_name="video")` or `field_name="audio"` | [Video](#video) and [Audio](#audio) |
| CSV, Excel, pandas, and other tables | `ScenarioList.from_source(source_type, data)` | [Importing data](https://docs.expectedparrot.com/en/latest/guides/importing_data.md) |
| A directory of files | `ScenarioList.from_source("directory", path, pattern=...)` | [A collection of files](#a-collection-of-files) |

The examples below use local filenames; replace them with your files. Loading them constructs EDSL objects locally. Calling `run()` sends the referenced content to your selected model. Use [notebook login or provider credentials](https://docs.expectedparrot.com/en/latest/api_keys.md) to configure access.

For attachments, select a model that accepts that media type. Replace `MODEL_NAME` and `SERVICE_NAME` with current identifiers from [Language models](https://docs.expectedparrot.com/en/latest/language_models.md). `Model.check_working_models(works_with_images=True)` helps find image-capable models; that filter does not establish PDF, video, or audio support.

## Images

Create an image scenario, reference its `image` field, and ask a question:

```python theme={null}
from edsl import Model, QuestionFreeText, Scenario

image_scenario = Scenario.from_image("image.png", image_name="image")
image_scenario["filename"] = "image.png"
image_question = QuestionFreeText(
    question_name="description",
    question_text="Describe the main objects in this image: {{ scenario.image }}",
)
image_model = Model("MODEL_NAME", service_name="SERVICE_NAME")  # Choose an image-capable model.
image_results = image_question.by(image_scenario).by(image_model).run()
image_results.select("scenario.filename", "answer.description")
```

`image_name="image"` sets the field used in the question. Without it, `from_image()` uses the filename before its first dot. The `filename` field above is ordinary metadata retained in the results.

If you already have a FileStore, use `Scenario({"image": file})` or `file.to_scenario(key_name="image")`. This is also how you attach an image retrieved from Expected Parrot or returned by [image generation](https://docs.expectedparrot.com/en/latest/filestore.md#generate-image-inputs).

## PDF attachments

Attach the whole PDF when the question should use its text, charts, images, and layout together. Select a model with PDF input support:

```python theme={null}
from edsl import Model, QuestionFreeText, Scenario

pdf_scenario = Scenario.from_file("report.pdf", field_name="document")
pdf_scenario["filename"] = "report.pdf"
pdf_question = QuestionFreeText(
    question_name="findings",
    question_text="Summarize the main findings and supporting charts in this report: {{ scenario.document }}",
)
pdf_model = Model("MODEL_NAME", service_name="SERVICE_NAME")  # Choose a PDF-capable model.
pdf_results = pdf_question.by(pdf_scenario).by(pdf_model).run()
pdf_results.select("scenario.filename", "answer.findings")
```

This gives the model the document as one attachment. Use the following text or image workflows when you want a separate response for each page.

## PDF text

`ScenarioList.from_source("pdf", ...)` extracts text into one scenario per page. Each row has `filename`, `page`, and `text`; page numbers start at **1**. Text extraction uses PyMuPDF (`python -m pip install pymupdf`).

```python theme={null}
from edsl import Model, QuestionFreeText, ScenarioList

pages = ScenarioList.from_source("pdf", "report.pdf")
pages.select("filename", "page", "text")
# To use only the first page: pages = pages.filter("page == 1")
```

Ask the same question about each page's text:

```python theme={null}
page_question = QuestionFreeText(
    question_name="summary",
    question_text="Summarize this page: {{ scenario.text }}",
)
page_results = page_question.by(pages).by(Model()).run()
page_results.select("scenario.filename", "scenario.page", "answer.summary")
```

For all extracted text in a single Scenario, use:

```python theme={null}
from edsl import Scenario

document_text = Scenario.from_pdf("report.pdf")
document_text["text"]
# To ask the same question about the complete text:
# page_question.by(document_text).by(Model()).run()
```

`Scenario.from_pdf()` returns `filename` and `text`. These text workflows can use a text model. For scanned pages or questions about visual layout, use a PDF attachment or page images.

## PDF page images

Render each PDF page as an image when you want to ask about diagrams, layout, or scanned content page by page. This conversion uses `pdf2image` and its system dependency Poppler.

```bash theme={null}
python -m pip install pdf2image
```

Install Poppler for your operating system as described in the [pdf2image installation guide](https://pdf2image.readthedocs.io/en/latest/installation.html). Then create page scenarios and use an image-capable model:

```python theme={null}
from edsl import Model, QuestionFreeText, ScenarioList

page_images = ScenarioList.from_source("pdf_to_image", "report.pdf")
# This converter's page index starts at 0; add a familiar page number for results.
page_images = page_images.mutate("page_number = page + 1").add_value("filename", "report.pdf")
visual_question = QuestionFreeText(
    question_name="visual_summary",
    question_text="Describe the charts and layout on this page: {{ scenario.content }}",
)
visual_model = Model("MODEL_NAME", service_name="SERVICE_NAME")
visual_results = visual_question.by(page_images).by(visual_model).run()
visual_results.select("scenario.filename", "scenario.page_number", "answer.visual_summary")
```

The image attachment is in `content`. It contains the image data even after the converter's temporary files are cleaned up. To save a page image yourself, use `page_images[0]["content"].write("page-1.jpg")`.

`Scenario.from_pdf_to_image("report.pdf")` is an alternative that returns **one** Scenario with separate attachment fields `page_0`, `page_1`, and so on. Reference the particular page fields you want the question to receive, as in the [multiple-attachment example](#multiple-attachments-in-one-scenario).

## Video

Use a video file as a scenario input and refer to it in the question. This example uses EDSL's `google` service; choose a current model that supports [video input](https://ai.google.dev/gemini-api/docs/video-understanding) and replace `GOOGLE_MODEL_NAME`:

```python theme={null}
from edsl import Model, QuestionFreeText, Scenario

video_scenario = Scenario.from_file("clip.mp4", field_name="video")
video_scenario["filename"] = "clip.mp4"
video_question = QuestionFreeText(
    question_name="events",
    question_text="Describe the main events in this video in chronological order: {{ scenario.video }}",
)
video_model = Model("GOOGLE_MODEL_NAME", service_name="google")
video_results = video_question.by(video_scenario).by(video_model).run()
video_results.select("scenario.filename", "answer.events")
```

EDSL passes the video attachment through the Google service's file-upload flow. You do not need to call a separate upload method before running the question. See the provider's guide for supported formats and duration limits.

## Audio

Audio uses the same attachment pattern. For example, transcribe an MP3 using a Google model with [audio input support](https://ai.google.dev/gemini-api/docs/audio):

```python theme={null}
from edsl import Model, QuestionFreeText, Scenario

audio_scenario = Scenario.from_file("interview.mp3", field_name="audio")
audio_scenario["filename"] = "interview.mp3"
audio_question = QuestionFreeText(
    question_name="transcript",
    question_text="Transcribe the speech in this recording: {{ scenario.audio }}",
)
audio_model = Model("GOOGLE_MODEL_NAME", service_name="google")
audio_results = audio_question.by(audio_scenario).by(audio_model).run()
audio_results.select("scenario.filename", "answer.transcript")
```

You can instead ask about speakers, topics, or other features of the recording by changing the question. To analyze an existing transcript as text, put the transcript string in a Scenario and reference that field, as in the [qualitative coding example](https://docs.expectedparrot.com/en/latest/research/qualitative.md).

## A collection of files

Create one scenario per file to apply the same questions across a collection. For example, reuse the image question and model from [Images](#images) with a directory of PNGs:

```python theme={null}
from edsl import ScenarioList

images = ScenarioList.from_source(
    "directory", "images", pattern="*.png", recursive=True,
).rename({"file": "image"})
images.select("file_name", "file_path")

collection_results = image_question.by(images).by(image_model).run()
collection_results.select("scenario.file_path", "answer.description")
```

The directory source stores each FileStore under `file` and includes metadata such as `file_name` and `file_path`. Renaming `file` to `image` matches the field referenced by `image_question`. Change the pattern and question to work with PDFs, videos, or audio files instead. Use `metadata=False` if you only want the attachment field.

## Multiple attachments in one scenario

Put several files in the same Scenario when the model should compare them in one response. Reference each attachment explicitly:

```python theme={null}
from edsl import FileStore, Model, QuestionFreeText, Scenario

comparison = Scenario({
    "record_id": "design-1",
    "before": FileStore("before.png"),
    "after": FileStore("after.png"),
})
comparison_question = QuestionFreeText(
    question_name="changes",
    question_text="Compare the original image {{ scenario.before }} with the revised image {{ scenario.after }}. What changed?",
)
comparison_model = Model("MODEL_NAME", service_name="SERVICE_NAME")
comparison_results = comparison_question.by(comparison).by(comparison_model).run()
comparison_results.select("scenario.record_id", "answer.changes")
```

A ScenarioList of two images produces separate interviews for those images. Two image fields in one Scenario let a question receive both together. The same pattern works for other combinations of files supported by the selected model.

For retrieving attachments from Expected Parrot, previewing them, or writing them to disk, see [FileStore](https://docs.expectedparrot.com/en/latest/filestore.md#storage-and-sharing).

## Common methods

| Call | Arguments and result |
| - | - |
| `Scenario({"message": "Hello"})` | Create one row of named inputs. |
| `ScenarioList([scenario, ...])` | Collect rows for a study. |
| `ScenarioList.from_list("message", values)` | Create one scenario per value, stored under the specified field name. |
| `scenarios.select("record_id", "message")` | Return a ScenarioList with the selected fields. |
| `scenarios.filter("condition == 'short'")` | Return rows matching a Boolean expression over field names. |
| `scenarios.mutate("length = len(message)", functions_dict={"len": len})` | Return rows with a field computed from an expression. Supply custom functions explicitly. |
| `scenarios.times(other)` | Return every combination of rows from the two ScenarioLists. Use distinct field names to retain both inputs. |

This constructs four conditions without running a model:

```python theme={null}
from edsl import ScenarioList

messages = ScenarioList.from_list("message", ["Save time", "Reduce errors"])
tones = ScenarioList.from_list("tone", ["formal", "casual"])
conditions = messages.times(tones)
conditions
# In a script: conditions.print()
```

## Clean and transform rows

ScenarioList transformations let you prepare inputs before building a job. The following operates entirely on local data:

```python theme={null}
from edsl import ScenarioList

records = ScenarioList.from_list_of_dicts([
    {"record_id": "r1", "message": "  Save time  ", "score": 4},
    {"record_id": "r2", "message": "Reduce errors", "score": 2},
])
cleaned = records.transform("message", str.strip)
prepared = cleaned.transform("message", len, new_name="length").rename({"message": "text"})
prepared.select("record_id", "text", "length")
```

Assign the returned collection when using these transformations:

| Task | Methods |
| - | - |
| Select fields | `select("record_id", "text")` keeps fields; `drop("score")` removes them. `keep()` is an alias for `select()`. |
| Add fields | `add_value("source", "pilot")` adds one value to all rows; `add_list("label", labels)` adds one value per row. |
| Compute values | `mutate("doubled = score * 2")` evaluates an expression; `transform("text", str.lower, new_name="normalized")` applies a Python function to a field. Pass `functions_dict` to `mutate()` when the expression calls a function. |
| Handle missing data | `replace_values({"NA": None})` replaces matching values across fields; `fillna("")` fills missing values; `filter_na(["text"])` removes rows missing required fields. |
| Normalize imported data | `snakify()` converts field names to Python identifiers; `numberify()` converts numeric strings where possible. Inspect the result before analysis. |
| Choose rows | `filter("score >= 3")`, `order_by("score", reverse=True)`, and `slice("0:10")` filter, sort, and slice. |
| Sample or split | `sample(n=2, seed="pilot")` samples without replacement; `shuffle(seed="pilot")` changes order; `split(frac_left=0.5, seed=42)` returns two ScenarioLists. |
| Remove duplicate rows | `unique()` returns distinct rows. |
| Disambiguate repeated labels | `uniquify("label")` keeps rows and appends suffixes to repeated field values. Preserve the original record identifier separately when it is needed for joins. |

For a single Scenario, use `select()`, `drop()`, `rename({"old": "new"})`, and `replace_value("field", value)` to transform the mapping. `scenario.replicate(n)` returns a ScenarioList of copies.

## Join, reshape, and summarize

Use `left_join(other, by="record_id")`, `inner_join(...)`, or `right_join(...)` for matching records by a key, rather than `times()` for all combinations. Check key uniqueness and resulting row counts when joining.

```python theme={null}
labels = ScenarioList.from_list_of_dicts([
    {"record_id": "r1", "label": "benefit"},
    {"record_id": "r2", "label": "benefit"},
])
labeled = records.left_join(labels, by="record_id")
labeled.tally("label")
```

| Task | Method and result |
| - | - |
| Split long text | `scenarios.chunk("text", num_words=200, include_original=True)` returns one scenario per chunk, retaining other fields. `Scenario.chunk()` also works on a single record. |
| Turn lists into rows | `scenarios.expand("tags")` creates a row for each list item. |
| Turn nested values into columns | `unpack_dict("metadata", prefix="meta_")` expands dictionary keys; `unpack("scores", new_names=["first", "second"])` expands list positions. |
| Combine rows | `collapse("text", separator="\n")` groups on the remaining fields and combines text. |
| Convert wide to long | `unpivot(id_vars=["record_id"], value_vars=["before", "after"])` produces `variable` and `value` fields. `pivot(id_vars=["record_id"])` reverses that layout when keys are unique. |
| Aggregate | `group_by(id_vars=["condition"], variables=["score"], func=lambda scores: {"total": sum(scores)})` aggregates each group. The function receives one list per selected variable and must return a dictionary. |
| Count or query | `tally("condition")` returns counts; `sql("SELECT condition, COUNT(*) AS n FROM self GROUP BY condition")` returns a Dataset. |

`to_agent_list()` converts rows to respondent profiles; `to_dataset()` returns a table. Use `to_dicts()` for ordinary row dictionaries and `to_list()` after selecting the fields you need. For pandas, Polars, CSV, and Excel outputs, see [table conversions](https://docs.expectedparrot.com/en/latest/dataset.md#export-and-convert-tables).

`scenarios.embed("text", model=embedding_model, cache=embedding_cache)` adds vectors for a text field. See [text embeddings](https://docs.expectedparrot.com/en/latest/dataset.md#embed-text) for model selection, caching, and a local example.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.