edsl 0.1.50__py3-none-any.whl → 0.1.51__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 (109) hide show
  1. edsl/__version__.py +1 -1
  2. edsl/base/base_exception.py +2 -2
  3. edsl/buckets/bucket_collection.py +1 -1
  4. edsl/buckets/exceptions.py +32 -0
  5. edsl/buckets/token_bucket_api.py +26 -10
  6. edsl/caching/cache.py +5 -2
  7. edsl/caching/remote_cache_sync.py +5 -5
  8. edsl/caching/sql_dict.py +12 -11
  9. edsl/config/__init__.py +1 -1
  10. edsl/config/config_class.py +4 -2
  11. edsl/conversation/Conversation.py +7 -4
  12. edsl/conversation/car_buying.py +1 -3
  13. edsl/conversation/mug_negotiation.py +2 -6
  14. edsl/coop/__init__.py +11 -8
  15. edsl/coop/coop.py +13 -13
  16. edsl/coop/coop_functions.py +1 -1
  17. edsl/coop/ep_key_handling.py +1 -1
  18. edsl/coop/price_fetcher.py +2 -2
  19. edsl/coop/utils.py +2 -2
  20. edsl/dataset/dataset.py +144 -63
  21. edsl/dataset/dataset_operations_mixin.py +14 -6
  22. edsl/dataset/dataset_tree.py +3 -3
  23. edsl/dataset/display/table_renderers.py +6 -3
  24. edsl/dataset/file_exports.py +4 -4
  25. edsl/dataset/r/ggplot.py +3 -3
  26. edsl/inference_services/available_model_fetcher.py +2 -2
  27. edsl/inference_services/data_structures.py +5 -5
  28. edsl/inference_services/inference_service_abc.py +1 -1
  29. edsl/inference_services/inference_services_collection.py +1 -1
  30. edsl/inference_services/service_availability.py +3 -3
  31. edsl/inference_services/services/azure_ai.py +3 -3
  32. edsl/inference_services/services/google_service.py +1 -1
  33. edsl/inference_services/services/test_service.py +1 -1
  34. edsl/instructions/change_instruction.py +5 -4
  35. edsl/instructions/instruction.py +1 -0
  36. edsl/instructions/instruction_collection.py +5 -4
  37. edsl/instructions/instruction_handler.py +10 -8
  38. edsl/interviews/exception_tracking.py +1 -1
  39. edsl/interviews/interview.py +1 -1
  40. edsl/interviews/interview_status_dictionary.py +1 -1
  41. edsl/interviews/interview_task_manager.py +2 -2
  42. edsl/interviews/request_token_estimator.py +3 -2
  43. edsl/interviews/statistics.py +2 -2
  44. edsl/invigilators/invigilators.py +2 -2
  45. edsl/jobs/__init__.py +39 -2
  46. edsl/jobs/async_interview_runner.py +1 -1
  47. edsl/jobs/check_survey_scenario_compatibility.py +5 -5
  48. edsl/jobs/data_structures.py +2 -2
  49. edsl/jobs/jobs.py +2 -2
  50. edsl/jobs/jobs_checks.py +5 -5
  51. edsl/jobs/jobs_component_constructor.py +2 -2
  52. edsl/jobs/jobs_pricing_estimation.py +1 -1
  53. edsl/jobs/jobs_runner_asyncio.py +2 -2
  54. edsl/jobs/remote_inference.py +1 -1
  55. edsl/jobs/results_exceptions_handler.py +2 -2
  56. edsl/language_models/language_model.py +5 -1
  57. edsl/notebooks/__init__.py +24 -1
  58. edsl/notebooks/exceptions.py +82 -0
  59. edsl/notebooks/notebook.py +7 -3
  60. edsl/notebooks/notebook_to_latex.py +1 -1
  61. edsl/prompts/__init__.py +23 -2
  62. edsl/prompts/prompt.py +1 -1
  63. edsl/questions/__init__.py +4 -4
  64. edsl/questions/answer_validator_mixin.py +0 -5
  65. edsl/questions/compose_questions.py +2 -2
  66. edsl/questions/descriptors.py +1 -1
  67. edsl/questions/question_base.py +32 -3
  68. edsl/questions/question_base_prompts_mixin.py +4 -4
  69. edsl/questions/question_budget.py +503 -102
  70. edsl/questions/question_check_box.py +658 -156
  71. edsl/questions/question_dict.py +176 -2
  72. edsl/questions/question_extract.py +401 -61
  73. edsl/questions/question_free_text.py +77 -9
  74. edsl/questions/question_functional.py +118 -9
  75. edsl/questions/{derived/question_likert_five.py → question_likert_five.py} +2 -2
  76. edsl/questions/{derived/question_linear_scale.py → question_linear_scale.py} +3 -4
  77. edsl/questions/question_list.py +246 -26
  78. edsl/questions/question_matrix.py +586 -73
  79. edsl/questions/question_multiple_choice.py +213 -47
  80. edsl/questions/question_numerical.py +360 -29
  81. edsl/questions/question_rank.py +401 -124
  82. edsl/questions/question_registry.py +3 -3
  83. edsl/questions/{derived/question_top_k.py → question_top_k.py} +3 -3
  84. edsl/questions/{derived/question_yes_no.py → question_yes_no.py} +3 -4
  85. edsl/questions/register_questions_meta.py +2 -1
  86. edsl/questions/response_validator_abc.py +6 -2
  87. edsl/questions/response_validator_factory.py +10 -12
  88. edsl/results/report.py +1 -1
  89. edsl/results/result.py +7 -4
  90. edsl/results/results.py +471 -271
  91. edsl/results/results_selector.py +2 -2
  92. edsl/scenarios/construct_download_link.py +3 -3
  93. edsl/scenarios/scenario.py +1 -2
  94. edsl/scenarios/scenario_list.py +41 -23
  95. edsl/surveys/survey_css.py +3 -3
  96. edsl/surveys/survey_simulator.py +2 -1
  97. edsl/tasks/__init__.py +22 -2
  98. edsl/tasks/exceptions.py +72 -0
  99. edsl/tasks/task_history.py +3 -3
  100. edsl/tokens/__init__.py +27 -1
  101. edsl/tokens/exceptions.py +37 -0
  102. edsl/tokens/interview_token_usage.py +3 -2
  103. edsl/tokens/token_usage.py +4 -3
  104. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/METADATA +1 -1
  105. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/RECORD +108 -106
  106. edsl/questions/derived/__init__.py +0 -0
  107. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/LICENSE +0 -0
  108. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/WHEEL +0 -0
  109. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
  from typing import Any, Optional, TYPE_CHECKING
