edsl 0.1.48__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 +129 -38
  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.48.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.48.dist-info/RECORD +0 -347
  238. {edsl-0.1.48.dist-info → edsl-0.1.50.dist-info}/LICENSE +0 -0
  239. {edsl-0.1.48.dist-info → edsl-0.1.50.dist-info}/WHEEL +0 -0
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations
2
- from typing import Any, Optional, Union, List
2
+ from typing import Optional, List
3
3
 
4
4
  from pydantic import Field, BaseModel, validator
5
5
 
@@ -40,14 +40,18 @@ def create_budget_model(
40
40
  @validator("answer")
41
41
  def validate_answer(cls, v):
42
42
  if len(v) != len(question_options):
43
- raise ValueError(f"Must provide {len(question_options)} values")
43
+ from .exceptions import QuestionAnswerValidationError
44
+ raise QuestionAnswerValidationError(f"Must provide {len(question_options)} values")
44
45
  if any(x < 0 for x in v):
45
- raise ValueError("All values must be non-negative")
46
+ from .exceptions import QuestionAnswerValidationError
47
+ raise QuestionAnswerValidationError("All values must be non-negative")
46
48
  total = sum(v)
47
49
  if not permissive and total != budget_sum:
48
- raise ValueError(f"Sum of numbers must equal {budget_sum}")
50
+ from .exceptions import QuestionAnswerValidationError
51
+ raise QuestionAnswerValidationError(f"Sum of numbers must equal {budget_sum}")
49
52
  elif permissive and total > budget_sum:
50
- raise ValueError(f"Sum of numbers cannot exceed {budget_sum}")
53
+ from .exceptions import QuestionAnswerValidationError
54
+ raise QuestionAnswerValidationError(f"Sum of numbers cannot exceed {budget_sum}")
51
55
  return v
52
56
 
53
57
  class Config:
@@ -1,11 +1,9 @@
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
 
5
4
  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
5
+ from pydantic import BaseModel, Field
6
+ from typing import List, Literal, Annotated
9
7
 
10
8
  from .exceptions import QuestionAnswerValidationError
11
9
  from ..scenarios import Scenario
@@ -1,6 +1,18 @@
1
+ """
2
+ question_dict.py
3
+
4
+ Drop-in replacement for `QuestionDict`, with dynamic creation of a Pydantic model
5
+ to validate user responses automatically (just like QuestionNumerical).
6
+
7
+
8
+ Failure:
9
+
10
+ ```python { "first_name": "Kris", "last_name": "Rosemann", "phone": "(262) 506-6064", "email": "InvestorRelations@generac.com", "title": "Senior Manager Corporate Development & Investor Relations", "external": False } ``` The first name and last name are extracted directly from the text. The phone number and email are provided in the text. The title is also given in the text. The email domain "generac.com" suggests that it is an internal email address, so "external" is set to False.
11
+ """
12
+
1
13
  from __future__ import annotations
2
14
  from typing import Union, Optional, Dict, List, Any, Type
3
- from pydantic import BaseModel, Field, field_validator
15
+ from pydantic import BaseModel, Field, create_model, ValidationError
4
16
  from jinja2 import Environment, FileSystemLoader, TemplateNotFound
5
17
  from pathlib import Path
6
18
 
@@ -16,7 +28,77 @@ from .exceptions import QuestionCreationValidationError
16
28
  from .decorators import inject_exception
17
29
 
18
30
 
31
+ def _parse_type_string(type_str: str) -> Any:
32
+ """
33
+ Very simplistic parser that can map:
34
+ - "int" -> int
35
+ - "float" -> float
36
+ - "str" -> str
37
+ - "list[str]" -> List[str]
38
+ - ...
39
+ Expand this as needed for more advanced usage.
40
+ """
41
+ type_str = type_str.strip().lower()
42
+ if type_str == "int":
43
+ return int
44
+ elif type_str == "float":
45
+ return float
46
+ elif type_str == "str":
47
+ return str
48
+ elif type_str == "list":
49
+ return List[Any]
50
+ elif type_str.startswith("list["):
51
+ # e.g. "list[str]" or "list[int]" etc.
52
+ inner = type_str[len("list["):-1].strip()
53
+ return List[_parse_type_string(inner)]
54
+ # If none matched, return a very permissive type or raise an error
55
+ return Any
56
+
57
+
58
+ def create_dict_response(
59
+ answer_keys: List[str],
60
+ value_types: List[str],
61
+ permissive: bool = False,
62
+ ) -> Type[BaseModel]:
63
+ """
64
+ Dynamically builds a Pydantic model that has:
65
+ - an `answer` submodel containing your required keys
66
+ - an optional `comment` field
67
+
68
+ If `permissive=False`, extra keys in `answer` are forbidden.
69
+ If `permissive=True`, extra keys in `answer` are allowed.
70
+ """
71
+
72
+ # 1) Build the 'answer' submodel fields
73
+ # Each key is required (using `...`), with the associated type from value_types.
74
+ field_definitions = {}
75
+ for key, t_str in zip(answer_keys, value_types):
76
+ python_type = _parse_type_string(t_str)
77
+ field_definitions[key] = (python_type, Field(...))
78
+
79
+ # Use Pydantic's create_model to construct an "AnswerSubModel" with these fields
80
+ AnswerSubModel = create_model(
81
+ "AnswerSubModel",
82
+ __base__=BaseModel,
83
+ **field_definitions
84
+ )
85
+
86
+ # 2) Define the top-level model with `answer` + optional `comment`
87
+ class DictResponse(BaseModel):
88
+ answer: AnswerSubModel
89
+ comment: Optional[str] = None
90
+ generated_tokens: Optional[Any] = Field(None)
91
+
92
+ class Config:
93
+ # If permissive=False, forbid extra keys in `answer`
94
+ # If permissive=True, allow them
95
+ extra = "allow" if permissive else "forbid"
96
+
97
+ return DictResponse
98
+
99
+
19
100
  class DictResponseValidator(ResponseValidatorABC):
101
+ """Optional placeholder if you still want a validator class around it."""
20
102
  required_params = ["answer_keys", "permissive"]
21
103
 
22
104
  valid_examples = [
@@ -42,17 +124,19 @@ class DictResponseValidator(ResponseValidatorABC):
42
124
  ),
43
125
  (
44
126
  {"answer": {"ingredients": "milk"}}, # Should be a list
45
- {"answer_keys": ["ingredients"], "value_types": ["list"]},
127
+ {"answer_keys": ["ingredients"], "value_types": ["list[str]"]},
46
128
  "Key 'ingredients' should be a list, got str",
47
129
  )
48
130
  ]
