edsl 0.1.31.dev4__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 (188) 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 +136 -221
  8. edsl/agents/InvigilatorBase.py +148 -59
  9. edsl/agents/{PromptConstructionMixin.py → PromptConstructor.py} +154 -85
  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 +48 -47
  23. edsl/conjure/Conjure.py +6 -0
  24. edsl/coop/PriceFetcher.py +58 -0
  25. edsl/coop/coop.py +50 -7
  26. edsl/data/Cache.py +35 -1
  27. edsl/data/CacheHandler.py +3 -4
  28. edsl/data_transfer_models.py +73 -38
  29. edsl/enums.py +8 -0
  30. edsl/exceptions/general.py +10 -8
  31. edsl/exceptions/language_models.py +25 -1
  32. edsl/exceptions/questions.py +62 -5
  33. edsl/exceptions/results.py +4 -0
  34. edsl/inference_services/AnthropicService.py +13 -11
  35. edsl/inference_services/AwsBedrock.py +112 -0
  36. edsl/inference_services/AzureAI.py +214 -0
  37. edsl/inference_services/DeepInfraService.py +4 -3
  38. edsl/inference_services/GoogleService.py +16 -12
  39. edsl/inference_services/GroqService.py +5 -4
  40. edsl/inference_services/InferenceServiceABC.py +58 -3
  41. edsl/inference_services/InferenceServicesCollection.py +13 -8
  42. edsl/inference_services/MistralAIService.py +120 -0
  43. edsl/inference_services/OllamaService.py +18 -0
  44. edsl/inference_services/OpenAIService.py +55 -56
  45. edsl/inference_services/TestService.py +80 -0
  46. edsl/inference_services/TogetherAIService.py +170 -0
  47. edsl/inference_services/models_available_cache.py +25 -0
  48. edsl/inference_services/registry.py +19 -1
  49. edsl/jobs/Answers.py +10 -12
  50. edsl/jobs/FailedQuestion.py +78 -0
  51. edsl/jobs/Jobs.py +137 -41
  52. edsl/jobs/buckets/BucketCollection.py +24 -15
  53. edsl/jobs/buckets/TokenBucket.py +105 -18
  54. edsl/jobs/interviews/Interview.py +393 -83
  55. edsl/jobs/interviews/{interview_exception_tracking.py → InterviewExceptionCollection.py} +22 -18
  56. edsl/jobs/interviews/InterviewExceptionEntry.py +167 -0
  57. edsl/jobs/runners/JobsRunnerAsyncio.py +152 -160
  58. edsl/jobs/runners/JobsRunnerStatus.py +331 -0
  59. edsl/jobs/tasks/QuestionTaskCreator.py +30 -23
  60. edsl/jobs/tasks/TaskCreators.py +1 -1
  61. edsl/jobs/tasks/TaskHistory.py +205 -126
  62. edsl/language_models/LanguageModel.py +297 -177
  63. edsl/language_models/ModelList.py +2 -2
  64. edsl/language_models/RegisterLanguageModelsMeta.py +14 -29
  65. edsl/language_models/fake_openai_call.py +15 -0
  66. edsl/language_models/fake_openai_service.py +61 -0
  67. edsl/language_models/registry.py +25 -8
  68. edsl/language_models/repair.py +0 -19
  69. edsl/language_models/utilities.py +61 -0
  70. edsl/notebooks/Notebook.py +20 -2
  71. edsl/prompts/Prompt.py +52 -2
  72. edsl/questions/AnswerValidatorMixin.py +23 -26
  73. edsl/questions/QuestionBase.py +330 -249
  74. edsl/questions/QuestionBaseGenMixin.py +133 -0
  75. edsl/questions/QuestionBasePromptsMixin.py +266 -0
  76. edsl/questions/QuestionBudget.py +99 -42
  77. edsl/questions/QuestionCheckBox.py +227 -36
  78. edsl/questions/QuestionExtract.py +98 -28
  79. edsl/questions/QuestionFreeText.py +47 -31
  80. edsl/questions/QuestionFunctional.py +7 -0
  81. edsl/questions/QuestionList.py +141 -23
  82. edsl/questions/QuestionMultipleChoice.py +159 -66
  83. edsl/questions/QuestionNumerical.py +88 -47
  84. edsl/questions/QuestionRank.py +182 -25
  85. edsl/questions/Quick.py +41 -0
  86. edsl/questions/RegisterQuestionsMeta.py +31 -12
  87. edsl/questions/ResponseValidatorABC.py +170 -0
  88. edsl/questions/__init__.py +3 -4
  89. edsl/questions/decorators.py +21 -0
  90. edsl/questions/derived/QuestionLikertFive.py +10 -5
  91. edsl/questions/derived/QuestionLinearScale.py +15 -2
  92. edsl/questions/derived/QuestionTopK.py +10 -1
  93. edsl/questions/derived/QuestionYesNo.py +24 -3
  94. edsl/questions/descriptors.py +43 -7
  95. edsl/questions/prompt_templates/question_budget.jinja +13 -0
  96. edsl/questions/prompt_templates/question_checkbox.jinja +32 -0
  97. edsl/questions/prompt_templates/question_extract.jinja +11 -0
  98. edsl/questions/prompt_templates/question_free_text.jinja +3 -0
  99. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -0
  100. edsl/questions/prompt_templates/question_list.jinja +17 -0
  101. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -0
  102. edsl/questions/prompt_templates/question_numerical.jinja +37 -0
  103. edsl/questions/question_registry.py +6 -2
  104. edsl/questions/templates/__init__.py +0 -0
  105. edsl/questions/templates/budget/__init__.py +0 -0
  106. edsl/questions/templates/budget/answering_instructions.jinja +7 -0
  107. edsl/questions/templates/budget/question_presentation.jinja +7 -0
  108. edsl/questions/templates/checkbox/__init__.py +0 -0
  109. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -0
  110. edsl/questions/templates/checkbox/question_presentation.jinja +22 -0
  111. edsl/questions/templates/extract/__init__.py +0 -0
  112. edsl/questions/templates/extract/answering_instructions.jinja +7 -0
  113. edsl/questions/templates/extract/question_presentation.jinja +1 -0
  114. edsl/questions/templates/free_text/__init__.py +0 -0
  115. edsl/questions/templates/free_text/answering_instructions.jinja +0 -0
  116. edsl/questions/templates/free_text/question_presentation.jinja +1 -0
  117. edsl/questions/templates/likert_five/__init__.py +0 -0
  118. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -0
  119. edsl/questions/templates/likert_five/question_presentation.jinja +12 -0
  120. edsl/questions/templates/linear_scale/__init__.py +0 -0
  121. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -0
  122. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -0
  123. edsl/questions/templates/list/__init__.py +0 -0
  124. edsl/questions/templates/list/answering_instructions.jinja +4 -0
  125. edsl/questions/templates/list/question_presentation.jinja +5 -0
  126. edsl/questions/templates/multiple_choice/__init__.py +0 -0
  127. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -0
  128. edsl/questions/templates/multiple_choice/html.jinja +0 -0
  129. edsl/questions/templates/multiple_choice/question_presentation.jinja +12 -0
  130. edsl/questions/templates/numerical/__init__.py +0 -0
  131. edsl/questions/templates/numerical/answering_instructions.jinja +8 -0
  132. edsl/questions/templates/numerical/question_presentation.jinja +7 -0
  133. edsl/questions/templates/rank/__init__.py +0 -0
  134. edsl/questions/templates/rank/answering_instructions.jinja +11 -0
  135. edsl/questions/templates/rank/question_presentation.jinja +15 -0
  136. edsl/questions/templates/top_k/__init__.py +0 -0
  137. edsl/questions/templates/top_k/answering_instructions.jinja +8 -0
  138. edsl/questions/templates/top_k/question_presentation.jinja +22 -0
  139. edsl/questions/templates/yes_no/__init__.py +0 -0
  140. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -0
  141. edsl/questions/templates/yes_no/question_presentation.jinja +12 -0
  142. edsl/results/Dataset.py +20 -0
  143. edsl/results/DatasetExportMixin.py +58 -30
  144. edsl/results/DatasetTree.py +145 -0
  145. edsl/results/Result.py +32 -5
  146. edsl/results/Results.py +135 -46
  147. edsl/results/ResultsDBMixin.py +3 -3
  148. edsl/results/Selector.py +118 -0
  149. edsl/results/tree_explore.py +115 -0
  150. edsl/scenarios/FileStore.py +71 -10
  151. edsl/scenarios/Scenario.py +109 -24
  152. edsl/scenarios/ScenarioImageMixin.py +2 -2
  153. edsl/scenarios/ScenarioList.py +546 -21
  154. edsl/scenarios/ScenarioListExportMixin.py +24 -4
  155. edsl/scenarios/ScenarioListPdfMixin.py +153 -4
  156. edsl/study/SnapShot.py +8 -1
  157. edsl/study/Study.py +32 -0
  158. edsl/surveys/Rule.py +15 -3
  159. edsl/surveys/RuleCollection.py +21 -5
  160. edsl/surveys/Survey.py +707 -298
  161. edsl/surveys/SurveyExportMixin.py +71 -9
  162. edsl/surveys/SurveyFlowVisualizationMixin.py +2 -1
  163. edsl/surveys/SurveyQualtricsImport.py +284 -0
  164. edsl/surveys/instructions/ChangeInstruction.py +47 -0
  165. edsl/surveys/instructions/Instruction.py +34 -0
  166. edsl/surveys/instructions/InstructionCollection.py +77 -0
  167. edsl/surveys/instructions/__init__.py +0 -0
  168. edsl/templates/error_reporting/base.html +24 -0
  169. edsl/templates/error_reporting/exceptions_by_model.html +35 -0
  170. edsl/templates/error_reporting/exceptions_by_question_name.html +17 -0
  171. edsl/templates/error_reporting/exceptions_by_type.html +17 -0
  172. edsl/templates/error_reporting/interview_details.html +116 -0
  173. edsl/templates/error_reporting/interviews.html +10 -0
  174. edsl/templates/error_reporting/overview.html +5 -0
  175. edsl/templates/error_reporting/performance_plot.html +2 -0
  176. edsl/templates/error_reporting/report.css +74 -0
  177. edsl/templates/error_reporting/report.html +118 -0
  178. edsl/templates/error_reporting/report.js +25 -0
  179. edsl/utilities/utilities.py +40 -1
  180. {edsl-0.1.31.dev4.dist-info → edsl-0.1.33.dist-info}/METADATA +8 -2
  181. edsl-0.1.33.dist-info/RECORD +295 -0
  182. edsl/jobs/interviews/InterviewTaskBuildingMixin.py +0 -271
  183. edsl/jobs/interviews/retry_management.py +0 -37
  184. edsl/jobs/runners/JobsRunnerStatusMixin.py +0 -303
  185. edsl/utilities/gcp_bucket/simple_example.py +0 -9
  186. edsl-0.1.31.dev4.dist-info/RECORD +0 -204
  187. {edsl-0.1.31.dev4.dist-info → edsl-0.1.33.dist-info}/LICENSE +0 -0
  188. {edsl-0.1.31.dev4.dist-info → edsl-0.1.33.dist-info}/WHEEL +0 -0