3
3
 
4
+ import random
4
5
  from jinja2 import Template
5
- from pydantic import BaseModel, Field
6
+ from pydantic import BaseModel, Field, model_validator, ValidationError
6
7
  from typing import List, Literal, Annotated
7
8
 
8
9
  from .exceptions import QuestionAnswerValidationError
@@ -17,9 +18,45 @@ from .decorators import inject_exception
17
18
  from .response_validator_abc import ResponseValidatorABC
18
19
 
19
20
  if TYPE_CHECKING:
20
- from .data_structures import (
21
- BaseResponse,
22
- )
21
+ pass
22
+
23
+
24
+ class CheckboxResponse(BaseModel):
25
+ """
26
+ Base Pydantic model for validating checkbox responses.
27
+
28
+ This model defines the structure and validation rules for responses to
29
+ checkbox questions, ensuring that selected options are properly formatted
30
+ as a list of choices.
31
+
32
+ Attributes:
33
+ answer: List of selected choices
34
+ comment: Optional comment provided with the answer
35
+ generated_tokens: Optional raw LLM output for token tracking
36
+
37
+ Examples:
38
+ >>> # Valid response with list of options
39
+ >>> response = CheckboxResponse(answer=[0, 1])
40
+ >>> response.answer
41
+ [0, 1]
42
+
43
+ >>> # Valid response with comment
44
+ >>> response = CheckboxResponse(answer=[1], comment="This is my choice")
45
+ >>> response.answer
46
+ [1]
47
+ >>> response.comment
48
+ 'This is my choice'
49
+
50
+ >>> # Invalid non-list answer
51
+ >>> try:
52
+ ... CheckboxResponse(answer=1)
53
+ ... except Exception as e:
54
+ ... print("Validation error occurred")
55
+ Validation error occurred
56
+ """
57
+ answer: List[Any]
58
+ comment: Optional[str] = None
59
+ generated_tokens: Optional[Any] = None
23
60
 
24
61
 
