edsl 0.1.39__py3-none-any.whl → 0.1.39.dev1__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 (212) hide show
  1. edsl/Base.py +116 -197
  2. edsl/__init__.py +7 -15
  3. edsl/__version__.py +1 -1
  4. edsl/agents/Agent.py +147 -351
  5. edsl/agents/AgentList.py +73 -211
  6. edsl/agents/Invigilator.py +50 -101
  7. edsl/agents/InvigilatorBase.py +70 -62
  8. edsl/agents/PromptConstructor.py +225 -143
  9. edsl/agents/__init__.py +1 -0
  10. edsl/agents/prompt_helpers.py +3 -3
  11. edsl/auto/AutoStudy.py +5 -18
  12. edsl/auto/StageBase.py +40 -53
  13. edsl/auto/StageQuestions.py +1 -2
  14. edsl/auto/utilities.py +6 -0
  15. edsl/config.py +2 -22
  16. edsl/conversation/car_buying.py +1 -2
  17. edsl/coop/PriceFetcher.py +1 -1
  18. edsl/coop/coop.py +47 -125
  19. edsl/coop/utils.py +14 -14
  20. edsl/data/Cache.py +27 -45
  21. edsl/data/CacheEntry.py +15 -12
  22. edsl/data/CacheHandler.py +12 -31
  23. edsl/data/RemoteCacheSync.py +46 -154
  24. edsl/data/__init__.py +3 -4
  25. edsl/data_transfer_models.py +1 -2
  26. edsl/enums.py +0 -27
  27. edsl/exceptions/__init__.py +50 -50
  28. edsl/exceptions/agents.py +0 -12
  29. edsl/exceptions/questions.py +6 -24
  30. edsl/exceptions/scenarios.py +0 -7
  31. edsl/inference_services/AnthropicService.py +19 -38
  32. edsl/inference_services/AwsBedrock.py +2 -0
  33. edsl/inference_services/AzureAI.py +2 -0
  34. edsl/inference_services/GoogleService.py +12 -7
  35. edsl/inference_services/InferenceServiceABC.py +85 -18
  36. edsl/inference_services/InferenceServicesCollection.py +79 -120
  37. edsl/inference_services/MistralAIService.py +3 -0
  38. edsl/inference_services/OpenAIService.py +35 -47
  39. edsl/inference_services/PerplexityService.py +3 -0
  40. edsl/inference_services/TestService.py +10 -11
  41. edsl/inference_services/TogetherAIService.py +3 -5
  42. edsl/jobs/Answers.py +14 -1
  43. edsl/jobs/Jobs.py +431 -356
  44. edsl/jobs/JobsChecks.py +10 -35
  45. edsl/jobs/JobsPrompts.py +4 -6
  46. edsl/jobs/JobsRemoteInferenceHandler.py +133 -205
  47. edsl/jobs/buckets/BucketCollection.py +3 -44
  48. edsl/jobs/buckets/TokenBucket.py +21 -53
  49. edsl/jobs/interviews/Interview.py +408 -143
  50. edsl/jobs/runners/JobsRunnerAsyncio.py +403 -88
  51. edsl/jobs/runners/JobsRunnerStatus.py +165 -133
  52. edsl/jobs/tasks/QuestionTaskCreator.py +19 -21
  53. edsl/jobs/tasks/TaskHistory.py +18 -38
  54. edsl/jobs/tasks/task_status_enum.py +2 -0
  55. edsl/language_models/KeyLookup.py +30 -0
  56. edsl/language_models/LanguageModel.py +236 -194
  57. edsl/language_models/ModelList.py +19 -28
  58. edsl/language_models/__init__.py +2 -1
  59. edsl/language_models/registry.py +190 -0
  60. edsl/language_models/repair.py +2 -2
  61. edsl/language_models/unused/ReplicateBase.py +83 -0
  62. edsl/language_models/utilities.py +4 -5
  63. edsl/notebooks/Notebook.py +14 -19
  64. edsl/prompts/Prompt.py +39 -29
  65. edsl/questions/{answer_validator_mixin.py → AnswerValidatorMixin.py} +2 -47
  66. edsl/questions/QuestionBase.py +214 -68
  67. edsl/questions/{question_base_gen_mixin.py → QuestionBaseGenMixin.py} +50 -57
  68. edsl/questions/QuestionBasePromptsMixin.py +3 -7
  69. edsl/questions/QuestionBudget.py +1 -1
  70. edsl/questions/QuestionCheckBox.py +3 -3
  71. edsl/questions/QuestionExtract.py +7 -5
  72. edsl/questions/QuestionFreeText.py +3 -2
  73. edsl/questions/QuestionList.py +18 -10
  74. edsl/questions/QuestionMultipleChoice.py +23 -67
  75. edsl/questions/QuestionNumerical.py +4 -2
  76. edsl/questions/QuestionRank.py +17 -7
  77. edsl/questions/{response_validator_abc.py → ResponseValidatorABC.py} +26 -40
  78. edsl/questions/SimpleAskMixin.py +3 -4
  79. edsl/questions/__init__.py +1 -2
  80. edsl/questions/derived/QuestionLinearScale.py +3 -6
  81. edsl/questions/derived/QuestionTopK.py +1 -1
  82. edsl/questions/descriptors.py +3 -17
  83. edsl/questions/question_registry.py +1 -1
  84. edsl/results/CSSParameterizer.py +1 -1
  85. edsl/results/Dataset.py +7 -170
  86. edsl/results/DatasetExportMixin.py +305 -168
  87. edsl/results/DatasetTree.py +8 -28
  88. edsl/results/Result.py +206 -298
  89. edsl/results/Results.py +131 -149
  90. edsl/results/ResultsDBMixin.py +238 -0
  91. edsl/results/ResultsExportMixin.py +0 -2
  92. edsl/results/{results_selector.py → Selector.py} +13 -23
  93. edsl/results/TableDisplay.py +171 -98
  94. edsl/results/__init__.py +1 -1
  95. edsl/scenarios/FileStore.py +239 -150
  96. edsl/scenarios/Scenario.py +193 -90
  97. edsl/scenarios/ScenarioHtmlMixin.py +3 -4
  98. edsl/scenarios/{scenario_join.py → ScenarioJoin.py} +6 -10
  99. edsl/scenarios/ScenarioList.py +244 -415
  100. edsl/scenarios/ScenarioListExportMixin.py +7 -0
  101. edsl/scenarios/ScenarioListPdfMixin.py +37 -15
  102. edsl/scenarios/__init__.py +2 -1
  103. edsl/study/ObjectEntry.py +1 -1
  104. edsl/study/SnapShot.py +1 -1
  105. edsl/study/Study.py +12 -5
  106. edsl/surveys/Rule.py +4 -5
  107. edsl/surveys/RuleCollection.py +27 -25
  108. edsl/surveys/Survey.py +791 -270
  109. edsl/surveys/SurveyCSS.py +8 -20
  110. edsl/surveys/{SurveyFlowVisualization.py → SurveyFlowVisualizationMixin.py} +9 -11
  111. edsl/surveys/__init__.py +2 -4
  112. edsl/surveys/descriptors.py +2 -6
  113. edsl/surveys/instructions/ChangeInstruction.py +2 -1
  114. edsl/surveys/instructions/Instruction.py +13 -4
  115. edsl/surveys/instructions/InstructionCollection.py +6 -11
  116. edsl/templates/error_reporting/interview_details.html +1 -1
  117. edsl/templates/error_reporting/report.html +1 -1
  118. edsl/tools/plotting.py +1 -1
  119. edsl/utilities/utilities.py +23 -35
  120. {edsl-0.1.39.dist-info → edsl-0.1.39.dev1.dist-info}/METADATA +10 -12
  121. edsl-0.1.39.dev1.dist-info/RECORD +277 -0
  122. {edsl-0.1.39.dist-info → edsl-0.1.39.dev1.dist-info}/WHEEL +1 -1
  123. edsl/agents/QuestionInstructionPromptBuilder.py +0 -128
  124. edsl/agents/QuestionTemplateReplacementsBuilder.py +0 -137
  125. edsl/agents/question_option_processor.py +0 -172
  126. edsl/coop/CoopFunctionsMixin.py +0 -15
  127. edsl/coop/ExpectedParrotKeyHandler.py +0 -125
  128. edsl/exceptions/inference_services.py +0 -5
  129. edsl/inference_services/AvailableModelCacheHandler.py +0 -184
  130. edsl/inference_services/AvailableModelFetcher.py +0 -215
  131. edsl/inference_services/ServiceAvailability.py +0 -135
  132. edsl/inference_services/data_structures.py +0 -134
  133. edsl/jobs/AnswerQuestionFunctionConstructor.py +0 -223
  134. edsl/jobs/FetchInvigilator.py +0 -47
  135. edsl/jobs/InterviewTaskManager.py +0 -98
  136. edsl/jobs/InterviewsConstructor.py +0 -50
  137. edsl/jobs/JobsComponentConstructor.py +0 -189
  138. edsl/jobs/JobsRemoteInferenceLogger.py +0 -239
  139. edsl/jobs/RequestTokenEstimator.py +0 -30
  140. edsl/jobs/async_interview_runner.py +0 -138
  141. edsl/jobs/buckets/TokenBucketAPI.py +0 -211
  142. edsl/jobs/buckets/TokenBucketClient.py +0 -191
  143. edsl/jobs/check_survey_scenario_compatibility.py +0 -85
  144. edsl/jobs/data_structures.py +0 -120
  145. edsl/jobs/decorators.py +0 -35
  146. edsl/jobs/jobs_status_enums.py +0 -9
  147. edsl/jobs/loggers/HTMLTableJobLogger.py +0 -304
  148. edsl/jobs/results_exceptions_handler.py +0 -98
  149. edsl/language_models/ComputeCost.py +0 -63
  150. edsl/language_models/PriceManager.py +0 -127
  151. edsl/language_models/RawResponseHandler.py +0 -106
  152. edsl/language_models/ServiceDataSources.py +0 -0
  153. edsl/language_models/key_management/KeyLookup.py +0 -63
  154. edsl/language_models/key_management/KeyLookupBuilder.py +0 -273
  155. edsl/language_models/key_management/KeyLookupCollection.py +0 -38
  156. edsl/language_models/key_management/__init__.py +0 -0
  157. edsl/language_models/key_management/models.py +0 -131
  158. edsl/language_models/model.py +0 -256
  159. edsl/notebooks/NotebookToLaTeX.py +0 -142
  160. edsl/questions/ExceptionExplainer.py +0 -77
  161. edsl/questions/HTMLQuestion.py +0 -103
  162. edsl/questions/QuestionMatrix.py +0 -265
  163. edsl/questions/data_structures.py +0 -20
  164. edsl/questions/loop_processor.py +0 -149
  165. edsl/questions/response_validator_factory.py +0 -34
  166. edsl/questions/templates/matrix/__init__.py +0 -1
  167. edsl/questions/templates/matrix/answering_instructions.jinja +0 -5
  168. edsl/questions/templates/matrix/question_presentation.jinja +0 -20
  169. edsl/results/MarkdownToDocx.py +0 -122
  170. edsl/results/MarkdownToPDF.py +0 -111
  171. edsl/results/TextEditor.py +0 -50
  172. edsl/results/file_exports.py +0 -252
  173. edsl/results/smart_objects.py +0 -96
  174. edsl/results/table_data_class.py +0 -12
  175. edsl/results/table_renderers.py +0 -118
  176. edsl/scenarios/ConstructDownloadLink.py +0 -109
  177. edsl/scenarios/DocumentChunker.py +0 -102
  178. edsl/scenarios/DocxScenario.py +0 -16
  179. edsl/scenarios/PdfExtractor.py +0 -40
  180. edsl/scenarios/directory_scanner.py +0 -96
  181. edsl/scenarios/file_methods.py +0 -85
  182. edsl/scenarios/handlers/__init__.py +0 -13
  183. edsl/scenarios/handlers/csv.py +0 -49
  184. edsl/scenarios/handlers/docx.py +0 -76
  185. edsl/scenarios/handlers/html.py +0 -37
  186. edsl/scenarios/handlers/json.py +0 -111
  187. edsl/scenarios/handlers/latex.py +0 -5
  188. edsl/scenarios/handlers/md.py +0 -51
  189. edsl/scenarios/handlers/pdf.py +0 -68
  190. edsl/scenarios/handlers/png.py +0 -39
  191. edsl/scenarios/handlers/pptx.py +0 -105
  192. edsl/scenarios/handlers/py.py +0 -294
  193. edsl/scenarios/handlers/sql.py +0 -313
  194. edsl/scenarios/handlers/sqlite.py +0 -149
  195. edsl/scenarios/handlers/txt.py +0 -33
  196. edsl/scenarios/scenario_selector.py +0 -156
  197. edsl/surveys/ConstructDAG.py +0 -92
  198. edsl/surveys/EditSurvey.py +0 -221
  199. edsl/surveys/InstructionHandler.py +0 -100
  200. edsl/surveys/MemoryManagement.py +0 -72
  201. edsl/surveys/RuleManager.py +0 -172
  202. edsl/surveys/Simulator.py +0 -75
  203. edsl/surveys/SurveyToApp.py +0 -141
  204. edsl/utilities/PrettyList.py +0 -56
  205. edsl/utilities/is_notebook.py +0 -18
  206. edsl/utilities/is_valid_variable_name.py +0 -11
  207. edsl/utilities/remove_edsl_version.py +0 -24
  208. edsl-0.1.39.dist-info/RECORD +0 -358
  209. /edsl/questions/{register_questions_meta.py → RegisterQuestionsMeta.py} +0 -0
  210. /edsl/results/{results_fetch_mixin.py → ResultsFetchMixin.py} +0 -0
  211. /edsl/results/{results_tools_mixin.py → ResultsToolsMixin.py} +0 -0
  212. {edsl-0.1.39.dist-info → edsl-0.1.39.dev1.dist-info}/LICENSE +0 -0
