edsl 0.1.32__py3-none-any.whl → 0.1.33__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 (181) hide show
  1. edsl/Base.py +9 -3
  2. edsl/TemplateLoader.py +24 -0
  3. edsl/__init__.py +8 -3
  4. edsl/__version__.py +1 -1
  5. edsl/agents/Agent.py +40 -8
  6. edsl/agents/AgentList.py +43 -0
  7. edsl/agents/Invigilator.py +135 -219
  8. edsl/agents/InvigilatorBase.py +148 -59
  9. edsl/agents/{PromptConstructionMixin.py → PromptConstructor.py} +138 -89
  10. edsl/agents/__init__.py +1 -0
  11. edsl/auto/AutoStudy.py +117 -0
  12. edsl/auto/StageBase.py +230 -0
  13. edsl/auto/StageGenerateSurvey.py +178 -0
  14. edsl/auto/StageLabelQuestions.py +125 -0
  15. edsl/auto/StagePersona.py +61 -0
  16. edsl/auto/StagePersonaDimensionValueRanges.py +88 -0
  17. edsl/auto/StagePersonaDimensionValues.py +74 -0
  18. edsl/auto/StagePersonaDimensions.py +69 -0
  19. edsl/auto/StageQuestions.py +73 -0
  20. edsl/auto/SurveyCreatorPipeline.py +21 -0
  21. edsl/auto/utilities.py +224 -0
  22. edsl/config.py +47 -56
  23. edsl/coop/PriceFetcher.py +58 -0
  24. edsl/coop/coop.py +50 -7
  25. edsl/data/Cache.py +35 -1
  26. edsl/data_transfer_models.py +73 -38
  27. edsl/enums.py +4 -0
  28. edsl/exceptions/language_models.py +25 -1
  29. edsl/exceptions/questions.py +62 -5
  30. edsl/exceptions/results.py +4 -0
  31. edsl/inference_services/AnthropicService.py +13 -11
  32. edsl/inference_services/AwsBedrock.py +19 -17
  33. edsl/inference_services/AzureAI.py +37 -20
  34. edsl/inference_services/GoogleService.py +16 -12
  35. edsl/inference_services/GroqService.py +2 -0
  36. edsl/inference_services/InferenceServiceABC.py +58 -3
  37. edsl/inference_services/MistralAIService.py +120 -0
  38. edsl/inference_services/OpenAIService.py +48 -54
  39. edsl/inference_services/TestService.py +80 -0
  40. edsl/inference_services/TogetherAIService.py +170 -0
  41. edsl/inference_services/models_available_cache.py +0 -6
  42. edsl/inference_services/registry.py +6 -0
  43. edsl/jobs/Answers.py +10 -12
  44. edsl/jobs/FailedQuestion.py +78 -0
  45. edsl/jobs/Jobs.py +37 -22
  46. edsl/jobs/buckets/BucketCollection.py +24 -15
  47. edsl/jobs/buckets/TokenBucket.py +93 -14
  48. edsl/jobs/interviews/Interview.py +366 -78
  49. edsl/jobs/interviews/{interview_exception_tracking.py → InterviewExceptionCollection.py} +14 -68
  50. edsl/jobs/interviews/InterviewExceptionEntry.py +85 -19
  51. edsl/jobs/runners/JobsRunnerAsyncio.py +146 -175
  52. edsl/jobs/runners/JobsRunnerStatus.py +331 -0
  53. edsl/jobs/tasks/QuestionTaskCreator.py +30 -23
  54. edsl/jobs/tasks/TaskHistory.py +148 -213
  55. edsl/language_models/LanguageModel.py +261 -156
  56. edsl/language_models/ModelList.py +2 -2
  57. edsl/language_models/RegisterLanguageModelsMeta.py +14 -29
  58. edsl/language_models/fake_openai_call.py +15 -0
  59. edsl/language_models/fake_openai_service.py +61 -0
  60. edsl/language_models/registry.py +23 -6
  61. edsl/language_models/repair.py +0 -19
  62. edsl/language_models/utilities.py +61 -0
  63. edsl/notebooks/Notebook.py +20 -2
  64. edsl/prompts/Prompt.py +52 -2
  65. edsl/questions/AnswerValidatorMixin.py +23 -26
  66. edsl/questions/QuestionBase.py +330 -249
  67. edsl/questions/QuestionBaseGenMixin.py +133 -0
  68. edsl/questions/QuestionBasePromptsMixin.py +266 -0
  69. edsl/questions/QuestionBudget.py +99 -41
  70. edsl/questions/QuestionCheckBox.py +227 -35
  71. edsl/questions/QuestionExtract.py +98 -27
  72. edsl/questions/QuestionFreeText.py +52 -29
  73. edsl/questions/QuestionFunctional.py +7 -0
  74. edsl/questions/QuestionList.py +141 -22
  75. edsl/questions/QuestionMultipleChoice.py +159 -65
  76. edsl/questions/QuestionNumerical.py +88 -46
  77. edsl/questions/QuestionRank.py +182 -24
  78. edsl/questions/Quick.py +41 -0
  79. edsl/questions/RegisterQuestionsMeta.py +31 -12
  80. edsl/questions/ResponseValidatorABC.py +170 -0
  81. edsl/questions/__init__.py +3 -4
  82. edsl/questions/decorators.py +21 -0
  83. edsl/questions/derived/QuestionLikertFive.py +10 -5
  84. edsl/questions/derived/QuestionLinearScale.py +15 -2
  85. edsl/questions/derived/QuestionTopK.py +10 -1
  86. edsl/questions/derived/QuestionYesNo.py +24 -3
  87. edsl/questions/descriptors.py +43 -7
  88. edsl/questions/prompt_templates/question_budget.jinja +13 -0
  89. edsl/questions/prompt_templates/question_checkbox.jinja +32 -0
  90. edsl/questions/prompt_templates/question_extract.jinja +11 -0
  91. edsl/questions/prompt_templates/question_free_text.jinja +3 -0
  92. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -0
  93. edsl/questions/prompt_templates/question_list.jinja +17 -0
  94. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -0
  95. edsl/questions/prompt_templates/question_numerical.jinja +37 -0
  96. edsl/questions/question_registry.py +6 -2
  97. edsl/questions/templates/__init__.py +0 -0
  98. edsl/questions/templates/budget/__init__.py +0 -0
  99. edsl/questions/templates/budget/answering_instructions.jinja +7 -0
  100. edsl/questions/templates/budget/question_presentation.jinja +7 -0
  101. edsl/questions/templates/checkbox/__init__.py +0 -0
  102. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -0
  103. edsl/questions/templates/checkbox/question_presentation.jinja +22 -0
  104. edsl/questions/templates/extract/__init__.py +0 -0
  105. edsl/questions/templates/extract/answering_instructions.jinja +7 -0
  106. edsl/questions/templates/extract/question_presentation.jinja +1 -0
  107. edsl/questions/templates/free_text/__init__.py +0 -0
  108. edsl/questions/templates/free_text/answering_instructions.jinja +0 -0
  109. edsl/questions/templates/free_text/question_presentation.jinja +1 -0
  110. edsl/questions/templates/likert_five/__init__.py +0 -0
  111. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -0
  112. edsl/questions/templates/likert_five/question_presentation.jinja +12 -0
  113. edsl/questions/templates/linear_scale/__init__.py +0 -0
  114. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -0
  115. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -0
  116. edsl/questions/templates/list/__init__.py +0 -0
  117. edsl/questions/templates/list/answering_instructions.jinja +4 -0
  118. edsl/questions/templates/list/question_presentation.jinja +5 -0
  119. edsl/questions/templates/multiple_choice/__init__.py +0 -0
  120. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -0
  121. edsl/questions/templates/multiple_choice/html.jinja +0 -0
  122. edsl/questions/templates/multiple_choice/question_presentation.jinja +12 -0
  123. edsl/questions/templates/numerical/__init__.py +0 -0
  124. edsl/questions/templates/numerical/answering_instructions.jinja +8 -0
  125. edsl/questions/templates/numerical/question_presentation.jinja +7 -0
  126. edsl/questions/templates/rank/__init__.py +0 -0
  127. edsl/questions/templates/rank/answering_instructions.jinja +11 -0
  128. edsl/questions/templates/rank/question_presentation.jinja +15 -0
  129. edsl/questions/templates/top_k/__init__.py +0 -0
  130. edsl/questions/templates/top_k/answering_instructions.jinja +8 -0
  131. edsl/questions/templates/top_k/question_presentation.jinja +22 -0
  132. edsl/questions/templates/yes_no/__init__.py +0 -0
  133. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -0
  134. edsl/questions/templates/yes_no/question_presentation.jinja +12 -0
  135. edsl/results/Dataset.py +20 -0
  136. edsl/results/DatasetExportMixin.py +46 -48
  137. edsl/results/DatasetTree.py +145 -0
  138. edsl/results/Result.py +32 -5
  139. edsl/results/Results.py +135 -46
  140. edsl/results/ResultsDBMixin.py +3 -3
  141. edsl/results/Selector.py +118 -0
  142. edsl/results/tree_explore.py +115 -0
  143. edsl/scenarios/FileStore.py +71 -10
  144. edsl/scenarios/Scenario.py +96 -25
  145. edsl/scenarios/ScenarioImageMixin.py +2 -2
  146. edsl/scenarios/ScenarioList.py +361 -39
  147. edsl/scenarios/ScenarioListExportMixin.py +9 -0
  148. edsl/scenarios/ScenarioListPdfMixin.py +150 -4
  149. edsl/study/SnapShot.py +8 -1
  150. edsl/study/Study.py +32 -0
  151. edsl/surveys/Rule.py +10 -1
  152. edsl/surveys/RuleCollection.py +21 -5
  153. edsl/surveys/Survey.py +637 -311
  154. edsl/surveys/SurveyExportMixin.py +71 -9
  155. edsl/surveys/SurveyFlowVisualizationMixin.py +2 -1
  156. edsl/surveys/SurveyQualtricsImport.py +75 -4
  157. edsl/surveys/instructions/ChangeInstruction.py +47 -0
  158. edsl/surveys/instructions/Instruction.py +34 -0
  159. edsl/surveys/instructions/InstructionCollection.py +77 -0
  160. edsl/surveys/instructions/__init__.py +0 -0
  161. edsl/templates/error_reporting/base.html +24 -0
  162. edsl/templates/error_reporting/exceptions_by_model.html +35 -0
  163. edsl/templates/error_reporting/exceptions_by_question_name.html +17 -0
  164. edsl/templates/error_reporting/exceptions_by_type.html +17 -0
  165. edsl/templates/error_reporting/interview_details.html +116 -0
  166. edsl/templates/error_reporting/interviews.html +10 -0
  167. edsl/templates/error_reporting/overview.html +5 -0
  168. edsl/templates/error_reporting/performance_plot.html +2 -0
  169. edsl/templates/error_reporting/report.css +74 -0
  170. edsl/templates/error_reporting/report.html +118 -0
  171. edsl/templates/error_reporting/report.js +25 -0
  172. edsl/utilities/utilities.py +9 -1
  173. {edsl-0.1.32.dist-info → edsl-0.1.33.dist-info}/METADATA +5 -2
  174. edsl-0.1.33.dist-info/RECORD +295 -0
  175. edsl/jobs/interviews/InterviewTaskBuildingMixin.py +0 -286
  176. edsl/jobs/interviews/retry_management.py +0 -37
  177. edsl/jobs/runners/JobsRunnerStatusMixin.py +0 -333
  178. edsl/utilities/gcp_bucket/simple_example.py +0 -9
  179. edsl-0.1.32.dist-info/RECORD +0 -209
  180. {edsl-0.1.32.dist-info → edsl-0.1.33.dist-info}/LICENSE +0 -0
  181. {edsl-0.1.32.dist-info → edsl-0.1.33.dist-info}/WHEEL +0 -0
