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
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.
544
+ # endregion
197
545
 
198
- Example:
199
-
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],
@@ -369,73 +811,28 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
369
811
  if start_index < existing_end_index and end_index > existing_end_index:
370
812
  raise ValueError(f"Group {group_name} overlaps with the new group.")
371
813
 
372
- self.question_groups[group_name] = (start_index, end_index)
373
- return self
374
-
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.
381
-
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
- ]
433
-
434
- self.memory_plan.add_memory_collection(
435
- focal_question=focal_question_name, prior_questions=prior_question_names
436
- )
814
+ self.question_groups[group_name] = (start_index, end_index)
437
815
  return self
438
816
 
817
+ # endregion
818
+
819
+ # region: Survey rules
820
+ def show_rules(self) -> None:
821
+ """Print out the rules in the survey.
822
+
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()
835
+
439
836
  def add_stop_rule(
440
837
  self, question: Union[QuestionBase, str], expression: str
441
838
  ) -> Survey:
@@ -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,41 @@ 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
+ >>> Survey.example().show_rules()
870
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
871
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
872
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
873
+ │ 0 │ True │ 1 │ -1 │ False │
874
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
875
+ │ 1 │ True │ 2 │ -1 │ False │
876
+ │ 2 │ True │ 3 │ -1 │ False │
877
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
878
+ >>> Survey.example().clear_non_default_rules().show_rules()
879
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
880
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
881
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
882
+ │ 0 │ True │ 1 │ -1 │ False │
883
+ │ 1 │ True │ 2 │ -1 │ False │
884
+ │ 2 │ True │ 3 │ -1 │ False │
885
+ └───────────┴────────────┴────────┴──────────┴─────────────┘
886
+ """
887
+ s = Survey()
888
+ for question in self.questions:
889
+ s.add_question(question)
890
+ return s
891
+
463
892
  def add_skip_rule(
464
893
  self, question: Union[QuestionBase, str], expression: str
465
894
  ) -> Survey:
@@ -487,36 +916,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
487
916
  self._add_rule(question, expression, question_index + 1, before_rule=True)
488
917
  return self
489
918
 
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
919
  def _get_new_rule_priority(
521
920
  self, question_index: int, before_rule: bool = False
522
921
  ) -> int:
@@ -615,9 +1014,9 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
615
1014
 
616
1015
  return self
617
1016
 
618
- ###################
619
- # FORWARD METHODS
620
- ###################
1017
+ # endregion
1018
+
1019
+ # region: Forward methods
621
1020
  def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
622
1021
  """Add Agents, Scenarios, and LanguageModels to a survey and returns a runnable Jobs object.
623
1022
 
@@ -640,34 +1039,63 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
640
1039
 
641
1040
  return Jobs(survey=self)
642
1041
 
1042
+ # endregion
1043
+
1044
+ # region: Running the survey
1045
+
1046
+ def __call__(self, model=None, agent=None, cache=None, **kwargs):
1047
+ """Run the survey with default model, taking the required survey as arguments.
1048
+
1049
+ >>> from edsl.questions import QuestionFunctional
1050
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1051
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
1052
+ >>> s = Survey([q])
1053
+ >>> s(period = "morning", cache = False).select("answer.q0").first()
1054
+ 'yes'
1055
+ >>> s(period = "evening", cache = False).select("answer.q0").first()
1056
+ 'no'
1057
+ """
1058
+ job = self.get_job(model, agent, **kwargs)
1059
+ return job.run(cache=cache)
1060
+
1061
+ async def run_async(self, model=None, agent=None, cache=None, **kwargs):
1062
+ """Run the survey with default model, taking the required survey as arguments.
1063
+
1064
+ >>> from edsl.questions import QuestionFunctional
1065
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1066
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
1067
+ >>> s = Survey([q])
1068
+ >>> s(period = "morning").select("answer.q0").first()
1069
+ 'yes'
1070
+ >>> s(period = "evening").select("answer.q0").first()
1071
+ 'no'
1072
+ """
1073
+ # TODO: temp fix by creating a cache
1074
+ if cache is None:
1075
+ from edsl.data import Cache
1076
+
1077
+ c = Cache()
1078
+ else:
1079
+ c = cache
1080
+ jobs: "Jobs" = self.get_job(model, agent, **kwargs)
1081
+ return await jobs.run_async(cache=c)
1082
+
643
1083
  def run(self, *args, **kwargs) -> "Results":
644
1084
  """Turn the survey into a Job and runs it.
645
1085
 
646
- Here we run a survey but with debug mode on (so LLM calls are not made)
647
-
648
1086
  >>> from edsl import QuestionFreeText
649
1087
  >>> 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