edsl/surveys/Survey.py CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
  import re
5
+ import tempfile
6
+ import requests
7
+
5
8
  from typing import Any, Generator, Optional, Union, List, Literal, Callable
6
9
  from uuid import uuid4
7
10
  from edsl.Base import Base
@@ -17,6 +20,24 @@ from edsl.surveys.SurveyExportMixin import SurveyExportMixin
17
20
  from edsl.surveys.SurveyFlowVisualizationMixin import SurveyFlowVisualizationMixin
18
21
  from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
19
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
+
20
41
 
21
42
  class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
22
43
  """A collection of questions that supports skip logic."""
@@ -39,11 +60,13 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
39
60
 
40
61
  def __init__(
41
62
  self,
42
- questions: Optional[list[QuestionBase]] = None,
63
+ questions: Optional[
64
+ list[Union[QuestionBase, Instruction, ChangeInstruction]]
65
+ ] = None,
43
66
  memory_plan: Optional[MemoryPlan] = None,
44
67
  rule_collection: Optional[RuleCollection] = None,
45
68
  question_groups: Optional[dict[str, tuple[int, int]]] = None,
46
- name: str = None,
69
+ name: Optional[str] = None,
47
70
  ):
48
71
  """Create a new survey.
49
72
 
@@ -53,13 +76,33 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
53
76
  :param question_groups: The groups of questions in the survey.
54
77
  :param name: The name of the survey - DEPRECATED.
55
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
+
56
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
+
57
97
  self.rule_collection = RuleCollection(
58
- num_questions=len(questions) if questions else None
98
+ num_questions=len(true_questions) if true_questions else None
59
99
  )
60
100
  # the RuleCollection needs to be present while we add the questions; we might override this later
61
101
  # if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
62
- self.questions = questions or []
102
+
103
+ self.questions = true_questions
104
+ self.instruction_names_to_instructions = instruction_names_to_instructions
105
+
63
106
  self.memory_plan = memory_plan or MemoryPlan(self)
64
107
  if question_groups is not None:
65
108
  self.question_groups = question_groups
@@ -75,6 +118,241 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
75
118
 
76
119
  warnings.warn("name parameter to a survey is deprecated.")
77
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
+
292
+ def simulate(self) -> dict:
293
+ """Simulate the survey and return the answers."""
294
+ i = self.gen_path_through_survey()
295
+ q = next(i)
296
+ while True:
297
+ try:
298
+ answer = q._simulate_answer()
299
+ q = i.send({q.question_name: answer["answer"]})
300
+ except StopIteration:
301
+ break
302
+ return self.answers
303
+
304
+ def create_agent(self) -> "Agent":
305
+ """Create an agent from the simulated answers."""
306
+ answers_dict = self.simulate()
307
+
308
+ def construct_answer_dict_function(traits: dict) -> Callable:
309
+ def func(self, question: "QuestionBase", scenario=None):
310
+ return traits.get(question.question_name, None)
311
+
312
+ return func
313
+
314
+ return Agent(traits=answers_dict).add_direct_question_answering_method(
315
+ construct_answer_dict_function(answers_dict)
316
+ )
317
+
318
+ def simulate_results(self) -> "Results":
319
+ """Simulate the survey and return the results."""
320
+ a = self.create_agent()
321
+ return self.by([a]).run()
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
+
78
356
  def get(self, question_name: str) -> QuestionBase:
79
357
  """
