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,93 @@
1
1
  from __future__ import annotations
2
- from typing import Optional, Callable
2
+ from typing import Optional, Callable, Any
3
3
  import inspect
4
4
 
5
+ from pydantic import BaseModel
6
+
5
7
  from .question_base import QuestionBase
8
+ from .response_validator_abc import ResponseValidatorABC
9
+ from .exceptions import QuestionErrors, QuestionAnswerValidationError, QuestionNotImplementedError
6
10
 
7
11
  from ..utilities.restricted_python import create_restricted_function
8
- from ..utilities.decorators import add_edsl_version, remove_edsl_version
12
+
13
+
14
+ class FunctionalResponse(BaseModel):
15
+ """
16
+ Pydantic model for functional question responses.
17
+
18
+ Since functional questions are evaluated directly by Python code rather than an LLM,
19
+ this model primarily serves as a structured way to represent the output.
20
+
21
+ Attributes:
22
+ answer: The result of the function evaluation
23
+ comment: Optional comment about the result
24
+ generated_tokens: Optional token usage data
25
+
26
+ Examples:
27
+ >>> # Valid response with a numeric answer
28
+ >>> response = FunctionalResponse(answer=42)
29
+ >>> response.answer
30
+ 42
31
+
32
+ >>> # Valid response with a string answer and a comment
33
+ >>> response = FunctionalResponse(answer="Hello world", comment="Function executed successfully")
34
+ >>> response.answer
35
+ 'Hello world'
36
+ >>> response.comment
37
+ 'Function executed successfully'
38
+ """
39
+ answer: Any
40
+ comment: Optional[str] = None
41
+ generated_tokens: Optional[Any] = None
42
+
43
+
44
+ class FunctionalResponseValidator(ResponseValidatorABC):
45
+ """
46
+ Validator for functional question responses.
47
+
48
+ Since functional questions are evaluated directly and not by an LLM,
49
+ this validator is minimal and mainly serves for consistency with other question types.
50
+ """
51
+ required_params = []
52
+ valid_examples = [
53
+ (
54
+ {"answer": 42},
55
+ {},
56
+ ),
57
+ (
58
+ {"answer": "Hello world", "comment": "Function executed successfully"},
59
+ {},
60
+ ),
61
+ ]
62
+ invalid_examples = []
63
+
64
+ def fix(self, response, verbose=False):
65
+ """
66
+ Attempt to fix an invalid response.
67
+
68
+ Since functional questions are evaluated directly, this method is mainly
69
+ for consistency with other question types.
70
+
71
+ Args:
72
+ response: The response to fix
73
+ verbose: Whether to print verbose output
74
+
75
+ Returns:
76
+ The fixed response or the original response if it cannot be fixed
77
+ """
78
+ if verbose:
79
+ print(f"Fixing functional response: {response}")
80
+
81
+ # Handle case where response is a raw value without the proper structure
82
+ if not isinstance(response, dict):
83
+ try:
84
+ return {"answer": response}
85
+ except Exception as e:
86
+ if verbose:
87
+ print(f"Failed to fix response: {e}")
88
+ return {"answer": None, "comment": "Failed to execute function"}
89
+
90
+ return response
9
91
 
10
92
 
11
93
  class QuestionFunctional(QuestionBase):
@@ -41,7 +123,7 @@ class QuestionFunctional(QuestionBase):
41
123
  function_name = ""
42
124
 
43
125
  _response_model = None
44
- response_validator_class = None
126
+ response_validator_class = FunctionalResponseValidator
45
127
 
