edsl 0.1.36.dev6__py3-none-any.whl → 0.1.37.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 (257) hide show
  1. edsl/Base.py +303 -303
  2. edsl/BaseDiff.py +260 -260
  3. edsl/TemplateLoader.py +24 -24
  4. edsl/__init__.py +48 -47
  5. edsl/__version__.py +1 -1
  6. edsl/agents/Agent.py +804 -804
  7. edsl/agents/AgentList.py +345 -337
  8. edsl/agents/Invigilator.py +222 -222
  9. edsl/agents/InvigilatorBase.py +305 -294
  10. edsl/agents/PromptConstructor.py +312 -312
  11. edsl/agents/__init__.py +3 -3
  12. edsl/agents/descriptors.py +86 -86
  13. edsl/agents/prompt_helpers.py +129 -129
  14. edsl/auto/AutoStudy.py +117 -117
  15. edsl/auto/StageBase.py +230 -230
  16. edsl/auto/StageGenerateSurvey.py +178 -178
  17. edsl/auto/StageLabelQuestions.py +125 -125
  18. edsl/auto/StagePersona.py +61 -61
  19. edsl/auto/StagePersonaDimensionValueRanges.py +88 -88
  20. edsl/auto/StagePersonaDimensionValues.py +74 -74
  21. edsl/auto/StagePersonaDimensions.py +69 -69
  22. edsl/auto/StageQuestions.py +73 -73
  23. edsl/auto/SurveyCreatorPipeline.py +21 -21
  24. edsl/auto/utilities.py +224 -224
  25. edsl/base/Base.py +289 -289
  26. edsl/config.py +149 -149
  27. edsl/conjure/AgentConstructionMixin.py +152 -152
  28. edsl/conjure/Conjure.py +62 -62
  29. edsl/conjure/InputData.py +659 -659
  30. edsl/conjure/InputDataCSV.py +48 -48
  31. edsl/conjure/InputDataMixinQuestionStats.py +182 -182
  32. edsl/conjure/InputDataPyRead.py +91 -91
  33. edsl/conjure/InputDataSPSS.py +8 -8
  34. edsl/conjure/InputDataStata.py +8 -8
  35. edsl/conjure/QuestionOptionMixin.py +76 -76
  36. edsl/conjure/QuestionTypeMixin.py +23 -23
  37. edsl/conjure/RawQuestion.py +65 -65
  38. edsl/conjure/SurveyResponses.py +7 -7
  39. edsl/conjure/__init__.py +9 -9
  40. edsl/conjure/naming_utilities.py +263 -263
  41. edsl/conjure/utilities.py +201 -201
  42. edsl/conversation/Conversation.py +238 -238
  43. edsl/conversation/car_buying.py +58 -58
  44. edsl/conversation/mug_negotiation.py +81 -81
  45. edsl/conversation/next_speaker_utilities.py +93 -93
  46. edsl/coop/PriceFetcher.py +54 -54
  47. edsl/coop/__init__.py +2 -2
  48. edsl/coop/coop.py +824 -849
  49. edsl/coop/utils.py +131 -131
  50. edsl/data/Cache.py +527 -527
  51. edsl/data/CacheEntry.py +228 -228
  52. edsl/data/CacheHandler.py +149 -149
  53. edsl/data/RemoteCacheSync.py +97 -84
  54. edsl/data/SQLiteDict.py +292 -292
  55. edsl/data/__init__.py +4 -4
  56. edsl/data/orm.py +10 -10
  57. edsl/data_transfer_models.py +73 -73
  58. edsl/enums.py +173 -173
  59. edsl/exceptions/__init__.py +50 -50
  60. edsl/exceptions/agents.py +40 -40
  61. edsl/exceptions/configuration.py +16 -16
  62. edsl/exceptions/coop.py +10 -10
  63. edsl/exceptions/data.py +14 -14
  64. edsl/exceptions/general.py +34 -34
  65. edsl/exceptions/jobs.py +33 -33
  66. edsl/exceptions/language_models.py +63 -63
  67. edsl/exceptions/prompts.py +15 -15
  68. edsl/exceptions/questions.py +91 -91
  69. edsl/exceptions/results.py +26 -26
  70. edsl/exceptions/surveys.py +34 -34
  71. edsl/inference_services/AnthropicService.py +87 -87
  72. edsl/inference_services/AwsBedrock.py +115 -115
  73. edsl/inference_services/AzureAI.py +217 -217
  74. edsl/inference_services/DeepInfraService.py +18 -18
  75. edsl/inference_services/GoogleService.py +156 -156
  76. edsl/inference_services/GroqService.py +20 -20
  77. edsl/inference_services/InferenceServiceABC.py +147 -147
  78. edsl/inference_services/InferenceServicesCollection.py +74 -72
  79. edsl/inference_services/MistralAIService.py +123 -123
  80. edsl/inference_services/OllamaService.py +18 -18
  81. edsl/inference_services/OpenAIService.py +224 -224
  82. edsl/inference_services/TestService.py +89 -89
  83. edsl/inference_services/TogetherAIService.py +170 -170
  84. edsl/inference_services/models_available_cache.py +118 -118
  85. edsl/inference_services/rate_limits_cache.py +25 -25
  86. edsl/inference_services/registry.py +39 -39
  87. edsl/inference_services/write_available.py +10 -10
  88. edsl/jobs/Answers.py +56 -56
  89. edsl/jobs/Jobs.py +1112 -1112
  90. edsl/jobs/__init__.py +1 -1
  91. edsl/jobs/buckets/BucketCollection.py +63 -63
  92. edsl/jobs/buckets/ModelBuckets.py +65 -65
  93. edsl/jobs/buckets/TokenBucket.py +248 -248
  94. edsl/jobs/interviews/Interview.py +661 -651
  95. edsl/jobs/interviews/InterviewExceptionCollection.py +99 -99
  96. edsl/jobs/interviews/InterviewExceptionEntry.py +182 -182
  97. edsl/jobs/interviews/InterviewStatistic.py +63 -63
  98. edsl/jobs/interviews/InterviewStatisticsCollection.py +25 -25
  99. edsl/jobs/interviews/InterviewStatusDictionary.py +78 -78
  100. edsl/jobs/interviews/InterviewStatusLog.py +92 -92
  101. edsl/jobs/interviews/ReportErrors.py +66 -66
  102. edsl/jobs/interviews/interview_status_enum.py +9 -9
  103. edsl/jobs/runners/JobsRunnerAsyncio.py +338 -337
  104. edsl/jobs/runners/JobsRunnerStatus.py +332 -332
  105. edsl/jobs/tasks/QuestionTaskCreator.py +242 -242
  106. edsl/jobs/tasks/TaskCreators.py +64 -64
  107. edsl/jobs/tasks/TaskHistory.py +441 -441
  108. edsl/jobs/tasks/TaskStatusLog.py +23 -23
  109. edsl/jobs/tasks/task_status_enum.py +163 -163
  110. edsl/jobs/tokens/InterviewTokenUsage.py +27 -27
  111. edsl/jobs/tokens/TokenUsage.py +34 -34
  112. edsl/language_models/LanguageModel.py +718 -718
  113. edsl/language_models/ModelList.py +102 -102
  114. edsl/language_models/RegisterLanguageModelsMeta.py +184 -184
  115. edsl/language_models/__init__.py +2 -2
  116. edsl/language_models/fake_openai_call.py +15 -15
  117. edsl/language_models/fake_openai_service.py +61 -61
  118. edsl/language_models/registry.py +137 -137
  119. edsl/language_models/repair.py +156 -156
  120. edsl/language_models/unused/ReplicateBase.py +83 -83
  121. edsl/language_models/utilities.py +64 -64
  122. edsl/notebooks/Notebook.py +259 -259
  123. edsl/notebooks/__init__.py +1 -1
  124. edsl/prompts/Prompt.py +353 -358
  125. edsl/prompts/__init__.py +2 -2
  126. edsl/questions/AnswerValidatorMixin.py +289 -289
  127. edsl/questions/QuestionBase.py +616 -616
  128. edsl/questions/QuestionBaseGenMixin.py +161 -161
  129. edsl/questions/QuestionBasePromptsMixin.py +266 -266
  130. edsl/questions/QuestionBudget.py +227 -227
  131. edsl/questions/QuestionCheckBox.py +359 -359
  132. edsl/questions/QuestionExtract.py +183 -183
  133. edsl/questions/QuestionFreeText.py +114 -113
  134. edsl/questions/QuestionFunctional.py +159 -159
  135. edsl/questions/QuestionList.py +231 -231
  136. edsl/questions/QuestionMultipleChoice.py +286 -286
  137. edsl/questions/QuestionNumerical.py +153 -153
  138. edsl/questions/QuestionRank.py +324 -324
  139. edsl/questions/Quick.py +41 -41
  140. edsl/questions/RegisterQuestionsMeta.py +71 -71
  141. edsl/questions/ResponseValidatorABC.py +174 -174
  142. edsl/questions/SimpleAskMixin.py +73 -73
  143. edsl/questions/__init__.py +26 -26
  144. edsl/questions/compose_questions.py +98 -98
  145. edsl/questions/decorators.py +21 -21
  146. edsl/questions/derived/QuestionLikertFive.py +76 -76
  147. edsl/questions/derived/QuestionLinearScale.py +87 -87
  148. edsl/questions/derived/QuestionTopK.py +91 -91
  149. edsl/questions/derived/QuestionYesNo.py +82 -82
  150. edsl/questions/descriptors.py +418 -418
  151. edsl/questions/prompt_templates/question_budget.jinja +13 -13
  152. edsl/questions/prompt_templates/question_checkbox.jinja +32 -32
  153. edsl/questions/prompt_templates/question_extract.jinja +11 -11
  154. edsl/questions/prompt_templates/question_free_text.jinja +3 -3
  155. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -11
  156. edsl/questions/prompt_templates/question_list.jinja +17 -17
  157. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -33
  158. edsl/questions/prompt_templates/question_numerical.jinja +36 -36
  159. edsl/questions/question_registry.py +147 -147
  160. edsl/questions/settings.py +12 -12
  161. edsl/questions/templates/budget/answering_instructions.jinja +7 -7
  162. edsl/questions/templates/budget/question_presentation.jinja +7 -7
  163. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -10
  164. edsl/questions/templates/checkbox/question_presentation.jinja +22 -22
  165. edsl/questions/templates/extract/answering_instructions.jinja +7 -7
  166. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -10
  167. edsl/questions/templates/likert_five/question_presentation.jinja +11 -11
  168. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -5
  169. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -5
  170. edsl/questions/templates/list/answering_instructions.jinja +3 -3
  171. edsl/questions/templates/list/question_presentation.jinja +5 -5
  172. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -9
  173. edsl/questions/templates/multiple_choice/question_presentation.jinja +11 -11
  174. edsl/questions/templates/numerical/answering_instructions.jinja +6 -6
  175. edsl/questions/templates/numerical/question_presentation.jinja +6 -6
  176. edsl/questions/templates/rank/answering_instructions.jinja +11 -11
  177. edsl/questions/templates/rank/question_presentation.jinja +15 -15
  178. edsl/questions/templates/top_k/answering_instructions.jinja +8 -8
  179. edsl/questions/templates/top_k/question_presentation.jinja +22 -22
  180. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -6
  181. edsl/questions/templates/yes_no/question_presentation.jinja +11 -11
  182. edsl/results/Dataset.py +293 -293
  183. edsl/results/DatasetExportMixin.py +693 -693
  184. edsl/results/DatasetTree.py +145 -145
  185. edsl/results/Result.py +435 -433
  186. edsl/results/Results.py +1160 -1158
  187. edsl/results/ResultsDBMixin.py +238 -238
  188. edsl/results/ResultsExportMixin.py +43 -43
  189. edsl/results/ResultsFetchMixin.py +33 -33
  190. edsl/results/ResultsGGMixin.py +121 -121
  191. edsl/results/ResultsToolsMixin.py +98 -98
  192. edsl/results/Selector.py +118 -118
  193. edsl/results/__init__.py +2 -2
  194. edsl/results/tree_explore.py +115 -115
  195. edsl/scenarios/FileStore.py +458 -443
  196. edsl/scenarios/Scenario.py +510 -507
  197. edsl/scenarios/ScenarioHtmlMixin.py +59 -59
  198. edsl/scenarios/ScenarioList.py +1101 -1101
  199. edsl/scenarios/ScenarioListExportMixin.py +52 -52
  200. edsl/scenarios/ScenarioListPdfMixin.py +261 -261
  201. edsl/scenarios/__init__.py +4 -2
  202. edsl/shared.py +1 -1
  203. edsl/study/ObjectEntry.py +173 -173
  204. edsl/study/ProofOfWork.py +113 -113
  205. edsl/study/SnapShot.py +80 -80
  206. edsl/study/Study.py +528 -528
  207. edsl/study/__init__.py +4 -4
  208. edsl/surveys/DAG.py +148 -148
  209. edsl/surveys/Memory.py +31 -31
  210. edsl/surveys/MemoryPlan.py +244 -244
  211. edsl/surveys/Rule.py +324 -324
  212. edsl/surveys/RuleCollection.py +387 -387
  213. edsl/surveys/Survey.py +1772 -1772
  214. edsl/surveys/SurveyCSS.py +261 -261
  215. edsl/surveys/SurveyExportMixin.py +259 -259
  216. edsl/surveys/SurveyFlowVisualizationMixin.py +121 -121
  217. edsl/surveys/SurveyQualtricsImport.py +284 -284
  218. edsl/surveys/__init__.py +3 -3
  219. edsl/surveys/base.py +53 -53
  220. edsl/surveys/descriptors.py +56 -56
  221. edsl/surveys/instructions/ChangeInstruction.py +47 -47
  222. edsl/surveys/instructions/Instruction.py +51 -51
  223. edsl/surveys/instructions/InstructionCollection.py +77 -77
  224. edsl/templates/error_reporting/base.html +23 -23
  225. edsl/templates/error_reporting/exceptions_by_model.html +34 -34
  226. edsl/templates/error_reporting/exceptions_by_question_name.html +16 -16
  227. edsl/templates/error_reporting/exceptions_by_type.html +16 -16
  228. edsl/templates/error_reporting/interview_details.html +115 -115
  229. edsl/templates/error_reporting/interviews.html +9 -9
  230. edsl/templates/error_reporting/overview.html +4 -4
  231. edsl/templates/error_reporting/performance_plot.html +1 -1
  232. edsl/templates/error_reporting/report.css +73 -73
  233. edsl/templates/error_reporting/report.html +117 -117
  234. edsl/templates/error_reporting/report.js +25 -25
  235. edsl/tools/__init__.py +1 -1
  236. edsl/tools/clusters.py +192 -192
  237. edsl/tools/embeddings.py +27 -27
  238. edsl/tools/embeddings_plotting.py +118 -118
  239. edsl/tools/plotting.py +112 -112
  240. edsl/tools/summarize.py +18 -18
  241. edsl/utilities/SystemInfo.py +28 -28
  242. edsl/utilities/__init__.py +22 -22
  243. edsl/utilities/ast_utilities.py +25 -25
  244. edsl/utilities/data/Registry.py +6 -6
  245. edsl/utilities/data/__init__.py +1 -1
  246. edsl/utilities/data/scooter_results.json +1 -1
  247. edsl/utilities/decorators.py +77 -77
  248. edsl/utilities/gcp_bucket/cloud_storage.py +96 -96
  249. edsl/utilities/interface.py +627 -627
  250. edsl/utilities/repair_functions.py +28 -28
  251. edsl/utilities/restricted_python.py +70 -70
  252. edsl/utilities/utilities.py +391 -391
  253. {edsl-0.1.36.dev6.dist-info → edsl-0.1.37.dev2.dist-info}/LICENSE +21 -21
  254. {edsl-0.1.36.dev6.dist-info → edsl-0.1.37.dev2.dist-info}/METADATA +1 -1
  255. edsl-0.1.37.dev2.dist-info/RECORD +279 -0
  256. edsl-0.1.36.dev6.dist-info/RECORD +0 -279
  257. {edsl-0.1.36.dev6.dist-info → edsl-0.1.37.dev2.dist-info}/WHEEL +0 -0