edsl/surveys/Survey.py CHANGED
@@ -2,93 +2,43 @@
2
2
 
3
3
  from __future__ import annotations
4
4
  import re
5
- import random
6
-
7
- from typing import (
8
- Any,
9
- Generator,
10
- Optional,
11
- Union,
12
- List,
13
- Literal,
14
- Callable,
15
- TYPE_CHECKING,
16
- )
5
+ import tempfile
6
+ import requests
7
+
8
+ from typing import Any, Generator, Optional, Union, List, Literal, Callable
17
9
  from uuid import uuid4
18
10
  from edsl.Base import Base
19
- from edsl.exceptions.surveys import SurveyCreationError, SurveyHasNoRulesError
11
+ from edsl.exceptions import SurveyCreationError, SurveyHasNoRulesError
20
12
  from edsl.exceptions.surveys import SurveyError
21
- from collections import UserDict
22
-
23
-
24
- class PseudoIndices(UserDict):
25
- @property
26
- def max_pseudo_index(self) -> float:
27
- """Return the maximum pseudo index in the survey.
28
- >>> Survey.example()._pseudo_indices.max_pseudo_index
29
- 2
30
- """
31
- if len(self) == 0:
32
- return -1
33
- return max(self.values())
34
-
35
- @property
36
- def last_item_was_instruction(self) -> bool:
37
- """Return whether the last item added to the survey was an instruction.
38
-
39
- This is used to determine the pseudo-index of the next item added to the survey.
40
-
41
- Example:
42
-
43
- >>> s = Survey.example()
44
- >>> s._pseudo_indices.last_item_was_instruction
45
- False
46
- >>> from edsl.surveys.instructions.Instruction import Instruction
47
- >>> s = s.add_instruction(Instruction(text="Pay attention to the following questions.", name="intro"))
48
- >>> s._pseudo_indices.last_item_was_instruction
49
- True
50
- """
51
- return isinstance(self.max_pseudo_index, float)
52
-
53
-
54
- if TYPE_CHECKING:
55
- from edsl.questions.QuestionBase import QuestionBase
56
- from edsl.agents.Agent import Agent
57
- from edsl.surveys.DAG import DAG
58
- from edsl.language_models.LanguageModel import LanguageModel
59
- from edsl.scenarios.Scenario import Scenario
60
- from edsl.data.Cache import Cache
61
-
62
- # This is a hack to get around the fact that TypeAlias is not available in typing until Python 3.10
63
- try:
64
- from typing import TypeAlias
65
- except ImportError:
66
- from typing import _GenericAlias as TypeAlias
67
-
68
- QuestionType: TypeAlias = Union[QuestionBase, Instruction, ChangeInstruction]
69
- QuestionGroupType: TypeAlias = dict[str, tuple[int, int]]
70
13
 
14
+ from edsl.questions.QuestionBase import QuestionBase
15
+ from edsl.surveys.base import RulePriority, EndOfSurvey
16
+ from edsl.surveys.DAG import DAG
17
+ from edsl.surveys.descriptors import QuestionsDescriptor
18
+ from edsl.surveys.MemoryPlan import MemoryPlan
19
+ from edsl.surveys.Rule import Rule
20
+ from edsl.surveys.RuleCollection import RuleCollection
21
+ from edsl.surveys.SurveyExportMixin import SurveyExportMixin
22
+ from edsl.surveys.SurveyFlowVisualizationMixin import SurveyFlowVisualizationMixin
23
+ from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
71
24
 
72
- from edsl.utilities.remove_edsl_version import remove_edsl_version
25
+ from edsl.agents.Agent import Agent
73
26
 
74
27
  from edsl.surveys.instructions.InstructionCollection import InstructionCollection
75
28
  from edsl.surveys.instructions.Instruction import Instruction
76
29
  from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
77
30
 
78
- from edsl.surveys.base import EndOfSurvey
79
- from edsl.surveys.descriptors import QuestionsDescriptor
80
- from edsl.surveys.MemoryPlan import MemoryPlan
81
- from edsl.surveys.RuleCollection import RuleCollection
82
- from edsl.surveys.SurveyExportMixin import SurveyExportMixin
83
- from edsl.surveys.SurveyFlowVisualization import SurveyFlowVisualization
84
- from edsl.surveys.InstructionHandler import InstructionHandler
85
- from edsl.surveys.EditSurvey import EditSurvey
86
- from edsl.surveys.Simulator import Simulator
87
- from edsl.surveys.MemoryManagement import MemoryManagement
88
- from edsl.surveys.RuleManager import RuleManager
89
31
 
32
+ class ValidatedString(str):
33
+ def __new__(cls, content):
34
+ if "<>" in content:
35
+ raise SurveyCreationError(
36
+ "The expression contains '<>', which is not allowed. You probably mean '!='."
37
+ )
38
+ return super().__new__(cls, content)
90
39
 
91
- class Survey(SurveyExportMixin, Base):
40
+
41
+ class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
92
42
  """A collection of questions that supports skip logic."""
93
43
 
94
44
  __documentation__ = """https://docs.expectedparrot.com/en/latest/surveys.html"""
@@ -111,12 +61,13 @@ class Survey(SurveyExportMixin, Base):
111
61
 
