edsl 0.1.38.dev4__py3-none-any.whl → 0.1.39__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 (212) hide show
  1. edsl/Base.py +197 -116
  2. edsl/__init__.py +15 -7
  3. edsl/__version__.py +1 -1
  4. edsl/agents/Agent.py +351 -147
  5. edsl/agents/AgentList.py +211 -73
  6. edsl/agents/Invigilator.py +101 -50
  7. edsl/agents/InvigilatorBase.py +62 -70
  8. edsl/agents/PromptConstructor.py +143 -225
  9. edsl/agents/QuestionInstructionPromptBuilder.py +128 -0
  10. edsl/agents/QuestionTemplateReplacementsBuilder.py +137 -0
  11. edsl/agents/__init__.py +0 -1
  12. edsl/agents/prompt_helpers.py +3 -3
  13. edsl/agents/question_option_processor.py +172 -0
  14. edsl/auto/AutoStudy.py +18 -5
  15. edsl/auto/StageBase.py +53 -40
  16. edsl/auto/StageQuestions.py +2 -1
  17. edsl/auto/utilities.py +0 -6
  18. edsl/config.py +22 -2
  19. edsl/conversation/car_buying.py +2 -1
  20. edsl/coop/CoopFunctionsMixin.py +15 -0
  21. edsl/coop/ExpectedParrotKeyHandler.py +125 -0
  22. edsl/coop/PriceFetcher.py +1 -1
  23. edsl/coop/coop.py +125 -47
  24. edsl/coop/utils.py +14 -14
  25. edsl/data/Cache.py +45 -27
  26. edsl/data/CacheEntry.py +12 -15
  27. edsl/data/CacheHandler.py +31 -12
  28. edsl/data/RemoteCacheSync.py +154 -46
  29. edsl/data/__init__.py +4 -3
  30. edsl/data_transfer_models.py +2 -1
  31. edsl/enums.py +27 -0
  32. edsl/exceptions/__init__.py +50 -50
  33. edsl/exceptions/agents.py +12 -0
  34. edsl/exceptions/inference_services.py +5 -0
  35. edsl/exceptions/questions.py +24 -6
  36. edsl/exceptions/scenarios.py +7 -0
  37. edsl/inference_services/AnthropicService.py +38 -19
  38. edsl/inference_services/AvailableModelCacheHandler.py +184 -0
  39. edsl/inference_services/AvailableModelFetcher.py +215 -0
  40. edsl/inference_services/AwsBedrock.py +0 -2
  41. edsl/inference_services/AzureAI.py +0 -2
  42. edsl/inference_services/GoogleService.py +7 -12
  43. edsl/inference_services/InferenceServiceABC.py +18 -85
  44. edsl/inference_services/InferenceServicesCollection.py +120 -79
  45. edsl/inference_services/MistralAIService.py +0 -3
  46. edsl/inference_services/OpenAIService.py +47 -35
  47. edsl/inference_services/PerplexityService.py +0 -3
  48. edsl/inference_services/ServiceAvailability.py +135 -0
  49. edsl/inference_services/TestService.py +11 -10
  50. edsl/inference_services/TogetherAIService.py +5 -3
  51. edsl/inference_services/data_structures.py +134 -0
  52. edsl/jobs/AnswerQuestionFunctionConstructor.py +223 -0
  53. edsl/jobs/Answers.py +1 -14
  54. edsl/jobs/FetchInvigilator.py +47 -0
  55. edsl/jobs/InterviewTaskManager.py +98 -0
  56. edsl/jobs/InterviewsConstructor.py +50 -0
  57. edsl/jobs/Jobs.py +356 -431
  58. edsl/jobs/JobsChecks.py +35 -10
  59. edsl/jobs/JobsComponentConstructor.py +189 -0
  60. edsl/jobs/JobsPrompts.py +6 -4
  61. edsl/jobs/JobsRemoteInferenceHandler.py +205 -133
  62. edsl/jobs/JobsRemoteInferenceLogger.py +239 -0
  63. edsl/jobs/RequestTokenEstimator.py +30 -0
  64. edsl/jobs/async_interview_runner.py +138 -0
  65. edsl/jobs/buckets/BucketCollection.py +44 -3
  66. edsl/jobs/buckets/TokenBucket.py +53 -21
  67. edsl/jobs/buckets/TokenBucketAPI.py +211 -0
  68. edsl/jobs/buckets/TokenBucketClient.py +191 -0
  69. edsl/jobs/check_survey_scenario_compatibility.py +85 -0
  70. edsl/jobs/data_structures.py +120 -0
  71. edsl/jobs/decorators.py +35 -0
  72. edsl/jobs/interviews/Interview.py +143 -408
  73. edsl/jobs/jobs_status_enums.py +9 -0
  74. edsl/jobs/loggers/HTMLTableJobLogger.py +304 -0
  75. edsl/jobs/results_exceptions_handler.py +98 -0
  76. edsl/jobs/runners/JobsRunnerAsyncio.py +88 -403
  77. edsl/jobs/runners/JobsRunnerStatus.py +133 -165
  78. edsl/jobs/tasks/QuestionTaskCreator.py +21 -19
  79. edsl/jobs/tasks/TaskHistory.py +38 -18
  80. edsl/jobs/tasks/task_status_enum.py +0 -2
  81. edsl/language_models/ComputeCost.py +63 -0
  82. edsl/language_models/LanguageModel.py +194 -236
  83. edsl/language_models/ModelList.py +28 -19
  84. edsl/language_models/PriceManager.py +127 -0
  85. edsl/language_models/RawResponseHandler.py +106 -0
  86. edsl/language_models/ServiceDataSources.py +0 -0
  87. edsl/language_models/__init__.py +1 -2
  88. edsl/language_models/key_management/KeyLookup.py +63 -0
  89. edsl/language_models/key_management/KeyLookupBuilder.py +273 -0
  90. edsl/language_models/key_management/KeyLookupCollection.py +38 -0
  91. edsl/language_models/key_management/__init__.py +0 -0
  92. edsl/language_models/key_management/models.py +131 -0
  93. edsl/language_models/model.py +256 -0
  94. edsl/language_models/repair.py +2 -2
  95. edsl/language_models/utilities.py +5 -4
  96. edsl/notebooks/Notebook.py +19 -14
  97. edsl/notebooks/NotebookToLaTeX.py +142 -0
  98. edsl/prompts/Prompt.py +29 -39
  99. edsl/questions/ExceptionExplainer.py +77 -0
  100. edsl/questions/HTMLQuestion.py +103 -0
  101. edsl/questions/QuestionBase.py +68 -214
  102. edsl/questions/QuestionBasePromptsMixin.py +7 -3
  103. edsl/questions/QuestionBudget.py +1 -1
  104. edsl/questions/QuestionCheckBox.py +3 -3
  105. edsl/questions/QuestionExtract.py +5 -7
  106. edsl/questions/QuestionFreeText.py +2 -3
  107. edsl/questions/QuestionList.py +10 -18
  108. edsl/questions/QuestionMatrix.py +265 -0
  109. edsl/questions/QuestionMultipleChoice.py +67 -23
  110. edsl/questions/QuestionNumerical.py +2 -4
  111. edsl/questions/QuestionRank.py +7 -17
  112. edsl/questions/SimpleAskMixin.py +4 -3
  113. edsl/questions/__init__.py +2 -1
  114. edsl/questions/{AnswerValidatorMixin.py → answer_validator_mixin.py} +47 -2
  115. edsl/questions/data_structures.py +20 -0
  116. edsl/questions/derived/QuestionLinearScale.py +6 -3
  117. edsl/questions/derived/QuestionTopK.py +1 -1
  118. edsl/questions/descriptors.py +17 -3
  119. edsl/questions/loop_processor.py +149 -0
  120. edsl/questions/{QuestionBaseGenMixin.py → question_base_gen_mixin.py} +57 -50
  121. edsl/questions/question_registry.py +1 -1
  122. edsl/questions/{ResponseValidatorABC.py → response_validator_abc.py} +40 -26
  123. edsl/questions/response_validator_factory.py +34 -0
  124. edsl/questions/templates/matrix/__init__.py +1 -0
  125. edsl/questions/templates/matrix/answering_instructions.jinja +5 -0
  126. edsl/questions/templates/matrix/question_presentation.jinja +20 -0
  127. edsl/results/CSSParameterizer.py +1 -1
  128. edsl/results/Dataset.py +170 -7
  129. edsl/results/DatasetExportMixin.py +168 -305
  130. edsl/results/DatasetTree.py +28 -8
  131. edsl/results/MarkdownToDocx.py +122 -0
  132. edsl/results/MarkdownToPDF.py +111 -0
  133. edsl/results/Result.py +298 -206
  134. edsl/results/Results.py +149 -131
  135. edsl/results/ResultsExportMixin.py +2 -0
  136. edsl/results/TableDisplay.py +98 -171
  137. edsl/results/TextEditor.py +50 -0
  138. edsl/results/__init__.py +1 -1
  139. edsl/results/file_exports.py +252 -0
  140. edsl/results/{Selector.py → results_selector.py} +23 -13
  141. edsl/results/smart_objects.py +96 -0
  142. edsl/results/table_data_class.py +12 -0
  143. edsl/results/table_renderers.py +118 -0
  144. edsl/scenarios/ConstructDownloadLink.py +109 -0
  145. edsl/scenarios/DocumentChunker.py +102 -0
  146. edsl/scenarios/DocxScenario.py +16 -0
  147. edsl/scenarios/FileStore.py +150 -239
  148. edsl/scenarios/PdfExtractor.py +40 -0
  149. edsl/scenarios/Scenario.py +90 -193
  150. edsl/scenarios/ScenarioHtmlMixin.py +4 -3
  151. edsl/scenarios/ScenarioList.py +415 -244
  152. edsl/scenarios/ScenarioListExportMixin.py +0 -7
  153. edsl/scenarios/ScenarioListPdfMixin.py +15 -37
  154. edsl/scenarios/__init__.py +1 -2
  155. edsl/scenarios/directory_scanner.py +96 -0
  156. edsl/scenarios/file_methods.py +85 -0
  157. edsl/scenarios/handlers/__init__.py +13 -0
  158. edsl/scenarios/handlers/csv.py +49 -0
  159. edsl/scenarios/handlers/docx.py +76 -0
  160. edsl/scenarios/handlers/html.py +37 -0
  161. edsl/scenarios/handlers/json.py +111 -0
  162. edsl/scenarios/handlers/latex.py +5 -0
  163. edsl/scenarios/handlers/md.py +51 -0
  164. edsl/scenarios/handlers/pdf.py +68 -0
  165. edsl/scenarios/handlers/png.py +39 -0
  166. edsl/scenarios/handlers/pptx.py +105 -0
  167. edsl/scenarios/handlers/py.py +294 -0
  168. edsl/scenarios/handlers/sql.py +313 -0
  169. edsl/scenarios/handlers/sqlite.py +149 -0
  170. edsl/scenarios/handlers/txt.py +33 -0
  171. edsl/scenarios/{ScenarioJoin.py → scenario_join.py} +10 -6
  172. edsl/scenarios/scenario_selector.py +156 -0
  173. edsl/study/ObjectEntry.py +1 -1
  174. edsl/study/SnapShot.py +1 -1
  175. edsl/study/Study.py +5 -12
  176. edsl/surveys/ConstructDAG.py +92 -0
  177. edsl/surveys/EditSurvey.py +221 -0
  178. edsl/surveys/InstructionHandler.py +100 -0
  179. edsl/surveys/MemoryManagement.py +72 -0
  180. edsl/surveys/Rule.py +5 -4
  181. edsl/surveys/RuleCollection.py +25 -27
  182. edsl/surveys/RuleManager.py +172 -0
  183. edsl/surveys/Simulator.py +75 -0
  184. edsl/surveys/Survey.py +270 -791
  185. edsl/surveys/SurveyCSS.py +20 -8
  186. edsl/surveys/{SurveyFlowVisualizationMixin.py → SurveyFlowVisualization.py} +11 -9
  187. edsl/surveys/SurveyToApp.py +141 -0
  188. edsl/surveys/__init__.py +4 -2
  189. edsl/surveys/descriptors.py +6 -2
  190. edsl/surveys/instructions/ChangeInstruction.py +1 -2
  191. edsl/surveys/instructions/Instruction.py +4 -13
  192. edsl/surveys/instructions/InstructionCollection.py +11 -6
  193. edsl/templates/error_reporting/interview_details.html +1 -1
  194. edsl/templates/error_reporting/report.html +1 -1
  195. edsl/tools/plotting.py +1 -1
  196. edsl/utilities/PrettyList.py +56 -0
  197. edsl/utilities/is_notebook.py +18 -0
  198. edsl/utilities/is_valid_variable_name.py +11 -0
  199. edsl/utilities/remove_edsl_version.py +24 -0
  200. edsl/utilities/utilities.py +35 -23
  201. {edsl-0.1.38.dev4.dist-info → edsl-0.1.39.dist-info}/METADATA +12 -10
  202. edsl-0.1.39.dist-info/RECORD +358 -0
  203. {edsl-0.1.38.dev4.dist-info → edsl-0.1.39.dist-info}/WHEEL +1 -1
  204. edsl/language_models/KeyLookup.py +0 -30
  205. edsl/language_models/registry.py +0 -190
  206. edsl/language_models/unused/ReplicateBase.py +0 -83
  207. edsl/results/ResultsDBMixin.py +0 -238
  208. edsl-0.1.38.dev4.dist-info/RECORD +0 -277
  209. /edsl/questions/{RegisterQuestionsMeta.py → register_questions_meta.py} +0 -0
  210. /edsl/results/{ResultsFetchMixin.py → results_fetch_mixin.py} +0 -0
  211. /edsl/results/{ResultsToolsMixin.py → results_tools_mixin.py} +0 -0
  212. {edsl-0.1.38.dev4.dist-info → edsl-0.1.39.dist-info}/LICENSE +0 -0
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+ from typing import Union, Optional, Dict, List, Any
3
+
4
+ from pydantic import BaseModel, Field, field_validator
5
+ from jinja2 import Template
6
+ import random
7
+ from edsl.questions.QuestionBase import QuestionBase
8
+ from edsl.questions.descriptors import (
9
+ QuestionOptionsDescriptor,
10
+ OptionLabelDescriptor,
11
+ QuestionTextDescriptor,
12
+ )
13
+ from edsl.questions.response_validator_abc import ResponseValidatorABC
14
+ from edsl.questions.decorators import inject_exception
15
+ from edsl.exceptions.questions import (
16
+ QuestionAnswerValidationError,
17
+ QuestionCreationValidationError,
18
+ )
19
+
20
+
21
+ def create_matrix_response(
22
+ question_items: List[str],
23
+ question_options: List[Union[int, str, float]],
24
+ permissive: bool = False,
25
+ ):
26
+ """Create a response model for matrix questions.
27
+
28
+ The response model validates that:
29
+ 1. All question items are answered
30
+ 2. Each answer is from the allowed options
31
+ """
32
+
33
+ if permissive:
34
+
35
+ class MatrixResponse(BaseModel):
36
+ answer: Dict[str, Any]
37
+ comment: Optional[str] = None
38
+ generated_tokens: Optional[Any] = None
39
+
40
+ else:
41
+
42
+ class MatrixResponse(BaseModel):
43
+ answer: Dict[str, Union[int, str, float]] = Field(
44
+ ..., description="Mapping of items to selected options"
45
+ )
46
+ comment: Optional[str] = None
47
+ generated_tokens: Optional[Any] = None
48
+
49
+ @field_validator("answer")
50
+ def validate_answer(cls, v, values, **kwargs):
51
+ # Check that all items have responses
52
+ if not all(item in v for item in question_items):
53
+ missing = set(question_items) - set(v.keys())
54
+ raise ValueError(f"Missing responses for items: {missing}")
55
+
56
+ # Check that all responses are valid options
57
+ if not all(answer in question_options for answer in v.values()):
58
+ invalid = [ans for ans in v.values() if ans not in question_options]
59
+ raise ValueError(f"Invalid options selected: {invalid}")
60
+ return v
61
+
62
+ return MatrixResponse
63
+
64
+
65
+ class MatrixResponseValidator(ResponseValidatorABC):
66
+ required_params = ["question_items", "question_options", "permissive"]
67
+
68
+ valid_examples = [
69
+ (
70
+ {"answer": {"Item1": 1, "Item2": 2}},
71
+ {
72
+ "question_items": ["Item1", "Item2"],
73
+ "question_options": [1, 2, 3],
74
+ },
75
+ )
76
+ ]
77
+
78
+ invalid_examples = [
79
+ (
80
+ {"answer": {"Item1": 1}},
81
+ {
82
+ "question_items": ["Item1", "Item2"],
83
+ "question_options": [1, 2, 3],
84
+ },
85
+ "Missing responses for some items",
86
+ ),
87
+ (
88
+ {"answer": {"Item1": 4, "Item2": 5}},
89
+ {
90
+ "question_items": ["Item1", "Item2"],
91
+ "question_options": [1, 2, 3],
92
+ },
93
+ "Invalid options selected",
94
+ ),
95
+ ]
96
+
97
+ def fix(self, response, verbose=False):
98
+ if verbose:
99
+ print(f"Fixing matrix response: {response}")
100
+
101
+ # If we have generated tokens, try to parse them
102
+ if "generated_tokens" in response:
103
+ try:
104
+ import json
105
+
106
+ fixed = json.loads(response["generated_tokens"])
107
+ if isinstance(fixed, dict):
108
+ # Map numeric keys to question items
109
+ mapped_answer = {}
110
+ for idx, item in enumerate(self.question_items):
111
+ if str(idx) in fixed:
112
+ mapped_answer[item] = fixed[str(idx)]
113
+ if (
114
+ mapped_answer
115
+ ): # Only return if we successfully mapped some answers
116
+ return {"answer": mapped_answer}
117
+ except:
118
+ pass
119
+
120
+ # If answer uses numeric keys, map them to question items
121
+ if "answer" in response and isinstance(response["answer"], dict):
122
+ if all(str(key).isdigit() for key in response["answer"].keys()):
123
+ mapped_answer = {}
124
+ for idx, item in enumerate(self.question_items):
125
+ if str(idx) in response["answer"]:
126
+ mapped_answer[item] = response["answer"][str(idx)]
127
+ if mapped_answer: # Only update if we successfully mapped some answers
128
+ response["answer"] = mapped_answer
129
+
130
+ return response
131
+
132
+
133
+ class QuestionMatrix(QuestionBase):
134
+ """A question that presents a matrix/grid where multiple items are rated using the same scale."""
135
+
136
+ question_type = "matrix"
137
+ question_text: str = QuestionTextDescriptor()
138
+ question_items: List[str] = QuestionOptionsDescriptor()
139
+ question_options: List[Union[int, str, float]] = QuestionOptionsDescriptor()
140
+ option_labels: Optional[Dict[Union[int, str, float], str]] = OptionLabelDescriptor()
141
+
142
+ _response_model = None
143
+ response_validator_class = MatrixResponseValidator
144
+
145
+ def __init__(
146
+ self,
147
+ question_name: str,
148
+ question_text: str,
149
+ question_items: List[str],
150
+ question_options: List[Union[int, str, float]],
151
+ option_labels: Optional[Dict[Union[int, str, float], str]] = None,
152
+ include_comment: bool = True,
153
+ answering_instructions: Optional[str] = None,
154
+ question_presentation: Optional[str] = None,
155
+ permissive: bool = False,
156
+ ):
157
+ """Initialize a matrix question.
158
+
159
+ Args:
160
+ question_name: The name of the question
161
+ question_text: The text of the question
162
+ question_items: List of items to be rated
163
+ question_options: List of rating options
164
+ option_labels: Optional mapping of options to their labels
165
+ include_comment: Whether to include a comment field
166
+ answering_instructions: Optional custom instructions
167
+ question_presentation: Optional custom presentation
168
+ permissive: Whether to strictly validate responses
169
+ """
170
+ self.question_name = question_name
171
+
172
+ try:
173
+ self.question_text = question_text
174
+ except Exception as e:
175
+ raise QuestionCreationValidationError(
176
+ "question_text cannot be empty or too short!"
177
+ ) from e
178
+
179
+ self.question_items = question_items
180
+ self.question_options = question_options
181
+ self.option_labels = option_labels or {}
182
+
183
+ self.include_comment = include_comment
184
+ self.answering_instructions = answering_instructions
185
+ self.question_presentation = question_presentation
186
+ self.permissive = permissive
187
+
188
+ def create_response_model(self):
189
+ return create_matrix_response(
190
+ self.question_items, self.question_options, self.permissive
191
+ )
192
+
193
+ @property
194
+ def question_html_content(self) -> str:
195
+ """Generate HTML representation of the matrix question."""
196
+ template = Template(
197
+ """
198
+ <table class="matrix-question">
199
+ <tr>
200
+ <th></th>
201
+ {% for option in question_options %}
202
+ <th>
203
+ {{ option }}
204
+ {% if option in option_labels %}
205
+ <br>
206
+ <small>{{ option_labels[option] }}</small>
207
+ {% endif %}
208
+ </th>
209
+ {% endfor %}
210
+ </tr>
211
+ {% for item in question_items %}
212
+ <tr>
213
+ <td>{{ item }}</td>
214
+ {% for option in question_options %}
215
+ <td>
216
+ <input type="radio"
217
+ name="{{ question_name }}_{{ item }}"
218
+ value="{{ option }}"
219
+ id="{{ question_name }}_{{ item }}_{{ option }}">
220
+ </td>
221
+ {% endfor %}
222
+ </tr>
223
+ {% endfor %}
224
+ </table>
225
+ """
226
+ )
227
+
228
+ return template.render(
229
+ question_name=self.question_name,
230
+ question_items=self.question_items,
231
+ question_options=self.question_options,
232
+ option_labels=self.option_labels,
233
+ )
234
+
235
+ @classmethod
236
+ @inject_exception
237
+ def example(cls) -> QuestionMatrix:
238
+ """Return an example matrix question."""
239
+ return cls(
240
+ question_name="child_happiness",
241
+ question_text="How happy would you be with different numbers of children?",
242
+ question_items=[
243
+ "No children",
244
+ "1 child",
245
+ "2 children",
246
+ "3 or more children",
247
+ ],
248
+ question_options=[1, 2, 3, 4, 5],
249
+ option_labels={1: "Very sad", 3: "Neutral", 5: "Extremely happy"},
250
+ )
251
+
252
+ def _simulate_answer(self) -> dict:
253
+ """Simulate a random valid answer."""
254
+ return {
255
+ "answer": {
256
+ item: random.choice(self.question_options)
257
+ for item in self.question_items
258
+ }
259
+ }
260
+
261
+
262
+ if __name__ == "__main__":
263
+ import doctest
264
+
265
+ doctest.testmod(optionflags=doctest.ELLIPSIS)
@@ -8,7 +8,7 @@ from edsl.scenarios.Scenario import Scenario
8
8
  from edsl.questions.QuestionBase import QuestionBase
