---
title: "Getting started with saydo"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Getting started with saydo}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 7, fig.height = 4)
options(pillar.width = 90)
```

saydo links what people *say* to what they *do*. It applies a
qualitative codebook to text with a large language model, checks the coding
against a human coder, and then tests whether each theme goes with a
difference in a quantitative metric.

This article walks through the whole workflow on simulated data. Every step
runs offline with `mock_coder()`, a keyword-based stand-in for the LLM, so
no API key is needed. The step that calls a real model is shown but not run.

```{r setup}
library(saydo)
```

## The data

`mock_mixed_data()` simulates users from three hidden profiles. Frustrated
users have long sessions and many errors, efficient users have short clean
sessions, and casual users sit in between. Each user leaves a line of
feedback whose wording depends on their profile. Feature requests are added
at random, so they should show no real effect.

```{r data}
users <- mock_mixed_data(n_users = 150, seed = 2026)
users
```

The `profile` column is the ground truth. Real data would not have it; here
it lets us check that the workflow recovers what was built in.

## Step 1: write a codebook

A codebook maps each code name to a definition. The definitions are what
the LLM sees, so make them specific: what counts, and what does not. Code
names become column names, so they must be syntactic R names.

```{r codebook}
codebook <- c(
  UI_Confusion        = "The user reports being lost, confused, or unable to find something in the interface",
  Positive_Experience = "The user expresses satisfaction, praise, or enjoyment of the product",
  Feature_Request     = "The user asks for a feature or capability that does not currently exist"
)
```

## Step 2: code the text

With a real LLM, pass any [ellmer](https://ellmer.tidyverse.org) chat
object. Pin the exact model so the coding can be reproduced:

```{r llm, eval = FALSE}
coded <- llm_auto_code(
  users, user_id, transcript_feedback, codebook,
  chat = ellmer::chat_openai(model = "gpt-4.1-mini")
)
```

Each text is coded in its own conversation, so earlier texts cannot
influence later ones. The first text is sent on its own: if the API key,
account credit or model name is wrong, you get an error within seconds
rather than a stalled batch. The remaining texts are sent in parallel
(`max_active` at a time). Empty or missing texts are not sent and get `NA`.

Here we use the offline coder instead:

```{r code}
coded <- llm_auto_code(users, user_id, transcript_feedback, codebook,
                       coder = mock_coder())
coded[, c("user_id", names(codebook))]
```

## Step 3: check the coding against a human

Before trusting automated codes, have a person code a random sample blind
and compare. To illustrate, we simulate a human coder who follows the
hidden profiles but makes occasional mistakes:

```{r human}
set.seed(1)
sample_rows <- sample(nrow(users), 50)
flip <- function(x, rate = 0.08) xor(x, runif(length(x)) < rate)

human <- data.frame(
  user_id = users$user_id[sample_rows],
  UI_Confusion = flip(users$profile[sample_rows] == "frustrated"),
  Positive_Experience = flip(users$profile[sample_rows] == "efficient"),
  Feature_Request = flip(grepl("wish|please add|would be great",
                               users$transcript_feedback[sample_rows]))
)

code_agreement(coded, human)
```

How to read it:

- **kappa** corrects raw agreement for chance. As a rough guide, above 0.6
  is substantial and above 0.8 near-perfect.
- **precision** is the share of the LLM's "present" codes that the human
  agrees with. Low precision means the LLM over-applies the code.
- **recall** is the share of the human's "present" codes that the LLM also
  found. Low recall means the LLM misses the theme.

If a code scores poorly, sharpen its definition and re-code. Report these
figures alongside your analysis.

## Step 4: link themes to the metrics

`cross_tab_themes()` compares the metric between units with and without
each theme, using Welch's t-test, and adjusts the p-values for the number
of themes tested (Benjamini–Hochberg by default).

```{r cross-tab}
results <- cross_tab_themes(coded, session_duration_sec)
results
```

The codebook and ID column are remembered from `llm_auto_code()`, so you
only name the metric. Run it again for other metrics:

```{r errors}
cross_tab_themes(coded, errors_encountered)[, c("theme", "difference", "p_adjusted", "significant")]
```

These are associations, not causal effects. Confused users have longer
sessions, but the comparison does not adjust for anything else. For that,
fit your own model on `coded`.

## Step 5: plot

```{r plot, fig.alt = "Dot plot of the mean difference in session duration per theme, with confidence intervals."}
plot_theme_impact(results)
```

## Segment-level transcripts

Interviews are often coded by segment, with many rows per participant,
while the metrics have one row per participant. Code the segments as they
are, then pass the participant table as `metrics`. A theme counts as
present for a participant if it appears in any of their segments:

```{r segments}
segments <- data.frame(
  participant = c("P1", "P1", "P2", "P2", "P3", "P4", "P4", "P5", "P6"),
  segment_id  = 1:9,
  text = c("It was fine at first.", "Then I got completely lost.",
           "Everything was clean and fast.", "I love the export.",
           "So confusing, I could not find the settings.",
           "Normal day.", "Nothing to report.",
           "I got lost twice.", "It works."),
  stringsAsFactors = FALSE
)
logs <- data.frame(participant = paste0("P", 1:6),
                   minutes_active = c(42, 12, 39, 15, 45, 14))

coded_segments <- llm_auto_code(segments, participant, text,
                                c("UI_Confusion", "Positive_Experience"),
                                coder = mock_coder())

cross_tab_themes(coded_segments, minutes_active, metrics = logs)
```

To check agreement at segment level, give each segment its own ID and
pass it as `id_col`, e.g. `code_agreement(coded_segments, human, id_col = "segment_id")`.

## Data protection

With a hosted LLM, every text is sent to the provider. Check that this is
allowed under your ethics approval and data agreements before coding real
participant data, or run a local model with `ellmer::chat_ollama()`.
