edsl 0.1.49__py3-none-any.whl → 0.1.50__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 (239) 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 +75 -0
  14. edsl/buckets/model_buckets.py +1 -2
  15. edsl/buckets/token_bucket.py +11 -6
  16. edsl/buckets/token_bucket_api.py +1 -2
  17. edsl/buckets/token_bucket_client.py +9 -7
  18. edsl/caching/cache.py +7 -2
  19. edsl/caching/cache_entry.py +10 -9
  20. edsl/caching/exceptions.py +113 -7
  21. edsl/caching/remote_cache_sync.py +1 -2
  22. edsl/caching/sql_dict.py +17 -12
  23. edsl/cli.py +43 -0
  24. edsl/config/config_class.py +30 -6
  25. edsl/conversation/Conversation.py +3 -2
  26. edsl/conversation/exceptions.py +58 -0
  27. edsl/conversation/mug_negotiation.py +0 -2
  28. edsl/coop/__init__.py +20 -1
  29. edsl/coop/coop.py +120 -29
  30. edsl/coop/exceptions.py +188 -9
  31. edsl/coop/price_fetcher.py +3 -6
  32. edsl/coop/utils.py +4 -6
  33. edsl/dataset/__init__.py +5 -4
  34. edsl/dataset/dataset.py +53 -43
  35. edsl/dataset/dataset_operations_mixin.py +86 -72
  36. edsl/dataset/dataset_tree.py +9 -5
  37. edsl/dataset/display/table_display.py +0 -2
  38. edsl/dataset/display/table_renderers.py +0 -1
  39. edsl/dataset/exceptions.py +125 -0
  40. edsl/dataset/file_exports.py +18 -11
  41. edsl/dataset/r/ggplot.py +13 -6
  42. edsl/display/__init__.py +27 -0
  43. edsl/display/core.py +147 -0
  44. edsl/display/plugin.py +189 -0
  45. edsl/display/utils.py +52 -0
  46. edsl/inference_services/__init__.py +9 -1
  47. edsl/inference_services/available_model_cache_handler.py +1 -1
  48. edsl/inference_services/available_model_fetcher.py +4 -5
  49. edsl/inference_services/data_structures.py +9 -6
  50. edsl/inference_services/exceptions.py +132 -1
  51. edsl/inference_services/inference_service_abc.py +2 -2
  52. edsl/inference_services/inference_services_collection.py +2 -6
  53. edsl/inference_services/registry.py +4 -3
  54. edsl/inference_services/service_availability.py +2 -1
  55. edsl/inference_services/services/anthropic_service.py +4 -1
  56. edsl/inference_services/services/aws_bedrock.py +13 -12
  57. edsl/inference_services/services/azure_ai.py +12 -10
  58. edsl/inference_services/services/deep_infra_service.py +1 -4
  59. edsl/inference_services/services/deep_seek_service.py +1 -5
  60. edsl/inference_services/services/google_service.py +6 -2
  61. edsl/inference_services/services/groq_service.py +1 -1
  62. edsl/inference_services/services/mistral_ai_service.py +4 -2
  63. edsl/inference_services/services/ollama_service.py +1 -1
  64. edsl/inference_services/services/open_ai_service.py +7 -5
  65. edsl/inference_services/services/perplexity_service.py +6 -2
  66. edsl/inference_services/services/test_service.py +8 -7
  67. edsl/inference_services/services/together_ai_service.py +2 -3
  68. edsl/inference_services/services/xai_service.py +1 -1
  69. edsl/instructions/__init__.py +1 -1
  70. edsl/instructions/change_instruction.py +3 -2
  71. edsl/instructions/exceptions.py +61 -0
  72. edsl/instructions/instruction.py +5 -2
  73. edsl/instructions/instruction_collection.py +2 -1
  74. edsl/instructions/instruction_handler.py +4 -9
  75. edsl/interviews/ReportErrors.py +0 -3
  76. edsl/interviews/__init__.py +9 -2
  77. edsl/interviews/answering_function.py +11 -13
  78. edsl/interviews/exception_tracking.py +14 -7
  79. edsl/interviews/exceptions.py +79 -0
  80. edsl/interviews/interview.py +32 -29
  81. edsl/interviews/interview_status_dictionary.py +4 -2
  82. edsl/interviews/interview_status_log.py +2 -1
  83. edsl/interviews/interview_task_manager.py +3 -3
  84. edsl/interviews/request_token_estimator.py +3 -1
  85. edsl/interviews/statistics.py +2 -3
  86. edsl/invigilators/__init__.py +7 -1
  87. edsl/invigilators/exceptions.py +79 -0
  88. edsl/invigilators/invigilator_base.py +0 -1
  89. edsl/invigilators/invigilators.py +8 -12
  90. edsl/invigilators/prompt_constructor.py +1 -5
  91. edsl/invigilators/prompt_helpers.py +8 -4
  92. edsl/invigilators/question_instructions_prompt_builder.py +1 -1
  93. edsl/invigilators/question_option_processor.py +9 -5
  94. edsl/invigilators/question_template_replacements_builder.py +3 -2
  95. edsl/jobs/__init__.py +3 -3
  96. edsl/jobs/async_interview_runner.py +24 -22
  97. edsl/jobs/check_survey_scenario_compatibility.py +7 -6
  98. edsl/jobs/data_structures.py +7 -4
  99. edsl/jobs/exceptions.py +177 -8
  100. edsl/jobs/fetch_invigilator.py +1 -1
  101. edsl/jobs/jobs.py +72 -67
  102. edsl/jobs/jobs_checks.py +2 -3
  103. edsl/jobs/jobs_component_constructor.py +2 -2
  104. edsl/jobs/jobs_pricing_estimation.py +3 -2
  105. edsl/jobs/jobs_remote_inference_logger.py +5 -4
  106. edsl/jobs/jobs_runner_asyncio.py +1 -2
  107. edsl/jobs/jobs_runner_status.py +8 -9
  108. edsl/jobs/remote_inference.py +26 -23
  109. edsl/jobs/results_exceptions_handler.py +8 -5
  110. edsl/key_management/__init__.py +3 -1
  111. edsl/key_management/exceptions.py +62 -0
  112. edsl/key_management/key_lookup.py +1 -1
  113. edsl/key_management/key_lookup_builder.py +37 -14
  114. edsl/key_management/key_lookup_collection.py +2 -0
  115. edsl/language_models/__init__.py +1 -1
  116. edsl/language_models/exceptions.py +302 -14
  117. edsl/language_models/language_model.py +4 -7
  118. edsl/language_models/model.py +4 -4
  119. edsl/language_models/model_list.py +1 -1
  120. edsl/language_models/price_manager.py +1 -1
  121. edsl/language_models/raw_response_handler.py +14 -9
  122. edsl/language_models/registry.py +17 -21
  123. edsl/language_models/repair.py +0 -6
  124. edsl/language_models/unused/fake_openai_service.py +0 -1
  125. edsl/load_plugins.py +69 -0
  126. edsl/logger.py +146 -0
  127. edsl/notebooks/notebook.py +1 -1
  128. edsl/notebooks/notebook_to_latex.py +0 -1
  129. edsl/plugins/__init__.py +63 -0
  130. edsl/plugins/built_in/export_example.py +50 -0
  131. edsl/plugins/built_in/pig_latin.py +67 -0
  132. edsl/plugins/cli.py +372 -0
  133. edsl/plugins/cli_typer.py +283 -0
  134. edsl/plugins/exceptions.py +31 -0
  135. edsl/plugins/hookspec.py +51 -0
  136. edsl/plugins/plugin_host.py +128 -0
  137. edsl/plugins/plugin_manager.py +633 -0
  138. edsl/plugins/plugins_registry.py +168 -0
  139. edsl/prompts/__init__.py +2 -0
  140. edsl/prompts/exceptions.py +107 -5
  141. edsl/prompts/prompt.py +14 -6
  142. edsl/questions/HTMLQuestion.py +5 -11
  143. edsl/questions/Quick.py +0 -1
  144. edsl/questions/__init__.py +2 -0
  145. edsl/questions/answer_validator_mixin.py +318 -318
  146. edsl/questions/compose_questions.py +2 -2
  147. edsl/questions/descriptors.py +10 -49
  148. edsl/questions/exceptions.py +278 -22
  149. edsl/questions/loop_processor.py +7 -5
  150. edsl/questions/prompt_templates/question_list.jinja +3 -0
  151. edsl/questions/question_base.py +14 -16
  152. edsl/questions/question_base_gen_mixin.py +2 -2
  153. edsl/questions/question_base_prompts_mixin.py +9 -3
  154. edsl/questions/question_budget.py +9 -5
  155. edsl/questions/question_check_box.py +3 -5
  156. edsl/questions/question_dict.py +171 -194
  157. edsl/questions/question_extract.py +1 -1
  158. edsl/questions/question_free_text.py +4 -6
  159. edsl/questions/question_functional.py +4 -3
  160. edsl/questions/question_list.py +36 -9
  161. edsl/questions/question_matrix.py +95 -61
  162. edsl/questions/question_multiple_choice.py +6 -4
  163. edsl/questions/question_numerical.py +2 -4
  164. edsl/questions/question_registry.py +4 -2
  165. edsl/questions/register_questions_meta.py +0 -1
  166. edsl/questions/response_validator_abc.py +7 -13
  167. edsl/questions/templates/dict/answering_instructions.jinja +1 -0
  168. edsl/questions/templates/rank/question_presentation.jinja +1 -1
  169. edsl/results/__init__.py +1 -1
  170. edsl/results/exceptions.py +141 -7
  171. edsl/results/report.py +0 -1
  172. edsl/results/result.py +4 -5
  173. edsl/results/results.py +10 -51
  174. edsl/results/results_selector.py +8 -4
  175. edsl/scenarios/PdfExtractor.py +2 -2
  176. edsl/scenarios/construct_download_link.py +69 -35
  177. edsl/scenarios/directory_scanner.py +33 -14
  178. edsl/scenarios/document_chunker.py +1 -1
  179. edsl/scenarios/exceptions.py +238 -14
  180. edsl/scenarios/file_methods.py +1 -1
  181. edsl/scenarios/file_store.py +7 -3
  182. edsl/scenarios/handlers/__init__.py +17 -0
  183. edsl/scenarios/handlers/docx_file_store.py +0 -5
  184. edsl/scenarios/handlers/pdf_file_store.py +0 -1
  185. edsl/scenarios/handlers/pptx_file_store.py +0 -5
  186. edsl/scenarios/handlers/py_file_store.py +0 -1
  187. edsl/scenarios/handlers/sql_file_store.py +1 -4
  188. edsl/scenarios/handlers/sqlite_file_store.py +0 -1
  189. edsl/scenarios/handlers/txt_file_store.py +1 -1
  190. edsl/scenarios/scenario.py +0 -1
  191. edsl/scenarios/scenario_list.py +152 -18
  192. edsl/scenarios/scenario_list_pdf_tools.py +1 -0
  193. edsl/scenarios/scenario_selector.py +0 -1
  194. edsl/surveys/__init__.py +3 -4
  195. edsl/surveys/dag/__init__.py +4 -2
  196. edsl/surveys/descriptors.py +1 -1
  197. edsl/surveys/edit_survey.py +1 -0
  198. edsl/surveys/exceptions.py +165 -9
  199. edsl/surveys/memory/__init__.py +5 -3
  200. edsl/surveys/memory/memory_management.py +1 -0
  201. edsl/surveys/memory/memory_plan.py +6 -15
  202. edsl/surveys/rules/__init__.py +5 -3
  203. edsl/surveys/rules/rule.py +1 -2
  204. edsl/surveys/rules/rule_collection.py +1 -1
  205. edsl/surveys/survey.py +12 -24
  206. edsl/surveys/survey_export.py +6 -3
  207. edsl/surveys/survey_flow_visualization.py +10 -1
  208. edsl/tasks/__init__.py +2 -0
  209. edsl/tasks/question_task_creator.py +3 -3
  210. edsl/tasks/task_creators.py +1 -3
  211. edsl/tasks/task_history.py +5 -7
  212. edsl/tasks/task_status_log.py +1 -2
  213. edsl/tokens/__init__.py +3 -1
  214. edsl/tokens/token_usage.py +1 -1
  215. edsl/utilities/__init__.py +21 -1
  216. edsl/utilities/decorators.py +1 -2
  217. edsl/utilities/markdown_to_docx.py +2 -2
  218. edsl/utilities/markdown_to_pdf.py +1 -1
  219. edsl/utilities/repair_functions.py +0 -1
  220. edsl/utilities/restricted_python.py +0 -1
  221. edsl/utilities/template_loader.py +2 -3
  222. edsl/utilities/utilities.py +8 -29
  223. {edsl-0.1.49.dist-info → edsl-0.1.50.dist-info}/METADATA +32 -2
  224. edsl-0.1.50.dist-info/RECORD +363 -0
  225. edsl-0.1.50.dist-info/entry_points.txt +3 -0
  226. edsl/dataset/smart_objects.py +0 -96
  227. edsl/exceptions/BaseException.py +0 -21
  228. edsl/exceptions/__init__.py +0 -54
  229. edsl/exceptions/configuration.py +0 -16
  230. edsl/exceptions/general.py +0 -34
  231. edsl/study/ObjectEntry.py +0 -173
  232. edsl/study/ProofOfWork.py +0 -113
  233. edsl/study/SnapShot.py +0 -80
  234. edsl/study/Study.py +0 -520
  235. edsl/study/__init__.py +0 -6
  236. edsl/utilities/interface.py +0 -135
  237. edsl-0.1.49.dist-info/RECORD +0 -347
  238. {edsl-0.1.49.dist-info → edsl-0.1.50.dist-info}/LICENSE +0 -0
  239. {edsl-0.1.49.dist-info → edsl-0.1.50.dist-info}/WHEEL +0 -0
