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.
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.
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.
analyze_focus_group() runs descriptive analyses without
a model unless the caller opts in by supplying config.
analysis <- analyze_focus_group(
result,
num_topics = 4,
include_plots = TRUE
)
print(analysis)
analysis$basic_stats
analysis$tfidf
analysis$readability
analysis$issuesThe 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:
Thematic analysis and model summaries run only when an explicit
config is passed to analyze_focus_group().
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.
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.
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_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.
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
#> <focus_group_summary>
#> By: participant | Lengths: headline, brief
#> Model: openai/gpt-4o-mini | Calls: 2 | Structured output: schema
#> Claims kept: 2 | Quotes: 2
#> P1: P1 gave a view on the hours.
#> P2: P2 gave a view on the hours.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.
by_person$metadata$handles
#> speaker_id handle label role
#> 1 Facilitator Facilitator Moderator moderator
#> 2 Ana Ana P1 participant
#> 3 Ben Ben P2 participant
by_person$claims[, c("unit", "status", "text")]
#> unit status text
#> 1 P1 stated P1 gave a view on the hours.
#> 2 P2 stated P2 gave a view on the hours.
by_person$quotes[, c("unit", "message_id", "speaker_id", "verbatim", "start", "end")]
#> unit message_id speaker_id verbatim start end
#> 1 P1 2 Ana Evening hours help working parents 1 34
#> 2 P2 3 Ben I see it differently. Mornings 1 30A 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:
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")]
#> call_id unit attempt
#> 1 1 P1 1
#> 2 2 P2 1
#> 3 3 P2 2
cat(reasked$calls$messages[[3]][[3]]$content)
#> Your reply did not pass these checks:
#> - Quote 1 of claim 1 is not in message 3. Message 3 reads: "I see it differently. Mornings are too crowded now."
#> - The reply has no claim that reports what P2 said; give at least one claim with "unit": "P2", P2 among its speakers, and a message by P2 as evidence.
#> - The "headline" list has no claim that passed the checks.
#>
#> Reply again with the complete JSON object in the same format, with every point above corrected. Cite only messages shown in the transcript, copy each quote exactly from the message it cites, and give the status "not_stated" to anything the transcript does not show.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.
by_item <- summarize_focus_group(
result, config,
by = "item",
compression = c("headline", "detailed")
)
by_item$summariesfg_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".
fg_turns(imported)[, c("message_id", "item", "speaker_id", "handle", "words", "link")]
#> message_id item speaker_id handle words link
#> 1 1 1 Facilitator Facilitator 7 sequence
#> 2 2 1 Ana Ana 7 sequence
#> 3 3 1 Ben Ben 9 sequence
#> 4 4 1 Ana Ana 9 sequence
#> 5 5 2 Facilitator Facilitator 10 sequence
#> 6 6 2 Ben Ben 11 sequencefg_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.
session <- readRDS(system.file("extdata", "player-session.rds",
package = "FocusGroup"))
page <- fg_player(session, file = tempfile(fileext = ".html"), open = FALSE)
file.exists(page)
#> [1] TRUE
table(session$routing$rule)
#>
#> addressed called_on highest_score
#> 1 2 5Each 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.
The R6 interface exposes the parts assembled by
run_focus_group().
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.
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.