112
62
  def __init__(
113
63
  self,
114
- questions: Optional[List["QuestionType"]] = None,
115
- memory_plan: Optional["MemoryPlan"] = None,
116
- rule_collection: Optional["RuleCollection"] = None,
117
- question_groups: Optional["QuestionGroupType"] = None,
64
+ questions: Optional[
65
+ list[Union[QuestionBase, Instruction, ChangeInstruction]]
66
+ ] = None,
67
+ memory_plan: Optional[MemoryPlan] = None,
68
+ rule_collection: Optional[RuleCollection] = None,
69
+ question_groups: Optional[dict[str, tuple[int, int]]] = None,
118
70
  name: Optional[str] = None,
119
- questions_to_randomize: Optional[List[str]] = None,
120
71
  ):
121
72
  """Create a new survey.
122
73
 
@@ -138,7 +89,11 @@ class Survey(SurveyExportMixin, Base):
138
89
 
139
90
  self.raw_passed_questions = questions
140
91
 
141
- true_questions = self._process_raw_questions(self.raw_passed_questions)
92
+ (
93
+ true_questions,
94
+ instruction_names_to_instructions,
95
+ self.pseudo_indices,
96
+ ) = self._separate_questions_and_instructions(questions or [])
142
97
 
143
98
  self.rule_collection = RuleCollection(
144
99
  num_questions=len(true_questions) if true_questions else None
@@ -146,9 +101,8 @@ class Survey(SurveyExportMixin, Base):
146
101
  # the RuleCollection needs to be present while we add the questions; we might override this later
147
102
  # if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
148
103
 
149
- # this is where the Questions constructor is called.
150
104
  self.questions = true_questions
151
- # self.instruction_names_to_instructions = instruction_names_to_instructions
105
+ self.instruction_names_to_instructions = instruction_names_to_instructions
152
106
 
153
107
  self.memory_plan = memory_plan or MemoryPlan(self)
154
108
  if question_groups is not None:
@@ -156,7 +110,7 @@ class Survey(SurveyExportMixin, Base):
156
110
  else:
157
111
  self.question_groups = {}
158
112
 
159
- # if a rule collection is provided, use it instead of the constructed one
113
+ # if a rule collection is provided, use it instead
160
114
  if rule_collection is not None:
161
115
  self.rule_collection = rule_collection
162
116
 
@@ -165,58 +119,97 @@ class Survey(SurveyExportMixin, Base):
165
119
 
166
120
  warnings.warn("name parameter to a survey is deprecated.")
167
121
 
168
- if questions_to_randomize is not None:
169
- self.questions_to_randomize = questions_to_randomize
170
- else:
171
- self.questions_to_randomize = []
172
-
173
- self._seed = None
174
-
175
- def draw(self) -> "Survey":
176
- """Return a new survey with a randomly selected permutation of the options."""
177
- if self._seed is None: # only set once
178
- self._seed = hash(self)
179
- random.seed(self._seed)
180
-
181
- if len(self.questions_to_randomize) == 0:
182
- return self
183
-
184
- new_questions = []
185
- for question in self.questions:
186
- if question.question_name in self.questions_to_randomize:
187
- new_questions.append(question.draw())
188
- else:
189
- new_questions.append(question.duplicate())
190
-
191
- d = self.to_dict()
192
- d["questions"] = [q.to_dict() for q in new_questions]
193
- return Survey.from_dict(d)
194
-
195
- def _process_raw_questions(self, questions: Optional[List["QuestionType"]]) -> list:
196
- """Process the raw questions passed to the survey."""
197
- handler = InstructionHandler(self)
198
- components = handler.separate_questions_and_instructions(questions or [])
199
- self._instruction_names_to_instructions = (
200
- components.instruction_names_to_instructions
201
- )
202
- self._pseudo_indices = PseudoIndices(components.pseudo_indices)
203
- return components.true_questions
204
-
205
- # region: Survey instruction handling
122
+ # region: Suvry instruction handling
206
123
  @property
207
- def _relevant_instructions_dict(self) -> InstructionCollection:
124
+ def relevant_instructions_dict(self) -> InstructionCollection:
208
125
  """Return a dictionary with keys as question names and values as instructions that are relevant to the question.
209
126
 
210
127
  >>> s = Survey.example(include_instructions=True)
211
- >>> s._relevant_instructions_dict
128
+ >>> s.relevant_instructions_dict
212
129
  {'q0': [Instruction(name="attention", text="Please pay attention!")], 'q1': [Instruction(name="attention", text="Please pay attention!")], 'q2': [Instruction(name="attention", text="Please pay attention!")]}
213
130
 
214
131
  """
215
132
  return InstructionCollection(
216
- self._instruction_names_to_instructions, self.questions
133
+ self.instruction_names_to_instructions, self.questions
217
134
  )
218
135
 
219
- def _relevant_instructions(self, question: QuestionBase) -> dict:
136
+ @staticmethod
137
+ def _separate_questions_and_instructions(questions_and_instructions: list) -> tuple:
138
+ """
139
+ The 'pseudo_indices' attribute is a dictionary that maps question names to pseudo-indices
140
+ that are used to order questions and instructions in the survey.
141
+ Only questions get real indices; instructions get pseudo-indices.
142
+ However, the order of the pseudo-indices is the same as the order questions and instructions are added to the survey.
143
+
144
+ We don't have to know how many instructions there are to calculate the pseudo-indices because they are
145
+ calculated by the inverse of one minus the sum of 1/2^n for n in the number of instructions run so far.
146
+
147
+ >>> from edsl import Instruction
148
+ >>> i = Instruction(text = "Pay attention to the following questions.", name = "intro")
149
+ >>> i2 = Instruction(text = "How are you feeling today?", name = "followon_intro")
150
+ >>> from edsl import QuestionFreeText; q1 = QuestionFreeText.example()
151
+ >>> from edsl import QuestionMultipleChoice; q2 = QuestionMultipleChoice.example()
152
+ >>> s = Survey([q1, i, i2, q2])
153
+ >>> len(s.instruction_names_to_instructions)
154
+ 2
155
+ >>> s.pseudo_indices
156
+ {'how_are_you': 0, 'intro': 0.5, 'followon_intro': 0.75, 'how_feeling': 1}
157
+
158
+ >>> from edsl import ChangeInstruction
159
+ >>> q3 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
160
+ >>> i_change = ChangeInstruction(drop = ["intro"])
161
+ >>> s = Survey([q1, i, q2, i_change, q3])
162
+ >>> [i.name for i in s.relevant_instructions(q1)]
163
+ []
164
+ >>> [i.name for i in s.relevant_instructions(q2)]
165
+ ['intro']
166
+ >>> [i.name for i in s.relevant_instructions(q3)]
167
+ []
168
+
169
+ >>> i_change = ChangeInstruction(keep = ["poop"], drop = [])
170
+ >>> s = Survey([q1, i, q2, i_change])
171
+ Traceback (most recent call last):
172
+ ...
173
+ ValueError: ChangeInstruction change_instruction_0 references instruction poop which does not exist.
174
+ """
175
+ from edsl.surveys.instructions.Instruction import Instruction
176
+ from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
177
+
178
+ true_questions = []
179
+ instruction_names_to_instructions = {}
180
+
181
+ num_change_instructions = 0
182
+ pseudo_indices = {}
183
+ instructions_run_length = 0
184
+ for entry in questions_and_instructions:
185
+ if isinstance(entry, Instruction) or isinstance(entry, ChangeInstruction):
186
+ if isinstance(entry, ChangeInstruction):
187
+ entry.add_name(num_change_instructions)
188
+ num_change_instructions += 1
189
+ for prior_instruction in entry.keep + entry.drop:
190
+ if prior_instruction not in instruction_names_to_instructions:
191
+ raise ValueError(
192
+ f"ChangeInstruction {entry.name} references instruction {prior_instruction} which does not exist."
193
+ )
194
+ instructions_run_length += 1
195
+ delta = 1 - 1.0 / (2.0**instructions_run_length)
196
+ pseudo_index = (len(true_questions) - 1) + delta
197
+ entry.pseudo_index = pseudo_index
198
+ instruction_names_to_instructions[entry.name] = entry
199
+ elif isinstance(entry, QuestionBase):
200
+ pseudo_index = len(true_questions)
201
+ instructions_run_length = 0
202
+ true_questions.append(entry)
203
+ else:
204
+ raise ValueError(
205
+ f"Entry {repr(entry)} is not a QuestionBase or an Instruction."
206
+ )
207
+
208
+ pseudo_indices[entry.name] = pseudo_index
209
+
210
+ return true_questions, instruction_names_to_instructions, pseudo_indices
211
+
212
+ def relevant_instructions(self, question) -> dict:
220
213
  """This should be a dictionry with keys as question names and values as instructions that are relevant to the question.
221
214
 
222
215
  :param question: The question to get the relevant instructions for.
@@ -224,13 +217,38 @@ class Survey(SurveyExportMixin, Base):
224
217
  # Did the instruction come before the question and was it not modified by a change instruction?
225
218
 