edsl/surveys/Survey.py CHANGED
@@ -1,1772 +1,1772 @@
1
- """A Survey is collection of questions that can be administered to an Agent."""
2
-
3
- from __future__ import annotations
4
- import re
5
- import tempfile
6
- import requests
7
-
8
- from typing import Any, Generator, Optional, Union, List, Literal, Callable
9
- from uuid import uuid4
10
- from edsl.Base import Base
11
- from edsl.exceptions import SurveyCreationError, SurveyHasNoRulesError
12
- from edsl.questions.QuestionBase import QuestionBase
13
- from edsl.surveys.base import RulePriority, EndOfSurvey
14
- from edsl.surveys.DAG import DAG
15
- from edsl.surveys.descriptors import QuestionsDescriptor
16
- from edsl.surveys.MemoryPlan import MemoryPlan
17
- from edsl.surveys.Rule import Rule
18
- from edsl.surveys.RuleCollection import RuleCollection
19
- from edsl.surveys.SurveyExportMixin import SurveyExportMixin
20
- from edsl.surveys.SurveyFlowVisualizationMixin import SurveyFlowVisualizationMixin
21
- from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
22
-
23
- from edsl.agents.Agent import Agent
24
-
25
- from edsl.surveys.instructions.InstructionCollection import InstructionCollection
26
- from edsl.surveys.instructions.Instruction import Instruction
27
- from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
28
-
29
-
30
- class ValidatedString(str):
31
- def __new__(cls, content):
32
- if "<>" in content:
33
- raise ValueError(
34
- "The expression contains '<>', which is not allowed. You probably mean '!='."
35
- )
36
- return super().__new__(cls, content)
37
-
38
-
39
- class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
40
- """A collection of questions that supports skip logic."""
41
-
42
- questions = QuestionsDescriptor()
43
- """
44
- A collection of questions that supports skip logic.
45
-
46
- Initalization:
47
- - `questions`: the questions in the survey (optional)
48
- - `question_names`: the names of the questions (optional)
49
- - `name`: the name of the survey (optional)
50
-
51
- Methods:
52
- -
53
-
54
- Notes:
55
- - The presumed order of the survey is the order in which questions are added.
56
- """
57
-
58
- def __init__(
59
- self,
60
- questions: Optional[
61
- list[Union[QuestionBase, Instruction, ChangeInstruction]]
62
- ] = None,
63
- memory_plan: Optional[MemoryPlan] = None,
64
- rule_collection: Optional[RuleCollection] = None,
65
- question_groups: Optional[dict[str, tuple[int, int]]] = None,
66
- name: Optional[str] = None,
67
- ):
68
- """Create a new survey.
69
-
70
- :param questions: The questions in the survey.
71
- :param memory_plan: The memory plan for the survey.
72
- :param rule_collection: The rule collection for the survey.
73
- :param question_groups: The groups of questions in the survey.
74
- :param name: The name of the survey - DEPRECATED.
75
-
76
-
77
- >>> from edsl import QuestionFreeText
78
- >>> q1 = QuestionFreeText(question_text = "What is your name?", question_name = "name")
79
- >>> q2 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
80
- >>> q3 = QuestionFreeText(question_text = "Is a hot dog a sandwich", question_name = "food")
81
- >>> s = Survey([q1, q2, q3], question_groups = {"demographics": (0, 1), "substantive":(3)})
82
-
83
-
84
- """
85
-
86
- self.raw_passed_questions = questions
87
-
88
- (
89
- true_questions,
90
- instruction_names_to_instructions,
91
- self.pseudo_indices,
92
- ) = self._separate_questions_and_instructions(questions or [])
93
-
94
- self.rule_collection = RuleCollection(
95
- num_questions=len(true_questions) if true_questions else None
96
- )
97
- # the RuleCollection needs to be present while we add the questions; we might override this later
98
- # if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
99
-
100
- self.questions = true_questions
101
- self.instruction_names_to_instructions = instruction_names_to_instructions
102
-
103
- self.memory_plan = memory_plan or MemoryPlan(self)
104
- if question_groups is not None:
105
- self.question_groups = question_groups
106
- else:
107
- self.question_groups = {}
108
-
109
- # if a rule collection is provided, use it instead
110
- if rule_collection is not None:
111
- self.rule_collection = rule_collection
112
-
113
- if name is not None:
114
- import warnings
115
-
116
- warnings.warn("name parameter to a survey is deprecated.")
117
-
118
- # region: Suvry instruction handling
119
- @property
120
- def relevant_instructions_dict(self) -> InstructionCollection:
121
- """Return a dictionary with keys as question names and values as instructions that are relevant to the question.
122
-
123
- >>> s = Survey.example(include_instructions=True)
124
- >>> s.relevant_instructions_dict
125
- {'q0': [Instruction(name="attention", text="Please pay attention!")], 'q1': [Instruction(name="attention", text="Please pay attention!")], 'q2': [Instruction(name="attention", text="Please pay attention!")]}
126
-
127
- """
128
- return InstructionCollection(
129
- self.instruction_names_to_instructions, self.questions
130
- )
131
-
132
- @staticmethod
133
- def _separate_questions_and_instructions(questions_and_instructions: list) -> tuple:
134
- """
135
- The 'pseudo_indices' attribute is a dictionary that maps question names to pseudo-indices
136
- that are used to order questions and instructions in the survey.
137
- Only questions get real indices; instructions get pseudo-indices.
138
- However, the order of the pseudo-indices is the same as the order questions and instructions are added to the survey.
139
-
140
- We don't have to know how many instructions there are to calculate the pseudo-indices because they are
141
- calculated by the inverse of one minus the sum of 1/2^n for n in the number of instructions run so far.
142
-
143
- >>> from edsl import Instruction
144
- >>> i = Instruction(text = "Pay attention to the following questions.", name = "intro")
145
- >>> i2 = Instruction(text = "How are you feeling today?", name = "followon_intro")
146
- >>> from edsl import QuestionFreeText; q1 = QuestionFreeText.example()
147
- >>> from edsl import QuestionMultipleChoice; q2 = QuestionMultipleChoice.example()
148
- >>> s = Survey([q1, i, i2, q2])
149
- >>> len(s.instruction_names_to_instructions)
150
- 2
151
- >>> s.pseudo_indices
152
- {'how_are_you': 0, 'intro': 0.5, 'followon_intro': 0.75, 'how_feeling': 1}
153
-
154
- >>> from edsl import ChangeInstruction
155
- >>> q3 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
156
- >>> i_change = ChangeInstruction(drop = ["intro"])
157
- >>> s = Survey([q1, i, q2, i_change, q3])
158
- >>> [i.name for i in s.relevant_instructions(q1)]
159
- []
160
- >>> [i.name for i in s.relevant_instructions(q2)]
161
- ['intro']
162
- >>> [i.name for i in s.relevant_instructions(q3)]
163
- []
164
-
165
- >>> i_change = ChangeInstruction(keep = ["poop"], drop = [])
166
- >>> s = Survey([q1, i, q2, i_change])
167
- Traceback (most recent call last):
168
- ...
169
- ValueError: ChangeInstruction change_instruction_0 references instruction poop which does not exist.
170
- """
171
- from edsl.surveys.instructions.Instruction import Instruction
172
- from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
173
-
174
- true_questions = []
175
- instruction_names_to_instructions = {}
176
-
177
- num_change_instructions = 0
178
- pseudo_indices = {}
179
- instructions_run_length = 0
180
- for entry in questions_and_instructions:
181
- if isinstance(entry, Instruction) or isinstance(entry, ChangeInstruction):
182
- if isinstance(entry, ChangeInstruction):
183
- entry.add_name(num_change_instructions)
184
- num_change_instructions += 1
185
- for prior_instruction in entry.keep + entry.drop:
186
- if prior_instruction not in instruction_names_to_instructions:
187
- raise ValueError(
188
- f"ChangeInstruction {entry.name} references instruction {prior_instruction} which does not exist."
189
- )
190
- instructions_run_length += 1
191
- delta = 1 - 1.0 / (2.0**instructions_run_length)
192
- pseudo_index = (len(true_questions) - 1) + delta
193
- entry.pseudo_index = pseudo_index
194
- instruction_names_to_instructions[entry.name] = entry
195
- elif isinstance(entry, QuestionBase):
196
- pseudo_index = len(true_questions)
197
- instructions_run_length = 0
198
- true_questions.append(entry)
199
- else:
200
- raise ValueError(
201
- f"Entry {repr(entry)} is not a QuestionBase or an Instruction."
202
- )
203
-
204
- pseudo_indices[entry.name] = pseudo_index
205
-
206
- return true_questions, instruction_names_to_instructions, pseudo_indices
207
-
208
- def relevant_instructions(self, question) -> dict:
209
- """This should be a dictionry with keys as question names and values as instructions that are relevant to the question.
210
-
211
- :param question: The question to get the relevant instructions for.
212
-
213
- # Did the instruction come before the question and was it not modified by a change instruction?
214
-
215
- """
216
- return self.relevant_instructions_dict[question]
217
-
218
- @property
219
- def max_pseudo_index(self) -> float:
220
- """Return the maximum pseudo index in the survey.
221
-
222
- Example:
223
-
224
- >>> s = Survey.example()
225
- >>> s.max_pseudo_index
226
- 2
227
- """
228
- if len(self.pseudo_indices) == 0:
229
- return -1
230
- return max(self.pseudo_indices.values())
231
-
232
- @property
233
- def last_item_was_instruction(self) -> bool:
234
- """Return whether the last item added to the survey was an instruction.
235
- This is used to determine the pseudo-index of the next item added to the survey.
236
-
237
- Example:
238
-
239
- >>> s = Survey.example()
240
- >>> s.last_item_was_instruction
241
- False
242
- >>> from edsl.surveys.instructions.Instruction import Instruction
243
- >>> s = s.add_instruction(Instruction(text="Pay attention to the following questions.", name="intro"))
244
- >>> s.last_item_was_instruction
245
- True
246
- """
247
- return isinstance(self.max_pseudo_index, float)
248
-
249
- def add_instruction(
250
- self, instruction: Union["Instruction", "ChangeInstruction"]
251
- ) -> Survey:
252
- """
253
- Add an instruction to the survey.
254
-
255
- :param instruction: The instruction to add to the survey.
256
-
257
- >>> from edsl import Instruction
258
- >>> i = Instruction(text="Pay attention to the following questions.", name="intro")
259
- >>> s = Survey().add_instruction(i)
260
- >>> s.instruction_names_to_instructions
261
- {'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
262
- >>> s.pseudo_indices
263
- {'intro': -0.5}
264
- """
265
- import math
266
-
267
- if instruction.name in self.instruction_names_to_instructions:
268
- raise SurveyCreationError(
269
- f"""Instruction name '{instruction.name}' already exists in survey. Existing names are {self.instruction_names_to_instructions.keys()}."""
270
- )
271
- self.instruction_names_to_instructions[instruction.name] = instruction
272
-
273
- # was the last thing added an instruction or a question?
274
- if self.last_item_was_instruction:
275
- pseudo_index = (
276
- self.max_pseudo_index
277
- + (math.ceil(self.max_pseudo_index) - self.max_pseudo_index) / 2
278
- )
279
- else:
280
- pseudo_index = self.max_pseudo_index + 1.0 / 2.0
281
- self.pseudo_indices[instruction.name] = pseudo_index
282
-
283
- return self
284
-
285
- # endregion
286
-
287
- # region: Simulation methods
288
-
289
- @classmethod
290
- def random_survey(self):
291
- """Create a random survey."""
292
- from edsl.questions import QuestionMultipleChoice, QuestionFreeText
293
- from random import choice
294
-
295
- num_questions = 10
296
- questions = []
297
- for i in range(num_questions):
298
- if choice([True, False]):
299
- q = QuestionMultipleChoice(
300
- question_text="nothing",
301
- question_name="q_" + str(i),
302
- question_options=list(range(3)),
303
- )
304
- questions.append(q)
305
- else:
306
- questions.append(
307
- QuestionFreeText(
308
- question_text="nothing", question_name="q_" + str(i)
309
- )
310
- )
311
- s = Survey(questions)
312
- start_index = choice(range(num_questions - 1))
313
- end_index = choice(range(start_index + 1, 10))
314
- s = s.add_rule(f"q_{start_index}", "True", f"q_{end_index}")
315
- question_to_delete = choice(range(num_questions))
316
- s.delete_question(f"q_{question_to_delete}")
317
- return s
318
-
319
- def simulate(self) -> dict:
320
- """Simulate the survey and return the answers."""
321
- i = self.gen_path_through_survey()
322
- q = next(i)
323
- num_passes = 0
324
- while True:
325
- num_passes += 1
326
- try:
327
- answer = q._simulate_answer()
328
- q = i.send({q.question_name: answer["answer"]})
329
- except StopIteration:
330
- break
331
-
332
- if num_passes > 100:
333
- print("Too many passes.")
334
- raise Exception("Too many passes.")
335
- return self.answers
336
-
337
- def create_agent(self) -> "Agent":
338
- """Create an agent from the simulated answers."""
339
- answers_dict = self.simulate()
340
-
341
- def construct_answer_dict_function(traits: dict) -> Callable:
342
- def func(self, question: "QuestionBase", scenario=None):
343
- return traits.get(question.question_name, None)
344
-
345
- return func
346
-
347
- return Agent(traits=answers_dict).add_direct_question_answering_method(
348
- construct_answer_dict_function(answers_dict)
349
- )
350
-
351
- def simulate_results(self) -> "Results":
352
- """Simulate the survey and return the results."""
353
- a = self.create_agent()
354
- return self.by([a]).run()
355
-
356
- # endregion
357
-
358
- # region: Access methods
359
- def _get_question_index(
360
- self, q: Union[QuestionBase, str, EndOfSurvey.__class__]
361
- ) -> Union[int, EndOfSurvey.__class__]:
362
- """Return the index of the question or EndOfSurvey object.
363
-
364
- :param q: The question or question name to get the index of.
365
-
366
- It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
367
-
368
- >>> s = Survey.example()
369
- >>> s._get_question_index("q0")
370
- 0
371
-
372
- This doesnt' work with questions that don't exist:
373
-
374
- >>> s._get_question_index("poop")
375
- Traceback (most recent call last):
376
- ...
377
- ValueError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
378
- """
379
- if q == EndOfSurvey:
380
- return EndOfSurvey
381
- else:
382
- question_name = q if isinstance(q, str) else q.question_name
383
- if question_name not in self.question_name_to_index:
384
- raise ValueError(
385
- f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
386
- )
387
- return self.question_name_to_index[question_name]
388
-
389
- def get(self, question_name: str) -> QuestionBase:
390
- """
391
- Return the question object given the question name.
392
-
393
- :param question_name: The name of the question to get.
394
-
395
- >>> s = Survey.example()
396
- >>> s.get_question("q0")
397
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
398
- """
399
- if question_name not in self.question_name_to_index:
400
- raise KeyError(f"Question name {question_name} not found in survey.")
401
- index = self.question_name_to_index[question_name]
402
- return self._questions[index]
403
-
404
- def get_question(self, question_name: str) -> QuestionBase:
405
- """Return the question object given the question name."""
406
- # import warnings
407
- # warnings.warn("survey.get_question is deprecated. Use subscript operator instead.")
408
- return self.get(question_name)
409
-
410
- def question_names_to_questions(self) -> dict:
411
- """Return a dictionary mapping question names to question attributes."""
412
- return {q.question_name: q for q in self.questions}
413
-
414
- @property
415
- def question_names(self) -> list[str]:
416
- """Return a list of question names in the survey.
417
-
418
- Example:
419
-
420
- >>> s = Survey.example()
421
- >>> s.question_names
422
- ['q0', 'q1', 'q2']
423
- """
424
- # return list(self.question_name_to_index.keys())
425
- return [q.question_name for q in self.questions]
426
-
427
- @property
428
- def question_name_to_index(self) -> dict[str, int]:
429
- """Return a dictionary mapping question names to question indices.
430
-
431
- Example:
432
-
433
- >>> s = Survey.example()
434
- >>> s.question_name_to_index
435
- {'q0': 0, 'q1': 1, 'q2': 2}
436
- """
437
- return {q.question_name: i for i, q in enumerate(self.questions)}
438
-
439
- # endregion
440
-
441
- # region: serialization methods
442
- def __hash__(self) -> int:
443
- """Return a hash of the question."""
444
- from edsl.utilities.utilities import dict_hash
445
-
446
- return dict_hash(self._to_dict())
447
-
448
- def _to_dict(self) -> dict[str, Any]:
449
- """Serialize the Survey object to a dictionary.
450
-
451
- >>> s = Survey.example()
452
- >>> s._to_dict().keys()
453
- dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
454
- """
455
- return {
456
- "questions": [
457
- q._to_dict() for q in self.recombined_questions_and_instructions()
458
- ],
459
- "memory_plan": self.memory_plan.to_dict(),
460
- "rule_collection": self.rule_collection.to_dict(),
461
- "question_groups": self.question_groups,
462
- }
463
-
464
- @add_edsl_version
465
- def to_dict(self) -> dict[str, Any]:
466
- """Serialize the Survey object to a dictionary.
467
-
468
- >>> s = Survey.example()
469
- >>> s.to_dict().keys()
470
- dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups', 'edsl_version', 'edsl_class_name'])
471
-
472
- """
473
- return self._to_dict()
474
-
475
- @classmethod
476
- @remove_edsl_version
477
- def from_dict(cls, data: dict) -> Survey:
478
- """Deserialize the dictionary back to a Survey object.
479
-
480
- :param data: The dictionary to deserialize.
481
-
482
- >>> d = Survey.example().to_dict()
483
- >>> s = Survey.from_dict(d)
484
- >>> s == Survey.example()
485
- True
486
-
487
- >>> s = Survey.example(include_instructions = True)
488
- >>> d = s.to_dict()
489
- >>> news = Survey.from_dict(d)
490
- >>> news == s
491
- True
492
-
493
- """
494
-
495
- def get_class(pass_dict):
496
- if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
497
- return QuestionBase
498
- elif class_name == "Instruction":
499
- from edsl.surveys.instructions.Instruction import Instruction
500
-
501
- return Instruction
502
- elif class_name == "ChangeInstruction":
503
- from edsl.surveys.instructions.ChangeInstruction import (
504
- ChangeInstruction,
505
- )
506
-
507
- return ChangeInstruction
508
- else:
509
- # some data might not have the edsl_class_name
510
- return QuestionBase
511
- # raise ValueError(f"Class {pass_dict['edsl_class_name']} not found")
512
-
513
- questions = [
514
- get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
515
- ]
516
- memory_plan = MemoryPlan.from_dict(data["memory_plan"])
517
- survey = cls(
518
- questions=questions,
519
- memory_plan=memory_plan,
520
- rule_collection=RuleCollection.from_dict(data["rule_collection"]),
521
- question_groups=data["question_groups"],
522
- )
523
- return survey
524
-
525
- # endregion
526
-
527
- # region: Survey template parameters
528
- @property
529
- def scenario_attributes(self) -> list[str]:
530
- """Return a list of attributes that admissible Scenarios should have.
531
-
532
- Here we have a survey with a question that uses a jinja2 style {{ }} template:
533
-
534
- >>> from edsl import QuestionFreeText
535
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
536
- >>> s.scenario_attributes
537
- ['greeting']
538
-
539
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
540
- >>> s.scenario_attributes
541
- ['greeting', 'attribute']
542
-
543
-
544
- """
545
- temp = []
546
- for question in self.questions:
547
- question_text = question.question_text
548
- # extract the contents of all {{ }} in the question text using regex
549
- matches = re.findall(r"\{\{(.+?)\}\}", question_text)
550
- # remove whitespace
551
- matches = [match.strip() for match in matches]
552
- # add them to the temp list
553
- temp.extend(matches)
554
- return temp
555
-
556
- @property
557
- def parameters(self):
558
- """Return a set of parameters in the survey.
559
-
560
- >>> s = Survey.example()
561
- >>> s.parameters
562
- set()
563
- """
564
- return set.union(*[q.parameters for q in self.questions])
565
-
566
- @property
567
- def parameters_by_question(self):
568
- """Return a dictionary of parameters by question in the survey.
569
- >>> from edsl import QuestionFreeText
570
- >>> q = QuestionFreeText(question_name = "example", question_text = "What is the capital of {{ country}}?")
571
- >>> s = Survey([q])
572
- >>> s.parameters_by_question
573
- {'example': {'country'}}
574
- """
575
- return {q.question_name: q.parameters for q in self.questions}
576
-
577
- # endregion
578
-
579
- # region: Survey construction
580
-
581
- # region: Adding questions and combining surveys
582
- def __add__(self, other: Survey) -> Survey:
583
- """Combine two surveys.
584
-
585
- :param other: The other survey to combine with this one.
586
- >>> s1 = Survey.example()
587
- >>> from edsl import QuestionFreeText
588
- >>> s2 = Survey([QuestionFreeText(question_text="What is your name?", question_name="yo")])
589
- >>> s3 = s1 + s2
590
- Traceback (most recent call last):
591
- ...
592
- 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.")
593
- >>> s3 = s1.clear_non_default_rules() + s2
594
- >>> len(s3.questions)
595
- 4
596
-
597
- """
598
- if (
599
- len(self.rule_collection.non_default_rules) > 0
600
- or len(other.rule_collection.non_default_rules) > 0
601
- ):
602
- raise ValueError(
603
- "Cannot combine two surveys with non-default rules.",
604
- "Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.",
605
- )
606
-
607
- return Survey(questions=self.questions + other.questions)
608
-
609
- def move_question(self, identifier: Union[str, int], new_index: int):
610
- if isinstance(identifier, str):
611
- if identifier not in self.question_names:
612
- raise ValueError(
613
- f"Question name '{identifier}' does not exist in the survey."
614
- )
615
- index = self.question_name_to_index[identifier]
616
- elif isinstance(identifier, int):
617
- if identifier < 0 or identifier >= len(self.questions):
618
- raise ValueError(f"Index {identifier} is out of range.")
619
- index = identifier
620
- else:
621
- raise TypeError(
622
- "Identifier must be either a string (question name) or an integer (question index)."
623
- )
624
-
625
- moving_question = self._questions[index]
626
-
627
- new_survey = self.delete_question(index)
628
- new_survey.add_question(moving_question, new_index)
629
- return new_survey
630
-
631
- def delete_question(self, identifier: Union[str, int]) -> Survey:
632
- """
633
- Delete a question from the survey.
634
-
635
- :param identifier: The name or index of the question to delete.
636
- :return: The updated Survey object.
637
-
638
- >>> from edsl import QuestionMultipleChoice, Survey
639
- >>> q1 = QuestionMultipleChoice(question_text="Q1", question_options=["A", "B"], question_name="q1")
640
- >>> q2 = QuestionMultipleChoice(question_text="Q2", question_options=["C", "D"], question_name="q2")
641
- >>> s = Survey().add_question(q1).add_question(q2)
642
- >>> _ = s.delete_question("q1")
643
- >>> len(s.questions)
644
- 1
645
- >>> _ = s.delete_question(0)
646
- >>> len(s.questions)
647
- 0
648
- """
649
- if isinstance(identifier, str):
650
- if identifier not in self.question_names:
651
- raise ValueError(
652
- f"Question name '{identifier}' does not exist in the survey."
653
- )
654
- index = self.question_name_to_index[identifier]
655
- elif isinstance(identifier, int):
656
- if identifier < 0 or identifier >= len(self.questions):
657
- raise ValueError(f"Index {identifier} is out of range.")
658
- index = identifier
659
- else:
660
- raise TypeError(
661
- "Identifier must be either a string (question name) or an integer (question index)."
662
- )
663
-
664
- # Remove the question
665
- deleted_question = self._questions.pop(index)
666
- del self.pseudo_indices[deleted_question.question_name]
667
- # del self.question_name_to_index[deleted_question.question_name]
668
-
669
- # Update indices
670
- for question_name, old_index in self.pseudo_indices.items():
671
- if old_index > index:
672
- self.pseudo_indices[question_name] = old_index - 1
673
-
674
- # for question_name, old_index in self.question_name_to_index.items():
675
- # if old_index > index:
676
- # self.question_name_to_index[question_name] = old_index - 1
677
-
678
- # Update rules
679
- new_rule_collection = RuleCollection()
680
- for rule in self.rule_collection:
681
- if rule.current_q == index:
682
- continue # Remove rules associated with the deleted question
683
- if rule.current_q > index:
684
- rule.current_q -= 1
685
- if rule.next_q > index:
686
- rule.next_q -= 1
687
-
688
- if rule.next_q == index:
689
- if index == len(self.questions):
690
- rule.next_q = EndOfSurvey
691
- else:
692
- rule.next_q = index
693
- # rule.next_q = min(index, len(self.questions) - 1)
694
- # continue
695
-
696
- # if rule.next_q == index:
697
- # rule.next_q = min(
698
- # rule.next_q, len(self.questions) - 1
699
- # ) # Adjust to last question if necessary
700
-
701
- new_rule_collection.add_rule(rule)
702
- self.rule_collection = new_rule_collection
703
-
704
- # Update memory plan if it exists
705
- if hasattr(self, "memory_plan"):
706
- self.memory_plan.remove_question(deleted_question.question_name)
707
-
708
- return self
709
-
710
- def add_question(
711
- self, question: QuestionBase, index: Optional[int] = None
712
- ) -> Survey:
713
- """
714
- Add a question to survey.
715
-
716
- :param question: The question to add to the survey.
717
- :param question_name: The name of the question. If not provided, the question name is used.
718
-
719
- The question is appended at the end of the self.questions list
720
- A default rule is created that the next index is the next question.
721
-
722
- >>> from edsl import QuestionMultipleChoice
723
- >>> q = QuestionMultipleChoice(question_text = "Do you like school?", question_options=["yes", "no"], question_name="q0")
724
- >>> s = Survey().add_question(q)
725
-
726
- >>> s = Survey().add_question(q).add_question(q)
727
- Traceback (most recent call last):
728
- ...
729
- edsl.exceptions.surveys.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
730
- """
731
- if question.question_name in self.question_names:
732
- raise SurveyCreationError(
733
- f"""Question name '{question.question_name}' already exists in survey. Existing names are {self.question_names}."""
734
- )
735
- if index is None:
736
- index = len(self.questions)
737
-
738
- if index > len(self.questions):
739
- raise ValueError(
740
- f"Index {index} is greater than the number of questions in the survey."
741
- )
742
- if index < 0:
743
- raise ValueError(f"Index {index} is less than 0.")
744
-
745
- interior_insertion = index != len(self.questions)
746
-
747
- # index = len(self.questions)
748
- # TODO: This is a bit ugly because the user
749
- # doesn't "know" about _questions - it's generated by the
750
- # descriptor.
751
- self._questions.insert(index, question)
752
-
753
- if interior_insertion:
754
- for question_name, old_index in self.pseudo_indices.items():
755
- if old_index >= index:
756
- self.pseudo_indices[question_name] = old_index + 1
757
-
758
- self.pseudo_indices[question.question_name] = index
759
-
760
- ## Re-do question_name to index - this is done automatically
761
- # for question_name, old_index in self.question_name_to_index.items():
762
- # if old_index >= index:
763
- # self.question_name_to_index[question_name] = old_index + 1
764
-
765
- ## Need to re-do the rule collection and the indices of the questions
766
-
767
- ## If a rule is before the insertion index and next_q is also before the insertion index, no change needed.
768
- ## If the rule is before the insertion index but next_q is after the insertion index, increment the next_q by 1
769
- ## If the rule is after the insertion index, increment the current_q by 1 and the next_q by 1
770
-
771
- # using index + 1 presumes there is a next question
772
- if interior_insertion:
773
- for rule in self.rule_collection:
774
- if rule.current_q >= index:
775
- rule.current_q += 1
776
- if rule.next_q >= index:
777
- rule.next_q += 1
778
-
779
- # add a new rule
780
- self.rule_collection.add_rule(
781
- Rule(
782
- current_q=index,
783
- expression="True",
784
- next_q=index + 1,
785
- question_name_to_index=self.question_name_to_index,
786
- priority=RulePriority.DEFAULT.value,
787
- )
788
- )
789
-
790
- # a question might be added before the memory plan is created
791
- # it's ok because the memory plan will be updated when it is created
792
- if hasattr(self, "memory_plan"):
793
- self.memory_plan.add_question(question)
794
-
795
- return self
796
-
797
- def recombined_questions_and_instructions(
798
- self,
799
- ) -> list[Union[QuestionBase, "Instruction"]]:
800
- """Return a list of questions and instructions sorted by pseudo index."""
801
- questions_and_instructions = self._questions + list(
802
- self.instruction_names_to_instructions.values()
803
- )
804
- return sorted(
805
- questions_and_instructions, key=lambda x: self.pseudo_indices[x.name]
806
- )
807
-
808
- # endregion
809
-
810
- # region: Memory plan methods
811
- def set_full_memory_mode(self) -> Survey:
812
- """Add instructions to a survey that the agent should remember all of the answers to the questions in the survey.
813
-
814
- >>> s = Survey.example().set_full_memory_mode()
815
-
816
- """
817
- self._set_memory_plan(lambda i: self.question_names[:i])
818
- return self
819
-
820
- def set_lagged_memory(self, lags: int) -> Survey:
821
- """Add instructions to a survey that the agent should remember the answers to the questions in the survey.
822
-
823
- The agent should remember the answers to the questions in the survey from the previous lags.
824
- """
825
- self._set_memory_plan(lambda i: self.question_names[max(0, i - lags) : i])
826
- return self
827
-
828
- def _set_memory_plan(self, prior_questions_func: Callable):
829
- """Set memory plan based on a provided function determining prior questions.
830
-
831
- :param prior_questions_func: A function that takes the index of the current question and returns a list of prior questions to remember.
832
-
833
- >>> s = Survey.example()
834
- >>> s._set_memory_plan(lambda i: s.question_names[:i])
835
-
836
- """
837
- for i, question_name in enumerate(self.question_names):
838
- self.memory_plan.add_memory_collection(
839
- focal_question=question_name,
840
- prior_questions=prior_questions_func(i),
841
- )
842
-
843
- def add_targeted_memory(
844
- self,
845
- focal_question: Union[QuestionBase, str],
846
- prior_question: Union[QuestionBase, str],
847
- ) -> Survey:
848
- """Add instructions to a survey than when answering focal_question.
849
-
850
- :param focal_question: The question that the agent is answering.
851
- :param prior_question: The question that the agent should remember when answering the focal question.
852
-
853
- Here we add instructions to a survey than when answering q2 they should remember q1:
854
-
855
- >>> s = Survey.example().add_targeted_memory("q2", "q0")
856
- >>> s.memory_plan
857
- {'q2': Memory(prior_questions=['q0'])}
858
-
859
- The agent should also remember the answers to prior_questions listed in prior_questions.
860
- """
861
- focal_question_name = self.question_names[
862
- self._get_question_index(focal_question)
863
- ]
864
- prior_question_name = self.question_names[
865
- self._get_question_index(prior_question)
866
- ]
867
-
868
- self.memory_plan.add_single_memory(
869
- focal_question=focal_question_name,
870
- prior_question=prior_question_name,
871
- )
872
-
873
- return self
874
-
875
- def add_memory_collection(
876
- self,
877
- focal_question: Union[QuestionBase, str],
878
- prior_questions: List[Union[QuestionBase, str]],
879
- ) -> Survey:
880
- """Add prior questions and responses so the agent has them when answering.
881
-
882
- 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.
883
-
884
- :param focal_question: The question that the agent is answering.
885
- :param prior_questions: The questions that the agent should remember when answering the focal question.
886
-
887
- Here we have it so that when answering q2, the agent should remember answers to q0 and q1:
888
-
889
- >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
890
- >>> s.memory_plan
891
- {'q2': Memory(prior_questions=['q0', 'q1'])}
892
- """
893
- focal_question_name = self.question_names[
894
- self._get_question_index(focal_question)
895
- ]
896
-
897
- prior_question_names = [
898
- self.question_names[self._get_question_index(prior_question)]
899
- for prior_question in prior_questions
900
- ]
901
-
902
- self.memory_plan.add_memory_collection(
903
- focal_question=focal_question_name, prior_questions=prior_question_names
904
- )
905
- return self
906
-
907
- # endregion
908
- # endregion
909
- # endregion
910
-
911
- # region: Question groups
912
- def add_question_group(
913
- self,
914
- start_question: Union[QuestionBase, str],
915
- end_question: Union[QuestionBase, str],
916
- group_name: str,
917
- ) -> Survey:
918
- """Add a group of questions to the survey.
919
-
920
- :param start_question: The first question in the group.
921
- :param end_question: The last question in the group.
922
- :param group_name: The name of the group.
923
-
924
- Example:
925
-
926
- >>> s = Survey.example().add_question_group("q0", "q1", "group1")
927
- >>> s.question_groups
928
- {'group1': (0, 1)}
929
-
930
- The name of the group must be a valid identifier:
931
-
932
- >>> s = Survey.example().add_question_group("q0", "q2", "1group1")
933
- Traceback (most recent call last):
934
- ...
935
- ValueError: Group name 1group1 is not a valid identifier.
936
-
937
- The name of the group cannot be the same as an existing question name:
938
-
939
- >>> s = Survey.example().add_question_group("q0", "q1", "q0")
940
- Traceback (most recent call last):
941
- ...
942
- ValueError: Group name q0 already exists as a question name in the survey.
943
-
944
- The start index must be less than the end index:
945
-
946
- >>> s = Survey.example().add_question_group("q1", "q0", "group1")
947
- Traceback (most recent call last):
948
- ...
949
- ValueError: Start index 1 is greater than end index 0.
950
- """
951
-
952
- if not group_name.isidentifier():
953
- raise ValueError(f"Group name {group_name} is not a valid identifier.")
954
-
955
- if group_name in self.question_groups:
956
- raise ValueError(f"Group name {group_name} already exists in the survey.")
957
-
958
- if group_name in self.question_name_to_index:
959
- raise ValueError(
960
- f"Group name {group_name} already exists as a question name in the survey."
961
- )
962
-
963
- start_index = self._get_question_index(start_question)
964
- end_index = self._get_question_index(end_question)
965
-
966
- if start_index > end_index:
967
- raise ValueError(
968
- f"Start index {start_index} is greater than end index {end_index}."
969
- )
970
-
971
- for existing_group_name, (
972
- existing_start_index,
973
- existing_end_index,
974
- ) in self.question_groups.items():
975
- if start_index < existing_start_index and end_index > existing_end_index:
976
- raise ValueError(
977
- f"Group {group_name} contains the questions in the new group."
978
- )
979
- if start_index > existing_start_index and end_index < existing_end_index:
980
- raise ValueError(f"Group {group_name} is contained in the new group.")
981
- if start_index < existing_start_index and end_index > existing_start_index:
982
- raise ValueError(f"Group {group_name} overlaps with the new group.")
983
- if start_index < existing_end_index and end_index > existing_end_index:
984
- raise ValueError(f"Group {group_name} overlaps with the new group.")
985
-
986
- self.question_groups[group_name] = (start_index, end_index)
987
- return self
988
-
989
- # endregion
990
-
991
- # region: Survey rules
992
- def show_rules(self) -> None:
993
- """Print out the rules in the survey.
994
-
995
- >>> s = Survey.example()
996
- >>> s.show_rules()
997
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
998
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
999
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1000
- │ 0 │ True │ 1 │ -1 │ False │
1001
- │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1002
- │ 1 │ True │ 2 │ -1 │ False │
1003
- │ 2 │ True │ 3 │ -1 │ False │
1004
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
1005
- """
1006
- self.rule_collection.show_rules()
1007
-
1008
- def add_stop_rule(
1009
- self, question: Union[QuestionBase, str], expression: str
1010
- ) -> Survey:
1011
- """Add a rule that stops the survey.
1012
-
1013
- :param question: The question to add the stop rule to.
1014
- :param expression: The expression to evaluate.
1015
-
1016
- If this rule is true, the survey ends.
1017
- The rule is evaluated *after* the question is answered. If the rule is true, the survey ends.
1018
-
1019
- Here, answering "yes" to q0 ends the survey:
1020
-
1021
- >>> s = Survey.example().add_stop_rule("q0", "q0 == 'yes'")
1022
- >>> s.next_question("q0", {"q0": "yes"})
1023
- EndOfSurvey
1024
-
1025
- By comparison, answering "no" to q0 does not end the survey:
1026
-
1027
- >>> s.next_question("q0", {"q0": "no"}).question_name
1028
- 'q1'
1029
-
1030
- >>> s.add_stop_rule("q0", "q1 <> 'yes'")
1031
- Traceback (most recent call last):
1032
- ...
1033
- ValueError: The expression contains '<>', which is not allowed. You probably mean '!='.
1034
- """
1035
- expression = ValidatedString(expression)
1036
- self.add_rule(question, expression, EndOfSurvey)
1037
- return self
1038
-
1039
- def clear_non_default_rules(self) -> Survey:
1040
- """Remove all non-default rules from the survey.
1041
-
1042
- >>> Survey.example().show_rules()
1043
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1044
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1045
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1046
- │ 0 │ True │ 1 │ -1 │ False │
1047
- │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1048
- │ 1 │ True │ 2 │ -1 │ False │
1049
- │ 2 │ True │ 3 │ -1 │ False │
1050
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
1051
- >>> Survey.example().clear_non_default_rules().show_rules()
1052
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1053
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1054
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1055
- │ 0 │ True │ 1 │ -1 │ False │
1056
- │ 1 │ True │ 2 │ -1 │ False │
1057
- │ 2 │ True │ 3 │ -1 │ False │
1058
- └───────────┴────────────┴────────┴──────────┴─────────────┘
1059
- """
1060
- s = Survey()
1061
- for question in self.questions:
1062
- s.add_question(question)
1063
- return s
1064
-
1065
- def add_skip_rule(
1066
- self, question: Union[QuestionBase, str], expression: str
1067
- ) -> Survey:
1068
- """
1069
- Adds a per-question skip rule to the survey.
1070
-
1071
- :param question: The question to add the skip rule to.
1072
- :param expression: The expression to evaluate.
1073
-
1074
- This adds a rule that skips 'q0' always, before the question is answered:
1075
-
1076
- >>> from edsl import QuestionFreeText
1077
- >>> q0 = QuestionFreeText.example()
1078
- >>> q0.question_name = "q0"
1079
- >>> q1 = QuestionFreeText.example()
1080
- >>> q1.question_name = "q1"
1081
- >>> s = Survey([q0, q1]).add_skip_rule("q0", "True")
1082
- >>> s.next_question("q0", {}).question_name
1083
- 'q1'
1084
-
1085
- Note that this is different from a rule that jumps to some other question *after* the question is answered.
1086
-
1087
- """
1088
- question_index = self._get_question_index(question)
1089
- self._add_rule(question, expression, question_index + 1, before_rule=True)
1090
- return self
1091
-
1092
- def _get_new_rule_priority(
1093
- self, question_index: int, before_rule: bool = False
1094
- ) -> int:
1095
- """Return the priority for the new rule.
1096
-
1097
- :param question_index: The index of the question to add the rule to.
1098
- :param before_rule: Whether the rule is evaluated before the question is answered.
1099
-
1100
- >>> s = Survey.example()
1101
- >>> s._get_new_rule_priority(0)
1102
- 1
1103
- """
1104
- current_priorities = [
1105
- rule.priority
1106
- for rule in self.rule_collection.applicable_rules(
1107
- question_index, before_rule
1108
- )
1109
- ]
1110
- if len(current_priorities) == 0:
1111
- return RulePriority.DEFAULT.value + 1
1112
-
1113
- max_priority = max(current_priorities)
1114
- # newer rules take priority over older rules
1115
- new_priority = (
1116
- RulePriority.DEFAULT.value
1117
- if len(current_priorities) == 0
1118
- else max_priority + 1
1119
- )
1120
- return new_priority
1121
-
1122
- def add_rule(
1123
- self,
1124
- question: Union[QuestionBase, str],
1125
- expression: str,
1126
- next_question: Union[QuestionBase, int],
1127
- before_rule: bool = False,
1128
- ) -> Survey:
1129
- """
1130
- Add a rule to a Question of the Survey.
1131
-
1132
- :param question: The question to add the rule to.
1133
- :param expression: The expression to evaluate.
1134
- :param next_question: The next question to go to if the rule is true.
1135
- :param before_rule: Whether the rule is evaluated before the question is answered.
1136
-
1137
- This adds a rule that if the answer to q0 is 'yes', the next question is q2 (as opposed to q1)
1138
-
1139
- >>> s = Survey.example().add_rule("q0", "{{ q0 }} == 'yes'", "q2")
1140
- >>> s.next_question("q0", {"q0": "yes"}).question_name
1141
- 'q2'
1142
-
1143
- """
1144
- return self._add_rule(
1145
- question, expression, next_question, before_rule=before_rule
1146
- )
1147
-
1148
- def _add_rule(
1149
- self,
1150
- question: Union[QuestionBase, str],
1151
- expression: str,
1152
- next_question: Union[QuestionBase, str, int],
1153
- before_rule: bool = False,
1154
- ) -> Survey:
1155
- """
1156
- Add a rule to a Question of the Survey with the appropriate priority.
1157
-
1158
- :param question: The question to add the rule to.
1159
- :param expression: The expression to evaluate.
1160
- :param next_question: The next question to go to if the rule is true.
1161
- :param before_rule: Whether the rule is evaluated before the question is answered.
1162
-
1163
-
1164
- - The last rule added for the question will have the highest priority.
1165
- - If there are no rules, the rule added gets priority -1.
1166
- """
1167
- question_index = self._get_question_index(question)
1168
-
1169
- # Might not have the name of the next question yet
1170
- if isinstance(next_question, int):
1171
- next_question_index = next_question
1172
- else:
1173
- next_question_index = self._get_question_index(next_question)
1174
-
1175
- new_priority = self._get_new_rule_priority(question_index, before_rule)
1176
-
1177
- self.rule_collection.add_rule(
1178
- Rule(
1179
- current_q=question_index,
1180
- expression=expression,
1181
- next_q=next_question_index,
1182
- question_name_to_index=self.question_name_to_index,
1183
- priority=new_priority,
1184
- before_rule=before_rule,
1185
- )
1186
- )
1187
-
1188
- return self
1189
-
1190
- # endregion
1191
-
1192
- # region: Forward methods
1193
- def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
1194
- """Add Agents, Scenarios, and LanguageModels to a survey and returns a runnable Jobs object.
1195
-
1196
- :param args: The Agents, Scenarios, and LanguageModels to add to the survey.
1197
-
1198
- This takes the survey and adds an Agent and a Scenario via 'by' which converts to a Jobs object:
1199
-
1200
- >>> s = Survey.example(); from edsl import Agent; from edsl import Scenario
1201
- >>> s.by(Agent.example()).by(Scenario.example())
1202
- Jobs(...)
1203
- """
1204
- from edsl.jobs.Jobs import Jobs
1205
-
1206
- job = Jobs(survey=self)
1207
- return job.by(*args)
1208
-
1209
- def to_jobs(self):
1210
- """Convert the survey to a Jobs object."""
1211
- from edsl.jobs.Jobs import Jobs
1212
-
1213
- return Jobs(survey=self)
1214
-
1215
- def show_prompts(self):
1216
- return self.to_jobs().show_prompts()
1217
-
1218
- # endregion
1219
-
1220
- # region: Running the survey
1221
-
1222
- def __call__(self, model=None, agent=None, cache=None, **kwargs):
1223
- """Run the survey with default model, taking the required survey as arguments.
1224
-
1225
- >>> from edsl.questions import QuestionFunctional
1226
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1227
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1228
- >>> s = Survey([q])
1229
- >>> s(period = "morning", cache = False).select("answer.q0").first()
1230
- 'yes'
1231
- >>> s(period = "evening", cache = False).select("answer.q0").first()
1232
- 'no'
1233
- """
1234
- job = self.get_job(model, agent, **kwargs)
1235
- return job.run(cache=cache)
1236
-
1237
- async def run_async(self, model=None, agent=None, cache=None, **kwargs):
1238
- """Run the survey with default model, taking the required survey as arguments.
1239
-
1240
- >>> from edsl.questions import QuestionFunctional
1241
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1242
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1243
- >>> s = Survey([q])
1244
- >>> s(period = "morning").select("answer.q0").first()
1245
- 'yes'
1246
- >>> s(period = "evening").select("answer.q0").first()
1247
- 'no'
1248
- """
1249
- # TODO: temp fix by creating a cache
1250
- if cache is None:
1251
- from edsl.data import Cache
1252
-
1253
- c = Cache()
1254
- else:
1255
- c = cache
1256
- jobs: "Jobs" = self.get_job(model, agent, **kwargs)
1257
- return await jobs.run_async(cache=c)
1258
-
1259
- def run(self, *args, **kwargs) -> "Results":
1260
- """Turn the survey into a Job and runs it.
1261
-
1262
- >>> from edsl import QuestionFreeText
1263
- >>> s = Survey([QuestionFreeText.example()])
1264
- >>> from edsl.language_models import LanguageModel
1265
- >>> m = LanguageModel.example(test_model = True, canned_response = "Great!")
1266
- >>> results = s.by(m).run(cache = False)
1267
- >>> results.select('answer.*')
1268
- Dataset([{'answer.how_are_you': ['Great!']}])
1269
- """
1270
- from edsl.jobs.Jobs import Jobs
1271
-
1272
- return Jobs(survey=self).run(*args, **kwargs)
1273
-
1274
- # region: Survey flow
1275
- def next_question(
1276
- self, current_question: Union[str, QuestionBase], answers: dict
1277
- ) -> Union[QuestionBase, EndOfSurvey.__class__]:
1278
- """
1279
- Return the next question in a survey.
1280
-
1281
- :param current_question: The current question in the survey.
1282
- :param answers: The answers for the survey so far
1283
-
1284
- - If called with no arguments, it returns the first question in the survey.
1285
- - If no answers are provided for a question with a rule, the next question is returned. If answers are provided, the next question is determined by the rules and the answers.
1286
- - If the next question is the last question in the survey, an EndOfSurvey object is returned.
1287
-
1288
- >>> s = Survey.example()
1289
- >>> s.next_question("q0", {"q0": "yes"}).question_name
1290
- 'q2'
1291
- >>> s.next_question("q0", {"q0": "no"}).question_name
1292
- 'q1'
1293
-
1294
- """
1295
- if isinstance(current_question, str):
1296
- current_question = self.get_question(current_question)
1297
-
1298
- question_index = self.question_name_to_index[current_question.question_name]
1299
- next_question_object = self.rule_collection.next_question(
1300
- question_index, answers
1301
- )
1302
-
1303
- if next_question_object.num_rules_found == 0:
1304
- raise SurveyHasNoRulesError
1305
-
1306
- if next_question_object.next_q == EndOfSurvey:
1307
- return EndOfSurvey
1308
- else:
1309
- if next_question_object.next_q >= len(self.questions):
1310
- return EndOfSurvey
1311
- else:
1312
- return self.questions[next_question_object.next_q]
1313
-
1314
- def gen_path_through_survey(self) -> Generator[QuestionBase, dict, None]:
1315
- """
1316
- Generate a coroutine that can be used to conduct an Interview.
1317
-
1318
- The coroutine is a generator that yields a question and receives answers.
1319
- It starts with the first question in the survey.
1320
- The coroutine ends when an EndOfSurvey object is returned.
1321
-
1322
- For the example survey, this is the rule table:
1323
-
1324
- >>> s = Survey.example()
1325
- >>> s.show_rules()
1326
- ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1327
- ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1328
- ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1329
- │ 0 │ True │ 1 │ -1 │ False │
1330
- │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1331
- │ 1 │ True │ 2 │ -1 │ False │
1332
- │ 2 │ True │ 3 │ -1 │ False │
1333
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
1334
-
1335
- Note that q0 has a rule that if the answer is 'yes', the next question is q2. If the answer is 'no', the next question is q1.
1336
-
1337
- Here is the path through the survey if the answer to q0 is 'yes':
1338
-
1339
- >>> i = s.gen_path_through_survey()
1340
- >>> next(i)
1341
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1342
- >>> i.send({"q0": "yes"})
1343
- Question('multiple_choice', question_name = \"""q2\""", question_text = \"""Why?\""", question_options = ['**lack*** of killer bees in cafeteria', 'other'])
1344
-
1345
- And here is the path through the survey if the answer to q0 is 'no':
1346
-
1347
- >>> i2 = s.gen_path_through_survey()
1348
- >>> next(i2)
1349
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1350
- >>> i2.send({"q0": "no"})
1351
- Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
1352
-
1353
-
1354
- """
1355
- self.answers = {}
1356
- question = self._questions[0]
1357
- # should the first question be skipped?
1358
- if self.rule_collection.skip_question_before_running(0, self.answers):
1359
- question = self.next_question(question, self.answers)
1360
-
1361
- while not question == EndOfSurvey:
1362
- # breakpoint()
1363
- answer = yield question
1364
- self.answers.update(answer)
1365
- # print(f"Answers: {self.answers}")
1366
- ## TODO: This should also include survey and agent attributes
1367
- question = self.next_question(question, self.answers)
1368
-
1369
- # endregion
1370
-
1371
- # regions: DAG construction
1372
- def textify(self, index_dag: DAG) -> DAG:
1373
- """Convert the DAG of question indices to a DAG of question names.
1374
-
1375
- :param index_dag: The DAG of question indices.
1376
-
1377
- Example:
1378
-
1379
- >>> s = Survey.example()
1380
- >>> d = s.dag()
1381
- >>> d
1382
- {1: {0}, 2: {0}}
1383
- >>> s.textify(d)
1384
- {'q1': {'q0'}, 'q2': {'q0'}}
1385
- """
1386
-
1387
- def get_name(index: int):
1388
- """Return the name of the question given the index."""
1389
- if index >= len(self.questions):
1390
- return EndOfSurvey
1391
- try:
1392
- return self.questions[index].question_name
1393
- except IndexError:
1394
- print(
1395
- f"The index is {index} but the length of the questions is {len(self.questions)}"
1396
- )
1397
- raise
1398
-
1399
- try:
1400
- text_dag = {}
1401
- for child_index, parent_indices in index_dag.items():
1402
- parent_names = {get_name(index) for index in parent_indices}
1403
- child_name = get_name(child_index)
1404
- text_dag[child_name] = parent_names
1405
- return text_dag
1406
- except IndexError:
1407
- raise
1408
-
1409
- @property
1410
- def piping_dag(self) -> DAG:
1411
- """Figures out the DAG of piping dependencies.
1412
-
1413
- >>> from edsl import QuestionFreeText
1414
- >>> q0 = QuestionFreeText(question_text="Here is a question", question_name="q0")
1415
- >>> q1 = QuestionFreeText(question_text="You previously answered {{ q0 }}---how do you feel now?", question_name="q1")
1416
- >>> s = Survey([q0, q1])
1417
- >>> s.piping_dag
1418
- {1: {0}}
1419
- """
1420
- d = {}
1421
- for question_name, depenencies in self.parameters_by_question.items():
1422
- if depenencies:
1423
- question_index = self.question_name_to_index[question_name]
1424
- for dependency in depenencies:
1425
- if dependency not in self.question_name_to_index:
1426
- pass
1427
- else:
1428
- dependency_index = self.question_name_to_index[dependency]
1429
- if question_index not in d:
1430
- d[question_index] = set()
1431
- d[question_index].add(dependency_index)
1432
- return d
1433
-
1434
- def dag(self, textify: bool = False) -> DAG:
1435
- """Return the DAG of the survey, which reflects both skip-logic and memory.
1436
-
1437
- :param textify: Whether to return the DAG with question names instead of indices.
1438
-
1439
- >>> s = Survey.example()
1440
- >>> d = s.dag()
1441
- >>> d
1442
- {1: {0}, 2: {0}}
1443
-
1444
- """
1445
- memory_dag = self.memory_plan.dag
1446
- rule_dag = self.rule_collection.dag
1447
- piping_dag = self.piping_dag
1448
- if textify:
1449
- memory_dag = DAG(self.textify(memory_dag))
1450
- rule_dag = DAG(self.textify(rule_dag))
1451
- piping_dag = DAG(self.textify(piping_dag))
1452
- return memory_dag + rule_dag + piping_dag
1453
-
1454
- ###################
1455
- # DUNDER METHODS
1456
- ###################
1457
- def __len__(self) -> int:
1458
- """Return the number of questions in the survey.
1459
-
1460
- >>> s = Survey.example()
1461
- >>> len(s)
1462
- 3
1463
- """
1464
- return len(self._questions)
1465
-
1466
- def __getitem__(self, index) -> QuestionBase:
1467
- """Return the question object given the question index.
1468
-
1469
- :param index: The index of the question to get.
1470
-
1471
- >>> s = Survey.example()
1472
- >>> s[0]
1473
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1474
-
1475
- """
1476
- if isinstance(index, int):
1477
- return self._questions[index]
1478
- elif isinstance(index, str):
1479
- return getattr(self, index)
1480
-
1481
- def _diff(self, other):
1482
- """Used for debugging. Print out the differences between two surveys."""
1483
- from rich import print
1484
-
1485
- for key, value in self.to_dict().items():
1486
- if value != other.to_dict()[key]:
1487
- print(f"Key: {key}")
1488
- print("\n")
1489
- print(f"Self: {value}")
1490
- print("\n")
1491
- print(f"Other: {other.to_dict()[key]}")
1492
- print("\n\n")
1493
-
1494
- def __eq__(self, other) -> bool:
1495
- """Return True if the two surveys have the same to_dict.
1496
-
1497
- :param other: The other survey to compare to.
1498
-
1499
- >>> s = Survey.example()
1500
- >>> s == s
1501
- True
1502
-
1503
- >>> s == "poop"
1504
- False
1505
-
1506
- """
1507
- if not isinstance(other, Survey):
1508
- return False
1509
- return self.to_dict() == other.to_dict()
1510
-
1511
- @classmethod
1512
- def from_qsf(
1513
- cls, qsf_file: Optional[str] = None, url: Optional[str] = None
1514
- ) -> Survey:
1515
- """Create a Survey object from a Qualtrics QSF file."""
1516
-
1517
- if url and qsf_file:
1518
- raise ValueError("Only one of url or qsf_file can be provided.")
1519
-
1520
- if (not url) and (not qsf_file):
1521
- raise ValueError("Either url or qsf_file must be provided.")
1522
-
1523
- if url:
1524
- response = requests.get(url)
1525
- response.raise_for_status() # Ensure the request was successful
1526
-
1527
- # Save the Excel file to a temporary file
1528
- with tempfile.NamedTemporaryFile(suffix=".qsf", delete=False) as temp_file:
1529
- temp_file.write(response.content)
1530
- qsf_file = temp_file.name
1531
-
1532
- from edsl.surveys.SurveyQualtricsImport import SurveyQualtricsImport
1533
-
1534
- so = SurveyQualtricsImport(qsf_file)
1535
- return so.create_survey()
1536
-
1537
- # region: Display methods
1538
- def print(self):
1539
- """Print the survey in a rich format.
1540
-
1541
- >>> s = Survey.example()
1542
- >>> s.print()
1543
- {
1544
- "questions": [
1545
- ...
1546
- }
1547
- """
1548
- from rich import print_json
1549
- import json
1550
-
1551
- print_json(json.dumps(self.to_dict()))
1552
-
1553
- def __repr__(self) -> str:
1554
- """Return a string representation of the survey."""
1555
-
1556
- # questions_string = ", ".join([repr(q) for q in self._questions])
1557
- questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1558
- # question_names_string = ", ".join([repr(name) for name in self.question_names])
1559
- return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups})"
1560
-
1561
- def _repr_html_(self) -> str:
1562
- from edsl.utilities.utilities import data_to_html
1563
-
1564
- return data_to_html(self.to_dict())
1565
-
1566
- def rich_print(self) -> Table:
1567
- """Print the survey in a rich format.
1568
-
1569
- >>> t = Survey.example().rich_print()
1570
- >>> print(t) # doctest: +SKIP
1571
- ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
1572
- ┃ Questions ┃
1573
- ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
1574
- │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ │
1575
- │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1576
- │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩ │
1577
- │ │ q0 │ multiple_choice │ Do you like school? │ yes, no │ │
1578
- │ └───────────────┴─────────────────┴─────────────────────┴─────────┘ │
1579
- │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
1580
- │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1581
- │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
1582
- │ │ q1 │ multiple_choice │ Why not? │ killer bees in cafeteria, other │ │
1583
- │ └───────────────┴─────────────────┴───────────────┴─────────────────────────────────┘ │
1584
- │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
1585
- │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1586
- │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
1587
- │ │ q2 │ multiple_choice │ Why? │ **lack*** of killer bees in cafeteria, other │ │
1588
- │ └───────────────┴─────────────────┴───────────────┴──────────────────────────────────────────────┘ │
1589
- └────────────────────────────────────────────────────────────────────────────────────────────────────┘
1590
- """
1591
- from rich.table import Table
1592
-
1593
- table = Table(show_header=True, header_style="bold magenta")
1594
- table.add_column("Questions", style="dim")
1595
-
1596
- for question in self._questions:
1597
- table.add_row(question.rich_print())
1598
-
1599
- return table
1600
-
1601
- # endregion
1602
-
1603
- def codebook(self) -> dict[str, str]:
1604
- """Create a codebook for the survey, mapping question names to question text.
1605
-
1606
- >>> s = Survey.example()
1607
- >>> s.codebook()
1608
- {'q0': 'Do you like school?', 'q1': 'Why not?', 'q2': 'Why?'}
1609
- """
1610
- codebook = {}
1611
- for question in self._questions:
1612
- codebook[question.question_name] = question.question_text
1613
- return codebook
1614
-
1615
- # region: Export methods
1616
- def to_csv(self, filename: str = None):
1617
- """Export the survey to a CSV file.
1618
-
1619
- :param filename: The name of the file to save the CSV to.
1620
-
1621
- >>> s = Survey.example()
1622
- >>> s.to_csv() # doctest: +SKIP
1623
- index question_name question_text question_options question_type
1624
- 0 0 q0 Do you like school? [yes, no] multiple_choice
1625
- 1 1 q1 Why not? [killer bees in cafeteria, other] multiple_choice
1626
- 2 2 q2 Why? [**lack*** of killer bees in cafeteria, other] multiple_choice
1627
- """
1628
- raw_data = []
1629
- for index, question in enumerate(self._questions):
1630
- d = {"index": index}
1631
- question_dict = question.to_dict()
1632
- _ = question_dict.pop("edsl_version")
1633
- _ = question_dict.pop("edsl_class_name")
1634
- d.update(question_dict)
1635
- raw_data.append(d)
1636
- from pandas import DataFrame
1637
-
1638
- df = DataFrame(raw_data)
1639
- if filename:
1640
- df.to_csv(filename, index=False)
1641
- else:
1642
- return df
1643
-
1644
- def web(
1645
- self,
1646
- platform: Literal[
1647
- "google_forms", "lime_survey", "survey_monkey"
1648
- ] = "google_forms",
1649
- email=None,
1650
- ):
1651
- from edsl.coop import Coop
1652
-
1653
- c = Coop()
1654
-
1655
- res = c.web(self.to_dict(), platform, email)
1656
- return res
1657
-
1658
- # endregion
1659
-
1660
- @classmethod
1661
- def example(
1662
- cls,
1663
- params: bool = False,
1664
- randomize: bool = False,
1665
- include_instructions=False,
1666
- custom_instructions: Optional[str] = None,
1667
- ) -> Survey:
1668
- """Return an example survey.
1669
-
1670
- >>> s = Survey.example()
1671
- >>> [q.question_text for q in s.questions]
1672
- ['Do you like school?', 'Why not?', 'Why?']
1673
- """
1674
- from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1675
-
1676
- addition = "" if not randomize else str(uuid4())
1677
- q0 = QuestionMultipleChoice(
1678
- question_text=f"Do you like school?{addition}",
1679
- question_options=["yes", "no"],
1680
- question_name="q0",
1681
- )
1682
- q1 = QuestionMultipleChoice(
1683
- question_text="Why not?",
1684
- question_options=["killer bees in cafeteria", "other"],
1685
- question_name="q1",
1686
- )
1687
- q2 = QuestionMultipleChoice(
1688
- question_text="Why?",
1689
- question_options=["**lack*** of killer bees in cafeteria", "other"],
1690
- question_name="q2",
1691
- )
1692
- if params:
1693
- q3 = QuestionMultipleChoice(
1694
- question_text="To the question '{{ q0.question_text}}', you said '{{ q0.answer }}'. Do you still feel this way?",
1695
- question_options=["yes", "no"],
1696
- question_name="q3",
1697
- )
1698
- s = cls(questions=[q0, q1, q2, q3])
1699
- return s
1700
-
1701
- if include_instructions:
1702
- from edsl import Instruction
1703
-
1704
- custom_instructions = (
1705
- custom_instructions if custom_instructions else "Please pay attention!"
1706
- )
1707
-
1708
- i = Instruction(text=custom_instructions, name="attention")
1709
- s = cls(questions=[i, q0, q1, q2])
1710
- return s
1711
-
1712
- s = cls(questions=[q0, q1, q2])
1713
- s = s.add_rule(q0, "q0 == 'yes'", q2)
1714
- return s
1715
-
1716
- def get_job(self, model=None, agent=None, **kwargs):
1717
- if model is None:
1718
- from edsl import Model
1719
-
1720
- model = Model()
1721
-
1722
- from edsl.scenarios.Scenario import Scenario
1723
-
1724
- s = Scenario(kwargs)
1725
-
1726
- if not agent:
1727
- from edsl import Agent
1728
-
1729
- agent = Agent()
1730
-
1731
- return self.by(s).by(agent).by(model)
1732
-
1733
-
1734
- def main():
1735
- """Run the example survey."""
1736
-
1737
- def example_survey():
1738
- """Return an example survey."""
1739
- from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1740
- from edsl.surveys.Survey import Survey
1741
-
1742
- q0 = QuestionMultipleChoice(
1743
- question_text="Do you like school?",
1744
- question_options=["yes", "no"],
1745
- question_name="q0",
1746
- )
1747
- q1 = QuestionMultipleChoice(
1748
- question_text="Why not?",
1749
- question_options=["killer bees in cafeteria", "other"],
1750
- question_name="q1",
1751
- )
1752
- q2 = QuestionMultipleChoice(
1753
- question_text="Why?",
1754
- question_options=["**lack*** of killer bees in cafeteria", "other"],
1755
- question_name="q2",
1756
- )
1757
- s = Survey(questions=[q0, q1, q2])
1758
- s = s.add_rule(q0, "q0 == 'yes'", q2)
1759
- return s
1760
-
1761
- s = example_survey()
1762
- survey_dict = s.to_dict()
1763
- s2 = Survey.from_dict(survey_dict)
1764
- results = s2.run()
1765
- print(results)
1766
-
1767
-
1768
- if __name__ == "__main__":
1769
- import doctest
1770
-
1771
- # doctest.testmod(optionflags=doctest.ELLIPSIS | doctest.SKIP)
1772
- doctest.testmod(optionflags=doctest.ELLIPSIS)
1
+ """A Survey is collection of questions that can be administered to an Agent."""
2
+
3
+ from __future__ import annotations
4
+ import re
5
+ import tempfile
6
+ import requests
7
+
8
+ from typing import Any, Generator, Optional, Union, List, Literal, Callable
9
+ from uuid import uuid4
10
+ from edsl.Base import Base
11
+ from edsl.exceptions import SurveyCreationError, SurveyHasNoRulesError
12
+ from edsl.questions.QuestionBase import QuestionBase
13
+ from edsl.surveys.base import RulePriority, EndOfSurvey
14
+ from edsl.surveys.DAG import DAG
15
+ from edsl.surveys.descriptors import QuestionsDescriptor
16
+ from edsl.surveys.MemoryPlan import MemoryPlan
17
+ from edsl.surveys.Rule import Rule
18
+ from edsl.surveys.RuleCollection import RuleCollection
19
+ from edsl.surveys.SurveyExportMixin import SurveyExportMixin
20
+ from edsl.surveys.SurveyFlowVisualizationMixin import SurveyFlowVisualizationMixin
21
+ from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
22
+
23
+ from edsl.agents.Agent import Agent
24
+
25
+ from edsl.surveys.instructions.InstructionCollection import InstructionCollection
26
+ from edsl.surveys.instructions.Instruction import Instruction
27
+ from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
28
+
29
+
30
+ class ValidatedString(str):
31
+ def __new__(cls, content):
32
+ if "<>" in content:
33
+ raise ValueError(
34
+ "The expression contains '<>', which is not allowed. You probably mean '!='."
35
+ )
36
+ return super().__new__(cls, content)
37
+
38
+
39
+ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
40
+ """A collection of questions that supports skip logic."""
41
+
42
+ questions = QuestionsDescriptor()
43
+ """
44
+ A collection of questions that supports skip logic.
45
+
46
+ Initalization:
47
+ - `questions`: the questions in the survey (optional)
48
+ - `question_names`: the names of the questions (optional)
49
+ - `name`: the name of the survey (optional)
50
+
51
+ Methods:
52
+ -
53
+
54
+ Notes:
55
+ - The presumed order of the survey is the order in which questions are added.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ questions: Optional[
61
+ list[Union[QuestionBase, Instruction, ChangeInstruction]]
62
+ ] = None,
63
+ memory_plan: Optional[MemoryPlan] = None,
64
+ rule_collection: Optional[RuleCollection] = None,
65
+ question_groups: Optional[dict[str, tuple[int, int]]] = None,
66
+ name: Optional[str] = None,
67
+ ):
68
+ """Create a new survey.
69
+
70
+ :param questions: The questions in the survey.
71
+ :param memory_plan: The memory plan for the survey.
72
+ :param rule_collection: The rule collection for the survey.
73
+ :param question_groups: The groups of questions in the survey.
74
+ :param name: The name of the survey - DEPRECATED.
75
+
76
+
77
+ >>> from edsl import QuestionFreeText
78
+ >>> q1 = QuestionFreeText(question_text = "What is your name?", question_name = "name")
79
+ >>> q2 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
80
+ >>> q3 = QuestionFreeText(question_text = "Is a hot dog a sandwich", question_name = "food")
81
+ >>> s = Survey([q1, q2, q3], question_groups = {"demographics": (0, 1), "substantive":(3)})
82
+
83
+
84
+ """
85
+
86
+ self.raw_passed_questions = questions
87
+
88
+ (
89
+ true_questions,
90
+ instruction_names_to_instructions,
91
+ self.pseudo_indices,
92
+ ) = self._separate_questions_and_instructions(questions or [])
93
+
94
+ self.rule_collection = RuleCollection(
95
+ num_questions=len(true_questions) if true_questions else None
96
+ )
97
+ # the RuleCollection needs to be present while we add the questions; we might override this later
98
+ # if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
99
+
100
+ self.questions = true_questions
101
+ self.instruction_names_to_instructions = instruction_names_to_instructions
102
+
103
+ self.memory_plan = memory_plan or MemoryPlan(self)
104
+ if question_groups is not None:
105
+ self.question_groups = question_groups
106
+ else:
107
+ self.question_groups = {}
108
+
109
+ # if a rule collection is provided, use it instead
110
+ if rule_collection is not None:
111
+ self.rule_collection = rule_collection
112
+
113
+ if name is not None:
114
+ import warnings
115
+
116
+ warnings.warn("name parameter to a survey is deprecated.")
117
+
118
+ # region: Suvry instruction handling
119
+ @property
120
+ def relevant_instructions_dict(self) -> InstructionCollection:
121
+ """Return a dictionary with keys as question names and values as instructions that are relevant to the question.
122
+
123
+ >>> s = Survey.example(include_instructions=True)
124
+ >>> s.relevant_instructions_dict
125
+ {'q0': [Instruction(name="attention", text="Please pay attention!")], 'q1': [Instruction(name="attention", text="Please pay attention!")], 'q2': [Instruction(name="attention", text="Please pay attention!")]}
126
+
127
+ """
128
+ return InstructionCollection(
129
+ self.instruction_names_to_instructions, self.questions
130
+ )
131
+
132
+ @staticmethod
133
+ def _separate_questions_and_instructions(questions_and_instructions: list) -> tuple:
134
+ """
135
+ The 'pseudo_indices' attribute is a dictionary that maps question names to pseudo-indices
136
+ that are used to order questions and instructions in the survey.
137
+ Only questions get real indices; instructions get pseudo-indices.
138
+ However, the order of the pseudo-indices is the same as the order questions and instructions are added to the survey.
139
+
140
+ We don't have to know how many instructions there are to calculate the pseudo-indices because they are
141
+ calculated by the inverse of one minus the sum of 1/2^n for n in the number of instructions run so far.
142
+
143
+ >>> from edsl import Instruction
144
+ >>> i = Instruction(text = "Pay attention to the following questions.", name = "intro")
145
+ >>> i2 = Instruction(text = "How are you feeling today?", name = "followon_intro")
146
+ >>> from edsl import QuestionFreeText; q1 = QuestionFreeText.example()
147
+ >>> from edsl import QuestionMultipleChoice; q2 = QuestionMultipleChoice.example()
148
+ >>> s = Survey([q1, i, i2, q2])
149
+ >>> len(s.instruction_names_to_instructions)
150
+ 2
151
+ >>> s.pseudo_indices
152
+ {'how_are_you': 0, 'intro': 0.5, 'followon_intro': 0.75, 'how_feeling': 1}
153
+
154
+ >>> from edsl import ChangeInstruction
155
+ >>> q3 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
156
+ >>> i_change = ChangeInstruction(drop = ["intro"])
157
+ >>> s = Survey([q1, i, q2, i_change, q3])
158
+ >>> [i.name for i in s.relevant_instructions(q1)]
159
+ []
160
+ >>> [i.name for i in s.relevant_instructions(q2)]
161
+ ['intro']
162
+ >>> [i.name for i in s.relevant_instructions(q3)]
163
+ []
164
+
165
+ >>> i_change = ChangeInstruction(keep = ["poop"], drop = [])
166
+ >>> s = Survey([q1, i, q2, i_change])
167
+ Traceback (most recent call last):
168
+ ...
169
+ ValueError: ChangeInstruction change_instruction_0 references instruction poop which does not exist.
170
+ """
171
+ from edsl.surveys.instructions.Instruction import Instruction
172
+ from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
173
+
174
+ true_questions = []
175
+ instruction_names_to_instructions = {}
176
+
177
+ num_change_instructions = 0
178
+ pseudo_indices = {}
179
+ instructions_run_length = 0
180
+ for entry in questions_and_instructions:
181
+ if isinstance(entry, Instruction) or isinstance(entry, ChangeInstruction):
182
+ if isinstance(entry, ChangeInstruction):
183
+ entry.add_name(num_change_instructions)
184
+ num_change_instructions += 1
185
+ for prior_instruction in entry.keep + entry.drop:
186
+ if prior_instruction not in instruction_names_to_instructions:
187
+ raise ValueError(
188
+ f"ChangeInstruction {entry.name} references instruction {prior_instruction} which does not exist."
189
+ )
190
+ instructions_run_length += 1
191
+ delta = 1 - 1.0 / (2.0**instructions_run_length)
192
+ pseudo_index = (len(true_questions) - 1) + delta
193
+ entry.pseudo_index = pseudo_index
194
+ instruction_names_to_instructions[entry.name] = entry
195
+ elif isinstance(entry, QuestionBase):
196
+ pseudo_index = len(true_questions)
197
+ instructions_run_length = 0
198
+ true_questions.append(entry)
199
+ else:
200
+ raise ValueError(
201
+ f"Entry {repr(entry)} is not a QuestionBase or an Instruction."
202
+ )
203
+
204
+ pseudo_indices[entry.name] = pseudo_index
205
+
206
+ return true_questions, instruction_names_to_instructions, pseudo_indices
207
+
208
+ def relevant_instructions(self, question) -> dict:
209
+ """This should be a dictionry with keys as question names and values as instructions that are relevant to the question.
210
+
211
+ :param question: The question to get the relevant instructions for.
212
+
213
+ # Did the instruction come before the question and was it not modified by a change instruction?
214
+
215
+ """
216
+ return self.relevant_instructions_dict[question]
217
+
218
+ @property
219
+ def max_pseudo_index(self) -> float:
220
+ """Return the maximum pseudo index in the survey.
221
+
222
+ Example:
223
+
224
+ >>> s = Survey.example()
225
+ >>> s.max_pseudo_index
226
+ 2
227
+ """
228
+ if len(self.pseudo_indices) == 0:
229
+ return -1
230
+ return max(self.pseudo_indices.values())
231
+
232
+ @property
233
+ def last_item_was_instruction(self) -> bool:
234
+ """Return whether the last item added to the survey was an instruction.
235
+ This is used to determine the pseudo-index of the next item added to the survey.
236
+
237
+ Example:
238
+
239
+ >>> s = Survey.example()
240
+ >>> s.last_item_was_instruction
241
+ False
242
+ >>> from edsl.surveys.instructions.Instruction import Instruction
243
+ >>> s = s.add_instruction(Instruction(text="Pay attention to the following questions.", name="intro"))
244
+ >>> s.last_item_was_instruction
245
+ True
246
+ """
247
+ return isinstance(self.max_pseudo_index, float)
248
+
249
+ def add_instruction(
250
+ self, instruction: Union["Instruction", "ChangeInstruction"]
251
+ ) -> Survey:
252
+ """
253
+ Add an instruction to the survey.
254
+
255
+ :param instruction: The instruction to add to the survey.
256
+
257
+ >>> from edsl import Instruction
258
+ >>> i = Instruction(text="Pay attention to the following questions.", name="intro")
259
+ >>> s = Survey().add_instruction(i)
260
+ >>> s.instruction_names_to_instructions
261
+ {'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
262
+ >>> s.pseudo_indices
263
+ {'intro': -0.5}
264
+ """
265
+ import math
266
+
267
+ if instruction.name in self.instruction_names_to_instructions:
268
+ raise SurveyCreationError(
269
+ f"""Instruction name '{instruction.name}' already exists in survey. Existing names are {self.instruction_names_to_instructions.keys()}."""
270
+ )
271
+ self.instruction_names_to_instructions[instruction.name] = instruction
272
+
273
+ # was the last thing added an instruction or a question?
274
+ if self.last_item_was_instruction:
275
+ pseudo_index = (
276
+ self.max_pseudo_index
277
+ + (math.ceil(self.max_pseudo_index) - self.max_pseudo_index) / 2
278
+ )
279
+ else:
280
+ pseudo_index = self.max_pseudo_index + 1.0 / 2.0
281
+ self.pseudo_indices[instruction.name] = pseudo_index
282
+
283
+ return self
284
+
285
+ # endregion
286
+
287
+ # region: Simulation methods
288
+
289
+ @classmethod
290
+ def random_survey(self):
291
+ """Create a random survey."""
292
+ from edsl.questions import QuestionMultipleChoice, QuestionFreeText
293
+ from random import choice
294
+
295
+ num_questions = 10
296
+ questions = []
297
+ for i in range(num_questions):
298
+ if choice([True, False]):
299
+ q = QuestionMultipleChoice(
300
+ question_text="nothing",
301
+ question_name="q_" + str(i),
302
+ question_options=list(range(3)),
303
+ )
304
+ questions.append(q)
305
+ else:
306
+ questions.append(
307
+ QuestionFreeText(
308
+ question_text="nothing", question_name="q_" + str(i)
309
+ )
310
+ )
311
+ s = Survey(questions)
312
+ start_index = choice(range(num_questions - 1))
313
+ end_index = choice(range(start_index + 1, 10))
314
+ s = s.add_rule(f"q_{start_index}", "True", f"q_{end_index}")
315
+ question_to_delete = choice(range(num_questions))
316
+ s.delete_question(f"q_{question_to_delete}")
317
+ return s
318
+
319
+ def simulate(self) -> dict:
320
+ """Simulate the survey and return the answers."""
321
+ i = self.gen_path_through_survey()
322
+ q = next(i)
323
+ num_passes = 0
324
+ while True:
325
+ num_passes += 1
326
+ try:
327
+ answer = q._simulate_answer()
328
+ q = i.send({q.question_name: answer["answer"]})
329
+ except StopIteration:
330
+ break
331
+
332
+ if num_passes > 100:
333
+ print("Too many passes.")
334
+ raise Exception("Too many passes.")
335
+ return self.answers
336
+
337
+ def create_agent(self) -> "Agent":
338
+ """Create an agent from the simulated answers."""
339
+ answers_dict = self.simulate()
340
+
341
+ def construct_answer_dict_function(traits: dict) -> Callable:
342
+ def func(self, question: "QuestionBase", scenario=None):
343
+ return traits.get(question.question_name, None)
344
+
345
+ return func
346
+
347
+ return Agent(traits=answers_dict).add_direct_question_answering_method(
348
+ construct_answer_dict_function(answers_dict)
349
+ )
350
+
351
+ def simulate_results(self) -> "Results":
352
+ """Simulate the survey and return the results."""
353
+ a = self.create_agent()
354
+ return self.by([a]).run()
355
+
356
+ # endregion
357
+
358
+ # region: Access methods
359
+ def _get_question_index(
360
+ self, q: Union[QuestionBase, str, EndOfSurvey.__class__]
361
+ ) -> Union[int, EndOfSurvey.__class__]:
362
+ """Return the index of the question or EndOfSurvey object.
363
+
364
+ :param q: The question or question name to get the index of.
365
+
366
+ It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
367
+
368
+ >>> s = Survey.example()
369
+ >>> s._get_question_index("q0")
370
+ 0
371
+
372
+ This doesnt' work with questions that don't exist:
373
+
374
+ >>> s._get_question_index("poop")
375
+ Traceback (most recent call last):
376
+ ...
377
+ ValueError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
378
+ """
379
+ if q == EndOfSurvey:
380
+ return EndOfSurvey
381
+ else:
382
+ question_name = q if isinstance(q, str) else q.question_name
383
+ if question_name not in self.question_name_to_index:
384
+ raise ValueError(
385
+ f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
386
+ )
387
+ return self.question_name_to_index[question_name]
388
+
389
+ def get(self, question_name: str) -> QuestionBase:
390
+ """
391
+ Return the question object given the question name.
392
+
393
+ :param question_name: The name of the question to get.
394
+
395
+ >>> s = Survey.example()
396
+ >>> s.get_question("q0")
397
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
398
+ """
399
+ if question_name not in self.question_name_to_index:
400
+ raise KeyError(f"Question name {question_name} not found in survey.")
401
+ index = self.question_name_to_index[question_name]
402
+ return self._questions[index]
403
+
404
+ def get_question(self, question_name: str) -> QuestionBase:
405
+ """Return the question object given the question name."""
406
+ # import warnings
407
+ # warnings.warn("survey.get_question is deprecated. Use subscript operator instead.")
408
+ return self.get(question_name)
409
+
410
+ def question_names_to_questions(self) -> dict:
411
+ """Return a dictionary mapping question names to question attributes."""
412
+ return {q.question_name: q for q in self.questions}
413
+
414
+ @property
415
+ def question_names(self) -> list[str]:
416
+ """Return a list of question names in the survey.
417
+
418
+ Example:
419
+
420
+ >>> s = Survey.example()
421
+ >>> s.question_names
422
+ ['q0', 'q1', 'q2']
423
+ """
424
+ # return list(self.question_name_to_index.keys())
425
+ return [q.question_name for q in self.questions]
426
+
427
+ @property
428
+ def question_name_to_index(self) -> dict[str, int]:
429
+ """Return a dictionary mapping question names to question indices.
430
+
431
+ Example:
432
+
433
+ >>> s = Survey.example()
434
+ >>> s.question_name_to_index
435
+ {'q0': 0, 'q1': 1, 'q2': 2}
436
+ """
437
+ return {q.question_name: i for i, q in enumerate(self.questions)}
438
+
439
+ # endregion
440
+
441
+ # region: serialization methods
442
+ def __hash__(self) -> int:
443
+ """Return a hash of the question."""
444
+ from edsl.utilities.utilities import dict_hash
445
+
446
+ return dict_hash(self._to_dict())
447
+
448
+ def _to_dict(self) -> dict[str, Any]:
449
+ """Serialize the Survey object to a dictionary.
450
+
451
+ >>> s = Survey.example()
452
+ >>> s._to_dict().keys()
453
+ dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
454
+ """
455
+ return {
456
+ "questions": [
457
+ q._to_dict() for q in self.recombined_questions_and_instructions()
458
+ ],
459
+ "memory_plan": self.memory_plan.to_dict(),
460
+ "rule_collection": self.rule_collection.to_dict(),
461
+ "question_groups": self.question_groups,
462
+ }
463
+
464
+ @add_edsl_version
465
+ def to_dict(self) -> dict[str, Any]:
466
+ """Serialize the Survey object to a dictionary.
467
+
468
+ >>> s = Survey.example()
469
+ >>> s.to_dict().keys()
470
+ dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups', 'edsl_version', 'edsl_class_name'])
471
+
472
+ """
473
+ return self._to_dict()
474
+
475
+ @classmethod
476
+ @remove_edsl_version
477
+ def from_dict(cls, data: dict) -> Survey:
478
+ """Deserialize the dictionary back to a Survey object.
479
+
480
+ :param data: The dictionary to deserialize.
481
+
482
+ >>> d = Survey.example().to_dict()
483
+ >>> s = Survey.from_dict(d)
484
+ >>> s == Survey.example()
485
+ True
486
+
487
+ >>> s = Survey.example(include_instructions = True)
488
+ >>> d = s.to_dict()
489
+ >>> news = Survey.from_dict(d)
490
+ >>> news == s
491
+ True
492
+
493
+ """
494
+
495
+ def get_class(pass_dict):
496
+ if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
497
+ return QuestionBase
498
+ elif class_name == "Instruction":
499
+ from edsl.surveys.instructions.Instruction import Instruction
500
+
501
+ return Instruction
502
+ elif class_name == "ChangeInstruction":
503
+ from edsl.surveys.instructions.ChangeInstruction import (
504
+ ChangeInstruction,
505
+ )
506
+
507
+ return ChangeInstruction
508
+ else:
509
+ # some data might not have the edsl_class_name
510
+ return QuestionBase
511
+ # raise ValueError(f"Class {pass_dict['edsl_class_name']} not found")
512
+
513
+ questions = [
514
+ get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
515
+ ]
516
+ memory_plan = MemoryPlan.from_dict(data["memory_plan"])
517
+ survey = cls(
518
+ questions=questions,
519
+ memory_plan=memory_plan,
520
+ rule_collection=RuleCollection.from_dict(data["rule_collection"]),
521
+ question_groups=data["question_groups"],
522
+ )
523
+ return survey
524
+
525
+ # endregion
526
+
527
+ # region: Survey template parameters
528
+ @property
529
+ def scenario_attributes(self) -> list[str]:
530
+ """Return a list of attributes that admissible Scenarios should have.
531
+
532
+ Here we have a survey with a question that uses a jinja2 style {{ }} template:
533
+
534
+ >>> from edsl import QuestionFreeText
535
+ >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
536
+ >>> s.scenario_attributes
537
+ ['greeting']
538
+
539
+ >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
540
+ >>> s.scenario_attributes
541
+ ['greeting', 'attribute']
542
+
543
+
544
+ """
545
+ temp = []
546
+ for question in self.questions:
547
+ question_text = question.question_text
548
+ # extract the contents of all {{ }} in the question text using regex
549
+ matches = re.findall(r"\{\{(.+?)\}\}", question_text)
550
+ # remove whitespace
551
+ matches = [match.strip() for match in matches]
552
+ # add them to the temp list
553
+ temp.extend(matches)
554
+ return temp
555
+
556
+ @property
557
+ def parameters(self):
558
+ """Return a set of parameters in the survey.
559
+
560
+ >>> s = Survey.example()
561
+ >>> s.parameters
562
+ set()
563
+ """
564
+ return set.union(*[q.parameters for q in self.questions])
565
+
566
+ @property
567
+ def parameters_by_question(self):
568
+ """Return a dictionary of parameters by question in the survey.
569
+ >>> from edsl import QuestionFreeText
570
+ >>> q = QuestionFreeText(question_name = "example", question_text = "What is the capital of {{ country}}?")
571
+ >>> s = Survey([q])
572
+ >>> s.parameters_by_question
573
+ {'example': {'country'}}
574
+ """
575
+ return {q.question_name: q.parameters for q in self.questions}
576
+
577
+ # endregion
578
+
579
+ # region: Survey construction
580
+
581
+ # region: Adding questions and combining surveys
582
+ def __add__(self, other: Survey) -> Survey:
583
+ """Combine two surveys.
584
+
585
+ :param other: The other survey to combine with this one.
586
+ >>> s1 = Survey.example()
587
+ >>> from edsl import QuestionFreeText
588
+ >>> s2 = Survey([QuestionFreeText(question_text="What is your name?", question_name="yo")])
589
+ >>> s3 = s1 + s2
590
+ Traceback (most recent call last):
591
+ ...
592
+ 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.")
593
+ >>> s3 = s1.clear_non_default_rules() + s2
594
+ >>> len(s3.questions)
595
+ 4
596
+
597
+ """
598
+ if (
599
+ len(self.rule_collection.non_default_rules) > 0
600
+ or len(other.rule_collection.non_default_rules) > 0
601
+ ):
602
+ raise ValueError(
603
+ "Cannot combine two surveys with non-default rules.",
604
+ "Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.",
605
+ )
606
+
607
+ return Survey(questions=self.questions + other.questions)
608
+
609
+ def move_question(self, identifier: Union[str, int], new_index: int):
610
+ if isinstance(identifier, str):
611
+ if identifier not in self.question_names:
612
+ raise ValueError(
613
+ f"Question name '{identifier}' does not exist in the survey."
614
+ )
615
+ index = self.question_name_to_index[identifier]
616
+ elif isinstance(identifier, int):
617
+ if identifier < 0 or identifier >= len(self.questions):
618
+ raise ValueError(f"Index {identifier} is out of range.")
619
+ index = identifier
620
+ else:
621
+ raise TypeError(
622
+ "Identifier must be either a string (question name) or an integer (question index)."
623
+ )
624
+
625
+ moving_question = self._questions[index]
626
+
627
+ new_survey = self.delete_question(index)
628
+ new_survey.add_question(moving_question, new_index)
629
+ return new_survey
630
+
631
+ def delete_question(self, identifier: Union[str, int]) -> Survey:
632
+ """
633
+ Delete a question from the survey.
634
+
635
+ :param identifier: The name or index of the question to delete.
636
+ :return: The updated Survey object.
637
+
638
+ >>> from edsl import QuestionMultipleChoice, Survey
639
+ >>> q1 = QuestionMultipleChoice(question_text="Q1", question_options=["A", "B"], question_name="q1")
640
+ >>> q2 = QuestionMultipleChoice(question_text="Q2", question_options=["C", "D"], question_name="q2")
641
+ >>> s = Survey().add_question(q1).add_question(q2)
642
+ >>> _ = s.delete_question("q1")
643
+ >>> len(s.questions)
644
+ 1
645
+ >>> _ = s.delete_question(0)
646
+ >>> len(s.questions)
647
+ 0
648
+ """
649
+ if isinstance(identifier, str):
650
+ if identifier not in self.question_names:
651
+ raise ValueError(
652
+ f"Question name '{identifier}' does not exist in the survey."
653
+ )
654
+ index = self.question_name_to_index[identifier]
655
+ elif isinstance(identifier, int):
656
+ if identifier < 0 or identifier >= len(self.questions):
657
+ raise ValueError(f"Index {identifier} is out of range.")
658
+ index = identifier
659
+ else:
660
+ raise TypeError(
661
+ "Identifier must be either a string (question name) or an integer (question index)."
662
+ )
663
+
664
+ # Remove the question
665
+ deleted_question = self._questions.pop(index)
666
+ del self.pseudo_indices[deleted_question.question_name]
667
+ # del self.question_name_to_index[deleted_question.question_name]
668
+
669
+ # Update indices
670
+ for question_name, old_index in self.pseudo_indices.items():
671
+ if old_index > index:
672
+ self.pseudo_indices[question_name] = old_index - 1
673
+
674
+ # for question_name, old_index in self.question_name_to_index.items():
675
+ # if old_index > index:
676
+ # self.question_name_to_index[question_name] = old_index - 1
677
+
678
+ # Update rules
679
+ new_rule_collection = RuleCollection()
680
+ for rule in self.rule_collection:
681
+ if rule.current_q == index:
682
+ continue # Remove rules associated with the deleted question
683
+ if rule.current_q > index:
684
+ rule.current_q -= 1
685
+ if rule.next_q > index:
686
+ rule.next_q -= 1
687
+
688
+ if rule.next_q == index:
689
+ if index == len(self.questions):
690
+ rule.next_q = EndOfSurvey
691
+ else:
692
+ rule.next_q = index
693
+ # rule.next_q = min(index, len(self.questions) - 1)
694
+ # continue
695
+
696
+ # if rule.next_q == index:
697
+ # rule.next_q = min(
698
+ # rule.next_q, len(self.questions) - 1
699
+ # ) # Adjust to last question if necessary
700
+
701
+ new_rule_collection.add_rule(rule)
702
+ self.rule_collection = new_rule_collection
703
+
704
+ # Update memory plan if it exists
705
+ if hasattr(self, "memory_plan"):
706
+ self.memory_plan.remove_question(deleted_question.question_name)
707
+
708
+ return self
709
+
710
+ def add_question(
711
+ self, question: QuestionBase, index: Optional[int] = None
712
+ ) -> Survey:
713
+ """
714
+ Add a question to survey.
715
+
716
+ :param question: The question to add to the survey.
717
+ :param question_name: The name of the question. If not provided, the question name is used.
718
+
719
+ The question is appended at the end of the self.questions list
720
+ A default rule is created that the next index is the next question.
721
+
722
+ >>> from edsl import QuestionMultipleChoice
723
+ >>> q = QuestionMultipleChoice(question_text = "Do you like school?", question_options=["yes", "no"], question_name="q0")
724
+ >>> s = Survey().add_question(q)
725
+
726
+ >>> s = Survey().add_question(q).add_question(q)
727
+ Traceback (most recent call last):
728
+ ...
729
+ edsl.exceptions.surveys.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
730
+ """
731
+ if question.question_name in self.question_names:
732
+ raise SurveyCreationError(
733
+ f"""Question name '{question.question_name}' already exists in survey. Existing names are {self.question_names}."""
734
+ )
735
+ if index is None:
736
+ index = len(self.questions)
737
+
738
+ if index > len(self.questions):
739
+ raise ValueError(
740
+ f"Index {index} is greater than the number of questions in the survey."
741
+ )
742
+ if index < 0:
743
+ raise ValueError(f"Index {index} is less than 0.")
744
+
745
+ interior_insertion = index != len(self.questions)
746
+
747
+ # index = len(self.questions)
748
+ # TODO: This is a bit ugly because the user
749
+ # doesn't "know" about _questions - it's generated by the
750
+ # descriptor.
751
+ self._questions.insert(index, question)
752
+
753
+ if interior_insertion:
754
+ for question_name, old_index in self.pseudo_indices.items():
755
+ if old_index >= index:
756
+ self.pseudo_indices[question_name] = old_index + 1
757
+
758
+ self.pseudo_indices[question.question_name] = index
759
+
760
+ ## Re-do question_name to index - this is done automatically
761
+ # for question_name, old_index in self.question_name_to_index.items():
762
+ # if old_index >= index:
763
+ # self.question_name_to_index[question_name] = old_index + 1
764
+
765
+ ## Need to re-do the rule collection and the indices of the questions
766
+
767
+ ## If a rule is before the insertion index and next_q is also before the insertion index, no change needed.
768
+ ## If the rule is before the insertion index but next_q is after the insertion index, increment the next_q by 1
769
+ ## If the rule is after the insertion index, increment the current_q by 1 and the next_q by 1
770
+
771
+ # using index + 1 presumes there is a next question
772
+ if interior_insertion:
773
+ for rule in self.rule_collection:
774
+ if rule.current_q >= index:
775
+ rule.current_q += 1
776
+ if rule.next_q >= index:
777
+ rule.next_q += 1
778
+
779
+ # add a new rule
780
+ self.rule_collection.add_rule(
781
+ Rule(
782
+ current_q=index,
783
+ expression="True",
784
+ next_q=index + 1,
785
+ question_name_to_index=self.question_name_to_index,
786
+ priority=RulePriority.DEFAULT.value,
787
+ )
788
+ )
789
+
790
+ # a question might be added before the memory plan is created
791
+ # it's ok because the memory plan will be updated when it is created
792
+ if hasattr(self, "memory_plan"):
793
+ self.memory_plan.add_question(question)
794
+
795
+ return self
796
+
797
+ def recombined_questions_and_instructions(
798
+ self,
799
+ ) -> list[Union[QuestionBase, "Instruction"]]:
800
+ """Return a list of questions and instructions sorted by pseudo index."""
801
+ questions_and_instructions = self._questions + list(
802
+ self.instruction_names_to_instructions.values()
803
+ )
804
+ return sorted(
805
+ questions_and_instructions, key=lambda x: self.pseudo_indices[x.name]
806
+ )
807
+
808
+ # endregion
809
+
810
+ # region: Memory plan methods
811
+ def set_full_memory_mode(self) -> Survey:
812
+ """Add instructions to a survey that the agent should remember all of the answers to the questions in the survey.
813
+
814
+ >>> s = Survey.example().set_full_memory_mode()
815
+
816
+ """
817
+ self._set_memory_plan(lambda i: self.question_names[:i])
818
+ return self
819
+
820
+ def set_lagged_memory(self, lags: int) -> Survey:
821
+ """Add instructions to a survey that the agent should remember the answers to the questions in the survey.
822
+
823
+ The agent should remember the answers to the questions in the survey from the previous lags.
824
+ """
825
+ self._set_memory_plan(lambda i: self.question_names[max(0, i - lags) : i])
826
+ return self
827
+
828
+ def _set_memory_plan(self, prior_questions_func: Callable):
829
+ """Set memory plan based on a provided function determining prior questions.
830
+
831
+ :param prior_questions_func: A function that takes the index of the current question and returns a list of prior questions to remember.
832
+
833
+ >>> s = Survey.example()
834
+ >>> s._set_memory_plan(lambda i: s.question_names[:i])
835
+
836
+ """
837
+ for i, question_name in enumerate(self.question_names):
838
+ self.memory_plan.add_memory_collection(
839
+ focal_question=question_name,
840
+ prior_questions=prior_questions_func(i),
841
+ )
842
+
843
+ def add_targeted_memory(
844
+ self,
845
+ focal_question: Union[QuestionBase, str],
846
+ prior_question: Union[QuestionBase, str],
847
+ ) -> Survey:
848
+ """Add instructions to a survey than when answering focal_question.
849
+
850
+ :param focal_question: The question that the agent is answering.
851
+ :param prior_question: The question that the agent should remember when answering the focal question.
852
+
853
+ Here we add instructions to a survey than when answering q2 they should remember q1:
854
+
855
+ >>> s = Survey.example().add_targeted_memory("q2", "q0")
856
+ >>> s.memory_plan
857
+ {'q2': Memory(prior_questions=['q0'])}
858
+
859
+ The agent should also remember the answers to prior_questions listed in prior_questions.
860
+ """
861
+ focal_question_name = self.question_names[
862
+ self._get_question_index(focal_question)
863
+ ]
864
+ prior_question_name = self.question_names[
865
+ self._get_question_index(prior_question)
866
+ ]
867
+
868
+ self.memory_plan.add_single_memory(
869
+ focal_question=focal_question_name,
870
+ prior_question=prior_question_name,
871
+ )
872
+
873
+ return self
874
+
875
+ def add_memory_collection(
876
+ self,
877
+ focal_question: Union[QuestionBase, str],
878
+ prior_questions: List[Union[QuestionBase, str]],
879
+ ) -> Survey:
880
+ """Add prior questions and responses so the agent has them when answering.
881
+
882
+ 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.
883
+
884
+ :param focal_question: The question that the agent is answering.
885
+ :param prior_questions: The questions that the agent should remember when answering the focal question.
886
+
887
+ Here we have it so that when answering q2, the agent should remember answers to q0 and q1:
888
+
889
+ >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
890
+ >>> s.memory_plan
891
+ {'q2': Memory(prior_questions=['q0', 'q1'])}
892
+ """
893
+ focal_question_name = self.question_names[
894
+ self._get_question_index(focal_question)
895
+ ]
896
+
897
+ prior_question_names = [
898
+ self.question_names[self._get_question_index(prior_question)]
899
+ for prior_question in prior_questions
900
+ ]
901
+
902
+ self.memory_plan.add_memory_collection(
903
+ focal_question=focal_question_name, prior_questions=prior_question_names
904
+ )
905
+ return self
906
+
907
+ # endregion
908
+ # endregion
909
+ # endregion
910
+
911
+ # region: Question groups
912
+ def add_question_group(
913
+ self,
914
+ start_question: Union[QuestionBase, str],
915
+ end_question: Union[QuestionBase, str],
916
+ group_name: str,
917
+ ) -> Survey:
918
+ """Add a group of questions to the survey.
919
+
920
+ :param start_question: The first question in the group.
921
+ :param end_question: The last question in the group.
922
+ :param group_name: The name of the group.
923
+
924
+ Example:
925
+
926
+ >>> s = Survey.example().add_question_group("q0", "q1", "group1")
927
+ >>> s.question_groups
928
+ {'group1': (0, 1)}
929
+
930
+ The name of the group must be a valid identifier:
931
+
932
+ >>> s = Survey.example().add_question_group("q0", "q2", "1group1")
933
+ Traceback (most recent call last):
934
+ ...
935
+ ValueError: Group name 1group1 is not a valid identifier.
936
+
937
+ The name of the group cannot be the same as an existing question name:
938
+
939
+ >>> s = Survey.example().add_question_group("q0", "q1", "q0")
940
+ Traceback (most recent call last):
941
+ ...
942
+ ValueError: Group name q0 already exists as a question name in the survey.
943
+
944
+ The start index must be less than the end index:
945
+
946
+ >>> s = Survey.example().add_question_group("q1", "q0", "group1")
947
+ Traceback (most recent call last):
948
+ ...
949
+ ValueError: Start index 1 is greater than end index 0.
950
+ """
951
+
952
+ if not group_name.isidentifier():
953
+ raise ValueError(f"Group name {group_name} is not a valid identifier.")
954
+
955
+ if group_name in self.question_groups:
956
+ raise ValueError(f"Group name {group_name} already exists in the survey.")
957
+
958
+ if group_name in self.question_name_to_index:
959
+ raise ValueError(
960
+ f"Group name {group_name} already exists as a question name in the survey."
961
+ )
962
+
963
+ start_index = self._get_question_index(start_question)
964
+ end_index = self._get_question_index(end_question)
965
+
966
+ if start_index > end_index:
967
+ raise ValueError(
968
+ f"Start index {start_index} is greater than end index {end_index}."
969
+ )
970
+
971
+ for existing_group_name, (
972
+ existing_start_index,
973
+ existing_end_index,
974
+ ) in self.question_groups.items():
975
+ if start_index < existing_start_index and end_index > existing_end_index:
976
+ raise ValueError(
977
+ f"Group {group_name} contains the questions in the new group."
978
+ )
979
+ if start_index > existing_start_index and end_index < existing_end_index:
980
+ raise ValueError(f"Group {group_name} is contained in the new group.")
981
+ if start_index < existing_start_index and end_index > existing_start_index:
982
+ raise ValueError(f"Group {group_name} overlaps with the new group.")
983
+ if start_index < existing_end_index and end_index > existing_end_index:
984
+ raise ValueError(f"Group {group_name} overlaps with the new group.")
985
+
986
+ self.question_groups[group_name] = (start_index, end_index)
987
+ return self
988
+
989
+ # endregion
990
+
991
+ # region: Survey rules
992
+ def show_rules(self) -> None:
993
+ """Print out the rules in the survey.
994
+
995
+ >>> s = Survey.example()
996
+ >>> s.show_rules()
997
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
998
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
999
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1000
+ │ 0 │ True │ 1 │ -1 │ False │
1001
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1002
+ │ 1 │ True │ 2 │ -1 │ False │
1003
+ │ 2 │ True │ 3 │ -1 │ False │
1004
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
1005
+ """
1006
+ self.rule_collection.show_rules()
1007
+
1008
+ def add_stop_rule(
1009
+ self, question: Union[QuestionBase, str], expression: str
1010
+ ) -> Survey:
1011
+ """Add a rule that stops the survey.
1012
+
1013
+ :param question: The question to add the stop rule to.
1014
+ :param expression: The expression to evaluate.
1015
+
1016
+ If this rule is true, the survey ends.
1017
+ The rule is evaluated *after* the question is answered. If the rule is true, the survey ends.
1018
+
1019
+ Here, answering "yes" to q0 ends the survey:
1020
+
1021
+ >>> s = Survey.example().add_stop_rule("q0", "q0 == 'yes'")
1022
+ >>> s.next_question("q0", {"q0": "yes"})
1023
+ EndOfSurvey
1024
+
1025
+ By comparison, answering "no" to q0 does not end the survey:
1026
+
1027
+ >>> s.next_question("q0", {"q0": "no"}).question_name
1028
+ 'q1'
1029
+
1030
+ >>> s.add_stop_rule("q0", "q1 <> 'yes'")
1031
+ Traceback (most recent call last):
1032
+ ...
1033
+ ValueError: The expression contains '<>', which is not allowed. You probably mean '!='.
1034
+ """
1035
+ expression = ValidatedString(expression)
1036
+ self.add_rule(question, expression, EndOfSurvey)
1037
+ return self
1038
+
1039
+ def clear_non_default_rules(self) -> Survey:
1040
+ """Remove all non-default rules from the survey.
1041
+
1042
+ >>> Survey.example().show_rules()
1043
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1044
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1045
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1046
+ │ 0 │ True │ 1 │ -1 │ False │
1047
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1048
+ │ 1 │ True │ 2 │ -1 │ False │
1049
+ │ 2 │ True │ 3 │ -1 │ False │
1050
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
1051
+ >>> Survey.example().clear_non_default_rules().show_rules()
1052
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1053
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1054
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1055
+ │ 0 │ True │ 1 │ -1 │ False │
1056
+ │ 1 │ True │ 2 │ -1 │ False │
1057
+ │ 2 │ True │ 3 │ -1 │ False │
1058
+ └───────────┴────────────┴────────┴──────────┴─────────────┘
1059
+ """
1060
+ s = Survey()
1061
+ for question in self.questions:
1062
+ s.add_question(question)
1063
+ return s
1064
+
1065
+ def add_skip_rule(
1066
+ self, question: Union[QuestionBase, str], expression: str
1067
+ ) -> Survey:
1068
+ """
1069
+ Adds a per-question skip rule to the survey.
1070
+
1071
+ :param question: The question to add the skip rule to.
1072
+ :param expression: The expression to evaluate.
1073
+
1074
+ This adds a rule that skips 'q0' always, before the question is answered:
1075
+
1076
+ >>> from edsl import QuestionFreeText
1077
+ >>> q0 = QuestionFreeText.example()
1078
+ >>> q0.question_name = "q0"
1079
+ >>> q1 = QuestionFreeText.example()
1080
+ >>> q1.question_name = "q1"
1081
+ >>> s = Survey([q0, q1]).add_skip_rule("q0", "True")
1082
+ >>> s.next_question("q0", {}).question_name
1083
+ 'q1'
1084
+
1085
+ Note that this is different from a rule that jumps to some other question *after* the question is answered.
1086
+
1087
+ """
1088
+ question_index = self._get_question_index(question)
1089
+ self._add_rule(question, expression, question_index + 1, before_rule=True)
1090
+ return self
1091
+
1092
+ def _get_new_rule_priority(
1093
+ self, question_index: int, before_rule: bool = False
1094
+ ) -> int:
1095
+ """Return the priority for the new rule.
1096
+
1097
+ :param question_index: The index of the question to add the rule to.
1098
+ :param before_rule: Whether the rule is evaluated before the question is answered.
1099
+
1100
+ >>> s = Survey.example()
1101
+ >>> s._get_new_rule_priority(0)
1102
+ 1
1103
+ """
1104
+ current_priorities = [
1105
+ rule.priority
1106
+ for rule in self.rule_collection.applicable_rules(
1107
+ question_index, before_rule
1108
+ )
1109
+ ]
1110
+ if len(current_priorities) == 0:
1111
+ return RulePriority.DEFAULT.value + 1
1112
+
1113
+ max_priority = max(current_priorities)
1114
+ # newer rules take priority over older rules
1115
+ new_priority = (
1116
+ RulePriority.DEFAULT.value
1117
+ if len(current_priorities) == 0
1118
+ else max_priority + 1
1119
+ )
1120
+ return new_priority
1121
+
1122
+ def add_rule(
1123
+ self,
1124
+ question: Union[QuestionBase, str],
1125
+ expression: str,
1126
+ next_question: Union[QuestionBase, int],
1127
+ before_rule: bool = False,
1128
+ ) -> Survey:
1129
+ """
1130
+ Add a rule to a Question of the Survey.
1131
+
1132
+ :param question: The question to add the rule to.
1133
+ :param expression: The expression to evaluate.
1134
+ :param next_question: The next question to go to if the rule is true.
1135
+ :param before_rule: Whether the rule is evaluated before the question is answered.
1136
+
1137
+ This adds a rule that if the answer to q0 is 'yes', the next question is q2 (as opposed to q1)
1138
+
1139
+ >>> s = Survey.example().add_rule("q0", "{{ q0 }} == 'yes'", "q2")
1140
+ >>> s.next_question("q0", {"q0": "yes"}).question_name
1141
+ 'q2'
1142
+
1143
+ """
1144
+ return self._add_rule(
1145
+ question, expression, next_question, before_rule=before_rule
1146
+ )
1147
+
1148
+ def _add_rule(
1149
+ self,
1150
+ question: Union[QuestionBase, str],
1151
+ expression: str,
1152
+ next_question: Union[QuestionBase, str, int],
1153
+ before_rule: bool = False,
1154
+ ) -> Survey:
1155
+ """
1156
+ Add a rule to a Question of the Survey with the appropriate priority.
1157
+
1158
+ :param question: The question to add the rule to.
1159
+ :param expression: The expression to evaluate.
1160
+ :param next_question: The next question to go to if the rule is true.
1161
+ :param before_rule: Whether the rule is evaluated before the question is answered.
1162
+
1163
+
1164
+ - The last rule added for the question will have the highest priority.
1165
+ - If there are no rules, the rule added gets priority -1.
1166
+ """
1167
+ question_index = self._get_question_index(question)
1168
+
1169
+ # Might not have the name of the next question yet
1170
+ if isinstance(next_question, int):
1171
+ next_question_index = next_question
1172
+ else:
1173
+ next_question_index = self._get_question_index(next_question)
1174
+
1175
+ new_priority = self._get_new_rule_priority(question_index, before_rule)
1176
+
1177
+ self.rule_collection.add_rule(
1178
+ Rule(
1179
+ current_q=question_index,
1180
+ expression=expression,
1181
+ next_q=next_question_index,
1182
+ question_name_to_index=self.question_name_to_index,
1183
+ priority=new_priority,
1184
+ before_rule=before_rule,
1185
+ )
1186
+ )
1187
+
1188
+ return self
1189
+
1190
+ # endregion
1191
+
1192
+ # region: Forward methods
1193
+ def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
1194
+ """Add Agents, Scenarios, and LanguageModels to a survey and returns a runnable Jobs object.
1195
+
1196
+ :param args: The Agents, Scenarios, and LanguageModels to add to the survey.
1197
+
1198
+ This takes the survey and adds an Agent and a Scenario via 'by' which converts to a Jobs object:
1199
+
1200
+ >>> s = Survey.example(); from edsl import Agent; from edsl import Scenario
1201
+ >>> s.by(Agent.example()).by(Scenario.example())
1202
+ Jobs(...)
1203
+ """
1204
+ from edsl.jobs.Jobs import Jobs
1205
+
1206
+ job = Jobs(survey=self)
1207
+ return job.by(*args)
1208
+
1209
+ def to_jobs(self):
1210
+ """Convert the survey to a Jobs object."""
1211
+ from edsl.jobs.Jobs import Jobs
1212
+
1213
+ return Jobs(survey=self)
1214
+
1215
+ def show_prompts(self):
1216
+ return self.to_jobs().show_prompts()
1217
+
1218
+ # endregion
1219
+
1220
+ # region: Running the survey
1221
+
1222
+ def __call__(self, model=None, agent=None, cache=None, **kwargs):
1223
+ """Run the survey with default model, taking the required survey as arguments.
1224
+
1225
+ >>> from edsl.questions import QuestionFunctional
1226
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1227
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
1228
+ >>> s = Survey([q])
1229
+ >>> s(period = "morning", cache = False).select("answer.q0").first()
1230
+ 'yes'
1231
+ >>> s(period = "evening", cache = False).select("answer.q0").first()
1232
+ 'no'
1233
+ """
1234
+ job = self.get_job(model, agent, **kwargs)
1235
+ return job.run(cache=cache)
1236
+
1237
+ async def run_async(self, model=None, agent=None, cache=None, **kwargs):
1238
+ """Run the survey with default model, taking the required survey as arguments.
1239
+
1240
+ >>> from edsl.questions import QuestionFunctional
1241
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1242
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
1243
+ >>> s = Survey([q])
1244
+ >>> s(period = "morning").select("answer.q0").first()
1245
+ 'yes'
1246
+ >>> s(period = "evening").select("answer.q0").first()
1247
+ 'no'
1248
+ """
1249
+ # TODO: temp fix by creating a cache
1250
+ if cache is None:
1251
+ from edsl.data import Cache
1252
+
1253
+ c = Cache()
1254
+ else:
1255
+ c = cache
1256
+ jobs: "Jobs" = self.get_job(model, agent, **kwargs)
1257
+ return await jobs.run_async(cache=c)
1258
+
1259
+ def run(self, *args, **kwargs) -> "Results":
1260
+ """Turn the survey into a Job and runs it.
1261
+
1262
+ >>> from edsl import QuestionFreeText
1263
+ >>> s = Survey([QuestionFreeText.example()])
1264
+ >>> from edsl.language_models import LanguageModel
1265
+ >>> m = LanguageModel.example(test_model = True, canned_response = "Great!")
1266
+ >>> results = s.by(m).run(cache = False)
1267
+ >>> results.select('answer.*')
1268
+ Dataset([{'answer.how_are_you': ['Great!']}])
1269
+ """
1270
+ from edsl.jobs.Jobs import Jobs
1271
+
1272
+ return Jobs(survey=self).run(*args, **kwargs)
1273
+
1274
+ # region: Survey flow
1275
+ def next_question(
1276
+ self, current_question: Union[str, QuestionBase], answers: dict
1277
+ ) -> Union[QuestionBase, EndOfSurvey.__class__]:
1278
+ """
1279
+ Return the next question in a survey.
1280
+
1281
+ :param current_question: The current question in the survey.
1282
+ :param answers: The answers for the survey so far
1283
+
1284
+ - If called with no arguments, it returns the first question in the survey.
1285
+ - If no answers are provided for a question with a rule, the next question is returned. If answers are provided, the next question is determined by the rules and the answers.
1286
+ - If the next question is the last question in the survey, an EndOfSurvey object is returned.
1287
+
1288
+ >>> s = Survey.example()
1289
+ >>> s.next_question("q0", {"q0": "yes"}).question_name
1290
+ 'q2'
1291
+ >>> s.next_question("q0", {"q0": "no"}).question_name
1292
+ 'q1'
1293
+
1294
+ """
1295
+ if isinstance(current_question, str):
1296
+ current_question = self.get_question(current_question)
1297
+
1298
+ question_index = self.question_name_to_index[current_question.question_name]
1299
+ next_question_object = self.rule_collection.next_question(
1300
+ question_index, answers
1301
+ )
1302
+
1303
+ if next_question_object.num_rules_found == 0:
1304
+ raise SurveyHasNoRulesError
1305
+
1306
+ if next_question_object.next_q == EndOfSurvey:
1307
+ return EndOfSurvey
1308
+ else:
1309
+ if next_question_object.next_q >= len(self.questions):
1310
+ return EndOfSurvey
1311
+ else:
1312
+ return self.questions[next_question_object.next_q]
1313
+
1314
+ def gen_path_through_survey(self) -> Generator[QuestionBase, dict, None]:
1315
+ """
1316
+ Generate a coroutine that can be used to conduct an Interview.
1317
+
1318
+ The coroutine is a generator that yields a question and receives answers.
1319
+ It starts with the first question in the survey.
1320
+ The coroutine ends when an EndOfSurvey object is returned.
1321
+
1322
+ For the example survey, this is the rule table:
1323
+
1324
+ >>> s = Survey.example()
1325
+ >>> s.show_rules()
1326
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1327
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1328
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1329
+ │ 0 │ True │ 1 │ -1 │ False │
1330
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1331
+ │ 1 │ True │ 2 │ -1 │ False │
1332
+ │ 2 │ True │ 3 │ -1 │ False │
1333
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
1334
+
1335
+ Note that q0 has a rule that if the answer is 'yes', the next question is q2. If the answer is 'no', the next question is q1.
1336
+
1337
+ Here is the path through the survey if the answer to q0 is 'yes':
1338
+
1339
+ >>> i = s.gen_path_through_survey()
1340
+ >>> next(i)
1341
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1342
+ >>> i.send({"q0": "yes"})
1343
+ Question('multiple_choice', question_name = \"""q2\""", question_text = \"""Why?\""", question_options = ['**lack*** of killer bees in cafeteria', 'other'])
1344
+
1345
+ And here is the path through the survey if the answer to q0 is 'no':
1346
+
1347
+ >>> i2 = s.gen_path_through_survey()
1348
+ >>> next(i2)
1349
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1350
+ >>> i2.send({"q0": "no"})
1351
+ Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
1352
+
1353
+
1354
+ """
1355
+ self.answers = {}
1356
+ question = self._questions[0]
1357
+ # should the first question be skipped?
1358
+ if self.rule_collection.skip_question_before_running(0, self.answers):
1359
+ question = self.next_question(question, self.answers)
1360
+
1361
+ while not question == EndOfSurvey:
1362
+ # breakpoint()
1363
+ answer = yield question
1364
+ self.answers.update(answer)
1365
+ # print(f"Answers: {self.answers}")
1366
+ ## TODO: This should also include survey and agent attributes
1367
+ question = self.next_question(question, self.answers)
1368
+
1369
+ # endregion
1370
+
1371
+ # regions: DAG construction
1372
+ def textify(self, index_dag: DAG) -> DAG:
1373
+ """Convert the DAG of question indices to a DAG of question names.
1374
+
1375
+ :param index_dag: The DAG of question indices.
1376
+
1377
+ Example:
1378
+
1379
+ >>> s = Survey.example()
1380
+ >>> d = s.dag()
1381
+ >>> d
1382
+ {1: {0}, 2: {0}}
1383
+ >>> s.textify(d)
1384
+ {'q1': {'q0'}, 'q2': {'q0'}}
1385
+ """
1386
+
1387
+ def get_name(index: int):
1388
+ """Return the name of the question given the index."""
1389
+ if index >= len(self.questions):
1390
+ return EndOfSurvey
1391
+ try:
1392
+ return self.questions[index].question_name
1393
+ except IndexError:
1394
+ print(
1395
+ f"The index is {index} but the length of the questions is {len(self.questions)}"
1396
+ )
1397
+ raise
1398
+
1399
+ try:
1400
+ text_dag = {}
1401
+ for child_index, parent_indices in index_dag.items():
1402
+ parent_names = {get_name(index) for index in parent_indices}
1403
+ child_name = get_name(child_index)
1404
+ text_dag[child_name] = parent_names
1405
+ return text_dag
1406
+ except IndexError:
1407
+ raise
1408
+
1409
+ @property
1410
+ def piping_dag(self) -> DAG:
1411
+ """Figures out the DAG of piping dependencies.
1412
+
1413
+ >>> from edsl import QuestionFreeText
1414
+ >>> q0 = QuestionFreeText(question_text="Here is a question", question_name="q0")
1415
+ >>> q1 = QuestionFreeText(question_text="You previously answered {{ q0 }}---how do you feel now?", question_name="q1")
1416
+ >>> s = Survey([q0, q1])
1417
+ >>> s.piping_dag
1418
+ {1: {0}}
1419
+ """
1420
+ d = {}
1421
+ for question_name, depenencies in self.parameters_by_question.items():
1422
+ if depenencies:
1423
+ question_index = self.question_name_to_index[question_name]
1424
+ for dependency in depenencies:
1425
+ if dependency not in self.question_name_to_index:
1426
+ pass
1427
+ else:
1428
+ dependency_index = self.question_name_to_index[dependency]
1429
+ if question_index not in d:
1430
+ d[question_index] = set()
1431
+ d[question_index].add(dependency_index)
1432
+ return d
1433
+
1434
+ def dag(self, textify: bool = False) -> DAG:
1435
+ """Return the DAG of the survey, which reflects both skip-logic and memory.
1436
+
1437
+ :param textify: Whether to return the DAG with question names instead of indices.
1438
+
1439
+ >>> s = Survey.example()
1440
+ >>> d = s.dag()
1441
+ >>> d
1442
+ {1: {0}, 2: {0}}
1443
+
1444
+ """
1445
+ memory_dag = self.memory_plan.dag
1446
+ rule_dag = self.rule_collection.dag
1447
+ piping_dag = self.piping_dag
1448
+ if textify:
1449
+ memory_dag = DAG(self.textify(memory_dag))
1450
+ rule_dag = DAG(self.textify(rule_dag))
1451
+ piping_dag = DAG(self.textify(piping_dag))
1452
+ return memory_dag + rule_dag + piping_dag
1453
+
1454
+ ###################
1455
+ # DUNDER METHODS
1456
+ ###################
1457
+ def __len__(self) -> int:
1458
+ """Return the number of questions in the survey.
1459
+
1460
+ >>> s = Survey.example()
1461
+ >>> len(s)
1462
+ 3
1463
+ """
1464
+ return len(self._questions)
1465
+
1466
+ def __getitem__(self, index) -> QuestionBase:
1467
+ """Return the question object given the question index.
1468
+
1469
+ :param index: The index of the question to get.
1470
+
1471
+ >>> s = Survey.example()
1472
+ >>> s[0]
1473
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1474
+
1475
+ """
1476
+ if isinstance(index, int):
1477
+ return self._questions[index]
1478
+ elif isinstance(index, str):
1479
+ return getattr(self, index)
1480
+
1481
+ def _diff(self, other):
1482
+ """Used for debugging. Print out the differences between two surveys."""
1483
+ from rich import print
1484
+
1485
+ for key, value in self.to_dict().items():
1486
+ if value != other.to_dict()[key]:
1487
+ print(f"Key: {key}")
1488
+ print("\n")
1489
+ print(f"Self: {value}")
1490
+ print("\n")
1491
+ print(f"Other: {other.to_dict()[key]}")
1492
+ print("\n\n")
1493
+
1494
+ def __eq__(self, other) -> bool:
1495
+ """Return True if the two surveys have the same to_dict.
1496
+
1497
+ :param other: The other survey to compare to.
1498
+
1499
+ >>> s = Survey.example()
1500
+ >>> s == s
1501
+ True
1502
+
1503
+ >>> s == "poop"
1504
+ False
1505
+
1506
+ """
1507
+ if not isinstance(other, Survey):
1508
+ return False
1509
+ return self.to_dict() == other.to_dict()
1510
+
1511
+ @classmethod
1512
+ def from_qsf(
1513
+ cls, qsf_file: Optional[str] = None, url: Optional[str] = None
1514
+ ) -> Survey:
1515
+ """Create a Survey object from a Qualtrics QSF file."""
1516
+
1517
+ if url and qsf_file:
1518
+ raise ValueError("Only one of url or qsf_file can be provided.")
1519
+
1520
+ if (not url) and (not qsf_file):
1521
+ raise ValueError("Either url or qsf_file must be provided.")
1522
+
1523
+ if url:
1524
+ response = requests.get(url)
1525
+ response.raise_for_status() # Ensure the request was successful
1526
+
1527
+ # Save the Excel file to a temporary file
1528
+ with tempfile.NamedTemporaryFile(suffix=".qsf", delete=False) as temp_file:
1529
+ temp_file.write(response.content)
1530
+ qsf_file = temp_file.name
1531
+
1532
+ from edsl.surveys.SurveyQualtricsImport import SurveyQualtricsImport
1533
+
1534
+ so = SurveyQualtricsImport(qsf_file)
1535
+ return so.create_survey()
1536
+
1537
+ # region: Display methods
1538
+ def print(self):
1539
+ """Print the survey in a rich format.
1540
+
1541
+ >>> s = Survey.example()
1542
+ >>> s.print()
1543
+ {
1544
+ "questions": [
1545
+ ...
1546
+ }
1547
+ """
1548
+ from rich import print_json
1549
+ import json
1550
+
1551
+ print_json(json.dumps(self.to_dict()))
1552
+
1553
+ def __repr__(self) -> str:
1554
+ """Return a string representation of the survey."""
1555
+
1556
+ # questions_string = ", ".join([repr(q) for q in self._questions])
1557
+ questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1558
+ # question_names_string = ", ".join([repr(name) for name in self.question_names])
1559
+ return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups})"
1560
+
1561
+ def _repr_html_(self) -> str:
1562
+ from edsl.utilities.utilities import data_to_html
1563
+
1564
+ return data_to_html(self.to_dict())
1565
+
1566
+ def rich_print(self) -> Table:
1567
+ """Print the survey in a rich format.
1568
+
1569
+ >>> t = Survey.example().rich_print()
1570
+ >>> print(t) # doctest: +SKIP
1571
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
1572
+ ┃ Questions ┃
1573
+ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
1574
+ │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ │
1575
+ │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1576
+ │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩ │
1577
+ │ │ q0 │ multiple_choice │ Do you like school? │ yes, no │ │
1578
+ │ └───────────────┴─────────────────┴─────────────────────┴─────────┘ │
1579
+ │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
1580
+ │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1581
+ │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
1582
+ │ │ q1 │ multiple_choice │ Why not? │ killer bees in cafeteria, other │ │
1583
+ │ └───────────────┴─────────────────┴───────────────┴─────────────────────────────────┘ │
1584
+ │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
1585
+ │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1586
+ │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
1587
+ │ │ q2 │ multiple_choice │ Why? │ **lack*** of killer bees in cafeteria, other │ │
1588
+ │ └───────────────┴─────────────────┴───────────────┴──────────────────────────────────────────────┘ │
1589
+ └────────────────────────────────────────────────────────────────────────────────────────────────────┘
1590
+ """
1591
+ from rich.table import Table
1592
+
1593
+ table = Table(show_header=True, header_style="bold magenta")
1594
+ table.add_column("Questions", style="dim")
1595
+
1596
+ for question in self._questions:
1597
+ table.add_row(question.rich_print())
1598
+
1599
+ return table
1600
+
1601
+ # endregion
1602
+
1603
+ def codebook(self) -> dict[str, str]:
1604
+ """Create a codebook for the survey, mapping question names to question text.
1605
+
1606
+ >>> s = Survey.example()
1607
+ >>> s.codebook()
1608
+ {'q0': 'Do you like school?', 'q1': 'Why not?', 'q2': 'Why?'}
1609
+ """
1610
+ codebook = {}
1611
+ for question in self._questions:
1612
+ codebook[question.question_name] = question.question_text
1613
+ return codebook
1614
+
1615
+ # region: Export methods
1616
+ def to_csv(self, filename: str = None):
1617
+ """Export the survey to a CSV file.
1618
+
1619
+ :param filename: The name of the file to save the CSV to.
1620
+
1621
+ >>> s = Survey.example()
1622
+ >>> s.to_csv() # doctest: +SKIP
1623
+ index question_name question_text question_options question_type
1624
+ 0 0 q0 Do you like school? [yes, no] multiple_choice
1625
+ 1 1 q1 Why not? [killer bees in cafeteria, other] multiple_choice
1626
+ 2 2 q2 Why? [**lack*** of killer bees in cafeteria, other] multiple_choice
1627
+ """
1628
+ raw_data = []
1629
+ for index, question in enumerate(self._questions):
1630
+ d = {"index": index}
1631
+ question_dict = question.to_dict()
1632
+ _ = question_dict.pop("edsl_version")
1633
+ _ = question_dict.pop("edsl_class_name")
1634
+ d.update(question_dict)
1635
+ raw_data.append(d)
1636
+ from pandas import DataFrame
1637
+
1638
+ df = DataFrame(raw_data)
1639
+ if filename:
1640
+ df.to_csv(filename, index=False)
1641
+ else:
1642
+ return df
1643
+
1644
+ def web(
1645
+ self,
1646
+ platform: Literal[
1647
+ "google_forms", "lime_survey", "survey_monkey"
1648
+ ] = "google_forms",
1649
+ email=None,
1650
+ ):
1651
+ from edsl.coop import Coop
1652
+
1653
+ c = Coop()
1654
+
1655
+ res = c.web(self.to_dict(), platform, email)
1656
+ return res
1657
+
1658
+ # endregion
1659
+
1660
+ @classmethod
1661
+ def example(
1662
+ cls,
1663
+ params: bool = False,
1664
+ randomize: bool = False,
1665
+ include_instructions=False,
1666
+ custom_instructions: Optional[str] = None,
1667
+ ) -> Survey:
1668
+ """Return an example survey.
1669
+
1670
+ >>> s = Survey.example()
1671
+ >>> [q.question_text for q in s.questions]
1672
+ ['Do you like school?', 'Why not?', 'Why?']
1673
+ """
1674
+ from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1675
+
1676
+ addition = "" if not randomize else str(uuid4())
1677
+ q0 = QuestionMultipleChoice(
1678
+ question_text=f"Do you like school?{addition}",
1679
+ question_options=["yes", "no"],
1680
+ question_name="q0",
1681
+ )
1682
+ q1 = QuestionMultipleChoice(
1683
+ question_text="Why not?",
1684
+ question_options=["killer bees in cafeteria", "other"],
1685
+ question_name="q1",
1686
+ )
1687
+ q2 = QuestionMultipleChoice(
1688
+ question_text="Why?",
1689
+ question_options=["**lack*** of killer bees in cafeteria", "other"],
1690
+ question_name="q2",
1691
+ )
1692
+ if params:
1693
+ q3 = QuestionMultipleChoice(
1694
+ question_text="To the question '{{ q0.question_text}}', you said '{{ q0.answer }}'. Do you still feel this way?",
1695
+ question_options=["yes", "no"],
1696
+ question_name="q3",
1697
+ )
1698
+ s = cls(questions=[q0, q1, q2, q3])
1699
+ return s
1700
+
1701
+ if include_instructions:
1702
+ from edsl import Instruction
1703
+
1704
+ custom_instructions = (
1705
+ custom_instructions if custom_instructions else "Please pay attention!"
1706
+ )
1707
+
1708
+ i = Instruction(text=custom_instructions, name="attention")
1709
+ s = cls(questions=[i, q0, q1, q2])
1710
+ return s
1711
+
1712
+ s = cls(questions=[q0, q1, q2])
1713
+ s = s.add_rule(q0, "q0 == 'yes'", q2)
1714
+ return s
1715
+
1716
+ def get_job(self, model=None, agent=None, **kwargs):
1717
+ if model is None:
1718
+ from edsl import Model
1719
+
1720
+ model = Model()
1721
+
1722
+ from edsl.scenarios.Scenario import Scenario
1723
+
1724
+ s = Scenario(kwargs)
1725
+
1726
+ if not agent:
1727
+ from edsl import Agent
1728
+
1729
+ agent = Agent()
1730
+
1731
+ return self.by(s).by(agent).by(model)
1732
+
1733
+
1734
+ def main():
1735
+ """Run the example survey."""
1736
+
1737
+ def example_survey():
1738
+ """Return an example survey."""
1739
+ from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1740
+ from edsl.surveys.Survey import Survey
1741
+
1742
+ q0 = QuestionMultipleChoice(
1743
+ question_text="Do you like school?",
1744
+ question_options=["yes", "no"],
1745
+ question_name="q0",
1746
+ )
1747
+ q1 = QuestionMultipleChoice(
1748
+ question_text="Why not?",
1749
+ question_options=["killer bees in cafeteria", "other"],
1750
+ question_name="q1",
1751
+ )
1752
+ q2 = QuestionMultipleChoice(
1753
+ question_text="Why?",
1754
+ question_options=["**lack*** of killer bees in cafeteria", "other"],
1755
+ question_name="q2",
1756
+ )
1757
+ s = Survey(questions=[q0, q1, q2])
1758
+ s = s.add_rule(q0, "q0 == 'yes'", q2)
1759
+ return s
1760
+
1761
+ s = example_survey()
1762
+ survey_dict = s.to_dict()
1763
+ s2 = Survey.from_dict(survey_dict)
1764
+ results = s2.run()
1765
+ print(results)
1766
+
1767
+
1768
+ if __name__ == "__main__":
1769
+ import doctest
1770
+
1771
+ # doctest.testmod(optionflags=doctest.ELLIPSIS | doctest.SKIP)
1772
+ doctest.testmod(optionflags=doctest.ELLIPSIS)