edsl 0.1.33.dev1__py3-none-any.whl → 0.1.33.dev2__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 (163) hide show
  1. edsl/TemplateLoader.py +24 -0
  2. edsl/__init__.py +8 -4
  3. edsl/agents/Agent.py +46 -14
  4. edsl/agents/AgentList.py +43 -0
  5. edsl/agents/Invigilator.py +125 -212
  6. edsl/agents/InvigilatorBase.py +140 -32
  7. edsl/agents/PromptConstructionMixin.py +43 -66
  8. edsl/agents/__init__.py +1 -0
  9. edsl/auto/AutoStudy.py +117 -0
  10. edsl/auto/StageBase.py +230 -0
  11. edsl/auto/StageGenerateSurvey.py +178 -0
  12. edsl/auto/StageLabelQuestions.py +125 -0
  13. edsl/auto/StagePersona.py +61 -0
  14. edsl/auto/StagePersonaDimensionValueRanges.py +88 -0
  15. edsl/auto/StagePersonaDimensionValues.py +74 -0
  16. edsl/auto/StagePersonaDimensions.py +69 -0
  17. edsl/auto/StageQuestions.py +73 -0
  18. edsl/auto/SurveyCreatorPipeline.py +21 -0
  19. edsl/auto/utilities.py +224 -0
  20. edsl/config.py +38 -39
  21. edsl/coop/PriceFetcher.py +58 -0
  22. edsl/coop/coop.py +39 -5
  23. edsl/data/Cache.py +35 -1
  24. edsl/data_transfer_models.py +120 -38
  25. edsl/enums.py +2 -0
  26. edsl/exceptions/language_models.py +25 -1
  27. edsl/exceptions/questions.py +62 -5
  28. edsl/exceptions/results.py +4 -0
  29. edsl/inference_services/AnthropicService.py +13 -11
  30. edsl/inference_services/AwsBedrock.py +19 -17
  31. edsl/inference_services/AzureAI.py +37 -20
  32. edsl/inference_services/GoogleService.py +16 -12
  33. edsl/inference_services/GroqService.py +2 -0
  34. edsl/inference_services/InferenceServiceABC.py +24 -0
  35. edsl/inference_services/MistralAIService.py +120 -0
  36. edsl/inference_services/OpenAIService.py +41 -50
  37. edsl/inference_services/TestService.py +71 -0
  38. edsl/inference_services/models_available_cache.py +0 -6
  39. edsl/inference_services/registry.py +4 -0
  40. edsl/jobs/Answers.py +10 -12
  41. edsl/jobs/FailedQuestion.py +78 -0
  42. edsl/jobs/Jobs.py +18 -13
  43. edsl/jobs/buckets/TokenBucket.py +39 -14
  44. edsl/jobs/interviews/Interview.py +297 -77
  45. edsl/jobs/interviews/InterviewExceptionEntry.py +83 -19
  46. edsl/jobs/interviews/interview_exception_tracking.py +0 -70
  47. edsl/jobs/interviews/retry_management.py +3 -1
  48. edsl/jobs/runners/JobsRunnerAsyncio.py +116 -70
  49. edsl/jobs/runners/JobsRunnerStatusMixin.py +1 -1
  50. edsl/jobs/tasks/QuestionTaskCreator.py +30 -23
  51. edsl/jobs/tasks/TaskHistory.py +131 -213
  52. edsl/language_models/LanguageModel.py +239 -129
  53. edsl/language_models/ModelList.py +2 -2
  54. edsl/language_models/RegisterLanguageModelsMeta.py +14 -29
  55. edsl/language_models/fake_openai_call.py +15 -0
  56. edsl/language_models/fake_openai_service.py +61 -0
  57. edsl/language_models/registry.py +15 -2
  58. edsl/language_models/repair.py +0 -19
  59. edsl/language_models/utilities.py +61 -0
  60. edsl/prompts/Prompt.py +52 -2
  61. edsl/questions/AnswerValidatorMixin.py +23 -26
  62. edsl/questions/QuestionBase.py +273 -242
  63. edsl/questions/QuestionBaseGenMixin.py +133 -0
  64. edsl/questions/QuestionBasePromptsMixin.py +266 -0
  65. edsl/questions/QuestionBudget.py +6 -0
  66. edsl/questions/QuestionCheckBox.py +227 -35
  67. edsl/questions/QuestionExtract.py +98 -27
  68. edsl/questions/QuestionFreeText.py +46 -29
  69. edsl/questions/QuestionFunctional.py +7 -0
  70. edsl/questions/QuestionList.py +141 -22
  71. edsl/questions/QuestionMultipleChoice.py +173 -64
  72. edsl/questions/QuestionNumerical.py +87 -46
  73. edsl/questions/QuestionRank.py +182 -24
  74. edsl/questions/RegisterQuestionsMeta.py +31 -12
  75. edsl/questions/ResponseValidatorABC.py +169 -0
  76. edsl/questions/__init__.py +3 -4
  77. edsl/questions/decorators.py +21 -0
  78. edsl/questions/derived/QuestionLikertFive.py +10 -5
  79. edsl/questions/derived/QuestionLinearScale.py +11 -1
  80. edsl/questions/derived/QuestionTopK.py +6 -0
  81. edsl/questions/derived/QuestionYesNo.py +16 -1
  82. edsl/questions/descriptors.py +43 -7
  83. edsl/questions/prompt_templates/question_budget.jinja +13 -0
  84. edsl/questions/prompt_templates/question_checkbox.jinja +32 -0
  85. edsl/questions/prompt_templates/question_extract.jinja +11 -0
  86. edsl/questions/prompt_templates/question_free_text.jinja +3 -0
  87. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -0
  88. edsl/questions/prompt_templates/question_list.jinja +17 -0
  89. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -0
  90. edsl/questions/prompt_templates/question_numerical.jinja +37 -0
  91. edsl/questions/question_registry.py +6 -2
  92. edsl/questions/templates/__init__.py +0 -0
  93. edsl/questions/templates/checkbox/__init__.py +0 -0
  94. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -0
  95. edsl/questions/templates/checkbox/question_presentation.jinja +22 -0
  96. edsl/questions/templates/extract/answering_instructions.jinja +7 -0
  97. edsl/questions/templates/extract/question_presentation.jinja +1 -0
  98. edsl/questions/templates/free_text/__init__.py +0 -0
  99. edsl/questions/templates/free_text/answering_instructions.jinja +0 -0
  100. edsl/questions/templates/free_text/question_presentation.jinja +1 -0
  101. edsl/questions/templates/likert_five/__init__.py +0 -0
  102. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -0
  103. edsl/questions/templates/likert_five/question_presentation.jinja +12 -0
  104. edsl/questions/templates/linear_scale/__init__.py +0 -0
  105. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -0
  106. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -0
  107. edsl/questions/templates/list/__init__.py +0 -0
  108. edsl/questions/templates/list/answering_instructions.jinja +4 -0
  109. edsl/questions/templates/list/question_presentation.jinja +5 -0
  110. edsl/questions/templates/multiple_choice/__init__.py +0 -0
  111. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -0
  112. edsl/questions/templates/multiple_choice/html.jinja +0 -0
  113. edsl/questions/templates/multiple_choice/question_presentation.jinja +12 -0
  114. edsl/questions/templates/numerical/__init__.py +0 -0
  115. edsl/questions/templates/numerical/answering_instructions.jinja +8 -0
  116. edsl/questions/templates/numerical/question_presentation.jinja +7 -0
  117. edsl/questions/templates/rank/answering_instructions.jinja +11 -0
  118. edsl/questions/templates/rank/question_presentation.jinja +15 -0
  119. edsl/questions/templates/top_k/__init__.py +0 -0
  120. edsl/questions/templates/top_k/answering_instructions.jinja +8 -0
  121. edsl/questions/templates/top_k/question_presentation.jinja +22 -0
  122. edsl/questions/templates/yes_no/__init__.py +0 -0
  123. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -0
  124. edsl/questions/templates/yes_no/question_presentation.jinja +12 -0
  125. edsl/results/Dataset.py +20 -0
  126. edsl/results/DatasetExportMixin.py +41 -47
  127. edsl/results/DatasetTree.py +145 -0
  128. edsl/results/Result.py +32 -5
  129. edsl/results/Results.py +131 -45
  130. edsl/results/ResultsDBMixin.py +3 -3
  131. edsl/results/Selector.py +118 -0
  132. edsl/results/tree_explore.py +115 -0
  133. edsl/scenarios/Scenario.py +10 -4
  134. edsl/scenarios/ScenarioList.py +348 -39
  135. edsl/scenarios/ScenarioListExportMixin.py +9 -0
  136. edsl/study/SnapShot.py +8 -1
  137. edsl/surveys/RuleCollection.py +2 -2
  138. edsl/surveys/Survey.py +634 -315
  139. edsl/surveys/SurveyExportMixin.py +71 -9
  140. edsl/surveys/SurveyFlowVisualizationMixin.py +2 -1
  141. edsl/surveys/SurveyQualtricsImport.py +75 -4
  142. edsl/surveys/instructions/ChangeInstruction.py +47 -0
  143. edsl/surveys/instructions/Instruction.py +34 -0
  144. edsl/surveys/instructions/InstructionCollection.py +77 -0
  145. edsl/surveys/instructions/__init__.py +0 -0
  146. edsl/templates/error_reporting/base.html +24 -0
  147. edsl/templates/error_reporting/exceptions_by_model.html +35 -0
  148. edsl/templates/error_reporting/exceptions_by_question_name.html +17 -0
  149. edsl/templates/error_reporting/exceptions_by_type.html +17 -0
  150. edsl/templates/error_reporting/interview_details.html +111 -0
  151. edsl/templates/error_reporting/interviews.html +10 -0
  152. edsl/templates/error_reporting/overview.html +5 -0
  153. edsl/templates/error_reporting/performance_plot.html +2 -0
  154. edsl/templates/error_reporting/report.css +74 -0
  155. edsl/templates/error_reporting/report.html +118 -0
  156. edsl/templates/error_reporting/report.js +25 -0
  157. {edsl-0.1.33.dev1.dist-info → edsl-0.1.33.dev2.dist-info}/METADATA +4 -2
  158. edsl-0.1.33.dev2.dist-info/RECORD +289 -0
  159. edsl/jobs/interviews/InterviewTaskBuildingMixin.py +0 -286
  160. edsl/utilities/gcp_bucket/simple_example.py +0 -9
  161. edsl-0.1.33.dev1.dist-info/RECORD +0 -209
  162. {edsl-0.1.33.dev1.dist-info → edsl-0.1.33.dev2.dist-info}/LICENSE +0 -0
  163. {edsl-0.1.33.dev1.dist-info → edsl-0.1.33.dev2.dist-info}/WHEEL +0 -0
