bead 0.1.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.
- bead/__init__.py +11 -0
- bead/__main__.py +11 -0
- bead/active_learning/__init__.py +15 -0
- bead/active_learning/config.py +231 -0
- bead/active_learning/loop.py +566 -0
- bead/active_learning/models/__init__.py +24 -0
- bead/active_learning/models/base.py +852 -0
- bead/active_learning/models/binary.py +910 -0
- bead/active_learning/models/categorical.py +943 -0
- bead/active_learning/models/cloze.py +862 -0
- bead/active_learning/models/forced_choice.py +956 -0
- bead/active_learning/models/free_text.py +773 -0
- bead/active_learning/models/lora.py +365 -0
- bead/active_learning/models/magnitude.py +835 -0
- bead/active_learning/models/multi_select.py +795 -0
- bead/active_learning/models/ordinal_scale.py +811 -0
- bead/active_learning/models/peft_adapter.py +155 -0
- bead/active_learning/models/random_effects.py +639 -0
- bead/active_learning/selection.py +354 -0
- bead/active_learning/strategies.py +391 -0
- bead/active_learning/trainers/__init__.py +26 -0
- bead/active_learning/trainers/base.py +210 -0
- bead/active_learning/trainers/data_collator.py +172 -0
- bead/active_learning/trainers/dataset_utils.py +261 -0
- bead/active_learning/trainers/huggingface.py +304 -0
- bead/active_learning/trainers/lightning.py +324 -0
- bead/active_learning/trainers/metrics.py +424 -0
- bead/active_learning/trainers/mixed_effects.py +551 -0
- bead/active_learning/trainers/model_wrapper.py +509 -0
- bead/active_learning/trainers/registry.py +104 -0
- bead/adapters/__init__.py +11 -0
- bead/adapters/huggingface.py +61 -0
- bead/behavioral/__init__.py +116 -0
- bead/behavioral/analytics.py +646 -0
- bead/behavioral/extraction.py +343 -0
- bead/behavioral/merging.py +343 -0
- bead/cli/__init__.py +11 -0
- bead/cli/active_learning.py +513 -0
- bead/cli/active_learning_commands.py +779 -0
- bead/cli/completion.py +359 -0
- bead/cli/config.py +624 -0
- bead/cli/constraint_builders.py +286 -0
- bead/cli/deployment.py +859 -0
- bead/cli/deployment_trials.py +493 -0
- bead/cli/deployment_ui.py +332 -0
- bead/cli/display.py +378 -0
- bead/cli/items.py +960 -0
- bead/cli/items_factories.py +776 -0
- bead/cli/list_constraints.py +714 -0
- bead/cli/lists.py +490 -0
- bead/cli/main.py +430 -0
- bead/cli/models.py +877 -0
- bead/cli/resource_loaders.py +621 -0
- bead/cli/resources.py +1036 -0
- bead/cli/shell.py +356 -0
- bead/cli/simulate.py +840 -0
- bead/cli/templates.py +1158 -0
- bead/cli/training.py +1080 -0
- bead/cli/utils.py +614 -0
- bead/cli/workflow.py +1273 -0
- bead/config/__init__.py +68 -0
- bead/config/active_learning.py +1009 -0
- bead/config/config.py +192 -0
- bead/config/defaults.py +118 -0
- bead/config/deployment.py +217 -0
- bead/config/env.py +147 -0
- bead/config/item.py +45 -0
- bead/config/list.py +193 -0
- bead/config/loader.py +149 -0
- bead/config/logging.py +42 -0
- bead/config/model.py +49 -0
- bead/config/paths.py +46 -0
- bead/config/profiles.py +320 -0
- bead/config/resources.py +47 -0
- bead/config/serialization.py +210 -0
- bead/config/simulation.py +206 -0
- bead/config/template.py +238 -0
- bead/config/validation.py +267 -0
- bead/data/__init__.py +65 -0
- bead/data/base.py +87 -0
- bead/data/identifiers.py +97 -0
- bead/data/language_codes.py +61 -0
- bead/data/metadata.py +270 -0
- bead/data/range.py +123 -0
- bead/data/repository.py +358 -0
- bead/data/serialization.py +249 -0
- bead/data/timestamps.py +89 -0
- bead/data/validation.py +349 -0
- bead/data_collection/__init__.py +11 -0
- bead/data_collection/jatos.py +223 -0
- bead/data_collection/merger.py +154 -0
- bead/data_collection/prolific.py +198 -0
- bead/deployment/__init__.py +5 -0
- bead/deployment/distribution.py +402 -0
- bead/deployment/jatos/__init__.py +1 -0
- bead/deployment/jatos/api.py +200 -0
- bead/deployment/jatos/exporter.py +210 -0
- bead/deployment/jspsych/__init__.py +9 -0
- bead/deployment/jspsych/biome.json +44 -0
- bead/deployment/jspsych/config.py +411 -0
- bead/deployment/jspsych/generator.py +598 -0
- bead/deployment/jspsych/package.json +51 -0
- bead/deployment/jspsych/pnpm-lock.yaml +2141 -0
- bead/deployment/jspsych/randomizer.py +299 -0
- bead/deployment/jspsych/src/lib/list-distributor.test.ts +327 -0
- bead/deployment/jspsych/src/lib/list-distributor.ts +1282 -0
- bead/deployment/jspsych/src/lib/randomizer.test.ts +232 -0
- bead/deployment/jspsych/src/lib/randomizer.ts +367 -0
- bead/deployment/jspsych/src/plugins/cloze-dropdown.ts +252 -0
- bead/deployment/jspsych/src/plugins/forced-choice.ts +265 -0
- bead/deployment/jspsych/src/plugins/plugins.test.ts +141 -0
- bead/deployment/jspsych/src/plugins/rating.ts +248 -0
- bead/deployment/jspsych/src/slopit/index.ts +9 -0
- bead/deployment/jspsych/src/types/jatos.d.ts +256 -0
- bead/deployment/jspsych/src/types/jspsych.d.ts +228 -0
- bead/deployment/jspsych/templates/experiment.css +1 -0
- bead/deployment/jspsych/templates/experiment.js.template +289 -0
- bead/deployment/jspsych/templates/index.html +51 -0
- bead/deployment/jspsych/templates/randomizer.js +241 -0
- bead/deployment/jspsych/templates/randomizer.js.template +313 -0
- bead/deployment/jspsych/trials.py +723 -0
- bead/deployment/jspsych/tsconfig.json +23 -0
- bead/deployment/jspsych/tsup.config.ts +30 -0
- bead/deployment/jspsych/ui/__init__.py +1 -0
- bead/deployment/jspsych/ui/components.py +383 -0
- bead/deployment/jspsych/ui/styles.py +411 -0
- bead/dsl/__init__.py +80 -0
- bead/dsl/ast.py +168 -0
- bead/dsl/context.py +178 -0
- bead/dsl/errors.py +71 -0
- bead/dsl/evaluator.py +570 -0
- bead/dsl/grammar.lark +81 -0
- bead/dsl/parser.py +231 -0
- bead/dsl/stdlib.py +929 -0
- bead/evaluation/__init__.py +13 -0
- bead/evaluation/convergence.py +485 -0
- bead/evaluation/interannotator.py +398 -0
- bead/items/__init__.py +40 -0
- bead/items/adapters/__init__.py +70 -0
- bead/items/adapters/anthropic.py +224 -0
- bead/items/adapters/api_utils.py +167 -0
- bead/items/adapters/base.py +216 -0
- bead/items/adapters/google.py +259 -0
- bead/items/adapters/huggingface.py +1074 -0
- bead/items/adapters/openai.py +323 -0
- bead/items/adapters/registry.py +202 -0
- bead/items/adapters/sentence_transformers.py +224 -0
- bead/items/adapters/togetherai.py +309 -0
- bead/items/binary.py +515 -0
- bead/items/cache.py +558 -0
- bead/items/categorical.py +593 -0
- bead/items/cloze.py +757 -0
- bead/items/constructor.py +784 -0
- bead/items/forced_choice.py +413 -0
- bead/items/free_text.py +681 -0
- bead/items/generation.py +432 -0
- bead/items/item.py +396 -0
- bead/items/item_template.py +787 -0
- bead/items/magnitude.py +573 -0
- bead/items/multi_select.py +621 -0
- bead/items/ordinal_scale.py +569 -0
- bead/items/scoring.py +448 -0
- bead/items/validation.py +723 -0
- bead/lists/__init__.py +30 -0
- bead/lists/balancer.py +263 -0
- bead/lists/constraints.py +1067 -0
- bead/lists/experiment_list.py +286 -0
- bead/lists/list_collection.py +378 -0
- bead/lists/partitioner.py +1141 -0
- bead/lists/stratification.py +254 -0
- bead/participants/__init__.py +73 -0
- bead/participants/collection.py +699 -0
- bead/participants/merging.py +312 -0
- bead/participants/metadata_spec.py +491 -0
- bead/participants/models.py +276 -0
- bead/resources/__init__.py +29 -0
- bead/resources/adapters/__init__.py +19 -0
- bead/resources/adapters/base.py +104 -0
- bead/resources/adapters/cache.py +128 -0
- bead/resources/adapters/glazing.py +508 -0
- bead/resources/adapters/registry.py +117 -0
- bead/resources/adapters/unimorph.py +796 -0
- bead/resources/classification.py +856 -0
- bead/resources/constraint_builders.py +329 -0
- bead/resources/constraints.py +165 -0
- bead/resources/lexical_item.py +223 -0
- bead/resources/lexicon.py +744 -0
- bead/resources/loaders.py +209 -0
- bead/resources/template.py +441 -0
- bead/resources/template_collection.py +707 -0
- bead/resources/template_generation.py +349 -0
- bead/simulation/__init__.py +29 -0
- bead/simulation/annotators/__init__.py +15 -0
- bead/simulation/annotators/base.py +175 -0
- bead/simulation/annotators/distance_based.py +135 -0
- bead/simulation/annotators/lm_based.py +114 -0
- bead/simulation/annotators/oracle.py +182 -0
- bead/simulation/annotators/random.py +181 -0
- bead/simulation/dsl_extension/__init__.py +3 -0
- bead/simulation/noise_models/__init__.py +13 -0
- bead/simulation/noise_models/base.py +42 -0
- bead/simulation/noise_models/random_noise.py +82 -0
- bead/simulation/noise_models/systematic.py +132 -0
- bead/simulation/noise_models/temperature.py +86 -0
- bead/simulation/runner.py +144 -0
- bead/simulation/strategies/__init__.py +23 -0
- bead/simulation/strategies/base.py +123 -0
- bead/simulation/strategies/binary.py +103 -0
- bead/simulation/strategies/categorical.py +123 -0
- bead/simulation/strategies/cloze.py +224 -0
- bead/simulation/strategies/forced_choice.py +127 -0
- bead/simulation/strategies/free_text.py +105 -0
- bead/simulation/strategies/magnitude.py +116 -0
- bead/simulation/strategies/multi_select.py +129 -0
- bead/simulation/strategies/ordinal_scale.py +131 -0
- bead/templates/__init__.py +27 -0
- bead/templates/adapters/__init__.py +17 -0
- bead/templates/adapters/base.py +128 -0
- bead/templates/adapters/cache.py +178 -0
- bead/templates/adapters/huggingface.py +312 -0
- bead/templates/combinatorics.py +103 -0
- bead/templates/filler.py +605 -0
- bead/templates/renderers.py +177 -0
- bead/templates/resolver.py +178 -0
- bead/templates/strategies.py +1806 -0
- bead/templates/streaming.py +195 -0
- bead-0.1.0.dist-info/METADATA +212 -0
- bead-0.1.0.dist-info/RECORD +231 -0
- bead-0.1.0.dist-info/WHEEL +4 -0
- bead-0.1.0.dist-info/entry_points.txt +2 -0
- bead-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
"""Utilities for creating N-AFC (forced-choice) experimental items.
|
|
2
|
+
|
|
3
|
+
This module provides language-agnostic utilities for creating forced-choice
|
|
4
|
+
items where participants select from N alternatives (2AFC, 3AFC, 4AFC, etc.).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from itertools import combinations, product
|
|
12
|
+
from typing import Any
|
|
13
|
+
from uuid import UUID, uuid4
|
|
14
|
+
|
|
15
|
+
from bead.items.item import Item, MetadataValue
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_forced_choice_item(
|
|
19
|
+
*options: str,
|
|
20
|
+
item_template_id: UUID | None = None,
|
|
21
|
+
metadata: dict[str, MetadataValue] | None = None,
|
|
22
|
+
) -> Item:
|
|
23
|
+
"""Create an N-AFC (forced-choice) item from N text options.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
*options : str
|
|
28
|
+
Text for each option (2 or more required).
|
|
29
|
+
item_template_id : UUID | None
|
|
30
|
+
Template ID for the item. If None, generates new UUID.
|
|
31
|
+
metadata : dict[str, MetadataValue] | None
|
|
32
|
+
Additional metadata for item_metadata field.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
Item
|
|
37
|
+
Forced-choice item with options stored in the options field.
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
ValueError
|
|
42
|
+
If fewer than 2 options provided.
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> item = create_forced_choice_item(
|
|
47
|
+
... "The cat sat on the mat.",
|
|
48
|
+
... "The cats sat on the mat.",
|
|
49
|
+
... metadata={"contrast": "number"}
|
|
50
|
+
... )
|
|
51
|
+
>>> item.options[0]
|
|
52
|
+
'The cat sat on the mat.'
|
|
53
|
+
>>> item.options[1]
|
|
54
|
+
'The cats sat on the mat.'
|
|
55
|
+
|
|
56
|
+
>>> # 4AFC item
|
|
57
|
+
>>> item = create_forced_choice_item(
|
|
58
|
+
... "Option A text",
|
|
59
|
+
... "Option B text",
|
|
60
|
+
... "Option C text",
|
|
61
|
+
... "Option D text"
|
|
62
|
+
... )
|
|
63
|
+
>>> len(item.options)
|
|
64
|
+
4
|
|
65
|
+
"""
|
|
66
|
+
if len(options) < 2:
|
|
67
|
+
raise ValueError("At least 2 options required for forced-choice item")
|
|
68
|
+
|
|
69
|
+
if item_template_id is None:
|
|
70
|
+
item_template_id = uuid4()
|
|
71
|
+
|
|
72
|
+
# Build item metadata with n_options (consistent with other task types)
|
|
73
|
+
item_metadata: dict[str, MetadataValue] = {
|
|
74
|
+
"n_options": len(options),
|
|
75
|
+
}
|
|
76
|
+
if metadata:
|
|
77
|
+
item_metadata.update(metadata)
|
|
78
|
+
|
|
79
|
+
return Item(
|
|
80
|
+
item_template_id=item_template_id,
|
|
81
|
+
options=list(options),
|
|
82
|
+
item_metadata=item_metadata,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_forced_choice_items_from_groups(
|
|
87
|
+
items: list[Item],
|
|
88
|
+
group_by: Callable[[Item], Any],
|
|
89
|
+
n_alternatives: int = 2,
|
|
90
|
+
*,
|
|
91
|
+
extract_text: Callable[[Item], str] | None = None,
|
|
92
|
+
include_group_metadata: bool = True,
|
|
93
|
+
item_template_id: UUID | None = None,
|
|
94
|
+
) -> list[Item]:
|
|
95
|
+
"""Create forced-choice items by grouping source items.
|
|
96
|
+
|
|
97
|
+
Groups items by a property, then creates all N-way combinations within
|
|
98
|
+
each group as forced-choice items.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
items : list[Item]
|
|
103
|
+
Source items to group and combine.
|
|
104
|
+
group_by : Callable[[Item], Any]
|
|
105
|
+
Function to extract grouping key from items.
|
|
106
|
+
n_alternatives : int
|
|
107
|
+
Number of alternatives per forced-choice item (default: 2 for 2AFC).
|
|
108
|
+
extract_text : Callable[[Item], str] | None
|
|
109
|
+
Function to extract text from item. If None, tries common keys
|
|
110
|
+
("text", "sentence", "content") from rendered_elements.
|
|
111
|
+
include_group_metadata : bool
|
|
112
|
+
Whether to include group key in item metadata.
|
|
113
|
+
item_template_id : UUID | None
|
|
114
|
+
Template ID for all created items. If None, generates one per item.
|
|
115
|
+
|
|
116
|
+
Returns
|
|
117
|
+
-------
|
|
118
|
+
list[Item]
|
|
119
|
+
Forced-choice items created from groupings.
|
|
120
|
+
|
|
121
|
+
Examples
|
|
122
|
+
--------
|
|
123
|
+
Create 2AFC items with same verb (same-verb minimal pairs):
|
|
124
|
+
>>> items = [
|
|
125
|
+
... Item(
|
|
126
|
+
... item_template_id=uuid4(),
|
|
127
|
+
... rendered_elements={"text": "She walks."},
|
|
128
|
+
... item_metadata={"verb": "walk", "frame": "intransitive"}
|
|
129
|
+
... ),
|
|
130
|
+
... Item(
|
|
131
|
+
... item_template_id=uuid4(),
|
|
132
|
+
... rendered_elements={"text": "She walks the dog."},
|
|
133
|
+
... item_metadata={"verb": "walk", "frame": "transitive"}
|
|
134
|
+
... )
|
|
135
|
+
... ]
|
|
136
|
+
>>> fc_items = create_forced_choice_items_from_groups(
|
|
137
|
+
... items,
|
|
138
|
+
... group_by=lambda item: item.item_metadata["verb"],
|
|
139
|
+
... n_alternatives=2
|
|
140
|
+
... )
|
|
141
|
+
>>> len(fc_items)
|
|
142
|
+
1
|
|
143
|
+
>>> fc_items[0].rendered_elements["option_a"]
|
|
144
|
+
'She walks.'
|
|
145
|
+
|
|
146
|
+
Create 3AFC items grouped by template:
|
|
147
|
+
>>> fc_items = create_forced_choice_items_from_groups(
|
|
148
|
+
... items,
|
|
149
|
+
... group_by=lambda item: item.item_template_id,
|
|
150
|
+
... n_alternatives=3
|
|
151
|
+
... ) # doctest: +SKIP
|
|
152
|
+
"""
|
|
153
|
+
# Group items
|
|
154
|
+
groups: dict[Any, list[Item]] = defaultdict(list)
|
|
155
|
+
for item in items:
|
|
156
|
+
group_key = group_by(item)
|
|
157
|
+
groups[group_key].append(item)
|
|
158
|
+
|
|
159
|
+
# Create forced-choice items from each group
|
|
160
|
+
fc_items: list[Item] = []
|
|
161
|
+
|
|
162
|
+
for group_key, group_items in groups.items():
|
|
163
|
+
# Generate all N-way combinations within group
|
|
164
|
+
for combo in combinations(group_items, n_alternatives):
|
|
165
|
+
# Extract text from each item
|
|
166
|
+
texts: list[str] = []
|
|
167
|
+
for item in combo:
|
|
168
|
+
if extract_text:
|
|
169
|
+
text: str = extract_text(item)
|
|
170
|
+
else:
|
|
171
|
+
text = _extract_text_from_item(item)
|
|
172
|
+
texts.append(text)
|
|
173
|
+
|
|
174
|
+
# Build metadata
|
|
175
|
+
metadata: dict[str, MetadataValue] = {}
|
|
176
|
+
if include_group_metadata:
|
|
177
|
+
metadata["group_key"] = str(group_key)
|
|
178
|
+
|
|
179
|
+
# Include source item IDs
|
|
180
|
+
for i, item in enumerate(combo):
|
|
181
|
+
metadata[f"source_item_{i}_id"] = str(item.id)
|
|
182
|
+
|
|
183
|
+
# Create forced-choice item
|
|
184
|
+
fc_item = create_forced_choice_item(
|
|
185
|
+
*texts, item_template_id=item_template_id, metadata=metadata
|
|
186
|
+
)
|
|
187
|
+
fc_items.append(fc_item)
|
|
188
|
+
|
|
189
|
+
return fc_items
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def create_forced_choice_items_cross_product(
|
|
193
|
+
group1_items: list[Item],
|
|
194
|
+
group2_items: list[Item],
|
|
195
|
+
n_from_group1: int = 1,
|
|
196
|
+
n_from_group2: int = 1,
|
|
197
|
+
*,
|
|
198
|
+
extract_text: Callable[[Item], str] | None = None,
|
|
199
|
+
item_template_id: UUID | None = None,
|
|
200
|
+
metadata_fn: (
|
|
201
|
+
Callable[[list[Item], list[Item]], dict[str, MetadataValue]] | None
|
|
202
|
+
) = None,
|
|
203
|
+
) -> list[Item]:
|
|
204
|
+
"""Create forced-choice items from cross-product of two groups.
|
|
205
|
+
|
|
206
|
+
Combines n items from group1 with n items from group2 to create
|
|
207
|
+
(n_from_group1 + n_from_group2)-AFC items.
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
group1_items : list[Item]
|
|
212
|
+
Items in first group.
|
|
213
|
+
group2_items : list[Item]
|
|
214
|
+
Items in second group.
|
|
215
|
+
n_from_group1 : int
|
|
216
|
+
Number of items to select from group1 per combination (default: 1).
|
|
217
|
+
n_from_group2 : int
|
|
218
|
+
Number of items to select from group2 per combination (default: 1).
|
|
219
|
+
extract_text : Callable[[Item], str] | None
|
|
220
|
+
Function to extract text from items.
|
|
221
|
+
item_template_id : UUID | None
|
|
222
|
+
Template ID for all created items.
|
|
223
|
+
metadata_fn : Callable[[list[Item], list[Item]], dict[str, MetadataValue]] | None
|
|
224
|
+
Function to generate metadata from (group1_items_used, group2_items_used).
|
|
225
|
+
|
|
226
|
+
Returns
|
|
227
|
+
-------
|
|
228
|
+
list[Item]
|
|
229
|
+
Forced-choice items from cross-product.
|
|
230
|
+
|
|
231
|
+
Examples
|
|
232
|
+
--------
|
|
233
|
+
Create 2AFC items pairing grammatical with ungrammatical:
|
|
234
|
+
>>> grammatical = [
|
|
235
|
+
... Item(
|
|
236
|
+
... uuid4(),
|
|
237
|
+
... rendered_elements={"text": "She walks."},
|
|
238
|
+
... item_metadata={"grammatical": True}
|
|
239
|
+
... )
|
|
240
|
+
... ]
|
|
241
|
+
>>> ungrammatical = [
|
|
242
|
+
... Item(
|
|
243
|
+
... uuid4(),
|
|
244
|
+
... rendered_elements={"text": "She walk."},
|
|
245
|
+
... item_metadata={"grammatical": False}
|
|
246
|
+
... )
|
|
247
|
+
... ]
|
|
248
|
+
>>> fc_items = create_forced_choice_items_cross_product(
|
|
249
|
+
... grammatical,
|
|
250
|
+
... ungrammatical,
|
|
251
|
+
... n_from_group1=1,
|
|
252
|
+
... n_from_group2=1
|
|
253
|
+
... )
|
|
254
|
+
>>> len(fc_items)
|
|
255
|
+
1
|
|
256
|
+
"""
|
|
257
|
+
# Generate combinations from each group
|
|
258
|
+
group1_combos = list(combinations(group1_items, n_from_group1))
|
|
259
|
+
group2_combos = list(combinations(group2_items, n_from_group2))
|
|
260
|
+
|
|
261
|
+
fc_items: list[Item] = []
|
|
262
|
+
|
|
263
|
+
# Cross-product of combinations
|
|
264
|
+
for combo1, combo2 in product(group1_combos, group2_combos):
|
|
265
|
+
all_items = list(combo1) + list(combo2)
|
|
266
|
+
|
|
267
|
+
# Extract texts
|
|
268
|
+
texts: list[str] = []
|
|
269
|
+
for item in all_items:
|
|
270
|
+
if extract_text:
|
|
271
|
+
text: str = extract_text(item)
|
|
272
|
+
else:
|
|
273
|
+
text = _extract_text_from_item(item)
|
|
274
|
+
texts.append(text)
|
|
275
|
+
|
|
276
|
+
# Build metadata
|
|
277
|
+
metadata: dict[str, MetadataValue]
|
|
278
|
+
if metadata_fn:
|
|
279
|
+
metadata = metadata_fn(list(combo1), list(combo2))
|
|
280
|
+
else:
|
|
281
|
+
metadata = {
|
|
282
|
+
"source_group1_ids": [str(item.id) for item in combo1],
|
|
283
|
+
"source_group2_ids": [str(item.id) for item in combo2],
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# Create forced-choice item
|
|
287
|
+
fc_item = create_forced_choice_item(
|
|
288
|
+
*texts, item_template_id=item_template_id, metadata=metadata
|
|
289
|
+
)
|
|
290
|
+
fc_items.append(fc_item)
|
|
291
|
+
|
|
292
|
+
return fc_items
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def create_filtered_forced_choice_items(
|
|
296
|
+
items: list[Item],
|
|
297
|
+
group_by: Callable[[Item], Any],
|
|
298
|
+
n_alternatives: int = 2,
|
|
299
|
+
*,
|
|
300
|
+
item_filter: Callable[[Item], bool] | None = None,
|
|
301
|
+
group_filter: Callable[[Any, list[Item]], bool] | None = None,
|
|
302
|
+
combination_filter: Callable[[tuple[Item, ...]], bool] | None = None,
|
|
303
|
+
extract_text: Callable[[Item], str] | None = None,
|
|
304
|
+
item_template_id: UUID | None = None,
|
|
305
|
+
) -> list[Item]:
|
|
306
|
+
"""Create forced-choice items with multi-level filtering.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
items : list[Item]
|
|
311
|
+
Source items.
|
|
312
|
+
group_by : Callable[[Item], Any]
|
|
313
|
+
Grouping function.
|
|
314
|
+
n_alternatives : int
|
|
315
|
+
Number of alternatives per item.
|
|
316
|
+
item_filter : Callable[[Item], bool] | None
|
|
317
|
+
Filter individual items before grouping.
|
|
318
|
+
group_filter : Callable[[Any, list[Item]], bool] | None
|
|
319
|
+
Filter groups (receives group_key and group_items).
|
|
320
|
+
combination_filter : Callable[[tuple[Item, ...]], bool] | None
|
|
321
|
+
Filter specific combinations.
|
|
322
|
+
extract_text : Callable[[Item], str] | None
|
|
323
|
+
Text extraction function.
|
|
324
|
+
item_template_id : UUID | None
|
|
325
|
+
Template ID for created items.
|
|
326
|
+
|
|
327
|
+
Returns
|
|
328
|
+
-------
|
|
329
|
+
list[Item]
|
|
330
|
+
Filtered forced-choice items.
|
|
331
|
+
|
|
332
|
+
Examples
|
|
333
|
+
--------
|
|
334
|
+
>>> fc_items = create_filtered_forced_choice_items(
|
|
335
|
+
... items,
|
|
336
|
+
... group_by=lambda i: i.item_metadata["verb"],
|
|
337
|
+
... n_alternatives=2,
|
|
338
|
+
... item_filter=lambda i: i.item_metadata.get("valid", True),
|
|
339
|
+
... group_filter=lambda key, items: len(items) >= 2,
|
|
340
|
+
... combination_filter=lambda combo: combo[0].id != combo[1].id
|
|
341
|
+
... ) # doctest: +SKIP
|
|
342
|
+
"""
|
|
343
|
+
# Filter items
|
|
344
|
+
filtered_items = items
|
|
345
|
+
if item_filter:
|
|
346
|
+
filtered_items = [item for item in items if item_filter(item)]
|
|
347
|
+
|
|
348
|
+
# Group items
|
|
349
|
+
groups: dict[Any, list[Item]] = defaultdict(list)
|
|
350
|
+
for item in filtered_items:
|
|
351
|
+
group_key = group_by(item)
|
|
352
|
+
groups[group_key].append(item)
|
|
353
|
+
|
|
354
|
+
# Filter groups
|
|
355
|
+
if group_filter:
|
|
356
|
+
groups = {k: v for k, v in groups.items() if group_filter(k, v)}
|
|
357
|
+
|
|
358
|
+
# Create combinations
|
|
359
|
+
fc_items: list[Item] = []
|
|
360
|
+
for group_key, group_items in groups.items():
|
|
361
|
+
for combo in combinations(group_items, n_alternatives):
|
|
362
|
+
# Filter combination
|
|
363
|
+
if combination_filter and not combination_filter(combo):
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# Extract texts
|
|
367
|
+
texts: list[str] = []
|
|
368
|
+
for item in combo:
|
|
369
|
+
if extract_text:
|
|
370
|
+
text: str = extract_text(item)
|
|
371
|
+
else:
|
|
372
|
+
text = _extract_text_from_item(item)
|
|
373
|
+
texts.append(text)
|
|
374
|
+
|
|
375
|
+
# Create item
|
|
376
|
+
metadata: dict[str, MetadataValue] = {
|
|
377
|
+
"group_key": str(group_key),
|
|
378
|
+
"source_item_ids": [str(item.id) for item in combo],
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
fc_item = create_forced_choice_item(
|
|
382
|
+
*texts, item_template_id=item_template_id, metadata=metadata
|
|
383
|
+
)
|
|
384
|
+
fc_items.append(fc_item)
|
|
385
|
+
|
|
386
|
+
return fc_items
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _extract_text_from_item(item: Item) -> str:
|
|
390
|
+
"""Extract text from item's rendered_elements.
|
|
391
|
+
|
|
392
|
+
Tries common keys: "text", "sentence", "content".
|
|
393
|
+
Falls back to string representation if not found.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
item : Item
|
|
398
|
+
Item to extract text from.
|
|
399
|
+
|
|
400
|
+
Returns
|
|
401
|
+
-------
|
|
402
|
+
str
|
|
403
|
+
Extracted text.
|
|
404
|
+
"""
|
|
405
|
+
for key in ["text", "sentence", "content"]:
|
|
406
|
+
if key in item.rendered_elements:
|
|
407
|
+
return item.rendered_elements[key]
|
|
408
|
+
|
|
409
|
+
# Fallback: use first value or string representation
|
|
410
|
+
if item.rendered_elements:
|
|
411
|
+
return next(iter(item.rendered_elements.values()))
|
|
412
|
+
|
|
413
|
+
return str(item.rendered_elements)
|