9
9
  from edsl.questions.descriptors import QuestionOptionsDescriptor
10
10
  from edsl.questions.decorators import inject_exception
11
- from edsl.questions.ResponseValidatorABC import ResponseValidatorABC
11
+ from edsl.questions.response_validator_abc import ResponseValidatorABC
12
12
 
13
13
 
14
14
  def create_response_model(choices: List[str], permissive: bool = False):
@@ -120,9 +120,9 @@ class QuestionMultipleChoice(QuestionBase):
120
120
 
121
121
  question_type = "multiple_choice"
122
122
  purpose = "When options are known and limited"
123
- question_options: Union[
124
- list[str], list[list], list[float], list[int]
125
- ] = QuestionOptionsDescriptor()
123
+ question_options: Union[list[str], list[list], list[float], list[int]] = (
124
+ QuestionOptionsDescriptor()
125
+ )
126
126
  _response_model = None
127
127
  response_validator_class = MultipleChoiceResponseValidator
128
128
 
@@ -175,8 +175,54 @@ class QuestionMultipleChoice(QuestionBase):
175
175
  else:
176
176
  return create_response_model(self.question_options, self.permissive)
177
177
 
178
+ @staticmethod
179
+ def _translate_question_options(
180
+ question_options, substitution_dict: dict
181
+ ) -> list[str]:
182
+
183
+ if isinstance(question_options, str):
184
+ # If dynamic options are provided like {{ options }}, render them with the scenario
185
+ # We can check if it's in the Scenario.
186
+ from jinja2 import Environment, meta
187
+
188
+ env = Environment()
189
+ parsed_content = env.parse(question_options)
190
+ template_variables = list(meta.find_undeclared_variables(parsed_content))
191
+ # print("The template variables are: ", template_variables)
192
+ question_option_key = template_variables[0]
193
+ # We need to deal with possibility it's actually an answer to a question.
194
+ potential_replacement = substitution_dict.get(question_option_key, None)
195
+
196
+ if isinstance(potential_replacement, list):
197
+ # translated_options = potential_replacement
198
+ return potential_replacement
199
+
200
+ if isinstance(potential_replacement, QuestionBase):
201
+ if hasattr(potential_replacement, "answer") and isinstance(
202
+ potential_replacement.answer, list
203
+ ):
204
+ return potential_replacement.answer
205
+ # translated_options = potential_replacement.answer
206
+
207
+ # if not isinstance(potential_replacement, list):
208
+ # translated_options = potential_replacement
209
+
210
+ if potential_replacement is None:
211
+ # Nope - maybe it's in the substition dict?
212
+ raise ValueError(
213
+ f"Could not find the key '{question_option_key}' in the scenario."
214
+ f"The substition dict was: '{substitution_dict}.'"
215
+ f"The question options were: '{question_options}'."
216
+ )
217
+ else:
218
+ translated_options = [
219
+ Template(str(option)).render(substitution_dict)
220
+ for option in question_options
221
+ ]
222
+ return translated_options
223
+
178
224
  def _translate_answer_code_to_answer(
179
- self, answer_code: int, scenario: Optional["Scenario"] = None
225
+ self, answer_code: int, replacements_dict: Optional[dict] = None
180
226
  ):