25
62
  def create_checkbox_response_model(
@@ -30,40 +67,218 @@ def create_checkbox_response_model(
30
67
  ):
31
68
  """
32
69
  Dynamically create a CheckboxResponse model with a predefined list of choices.
33
-
34
- :param choices: A list of allowed values for the answer field.
35
- :param include_comment: Whether to include a comment field in the model.
36
- :return: A new Pydantic model class.
70
+
71
+ This function creates a customized Pydantic model for checkbox questions that
72
+ validates both the format of the response and any constraints on selection count.
73
+
74
+ Args:
75
+ choices: A list of allowed values for the answer field
76
+ min_selections: Optional minimum number of selections required
77
+ max_selections: Optional maximum number of selections allowed
78
+ permissive: If True, constraints are not enforced
79
+
80
+ Returns:
81
+ A new Pydantic model class with appropriate validation
82
+
83
+ Examples:
84
+ >>> # Create model with constraints
85
+ >>> choices = [0, 1, 2, 3]
86
+ >>> ConstrainedModel = create_checkbox_response_model(
87
+ ... choices=choices,
88
+ ... min_selections=1,
89
+ ... max_selections=2
90
+ ... )
91
+
92
+ >>> # Valid response within constraints
93
+ >>> response = ConstrainedModel(answer=[0, 1])
94
+ >>> response.answer
95
+ [0, 1]
96
+
97
+ >>> # Too few selections fails validation
98
+ >>> try:
99
+ ... ConstrainedModel(answer=[])
100
+ ... except Exception as e:
101
+ ... "at least 1" in str(e)
102
+ True
103
+
104
+ >>> # Too many selections fails validation
105
+ >>> try:
106
+ ... ConstrainedModel(answer=[0, 1, 2])
107
+ ... except Exception as e:
108
+ ... "at most 2" in str(e)
109
+ True
110
+
111
+ >>> # Invalid choice fails validation
112
+ >>> try:
113
+ ... ConstrainedModel(answer=[4])
114
+ ... except Exception as e:
115
+ ... any(x in str(e) for x in ["Invalid choice", "not a valid enumeration member", "validation error"])
116
+ True
117
+
118
+ >>> # Permissive model ignores constraints
119
+ >>> PermissiveModel = create_checkbox_response_model(
120
+ ... choices=choices,
121
+ ... min_selections=1,
122
+ ... max_selections=2,
123
+ ... permissive=True
124
+ ... )
125
+ >>> response = PermissiveModel(answer=[0, 1, 2])
126
+ >>> len(response.answer)
127
+ 3
37
128
  """
38
129
  # Convert the choices list to a tuple for use with Literal
39
130
  choice_tuple = tuple(choices)
40
131
 
41
- field_params = {}
42
- if min_selections is not None and not permissive:
43
- field_params["min_items"] = min_selections
44
- if max_selections is not None and not permissive:
45
- field_params["max_items"] = max_selections
46
-
47
- class CheckboxResponse(BaseModel):
48
- answer: Annotated[
49
- List[Literal[choice_tuple]],
50
- Field(..., **field_params),
51
- ] = Field(..., description="List of selected choices")
52
- comment: Optional[str] = Field(None, description="Optional comment field")
53
- generated_tokens: Optional[Any] = Field(default=None)
54
-
55
- class Config:
56
- @staticmethod
57
- def json_schema_extra(schema: dict, model: BaseModel) -> None:
58
- # Add the list of choices to the schema for better documentation
59
- for prop in schema.get("properties", {}).values():
60
- if prop.get("title") == "answer":
61
- prop["items"] = {"enum": choices}
62
-
63
- return CheckboxResponse
132
+ if permissive:
133
+ # For permissive mode, we still validate the choice values but ignore count constraints
134
+ class PermissiveCheckboxResponse(CheckboxResponse):
135
+ """Checkbox response model with choices validation but no count constraints."""
136
+
137
+ answer: Annotated[
138
+ List[Literal[choice_tuple]],
139
+ Field(description="List of selected choices"),
140
+ ]
141
+
142
+ @model_validator(mode='after')
143
+ def validate_choices(self):
144
+ """Validate that each selected choice is valid."""
145
+ for choice in self.answer:
146
+ if choice not in choices:
147
+ validation_error = ValidationError.from_exception_data(
148
+ title='CheckboxResponse',
149
+ line_errors=[{
150
+ 'type': 'value_error',
151
+ 'loc': ('answer',),
152
+ 'msg': f'Invalid choice: {choice}. Must be one of: {choices}',
153
+ 'input': choice,
154
+ 'ctx': {'error': 'Invalid choice'}
155
+ }]
156
+ )
157
+ raise QuestionAnswerValidationError(
158
+ message=f"Invalid choice: {choice}. Must be one of: {choices}",
159
+ data=self.model_dump(),
160
+ model=self.__class__,
161
+ pydantic_error=validation_error
162
+ )
163
+ return self
164
+
165
+ return PermissiveCheckboxResponse
166
+ else:
167
+ # For non-permissive mode, enforce both choice values and count constraints
168
+ class ConstrainedCheckboxResponse(CheckboxResponse):
169
+ """Checkbox response model with both choice and count constraints."""
170
+
171
+ answer: Annotated[
172
+ List[Literal[choice_tuple]],
173
+ Field(description="List of selected choices"),
174
+ ]
175
+
176
+ @model_validator(mode='after')
177
+ def validate_selection_count(self):
178
+ """Validate that the number of selections meets constraints."""
179
+ if min_selections is not None and len(self.answer) < min_selections:
180
+ validation_error = ValidationError.from_exception_data(
181
+ title='CheckboxResponse',
182
+ line_errors=[{
183
+ 'type': 'value_error',
184
+ 'loc': ('answer',),
185
+ 'msg': f'Must select at least {min_selections} option(s)',
186
+ 'input': self.answer,
187
+ 'ctx': {'error': 'Too few selections'}
188
+ }]
189
+ )
190
+ raise QuestionAnswerValidationError(
191
+ message=f"Must select at least {min_selections} option(s), got {len(self.answer)}",
192
+ data=self.model_dump(),
193
+ model=self.__class__,
194
+ pydantic_error=validation_error
195
+ )
196
+
197
+ if max_selections is not None and len(self.answer) > max_selections:
198
+ validation_error = ValidationError.from_exception_data(
199
+ title='CheckboxResponse',
200
+ line_errors=[{
201
+ 'type': 'value_error',
202
+ 'loc': ('answer',),
203
+ 'msg': f'Must select at most {max_selections} option(s)',
204
+ 'input': self.answer,
205
+ 'ctx': {'error': 'Too many selections'}
206
+ }]
207
+ )
208
+ raise QuestionAnswerValidationError(
209
+ message=f"Must select at most {max_selections} option(s), got {len(self.answer)}",
210
+ data=self.model_dump(),
211
+ model=self.__class__,
212
+ pydantic_error=validation_error
213
+ )
214
+
215
+ # Also validate that each choice is valid
216
+ for choice in self.answer:
217
+ if choice not in choices:
218
+ validation_error = ValidationError.from_exception_data(
219
+ title='CheckboxResponse',
220
+ line_errors=[{
221
+ 'type': 'value_error',
222
+ 'loc': ('answer',),
223
+ 'msg': f'Invalid choice: {choice}. Must be one of: {choices}',
224
+ 'input': choice,
225
+ 'ctx': {'error': 'Invalid choice'}
226
+ }]
227
+ )
228
+ raise QuestionAnswerValidationError(
229
+ message=f"Invalid choice: {choice}. Must be one of: {choices}",
230
+ data=self.model_dump(),
231
+ model=self.__class__,
232
+ pydantic_error=validation_error
233
+ )
234
+
235
+ return self
236
+
237
+ return ConstrainedCheckboxResponse
64
238
 
65
239
 
66
240
  class CheckBoxResponseValidator(ResponseValidatorABC):
241
+ """
242
+ Validator for checkbox question responses.
243
+
244
+ This class implements the validation and fixing logic for checkbox responses.
245
+ It ensures that responses contain valid selections from the available options
246
+ and that the number of selections meets any constraints.
247
+
248
+ Attributes:
249
+ required_params: List of required parameters for validation.
250
+ valid_examples: Examples of valid responses for testing.
251
+ invalid_examples: Examples of invalid responses for testing.
252
+
253
+ Examples:
254
+ >>> from edsl import QuestionCheckBox
255
+ >>> q = QuestionCheckBox.example()
256
+ >>> validator = q.response_validator
257
+
258
+ >>> # Fix string to list
259
+ >>> response = {"answer": 1}
260
+ >>> fixed = validator.fix(response)
261
+ >>> isinstance(fixed["answer"], list)
262
+ True
263
+
264
+ >>> # Extract selections from text
265
+ >>> response = {"generated_tokens": "I choose options 0 and 2"}
266
+ >>> fixed = validator.fix(response)
267
+ >>> sorted(fixed["answer"])
268
+ [0, 2]
269
+
270
+ >>> # Fix comma-separated list
271
+ >>> response = {"generated_tokens": "0, 1, 3"}
272
+ >>> fixed = validator.fix(response)
273
+ >>> sorted(fixed["answer"])
274
+ [0, 1, 3]
275
+
276
+ >>> # Preserve comments when fixing
277
+ >>> response = {"answer": 1, "comment": "My explanation"}
278
+ >>> fixed = validator.fix(response)
279
+ >>> "comment" in fixed and fixed["comment"] == "My explanation"
280
+ True
281
+ """
67
282
  required_params = [
68
283
  "question_options",
69
284
  "min_selections",
@@ -80,12 +295,12 @@ class CheckBoxResponseValidator(ResponseValidatorABC):
80
295
  (
81
296
  {"answer": [-1]},
82
297
  {"question_options": ["Good", "Great", "OK", "Bad"]},
83
- "Answer code must be a non-negative integer",
298
+ "Invalid choice",
84
299
  ),
85
300
  (
86
301
  {"answer": 1},
87
302
  {"question_options": ["Good", "Great", "OK", "Bad"]},
88
- "Answer code must be a list",
303
+ "value is not a valid list",
89
304
  ),
90
305
  (
91
306
  {"answer": [1, 2, 3, 4]},
@@ -94,84 +309,217 @@ class CheckBoxResponseValidator(ResponseValidatorABC):
94
309
  "min_selections": 1,
95
310
  "max_selections": 2,
96
311
  },
97
- "Too many options selected",
312
+ "Must select at most 2",
98
313
  ),
99
314
  ]
