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,8 +1,22 @@
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
4
16
  from jinja2 import Environment, FileSystemLoader, TemplateNotFound
5
17
  from pathlib import Path
18
+ import re
19
+ import ast
6
20
 
7
21
  from .question_base import QuestionBase
8
22
  from .descriptors import (
@@ -16,9 +30,251 @@ from .exceptions import QuestionCreationValidationError
16
30
  from .decorators import inject_exception
17
31
 
18
32
 
33
+ def _parse_type_string(type_str: str) -> Any:
34
+ """
35
+ Very simplistic parser that can map:
36
+ - "int" -> int
37
+ - "float" -> float
38
+ - "str" -> str
39
+ - "list[str]" -> List[str]
40
+ - ...
41
+ Expand this as needed for more advanced usage.
42
+ """
43
+ type_str = type_str.strip().lower()
44
+ if type_str == "int":
45
+ return int
46
+ elif type_str == "float":
47
+ return float
48
+ elif type_str == "str":
49
+ return str
50
+ elif type_str == "list":
51
+ return List[Any]
52
+ elif type_str.startswith("list["):
53
+ # e.g. "list[str]" or "list[int]" etc.
54
+ inner = type_str[len("list["):-1].strip()
55
+ return List[_parse_type_string(inner)]
56
+ # If none matched, return a very permissive type or raise an error
57
+ return Any
58
+
59
+
60
+ def create_dict_response(
61
+ answer_keys: List[str],
62
+ value_types: List[str],
63
+ permissive: bool = False,
64
+ ) -> Type[BaseModel]:
65
+ """
66
+ Dynamically builds a Pydantic model that has:
67
+ - an `answer` submodel containing your required keys
68
+ - an optional `comment` field
69
+
70
+ If `permissive=False`, extra keys in `answer` are forbidden.
71
+ If `permissive=True`, extra keys in `answer` are allowed.
72
+ """
73
+
74
+ # 1) Build the 'answer' submodel fields
75
+ # Each key is required (using `...`), with the associated type from value_types.
76
+ field_definitions = {}
77
+ for key, t_str in zip(answer_keys, value_types):
78
+ python_type = _parse_type_string(t_str)
79
+ field_definitions[key] = (python_type, Field(...))
80
+
81
+ # Use Pydantic's create_model to construct an "AnswerSubModel" with these fields
82
+ AnswerSubModel = create_model(
83
+ "AnswerSubModel",
84
+ __base__=BaseModel,
85
+ **field_definitions
86
+ )
87
+
88
+ # 2) Define the top-level model with `answer` + optional `comment`
89
+ class DictResponse(BaseModel):
90
+ answer: AnswerSubModel
91
+ comment: Optional[str] = None
92
+ generated_tokens: Optional[Any] = Field(None)
93
+
94
+ class Config:
95
+ # If permissive=False, forbid extra keys in `answer`
96
+ # If permissive=True, allow them
97
+ extra = "allow" if permissive else "forbid"
98
+
99
+ return DictResponse
100
+
101
+
19
102
  class DictResponseValidator(ResponseValidatorABC):
103
+ """
104
+ Validator for dictionary responses with specific keys and value types.
105
+
106
+ This validator ensures that:
107
+ 1. All required keys are present in the answer
108
+ 2. Each value has the correct type as specified
109
+ 3. Extra keys are forbidden unless permissive=True
110
+
111
+ Examples:
112
+ >>> from edsl.questions import QuestionDict
113
+ >>> q = QuestionDict(
114
+ ... question_name="recipe",
115
+ ... question_text="Describe a recipe",
116
+ ... answer_keys=["name", "ingredients", "steps"],
117
+ ... value_types=["str", "list[str]", "list[str]"]
118
+ ... )
119
+ >>> validator = q.response_validator
120
+ >>> result = validator.validate({
121
+ ... "answer": {
122
+ ... "name": "Pancakes",
123
+ ... "ingredients": ["flour", "milk", "eggs"],
124
+ ... "steps": ["Mix", "Cook", "Serve"]
125
+ ... }
126
+ ... })
127
+ >>> sorted(result.keys())
128
+ ['answer', 'comment', 'generated_tokens']
129
+ """
20
130
  required_params = ["answer_keys", "permissive"]
21
131
 
132
+ def fix(self, response, verbose=False):
133
+ """
134
+ Attempt to fix an invalid dictionary response.
135
+
136
+ Examples:
137
+ >>> # Set up validator with proper response model
138
+ >>> from pydantic import BaseModel, create_model, Field
139
+ >>> from typing import Optional
140
+ >>> # Create a proper response model that matches our expected structure
141
+ >>> AnswerModel = create_model('AnswerModel', name=(str, ...), age=(int, ...))
142
+ >>> ResponseModel = create_model(
143
+ ... 'ResponseModel',
144
+ ... answer=(AnswerModel, ...),
145
+ ... comment=(Optional[str], None),
146
+ ... generated_tokens=(Optional[Any], None)
147
+ ... )
148
+ >>> validator = DictResponseValidator(
149
+ ... response_model=ResponseModel,
150
+ ... answer_keys=["name", "age"],
151
+ ... permissive=False
152
+ ... )
153
+ >>> validator.value_types = ["str", "int"]
154
+
155
+ # Fix dictionary with comment on same line
156
+ >>> response = "{'name': 'john', 'age': 23} Here you go."
157
+ >>> result = validator.fix(response)
158
+ >>> dict(result['answer']) # Convert to dict for consistent output
159
+ {'name': 'john', 'age': 23}
160
+ >>> result['comment']
161
+ 'Here you go.'
162
+
163
+ # Fix type conversion (string to int)
164
+ >>> response = {"answer": {"name": "john", "age": "23"}}
165
+ >>> result = validator.fix(response)
166
+ >>> dict(result['answer']) # Convert to dict for consistent output
167
+ {'name': 'john', 'age': 23}
168
+
169
+ # Fix list from comma-separated string
170
+ >>> AnswerModel2 = create_model('AnswerModel2', name=(str, ...), hobbies=(List[str], ...))
171
+ >>> ResponseModel2 = create_model(
172
+ ... 'ResponseModel2',
173
+ ... answer=(AnswerModel2, ...),
174
+ ... comment=(Optional[str], None),
175
+ ... generated_tokens=(Optional[Any], None)
176
+ ... )
177
+ >>> validator = DictResponseValidator(
178
+ ... response_model=ResponseModel2,
179
+ ... answer_keys=["name", "hobbies"],
180
+ ... permissive=False
181
+ ... )
182
+ >>> validator.value_types = ["str", "list[str]"]
183
+ >>> response = {"answer": {"name": "john", "hobbies": "reading, gaming, coding"}}
184
+ >>> result = validator.fix(response)
185
+ >>> dict(result['answer']) # Convert to dict for consistent output
186
+ {'name': 'john', 'hobbies': ['reading', 'gaming', 'coding']}
187
+
188
+ # Handle invalid input gracefully
189
+ >>> response = "not a dictionary"
190
+ >>> validator.fix(response)
191
+ 'not a dictionary'
192
+ """
193
+ # First try to separate dictionary from trailing comment if they're on the same line
194
+ if isinstance(response, str):
195
+ # Try to find where the dictionary ends and comment begins
196
+ try:
197
+ dict_match = re.match(r'(\{.*?\})(.*)', response.strip())
198
+ if dict_match:
199
+ dict_str, comment = dict_match.groups()
200
+ try:
201
+ answer_dict = ast.literal_eval(dict_str)
202
+ response = {
203
+ "answer": answer_dict,
204
+ "comment": comment.strip() if comment.strip() else None
205
+ }
206
+ except (ValueError, SyntaxError):
207
+ pass
208
+ except Exception:
209
+ pass
210
+
211
+ # Continue with existing fix logic
212
+ if "answer" not in response or not isinstance(response["answer"], dict):
213
+ if verbose:
214
+ print("Cannot fix response: 'answer' field missing or not a dictionary")
215
+ return response
216
+
217
+ answer_dict = response["answer"]
218
+ fixed_answer = {}
219
+
220
+ # Try to convert values to expected types
221
+ for key, type_str in zip(self.answer_keys, getattr(self, "value_types", [])):
222
+ if key in answer_dict:
223
+ value = answer_dict[key]
224
+ # Try type conversion based on the expected type
225
+ if type_str == "int" and not isinstance(value, int):
226
+ try:
227
+ fixed_answer[key] = int(value)
228
+ if verbose:
229
+ print(f"Converted '{key}' from {type(value).__name__} to int")
230
+ continue
231
+ except (ValueError, TypeError):
232
+ pass
233
+
234
+ elif type_str == "float" and not isinstance(value, float):
235
+ try:
236
+ fixed_answer[key] = float(value)
237
+ if verbose:
238
+ print(f"Converted '{key}' from {type(value).__name__} to float")
239
+ continue
240
+ except (ValueError, TypeError):
241
+ pass
242
+
243
+ elif type_str.startswith("list[") and not isinstance(value, list):
244
+ # Try to convert string to list by splitting
245
+ if isinstance(value, str):
246
+ items = [item.strip() for item in value.split(",")]
247
+ fixed_answer[key] = items
248
+ if verbose:
249
+ print(f"Converted '{key}' from string to list: {items}")
250
+ continue
251
+
252
+ # If no conversion needed or possible, keep original
253
+ fixed_answer[key] = value
254
+
255
+ # Preserve any keys we didn't try to fix
256
+ for key, value in answer_dict.items():
257
+ if key not in fixed_answer:
258
+ fixed_answer[key] = value
259
+
260
+ # Return fixed response
261
+ fixed_response = {
262
+ "answer": fixed_answer,
263
+ "comment": response.get("comment"),
264
+ "generated_tokens": response.get("generated_tokens")
265
+ }
266
+
267
+ try:
268
+ # Validate the fixed answer
269
+ self.response_model.model_validate(fixed_response)
270
+ if verbose:
271
+ print("Successfully fixed response")
272
+ return fixed_response
273
+ except Exception as e:
274
+ if verbose:
275
+ print(f"Validation failed for fixed answer: {e}")
276
+ return response
277
+
22
278
  valid_examples = [
23
279
  (
24
280
  {
@@ -42,17 +298,19 @@ class DictResponseValidator(ResponseValidatorABC):
42
298
  ),
43
299
  (
44
300
  {"answer": {"ingredients": "milk"}}, # Should be a list
45
- {"answer_keys": ["ingredients"], "value_types": ["list"]},
301
+ {"answer_keys": ["ingredients"], "value_types": ["list[str]"]},
46
302
  "Key 'ingredients' should be a list, got str",
47
303
  )
48
304
  ]
49
305
 
50
306
 
51
307
  class QuestionDict(QuestionBase):
52
- """ A QuestionDict allows you to create questions that expect dictionary responses with specific keys and value types.
308
+ """A QuestionDict allows you to create questions that expect dictionary responses
309
+ with specific keys and value types. It dynamically builds a pydantic model
310
+ so that Pydantic automatically raises ValidationError for missing/invalid fields.
311
+
312
+ Documentation: https://docs.expectedparrot.com/en/latest/questions.html#questiondict
53
313
 
54
- Documenation: https://docs.expectedparrot.com/en/latest/questions.html#questiondict
55
-
56
314
  Parameters
57
315
  ----------
58
316
  question_name : str
@@ -73,16 +331,8 @@ class QuestionDict(QuestionBase):
73
331
  Additional instructions for answering
74
332
  permissive : bool
75
333
  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
334
  """
335
+
86
336
  question_type = "dict"
87
337
  question_text: str = QuestionTextDescriptor()
88
338
  answer_keys: List[str] = AnswerKeysDescriptor()
@@ -92,121 +342,6 @@ class QuestionDict(QuestionBase):
92
342
  _response_model = None
93
343
  response_validator_class = DictResponseValidator
94
344
 
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
345
  def __init__(
211
346
  self,
212
347
  question_name: str,
@@ -243,67 +378,54 @@ class QuestionDict(QuestionBase):
243
378
  "Length of value_descriptions must match length of answer_keys."
244
379
  )
245
380
 
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)}]"
381
+ def create_response_model(self) -> Type[BaseModel]:
382
+ """
383
+ Build and return the Pydantic model that should parse/validate user answers.
384
+ This is similar to `QuestionNumerical.create_response_model`, but for dicts.
385
+ """
386
+ return create_dict_response(
387
+ answer_keys=self.answer_keys,
388
+ value_types=self.value_types or [],
389
+ permissive=self.permissive
390
+ )
294
391
 
295
- raise QuestionCreationValidationError(
296
- f"Invalid type in value_types: {t}. Must be a type or string."
297
- )
392
+ def _get_default_answer(self) -> Dict[str, Any]:
393
+ """Build a default example answer based on the declared types."""
394
+ if not self.value_types:
395
+ # If user didn't specify types, return some default structure
396
+ return {
397
+ "title": "Sample Recipe",
398
+ "ingredients": ["ingredient1", "ingredient2"],
399
+ "num_ingredients": 2,
400
+ "instructions": "Sample instructions"
401
+ }
298
402
 
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
403
+ answer = {}
404
+ for key, type_str in zip(self.answer_keys, self.value_types):
405
+ t_str = type_str.lower()
406
+ if t_str.startswith("list["):
407
+ # e.g. list[str], list[int], etc.
408
+ inner = t_str[len("list["):-1].strip()
409
+ if inner == "str":
410
+ answer[key] = ["sample_string"]
411
+ elif inner == "int":
412
+ answer[key] = [1]
413
+ elif inner == "float":
414
+ answer[key] = [1.0]
415
+ else:
416
+ answer[key] = []
417
+ elif t_str == "str":
418
+ answer[key] = "sample_string"
419
+ elif t_str == "int":
420
+ answer[key] = 1
421
+ elif t_str == "float":
422
+ answer[key] = 1.0
423
+ elif t_str == "list":
424
+ answer[key] = []
425
+ else:
426
+ # fallback
427
+ answer[key] = None
428
+ return answer
307
429
 
308
430
  def _render_template(self, template_name: str) -> str:
309
431
  """Render a template using Jinja."""
@@ -322,6 +444,34 @@ class QuestionDict(QuestionBase):
322
444
  except TemplateNotFound:
323
445
  return f"Template {template_name} not found in {template_dir}."
324
446
 
447
+ @staticmethod
448
+ def _normalize_value_types(value_types: Optional[List[Union[str, type]]]) -> Optional[List[str]]:
449
+ """
450
+ Convert all value_types to string representations (e.g. "int", "list[str]", etc.).
451
+ This logic is similar to your original approach but expanded to handle
452
+ python `type` objects as well as string hints.
453
+ """
454
+ if not value_types:
455
+ return None
456
+
457
+ def normalize_type(t) -> str:
458
+ # Already a string?
459
+ if isinstance(t, str):
460
+ return t.lower().strip()
461
+
462
+ # It's a Python built-in type?
463
+ if hasattr(t, "__name__"):
464
+ if t.__name__ == "List":
465
+ return "list"
466
+ # For int, float, str, etc.
467
+ return t.__name__.lower()
468
+
469
+ # If it's a generic type like List[str], parse from its __origin__ / __args__
470
+ # or fallback:
471
+ return str(t).lower()
472
+
473
+ return [normalize_type(t) for t in value_types]
474
+
325
475
  def to_dict(self, add_edsl_version: bool = True) -> dict:
326
476
  """Serialize to JSON-compatible dictionary."""
327
477
  return {
@@ -366,12 +516,13 @@ class QuestionDict(QuestionBase):
366
516
  )
367
517
 
368
518
  def _simulate_answer(self) -> dict:
369
- """Simulate an answer for the question."""
370
- return {
371
- "answer": self._get_default_answer(),
372
- "comment": None
373
- }
519
+ """Simulate an answer for the question."""
520
+ return {
521
+ "answer": self._get_default_answer(),
522
+ "comment": None
523
+ }
524
+
374
525
 
375
526
  if __name__ == "__main__":
376
527
  q = QuestionDict.example()
377
- print(q.to_dict())
528
+ print(q.to_dict())