@@ -1,16 +1,18 @@
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
5
  from pydantic import Field
6
6
  from json_repair import repair_json
7
-
8
7
  from .exceptions import QuestionAnswerValidationError
9
8
  from .question_base import QuestionBase
10
9
  from .descriptors import IntegerOrNoneDescriptor
11
10
  from .decorators import inject_exception
12
11
  from .response_validator_abc import ResponseValidatorABC
13
12
 
13
+ # Forward reference for function return type annotation
14
+ ListResponse = ForwardRef("ListResponse")
15
+
14
16
  def convert_string(s: str) -> Union[float, int, str, dict]:
15
17
  """Convert a string to a more appropriate type if possible.
16
18
 
@@ -54,10 +56,10 @@ def convert_string(s: str) -> Union[float, int, str, dict]:
54
56
  return s
55
57
 
56
58
 
57
- def create_model(max_list_items: int, permissive: bool) -> "ListResponse":
59
+ def create_model(min_list_items: Optional[int], max_list_items: Optional[int], permissive: bool) -> "ListResponse":
58
60
  from pydantic import BaseModel
59
61
 
60
- if permissive or max_list_items is None:
62
+ if permissive or (max_list_items is None and min_list_items is None):
61
63
 
62
64
  class ListResponse(BaseModel):
63
65
  answer: list[Any]
@@ -65,6 +67,14 @@ def create_model(max_list_items: int, permissive: bool) -> "ListResponse":
65
67
  generated_tokens: Optional[str] = None
66
68
 
67
69
  else:
70
+ # Determine field constraints
71
+ field_kwargs = {"...": None}
72
+
73
+ if min_list_items is not None:
74
+ field_kwargs["min_items"] = min_list_items
75
+
76
+ if max_list_items is not None:
77
+ field_kwargs["max_items"] = max_list_items
68
78
 
69
79
  class ListResponse(BaseModel):
70
80
  """