80
358
  Return the question object given the question name.
@@ -90,22 +368,184 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
90
368
  index = self.question_name_to_index[question_name]
91
369
  return self._questions[index]
92
370
 
93
- def question_names_to_questions(self) -> dict:
94
- """Return a dictionary mapping question names to question attributes."""
95
- return {q.question_name: q for q in self.questions}
96
-
97
371
  def get_question(self, question_name: str) -> QuestionBase:
98
372
  """Return the question object given the question name."""
99
373
  # import warnings
100
374
  # warnings.warn("survey.get_question is deprecated. Use subscript operator instead.")
101
375
  return self.get(question_name)
102
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
103
409
  def __hash__(self) -> int:
104
410
  """Return a hash of the question."""
105
411
  from edsl.utilities.utilities import dict_hash
106
412
 
107
413
  return dict_hash(self._to_dict())
108
414
 
415
+ def _to_dict(self) -> dict[str, Any]:
416
+ """Serialize the Survey object to a dictionary.
417
+
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'])
438
+
439
+ """
440
+ return self._to_dict()
441
+
442
+ @classmethod
443
+ @remove_edsl_version
444
+ def from_dict(cls, data: dict) -> Survey:
445
+ """Deserialize the dictionary back to a Survey object.
446
+
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 = []
513
+ for question in self.questions:
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
522
+
523
+ @property
524
+ def parameters(self):
525
+ """Return a set of parameters in the survey.
526
+
527
+ >>> s = Survey.example()
528
+ >>> s.parameters
529
+ set()
530
+ """
531
+ return set.union(*[q.parameters for q in self.questions])
532
+
533
+ @property
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
+ """
542
+ return {q.question_name: q.parameters for q in self.questions}
543
+
544
+ # endregion
545
+
546
+ # region: Survey construction
547
+
548
+ # region: Adding questions and combining surveys
109
549
  def __add__(self, other: Survey) -> Survey:
110
550
  """Combine two surveys.
111
551
 
@@ -133,45 +573,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
133
573
 
134
574
  return Survey(questions=self.questions + other.questions)
135
575
 
136
- def clear_non_default_rules(self) -> Survey:
137
- s = Survey()
138
- for question in self.questions:
139
- s.add_question(question)
140
- return s
141
-
142
- @property
143
- def parameters(self):
144
- return set.union(*[q.parameters for q in self.questions])
145
-
146
- @property
147
- def parameters_by_question(self):
148
- return {q.question_name: q.parameters for q in self.questions}
149
-
150
- @property
151
- def question_names(self) -> list[str]:
152
- """Return a list of question names in the survey.
153
-
154
- Example:
155
-
156
- >>> s = Survey.example()
157
- >>> s.question_names
158
- ['q0', 'q1', 'q2']
159
- """
160
- # return list(self.question_name_to_index.keys())
161
- return [q.question_name for q in self.questions]
162
-
163
- @property
164
- def question_name_to_index(self) -> dict[str, int]:
165
- """Return a dictionary mapping question names to question indices.
166
-
167
- Example:
168
-
169
- >>> s = Survey.example()
170
- >>> s.question_name_to_index
171
- {'q0': 0, 'q1': 1, 'q2': 2}
172
- """
173
- return {q.question_name: i for i, q in enumerate(self.questions)}
174
-
175
576
  def add_question(self, question: QuestionBase) -> Survey:
176
577
  """
