FocusGroup Architecture

Architecture

FocusGroup supports the design and examination of moderated discussions before fieldwork. Researchers can compare moderator guides, participant sources, and speaker-selection rules while retaining one transcript format for analysis. A generated transcript describes the specified session and does not estimate population attitudes.

Most studies can begin with run_focus_group(). This article explains the underlying objects for researchers who need direct construction, offline execution, or custom turn-taking rules.

FGAgent represents a participant or moderator and generates that agent’s turns. Its model configuration is stored in the config field. ConversationFlow is the base class for custom turn-taking rules. FocusGroup advances the moderator guide and records each message. LLMR supplies model configurations and provider calls.

Main functions

run_focus_group() is the single high-level session runner. It requires an explicit config, constructs the roster and flow, performs a call-count preflight, runs the guide, and returns a focus_group_result.

Its main arguments are consistent with the other package entry points:

The package supplies three built-in flow modes: "round_robin", "probabilistic", and "desire_based". Construct them with create_conversation_flow(). Their implementation classes are internal. ConversationFlow remains public so packages and researchers can define new selection rules.

create_agents(), create_agents_from_data(), and create_agents_from_survey() return agent-ID-keyed lists that include the moderator MOD. Each requires an explicit config because the returned agents can generate model output.

focus_group_from_transcript() creates a FocusGroup from an existing transcript without generating new turns. moderator_id identifies the moderator when known. If it is omitted, a speaker named exactly mod, moderator, facilitator, or interviewer (in any case) is the moderator. When no speaker matches, the importer says so and analyzes every speaker as a participant.

Offline execution and replay

The .runner argument accepts an optional function for offline tests or replay that uses the request and response data-frame format used across LLMR. The package calls .runner(experiments, ...), where experiments is a data frame with config and messages list-columns. The function returns those rows with at least response_text; it may also return response_id, token counts, success, and related diagnostics.

The function handles moderator, participant, desire-scoring, and summary requests. This format permits an archive replay function to execute a full session without a live provider. A configuration remains explicit and is recorded with the experiment so readers can see how it was run.

Running a session

FocusGroup$run_simulation() first records the participant roster as a System message. It then reads question_script in order. For each entry, the moderator generates one message. Question phases call select_next_speaker() and collect no more than max_participant_responses participant messages, followed at a call-on point by the replies of the participants the moderator calls on. Opening and closing phases do not collect participant responses.

The round-robin mode cycles through participants. The probabilistic mode samples from speaking propensities. The desire-based mode asks eligible participants for model-produced scores and selects among eligible candidates; it passes over the participant who spoke just before, and a moderator line in between ends that exclusion, so whoever closed one question may open the next. A score is read from the reply’s final answer, never from reasoning the model returns with it. A desire score that cannot be obtained, after one retry with a larger budget, stops the session with an error of class focusgroup_desire_failure. With on_desire_failure = "random" the session continues instead: the speaker is chosen among the participants with valid scores by the same rule, or by a uniform draw when none has one, and the failure is recorded in the message metadata. A failure is never recorded as a score of zero.

Two kinds of turn are given without the flow. When a moderator line puts its last question to one participant by handle (“P3, do you agree?”, “Could you say more, P3?”), that participant takes the next turn. When the discussion of a call-on item ends (call_on_silent, by default the first, middle, and last question), the moderator calls on each participant who has not spoken since the previous such point, in roster order: a fixed line, delivered with no model call, then the participant’s reply, with no desire score. Krueger (2002) describes the opening question of a focus group as a round robin; the first point gives every participant a turn during the first question. Both kinds of turn count as participant turns, for max_participant_responses (an addressed turn) and for fg_before_turn(), and the flow’s state is updated after them as after its own picks. Round robin keeps its place in the rotation, and passes over the participant who has just spoken when the rotation comes to them, so that no one speaks twice in a row.

The loop stops when the script is exhausted, num_rounds is reached, or the last closing entry is processed. It stores a final summary in final_summary, unless the session runs with final_summary = FALSE. The summary is not appended to conversation_log.

The agents see each other under handles: participants as P1, P2, … in roster order and the moderator as Moderator by default. Every prompt, the desire scores, probes, and the interim summary among them, is built from one view of the log in which each speaker appears under its handle, and each participant’s system message names its own. The log keeps the agent IDs.

Krueger, R. A. (2002). Designing and Conducting Focus Group Interviews. University of Minnesota.