edsl/surveys/Survey.py CHANGED
@@ -20,6 +20,24 @@ from edsl.surveys.SurveyExportMixin import SurveyExportMixin
20
20
  from edsl.surveys.SurveyFlowVisualizationMixin import SurveyFlowVisualizationMixin
21
21
  from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
22
22
 
23
+ from edsl.agents.Agent import Agent
24
+
25
+
26
+ class ValidatedString(str):
27
+ def __new__(cls, content):
28
+ if "<>" in content:
29
+ raise ValueError(
30
+ "The expression contains '<>', which is not allowed. You probably mean '!='."
31
+ )
32
+ return super().__new__(cls, content)
33
+
34
+
35
+ # from edsl.surveys.Instruction import Instruction
36
+ # from edsl.surveys.Instruction import ChangeInstruction
37
+ from edsl.surveys.instructions.InstructionCollection import InstructionCollection
38
+ from edsl.surveys.instructions.Instruction import Instruction
39
+ from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
40
+
23
41
 
24
42
  class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
25
43
  """A collection of questions that supports skip logic."""
@@ -42,11 +60,13 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
42
60
 
43
61
  def __init__(
44
62
  self,
45
- questions: Optional[list[QuestionBase]] = None,
63
+ questions: Optional[
64
+ list[Union[QuestionBase, Instruction, ChangeInstruction]]
65
+ ] = None,
46
66
  memory_plan: Optional[MemoryPlan] = None,
47
67
  rule_collection: Optional[RuleCollection] = None,
48
68
  question_groups: Optional[dict[str, tuple[int, int]]] = None,
49
- name: str = None,
69
+ name: Optional[str] = None,
50
70
  ):
51
71
  """Create a new survey.
52
72
 
@@ -56,13 +76,33 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
56
76
  :param question_groups: The groups of questions in the survey.
57
77
  :param name: The name of the survey - DEPRECATED.
58
78
 
79
+
80
+ >>> from edsl import QuestionFreeText
81
+ >>> q1 = QuestionFreeText(question_text = "What is your name?", question_name = "name")
82
+ >>> q2 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
83
+ >>> q3 = QuestionFreeText(question_text = "Is a hot dog a sandwich", question_name = "food")
84
+ >>> s = Survey([q1, q2, q3], question_groups = {"demographics": (0, 1), "substantive":(3)})
85
+
86
+
59
87
  """
88
+
89
+ self.raw_passed_questions = questions
90
+
91
+ (
92
+ true_questions,
93
+ instruction_names_to_instructions,
94
+ self.pseudo_indices,
95
+ ) = self._separate_questions_and_instructions(questions or [])
96
+
60
97
  self.rule_collection = RuleCollection(
61
- num_questions=len(questions) if questions else None
98
+ num_questions=len(true_questions) if true_questions else None
62
99
  )