100
315
 
101
316
  def fix(self, response, verbose=False):
317
+ """
318
+ Fix common issues in checkbox responses.
319
+
320
+ This method attempts to extract valid selections from responses with
321
+ format issues. It can handle:
322
+ 1. Single values that should be lists
323
+ 2. Comma-separated strings in answer field or generated_tokens
324
+ 3. Finding option indices mentioned in text
325
+
326
+ Args:
327
+ response: The response dictionary to fix
328
+ verbose: If True, print information about the fixing process
329
+
330
+ Returns:
331
+ A fixed version of the response dictionary with a valid list of selections
332
+
333
+ Notes:
334
+ - First tries to convert to a list if the answer is not already a list
335
+ - Then tries to parse comma-separated values from answer or generated_tokens
336
+ - Finally tries to find option indices mentioned in the text
337
+ - Preserves any comment in the original response
338
+ """
102
339
  if verbose:
103
340
  print("Invalid response of QuestionCheckBox was: ", response)
104
- response_text = response.get("generated_tokens")
105
- if response_text is None or response_text == "": # nothing to be done
106
- return response
107
- # Maybe it's a comma separated list?
108
- proposed_list = response_text.split(",")
109
- proposed_list = [item.strip() for item in proposed_list]
110
- if verbose:
111
- print("Using code? ", self.use_code)
112
- if self.use_code:
113
- try:
114
- proposed_list = [int(i) for i in proposed_list]
115
- except ValueError:
116
- # print("Could not convert to int")
117
- pass
118
-
119
- if verbose:
120
- print("Proposed solution is: ", proposed_list)
121
-
122
- # print(f"Ivalid generated tokens was was: {response_text}")
123
- if "comment" in response:
124
- proposed_data = {
125
- "answer": proposed_list,
126
- "comment": response["comment"],
127
- "generated_tokens": response.get("generated_tokens", None),
128
- }
129
- else:
341
+
342
+ # Check if answer exists and is a comma-separated string (common LLM output format)
343
+ if "answer" in response and isinstance(response["answer"], str) and "," in response["answer"]:
344
+ if verbose:
345
+ print(f"Parsing comma-separated answer string: {response['answer']}")
346
+
347
+ # Split by commas and strip whitespace
348
+ proposed_list = response["answer"].split(",")
349
+ proposed_list = [item.strip() for item in proposed_list]
350
+
351
+ # Try to convert to integers if use_code is True
352
+ if self.use_code:
353
+ try:
354
+ proposed_list = [int(i) for i in proposed_list]
355
+ except ValueError:
356
+ # If we can't convert to integers, try to match values to indices
357
+ if verbose:
358
+ print("Could not convert comma-separated values to integers, trying to match options")
359
+
360
+ # Try to match option text values to their indices
361
+ index_map = {}
362
+ for i, option in enumerate(self.question_options):
363
+ index_map[option.lower().strip()] = i
364
+
365
+ converted_list = []
366
+ for item in proposed_list:
367
+ item_lower = item.lower().strip()
368
+ if item_lower in index_map:
369
+ converted_list.append(index_map[item_lower])
370
+
371
+ if converted_list:
372
+ proposed_list = converted_list
373
+
374
+ if verbose:
375
+ print("Proposed solution from comma separation is: ", proposed_list)
376
+
130
377
  proposed_data = {
131
378
  "answer": proposed_list,
132
- "generated_tokens": response.get("generated_tokens", None),
379
+ "comment": response.get("comment"),
380
+ "generated_tokens": response.get("generated_tokens"),
133
381
  }