A FocusGroup object runs once. Calling run_simulation() on an object that has already run raises an error. Construct a new object for another full session. To continue a recorded session under other histories, pass it to run_focus_group_arms(), as described below; the continuation experiment in run_focus_studio() compares one next message.

A message can also be placed into a running session: fg_injection() describes it and the point where it goes, after the moderator’s line for a guide item (fg_after_item()), before a participant turn (fg_before_turn()), or before the closing line ("before_closing"). run_focus_group_arms() runs the session to that point once, copies the whole session there, and continues each copy to the end under its own arm, so the arms share every message before the injection. fg_probe() asks each participant a private question at stated points; the answers are saved apart from the transcript and never enter the discussion. With scale = c(1, 7), each answer is also read as one whole number from 1 to 7 into the value column of probes; an answer from which no single number on the scale can be read, such as “3 or 4” or “6/10”, is re-asked once, and stays NA, with a warning, when the re-ask gives none either.

An injection from a participant’s ID or handle at fg_before_turn(t) is that participant’s turn t, said as written with no model call. It is recorded with the routing rule "injected" and takes the place of the turn the session was about to give: the flow’s pick, a turn the moderator’s question addressed, or the reply to a call-on line.

run_focus_group_arms() also continues a recorded session. Given a focus_group_result or a FocusGroup as its first argument, and arms that branch at fg_before_turn(t), it reads the record into a new object, sets the engine to the state it was in just before turn t (the guide item and its stage, the turns given on it, the call-on queue, an addressed participant, the question and the interim summary in force, and the turn-taking rule’s state), and runs each arm from there with the configuration supplied. The messages before turn t are the shared prefix. An arm that is NULL regenerates turn t and what follows; under round robin, with a model that answers the same messages the same way, it repeats the record. A human transcript read with focus_group_from_transcript() can be continued once its participants have personas; its later moderator lines are delivered as recorded.

Messages and prompts

The default message_mode = "roleflip" presents an agent’s prior messages as assistant messages and labels other speakers’ messages as user messages. message_mode = "flat" places the transcript in one user message. The selected mode is recorded with the session.

get_default_prompt_templates() returns only templates supported by package operations. Pass named replacements through prompt_templates in FocusGroup$new(). A custom participant template containing {{conversation_history}} or {{persona_description}} uses the flat message path.

Stored data

conversation_log is an ordered list of message records. Each record has a unique integer message_id, the moderator cycle in round, the guide item in item, phase, the agent ID in speaker_id, and the label the agents saw in handle. Moderator and participant messages responding to the same question share a round. A generated message also records what the provider returned about the call, such as its response identifier, finish reason, and token counts, with the provider and model that produced it.

run_focus_group() exposes this log as the transcript data frame in its result. A focus_group_result always has these fields:

When a model call fails during run_simulation(), run_focus_group() raises an error of class focusgroup_incomplete_session. Its partial field is a focus_group_result built from the messages logged before the failure, its phase field names the phase that was running, and its parent field holds the original error. A failed final summary is the exception: it produces a warning and a complete result whose summary is NA.

The class has a concise print method. FocusGroup also has a concise S3 print method for direct R6 objects.

format(result) writes the result as the lines of a session file, one JSON object per line, each one row of a table of the result: writeLines(format(result), "session.fg.jsonl"). The file holds no model configuration, key, endpoint, or runner, and focus_group_from_transcript() reads it back into a new FocusGroup with every setting and record, so that it can be analyzed, replayed with fg_player(), or continued. saveRDS() of a result, by contrast, keeps focus_group, with its configuration and runner. The file system.file("fg-session-spec.md", package = "FocusGroup") describes the format for converting a transcript by hand or with a language model.

Analysis

FocusGroup$analyze() and the descriptive text methods operate on the stored log. analyze_focus_group() collects the principal analyses in a focus_group_analysis with these returned components: basic_stats, topics, tfidf, readability, themes, model_summary, plots, and issues.

Missing optional packages and unsuitable data produce empty components with consistent columns where the result is tabular, and an explanatory row in issues. plots is an empty list when ggplot2 is unavailable. Descriptive analyses run offline. Thematic analysis and model summaries run only when analyze_focus_group() receives an explicit config. A requested model analysis that encounters a provider failure raises the provider condition.

Extending the package

To add a turn-taking rule, subclass ConversationFlow and implement select_next_speaker(). Override update_state_post_selection() when the rule keeps state, and note_moderator_turn() when a moderator line should change it. An extension can construct its class directly; built-in modes remain available through create_conversation_flow().

See the function and class help pages for argument defaults and return values.