--- title: "FocusGroup Architecture" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{FocusGroup Architecture} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include=FALSE} knitr::opts_chunk$set(eval = FALSE) ``` # 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: - `n_participants` is the participant count, excluding the moderator. - `guide` is a named vector or list of phase counts or moderator scripts. - `flow` selects the speaker-selection rule. - `message_mode` selects role-flipped or flat message construction. - `config` is the model configuration, and `admin_config` is the group-level configuration on direct `FocusGroup` construction. - `.runner`, when supplied, is an optional function used instead of live model calls. 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: - `focus_group`: the underlying R6 object. - `transcript`: one row per logged message. - `summary`: the final model summary. - `participants`: a data frame describing the roster. - `usage`: token use in LLMR's `sent`, `rec`, and `total` vocabulary, with `unknown`, the number of calls whose usage was incomplete. Unreported counts are unknown rather than zero, so the totals are lower bounds when `unknown` is positive; `unknown` is `NA` when that number cannot be determined, as after a call that raised an error. - `metadata`: `topic`, `purpose`, `flow`, `message_mode`, `n_participants`, `estimated_calls`, `provider`, `model`, `complete`, and `failure`, with no credentials. `complete` is `FALSE` for a session that stopped early, and `failure` then records the phase and the error message. 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.