@@ -1,26 +1,97 @@
1
1
  from __future__ import annotations
2
- import textwrap
2
+ from decimal import Decimal
3
3
  from random import uniform
4
- from typing import Any, Optional, Union
4
+ from typing import Any, Optional, Union, Literal
5
+
6
+ from pydantic import BaseModel, Field, field_validator
5
7
 
6
8
  from edsl.exceptions import QuestionAnswerValidationError
7
9
  from edsl.questions.QuestionBase import QuestionBase
8
10
  from edsl.questions.descriptors import NumericalOrNoneDescriptor
11
+ from edsl.questions.decorators import inject_exception
12
+ from edsl.questions.ResponseValidatorABC import ResponseValidatorABC
13
+ from edsl.exceptions.questions import QuestionAnswerValidationError
14
+
15
+
16
+ def create_numeric_response(
17
+ min_value: Optional[Decimal] = None,
18
+ max_value: Optional[Decimal] = None,
19
+ permissive=False,
20
+ ):
21
+ field_kwargs = {}
22
+ if not permissive:
23
+ field_kwargs = {}
24
+ if min_value is not None:
25
+ field_kwargs["ge"] = min_value
26
+ if max_value is not None:
27
+ field_kwargs["le"] = max_value
28
+
29
+ class ConstrainedNumericResponse(BaseModel):
30
+ answer: Union[Decimal] = Field(**field_kwargs)
31
+ comment: Optional[str] = Field(None)
32
+ generated_tokens: Optional[Any] = Field(None)
33
+
34
+ return ConstrainedNumericResponse
35
+
36
+
37
+ class NumericalResponseValidator(ResponseValidatorABC):
38
+ required_params = ["min_value", "max_value", "permissive"]
39
+
40
+ valid_examples = [
41
+ ({"answer": 1}, {"min_value": 0, "max_value": 10}),
42
+ ({"answer": 1}, {"min_value": None, "max_value": None}),
43
+ ]
44
+
45
+ invalid_examples = [
46
+ ({"answer": 10}, {"min_value": 0, "max_value": 5}, "Answer is out of range"),
47
+ ({"answer": "ten"}, {"min_value": 0, "max_value": 5}, "Answer is not a number"),
48
+ ({}, {"min_value": 0, "max_value": 5}, "Answer key is missing"),
49
+ ]
50
+
51
+ def fix(self, response, verbose=False):
52
+ response_text = str(response).lower()
53
+ import re
54
+
55
+ if verbose:
56
+ print(f"Ivalid generated tokens was was: {response_text}")
57
+ pattern = r"\b\d+(?:\.\d+)?\b"
58
+ match = re.search(pattern, response_text.replace(",", ""))
59
+ solution = match.group(0) if match else response.get("answer")
60
+ if verbose:
61
+ print("Proposed solution is: ", solution)
62
+ if "comment" in response:
63
+ return {"answer": solution, "comment": response["comment"]}
64
+ else:
65
+ return {"answer": solution}
66
+
67
+ def _check_constraints(self, pydantic_edsl_answer: BaseModel):
68
+ pass
9
69
 