@@ -73,7 +83,7 @@ def create_model(max_list_items: int, permissive: bool) -> "ListResponse":
73
83
  {'answer': ['Apple', 'Cherry'], 'comment': None, 'generated_tokens': None}
74
84
  """
75
85
 
76
- answer: list[Any] = Field(..., min_items=0, max_items=max_list_items)
86
+ answer: list[Any] = Field(**field_kwargs)
77
87
  comment: Optional[str] = None
78
88
  generated_tokens: Optional[str] = None
79
89
 
@@ -81,7 +91,7 @@ def create_model(max_list_items: int, permissive: bool) -> "ListResponse":
81
91
 
82
92
 
83
93
  class ListResponseValidator(ResponseValidatorABC):
84
- required_params = ["max_list_items", "permissive"]
94
+ required_params = ["min_list_items", "max_list_items", "permissive"]
85
95
  valid_examples = [({"answer": ["hello", "world"]}, {"max_list_items": 5})]
86
96
 
87
97
  invalid_examples = [
@@ -90,6 +100,11 @@ class ListResponseValidator(ResponseValidatorABC):
90
100
  {"max_list_items": 5},
91
101
  "Too many items.",
92
102
  ),
103
+ (
104
+ {"answer": ["hello"]},
105
+ {"min_list_items": 2},
106
+ "Too few items.",
107
+ ),
93
108
  ]
94
109
 
95
110
  def _check_constraints(self, response) -> None:
@@ -98,6 +113,12 @@ class ListResponseValidator(ResponseValidatorABC):
98
113
  and len(response.answer) > self.max_list_items
99
114
  ):
100
115
  raise QuestionAnswerValidationError("Too many items.")
116
+
117
+ if (
118
+ self.min_list_items is not None
119
+ and len(response.answer) < self.min_list_items
120
+ ):
121
+ raise QuestionAnswerValidationError("Too few items.")
101
122
 
102
123
  def fix(self, response, verbose=False):
103
124
  if verbose:
@@ -122,6 +143,7 @@ class QuestionList(QuestionBase):
122
143
 
123
144
  question_type = "list"
124
145
  max_list_items: int = IntegerOrNoneDescriptor()
146
+ min_list_items: int = IntegerOrNoneDescriptor()
125
147
  _response_model = None
126
148
  response_validator_class = ListResponseValidator
127
149
 
@@ -131,6 +153,7 @@ class QuestionList(QuestionBase):
131
153
  question_text: str,
132
154
  include_comment: bool = True,
133
155
  max_list_items: Optional[int] = None,
156
+ min_list_items: Optional[int] = None,
134
157
  answering_instructions: Optional[str] = None,
135
158
  question_presentation: Optional[str] = None,
136
159
  permissive: bool = False,
@@ -140,12 +163,14 @@ class QuestionList(QuestionBase):
140
163
  :param question_name: The name of the question.
141
164
  :param question_text: The text of the question.
142
165
  :param max_list_items: The maximum number of items that can be in the answer list.
166
+ :param min_list_items: The minimum number of items that must be in the answer list.
143
167
 
144
168
  >>> QuestionList.example().self_check()
145
169
  """