226
219
  """
227
- return InstructionCollection(
228
- self._instruction_names_to_instructions, self.questions
229
- )[question]
220
+ return self.relevant_instructions_dict[question]
230
221
 
231
- def show_flow(self, filename: Optional[str] = None) -> None:
232
- """Show the flow of the survey."""
233
- SurveyFlowVisualization(self).show_flow(filename=filename)
222
+ @property
223
+ def max_pseudo_index(self) -> float:
224
+ """Return the maximum pseudo index in the survey.
225
+
226
+ Example:
227
+
228
+ >>> s = Survey.example()
229
+ >>> s.max_pseudo_index
230
+ 2
231
+ """
232
+ if len(self.pseudo_indices) == 0:
233
+ return -1
234
+ return max(self.pseudo_indices.values())
235
+
236
+ @property
237
+ def last_item_was_instruction(self) -> bool:
238
+ """Return whether the last item added to the survey was an instruction.
239
+ This is used to determine the pseudo-index of the next item added to the survey.
240
+
241
+ Example:
242
+
243
+ >>> s = Survey.example()
244
+ >>> s.last_item_was_instruction
245
+ False
246
+ >>> from edsl.surveys.instructions.Instruction import Instruction
247
+ >>> s = s.add_instruction(Instruction(text="Pay attention to the following questions.", name="intro"))
248
+ >>> s.last_item_was_instruction
249
+ True
250
+ """
251
+ return isinstance(self.max_pseudo_index, float)
234
252
 
235
253
  def add_instruction(
236
254
  self, instruction: Union["Instruction", "ChangeInstruction"]
@@ -243,21 +261,101 @@ class Survey(SurveyExportMixin, Base):
243
261
  >>> from edsl import Instruction
244
262
  >>> i = Instruction(text="Pay attention to the following questions.", name="intro")
245
263
  >>> s = Survey().add_instruction(i)
246
- >>> s._instruction_names_to_instructions
264
+ >>> s.instruction_names_to_instructions
247
265
  {'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
248
- >>> s._pseudo_indices
266
+ >>> s.pseudo_indices
249
267
  {'intro': -0.5}
250
268
  """
251
- return EditSurvey(self).add_instruction(instruction)
269
+ import math
270
+
271
+ if instruction.name in self.instruction_names_to_instructions:
272
+ raise SurveyCreationError(
273
+ f"""Instruction name '{instruction.name}' already exists in survey. Existing names are {self.instruction_names_to_instructions.keys()}."""
274
+ )
275
+ self.instruction_names_to_instructions[instruction.name] = instruction
276
+
277
+ # was the last thing added an instruction or a question?
278
+ if self.last_item_was_instruction:
279
+ pseudo_index = (
280
+ self.max_pseudo_index
281
+ + (math.ceil(self.max_pseudo_index) - self.max_pseudo_index) / 2
282
+ )
283
+ else:
284
+ pseudo_index = self.max_pseudo_index + 1.0 / 2.0
285
+ self.pseudo_indices[instruction.name] = pseudo_index
286
+
287
+ return self
252
288
 
253
289
  # endregion
290
+
291
+ # region: Simulation methods
292
+
254
293
  @classmethod
255
- def random_survey(cls):
256
- return Simulator.random_survey()
294
+ def random_survey(self):
295
+ """Create a random survey."""
296
+ from edsl.questions import QuestionMultipleChoice, QuestionFreeText
297
+ from random import choice
298
+
299
+ num_questions = 10
300
+ questions = []
301
+ for i in range(num_questions):
302
+ if choice([True, False]):
303
+ q = QuestionMultipleChoice(
304
+ question_text="nothing",
305
+ question_name="q_" + str(i),
306
+ question_options=list(range(3)),
307
+ )
308
+ questions.append(q)
309
+ else:
310
+ questions.append(
311
+ QuestionFreeText(
312
+ question_text="nothing", question_name="q_" + str(i)
313
+ )
314
+ )
315
+ s = Survey(questions)
316
+ start_index = choice(range(num_questions - 1))
317
+ end_index = choice(range(start_index + 1, 10))
318
+ s = s.add_rule(f"q_{start_index}", "True", f"q_{end_index}")
319
+ question_to_delete = choice(range(num_questions))
320
+ s.delete_question(f"q_{question_to_delete}")
321
+ return s
257
322
 
258
323
  def simulate(self) -> dict:
259
324
  """Simulate the survey and return the answers."""
260
- return Simulator(self).simulate()
325
+ i = self.gen_path_through_survey()
326
+ q = next(i)
327
+ num_passes = 0
328
+ while True:
329
+ num_passes += 1
330
+ try:
331
+ answer = q._simulate_answer()
332
+ q = i.send({q.question_name: answer["answer"]})
333
+ except StopIteration:
334
+ break
335
+
336
+ if num_passes > 100:
337
+ print("Too many passes.")
338
+ raise Exception("Too many passes.")
339
+ return self.answers
340
+
341
+ def create_agent(self) -> "Agent":
342
+ """Create an agent from the simulated answers."""
343
+ answers_dict = self.simulate()
344
+
345
+ def construct_answer_dict_function(traits: dict) -> Callable:
346
+ def func(self, question: "QuestionBase", scenario=None):
347
+ return traits.get(question.question_name, None)
348
+
349
+ return func
350
+
351
+ return Agent(traits=answers_dict).add_direct_question_answering_method(
352
+ construct_answer_dict_function(answers_dict)
353
+ )
354
+
355
+ def simulate_results(self) -> "Results":
356
+ """Simulate the survey and return the results."""
357
+ a = self.create_agent()
358
+ return self.by([a]).run()
261
359
 
262
360
  # endregion
263
361
 
@@ -293,19 +391,26 @@ class Survey(SurveyExportMixin, Base):
293
391
  )
294
392
  return self.question_name_to_index[question_name]
295
393
 
296
- def _get_question_by_name(self, question_name: str) -> QuestionBase:
394
+ def get(self, question_name: str) -> QuestionBase:
297
395
  """
298
396
  Return the question object given the question name.
299
397
 
300
398
  :param question_name: The name of the question to get.
301
399
 
302
400
  >>> s = Survey.example()
303
- >>> s._get_question_by_name("q0")
401
+ >>> s.get_question("q0")
304
402
  Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
305
403
  """
306
404
  if question_name not in self.question_name_to_index:
307
405
  raise SurveyError(f"Question name {question_name} not found in survey.")
308
- return self._questions[self.question_name_to_index[question_name]]
406
+ index = self.question_name_to_index[question_name]
407
+ return self._questions[index]
408
+
409
+ def get_question(self, question_name: str) -> QuestionBase:
410
+ """Return the question object given the question name."""
411
+ # import warnings
412
+ # warnings.warn("survey.get_question is deprecated. Use subscript operator instead.")
413
+ return self.get(question_name)
309
414
 
310
415
  def question_names_to_questions(self) -> dict:
311
416
  """Return a dictionary mapping question names to question attributes."""
@@ -338,6 +443,12 @@ class Survey(SurveyExportMixin, Base):
338
443
  # endregion
339
444
 
340
445
  # region: serialization methods
446
+ def __hash__(self) -> int:
447
+ """Return a hash of the question."""
448
+ from edsl.utilities.utilities import dict_hash
449
+
450
+ return dict_hash(self.to_dict(add_edsl_version=False))
451
+
341
452
  def to_dict(self, add_edsl_version=True) -> dict[str, Any]:
342
453
  """Serialize the Survey object to a dictionary.
343
454
 
@@ -345,12 +456,10 @@ class Survey(SurveyExportMixin, Base):
345
456
  >>> s.to_dict(add_edsl_version = False).keys()
346
457
  dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
347
458
  """