10
70
 
11
71
  class QuestionNumerical(QuestionBase):
12
- """This question prompts the agent to answer with a numerical value."""
72
+ """This question prompts the agent to answer with a numerical value.
73
+
74
+ >>> QuestionNumerical.self_check()
75
+
76
+ """
13
77
 
14
78
  question_type = "numerical"
15
79
  min_value: Optional[float] = NumericalOrNoneDescriptor()
16
80
  max_value: Optional[float] = NumericalOrNoneDescriptor()
17
81
 
82
+ _response_model = None
83
+ response_validator_class = NumericalResponseValidator
84
+
18
85
  def __init__(
19
86
  self,
20
87
  question_name: str,
21
88
  question_text: str,
22
89
  min_value: Optional[Union[int, float]] = None,
23
90
  max_value: Optional[Union[int, float]] = None,
91
+ include_comment: bool = True,
92
+ question_presentation: Optional[str] = None,
93
+ answering_instructions: Optional[str] = None,
94
+ permissive: bool = False,
24
95
  ):
25
96
  """Initialize the question.
26
97
 
@@ -34,30 +105,17 @@ class QuestionNumerical(QuestionBase):
34
105
  self.min_value = min_value
35
106
  self.max_value = max_value
36
107
 
108
+ self.include_comment = include_comment
109
+ self.question_presentation = question_presentation
110
+ self.answering_instructions = answering_instructions
111
+ self.permissive = permissive
112
+
113
+ def create_response_model(self):
114
+ return create_numeric_response(self.min_value, self.max_value, self.permissive)
115
+
37
116
  ################
38
117
  # Answer methods
39
118
  ################
40
- def _validate_answer(
41
- self, answer: dict[str, Any]
42
- ) -> dict[str, Union[str, float, int]]:
43
- """Validate the answer."""
44
- self._validate_answer_template_basic(answer)
45
- self._validate_answer_key_value_numeric(answer, "answer")
46
- self._validate_answer_numerical(answer)
47
- return answer
48
-
49
- def _translate_answer_code_to_answer(self, answer, scenario: "Scenario" = None):
50
- """There is no answer code."""
51
- return answer
52
-
53
- def _simulate_answer(self, human_readable: bool = True):
54
- """Simulate a valid answer for debugging purposes."""
55
- from edsl.utilities.utilities import random_string
56
-
57
- return {
58
- "answer": uniform(self.min_value, self.max_value),
59
- "comment": random_string(),
60
- }
61
119
 
62
120
  @property
63
121
  def question_html_content(self) -> str:
@@ -76,36 +134,19 @@ class QuestionNumerical(QuestionBase):
76
134
  # Helpful methods
77
135
  ################
78
136
  @classmethod
79
- def example(cls) -> QuestionNumerical:
137
+ @inject_exception
138
+ def example(cls, include_comment=False) -> QuestionNumerical:
80
139
  """Return an example question."""
81
140
  return cls(
82
141
  question_name="age",
83
- question_text="How old are you in years?",
142
+ question_text="You are a 45 year old man. How old are you in years?",
84
143
  min_value=0,
85
144
  max_value=86.7,
145
+ include_comment=include_comment,
86
146
  )
87
147
 
88
148
 
89
- def main():
90
- """Show example usage."""
91
- from edsl.questions.QuestionNumerical import QuestionNumerical
92
-
93
- q = QuestionNumerical.example()
94
- q.question_text
95
- q.min_value
96
- q.max_value
97
- # validate an answer
98
- q._validate_answer({"answer": 1, "comment": "I like custard"})
99
- # translate answer code
100
- q._translate_answer_code_to_answer(1)
101
- # simulate answer
102
- q._simulate_answer()
103
- q._simulate_answer(human_readable=False)
104
- q._validate_answer(q._simulate_answer(human_readable=False))
105
- # serialization (inherits from Question)
106
- q.to_dict()
107
- assert q.from_dict(q.to_dict()) == q
108
-
149
+ if __name__ == "__main__":
109
150
  import doctest
110
151
 
111
152
  doctest.testmod(optionflags=doctest.ELLIPSIS)
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  import random
3
+ import textwrap
3
4
  from jinja2 import Template
4
5
  from typing import Any, Optional, Union
5
6
  from edsl.questions.QuestionBase import QuestionBase
@@ -10,6 +11,129 @@ from edsl.questions.descriptors import (
10
11
  NumSelectionsDescriptor,
11
12
  )
12
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
23
+
24
+
25
+ def create_response_model(
26
+ choices: list,
27
+ num_selections: Optional[int] = None,
28
+ permissive: bool = False,
29
+ ):
30
+ """
31
+ :param choices: A list of allowed values for the answer field.
32
+ :param include_comment: Whether to include a comment field in the model.
33
+ :return: A new Pydantic model class.
34
+ """
35
+ # Convert the choices list to a tuple for use with Literal
36
+ choice_tuple = tuple(choices)
37
+
38
+ field_params = {}
39
+ if num_selections is not None and not permissive:
40
+ field_params["min_items"] = num_selections
41
+ field_params["max_items"] = num_selections
42
+
43
+ class RankResponse(BaseModel):
44
+ answer: Annotated[
45
+ List[Literal[choice_tuple]],
46
+ Field(..., **field_params),
47
+ ] = Field(..., description="List of selected choices")
48
+ comment: Optional[str] = Field(None, description="Optional comment field")
49
+ generated_tokens: Optional[Any] = Field(None)
50
+
51
+ class Config:
52
+ @staticmethod
53
+ def json_schema_extra(schema: dict, model: BaseModel) -> None:
54
+ # Add the list of choices to the schema for better documentation
55
+ for prop in schema.get("properties", {}).values():
56
+ if prop.get("title") == "answer":
57
+ prop["items"] = {"enum": choices}
58
+
59
+ return RankResponse
60
+
61
+
62
+ class RankResponseValidator(ResponseValidatorABC):
63
+ required_params = ["num_selections", "permissive", "use_code", "question_options"]
64
+ valid_examples = []
65
+ invalid_examples = []
66
+
67
+ def fix(self, response, verbose=False):
68
+ if verbose:
69
+ print("Invalid response of QuestionRank was: ", False)
70
+ response_text = response.get("generated_tokens")
71
+ if response_text is None or response_text == "": # nothing to be done
72
+ return response
73
+ # Maybe it's a comma separated list?
74
+ response_text = str(response.get("answer"))
75
+ proposed_list = (
76
+ response_text.replace("[", "").replace("]", "").replace("'", "").split(",")
77
+ )
78
+ proposed_list = [item.strip() for item in proposed_list]
79
+
80
+ if verbose:
81
+ print("Using code? ", self.use_code)
82
+ if self.use_code:
83
+ try:
84
+ proposed_list = [int(i) for i in proposed_list]
85
+ except ValueError:
86
+ # print("Could not convert to int")
87
+ pass
88
+
89
+ if verbose:
90
+ print("Proposed solution is: ", proposed_list)
91
+
92
+ # print(f"Ivalid generated tokens was was: {response_text}")
93
+ if "comment" in response:
94
+ proposed_data = {
95
+ "answer": proposed_list,
96
+ "comment": response["comment"],
97
+ "generated_tokens": response.get("generated_tokens", None),
98
+ }
99
+ else:
100
+ proposed_data = {
101
+ "answer": proposed_list,
102
+ "generated_tokens": response.get("generated_tokens", None),
103
+ }
104
+
105
+ try:
106
+ self.response_model(**proposed_data)
107
+ return proposed_data
108
+ except Exception as e:
109
+ if verbose:
110
+ print(f"Proposed solution {proposed_data} is invalid. Error: {e}")
111
+ # return response
112
+ if verbose:
113
+ print("Now seeing if responses show up in the answer")
114
+ matches = []
115
+ for index, option in enumerate(self.question_options):
116
+ if self.use_code:
117
+ if str(index) in response_text:
118
+ if index not in matches:
119
+ matches.append(index)
120
+ else:
121
+ if option in response_text:
122
+ if option not in matches:
123
+ matches.append(option)
124
+ proposed_data = {
125
+ "answer": matches,
126
+ "comment": response.get("comment", None),
127
+ "generated_tokens": response.get("generated_tokens", None),
128
+ }
129
+ try:
130
+ self.response_model(**proposed_data)
131
+ return proposed_data
132
+ except Exception as e:
133
+ if verbose:
134
+ print(f"Proposed solution {proposed_data} is invalid. Error: {e}")
135
+ return response
136
+
13
137
 
14
138
  class QuestionRank(QuestionBase):
15
139
  """This question prompts the agent to rank options from a list."""
@@ -18,12 +142,20 @@ class QuestionRank(QuestionBase):
18
142
  question_options: list[str] = QuestionOptionsDescriptor()
19
143
  num_selections = NumSelectionsDescriptor()
20
144
 
145
+ _response_model = None
146
+ response_validator_class = RankResponseValidator
147
+
21
148
  def __init__(
22
149
  self,
23
150
  question_name: str,
24
151
  question_text: str,
25
152
  question_options: list[str],
26
153
  num_selections: Optional[int] = None,
154
+ question_presentation: Optional[str] = None,
155
+ answering_instructions: Optional[str] = None,
156
+ permissive: bool = False,
157
+ use_code: bool = True,
158
+ include_comment: bool = True,
27
159
  ):
28
160
  """Initialize the question.
