edsl 0.1.49__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 (257) hide show
  1. edsl/__init__.py +124 -53
  2. edsl/__version__.py +1 -1
  3. edsl/agents/agent.py +21 -21
  4. edsl/agents/agent_list.py +2 -5
  5. edsl/agents/exceptions.py +119 -5
  6. edsl/base/__init__.py +10 -35
  7. edsl/base/base_class.py +71 -36
  8. edsl/base/base_exception.py +204 -0
  9. edsl/base/data_transfer_models.py +1 -1
  10. edsl/base/exceptions.py +94 -0
  11. edsl/buckets/__init__.py +15 -1
  12. edsl/buckets/bucket_collection.py +3 -4
  13. edsl/buckets/exceptions.py +107 -0
  14. edsl/buckets/model_buckets.py +1 -2
  15. edsl/buckets/token_bucket.py +11 -6
  16. edsl/buckets/token_bucket_api.py +27 -12
  17. edsl/buckets/token_bucket_client.py +9 -7
  18. edsl/caching/cache.py +12 -4
  19. edsl/caching/cache_entry.py +10 -9
  20. edsl/caching/exceptions.py +113 -7
  21. edsl/caching/remote_cache_sync.py +6 -7
  22. edsl/caching/sql_dict.py +20 -14
  23. edsl/cli.py +43 -0
  24. edsl/config/__init__.py +1 -1
  25. edsl/config/config_class.py +32 -6
  26. edsl/conversation/Conversation.py +8 -4
  27. edsl/conversation/car_buying.py +1 -3
  28. edsl/conversation/exceptions.py +58 -0
  29. edsl/conversation/mug_negotiation.py +2 -8
  30. edsl/coop/__init__.py +28 -6
  31. edsl/coop/coop.py +120 -29
  32. edsl/coop/coop_functions.py +1 -1
  33. edsl/coop/ep_key_handling.py +1 -1
  34. edsl/coop/exceptions.py +188 -9
  35. edsl/coop/price_fetcher.py +5 -8
  36. edsl/coop/utils.py +4 -6
  37. edsl/dataset/__init__.py +5 -4
  38. edsl/dataset/dataset.py +177 -86
  39. edsl/dataset/dataset_operations_mixin.py +98 -76
  40. edsl/dataset/dataset_tree.py +11 -7
  41. edsl/dataset/display/table_display.py +0 -2
  42. edsl/dataset/display/table_renderers.py +6 -4
  43. edsl/dataset/exceptions.py +125 -0
  44. edsl/dataset/file_exports.py +18 -11
  45. edsl/dataset/r/ggplot.py +13 -6
  46. edsl/display/__init__.py +27 -0
  47. edsl/display/core.py +147 -0
  48. edsl/display/plugin.py +189 -0
  49. edsl/display/utils.py +52 -0
  50. edsl/inference_services/__init__.py +9 -1
  51. edsl/inference_services/available_model_cache_handler.py +1 -1
  52. edsl/inference_services/available_model_fetcher.py +5 -6
  53. edsl/inference_services/data_structures.py +10 -7
  54. edsl/inference_services/exceptions.py +132 -1
  55. edsl/inference_services/inference_service_abc.py +2 -2
  56. edsl/inference_services/inference_services_collection.py +2 -6
  57. edsl/inference_services/registry.py +4 -3
  58. edsl/inference_services/service_availability.py +4 -3
  59. edsl/inference_services/services/anthropic_service.py +4 -1
  60. edsl/inference_services/services/aws_bedrock.py +13 -12
  61. edsl/inference_services/services/azure_ai.py +12 -10
  62. edsl/inference_services/services/deep_infra_service.py +1 -4
  63. edsl/inference_services/services/deep_seek_service.py +1 -5
  64. edsl/inference_services/services/google_service.py +7 -3
  65. edsl/inference_services/services/groq_service.py +1 -1
  66. edsl/inference_services/services/mistral_ai_service.py +4 -2
  67. edsl/inference_services/services/ollama_service.py +1 -1
  68. edsl/inference_services/services/open_ai_service.py +7 -5
  69. edsl/inference_services/services/perplexity_service.py +6 -2
  70. edsl/inference_services/services/test_service.py +8 -7
  71. edsl/inference_services/services/together_ai_service.py +2 -3
  72. edsl/inference_services/services/xai_service.py +1 -1
  73. edsl/instructions/__init__.py +1 -1
  74. edsl/instructions/change_instruction.py +7 -5
  75. edsl/instructions/exceptions.py +61 -0
  76. edsl/instructions/instruction.py +6 -2
  77. edsl/instructions/instruction_collection.py +6 -4
  78. edsl/instructions/instruction_handler.py +12 -15
  79. edsl/interviews/ReportErrors.py +0 -3
  80. edsl/interviews/__init__.py +9 -2
  81. edsl/interviews/answering_function.py +11 -13
  82. edsl/interviews/exception_tracking.py +15 -8
  83. edsl/interviews/exceptions.py +79 -0
  84. edsl/interviews/interview.py +33 -30
  85. edsl/interviews/interview_status_dictionary.py +4 -2
  86. edsl/interviews/interview_status_log.py +2 -1
  87. edsl/interviews/interview_task_manager.py +5 -5
  88. edsl/interviews/request_token_estimator.py +5 -2
  89. edsl/interviews/statistics.py +3 -4
  90. edsl/invigilators/__init__.py +7 -1
  91. edsl/invigilators/exceptions.py +79 -0
  92. edsl/invigilators/invigilator_base.py +0 -1
  93. edsl/invigilators/invigilators.py +9 -13
  94. edsl/invigilators/prompt_constructor.py +1 -5
  95. edsl/invigilators/prompt_helpers.py +8 -4
  96. edsl/invigilators/question_instructions_prompt_builder.py +1 -1
  97. edsl/invigilators/question_option_processor.py +9 -5
  98. edsl/invigilators/question_template_replacements_builder.py +3 -2
  99. edsl/jobs/__init__.py +42 -5
  100. edsl/jobs/async_interview_runner.py +25 -23
  101. edsl/jobs/check_survey_scenario_compatibility.py +11 -10
  102. edsl/jobs/data_structures.py +8 -5
  103. edsl/jobs/exceptions.py +177 -8
  104. edsl/jobs/fetch_invigilator.py +1 -1
  105. edsl/jobs/jobs.py +74 -69
  106. edsl/jobs/jobs_checks.py +6 -7
  107. edsl/jobs/jobs_component_constructor.py +4 -4
  108. edsl/jobs/jobs_pricing_estimation.py +4 -3
  109. edsl/jobs/jobs_remote_inference_logger.py +5 -4
  110. edsl/jobs/jobs_runner_asyncio.py +3 -4
  111. edsl/jobs/jobs_runner_status.py +8 -9
  112. edsl/jobs/remote_inference.py +27 -24
  113. edsl/jobs/results_exceptions_handler.py +10 -7
  114. edsl/key_management/__init__.py +3 -1
  115. edsl/key_management/exceptions.py +62 -0
  116. edsl/key_management/key_lookup.py +1 -1
  117. edsl/key_management/key_lookup_builder.py +37 -14
  118. edsl/key_management/key_lookup_collection.py +2 -0
  119. edsl/language_models/__init__.py +1 -1
  120. edsl/language_models/exceptions.py +302 -14
  121. edsl/language_models/language_model.py +9 -8
  122. edsl/language_models/model.py +4 -4
  123. edsl/language_models/model_list.py +1 -1
  124. edsl/language_models/price_manager.py +1 -1
  125. edsl/language_models/raw_response_handler.py +14 -9
  126. edsl/language_models/registry.py +17 -21
  127. edsl/language_models/repair.py +0 -6
  128. edsl/language_models/unused/fake_openai_service.py +0 -1
  129. edsl/load_plugins.py +69 -0
  130. edsl/logger.py +146 -0
  131. edsl/notebooks/__init__.py +24 -1
  132. edsl/notebooks/exceptions.py +82 -0
  133. edsl/notebooks/notebook.py +7 -3
  134. edsl/notebooks/notebook_to_latex.py +1 -2
  135. edsl/plugins/__init__.py +63 -0
  136. edsl/plugins/built_in/export_example.py +50 -0
  137. edsl/plugins/built_in/pig_latin.py +67 -0
  138. edsl/plugins/cli.py +372 -0
  139. edsl/plugins/cli_typer.py +283 -0
  140. edsl/plugins/exceptions.py +31 -0
  141. edsl/plugins/hookspec.py +51 -0
  142. edsl/plugins/plugin_host.py +128 -0
  143. edsl/plugins/plugin_manager.py +633 -0
  144. edsl/plugins/plugins_registry.py +168 -0
  145. edsl/prompts/__init__.py +24 -1
  146. edsl/prompts/exceptions.py +107 -5
  147. edsl/prompts/prompt.py +15 -7
  148. edsl/questions/HTMLQuestion.py +5 -11
  149. edsl/questions/Quick.py +0 -1
  150. edsl/questions/__init__.py +6 -4
  151. edsl/questions/answer_validator_mixin.py +318 -323
  152. edsl/questions/compose_questions.py +3 -3
  153. edsl/questions/descriptors.py +11 -50
  154. edsl/questions/exceptions.py +278 -22
  155. edsl/questions/loop_processor.py +7 -5
  156. edsl/questions/prompt_templates/question_list.jinja +3 -0
  157. edsl/questions/question_base.py +46 -19
  158. edsl/questions/question_base_gen_mixin.py +2 -2
  159. edsl/questions/question_base_prompts_mixin.py +13 -7
  160. edsl/questions/question_budget.py +503 -98
  161. edsl/questions/question_check_box.py +660 -160
  162. edsl/questions/question_dict.py +345 -194
  163. edsl/questions/question_extract.py +401 -61
  164. edsl/questions/question_free_text.py +80 -14
  165. edsl/questions/question_functional.py +119 -9
  166. edsl/questions/{derived/question_likert_five.py → question_likert_five.py} +2 -2
  167. edsl/questions/{derived/question_linear_scale.py → question_linear_scale.py} +3 -4
  168. edsl/questions/question_list.py +275 -28
  169. edsl/questions/question_matrix.py +643 -96
  170. edsl/questions/question_multiple_choice.py +219 -51
  171. edsl/questions/question_numerical.py +361 -32
  172. edsl/questions/question_rank.py +401 -124
  173. edsl/questions/question_registry.py +7 -5
  174. edsl/questions/{derived/question_top_k.py → question_top_k.py} +3 -3
  175. edsl/questions/{derived/question_yes_no.py → question_yes_no.py} +3 -4
  176. edsl/questions/register_questions_meta.py +2 -2
  177. edsl/questions/response_validator_abc.py +13 -15
  178. edsl/questions/response_validator_factory.py +10 -12
  179. edsl/questions/templates/dict/answering_instructions.jinja +1 -0
  180. edsl/questions/templates/rank/question_presentation.jinja +1 -1
  181. edsl/results/__init__.py +1 -1
  182. edsl/results/exceptions.py +141 -7
  183. edsl/results/report.py +1 -2
  184. edsl/results/result.py +11 -9
  185. edsl/results/results.py +480 -321
  186. edsl/results/results_selector.py +8 -4
  187. edsl/scenarios/PdfExtractor.py +2 -2
  188. edsl/scenarios/construct_download_link.py +69 -35
  189. edsl/scenarios/directory_scanner.py +33 -14
  190. edsl/scenarios/document_chunker.py +1 -1
  191. edsl/scenarios/exceptions.py +238 -14
  192. edsl/scenarios/file_methods.py +1 -1
  193. edsl/scenarios/file_store.py +7 -3
  194. edsl/scenarios/handlers/__init__.py +17 -0
  195. edsl/scenarios/handlers/docx_file_store.py +0 -5
  196. edsl/scenarios/handlers/pdf_file_store.py +0 -1
  197. edsl/scenarios/handlers/pptx_file_store.py +0 -5
  198. edsl/scenarios/handlers/py_file_store.py +0 -1
  199. edsl/scenarios/handlers/sql_file_store.py +1 -4
  200. edsl/scenarios/handlers/sqlite_file_store.py +0 -1
  201. edsl/scenarios/handlers/txt_file_store.py +1 -1
  202. edsl/scenarios/scenario.py +1 -3
  203. edsl/scenarios/scenario_list.py +179 -27
  204. edsl/scenarios/scenario_list_pdf_tools.py +1 -0
  205. edsl/scenarios/scenario_selector.py +0 -1
  206. edsl/surveys/__init__.py +3 -4
  207. edsl/surveys/dag/__init__.py +4 -2
  208. edsl/surveys/descriptors.py +1 -1
  209. edsl/surveys/edit_survey.py +1 -0
  210. edsl/surveys/exceptions.py +165 -9
  211. edsl/surveys/memory/__init__.py +5 -3
  212. edsl/surveys/memory/memory_management.py +1 -0
  213. edsl/surveys/memory/memory_plan.py +6 -15
  214. edsl/surveys/rules/__init__.py +5 -3
  215. edsl/surveys/rules/rule.py +1 -2
  216. edsl/surveys/rules/rule_collection.py +1 -1
  217. edsl/surveys/survey.py +12 -24
  218. edsl/surveys/survey_css.py +3 -3
  219. edsl/surveys/survey_export.py +6 -3
  220. edsl/surveys/survey_flow_visualization.py +10 -1
  221. edsl/surveys/survey_simulator.py +2 -1
  222. edsl/tasks/__init__.py +23 -1
  223. edsl/tasks/exceptions.py +72 -0
  224. edsl/tasks/question_task_creator.py +3 -3
  225. edsl/tasks/task_creators.py +1 -3
  226. edsl/tasks/task_history.py +8 -10
  227. edsl/tasks/task_status_log.py +1 -2
  228. edsl/tokens/__init__.py +29 -1
  229. edsl/tokens/exceptions.py +37 -0
  230. edsl/tokens/interview_token_usage.py +3 -2
  231. edsl/tokens/token_usage.py +4 -3
  232. edsl/utilities/__init__.py +21 -1
  233. edsl/utilities/decorators.py +1 -2
  234. edsl/utilities/markdown_to_docx.py +2 -2
  235. edsl/utilities/markdown_to_pdf.py +1 -1
  236. edsl/utilities/repair_functions.py +0 -1
  237. edsl/utilities/restricted_python.py +0 -1
  238. edsl/utilities/template_loader.py +2 -3
  239. edsl/utilities/utilities.py +8 -29
  240. {edsl-0.1.49.dist-info → edsl-0.1.51.dist-info}/METADATA +32 -2
  241. edsl-0.1.51.dist-info/RECORD +365 -0
  242. edsl-0.1.51.dist-info/entry_points.txt +3 -0
  243. edsl/dataset/smart_objects.py +0 -96
  244. edsl/exceptions/BaseException.py +0 -21
  245. edsl/exceptions/__init__.py +0 -54
  246. edsl/exceptions/configuration.py +0 -16
  247. edsl/exceptions/general.py +0 -34
  248. edsl/questions/derived/__init__.py +0 -0
  249. edsl/study/ObjectEntry.py +0 -173
  250. edsl/study/ProofOfWork.py +0 -113
  251. edsl/study/SnapShot.py +0 -80
  252. edsl/study/Study.py +0 -520
  253. edsl/study/__init__.py +0 -6
  254. edsl/utilities/interface.py +0 -135
  255. edsl-0.1.49.dist-info/RECORD +0 -347
  256. {edsl-0.1.49.dist-info → edsl-0.1.51.dist-info}/LICENSE +0 -0
  257. {edsl-0.1.49.dist-info → edsl-0.1.51.dist-info}/WHEEL +0 -0