63
100
  # the RuleCollection needs to be present while we add the questions; we might override this later
64
101
  # if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
65
- self.questions = questions or []
102
+
103
+ self.questions = true_questions
104
+ self.instruction_names_to_instructions = instruction_names_to_instructions
105
+
66
106
  self.memory_plan = memory_plan or MemoryPlan(self)
67
107
  if question_groups is not None:
68
108
  self.question_groups = question_groups
@@ -78,6 +118,177 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
78
118
 
79
119
  warnings.warn("name parameter to a survey is deprecated.")
80
120
 
121
+ # region: Suvry instruction handling
122
+ @property
123
+ def relevant_instructions_dict(self) -> InstructionCollection:
124
+ """Return a dictionary with keys as question names and values as instructions that are relevant to the question.
125
+
126
+ >>> s = Survey.example(include_instructions=True)
127
+ >>> s.relevant_instructions_dict
128
+ {'q0': [Instruction(name="attention", text="Please pay attention!")], 'q1': [Instruction(name="attention", text="Please pay attention!")], 'q2': [Instruction(name="attention", text="Please pay attention!")]}
129
+
130
+ """
131
+ return InstructionCollection(
132
+ self.instruction_names_to_instructions, self.questions
133
+ )
134
+
135
+ @staticmethod
136
+ def _separate_questions_and_instructions(questions_and_instructions: list) -> tuple:
137
+ """
138
+ The 'pseudo_indices' attribute is a dictionary that maps question names to pseudo-indices
139
+ that are used to order questions and instructions in the survey.
140
+ Only questions get real indices; instructions get pseudo-indices.
141
+ However, the order of the pseudo-indices is the same as the order questions and instructions are added to the survey.
142
+
143
+ We don't have to know how many instructions there are to calculate the pseudo-indices because they are
144
+ calculated by the inverse of one minus the sum of 1/2^n for n in the number of instructions run so far.
145
+
146
+ >>> from edsl import Instruction
147
+ >>> i = Instruction(text = "Pay attention to the following questions.", name = "intro")
148
+ >>> i2 = Instruction(text = "How are you feeling today?", name = "followon_intro")
149
+ >>> from edsl import QuestionFreeText; q1 = QuestionFreeText.example()
150
+ >>> from edsl import QuestionMultipleChoice; q2 = QuestionMultipleChoice.example()
151
+ >>> s = Survey([q1, i, i2, q2])
152
+ >>> len(s.instruction_names_to_instructions)
153
+ 2
154
+ >>> s.pseudo_indices
155
+ {'how_are_you': 0, 'intro': 0.5, 'followon_intro': 0.75, 'how_feeling': 1}
156
+
157
+ >>> from edsl import ChangeInstruction
158
+ >>> q3 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
159
+ >>> i_change = ChangeInstruction(drop = ["intro"])
160
+ >>> s = Survey([q1, i, q2, i_change, q3])
161
+ >>> [i.name for i in s.relevant_instructions(q1)]
162
+ []
163
+ >>> [i.name for i in s.relevant_instructions(q2)]
164
+ ['intro']
165
+ >>> [i.name for i in s.relevant_instructions(q3)]
166
+ []
167
+
168
+ >>> i_change = ChangeInstruction(keep = ["poop"], drop = [])
169
+ >>> s = Survey([q1, i, q2, i_change])
170
+ Traceback (most recent call last):
171
+ ...
172
+ ValueError: ChangeInstruction change_instruction_0 references instruction poop which does not exist.
173
+ """
174
+ from edsl.surveys.instructions.Instruction import Instruction
175
+ from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
176
+
177
+ true_questions = []
178
+ instruction_names_to_instructions = {}
179
+
180
+ num_change_instructions = 0
181
+ pseudo_indices = {}
182
+ instructions_run_length = 0
183
+ for entry in questions_and_instructions:
184
+ if isinstance(entry, Instruction) or isinstance(entry, ChangeInstruction):
185
+ if isinstance(entry, ChangeInstruction):
186
+ entry.add_name(num_change_instructions)
187
+ num_change_instructions += 1
188
+ for prior_instruction in entry.keep + entry.drop:
189
+ if prior_instruction not in instruction_names_to_instructions:
190
+ raise ValueError(
191
+ f"ChangeInstruction {entry.name} references instruction {prior_instruction} which does not exist."
192
+ )
193
+ instructions_run_length += 1
194
+ delta = 1 - 1.0 / (2.0**instructions_run_length)
195
+ pseudo_index = (len(true_questions) - 1) + delta
196
+ entry.pseudo_index = pseudo_index
197
+ instruction_names_to_instructions[entry.name] = entry
198
+ elif isinstance(entry, QuestionBase):
199
+ pseudo_index = len(true_questions)
200
+ instructions_run_length = 0
201
+ true_questions.append(entry)
202
+ else:
203
+ raise ValueError(
204
+ f"Entry {repr(entry)} is not a QuestionBase or an Instruction."
205
+ )
206
+
207
+ pseudo_indices[entry.name] = pseudo_index
208
+
209
+ return true_questions, instruction_names_to_instructions, pseudo_indices
210
+
211
+ def relevant_instructions(self, question) -> dict:
212
+ """This should be a dictionry with keys as question names and values as instructions that are relevant to the question.
213
+
214
+ :param question: The question to get the relevant instructions for.
215
+
216
+ # Did the instruction come before the question and was it not modified by a change instruction?
217
+
218
+ """
219
+ return self.relevant_instructions_dict[question]
220
+
221
+ @property
222
+ def max_pseudo_index(self) -> float:
223
+ """Return the maximum pseudo index in the survey.
224
+
225
+ Example:
226
+
227
+ >>> s = Survey.example()
228
+ >>> s.max_pseudo_index
229
+ 2
230
+ """
231
+ if len(self.pseudo_indices) == 0:
232
+ return -1
233
+ return max(self.pseudo_indices.values())
234
+
235
+ @property
236
+ def last_item_was_instruction(self) -> bool:
237
+ """Return whether the last item added to the survey was an instruction.
238
+ This is used to determine the pseudo-index of the next item added to the survey.
239
+
240
+ Example:
241
+
242
+ >>> s = Survey.example()
243
+ >>> s.last_item_was_instruction
244
+ False
245
+ >>> from edsl.surveys.instructions.Instruction import Instruction
246
+ >>> s = s.add_instruction(Instruction(text="Pay attention to the following questions.", name="intro"))
247
+ >>> s.last_item_was_instruction
248
+ True
249
+ """
250
+ return isinstance(self.max_pseudo_index, float)
251
+
252
+ def add_instruction(
253
+ self, instruction: Union["Instruction", "ChangeInstruction"]
254
+ ) -> Survey:
255
+ """
256
+ Add an instruction to the survey.
257
+
258
+ :param instruction: The instruction to add to the survey.
259
+
260
+ >>> from edsl import Instruction
261
+ >>> i = Instruction(text="Pay attention to the following questions.", name="intro")
262
+ >>> s = Survey().add_instruction(i)
263
+ >>> s.instruction_names_to_instructions
264
+ {'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
265
+ >>> s.pseudo_indices
266
+ {'intro': -0.5}
267
+ """
268
+ import math
269
+
270
+ if instruction.name in self.instruction_names_to_instructions:
271
+ raise SurveyCreationError(
272
+ f"""Instruction name '{instruction.name}' already exists in survey. Existing names are {self.instruction_names_to_instructions.keys()}."""
273
+ )
274
+ self.instruction_names_to_instructions[instruction.name] = instruction
275
+
276
+ # was the last thing added an instruction or a question?
277
+ if self.last_item_was_instruction:
278
+ pseudo_index = (
279
+ self.max_pseudo_index
280
+ + (math.ceil(self.max_pseudo_index) - self.max_pseudo_index) / 2
281
+ )
282
+ else:
283
+ pseudo_index = self.max_pseudo_index + 1.0 / 2.0
284
+ self.pseudo_indices[instruction.name] = pseudo_index
285
+
286
+ return self
287
+
288
+ # endregion
289
+
290
+ # region: Simulation methods
291
+
81
292
  def simulate(self) -> dict:
82
293
  """Simulate the survey and return the answers."""
83
294
  i = self.gen_path_through_survey()
@@ -93,9 +304,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
93
304
  def create_agent(self) -> "Agent":
94
305
  """Create an agent from the simulated answers."""
95
306
  answers_dict = self.simulate()
96
- from edsl.agents.Agent import Agent
97
-
98
- a = Agent(traits=answers_dict)
99
307
 
100
308
  def construct_answer_dict_function(traits: dict) -> Callable:
101
309
  def func(self, question: "QuestionBase", scenario=None):
@@ -103,16 +311,48 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
103
311
 
104
312
  return func
105
313
 
106
- a.add_direct_question_answering_method(
314
+ return Agent(traits=answers_dict).add_direct_question_answering_method(
107
315
  construct_answer_dict_function(answers_dict)
108
316
  )
109
- return a
110
317
 
111
318
  def simulate_results(self) -> "Results":
112
319
  """Simulate the survey and return the results."""
113
320
  a = self.create_agent()
114
321
  return self.by([a]).run()
115
322
 
323
+ # endregion
324
+
325
+ # region: Access methods
326
+ def _get_question_index(
327
+ self, q: Union[QuestionBase, str, EndOfSurvey.__class__]
328
+ ) -> Union[int, EndOfSurvey.__class__]:
329
+ """Return the index of the question or EndOfSurvey object.
330
+
331
+ :param q: The question or question name to get the index of.
332
+
333
+ It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
334
+
335
+ >>> s = Survey.example()
336
+ >>> s._get_question_index("q0")
337
+ 0
338
+
339
+ This doesnt' work with questions that don't exist:
340
+
341
+ >>> s._get_question_index("poop")
342
+ Traceback (most recent call last):
343
+ ...
344
+ ValueError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
345
+ """
346
+ if q == EndOfSurvey:
347
+ return EndOfSurvey
348
+ else:
349
+ question_name = q if isinstance(q, str) else q.question_name
350
+ if question_name not in self.question_name_to_index:
351
+ raise ValueError(
352
+ f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
353
+ )
354
+ return self.question_name_to_index[question_name]
355
+
116
356
  def get(self, question_name: str) -> QuestionBase:
117
357
  """
118
358
  Return the question object given the question name.
@@ -128,54 +368,157 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
128
368
  index = self.question_name_to_index[question_name]
129
369
  return self._questions[index]
130
370
 
131
- def question_names_to_questions(self) -> dict:
132
- """Return a dictionary mapping question names to question attributes."""
133
- return {q.question_name: q for q in self.questions}
134
-
135
371
  def get_question(self, question_name: str) -> QuestionBase:
136
372
  """Return the question object given the question name."""
137
373
  # import warnings
138
374
  # warnings.warn("survey.get_question is deprecated. Use subscript operator instead.")
139
375
  return self.get(question_name)
140
376
 
377
+ def question_names_to_questions(self) -> dict:
378
+ """Return a dictionary mapping question names to question attributes."""
379
+ return {q.question_name: q for q in self.questions}
380
+
381
+ @property
382
+ def question_names(self) -> list[str]:
383
+ """Return a list of question names in the survey.
384
+
385
+ Example:
386
+
387
+ >>> s = Survey.example()
388
+ >>> s.question_names
389
+ ['q0', 'q1', 'q2']
390
+ """
391
+ # return list(self.question_name_to_index.keys())
392
+ return [q.question_name for q in self.questions]
393
+
394
+ @property
395
+ def question_name_to_index(self) -> dict[str, int]:
396
+ """Return a dictionary mapping question names to question indices.
397
+
398
+ Example:
399
+
400
+ >>> s = Survey.example()
401
+ >>> s.question_name_to_index
402
+ {'q0': 0, 'q1': 1, 'q2': 2}
403
+ """
404
+ return {q.question_name: i for i, q in enumerate(self.questions)}
405
+
406
+ # endregion
407
+
408
+ # region: serialization methods
141
409
  def __hash__(self) -> int:
142
410
  """Return a hash of the question."""
143
411
  from edsl.utilities.utilities import dict_hash
144
412
 
145
413
  return dict_hash(self._to_dict())
146
414
 
147
- def __add__(self, other: Survey) -> Survey:
148
- """Combine two surveys.
415
+ def _to_dict(self) -> dict[str, Any]:
416
+ """Serialize the Survey object to a dictionary.
149
417
 
150
- :param other: The other survey to combine with this one.
151
- >>> s1 = Survey.example()
152
- >>> from edsl import QuestionFreeText
153
- >>> s2 = Survey([QuestionFreeText(question_text="What is your name?", question_name="yo")])
154
- >>> s3 = s1 + s2
155
- Traceback (most recent call last):
156
- ...
157
- ValueError: ('Cannot combine two surveys with non-default rules.', "Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.")
158
- >>> s3 = s1.clear_non_default_rules() + s2
159
- >>> len(s3.questions)
160
- 4
418
+ >>> s = Survey.example()
419
+ >>> s._to_dict().keys()
420
+ dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
421
+ """
422
+ return {
423
+ "questions": [
424
+ q._to_dict() for q in self.recombined_questions_and_instructions()
425
+ ],
426
+ "memory_plan": self.memory_plan.to_dict(),
427
+ "rule_collection": self.rule_collection.to_dict(),
428
+ "question_groups": self.question_groups,
429
+ }
430
+
431
+ @add_edsl_version
432
+ def to_dict(self) -> dict[str, Any]:
433
+ """Serialize the Survey object to a dictionary.
434
+
435
+ >>> s = Survey.example()
436
+ >>> s.to_dict().keys()
437
+ dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups', 'edsl_version', 'edsl_class_name'])
161
438
 
162
439
  """
163
- if (
164
- len(self.rule_collection.non_default_rules) > 0
165
- or len(other.rule_collection.non_default_rules) > 0
166
- ):
167
- raise ValueError(
168
- "Cannot combine two surveys with non-default rules.",
169
- "Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.",
170
- )
440
+ return self._to_dict()
171
441
 
172
- return Survey(questions=self.questions + other.questions)
442
+ @classmethod
443
+ @remove_edsl_version
444
+ def from_dict(cls, data: dict) -> Survey:
445
+ """Deserialize the dictionary back to a Survey object.
173
446
 
174
- def clear_non_default_rules(self) -> Survey:
175
- s = Survey()
447
+ :param data: The dictionary to deserialize.
448
+
449
+ >>> d = Survey.example().to_dict()
450
+ >>> s = Survey.from_dict(d)
451
+ >>> s == Survey.example()
452
+ True
453
+
454
+ >>> s = Survey.example(include_instructions = True)
455
+ >>> d = s.to_dict()
456
+ >>> news = Survey.from_dict(d)
457
+ >>> news == s
458
+ True
459
+
460
+ """
461
+
462
+ def get_class(pass_dict):
463
+ if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
464
+ return QuestionBase
465
+ elif class_name == "Instruction":
466
+ from edsl.surveys.instructions.Instruction import Instruction
467
+
468
+ return Instruction
469
+ elif class_name == "ChangeInstruction":
470
+ from edsl.surveys.instructions.ChangeInstruction import (
471
+ ChangeInstruction,
472
+ )
473
+
474
+ return ChangeInstruction
475
+ else:
476
+ # some data might not have the edsl_class_name
477
+ return QuestionBase
478
+ # raise ValueError(f"Class {pass_dict['edsl_class_name']} not found")
479
+
480
+ questions = [
481
+ get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
482
+ ]
483
+ memory_plan = MemoryPlan.from_dict(data["memory_plan"])
484
+ survey = cls(
485
+ questions=questions,
486
+ memory_plan=memory_plan,
487
+ rule_collection=RuleCollection.from_dict(data["rule_collection"]),
488
+ question_groups=data["question_groups"],
489
+ )
490
+ return survey
491
+
492
+ # endregion
493
+
494
+ # region: Survey template parameters
495
+ @property
496
+ def scenario_attributes(self) -> list[str]:
497
+ """Return a list of attributes that admissible Scenarios should have.
498
+
499
+ Here we have a survey with a question that uses a jinja2 style {{ }} template:
500
+
501
+ >>> from edsl import QuestionFreeText
502
+ >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
503
+ >>> s.scenario_attributes
504
+ ['greeting']
505
+
506
+ >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
507
+ >>> s.scenario_attributes
508
+ ['greeting', 'attribute']
509
+
510
+
511
+ """
512
+ temp = []
176
513
  for question in self.questions:
177
- s.add_question(question)
178
- return s
514
+ question_text = question.question_text
515
+ # extract the contents of all {{ }} in the question text using regex
516
+ matches = re.findall(r"\{\{(.+?)\}\}", question_text)
517
+ # remove whitespace
518
+ matches = [match.strip() for match in matches]
519
+ # add them to the temp list
520
+ temp.extend(matches)
521
+ return temp
179
522
 
180
523
  @property
181
524
  def parameters(self):
@@ -189,32 +532,46 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
189
532
 
190
533
  @property
191
534
  def parameters_by_question(self):
535
+ """Return a dictionary of parameters by question in the survey.
536
+ >>> from edsl import QuestionFreeText
537
+ >>> q = QuestionFreeText(question_name = "example", question_text = "What is the capital of {{ country}}?")
538
+ >>> s = Survey([q])
539
+ >>> s.parameters_by_question
540
+ {'example': {'country'}}
541
+ """
192
542
  return {q.question_name: q.parameters for q in self.questions}
193
543
 
194
- @property
195
- def question_names(self) -> list[str]:
196
- """Return a list of question names in the survey.
197
-
198
- Example:
544
+ # endregion
199
545
 
200
- >>> s = Survey.example()
201
- >>> s.question_names
202
- ['q0', 'q1', 'q2']
203
- """
204
- # return list(self.question_name_to_index.keys())
205
- return [q.question_name for q in self.questions]
546
+ # region: Survey construction
206
547
 
207
- @property
208
- def question_name_to_index(self) -> dict[str, int]:
209
- """Return a dictionary mapping question names to question indices.
548
+ # region: Adding questions and combining surveys
549
+ def __add__(self, other: Survey) -> Survey:
550
+ """Combine two surveys.
210
551
 
211
- Example:
552
+ :param other: The other survey to combine with this one.
553
+ >>> s1 = Survey.example()
554
+ >>> from edsl import QuestionFreeText
555
+ >>> s2 = Survey([QuestionFreeText(question_text="What is your name?", question_name="yo")])
556
+ >>> s3 = s1 + s2
557
+ Traceback (most recent call last):
558
+ ...
559
+ ValueError: ('Cannot combine two surveys with non-default rules.', "Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.")
560
+ >>> s3 = s1.clear_non_default_rules() + s2
561
+ >>> len(s3.questions)
562
+ 4
212
563
 
213
- >>> s = Survey.example()
214
- >>> s.question_name_to_index
215
- {'q0': 0, 'q1': 1, 'q2': 2}
216
564
  """
217
- return {q.question_name: i for i, q in enumerate(self.questions)}
565
+ if (
566
+ len(self.rule_collection.non_default_rules) > 0
567
+ or len(other.rule_collection.non_default_rules) > 0
568
+ ):
569
+ raise ValueError(
570
+ "Cannot combine two surveys with non-default rules.",
571
+ "Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.",
572
+ )
573
+
574
+ return Survey(questions=self.questions + other.questions)
218
575
 
219
576
  def add_question(self, question: QuestionBase) -> Survey:
220
577
  """
@@ -233,11 +590,11 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
233
590
  >>> s = Survey().add_question(q).add_question(q)
234
591
  Traceback (most recent call last):
235
592
  ...
236
- edsl.exceptions.surveys.SurveyCreationError: Question name already exists in survey. ...
593
+ edsl.exceptions.surveys.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
237
594
  """
238
595
  if question.question_name in self.question_names:
239
596
  raise SurveyCreationError(
240
- f"""Question name already exists in survey. Please use a different name for the offensing question. The problemetic question name is {question.question_name}."""
597
+ f"""Question name '{question.question_name}' already exists in survey. Existing names are {self.question_names}."""
241
598
  )
242
599
  index = len(self.questions)
243
600
  # TODO: This is a bit ugly because the user
@@ -245,6 +602,8 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
245
602
  # descriptor.
246
603
  self._questions.append(question)
247
604
 
605
+ self.pseudo_indices[question.question_name] = index
606
+
248
607
  # using index + 1 presumes there is a next question
249
608
  self.rule_collection.add_rule(
250
609
  Rule(
@@ -263,6 +622,20 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
263
622
 
264
623
  return self
265
624
 
625
+ def recombined_questions_and_instructions(
626
+ self,
627
+ ) -> list[Union[QuestionBase, "Instruction"]]:
628
+ """Return a list of questions and instructions sorted by pseudo index."""
629
+ questions_and_instructions = self._questions + list(
630
+ self.instruction_names_to_instructions.values()
631
+ )
632
+ return sorted(
633
+ questions_and_instructions, key=lambda x: self.pseudo_indices[x.name]
634
+ )
635
+
636
+ # endregion
637
+
638
+ # region: Memory plan methods
266
639
  def set_full_memory_mode(self) -> Survey:
267
640
  """Add instructions to a survey that the agent should remember all of the answers to the questions in the survey.
268
641
 
@@ -295,6 +668,75 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
295
668
  prior_questions=prior_questions_func(i),
296
669
  )