29
161
 
@@ -37,16 +169,33 @@ class QuestionRank(QuestionBase):
37
169
  self.question_text = question_text
38
170
  self.question_options = question_options
39
171
  self.num_selections = num_selections or len(question_options)
172
+ self.question_presentation = question_presentation
173
+ self.answering_instructions = answering_instructions
174
+ self.permissive = permissive
175
+ self.use_code = use_code
176
+ self.include_comment = include_comment
177
+
178
+ def create_response_model(self):
179
+ choices = (
180
+ self.question_options
181
+ if not self.use_code
182
+ else range(len(self.question_options))
183
+ )
184
+ return create_response_model(
185
+ choices=choices,
186
+ num_selections=self.num_selections,
187
+ permissive=self.permissive,
188
+ )
40
189
 
41
190
  ################
42
191
  # Answer methods
43
192
  ################
44
- def _validate_answer(self, answer: Any) -> dict[str, list[int]]:
45
- """Validate the answer."""
46
- self._validate_answer_template_basic(answer)
47
- self._validate_answer_key_value(answer, "answer", list)
48
- self._validate_answer_rank(answer)
49
- return answer
193
+ # def _validate_answer(self, answer: Any) -> dict[str, list[int]]:
194
+ # """Validate the answer."""
195
+ # self._validate_answer_template_basic(answer)
196
+ # self._validate_answer_key_value(answer, "answer", list)
197
+ # self._validate_answer_rank(answer)
198
+ # return answer
50
199
 