177
578
  Add a question to survey.
@@ -189,11 +590,11 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
189
590
  >>> s = Survey().add_question(q).add_question(q)
190
591
  Traceback (most recent call last):
191
592
  ...
192
- 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'].
193
594
  """
194
595
  if question.question_name in self.question_names:
195
596
  raise SurveyCreationError(
196
- 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}."""
197
598
  )
198
599
  index = len(self.questions)
199
600
  # TODO: This is a bit ugly because the user
@@ -201,6 +602,8 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
201
602
  # descriptor.
202
603
  self._questions.append(question)
203
604
 
605
+ self.pseudo_indices[question.question_name] = index
606
+
204
607
  # using index + 1 presumes there is a next question
205
608
  self.rule_collection.add_rule(
206
609
  Rule(
@@ -219,6 +622,20 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
219
622
 
220
623
  return self
221
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
222
639
  def set_full_memory_mode(self) -> Survey:
223
640
  """Add instructions to a survey that the agent should remember all of the answers to the questions in the survey.
224
641
 
@@ -251,6 +668,75 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
251
668
  prior_questions=prior_questions_func(i),
252
669
  )
253
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
254
740
  def add_question_group(
255
741
  self,
256
742
  start_question: Union[QuestionBase, str],
@@ -325,73 +811,28 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
325
811
  if start_index < existing_end_index and end_index > existing_end_index:
326
812
  raise ValueError(f"Group {group_name} overlaps with the new group.")
327
813
 
328
- self.question_groups[group_name] = (start_index, end_index)
329
- return self
330
-
331
- def add_targeted_memory(
332
- self,
333
- focal_question: Union[QuestionBase, str],
334
- prior_question: Union[QuestionBase, str],
335
- ) -> Survey:
336
- """Add instructions to a survey than when answering focal_question.
337
-
338
- :param focal_question: The question that the agent is answering.
339
- :param prior_question: The question that the agent should remember when answering the focal question.
340
-
341
- Here we add instructions to a survey than when answering q2 they should remember q1:
342
-
343
- >>> s = Survey.example().add_targeted_memory("q2", "q0")
344
- >>> s.memory_plan
345
- {'q2': Memory(prior_questions=['q0'])}
346
-
347
- The agent should also remember the answers to prior_questions listed in prior_questions.
348
- """
349
- focal_question_name = self.question_names[
350
- self._get_question_index(focal_question)
351
- ]
352
- prior_question_name = self.question_names[
353
- self._get_question_index(prior_question)
354
- ]
355
-
356
- self.memory_plan.add_single_memory(
357
- focal_question=focal_question_name,
358
- prior_question=prior_question_name,
359
- )
360
-
361
- return self
362
-
363
- def add_memory_collection(
364
- self,
365
- focal_question: Union[QuestionBase, str],
366
- prior_questions: List[Union[QuestionBase, str]],
367
- ) -> Survey:
368
- """Add prior questions and responses so the agent has them when answering.
369
-
370
- 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.
371
-
372
- :param focal_question: The question that the agent is answering.
373
- :param prior_questions: The questions that the agent should remember when answering the focal question.
374
-
375
- Here we have it so that when answering q2, the agent should remember answers to q0 and q1:
376
-
377
- >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
378
- >>> s.memory_plan
379
- {'q2': Memory(prior_questions=['q0', 'q1'])}
380
- """
381
- focal_question_name = self.question_names[
382
- self._get_question_index(focal_question)
383
- ]
384
-
385
- prior_question_names = [
386
- self.question_names[self._get_question_index(prior_question)]
387
- for prior_question in prior_questions
388
- ]
389
-
390
- self.memory_plan.add_memory_collection(
391
- focal_question=focal_question_name, prior_questions=prior_question_names
392
- )
814
+ self.question_groups[group_name] = (start_index, end_index)
393
815
  return self
394
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
+
395
836
  def add_stop_rule(
396
837
  self, question: Union[QuestionBase, str], expression: str
397
838
  ) -> Survey:
@@ -400,6 +841,7 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
400
841
  :param question: The question to add the stop rule to.
401
842
  :param expression: The expression to evaluate.
402
843
 
844
+ If this rule is true, the survey ends.
403
845
  The rule is evaluated *after* the question is answered. If the rule is true, the survey ends.
404
846
 
405
847
  Here, answering "yes" to q0 ends the survey:
@@ -412,10 +854,42 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
412
854
 
413
855
  >>> s.next_question("q0", {"q0": "no"}).question_name
414
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 '!='.
415
862
  """
863
+ expression = ValidatedString(expression)
416
864
  self.add_rule(question, expression, EndOfSurvey)
417
865
  return self
418
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
+
419
893
  def add_skip_rule(
420
894
  self, question: Union[QuestionBase, str], expression: str
421
895
  ) -> Survey:
@@ -443,36 +917,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
443
917
  self._add_rule(question, expression, question_index + 1, before_rule=True)
444
918
  return self
445
919
 
446
- def _get_question_index(
447
- self, q: Union[QuestionBase, str, EndOfSurvey.__class__]
448
- ) -> Union[int, EndOfSurvey.__class__]:
449
- """Return the index of the question or EndOfSurvey object.
450
-
451
- :param q: The question or question name to get the index of.
452
-
453
- It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
454
-
455
- >>> s = Survey.example()
456
- >>> s._get_question_index("q0")
457
- 0
458
-
459
- This doesnt' work with questions that don't exist:
460
-
461
- >>> s._get_question_index("poop")
462
- Traceback (most recent call last):
463
- ...
464
- ValueError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
465
- """
466
- if q == EndOfSurvey:
467
- return EndOfSurvey
468
- else:
469
- question_name = q if isinstance(q, str) else q.question_name
470
- if question_name not in self.question_name_to_index:
471
- raise ValueError(
472
- f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
473
- )
474
- return self.question_name_to_index[question_name]
475
-
476
920
  def _get_new_rule_priority(
477
921
  self, question_index: int, before_rule: bool = False
478
922
  ) -> int:
@@ -571,9 +1015,9 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
571
1015
 
572
1016
  return self
573
1017
 
574
- ###################
575
- # FORWARD METHODS
576
- ###################
1018
+ # endregion
1019
+
1020
+ # region: Forward methods
577
1021
  def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
578
1022
  """Add Agents, Scenarios, and LanguageModels to a survey and returns a runnable Jobs object.
579
1023
 
@@ -596,34 +1040,63 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
596
1040
 
597
1041
  return Jobs(survey=self)
598
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
+
599
1084
  def run(self, *args, **kwargs) -> "Results":
600
1085
  """Turn the survey into a Job and runs it.
601
1086
 
602
- Here we run a survey but with debug mode on (so LLM calls are not made)
603
-
604
1087
  >>> from edsl import QuestionFreeText
605
1088
  >>> s = Survey([QuestionFreeText.example()])
606
- >>> results = s.run(debug = True, cache = False)
607
- >>> results.select('answer.*').print(format = "rich")
608
- ┏━━━━━━━━━━━━━━┓
609
- answer
610
- .how_are_you
611
- ┡━━━━━━━━━━━━━━┩
612
- ...
613
- └──────────────┘
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!']}])
614
1094
  """
615
1095
  from edsl.jobs.Jobs import Jobs
616
1096
 
617
1097
  return Jobs(survey=self).run(*args, **kwargs)
618
1098
 
619
- ########################
620
- ## Survey-Taking Methods
621
- ########################
622
-
623
- def _first_question(self) -> QuestionBase:
624
- """Return the first question in the survey."""
625
- return self.questions[0]
626
-
1099
+ # region: Survey flow
627
1100
  def next_question(
628
1101
  self, current_question: Union[str, QuestionBase], answers: dict
629
1102
  ) -> Union[QuestionBase, EndOfSurvey.__class__]:
@@ -701,41 +1174,26 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
701
1174
  Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
702
1175
  >>> i2.send({"q0": "no"})
703
1176
  Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
1177
+
1178
+
704
1179
  """
705
- question = self._first_question()
1180
+ self.answers = {}
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
+
706
1186
  while not question == EndOfSurvey:
707
- self.answers = yield question
1187
+ # breakpoint()
1188
+ answer = yield question
1189
+ self.answers.update(answer)
1190
+ # print(f"Answers: {self.answers}")
708
1191
  ## TODO: This should also include survey and agent attributes
709
1192
  question = self.next_question(question, self.answers)
710
1193
 
711
- @property
712
- def scenario_attributes(self) -> list[str]:
713
- """Return a list of attributes that admissible Scenarios should have.
714
-
715
- Here we have a survey with a question that uses a jinja2 style {{ }} template:
716
-
717
- >>> from edsl import QuestionFreeText
718
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
719
- >>> s.scenario_attributes
720
- ['greeting']
721
-
722
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
723
- >>> s.scenario_attributes
724
- ['greeting', 'attribute']
725
-
726
-
727
- """
728
- temp = []
729
- for question in self.questions:
730
- question_text = question.question_text
731
- # extract the contents of all {{ }} in the question text using regex
732
- matches = re.findall(r"\{\{(.+?)\}\}", question_text)
733
- # remove whitespace
734
- matches = [match.strip() for match in matches]
735
- # add them to the temp list
736
- temp.extend(matches)
737
- return temp
1194
+ # endregion
738
1195
 
1196
+ # regions: DAG construction
739
1197
  def textify(self, index_dag: DAG) -> DAG:
740
1198
  """Convert the DAG of question indices to a DAG of question names.
741
1199
 
@@ -775,6 +1233,15 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
775
1233
 
776
1234
  @property
777
1235
  def piping_dag(self) -> DAG:
1236
+ """Figures out the DAG of piping dependencies.
1237
+
1238
+ >>> from edsl import QuestionFreeText
1239
+ >>> q0 = QuestionFreeText(question_text="Here is a question", question_name="q0")
1240
+ >>> q1 = QuestionFreeText(question_text="You previously answered {{ q0 }}---how do you feel now?", question_name="q1")
1241
+ >>> s = Survey([q0, q1])
1242
+ >>> s.piping_dag
1243
+ {1: {0}}
1244
+ """
778
1245
  d = {}
779
1246
  for question_name, depenencies in self.parameters_by_question.items():
780
1247
  if depenencies:
@@ -866,62 +1333,33 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
866
1333
  return False
867
1334
  return self.to_dict() == other.to_dict()
868
1335
 
869
- ###################
870
- # SERIALIZATION METHODS
871
- ###################
872
-
873
- def _to_dict(self) -> dict[str, Any]:
874
- """Serialize the Survey object to a dictionary.
875
-
876
- >>> s = Survey.example()
877
- >>> s._to_dict().keys()
878
- dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
879
-
880
- """
881
- return {
882
- "questions": [q._to_dict() for q in self._questions],
883
- "memory_plan": self.memory_plan.to_dict(),
884
- "rule_collection": self.rule_collection.to_dict(),
885
- "question_groups": self.question_groups,
886
- }
887
-
888
- @add_edsl_version
889
- def to_dict(self) -> dict[str, Any]:
890
- """Serialize the Survey object to a dictionary.
1336
+ @classmethod
1337
+ def from_qsf(
1338
+ cls, qsf_file: Optional[str] = None, url: Optional[str] = None
1339
+ ) -> Survey:
1340
+ """Create a Survey object from a Qualtrics QSF file."""
891
1341
 
892
- >>> s = Survey.example()
893
- >>> s.to_dict().keys()
894
- dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups', 'edsl_version', 'edsl_class_name'])
1342
+ if url and qsf_file:
1343
+ raise ValueError("Only one of url or qsf_file can be provided.")
895
1344
 
896
- """
897
- return self._to_dict()
1345
+ if (not url) and (not qsf_file):
1346
+ raise ValueError("Either url or qsf_file must be provided.")
898
1347
 
899
- @classmethod
900
- @remove_edsl_version
901
- def from_dict(cls, data: dict) -> Survey:
902
- """Deserialize the dictionary back to a Survey object.
1348
+ if url:
1349
+ response = requests.get(url)
1350
+ response.raise_for_status() # Ensure the request was successful
903
1351
 
904
- :param data: The dictionary to deserialize.
1352
+ # Save the Excel file to a temporary file
1353
+ with tempfile.NamedTemporaryFile(suffix=".qsf", delete=False) as temp_file:
1354
+ temp_file.write(response.content)
1355
+ qsf_file = temp_file.name
905
1356
 
906
- >>> d = Survey.example().to_dict()
907
- >>> s = Survey.from_dict(d)
908
- >>> s == Survey.example()
909
- True
1357
+ from edsl.surveys.SurveyQualtricsImport import SurveyQualtricsImport
910
1358
 
911
- """
912
- questions = [QuestionBase.from_dict(q_dict) for q_dict in data["questions"]]
913
- memory_plan = MemoryPlan.from_dict(data["memory_plan"])
914
- survey = cls(
915
- questions=questions,
916
- memory_plan=memory_plan,
917
- rule_collection=RuleCollection.from_dict(data["rule_collection"]),
918
- question_groups=data["question_groups"],
919
- )
920
- return survey
1359
+ so = SurveyQualtricsImport(qsf_file)
1360
+ return so.create_survey()
921
1361
 
922
- ###################
923
- # DISPLAY METHODS
924
- ###################
1362
+ # region: Display methods
925
1363
  def print(self):
926
1364
  """Print the survey in a rich format.
927
1365
 
@@ -940,7 +1378,8 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
940
1378
  def __repr__(self) -> str:
941
1379
  """Return a string representation of the survey."""
942
1380
 
943
- 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 []])
944
1383
  # question_names_string = ", ".join([repr(name) for name in self.question_names])
