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.
Files changed (231) hide show
  1. bead/__init__.py +11 -0
  2. bead/__main__.py +11 -0
  3. bead/active_learning/__init__.py +15 -0
  4. bead/active_learning/config.py +231 -0
  5. bead/active_learning/loop.py +566 -0
  6. bead/active_learning/models/__init__.py +24 -0
  7. bead/active_learning/models/base.py +852 -0
  8. bead/active_learning/models/binary.py +910 -0
  9. bead/active_learning/models/categorical.py +943 -0
  10. bead/active_learning/models/cloze.py +862 -0
  11. bead/active_learning/models/forced_choice.py +956 -0
  12. bead/active_learning/models/free_text.py +773 -0
  13. bead/active_learning/models/lora.py +365 -0
  14. bead/active_learning/models/magnitude.py +835 -0
  15. bead/active_learning/models/multi_select.py +795 -0
  16. bead/active_learning/models/ordinal_scale.py +811 -0
  17. bead/active_learning/models/peft_adapter.py +155 -0
  18. bead/active_learning/models/random_effects.py +639 -0
  19. bead/active_learning/selection.py +354 -0
  20. bead/active_learning/strategies.py +391 -0
  21. bead/active_learning/trainers/__init__.py +26 -0
  22. bead/active_learning/trainers/base.py +210 -0
  23. bead/active_learning/trainers/data_collator.py +172 -0
  24. bead/active_learning/trainers/dataset_utils.py +261 -0
  25. bead/active_learning/trainers/huggingface.py +304 -0
  26. bead/active_learning/trainers/lightning.py +324 -0
  27. bead/active_learning/trainers/metrics.py +424 -0
  28. bead/active_learning/trainers/mixed_effects.py +551 -0
  29. bead/active_learning/trainers/model_wrapper.py +509 -0
  30. bead/active_learning/trainers/registry.py +104 -0
  31. bead/adapters/__init__.py +11 -0
  32. bead/adapters/huggingface.py +61 -0
  33. bead/behavioral/__init__.py +116 -0
  34. bead/behavioral/analytics.py +646 -0
  35. bead/behavioral/extraction.py +343 -0
  36. bead/behavioral/merging.py +343 -0
  37. bead/cli/__init__.py +11 -0
  38. bead/cli/active_learning.py +513 -0
  39. bead/cli/active_learning_commands.py +779 -0
  40. bead/cli/completion.py +359 -0
  41. bead/cli/config.py +624 -0
  42. bead/cli/constraint_builders.py +286 -0
  43. bead/cli/deployment.py +859 -0
  44. bead/cli/deployment_trials.py +493 -0
  45. bead/cli/deployment_ui.py +332 -0
  46. bead/cli/display.py +378 -0
  47. bead/cli/items.py +960 -0
  48. bead/cli/items_factories.py +776 -0
  49. bead/cli/list_constraints.py +714 -0
  50. bead/cli/lists.py +490 -0
  51. bead/cli/main.py +430 -0
  52. bead/cli/models.py +877 -0
  53. bead/cli/resource_loaders.py +621 -0
  54. bead/cli/resources.py +1036 -0
  55. bead/cli/shell.py +356 -0
  56. bead/cli/simulate.py +840 -0
  57. bead/cli/templates.py +1158 -0
  58. bead/cli/training.py +1080 -0
  59. bead/cli/utils.py +614 -0
  60. bead/cli/workflow.py +1273 -0
  61. bead/config/__init__.py +68 -0
  62. bead/config/active_learning.py +1009 -0
  63. bead/config/config.py +192 -0
  64. bead/config/defaults.py +118 -0
  65. bead/config/deployment.py +217 -0
  66. bead/config/env.py +147 -0
  67. bead/config/item.py +45 -0
  68. bead/config/list.py +193 -0
  69. bead/config/loader.py +149 -0
  70. bead/config/logging.py +42 -0
  71. bead/config/model.py +49 -0
  72. bead/config/paths.py +46 -0
  73. bead/config/profiles.py +320 -0
  74. bead/config/resources.py +47 -0
  75. bead/config/serialization.py +210 -0
  76. bead/config/simulation.py +206 -0
  77. bead/config/template.py +238 -0
  78. bead/config/validation.py +267 -0
  79. bead/data/__init__.py +65 -0
  80. bead/data/base.py +87 -0
  81. bead/data/identifiers.py +97 -0
  82. bead/data/language_codes.py +61 -0
  83. bead/data/metadata.py +270 -0
  84. bead/data/range.py +123 -0
  85. bead/data/repository.py +358 -0
  86. bead/data/serialization.py +249 -0
  87. bead/data/timestamps.py +89 -0
  88. bead/data/validation.py +349 -0
  89. bead/data_collection/__init__.py +11 -0
  90. bead/data_collection/jatos.py +223 -0
  91. bead/data_collection/merger.py +154 -0
  92. bead/data_collection/prolific.py +198 -0
  93. bead/deployment/__init__.py +5 -0
  94. bead/deployment/distribution.py +402 -0
  95. bead/deployment/jatos/__init__.py +1 -0
  96. bead/deployment/jatos/api.py +200 -0
  97. bead/deployment/jatos/exporter.py +210 -0
  98. bead/deployment/jspsych/__init__.py +9 -0
  99. bead/deployment/jspsych/biome.json +44 -0
  100. bead/deployment/jspsych/config.py +411 -0
  101. bead/deployment/jspsych/generator.py +598 -0
  102. bead/deployment/jspsych/package.json +51 -0
  103. bead/deployment/jspsych/pnpm-lock.yaml +2141 -0
  104. bead/deployment/jspsych/randomizer.py +299 -0
  105. bead/deployment/jspsych/src/lib/list-distributor.test.ts +327 -0
  106. bead/deployment/jspsych/src/lib/list-distributor.ts +1282 -0
  107. bead/deployment/jspsych/src/lib/randomizer.test.ts +232 -0
  108. bead/deployment/jspsych/src/lib/randomizer.ts +367 -0
  109. bead/deployment/jspsych/src/plugins/cloze-dropdown.ts +252 -0
  110. bead/deployment/jspsych/src/plugins/forced-choice.ts +265 -0
  111. bead/deployment/jspsych/src/plugins/plugins.test.ts +141 -0
  112. bead/deployment/jspsych/src/plugins/rating.ts +248 -0
  113. bead/deployment/jspsych/src/slopit/index.ts +9 -0
  114. bead/deployment/jspsych/src/types/jatos.d.ts +256 -0
  115. bead/deployment/jspsych/src/types/jspsych.d.ts +228 -0
  116. bead/deployment/jspsych/templates/experiment.css +1 -0
  117. bead/deployment/jspsych/templates/experiment.js.template +289 -0
  118. bead/deployment/jspsych/templates/index.html +51 -0
  119. bead/deployment/jspsych/templates/randomizer.js +241 -0
  120. bead/deployment/jspsych/templates/randomizer.js.template +313 -0
  121. bead/deployment/jspsych/trials.py +723 -0
  122. bead/deployment/jspsych/tsconfig.json +23 -0
  123. bead/deployment/jspsych/tsup.config.ts +30 -0
  124. bead/deployment/jspsych/ui/__init__.py +1 -0
  125. bead/deployment/jspsych/ui/components.py +383 -0
  126. bead/deployment/jspsych/ui/styles.py +411 -0
  127. bead/dsl/__init__.py +80 -0
  128. bead/dsl/ast.py +168 -0
  129. bead/dsl/context.py +178 -0
  130. bead/dsl/errors.py +71 -0
  131. bead/dsl/evaluator.py +570 -0
  132. bead/dsl/grammar.lark +81 -0
  133. bead/dsl/parser.py +231 -0
  134. bead/dsl/stdlib.py +929 -0
  135. bead/evaluation/__init__.py +13 -0
  136. bead/evaluation/convergence.py +485 -0
  137. bead/evaluation/interannotator.py +398 -0
  138. bead/items/__init__.py +40 -0
  139. bead/items/adapters/__init__.py +70 -0
  140. bead/items/adapters/anthropic.py +224 -0
  141. bead/items/adapters/api_utils.py +167 -0
  142. bead/items/adapters/base.py +216 -0
  143. bead/items/adapters/google.py +259 -0
  144. bead/items/adapters/huggingface.py +1074 -0
  145. bead/items/adapters/openai.py +323 -0
  146. bead/items/adapters/registry.py +202 -0
  147. bead/items/adapters/sentence_transformers.py +224 -0
  148. bead/items/adapters/togetherai.py +309 -0
  149. bead/items/binary.py +515 -0
  150. bead/items/cache.py +558 -0
  151. bead/items/categorical.py +593 -0
  152. bead/items/cloze.py +757 -0
  153. bead/items/constructor.py +784 -0
  154. bead/items/forced_choice.py +413 -0
  155. bead/items/free_text.py +681 -0
  156. bead/items/generation.py +432 -0
  157. bead/items/item.py +396 -0
  158. bead/items/item_template.py +787 -0
  159. bead/items/magnitude.py +573 -0
  160. bead/items/multi_select.py +621 -0
  161. bead/items/ordinal_scale.py +569 -0
  162. bead/items/scoring.py +448 -0
  163. bead/items/validation.py +723 -0
  164. bead/lists/__init__.py +30 -0
  165. bead/lists/balancer.py +263 -0
  166. bead/lists/constraints.py +1067 -0
  167. bead/lists/experiment_list.py +286 -0
  168. bead/lists/list_collection.py +378 -0
  169. bead/lists/partitioner.py +1141 -0
  170. bead/lists/stratification.py +254 -0
  171. bead/participants/__init__.py +73 -0
  172. bead/participants/collection.py +699 -0
  173. bead/participants/merging.py +312 -0
  174. bead/participants/metadata_spec.py +491 -0
  175. bead/participants/models.py +276 -0
  176. bead/resources/__init__.py +29 -0
  177. bead/resources/adapters/__init__.py +19 -0
  178. bead/resources/adapters/base.py +104 -0
  179. bead/resources/adapters/cache.py +128 -0
  180. bead/resources/adapters/glazing.py +508 -0
  181. bead/resources/adapters/registry.py +117 -0
  182. bead/resources/adapters/unimorph.py +796 -0
  183. bead/resources/classification.py +856 -0
  184. bead/resources/constraint_builders.py +329 -0
  185. bead/resources/constraints.py +165 -0
  186. bead/resources/lexical_item.py +223 -0
  187. bead/resources/lexicon.py +744 -0
  188. bead/resources/loaders.py +209 -0
  189. bead/resources/template.py +441 -0
  190. bead/resources/template_collection.py +707 -0
  191. bead/resources/template_generation.py +349 -0
  192. bead/simulation/__init__.py +29 -0
  193. bead/simulation/annotators/__init__.py +15 -0
  194. bead/simulation/annotators/base.py +175 -0
  195. bead/simulation/annotators/distance_based.py +135 -0
  196. bead/simulation/annotators/lm_based.py +114 -0
  197. bead/simulation/annotators/oracle.py +182 -0
  198. bead/simulation/annotators/random.py +181 -0
  199. bead/simulation/dsl_extension/__init__.py +3 -0
  200. bead/simulation/noise_models/__init__.py +13 -0
  201. bead/simulation/noise_models/base.py +42 -0
  202. bead/simulation/noise_models/random_noise.py +82 -0
  203. bead/simulation/noise_models/systematic.py +132 -0
  204. bead/simulation/noise_models/temperature.py +86 -0
  205. bead/simulation/runner.py +144 -0
  206. bead/simulation/strategies/__init__.py +23 -0
  207. bead/simulation/strategies/base.py +123 -0
  208. bead/simulation/strategies/binary.py +103 -0
  209. bead/simulation/strategies/categorical.py +123 -0
  210. bead/simulation/strategies/cloze.py +224 -0
  211. bead/simulation/strategies/forced_choice.py +127 -0
  212. bead/simulation/strategies/free_text.py +105 -0
  213. bead/simulation/strategies/magnitude.py +116 -0
  214. bead/simulation/strategies/multi_select.py +129 -0
  215. bead/simulation/strategies/ordinal_scale.py +131 -0
  216. bead/templates/__init__.py +27 -0
  217. bead/templates/adapters/__init__.py +17 -0
  218. bead/templates/adapters/base.py +128 -0
  219. bead/templates/adapters/cache.py +178 -0
  220. bead/templates/adapters/huggingface.py +312 -0
  221. bead/templates/combinatorics.py +103 -0
  222. bead/templates/filler.py +605 -0
  223. bead/templates/renderers.py +177 -0
  224. bead/templates/resolver.py +178 -0
  225. bead/templates/strategies.py +1806 -0
  226. bead/templates/streaming.py +195 -0
  227. bead-0.1.0.dist-info/METADATA +212 -0
  228. bead-0.1.0.dist-info/RECORD +231 -0
  229. bead-0.1.0.dist-info/WHEEL +4 -0
  230. bead-0.1.0.dist-info/entry_points.txt +2 -0
  231. bead-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,573 @@