297
670
 
671
+ def add_targeted_memory(
672
+ self,
673
+ focal_question: Union[QuestionBase, str],
674
+ prior_question: Union[QuestionBase, str],
675
+ ) -> Survey:
676
+ """Add instructions to a survey than when answering focal_question.
677
+
678
+ :param focal_question: The question that the agent is answering.
679
+ :param prior_question: The question that the agent should remember when answering the focal question.
680
+
681
+ Here we add instructions to a survey than when answering q2 they should remember q1:
682
+
683
+ >>> s = Survey.example().add_targeted_memory("q2", "q0")
684
+ >>> s.memory_plan
685
+ {'q2': Memory(prior_questions=['q0'])}
686
+
687
+ The agent should also remember the answers to prior_questions listed in prior_questions.
688
+ """
689
+ focal_question_name = self.question_names[
690
+ self._get_question_index(focal_question)
691
+ ]
692
+ prior_question_name = self.question_names[
693
+ self._get_question_index(prior_question)
694
+ ]
695
+
696
+ self.memory_plan.add_single_memory(
697
+ focal_question=focal_question_name,
698
+ prior_question=prior_question_name,
699
+ )
700
+
701
+ return self
702
+
703
+ def add_memory_collection(
704
+ self,
705
+ focal_question: Union[QuestionBase, str],
706
+ prior_questions: List[Union[QuestionBase, str]],
707
+ ) -> Survey:
708
+ """Add prior questions and responses so the agent has them when answering.
709
+
710
+ This adds instructions to a survey than when answering focal_question, the agent should also remember the answers to prior_questions listed in prior_questions.
711
+
712
+ :param focal_question: The question that the agent is answering.
713
+ :param prior_questions: The questions that the agent should remember when answering the focal question.
714
+
715
+ Here we have it so that when answering q2, the agent should remember answers to q0 and q1:
716
+
717
+ >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
718
+ >>> s.memory_plan
719
+ {'q2': Memory(prior_questions=['q0', 'q1'])}
720
+ """
721
+ focal_question_name = self.question_names[
722
+ self._get_question_index(focal_question)
723
+ ]
724
+
725
+ prior_question_names = [
726
+ self.question_names[self._get_question_index(prior_question)]
727
+ for prior_question in prior_questions
728
+ ]
729
+
730
+ self.memory_plan.add_memory_collection(
731
+ focal_question=focal_question_name, prior_questions=prior_question_names
732
+ )
733
+ return self
734
+
735
+ # endregion
736
+ # endregion
737
+ # endregion
738
+
739
+ # region: Question groups
298
740
  def add_question_group(
299
741
  self,
300
742
  start_question: Union[QuestionBase, str],
@@ -372,69 +814,24 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
372
814
  self.question_groups[group_name] = (start_index, end_index)
373
815
  return self
374
816
 
375
- def add_targeted_memory(
376
- self,
377
- focal_question: Union[QuestionBase, str],
378
- prior_question: Union[QuestionBase, str],
379
- ) -> Survey:
380
- """Add instructions to a survey than when answering focal_question.
817
+ # endregion
381
818
 
382
- :param focal_question: The question that the agent is answering.
383
- :param prior_question: The question that the agent should remember when answering the focal question.
384
-
385
- Here we add instructions to a survey than when answering q2 they should remember q1:
386
-
387
- >>> s = Survey.example().add_targeted_memory("q2", "q0")
388
- >>> s.memory_plan
389
- {'q2': Memory(prior_questions=['q0'])}
390
-
391
- The agent should also remember the answers to prior_questions listed in prior_questions.
392
- """
393
- focal_question_name = self.question_names[
394
- self._get_question_index(focal_question)
395
- ]
396
- prior_question_name = self.question_names[
397
- self._get_question_index(prior_question)
398
- ]
399
-
400
- self.memory_plan.add_single_memory(
401
- focal_question=focal_question_name,
402
- prior_question=prior_question_name,
403
- )
404
-
405
- return self
406
-
407
- def add_memory_collection(
408
- self,
409
- focal_question: Union[QuestionBase, str],
410
- prior_questions: List[Union[QuestionBase, str]],
411
- ) -> Survey:
412
- """Add prior questions and responses so the agent has them when answering.
413
-
414
- This adds instructions to a survey than when answering focal_question, the agent should also remember the answers to prior_questions listed in prior_questions.
415
-
416
- :param focal_question: The question that the agent is answering.
417
- :param prior_questions: The questions that the agent should remember when answering the focal question.
418
-
419
- Here we have it so that when answering q2, the agent should remember answers to q0 and q1:
420
-
421
- >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
422
- >>> s.memory_plan
423
- {'q2': Memory(prior_questions=['q0', 'q1'])}
424
- """
425
- focal_question_name = self.question_names[
426
- self._get_question_index(focal_question)
427
- ]
428
-
429
- prior_question_names = [
430
- self.question_names[self._get_question_index(prior_question)]
431
- for prior_question in prior_questions
432
- ]
819
+ # region: Survey rules
820
+ def show_rules(self) -> None:
821
+ """Print out the rules in the survey.
433
822
 
434
- self.memory_plan.add_memory_collection(
435
- focal_question=focal_question_name, prior_questions=prior_question_names
436
- )
437
- return self
823
+ >>> s = Survey.example()
824
+ >>> s.show_rules()
825
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
826
+ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
827
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
828
+ │ 0 │ True │ 1 │ -1 │ False │
829
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
830
+ │ 1 │ True │ 2 │ -1 │ False │
831
+ │ 2 │ True │ 3 │ -1 │ False │
832
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
833
+ """
834
+ self.rule_collection.show_rules()
438
835
 
439
836
  def add_stop_rule(
440
837
  self, question: Union[QuestionBase, str], expression: str
@@ -444,6 +841,7 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
444
841
  :param question: The question to add the stop rule to.
445
842
  :param expression: The expression to evaluate.
446
843
 
844
+ If this rule is true, the survey ends.
447
845
  The rule is evaluated *after* the question is answered. If the rule is true, the survey ends.
448
846
 
449
847
  Here, answering "yes" to q0 ends the survey:
@@ -456,10 +854,42 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
456
854
 
457
855
  >>> s.next_question("q0", {"q0": "no"}).question_name
458
856
  'q1'
857
+
858
+ >>> s.add_stop_rule("q0", "q1 <> 'yes'")
859
+ Traceback (most recent call last):
860
+ ...
861
+ ValueError: The expression contains '<>', which is not allowed. You probably mean '!='.
459
862
  """
863
+ expression = ValidatedString(expression)
460
864
  self.add_rule(question, expression, EndOfSurvey)
461
865
  return self
462
866
 
867
+ def clear_non_default_rules(self) -> Survey:
868
+ """Remove all non-default rules from the survey.
869
+
870
+ >>> Survey.example().show_rules()
871
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
872
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
873
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
874
+ │ 0 │ True │ 1 │ -1 │ False │
875
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
876
+ │ 1 │ True │ 2 │ -1 │ False │
877
+ │ 2 │ True │ 3 │ -1 │ False │
878
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
879
+ >>> Survey.example().clear_non_default_rules().show_rules()
880
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
881
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
882
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
883
+ │ 0 │ True │ 1 │ -1 │ False │
884
+ │ 1 │ True │ 2 │ -1 │ False │
885
+ │ 2 │ True │ 3 │ -1 │ False │
886
+ └───────────┴────────────┴────────┴──────────┴─────────────┘
887
+ """
888
+ s = Survey()
889
+ for question in self.questions:
890
+ s.add_question(question)
891
+ return s
892
+
463
893
  def add_skip_rule(
464
894
  self, question: Union[QuestionBase, str], expression: str
465
895
  ) -> Survey:
@@ -487,36 +917,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
487
917
  self._add_rule(question, expression, question_index + 1, before_rule=True)
488
918
  return self
489
919
 
490
- def _get_question_index(
491
- self, q: Union[QuestionBase, str, EndOfSurvey.__class__]
492
- ) -> Union[int, EndOfSurvey.__class__]:
493
- """Return the index of the question or EndOfSurvey object.
494
-
495
- :param q: The question or question name to get the index of.
496
-
497
- It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
498
-
499
- >>> s = Survey.example()
500
- >>> s._get_question_index("q0")
501
- 0
502
-
503
- This doesnt' work with questions that don't exist:
504
-
505
- >>> s._get_question_index("poop")
506
- Traceback (most recent call last):
507
- ...
508
- ValueError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
509
- """
510
- if q == EndOfSurvey:
511
- return EndOfSurvey
512
- else:
513
- question_name = q if isinstance(q, str) else q.question_name
514
- if question_name not in self.question_name_to_index:
515
- raise ValueError(
516
- f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
517
- )
518
- return self.question_name_to_index[question_name]
519
-
520
920
  def _get_new_rule_priority(
521
921
  self, question_index: int, before_rule: bool = False
522
922
  ) -> int:
@@ -615,9 +1015,9 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
615
1015
 
616
1016
  return self
617
1017
 
618
- ###################
619
- # FORWARD METHODS
620
- ###################
1018
+ # endregion
1019
+
1020
+ # region: Forward methods
621
1021
  def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
622
1022
  """Add Agents, Scenarios, and LanguageModels to a survey and returns a runnable Jobs object.
623
1023
 
@@ -640,34 +1040,63 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
640
1040
 
641
1041
  return Jobs(survey=self)
642
1042
 
1043
+ # endregion
1044
+
1045
+ # region: Running the survey
1046
+
1047
+ def __call__(self, model=None, agent=None, cache=None, **kwargs):
1048
+ """Run the survey with default model, taking the required survey as arguments.
1049
+
1050
+ >>> from edsl.questions import QuestionFunctional
1051
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1052
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
1053
+ >>> s = Survey([q])
1054
+ >>> s(period = "morning", cache = False).select("answer.q0").first()
1055
+ 'yes'
1056
+ >>> s(period = "evening", cache = False).select("answer.q0").first()
1057
+ 'no'
1058
+ """
1059
+ job = self.get_job(model, agent, **kwargs)
1060
+ return job.run(cache=cache)
1061
+
1062
+ async def run_async(self, model=None, agent=None, cache=None, **kwargs):
1063
+ """Run the survey with default model, taking the required survey as arguments.
1064
+
1065
+ >>> from edsl.questions import QuestionFunctional
1066
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1067
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
1068
+ >>> s = Survey([q])
1069
+ >>> s(period = "morning").select("answer.q0").first()
1070
+ 'yes'
1071
+ >>> s(period = "evening").select("answer.q0").first()
1072
+ 'no'
1073
+ """
1074
+ # TODO: temp fix by creating a cache
1075
+ if cache is None:
1076
+ from edsl.data import Cache
1077
+
1078
+ c = Cache()
1079
+ else:
1080
+ c = cache
1081
+ jobs: "Jobs" = self.get_job(model, agent, **kwargs)
1082
+ return await jobs.run_async(cache=c)
1083
+
643
1084
  def run(self, *args, **kwargs) -> "Results":
644
1085
  """Turn the survey into a Job and runs it.
645
1086
 
646
- Here we run a survey but with debug mode on (so LLM calls are not made)
647
-
648
1087
  >>> from edsl import QuestionFreeText
649
1088
  >>> s = Survey([QuestionFreeText.example()])
650
- >>> results = s.run(debug = True, cache = False)
651
- >>> results.select('answer.*').print(format = "rich")
652
- ┏━━━━━━━━━━━━━━┓
653
- answer
654
- .how_are_you
655
- ┡━━━━━━━━━━━━━━┩
656
- ...
657
- └──────────────┘
1089
+ >>> from edsl.language_models import LanguageModel
1090
+ >>> m = LanguageModel.example(test_model = True, canned_response = "Great!")
1091
+ >>> results = s.by(m).run(cache = False)
1092
+ >>> results.select('answer.*')
1093
+ Dataset([{'answer.how_are_you': ['Great!']}])
658
1094
  """
659
1095
  from edsl.jobs.Jobs import Jobs
660
1096
 
661
1097
  return Jobs(survey=self).run(*args, **kwargs)
662
1098
 
663
- ########################
664
- ## Survey-Taking Methods
665
- ########################
666
-
667
- def _first_question(self) -> QuestionBase:
668
- """Return the first question in the survey."""
669
- return self.questions[0]
670
-
1099
+ # region: Survey flow
671
1100
  def next_question(
672
1101
  self, current_question: Union[str, QuestionBase], answers: dict
673
1102
  ) -> Union[QuestionBase, EndOfSurvey.__class__]:
@@ -745,9 +1174,15 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
745
1174
  Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
746
1175
  >>> i2.send({"q0": "no"})
747
1176
  Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
1177
+
1178
+
748
1179
  """
749
1180
  self.answers = {}
750
- question = self._first_question()
1181
+ question = self._questions[0]
1182
+ # should the first question be skipped?
1183
+ if self.rule_collection.skip_question_before_running(0, self.answers):
1184
+ question = self.next_question(question, self.answers)
1185
+
751
1186
  while not question == EndOfSurvey:
752
1187
  # breakpoint()
753
1188
  answer = yield question
@@ -756,34 +1191,9 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
756
1191
  ## TODO: This should also include survey and agent attributes
757
1192
  question = self.next_question(question, self.answers)
758
1193
 
759
- @property
760
- def scenario_attributes(self) -> list[str]:
761
- """Return a list of attributes that admissible Scenarios should have.
762
-
763
- Here we have a survey with a question that uses a jinja2 style {{ }} template:
764
-
765
- >>> from edsl import QuestionFreeText
766
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
767
- >>> s.scenario_attributes
768
- ['greeting']
769
-
770
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
771
- >>> s.scenario_attributes
772
- ['greeting', 'attribute']
773
-
774
-
775
- """
776
- temp = []
777
- for question in self.questions:
778
- question_text = question.question_text
779
- # extract the contents of all {{ }} in the question text using regex
780
- matches = re.findall(r"\{\{(.+?)\}\}", question_text)
781
- # remove whitespace
782
- matches = [match.strip() for match in matches]
783
- # add them to the temp list
784
- temp.extend(matches)
785
- return temp
1194
+ # endregion
786
1195
 
1196
+ # regions: DAG construction
787
1197
  def textify(self, index_dag: DAG) -> DAG:
788
1198
  """Convert the DAG of question indices to a DAG of question names.
789
1199
 
@@ -923,59 +1333,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
923
1333
  return False
924
1334
  return self.to_dict() == other.to_dict()
925
1335
 
926
- ###################
927
- # SERIALIZATION METHODS
928
- ###################
929
-
930
- def _to_dict(self) -> dict[str, Any]:
931
- """Serialize the Survey object to a dictionary.
932
-
933
- >>> s = Survey.example()
934
- >>> s._to_dict().keys()
935
- dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
936
-
937
- """
938
- return {
939
- "questions": [q._to_dict() for q in self._questions],
940
- "memory_plan": self.memory_plan.to_dict(),
941
- "rule_collection": self.rule_collection.to_dict(),
942
- "question_groups": self.question_groups,
943
- }
944
-
945
- @add_edsl_version
946
- def to_dict(self) -> dict[str, Any]:
947
- """Serialize the Survey object to a dictionary.
948
-
949
- >>> s = Survey.example()
950
- >>> s.to_dict().keys()
951
- dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups', 'edsl_version', 'edsl_class_name'])
952
-
953
- """
954
- return self._to_dict()
955
-
956
- @classmethod
957
- @remove_edsl_version
958
- def from_dict(cls, data: dict) -> Survey:
959
- """Deserialize the dictionary back to a Survey object.
960
-
961
- :param data: The dictionary to deserialize.
962
-
963
- >>> d = Survey.example().to_dict()
964
- >>> s = Survey.from_dict(d)
965
- >>> s == Survey.example()
966
- True
967
-
968
- """
969
- questions = [QuestionBase.from_dict(q_dict) for q_dict in data["questions"]]
970
- memory_plan = MemoryPlan.from_dict(data["memory_plan"])
971
- survey = cls(
972
- questions=questions,
973
- memory_plan=memory_plan,
974
- rule_collection=RuleCollection.from_dict(data["rule_collection"]),
975
- question_groups=data["question_groups"],
976
- )
977
- return survey
978
-
979
1336
  @classmethod
980
1337
  def from_qsf(
981
1338
  cls, qsf_file: Optional[str] = None, url: Optional[str] = None
@@ -1002,9 +1359,7 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1002
1359
  so = SurveyQualtricsImport(qsf_file)
1003
1360
  return so.create_survey()
1004
1361
 
1005
- ###################
1006
- # DISPLAY METHODS
1007
- ###################
1362
+ # region: Display methods
1008
1363
  def print(self):
1009
1364
  """Print the survey in a rich format.
1010
1365
 
@@ -1023,7 +1378,8 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1023
1378
  def __repr__(self) -> str:
1024
1379
  """Return a string representation of the survey."""
1025
1380
 
1026
- questions_string = ", ".join([repr(q) for q in self._questions])
1381
+ # questions_string = ", ".join([repr(q) for q in self._questions])
1382
+ questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1027
1383
  # question_names_string = ", ".join([repr(name) for name in self.question_names])
1028
1384
  return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups})"
1029
1385
 
@@ -1032,22 +1388,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1032
1388
 
1033
1389
  return data_to_html(self.to_dict())
1034
1390
 
1035
- def show_rules(self) -> None:
1036
- """Print out the rules in the survey.
1037
-
1038
- >>> s = Survey.example()
1039
- >>> s.show_rules()
1040
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1041
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1042
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1043
- │ 0 │ True │ 1 │ -1 │ False │
1044
- │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1045
- │ 1 │ True │ 2 │ -1 │ False │
1046
- │ 2 │ True │ 3 │ -1 │ False │
1047
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
1048
- """
1049
- self.rule_collection.show_rules()
1050
-
1051
1391
  def rich_print(self) -> Table:
1052
1392
  """Print the survey in a rich format.
1053
1393
 
@@ -1083,6 +1423,8 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1083
1423
 
1084
1424
  return table
1085
1425
 
1426
+ # endregion
1427
+
1086
1428
  def codebook(self) -> dict[str, str]:
1087
1429
  """Create a codebook for the survey, mapping question names to question text.
1088
1430
 
@@ -1095,6 +1437,7 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1095
1437
  codebook[question.question_name] = question.question_text
1096
1438
  return codebook
1097
1439
 
1440
+ # region: Export methods
1098
1441
  def to_csv(self, filename: str = None):
1099
1442
  """Export the survey to a CSV file.
1100
1443
 
@@ -1137,8 +1480,16 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1137
1480
  res = c.web(self.to_dict(), platform, email)
1138
1481
  return res
1139
1482
 
1483
+ # endregion
1484
+
1140
1485
  @classmethod
1141
- def example(cls, params: bool = False, randomize: bool = False) -> Survey:
1486
+ def example(
1487
+ cls,
1488
+ params: bool = False,
1489
+ randomize: bool = False,
1490
+ include_instructions=False,
1491
+ custom_instructions: Optional[str] = None,
1492
+ ) -> Survey:
1142
1493
  """Return an example survey.
1143
1494
 
1144
1495
  >>> s = Survey.example()
@@ -1171,6 +1522,18 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1171
1522
  )
1172
1523
  s = cls(questions=[q0, q1, q2, q3])
1173
1524
  return s
1525
+
1526
+ if include_instructions:
1527
+ from edsl import Instruction
1528
+
1529
+ custom_instructions = (
1530
+ custom_instructions if custom_instructions else "Please pay attention!"
1531
+ )
1532
+
1533
+ i = Instruction(text=custom_instructions, name="attention")
1534
+ s = cls(questions=[i, q0, q1, q2])
1535
+ return s
1536
+
1174
1537
  s = cls(questions=[q0, q1, q2])
1175
1538
  s = s.add_rule(q0, "q0 == 'yes'", q2)
1176
1539
  return s
@@ -1192,43 +1555,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1192
1555
 
1193
1556
  return self.by(s).by(agent).by(model)
1194
1557
 
1195
- def __call__(self, model=None, agent=None, cache=None, **kwargs):
1196
- """Run the survey with default model, taking the required survey as arguments.
1197
-
1198
- >>> from edsl.questions import QuestionFunctional
1199
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1200
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1201
- >>> s = Survey([q])
1202
- >>> s(period = "morning", cache = False).select("answer.q0").first()
1203
- 'yes'
1204
- >>> s(period = "evening", cache = False).select("answer.q0").first()
1205
- 'no'
1206
- """
1207
- job = self.get_job(model, agent, **kwargs)
1208
- return job.run(cache=cache)
1209
-
1210
- async def run_async(self, model=None, agent=None, cache=None, **kwargs):
1211
- """Run the survey with default model, taking the required survey as arguments.
1212
-
1213
- >>> from edsl.questions import QuestionFunctional
1214
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1215
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1216
- >>> s = Survey([q])
1217
- >>> s(period = "morning").select("answer.q0").first()
1218
- 'yes'
1219
- >>> s(period = "evening").select("answer.q0").first()
1220
- 'no'
1221
- """
1222
- # TODO: temp fix by creating a cache
1223
- if cache is None:
1224
- from edsl.data import Cache
1225
-
1226
- c = Cache()
1227
- else:
1228
- c = cache
1229
- jobs: "Jobs" = self.get_job(model, agent, **kwargs)
1230
- return await jobs.run_async(cache=c)
1231
-
1232
1558
 
1233
1559
  def main():
1234
1560
  """Run the example survey."""