146
170
  self.question_name = question_name
147
171
  self.question_text = question_text
148
172
  self.max_list_items = max_list_items
173
+ self.min_list_items = min_list_items
149
174
  self.permissive = permissive
150
175
 
151
176
  self.include_comment = include_comment
@@ -153,7 +178,7 @@ class QuestionList(QuestionBase):
153
178
  self.question_presentations = question_presentation
154
179
 
155
180
  def create_response_model(self):
156
- return create_model(self.max_list_items, self.permissive)
181
+ return create_model(self.min_list_items, self.max_list_items, self.permissive)
157
182
 
158
183
  @property
159
184
  def question_html_content(self) -> str:
@@ -183,7 +208,7 @@ class QuestionList(QuestionBase):
183
208
  @classmethod
184
209
  @inject_exception
185
210
  def example(
186
- cls, include_comment=True, max_list_items=None, permissive=False
211
+ cls, include_comment=True, max_list_items=None, min_list_items=None, permissive=False
187
212
  ) -> QuestionList:
188
213
  """Return an example of a list question."""
189
214
  return cls(
@@ -191,6 +216,7 @@ class QuestionList(QuestionBase):
191
216
  question_text="What are your favorite foods?",
192
217
  include_comment=include_comment,
193
218
  max_list_items=max_list_items,
219
+ min_list_items=min_list_items,
194
220
  permissive=permissive,
195
221
  )