@@ -1,11 +1,10 @@
1
1
  from __future__ import annotations
2
- import random
3
- from typing import Any, Optional, Union, TYPE_CHECKING
2
+ from typing import Any, Optional, TYPE_CHECKING
4
3
 
4
+ import random
5
5
  from jinja2 import Template
6
- from pydantic import field_validator
7
- from pydantic import BaseModel, Field, conlist
8
- from typing import List, Literal, Optional, Annotated
6
+ from pydantic import BaseModel, Field, model_validator, ValidationError
7
+ from typing import List, Literal, Annotated
9
8
 
10
9
  from .exceptions import QuestionAnswerValidationError
11
10
  from ..scenarios import Scenario
@@ -19,9 +18,45 @@ from .decorators import inject_exception
19
18
  from .response_validator_abc import ResponseValidatorABC
20
19
 
21
20
  if TYPE_CHECKING:
22
- from .data_structures import (
23
- BaseResponse,
24
- )
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
25
60
 
26
61
 
27
62
  def create_checkbox_response_model(
@@ -32,40 +67,218 @@ def create_checkbox_response_model(
32
67
  ):
33
68
  """
34
69
  Dynamically create a CheckboxResponse model with a predefined list of choices.
35
-
36
- :param choices: A list of allowed values for the answer field.
37
- :param include_comment: Whether to include a comment field in the model.
38
- :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
39
128
  """
40
129
  # Convert the choices list to a tuple for use with Literal
41
130
  choice_tuple = tuple(choices)
42
131
 
43
- field_params = {}
44
- if min_selections is not None and not permissive:
45
- field_params["min_items"] = min_selections
46
- if max_selections is not None and not permissive:
47
- field_params["max_items"] = max_selections
48
-
49
- class CheckboxResponse(BaseModel):
50
- answer: Annotated[
51
- List[Literal[choice_tuple]],
52
- Field(..., **field_params),
53
- ] = Field(..., description="List of selected choices")
54
- comment: Optional[str] = Field(None, description="Optional comment field")
55
- generated_tokens: Optional[Any] = Field(default=None)
56
-
57
- class Config:
58
- @staticmethod
59
- def json_schema_extra(schema: dict, model: BaseModel) -> None:
60
- # Add the list of choices to the schema for better documentation
61
- for prop in schema.get("properties", {}).values():
62
- if prop.get("title") == "answer":
63
- prop["items"] = {"enum": choices}
64
-
65
- 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
66
238
 
67
239
 
68
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
+ """
69
282
  required_params = [
70
283
  "question_options",
71
284
  "min_selections",
@@ -82,12 +295,12 @@ class CheckBoxResponseValidator(ResponseValidatorABC):
82
295
  (
83
296
  {"answer": [-1]},
84
297
  {"question_options": ["Good", "Great", "OK", "Bad"]},
85
- "Answer code must be a non-negative integer",
298
+ "Invalid choice",
86
299
  ),
87
300
  (
88
301
  {"answer": 1},
89
302
  {"question_options": ["Good", "Great", "OK", "Bad"]},
90
- "Answer code must be a list",
303
+ "value is not a valid list",
91
304
  ),
92
305
  (
93
306
  {"answer": [1, 2, 3, 4]},
@@ -96,84 +309,217 @@ class CheckBoxResponseValidator(ResponseValidatorABC):
96
309
  "min_selections": 1,
97
310
  "max_selections": 2,
98
311
  },
99
- "Too many options selected",
312
+ "Must select at most 2",
100
313
  ),
101
314
  ]
102
315
 
103
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
+ """
104
339
  if verbose:
105
340
  print("Invalid response of QuestionCheckBox was: ", response)
106
- response_text = response.get("generated_tokens")
107
- if response_text is None or response_text == "": # nothing to be done
108
- return response
109
- # Maybe it's a comma separated list?
110
- proposed_list = response_text.split(",")
111
- proposed_list = [item.strip() for item in proposed_list]
112
- if verbose:
113
- print("Using code? ", self.use_code)
114
- if self.use_code:
115
- try:
116
- proposed_list = [int(i) for i in proposed_list]
117
- except ValueError:
118
- # print("Could not convert to int")
119
- pass
120
-
121
- if verbose:
122
- print("Proposed solution is: ", proposed_list)
123
-
124
- # print(f"Ivalid generated tokens was was: {response_text}")
125
- if "comment" in response:
126
- proposed_data = {
127
- "answer": proposed_list,
128
- "comment": response["comment"],
129
- "generated_tokens": response.get("generated_tokens", None),
130
- }
131
- 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
+
132
377
  proposed_data = {
133
378
  "answer": proposed_list,
134
- "generated_tokens": response.get("generated_tokens", None),
379
+ "comment": response.get("comment"),
380
+ "generated_tokens": response.get("generated_tokens"),
135
381
  }
136
-
137
- try:
138
- self.response_model(**proposed_data)
139
- print("Proposed solution is valid")
140
- print("Returning proposed data: ", proposed_data)
141
- return proposed_data
142
- except Exception as e:
143
- if verbose:
144
- print(f"Proposed solution {proposed_data} is invalid. Error: {e}")
145
- # return response
146
- if verbose:
147
- print("Now seeing if responses show up in the answer")
148
- matches = []
149
- for index, option in enumerate(self.question_options):
150
- if self.use_code:
151
- if str(index) in response_text:
152
- matches.append(index)
153
- else:
154
- if option in response_text:
155
- matches.append(index)
156
- proposed_data = {
157
- "answer": matches,
158
- "comment": response.get("comment", None),
159
- "generated_tokens": response.get("generated_tokens", None),
160
- }
161
- try:
162
- self.response_model(**proposed_data)
163
- return proposed_data
164
- 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):
165
393
  if verbose:
166
- print(f"Proposed solution {proposed_data} is invalid. Error: {e}")
167
- return response
168
-
169
- def custom_validate(self, response) -> 'BaseResponse':
170
- if response.answer is None:
171
- raise QuestionAnswerValidationError("Answer is missing.")
172
- 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
173
483
 
174
484
 
175
485
  class QuestionCheckBox(QuestionBase):
176
- """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
+ """
177
523
 
178
524
  question_type = "checkbox"
179
525
  purpose = "When options are known and limited"
@@ -197,13 +543,41 @@ class QuestionCheckBox(QuestionBase):
197
543
  answering_instructions: Optional[str] = None,
198
544
  permissive: bool = False,
199
545
  ):
200
- """Instantiate a new QuestionCheckBox.
201
-
202
- :param question_name: The name of the question.
203
- :param question_text: The text of the question.
204
- :param question_options: The options the respondent should select from.
205
- :param min_selections: The minimum number of options that must be selected.
206
- :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
+ ... )
207
581
  """
208
582
  self.question_name = question_name
209
583
  self.question_text = question_text
@@ -219,18 +593,35 @@ class QuestionCheckBox(QuestionBase):
219
593
  self.answering_instructions = answering_instructions
220
594
 
221
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
+ """
222
611
  if not self._use_code:
612
+ # Use option text values as valid choices
223
613
  return create_checkbox_response_model(
224
614
  self.question_options,
225
615
  min_selections=self.min_selections,
226
- max_selections=self.max_selections, # include_comment=self._include_comment
616
+ max_selections=self.max_selections,
227
617
  permissive=self.permissive,
228
618
  )
229
619
  else:
620
+ # Use option indices (0, 1, 2...) as valid choices
230
621
  return create_checkbox_response_model(
231
622
  list(range(len(self.question_options))),
232
623
  min_selections=self.min_selections,
233
- max_selections=self.max_selections, # include_comment=self._include_comment
624
+ max_selections=self.max_selections,
234
625
  permissive=self.permissive,
235
626
  )
236
627
 
@@ -238,10 +629,27 @@ class QuestionCheckBox(QuestionBase):
238
629
  self, answer_codes, scenario: "Scenario" = None
239
630
  ):
240
631
  """