134
-
135
- try:
136
- self.response_model(**proposed_data)
137
- print("Proposed solution is valid")
138
- print("Returning proposed data: ", proposed_data)
139
- return proposed_data
140
- except Exception as e:
141
- if verbose:
142
- print(f"Proposed solution {proposed_data} is invalid. Error: {e}")
143
- # return response
144
- if verbose:
145
- print("Now seeing if responses show up in the answer")
146
- matches = []
147
- for index, option in enumerate(self.question_options):
148
- if self.use_code:
149
- if str(index) in response_text:
150
- matches.append(index)
151
- else:
152
- if option in response_text:
153
- matches.append(index)
154
- proposed_data = {
155
- "answer": matches,
156
- "comment": response.get("comment", None),
157
- "generated_tokens": response.get("generated_tokens", None),
158
- }
159
- try:
160
- self.response_model(**proposed_data)
161
- return proposed_data
162
- except Exception as e:
382
+
383
+ # Try validating with the proposed solution
384
+ try:
385
+ validated = self._base_validate(proposed_data)
386
+ return validated.model_dump()
387
+ except Exception as e:
388
+ if verbose:
389
+ print(f"Comma-separated solution invalid: {e}")
390
+
391
+ # If answer exists but is not a list, convert it to a list
392
+ elif "answer" in response and not isinstance(response["answer"], list):
163
393
  if verbose:
164
- print(f"Proposed solution {proposed_data} is invalid. Error: {e}")
165
- return response
166
-
167
- def custom_validate(self, response) -> 'BaseResponse':
168
- if response.answer is None:
169
- raise QuestionAnswerValidationError("Answer is missing.")
170
- return response.dict()
394
+ print(f"Converting non-list answer {response['answer']} to a list")
395
+ answer_value = response["answer"]
396
+ response = {**response, "answer": [answer_value]}
397
+
398
+ # Try validating the fixed response
399
+ try:
400
+ validated = self._base_validate(response)
401
+ return validated.model_dump()
402
+ except Exception:
403
+ if verbose:
404
+ print("Converting to list didn't fix the issue")
405
+
406
+ # Try parsing from generated_tokens if present
407
+ response_text = response.get("generated_tokens")
408
+ if response_text and isinstance(response_text, str):
409
+ # Try comma-separated list first
410
+ if "," in response_text:
411
+ proposed_list = response_text.split(",")
412
+ proposed_list = [item.strip() for item in proposed_list]
413
+
414
+ if self.use_code:
415
+ try:
416
+ proposed_list = [int(i) for i in proposed_list]
417
+ except ValueError:
418
+ # If we can't convert to integers, try to match values to indices
419
+ if verbose:
420
+ print("Could not convert comma-separated values to integers, trying to match options")
421
+
422
+ # Try to match option text values to their indices
423
+ index_map = {}
424
+ for i, option in enumerate(self.question_options):
425
+ index_map[option.lower().strip()] = i
426
+
427
+ converted_list = []
428
+ for item in proposed_list:
429
+ item_lower = item.lower().strip()
430
+ if item_lower in index_map:
431
+ converted_list.append(index_map[item_lower])
432
+
433
+ if converted_list:
434
+ proposed_list = converted_list
435
+
436
+ if verbose:
437
+ print("Proposed solution from comma separation is: ", proposed_list)
438
+
439
+ proposed_data = {
440
+ "answer": proposed_list,
441
+ "comment": response.get("comment"),
442
+ "generated_tokens": response.get("generated_tokens"),
443
+ }
444
+
445
+ # Try validating with the proposed solution
446
+ try:
447
+ validated = self._base_validate(proposed_data)
448
+ return validated.model_dump()
449
+ except Exception as e:
450
+ if verbose:
451
+ print(f"Comma-separated solution invalid: {e}")
452
+
453
+ # Try finding option indices mentioned in the text
454
+ matches = []
455
+ for index, option in enumerate(self.question_options):
456
+ if self.use_code:
457
+ if str(index) in response_text:
458
+ matches.append(index)
459
+ else:
460
+ if option in response_text:
461
+ matches.append(option)
462
+
463
+ if matches:
464
+ if verbose:
465
+ print(f"Found options mentioned in text: {matches}")
466
+
467
+ proposed_data = {
468
+ "answer": matches,
469
+ "comment": response.get("comment"),
470
+ "generated_tokens": response.get("generated_tokens"),
471
+ }
472
+
473
+ # Try validating with the proposed solution
474
+ try:
475
+ validated = self._base_validate(proposed_data)
476
+ return validated.model_dump()
477
+ except Exception as e:
478
+ if verbose:
479
+ print(f"Text matching solution invalid: {e}")
480
+
481
+ # If nothing worked, return the original response
482
+ return response
171
483
 