196
222
 
@@ -199,10 +225,11 @@ def main():
199
225
  """Create an example of a list question and demonstrate its functionality."""
200
226
  from edsl.questions import QuestionList
201
227
 
202
- q = QuestionList.example(max_list_items=5)
228
+ q = QuestionList.example(max_list_items=5, min_list_items=2)
203
229
  q.question_text
204
230
  q.question_name
205
231
  q.max_list_items
232
+ q.min_list_items
206
233
  # validate an answer
207
234
  q._validate_answer({"answer": ["pasta", "garlic", "oil", "parmesan"]})
208
235
  # translate answer code
@@ -1,9 +1,26 @@
1
+ """
2
+ question_matrix.py
3
+
4
+ Drop-in replacement for `QuestionMatrix` with a dynamic Pydantic approach
5
+ that automatically raises ValidationError for invalid matrix answers.
6
+ """
7
+
1
8
  from __future__ import annotations
2
- from typing import Union, Optional, Dict, List, Any
9
+ from typing import (
10
+ Union,
11
+ Optional,
12
+ Dict,
13
+ List,
14
+ Any,
15
+ Type,
16
+ get_args,
17
+ Literal
18
+ )
19
+ import random
3
20
 
4
- from pydantic import BaseModel, Field, field_validator
21
+ from pydantic import BaseModel, Field, create_model, ValidationError
5
22
  from jinja2 import Template
6
- import random
23
+
7
24
  from .question_base import QuestionBase