945
1384
  return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups})"
946
1385
 
@@ -949,22 +1388,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
949
1388
 
950
1389
  return data_to_html(self.to_dict())
951
1390
 
952
- def show_rules(self) -> None:
953
- """Print out the rules in the survey.
954
-
955
- >>> s = Survey.example()
956
- >>> s.show_rules()
957
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
958
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
959
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
960
- │ 0 │ True │ 1 │ -1 │ False │
961
- │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
962
- │ 1 │ True │ 2 │ -1 │ False │
963
- │ 2 │ True │ 3 │ -1 │ False │
964
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
965
- """
966
- self.rule_collection.show_rules()
967
-
968
1391
  def rich_print(self) -> Table:
969
1392
  """Print the survey in a rich format.
970
1393
 
@@ -1000,6 +1423,8 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1000
1423
 
1001
1424
  return table
1002
1425
 
1426
+ # endregion
1427
+
1003
1428
  def codebook(self) -> dict[str, str]:
1004
1429
  """Create a codebook for the survey, mapping question names to question text.
1005
1430
 
@@ -1012,6 +1437,7 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1012
1437
  codebook[question.question_name] = question.question_text
1013
1438
  return codebook
1014
1439
 
1440
+ # region: Export methods
1015
1441
  def to_csv(self, filename: str = None):