51
200
  def _translate_answer_code_to_answer(
52
201
  self, answer_codes, scenario: Scenario = None
@@ -60,24 +209,27 @@ class QuestionRank(QuestionBase):
60
209
  ]
61
210
  translated_codes = []
62
211
  for answer_code in answer_codes:
63
- translated_codes.append(translated_options[int(answer_code)])
212
+ if self._use_code:
213
+ translated_codes.append(translated_options[int(answer_code)])
214
+ else:
215
+ translated_codes.append(answer_code)
64
216
  return translated_codes
65
217
 
66
- def _simulate_answer(self, human_readable=True) -> dict[str, Union[int, str]]:
67
- """Simulate a valid answer for debugging purposes."""
68
- from edsl.utilities.utilities import random_string
218
+ # def _simulate_answer(self, human_readable=True) -> dict[str, Union[int, str]]:
219
+ # """Simulate a valid answer for debugging purposes."""
220
+ # from edsl.utilities.utilities import random_string
69
221
 
70
- if human_readable:
71
- selected = random.sample(self.question_options, self.num_selections)
72
- else:
73
- selected = random.sample(
74
- range(len(self.question_options)), self.num_selections
75
- )
76
- answer = {
77
- "answer": selected,
78
- "comment": random_string(),
79
- }
80
- return answer
222
+ # if human_readable:
223
+ # selected = random.sample(self.question_options, self.num_selections)
224
+ # else:
225
+ # selected = random.sample(
226
+ # range(len(self.question_options)), self.num_selections
227
+ # )
228
+ # answer = {
229
+ # "answer": selected,
230
+ # "comment": random_string(),
231
+ # }
232
+ # return answer
81
233
 