- └──────────────┘
1088
+ >>> from edsl.language_models import LanguageModel
1089
+ >>> m = LanguageModel.example(test_model = True, canned_response = "Great!")
1090
+ >>> results = s.by(m).run(cache = False)
1091
+ >>> results.select('answer.*')
1092
+ Dataset([{'answer.how_are_you': ['Great!']}])
658
1093
  """
659
1094
  from edsl.jobs.Jobs import Jobs
660
1095
 
661
1096
  return Jobs(survey=self).run(*args, **kwargs)
662
1097
 
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
-
1098
+ # region: Survey flow
671
1099
  def next_question(
672
1100
  self, current_question: Union[str, QuestionBase], answers: dict
673
1101
  ) -> Union[QuestionBase, EndOfSurvey.__class__]:
@@ -747,7 +1175,7 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
747
1175
  Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
748
1176
  """
749
1177
  self.answers = {}
750
- question = self._first_question()
1178
+ question = self._questions[0]
751
1179
  while not question == EndOfSurvey:
752
1180
  # breakpoint()
753
1181
  answer = yield question
@@ -756,34 +1184,9 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
756
1184
  ## TODO: This should also include survey and agent attributes
757
1185
  question = self.next_question(question, self.answers)
758
1186
 
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
1187
+ # endregion
786
1188
 
1189
+ # regions: DAG construction
787
1190
  def textify(self, index_dag: DAG) -> DAG:
788
1191
  """Convert the DAG of question indices to a DAG of question names.
789
1192
 
@@ -923,59 +1326,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
923
1326
  return False
924
1327
  return self.to_dict() == other.to_dict()
925
1328
 
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
1329
  @classmethod
980
1330
  def from_qsf(
981
1331
  cls, qsf_file: Optional[str] = None, url: Optional[str] = None
@@ -1002,9 +1352,7 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1002
1352
  so = SurveyQualtricsImport(qsf_file)
1003
1353
  return so.create_survey()
1004
1354
 
1005
- ###################
1006
- # DISPLAY METHODS
1007
- ###################
1355
+ # region: Display methods
1008
1356
  def print(self):
1009
1357
  """Print the survey in a rich format.
1010
1358
 
@@ -1023,7 +1371,8 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1023
1371
  def __repr__(self) -> str:
1024
1372
  """Return a string representation of the survey."""
1025
1373
 
1026
- questions_string = ", ".join([repr(q) for q in self._questions])
1374
+ # questions_string = ", ".join([repr(q) for q in self._questions])
1375
+ questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1027
1376
  # question_names_string = ", ".join([repr(name) for name in self.question_names])
1028
1377
  return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups})"
1029
1378
 
@@ -1032,22 +1381,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1032
1381
 
1033
1382
  return data_to_html(self.to_dict())
1034
1383
 
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
1384
  def rich_print(self) -> Table:
1052
1385
  """Print the survey in a rich format.
1053
1386
 
@@ -1083,6 +1416,8 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1083
1416
 
1084
1417
  return table
1085
1418
 
1419
+ # endregion
1420
+
1086
1421
  def codebook(self) -> dict[str, str]:
1087
1422
  """Create a codebook for the survey, mapping question names to question text.
1088
1423
 
@@ -1095,6 +1430,7 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1095
1430
  codebook[question.question_name] = question.question_text
1096
1431
  return codebook
1097
1432
 
1433
+ # region: Export methods
1098
1434
  def to_csv(self, filename: str = None):
1099
1435
  """Export the survey to a CSV file.
1100
1436
 
@@ -1137,8 +1473,16 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1137
1473
  res = c.web(self.to_dict(), platform, email)
1138
1474
  return res
1139
1475
 
1476
+ # endregion
1477
+
1140
1478
  @classmethod
1141
- def example(cls, params: bool = False, randomize: bool = False) -> Survey:
1479
+ def example(
1480
+ cls,
1481
+ params: bool = False,
1482
+ randomize: bool = False,
1483
+ include_instructions=False,
1484
+ custom_instructions: Optional[str] = None,
1485
+ ) -> Survey:
1142
1486
  """Return an example survey.
1143
1487
 
1144
1488
  >>> s = Survey.example()
@@ -1171,6 +1515,18 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1171
1515
  )
1172
1516
  s = cls(questions=[q0, q1, q2, q3])
1173
1517
  return s
1518
+
1519
+ if include_instructions:
1520
+ from edsl import Instruction
1521
+
1522
+ custom_instructions = (
1523
+ custom_instructions if custom_instructions else "Please pay attention!"
1524
+ )
1525
+
1526
+ i = Instruction(text=custom_instructions, name="attention")
1527
+ s = cls(questions=[i, q0, q1, q2])
1528
+ return s
1529
+
1174
1530
  s = cls(questions=[q0, q1, q2])
1175
1531
  s = s.add_rule(q0, "q0 == 'yes'", q2)
1176
1532
  return s
@@ -1192,43 +1548,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1192
1548
 
1193
1549
  return self.by(s).by(agent).by(model)
1194
1550
 
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
1551
 
1233
1552
  def main():
1234
1553
  """Run the example survey."""