| Title: | Benchmarked Silicon Samples for Survey and Experiment Design |
|---|---|
| Description: | Administers survey and experimental instruments to panels of language-model personas, with respondent-level randomization, benchmark comparison against human data, and conjoint estimation from recorded respondent-level profile assignments. |
| Authors: | Ali Sanaei [aut, cre, cph] |
| Maintainer: | Ali Sanaei <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 0.6.1 |
| Built: | 2026-10-13 11:45:10 UTC |
| Source: | https://github.com/asanaei/llmrpanel |
Attaches persona metadata to a decoded data frame. When a persona_frame is
passed to panel_from_data() without a template, demographic fields and
stated answers are rendered separately. A question map supplies the wording
used for answer fields. Plain data frames use the flat key-value rendering.
as_persona_frame(data, questions = NULL, demographics = NULL, answers = NULL)as_persona_frame(data, questions = NULL, demographics = NULL, answers = NULL)
data |
A decoded data frame, one respondent per row. Values should
already be human-readable labels (decode a labelled survey file with, for
example, |
questions |
Optional named character vector mapping column names to the
human question wording, e.g.
|
demographics |
Optional character vector of columns to treat as
demographic background (the rest become stated answers). Defaults to the
common demographic names found in |
answers |
Optional character vector restricting which columns may appear
as stated answers. Defaults to every column that is not a demographic, an
|
data with the persona contract attached and class persona_frame.
panel_from_data(), panel_from_personas(),
LLMR::llm_persona_split().
df <- data.frame( age = c("35-44", "65+"), pid = c("Strong Democrat", "Strong Republican"), ab = c("Always legal", "Never legal")) pf <- as_persona_frame( df, questions = c(pid = "Party identification", ab = "Abortion position"), demographics = "age")df <- data.frame( age = c("35-44", "65+"), pid = c("Strong Democrat", "Strong Republican"), ab = c("Always legal", "Never legal")) pf <- as_persona_frame( df, questions = c(pid = "Party identification", ab = "Abortion position"), demographics = "age")
Average marginal component effects from a conjoint_instrument()
administration: one OLS regression of profile choice on
treatment-coded dummies for all attributes simultaneously, with CR1
cluster-robust standard errors clustered by persona and 95% intervals
on the t distribution with G - 1 degrees of freedom (G personas).
Under uniform, independent profile randomization this is the standard
AMCE estimator. The regression uses the respondent-level profiles recorded
during administration, not the profiles in the initial design table.
conjoint_amce(responses)conjoint_amce(responses)
responses |
A |
A conjoint_amce tibble: attribute, level, estimate, std_error,
ci_lo, ci_hi. Baseline levels (the first level present, in the
design's order) appear with estimate 0 and std_error = NA, so the
table feeds the familiar conjoint plot directly. The ordinary columns
n_profiles, n_respondents, n_dropped_na, and
n_execution_failures record the profile rows used, the respondents
administered, missing task responses dropped, and failed executions.
Hainmueller, Jens, Daniel J. Hopkins, and Teppei Yamamoto (2014). "Causal Inference in Conjoint Analysis: Understanding Multidimensional Choices via Stated Preference Experiments." Political Analysis 22(1), 1-30.
## Not run: set.seed(110) panel <- panel_from_margins(list(group = c(A = .5, B = .5)), n = 6) design <- conjoint_design( list(color = c("blue", "red"), cost = c("low", "high")), n_tasks = 6) instrument <- conjoint_instrument(design) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") r <- panel_administer(panel, instrument, cfg) conjoint_amce(r) ## End(Not run)## Not run: set.seed(110) panel <- panel_from_margins(list(group = c(A = .5, B = .5)), n = 6) design <- conjoint_design( list(color = c("blue", "red"), cost = c("low", "high")), n_tasks = 6) instrument <- conjoint_instrument(design) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") r <- panel_administer(panel, instrument, cfg) conjoint_amce(r) ## End(Not run)
Random profile pairs (or k-tuples) over the supplied attributes, the design for a forced-choice conjoint. Profiles are sampled uniformly and independently per attribute; set a seed beforehand for a reproducible design (the function never sets one). At administration, fresh profiles are drawn independently for every respondent from the same attribute levels.
conjoint_design(attributes, n_tasks = 5L, profiles_per_task = 2L)conjoint_design(attributes, n_tasks = 5L, profiles_per_task = 2L)
attributes |
Named list of level vectors. |
n_tasks |
Tasks per respondent. |
profiles_per_task |
Profiles shown per task (default 2). |
A conjoint_design list with fields profiles, a tibble containing
task, profile, and one column per attribute, and attributes, the
named list of attribute levels. Render it into forced-choice items with
conjoint_instrument() and estimate with conjoint_amce() after
administration. Profiles within a task are distinct when the attribute
space permits them. When it does not, duplicates remain and a warning is
issued.
set.seed(110) conjoint_design( list(price = c("$10", "$20"), speed = c("slow", "fast")), n_tasks = 4)set.seed(110) conjoint_design( list(price = c("$10", "$20"), speed = c("slow", "fast")), n_tasks = 4)
Converts a conjoint_design() into one forced-choice item per task:
each respondent receives a fresh independent profile draw at administration
and is asked to pick one by label.
conjoint_instrument(design, question = "Which profile do you prefer?")conjoint_instrument(design, question = "Which profile do you prefer?")
design |
A |
question |
Question text shown above each task's profiles. |
Only option order is randomized; item order stays fixed so the task ids remain interpretable. Attribute order inside each profile description follows the design's column order. The profiles recorded in the design are not reused across respondents.
A panel_instrument whose items are task-level choice items
(ids task_1, task_2, ...; options "Profile 1", "Profile 2",
...). Each item carries the attribute levels used for its respondent-level
draws, and the instrument's $conjoint field carries the design metadata
for conjoint_amce().
set.seed(110) panel <- panel_from_margins(list(group = c(A = .5, B = .5)), n = 4) design <- conjoint_design( list(economy = c("weak", "strong"), taxes = c("lower", "higher")), n_tasks = 3) instrument <- conjoint_instrument(design, "Which candidate do you prefer?") instrument cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") ## Not run: panel_administer(panel, instrument, cfg) ## End(Not run)set.seed(110) panel <- panel_from_margins(list(group = c(A = .5, B = .5)), n = 4) design <- conjoint_design( list(economy = c("weak", "strong"), taxes = c("lower", "higher")), n_tasks = 3) instrument <- conjoint_instrument(design, "Which candidate do you prefer?") instrument cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") ## Not run: panel_administer(panel, instrument, cfg) ## End(Not run)
Creates one request for each combination of persona and item. The persona
goes in the system message, the item and its options in the user message.
A closed-item reply is matched against the options offered, and anything
unmatched becomes NA. Open items come back verbatim.
panel_administer( panel, instrument, config, max_calls = 5000L, confirm = FALSE, price_table = NULL, tokens_per_call = NULL, .runner = NULL, ... )panel_administer( panel, instrument, config, max_calls = 5000L, confirm = FALSE, price_table = NULL, tokens_per_call = NULL, .runner = NULL, ... )
panel |
A |
instrument |
|
config |
An |
max_calls |
Integer. If the run would make more than this many calls
(personas times items), it stops unless |
confirm |
Logical. Set |
price_table, tokens_per_call
|
Optional. When both are supplied, the
preflight reports a cost figure computed from your own |
.runner |
Optional runner for offline or deterministic testing: a
|
... |
Passed to the runner (e.g. |
A panel_responses object with fields data, panel, instrument,
benchmark, and usage. data is a tibble with persona_id, item_id,
type, item_position (the item's fixed 1-based position in the
instrument; each request is independent, so no questionnaire order is
ever shown to the model), option_order (what this respondent saw,
|-separated),
response (matched option or NA; verbatim text for open items), and
score (1-based scale position for Likert items). score uses the item's
canonical scale rather than its displayed order. response_text,
response_id, success, error_message, finish_reason, model, and
provider retain execution provenance as ordinary columns. Conjoint
administrations also include a profiles list-column. benchmark is
NULL until panel_benchmark() is called. usage retains execution
diagnostics and any token counts or per-call duration; it is NULL when
the runner returned none of those usage fields.
set.seed(110) # the panel draw is local; the model call is not panel <- panel_from_margins(list(party = c(left = .5, right = .5)), n = 6) instrument <- panel_instrument( item_likert("wk4", "A four-day work week would benefit society."), randomize = character(0)) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") ## Not run: resp <- panel_administer(panel, instrument, cfg) resp ## End(Not run) # The `.runner` seam answers without a provider, for tests or for a # deterministic or external respondent: deterministic <- function(experiments, ...) { experiments$response_text <- "agree" experiments } panel_administer(panel, instrument, cfg, .runner = deterministic)set.seed(110) # the panel draw is local; the model call is not panel <- panel_from_margins(list(party = c(left = .5, right = .5)), n = 6) instrument <- panel_instrument( item_likert("wk4", "A four-day work week would benefit society."), randomize = character(0)) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") ## Not run: resp <- panel_administer(panel, instrument, cfg) resp ## End(Not run) # The `.runner` seam answers without a provider, for tests or for a # deterministic or external respondent: deterministic <- function(experiments, ...) { experiments$response_text <- "agree" experiments } panel_administer(panel, instrument, cfg, .runner = deterministic)
Retrieves the batch results and parses them into a panel_responses,
identical in shape to a synchronous panel_administer() run. Responses are
joined to the grid by request id, so the order the provider returns them in
does not matter.
panel_batch_fetch(job)panel_batch_fetch(job)
job |
A |
A panel_responses object.
panel_batch_submit(), panel_batch_status().
## Not run: job <- readRDS("panel_job.rds") responses <- panel_batch_fetch(job) ## End(Not run)## Not run: job <- readRDS("panel_job.rds") responses <- panel_batch_fetch(job) ## End(Not run)
Check the status of a panel batch job
panel_batch_status(job)panel_batch_status(job)
job |
A |
The LLMR batch status (a one-row tibble).
panel_batch_submit(), panel_batch_fetch().
## Not run: job <- readRDS("panel_job.rds") panel_batch_status(job) ## End(Not run)## Not run: job <- readRDS("panel_job.rds") panel_batch_status(job) ## End(Not run)
Submits one request per persona and item to a provider's batch API and returns
a job handle. Use panel_batch_status() to inspect the job and
panel_batch_fetch() to retrieve completed results. Provider services
determine prices and completion times.
panel_batch_submit( panel, instrument, config, state_path = NULL, max_calls = 5000L, confirm = FALSE )panel_batch_submit( panel, instrument, config, state_path = NULL, max_calls = 5000L, confirm = FALSE )
panel |
A |
instrument |
|
config |
An |
state_path |
Optional path; when given the job is also saved there as RDS so it can be fetched from another session. |
max_calls |
Integer. If the run would make more than this many calls, it
stops unless |
confirm |
Logical. Set |
All personas are administered under one config (one model). The handle
carries the survey prompts and rendered persona text. When state_path is
supplied, the API key must be referenced through an environment variable so
its value is not written to the saved state.
A panel_batch_job handle.
panel_batch_fetch(), panel_batch_status(),
panel_administer().
## Not run: panel <- panel_from_margins(list(party = c(left = .5, right = .5)), n = 200) instrument <- panel_instrument(item_likert("wk4", "A four-day work week helps.")) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") job <- panel_batch_submit(panel, instrument, cfg, state_path = "panel_job.rds") # ... later: resp <- panel_batch_fetch(job) ## End(Not run)## Not run: panel <- panel_from_margins(list(party = c(left = .5, right = .5)), n = 200) instrument <- panel_instrument(item_likert("wk4", "A four-day work week helps.")) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") job <- panel_batch_submit(panel, instrument, cfg, state_path = "panel_job.rds") # ... later: resp <- panel_batch_fetch(job) ## End(Not run)
Compares closed-item response shares with human benchmark shares supplied by the user. The result contains deviations for covered item-response pairs, the number of closed items covered, and nonresponse rates by item. The function does not alter responses or adjust response shares. Without a benchmark, response shares describe the configured model under the supplied personas, not a human population.
panel_benchmark(responses, benchmark, benchmark_name = "benchmark")panel_benchmark(responses, benchmark, benchmark_name = "benchmark")
responses |
A |
benchmark |
A data frame with columns |
benchmark_name |
How the source should be cited in reports (e.g.
|
responses with its benchmark field set:
$table (per covered item and response: share_silicon,
share_human, deviation), $nonresponse (nonresponse and execution
failure rates per item),
$items_covered / $items_total, $mean_abs_dev, $max_dev.
## Not run: set.seed(110) panel <- panel_from_margins(list(party = c(left = .5, right = .5)), n = 12) instrument <- panel_instrument(item_choice("plan", "Which plan do you prefer?", c("A", "B"))) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") r <- panel_administer(panel, instrument, cfg) r bench <- data.frame(item_id = "plan", response = c("A", "B"), share = c(.5, .5)) panel_benchmark(r, bench, "toy human study") ## End(Not run)## Not run: set.seed(110) panel <- panel_from_margins(list(party = c(left = .5, right = .5)), n = 12) instrument <- panel_instrument(item_choice("plan", "Which plan do you prefer?", c("A", "B"))) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") r <- panel_administer(panel, instrument, cfg) r bench <- data.frame(item_id = "plan", response = c("A", "B"), share = c(.5, .5)) panel_benchmark(r, bench, "toy human study") ## End(Not run)
Counts execution and parse failures by item. For closed items administered with randomized option order, it also applies a chi-squared test to the chosen response and the option shown first. The test does not use the full option permutation.
panel_bias_audit(responses)panel_bias_audit(responses)
responses |
A |
A tibble: item_id, n, parse_failures, execution_failures,
order_effect_p (the first-option chi-squared p-value; NA when order was
not randomized or cells are too sparse).
panel <- panel_from_margins(list(group = c(A = 1)), n = 4) instrument <- panel_instrument( item_choice("pick", "Choose one.", c("A", "B")), randomize = character(0)) config <- LLMR::llm_config("groq", "example-model") runner <- function(experiments, ...) { experiments$response_text <- "A" experiments$success <- TRUE experiments } responses <- panel_administer(panel, instrument, config, .runner = runner) panel_bias_audit(responses)panel <- panel_from_margins(list(group = c(A = 1)), n = 4) instrument <- panel_instrument( item_choice("pick", "Choose one.", c("A", "B")), randomize = character(0)) config <- LLMR::llm_config("groq", "example-model") runner <- function(experiments, ...) { experiments$response_text <- "A" experiments$success <- TRUE experiments } responses <- panel_administer(panel, instrument, config, .runner = runner) panel_bias_audit(responses)
Samples rows from a data frame with replacement, which preserves the
joint distribution of the selected attributes, and renders each sampled
row as a persona. This is the joint-distribution counterpart of
panel_from_margins(), which samples attributes independently. The
margins the report cites are computed from the source data, one
prop.table(table()) per selected column.
panel_from_data( data, n, persona_template = NULL, columns = NULL, weights = NULL )panel_from_data( data, n, persona_template = NULL, columns = NULL, weights = NULL )
data |
A data frame, one row per source case. |
n |
Panel size. |
persona_template |
Text with |
columns |
Attribute columns to keep. Defaults to every column
except the |
weights |
Optional name of a single column of nonnegative sampling weights (rows are drawn with probability proportional to it). |
For a reproducible panel, set a seed before calling (the function never sets one itself).
A silicon_panel: a tibble with persona_id, the selected
attribute columns, and persona.
set.seed(110) src <- data.frame( education = c("college", "college", "no college", "no college"), income = c("high", "high", "low", "low"), weight = c(2, 2, 1, 1)) panel_from_data(src, n = 10, columns = c("education", "income"), weights = "weight", persona_template = "A {education} respondent earning {income}.")set.seed(110) src <- data.frame( education = c("college", "college", "no college", "no college"), income = c("high", "high", "low", "low"), weight = c(2, 2, 1, 1)) panel_from_data(src, n = 10, columns = c("education", "income"), weights = "weight", persona_template = "A {education} respondent earning {income}.")
Samples n personas with attributes drawn independently from the
supplied margins, and renders each persona's text from a template.
Attributes are sampled independently, so the result does not preserve their
joint distribution. Use panel_from_data() to sample complete microdata
rows.
panel_from_margins(margins, n, persona_template = NULL)panel_from_margins(margins, n, persona_template = NULL)
margins |
A named list; each element a named probability vector,
e.g. |
n |
Panel size. |
persona_template |
Text with |
For a reproducible panel, set a seed before calling (the function never sets one itself).
A silicon_panel: a tibble with persona_id, one column per
attribute, and persona (the rendered text).
set.seed(110) panel <- panel_from_margins( list(cohort = c(young = .3, middle = .45, older = .25), party = c(left = .45, right = .45, independent = .10)), n = 50, persona_template = "A {cohort} voter who leans {party}." ) panelset.seed(110) panel <- panel_from_margins( list(cohort = c(young = .3, middle = .45, older = .25), party = c(left = .45, right = .45, independent = .10)), n = 50, persona_template = "A {cohort} voter who leans {party}." ) panel
Turns rows of a persona data frame (one respondent per row, demographics plus
survey or attitude answers) into a silicon_panel whose personas can be
administered survey items. It is built for frames following the LLMR persona
contract, such as LLMR::anes_2024_personas: the demographics and the answers
are read with LLMR::llm_persona_split() (so answers are keyed by their
question wording when the frame carries a dictionary), and each persona is
rendered as a person to answer in character.
panel_from_personas(data, n = NULL, rows = NULL, weights = NULL)panel_from_personas(data, n = NULL, rows = NULL, weights = NULL)
data |
A persona data frame, such as |
n |
Optional panel size. With |
rows |
Optional row selector: an integer or logical vector, or a predicate
|
weights |
Optional survey weights for the draw: a column name in |
Unlike panel_from_margins() and panel_from_data(), this constructor keeps
each selected respondent's answers together. The margins attribute contains
the demographic distribution of the selected rows.
For a reproducible draw, set a seed before calling (the function never sets one itself).
A silicon_panel: a tibble with persona_id, the demographic columns,
and persona.
LLMR::anes_2024_personas, panel_administer().
if (requireNamespace("LLMR", quietly = TRUE)) { set.seed(110) panel <- panel_from_personas(LLMR::anes_2024_personas, n = 8) }if (requireNamespace("LLMR", quietly = TRUE)) { set.seed(110) panel <- panel_from_personas(LLMR::anes_2024_personas, n = 8) }
LLMRpanel provides LLMR::diagnostics(), LLMR::report(), and plot()
methods for panel_responses objects. It provides tibble::as_tibble()
methods for panel_responses and silicon_panel objects.
Assemble an instrument
panel_instrument(items, randomize = "option_order")panel_instrument(items, randomize = "option_order")
items |
A list of panel_items ( |
randomize |
Which orders to randomize per respondent. The only
implemented value is |
An object of class panel_instrument.
panel_instrument(list( item_likert("trust", "How much do you trust the city council?"), item_open("reason", "What is the main reason for your answer?")))panel_instrument(list( item_likert("trust", "How much do you trust the city council?"), item_open("reason", "What is the main reason for your answer?")))
Three item types cover most quantitative instruments: a Likert item (an
agree-disagree battery row), a forced choice, and an open item (free
text, returned verbatim). Likert responses also get a numeric score
(position on the scale as given, 1-based).
item_likert( id, text, scale = c("strongly disagree", "disagree", "neutral", "agree", "strongly agree") ) item_choice(id, text, options) item_open(id, text)item_likert( id, text, scale = c("strongly disagree", "disagree", "neutral", "agree", "strongly agree") ) item_choice(id, text, options) item_open(id, text)
id |
Item identifier (unique within an instrument). |
text |
The question text. |
scale |
For |
options |
For |
An object of class panel_item.
item_likert("wk4", "A four-day work week would benefit society.") item_choice("vote", "Which proposal do you prefer?", c("A", "B")) item_open("why", "In one sentence, why?")item_likert("wk4", "A four-day work week would benefit society.") item_choice("vote", "Which proposal do you prefer?", c("A", "B")) item_open("why", "In one sentence, why?")
Summarizes token and outcome diagnostics recorded by panel_administer() or
panel_batch_fetch(). The diagnostics are stored in the usage
field of a panel_responses object and summarized by LLMR::llm_usage().
Model and provider remain in the returned frame. A supplied price_table
adds a cost column. The package contains no price table. When the runner
records per-call duration, its sum is returned as duration_s.
panel_usage(responses, price_table = NULL)panel_usage(responses, price_table = NULL)
responses |
A |
price_table |
Optional price table passed to |
A one-row usage tibble, or a typed empty tibble when the runner returned no recorded usage fields.
panel_administer(), LLMR::llm_usage().
panel <- panel_from_margins(list(group = c(A = 1)), n = 2) instrument <- panel_instrument( item_choice("pick", "Choose one.", c("A", "B")), randomize = character(0)) config <- LLMR::llm_config("groq", "example-model") runner <- function(experiments, ...) { experiments$response_text <- "A" experiments$sent_tokens <- 4L experiments$rec_tokens <- 1L experiments$total_tokens <- 5L experiments$success <- TRUE experiments } responses <- panel_administer(panel, instrument, config, .runner = runner) panel_usage(responses)panel <- panel_from_margins(list(group = c(A = 1)), n = 2) instrument <- panel_instrument( item_choice("pick", "Choose one.", c("A", "B")), randomize = character(0)) config <- LLMR::llm_config("groq", "example-model") runner <- function(experiments, ...) { experiments$response_text <- "A" experiments$sent_tokens <- 4L experiments$rec_tokens <- 1L experiments$total_tokens <- 5L experiments$success <- TRUE experiments } responses <- panel_administer(panel, instrument, config, .runner = runner) panel_usage(responses)
Plots the comparison recorded by panel_benchmark(). Each covered response
level has one point for the panel share and one for the benchmark share,
joined by a segment. Response levels follow the instrument's option order,
and items appear in separate panels. The method requires a benchmark
record.
## S3 method for class 'panel_responses' plot(x, ...)## S3 method for class 'panel_responses' plot(x, ...)
x |
A |
... |
Ignored; reserved for generic dispatch. |
A ggplot object.
if (requireNamespace("ggplot2", quietly = TRUE)) { set.seed(110) panel <- panel_from_margins(list(party = c(left = .5, right = .5)), n = 12, persona_template = "A voter who leans {party}.") instrument <- panel_instrument( item_choice("plan", "Which plan do you prefer?", c("Plan A", "Plan B"))) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") by_party <- function(experiments, ...) { experiments$response_text <- ifelse( grepl("leans left", vapply(experiments$messages, `[[`, "", "system")), "Plan A", "Plan B") experiments } r <- panel_administer(panel, instrument, cfg, .runner = by_party) bench <- data.frame(item_id = "plan", response = c("Plan A", "Plan B"), share = c(.55, .45)) plot(panel_benchmark(r, bench, "city survey 2025")) }if (requireNamespace("ggplot2", quietly = TRUE)) { set.seed(110) panel <- panel_from_margins(list(party = c(left = .5, right = .5)), n = 12, persona_template = "A voter who leans {party}.") instrument <- panel_instrument( item_choice("plan", "Which plan do you prefer?", c("Plan A", "Plan B"))) cfg <- LLMR::llm_config("groq", "openai/gpt-oss-20b") by_party <- function(experiments, ...) { experiments$response_text <- ifelse( grepl("leans left", vapply(experiments$messages, `[[`, "", "system")), "Plan A", "Plan B") experiments } r <- panel_administer(panel, instrument, cfg, .runner = by_party) bench <- data.frame(item_id = "plan", response = c("Plan A", "Plan B"), share = c(.55, .45)) plot(panel_benchmark(r, bench, "city survey 2025")) }
Starts a Shiny application that builds a persona panel, administers a choice item or conjoint instrument, and presents the package's diagnostics and design analyses. Choice-item response shares can be compared with an optional benchmark. The application can download the responses and report in a zip file, with the benchmark table when one is available.
run_panel_studio(...)run_panel_studio(...)
... |
Passed to |
The GUI is optional. It needs the suggested packages shiny, bslib, DT,
and LLMR.shiny; install them first. Keys are read from environment variables
only, never pasted into the app; a deterministic demo mode runs offline.
Invisibly, the value of shiny::runApp(); called for the side effect
of starting the app.
if (interactive() && requireNamespace("shiny", quietly = TRUE) && requireNamespace("LLMR.shiny", quietly = TRUE)) { run_panel_studio() }if (interactive() && requireNamespace("shiny", quietly = TRUE) && requireNamespace("LLMR.shiny", quietly = TRUE)) { run_panel_studio() }