82
234
  @property
83
235
  def question_html_content(self) -> str:
@@ -129,13 +281,15 @@ class QuestionRank(QuestionBase):
129
281
  # Helpful methods
130
282
  ################
131
283
  @classmethod
132
- def example(cls) -> QuestionRank:
284
+ def example(cls, use_code=False, include_comment=True) -> QuestionRank:
133
285
  """Return an example question."""
134
286
  return cls(
135
287
  question_name="rank_foods",
136
288
  question_text="Rank your favorite foods.",
137
289
  question_options=["Pizza", "Pasta", "Salad", "Soup"],
138
290
  num_selections=2,
291
+ use_code=use_code,
292
+ include_comment=include_comment,
139
293
  )
140
294
 
141
295
 
@@ -143,7 +297,7 @@ def main():
143
297
  """Show example usage."""
144
298
  from edsl.questions.QuestionRank import QuestionRank
145
299
 
146
- q = QuestionRank.example()
300
+ q = QuestionRank.example(use_code=True)
147
301
  q.question_text
148
302
  q.question_name
149
303
  q.question_options
@@ -152,7 +306,7 @@ def main():
152
306
  answer = {"answer": [0, 1], "comment": "I like pizza and pasta."}
153
307
  q._validate_answer(answer)
154
308
  # translate an answer code to an answer
155
- q._translate_answer_code_to_answer([0, 1])
309
+ # q._translate_answer_code_to_answer([0, 1])
156
310
  # simulate answer
157
311
  q._simulate_answer()
158
312
  q._simulate_answer(human_readable=False)
@@ -161,6 +315,10 @@ def main():
161
315
  q.to_dict()
162
316
  assert q.from_dict(q.to_dict()) == q
163
317
 
318
+ q = QuestionRank.example(use_code=False)
319
+ answer = {"answer": ["Pizza", "Pasta"], "comment": "I like pizza and pasta."}
320
+ q._validate_answer(answer)
321
+
164
322
  import doctest
165
323
 
166
324
  doctest.testmod(optionflags=doctest.ELLIPSIS)
@@ -4,6 +4,8 @@ from abc import ABCMeta
4
4
  from edsl.enums import QuestionType
5
5
  from edsl.exceptions.questions import QuestionMissingTypeError, QuestionBadTypeError
6
6
 
7
+ import inspect
8
+
7
9
 
8
10
  class RegisterQuestionsMeta(ABCMeta):
9
11
  """Metaclass to register output elements in a registry i.e., those that have a parent."""
@@ -13,22 +15,39 @@ class RegisterQuestionsMeta(ABCMeta):
13
15
  def __init__(cls, name, bases, dct):
14
16
  """Initialize the class and adds it to the registry if it's not the base class."""
15
17
  super(RegisterQuestionsMeta, cls).__init__(name, bases, dct)
16
- if name != "QuestionBase":
18
+ if (
19
+ name != "QuestionBase"
20
+ and name != "QuestionFunctional"
21
+ and name != "QuestionAddTwoNumbers"
22
+ ):
17
23
  ## Enforce that all questions have a question_type class attribute
18
24
  ## and it comes from our enum of valid question types.
19
- if not hasattr(cls, "question_type"):
20
- raise QuestionMissingTypeError(
21
- "Question must have a question_type class attribute"
22
- )
25
+ required_attributes = [
26
+ "question_type",
27
+ "_response_model",
28
+ "response_validator_class",
29
+ ]
30
+ for attr in required_attributes:
31
+ if not hasattr(cls, attr):
32
+ raise QuestionMissingTypeError(
33
+ f"Question must have a {attr} class attribute"
34
+ )
23
35
 
24
- if not QuestionType.is_value_valid(cls.question_type):
25
- acceptable_values = [item.value for item in QuestionType]
26
- raise QuestionBadTypeError(
27
- f"""question_type must be one of {QuestionType} values, which are
28
- currently {acceptable_values}"""
29
- ""
30
- )
36
+ init_signature = inspect.signature(cls.__init__)
37
+ init_params = [p for p in init_signature.parameters if p != "self"]
38
+ required_params = [
39
+ "question_presentation",
40
+ "answering_instructions",
41
+ "question_name",
42
+ "question_text",
43
+ ]
44
+ for param in required_params:
45
+ if param not in init_params:
46
+ raise QuestionBadTypeError(
47
+ f"Question type {name} must have a question_presentation parameter in its __init__ method"
48
+ )
31
49
 
50
+ if name != "QuestionBase":
32
51
  RegisterQuestionsMeta._registry[name] = cls
33
52
 
34
53
  @classmethod