181
227
  """Translate the answer code to the actual answer.
182
228
 
@@ -192,26 +238,24 @@ class QuestionMultipleChoice(QuestionBase):
192
238
  'Happy'
193
239
 
194
240
  """
241
+ if replacements_dict is None:
242
+ replacements_dict = {}
243
+ translated_options = self._translate_question_options(
244
+ self.question_options, replacements_dict
245
+ )
195
246
 
196
- scenario = scenario or Scenario()
197
-
198
- if isinstance(self.question_options, str):
199
- # If dynamic options are provided like {{ options }}, render them with the scenario
200
- from jinja2 import Environment, meta
201
-
202
- env = Environment()
203
- parsed_content = env.parse(self.question_options)
204
- question_option_key = list(meta.find_undeclared_variables(parsed_content))[
205
- 0
206
- ]
207
- translated_options = scenario.get(question_option_key)
208
- else:
209
- translated_options = [
210
- Template(str(option)).render(scenario)
211
- for option in self.question_options
212
- ]
213
247
  if self._use_code:
214
- return translated_options[int(answer_code)]
248
+ try:
249
+ return translated_options[int(answer_code)]
250
+ except IndexError:
251
+ raise ValueError(
252
+ f"Answer code is out of range. The answer code index was: {int(answer_code)}. The options were: {translated_options}."
253
+ )
254
+ except TypeError:
255
+ raise ValueError(
256
+ f"The answer code was: '{answer_code}.'",
257
+ f"The options were: '{translated_options}'.",
258
+ )
215
259
  else:
216
260
  # return translated_options[answer_code]
217
261
  return answer_code
@@ -1,17 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
- # from decimal import Decimal
4
3
  from random import uniform
5
4
  from typing import Any, Optional, Union, Literal
6
5
 
7
6
  from pydantic import BaseModel, Field, field_validator
8
7
 
9
- from edsl.exceptions import QuestionAnswerValidationError
8
+ from edsl.exceptions.questions import QuestionAnswerValidationError
10
9
  from edsl.questions.QuestionBase import QuestionBase
11
10
  from edsl.questions.descriptors import NumericalOrNoneDescriptor
12
11
  from edsl.questions.decorators import inject_exception
13
- from edsl.questions.ResponseValidatorABC import ResponseValidatorABC
14
- from edsl.exceptions.questions import QuestionAnswerValidationError
12
+ from edsl.questions.response_validator_abc import ResponseValidatorABC
15
13
 
16
14
 
17
15
  def create_numeric_response(
@@ -1,25 +1,14 @@
1
1
  from __future__ import annotations
2
- import random
3
- import textwrap
4
- from jinja2 import Template
5
- from typing import Any, Optional, Union
6
- from edsl.questions.QuestionBase import QuestionBase
7
- from edsl.exceptions import QuestionAnswerValidationError
2
+ from typing import Optional, Any, List, Annotated, Literal
3
+
4
+ from pydantic import BaseModel, Field
8
5
 
6
+ from edsl.questions.QuestionBase import QuestionBase
9
7
  from edsl.questions.descriptors import (
10
8
  QuestionOptionsDescriptor,
11
9
  NumSelectionsDescriptor,
12
10
  )
13
-
14
- from edsl.prompts import Prompt
15
-
16
- from pydantic import field_validator
17
- from edsl.questions.ResponseValidatorABC import ResponseValidatorABC
18
- from edsl.questions.ResponseValidatorABC import BaseResponse
19
- from edsl.exceptions import QuestionAnswerValidationError
20
-
21
- from pydantic import BaseModel, Field, create_model
22
- from typing import Optional, Any, List, Annotated, Literal
11
+ from edsl.questions.response_validator_abc import ResponseValidatorABC
23
12
 
24
13
 
25
14
  def create_response_model(
@@ -201,7 +190,8 @@ class QuestionRank(QuestionBase):
201
190
  self, answer_codes, scenario: Scenario = None
202
191
  ) -> list[str]:
203
192
  """Translate the answer code to the actual answer."""
204
- from edsl.scenarios import Scenario
193
+ from edsl.scenarios.Scenario import Scenario
194
+ from jinja2 import Template
205
195
 
206
196
  scenario = scenario or Scenario()
207
197
  translated_options = [
@@ -1,5 +1,3 @@
1
- from rich.table import Table
2
- from rich.console import Console
3
1
  import math
4
2
 
5
3
 
@@ -10,6 +8,9 @@ def logprob_to_prob(logprob):
10
8
 
11
9
 
12
10
  def format_output(data):
11
+ from rich.table import Table
12
+ from rich.console import Console
13
+
13
14
  content = data["choices"][0]["logprobs"]["content"]
14
15
  table = Table(show_header=True, header_style="bold magenta")
15
16
 
@@ -65,7 +66,7 @@ class SimpleAskMixin:
65
66
  system_prompt="You are a helpful agent pretending to be a human. Do not break character",
66
67
  top_logprobs=4,
67
68
  ):
68
- from edsl import Model
69
+ from edsl.language_models.model import Model
69
70
 
70
71
  if model is None:
71
72
  model = Model()
@@ -1,6 +1,6 @@
1
1
  # Schemas
2
2
  from edsl.questions.settings import Settings
3
- from edsl.questions.RegisterQuestionsMeta import RegisterQuestionsMeta
3
+ from edsl.questions.register_questions_meta import RegisterQuestionsMeta
4
4
 
5
5
  # Base Class
6
6
  from edsl.questions.QuestionBase import QuestionBase
@@ -11,6 +11,7 @@ from edsl.questions.QuestionExtract import QuestionExtract
11
11
  from edsl.questions.QuestionFreeText import QuestionFreeText
12
12
  from edsl.questions.QuestionFunctional import QuestionFunctional
13
13
  from edsl.questions.QuestionList import QuestionList
14
+ from edsl.questions.QuestionMatrix import QuestionMatrix
14
15
  from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
15
16
  from edsl.questions.QuestionNumerical import QuestionNumerical
16
17
  from edsl.questions.QuestionBudget import QuestionBudget
@@ -2,7 +2,7 @@
2
2
 
3
3
  import re
4
4
  from typing import Any, Type, Union
5
- from edsl.exceptions import (
5
+ from edsl.exceptions.questions import (
6
6
  QuestionAnswerValidationError,
7
7
  )
8
8
 
@@ -213,7 +213,12 @@ class AnswerValidatorMixin:
213
213
  - is not less than `min_value`
214
214
  - is not greater than `max_value`
215
215
  """
216
- value = float(answer.get("answer"))
216
+ try:
217
+ value = float(answer.get("answer"))
218
+ except ValueError:
219
+ raise QuestionAnswerValidationError(
220
+ f"Answer must real number or convertible to a real number (got {answer.get('answer')})."
221
+ )
217
222
  if self.min_value is not None and value < self.min_value:
218
223
  raise QuestionAnswerValidationError(
219
224
  f"Value {value} is less than {self.min_value}"
@@ -279,6 +284,46 @@ class AnswerValidatorMixin:
279
284
  f"Rank answer {value}, but exactly {self.num_selections} selections required."
280
285
  )
281
286
 
287
+ def _validate_answer_matrix(self, answer: dict[str, Any]) -> None:
288
+ """Validate QuestionMatrix-specific answer.
289
+
290
+ Check that answer["answer"]:
291
+ - is a dictionary
292
+ - has all required question_items as keys
293
+ - has values that are valid options from question_options
294
+ - has the correct number of responses (one per item)
295
+ """
296
+ value = answer.get("answer")
297
+
298
+ # Check that answer is a dictionary
299
+ if not isinstance(value, dict):
300
+ raise QuestionAnswerValidationError(
301
+ f"Matrix answer must be a dictionary mapping items to responses (got {value})"
302
+ )
303
+
304
+ # Check that all required items are present
305
+ required_items = set(self.question_items)
306
+ provided_items = set(value.keys())
307
+
308
+ if missing_items := (required_items - provided_items):
309
+ raise QuestionAnswerValidationError(
310
+ f"Missing responses for items: {missing_items}"
311
+ )
312
+
313
+ if extra_items := (provided_items - required_items):
314
+ raise QuestionAnswerValidationError(
315
+ f"Unexpected responses for items: {extra_items}"
316
+ )
317
+
318
+ # Check that all responses are valid options
319
+ valid_options = set(self.question_options)
320
+ for item, response in value.items():
321
+ if response not in valid_options:
322
+ raise QuestionAnswerValidationError(
323
+ f"Invalid response '{response}' for item '{item}'. "
324
+ f"Must be one of: {valid_options}"
325
+ )
326
+
282
327
 
283
328
  if __name__ == "__main__":
284
329
  pass
@@ -0,0 +1,20 @@
1
+ from typing import Any, Optional, TypedDict
2
+ from pydantic import BaseModel
3
+
4
+
5
+ class RawEdslAnswerDict(TypedDict):
6
+ answer: Any
7
+ comment: Optional[str]
8
+ generated_tokens: Optional[str]
9
+
10
+
11
+ class BaseResponse(BaseModel):
12
+ answer: Any
13
+ comment: Optional[str] = None
14
+ generated_tokens: Optional[str] = None
15
+
16
+
17
+ class EdslAnswerDict(TypedDict):
18
+ answer: Any
19
+ comment: Optional[str]
20
+ generated_tokens: Optional[str]
@@ -40,9 +40,12 @@ class QuestionLinearScale(QuestionMultipleChoice):
40
40
  include_comment=include_comment,
41
41
  )
42
42
  self.question_options = question_options
43
- self.option_labels = (
44
- {int(k): v for k, v in option_labels.items()} if option_labels else {}
45
- )
43
+ if isinstance(option_labels, str):
44
+ self.option_labels = option_labels
45
+ else:
46
+ self.option_labels = (
47
+ {int(k): v for k, v in option_labels.items()} if option_labels else {}
48
+ )
46
49
  self.answering_instructions = answering_instructions
47
50
  self.question_presentation = question_presentation
48
51
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
  from typing import Optional
3
3
 
4
- from edsl.exceptions import QuestionCreationValidationError
4
+ from edsl.exceptions.questions import QuestionCreationValidationError
5
5
  from edsl.questions.QuestionCheckBox import QuestionCheckBox
6
6
  from edsl.questions.decorators import inject_exception
7
7
 
@@ -3,7 +3,7 @@
3
3
  from abc import ABC, abstractmethod
4
4
  import re
5
5
  from typing import Any, Callable, List, Optional
6
- from edsl.exceptions import (
6
+ from edsl.exceptions.questions import (
7
7
  QuestionCreationValidationError,
8
8
  QuestionAnswerValidationError,
9
9
  )
@@ -181,11 +181,25 @@ class NumSelectionsDescriptor(BaseDescriptor):
181
181
 
182
182
 
183
183
  class OptionLabelDescriptor(BaseDescriptor):
184
- """Validate that the `option_label` attribute is a string."""
184
+ """Validate that the `option_label` attribute is a string.
185
+
186
+ >>> class TestQuestion:
187
+ ... option_label = OptionLabelDescriptor()
188
+ ... def __init__(self, option_label: str):
189
+ ... self.option_label = option_label
190
+
191
+ >>> _ = TestQuestion("{{Option}}")
192
+
193
+ """
185
194
 
186
195
  def validate(self, value, instance):
187
196
  """Validate the value is a string."""
188
- # key_values = [int(v) for v in value.keys()]
197
+ if isinstance(value, str):
198
+ if "{{" in value and "}}" in value:
199
+ # they're trying to use a dynamic question name - let's let this play out
200
+ return None
201
+
202
+ key_values = [int(v) for v in value.keys()]
189
203
 
190
204
  if value and (key_values := [float(v) for v in value.keys()]) != []:
191
205
  if min(key_values) != min(instance.question_options):