--- title: "Focus Group Analysis" date: "`r Sys.Date()`" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Focus Group Analysis} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) library(FocusGroup) ``` # Analyze simulated and imported transcripts How much did each speaker contribute? Which terms and topics distinguished their turns? How did moderator questions organize the discussion, and where do readability, recurring themes, or participation patterns differ? FocusGroup addresses these questions for simulated sessions and imported transcripts. Descriptive analysis runs offline. Thematic analysis and model summaries are separate, opt-in model tasks. A live session or requested model analysis uses LLMR, requires a provider key in its usual environment variable, and receives an explicit model configuration. ## Run a focus group ```{r create_fg, eval = FALSE} config <- LLMR::llm_config( provider = "openai", model = "gpt-4o-mini", temperature = 0.7 ) result <- run_focus_group( topic = "Impact of Social Media on Mental Health", n_participants = 5, guide = c( Opening = 1, Icebreaker = 1, Engagement = 2, Exploration = 3, Closing = 1 ), flow = "desire_based", config = config, seed = 110, message_mode = "roleflip" ) print(result) print(result$focus_group) ``` The returned `focus_group_result` has six returned components: `focus_group`, `transcript`, `summary`, `participants`, `usage`, and `metadata`. ```{r view_transcript, eval = FALSE} head(result$transcript) result$participants result$usage ``` Each transcript row has a unique `message_id`. `round` identifies the moderator cycle, so a moderator question and its participant responses share a round. `phase` retains the guide phase. Generated rows also carry provider response metadata and token counts; a count the provider did not report is `NA`. ## Run descriptive analysis offline `analyze_focus_group()` runs descriptive analyses without a model unless the caller opts in by supplying `config`. ```{r offline_analysis, eval = FALSE} analysis <- analyze_focus_group( result, num_topics = 4, include_plots = TRUE ) print(analysis) analysis$basic_stats analysis$tfidf analysis$readability analysis$issues ``` The `focus_group_analysis` returned components are `basic_stats`, `topics`, `tfidf`, `readability`, `themes`, `model_summary`, `plots`, and `issues`. A component that cannot be computed is an empty component with consistent columns where it is tabular. For example, `plots` is an empty list if ggplot2 is unavailable. `issues` identifies an optional analysis that could not run and gives its reason. Individual descriptive analyses remain available as methods on the underlying `FocusGroup` object: ```{r individual_analysis, eval = FALSE} readability <- result$focus_group$analyze_readability() participation <- result$focus_group$analyze_participation_balance() questions <- result$focus_group$analyze_question_patterns() readability participation$participation_stats questions$question_patterns ``` ## Opt in to model analysis Thematic analysis and model summaries run only when an explicit `config` is passed to `analyze_focus_group()`. ```{r model_analysis, eval = FALSE} model_analysis <- analyze_focus_group( result, num_topics = 4, config = config ) cat(model_analysis$themes) cat(model_analysis$model_summary) ``` If a requested model analysis fails, the provider condition is raised. The failure is not converted to a missing field or omitted from the result. ## Import and analyze an existing transcript Importing a transcript creates no model output. Pass `moderator_id` when the moderator is known. If it is omitted, a speaker named exactly `mod`, `moderator`, `facilitator`, or `interviewer` (in any case) is the moderator. ```{r import, eval = FALSE} transcript <- data.frame( speaker = c("Facilitator", "Ana", "Ben", "Ana"), text = c( "What should change about the library hours?", "Evening hours help working parents.", "Morning crowding has become difficult.", "Both schedules need enough staff." ) ) imported <- focus_group_from_transcript( transcript, topic = "Library hours", moderator_id = "Facilitator" ) imported_analysis <- analyze_focus_group(imported, include_plots = FALSE) print(imported_analysis) ``` ## Summarize a discussion `summarize_focus_group()` summarizes a session, a set of arms, or an imported transcript by participant, guide item, disagreement, trajectory, turn, or the whole session, at one or more of three lengths: `"headline"`, `"brief"`, and `"detailed"`. The model returns claims as JSON. Each claim gives its status (`"stated"`, `"implied"`, `"contradictory"`, or `"not_stated"`), the ids of the messages it rests on, and any words it quotes. Every claim is checked against the transcript before prose is rendered from it: the cited messages must exist and be by the speakers the claim names, each quote must appear in the message it cites, and a claim for the whole group must cite every participant who spoke. The chunks below run offline. The scripted runner stands in for the model: it reads the transcript from the prompt and answers with one claim per participant, quoting the first five words of that participant's first message. A live call passes `config` and no runner. ```{r summarize} transcript <- data.frame( speaker = c("Facilitator", "Ana", "Ben", "Ana", "Facilitator", "Ben"), text = c( "What should change about the library hours?", "Evening hours help working parents like me.", "I see it differently. Mornings are too crowded now.", "Crowded mornings still beat being locked out after work.", "What would you give up to pay for longer hours?", "I would give up the Sunday story hour before anything else." ) ) imported <- focus_group_from_transcript( transcript, topic = "Library hours", moderator_id = "Facilitator" ) scripted <- function(experiments, ...) { prompt <- experiments$messages[[1]][[1]]$content unit <- sub(".*one participant, (P[0-9]+)\\..*", "\\1", prompt) lines <- regmatches(prompt, gregexpr("\\[#[0-9]+\\] P[0-9]+: [^\n]*", prompt))[[1]] own <- lines[grepl(paste0("^\\[#[0-9]+\\] ", unit, ": "), lines)][1] id <- as.integer(sub("^\\[#([0-9]+)\\].*", "\\1", own)) words <- strsplit(sub("^[^:]*: ", "", own), " ")[[1]] claim <- list( id = 1, unit = unit, kind = "position", status = "stated", text = paste(unit, "gave a view on the hours."), speakers = list(unit), evidence = list(id), context = list(), quotes = list(list(message_id = id, speaker = unit, text = paste(words[1:5], collapse = " "))) ) reply <- list(claims = list(claim), levels = list(headline = list(1), brief = list(1))) experiments$response_text <- as.character(jsonlite::toJSON(reply, auto_unbox = TRUE)) experiments } summary_config <- LLMR::llm_config("openai", "gpt-4o-mini") by_person <- summarize_focus_group( imported, summary_config, compression = c("headline", "brief"), .runner = scripted ) by_person ``` The summarizing model labels the speakers by their handles: Ana and Ben are labeled P1 and P2, and the facilitator Moderator. Names inside the messages reach it as written, as the speakers said them. The researcher's columns keep the names, and each quote is cut from the message as recorded. ```{r summarize_records} by_person$metadata$handles by_person$claims[, c("unit", "status", "text")] by_person$quotes[, c("unit", "message_id", "speaker_id", "verbatim", "start", "end")] ``` A quote passes when it occurs in the cited message once case, whitespace, and quotation marks are set aside; `verbatim`, `start`, and `end` give it as it stands in the source. A reply that fails a check is sent back once, with each failure stated. Here the first reply about P2 misquotes the message: ```{r summarize_reask} calls <- 0L misquote_once <- function(experiments, ...) { calls <<- calls + 1L out <- scripted(experiments, ...) if (calls == 2L) { out$response_text <- sub("I see it differently.", "I see it quite differently.", out$response_text, fixed = TRUE) } out } reasked <- summarize_focus_group( imported, summary_config, compression = "headline", .runner = misquote_once ) reasked$calls[, c("call_id", "unit", "attempt")] cat(reasked$calls$messages[[3]][[3]]$content) ``` When the second reply fails as well, the run stops with an error of class `focusgroup_summary_check_failure`; its `partial` field holds the units completed before it, and its `checks` and `replies` fields show what failed. With `on_fail = "drop"` the failing claims move to `rejected` instead, except claims of a disagreement, of a position held by one participant alone, or of a change, which still stop the run. `by = "item"` sends one guide item at a time, which also keeps a long session within the prompt window. `by = "turn"` gives the gist of every message. `by = "trajectory"` follows the discussion through stages fixed in advance: thirds of the participant turns for the headline, one stage per guide item for the longer lengths. `by = "disagreements"` lists each disagreement, and each position held by one participant alone, with how it ended. Private probe answers reach the model only with `include_probes = TRUE`. ```{r summarize_live, eval = FALSE} by_item <- summarize_focus_group( result, config, by = "item", compression = c("headline", "detailed") ) by_item$summaries ``` `fg_turns()` gives the turn table the summaries read, with no model call: each message's guide item, handle, and length, and how the speaker came to have the floor. An imported transcript has no routing record, so every link is `"sequence"`. ```{r turns} fg_turns(imported)[, c("message_id", "item", "speaker_id", "handle", "words", "link")] ``` ## Replay a session `fg_player()` writes a session to a self-contained HTML file that steps through it message by message, built from the records the session saved and without any model call. The package includes a synthetic session made with a scripted runner, which the chunk below replays; `open = FALSE` keeps the browser closed while the vignette is built. ```{r player} session <- readRDS(system.file("extdata", "player-session.rds", package = "FocusGroup")) page <- fg_player(session, file = tempfile(fileext = ".html"), open = FALSE) file.exists(page) table(session$routing$rule) ``` Each speaker selection in `session$routing` appears as a panel before the turn it chose. Under desire-based selection the panel shows the score of every participant asked, marks the previous speaker as not asked, and states whether a tie was drawn at random; a call-on or a participant addressed by the moderator is marked as such. For a printable page, use `mode = "static"`; for a study with arms, pass the result of `run_focus_group_arms()` to write one file per arm. ## Construct the objects directly The R6 interface exposes the parts assembled by `run_focus_group()`. ```{r direct, eval = FALSE} agents <- create_agents( n_participants = 3, demographics = data.frame( age = c(25, 35, 45), gender = c("male", "female", "non-binary"), education = c("high school", "bachelor's", "master's"), stringsAsFactors = FALSE ), config = config ) flow <- create_conversation_flow( mode = "desire_based", agents = agents, moderator_id = "MOD" ) fg <- FocusGroup$new( topic = "Impact of Social Media on Mental Health", purpose = "Explore perspectives and experiences related to social media.", agents = agents, moderator_id = "MOD", turn_taking_flow = flow, admin_config = config ) ``` Built-in flows are available only through `create_conversation_flow()`. `ConversationFlow` is the base class for custom turn-taking rules. ## Interpretation A generated transcript is evidence about the specified simulation, not about a population. Analysis should retain the guide, participant records, model configuration, and message construction recorded with the result.