1
+ """Utilities for creating magnitude experimental items.
2
+
3
+ This module provides language-agnostic utilities for creating magnitude
4
+ items where participants enter numeric values (bounded or unbounded),
5
+ such as reading times, confidence ratings, or counts.
6
+
7
+ Integration Points
8
+ ------------------
9
+ - Active Learning: bead/active_learning/models/magnitude.py
10
+ - Simulation: bead/simulation/strategies/magnitude.py
11
+ - Deployment: bead/deployment/jspsych/ (numeric input)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections import defaultdict
17
+ from collections.abc import Callable
18
+ from itertools import product
19
+ from typing import Any
20
+ from uuid import UUID, uuid4
21
+
22
+ from bead.items.item import Item, MetadataValue
23
+
24
+
25
+ def create_magnitude_item(
26
+ text: str,
27
+ unit: str | None = None,
28
+ bounds: tuple[int | float | None, int | float | None] = (None, None),
29
+ prompt: str | None = None,
30
+ step: int | float | None = None,
31
+ item_template_id: UUID | None = None,
32
+ metadata: dict[str, MetadataValue] | None = None,
33
+ ) -> Item:
34
+ """Create a magnitude (numeric input) item.
35
+
36
+ Parameters
37
+ ----------
38
+ text : str
39
+ The stimulus text or question.
40
+ unit : str | None
41
+ Optional unit for the value (e.g., "ms", "%", "count").
42
+ bounds : tuple[int | float | None, int | float | None]
43
+ Tuple of (min, max) bounds. None means unbounded in that direction.
44
+ Default: (None, None) for fully unbounded.
45
+ prompt : str | None
46
+ Optional prompt for the numeric input.
47
+ If None, uses "Enter a value:".
48
+ step : int | float | None
49
+ Optional step size for input validation (e.g., 1 for integers, 0.01 for
50
+ hundredths).
51
+ item_template_id : UUID | None
52
+ Template ID for the item. If None, generates new UUID.
53
+ metadata : dict[str, MetadataValue] | None
54
+ Additional metadata for item_metadata field.
55
+
56
+ Returns
57
+ -------
58
+ Item
59
+ Magnitude item with text and prompt in rendered_elements.
60
+
61
+ Raises
62
+ ------
63
+ ValueError
64
+ If text is empty or if both bounds are provided and min >= max.
65
+
66
+ Examples
67
+ --------
68
+ >>> item = create_magnitude_item(
69
+ ... text="How long did it take to read this sentence?",
70
+ ... unit="ms",
71
+ ... bounds=(0, None),
72
+ ... prompt="Enter time in milliseconds:"
73
+ ... )
74
+ >>> item.rendered_elements["text"]
75
+ 'How long did it take to read this sentence?'
76
+ >>> item.item_metadata["unit"]
77
+ 'ms'
78
+ >>> item.item_metadata["min_value"]
79
+ 0
80
+ >>> item.item_metadata["max_value"] is None
81
+ True
82
+
83
+ >>> # Confidence with bounded range
84
+ >>> item = create_magnitude_item(
85
+ ... text="How confident are you in your answer?",
86
+ ... unit="%",
87
+ ... bounds=(0, 100),
88
+ ... step=1
89
+ ... )
90
+ >>> item.item_metadata["max_value"]
91
+ 100
92
+ """
93
+ if not text or not text.strip():
94
+ raise ValueError("text cannot be empty")
95
+
96
+ min_value, max_value = bounds
97
+
98
+ # Validate bounds if both are provided
99
+ if min_value is not None and max_value is not None:
100
+ if min_value >= max_value:
101
+ raise ValueError(
102
+ f"min_value ({min_value}) must be less than max_value ({max_value})"
103
+ )
104
+
105
+ if item_template_id is None:
106
+ item_template_id = uuid4()
107
+
108
+ if prompt is None:
109
+ prompt = "Enter a value:"
110
+
111
+ rendered_elements: dict[str, str] = {
112
+ "text": text,
113
+ "prompt": prompt,
114
+ }
115
+
116
+ if unit:
117
+ rendered_elements["unit"] = unit
118
+
119
+ # Build item metadata
120
+ item_metadata: dict[str, MetadataValue] = {
121
+ "min_value": min_value,
122
+ "max_value": max_value,
123
+ }
124
+
125
+ if unit:
126
+ item_metadata["unit"] = unit
127
+
128
+ if step is not None:
129
+ item_metadata["step"] = step
130
+
131
+ if metadata:
132
+ item_metadata.update(metadata)
133
+
134
+ return Item(
135
+ item_template_id=item_template_id,
136
+ rendered_elements=rendered_elements,
137
+ item_metadata=item_metadata,
138
+ )
139
+
140
+
141
+ def create_magnitude_items_from_texts(
142
+ texts: list[str],
143
+ unit: str | None = None,
144
+ bounds: tuple[int | float | None, int | float | None] = (None, None),
145
+ prompt: str | None = None,
146
+ step: int | float | None = None,
147
+ *,
148
+ item_template_id: UUID | None = None,
149
+ metadata_fn: Callable[[str], dict[str, MetadataValue]] | None = None,
150
+ ) -> list[Item]:
151
+ """Create magnitude items from a list of texts.
152
+
153
+ Parameters
154
+ ----------
155
+ texts : list[str]
156
+ List of stimulus texts.
157
+ unit : str | None
158
+ Optional unit for all items.
159
+ bounds : tuple[int | float | None, int | float | None]
160
+ Bounds (min, max) for all items.
161
+ prompt : str | None
162
+ The question/prompt for all items.
163
+ step : int | float | None
164
+ Step size for all items.
165
+ item_template_id : UUID | None
166
+ Template ID for all created items. If None, generates one per item.
167
+ metadata_fn : Callable[[str], dict[str, MetadataValue]] | None
168
+ Function to generate metadata from each text.
169
+
170
+ Returns
171
+ -------
172
+ list[Item]
173
+ Magnitude items for each text.
174
+
175
+ Examples
176
+ --------
177
+ >>> texts = ["Sentence 1", "Sentence 2", "Sentence 3"]
178
+ >>> items = create_magnitude_items_from_texts(
179
+ ... texts,
180
+ ... unit="ms",
181
+ ... bounds=(0, None),
182
+ ... prompt="Reading time?",
183
+ ... metadata_fn=lambda t: {"text_length": len(t)}
184
+ ... )
185
+ >>> len(items)
186
+ 3
187
+ >>> items[0].item_metadata["unit"]
188
+ 'ms'
189
+ """
190
+ magnitude_items: list[Item] = []
191
+
192
+ for text in texts:
193
+ item_metadata: dict[str, MetadataValue] = {}
194
+ if metadata_fn:
195
+ item_metadata = metadata_fn(text)
196
+
197
+ item = create_magnitude_item(
198
+ text=text,
199
+ unit=unit,
200
+ bounds=bounds,
201
+ prompt=prompt,
202
+ step=step,
203
+ item_template_id=item_template_id,
204
+ metadata=item_metadata,
205
+ )
206
+ magnitude_items.append(item)
207
+
208
+ return magnitude_items
209
+
210
+
211
+ def create_magnitude_items_from_groups(
212
+ items: list[Item],
213
+ group_by: Callable[[Item], Any],
214
+ unit: str | None = None,
215
+ bounds: tuple[int | float | None, int | float | None] = (None, None),
216
+ prompt: str | None = None,
217
+ step: int | float | None = None,
218
+ *,
219
+ extract_text: Callable[[Item], str] | None = None,
220
+ include_group_metadata: bool = True,
221
+ item_template_id: UUID | None = None,
222
+ ) -> list[Item]:
223
+ """Create magnitude items from grouped source items.
224
+
225
+ Groups items and creates one magnitude item per source item,
226
+ preserving group information in metadata.
227
+
228
+ Parameters
229
+ ----------
230
+ items : list[Item]
231
+ Source items to process.
232
+ group_by : Callable[[Item], Any]
233
+ Function to extract grouping key from items.
234
+ unit : str | None
235
+ Optional unit for all items.
236
+ bounds : tuple[int | float | None, int | float | None]
237
+ Bounds (min, max) for all items.
238
+ prompt : str | None
239
+ The question/prompt for all items.
240
+ step : int | float | None
241
+ Step size for all items.
242
+ extract_text : Callable[[Item], str] | None
243
+ Function to extract text from item. If None, tries common keys.
244
+ include_group_metadata : bool
245
+ Whether to include group key in item metadata.
246
+ item_template_id : UUID | None
247
+ Template ID for all created items. If None, generates one per item.
248
+
249
+ Returns
250
+ -------
251
+ list[Item]
252
+ Magnitude items from source items.
253
+
254
+ Examples
255
+ --------
256
+ >>> source_items = [
257
+ ... Item(
258
+ ... uuid4(),
259
+ ... rendered_elements={"text": "The cat sat."},
260
+ ... item_metadata={"category": "simple"}
261
+ ... )
262
+ ... ]
263
+ >>> magnitude_items = create_magnitude_items_from_groups(
264
+ ... source_items,
265
+ ... group_by=lambda i: i.item_metadata["category"],
266
+ ... unit="ms",
267
+ ... bounds=(0, None),
268
+ ... prompt="Reading time:"
269
+ ... )
270
+ >>> len(magnitude_items)
271
+ 1
272
+ """
273
+ # Group items
274
+ groups: dict[Any, list[Item]] = defaultdict(list)
275
+ for item in items:
276
+ group_key = group_by(item)
277
+ groups[group_key].append(item)
278
+
279
+ magnitude_items: list[Item] = []
280
+
281
+ for group_key, group_items in groups.items():
282
+ for item in group_items:
283
+ # Extract text
284
+ if extract_text:
285
+ text: str = extract_text(item)
286
+ else:
287
+ text = _extract_text_from_item(item)
288
+
289
+ # Build metadata
290
+ item_metadata: dict[str, MetadataValue] = {
291
+ "source_item_id": str(item.id),
292
+ }
293
+ if include_group_metadata:
294
+ item_metadata["group_key"] = str(group_key)
295
+
296
+ # Create magnitude item
297
+ magnitude_item = create_magnitude_item(
298
+ text=text,
299
+ unit=unit,
300
+ bounds=bounds,
301
+ prompt=prompt,
302
+ step=step,
303
+ item_template_id=item_template_id,
304
+ metadata=item_metadata,
305
+ )
306
+ magnitude_items.append(magnitude_item)
307
+
308
+ return magnitude_items
309
+
310
+
311
+ def create_magnitude_items_cross_product(
312
+ texts: list[str],
313
+ prompts: list[str],
314
+ unit: str | None = None,
315
+ bounds: tuple[int | float | None, int | float | None] = (None, None),
316
+ step: int | float | None = None,
317
+ *,
318
+ item_template_id: UUID | None = None,
319
+ metadata_fn: (Callable[[str, str], dict[str, MetadataValue]] | None) = None,
320
+ ) -> list[Item]:
321
+ """Create magnitude items from cross-product of texts and prompts.
322
+
323
+ Useful when you want to apply multiple prompts to each text.
324
+
325
+ Parameters
326
+ ----------
327
+ texts : list[str]
328
+ List of stimulus texts.
329
+ prompts : list[str]
330
+ List of prompts to apply.
331
+ unit : str | None
332
+ Optional unit for all items.
333
+ bounds : tuple[int | float | None, int | float | None]
334
+ Bounds (min, max) for all items.
335
+ step : int | float | None
336
+ Step size for all items.
337
+ item_template_id : UUID | None
338
+ Template ID for all created items.
339
+ metadata_fn : Callable[[str, str], dict[str, MetadataValue]] | None
340
+ Function to generate metadata from (text, prompt).
341
+
342
+ Returns
343
+ -------
344
+ list[Item]
345
+ Magnitude items from cross-product.
346
+
347
+ Examples
348
+ --------
349
+ >>> texts = ["Sentence 1.", "Sentence 2."]
350
+ >>> prompts = ["Reading time?", "Processing time?"]
351
+ >>> items = create_magnitude_items_cross_product(
352
+ ... texts, prompts, unit="ms", bounds=(0, None)
353
+ ... )
354
+ >>> len(items)
355
+ 4
356
+ """
357
+ magnitude_items: list[Item] = []
358
+
359
+ for text, prompt in product(texts, prompts):
360
+ item_metadata: dict[str, MetadataValue] = {}
361
+ if metadata_fn:
362
+ item_metadata = metadata_fn(text, prompt)
363
+
364
+ item = create_magnitude_item(
365
+ text=text,
366
+ unit=unit,
367
+ bounds=bounds,
368
+ prompt=prompt,
369
+ step=step,
370
+ item_template_id=item_template_id,
371
+ metadata=item_metadata,
372
+ )
373
+ magnitude_items.append(item)
374
+
375
+ return magnitude_items
376
+
377
+
378
+ def create_filtered_magnitude_items(
379
+ items: list[Item],
380
+ unit: str | None = None,
381
+ bounds: tuple[int | float | None, int | float | None] = (None, None),
382
+ prompt: str | None = None,
383
+ step: int | float | None = None,
384
+ *,
385
+ item_filter: Callable[[Item], bool] | None = None,
386
+ extract_text: Callable[[Item], str] | None = None,
387
+ item_template_id: UUID | None = None,
388
+ ) -> list[Item]:
389
+ """Create magnitude items with filtering.
390
+
391
+ Parameters
392
+ ----------
393
+ items : list[Item]
394
+ Source items.
395
+ unit : str | None
396
+ Optional unit for all items.
397
+ bounds : tuple[int | float | None, int | float | None]
398
+ Bounds (min, max) for all items.
399
+ prompt : str | None
400
+ The question/prompt for all items.
401
+ step : int | float | None
402
+ Step size for all items.
403
+ item_filter : Callable[[Item], bool] | None
404
+ Filter individual items.
405
+ extract_text : Callable[[Item], str] | None
406
+ Text extraction function.
407
+ item_template_id : UUID | None
408
+ Template ID for created items.
409
+
410
+ Returns
411
+ -------
412
+ list[Item]
413
+ Filtered magnitude items.
414
+
415
+ Examples
416
+ --------
417
+ >>> magnitude_items = create_filtered_magnitude_items(
418
+ ... items,
419
+ ... unit="ms",
420
+ ... bounds=(0, None),
421
+ ... prompt="Reading time:",
422
+ ... item_filter=lambda i: i.item_metadata.get("valid", True)
423
+ ... ) # doctest: +SKIP
424
+ """
425
+ # Filter items
426
+ filtered_items = items
427
+ if item_filter:
428
+ filtered_items = [item for item in items if item_filter(item)]
429
+
430
+ magnitude_items: list[Item] = []
431
+
432
+ for item in filtered_items:
433
+ # Extract text
434
+ if extract_text:
435
+ text: str = extract_text(item)
436
+ else:
437
+ text = _extract_text_from_item(item)
438
+
439
+ # Create magnitude item
440
+ item_metadata: dict[str, MetadataValue] = {
441
+ "source_item_id": str(item.id),
442
+ }
443
+
444
+ magnitude_item = create_magnitude_item(
445
+ text=text,
446
+ unit=unit,
447
+ bounds=bounds,
448
+ prompt=prompt,
449
+ step=step,
450
+ item_template_id=item_template_id,
451
+ metadata=item_metadata,
452
+ )
453
+ magnitude_items.append(magnitude_item)
454
+
455
+ return magnitude_items
456
+
457
+
458
+ def create_reading_time_item(
459
+ text: str,
460
+ item_template_id: UUID | None = None,
461
+ metadata: dict[str, MetadataValue] | None = None,
462
+ ) -> Item:
463
+ """Create a reading time measurement item.
464
+
465
+ Convenience function for reading time in milliseconds with a lower bound
466
+ of 0 (no upper bound).
467
+
468
+ Parameters
469
+ ----------
470
+ text : str
471
+ The text to measure reading time for.
472
+ item_template_id : UUID | None
473
+ Template ID for the item. If None, generates new UUID.
474
+ metadata : dict[str, MetadataValue] | None
475
+ Additional metadata for item_metadata field.
476
+
477
+ Returns
478
+ -------
479
+ Item
480
+ Reading time magnitude item.
481
+
482
+ Examples
483
+ --------
484
+ >>> item = create_reading_time_item("The cat sat on the mat.")
485
+ >>> item.item_metadata["unit"]
486
+ 'ms'
487
+ >>> item.item_metadata["min_value"]
488
+ 0
489
+ """
490
+ return create_magnitude_item(
491
+ text,
492
+ unit="ms",
493
+ bounds=(0, None),
494
+ prompt="How long did it take to read?",
495
+ step=1,
496
+ item_template_id=item_template_id,
497
+ metadata=metadata,
498
+ )
499
+
500
+
501
+ def create_confidence_item(
502
+ text: str,
503
+ item_template_id: UUID | None = None,
504
+ metadata: dict[str, MetadataValue] | None = None,
505
+ ) -> Item:
506
+ """Create a confidence rating item (0-100%).
507
+
508
+ Convenience function for confidence percentage with bounds (0, 100).
509
+
510
+ Parameters
511
+ ----------
512
+ text : str
513
+ The text or question to rate confidence for.
514
+ item_template_id : UUID | None
515
+ Template ID for the item. If None, generates new UUID.
516
+ metadata : dict[str, MetadataValue] | None
517
+ Additional metadata for item_metadata field.
518
+
519
+ Returns
520
+ -------
521
+ Item
522
+ Confidence magnitude item.
523
+
524
+ Examples
525
+ --------
526
+ >>> item = create_confidence_item("Is this sentence grammatical?")
527
+ >>> item.item_metadata["unit"]
528
+ '%'
529
+ >>> item.item_metadata["max_value"]
530
+ 100
531
+ """
532
+ return create_magnitude_item(
533
+ text,
534
+ unit="%",
535
+ bounds=(0, 100),
536
+ prompt="How confident are you?",
537
+ step=1,
538
+ item_template_id=item_template_id,
539
+ metadata=metadata,
540
+ )
541
+
542
+
543
+ def _extract_text_from_item(item: Item) -> str:
544
+ """Extract text from item's rendered_elements.
545
+
546
+ Tries common keys: "text", "sentence", "content".
547
+ Raises error if no suitable text found.
548
+
549
+ Parameters
550
+ ----------
551
+ item : Item
552
+ Item to extract text from.
553
+
554
+ Returns
555
+ -------
556
+ str
557
+ Extracted text.
558
+
559
+ Raises
560
+ ------
561
+ ValueError
562
+ If no suitable text key found in rendered_elements.
563
+ """
564
+ for key in ["text", "sentence", "content"]:
565
+ if key in item.rendered_elements:
566
+ return item.rendered_elements[key]
567
+
568
+ raise ValueError(
569
+ f"Cannot extract text from item {item.id}. "
570
+ f"Expected one of ['text', 'sentence', 'content'] in rendered_elements, "
571
+ f"but found keys: {list(item.rendered_elements.keys())}. "
572
+ f"Use the extract_text parameter to provide a custom extraction function."
573
+ )