172
484
 
173
485
  class QuestionCheckBox(QuestionBase):
174
- """This question prompts the agent to select options from a list."""
486
+ """
487
+ A question that prompts the agent to select multiple options from a list.
488
+
489
+ QuestionCheckBox allows agents to select one or more items from a predefined
490
+ list of options. It's useful for "select all that apply" scenarios, multi-select
491
+ preferences, or any question where multiple valid selections are possible.
492
+
493
+ Attributes:
494
+ question_type (str): Identifier for this question type, set to "checkbox".
495
+ purpose (str): Brief description of when to use this question type.
496
+ question_options: List of available options to select from.
497
+ min_selections: Optional minimum number of selections required.
498
+ max_selections: Optional maximum number of selections allowed.
499
+ _response_model: Initially None, set by create_response_model().
500
+ response_validator_class: Class used to validate and fix responses.
501
+
502
+ Examples:
503
+ >>> # Basic creation works
504
+ >>> q = QuestionCheckBox.example()
505
+ >>> q.question_type
506
+ 'checkbox'
507
+
508
+ >>> # Create preferences question with selection constraints
509
+ >>> q = QuestionCheckBox(
510
+ ... question_name="favorite_fruits",
511
+ ... question_text="Which fruits do you like?",
512
+ ... question_options=["Apple", "Banana", "Cherry", "Durian", "Elderberry"],
513
+ ... min_selections=1,
514
+ ... max_selections=3
515
+ ... )
516
+ >>> q.question_options
517
+ ['Apple', 'Banana', 'Cherry', 'Durian', 'Elderberry']
518
+ >>> q.min_selections
519
+ 1
520
+ >>> q.max_selections
521
+ 3
522
+ """
175
523
 
176
524
  question_type = "checkbox"
177
525
  purpose = "When options are known and limited"
@@ -195,13 +543,41 @@ class QuestionCheckBox(QuestionBase):
195
543
  answering_instructions: Optional[str] = None,
196
544
  permissive: bool = False,
197
545
  ):
198
- """Instantiate a new QuestionCheckBox.
199
-
200
- :param question_name: The name of the question.
201
- :param question_text: The text of the question.
202
- :param question_options: The options the respondent should select from.
203
- :param min_selections: The minimum number of options that must be selected.
204
- :param max_selections: The maximum number of options that must be selected.
546
+ """
547
+ Initialize a new checkbox question.
548
+
549
+ Args:
550
+ question_name: Identifier for the question, used in results and templates.
551
+ Must be a valid Python variable name.
552
+ question_text: The actual text of the question to be asked.
553
+ question_options: List of options the agent can select from.
554
+ min_selections: Optional minimum number of options that must be selected.
555
+ max_selections: Optional maximum number of options that can be selected.
556
+ include_comment: Whether to allow comments with the answer.
557
+ use_code: If True, use indices (0,1,2...) instead of option text values.
558
+ question_presentation: Optional custom presentation template.
559
+ answering_instructions: Optional additional instructions.
560
+ permissive: If True, ignore selection count constraints during validation.
561
+
562
+ Examples:
563
+ >>> q = QuestionCheckBox(
564
+ ... question_name="symptoms",
565
+ ... question_text="Select all symptoms you are experiencing:",
566
+ ... question_options=["Fever", "Cough", "Headache", "Fatigue"],
567
+ ... min_selections=1
568
+ ... )
569
+ >>> q.question_name
570
+ 'symptoms'
571
+
572
+ >>> # Question with both min and max
573
+ >>> q = QuestionCheckBox(
574
+ ... question_name="pizza_toppings",
575
+ ... question_text="Choose 2-4 toppings for your pizza:",
576
+ ... question_options=["Cheese", "Pepperoni", "Mushroom", "Onion",
577
+ ... "Sausage", "Bacon", "Pineapple"],
578
+ ... min_selections=2,
579
+ ... max_selections=4
580
+ ... )
205
581
  """
206
582
  self.question_name = question_name
207
583
  self.question_text = question_text
@@ -217,18 +593,35 @@ class QuestionCheckBox(QuestionBase):
217
593
  self.answering_instructions = answering_instructions
218
594
 
219
595
  def create_response_model(self):
596
+ """
597
+ Create a response model with the appropriate constraints.
598
+
599
+ This method creates a Pydantic model customized with the options and
600
+ selection count constraints specified for this question instance.
601
+
602
+ Returns:
603
+ A Pydantic model class tailored to this question's constraints.
604
+
605
+ Examples:
606
+ >>> q = QuestionCheckBox.example()
607
+ >>> model = q.create_response_model()
608
+ >>> model(answer=[0, 2]) # Select first and third options
609
+ ConstrainedCheckboxResponse(answer=[0, 2], comment=None, generated_tokens=None)
610
+ """
220
611
  if not self._use_code:
612
+ # Use option text values as valid choices
221
613
  return create_checkbox_response_model(
222
614
  self.question_options,
223
615
  min_selections=self.min_selections,
224
- max_selections=self.max_selections, # include_comment=self._include_comment
616
+ max_selections=self.max_selections,
225
617
  permissive=self.permissive,
226
618
  )
227
619
  else:
620
+ # Use option indices (0, 1, 2...) as valid choices
228
621
  return create_checkbox_response_model(
229
622
  list(range(len(self.question_options))),
230
623
  min_selections=self.min_selections,
231
- max_selections=self.max_selections, # include_comment=self._include_comment
624
+ max_selections=self.max_selections,
232
625
  permissive=self.permissive,
233
626
  )
234
627
 
@@ -236,10 +629,27 @@ class QuestionCheckBox(QuestionBase):
236
629
  self, answer_codes, scenario: "Scenario" = None
237
630
  ):
238
631
  """