49
131
 
50
132
 
51
133
  class QuestionDict(QuestionBase):
52
- """ A QuestionDict allows you to create questions that expect dictionary responses with specific keys and value types.
134
+ """A QuestionDict allows you to create questions that expect dictionary responses
135
+ with specific keys and value types. It dynamically builds a pydantic model
136
+ so that Pydantic automatically raises ValidationError for missing/invalid fields.
137
+
138
+ Documentation: https://docs.expectedparrot.com/en/latest/questions.html#questiondict
53
139
 
54
- Documenation: https://docs.expectedparrot.com/en/latest/questions.html#questiondict
55
-
56
140
  Parameters
57
141
  ----------
58
142
  question_name : str
@@ -73,16 +157,8 @@ class QuestionDict(QuestionBase):
73
157
  Additional instructions for answering
74
158
  permissive : bool
75
159
  If True, allows additional keys not specified in answer_keys
76
-
77
- Examples
78
- --------
79
- >>> q = QuestionDict(
80
- ... question_name="tweet",
81
- ... question_text="Draft a tweet.",
82
- ... answer_keys=["text", "characters"],
83
- ... value_descriptions=["The text of the tweet", "The number of characters in the tweet"]
84
- ... )
85
160
  """
161
+
86
162
  question_type = "dict"
87
163
  question_text: str = QuestionTextDescriptor()
88
164
  answer_keys: List[str] = AnswerKeysDescriptor()
@@ -92,121 +168,6 @@ class QuestionDict(QuestionBase):
92
168
  _response_model = None
93
169
  response_validator_class = DictResponseValidator
94
170
 
95
- def _get_default_answer(self) -> Dict[str, Any]:
96
- """Get default answer based on types."""
97
- answer = {}
98
- if not self.value_types:
99
- return {
100
- "title": "Sample Recipe",
101
- "ingredients": ["ingredient1", "ingredient2"],
102
- "num_ingredients": 2,
103
- "instructions": "Sample instructions"
104
- }
105
-
106
- for key, type_str in zip(self.answer_keys, self.value_types):
107
- if type_str.startswith(('list[', 'list')):
108
- if '[' in type_str:
109
- element_type = type_str[type_str.index('[') + 1:type_str.rindex(']')].lower()
110
- if element_type == 'str':
111
- answer[key] = ["sample_string"]
112
- elif element_type == 'int':
113
- answer[key] = [1]
114
- elif element_type == 'float':
115
- answer[key] = [1.0]
116
- else:
117
- answer[key] = []
118
- else:
119
- answer[key] = []
120
- else:
121
- if type_str == 'str':
122
- answer[key] = "sample_string"
123
- elif type_str == 'int':
124
- answer[key] = 1
125
- elif type_str == 'float':
126
- answer[key] = 1.0
127
- else:
128
- answer[key] = None
129
-
130
- return answer
131
-
132
- def create_response_model(
133
- self,
134
- ) -> Type[BaseModel]:
135
- """Create a response model for dict questions."""
136
- default_answer = self._get_default_answer()
137
-
138
- class DictResponse(BaseModel):
139
- answer: Dict[str, Any] = Field(
140
- default_factory=lambda: default_answer.copy()
141
- )
142
- comment: Optional[str] = None
143
-
144
- @field_validator("answer")
145
- def validate_answer(cls, v, values, **kwargs):
146
- # Ensure all keys exist
147
- missing_keys = set(self.answer_keys) - set(v.keys())
148
- if missing_keys:
149
- raise ValueError(f"Missing required keys: {missing_keys}")
150
-
151
- # Validate value types if not permissive
152
- if not self.permissive and self.value_types:
153
- for key, type_str in zip(self.answer_keys, self.value_types):
154
- if key not in v:
155
- continue
156
-
157
- value = v[key]
158
- type_str = type_str.lower() # Normalize to lowercase
159
-
160
- # Handle list types
161
- if type_str.startswith(('list[', 'list')):
162
- if not isinstance(value, list):
163
- raise ValueError(f"Key '{key}' should be a list, got {type(value).__name__}")
164
-
165
- # If it's a parameterized list, check element types
166
- if '[' in type_str:
167
- element_type = type_str[type_str.index('[') + 1:type_str.rindex(']')]
168
- element_type = element_type.lower().strip()
169
-
170
- for i, elem in enumerate(value):
171
- expected_type = {
172
- 'str': str,
173
- 'int': int,
174
- 'float': float,
175
- 'list': list
176
- }.get(element_type)
177
-
178
- if expected_type and not isinstance(elem, expected_type):
179
- raise ValueError(
180
- f"List element at index {i} for key '{key}' "
181
- f"has type {type(elem).__name__}, expected {element_type}"
182
- )
183
- else:
184
- # Handle basic types
185
- expected_type = {
186
- 'str': str,
187
- 'int': int,
188
- 'float': float,
189
- 'list': list,
190
- }.get(type_str)
191
-
192
- if expected_type and not isinstance(value, expected_type):
193
- raise ValueError(
194
- f"Key '{key}' has value of type {type(value).__name__}, expected {type_str}"
195
- )
196
- return v
197
-
198
- model_config = {
199
- "json_schema_extra": {
200
- "examples": [{
201
- "answer": default_answer,
202
- "comment": None
203
- }]
204
- }
205
- }
206
-
207
- DictResponse.__name__ = "DictResponse"
208
- return DictResponse
209
-
210
171
  def __init__(
211
172
  self,
212
173
  question_name: str,
@@ -243,67 +204,54 @@ class QuestionDict(QuestionBase):
243
204
  "Length of value_descriptions must match length of answer_keys."
244
205
  )
245
206
 
246
- @staticmethod
247
- def _normalize_value_types(value_types: Optional[List[Union[str, type]]]) -> Optional[List[str]]:
248
- """Convert all value_types to string representations, including type hints."""
249
- if not value_types:
250
- return None
251
-
252
- def normalize_type(t) -> str:
253
- # Handle string representations of List
254
- t_str = str(t)
255
- if t_str == 'List':
256
- return 'list'
257
-
258
- # Handle string inputs
259
- if isinstance(t, str):
260
- t = t.lower()
261
- # Handle list types
262
- if t.startswith(('list[', 'list')):
263
- if '[' in t:
264
- # Normalize the inner type
265
- inner_type = t[t.index('[') + 1:t.rindex(']')].strip().lower()
266
- return f"list[{inner_type}]"
267
- return "list"
268
- return t
269
-
270
- # Handle List the same as list
271
- if t_str == "<class 'List'>":
272
- return "list"
273
-
274
- # If it's list type
275
- if t is list:
276
- return "list"
277
-
278
- # If it's a basic type
279
- if hasattr(t, "__name__"):
280
- return t.__name__.lower()
281
-
282
- # If it's a typing.List
283
- if t_str.startswith(('list[', 'list')):
284
- return t_str.replace('typing.', '').lower()
285
-
286
- # Handle generic types
287
- if hasattr(t, "__origin__"):
288
- origin = t.__origin__.__name__.lower()
289
- args = [
290
- arg.__name__.lower() if hasattr(arg, "__name__") else str(arg).lower()
291
- for arg in t.__args__
292
- ]
293
- return f"{origin}[{', '.join(args)}]"
207
+ def create_response_model(self) -> Type[BaseModel]:
208
+ """
209
+ Build and return the Pydantic model that should parse/validate user answers.
210
+ This is similar to `QuestionNumerical.create_response_model`, but for dicts.
211
+ """
212
+ return create_dict_response(
213
+ answer_keys=self.answer_keys,
214
+ value_types=self.value_types or [],
215
+ permissive=self.permissive
216
+ )
294
217
 
295
- raise QuestionCreationValidationError(
296
- f"Invalid type in value_types: {t}. Must be a type or string."
297
- )
218
+ def _get_default_answer(self) -> Dict[str, Any]:
219
+ """Build a default example answer based on the declared types."""
220
+ if not self.value_types:
221
+ # If user didn't specify types, return some default structure
222
+ return {
223
+ "title": "Sample Recipe",
224
+ "ingredients": ["ingredient1", "ingredient2"],
225
+ "num_ingredients": 2,
226
+ "instructions": "Sample instructions"
227
+ }
298
228
 
299
- normalized = []
300
- for t in value_types:
301
- try:
302
- normalized.append(normalize_type(t))
303
- except Exception as e:
304
- raise QuestionCreationValidationError(f"Error normalizing type {t}: {str(e)}")
305
-
306
- return normalized
229
+ answer = {}
230
+ for key, type_str in zip(self.answer_keys, self.value_types):
231
+ t_str = type_str.lower()
232
+ if t_str.startswith("list["):
233
+ # e.g. list[str], list[int], etc.
234
+ inner = t_str[len("list["):-1].strip()
235
+ if inner == "str":
236
+ answer[key] = ["sample_string"]
237
+ elif inner == "int":
238
+ answer[key] = [1]
239
+ elif inner == "float":
240
+ answer[key] = [1.0]
241
+ else:
242
+ answer[key] = []
243
+ elif t_str == "str":
244
+ answer[key] = "sample_string"
245
+ elif t_str == "int":
246
+ answer[key] = 1
247
+ elif t_str == "float":
248
+ answer[key] = 1.0
249
+ elif t_str == "list":
250
+ answer[key] = []
251
+ else:
252
+ # fallback
253
+ answer[key] = None
254
+ return answer
307
255
 
308
256
  def _render_template(self, template_name: str) -> str:
309
257
  """Render a template using Jinja."""
@@ -322,6 +270,34 @@ class QuestionDict(QuestionBase):
322
270
  except TemplateNotFound:
323
271
  return f"Template {template_name} not found in {template_dir}."
324
272
 
273
+ @staticmethod
274
+ def _normalize_value_types(value_types: Optional[List[Union[str, type]]]) -> Optional[List[str]]:
275
+ """
276
+ Convert all value_types to string representations (e.g. "int", "list[str]", etc.).
277
+ This logic is similar to your original approach but expanded to handle
278
+ python `type` objects as well as string hints.
279
+ """
280
+ if not value_types:
281
+ return None
282
+
283
+ def normalize_type(t) -> str:
284
+ # Already a string?
285
+ if isinstance(t, str):
286
+ return t.lower().strip()
287
+
288
+ # It's a Python built-in type?
289
+ if hasattr(t, "__name__"):
290
+ if t.__name__ == "List":
291
+ return "list"
292
+ # For int, float, str, etc.
293
+ return t.__name__.lower()
294
+
295
+ # If it's a generic type like List[str], parse from its __origin__ / __args__
296
+ # or fallback:
297
+ return str(t).lower()
298
+
299
+ return [normalize_type(t) for t in value_types]
300
+
325
301
  def to_dict(self, add_edsl_version: bool = True) -> dict:
326
302
  """Serialize to JSON-compatible dictionary."""
327
303
  return {
@@ -366,12 +342,13 @@ class QuestionDict(QuestionBase):
366
342
  )
367
343
 
368
344
  def _simulate_answer(self) -> dict:
369
- """Simulate an answer for the question."""
370
- return {
371
- "answer": self._get_default_answer(),
372
- "comment": None
373
- }
345
+ """Simulate an answer for the question."""
346
+ return {
347
+ "answer": self._get_default_answer(),
348
+ "comment": None
349
+ }
350
+
374
351
 
375
352
  if __name__ == "__main__":
376
353
  q = QuestionDict.example()
377
- print(q.to_dict())
354
+ print(q.to_dict())
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
  import json
3
3
  import re
4
4
  from typing import Dict, Any
5
- from typing import Any, Optional, Dict
5
+ from typing import Optional
6
6
 
7
7
  from pydantic import create_model, Field
8
8
 
@@ -1,14 +1,11 @@
1
1
  from __future__ import annotations
2
- from typing import Any, Optional
3
- from typing import Optional, Any, List
2
+ from typing import Optional
4
3
 
5
4
  from uuid import uuid4
6
5
 
7
- from pydantic import field_validator, model_validator, BaseModel
6
+ from pydantic import model_validator, BaseModel
8
7
 
9
- from ..prompts import Prompt
10
8
 
11
- from .exceptions import QuestionAnswerValidationError
12
9
  from .question_base import QuestionBase
13
10
  from .response_validator_abc import ResponseValidatorABC
14
11
  from .decorators import inject_exception
@@ -48,7 +45,8 @@ class FreeTextResponse(BaseModel):
48
45
  if self.generated_tokens is not None: # If generated_tokens exists
49
46
  # Ensure exact string equality
50
47
  if self.answer.strip() != self.generated_tokens.strip(): # They MUST match exactly
51
- raise ValueError(
48
+ from .exceptions import QuestionAnswerValidationError
49
+ raise QuestionAnswerValidationError(
52
50
  f"answer '{self.answer}' must exactly match generated_tokens '{self.generated_tokens}'. "
53
51
  f"Type of answer: {type(self.answer)}, Type of tokens: {type(self.generated_tokens)}"
54
52
  )
@@ -5,7 +5,6 @@ import inspect
5
5
  from .question_base import QuestionBase
6
6
 
7
7
  from ..utilities.restricted_python import create_restricted_function
8
- from ..utilities.decorators import add_edsl_version, remove_edsl_version
9
8
 
10
9
 
11
10
  class QuestionFunctional(QuestionBase):
@@ -99,11 +98,13 @@ class QuestionFunctional(QuestionBase):
99
98
 
100
99
  def _simulate_answer(self, human_readable=True) -> dict[str, str]:
101
100
  """Required by Question, but not used by QuestionFunctional."""
102
- raise NotImplementedError
101
+ from .exceptions import QuestionNotImplementedError
102
+ raise QuestionNotImplementedError("_simulate_answer not implemented for QuestionFunctional")
103
103
 
104
104
  def _validate_answer(self, answer: dict[str, str]):
105
105
  """Required by Question, but not used by QuestionFunctional."""
106
- raise NotImplementedError
106
+ from .exceptions import QuestionNotImplementedError
107
+ raise QuestionNotImplementedError("_validate_answer not implemented for QuestionFunctional")
107
108
 
108
109
  @property
109
110
  def question_html_content(self) -> str: