edsl 0.1.39.dev3__py3-none-any.whl → 0.1.39.dev4__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 (344) hide show
  1. edsl/Base.py +413 -332
  2. edsl/BaseDiff.py +260 -260
  3. edsl/TemplateLoader.py +24 -24
  4. edsl/__init__.py +57 -49
  5. edsl/__version__.py +1 -1
  6. edsl/agents/Agent.py +1071 -867
  7. edsl/agents/AgentList.py +551 -413
  8. edsl/agents/Invigilator.py +284 -233
  9. edsl/agents/InvigilatorBase.py +257 -270
  10. edsl/agents/PromptConstructor.py +272 -354
  11. edsl/agents/QuestionInstructionPromptBuilder.py +128 -0
  12. edsl/agents/QuestionTemplateReplacementsBuilder.py +137 -0
  13. edsl/agents/__init__.py +2 -3
  14. edsl/agents/descriptors.py +99 -99
  15. edsl/agents/prompt_helpers.py +129 -129
  16. edsl/agents/question_option_processor.py +172 -0
  17. edsl/auto/AutoStudy.py +130 -117
  18. edsl/auto/StageBase.py +243 -230
  19. edsl/auto/StageGenerateSurvey.py +178 -178
  20. edsl/auto/StageLabelQuestions.py +125 -125
  21. edsl/auto/StagePersona.py +61 -61
  22. edsl/auto/StagePersonaDimensionValueRanges.py +88 -88
  23. edsl/auto/StagePersonaDimensionValues.py +74 -74
  24. edsl/auto/StagePersonaDimensions.py +69 -69
  25. edsl/auto/StageQuestions.py +74 -73
  26. edsl/auto/SurveyCreatorPipeline.py +21 -21
  27. edsl/auto/utilities.py +218 -224
  28. edsl/base/Base.py +279 -279
  29. edsl/config.py +177 -157
  30. edsl/conversation/Conversation.py +290 -290
  31. edsl/conversation/car_buying.py +59 -58
  32. edsl/conversation/chips.py +95 -95
  33. edsl/conversation/mug_negotiation.py +81 -81
  34. edsl/conversation/next_speaker_utilities.py +93 -93
  35. edsl/coop/CoopFunctionsMixin.py +15 -0
  36. edsl/coop/ExpectedParrotKeyHandler.py +125 -0
  37. edsl/coop/PriceFetcher.py +54 -54
  38. edsl/coop/__init__.py +2 -2
  39. edsl/coop/coop.py +1106 -1028
  40. edsl/coop/utils.py +131 -131
  41. edsl/data/Cache.py +573 -555
  42. edsl/data/CacheEntry.py +230 -233
  43. edsl/data/CacheHandler.py +168 -149
  44. edsl/data/RemoteCacheSync.py +186 -78
  45. edsl/data/SQLiteDict.py +292 -292
  46. edsl/data/__init__.py +5 -4
  47. edsl/data/hack.py +10 -0
  48. edsl/data/orm.py +10 -10
  49. edsl/data_transfer_models.py +74 -73
  50. edsl/enums.py +202 -175
  51. edsl/exceptions/BaseException.py +21 -21
  52. edsl/exceptions/__init__.py +54 -54
  53. edsl/exceptions/agents.py +54 -42
  54. edsl/exceptions/cache.py +5 -5
  55. edsl/exceptions/configuration.py +16 -16
  56. edsl/exceptions/coop.py +10 -10
  57. edsl/exceptions/data.py +14 -14
  58. edsl/exceptions/general.py +34 -34
  59. edsl/exceptions/inference_services.py +5 -0
  60. edsl/exceptions/jobs.py +33 -33
  61. edsl/exceptions/language_models.py +63 -63
  62. edsl/exceptions/prompts.py +15 -15
  63. edsl/exceptions/questions.py +109 -91
  64. edsl/exceptions/results.py +29 -29
  65. edsl/exceptions/scenarios.py +29 -22
  66. edsl/exceptions/surveys.py +37 -37
  67. edsl/inference_services/AnthropicService.py +106 -87
  68. edsl/inference_services/AvailableModelCacheHandler.py +184 -0
  69. edsl/inference_services/AvailableModelFetcher.py +215 -0
  70. edsl/inference_services/AwsBedrock.py +118 -120
  71. edsl/inference_services/AzureAI.py +215 -217
  72. edsl/inference_services/DeepInfraService.py +18 -18
  73. edsl/inference_services/GoogleService.py +143 -148
  74. edsl/inference_services/GroqService.py +20 -20
  75. edsl/inference_services/InferenceServiceABC.py +80 -147
  76. edsl/inference_services/InferenceServicesCollection.py +138 -97
  77. edsl/inference_services/MistralAIService.py +120 -123
  78. edsl/inference_services/OllamaService.py +18 -18
  79. edsl/inference_services/OpenAIService.py +236 -224
  80. edsl/inference_services/PerplexityService.py +160 -163
  81. edsl/inference_services/ServiceAvailability.py +135 -0
  82. edsl/inference_services/TestService.py +90 -89
  83. edsl/inference_services/TogetherAIService.py +172 -170
  84. edsl/inference_services/data_structures.py +134 -0
  85. edsl/inference_services/models_available_cache.py +118 -118
  86. edsl/inference_services/rate_limits_cache.py +25 -25
  87. edsl/inference_services/registry.py +41 -41
  88. edsl/inference_services/write_available.py +10 -10
  89. edsl/jobs/AnswerQuestionFunctionConstructor.py +223 -0
  90. edsl/jobs/Answers.py +43 -56
  91. edsl/jobs/FetchInvigilator.py +47 -0
  92. edsl/jobs/InterviewTaskManager.py +98 -0
  93. edsl/jobs/InterviewsConstructor.py +50 -0
  94. edsl/jobs/Jobs.py +823 -898
  95. edsl/jobs/JobsChecks.py +172 -147
  96. edsl/jobs/JobsComponentConstructor.py +189 -0
  97. edsl/jobs/JobsPrompts.py +270 -268
  98. edsl/jobs/JobsRemoteInferenceHandler.py +311 -239
  99. edsl/jobs/JobsRemoteInferenceLogger.py +239 -0
  100. edsl/jobs/RequestTokenEstimator.py +30 -0
  101. edsl/jobs/__init__.py +1 -1
  102. edsl/jobs/async_interview_runner.py +138 -0
  103. edsl/jobs/buckets/BucketCollection.py +104 -63
  104. edsl/jobs/buckets/ModelBuckets.py +65 -65
  105. edsl/jobs/buckets/TokenBucket.py +283 -251
  106. edsl/jobs/buckets/TokenBucketAPI.py +211 -0
  107. edsl/jobs/buckets/TokenBucketClient.py +191 -0
  108. edsl/jobs/check_survey_scenario_compatibility.py +85 -0
  109. edsl/jobs/data_structures.py +120 -0
  110. edsl/jobs/decorators.py +35 -0
  111. edsl/jobs/interviews/Interview.py +396 -661
  112. edsl/jobs/interviews/InterviewExceptionCollection.py +99 -99
  113. edsl/jobs/interviews/InterviewExceptionEntry.py +186 -186
  114. edsl/jobs/interviews/InterviewStatistic.py +63 -63
  115. edsl/jobs/interviews/InterviewStatisticsCollection.py +25 -25
  116. edsl/jobs/interviews/InterviewStatusDictionary.py +78 -78
  117. edsl/jobs/interviews/InterviewStatusLog.py +92 -92
  118. edsl/jobs/interviews/ReportErrors.py +66 -66
  119. edsl/jobs/interviews/interview_status_enum.py +9 -9
  120. edsl/jobs/jobs_status_enums.py +9 -0
  121. edsl/jobs/loggers/HTMLTableJobLogger.py +304 -0
  122. edsl/jobs/results_exceptions_handler.py +98 -0
  123. edsl/jobs/runners/JobsRunnerAsyncio.py +151 -466
  124. edsl/jobs/runners/JobsRunnerStatus.py +297 -330
  125. edsl/jobs/tasks/QuestionTaskCreator.py +244 -242
  126. edsl/jobs/tasks/TaskCreators.py +64 -64
  127. edsl/jobs/tasks/TaskHistory.py +470 -450
  128. edsl/jobs/tasks/TaskStatusLog.py +23 -23
  129. edsl/jobs/tasks/task_status_enum.py +161 -163
  130. edsl/jobs/tokens/InterviewTokenUsage.py +27 -27
  131. edsl/jobs/tokens/TokenUsage.py +34 -34
  132. edsl/language_models/ComputeCost.py +63 -0
  133. edsl/language_models/LanguageModel.py +626 -668
  134. edsl/language_models/ModelList.py +164 -155
  135. edsl/language_models/PriceManager.py +127 -0
  136. edsl/language_models/RawResponseHandler.py +106 -0
  137. edsl/language_models/RegisterLanguageModelsMeta.py +184 -184
  138. edsl/language_models/ServiceDataSources.py +0 -0
  139. edsl/language_models/__init__.py +2 -3
  140. edsl/language_models/fake_openai_call.py +15 -15
  141. edsl/language_models/fake_openai_service.py +61 -61
  142. edsl/language_models/key_management/KeyLookup.py +63 -0
  143. edsl/language_models/key_management/KeyLookupBuilder.py +273 -0
  144. edsl/language_models/key_management/KeyLookupCollection.py +38 -0
  145. edsl/language_models/key_management/__init__.py +0 -0
  146. edsl/language_models/key_management/models.py +131 -0
  147. edsl/language_models/model.py +256 -0
  148. edsl/language_models/repair.py +156 -156
  149. edsl/language_models/utilities.py +65 -64
  150. edsl/notebooks/Notebook.py +263 -258
  151. edsl/notebooks/NotebookToLaTeX.py +142 -0
  152. edsl/notebooks/__init__.py +1 -1
  153. edsl/prompts/Prompt.py +352 -362
  154. edsl/prompts/__init__.py +2 -2
  155. edsl/questions/ExceptionExplainer.py +77 -0
  156. edsl/questions/HTMLQuestion.py +103 -0
  157. edsl/questions/QuestionBase.py +518 -664
  158. edsl/questions/QuestionBasePromptsMixin.py +221 -217
  159. edsl/questions/QuestionBudget.py +227 -227
  160. edsl/questions/QuestionCheckBox.py +359 -359
  161. edsl/questions/QuestionExtract.py +180 -182
  162. edsl/questions/QuestionFreeText.py +113 -114
  163. edsl/questions/QuestionFunctional.py +166 -166
  164. edsl/questions/QuestionList.py +223 -231
  165. edsl/questions/QuestionMatrix.py +265 -0
  166. edsl/questions/QuestionMultipleChoice.py +330 -286
  167. edsl/questions/QuestionNumerical.py +151 -153
  168. edsl/questions/QuestionRank.py +314 -324
  169. edsl/questions/Quick.py +41 -41
  170. edsl/questions/SimpleAskMixin.py +74 -73
  171. edsl/questions/__init__.py +27 -26
  172. edsl/questions/{AnswerValidatorMixin.py → answer_validator_mixin.py} +334 -289
  173. edsl/questions/compose_questions.py +98 -98
  174. edsl/questions/data_structures.py +20 -0
  175. edsl/questions/decorators.py +21 -21
  176. edsl/questions/derived/QuestionLikertFive.py +76 -76
  177. edsl/questions/derived/QuestionLinearScale.py +90 -87
  178. edsl/questions/derived/QuestionTopK.py +93 -93
  179. edsl/questions/derived/QuestionYesNo.py +82 -82
  180. edsl/questions/descriptors.py +427 -413
  181. edsl/questions/loop_processor.py +149 -0
  182. edsl/questions/prompt_templates/question_budget.jinja +13 -13
  183. edsl/questions/prompt_templates/question_checkbox.jinja +32 -32
  184. edsl/questions/prompt_templates/question_extract.jinja +11 -11
  185. edsl/questions/prompt_templates/question_free_text.jinja +3 -3
  186. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -11
  187. edsl/questions/prompt_templates/question_list.jinja +17 -17
  188. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -33
  189. edsl/questions/prompt_templates/question_numerical.jinja +36 -36
  190. edsl/questions/{QuestionBaseGenMixin.py → question_base_gen_mixin.py} +168 -161
  191. edsl/questions/question_registry.py +177 -177
  192. edsl/questions/{RegisterQuestionsMeta.py → register_questions_meta.py} +71 -71
  193. edsl/questions/{ResponseValidatorABC.py → response_validator_abc.py} +188 -174
  194. edsl/questions/response_validator_factory.py +34 -0
  195. edsl/questions/settings.py +12 -12
  196. edsl/questions/templates/budget/answering_instructions.jinja +7 -7
  197. edsl/questions/templates/budget/question_presentation.jinja +7 -7
  198. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -10
  199. edsl/questions/templates/checkbox/question_presentation.jinja +22 -22
  200. edsl/questions/templates/extract/answering_instructions.jinja +7 -7
  201. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -10
  202. edsl/questions/templates/likert_five/question_presentation.jinja +11 -11
  203. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -5
  204. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -5
  205. edsl/questions/templates/list/answering_instructions.jinja +3 -3
  206. edsl/questions/templates/list/question_presentation.jinja +5 -5
  207. edsl/questions/templates/matrix/__init__.py +1 -0
  208. edsl/questions/templates/matrix/answering_instructions.jinja +5 -0
  209. edsl/questions/templates/matrix/question_presentation.jinja +20 -0
  210. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -9
  211. edsl/questions/templates/multiple_choice/question_presentation.jinja +11 -11
  212. edsl/questions/templates/numerical/answering_instructions.jinja +6 -6
  213. edsl/questions/templates/numerical/question_presentation.jinja +6 -6
  214. edsl/questions/templates/rank/answering_instructions.jinja +11 -11
  215. edsl/questions/templates/rank/question_presentation.jinja +15 -15
  216. edsl/questions/templates/top_k/answering_instructions.jinja +8 -8
  217. edsl/questions/templates/top_k/question_presentation.jinja +22 -22
  218. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -6
  219. edsl/questions/templates/yes_no/question_presentation.jinja +11 -11
  220. edsl/results/CSSParameterizer.py +108 -108
  221. edsl/results/Dataset.py +587 -424
  222. edsl/results/DatasetExportMixin.py +594 -731
  223. edsl/results/DatasetTree.py +295 -275
  224. edsl/results/MarkdownToDocx.py +122 -0
  225. edsl/results/MarkdownToPDF.py +111 -0
  226. edsl/results/Result.py +557 -465
  227. edsl/results/Results.py +1183 -1165
  228. edsl/results/ResultsExportMixin.py +45 -43
  229. edsl/results/ResultsGGMixin.py +121 -121
  230. edsl/results/TableDisplay.py +125 -198
  231. edsl/results/TextEditor.py +50 -0
  232. edsl/results/__init__.py +2 -2
  233. edsl/results/file_exports.py +252 -0
  234. edsl/results/{ResultsFetchMixin.py → results_fetch_mixin.py} +33 -33
  235. edsl/results/{Selector.py → results_selector.py} +145 -135
  236. edsl/results/{ResultsToolsMixin.py → results_tools_mixin.py} +98 -98
  237. edsl/results/smart_objects.py +96 -0
  238. edsl/results/table_data_class.py +12 -0
  239. edsl/results/table_display.css +77 -77
  240. edsl/results/table_renderers.py +118 -0
  241. edsl/results/tree_explore.py +115 -115
  242. edsl/scenarios/ConstructDownloadLink.py +109 -0
  243. edsl/scenarios/DocumentChunker.py +102 -0
  244. edsl/scenarios/DocxScenario.py +16 -0
  245. edsl/scenarios/FileStore.py +511 -632
  246. edsl/scenarios/PdfExtractor.py +40 -0
  247. edsl/scenarios/Scenario.py +498 -601
  248. edsl/scenarios/ScenarioHtmlMixin.py +65 -64
  249. edsl/scenarios/ScenarioList.py +1458 -1287
  250. edsl/scenarios/ScenarioListExportMixin.py +45 -52
  251. edsl/scenarios/ScenarioListPdfMixin.py +239 -261
  252. edsl/scenarios/__init__.py +3 -4
  253. edsl/scenarios/directory_scanner.py +96 -0
  254. edsl/scenarios/file_methods.py +85 -0
  255. edsl/scenarios/handlers/__init__.py +13 -0
  256. edsl/scenarios/handlers/csv.py +38 -0
  257. edsl/scenarios/handlers/docx.py +76 -0
  258. edsl/scenarios/handlers/html.py +37 -0
  259. edsl/scenarios/handlers/json.py +111 -0
  260. edsl/scenarios/handlers/latex.py +5 -0
  261. edsl/scenarios/handlers/md.py +51 -0
  262. edsl/scenarios/handlers/pdf.py +68 -0
  263. edsl/scenarios/handlers/png.py +39 -0
  264. edsl/scenarios/handlers/pptx.py +105 -0
  265. edsl/scenarios/handlers/py.py +294 -0
  266. edsl/scenarios/handlers/sql.py +313 -0
  267. edsl/scenarios/handlers/sqlite.py +149 -0
  268. edsl/scenarios/handlers/txt.py +33 -0
  269. edsl/scenarios/{ScenarioJoin.py → scenario_join.py} +131 -127
  270. edsl/scenarios/scenario_selector.py +156 -0
  271. edsl/shared.py +1 -1
  272. edsl/study/ObjectEntry.py +173 -173
  273. edsl/study/ProofOfWork.py +113 -113
  274. edsl/study/SnapShot.py +80 -80
  275. edsl/study/Study.py +521 -528
  276. edsl/study/__init__.py +4 -4
  277. edsl/surveys/ConstructDAG.py +92 -0
  278. edsl/surveys/DAG.py +148 -148
  279. edsl/surveys/EditSurvey.py +221 -0
  280. edsl/surveys/InstructionHandler.py +100 -0
  281. edsl/surveys/Memory.py +31 -31
  282. edsl/surveys/MemoryManagement.py +72 -0
  283. edsl/surveys/MemoryPlan.py +244 -244
  284. edsl/surveys/Rule.py +327 -326
  285. edsl/surveys/RuleCollection.py +385 -387
  286. edsl/surveys/RuleManager.py +172 -0
  287. edsl/surveys/Simulator.py +75 -0
  288. edsl/surveys/Survey.py +1280 -1801
  289. edsl/surveys/SurveyCSS.py +273 -261
  290. edsl/surveys/SurveyExportMixin.py +259 -259
  291. edsl/surveys/{SurveyFlowVisualizationMixin.py → SurveyFlowVisualization.py} +181 -179
  292. edsl/surveys/SurveyQualtricsImport.py +284 -284
  293. edsl/surveys/SurveyToApp.py +141 -0
  294. edsl/surveys/__init__.py +5 -3
  295. edsl/surveys/base.py +53 -53
  296. edsl/surveys/descriptors.py +60 -56
  297. edsl/surveys/instructions/ChangeInstruction.py +48 -49
  298. edsl/surveys/instructions/Instruction.py +56 -65
  299. edsl/surveys/instructions/InstructionCollection.py +82 -77
  300. edsl/templates/error_reporting/base.html +23 -23
  301. edsl/templates/error_reporting/exceptions_by_model.html +34 -34
  302. edsl/templates/error_reporting/exceptions_by_question_name.html +16 -16
  303. edsl/templates/error_reporting/exceptions_by_type.html +16 -16
  304. edsl/templates/error_reporting/interview_details.html +115 -115
  305. edsl/templates/error_reporting/interviews.html +19 -19
  306. edsl/templates/error_reporting/overview.html +4 -4
  307. edsl/templates/error_reporting/performance_plot.html +1 -1
  308. edsl/templates/error_reporting/report.css +73 -73
  309. edsl/templates/error_reporting/report.html +117 -117
  310. edsl/templates/error_reporting/report.js +25 -25
  311. edsl/test_h +1 -0
  312. edsl/tools/__init__.py +1 -1
  313. edsl/tools/clusters.py +192 -192
  314. edsl/tools/embeddings.py +27 -27
  315. edsl/tools/embeddings_plotting.py +118 -118
  316. edsl/tools/plotting.py +112 -112
  317. edsl/tools/summarize.py +18 -18
  318. edsl/utilities/PrettyList.py +56 -0
  319. edsl/utilities/SystemInfo.py +28 -28
  320. edsl/utilities/__init__.py +22 -22
  321. edsl/utilities/ast_utilities.py +25 -25
  322. edsl/utilities/data/Registry.py +6 -6
  323. edsl/utilities/data/__init__.py +1 -1
  324. edsl/utilities/data/scooter_results.json +1 -1
  325. edsl/utilities/decorators.py +77 -77
  326. edsl/utilities/gcp_bucket/cloud_storage.py +96 -96
  327. edsl/utilities/gcp_bucket/example.py +50 -0
  328. edsl/utilities/interface.py +627 -627
  329. edsl/utilities/is_notebook.py +18 -0
  330. edsl/utilities/is_valid_variable_name.py +11 -0
  331. edsl/utilities/naming_utilities.py +263 -263
  332. edsl/utilities/remove_edsl_version.py +24 -0
  333. edsl/utilities/repair_functions.py +28 -28
  334. edsl/utilities/restricted_python.py +70 -70
  335. edsl/utilities/utilities.py +436 -424
  336. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev4.dist-info}/LICENSE +21 -21
  337. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev4.dist-info}/METADATA +13 -11
  338. edsl-0.1.39.dev4.dist-info/RECORD +361 -0
  339. edsl/language_models/KeyLookup.py +0 -30
  340. edsl/language_models/registry.py +0 -190
  341. edsl/language_models/unused/ReplicateBase.py +0 -83
  342. edsl/results/ResultsDBMixin.py +0 -238
  343. edsl-0.1.39.dev3.dist-info/RECORD +0 -277
  344. {edsl-0.1.39.dev3.dist-info → edsl-0.1.39.dev4.dist-info}/WHEEL +0 -0