241
- Translate the answer code to the actual answer.
242
-
243
- For example, for question options ["a", "b", "c"],the answer codes are 0, 1, and 2.
244
- 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']
245
653
  """
246
654
  scenario = scenario or Scenario()
247
655
  translated_options = [
@@ -255,38 +663,73 @@ class QuestionCheckBox(QuestionBase):
255
663
  translated_codes.append(answer_code)
256
664
  return translated_codes
257
665
 
258
- # def _simulate_answer(self, human_readable=True) -> dict[str, Union[int, str]]:
259
- # """Simulate a valid answer for debugging purposes."""
260
- # from edsl.utilities.utilities import random_string
261
-
262
- # min_selections = self.min_selections or 1
263
- # max_selections = self.max_selections or len(self.question_options)
264
- # num_selections = random.randint(min_selections, max_selections)
265
- # if human_readable:
266
- # # Select a random number of options from self.question_options
267
- # selected_options = random.sample(self.question_options, num_selections)
268
- # answer = {
269
- # "answer": selected_options,
270
- # "comment": random_string(),
271
- # }
272
- # else:
273
- # # Select a random number of indices from the range of self.question_options
274
- # selected_indices = random.sample(
275
- # range(len(self.question_options)), num_selections
276
- # )
277
- # answer = {
278
- # "answer": selected_indices,
279
- # "comment": random_string(),
280
- # }
281
- # 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
282
714
 
283
715
  @property
284
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
+ """
285
727
  instructions = ""
286
728
  if self.min_selections is not None:
287
729
  instructions += f"Select at least {self.min_selections} option(s). "
288
730
  if self.max_selections is not None:
289
731
  instructions += f"Select at most {self.max_selections} option(s)."
732
+
290
733
  question_html_content = Template(
291
734
  """
292
735
  <p>{{ instructions }}</p>
@@ -310,7 +753,30 @@ class QuestionCheckBox(QuestionBase):
310
753
  @classmethod
311
754
  @inject_exception
312
755
  def example(cls, include_comment=False, use_code=True) -> QuestionCheckBox:
313
- """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
+ """
314
780
  return cls(
315
781
  question_name="never_eat",
316
782
  question_text="Which of the following foods would you eat if you had to?",
@@ -329,31 +795,65 @@ class QuestionCheckBox(QuestionBase):
329
795
 
330
796
 
331
797
  def main():
332
- """Create an example QuestionCheckBox and test its methods."""
333
- from edsl.questions.QuestionCheckBox import QuestionCheckBox
334
-
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...")
335
810
  q = QuestionCheckBox.example()
336
- q.question_text
337
- q.question_options
338
- q.question_name
339
- # validate an answer
340
- q._validate_answer({"answer": [1, 2], "comment": "I like custard"})
341
- # translate answer code
342
- q._translate_answer_code_to_answer([1, 2])
343
- # simulate answer
344
- q._simulate_answer()
345
- q._simulate_answer(human_readable=False)
346
- q._validate_answer(q._simulate_answer(human_readable=False))
347
- # serialization (inherits from Question)
348
- q.to_dict()
349
- assert q.from_dict(q.to_dict()) == q
350
-
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...")
351
849
  import doctest
352
-
353
850
  doctest.testmod(optionflags=doctest.ELLIPSIS)
851
+ print("Doctests completed")
354
852
 
355
853
 
356
854
  if __name__ == "__main__":
357
855
  import doctest
358
-
359
856
  doctest.testmod(optionflags=doctest.ELLIPSIS)
857
+
858
+ # Uncomment to run demonstration
859
+ # main()