althing 1.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- althing/__init__.py +46 -0
- althing/__main__.py +9 -0
- althing/__version__.py +1 -0
- althing/_compat_env.py +27 -0
- althing/_runners.py +953 -0
- althing/agent_assets/__init__.py +3 -0
- althing/agent_assets/commands/althing-poll.md +75 -0
- althing/agent_assets/skills/concept-test/SKILL.md +72 -0
- althing/agent_assets/skills/focus-group/SKILL.md +77 -0
- althing/agent_assets/skills/name-test/SKILL.md +62 -0
- althing/agent_assets/skills/pricing-probe/SKILL.md +75 -0
- althing/agent_assets/skills/survey-prescreen/SKILL.md +76 -0
- althing/aggregation.py +240 -0
- althing/analysis/__init__.py +46 -0
- althing/analysis/distribution.py +323 -0
- althing/analysis/inspect.py +586 -0
- althing/analysis/subgroup.py +168 -0
- althing/analysis/subgroup_cli.py +598 -0
- althing/analyze.py +839 -0
- althing/attachments/__init__.py +77 -0
- althing/attachments/filter.py +67 -0
- althing/attachments/models.py +50 -0
- althing/attachments/pdf.py +537 -0
- althing/attachments/store.py +111 -0
- althing/calibration.py +239 -0
- althing/checkpoint.py +763 -0
- althing/cli/__init__.py +1 -0
- althing/cli/commands.py +8080 -0
- althing/cli/mcp_install.py +487 -0
- althing/cli/output.py +106 -0
- althing/cli/parser.py +1706 -0
- althing/cli/progress.py +93 -0
- althing/cli/repl.py +122 -0
- althing/cli/slash.py +197 -0
- althing/conditions.py +261 -0
- althing/convergence.py +1091 -0
- althing/cost.py +950 -0
- althing/cost_summary.py +452 -0
- althing/credentials.py +311 -0
- althing/data/__init__.py +8 -0
- althing/data/synthbench-snapshot.json +120 -0
- althing/diff.py +331 -0
- althing/domains.py +284 -0
- althing/ensemble.py +922 -0
- althing/fetch/__init__.py +38 -0
- althing/fetch/cache.py +360 -0
- althing/fetch/ladder.py +437 -0
- althing/fetch/lower.py +199 -0
- althing/fetch/perimeter.py +494 -0
- althing/instrument.py +751 -0
- althing/integrations/__init__.py +21 -0
- althing/integrations/composio.py +345 -0
- althing/llm/__init__.py +32 -0
- althing/llm/aliases.py +138 -0
- althing/llm/capabilities.py +86 -0
- althing/llm/client.py +267 -0
- althing/llm/errors.py +162 -0
- althing/llm/fast_default.py +71 -0
- althing/llm/models.py +340 -0
- althing/llm/providers/__init__.py +18 -0
- althing/llm/providers/_anthropic_format.py +281 -0
- althing/llm/providers/_openai_format.py +381 -0
- althing/llm/providers/anthropic.py +172 -0
- althing/llm/providers/base.py +60 -0
- althing/llm/providers/gemini.py +107 -0
- althing/llm/providers/openai_compat.py +102 -0
- althing/llm/providers/openrouter.py +357 -0
- althing/llm/providers/xai.py +98 -0
- althing/llm/retry.py +113 -0
- althing/logging_config.py +80 -0
- althing/main.py +308 -0
- althing/mcp/__init__.py +7 -0
- althing/mcp/compat.py +110 -0
- althing/mcp/data.py +700 -0
- althing/mcp/sampling.py +380 -0
- althing/mcp/server.py +3371 -0
- althing/metadata.py +214 -0
- althing/metadata_migrations.py +70 -0
- althing/orchestrator.py +2119 -0
- althing/pack_diff.py +271 -0
- althing/packs/__init__.py +3 -0
- althing/packs/ai-eval-buyers.yaml +247 -0
- althing/packs/broad-professionals.yaml +251 -0
- althing/packs/developer.yaml +271 -0
- althing/packs/enterprise-ai-buyers.yaml +245 -0
- althing/packs/enterprise-buyer.yaml +252 -0
- althing/packs/general-consumer.yaml +271 -0
- althing/packs/healthcare-patient.yaml +271 -0
- althing/packs/instruments/__init__.py +1 -0
- althing/packs/instruments/churn-diagnosis.yaml +73 -0
- althing/packs/instruments/feature-prioritization.yaml +75 -0
- althing/packs/instruments/general-survey.yaml +46 -0
- althing/packs/instruments/happiness-probe.yaml +41 -0
- althing/packs/instruments/landing-page-comprehension.yaml +75 -0
- althing/packs/instruments/market-research.yaml +50 -0
- althing/packs/instruments/name-test.yaml +74 -0
- althing/packs/instruments/pricing-discovery.yaml +80 -0
- althing/packs/instruments/product-feedback.yaml +46 -0
- althing/packs/job-seekers.yaml +188 -0
- althing/packs/market-research-critics.yaml +217 -0
- althing/packs/product-research.yaml +248 -0
- althing/packs/recruiters-talent.yaml +264 -0
- althing/packs/skeptical-executives.yaml +227 -0
- althing/packs/startup-founder.yaml +242 -0
- althing/packs/students.yaml +352 -0
- althing/persistence.py +510 -0
- althing/perturbation.py +333 -0
- althing/plugins/__init__.py +34 -0
- althing/plugins/hooks.py +159 -0
- althing/plugins/lint.py +352 -0
- althing/plugins/manager.py +176 -0
- althing/plugins/manifest.py +100 -0
- althing/plugins/registry.py +66 -0
- althing/poll_summary.py +1195 -0
- althing/preflight.py +155 -0
- althing/profiles.py +264 -0
- althing/prompts.py +311 -0
- althing/py.typed +0 -0
- althing/question_budget.py +140 -0
- althing/registry/__init__.py +178 -0
- althing/registry/cache.py +223 -0
- althing/registry/fetch.py +104 -0
- althing/registry/github.py +212 -0
- althing/reporting/__init__.py +17 -0
- althing/reporting/loader.py +69 -0
- althing/reporting/markdown.py +450 -0
- althing/response_coercion.py +161 -0
- althing/routing.py +132 -0
- althing/runtime.py +552 -0
- althing/schemas/__init__.py +53 -0
- althing/schemas/v1.0.0.json +161 -0
- althing/sdk.py +1458 -0
- althing/stats.py +1522 -0
- althing/structured/__init__.py +5 -0
- althing/structured/models.py +149 -0
- althing/structured/output.py +376 -0
- althing/structured/retry.py +170 -0
- althing/structured/schemas.py +127 -0
- althing/structured/validate.py +303 -0
- althing/structured/verdict.py +146 -0
- althing/synthbench.py +768 -0
- althing/synthbench_submit.py +582 -0
- althing/synthesis.py +1529 -0
- althing/synthesis_recovery.py +467 -0
- althing/templates.py +179 -0
- althing/text_width.py +126 -0
- althing-1.7.0.dist-info/METADATA +1473 -0
- althing-1.7.0.dist-info/RECORD +156 -0
- althing-1.7.0.dist-info/WHEEL +5 -0
- althing-1.7.0.dist-info/entry_points.txt +3 -0
- althing-1.7.0.dist-info/licenses/LICENSE +21 -0
- althing-1.7.0.dist-info/top_level.txt +3 -0
- synth_panel/__init__.py +60 -0
- synth_panel/__main__.py +17 -0
- synthpanel/__init__.py +60 -0
- synthpanel/__main__.py +17 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""CLI-level subgroup analysis for ``althing analyze subgroup``.
|
|
2
|
+
|
|
3
|
+
Takes a saved panel result + a persona YAML/pack (because results store
|
|
4
|
+
persona names, not full dicts) and produces per-subgroup distributions
|
|
5
|
+
for each question that has a ``response_schema``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from althing.analysis.distribution import InvalidResponseSchemaError
|
|
15
|
+
from althing.analysis.subgroup import UnknownPersonaFieldError, subgroup_breakdown
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"AGE_DECADE_5Y_BANDS",
|
|
19
|
+
"AGE_DECADE_BANDS",
|
|
20
|
+
"FieldBreakdown",
|
|
21
|
+
"QuestionSubgroupResult",
|
|
22
|
+
"SubgroupAnalysisResult",
|
|
23
|
+
"analyze_subgroup",
|
|
24
|
+
"format_subgroup_json",
|
|
25
|
+
"format_subgroup_text",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Age auto-bin strategies
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
AGE_DECADE_BANDS: list[tuple[int, int]] = [
|
|
33
|
+
(18, 27),
|
|
34
|
+
(28, 37),
|
|
35
|
+
(38, 47),
|
|
36
|
+
(48, 57),
|
|
37
|
+
(58, 67),
|
|
38
|
+
(68, 77),
|
|
39
|
+
(78, 99),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
AGE_DECADE_5Y_BANDS: list[tuple[int, int]] = [
|
|
43
|
+
(18, 22),
|
|
44
|
+
(23, 27),
|
|
45
|
+
(28, 32),
|
|
46
|
+
(33, 37),
|
|
47
|
+
(38, 42),
|
|
48
|
+
(43, 47),
|
|
49
|
+
(48, 52),
|
|
50
|
+
(53, 57),
|
|
51
|
+
(58, 62),
|
|
52
|
+
(63, 67),
|
|
53
|
+
(68, 72),
|
|
54
|
+
(73, 99),
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
# Maps special --by token → (actual persona field, age_bands or None)
|
|
58
|
+
_AUTO_BIN_STRATEGIES: dict[str, tuple[str, list[tuple[int, int]] | None]] = {
|
|
59
|
+
"age_decade": ("age", AGE_DECADE_BANDS),
|
|
60
|
+
"age_decade_5y": ("age", AGE_DECADE_5Y_BANDS),
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_SPARSE_N = 3 # subgroups with n < this trigger a warning
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Result dataclasses
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class FieldBreakdown:
|
|
73
|
+
field: str
|
|
74
|
+
n_buckets: int
|
|
75
|
+
buckets: list[dict[str, Any]]
|
|
76
|
+
effect_size: float | None
|
|
77
|
+
effect_size_type: str # "eta-squared" | "cramers-v" | "density-ratio" | "none"
|
|
78
|
+
p_value: float | None
|
|
79
|
+
warnings: list[str]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class QuestionSubgroupResult:
|
|
84
|
+
question_index: int
|
|
85
|
+
question_text: str
|
|
86
|
+
response_schema_type: str
|
|
87
|
+
n_responses: int
|
|
88
|
+
by_field: list[FieldBreakdown]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class SubgroupAnalysisResult:
|
|
93
|
+
result_id: str
|
|
94
|
+
fields: list[str]
|
|
95
|
+
per_question: list[QuestionSubgroupResult]
|
|
96
|
+
global_warnings: list[str]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Effect size helpers
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _eta_squared(groups: list[list[float]]) -> float | None:
|
|
105
|
+
"""One-way ANOVA eta-squared from grouped numeric values."""
|
|
106
|
+
all_vals = [v for g in groups for v in g]
|
|
107
|
+
n = len(all_vals)
|
|
108
|
+
if n == 0:
|
|
109
|
+
return None
|
|
110
|
+
grand_mean = sum(all_vals) / n
|
|
111
|
+
ss_total = sum((v - grand_mean) ** 2 for v in all_vals)
|
|
112
|
+
if ss_total == 0.0:
|
|
113
|
+
return 0.0
|
|
114
|
+
ss_between = sum(len(g) * ((sum(g) / len(g)) - grand_mean) ** 2 for g in groups if g)
|
|
115
|
+
return round(ss_between / ss_total, 4)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cramers_v_contingency(rows: list[list[str]]) -> tuple[float, float, int]:
|
|
119
|
+
"""Cramer's V for a groups x categories contingency table.
|
|
120
|
+
|
|
121
|
+
``rows[i]`` is the list of categorical responses in group i.
|
|
122
|
+
Returns (cramers_v, chi2, df).
|
|
123
|
+
"""
|
|
124
|
+
categories: list[str] = sorted({c for row in rows for c in row})
|
|
125
|
+
n_cols = len(categories)
|
|
126
|
+
n_rows = len(rows)
|
|
127
|
+
if n_rows < 2 or n_cols < 2:
|
|
128
|
+
return 0.0, 0.0, 0
|
|
129
|
+
|
|
130
|
+
cat_idx = {c: j for j, c in enumerate(categories)}
|
|
131
|
+
table = [[0] * n_cols for _ in range(n_rows)]
|
|
132
|
+
for i, row in enumerate(rows):
|
|
133
|
+
for val in row:
|
|
134
|
+
j = cat_idx.get(val)
|
|
135
|
+
if j is not None:
|
|
136
|
+
table[i][j] += 1
|
|
137
|
+
|
|
138
|
+
row_totals = [sum(table[i]) for i in range(n_rows)]
|
|
139
|
+
col_totals = [sum(table[i][j] for i in range(n_rows)) for j in range(n_cols)]
|
|
140
|
+
n_total = sum(row_totals)
|
|
141
|
+
if n_total == 0:
|
|
142
|
+
return 0.0, 0.0, 0
|
|
143
|
+
|
|
144
|
+
chi2 = 0.0
|
|
145
|
+
for i in range(n_rows):
|
|
146
|
+
if row_totals[i] == 0:
|
|
147
|
+
continue
|
|
148
|
+
for j in range(n_cols):
|
|
149
|
+
if col_totals[j] == 0:
|
|
150
|
+
continue
|
|
151
|
+
exp = (row_totals[i] * col_totals[j]) / n_total
|
|
152
|
+
diff = table[i][j] - exp
|
|
153
|
+
chi2 += diff * diff / exp
|
|
154
|
+
|
|
155
|
+
df = (n_rows - 1) * (n_cols - 1)
|
|
156
|
+
if df <= 0 or n_total <= 0:
|
|
157
|
+
return 0.0, chi2, df
|
|
158
|
+
|
|
159
|
+
denom = n_total * min(n_rows - 1, n_cols - 1)
|
|
160
|
+
if denom <= 0:
|
|
161
|
+
return 0.0, chi2, df
|
|
162
|
+
|
|
163
|
+
v = math.sqrt(chi2 / denom)
|
|
164
|
+
return round(max(0.0, min(1.0, v)), 4), round(chi2, 4), df
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _chi2_sf(x: float, df: int) -> float:
|
|
168
|
+
"""Survival function (1 - CDF) of the chi-squared distribution."""
|
|
169
|
+
from althing.stats import _chi2_sf as _stats_chi2_sf # type: ignore[attr-defined]
|
|
170
|
+
|
|
171
|
+
return _stats_chi2_sf(x, df)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Response value extraction
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _response_value(resp: dict[str, Any]) -> Any:
|
|
180
|
+
"""Extract the typed response value from a stored response dict."""
|
|
181
|
+
if resp.get("structured"):
|
|
182
|
+
return resp.get("response")
|
|
183
|
+
extraction = resp.get("extraction")
|
|
184
|
+
if extraction is not None:
|
|
185
|
+
return extraction
|
|
186
|
+
return resp.get("response")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _flatten_structured_value(val: Any, schema_type: str) -> Any:
|
|
190
|
+
"""Try to unwrap a dict-wrapped structured response to a scalar/list."""
|
|
191
|
+
if not isinstance(val, dict):
|
|
192
|
+
return val
|
|
193
|
+
if schema_type == "scale":
|
|
194
|
+
for key in ("score", "rating", "value", "answer", "response"):
|
|
195
|
+
v = val.get(key)
|
|
196
|
+
if isinstance(v, (int, float)):
|
|
197
|
+
return v
|
|
198
|
+
elif schema_type == "enum":
|
|
199
|
+
for key in ("option", "answer", "value", "choice", "response"):
|
|
200
|
+
v = val.get(key)
|
|
201
|
+
if isinstance(v, str):
|
|
202
|
+
return v
|
|
203
|
+
elif schema_type == "tagged_themes":
|
|
204
|
+
for key in ("themes", "tags", "values", "response"):
|
|
205
|
+
v = val.get(key)
|
|
206
|
+
if isinstance(v, list):
|
|
207
|
+
return v
|
|
208
|
+
return val
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# Core analysis
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _resolve_field_and_bands(by_token: str) -> tuple[str, list[tuple[int, int]] | None]:
|
|
217
|
+
"""Map a --by token to (persona_field, age_bands or None)."""
|
|
218
|
+
if by_token in _AUTO_BIN_STRATEGIES:
|
|
219
|
+
return _AUTO_BIN_STRATEGIES[by_token]
|
|
220
|
+
return by_token, None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _build_field_breakdown(
|
|
224
|
+
by_token: str,
|
|
225
|
+
responses_per_panelist: list[Any],
|
|
226
|
+
personas: list[dict[str, Any]],
|
|
227
|
+
response_schema: dict[str, Any],
|
|
228
|
+
) -> FieldBreakdown:
|
|
229
|
+
persona_field, age_bands = _resolve_field_and_bands(by_token)
|
|
230
|
+
schema_type = response_schema.get("type", "text")
|
|
231
|
+
warnings: list[str] = []
|
|
232
|
+
|
|
233
|
+
try:
|
|
234
|
+
breakdown = subgroup_breakdown(
|
|
235
|
+
responses_per_panelist,
|
|
236
|
+
personas,
|
|
237
|
+
field=persona_field,
|
|
238
|
+
response_schema=response_schema,
|
|
239
|
+
age_bands=age_bands,
|
|
240
|
+
)
|
|
241
|
+
except UnknownPersonaFieldError:
|
|
242
|
+
return FieldBreakdown(
|
|
243
|
+
field=by_token,
|
|
244
|
+
n_buckets=0,
|
|
245
|
+
buckets=[],
|
|
246
|
+
effect_size=None,
|
|
247
|
+
effect_size_type="none",
|
|
248
|
+
p_value=None,
|
|
249
|
+
warnings=[f"Persona field '{persona_field}' not found in any persona."],
|
|
250
|
+
)
|
|
251
|
+
except InvalidResponseSchemaError as exc:
|
|
252
|
+
return FieldBreakdown(
|
|
253
|
+
field=by_token,
|
|
254
|
+
n_buckets=0,
|
|
255
|
+
buckets=[],
|
|
256
|
+
effect_size=None,
|
|
257
|
+
effect_size_type="none",
|
|
258
|
+
p_value=None,
|
|
259
|
+
warnings=[f"Cannot analyze question with response_schema: {exc}"],
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
buckets = breakdown["buckets"]
|
|
263
|
+
|
|
264
|
+
# Sparse subgroup warnings
|
|
265
|
+
sparse = [b["label"] for b in buckets if b["n"] < _SPARSE_N and b["n"] > 0]
|
|
266
|
+
if sparse:
|
|
267
|
+
warnings.append(
|
|
268
|
+
f"Sparse subgroup(s) (n < {_SPARSE_N}): {', '.join(sparse)}. "
|
|
269
|
+
"Effect-size and significance tests suppressed for sparse groups."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
non_sparse_buckets = [b for b in buckets if b["n"] >= _SPARSE_N]
|
|
273
|
+
suppress_stats = len(non_sparse_buckets) < 2
|
|
274
|
+
|
|
275
|
+
effect_size: float | None = None
|
|
276
|
+
effect_size_type = "none"
|
|
277
|
+
p_value: float | None = None
|
|
278
|
+
|
|
279
|
+
if not suppress_stats:
|
|
280
|
+
if schema_type == "scale":
|
|
281
|
+
effect_size, p_value = _scale_effect_size(non_sparse_buckets, response_schema)
|
|
282
|
+
effect_size_type = "eta-squared"
|
|
283
|
+
elif schema_type == "enum":
|
|
284
|
+
effect_size, p_value = _enum_effect_size(non_sparse_buckets)
|
|
285
|
+
effect_size_type = "cramers-v"
|
|
286
|
+
elif schema_type == "tagged_themes":
|
|
287
|
+
effect_size_type = "density-ratio"
|
|
288
|
+
# No single effect-size for multi-tag; leave None (per-tag density in buckets)
|
|
289
|
+
|
|
290
|
+
return FieldBreakdown(
|
|
291
|
+
field=by_token,
|
|
292
|
+
n_buckets=breakdown["n_buckets"],
|
|
293
|
+
buckets=buckets,
|
|
294
|
+
effect_size=effect_size,
|
|
295
|
+
effect_size_type=effect_size_type,
|
|
296
|
+
p_value=p_value,
|
|
297
|
+
warnings=warnings,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _scale_effect_size(
|
|
302
|
+
non_sparse_buckets: list[dict[str, Any]],
|
|
303
|
+
response_schema: dict[str, Any],
|
|
304
|
+
) -> tuple[float | None, float | None]:
|
|
305
|
+
groups: list[list[float]] = []
|
|
306
|
+
for bucket in non_sparse_buckets:
|
|
307
|
+
dist = bucket.get("distribution", {})
|
|
308
|
+
vals: list[float] = []
|
|
309
|
+
for entry in dist.get("distribution", []):
|
|
310
|
+
v = entry.get("value")
|
|
311
|
+
c = entry.get("count", 0)
|
|
312
|
+
if isinstance(v, (int, float)) and c > 0:
|
|
313
|
+
vals.extend([float(v)] * c)
|
|
314
|
+
groups.append(vals)
|
|
315
|
+
eta_sq = _eta_squared(groups)
|
|
316
|
+
return eta_sq, None # F-test p-value requires F-distribution; omitted
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _enum_effect_size(
|
|
320
|
+
non_sparse_buckets: list[dict[str, Any]],
|
|
321
|
+
) -> tuple[float | None, float | None]:
|
|
322
|
+
rows: list[list[str]] = []
|
|
323
|
+
for bucket in non_sparse_buckets:
|
|
324
|
+
dist = bucket.get("distribution", {})
|
|
325
|
+
row: list[str] = []
|
|
326
|
+
for entry in dist.get("distribution", []):
|
|
327
|
+
opt = entry.get("option", "")
|
|
328
|
+
cnt = entry.get("count", 0)
|
|
329
|
+
row.extend([opt] * cnt)
|
|
330
|
+
rows.append(row)
|
|
331
|
+
v, chi2, df = _cramers_v_contingency(rows)
|
|
332
|
+
p_value: float | None = None
|
|
333
|
+
if df > 0 and chi2 > 0:
|
|
334
|
+
import contextlib
|
|
335
|
+
|
|
336
|
+
with contextlib.suppress(Exception):
|
|
337
|
+
p_value = round(_chi2_sf(chi2, df), 4)
|
|
338
|
+
return v if v > 0 else None, p_value
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
# Public entry point
|
|
343
|
+
# ---------------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def analyze_subgroup(
|
|
347
|
+
data: dict[str, Any],
|
|
348
|
+
personas: list[dict[str, Any]],
|
|
349
|
+
by_tokens: list[str],
|
|
350
|
+
*,
|
|
351
|
+
question_indices: list[int] | None = None,
|
|
352
|
+
) -> SubgroupAnalysisResult:
|
|
353
|
+
"""Run subgroup analysis on a saved panel result.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
data: Panel result dict (from result JSON file or MCP data).
|
|
357
|
+
personas: Full persona dicts to match against result by name.
|
|
358
|
+
by_tokens: List of persona fields (or auto-bin tokens like
|
|
359
|
+
``age_decade``) to group by.
|
|
360
|
+
question_indices: Optional filter — only analyze these question
|
|
361
|
+
indices (0-based). None means all questions.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
SubgroupAnalysisResult with per-question breakdowns.
|
|
365
|
+
"""
|
|
366
|
+
result_id = data.get("id", "unknown")
|
|
367
|
+
raw_results = data.get("results") or []
|
|
368
|
+
saved_questions = data.get("questions") or []
|
|
369
|
+
global_warnings: list[str] = []
|
|
370
|
+
|
|
371
|
+
# Build name → persona dict lookup
|
|
372
|
+
persona_by_name: dict[str, dict[str, Any]] = {p.get("name", ""): p for p in personas if p.get("name")}
|
|
373
|
+
|
|
374
|
+
# Align panelists with their full persona dicts
|
|
375
|
+
ordered_personas: list[dict[str, Any]] = []
|
|
376
|
+
ordered_raw_responses: list[list[dict[str, Any]]] = []
|
|
377
|
+
|
|
378
|
+
matched = 0
|
|
379
|
+
for entry in raw_results:
|
|
380
|
+
name = entry.get("persona", "")
|
|
381
|
+
persona_dict = persona_by_name.get(name)
|
|
382
|
+
if persona_dict is None:
|
|
383
|
+
global_warnings.append(f"Persona '{name}' not found in provided --personas file. Skipped.")
|
|
384
|
+
continue
|
|
385
|
+
matched += 1
|
|
386
|
+
ordered_personas.append(persona_dict)
|
|
387
|
+
ordered_raw_responses.append(entry.get("responses") or [])
|
|
388
|
+
|
|
389
|
+
if not matched:
|
|
390
|
+
global_warnings.append(
|
|
391
|
+
"No panelist names matched the provided personas. Check that --personas is the same file used for the run."
|
|
392
|
+
)
|
|
393
|
+
return SubgroupAnalysisResult(
|
|
394
|
+
result_id=result_id,
|
|
395
|
+
fields=by_tokens,
|
|
396
|
+
per_question=[],
|
|
397
|
+
global_warnings=global_warnings,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
# Derive question list (from saved or from first panelist)
|
|
401
|
+
if saved_questions:
|
|
402
|
+
questions = saved_questions
|
|
403
|
+
else:
|
|
404
|
+
questions = _infer_questions(ordered_raw_responses)
|
|
405
|
+
|
|
406
|
+
per_question: list[QuestionSubgroupResult] = []
|
|
407
|
+
for qi, q in enumerate(questions):
|
|
408
|
+
if question_indices is not None and qi not in question_indices:
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
q_text = q.get("text", f"Question {qi + 1}") if isinstance(q, dict) else str(q)
|
|
412
|
+
response_schema: dict[str, Any] = {}
|
|
413
|
+
if isinstance(q, dict):
|
|
414
|
+
response_schema = q.get("response_schema") or q.get("extraction_schema") or {}
|
|
415
|
+
|
|
416
|
+
schema_type = response_schema.get("type", "text") if isinstance(response_schema, dict) else "text"
|
|
417
|
+
if not isinstance(response_schema, dict) or schema_type not in (
|
|
418
|
+
"scale",
|
|
419
|
+
"enum",
|
|
420
|
+
"tagged_themes",
|
|
421
|
+
):
|
|
422
|
+
# text-only questions: nothing meaningful to aggregate
|
|
423
|
+
continue
|
|
424
|
+
|
|
425
|
+
# Extract the typed response value for each matched panelist
|
|
426
|
+
per_panelist_values: list[Any] = []
|
|
427
|
+
for panelist_responses in ordered_raw_responses:
|
|
428
|
+
if qi < len(panelist_responses):
|
|
429
|
+
resp = panelist_responses[qi]
|
|
430
|
+
if resp.get("error") or resp.get("follow_up"):
|
|
431
|
+
per_panelist_values.append(None)
|
|
432
|
+
else:
|
|
433
|
+
raw_val = _response_value(resp)
|
|
434
|
+
per_panelist_values.append(_flatten_structured_value(raw_val, schema_type))
|
|
435
|
+
else:
|
|
436
|
+
per_panelist_values.append(None)
|
|
437
|
+
|
|
438
|
+
by_breakdowns: list[FieldBreakdown] = []
|
|
439
|
+
for token in by_tokens:
|
|
440
|
+
bd = _build_field_breakdown(
|
|
441
|
+
token,
|
|
442
|
+
per_panelist_values,
|
|
443
|
+
ordered_personas,
|
|
444
|
+
response_schema,
|
|
445
|
+
)
|
|
446
|
+
by_breakdowns.append(bd)
|
|
447
|
+
|
|
448
|
+
per_question.append(
|
|
449
|
+
QuestionSubgroupResult(
|
|
450
|
+
question_index=qi,
|
|
451
|
+
question_text=q_text,
|
|
452
|
+
response_schema_type=schema_type,
|
|
453
|
+
n_responses=sum(1 for v in per_panelist_values if v is not None),
|
|
454
|
+
by_field=by_breakdowns,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return SubgroupAnalysisResult(
|
|
459
|
+
result_id=result_id,
|
|
460
|
+
fields=by_tokens,
|
|
461
|
+
per_question=per_question,
|
|
462
|
+
global_warnings=global_warnings,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _infer_questions(ordered_raw_responses: list[list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
|
467
|
+
"""Derive question list from first panelist's responses."""
|
|
468
|
+
if not ordered_raw_responses:
|
|
469
|
+
return []
|
|
470
|
+
seen: list[dict[str, Any]] = []
|
|
471
|
+
seen_texts: set[str] = set()
|
|
472
|
+
for resp in ordered_raw_responses[0]:
|
|
473
|
+
if resp.get("follow_up"):
|
|
474
|
+
continue
|
|
475
|
+
q_text = resp.get("question", "")
|
|
476
|
+
if q_text and q_text not in seen_texts:
|
|
477
|
+
seen.append({"text": q_text})
|
|
478
|
+
seen_texts.add(q_text)
|
|
479
|
+
return seen
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ---------------------------------------------------------------------------
|
|
483
|
+
# Text formatter
|
|
484
|
+
# ---------------------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
_EFFECT_LABELS = {
|
|
487
|
+
"eta-squared": "η²",
|
|
488
|
+
"cramers-v": "V",
|
|
489
|
+
"density-ratio": "density",
|
|
490
|
+
"none": "",
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def format_subgroup_text(result: SubgroupAnalysisResult) -> str:
|
|
495
|
+
lines: list[str] = []
|
|
496
|
+
lines.append(f"Subgroup analysis — {result.result_id}")
|
|
497
|
+
lines.append(f"Grouping by: {', '.join(result.fields)}")
|
|
498
|
+
if result.global_warnings:
|
|
499
|
+
for w in result.global_warnings:
|
|
500
|
+
lines.append(f" WARNING: {w}")
|
|
501
|
+
lines.append("")
|
|
502
|
+
|
|
503
|
+
if not result.per_question:
|
|
504
|
+
lines.append("No structured questions found. Run the panel with response_schema to enable subgroup analysis.")
|
|
505
|
+
return "\n".join(lines)
|
|
506
|
+
|
|
507
|
+
for qr in result.per_question:
|
|
508
|
+
lines.append(f"Q{qr.question_index + 1}: {qr.question_text} [{qr.response_schema_type}] (n={qr.n_responses})")
|
|
509
|
+
for bd in qr.by_field:
|
|
510
|
+
label = _EFFECT_LABELS.get(bd.effect_size_type, "")
|
|
511
|
+
if bd.effect_size is not None:
|
|
512
|
+
eff_str = f"{label}={bd.effect_size:.3f}"
|
|
513
|
+
if bd.p_value is not None:
|
|
514
|
+
eff_str += f" p={bd.p_value:.4f}"
|
|
515
|
+
lines.append(f" --by {bd.field} ({eff_str})")
|
|
516
|
+
else:
|
|
517
|
+
lines.append(f" --by {bd.field}")
|
|
518
|
+
for w in bd.warnings:
|
|
519
|
+
lines.append(f" ! {w}")
|
|
520
|
+
for bucket in bd.buckets:
|
|
521
|
+
dist = bucket.get("distribution", {})
|
|
522
|
+
lines.append(_format_bucket(bucket, dist, qr.response_schema_type))
|
|
523
|
+
lines.append("")
|
|
524
|
+
|
|
525
|
+
return "\n".join(lines)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _format_bucket(bucket: dict[str, Any], dist: dict[str, Any], schema_type: str) -> str:
|
|
529
|
+
label = bucket.get("label", "?")
|
|
530
|
+
n = bucket.get("n", 0)
|
|
531
|
+
parts: list[str] = [f" {label} (n={n})"]
|
|
532
|
+
|
|
533
|
+
if schema_type == "scale":
|
|
534
|
+
stats = dist.get("stats", {})
|
|
535
|
+
mean = stats.get("mean")
|
|
536
|
+
stdev = stats.get("stdev")
|
|
537
|
+
if mean is not None:
|
|
538
|
+
parts.append(f" mean={mean:.2f}")
|
|
539
|
+
if stdev is not None:
|
|
540
|
+
parts.append(f" sd={stdev:.2f}")
|
|
541
|
+
elif schema_type == "enum":
|
|
542
|
+
entries = dist.get("distribution", [])
|
|
543
|
+
total = sum(e.get("count", 0) for e in entries)
|
|
544
|
+
parts.append(
|
|
545
|
+
" "
|
|
546
|
+
+ " ".join(
|
|
547
|
+
f"{e['option']}={e['count']}" + (f"({e['count'] / total:.0%})" if total else "")
|
|
548
|
+
for e in entries
|
|
549
|
+
if e.get("count", 0) > 0
|
|
550
|
+
)
|
|
551
|
+
)
|
|
552
|
+
elif schema_type == "tagged_themes":
|
|
553
|
+
entries = dist.get("distribution", [])
|
|
554
|
+
total_resp = dist.get("n_valid", n)
|
|
555
|
+
theme_parts = []
|
|
556
|
+
for e in entries:
|
|
557
|
+
cnt = e.get("count", 0)
|
|
558
|
+
tag = e.get("tag", "?")
|
|
559
|
+
if cnt > 0:
|
|
560
|
+
density = cnt / total_resp if total_resp > 0 else 0
|
|
561
|
+
theme_parts.append(f"{tag}={density:.0%}")
|
|
562
|
+
parts.append(" " + " ".join(theme_parts) if theme_parts else " (no themes)")
|
|
563
|
+
|
|
564
|
+
return "".join(parts)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------------------
|
|
568
|
+
# JSON formatter
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def format_subgroup_json(result: SubgroupAnalysisResult) -> dict[str, Any]:
|
|
573
|
+
return {
|
|
574
|
+
"result_id": result.result_id,
|
|
575
|
+
"fields": result.fields,
|
|
576
|
+
"global_warnings": result.global_warnings,
|
|
577
|
+
"per_question": [
|
|
578
|
+
{
|
|
579
|
+
"question_index": qr.question_index,
|
|
580
|
+
"question_text": qr.question_text,
|
|
581
|
+
"response_schema_type": qr.response_schema_type,
|
|
582
|
+
"n_responses": qr.n_responses,
|
|
583
|
+
"by_field": [
|
|
584
|
+
{
|
|
585
|
+
"field": bd.field,
|
|
586
|
+
"n_buckets": bd.n_buckets,
|
|
587
|
+
"effect_size_type": bd.effect_size_type,
|
|
588
|
+
"effect_size": bd.effect_size,
|
|
589
|
+
"p_value": bd.p_value,
|
|
590
|
+
"warnings": bd.warnings,
|
|
591
|
+
"buckets": bd.buckets,
|
|
592
|
+
}
|
|
593
|
+
for bd in qr.by_field
|
|
594
|
+
],
|
|
595
|
+
}
|
|
596
|
+
for qr in result.per_question
|
|
597
|
+
],
|
|
598
|
+
}
|