8
25
  from .descriptors import (
9
26
  QuestionOptionsDescriptor,
@@ -14,8 +31,8 @@ from .response_validator_abc import ResponseValidatorABC
14
31
  from .decorators import inject_exception
15
32
 
16
33
  from .exceptions import (
17
- QuestionAnswerValidationError,
18
34
  QuestionCreationValidationError,
35
+ QuestionAnswerValidationError, # If you still want to raise custom exceptions
19
36
  )
20
37
 
21
38
 
@@ -23,47 +40,56 @@ def create_matrix_response(
23
40
  question_items: List[str],
24
41
  question_options: List[Union[int, str, float]],
25
42
  permissive: bool = False,
26
- ):
27
- """Create a response model for matrix questions.
28
-
29
- The response model validates that:
30
- 1. All question items are answered
31
- 2. Each answer is from the allowed options
43
+ ) -> Type[BaseModel]:
32
44
  """
45
+ Create a dynamic Pydantic model for matrix questions.
33
46
 
34
- if permissive:
35
-
36
- class MatrixResponse(BaseModel):
37
- answer: Dict[str, Any]
38
- comment: Optional[str] = None
39
- generated_tokens: Optional[Any] = None
47
+ If `permissive=False`, each item is a required field with a `Literal[...]` type
48
+ so that only the given question_options are allowed.
49
+ If `permissive=True`, each item can have any value, and extra items are allowed.
50
+ """
40
51
 
52
+ # If non-permissive, build a Literal for each valid option
53
+ # e.g. Literal[1,2,3] or Literal["Yes","No"] or a mix
54
+ if not permissive:
55
+ # If question_options is empty (edge case), fall back to 'Any'
56
+ if question_options:
57
+ AllowedOptions = Literal[tuple(question_options)] # type: ignore
58
+ else:
59
+ AllowedOptions = Any
41
60
  else:
42
-
43
- class MatrixResponse(BaseModel):
44
- answer: Dict[str, Union[int, str, float]] = Field(
45
- ..., description="Mapping of items to selected options"
46
- )
47
- comment: Optional[str] = None
48
- generated_tokens: Optional[Any] = None
49
-
50
- @field_validator("answer")
51
- def validate_answer(cls, v, values, **kwargs):
52
- # Check that all items have responses
53
- if not all(item in v for item in question_items):
54
- missing = set(question_items) - set(v.keys())
55
- raise ValueError(f"Missing responses for items: {missing}")
56
-
57
- # Check that all responses are valid options
58
- if not all(answer in question_options for answer in v.values()):
59
- invalid = [ans for ans in v.values() if ans not in question_options]
60
- raise ValueError(f"Invalid options selected: {invalid}")
61
- return v
61
+ # Permissive => let each item be anything
62
+ AllowedOptions = Any
63
+
64
+ # Build field definitions for an "AnswerSubModel", where each
65
+ # question_item is a required field with type AllowedOptions
66
+ field_definitions = {}
67
+ for item in question_items:
68
+ field_definitions[item] = (AllowedOptions, Field(...)) # required
69
+
70
+ # Dynamically create the submodel
71
+ MatrixAnswerSubModel = create_model(
72
+ "MatrixAnswerSubModel",
73
+ __base__=BaseModel,
74
+ **field_definitions
75
+ )
76
+
77
+ # Build the top-level model with `answer` + optional `comment`
78
+ class MatrixResponse(BaseModel):
79
+ answer: MatrixAnswerSubModel
80
+ comment: Optional[str] = None
81
+ generated_tokens: Optional[Any] = None
82
+
83
+ class Config:
84
+ # If permissive=False, forbid extra items in `answer`.
85
+ # If permissive=True, allow them.
86
+ extra = "allow" if permissive else "forbid"
62
87
 
63
88
  return MatrixResponse
64
89
 
65
90
 
66
91
  class MatrixResponseValidator(ResponseValidatorABC):
92
+ """Optional placeholder validator, if still needed for example/fixing logic."""
67
93
  required_params = ["question_items", "question_options", "permissive"]
68
94
 
69
95
  valid_examples = [
@@ -96,6 +122,10 @@ class MatrixResponseValidator(ResponseValidatorABC):
96
122
  ]
97
123
 
98
124
  def fix(self, response, verbose=False):
125
+ """
126
+ Example fix() method to try and repair a partially invalid response.
127
+ (This logic is carried over from your original code.)
128
+ """
99
129
  if verbose:
100
130
  print(f"Fixing matrix response: {response}")
101
131
 
@@ -111,12 +141,10 @@ class MatrixResponseValidator(ResponseValidatorABC):
111
141
  for idx, item in enumerate(self.question_items):
112
142
  if str(idx) in fixed:
113
143
  mapped_answer[item] = fixed[str(idx)]
114
- if (
115
- mapped_answer
116
- ): # Only return if we successfully mapped some answers
144
+ if mapped_answer:
117
145
  return {"answer": mapped_answer}
118
- except:
119
- pass
146
+ except (ValueError, KeyError, TypeError):
147
+ pass # Just continue
120
148
 
121
149
  # If answer uses numeric keys, map them to question items
122
150
  if "answer" in response and isinstance(response["answer"], dict):
@@ -125,14 +153,21 @@ class MatrixResponseValidator(ResponseValidatorABC):
125
153
  for idx, item in enumerate(self.question_items):
126
154
  if str(idx) in response["answer"]:
127
155
  mapped_answer[item] = response["answer"][str(idx)]
128
- if mapped_answer: # Only update if we successfully mapped some answers
156
+ if mapped_answer:
129
157
  response["answer"] = mapped_answer
130
158
 
131
159
  return response
132
160
 
133
161
 
134
162
  class QuestionMatrix(QuestionBase):
135
- """A question that presents a matrix/grid where multiple items are rated using the same scale."""
163
+ """
164
+ A question that presents a matrix/grid where multiple items are rated
165
+ or selected from the same set of options.
166
+
167
+ This version dynamically builds a Pydantic model at runtime
168
+ (via `create_matrix_response`) and automatically raises ValidationError
169
+ if the user provides an invalid or incomplete answer.
170
+ """
136
171
 
137
172
  question_type = "matrix"
138
173
  question_text: str = QuestionTextDescriptor()
@@ -155,18 +190,19 @@ class QuestionMatrix(QuestionBase):
155
190
  question_presentation: Optional[str] = None,
156
191
  permissive: bool = False,
157
192
  ):
158
- """Initialize a matrix question.
193
+ """
194
+ Initialize a matrix question.
159
195
 
160
196
  Args:
161
197
  question_name: The name of the question
162
198
  question_text: The text of the question
163
- question_items: List of items to be rated
164
- question_options: List of rating options
165
- option_labels: Optional mapping of options to their labels
199
+ question_items: List of items to be rated or answered
200
+ question_options: Possible answer options (e.g., [1,2,3] or ["Yes","No"])
201
+ option_labels: Optional mapping of options to labels (e.g. {1: "Sad", 5: "Happy"})
166
202
  include_comment: Whether to include a comment field
167
- answering_instructions: Optional custom instructions
168
- question_presentation: Optional custom presentation
169
- permissive: Whether to strictly validate responses
203
+ answering_instructions: Custom instructions
204
+ question_presentation: Custom presentation
205
+ permissive: Whether to allow any values & extra items instead of strictly checking
170
206
  """
171
207
  self.question_name = question_name
172
208
 
@@ -186,9 +222,14 @@ class QuestionMatrix(QuestionBase):
186
222
  self.question_presentation = question_presentation
187
223
  self.permissive = permissive
188
224
 
189
- def create_response_model(self):
225
+ def create_response_model(self) -> Type[BaseModel]:
226
+ """
227
+ Returns the pydantic model that will parse/validate a user answer.
228
+ """
190
229
  return create_matrix_response(
191
- self.question_items, self.question_options, self.permissive
230
+ self.question_items,
231
+ self.question_options,
232
+ self.permissive
192
233
  )
193
234
 
194
235
  @property
@@ -225,7 +266,6 @@ class QuestionMatrix(QuestionBase):
225
266
  </table>
226
267
  """
227
268
  )
228
-
229
269
  return template.render(
230
270
  question_name=self.question_name,
231
271
  question_items=self.question_items,
@@ -256,11 +296,5 @@ class QuestionMatrix(QuestionBase):
256
296
  "answer": {
257
297
  item: random.choice(self.question_options)
258
298
  for item in self.question_items
259
- }
260
- }
261
-
262
-
263
- if __name__ == "__main__":
264
- import doctest
265
-
266
- doctest.testmod(optionflags=doctest.ELLIPSIS)
299
+ }
300
+ }
@@ -4,7 +4,6 @@ from typing import Union, Literal, Optional, List, Any
4
4
  from jinja2 import Template
5
5
  from pydantic import BaseModel, Field
6
6
 
7
- from ..scenarios import Scenario
8
7
  from .question_base import QuestionBase
9
8
  from .descriptors import QuestionOptionsDescriptor
10
9
  from .decorators import inject_exception
@@ -314,7 +313,8 @@ class QuestionMultipleChoice(QuestionBase):
314
313
 
315
314
  if potential_replacement is None:
316
315
  # Nope - maybe it's in the substition dict?
317
- raise ValueError(
316
+ from .exceptions import QuestionValueError
317
+ raise QuestionValueError(
318
318
  f"Could not find the key '{question_option_key}' in the scenario."
319
319
  f"The substition dict was: '{substitution_dict}.'"
320
320
  f"The question options were: '{question_options}'."
@@ -353,11 +353,13 @@ class QuestionMultipleChoice(QuestionBase):
353
353
  try:
354
354
  return translated_options[int(answer_code)]
355
355
  except IndexError:
356
- raise ValueError(
356
+ from .exceptions import QuestionValueError
357
+ raise QuestionValueError(
357
358
  f"Answer code is out of range. The answer code index was: {int(answer_code)}. The options were: {translated_options}."
358
359
  )
359
360
  except TypeError:
360
- raise ValueError(
361
+ from .exceptions import QuestionValueError
362
+ raise QuestionValueError(
361
363
  f"The answer code was: '{answer_code}.'",
362
364
  f"The options were: '{translated_options}'.",
363
365
  )
@@ -1,11 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
- from random import uniform
4
- from typing import Any, Optional, Union, Literal
3
+ from typing import Any, Optional, Union
5
4
 
6
- from pydantic import BaseModel, Field, field_validator
5
+ from pydantic import BaseModel, Field
7
6
 
8
- from .exceptions import QuestionAnswerValidationError
9
7
  from .question_base import QuestionBase
10
8
  from .descriptors import NumericalOrNoneDescriptor
11
9
  from .decorators import inject_exception
@@ -42,7 +42,8 @@ class Question(metaclass=Meta):
42
42
 
43
43
  subclass = get_question_classes.get(question_type, None)
44
44
  if subclass is None:
45
- raise ValueError(
45
+ from .exceptions import QuestionValueError
46
+ raise QuestionValueError(
46
47
  f"No question registered with question_type {question_type}"
47
48
  )
48
49
 
@@ -144,7 +145,8 @@ def get_question_class(question_type):
144
145
  """Return the class for the given question type."""
145
146
  q2c = RegisterQuestionsMeta.question_types_to_classes()
146
147
  if question_type not in q2c:
147
- raise ValueError(
148
+ from .exceptions import QuestionValueError
149
+ raise QuestionValueError(
148
150
  f"The question type, {question_type}, is not recognized. Recognied types are: {q2c.keys()}"
149
151
  )
150
152
  return q2c.get(question_type)
@@ -2,7 +2,6 @@ from __future__ import annotations
2
2
  from abc import ABCMeta
3
3
  import inspect
4
4
 
5
- from ..enums import QuestionType
6
5
  from .exceptions import QuestionMissingTypeError, QuestionBadTypeError
7
6
 
8
7
  class RegisterQuestionsMeta(ABCMeta):
@@ -1,7 +1,7 @@
1
- from abc import ABC, abstractmethod
2
- from typing import Optional, Any, List, TypedDict, TYPE_CHECKING
1
+ from abc import ABC
2
+ from typing import Optional, List, TYPE_CHECKING
3
3
 
4
- from pydantic import BaseModel, Field, field_validator, ValidationError
4
+ from pydantic import BaseModel, ValidationError
5
5
 
6
6
  from .exceptions import QuestionAnswerValidationError
7
7
  from .ExceptionExplainer import ExceptionExplainer
@@ -22,7 +22,8 @@ class ResponseValidatorABC(ABC):
22
22
  required_class_vars = ["required_params", "valid_examples", "invalid_examples"]
23
23
  for var in required_class_vars:
24
24
  if not hasattr(cls, var):
25
- raise ValueError(f"Class {cls.__name__} must have a '{var}' attribute.")
25
+ from .exceptions import QuestionValueError
26
+ raise QuestionValueError(f"Class {cls.__name__} must have a '{var}' attribute.")
26
27
 
27
28
  def __init__(
28
29
  self,
@@ -41,7 +42,8 @@ class ResponseValidatorABC(ABC):
41
42
  param for param in self.required_params if param not in kwargs
42
43
  ]
43
44
  if missing_params:
44
- raise ValueError(
45
+ from .exceptions import QuestionValueError
46
+ raise QuestionValueError(
45
47
  f"Missing required parameters: {', '.join(missing_params)}"
46
48
  )
47
49
 
@@ -98,20 +100,12 @@ class ResponseValidatorABC(ABC):
98
100
  {'answer': 42, 'comment': None, 'generated_tokens': None}
99
101
  >>> rv.max_value
100
102
  86.7
101
- >>> rv.validate({"answer": "120"})
102
- Traceback (most recent call last):
103
- ...
104
- edsl.questions.exceptions.QuestionAnswerValidationError:...
105
103
  >>> from edsl import QuestionNumerical
106
104
  >>> q = QuestionNumerical.example()
107
105
  >>> q.permissive = True
108
106
  >>> rv = q.response_validator
109
107
  >>> rv.validate({"answer": "120"})
110
108
  {'answer': 120, 'comment': None, 'generated_tokens': None}
111
- >>> rv.validate({"answer": "poo"})
112
- Traceback (most recent call last):
113
- ...
114
- edsl.questions.exceptions.QuestionAnswerValidationError:...
115
109
  """
116
110
  proposed_edsl_answer_dict = self._preprocess(raw_edsl_answer_dict)
117
111
  try:
@@ -1,4 +1,5 @@
1
1
  Please respond with a dictionary using the following keys: {{ answer_keys | join(', ') }}.
2
+ Do not include "python" for create a code block. Just return the dictionary.
2
3
 
3
4
  {% if value_descriptions %}
4
5
  Here are descriptions of the values to provide:
@@ -11,5 +11,5 @@ The options are:
11
11
  {% endfor %}
12
12
  {% endif %}
13
13
  {% if num_selections %}
14
- You can inlcude up to {{num_selections}} options in your answer.
14
+ You have to include {{num_selections}} options in your answer.
15
15
  {% endif %}
edsl/results/__init__.py CHANGED
@@ -2,4 +2,4 @@
2
2
  from .results import Results
3
3
  from .result import Result
4
4
 
5
- __all__ = ["Results"]
5
+ __all__ = ["Results", "Result"]