@@ -0,0 +1,169 @@
1
+ from abc import ABC, abstractmethod
2
+ from pydantic import BaseModel, Field, field_validator
3
+ from decimal import Decimal
4
+ from typing import Optional, Any, List, TypedDict
5
+
6
+ from edsl.exceptions import QuestionAnswerValidationError
7
+ from pydantic import ValidationError
8
+
9
+
10
+ class BaseResponse(BaseModel):
11
+ answer: Any
12
+ comment: Optional[str] = None
13
+ generated_tokens: Optional[str] = None
14
+
15
+
16
+ class ResponseValidatorABC(ABC):
17
+ required_params: List[str] = []
18
+
19
+ def __init_subclass__(cls, **kwargs):
20
+ super().__init_subclass__(**kwargs)
21
+ required_class_vars = ["required_params", "valid_examples", "invalid_examples"]
22
+ for var in required_class_vars:
23
+ if not hasattr(cls, var):
24
+ raise ValueError(f"Class {cls.__name__} must have a '{var}' attribute.")
25
+
26
+ def __init__(
27
+ self,
28
+ response_model: type[BaseModel],
29
+ exception_to_throw: Optional[Exception] = None,
30
+ override_answer: Optional[dict] = None,
31
+ **kwargs,
32
+ ):
33
+ self.response_model = response_model
34
+ self.exception_to_throw = exception_to_throw # for testing
35
+ self.override_answer = override_answer # for testing
36
+ self.original_exception = None
37
+
38
+ # Validate required parameters
39
+ missing_params = [
40
+ param for param in self.required_params if param not in kwargs
41
+ ]
42
+ if missing_params:
43
+ raise ValueError(
44
+ f"Missing required parameters: {', '.join(missing_params)}"
45
+ )
46
+
47
+ # Set attributes
48
+ for key, value in kwargs.items():
49
+ setattr(self, key, value)
50
+
51
+ if not hasattr(self, "permissive"):
52
+ self.permissive = False
53
+
54
+ self.fixes_tried = 0
55
+
56
+ class RawEdslAnswerDict(TypedDict):
57
+ answer: Any
58
+ comment: Optional[str]
59
+ generated_tokens: Optional[str]
60
+
61
+ def _preprocess(self, data: RawEdslAnswerDict) -> RawEdslAnswerDict:
62
+ """This is for testing purposes. A question can be given an exception to throw or an answer to always return.
63
+
64
+ >>> rv = ResponseValidatorABC.example()
65
+ >>> rv.override_answer = {"answer": 42}
66
+ >>> rv.validate({"answer": 23})
67
+ {'answer': Decimal('42'), 'comment': None, 'generated_tokens': None}
68
+ """
69
+ if self.exception_to_throw:
70
+ raise self.exception_to_throw
71
+ return self.override_answer if self.override_answer else data
72
+
73
+ def _base_validate(self, data: RawEdslAnswerDict) -> BaseModel:
74
+ """This is the main validation function. It takes the response_model and checks the data against it, returning the instantiated model.
75
+
76
+ >>> rv = ResponseValidatorABC.example("numerical")
77
+ >>> rv._base_validate({"answer": 42})
78
+ ConstrainedNumericResponse(answer=Decimal('42'), comment=None, generated_tokens=None)
79
+ """
80
+ try:
81
+ return self.response_model(**data)
82
+ except ValidationError as e:
83
+ raise QuestionAnswerValidationError(e, data=data, model=self.response_model)
84
+
85
+ def post_validation_answer_convert(self, data):
86
+ return data
87
+
88
+ class EdslAnswerDict(TypedDict):
89
+ answer: Any
90
+ comment: Optional[str]
91
+ generated_tokens: Optional[str]
92
+
93
+ def validate(
94
+ self, raw_edsl_answer_dict: RawEdslAnswerDict, fix=False, verbose=False
95
+ ) -> EdslAnswerDict:
96
+ """This is the main validation function.
97
+
98
+ >>> rv = ResponseValidatorABC.example("numerical")
99
+ >>> rv.validate({"answer": 42})
100
+ {'answer': Decimal('42'), 'comment': None, 'generated_tokens': None}
101
+ >>> rv.max_value
102
+ 86.7
103
+ >>> rv.validate({"answer": "120"})
104
+ Traceback (most recent call last):
105
+ ...
106
+ edsl.exceptions.questions.QuestionAnswerValidationError:...
107
+ >>> from edsl import QuestionNumerical
108
+ >>> q = QuestionNumerical.example()
109
+ >>> q.permissive = True
110
+ >>> rv = q.response_validator
111
+ >>> rv.validate({"answer": "120"})
112
+ {'answer': Decimal('120'), 'comment': None, 'generated_tokens': None}
113
+ >>> rv.validate({"answer": "poo"})
114
+ Traceback (most recent call last):
115
+ ...
116
+ edsl.exceptions.questions.QuestionAnswerValidationError:...
117
+ """
118
+ proposed_edsl_answer_dict = self._preprocess(raw_edsl_answer_dict)
119
+ try:
120
+ pydantic_edsl_answer: BaseModel = self._base_validate(
121
+ proposed_edsl_answer_dict
122
+ )
123
+ edsl_answer_dict = self._extract_answer(pydantic_edsl_answer)
124
+ return self._post_process(edsl_answer_dict)
125
+ except QuestionAnswerValidationError as e:
126
+ if verbose:
127
+ print(f"Failed to validate {raw_edsl_answer_dict}; {str(e)}")
128
+ return self._handle_exception(e, raw_edsl_answer_dict)
129
+
130
+ def _handle_exception(self, e: Exception, raw_edsl_answer_dict) -> EdslAnswerDict:
131
+ if self.fixes_tried == 0:
132
+ self.original_exception = e
133
+
134
+ if self.fixes_tried == 0 and hasattr(self, "fix"):
135
+ self.fixes_tried += 1
136
+ fixed_data = self.fix(raw_edsl_answer_dict)
137
+ try:
138
+ return self.validate(fixed_data, fix=True)
139
+ except Exception as e:
140
+ pass # we don't log failed fixes
141
+
142
+ raise QuestionAnswerValidationError(
143
+ self.original_exception,
144
+ data=raw_edsl_answer_dict,
145
+ model=self.response_model,
146
+ )
147
+
148
+ def _check_constraints(self, pydantic_edsl_answer: BaseModel) -> dict:
149
+ pass
150
+
151
+ def _extract_answer(self, response: BaseModel) -> EdslAnswerDict:
152
+ return response.model_dump()
153
+
154
+ def _post_process(self, edsl_answer_dict: EdslAnswerDict) -> EdslAnswerDict:
155
+ return edsl_answer_dict
156
+
157
+ @classmethod
158
+ def example(cls, question_type="numerical"):
159
+ from edsl import Question
160
+
161
+ q = Question.example(question_type)
162
+ return q.response_validator
163
+
164
+
165
+ # Example usage
166
+ if __name__ == "__main__":
167
+ import doctest
168
+
169
+ doctest.testmod(optionflags=doctest.ELLIPSIS)