1016
1442
  """Export the survey to a CSV file.
1017
1443
 
@@ -1054,8 +1480,16 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1054
1480
  res = c.web(self.to_dict(), platform, email)
1055
1481
  return res
1056
1482
 
1483
+ # endregion
1484
+
1057
1485
  @classmethod
1058
- 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:
1059
1493
  """Return an example survey.
1060
1494
 
1061
1495
  >>> s = Survey.example()
@@ -1088,6 +1522,18 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1088
1522
  )
1089
1523
  s = cls(questions=[q0, q1, q2, q3])
1090
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
+
1091
1537
  s = cls(questions=[q0, q1, q2])
1092
1538
  s = s.add_rule(q0, "q0 == 'yes'", q2)
1093
1539
  return s
@@ -1109,43 +1555,6 @@ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
1109
1555
 
1110
1556
  return self.by(s).by(agent).by(model)
1111
1557
 
1112
- def __call__(self, model=None, agent=None, cache=None, **kwargs):
1113
- """Run the survey with default model, taking the required survey as arguments.
1114
-
1115
- >>> from edsl.questions import QuestionFunctional
1116
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1117
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1118
- >>> s = Survey([q])
1119
- >>> s(period = "morning", cache = False).select("answer.q0").first()
1120
- 'yes'
1121
- >>> s(period = "evening", cache = False).select("answer.q0").first()
1122
- 'no'
1123
- """
1124
- job = self.get_job(model, agent, **kwargs)
1125
- return job.run(cache=cache)
1126
-
1127
- async def run_async(self, model=None, agent=None, cache=None, **kwargs):
1128
- """Run the survey with default model, taking the required survey as arguments.
1129
-
1130
- >>> from edsl.questions import QuestionFunctional
1131
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1132
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1133
- >>> s = Survey([q])
1134
- >>> s(period = "morning").select("answer.q0").first()
1135
- 'yes'
1136
- >>> s(period = "evening").select("answer.q0").first()
1137
- 'no'
1138
- """
1139
- # TODO: temp fix by creating a cache
1140
- if cache is None:
1141
- from edsl.data import Cache
1142
-
1143
- c = Cache()
1144
- else:
1145
- c = cache
1146
- jobs: "Jobs" = self.get_job(model, agent, **kwargs)
1147
- return await jobs.run_async(cache=c)
1148
-
1149
1558
 
1150
1559
  def main():
1151
1560
  """Run the example survey."""