46
128
  def __init__(
47
129
  self,
@@ -74,6 +156,12 @@ class QuestionFunctional(QuestionBase):
74
156
  self.question_text = question_text
75
157
  self.instructions = self.default_instructions
76
158
 
159
+ def create_response_model(self):
160
+ """
161
+ Returns the Pydantic model for validating responses to this question.
162
+ """
163
+ return FunctionalResponse
164
+
77
165
  def activate(self):
78
166
  self.activated = True
79
167
 
@@ -86,12 +174,14 @@ class QuestionFunctional(QuestionBase):
86
174
  def answer_question_directly(self, scenario, agent_traits=None):
87
175
  """Return the answer to the question, ensuring the function is activated."""
88
176
  if not self.activated:
89
- raise Exception("Function not activated. Please activate it first.")
177
+ raise QuestionErrors("Function not activated. Please activate it first.")
90
178
  try:
91
- return {"answer": self.func(scenario, agent_traits), "comment": None}
179
+ result = {"answer": self.func(scenario, agent_traits), "comment": None}
180
+ # Validate the result using the Pydantic model
181
+ return self.create_response_model()(**result).model_dump()
92
182
  except Exception as e:
93
183
  print("Function execution error:", e)
94
- raise Exception("Error during function execution.")
184
+ raise QuestionErrors("Error during function execution.")
95
185
 
96
186
  def _translate_answer_code_to_answer(self, answer, scenario):
97
187
  """Required by Question, but not used by QuestionFunctional."""
@@ -99,11 +189,31 @@ class QuestionFunctional(QuestionBase):
99
189
 
100
190
  def _simulate_answer(self, human_readable=True) -> dict[str, str]:
101
191
  """Required by Question, but not used by QuestionFunctional."""
102
- raise NotImplementedError
192
+ raise QuestionNotImplementedError("_simulate_answer not implemented for QuestionFunctional")
103
193
 
104
194
  def _validate_answer(self, answer: dict[str, str]):
105
- """Required by Question, but not used by QuestionFunctional."""
106
- raise NotImplementedError
195
+ """Validate the answer using the Pydantic model."""
196
+ try:
197
+ return self.create_response_model()(**answer).model_dump()
198
+ except Exception as e:
199
+ from pydantic import ValidationError
200
+ # Create a ValidationError with a helpful message
201
+ validation_error = ValidationError.from_exception_data(
202
+ title='FunctionalResponse',
203
+ line_errors=[{
204
+ 'type': 'value_error',
205
+ 'loc': ('answer',),
206
+ 'msg': f'Function response validation failed: {str(e)}',
207
+ 'input': answer,
208
+ 'ctx': {'error': str(e)}
209
+ }]
210
+ )
211
+ raise QuestionAnswerValidationError(
212
+ message=f"Invalid function response: {str(e)}",
213
+ data=answer,
214
+ model=self.create_response_model(),
215
+ pydantic_error=validation_error
216
+ )
107
217
 
108
218
  @property
109
219
  def question_html_content(self) -> str:
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
  from typing import Optional
3
- from ..question_multiple_choice import QuestionMultipleChoice
4
- from ..decorators import inject_exception
3
+ from .question_multiple_choice import QuestionMultipleChoice
4
+ from .decorators import inject_exception
5
5
 
6
6
 
7
7
  class QuestionLikertFive(QuestionMultipleChoice):
@@ -1,10 +1,9 @@
1
1
  from __future__ import annotations
2
2
  from typing import Optional
3
3
 
4
- from ..descriptors import QuestionOptionsDescriptor, OptionLabelDescriptor
5
- from ..question_multiple_choice import QuestionMultipleChoice
6
-
7
- from ..decorators import inject_exception
4
+ from .descriptors import QuestionOptionsDescriptor, OptionLabelDescriptor
5
+ from .question_multiple_choice import QuestionMultipleChoice
6
+ from .decorators import inject_exception
8
7
 
9
8
 
10
9
  class QuestionLinearScale(QuestionMultipleChoice):
@@ -1,16 +1,17 @@
1
1
  from __future__ import annotations
2
2
  import json
3
- from typing import Any, Optional, Union
3
+ from typing import Any, Optional, Union, ForwardRef
4
4
 
5
- from pydantic import Field
5
+ from pydantic import Field, model_validator, ValidationError
6
6
  from json_repair import repair_json
7
-
8
- from .exceptions import QuestionAnswerValidationError
9
7
  from .question_base import QuestionBase
10
8
  from .descriptors import IntegerOrNoneDescriptor
11
9
  from .decorators import inject_exception
12
10
  from .response_validator_abc import ResponseValidatorABC
13
11
 
12
+ # Forward reference for function return type annotation
13
+ ListResponse = ForwardRef("ListResponse")
14
+
14
15
  def convert_string(s: str) -> Union[float, int, str, dict]:
15
16
  """Convert a string to a more appropriate type if possible.
16
17
 
@@ -54,61 +55,301 @@ def convert_string(s: str) -> Union[float, int, str, dict]:
54
55
  return s
55
56
 
56
57
 
57
- def create_model(max_list_items: int, permissive: bool) -> "ListResponse":
58
+ def create_model(min_list_items: Optional[int], max_list_items: Optional[int], permissive: bool) -> "ListResponse":
58
59
  from pydantic import BaseModel
59
60
 
60
- if permissive or max_list_items is None:
61
-
61
+ if permissive or (max_list_items is None and min_list_items is None):
62
62
  class ListResponse(BaseModel):
63
+ """
64
+ Pydantic model for validating list responses with no constraints.
65
+
66
+ Examples:
67
+ >>> # Valid response with any number of items
68
+ >>> response = ListResponse(answer=["one", "two", "three"])
69
+ >>> response.answer
70
+ ['one', 'two', 'three']
71
+
72
+ >>> # Empty list is valid in permissive mode
73
+ >>> response = ListResponse(answer=[])
74
+ >>> response.answer
75
+ []
76
+
77
+ >>> # Missing answer field raises error
78
+ >>> try:
79
+ ... ListResponse(you="will never be able to do this!")
80
+ ... except Exception as e:
81
+ ... "Field required" in str(e)
82
+ True
83
+ """
63
84
  answer: list[Any]
64
85
  comment: Optional[str] = None
65
86
  generated_tokens: Optional[str] = None
87
+
88
+ @classmethod
89
+ def model_validate(cls, obj, *args, **kwargs):
90
+ try:
91
+ return super().model_validate(obj, *args, **kwargs)
92
+ except ValidationError as e:
93
+ from .exceptions import QuestionAnswerValidationError
94
+ raise QuestionAnswerValidationError(
95
+ message=f"Invalid list response: {e}",
96
+ data=obj,
97
+ model=cls,
98
+ pydantic_error=e
99
+ )
66
100
 
67
101
  else:
102
+ # Determine field constraints
103
+ field_kwargs = {"...": None}
104
+
105
+ if min_list_items is not None:
106
+ field_kwargs["min_items"] = min_list_items
107
+
108
+ if max_list_items is not None:
109
+ field_kwargs["max_items"] = max_list_items
68
110
 
69
111
  class ListResponse(BaseModel):
70
112
  """
71
- >>> nr = ListResponse(answer=["Apple", "Cherry"])
72
- >>> nr.dict()
73
- {'answer': ['Apple', 'Cherry'], 'comment': None, 'generated_tokens': None}
113
+ Pydantic model for validating list responses with size constraints.
114
+
115
+ Examples:
116
+ >>> # Create a model with min=2, max=4 items
117
+ >>> ConstrainedList = create_model(min_list_items=2, max_list_items=4, permissive=False)
118
+
119
+ >>> # Valid response within constraints
120
+ >>> response = ConstrainedList(answer=["Apple", "Cherry", "Banana"])
121
+ >>> len(response.answer)
122
+ 3
123
+
124
+ >>> # Too few items raises error
125
+ >>> try:
126
+ ... ConstrainedList(answer=["Apple"])
127
+ ... except QuestionAnswerValidationError as e:
128
+ ... "must have at least 2 items" in str(e)
129
+ True
130
+
131
+ >>> # Too many items raises error
132
+ >>> try:
133
+ ... ConstrainedList(answer=["A", "B", "C", "D", "E"])
134
+ ... except QuestionAnswerValidationError as e:
135
+ ... "cannot have more than 4 items" in str(e)
136
+ True
137
+
138
+ >>> # Optional comment is allowed
139
+ >>> response = ConstrainedList(
140
+ ... answer=["Apple", "Cherry"],
141
+ ... comment="These are my favorites"
142
+ ... )
143
+ >>> response.comment
144
+ 'These are my favorites'
145
+
146
+ >>> # Generated tokens are optional
147
+ >>> response = ConstrainedList(
148
+ ... answer=["Apple", "Cherry"],
149
+ ... generated_tokens="Apple, Cherry"
150
+ ... )
151
+ >>> response.generated_tokens
152
+ 'Apple, Cherry'
74
153
  """
75
154
 
76
- answer: list[Any] = Field(..., min_items=0, max_items=max_list_items)
155
+ answer: list[Any] = Field(**field_kwargs)
77
156
  comment: Optional[str] = None
78
157
  generated_tokens: Optional[str] = None
79
158
 
159
+ @model_validator(mode='after')
160
+ def validate_list_constraints(self):
161
+ """
162
+ Validate that the list meets size constraints.
163
+
164
+ Returns:
165
+ The validated model instance.
166
+
167
+ Raises:
168
+ QuestionAnswerValidationError: If list size constraints are violated.
169
+ """
170
+ if max_list_items is not None and len(self.answer) > max_list_items:
171
+ from .exceptions import QuestionAnswerValidationError
172
+ validation_error = ValidationError.from_exception_data(
173
+ title='ListResponse',
174
+ line_errors=[{
175
+ 'type': 'value_error',
176
+ 'loc': ('answer',),
177
+ 'msg': f'List cannot have more than {max_list_items} items',
178
+ 'input': self.answer,
179
+ 'ctx': {'error': 'Too many items'}
180
+ }]
181
+ )
182
+ raise QuestionAnswerValidationError(
183
+ message=f"List cannot have more than {max_list_items} items",
184
+ data=self.model_dump(),
185
+ model=self.__class__,
186
+ pydantic_error=validation_error
187
+ )
188
+
189
+ if min_list_items is not None and len(self.answer) < min_list_items:
190
+ from .exceptions import QuestionAnswerValidationError
191
+ validation_error = ValidationError.from_exception_data(
192
+ title='ListResponse',
193
+ line_errors=[{
194
+ 'type': 'value_error',
195
+ 'loc': ('answer',),
196
+ 'msg': f'List must have at least {min_list_items} items',
197
+ 'input': self.answer,
198
+ 'ctx': {'error': 'Too few items'}
199
+ }]
200
+ )
201
+ raise QuestionAnswerValidationError(
202
+ message=f"List must have at least {min_list_items} items",
203
+ data=self.model_dump(),
204
+ model=self.__class__,
205
+ pydantic_error=validation_error
206
+ )
207
+ return self
208
+
209
+ @classmethod
210
+ def model_validate(cls, obj, *args, **kwargs):
211
+ try:
212
+ return super().model_validate(obj, *args, **kwargs)
213
+ except ValidationError as e:
214
+ from .exceptions import QuestionAnswerValidationError
215
+ raise QuestionAnswerValidationError(
216
+ message=f"Invalid list response: {e}",
217
+ data=obj,
218
+ model=cls,
219
+ pydantic_error=e
220
+ )
221
+
80
222
  return ListResponse
81
223
 
82
224
 
83
225
  class ListResponseValidator(ResponseValidatorABC):
84
- required_params = ["max_list_items", "permissive"]
226
+ required_params = ["min_list_items", "max_list_items", "permissive"]
85
227
  valid_examples = [({"answer": ["hello", "world"]}, {"max_list_items": 5})]
86
-
87
228
  invalid_examples = [
88
229
  (
89
230
  {"answer": ["hello", "world", "this", "is", "a", "test"]},
90
231
  {"max_list_items": 5},
91
- "Too many items.",
232
+ "List cannot have more than 5 items",
233
+ ),
234
+ (
235
+ {"answer": ["hello"]},
236
+ {"min_list_items": 2},
237
+ "List must have at least 2 items",
92
238
  ),
93
239
  ]
240
+
241
+ def validate(
242
+ self,
243
+ raw_edsl_answer_dict: dict,
244
+ fix=False,
245
+ verbose=False,
246
+ replacement_dict: dict = None,
247
+ ) -> dict:
248
+ """Override validate to handle missing answer key properly."""
249
+ # Check for missing answer key
250
+ if "answer" not in raw_edsl_answer_dict:
251
+ from .exceptions import QuestionAnswerValidationError
252
+ from pydantic import ValidationError
253
+
254
+ # Create a synthetic validation error
255
+ validation_error = ValidationError.from_exception_data(
256
+ title='ListResponse',
257
+ line_errors=[{
258
+ 'type': 'missing',
259
+ 'loc': ('answer',),
260
+ 'msg': 'Field required',
261
+ 'input': raw_edsl_answer_dict,
262
+ }]
263
+ )
264
+
265
+ raise QuestionAnswerValidationError(
266
+ message="Missing required 'answer' field in response",
267
+ data=raw_edsl_answer_dict,
268
+ model=self.response_model,
269
+ pydantic_error=validation_error
270
+ )
271
+
272
+ # Check if answer is not a list
273
+ if "answer" in raw_edsl_answer_dict and not isinstance(raw_edsl_answer_dict["answer"], list):
274
+ from .exceptions import QuestionAnswerValidationError
275
+ from pydantic import ValidationError
276
+
277
+ # Create a synthetic validation error
278
+ validation_error = ValidationError.from_exception_data(
279
+ title='ListResponse',
280
+ line_errors=[{
281
+ 'type': 'list_type',
282
+ 'loc': ('answer',),
283
+ 'msg': 'Input should be a valid list',
284
+ 'input': raw_edsl_answer_dict["answer"],
285
+ }]
286
+ )
287
+
288
+ raise QuestionAnswerValidationError(
289
+ message=f"Answer must be a list (got {type(raw_edsl_answer_dict['answer']).__name__})",
290
+ data=raw_edsl_answer_dict,
291
+ model=self.response_model,
292
+ pydantic_error=validation_error
293
+ )
294
+
295
+ # Continue with parent validation
296
+ return super().validate(raw_edsl_answer_dict, fix, verbose, replacement_dict)
94
297
 
95
298
  def _check_constraints(self, response) -> None:
96
- if (
97
- self.max_list_items is not None
98
- and len(response.answer) > self.max_list_items
99
- ):
100
- raise QuestionAnswerValidationError("Too many items.")
299
+ # This method can now be removed since validation is handled in the Pydantic model
300
+ pass
101
301
 
102
302
  def fix(self, response, verbose=False):
303
+ """
304
+ Fix common issues in list responses by splitting strings into lists.
305
+
306
+ Examples:
307
+ >>> from edsl import QuestionList
308
+ >>> q = QuestionList.example(min_list_items=2, max_list_items=4)
309
+ >>> validator = q.response_validator
310
+
311
+ >>> # Fix a string that should be a list
312
+ >>> bad_response = {"answer": "apple,banana,cherry"}
313
+ >>> try:
314
+ ... validator.validate(bad_response)
315
+ ... except Exception:
316
+ ... fixed = validator.fix(bad_response)
317
+ ... validated = validator.validate(fixed)
318
+ ... validated # Show full response
319
+ {'answer': ['apple', 'banana', 'cherry'], 'comment': None, 'generated_tokens': None}
320
+
321
+ >>> # Fix using generated_tokens when answer is invalid
322
+ >>> bad_response = {
323
+ ... "answer": None,
324
+ ... "generated_tokens": "pizza, pasta, salad"
325
+ ... }
326
+ >>> try:
327
+ ... validator.validate(bad_response)
328
+ ... except Exception:
329
+ ... fixed = validator.fix(bad_response)
330
+ ... validated = validator.validate(fixed)
331
+ ... validated
332
+ {'answer': ['pizza', ' pasta', ' salad'], 'comment': None, 'generated_tokens': None}
333
+
334
+ >>> # Preserve comments during fixing
335
+ >>> bad_response = {
336
+ ... "answer": "red,blue,green",
337
+ ... "comment": "These are colors"
338
+ ... }
339
+ >>> fixed = validator.fix(bad_response)
340
+ >>> fixed == {
341
+ ... "answer": ["red", "blue", "green"],
342
+ ... "comment": "These are colors"
343
+ ... }
344
+ True
345
+ """
103
346
  if verbose:
104
347
  print(f"Fixing list response: {response}")
105
348
  answer = str(response.get("answer") or response.get("generated_tokens", ""))
106
- if len(answer.split(",")) > 0:
107
- return (
108
- {"answer": answer.split(",")} | {"comment": response.get("comment")}
109
- if "comment" in response
110
- else {}
111
- )
349
+ result = {"answer": answer.split(",")}
350
+ if "comment" in response:
351
+ result["comment"] = response["comment"]
352
+ return result
112
353
 
113
354
  def _post_process(self, edsl_answer_dict):
114
355
  edsl_answer_dict["answer"] = [
@@ -122,6 +363,7 @@ class QuestionList(QuestionBase):
122
363
 
123
364
  question_type = "list"
124
365
  max_list_items: int = IntegerOrNoneDescriptor()
366
+ min_list_items: int = IntegerOrNoneDescriptor()
125
367
  _response_model = None
126
368
  response_validator_class = ListResponseValidator
127
369
 
@@ -131,6 +373,7 @@ class QuestionList(QuestionBase):
131
373
  question_text: str,
132
374
  include_comment: bool = True,
133
375
  max_list_items: Optional[int] = None,
376
+ min_list_items: Optional[int] = None,
134
377
  answering_instructions: Optional[str] = None,
135
378
  question_presentation: Optional[str] = None,
136
379
  permissive: bool = False,
@@ -140,12 +383,14 @@ class QuestionList(QuestionBase):
140
383
  :param question_name: The name of the question.
141
384
  :param question_text: The text of the question.
142
385
  :param max_list_items: The maximum number of items that can be in the answer list.
386
+ :param min_list_items: The minimum number of items that must be in the answer list.
143
387
 
144
388
  >>> QuestionList.example().self_check()
145
389
  """
146
390
  self.question_name = question_name
147
391
  self.question_text = question_text
148
392
  self.max_list_items = max_list_items
393
+ self.min_list_items = min_list_items
149
394
  self.permissive = permissive
150
395
 
151
396
  self.include_comment = include_comment
@@ -153,7 +398,7 @@ class QuestionList(QuestionBase):
153
398
  self.question_presentations = question_presentation
154
399
 
155
400
  def create_response_model(self):
156
- return create_model(self.max_list_items, self.permissive)
401
+ return create_model(self.min_list_items, self.max_list_items, self.permissive)
157
402
 
158
403
  @property
159
404
  def question_html_content(self) -> str:
@@ -183,7 +428,7 @@ class QuestionList(QuestionBase):
183
428
  @classmethod
184
429
  @inject_exception
185
430
  def example(
186
- cls, include_comment=True, max_list_items=None, permissive=False
431
+ cls, include_comment=True, max_list_items=None, min_list_items=None, permissive=False
187
432
  ) -> QuestionList:
188
433
  """Return an example of a list question."""
189
434
  return cls(
@@ -191,6 +436,7 @@ class QuestionList(QuestionBase):
191
436
  question_text="What are your favorite foods?",
192
437
  include_comment=include_comment,
193
438
  max_list_items=max_list_items,
439
+ min_list_items=min_list_items,
194
440
  permissive=permissive,
195
441
  )
196
442
 
@@ -199,10 +445,11 @@ def main():
199
445
  """Create an example of a list question and demonstrate its functionality."""
200
446
  from edsl.questions import QuestionList
201
447
 
202
- q = QuestionList.example(max_list_items=5)
448
+ q = QuestionList.example(max_list_items=5, min_list_items=2)
203
449
  q.question_text
204
450
  q.question_name
205
451
  q.max_list_items
452
+ q.min_list_items
206
453
  # validate an answer
207
454
  q._validate_answer({"answer": ["pasta", "garlic", "oil", "parmesan"]})
208
455
  # translate answer code