239
- Translate the answer code to the actual answer.
240
-
241
- For example, for question options ["a", "b", "c"],the answer codes are 0, 1, and 2.
242
- The LLM will respond with [0,1] and this code will translate it to ["a","b"].
632
+ Translate the answer codes to the actual answer text.
633
+
634
+ For checkbox questions with use_code=True, the agent responds with
635
+ option indices (e.g., [0, 1]) which need to be translated to their
636
+ corresponding option text values (e.g., ["Option A", "Option B"]).
637
+
638
+ Args:
639
+ answer_codes: List of selected option indices or values
640
+ scenario: Optional scenario with variables for template rendering
641
+
642
+ Returns:
643
+ List of selected option texts
644
+
645
+ Examples:
646
+ >>> q = QuestionCheckBox(
647
+ ... question_name="example",
648
+ ... question_text="Select options:",
649
+ ... question_options=["A", "B", "C"]
650
+ ... )
651
+ >>> q._translate_answer_code_to_answer([0, 2])
652
+ ['A', 'C']
243
653
  """
244
654
  scenario = scenario or Scenario()
245
655
  translated_options = [
@@ -253,38 +663,73 @@ class QuestionCheckBox(QuestionBase):
253
663
  translated_codes.append(answer_code)
254
664
  return translated_codes
255
665
 
256
- # def _simulate_answer(self, human_readable=True) -> dict[str, Union[int, str]]:
257
- # """Simulate a valid answer for debugging purposes."""
258
- # from edsl.utilities.utilities import random_string
259
-
260
- # min_selections = self.min_selections or 1
261
- # max_selections = self.max_selections or len(self.question_options)
262
- # num_selections = random.randint(min_selections, max_selections)
263
- # if human_readable:
264
- # # Select a random number of options from self.question_options
265
- # selected_options = random.sample(self.question_options, num_selections)
266
- # answer = {
267
- # "answer": selected_options,
268
- # "comment": random_string(),
269
- # }
270
- # else:
271
- # # Select a random number of indices from the range of self.question_options
272
- # selected_indices = random.sample(
273
- # range(len(self.question_options)), num_selections
274
- # )
275
- # answer = {
276
- # "answer": selected_indices,
277
- # "comment": random_string(),
278
- # }
279
- # return answer
666
+ def _simulate_answer(self, human_readable=True):
667
+ """
668
+ Simulate a valid answer for debugging purposes.
669
+
670
+ This method generates a random valid answer for the checkbox question,
671
+ useful for testing and demonstrations.
672
+
673
+ Args:
674
+ human_readable: If True, return option text values; if False, return indices
675
+
676
+ Returns:
677
+ A dictionary with a valid random answer
678
+
679
+ Examples:
680
+ >>> q = QuestionCheckBox.example()
681
+ >>> answer = q._simulate_answer(human_readable=False)
682
+ >>> len(answer["answer"]) >= q.min_selections
683
+ True
684
+ >>> len(answer["answer"]) <= q.max_selections
685
+ True
686
+ """
687
+ from edsl.utilities.utilities import random_string
688
+
689
+ min_sel = self.min_selections or 1
690
+ max_sel = self.max_selections or len(self.question_options)
691
+ # Ensure we don't try to select more options than available
692
+ max_sel = min(max_sel, len(self.question_options))
693
+ min_sel = min(min_sel, max_sel)
694
+
695
+ num_selections = random.randint(min_sel, max_sel)
696
+
697
+ if human_readable:
698
+ # Select a random number of options from self.question_options
699
+ selected_options = random.sample(self.question_options, num_selections)
700
+ answer = {
701
+ "answer": selected_options,
702
+ "comment": random_string(),
703
+ }
704
+ else:
705
+ # Select a random number of indices from the range of self.question_options
706
+ selected_indices = random.sample(
707
+ range(len(self.question_options)), num_selections
708
+ )
709
+ answer = {
710
+ "answer": selected_indices,
711
+ "comment": random_string(),
712
+ }
713
+ return answer
280
714
 
281
715
  @property
282
716
  def question_html_content(self) -> str:
717
+ """
718
+ Generate HTML content for rendering the question in web interfaces.
719
+
720
+ This property generates HTML markup for the question when it needs to be
721
+ displayed in web interfaces or HTML contexts. For a checkbox question,
722
+ this is a set of checkbox input elements, one for each option.
723
+
724
+ Returns:
725
+ str: HTML markup for rendering the question.
726
+ """
283
727
  instructions = ""
284
728
  if self.min_selections is not None:
285
729
  instructions += f"Select at least {self.min_selections} option(s). "
286
730
  if self.max_selections is not None:
287
731
  instructions += f"Select at most {self.max_selections} option(s)."
732
+
288
733
  question_html_content = Template(
289
734
  """