348
- from edsl import __version__
349
-
350
- d = {
459
+ return {
351
460
  "questions": [
352
461
  q.to_dict(add_edsl_version=add_edsl_version)
353
- for q in self._recombined_questions_and_instructions()
462
+ for q in self.recombined_questions_and_instructions()
354
463
  ],
355
464
  "memory_plan": self.memory_plan.to_dict(add_edsl_version=add_edsl_version),
356
465
  "rule_collection": self.rule_collection.to_dict(
@@ -358,13 +467,6 @@ class Survey(SurveyExportMixin, Base):
358
467
  ),
359
468
  "question_groups": self.question_groups,
360
469
  }
361
- if self.questions_to_randomize != []:
362
- d["questions_to_randomize"] = self.questions_to_randomize
363
-
364
- if add_edsl_version:
365
- d["edsl_version"] = __version__
366
- d["edsl_class_name"] = "Survey"
367
- return d
368
470
 
369
471
  @classmethod
370
472
  @remove_edsl_version
@@ -387,8 +489,6 @@ class Survey(SurveyExportMixin, Base):
387
489
  """
388
490
 
389
491
  def get_class(pass_dict):
390
- from edsl.questions.QuestionBase import QuestionBase
391
-
392
492
  if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
393
493
  return QuestionBase
394
494
  elif class_name == "Instruction":
@@ -408,16 +508,11 @@ class Survey(SurveyExportMixin, Base):
408
508
  get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
409
509
  ]
410
510
  memory_plan = MemoryPlan.from_dict(data["memory_plan"])
411
- if "questions_to_randomize" in data:
412
- questions_to_randomize = data["questions_to_randomize"]
413
- else:
414
- questions_to_randomize = None
415
511
  survey = cls(
416
512
  questions=questions,
417
513
  memory_plan=memory_plan,
418
514
  rule_collection=RuleCollection.from_dict(data["rule_collection"]),
419
515
  question_groups=data["question_groups"],
420
- questions_to_randomize=questions_to_randomize,
421
516
  )
422
517
  return survey
423
518
 
@@ -505,16 +600,27 @@ class Survey(SurveyExportMixin, Base):
505
600
 
506
601
  return Survey(questions=self.questions + other.questions)
507
602
 
508
- def move_question(self, identifier: Union[str, int], new_index: int) -> Survey:
509
- """
510
- >>> from edsl import QuestionMultipleChoice, Survey
511
- >>> s = Survey.example()
512
- >>> s.question_names
513
- ['q0', 'q1', 'q2']
514
- >>> s.move_question("q0", 2).question_names
515
- ['q1', 'q2', 'q0']
516
- """
517
- return EditSurvey(self).move_question(identifier, new_index)
603
+ def move_question(self, identifier: Union[str, int], new_index: int):
604
+ if isinstance(identifier, str):
605
+ if identifier not in self.question_names:
606
+ raise SurveyError(
607
+ f"Question name '{identifier}' does not exist in the survey."
608
+ )
609
+ index = self.question_name_to_index[identifier]
610
+ elif isinstance(identifier, int):
611
+ if identifier < 0 or identifier >= len(self.questions):
612
+ raise SurveyError(f"Index {identifier} is out of range.")
613
+ index = identifier
614
+ else:
615
+ raise SurveyError(
616
+ "Identifier must be either a string (question name) or an integer (question index)."
617
+ )
618
+
619
+ moving_question = self._questions[index]
620
+
621
+ new_survey = self.delete_question(index)
622
+ new_survey.add_question(moving_question, new_index)
623
+ return new_survey
518
624
 
519
625
  def delete_question(self, identifier: Union[str, int]) -> Survey:
520
626
  """
@@ -534,7 +640,54 @@ class Survey(SurveyExportMixin, Base):
534
640
  >>> len(s.questions)
535
641
  0
536
642
  """
537
- return EditSurvey(self).delete_question(identifier)
643
+ if isinstance(identifier, str):
644
+ if identifier not in self.question_names:
645
+ raise SurveyError(
646
+ f"Question name '{identifier}' does not exist in the survey."
647
+ )
648
+ index = self.question_name_to_index[identifier]
649
+ elif isinstance(identifier, int):
650
+ if identifier < 0 or identifier >= len(self.questions):
651
+ raise SurveyError(f"Index {identifier} is out of range.")
652
+ index = identifier
653
+ else:
654
+ raise SurveyError(
655
+ "Identifier must be either a string (question name) or an integer (question index)."
656
+ )
657
+
658
+ # Remove the question
659
+ deleted_question = self._questions.pop(index)
660
+ del self.pseudo_indices[deleted_question.question_name]
661
+
662
+ # Update indices
663
+ for question_name, old_index in self.pseudo_indices.items():
664
+ if old_index > index:
665
+ self.pseudo_indices[question_name] = old_index - 1
666
+
667
+ # Update rules
668
+ new_rule_collection = RuleCollection()
669
+ for rule in self.rule_collection:
670
+ if rule.current_q == index:
671
+ continue # Remove rules associated with the deleted question
672
+ if rule.current_q > index:
673
+ rule.current_q -= 1
674
+ if rule.next_q > index:
675
+ rule.next_q -= 1
676
+
677
+ if rule.next_q == index:
678
+ if index == len(self.questions):
679
+ rule.next_q = EndOfSurvey
680
+ else:
681
+ rule.next_q = index
682
+
683
+ new_rule_collection.add_rule(rule)
684
+ self.rule_collection = new_rule_collection
685
+
686
+ # Update memory plan if it exists
687
+ if hasattr(self, "memory_plan"):
688
+ self.memory_plan.remove_question(deleted_question.question_name)
689
+
690
+ return self
538
691
 
539
692
  def add_question(
540
693
  self, question: QuestionBase, index: Optional[int] = None
@@ -558,17 +711,81 @@ class Survey(SurveyExportMixin, Base):
558
711
  edsl.exceptions.surveys.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
559
712
  ...
560
713
  """
561
- return EditSurvey(self).add_question(question, index)
714
+ if question.question_name in self.question_names:
715
+ raise SurveyCreationError(
716
+ f"""Question name '{question.question_name}' already exists in survey. Existing names are {self.question_names}."""
717
+ )
718
+ if index is None:
719
+ index = len(self.questions)
562
720
 
563
- def _recombined_questions_and_instructions(
721
+ if index > len(self.questions):
722
+ raise SurveyCreationError(
723
+ f"Index {index} is greater than the number of questions in the survey."
724
+ )
725
+ if index < 0:
726
+ raise SurveyCreationError(f"Index {index} is less than 0.")
727
+
728
+ interior_insertion = index != len(self.questions)
729
+
730
+ # index = len(self.questions)
731
+ # TODO: This is a bit ugly because the user
732
+ # doesn't "know" about _questions - it's generated by the
733
+ # descriptor.
734
+ self._questions.insert(index, question)
735
+
736
+ if interior_insertion:
737
+ for question_name, old_index in self.pseudo_indices.items():
738
+ if old_index >= index:
739
+ self.pseudo_indices[question_name] = old_index + 1
740
+
741
+ self.pseudo_indices[question.question_name] = index
742
+
743
+ ## Re-do question_name to index - this is done automatically
744
+ # for question_name, old_index in self.question_name_to_index.items():
745
+ # if old_index >= index:
746
+ # self.question_name_to_index[question_name] = old_index + 1
747
+
748
+ ## Need to re-do the rule collection and the indices of the questions
749
+
750
+ ## If a rule is before the insertion index and next_q is also before the insertion index, no change needed.
751
+ ## If the rule is before the insertion index but next_q is after the insertion index, increment the next_q by 1
752
+ ## If the rule is after the insertion index, increment the current_q by 1 and the next_q by 1
753
+
754
+ # using index + 1 presumes there is a next question
755
+ if interior_insertion:
756
+ for rule in self.rule_collection:
757
+ if rule.current_q >= index:
758
+ rule.current_q += 1
759
+ if rule.next_q >= index:
760
+ rule.next_q += 1
761
+
762
+ # add a new rule
763
+ self.rule_collection.add_rule(
764
+ Rule(
765
+ current_q=index,
766
+ expression="True",
767
+ next_q=index + 1,
768
+ question_name_to_index=self.question_name_to_index,
769
+ priority=RulePriority.DEFAULT.value,
770
+ )
771
+ )
772
+
773
+ # a question might be added before the memory plan is created
774
+ # it's ok because the memory plan will be updated when it is created
775
+ if hasattr(self, "memory_plan"):
776
+ self.memory_plan.add_question(question)
777
+
778
+ return self
779
+
780
+ def recombined_questions_and_instructions(
564
781
  self,
565
782
  ) -> list[Union[QuestionBase, "Instruction"]]:
566
783
  """Return a list of questions and instructions sorted by pseudo index."""
567
784
  questions_and_instructions = self._questions + list(
568
- self._instruction_names_to_instructions.values()
785
+ self.instruction_names_to_instructions.values()
569
786
  )
570
787
  return sorted(
571
- questions_and_instructions, key=lambda x: self._pseudo_indices[x.name]
788
+ questions_and_instructions, key=lambda x: self.pseudo_indices[x.name]
572
789
  )
573
790
 
574
791
  # endregion
@@ -580,7 +797,7 @@ class Survey(SurveyExportMixin, Base):
580
797
  >>> s = Survey.example().set_full_memory_mode()
581
798
 
582
799
  """
583
- MemoryManagement(self)._set_memory_plan(lambda i: self.question_names[:i])
800
+ self._set_memory_plan(lambda i: self.question_names[:i])
584
801
  return self
585
802
 
586
803
  def set_lagged_memory(self, lags: int) -> Survey:
@@ -588,12 +805,10 @@ class Survey(SurveyExportMixin, Base):
588
805
 
589
806
  The agent should remember the answers to the questions in the survey from the previous lags.
590
807
  """
591
- MemoryManagement(self)._set_memory_plan(
592
- lambda i: self.question_names[max(0, i - lags) : i]
593
- )
808
+ self._set_memory_plan(lambda i: self.question_names[max(0, i - lags) : i])
594
809
  return self
595
810
 
596
- def _set_memory_plan(self, prior_questions_func: Callable) -> None:
811
+ def _set_memory_plan(self, prior_questions_func: Callable):
597
812
  """Set memory plan based on a provided function determining prior questions.
598
813
 
599
814
  :param prior_questions_func: A function that takes the index of the current question and returns a list of prior questions to remember.
@@ -602,7 +817,11 @@ class Survey(SurveyExportMixin, Base):
602
817
  >>> s._set_memory_plan(lambda i: s.question_names[:i])
603
818
 
604
819
  """
605
- MemoryManagement(self)._set_memory_plan(prior_questions_func)
820
+ for i, question_name in enumerate(self.question_names):
821
+ self.memory_plan.add_memory_collection(
822
+ focal_question=question_name,
823
+ prior_questions=prior_questions_func(i),
824
+ )
606
825
 
607
826
  def add_targeted_memory(
608
827
  self,
@@ -622,10 +841,20 @@ class Survey(SurveyExportMixin, Base):
622
841
 
623
842
  The agent should also remember the answers to prior_questions listed in prior_questions.
624
843
  """
625
- return MemoryManagement(self).add_targeted_memory(
626
- focal_question, prior_question
844
+ focal_question_name = self.question_names[
845
+ self._get_question_index(focal_question)
846
+ ]
847
+ prior_question_name = self.question_names[
848
+ self._get_question_index(prior_question)
849
+ ]
850
+
851
+ self.memory_plan.add_single_memory(
852
+ focal_question=focal_question_name,
853
+ prior_question=prior_question_name,
627
854
  )
628
855
 
856
+ return self
857
+
629
858
  def add_memory_collection(
630
859
  self,
631
860
  focal_question: Union[QuestionBase, str],
@@ -644,9 +873,23 @@ class Survey(SurveyExportMixin, Base):
644
873
  >>> s.memory_plan
645
874
  {'q2': Memory(prior_questions=['q0', 'q1'])}
646
875
  """
647
- return MemoryManagement(self).add_memory_collection(
648
- focal_question, prior_questions
876
+ focal_question_name = self.question_names[
877
+ self._get_question_index(focal_question)
878
+ ]
879
+
880
+ prior_question_names = [
881
+ self.question_names[self._get_question_index(prior_question)]
882
+ for prior_question in prior_questions
883
+ ]
884
+
885
+ self.memory_plan.add_memory_collection(
886
+ focal_question=focal_question_name, prior_questions=prior_question_names
649
887
  )
888
+ return self
889
+
890
+ # endregion
891
+ # endregion
892
+ # endregion
650
893
 
651
894
  # region: Question groups
652
895
  def add_question_group(
@@ -741,9 +984,16 @@ class Survey(SurveyExportMixin, Base):
741
984
 
742
985
  >>> s = Survey.example()
743
986
  >>> s.show_rules()
744
- Dataset([{'current_q': [0, 0, 1, 2]}, {'expression': ['True', "q0 == 'yes'", 'True', 'True']}, {'next_q': [1, 2, 2, 3]}, {'priority': [-1, 0, -1, -1]}, {'before_rule': [False, False, False, False]}])
987
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
988
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
989
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
990
+ │ 0 │ True │ 1 │ -1 │ False │
991
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
992
+ │ 1 │ True │ 2 │ -1 │ False │
993
+ │ 2 │ True │ 3 │ -1 │ False │
994
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
745
995
  """
746
- return self.rule_collection.show_rules()
996
+ self.rule_collection.show_rules()
747
997
 
748
998
  def add_stop_rule(
749
999
  self, question: Union[QuestionBase, str], expression: str
@@ -773,15 +1023,41 @@ class Survey(SurveyExportMixin, Base):
773
1023
  edsl.exceptions.surveys.SurveyCreationError: The expression contains '<>', which is not allowed. You probably mean '!='.
774
1024
  ...
775
1025
  """
776
- return RuleManager(self).add_stop_rule(question, expression)
1026
+ expression = ValidatedString(expression)
1027
+ prior_question_appears = False
1028
+ for prior_question in self.questions:
1029
+ if prior_question.question_name in expression:
1030
+ prior_question_appears = True
1031
+
1032
+ if not prior_question_appears:
1033
+ import warnings
1034
+
1035
+ warnings.warn(
1036
+ f"The expression {expression} does not contain any prior question names. This is probably a mistake."
1037
+ )
1038
+ self.add_rule(question, expression, EndOfSurvey)
1039
+ return self
777
1040
 
778
1041
  def clear_non_default_rules(self) -> Survey:
779
1042
  """Remove all non-default rules from the survey.
780
1043
 
781
1044
  >>> Survey.example().show_rules()
782
- Dataset([{'current_q': [0, 0, 1, 2]}, {'expression': ['True', "q0 == 'yes'", 'True', 'True']}, {'next_q': [1, 2, 2, 3]}, {'priority': [-1, 0, -1, -1]}, {'before_rule': [False, False, False, False]}])
1045
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1046
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1047
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1048
+ │ 0 │ True │ 1 │ -1 │ False │
1049
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1050
+ │ 1 │ True │ 2 │ -1 │ False │
1051
+ │ 2 │ True │ 3 │ -1 │ False │
1052
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
783
1053
  >>> Survey.example().clear_non_default_rules().show_rules()
784
- Dataset([{'current_q': [0, 1, 2]}, {'expression': ['True', 'True', 'True']}, {'next_q': [1, 2, 3]}, {'priority': [-1, -1, -1]}, {'before_rule': [False, False, False]}])
1054
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1055
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1056
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1057
+ │ 0 │ True │ 1 │ -1 │ False │
1058
+ │ 1 │ True │ 2 │ -1 │ False │
1059
+ │ 2 │ True │ 3 │ -1 │ False │
1060
+ └───────────┴────────────┴────────┴──────────┴─────────────┘
785
1061
  """
786
1062
  s = Survey()
787
1063
  for question in self.questions:
@@ -812,9 +1088,38 @@ class Survey(SurveyExportMixin, Base):
812
1088
 
813
1089
  """
814
1090
  question_index = self._get_question_index(question)
815
- return RuleManager(self).add_rule(
816
- question, expression, question_index + 1, before_rule=True
1091
+ self._add_rule(question, expression, question_index + 1, before_rule=True)
1092
+ return self
1093
+
1094
+ def _get_new_rule_priority(
1095
+ self, question_index: int, before_rule: bool = False
1096
+ ) -> int:
1097
+ """Return the priority for the new rule.
1098
+
1099
+ :param question_index: The index of the question to add the rule to.
1100
+ :param before_rule: Whether the rule is evaluated before the question is answered.
1101
+
1102
+ >>> s = Survey.example()
1103
+ >>> s._get_new_rule_priority(0)
1104
+ 1
1105
+ """
1106
+ current_priorities = [
1107
+ rule.priority
1108
+ for rule in self.rule_collection.applicable_rules(
1109
+ question_index, before_rule
1110
+ )
1111
+ ]
1112
+ if len(current_priorities) == 0:
1113
+ return RulePriority.DEFAULT.value + 1
1114
+
1115
+ max_priority = max(current_priorities)
1116
+ # newer rules take priority over older rules
1117
+ new_priority = (
1118
+ RulePriority.DEFAULT.value
1119
+ if len(current_priorities) == 0
1120
+ else max_priority + 1
817
1121
  )
1122
+ return new_priority
818
1123
 
819
1124
  def add_rule(
820
1125
  self,
@@ -838,10 +1143,52 @@ class Survey(SurveyExportMixin, Base):
838
1143
  'q2'
839
1144
 
840
1145
  """
841
- return RuleManager(self).add_rule(
1146
+ return self._add_rule(
842
1147
  question, expression, next_question, before_rule=before_rule
843
1148
  )
844
1149
 
1150
+ def _add_rule(
1151
+ self,
1152
+ question: Union[QuestionBase, str],
1153
+ expression: str,
1154
+ next_question: Union[QuestionBase, str, int],
1155
+ before_rule: bool = False,
1156
+ ) -> Survey:
1157
+ """
1158
+ Add a rule to a Question of the Survey with the appropriate priority.
1159
+
1160
+ :param question: The question to add the rule to.
1161
+ :param expression: The expression to evaluate.
1162
+ :param next_question: The next question to go to if the rule is true.
1163
+ :param before_rule: Whether the rule is evaluated before the question is answered.
1164
+
1165
+
1166
+ - The last rule added for the question will have the highest priority.
1167
+ - If there are no rules, the rule added gets priority -1.
1168
+ """
1169
+ question_index = self._get_question_index(question)
1170
+
1171
+ # Might not have the name of the next question yet
1172
+ if isinstance(next_question, int):
1173
+ next_question_index = next_question
1174
+ else:
1175
+ next_question_index = self._get_question_index(next_question)
1176
+
1177
+ new_priority = self._get_new_rule_priority(question_index, before_rule)
1178
+
1179
+ self.rule_collection.add_rule(
1180
+ Rule(
1181
+ current_q=question_index,
1182
+ expression=expression,
1183
+ next_q=next_question_index,
1184
+ question_name_to_index=self.question_name_to_index,
1185
+ priority=new_priority,
1186
+ before_rule=before_rule,
1187
+ )
1188
+ )
1189
+
1190
+ return self
1191
+
845
1192
  # endregion
846
1193
 
847
1194
  # region: Forward methods
@@ -852,26 +1199,22 @@ class Survey(SurveyExportMixin, Base):
852
1199
 
853
1200
  This takes the survey and adds an Agent and a Scenario via 'by' which converts to a Jobs object:
854
1201
 
855
- >>> s = Survey.example(); from edsl.agents import Agent; from edsl import Scenario
1202
+ >>> s = Survey.example(); from edsl import Agent; from edsl import Scenario
856
1203
  >>> s.by(Agent.example()).by(Scenario.example())
857
1204
  Jobs(...)
858
1205
  """
859
1206
  from edsl.jobs.Jobs import Jobs
860
1207
 
861
- return Jobs(survey=self).by(*args)
1208
+ job = Jobs(survey=self)
1209
+ return job.by(*args)
862
1210
 
863
1211
  def to_jobs(self):
864
- """Convert the survey to a Jobs object.
865
- >>> s = Survey.example()
866
- >>> s.to_jobs()
867
- Jobs(...)
868
- """
1212
+ """Convert the survey to a Jobs object."""
869
1213
  from edsl.jobs.Jobs import Jobs
870
1214
 
871
1215
  return Jobs(survey=self)
872
1216
 
873
1217
  def show_prompts(self):
874
- """Show the prompts for the survey."""
875
1218
  return self.to_jobs().show_prompts()
876
1219
 
877
1220
  # endregion
@@ -883,7 +1226,6 @@ class Survey(SurveyExportMixin, Base):
883
1226
  model=None,
884
1227
  agent=None,
885
1228
  cache=None,
886
- verbose=False,
887
1229
  disable_remote_cache: bool = False,
888
1230
  disable_remote_inference: bool = False,
889
1231
  **kwargs,
@@ -899,21 +1241,19 @@ class Survey(SurveyExportMixin, Base):
899
1241
  >>> s(period = "evening", cache = False, disable_remote_cache = True, disable_remote_inference = True).select("answer.q0").first()
900
1242
  'no'
901
1243
  """
902
-
903
- return self.get_job(model, agent, **kwargs).run(
1244
+ job = self.get_job(model, agent, **kwargs)
1245
+ return job.run(
904
1246
  cache=cache,
905
- verbose=verbose,
906
1247
  disable_remote_cache=disable_remote_cache,
907
1248
  disable_remote_inference=disable_remote_inference,
908
1249
  )
909
1250
 
910
1251
  async def run_async(
911
1252
  self,
912
- model: Optional["LanguageModel"] = None,
1253
+ model: Optional["Model"] = None,
913
1254
  agent: Optional["Agent"] = None,
914
1255
  cache: Optional["Cache"] = None,
915
1256
  disable_remote_inference: bool = False,
916
- disable_remote_cache: bool = False,
917
1257
  **kwargs,
918
1258
  ):
919
1259
  """Run the survey with default model, taking the required survey as arguments.
@@ -923,7 +1263,7 @@ class Survey(SurveyExportMixin, Base):
923
1263
  >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
924
1264
  >>> q = QuestionFunctional(question_name = "q0", func = f)
925
1265
  >>> s = Survey([q])
926
- >>> async def test_run_async(): result = await s.run_async(period="morning", disable_remote_inference = True, disable_remote_cache=True); print(result.select("answer.q0").first())
1266
+ >>> async def test_run_async(): result = await s.run_async(period="morning", disable_remote_inference = True); print(result.select("answer.q0").first())
927
1267
  >>> asyncio.run(test_run_async())
928
1268
  yes
929
1269
  >>> import asyncio
@@ -931,23 +1271,20 @@ class Survey(SurveyExportMixin, Base):
931
1271
  >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
932
1272
  >>> q = QuestionFunctional(question_name = "q0", func = f)
933
1273
  >>> s = Survey([q])
934
- >>> async def test_run_async(): result = await s.run_async(period="evening", disable_remote_inference = True, disable_remote_cache = True); print(result.select("answer.q0").first())
935
- >>> results = asyncio.run(test_run_async())
1274
+ >>> async def test_run_async(): result = await s.run_async(period="evening", disable_remote_inference = True); print(result.select("answer.q0").first())
1275
+ >>> asyncio.run(test_run_async())
936
1276
  no
937
1277
  """
938
1278
  # TODO: temp fix by creating a cache
939
1279
  if cache is None:
940
1280
  from edsl.data import Cache
1281
+
941
1282
  c = Cache()
942
1283
  else:
943
1284
  c = cache
944
-
945
-
946
-
947
- jobs: "Jobs" = self.get_job(model=model, agent=agent, **kwargs).using(c)
1285
+ jobs: "Jobs" = self.get_job(model=model, agent=agent, **kwargs)
948
1286
  return await jobs.run_async(
949
- disable_remote_inference=disable_remote_inference,
950
- disable_remote_cache=disable_remote_cache,
1287
+ cache=c, disable_remote_inference=disable_remote_inference
951
1288
  )
952
1289
 
953
1290
  def run(self, *args, **kwargs) -> "Results":
@@ -965,30 +1302,9 @@ class Survey(SurveyExportMixin, Base):
965
1302
 
966
1303
  return Jobs(survey=self).run(*args, **kwargs)
967
1304
 
968
- def using(self, obj: Union["Cache", "KeyLookup", "BucketCollection"]) -> "Jobs":
969
- """Turn the survey into a Job and appends the arguments to the Job."""
970
- from edsl.jobs.Jobs import Jobs
971
-
972
- return Jobs(survey=self).using(obj)
973
-
974
- def duplicate(self):
975
- """Duplicate the survey.
976
-
977
- >>> s = Survey.example()
978
- >>> s2 = s.duplicate()
979
- >>> s == s2
980
- True
981
- >>> s is s2
982
- False
983
-
984
- """
985
- return Survey.from_dict(self.to_dict())
986
-
987
1305
  # region: Survey flow
988
1306
  def next_question(
989
- self,
990
- current_question: Optional[Union[str, QuestionBase]] = None,
991
- answers: Optional[dict] = None,
1307
+ self, current_question: Union[str, QuestionBase], answers: dict
992
1308
  ) -> Union[QuestionBase, EndOfSurvey.__class__]:
993
1309
  """
994
1310
  Return the next question in a survey.
@@ -1007,11 +1323,8 @@ class Survey(SurveyExportMixin, Base):
1007
1323
  'q1'
1008
1324
 
1009
1325
  """
1010
- if current_question is None:
1011
- return self.questions[0]
1012
-
1013
1326
  if isinstance(current_question, str):
1014
- current_question = self._get_question_by_name(current_question)
1327
+ current_question = self.get_question(current_question)
1015
1328
 
1016
1329
  question_index = self.question_name_to_index[current_question.question_name]
1017
1330
  next_question_object = self.rule_collection.next_question(
@@ -1041,7 +1354,14 @@ class Survey(SurveyExportMixin, Base):
1041
1354
 
1042
1355
  >>> s = Survey.example()
1043
1356
  >>> s.show_rules()
1044
- Dataset([{'current_q': [0, 0, 1, 2]}, {'expression': ['True', "q0 == 'yes'", 'True', 'True']}, {'next_q': [1, 2, 2, 3]}, {'priority': [-1, 0, -1, -1]}, {'before_rule': [False, False, False, False]}])
1357
+ ┏━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━━━━━━┓
1358
+ ┃ current_q ┃ expression ┃ next_q ┃ priority ┃ before_rule ┃
1359
+ ┡━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━━━━━━┩
1360
+ │ 0 │ True │ 1 │ -1 │ False │
1361
+ │ 0 │ q0 == 'yes' │ 2 │ 0 │ False │
1362
+ │ 1 │ True │ 2 │ -1 │ False │
1363
+ │ 2 │ True │ 3 │ -1 │ False │
1364
+ └───────────┴─────────────┴────────┴──────────┴─────────────┘
1045
1365
 
1046
1366
  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.
1047
1367
 
@@ -1070,6 +1390,7 @@ class Survey(SurveyExportMixin, Base):
1070
1390
  question = self.next_question(question, self.answers)
1071
1391
 
1072
1392
  while not question == EndOfSurvey:
1393
+ # breakpoint()
1073
1394
  answer = yield question
1074
1395
  self.answers.update(answer)
1075
1396
  # print(f"Answers: {self.answers}")
@@ -1078,6 +1399,69 @@ class Survey(SurveyExportMixin, Base):
1078
1399
 
1079
1400
  # endregion
1080
1401
 
1402
+ # regions: DAG construction
1403
+ def textify(self, index_dag: DAG) -> DAG:
1404
+ """Convert the DAG of question indices to a DAG of question names.
1405
+
1406
+ :param index_dag: The DAG of question indices.
1407
+
1408
+ Example:
1409
+
1410
+ >>> s = Survey.example()
1411
+ >>> d = s.dag()
1412
+ >>> d
1413
+ {1: {0}, 2: {0}}
1414
+ >>> s.textify(d)
1415
+ {'q1': {'q0'}, 'q2': {'q0'}}
1416
+ """
1417
+
1418
+ def get_name(index: int):
1419
+ """Return the name of the question given the index."""
1420
+ if index >= len(self.questions):
1421
+ return EndOfSurvey
1422
+ try:
1423
+ return self.questions[index].question_name
1424
+ except IndexError:
1425
+ print(
1426
+ f"The index is {index} but the length of the questions is {len(self.questions)}"
1427
+ )
1428
+ raise SurveyError
1429
+
1430
+ try:
1431
+ text_dag = {}
1432
+ for child_index, parent_indices in index_dag.items():
1433
+ parent_names = {get_name(index) for index in parent_indices}
1434
+ child_name = get_name(child_index)
1435
+ text_dag[child_name] = parent_names
1436
+ return text_dag
1437
+ except IndexError:
1438
+ raise
1439
+
1440
+ @property
1441
+ def piping_dag(self) -> DAG:
1442
+ """Figures out the DAG of piping dependencies.
1443
+
1444
+ >>> from edsl import QuestionFreeText
1445
+ >>> q0 = QuestionFreeText(question_text="Here is a question", question_name="q0")
1446
+ >>> q1 = QuestionFreeText(question_text="You previously answered {{ q0 }}---how do you feel now?", question_name="q1")
1447
+ >>> s = Survey([q0, q1])
1448
+ >>> s.piping_dag
1449
+ {1: {0}}
1450
+ """
1451
+ d = {}
1452
+ for question_name, depenencies in self.parameters_by_question.items():
1453
+ if depenencies:
1454
+ question_index = self.question_name_to_index[question_name]
1455
+ for dependency in depenencies:
1456
+ if dependency not in self.question_name_to_index:
1457
+ pass
1458
+ else:
1459
+ dependency_index = self.question_name_to_index[dependency]
1460
+ if question_index not in d:
1461
+ d[question_index] = set()
1462
+ d[question_index].add(dependency_index)
1463
+ return d
1464
+
1081
1465
  def dag(self, textify: bool = False) -> DAG:
1082
1466
  """Return the DAG of the survey, which reflects both skip-logic and memory.
1083
1467
 
@@ -1089,9 +1473,14 @@ class Survey(SurveyExportMixin, Base):
1089
1473
  {1: {0}, 2: {0}}
1090
1474
 
1091
1475
  """
1092
- from edsl.surveys.ConstructDAG import ConstructDAG
1093
-
1094
- return ConstructDAG(self).dag(textify)
1476
+ memory_dag = self.memory_plan.dag
1477
+ rule_dag = self.rule_collection.dag
1478
+ piping_dag = self.piping_dag
1479
+ if textify:
1480
+ memory_dag = DAG(self.textify(memory_dag))
1481
+ rule_dag = DAG(self.textify(rule_dag))
1482
+ piping_dag = DAG(self.textify(piping_dag))
1483
+ return memory_dag + rule_dag + piping_dag
1095
1484
 
1096
1485
  ###################
1097
1486
  # DUNDER METHODS
@@ -1120,18 +1509,77 @@ class Survey(SurveyExportMixin, Base):
1120
1509
  elif isinstance(index, str):
1121
1510
  return getattr(self, index)
1122
1511
 
1123
- # def _diff(self, other):
1124
- # """Used for debugging. Print out the differences between two surveys."""
1125
- # from rich import print
1512
+ def _diff(self, other):
1513
+ """Used for debugging. Print out the differences between two surveys."""
1514
+ from rich import print
1515
+
1516
+ for key, value in self.to_dict().items():
1517
+ if value != other.to_dict()[key]:
1518
+ print(f"Key: {key}")
1519
+ print("\n")
1520
+ print(f"Self: {value}")
1521
+ print("\n")
1522
+ print(f"Other: {other.to_dict()[key]}")
1523
+ print("\n\n")
1524
+
1525
+ def __eq__(self, other) -> bool:
1526
+ """Return True if the two surveys have the same to_dict.
1527
+
1528
+ :param other: The other survey to compare to.
1529
+
1530
+ >>> s = Survey.example()
1531
+ >>> s == s
1532
+ True
1533
+
1534
+ >>> s == "poop"
1535
+ False
1536
+
1537
+ """
1538
+ if not isinstance(other, Survey):
1539
+ return False
1540
+ return self.to_dict() == other.to_dict()
1541
+
1542
+ @classmethod
1543
+ def from_qsf(
1544
+ cls, qsf_file: Optional[str] = None, url: Optional[str] = None
1545
+ ) -> Survey:
1546
+ """Create a Survey object from a Qualtrics QSF file."""
1126
1547
 
1127
- # for key, value in self.to_dict().items():
1128
- # if value != other.to_dict()[key]:
1129
- # print(f"Key: {key}")
1130
- # print("\n")
1131
- # print(f"Self: {value}")
1132
- # print("\n")
1133
- # print(f"Other: {other.to_dict()[key]}")
1134
- # print("\n\n")
1548
+ if url and qsf_file:
1549
+ raise ValueError("Only one of url or qsf_file can be provided.")
1550
+
1551
+ if (not url) and (not qsf_file):
1552
+ raise ValueError("Either url or qsf_file must be provided.")
1553
+
1554
+ if url:
1555
+ response = requests.get(url)
1556
+ response.raise_for_status() # Ensure the request was successful
1557
+
1558
+ # Save the Excel file to a temporary file
1559
+ with tempfile.NamedTemporaryFile(suffix=".qsf", delete=False) as temp_file:
1560
+ temp_file.write(response.content)
1561
+ qsf_file = temp_file.name
1562
+
1563
+ from edsl.surveys.SurveyQualtricsImport import SurveyQualtricsImport
1564
+
1565
+ so = SurveyQualtricsImport(qsf_file)
1566
+ return so.create_survey()
1567
+
1568
+ # region: Display methods
1569
+ def print(self):
1570
+ """Print the survey in a rich format.
1571
+
1572
+ >>> s = Survey.example()
1573
+ >>> s.print()
1574
+ {
1575
+ "questions": [
1576
+ ...
1577
+ }
1578
+ """
1579
+ from rich import print_json
1580
+ import json
1581
+
1582
+ print_json(json.dumps(self.to_dict()))
1135
1583
 
1136
1584
  def __repr__(self) -> str:
1137
1585
  """Return a string representation of the survey."""
@@ -1139,20 +1587,60 @@ class Survey(SurveyExportMixin, Base):
1139
1587
  # questions_string = ", ".join([repr(q) for q in self._questions])
1140
1588
  questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1141
1589
  # question_names_string = ", ".join([repr(name) for name in self.question_names])
1142
- return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups}, questions_to_randomize={self.questions_to_randomize})"
1590
+ return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups})"
1143
1591
 
1144
1592
  def _summary(self) -> dict:
1145
1593
  return {
1146
- "# questions": len(self),
1147
- "question_name list": self.question_names,
1594
+ "EDSL Class": "Survey",
1595
+ "Number of Questions": len(self),
1596
+ "Question Names": self.question_names,
1148
1597
  }
1149
1598
 
1599
+ def _repr_html_(self) -> str:
1600
+ footer = f"<a href={self.__documentation__}>(docs)</a>"
1601
+ return str(self.summary(format="html")) + footer
1602
+
1150
1603
  def tree(self, node_list: Optional[List[str]] = None):
1151
1604
  return self.to_scenario_list().tree(node_list=node_list)
1152
1605
 
1153
1606
  def table(self, *fields, tablefmt=None) -> Table:
1154
1607
  return self.to_scenario_list().to_dataset().table(*fields, tablefmt=tablefmt)
1155
1608
 
1609
+ def rich_print(self) -> Table:
1610
+ """Print the survey in a rich format.
1611
+
1612
+ >>> t = Survey.example().rich_print()
1613
+ >>> print(t) # doctest: +SKIP
1614
+ ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
1615
+ ┃ Questions ┃
1616
+ ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
1617
+ │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┓ │
1618
+ │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1619
+ │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━┩ │
1620
+ │ │ q0 │ multiple_choice │ Do you like school? │ yes, no │ │
1621
+ │ └───────────────┴─────────────────┴─────────────────────┴─────────┘ │
1622
+ │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
1623
+ │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1624
+ │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
1625
+ │ │ q1 │ multiple_choice │ Why not? │ killer bees in cafeteria, other │ │
1626
+ │ └───────────────┴─────────────────┴───────────────┴─────────────────────────────────┘ │
1627
+ │ ┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ │
1628
+ │ ┃ Question Name ┃ Question Type ┃ Question Text ┃ Options ┃ │
1629
+ │ ┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ │
1630
+ │ │ q2 │ multiple_choice │ Why? │ **lack*** of killer bees in cafeteria, other │ │
1631
+ │ └───────────────┴─────────────────┴───────────────┴──────────────────────────────────────────────┘ │
1632
+ └────────────────────────────────────────────────────────────────────────────────────────────────────┘
1633
+ """
1634
+ from rich.table import Table
1635
+
1636
+ table = Table(show_header=True, header_style="bold magenta")
1637
+ table.add_column("Questions", style="dim")
1638
+
1639
+ for question in self._questions:
1640
+ table.add_row(question.rich_print())
1641
+
1642
+ return table
1643
+
1156
1644
  # endregion
1157
1645
 
1158
1646
  def codebook(self) -> dict[str, str]:
@@ -1167,6 +1655,37 @@ class Survey(SurveyExportMixin, Base):
1167
1655
  codebook[question.question_name] = question.question_text
1168
1656
  return codebook
1169
1657
 
1658
+ # region: Export methods
1659
+ def to_csv(self, filename: str = None):
1660
+ """Export the survey to a CSV file.
1661
+
1662
+ :param filename: The name of the file to save the CSV to.
1663
+
1664
+ >>> s = Survey.example()
1665
+ >>> s.to_csv() # doctest: +SKIP
1666
+ index question_name question_text question_options question_type
1667
+ 0 0 q0 Do you like school? [yes, no] multiple_choice
1668
+ 1 1 q1 Why not? [killer bees in cafeteria, other] multiple_choice
1669
+ 2 2 q2 Why? [**lack*** of killer bees in cafeteria, other] multiple_choice
1670
+ """
1671
+ raw_data = []
1672
+ for index, question in enumerate(self._questions):
1673
+ d = {"index": index}
1674
+ question_dict = question.to_dict()
1675
+ _ = question_dict.pop("edsl_version")
1676
+ _ = question_dict.pop("edsl_class_name")
1677
+ d.update(question_dict)
1678
+ raw_data.append(d)
1679
+ from pandas import DataFrame
1680
+
1681
+ df = DataFrame(raw_data)
1682
+ if filename:
1683
+ df.to_csv(filename, index=False)
1684
+ else:
1685
+ return df
1686
+
1687
+ # endregion
1688
+
1170
1689
  @classmethod
1171
1690
  def example(
1172
1691
  cls,
@@ -1225,7 +1744,7 @@ class Survey(SurveyExportMixin, Base):
1225
1744
 
1226
1745
  def get_job(self, model=None, agent=None, **kwargs):
1227
1746
  if model is None:
1228
- from edsl.language_models.model import Model
1747
+ from edsl import Model
1229
1748
 
1230
1749
  model = Model()
1231
1750
 
@@ -1234,7 +1753,7 @@ class Survey(SurveyExportMixin, Base):
1234
1753
  s = Scenario(kwargs)
1235
1754
 
1236
1755
  if not agent:
1237
- from edsl.agents.Agent import Agent
1756
+ from edsl import Agent
1238
1757
 
1239
1758
  agent = Agent()
1240
1759
 
@@ -1246,24 +1765,26 @@ def main():
1246
1765
 
1247
1766
  def example_survey():
1248
1767
  """Return an example survey."""
1249
- from edsl import QuestionMultipleChoice, QuestionList, QuestionNumerical, Survey
1768
+ from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1769
+ from edsl.surveys.Survey import Survey
1250
1770
 
1251
1771
  q0 = QuestionMultipleChoice(
1772
+ question_text="Do you like school?",
1773
+ question_options=["yes", "no"],
1252
1774
  question_name="q0",
1253
- question_text="What is the capital of France?",
1254
- question_options=["London", "Paris", "Rome", "Boston", "I don't know"]
1255
1775
  )
1256
- q1 = QuestionList(
1776
+ q1 = QuestionMultipleChoice(
1777
+ question_text="Why not?",
1778
+ question_options=["killer bees in cafeteria", "other"],
1257
1779
  question_name="q1",
1258
- question_text="Name some cities in France.",
1259
- max_list_items = 5
1260
1780
  )
1261
- q2 = QuestionNumerical(
1781
+ q2 = QuestionMultipleChoice(
1782
+ question_text="Why?",
1783
+ question_options=["**lack*** of killer bees in cafeteria", "other"],
1262
1784
  question_name="q2",
1263
- question_text="What is the population of {{ q0.answer }}?"
1264
1785
  )
1265
1786
  s = Survey(questions=[q0, q1, q2])
1266
- s = s.add_rule(q0, "q0 == 'Paris'", q2)
1787
+ s = s.add_rule(q0, "q0 == 'yes'", q2)
1267
1788
  return s
1268
1789
 
1269
1790
  s = example_survey()