edsl/surveys/Survey.py CHANGED
@@ -1,1801 +1,1280 @@
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.exceptions.surveys import SurveyError
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
24
-
25
- from edsl.agents.Agent import Agent
26
-
27
- from edsl.surveys.instructions.InstructionCollection import InstructionCollection
28
- from edsl.surveys.instructions.Instruction import Instruction
29
- from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
30
-
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)
39
-
40
-
41
- class Survey(SurveyExportMixin, SurveyFlowVisualizationMixin, Base):
42
- """A collection of questions that supports skip logic."""
43
-
44
- __documentation__ = """https://docs.expectedparrot.com/en/latest/surveys.html"""
45
-
46
- questions = QuestionsDescriptor()
47
- """
48
- A collection of questions that supports skip logic.
49
-
50
- Initalization:
51
- - `questions`: the questions in the survey (optional)
52
- - `question_names`: the names of the questions (optional)
53
- - `name`: the name of the survey (optional)
54
-
55
- Methods:
56
- -
57
-
58
- Notes:
59
- - The presumed order of the survey is the order in which questions are added.
60
- """
61
-
62
- def __init__(
63
- self,
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,
70
- name: Optional[str] = None,
71
- ):
72
- """Create a new survey.
73
-
74
- :param questions: The questions in the survey.
75
- :param memory_plan: The memory plan for the survey.
76
- :param rule_collection: The rule collection for the survey.
77
- :param question_groups: The groups of questions in the survey.
78
- :param name: The name of the survey - DEPRECATED.
79
-
80
-
81
- >>> from edsl import QuestionFreeText
82
- >>> q1 = QuestionFreeText(question_text = "What is your name?", question_name = "name")
83
- >>> q2 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
84
- >>> q3 = QuestionFreeText(question_text = "Is a hot dog a sandwich", question_name = "food")
85
- >>> s = Survey([q1, q2, q3], question_groups = {"demographics": (0, 1), "substantive":(3)})
86
-
87
-
88
- """
89
-
90
- self.raw_passed_questions = questions
91
-
92
- (
93
- true_questions,
94
- instruction_names_to_instructions,
95
- self.pseudo_indices,
96
- ) = self._separate_questions_and_instructions(questions or [])
97
-
98
- self.rule_collection = RuleCollection(
99
- num_questions=len(true_questions) if true_questions else None
100
- )
101
- # the RuleCollection needs to be present while we add the questions; we might override this later
102
- # if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
103
-
104
- self.questions = true_questions
105
- self.instruction_names_to_instructions = instruction_names_to_instructions
106
-
107
- self.memory_plan = memory_plan or MemoryPlan(self)
108
- if question_groups is not None:
109
- self.question_groups = question_groups
110
- else:
111
- self.question_groups = {}
112
-
113
- # if a rule collection is provided, use it instead
114
- if rule_collection is not None:
115
- self.rule_collection = rule_collection
116
-
117
- if name is not None:
118
- import warnings
119
-
120
- warnings.warn("name parameter to a survey is deprecated.")
121
-
122
- # region: Suvry instruction handling
123
- @property
124
- def relevant_instructions_dict(self) -> InstructionCollection:
125
- """Return a dictionary with keys as question names and values as instructions that are relevant to the question.
126
-
127
- >>> s = Survey.example(include_instructions=True)
128
- >>> s.relevant_instructions_dict
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!")]}
130
-
131
- """
132
- return InstructionCollection(
133
- self.instruction_names_to_instructions, self.questions
134
- )
135
-
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:
213
- """This should be a dictionry with keys as question names and values as instructions that are relevant to the question.
214
-
215
- :param question: The question to get the relevant instructions for.
216
-
217
- # Did the instruction come before the question and was it not modified by a change instruction?
218
-
219
- """
220
- return self.relevant_instructions_dict[question]
221
-
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)
252
-
253
- def add_instruction(
254
- self, instruction: Union["Instruction", "ChangeInstruction"]
255
- ) -> Survey:
256
- """
257
- Add an instruction to the survey.
258
-
259
- :param instruction: The instruction to add to the survey.
260
-
261
- >>> from edsl import Instruction
262
- >>> i = Instruction(text="Pay attention to the following questions.", name="intro")
263
- >>> s = Survey().add_instruction(i)
264
- >>> s.instruction_names_to_instructions
265
- {'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
266
- >>> s.pseudo_indices
267
- {'intro': -0.5}
268
- """
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
288
-
289
- # endregion
290
-
291
- # region: Simulation methods
292
-
293
- @classmethod
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
322
-
323
- def simulate(self) -> dict:
324
- """Simulate the survey and return the answers."""
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()
359
-
360
- # endregion
361
-
362
- # region: Access methods
363
- def _get_question_index(
364
- self, q: Union[QuestionBase, str, EndOfSurvey.__class__]
365
- ) -> Union[int, EndOfSurvey.__class__]:
366
- """Return the index of the question or EndOfSurvey object.
367
-
368
- :param q: The question or question name to get the index of.
369
-
370
- It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
371
-
372
- >>> s = Survey.example()
373
- >>> s._get_question_index("q0")
374
- 0
375
-
376
- This doesnt' work with questions that don't exist:
377
-
378
- >>> s._get_question_index("poop")
379
- Traceback (most recent call last):
380
- ...
381
- edsl.exceptions.surveys.SurveyError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
382
- ...
383
- """
384
- if q == EndOfSurvey:
385
- return EndOfSurvey
386
- else:
387
- question_name = q if isinstance(q, str) else q.question_name
388
- if question_name not in self.question_name_to_index:
389
- raise SurveyError(
390
- f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
391
- )
392
- return self.question_name_to_index[question_name]
393
-
394
- def get(self, question_name: str) -> QuestionBase:
395
- """
396
- Return the question object given the question name.
397
-
398
- :param question_name: The name of the question to get.
399
-
400
- >>> s = Survey.example()
401
- >>> s.get_question("q0")
402
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
403
- """
404
- if question_name not in self.question_name_to_index:
405
- raise SurveyError(f"Question name {question_name} not found in survey.")
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)
414
-
415
- def question_names_to_questions(self) -> dict:
416
- """Return a dictionary mapping question names to question attributes."""
417
- return {q.question_name: q for q in self.questions}
418
-
419
- @property
420
- def question_names(self) -> list[str]:
421
- """Return a list of question names in the survey.
422
-
423
- Example:
424
-
425
- >>> s = Survey.example()
426
- >>> s.question_names
427
- ['q0', 'q1', 'q2']
428
- """
429
- return [q.question_name for q in self.questions]
430
-
431
- @property
432
- def question_name_to_index(self) -> dict[str, int]:
433
- """Return a dictionary mapping question names to question indices.
434
-
435
- Example:
436
-
437
- >>> s = Survey.example()
438
- >>> s.question_name_to_index
439
- {'q0': 0, 'q1': 1, 'q2': 2}
440
- """
441
- return {q.question_name: i for i, q in enumerate(self.questions)}
442
-
443
- # endregion
444
-
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
-
452
- def to_dict(self, add_edsl_version=True) -> dict[str, Any]:
453
- """Serialize the Survey object to a dictionary.
454
-
455
- >>> s = Survey.example()
456
- >>> s.to_dict(add_edsl_version = False).keys()
457
- dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
458
- """
459
- return {
460
- "questions": [
461
- q.to_dict(add_edsl_version=add_edsl_version)
462
- for q in self.recombined_questions_and_instructions()
463
- ],
464
- "memory_plan": self.memory_plan.to_dict(add_edsl_version=add_edsl_version),
465
- "rule_collection": self.rule_collection.to_dict(
466
- add_edsl_version=add_edsl_version
467
- ),
468
- "question_groups": self.question_groups,
469
- }
470
-
471
- @classmethod
472
- @remove_edsl_version
473
- def from_dict(cls, data: dict) -> Survey:
474
- """Deserialize the dictionary back to a Survey object.
475
-
476
- :param data: The dictionary to deserialize.
477
-
478
- >>> d = Survey.example().to_dict()
479
- >>> s = Survey.from_dict(d)
480
- >>> s == Survey.example()
481
- True
482
-
483
- >>> s = Survey.example(include_instructions = True)
484
- >>> d = s.to_dict()
485
- >>> news = Survey.from_dict(d)
486
- >>> news == s
487
- True
488
-
489
- """
490
-
491
- def get_class(pass_dict):
492
- if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
493
- return QuestionBase
494
- elif class_name == "Instruction":
495
- from edsl.surveys.instructions.Instruction import Instruction
496
-
497
- return Instruction
498
- elif class_name == "ChangeInstruction":
499
- from edsl.surveys.instructions.ChangeInstruction import (
500
- ChangeInstruction,
501
- )
502
-
503
- return ChangeInstruction
504
- else:
505
- return QuestionBase
506
-
507
- questions = [
508
- get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
509
- ]
510
- memory_plan = MemoryPlan.from_dict(data["memory_plan"])
511
- survey = cls(
512
- questions=questions,
513
- memory_plan=memory_plan,
514
- rule_collection=RuleCollection.from_dict(data["rule_collection"]),
515
- question_groups=data["question_groups"],
516
- )
517
- return survey
518
-
519
- # endregion
520
-
521
- # region: Survey template parameters
522
- @property
523
- def scenario_attributes(self) -> list[str]:
524
- """Return a list of attributes that admissible Scenarios should have.
525
-
526
- Here we have a survey with a question that uses a jinja2 style {{ }} template:
527
-
528
- >>> from edsl import QuestionFreeText
529
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
530
- >>> s.scenario_attributes
531
- ['greeting']
532
-
533
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
534
- >>> s.scenario_attributes
535
- ['greeting', 'attribute']
536
-
537
-
538
- """
539
- temp = []
540
- for question in self.questions:
541
- question_text = question.question_text
542
- # extract the contents of all {{ }} in the question text using regex
543
- matches = re.findall(r"\{\{(.+?)\}\}", question_text)
544
- # remove whitespace
545
- matches = [match.strip() for match in matches]
546
- # add them to the temp list
547
- temp.extend(matches)
548
- return temp
549
-
550
- @property
551
- def parameters(self):
552
- """Return a set of parameters in the survey.
553
-
554
- >>> s = Survey.example()
555
- >>> s.parameters
556
- set()
557
- """
558
- return set.union(*[q.parameters for q in self.questions])
559
-
560
- @property
561
- def parameters_by_question(self):
562
- """Return a dictionary of parameters by question in the survey.
563
- >>> from edsl import QuestionFreeText
564
- >>> q = QuestionFreeText(question_name = "example", question_text = "What is the capital of {{ country}}?")
565
- >>> s = Survey([q])
566
- >>> s.parameters_by_question
567
- {'example': {'country'}}
568
- """
569
- return {q.question_name: q.parameters for q in self.questions}
570
-
571
- # endregion
572
-
573
- # region: Survey construction
574
-
575
- # region: Adding questions and combining surveys
576
- def __add__(self, other: Survey) -> Survey:
577
- """Combine two surveys.
578
-
579
- :param other: The other survey to combine with this one.
580
- >>> s1 = Survey.example()
581
- >>> from edsl import QuestionFreeText
582
- >>> s2 = Survey([QuestionFreeText(question_text="What is your name?", question_name="yo")])
583
- >>> s3 = s1 + s2
584
- Traceback (most recent call last):
585
- ...
586
- edsl.exceptions.surveys.SurveyCreationError: ...
587
- ...
588
- >>> s3 = s1.clear_non_default_rules() + s2
589
- >>> len(s3.questions)
590
- 4
591
-
592
- """
593
- if (
594
- len(self.rule_collection.non_default_rules) > 0
595
- or len(other.rule_collection.non_default_rules) > 0
596
- ):
597
- raise SurveyCreationError(
598
- "Cannot combine two surveys with non-default rules. Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.",
599
- )
600
-
601
- return Survey(questions=self.questions + other.questions)
602
-
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
624
-
625
- def delete_question(self, identifier: Union[str, int]) -> Survey:
626
- """
627
- Delete a question from the survey.
628
-
629
- :param identifier: The name or index of the question to delete.
630
- :return: The updated Survey object.
631
-
632
- >>> from edsl import QuestionMultipleChoice, Survey
633
- >>> q1 = QuestionMultipleChoice(question_text="Q1", question_options=["A", "B"], question_name="q1")
634
- >>> q2 = QuestionMultipleChoice(question_text="Q2", question_options=["C", "D"], question_name="q2")
635
- >>> s = Survey().add_question(q1).add_question(q2)
636
- >>> _ = s.delete_question("q1")
637
- >>> len(s.questions)
638
- 1
639
- >>> _ = s.delete_question(0)
640
- >>> len(s.questions)
641
- 0
642
- """
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
691
-
692
- def add_question(
693
- self, question: QuestionBase, index: Optional[int] = None
694
- ) -> Survey:
695
- """
696
- Add a question to survey.
697
-
698
- :param question: The question to add to the survey.
699
- :param question_name: The name of the question. If not provided, the question name is used.
700
-
701
- The question is appended at the end of the self.questions list
702
- A default rule is created that the next index is the next question.
703
-
704
- >>> from edsl import QuestionMultipleChoice
705
- >>> q = QuestionMultipleChoice(question_text = "Do you like school?", question_options=["yes", "no"], question_name="q0")
706
- >>> s = Survey().add_question(q)
707
-
708
- >>> s = Survey().add_question(q).add_question(q)
709
- Traceback (most recent call last):
710
- ...
711
- edsl.exceptions.surveys.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
712
- ...
713
- """
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)
720
-
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(
781
- self,
782
- ) -> list[Union[QuestionBase, "Instruction"]]:
783
- """Return a list of questions and instructions sorted by pseudo index."""
784
- questions_and_instructions = self._questions + list(
785
- self.instruction_names_to_instructions.values()
786
- )
787
- return sorted(
788
- questions_and_instructions, key=lambda x: self.pseudo_indices[x.name]
789
- )
790
-
791
- # endregion
792
-
793
- # region: Memory plan methods
794
- def set_full_memory_mode(self) -> Survey:
795
- """Add instructions to a survey that the agent should remember all of the answers to the questions in the survey.
796
-
797
- >>> s = Survey.example().set_full_memory_mode()
798
-
799
- """
800
- self._set_memory_plan(lambda i: self.question_names[:i])
801
- return self
802
-
803
- def set_lagged_memory(self, lags: int) -> Survey:
804
- """Add instructions to a survey that the agent should remember the answers to the questions in the survey.
805
-
806
- The agent should remember the answers to the questions in the survey from the previous lags.
807
- """
808
- self._set_memory_plan(lambda i: self.question_names[max(0, i - lags) : i])
809
- return self
810
-
811
- def _set_memory_plan(self, prior_questions_func: Callable):
812
- """Set memory plan based on a provided function determining prior questions.
813
-
814
- :param prior_questions_func: A function that takes the index of the current question and returns a list of prior questions to remember.
815
-
816
- >>> s = Survey.example()
817
- >>> s._set_memory_plan(lambda i: s.question_names[:i])
818
-
819
- """
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
- )
825
-
826
- def add_targeted_memory(
827
- self,
828
- focal_question: Union[QuestionBase, str],
829
- prior_question: Union[QuestionBase, str],
830
- ) -> Survey:
831
- """Add instructions to a survey than when answering focal_question.
832
-
833
- :param focal_question: The question that the agent is answering.
834
- :param prior_question: The question that the agent should remember when answering the focal question.
835
-
836
- Here we add instructions to a survey than when answering q2 they should remember q1:
837
-
838
- >>> s = Survey.example().add_targeted_memory("q2", "q0")
839
- >>> s.memory_plan
840
- {'q2': Memory(prior_questions=['q0'])}
841
-
842
- The agent should also remember the answers to prior_questions listed in prior_questions.
843
- """
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,
854
- )
855
-
856
- return self
857
-
858
- def add_memory_collection(
859
- self,
860
- focal_question: Union[QuestionBase, str],
861
- prior_questions: List[Union[QuestionBase, str]],
862
- ) -> Survey:
863
- """Add prior questions and responses so the agent has them when answering.
864
-
865
- 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.
866
-
867
- :param focal_question: The question that the agent is answering.
868
- :param prior_questions: The questions that the agent should remember when answering the focal question.
869
-
870
- Here we have it so that when answering q2, the agent should remember answers to q0 and q1:
871
-
872
- >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
873
- >>> s.memory_plan
874
- {'q2': Memory(prior_questions=['q0', 'q1'])}
875
- """
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
887
- )
888
- return self
889
-
890
- # endregion
891
- # endregion
892
- # endregion
893
-
894
- # region: Question groups
895
- def add_question_group(
896
- self,
897
- start_question: Union[QuestionBase, str],
898
- end_question: Union[QuestionBase, str],
899
- group_name: str,
900
- ) -> Survey:
901
- """Add a group of questions to the survey.
902
-
903
- :param start_question: The first question in the group.
904
- :param end_question: The last question in the group.
905
- :param group_name: The name of the group.
906
-
907
- Example:
908
-
909
- >>> s = Survey.example().add_question_group("q0", "q1", "group1")
910
- >>> s.question_groups
911
- {'group1': (0, 1)}
912
-
913
- The name of the group must be a valid identifier:
914
-
915
- >>> s = Survey.example().add_question_group("q0", "q2", "1group1")
916
- Traceback (most recent call last):
917
- ...
918
- edsl.exceptions.surveys.SurveyCreationError: Group name 1group1 is not a valid identifier.
919
- ...
920
- >>> s = Survey.example().add_question_group("q0", "q1", "q0")
921
- Traceback (most recent call last):
922
- ...
923
- edsl.exceptions.surveys.SurveyCreationError: ...
924
- ...
925
- >>> s = Survey.example().add_question_group("q1", "q0", "group1")
926
- Traceback (most recent call last):
927
- ...
928
- edsl.exceptions.surveys.SurveyCreationError: ...
929
- ...
930
- """
931
-
932
- if not group_name.isidentifier():
933
- raise SurveyCreationError(
934
- f"Group name {group_name} is not a valid identifier."
935
- )
936
-
937
- if group_name in self.question_groups:
938
- raise SurveyCreationError(
939
- f"Group name {group_name} already exists in the survey."
940
- )
941
-
942
- if group_name in self.question_name_to_index:
943
- raise SurveyCreationError(
944
- f"Group name {group_name} already exists as a question name in the survey."
945
- )
946
-
947
- start_index = self._get_question_index(start_question)
948
- end_index = self._get_question_index(end_question)
949
-
950
- if start_index > end_index:
951
- raise SurveyCreationError(
952
- f"Start index {start_index} is greater than end index {end_index}."
953
- )
954
-
955
- for existing_group_name, (
956
- existing_start_index,
957
- existing_end_index,
958
- ) in self.question_groups.items():
959
- if start_index < existing_start_index and end_index > existing_end_index:
960
- raise SurveyCreationError(
961
- f"Group {group_name} contains the questions in the new group."
962
- )
963
- if start_index > existing_start_index and end_index < existing_end_index:
964
- raise SurveyCreationError(
965
- f"Group {group_name} is contained in the new group."
966
- )
967
- if start_index < existing_start_index and end_index > existing_start_index:
968
- raise SurveyCreationError(
969
- f"Group {group_name} overlaps with the new group."
970
- )
971
- if start_index < existing_end_index and end_index > existing_end_index:
972
- raise SurveyCreationError(
973
- f"Group {group_name} overlaps with the new group."
974
- )
975
-
976
- self.question_groups[group_name] = (start_index, end_index)
977
- return self
978
-
979
- # endregion
980
-
981
- # region: Survey rules
982
- def show_rules(self) -> None:
983
- """Print out the rules in the survey.
984
-
985
- >>> s = Survey.example()
986
- >>> s.show_rules()
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
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
995
- """
996
- self.rule_collection.show_rules()
997
-
998
- def add_stop_rule(
999
- self, question: Union[QuestionBase, str], expression: str
1000
- ) -> Survey:
1001
- """Add a rule that stops the survey.
1002
- The rule is evaluated *after* the question is answered. If the rule is true, the survey ends.
1003
-
1004
- :param question: The question to add the stop rule to.
1005
- :param expression: The expression to evaluate.
1006
-
1007
- If this rule is true, the survey ends.
1008
-
1009
- Here, answering "yes" to q0 ends the survey:
1010
-
1011
- >>> s = Survey.example().add_stop_rule("q0", "q0 == 'yes'")
1012
- >>> s.next_question("q0", {"q0": "yes"})
1013
- EndOfSurvey
1014
-
1015
- By comparison, answering "no" to q0 does not end the survey:
1016
-
1017
- >>> s.next_question("q0", {"q0": "no"}).question_name
1018
- 'q1'
1019
-
1020
- >>> s.add_stop_rule("q0", "q1 <> 'yes'")
1021
- Traceback (most recent call last):
1022
- ...
1023
- edsl.exceptions.surveys.SurveyCreationError: The expression contains '<>', which is not allowed. You probably mean '!='.
1024
- ...
1025
- """
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
1040
-
1041
- def clear_non_default_rules(self) -> Survey:
1042
- """Remove all non-default rules from the survey.
1043
-
1044
- >>> Survey.example().show_rules()
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
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
1053
- >>> Survey.example().clear_non_default_rules().show_rules()
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
- └───────────┴────────────┴────────┴──────────┴─────────────┘
1061
- """
1062
- s = Survey()
1063
- for question in self.questions:
1064
- s.add_question(question)
1065
- return s
1066
-
1067
- def add_skip_rule(
1068
- self, question: Union[QuestionBase, str], expression: str
1069
- ) -> Survey:
1070
- """
1071
- Adds a per-question skip rule to the survey.
1072
-
1073
- :param question: The question to add the skip rule to.
1074
- :param expression: The expression to evaluate.
1075
-
1076
- This adds a rule that skips 'q0' always, before the question is answered:
1077
-
1078
- >>> from edsl import QuestionFreeText
1079
- >>> q0 = QuestionFreeText.example()
1080
- >>> q0.question_name = "q0"
1081
- >>> q1 = QuestionFreeText.example()
1082
- >>> q1.question_name = "q1"
1083
- >>> s = Survey([q0, q1]).add_skip_rule("q0", "True")
1084
- >>> s.next_question("q0", {}).question_name
1085
- 'q1'
1086
-
1087
- Note that this is different from a rule that jumps to some other question *after* the question is answered.
1088
-
1089
- """
1090
- question_index = self._get_question_index(question)
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
1121
- )
1122
- return new_priority
1123
-
1124
- def add_rule(
1125
- self,
1126
- question: Union[QuestionBase, str],
1127
- expression: str,
1128
- next_question: Union[QuestionBase, int],
1129
- before_rule: bool = False,
1130
- ) -> Survey:
1131
- """
1132
- Add a rule to a Question of the Survey.
1133
-
1134
- :param question: The question to add the rule to.
1135
- :param expression: The expression to evaluate.
1136
- :param next_question: The next question to go to if the rule is true.
1137
- :param before_rule: Whether the rule is evaluated before the question is answered.
1138
-
1139
- This adds a rule that if the answer to q0 is 'yes', the next question is q2 (as opposed to q1)
1140
-
1141
- >>> s = Survey.example().add_rule("q0", "{{ q0 }} == 'yes'", "q2")
1142
- >>> s.next_question("q0", {"q0": "yes"}).question_name
1143
- 'q2'
1144
-
1145
- """
1146
- return self._add_rule(
1147
- question, expression, next_question, before_rule=before_rule
1148
- )
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
-
1192
- # endregion
1193
-
1194
- # region: Forward methods
1195
- def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
1196
- """Add Agents, Scenarios, and LanguageModels to a survey and returns a runnable Jobs object.
1197
-
1198
- :param args: The Agents, Scenarios, and LanguageModels to add to the survey.
1199
-
1200
- This takes the survey and adds an Agent and a Scenario via 'by' which converts to a Jobs object:
1201
-
1202
- >>> s = Survey.example(); from edsl import Agent; from edsl import Scenario
1203
- >>> s.by(Agent.example()).by(Scenario.example())
1204
- Jobs(...)
1205
- """
1206
- from edsl.jobs.Jobs import Jobs
1207
-
1208
- job = Jobs(survey=self)
1209
- return job.by(*args)
1210
-
1211
- def to_jobs(self):
1212
- """Convert the survey to a Jobs object."""
1213
- from edsl.jobs.Jobs import Jobs
1214
-
1215
- return Jobs(survey=self)
1216
-
1217
- def show_prompts(self):
1218
- return self.to_jobs().show_prompts()
1219
-
1220
- # endregion
1221
-
1222
- # region: Running the survey
1223
-
1224
- def __call__(
1225
- self,
1226
- model=None,
1227
- agent=None,
1228
- cache=None,
1229
- disable_remote_cache: bool = False,
1230
- disable_remote_inference: bool = False,
1231
- **kwargs,
1232
- ):
1233
- """Run the survey with default model, taking the required survey as arguments.
1234
-
1235
- >>> from edsl.questions import QuestionFunctional
1236
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1237
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1238
- >>> s = Survey([q])
1239
- >>> s(period = "morning", cache = False, disable_remote_cache = True, disable_remote_inference = True).select("answer.q0").first()
1240
- 'yes'
1241
- >>> s(period = "evening", cache = False, disable_remote_cache = True, disable_remote_inference = True).select("answer.q0").first()
1242
- 'no'
1243
- """
1244
- job = self.get_job(model, agent, **kwargs)
1245
- return job.run(
1246
- cache=cache,
1247
- disable_remote_cache=disable_remote_cache,
1248
- disable_remote_inference=disable_remote_inference,
1249
- )
1250
-
1251
- async def run_async(
1252
- self,
1253
- model: Optional["Model"] = None,
1254
- agent: Optional["Agent"] = None,
1255
- cache: Optional["Cache"] = None,
1256
- disable_remote_inference: bool = False,
1257
- **kwargs,
1258
- ):
1259
- """Run the survey with default model, taking the required survey as arguments.
1260
-
1261
- >>> import asyncio
1262
- >>> from edsl.questions import QuestionFunctional
1263
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1264
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1265
- >>> s = Survey([q])
1266
- >>> async def test_run_async(): result = await s.run_async(period="morning", disable_remote_inference = True); print(result.select("answer.q0").first())
1267
- >>> asyncio.run(test_run_async())
1268
- yes
1269
- >>> import asyncio
1270
- >>> from edsl.questions import QuestionFunctional
1271
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1272
- >>> q = QuestionFunctional(question_name = "q0", func = f)
1273
- >>> s = Survey([q])
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())
1276
- no
1277
- """
1278
- # TODO: temp fix by creating a cache
1279
- if cache is None:
1280
- from edsl.data import Cache
1281
-
1282
- c = Cache()
1283
- else:
1284
- c = cache
1285
- jobs: "Jobs" = self.get_job(model=model, agent=agent, **kwargs)
1286
- return await jobs.run_async(
1287
- cache=c, disable_remote_inference=disable_remote_inference
1288
- )
1289
-
1290
- def run(self, *args, **kwargs) -> "Results":
1291
- """Turn the survey into a Job and runs it.
1292
-
1293
- >>> from edsl import QuestionFreeText
1294
- >>> s = Survey([QuestionFreeText.example()])
1295
- >>> from edsl.language_models import LanguageModel
1296
- >>> m = LanguageModel.example(test_model = True, canned_response = "Great!")
1297
- >>> results = s.by(m).run(cache = False, disable_remote_cache = True, disable_remote_inference = True)
1298
- >>> results.select('answer.*')
1299
- Dataset([{'answer.how_are_you': ['Great!']}])
1300
- """
1301
- from edsl.jobs.Jobs import Jobs
1302
-
1303
- return Jobs(survey=self).run(*args, **kwargs)
1304
-
1305
- # region: Survey flow
1306
- def next_question(
1307
- self, current_question: Union[str, QuestionBase], answers: dict
1308
- ) -> Union[QuestionBase, EndOfSurvey.__class__]:
1309
- """
1310
- Return the next question in a survey.
1311
-
1312
- :param current_question: The current question in the survey.
1313
- :param answers: The answers for the survey so far
1314
-
1315
- - If called with no arguments, it returns the first question in the survey.
1316
- - 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.
1317
- - If the next question is the last question in the survey, an EndOfSurvey object is returned.
1318
-
1319
- >>> s = Survey.example()
1320
- >>> s.next_question("q0", {"q0": "yes"}).question_name
1321
- 'q2'
1322
- >>> s.next_question("q0", {"q0": "no"}).question_name
1323
- 'q1'
1324
-
1325
- """
1326
- if isinstance(current_question, str):
1327
- current_question = self.get_question(current_question)
1328
-
1329
- question_index = self.question_name_to_index[current_question.question_name]
1330
- next_question_object = self.rule_collection.next_question(
1331
- question_index, answers
1332
- )
1333
-
1334
- if next_question_object.num_rules_found == 0:
1335
- raise SurveyHasNoRulesError
1336
-
1337
- if next_question_object.next_q == EndOfSurvey:
1338
- return EndOfSurvey
1339
- else:
1340
- if next_question_object.next_q >= len(self.questions):
1341
- return EndOfSurvey
1342
- else:
1343
- return self.questions[next_question_object.next_q]
1344
-
1345
- def gen_path_through_survey(self) -> Generator[QuestionBase, dict, None]:
1346
- """
1347
- Generate a coroutine that can be used to conduct an Interview.
1348
-
1349
- The coroutine is a generator that yields a question and receives answers.
1350
- It starts with the first question in the survey.
1351
- The coroutine ends when an EndOfSurvey object is returned.
1352
-
1353
- For the example survey, this is the rule table:
1354
-
1355
- >>> s = Survey.example()
1356
- >>> s.show_rules()
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
- └───────────┴─────────────┴────────┴──────────┴─────────────┘
1365
-
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.
1367
-
1368
- Here is the path through the survey if the answer to q0 is 'yes':
1369
-
1370
- >>> i = s.gen_path_through_survey()
1371
- >>> next(i)
1372
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1373
- >>> i.send({"q0": "yes"})
1374
- Question('multiple_choice', question_name = \"""q2\""", question_text = \"""Why?\""", question_options = ['**lack*** of killer bees in cafeteria', 'other'])
1375
-
1376
- And here is the path through the survey if the answer to q0 is 'no':
1377
-
1378
- >>> i2 = s.gen_path_through_survey()
1379
- >>> next(i2)
1380
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1381
- >>> i2.send({"q0": "no"})
1382
- Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
1383
-
1384
-
1385
- """
1386
- self.answers = {}
1387
- question = self._questions[0]
1388
- # should the first question be skipped?
1389
- if self.rule_collection.skip_question_before_running(0, self.answers):
1390
- question = self.next_question(question, self.answers)
1391
-
1392
- while not question == EndOfSurvey:
1393
- # breakpoint()
1394
- answer = yield question
1395
- self.answers.update(answer)
1396
- # print(f"Answers: {self.answers}")
1397
- ## TODO: This should also include survey and agent attributes
1398
- question = self.next_question(question, self.answers)
1399
-
1400
- # endregion
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
-
1465
- def dag(self, textify: bool = False) -> DAG:
1466
- """Return the DAG of the survey, which reflects both skip-logic and memory.
1467
-
1468
- :param textify: Whether to return the DAG with question names instead of indices.
1469
-
1470
- >>> s = Survey.example()
1471
- >>> d = s.dag()
1472
- >>> d
1473
- {1: {0}, 2: {0}}
1474
-
1475
- """
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
1484
-
1485
- ###################
1486
- # DUNDER METHODS
1487
- ###################
1488
- def __len__(self) -> int:
1489
- """Return the number of questions in the survey.
1490
-
1491
- >>> s = Survey.example()
1492
- >>> len(s)
1493
- 3
1494
- """
1495
- return len(self._questions)
1496
-
1497
- def __getitem__(self, index) -> QuestionBase:
1498
- """Return the question object given the question index.
1499
-
1500
- :param index: The index of the question to get.
1501
-
1502
- >>> s = Survey.example()
1503
- >>> s[0]
1504
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1505
-
1506
- """
1507
- if isinstance(index, int):
1508
- return self._questions[index]
1509
- elif isinstance(index, str):
1510
- return getattr(self, index)
1511
-
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."""
1547
-
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()))
1583
-
1584
- def __repr__(self) -> str:
1585
- """Return a string representation of the survey."""
1586
-
1587
- # questions_string = ", ".join([repr(q) for q in self._questions])
1588
- questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1589
- # question_names_string = ", ".join([repr(name) for name in self.question_names])
1590
- return f"Survey(questions=[{questions_string}], memory_plan={self.memory_plan}, rule_collection={self.rule_collection}, question_groups={self.question_groups})"
1591
-
1592
- def _summary(self) -> dict:
1593
- return {
1594
- "EDSL Class": "Survey",
1595
- "Number of Questions": len(self),
1596
- "Question Names": self.question_names,
1597
- }
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
-
1603
- def tree(self, node_list: Optional[List[str]] = None):
1604
- return self.to_scenario_list().tree(node_list=node_list)
1605
-
1606
- def table(self, *fields, tablefmt=None) -> Table:
1607
- return self.to_scenario_list().to_dataset().table(*fields, tablefmt=tablefmt)
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
-
1644
- # endregion
1645
-
1646
- def codebook(self) -> dict[str, str]:
1647
- """Create a codebook for the survey, mapping question names to question text.
1648
-
1649
- >>> s = Survey.example()
1650
- >>> s.codebook()
1651
- {'q0': 'Do you like school?', 'q1': 'Why not?', 'q2': 'Why?'}
1652
- """
1653
- codebook = {}
1654
- for question in self._questions:
1655
- codebook[question.question_name] = question.question_text
1656
- return codebook
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
-
1689
- @classmethod
1690
- def example(
1691
- cls,
1692
- params: bool = False,
1693
- randomize: bool = False,
1694
- include_instructions=False,
1695
- custom_instructions: Optional[str] = None,
1696
- ) -> Survey:
1697
- """Return an example survey.
1698
-
1699
- >>> s = Survey.example()
1700
- >>> [q.question_text for q in s.questions]
1701
- ['Do you like school?', 'Why not?', 'Why?']
1702
- """
1703
- from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1704
-
1705
- addition = "" if not randomize else str(uuid4())
1706
- q0 = QuestionMultipleChoice(
1707
- question_text=f"Do you like school?{addition}",
1708
- question_options=["yes", "no"],
1709
- question_name="q0",
1710
- )
1711
- q1 = QuestionMultipleChoice(
1712
- question_text="Why not?",
1713
- question_options=["killer bees in cafeteria", "other"],
1714
- question_name="q1",
1715
- )
1716
- q2 = QuestionMultipleChoice(
1717
- question_text="Why?",
1718
- question_options=["**lack*** of killer bees in cafeteria", "other"],
1719
- question_name="q2",
1720
- )
1721
- if params:
1722
- q3 = QuestionMultipleChoice(
1723
- question_text="To the question '{{ q0.question_text}}', you said '{{ q0.answer }}'. Do you still feel this way?",
1724
- question_options=["yes", "no"],
1725
- question_name="q3",
1726
- )
1727
- s = cls(questions=[q0, q1, q2, q3])
1728
- return s
1729
-
1730
- if include_instructions:
1731
- from edsl import Instruction
1732
-
1733
- custom_instructions = (
1734
- custom_instructions if custom_instructions else "Please pay attention!"
1735
- )
1736
-
1737
- i = Instruction(text=custom_instructions, name="attention")
1738
- s = cls(questions=[i, q0, q1, q2])
1739
- return s
1740
-
1741
- s = cls(questions=[q0, q1, q2])
1742
- s = s.add_rule(q0, "q0 == 'yes'", q2)
1743
- return s
1744
-
1745
- def get_job(self, model=None, agent=None, **kwargs):
1746
- if model is None:
1747
- from edsl import Model
1748
-
1749
- model = Model()
1750
-
1751
- from edsl.scenarios.Scenario import Scenario
1752
-
1753
- s = Scenario(kwargs)
1754
-
1755
- if not agent:
1756
- from edsl import Agent
1757
-
1758
- agent = Agent()
1759
-
1760
- return self.by(s).by(agent).by(model)
1761
-
1762
-
1763
- def main():
1764
- """Run the example survey."""
1765
-
1766
- def example_survey():
1767
- """Return an example survey."""
1768
- from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1769
- from edsl.surveys.Survey import Survey
1770
-
1771
- q0 = QuestionMultipleChoice(
1772
- question_text="Do you like school?",
1773
- question_options=["yes", "no"],
1774
- question_name="q0",
1775
- )
1776
- q1 = QuestionMultipleChoice(
1777
- question_text="Why not?",
1778
- question_options=["killer bees in cafeteria", "other"],
1779
- question_name="q1",
1780
- )
1781
- q2 = QuestionMultipleChoice(
1782
- question_text="Why?",
1783
- question_options=["**lack*** of killer bees in cafeteria", "other"],
1784
- question_name="q2",
1785
- )
1786
- s = Survey(questions=[q0, q1, q2])
1787
- s = s.add_rule(q0, "q0 == 'yes'", q2)
1788
- return s
1789
-
1790
- s = example_survey()
1791
- survey_dict = s.to_dict()
1792
- s2 = Survey.from_dict(survey_dict)
1793
- results = s2.run()
1794
- print(results)
1795
-
1796
-
1797
- if __name__ == "__main__":
1798
- import doctest
1799
-
1800
- # doctest.testmod(optionflags=doctest.ELLIPSIS | doctest.SKIP)
1801
- 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 random
6
+
7
+ from typing import (
8
+ Any,
9
+ Generator,
10
+ Optional,
11
+ Union,
12
+ List,
13
+ Literal,
14
+ Callable,
15
+ TYPE_CHECKING,
16
+ )
17
+ from uuid import uuid4
18
+ from edsl.Base import Base
19
+ from edsl.exceptions.surveys import SurveyCreationError, SurveyHasNoRulesError
20
+ 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
+
71
+
72
+ from edsl.utilities.remove_edsl_version import remove_edsl_version
73
+
74
+ from edsl.surveys.instructions.InstructionCollection import InstructionCollection
75
+ from edsl.surveys.instructions.Instruction import Instruction
76
+ from edsl.surveys.instructions.ChangeInstruction import ChangeInstruction
77
+
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
+
90
+
91
+ class Survey(SurveyExportMixin, Base):
92
+ """A collection of questions that supports skip logic."""
93
+
94
+ __documentation__ = """https://docs.expectedparrot.com/en/latest/surveys.html"""
95
+
96
+ questions = QuestionsDescriptor()
97
+ """
98
+ A collection of questions that supports skip logic.
99
+
100
+ Initalization:
101
+ - `questions`: the questions in the survey (optional)
102
+ - `question_names`: the names of the questions (optional)
103
+ - `name`: the name of the survey (optional)
104
+
105
+ Methods:
106
+ -
107
+
108
+ Notes:
109
+ - The presumed order of the survey is the order in which questions are added.
110
+ """
111
+
112
+ def __init__(
113
+ 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,
118
+ name: Optional[str] = None,
119
+ questions_to_randomize: Optional[List[str]] = None,
120
+ ):
121
+ """Create a new survey.
122
+
123
+ :param questions: The questions in the survey.
124
+ :param memory_plan: The memory plan for the survey.
125
+ :param rule_collection: The rule collection for the survey.
126
+ :param question_groups: The groups of questions in the survey.
127
+ :param name: The name of the survey - DEPRECATED.
128
+
129
+
130
+ >>> from edsl import QuestionFreeText
131
+ >>> q1 = QuestionFreeText(question_text = "What is your name?", question_name = "name")
132
+ >>> q2 = QuestionFreeText(question_text = "What is your favorite color?", question_name = "color")
133
+ >>> q3 = QuestionFreeText(question_text = "Is a hot dog a sandwich", question_name = "food")
134
+ >>> s = Survey([q1, q2, q3], question_groups = {"demographics": (0, 1), "substantive":(3)})
135
+
136
+
137
+ """
138
+
139
+ self.raw_passed_questions = questions
140
+
141
+ true_questions = self._process_raw_questions(self.raw_passed_questions)
142
+
143
+ self.rule_collection = RuleCollection(
144
+ num_questions=len(true_questions) if true_questions else None
145
+ )
146
+ # the RuleCollection needs to be present while we add the questions; we might override this later
147
+ # if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
148
+
149
+ # this is where the Questions constructor is called.
150
+ self.questions = true_questions
151
+ # self.instruction_names_to_instructions = instruction_names_to_instructions
152
+
153
+ self.memory_plan = memory_plan or MemoryPlan(self)
154
+ if question_groups is not None:
155
+ self.question_groups = question_groups
156
+ else:
157
+ self.question_groups = {}
158
+
159
+ # if a rule collection is provided, use it instead of the constructed one
160
+ if rule_collection is not None:
161
+ self.rule_collection = rule_collection
162
+
163
+ if name is not None:
164
+ import warnings
165
+
166
+ warnings.warn("name parameter to a survey is deprecated.")
167
+
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
206
+ @property
207
+ def _relevant_instructions_dict(self) -> InstructionCollection:
208
+ """Return a dictionary with keys as question names and values as instructions that are relevant to the question.
209
+
210
+ >>> s = Survey.example(include_instructions=True)
211
+ >>> s._relevant_instructions_dict
212
+ {'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
+
214
+ """
215
+ return InstructionCollection(
216
+ self._instruction_names_to_instructions, self.questions
217
+ )
218
+
219
+ def _relevant_instructions(self, question: QuestionBase) -> dict:
220
+ """This should be a dictionry with keys as question names and values as instructions that are relevant to the question.
221
+
222
+ :param question: The question to get the relevant instructions for.
223
+
224
+ # Did the instruction come before the question and was it not modified by a change instruction?
225
+
226
+ """
227
+ return InstructionCollection(
228
+ self._instruction_names_to_instructions, self.questions
229
+ )[question]
230
+
231
+ def show_flow(self, filename: Optional[str] = None) -> None:
232
+ """Show the flow of the survey."""
233
+ SurveyFlowVisualization(self).show_flow(filename=filename)
234
+
235
+ def add_instruction(
236
+ self, instruction: Union["Instruction", "ChangeInstruction"]
237
+ ) -> Survey:
238
+ """
239
+ Add an instruction to the survey.
240
+
241
+ :param instruction: The instruction to add to the survey.
242
+
243
+ >>> from edsl import Instruction
244
+ >>> i = Instruction(text="Pay attention to the following questions.", name="intro")
245
+ >>> s = Survey().add_instruction(i)
246
+ >>> s._instruction_names_to_instructions
247
+ {'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
248
+ >>> s._pseudo_indices
249
+ {'intro': -0.5}
250
+ """
251
+ return EditSurvey(self).add_instruction(instruction)
252
+
253
+ # endregion
254
+ @classmethod
255
+ def random_survey(cls):
256
+ return Simulator.random_survey()
257
+
258
+ def simulate(self) -> dict:
259
+ """Simulate the survey and return the answers."""
260
+ return Simulator(self).simulate()
261
+
262
+ # endregion
263
+
264
+ # region: Access methods
265
+ def _get_question_index(
266
+ self, q: Union[QuestionBase, str, EndOfSurvey.__class__]
267
+ ) -> Union[int, EndOfSurvey.__class__]:
268
+ """Return the index of the question or EndOfSurvey object.
269
+
270
+ :param q: The question or question name to get the index of.
271
+
272
+ It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
273
+
274
+ >>> s = Survey.example()
275
+ >>> s._get_question_index("q0")
276
+ 0
277
+
278
+ This doesnt' work with questions that don't exist:
279
+
280
+ >>> s._get_question_index("poop")
281
+ Traceback (most recent call last):
282
+ ...
283
+ edsl.exceptions.surveys.SurveyError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
284
+ ...
285
+ """
286
+ if q == EndOfSurvey:
287
+ return EndOfSurvey
288
+ else:
289
+ question_name = q if isinstance(q, str) else q.question_name
290
+ if question_name not in self.question_name_to_index:
291
+ raise SurveyError(
292
+ f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
293
+ )
294
+ return self.question_name_to_index[question_name]
295
+
296
+ def _get_question_by_name(self, question_name: str) -> QuestionBase:
297
+ """
298
+ Return the question object given the question name.
299
+
300
+ :param question_name: The name of the question to get.
301
+
302
+ >>> s = Survey.example()
303
+ >>> s._get_question_by_name("q0")
304
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
305
+ """
306
+ if question_name not in self.question_name_to_index:
307
+ raise SurveyError(f"Question name {question_name} not found in survey.")
308
+ return self._questions[self.question_name_to_index[question_name]]
309
+
310
+ def question_names_to_questions(self) -> dict:
311
+ """Return a dictionary mapping question names to question attributes."""
312
+ return {q.question_name: q for q in self.questions}
313
+
314
+ @property
315
+ def question_names(self) -> list[str]:
316
+ """Return a list of question names in the survey.
317
+
318
+ Example:
319
+
320
+ >>> s = Survey.example()
321
+ >>> s.question_names
322
+ ['q0', 'q1', 'q2']
323
+ """
324
+ return [q.question_name for q in self.questions]
325
+
326
+ @property
327
+ def question_name_to_index(self) -> dict[str, int]:
328
+ """Return a dictionary mapping question names to question indices.
329
+
330
+ Example:
331
+
332
+ >>> s = Survey.example()
333
+ >>> s.question_name_to_index
334
+ {'q0': 0, 'q1': 1, 'q2': 2}
335
+ """
336
+ return {q.question_name: i for i, q in enumerate(self.questions)}
337
+
338
+ # endregion
339
+
340
+ # region: serialization methods
341
+ def to_dict(self, add_edsl_version=True) -> dict[str, Any]:
342
+ """Serialize the Survey object to a dictionary.
343
+
344
+ >>> s = Survey.example()
345
+ >>> s.to_dict(add_edsl_version = False).keys()
346
+ dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
347
+ """
348
+ from edsl import __version__
349
+
350
+ d = {
351
+ "questions": [
352
+ q.to_dict(add_edsl_version=add_edsl_version)
353
+ for q in self._recombined_questions_and_instructions()
354
+ ],
355
+ "memory_plan": self.memory_plan.to_dict(add_edsl_version=add_edsl_version),
356
+ "rule_collection": self.rule_collection.to_dict(
357
+ add_edsl_version=add_edsl_version
358
+ ),
359
+ "question_groups": self.question_groups,
360
+ }
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
+
369
+ @classmethod
370
+ @remove_edsl_version
371
+ def from_dict(cls, data: dict) -> Survey:
372
+ """Deserialize the dictionary back to a Survey object.
373
+
374
+ :param data: The dictionary to deserialize.
375
+
376
+ >>> d = Survey.example().to_dict()
377
+ >>> s = Survey.from_dict(d)
378
+ >>> s == Survey.example()
379
+ True
380
+
381
+ >>> s = Survey.example(include_instructions = True)
382
+ >>> d = s.to_dict()
383
+ >>> news = Survey.from_dict(d)
384
+ >>> news == s
385
+ True
386
+
387
+ """
388
+
389
+ def get_class(pass_dict):
390
+ from edsl.questions.QuestionBase import QuestionBase
391
+
392
+ if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
393
+ return QuestionBase
394
+ elif class_name == "Instruction":
395
+ from edsl.surveys.instructions.Instruction import Instruction
396
+
397
+ return Instruction
398
+ elif class_name == "ChangeInstruction":
399
+ from edsl.surveys.instructions.ChangeInstruction import (
400
+ ChangeInstruction,
401
+ )
402
+
403
+ return ChangeInstruction
404
+ else:
405
+ return QuestionBase
406
+
407
+ questions = [
408
+ get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
409
+ ]
410
+ 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
+ survey = cls(
416
+ questions=questions,
417
+ memory_plan=memory_plan,
418
+ rule_collection=RuleCollection.from_dict(data["rule_collection"]),
419
+ question_groups=data["question_groups"],
420
+ questions_to_randomize=questions_to_randomize,
421
+ )
422
+ return survey
423
+
424
+ # endregion
425
+
426
+ # region: Survey template parameters
427
+ @property
428
+ def scenario_attributes(self) -> list[str]:
429
+ """Return a list of attributes that admissible Scenarios should have.
430
+
431
+ Here we have a survey with a question that uses a jinja2 style {{ }} template:
432
+
433
+ >>> from edsl import QuestionFreeText
434
+ >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
435
+ >>> s.scenario_attributes
436
+ ['greeting']
437
+
438
+ >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
439
+ >>> s.scenario_attributes
440
+ ['greeting', 'attribute']
441
+
442
+
443
+ """
444
+ temp = []
445
+ for question in self.questions:
446
+ question_text = question.question_text
447
+ # extract the contents of all {{ }} in the question text using regex
448
+ matches = re.findall(r"\{\{(.+?)\}\}", question_text)
449
+ # remove whitespace
450
+ matches = [match.strip() for match in matches]
451
+ # add them to the temp list
452
+ temp.extend(matches)
453
+ return temp
454
+
455
+ @property
456
+ def parameters(self):
457
+ """Return a set of parameters in the survey.
458
+
459
+ >>> s = Survey.example()
460
+ >>> s.parameters
461
+ set()
462
+ """
463
+ return set.union(*[q.parameters for q in self.questions])
464
+
465
+ @property
466
+ def parameters_by_question(self):
467
+ """Return a dictionary of parameters by question in the survey.
468
+ >>> from edsl import QuestionFreeText
469
+ >>> q = QuestionFreeText(question_name = "example", question_text = "What is the capital of {{ country}}?")
470
+ >>> s = Survey([q])
471
+ >>> s.parameters_by_question
472
+ {'example': {'country'}}
473
+ """
474
+ return {q.question_name: q.parameters for q in self.questions}
475
+
476
+ # endregion
477
+
478
+ # region: Survey construction
479
+
480
+ # region: Adding questions and combining surveys
481
+ def __add__(self, other: Survey) -> Survey:
482
+ """Combine two surveys.
483
+
484
+ :param other: The other survey to combine with this one.
485
+ >>> s1 = Survey.example()
486
+ >>> from edsl import QuestionFreeText
487
+ >>> s2 = Survey([QuestionFreeText(question_text="What is your name?", question_name="yo")])
488
+ >>> s3 = s1 + s2
489
+ Traceback (most recent call last):
490
+ ...
491
+ edsl.exceptions.surveys.SurveyCreationError: ...
492
+ ...
493
+ >>> s3 = s1.clear_non_default_rules() + s2
494
+ >>> len(s3.questions)
495
+ 4
496
+
497
+ """
498
+ if (
499
+ len(self.rule_collection.non_default_rules) > 0
500
+ or len(other.rule_collection.non_default_rules) > 0
501
+ ):
502
+ raise SurveyCreationError(
503
+ "Cannot combine two surveys with non-default rules. Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.",
504
+ )
505
+
506
+ return Survey(questions=self.questions + other.questions)
507
+
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)
518
+
519
+ def delete_question(self, identifier: Union[str, int]) -> Survey:
520
+ """
521
+ Delete a question from the survey.
522
+
523
+ :param identifier: The name or index of the question to delete.
524
+ :return: The updated Survey object.
525
+
526
+ >>> from edsl import QuestionMultipleChoice, Survey
527
+ >>> q1 = QuestionMultipleChoice(question_text="Q1", question_options=["A", "B"], question_name="q1")
528
+ >>> q2 = QuestionMultipleChoice(question_text="Q2", question_options=["C", "D"], question_name="q2")
529
+ >>> s = Survey().add_question(q1).add_question(q2)
530
+ >>> _ = s.delete_question("q1")
531
+ >>> len(s.questions)
532
+ 1
533
+ >>> _ = s.delete_question(0)
534
+ >>> len(s.questions)
535
+ 0
536
+ """
537
+ return EditSurvey(self).delete_question(identifier)
538
+
539
+ def add_question(
540
+ self, question: QuestionBase, index: Optional[int] = None
541
+ ) -> Survey:
542
+ """
543
+ Add a question to survey.
544
+
545
+ :param question: The question to add to the survey.
546
+ :param question_name: The name of the question. If not provided, the question name is used.
547
+
548
+ The question is appended at the end of the self.questions list
549
+ A default rule is created that the next index is the next question.
550
+
551
+ >>> from edsl import QuestionMultipleChoice
552
+ >>> q = QuestionMultipleChoice(question_text = "Do you like school?", question_options=["yes", "no"], question_name="q0")
553
+ >>> s = Survey().add_question(q)
554
+
555
+ >>> s = Survey().add_question(q).add_question(q)
556
+ Traceback (most recent call last):
557
+ ...
558
+ edsl.exceptions.surveys.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
559
+ ...
560
+ """
561
+ return EditSurvey(self).add_question(question, index)
562
+
563
+ def _recombined_questions_and_instructions(
564
+ self,
565
+ ) -> list[Union[QuestionBase, "Instruction"]]:
566
+ """Return a list of questions and instructions sorted by pseudo index."""
567
+ questions_and_instructions = self._questions + list(
568
+ self._instruction_names_to_instructions.values()
569
+ )
570
+ return sorted(
571
+ questions_and_instructions, key=lambda x: self._pseudo_indices[x.name]
572
+ )
573
+
574
+ # endregion
575
+
576
+ # region: Memory plan methods
577
+ def set_full_memory_mode(self) -> Survey:
578
+ """Add instructions to a survey that the agent should remember all of the answers to the questions in the survey.
579
+
580
+ >>> s = Survey.example().set_full_memory_mode()
581
+
582
+ """
583
+ MemoryManagement(self)._set_memory_plan(lambda i: self.question_names[:i])
584
+ return self
585
+
586
+ def set_lagged_memory(self, lags: int) -> Survey:
587
+ """Add instructions to a survey that the agent should remember the answers to the questions in the survey.
588
+
589
+ The agent should remember the answers to the questions in the survey from the previous lags.
590
+ """
591
+ MemoryManagement(self)._set_memory_plan(
592
+ lambda i: self.question_names[max(0, i - lags) : i]
593
+ )
594
+ return self
595
+
596
+ def _set_memory_plan(self, prior_questions_func: Callable) -> None:
597
+ """Set memory plan based on a provided function determining prior questions.
598
+
599
+ :param prior_questions_func: A function that takes the index of the current question and returns a list of prior questions to remember.
600
+
601
+ >>> s = Survey.example()
602
+ >>> s._set_memory_plan(lambda i: s.question_names[:i])
603
+
604
+ """
605
+ MemoryManagement(self)._set_memory_plan(prior_questions_func)
606
+
607
+ def add_targeted_memory(
608
+ self,
609
+ focal_question: Union[QuestionBase, str],
610
+ prior_question: Union[QuestionBase, str],
611
+ ) -> Survey:
612
+ """Add instructions to a survey than when answering focal_question.
613
+
614
+ :param focal_question: The question that the agent is answering.
615
+ :param prior_question: The question that the agent should remember when answering the focal question.
616
+
617
+ Here we add instructions to a survey than when answering q2 they should remember q1:
618
+
619
+ >>> s = Survey.example().add_targeted_memory("q2", "q0")
620
+ >>> s.memory_plan
621
+ {'q2': Memory(prior_questions=['q0'])}
622
+
623
+ The agent should also remember the answers to prior_questions listed in prior_questions.
624
+ """
625
+ return MemoryManagement(self).add_targeted_memory(
626
+ focal_question, prior_question
627
+ )
628
+
629
+ def add_memory_collection(
630
+ self,
631
+ focal_question: Union[QuestionBase, str],
632
+ prior_questions: List[Union[QuestionBase, str]],
633
+ ) -> Survey:
634
+ """Add prior questions and responses so the agent has them when answering.
635
+
636
+ 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.
637
+
638
+ :param focal_question: The question that the agent is answering.
639
+ :param prior_questions: The questions that the agent should remember when answering the focal question.
640
+
641
+ Here we have it so that when answering q2, the agent should remember answers to q0 and q1:
642
+
643
+ >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
644
+ >>> s.memory_plan
645
+ {'q2': Memory(prior_questions=['q0', 'q1'])}
646
+ """
647
+ return MemoryManagement(self).add_memory_collection(
648
+ focal_question, prior_questions
649
+ )
650
+
651
+ # region: Question groups
652
+ def add_question_group(
653
+ self,
654
+ start_question: Union[QuestionBase, str],
655
+ end_question: Union[QuestionBase, str],
656
+ group_name: str,
657
+ ) -> Survey:
658
+ """Add a group of questions to the survey.
659
+
660
+ :param start_question: The first question in the group.
661
+ :param end_question: The last question in the group.
662
+ :param group_name: The name of the group.
663
+
664
+ Example:
665
+
666
+ >>> s = Survey.example().add_question_group("q0", "q1", "group1")
667
+ >>> s.question_groups
668
+ {'group1': (0, 1)}
669
+
670
+ The name of the group must be a valid identifier:
671
+
672
+ >>> s = Survey.example().add_question_group("q0", "q2", "1group1")
673
+ Traceback (most recent call last):
674
+ ...
675
+ edsl.exceptions.surveys.SurveyCreationError: Group name 1group1 is not a valid identifier.
676
+ ...
677
+ >>> s = Survey.example().add_question_group("q0", "q1", "q0")
678
+ Traceback (most recent call last):
679
+ ...
680
+ edsl.exceptions.surveys.SurveyCreationError: ...
681
+ ...
682
+ >>> s = Survey.example().add_question_group("q1", "q0", "group1")
683
+ Traceback (most recent call last):
684
+ ...
685
+ edsl.exceptions.surveys.SurveyCreationError: ...
686
+ ...
687
+ """
688
+
689
+ if not group_name.isidentifier():
690
+ raise SurveyCreationError(
691
+ f"Group name {group_name} is not a valid identifier."
692
+ )
693
+
694
+ if group_name in self.question_groups:
695
+ raise SurveyCreationError(
696
+ f"Group name {group_name} already exists in the survey."
697
+ )
698
+
699
+ if group_name in self.question_name_to_index:
700
+ raise SurveyCreationError(
701
+ f"Group name {group_name} already exists as a question name in the survey."
702
+ )
703
+
704
+ start_index = self._get_question_index(start_question)
705
+ end_index = self._get_question_index(end_question)
706
+
707
+ if start_index > end_index:
708
+ raise SurveyCreationError(
709
+ f"Start index {start_index} is greater than end index {end_index}."
710
+ )
711
+
712
+ for existing_group_name, (
713
+ existing_start_index,
714
+ existing_end_index,
715
+ ) in self.question_groups.items():
716
+ if start_index < existing_start_index and end_index > existing_end_index:
717
+ raise SurveyCreationError(
718
+ f"Group {group_name} contains the questions in the new group."
719
+ )
720
+ if start_index > existing_start_index and end_index < existing_end_index:
721
+ raise SurveyCreationError(
722
+ f"Group {group_name} is contained in the new group."
723
+ )
724
+ if start_index < existing_start_index and end_index > existing_start_index:
725
+ raise SurveyCreationError(
726
+ f"Group {group_name} overlaps with the new group."
727
+ )
728
+ if start_index < existing_end_index and end_index > existing_end_index:
729
+ raise SurveyCreationError(
730
+ f"Group {group_name} overlaps with the new group."
731
+ )
732
+
733
+ self.question_groups[group_name] = (start_index, end_index)
734
+ return self
735
+
736
+ # endregion
737
+
738
+ # region: Survey rules
739
+ def show_rules(self) -> None:
740
+ """Print out the rules in the survey.
741
+
742
+ >>> s = Survey.example()
743
+ >>> 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]}])
745
+ """
746
+ return self.rule_collection.show_rules()
747
+
748
+ def add_stop_rule(
749
+ self, question: Union[QuestionBase, str], expression: str
750
+ ) -> Survey:
751
+ """Add a rule that stops the survey.
752
+ The rule is evaluated *after* the question is answered. If the rule is true, the survey ends.
753
+
754
+ :param question: The question to add the stop rule to.
755
+ :param expression: The expression to evaluate.
756
+
757
+ If this rule is true, the survey ends.
758
+
759
+ Here, answering "yes" to q0 ends the survey:
760
+
761
+ >>> s = Survey.example().add_stop_rule("q0", "q0 == 'yes'")
762
+ >>> s.next_question("q0", {"q0": "yes"})
763
+ EndOfSurvey
764
+
765
+ By comparison, answering "no" to q0 does not end the survey:
766
+
767
+ >>> s.next_question("q0", {"q0": "no"}).question_name
768
+ 'q1'
769
+
770
+ >>> s.add_stop_rule("q0", "q1 <> 'yes'")
771
+ Traceback (most recent call last):
772
+ ...
773
+ edsl.exceptions.surveys.SurveyCreationError: The expression contains '<>', which is not allowed. You probably mean '!='.
774
+ ...
775
+ """
776
+ return RuleManager(self).add_stop_rule(question, expression)
777
+
778
+ def clear_non_default_rules(self) -> Survey:
779
+ """Remove all non-default rules from the survey.
780
+
781
+ >>> 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]}])
783
+ >>> 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]}])
785
+ """
786
+ s = Survey()
787
+ for question in self.questions:
788
+ s.add_question(question)
789
+ return s
790
+
791
+ def add_skip_rule(
792
+ self, question: Union[QuestionBase, str], expression: str
793
+ ) -> Survey:
794
+ """
795
+ Adds a per-question skip rule to the survey.
796
+
797
+ :param question: The question to add the skip rule to.
798
+ :param expression: The expression to evaluate.
799
+
800
+ This adds a rule that skips 'q0' always, before the question is answered:
801
+
802
+ >>> from edsl import QuestionFreeText
803
+ >>> q0 = QuestionFreeText.example()
804
+ >>> q0.question_name = "q0"
805
+ >>> q1 = QuestionFreeText.example()
806
+ >>> q1.question_name = "q1"
807
+ >>> s = Survey([q0, q1]).add_skip_rule("q0", "True")
808
+ >>> s.next_question("q0", {}).question_name
809
+ 'q1'
810
+
811
+ Note that this is different from a rule that jumps to some other question *after* the question is answered.
812
+
813
+ """
814
+ question_index = self._get_question_index(question)
815
+ return RuleManager(self).add_rule(
816
+ question, expression, question_index + 1, before_rule=True
817
+ )
818
+
819
+ def add_rule(
820
+ self,
821
+ question: Union[QuestionBase, str],
822
+ expression: str,
823
+ next_question: Union[QuestionBase, int],
824
+ before_rule: bool = False,
825
+ ) -> Survey:
826
+ """
827
+ Add a rule to a Question of the Survey.
828
+
829
+ :param question: The question to add the rule to.
830
+ :param expression: The expression to evaluate.
831
+ :param next_question: The next question to go to if the rule is true.
832
+ :param before_rule: Whether the rule is evaluated before the question is answered.
833
+
834
+ This adds a rule that if the answer to q0 is 'yes', the next question is q2 (as opposed to q1)
835
+
836
+ >>> s = Survey.example().add_rule("q0", "{{ q0 }} == 'yes'", "q2")
837
+ >>> s.next_question("q0", {"q0": "yes"}).question_name
838
+ 'q2'
839
+
840
+ """
841
+ return RuleManager(self).add_rule(
842
+ question, expression, next_question, before_rule=before_rule
843
+ )
844
+
845
+ # endregion
846
+
847
+ # region: Forward methods
848
+ def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
849
+ """Add Agents, Scenarios, and LanguageModels to a survey and returns a runnable Jobs object.
850
+
851
+ :param args: The Agents, Scenarios, and LanguageModels to add to the survey.
852
+
853
+ This takes the survey and adds an Agent and a Scenario via 'by' which converts to a Jobs object:
854
+
855
+ >>> s = Survey.example(); from edsl.agents import Agent; from edsl import Scenario
856
+ >>> s.by(Agent.example()).by(Scenario.example())
857
+ Jobs(...)
858
+ """
859
+ from edsl.jobs.Jobs import Jobs
860
+
861
+ return Jobs(survey=self).by(*args)
862
+
863
+ def to_jobs(self):
864
+ """Convert the survey to a Jobs object.
865
+ >>> s = Survey.example()
866
+ >>> s.to_jobs()
867
+ Jobs(...)
868
+ """
869
+ from edsl.jobs.Jobs import Jobs
870
+
871
+ return Jobs(survey=self)
872
+
873
+ def show_prompts(self):
874
+ """Show the prompts for the survey."""
875
+ return self.to_jobs().show_prompts()
876
+
877
+ # endregion
878
+
879
+ # region: Running the survey
880
+
881
+ def __call__(
882
+ self,
883
+ model=None,
884
+ agent=None,
885
+ cache=None,
886
+ verbose=False,
887
+ disable_remote_cache: bool = False,
888
+ disable_remote_inference: bool = False,
889
+ **kwargs,
890
+ ):
891
+ """Run the survey with default model, taking the required survey as arguments.
892
+
893
+ >>> from edsl.questions import QuestionFunctional
894
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
895
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
896
+ >>> s = Survey([q])
897
+ >>> s(period = "morning", cache = False, disable_remote_cache = True, disable_remote_inference = True).select("answer.q0").first()
898
+ 'yes'
899
+ >>> s(period = "evening", cache = False, disable_remote_cache = True, disable_remote_inference = True).select("answer.q0").first()
900
+ 'no'
901
+ """
902
+
903
+ return self.get_job(model, agent, **kwargs).run(
904
+ cache=cache,
905
+ verbose=verbose,
906
+ disable_remote_cache=disable_remote_cache,
907
+ disable_remote_inference=disable_remote_inference,
908
+ )
909
+
910
+ async def run_async(
911
+ self,
912
+ model: Optional["LanguageModel"] = None,
913
+ agent: Optional["Agent"] = None,
914
+ cache: Optional["Cache"] = None,
915
+ disable_remote_inference: bool = False,
916
+ disable_remote_cache: bool = False,
917
+ **kwargs,
918
+ ):
919
+ """Run the survey with default model, taking the required survey as arguments.
920
+
921
+ >>> import asyncio
922
+ >>> from edsl.questions import QuestionFunctional
923
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
924
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
925
+ >>> 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())
927
+ >>> asyncio.run(test_run_async())
928
+ yes
929
+ >>> import asyncio
930
+ >>> from edsl.questions import QuestionFunctional
931
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
932
+ >>> q = QuestionFunctional(question_name = "q0", func = f)
933
+ >>> 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())
936
+ no
937
+ """
938
+ # TODO: temp fix by creating a cache
939
+ if cache is None:
940
+ from edsl.data import Cache
941
+ c = Cache()
942
+ else:
943
+ c = cache
944
+
945
+
946
+
947
+ jobs: "Jobs" = self.get_job(model=model, agent=agent, **kwargs).using(c)
948
+ return await jobs.run_async(
949
+ disable_remote_inference=disable_remote_inference,
950
+ disable_remote_cache=disable_remote_cache,
951
+ )
952
+
953
+ def run(self, *args, **kwargs) -> "Results":
954
+ """Turn the survey into a Job and runs it.
955
+
956
+ >>> from edsl import QuestionFreeText
957
+ >>> s = Survey([QuestionFreeText.example()])
958
+ >>> from edsl.language_models import LanguageModel
959
+ >>> m = LanguageModel.example(test_model = True, canned_response = "Great!")
960
+ >>> results = s.by(m).run(cache = False, disable_remote_cache = True, disable_remote_inference = True)
961
+ >>> results.select('answer.*')
962
+ Dataset([{'answer.how_are_you': ['Great!']}])
963
+ """
964
+ from edsl.jobs.Jobs import Jobs
965
+
966
+ return Jobs(survey=self).run(*args, **kwargs)
967
+
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
+ # region: Survey flow
988
+ def next_question(
989
+ self,
990
+ current_question: Optional[Union[str, QuestionBase]] = None,
991
+ answers: Optional[dict] = None,
992
+ ) -> Union[QuestionBase, EndOfSurvey.__class__]:
993
+ """
994
+ Return the next question in a survey.
995
+
996
+ :param current_question: The current question in the survey.
997
+ :param answers: The answers for the survey so far
998
+
999
+ - If called with no arguments, it returns the first question in the survey.
1000
+ - 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.
1001
+ - If the next question is the last question in the survey, an EndOfSurvey object is returned.
1002
+
1003
+ >>> s = Survey.example()
1004
+ >>> s.next_question("q0", {"q0": "yes"}).question_name
1005
+ 'q2'
1006
+ >>> s.next_question("q0", {"q0": "no"}).question_name
1007
+ 'q1'
1008
+
1009
+ """
1010
+ if current_question is None:
1011
+ return self.questions[0]
1012
+
1013
+ if isinstance(current_question, str):
1014
+ current_question = self._get_question_by_name(current_question)
1015
+
1016
+ question_index = self.question_name_to_index[current_question.question_name]
1017
+ next_question_object = self.rule_collection.next_question(
1018
+ question_index, answers
1019
+ )
1020
+
1021
+ if next_question_object.num_rules_found == 0:
1022
+ raise SurveyHasNoRulesError
1023
+
1024
+ if next_question_object.next_q == EndOfSurvey:
1025
+ return EndOfSurvey
1026
+ else:
1027
+ if next_question_object.next_q >= len(self.questions):
1028
+ return EndOfSurvey
1029
+ else:
1030
+ return self.questions[next_question_object.next_q]
1031
+
1032
+ def gen_path_through_survey(self) -> Generator[QuestionBase, dict, None]:
1033
+ """
1034
+ Generate a coroutine that can be used to conduct an Interview.
1035
+
1036
+ The coroutine is a generator that yields a question and receives answers.
1037
+ It starts with the first question in the survey.
1038
+ The coroutine ends when an EndOfSurvey object is returned.
1039
+
1040
+ For the example survey, this is the rule table:
1041
+
1042
+ >>> s = Survey.example()
1043
+ >>> 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]}])
1045
+
1046
+ 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
+
1048
+ Here is the path through the survey if the answer to q0 is 'yes':
1049
+
1050
+ >>> i = s.gen_path_through_survey()
1051
+ >>> next(i)
1052
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1053
+ >>> i.send({"q0": "yes"})
1054
+ Question('multiple_choice', question_name = \"""q2\""", question_text = \"""Why?\""", question_options = ['**lack*** of killer bees in cafeteria', 'other'])
1055
+
1056
+ And here is the path through the survey if the answer to q0 is 'no':
1057
+
1058
+ >>> i2 = s.gen_path_through_survey()
1059
+ >>> next(i2)
1060
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1061
+ >>> i2.send({"q0": "no"})
1062
+ Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
1063
+
1064
+
1065
+ """
1066
+ self.answers = {}
1067
+ question = self._questions[0]
1068
+ # should the first question be skipped?
1069
+ if self.rule_collection.skip_question_before_running(0, self.answers):
1070
+ question = self.next_question(question, self.answers)
1071
+
1072
+ while not question == EndOfSurvey:
1073
+ answer = yield question
1074
+ self.answers.update(answer)
1075
+ # print(f"Answers: {self.answers}")
1076
+ ## TODO: This should also include survey and agent attributes
1077
+ question = self.next_question(question, self.answers)
1078
+
1079
+ # endregion
1080
+
1081
+ def dag(self, textify: bool = False) -> DAG:
1082
+ """Return the DAG of the survey, which reflects both skip-logic and memory.
1083
+
1084
+ :param textify: Whether to return the DAG with question names instead of indices.
1085
+
1086
+ >>> s = Survey.example()
1087
+ >>> d = s.dag()
1088
+ >>> d
1089
+ {1: {0}, 2: {0}}
1090
+
1091
+ """
1092
+ from edsl.surveys.ConstructDAG import ConstructDAG
1093
+
1094
+ return ConstructDAG(self).dag(textify)
1095
+
1096
+ ###################
1097
+ # DUNDER METHODS
1098
+ ###################
1099
+ def __len__(self) -> int:
1100
+ """Return the number of questions in the survey.
1101
+
1102
+ >>> s = Survey.example()
1103
+ >>> len(s)
1104
+ 3
1105
+ """
1106
+ return len(self._questions)
1107
+
1108
+ def __getitem__(self, index) -> QuestionBase:
1109
+ """Return the question object given the question index.
1110
+
1111
+ :param index: The index of the question to get.
1112
+
1113
+ >>> s = Survey.example()
1114
+ >>> s[0]
1115
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1116
+
1117
+ """
1118
+ if isinstance(index, int):
1119
+ return self._questions[index]
1120
+ elif isinstance(index, str):
1121
+ return getattr(self, index)
1122
+
1123
+ # def _diff(self, other):
1124
+ # """Used for debugging. Print out the differences between two surveys."""
1125
+ # from rich import print
1126
+
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")
1135
+
1136
+ def __repr__(self) -> str:
1137
+ """Return a string representation of the survey."""
1138
+
1139
+ # questions_string = ", ".join([repr(q) for q in self._questions])
1140
+ questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1141
+ # 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})"
1143
+
1144
+ def _summary(self) -> dict:
1145
+ return {
1146
+ "# questions": len(self),
1147
+ "question_name list": self.question_names,
1148
+ }
1149
+
1150
+ def tree(self, node_list: Optional[List[str]] = None):
1151
+ return self.to_scenario_list().tree(node_list=node_list)
1152
+
1153
+ def table(self, *fields, tablefmt=None) -> Table:
1154
+ return self.to_scenario_list().to_dataset().table(*fields, tablefmt=tablefmt)
1155
+
1156
+ # endregion
1157
+
1158
+ def codebook(self) -> dict[str, str]:
1159
+ """Create a codebook for the survey, mapping question names to question text.
1160
+
1161
+ >>> s = Survey.example()
1162
+ >>> s.codebook()
1163
+ {'q0': 'Do you like school?', 'q1': 'Why not?', 'q2': 'Why?'}
1164
+ """
1165
+ codebook = {}
1166
+ for question in self._questions:
1167
+ codebook[question.question_name] = question.question_text
1168
+ return codebook
1169
+
1170
+ @classmethod
1171
+ def example(
1172
+ cls,
1173
+ params: bool = False,
1174
+ randomize: bool = False,
1175
+ include_instructions=False,
1176
+ custom_instructions: Optional[str] = None,
1177
+ ) -> Survey:
1178
+ """Return an example survey.
1179
+
1180
+ >>> s = Survey.example()
1181
+ >>> [q.question_text for q in s.questions]
1182
+ ['Do you like school?', 'Why not?', 'Why?']
1183
+ """
1184
+ from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1185
+
1186
+ addition = "" if not randomize else str(uuid4())
1187
+ q0 = QuestionMultipleChoice(
1188
+ question_text=f"Do you like school?{addition}",
1189
+ question_options=["yes", "no"],
1190
+ question_name="q0",
1191
+ )
1192
+ q1 = QuestionMultipleChoice(
1193
+ question_text="Why not?",
1194
+ question_options=["killer bees in cafeteria", "other"],
1195
+ question_name="q1",
1196
+ )
1197
+ q2 = QuestionMultipleChoice(
1198
+ question_text="Why?",
1199
+ question_options=["**lack*** of killer bees in cafeteria", "other"],
1200
+ question_name="q2",
1201
+ )
1202
+ if params:
1203
+ q3 = QuestionMultipleChoice(
1204
+ question_text="To the question '{{ q0.question_text}}', you said '{{ q0.answer }}'. Do you still feel this way?",
1205
+ question_options=["yes", "no"],
1206
+ question_name="q3",
1207
+ )
1208
+ s = cls(questions=[q0, q1, q2, q3])
1209
+ return s
1210
+
1211
+ if include_instructions:
1212
+ from edsl import Instruction
1213
+
1214
+ custom_instructions = (
1215
+ custom_instructions if custom_instructions else "Please pay attention!"
1216
+ )
1217
+
1218
+ i = Instruction(text=custom_instructions, name="attention")
1219
+ s = cls(questions=[i, q0, q1, q2])
1220
+ return s
1221
+
1222
+ s = cls(questions=[q0, q1, q2])
1223
+ s = s.add_rule(q0, "q0 == 'yes'", q2)
1224
+ return s
1225
+
1226
+ def get_job(self, model=None, agent=None, **kwargs):
1227
+ if model is None:
1228
+ from edsl.language_models.model import Model
1229
+
1230
+ model = Model()
1231
+
1232
+ from edsl.scenarios.Scenario import Scenario
1233
+
1234
+ s = Scenario(kwargs)
1235
+
1236
+ if not agent:
1237
+ from edsl.agents.Agent import Agent
1238
+
1239
+ agent = Agent()
1240
+
1241
+ return self.by(s).by(agent).by(model)
1242
+
1243
+
1244
+ def main():
1245
+ """Run the example survey."""
1246
+
1247
+ def example_survey():
1248
+ """Return an example survey."""
1249
+ from edsl import QuestionMultipleChoice, QuestionList, QuestionNumerical, Survey
1250
+
1251
+ q0 = QuestionMultipleChoice(
1252
+ question_name="q0",
1253
+ question_text="What is the capital of France?",
1254
+ question_options=["London", "Paris", "Rome", "Boston", "I don't know"]
1255
+ )
1256
+ q1 = QuestionList(
1257
+ question_name="q1",
1258
+ question_text="Name some cities in France.",
1259
+ max_list_items = 5
1260
+ )
1261
+ q2 = QuestionNumerical(
1262
+ question_name="q2",
1263
+ question_text="What is the population of {{ q0.answer }}?"
1264
+ )
1265
+ s = Survey(questions=[q0, q1, q2])
1266
+ s = s.add_rule(q0, "q0 == 'Paris'", q2)
1267
+ return s
1268
+
1269
+ s = example_survey()
1270
+ survey_dict = s.to_dict()
1271
+ s2 = Survey.from_dict(survey_dict)
1272
+ results = s2.run()
1273
+ print(results)
1274
+
1275
+
1276
+ if __name__ == "__main__":
1277
+ import doctest
1278
+
1279
+ # doctest.testmod(optionflags=doctest.ELLIPSIS | doctest.SKIP)
1280
+ doctest.testmod(optionflags=doctest.ELLIPSIS)