290
735
  <p>{{ instructions }}</p>
@@ -308,7 +753,30 @@ class QuestionCheckBox(QuestionBase):
308
753
  @classmethod
309
754
  @inject_exception
310
755
  def example(cls, include_comment=False, use_code=True) -> QuestionCheckBox:
311
- """Return an example checkbox question."""
756
+ """
757
+ Create an example instance of a checkbox question.
758
+
759
+ This class method creates a predefined example of a checkbox question
760
+ for demonstration, testing, and documentation purposes.
761
+
762
+ Args:
763
+ include_comment: Whether to include a comment field with the answer.
764
+ use_code: Whether to use indices (True) or values (False) for answer codes.
765
+
766
+ Returns:
767
+ QuestionCheckBox: An example checkbox question.
768
+
769
+ Examples:
770
+ >>> q = QuestionCheckBox.example()
771
+ >>> q.question_name
772
+ 'never_eat'
773
+ >>> len(q.question_options)
774
+ 5
775
+ >>> q.min_selections
776
+ 2
777
+ >>> q.max_selections
778
+ 5
779
+ """
312
780
  return cls(
313
781
  question_name="never_eat",
314
782
  question_text="Which of the following foods would you eat if you had to?",
@@ -327,31 +795,65 @@ class QuestionCheckBox(QuestionBase):
327
795
 
328
796
 
329
797
  def main():
330
- """Create an example QuestionCheckBox and test its methods."""
331
- from edsl.questions.QuestionCheckBox import QuestionCheckBox
332
-
798
+ """
799
+ Demonstrate the functionality of the QuestionCheckBox class.
800
+
801
+ This function creates an example checkbox question and demonstrates its
802
+ key features including validation, serialization, and answer simulation.
803
+ It's primarily intended for testing and development purposes.
804
+
805
+ Note:
806
+ This function will be executed when the module is run directly,
807
+ but not when imported.
808
+ """
809
+ print("Creating a QuestionCheckBox example...")
333
810
  q = QuestionCheckBox.example()
334
- q.question_text
335
- q.question_options
336
- q.question_name
337
- # validate an answer
338
- q._validate_answer({"answer": [1, 2], "comment": "I like custard"})
339
- # translate answer code
340
- q._translate_answer_code_to_answer([1, 2])
341
- # simulate answer
342
- q._simulate_answer()
343
- q._simulate_answer(human_readable=False)
344
- q._validate_answer(q._simulate_answer(human_readable=False))
345
- # serialization (inherits from Question)
346
- q.to_dict()
347
- assert q.from_dict(q.to_dict()) == q
348
-
811
+ print(f"Question text: {q.question_text}")
812
+ print(f"Question name: {q.question_name}")
813
+ print(f"Question options: {q.question_options}")
814
+ print(f"Min selections: {q.min_selections}")
815
+ print(f"Max selections: {q.max_selections}")
816
+
817
+ # Validate an answer
818
+ print("\nValidating an answer...")
819
+ valid_answer = {"answer": [1, 2], "comment": "I like these foods"}
820
+ validated = q._validate_answer(valid_answer)
821
+ print(f"Validated answer: {validated}")
822
+
823
+ # Translate answer codes
824
+ print("\nTranslating answer codes...")
825
+ translated = q._translate_answer_code_to_answer([1, 2])
826
+ print(f"Translated answer: {translated}")
827
+
828
+ # Simulate answers
829
+ print("\nSimulating answers...")
830
+ simulated_human = q._simulate_answer(human_readable=True)
831
+ print(f"Simulated human-readable answer: {simulated_human}")
832
+
833
+ simulated_codes = q._simulate_answer(human_readable=False)
834
+ print(f"Simulated code answer: {simulated_codes}")
835
+
836
+ # Validate simulated answer
837
+ validated_simulated = q._validate_answer(simulated_codes)
838
+ print(f"Validated simulated answer: {validated_simulated}")
839
+
840
+ # Serialization demonstration
841
+ print("\nTesting serialization...")
842
+ serialized = q.to_dict()
843
+ print(f"Serialized question (keys): {list(serialized.keys())}")
844
+ deserialized = QuestionBase.from_dict(serialized)
845
+ print(f"Deserialization successful: {deserialized.question_text == q.question_text}")
846
+
847
+ # Run doctests
848
+ print("\nRunning doctests...")
349
849
  import doctest
350
-
351
850
  doctest.testmod(optionflags=doctest.ELLIPSIS)
851
+ print("Doctests completed")
352
852
 
353
853
 
354
854
  if __name__ == "__main__":
355
855
  import doctest
356
-
357
856
  doctest.testmod(optionflags=doctest.ELLIPSIS)
857
+
858
+ # Uncomment to run demonstration
859
+ # main()