edsl 0.1.47__py3-none-any.whl → 0.1.48__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 (314) hide show
  1. edsl/__init__.py +44 -39
  2. edsl/__version__.py +1 -1
  3. edsl/agents/__init__.py +4 -2
  4. edsl/agents/{Agent.py → agent.py} +442 -152
  5. edsl/agents/{AgentList.py → agent_list.py} +220 -162
  6. edsl/agents/descriptors.py +46 -7
  7. edsl/{exceptions/agents.py → agents/exceptions.py} +3 -12
  8. edsl/base/__init__.py +75 -0
  9. edsl/base/base_class.py +1303 -0
  10. edsl/base/data_transfer_models.py +114 -0
  11. edsl/base/enums.py +215 -0
  12. edsl/base.py +8 -0
  13. edsl/buckets/__init__.py +25 -0
  14. edsl/buckets/bucket_collection.py +324 -0
  15. edsl/buckets/model_buckets.py +206 -0
  16. edsl/buckets/token_bucket.py +502 -0
  17. edsl/{jobs/buckets/TokenBucketAPI.py → buckets/token_bucket_api.py} +1 -1
  18. edsl/buckets/token_bucket_client.py +509 -0
  19. edsl/caching/__init__.py +20 -0
  20. edsl/caching/cache.py +814 -0
  21. edsl/caching/cache_entry.py +427 -0
  22. edsl/{data/CacheHandler.py → caching/cache_handler.py} +14 -15
  23. edsl/caching/exceptions.py +24 -0
  24. edsl/caching/orm.py +30 -0
  25. edsl/{data/RemoteCacheSync.py → caching/remote_cache_sync.py} +3 -3
  26. edsl/caching/sql_dict.py +441 -0
  27. edsl/config/__init__.py +8 -0
  28. edsl/config/config_class.py +177 -0
  29. edsl/config.py +4 -176
  30. edsl/conversation/Conversation.py +7 -7
  31. edsl/conversation/car_buying.py +4 -4
  32. edsl/conversation/chips.py +6 -6
  33. edsl/coop/__init__.py +25 -2
  34. edsl/coop/coop.py +303 -67
  35. edsl/coop/{ExpectedParrotKeyHandler.py → ep_key_handling.py} +86 -10
  36. edsl/coop/exceptions.py +62 -0
  37. edsl/coop/price_fetcher.py +126 -0
  38. edsl/coop/utils.py +89 -24
  39. edsl/data_transfer_models.py +5 -72
  40. edsl/dataset/__init__.py +10 -0
  41. edsl/{results/Dataset.py → dataset/dataset.py} +116 -36
  42. edsl/{results/DatasetExportMixin.py → dataset/dataset_operations_mixin.py} +606 -122
  43. edsl/{results/DatasetTree.py → dataset/dataset_tree.py} +156 -75
  44. edsl/{results/TableDisplay.py → dataset/display/table_display.py} +18 -7
  45. edsl/{results → dataset/display}/table_renderers.py +58 -2
  46. edsl/{results → dataset}/file_exports.py +4 -5
  47. edsl/{results → dataset}/smart_objects.py +2 -2
  48. edsl/enums.py +5 -205
  49. edsl/inference_services/__init__.py +5 -0
  50. edsl/inference_services/{AvailableModelCacheHandler.py → available_model_cache_handler.py} +2 -3
  51. edsl/inference_services/{AvailableModelFetcher.py → available_model_fetcher.py} +8 -14
  52. edsl/inference_services/data_structures.py +3 -2
  53. edsl/{exceptions/inference_services.py → inference_services/exceptions.py} +1 -1
  54. edsl/inference_services/{InferenceServiceABC.py → inference_service_abc.py} +1 -1
  55. edsl/inference_services/{InferenceServicesCollection.py → inference_services_collection.py} +8 -7
  56. edsl/inference_services/registry.py +4 -41
  57. edsl/inference_services/{ServiceAvailability.py → service_availability.py} +5 -25
  58. edsl/inference_services/services/__init__.py +31 -0
  59. edsl/inference_services/{AnthropicService.py → services/anthropic_service.py} +3 -3
  60. edsl/inference_services/{AwsBedrock.py → services/aws_bedrock.py} +2 -2
  61. edsl/inference_services/{AzureAI.py → services/azure_ai.py} +2 -2
  62. edsl/inference_services/{DeepInfraService.py → services/deep_infra_service.py} +1 -3
  63. edsl/inference_services/{DeepSeekService.py → services/deep_seek_service.py} +2 -4
  64. edsl/inference_services/{GoogleService.py → services/google_service.py} +5 -4
  65. edsl/inference_services/{GroqService.py → services/groq_service.py} +1 -1
  66. edsl/inference_services/{MistralAIService.py → services/mistral_ai_service.py} +3 -3
  67. edsl/inference_services/{OllamaService.py → services/ollama_service.py} +1 -7
  68. edsl/inference_services/{OpenAIService.py → services/open_ai_service.py} +5 -6
  69. edsl/inference_services/{PerplexityService.py → services/perplexity_service.py} +3 -7
  70. edsl/inference_services/{TestService.py → services/test_service.py} +7 -6
  71. edsl/inference_services/{TogetherAIService.py → services/together_ai_service.py} +2 -6
  72. edsl/inference_services/{XAIService.py → services/xai_service.py} +1 -1
  73. edsl/inference_services/write_available.py +1 -2
  74. edsl/instructions/__init__.py +6 -0
  75. edsl/{surveys/instructions/Instruction.py → instructions/instruction.py} +11 -6
  76. edsl/{surveys/instructions/InstructionCollection.py → instructions/instruction_collection.py} +10 -5
  77. edsl/{surveys/InstructionHandler.py → instructions/instruction_handler.py} +3 -3
  78. edsl/{jobs/interviews → interviews}/ReportErrors.py +2 -2
  79. edsl/interviews/__init__.py +4 -0
  80. edsl/{jobs/AnswerQuestionFunctionConstructor.py → interviews/answering_function.py} +45 -18
  81. edsl/{jobs/interviews/InterviewExceptionEntry.py → interviews/exception_tracking.py} +107 -22
  82. edsl/interviews/interview.py +638 -0
  83. edsl/{jobs/interviews/InterviewStatusDictionary.py → interviews/interview_status_dictionary.py} +21 -12
  84. edsl/{jobs/interviews/InterviewStatusLog.py → interviews/interview_status_log.py} +16 -7
  85. edsl/{jobs/InterviewTaskManager.py → interviews/interview_task_manager.py} +12 -7
  86. edsl/{jobs/RequestTokenEstimator.py → interviews/request_token_estimator.py} +8 -3
  87. edsl/{jobs/interviews/InterviewStatistic.py → interviews/statistics.py} +36 -10
  88. edsl/invigilators/__init__.py +38 -0
  89. edsl/invigilators/invigilator_base.py +477 -0
  90. edsl/{agents/Invigilator.py → invigilators/invigilators.py} +263 -10
  91. edsl/invigilators/prompt_constructor.py +476 -0
  92. edsl/{agents → invigilators}/prompt_helpers.py +2 -1
  93. edsl/{agents/QuestionInstructionPromptBuilder.py → invigilators/question_instructions_prompt_builder.py} +18 -13
  94. edsl/{agents → invigilators}/question_option_processor.py +96 -21
  95. edsl/{agents/QuestionTemplateReplacementsBuilder.py → invigilators/question_template_replacements_builder.py} +64 -12
  96. edsl/jobs/__init__.py +7 -1
  97. edsl/jobs/async_interview_runner.py +99 -35
  98. edsl/jobs/check_survey_scenario_compatibility.py +7 -5
  99. edsl/jobs/data_structures.py +153 -22
  100. edsl/{exceptions/jobs.py → jobs/exceptions.py} +2 -1
  101. edsl/jobs/{FetchInvigilator.py → fetch_invigilator.py} +4 -4
  102. edsl/jobs/{loggers/HTMLTableJobLogger.py → html_table_job_logger.py} +6 -2
  103. edsl/jobs/{Jobs.py → jobs.py} +313 -167
  104. edsl/jobs/{JobsChecks.py → jobs_checks.py} +15 -7
  105. edsl/jobs/{JobsComponentConstructor.py → jobs_component_constructor.py} +19 -17
  106. edsl/jobs/{InterviewsConstructor.py → jobs_interview_constructor.py} +10 -5
  107. edsl/jobs/jobs_pricing_estimation.py +347 -0
  108. edsl/jobs/{JobsRemoteInferenceLogger.py → jobs_remote_inference_logger.py} +4 -3
  109. edsl/jobs/jobs_runner_asyncio.py +282 -0
  110. edsl/jobs/{JobsRemoteInferenceHandler.py → remote_inference.py} +19 -22
  111. edsl/jobs/results_exceptions_handler.py +2 -2
  112. edsl/key_management/__init__.py +28 -0
  113. edsl/key_management/key_lookup.py +161 -0
  114. edsl/{language_models/key_management/KeyLookupBuilder.py → key_management/key_lookup_builder.py} +118 -47
  115. edsl/key_management/key_lookup_collection.py +82 -0
  116. edsl/key_management/models.py +218 -0
  117. edsl/language_models/__init__.py +7 -2
  118. edsl/language_models/{ComputeCost.py → compute_cost.py} +18 -3
  119. edsl/{exceptions/language_models.py → language_models/exceptions.py} +2 -1
  120. edsl/language_models/language_model.py +1080 -0
  121. edsl/language_models/model.py +10 -25
  122. edsl/language_models/{ModelList.py → model_list.py} +9 -14
  123. edsl/language_models/{RawResponseHandler.py → raw_response_handler.py} +1 -1
  124. edsl/language_models/{RegisterLanguageModelsMeta.py → registry.py} +1 -1
  125. edsl/language_models/repair.py +4 -4
  126. edsl/language_models/utilities.py +4 -4
  127. edsl/notebooks/__init__.py +3 -1
  128. edsl/notebooks/{Notebook.py → notebook.py} +7 -8
  129. edsl/prompts/__init__.py +1 -1
  130. edsl/{exceptions/prompts.py → prompts/exceptions.py} +3 -1
  131. edsl/prompts/{Prompt.py → prompt.py} +101 -95
  132. edsl/questions/HTMLQuestion.py +1 -1
  133. edsl/questions/__init__.py +154 -25
  134. edsl/questions/answer_validator_mixin.py +1 -1
  135. edsl/questions/compose_questions.py +4 -3
  136. edsl/questions/derived/question_likert_five.py +166 -0
  137. edsl/questions/derived/{QuestionLinearScale.py → question_linear_scale.py} +4 -4
  138. edsl/questions/derived/{QuestionTopK.py → question_top_k.py} +4 -4
  139. edsl/questions/derived/{QuestionYesNo.py → question_yes_no.py} +4 -5
  140. edsl/questions/descriptors.py +24 -30
  141. edsl/questions/loop_processor.py +65 -19
  142. edsl/questions/question_base.py +881 -0
  143. edsl/questions/question_base_gen_mixin.py +15 -16
  144. edsl/questions/{QuestionBasePromptsMixin.py → question_base_prompts_mixin.py} +2 -2
  145. edsl/questions/{QuestionBudget.py → question_budget.py} +3 -4
  146. edsl/questions/{QuestionCheckBox.py → question_check_box.py} +16 -16
  147. edsl/questions/{QuestionDict.py → question_dict.py} +39 -5
  148. edsl/questions/{QuestionExtract.py → question_extract.py} +9 -9
  149. edsl/questions/question_free_text.py +282 -0
  150. edsl/questions/{QuestionFunctional.py → question_functional.py} +6 -5
  151. edsl/questions/{QuestionList.py → question_list.py} +6 -7
  152. edsl/questions/{QuestionMatrix.py → question_matrix.py} +6 -5
  153. edsl/questions/{QuestionMultipleChoice.py → question_multiple_choice.py} +126 -21
  154. edsl/questions/{QuestionNumerical.py → question_numerical.py} +5 -5
  155. edsl/questions/{QuestionRank.py → question_rank.py} +6 -6
  156. edsl/questions/question_registry.py +4 -9
  157. edsl/questions/register_questions_meta.py +8 -4
  158. edsl/questions/response_validator_abc.py +17 -16
  159. edsl/results/__init__.py +4 -1
  160. edsl/{exceptions/results.py → results/exceptions.py} +1 -1
  161. edsl/results/report.py +197 -0
  162. edsl/results/{Result.py → result.py} +131 -45
  163. edsl/results/{Results.py → results.py} +365 -220
  164. edsl/results/results_selector.py +344 -25
  165. edsl/scenarios/__init__.py +30 -3
  166. edsl/scenarios/{ConstructDownloadLink.py → construct_download_link.py} +7 -0
  167. edsl/scenarios/directory_scanner.py +156 -13
  168. edsl/scenarios/document_chunker.py +186 -0
  169. edsl/scenarios/exceptions.py +101 -0
  170. edsl/scenarios/file_methods.py +2 -3
  171. edsl/scenarios/{FileStore.py → file_store.py} +275 -189
  172. edsl/scenarios/handlers/__init__.py +14 -14
  173. edsl/scenarios/handlers/{csv.py → csv_file_store.py} +1 -2
  174. edsl/scenarios/handlers/{docx.py → docx_file_store.py} +8 -7
  175. edsl/scenarios/handlers/{html.py → html_file_store.py} +1 -2
  176. edsl/scenarios/handlers/{jpeg.py → jpeg_file_store.py} +1 -1
  177. edsl/scenarios/handlers/{json.py → json_file_store.py} +1 -1
  178. edsl/scenarios/handlers/latex_file_store.py +5 -0
  179. edsl/scenarios/handlers/{md.py → md_file_store.py} +1 -1
  180. edsl/scenarios/handlers/{pdf.py → pdf_file_store.py} +2 -2
  181. edsl/scenarios/handlers/{png.py → png_file_store.py} +1 -1
  182. edsl/scenarios/handlers/{pptx.py → pptx_file_store.py} +8 -7
  183. edsl/scenarios/handlers/{py.py → py_file_store.py} +1 -3
  184. edsl/scenarios/handlers/{sql.py → sql_file_store.py} +2 -1
  185. edsl/scenarios/handlers/{sqlite.py → sqlite_file_store.py} +2 -3
  186. edsl/scenarios/handlers/{txt.py → txt_file_store.py} +1 -1
  187. edsl/scenarios/scenario.py +928 -0
  188. edsl/scenarios/scenario_join.py +18 -5
  189. edsl/scenarios/{ScenarioList.py → scenario_list.py} +294 -106
  190. edsl/scenarios/{ScenarioListPdfMixin.py → scenario_list_pdf_tools.py} +16 -15
  191. edsl/scenarios/scenario_selector.py +5 -1
  192. edsl/study/ObjectEntry.py +2 -2
  193. edsl/study/SnapShot.py +5 -5
  194. edsl/study/Study.py +18 -19
  195. edsl/study/__init__.py +6 -4
  196. edsl/surveys/__init__.py +7 -4
  197. edsl/surveys/dag/__init__.py +2 -0
  198. edsl/surveys/{ConstructDAG.py → dag/construct_dag.py} +3 -3
  199. edsl/surveys/{DAG.py → dag/dag.py} +13 -10
  200. edsl/surveys/descriptors.py +1 -1
  201. edsl/surveys/{EditSurvey.py → edit_survey.py} +9 -9
  202. edsl/{exceptions/surveys.py → surveys/exceptions.py} +1 -2
  203. edsl/surveys/memory/__init__.py +3 -0
  204. edsl/surveys/{MemoryPlan.py → memory/memory_plan.py} +10 -9
  205. edsl/surveys/rules/__init__.py +3 -0
  206. edsl/surveys/{Rule.py → rules/rule.py} +103 -43
  207. edsl/surveys/{RuleCollection.py → rules/rule_collection.py} +21 -30
  208. edsl/surveys/{RuleManager.py → rules/rule_manager.py} +19 -13
  209. edsl/surveys/survey.py +1743 -0
  210. edsl/surveys/{SurveyExportMixin.py → survey_export.py} +22 -27
  211. edsl/surveys/{SurveyFlowVisualization.py → survey_flow_visualization.py} +11 -2
  212. edsl/surveys/{Simulator.py → survey_simulator.py} +10 -3
  213. edsl/tasks/__init__.py +32 -0
  214. edsl/{jobs/tasks/QuestionTaskCreator.py → tasks/question_task_creator.py} +115 -57
  215. edsl/tasks/task_creators.py +135 -0
  216. edsl/{jobs/tasks/TaskHistory.py → tasks/task_history.py} +86 -47
  217. edsl/{jobs/tasks → tasks}/task_status_enum.py +91 -7
  218. edsl/tasks/task_status_log.py +85 -0
  219. edsl/tokens/__init__.py +2 -0
  220. edsl/tokens/interview_token_usage.py +53 -0
  221. edsl/utilities/PrettyList.py +1 -1
  222. edsl/utilities/SystemInfo.py +25 -22
  223. edsl/utilities/__init__.py +29 -21
  224. edsl/utilities/gcp_bucket/__init__.py +2 -0
  225. edsl/utilities/gcp_bucket/cloud_storage.py +99 -96
  226. edsl/utilities/interface.py +44 -536
  227. edsl/{results/MarkdownToPDF.py → utilities/markdown_to_pdf.py} +13 -5
  228. edsl/utilities/repair_functions.py +1 -1
  229. {edsl-0.1.47.dist-info → edsl-0.1.48.dist-info}/METADATA +1 -1
  230. edsl-0.1.48.dist-info/RECORD +347 -0
  231. edsl/Base.py +0 -493
  232. edsl/BaseDiff.py +0 -260
  233. edsl/agents/InvigilatorBase.py +0 -260
  234. edsl/agents/PromptConstructor.py +0 -318
  235. edsl/coop/PriceFetcher.py +0 -54
  236. edsl/data/Cache.py +0 -582
  237. edsl/data/CacheEntry.py +0 -238
  238. edsl/data/SQLiteDict.py +0 -292
  239. edsl/data/__init__.py +0 -5
  240. edsl/data/orm.py +0 -10
  241. edsl/exceptions/cache.py +0 -5
  242. edsl/exceptions/coop.py +0 -14
  243. edsl/exceptions/data.py +0 -14
  244. edsl/exceptions/scenarios.py +0 -29
  245. edsl/jobs/Answers.py +0 -43
  246. edsl/jobs/JobsPrompts.py +0 -354
  247. edsl/jobs/buckets/BucketCollection.py +0 -134
  248. edsl/jobs/buckets/ModelBuckets.py +0 -65
  249. edsl/jobs/buckets/TokenBucket.py +0 -283
  250. edsl/jobs/buckets/TokenBucketClient.py +0 -191
  251. edsl/jobs/interviews/Interview.py +0 -395
  252. edsl/jobs/interviews/InterviewExceptionCollection.py +0 -99
  253. edsl/jobs/interviews/InterviewStatisticsCollection.py +0 -25
  254. edsl/jobs/runners/JobsRunnerAsyncio.py +0 -163
  255. edsl/jobs/runners/JobsRunnerStatusData.py +0 -0
  256. edsl/jobs/tasks/TaskCreators.py +0 -64
  257. edsl/jobs/tasks/TaskStatusLog.py +0 -23
  258. edsl/jobs/tokens/InterviewTokenUsage.py +0 -27
  259. edsl/language_models/LanguageModel.py +0 -635
  260. edsl/language_models/ServiceDataSources.py +0 -0
  261. edsl/language_models/key_management/KeyLookup.py +0 -63
  262. edsl/language_models/key_management/KeyLookupCollection.py +0 -38
  263. edsl/language_models/key_management/models.py +0 -137
  264. edsl/questions/QuestionBase.py +0 -544
  265. edsl/questions/QuestionFreeText.py +0 -130
  266. edsl/questions/derived/QuestionLikertFive.py +0 -76
  267. edsl/results/ResultsExportMixin.py +0 -45
  268. edsl/results/TextEditor.py +0 -50
  269. edsl/results/results_fetch_mixin.py +0 -33
  270. edsl/results/results_tools_mixin.py +0 -98
  271. edsl/scenarios/DocumentChunker.py +0 -104
  272. edsl/scenarios/Scenario.py +0 -548
  273. edsl/scenarios/ScenarioHtmlMixin.py +0 -65
  274. edsl/scenarios/ScenarioListExportMixin.py +0 -45
  275. edsl/scenarios/handlers/latex.py +0 -5
  276. edsl/shared.py +0 -1
  277. edsl/surveys/Survey.py +0 -1301
  278. edsl/surveys/SurveyQualtricsImport.py +0 -284
  279. edsl/surveys/SurveyToApp.py +0 -141
  280. edsl/surveys/instructions/__init__.py +0 -0
  281. edsl/tools/__init__.py +0 -1
  282. edsl/tools/clusters.py +0 -192
  283. edsl/tools/embeddings.py +0 -27
  284. edsl/tools/embeddings_plotting.py +0 -118
  285. edsl/tools/plotting.py +0 -112
  286. edsl/tools/summarize.py +0 -18
  287. edsl/utilities/data/Registry.py +0 -6
  288. edsl/utilities/data/__init__.py +0 -1
  289. edsl/utilities/data/scooter_results.json +0 -1
  290. edsl-0.1.47.dist-info/RECORD +0 -354
  291. /edsl/coop/{CoopFunctionsMixin.py → coop_functions.py} +0 -0
  292. /edsl/{results → dataset/display}/CSSParameterizer.py +0 -0
  293. /edsl/{language_models/key_management → dataset/display}/__init__.py +0 -0
  294. /edsl/{results → dataset/display}/table_data_class.py +0 -0
  295. /edsl/{results → dataset/display}/table_display.css +0 -0
  296. /edsl/{results/ResultsGGMixin.py → dataset/r/ggplot.py} +0 -0
  297. /edsl/{results → dataset}/tree_explore.py +0 -0
  298. /edsl/{surveys/instructions/ChangeInstruction.py → instructions/change_instruction.py} +0 -0
  299. /edsl/{jobs/interviews → interviews}/interview_status_enum.py +0 -0
  300. /edsl/jobs/{runners/JobsRunnerStatus.py → jobs_runner_status.py} +0 -0
  301. /edsl/language_models/{PriceManager.py → price_manager.py} +0 -0
  302. /edsl/language_models/{fake_openai_call.py → unused/fake_openai_call.py} +0 -0
  303. /edsl/language_models/{fake_openai_service.py → unused/fake_openai_service.py} +0 -0
  304. /edsl/notebooks/{NotebookToLaTeX.py → notebook_to_latex.py} +0 -0
  305. /edsl/{exceptions/questions.py → questions/exceptions.py} +0 -0
  306. /edsl/questions/{SimpleAskMixin.py → simple_ask_mixin.py} +0 -0
  307. /edsl/surveys/{Memory.py → memory/memory.py} +0 -0
  308. /edsl/surveys/{MemoryManagement.py → memory/memory_management.py} +0 -0
  309. /edsl/surveys/{SurveyCSS.py → survey_css.py} +0 -0
  310. /edsl/{jobs/tokens/TokenUsage.py → tokens/token_usage.py} +0 -0
  311. /edsl/{results/MarkdownToDocx.py → utilities/markdown_to_docx.py} +0 -0
  312. /edsl/{TemplateLoader.py → utilities/template_loader.py} +0 -0
  313. {edsl-0.1.47.dist-info → edsl-0.1.48.dist-info}/LICENSE +0 -0
  314. {edsl-0.1.47.dist-info → edsl-0.1.48.dist-info}/WHEEL +0 -0
edsl/surveys/Survey.py DELETED
@@ -1,1301 +0,0 @@
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
- # Cache the InstructionCollection
176
- self._cached_instruction_collection = None
177
-
178
- def question_names_valid(self) -> bool:
179
- """Check if the question names are valid."""
180
- return all(q.is_valid_question_name() for q in self.questions)
181
-
182
- def draw(self) -> "Survey":
183
- """Return a new survey with a randomly selected permutation of the options."""
184
- if self._seed is None: # only set once
185
- self._seed = hash(self)
186
- random.seed(self._seed)
187
-
188
- if len(self.questions_to_randomize) == 0:
189
- return self
190
-
191
- new_questions = []
192
- for question in self.questions:
193
- if question.question_name in self.questions_to_randomize:
194
- new_questions.append(question.draw())
195
- else:
196
- new_questions.append(question.duplicate())
197
-
198
- d = self.to_dict()
199
- d["questions"] = [q.to_dict() for q in new_questions]
200
- return Survey.from_dict(d)
201
-
202
- def _process_raw_questions(self, questions: Optional[List["QuestionType"]]) -> list:
203
- """Process the raw questions passed to the survey."""
204
- handler = InstructionHandler(self)
205
- components = handler.separate_questions_and_instructions(questions or [])
206
- self._instruction_names_to_instructions = (
207
- components.instruction_names_to_instructions
208
- )
209
- self._pseudo_indices = PseudoIndices(components.pseudo_indices)
210
- return components.true_questions
211
-
212
- # region: Survey instruction handling
213
- @property
214
- def _relevant_instructions_dict(self) -> InstructionCollection:
215
- """Return a dictionary with keys as question names and values as instructions that are relevant to the question."""
216
- if self._cached_instruction_collection is None:
217
- self._cached_instruction_collection = InstructionCollection(
218
- self._instruction_names_to_instructions, self.questions
219
- )
220
- return self._cached_instruction_collection
221
-
222
- def _relevant_instructions(self, question: QuestionBase) -> dict:
223
- """Return instructions that are relevant to the question."""
224
- return self._relevant_instructions_dict[question]
225
-
226
- def show_flow(self, filename: Optional[str] = None) -> None:
227
- """Show the flow of the survey."""
228
- SurveyFlowVisualization(self).show_flow(filename=filename)
229
-
230
- def add_instruction(
231
- self, instruction: Union["Instruction", "ChangeInstruction"]
232
- ) -> Survey:
233
- """
234
- Add an instruction to the survey.
235
-
236
- :param instruction: The instruction to add to the survey.
237
-
238
- >>> from edsl import Instruction
239
- >>> i = Instruction(text="Pay attention to the following questions.", name="intro")
240
- >>> s = Survey().add_instruction(i)
241
- >>> s._instruction_names_to_instructions
242
- {'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
243
- >>> s._pseudo_indices
244
- {'intro': -0.5}
245
- """
246
- return EditSurvey(self).add_instruction(instruction)
247
-
248
- # endregion
249
- @classmethod
250
- def random_survey(cls):
251
- return Simulator.random_survey()
252
-
253
- def simulate(self) -> dict:
254
- """Simulate the survey and return the answers."""
255
- return Simulator(self).simulate()
256
-
257
- # endregion
258
-
259
- # region: Access methods
260
- def _get_question_index(
261
- self, q: Union[QuestionBase, str, EndOfSurvey.__class__]
262
- ) -> Union[int, EndOfSurvey.__class__]:
263
- """Return the index of the question or EndOfSurvey object.
264
-
265
- :param q: The question or question name to get the index of.
266
-
267
- It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
268
-
269
- >>> s = Survey.example()
270
- >>> s._get_question_index("q0")
271
- 0
272
-
273
- This doesnt' work with questions that don't exist:
274
-
275
- >>> s._get_question_index("poop")
276
- Traceback (most recent call last):
277
- ...
278
- edsl.exceptions.surveys.SurveyError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
279
- ...
280
- """
281
- if q == EndOfSurvey:
282
- return EndOfSurvey
283
- else:
284
- question_name = q if isinstance(q, str) else q.question_name
285
- if question_name not in self.question_name_to_index:
286
- raise SurveyError(
287
- f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
288
- )
289
- return self.question_name_to_index[question_name]
290
-
291
- def _get_question_by_name(self, question_name: str) -> QuestionBase:
292
- """
293
- Return the question object given the question name.
294
-
295
- :param question_name: The name of the question to get.
296
-
297
- >>> s = Survey.example()
298
- >>> s._get_question_by_name("q0")
299
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
300
- """
301
- if question_name not in self.question_name_to_index:
302
- raise SurveyError(f"Question name {question_name} not found in survey.")
303
- return self._questions[self.question_name_to_index[question_name]]
304
-
305
- def question_names_to_questions(self) -> dict:
306
- """Return a dictionary mapping question names to question attributes."""
307
- return {q.question_name: q for q in self.questions}
308
-
309
- @property
310
- def question_names(self) -> list[str]:
311
- """Return a list of question names in the survey.
312
-
313
- Example:
314
-
315
- >>> s = Survey.example()
316
- >>> s.question_names
317
- ['q0', 'q1', 'q2']
318
- """
319
- return [q.question_name for q in self.questions]
320
-
321
- @property
322
- def question_name_to_index(self) -> dict[str, int]:
323
- """Return a dictionary mapping question names to question indices.
324
-
325
- Example:
326
-
327
- >>> s = Survey.example()
328
- >>> s.question_name_to_index
329
- {'q0': 0, 'q1': 1, 'q2': 2}
330
- """
331
- return {q.question_name: i for i, q in enumerate(self.questions)}
332
-
333
- # endregion
334
-
335
- # region: serialization methods
336
- def to_dict(self, add_edsl_version=True) -> dict[str, Any]:
337
- """Serialize the Survey object to a dictionary.
338
-
339
- >>> s = Survey.example()
340
- >>> s.to_dict(add_edsl_version = False).keys()
341
- dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
342
- """
343
- from edsl import __version__
344
-
345
- d = {
346
- "questions": [
347
- q.to_dict(add_edsl_version=add_edsl_version)
348
- for q in self._recombined_questions_and_instructions()
349
- ],
350
- "memory_plan": self.memory_plan.to_dict(add_edsl_version=add_edsl_version),
351
- "rule_collection": self.rule_collection.to_dict(
352
- add_edsl_version=add_edsl_version
353
- ),
354
- "question_groups": self.question_groups,
355
- }
356
- if self.questions_to_randomize != []:
357
- d["questions_to_randomize"] = self.questions_to_randomize
358
-
359
- if add_edsl_version:
360
- d["edsl_version"] = __version__
361
- d["edsl_class_name"] = "Survey"
362
- return d
363
-
364
- @classmethod
365
- @remove_edsl_version
366
- def from_dict(cls, data: dict) -> Survey:
367
- """Deserialize the dictionary back to a Survey object.
368
-
369
- :param data: The dictionary to deserialize.
370
-
371
- >>> d = Survey.example().to_dict()
372
- >>> s = Survey.from_dict(d)
373
- >>> s == Survey.example()
374
- True
375
-
376
- >>> s = Survey.example(include_instructions = True)
377
- >>> d = s.to_dict()
378
- >>> news = Survey.from_dict(d)
379
- >>> news == s
380
- True
381
-
382
- """
383
-
384
- def get_class(pass_dict):
385
- from edsl.questions.QuestionBase import QuestionBase
386
-
387
- if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
388
- return QuestionBase
389
- elif pass_dict.get("edsl_class_name") == "QuestionDict":
390
- from edsl.questions.QuestionDict import QuestionDict
391
-
392
- return QuestionDict
393
- elif class_name == "Instruction":
394
- from edsl.surveys.instructions.Instruction import Instruction
395
-
396
- return Instruction
397
- elif class_name == "ChangeInstruction":
398
- from edsl.surveys.instructions.ChangeInstruction import (
399
- ChangeInstruction,
400
- )
401
-
402
- return ChangeInstruction
403
- else:
404
- return QuestionBase
405
-
406
- questions = [
407
- get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
408
- ]
409
- memory_plan = MemoryPlan.from_dict(data["memory_plan"])
410
- if "questions_to_randomize" in data:
411
- questions_to_randomize = data["questions_to_randomize"]
412
- else:
413
- questions_to_randomize = None
414
- survey = cls(
415
- questions=questions,
416
- memory_plan=memory_plan,
417
- rule_collection=RuleCollection.from_dict(data["rule_collection"]),
418
- question_groups=data["question_groups"],
419
- questions_to_randomize=questions_to_randomize,
420
- )
421
- return survey
422
-
423
- # endregion
424
-
425
- # region: Survey template parameters
426
- @property
427
- def scenario_attributes(self) -> list[str]:
428
- """Return a list of attributes that admissible Scenarios should have.
429
-
430
- Here we have a survey with a question that uses a jinja2 style {{ }} template:
431
-
432
- >>> from edsl import QuestionFreeText
433
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
434
- >>> s.scenario_attributes
435
- ['greeting']
436
-
437
- >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
438
- >>> s.scenario_attributes
439
- ['greeting', 'attribute']
440
-
441
-
442
- """
443
- temp = []
444
- for question in self.questions:
445
- question_text = question.question_text
446
- # extract the contents of all {{ }} in the question text using regex
447
- matches = re.findall(r"\{\{(.+?)\}\}", question_text)
448
- # remove whitespace
449
- matches = [match.strip() for match in matches]
450
- # add them to the temp list
451
- temp.extend(matches)
452
- return temp
453
-
454
- @property
455
- def parameters(self):
456
- """Return a set of parameters in the survey.
457
-
458
- >>> s = Survey.example()
459
- >>> s.parameters
460
- set()
461
- """
462
- return set.union(*[q.parameters for q in self.questions])
463
-
464
- @property
465
- def parameters_by_question(self):
466
- """Return a dictionary of parameters by question in the survey.
467
- >>> from edsl import QuestionFreeText
468
- >>> q = QuestionFreeText(question_name = "example", question_text = "What is the capital of {{ country}}?")
469
- >>> s = Survey([q])
470
- >>> s.parameters_by_question
471
- {'example': {'country'}}
472
- """
473
- return {q.question_name: q.parameters for q in self.questions}
474
-
475
- # endregion
476
-
477
- # region: Survey construction
478
-
479
- # region: Adding questions and combining surveys
480
- def __add__(self, other: Survey) -> Survey:
481
- """Combine two surveys.
482
-
483
- :param other: The other survey to combine with this one.
484
- >>> s1 = Survey.example()
485
- >>> from edsl import QuestionFreeText
486
- >>> s2 = Survey([QuestionFreeText(question_text="What is your name?", question_name="yo")])
487
- >>> s3 = s1 + s2
488
- Traceback (most recent call last):
489
- ...
490
- edsl.exceptions.surveys.SurveyCreationError: ...
491
- ...
492
- >>> s3 = s1.clear_non_default_rules() + s2
493
- >>> len(s3.questions)
494
- 4
495
-
496
- """
497
- if (
498
- len(self.rule_collection.non_default_rules) > 0
499
- or len(other.rule_collection.non_default_rules) > 0
500
- ):
501
- raise SurveyCreationError(
502
- "Cannot combine two surveys with non-default rules. Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.",
503
- )
504
-
505
- return Survey(questions=self.questions + other.questions)
506
-
507
- def move_question(self, identifier: Union[str, int], new_index: int) -> Survey:
508
- """
509
- >>> from edsl import QuestionMultipleChoice, Survey
510
- >>> s = Survey.example()
511
- >>> s.question_names
512
- ['q0', 'q1', 'q2']
513
- >>> s.move_question("q0", 2).question_names
514
- ['q1', 'q2', 'q0']
515
- """
516
- return EditSurvey(self).move_question(identifier, new_index)
517
-
518
- def delete_question(self, identifier: Union[str, int]) -> Survey:
519
- """
520
- Delete a question from the survey.
521
-
522
- :param identifier: The name or index of the question to delete.
523
- :return: The updated Survey object.
524
-
525
- >>> from edsl import QuestionMultipleChoice, Survey
526
- >>> q1 = QuestionMultipleChoice(question_text="Q1", question_options=["A", "B"], question_name="q1")
527
- >>> q2 = QuestionMultipleChoice(question_text="Q2", question_options=["C", "D"], question_name="q2")
528
- >>> s = Survey().add_question(q1).add_question(q2)
529
- >>> _ = s.delete_question("q1")
530
- >>> len(s.questions)
531
- 1
532
- >>> _ = s.delete_question(0)
533
- >>> len(s.questions)
534
- 0
535
- """
536
- return EditSurvey(self).delete_question(identifier)
537
-
538
- def add_question(
539
- self, question: QuestionBase, index: Optional[int] = None
540
- ) -> Survey:
541
- """
542
- Add a question to survey.
543
-
544
- :param question: The question to add to the survey.
545
- :param question_name: The name of the question. If not provided, the question name is used.
546
-
547
- The question is appended at the end of the self.questions list
548
- A default rule is created that the next index is the next question.
549
-
550
- >>> from edsl import QuestionMultipleChoice
551
- >>> q = QuestionMultipleChoice(question_text = "Do you like school?", question_options=["yes", "no"], question_name="q0")
552
- >>> s = Survey().add_question(q)
553
-
554
- >>> s = Survey().add_question(q).add_question(q)
555
- Traceback (most recent call last):
556
- ...
557
- edsl.exceptions.surveys.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
558
- ...
559
- """
560
- return EditSurvey(self).add_question(question, index)
561
-
562
- def _recombined_questions_and_instructions(
563
- self,
564
- ) -> list[Union[QuestionBase, "Instruction"]]:
565
- """Return a list of questions and instructions sorted by pseudo index."""
566
- questions_and_instructions = self._questions + list(
567
- self._instruction_names_to_instructions.values()
568
- )
569
- return sorted(
570
- questions_and_instructions, key=lambda x: self._pseudo_indices[x.name]
571
- )
572
-
573
- # endregion
574
-
575
- # region: Memory plan methods
576
- def set_full_memory_mode(self) -> Survey:
577
- """Add instructions to a survey that the agent should remember all of the answers to the questions in the survey.
578
-
579
- >>> s = Survey.example().set_full_memory_mode()
580
-
581
- """
582
- MemoryManagement(self)._set_memory_plan(lambda i: self.question_names[:i])
583
- return self
584
-
585
- def set_lagged_memory(self, lags: int) -> Survey:
586
- """Add instructions to a survey that the agent should remember the answers to the questions in the survey.
587
-
588
- The agent should remember the answers to the questions in the survey from the previous lags.
589
- """
590
- MemoryManagement(self)._set_memory_plan(
591
- lambda i: self.question_names[max(0, i - lags) : i]
592
- )
593
- return self
594
-
595
- def _set_memory_plan(self, prior_questions_func: Callable) -> None:
596
- """Set memory plan based on a provided function determining prior questions.
597
-
598
- :param prior_questions_func: A function that takes the index of the current question and returns a list of prior questions to remember.
599
-
600
- >>> s = Survey.example()
601
- >>> s._set_memory_plan(lambda i: s.question_names[:i])
602
-
603
- """
604
- MemoryManagement(self)._set_memory_plan(prior_questions_func)
605
-
606
- def add_targeted_memory(
607
- self,
608
- focal_question: Union[QuestionBase, str],
609
- prior_question: Union[QuestionBase, str],
610
- ) -> Survey:
611
- """Add instructions to a survey than when answering focal_question.
612
-
613
- :param focal_question: The question that the agent is answering.
614
- :param prior_question: The question that the agent should remember when answering the focal question.
615
-
616
- Here we add instructions to a survey than when answering q2 they should remember q1:
617
-
618
- >>> s = Survey.example().add_targeted_memory("q2", "q0")
619
- >>> s.memory_plan
620
- {'q2': Memory(prior_questions=['q0'])}
621
-
622
- The agent should also remember the answers to prior_questions listed in prior_questions.
623
- """
624
- return MemoryManagement(self).add_targeted_memory(
625
- focal_question, prior_question
626
- )
627
-
628
- def add_memory_collection(
629
- self,
630
- focal_question: Union[QuestionBase, str],
631
- prior_questions: List[Union[QuestionBase, str]],
632
- ) -> Survey:
633
- """Add prior questions and responses so the agent has them when answering.
634
-
635
- 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.
636
-
637
- :param focal_question: The question that the agent is answering.
638
- :param prior_questions: The questions that the agent should remember when answering the focal question.
639
-
640
- Here we have it so that when answering q2, the agent should remember answers to q0 and q1:
641
-
642
- >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
643
- >>> s.memory_plan
644
- {'q2': Memory(prior_questions=['q0', 'q1'])}
645
- """
646
- return MemoryManagement(self).add_memory_collection(
647
- focal_question, prior_questions
648
- )
649
-
650
- # region: Question groups
651
- def add_question_group(
652
- self,
653
- start_question: Union[QuestionBase, str],
654
- end_question: Union[QuestionBase, str],
655
- group_name: str,
656
- ) -> Survey:
657
- """Add a group of questions to the survey.
658
-
659
- :param start_question: The first question in the group.
660
- :param end_question: The last question in the group.
661
- :param group_name: The name of the group.
662
-
663
- Example:
664
-
665
- >>> s = Survey.example().add_question_group("q0", "q1", "group1")
666
- >>> s.question_groups
667
- {'group1': (0, 1)}
668
-
669
- The name of the group must be a valid identifier:
670
-
671
- >>> s = Survey.example().add_question_group("q0", "q2", "1group1")
672
- Traceback (most recent call last):
673
- ...
674
- edsl.exceptions.surveys.SurveyCreationError: Group name 1group1 is not a valid identifier.
675
- ...
676
- >>> s = Survey.example().add_question_group("q0", "q1", "q0")
677
- Traceback (most recent call last):
678
- ...
679
- edsl.exceptions.surveys.SurveyCreationError: ...
680
- ...
681
- >>> s = Survey.example().add_question_group("q1", "q0", "group1")
682
- Traceback (most recent call last):
683
- ...
684
- edsl.exceptions.surveys.SurveyCreationError: ...
685
- ...
686
- """
687
-
688
- if not group_name.isidentifier():
689
- raise SurveyCreationError(
690
- f"Group name {group_name} is not a valid identifier."
691
- )
692
-
693
- if group_name in self.question_groups:
694
- raise SurveyCreationError(
695
- f"Group name {group_name} already exists in the survey."
696
- )
697
-
698
- if group_name in self.question_name_to_index:
699
- raise SurveyCreationError(
700
- f"Group name {group_name} already exists as a question name in the survey."
701
- )
702
-
703
- start_index = self._get_question_index(start_question)
704
- end_index = self._get_question_index(end_question)
705
-
706
- if start_index > end_index:
707
- raise SurveyCreationError(
708
- f"Start index {start_index} is greater than end index {end_index}."
709
- )
710
-
711
- for existing_group_name, (
712
- existing_start_index,
713
- existing_end_index,
714
- ) in self.question_groups.items():
715
- if start_index < existing_start_index and end_index > existing_end_index:
716
- raise SurveyCreationError(
717
- f"Group {group_name} contains the questions in the new group."
718
- )
719
- if start_index > existing_start_index and end_index < existing_end_index:
720
- raise SurveyCreationError(
721
- f"Group {group_name} is contained in the new group."
722
- )
723
- if start_index < existing_start_index and end_index > existing_start_index:
724
- raise SurveyCreationError(
725
- f"Group {group_name} overlaps with the new group."
726
- )
727
- if start_index < existing_end_index and end_index > existing_end_index:
728
- raise SurveyCreationError(
729
- f"Group {group_name} overlaps with the new group."
730
- )
731
-
732
- self.question_groups[group_name] = (start_index, end_index)
733
- return self
734
-
735
- # endregion
736
-
737
- # region: Survey rules
738
- def show_rules(self) -> None:
739
- """Print out the rules in the survey.
740
-
741
- >>> s = Survey.example()
742
- >>> s.show_rules()
743
- 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]}])
744
- """
745
- return self.rule_collection.show_rules()
746
-
747
- def add_stop_rule(
748
- self, question: Union[QuestionBase, str], expression: str
749
- ) -> Survey:
750
- """Add a rule that stops the survey.
751
- The rule is evaluated *after* the question is answered. If the rule is true, the survey ends.
752
-
753
- :param question: The question to add the stop rule to.
754
- :param expression: The expression to evaluate.
755
-
756
- If this rule is true, the survey ends.
757
-
758
- Here, answering "yes" to q0 ends the survey:
759
-
760
- >>> s = Survey.example().add_stop_rule("q0", "q0 == 'yes'")
761
- >>> s.next_question("q0", {"q0": "yes"})
762
- EndOfSurvey
763
-
764
- By comparison, answering "no" to q0 does not end the survey:
765
-
766
- >>> s.next_question("q0", {"q0": "no"}).question_name
767
- 'q1'
768
-
769
- >>> s.add_stop_rule("q0", "q1 <> 'yes'")
770
- Traceback (most recent call last):
771
- ...
772
- edsl.exceptions.surveys.SurveyCreationError: The expression contains '<>', which is not allowed. You probably mean '!='.
773
- ...
774
- """
775
- return RuleManager(self).add_stop_rule(question, expression)
776
-
777
- def clear_non_default_rules(self) -> Survey:
778
- """Remove all non-default rules from the survey.
779
-
780
- >>> Survey.example().show_rules()
781
- 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]}])
782
- >>> Survey.example().clear_non_default_rules().show_rules()
783
- Dataset([{'current_q': [0, 1, 2]}, {'expression': ['True', 'True', 'True']}, {'next_q': [1, 2, 3]}, {'priority': [-1, -1, -1]}, {'before_rule': [False, False, False]}])
784
- """
785
- s = Survey()
786
- for question in self.questions:
787
- s.add_question(question)
788
- return s
789
-
790
- def add_skip_rule(
791
- self, question: Union[QuestionBase, str], expression: str
792
- ) -> Survey:
793
- """
794
- Adds a per-question skip rule to the survey.
795
-
796
- :param question: The question to add the skip rule to.
797
- :param expression: The expression to evaluate.
798
-
799
- This adds a rule that skips 'q0' always, before the question is answered:
800
-
801
- >>> from edsl import QuestionFreeText
802
- >>> q0 = QuestionFreeText.example()
803
- >>> q0.question_name = "q0"
804
- >>> q1 = QuestionFreeText.example()
805
- >>> q1.question_name = "q1"
806
- >>> s = Survey([q0, q1]).add_skip_rule("q0", "True")
807
- >>> s.next_question("q0", {}).question_name
808
- 'q1'
809
-
810
- Note that this is different from a rule that jumps to some other question *after* the question is answered.
811
-
812
- """
813
- question_index = self._get_question_index(question)
814
- return RuleManager(self).add_rule(
815
- question, expression, question_index + 1, before_rule=True
816
- )
817
-
818
- def add_rule(
819
- self,
820
- question: Union[QuestionBase, str],
821
- expression: str,
822
- next_question: Union[QuestionBase, int],
823
- before_rule: bool = False,
824
- ) -> Survey:
825
- """
826
- Add a rule to a Question of the Survey.
827
-
828
- :param question: The question to add the rule to.
829
- :param expression: The expression to evaluate.
830
- :param next_question: The next question to go to if the rule is true.
831
- :param before_rule: Whether the rule is evaluated before the question is answered.
832
-
833
- This adds a rule that if the answer to q0 is 'yes', the next question is q2 (as opposed to q1)
834
-
835
- >>> s = Survey.example().add_rule("q0", "{{ q0 }} == 'yes'", "q2")
836
- >>> s.next_question("q0", {"q0": "yes"}).question_name
837
- 'q2'
838
-
839
- """
840
- return RuleManager(self).add_rule(
841
- question, expression, next_question, before_rule=before_rule
842
- )
843
-
844
- # endregion
845
-
846
- # region: Forward methods
847
- def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
848
- """Add Agents, Scenarios, and LanguageModels to a survey and returns a runnable Jobs object.
849
-
850
- :param args: The Agents, Scenarios, and LanguageModels to add to the survey.
851
-
852
- This takes the survey and adds an Agent and a Scenario via 'by' which converts to a Jobs object:
853
-
854
- >>> s = Survey.example(); from edsl.agents import Agent; from edsl import Scenario
855
- >>> s.by(Agent.example()).by(Scenario.example())
856
- Jobs(...)
857
- """
858
- from edsl.jobs.Jobs import Jobs
859
-
860
- return Jobs(survey=self).by(*args)
861
-
862
- def to_jobs(self):
863
- """Convert the survey to a Jobs object.
864
- >>> s = Survey.example()
865
- >>> s.to_jobs()
866
- Jobs(...)
867
- """
868
- from edsl.jobs.Jobs import Jobs
869
-
870
- return Jobs(survey=self)
871
-
872
- def show_prompts(self):
873
- """Show the prompts for the survey."""
874
- return self.to_jobs().show_prompts()
875
-
876
- # endregion
877
-
878
- # region: Running the survey
879
-
880
- def __call__(
881
- self,
882
- model=None,
883
- agent=None,
884
- cache=None,
885
- verbose=False,
886
- disable_remote_cache: bool = False,
887
- disable_remote_inference: bool = False,
888
- **kwargs,
889
- ):
890
- """Run the survey with default model, taking the required survey as arguments.
891
-
892
- >>> from edsl.questions import QuestionFunctional
893
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
894
- >>> q = QuestionFunctional(question_name = "q0", func = f)
895
- >>> s = Survey([q])
896
- >>> s(period = "morning", cache = False, disable_remote_cache = True, disable_remote_inference = True).select("answer.q0").first()
897
- 'yes'
898
- >>> s(period = "evening", cache = False, disable_remote_cache = True, disable_remote_inference = True).select("answer.q0").first()
899
- 'no'
900
- """
901
-
902
- return self.get_job(model, agent, **kwargs).run(
903
- cache=cache,
904
- verbose=verbose,
905
- disable_remote_cache=disable_remote_cache,
906
- disable_remote_inference=disable_remote_inference,
907
- )
908
-
909
- async def run_async(
910
- self,
911
- model: Optional["LanguageModel"] = None,
912
- agent: Optional["Agent"] = None,
913
- cache: Optional["Cache"] = None,
914
- disable_remote_inference: bool = False,
915
- disable_remote_cache: bool = False,
916
- **kwargs,
917
- ):
918
- """Run the survey with default model, taking the required survey as arguments.
919
-
920
- >>> import asyncio
921
- >>> from edsl.questions import QuestionFunctional
922
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
923
- >>> q = QuestionFunctional(question_name = "q0", func = f)
924
- >>> s = Survey([q])
925
- >>> 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())
926
- >>> asyncio.run(test_run_async())
927
- yes
928
- >>> import asyncio
929
- >>> from edsl.questions import QuestionFunctional
930
- >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
931
- >>> q = QuestionFunctional(question_name = "q0", func = f)
932
- >>> s = Survey([q])
933
- >>> 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())
934
- >>> results = asyncio.run(test_run_async())
935
- no
936
- """
937
- # TODO: temp fix by creating a cache
938
- if cache is None:
939
- from edsl.data import Cache
940
-
941
- c = Cache()
942
- else:
943
- c = cache
944
-
945
- jobs: "Jobs" = self.get_job(model=model, agent=agent, **kwargs).using(c)
946
- return await jobs.run_async(
947
- disable_remote_inference=disable_remote_inference,
948
- disable_remote_cache=disable_remote_cache,
949
- )
950
-
951
- def run(self, *args, **kwargs) -> "Results":
952
- """Turn the survey into a Job and runs it.
953
-
954
- >>> from edsl import QuestionFreeText
955
- >>> s = Survey([QuestionFreeText.example()])
956
- >>> from edsl.language_models import LanguageModel
957
- >>> m = LanguageModel.example(test_model = True, canned_response = "Great!")
958
- >>> results = s.by(m).run(cache = False, disable_remote_cache = True, disable_remote_inference = True)
959
- >>> results.select('answer.*')
960
- Dataset([{'answer.how_are_you': ['Great!']}])
961
- """
962
- from edsl.jobs.Jobs import Jobs
963
-
964
- return Jobs(survey=self).run(*args, **kwargs)
965
-
966
- def using(self, obj: Union["Cache", "KeyLookup", "BucketCollection"]) -> "Jobs":
967
- """Turn the survey into a Job and appends the arguments to the Job."""
968
- from edsl.jobs.Jobs import Jobs
969
-
970
- return Jobs(survey=self).using(obj)
971
-
972
- def duplicate(self):
973
- """Duplicate the survey.
974
-
975
- >>> s = Survey.example()
976
- >>> s2 = s.duplicate()
977
- >>> s == s2
978
- True
979
- >>> s is s2
980
- False
981
-
982
- """
983
- return Survey.from_dict(self.to_dict())
984
-
985
- # region: Survey flow
986
- def next_question(
987
- self,
988
- current_question: Optional[Union[str, QuestionBase]] = None,
989
- answers: Optional[dict] = None,
990
- ) -> Union[QuestionBase, EndOfSurvey.__class__]:
991
- """
992
- Return the next question in a survey.
993
-
994
- :param current_question: The current question in the survey.
995
- :param answers: The answers for the survey so far
996
-
997
- - If called with no arguments, it returns the first question in the survey.
998
- - 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.
999
- - If the next question is the last question in the survey, an EndOfSurvey object is returned.
1000
-
1001
- >>> s = Survey.example()
1002
- >>> s.next_question("q0", {"q0": "yes"}).question_name
1003
- 'q2'
1004
- >>> s.next_question("q0", {"q0": "no"}).question_name
1005
- 'q1'
1006
-
1007
- """
1008
- if current_question is None:
1009
- return self.questions[0]
1010
-
1011
- if isinstance(current_question, str):
1012
- current_question = self._get_question_by_name(current_question)
1013
-
1014
- question_index = self.question_name_to_index[current_question.question_name]
1015
- next_question_object = self.rule_collection.next_question(
1016
- question_index, answers
1017
- )
1018
-
1019
- if next_question_object.num_rules_found == 0:
1020
- raise SurveyHasNoRulesError
1021
-
1022
- if next_question_object.next_q == EndOfSurvey:
1023
- return EndOfSurvey
1024
- else:
1025
- if next_question_object.next_q >= len(self.questions):
1026
- return EndOfSurvey
1027
- else:
1028
- return self.questions[next_question_object.next_q]
1029
-
1030
- def gen_path_through_survey(self) -> Generator[QuestionBase, dict, None]:
1031
- """
1032
- Generate a coroutine that can be used to conduct an Interview.
1033
-
1034
- The coroutine is a generator that yields a question and receives answers.
1035
- It starts with the first question in the survey.
1036
- The coroutine ends when an EndOfSurvey object is returned.
1037
-
1038
- For the example survey, this is the rule table:
1039
-
1040
- >>> s = Survey.example()
1041
- >>> s.show_rules()
1042
- 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]}])
1043
-
1044
- 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.
1045
-
1046
- Here is the path through the survey if the answer to q0 is 'yes':
1047
-
1048
- >>> i = s.gen_path_through_survey()
1049
- >>> next(i)
1050
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1051
- >>> i.send({"q0": "yes"})
1052
- Question('multiple_choice', question_name = \"""q2\""", question_text = \"""Why?\""", question_options = ['**lack*** of killer bees in cafeteria', 'other'])
1053
-
1054
- And here is the path through the survey if the answer to q0 is 'no':
1055
-
1056
- >>> i2 = s.gen_path_through_survey()
1057
- >>> next(i2)
1058
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1059
- >>> i2.send({"q0": "no"})
1060
- Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
1061
-
1062
-
1063
- """
1064
- self.answers = {}
1065
- question = self._questions[0]
1066
- # should the first question be skipped?
1067
- if self.rule_collection.skip_question_before_running(0, self.answers):
1068
- question = self.next_question(question, self.answers)
1069
-
1070
- while not question == EndOfSurvey:
1071
- answer = yield question
1072
- self.answers.update(answer)
1073
- # print(f"Answers: {self.answers}")
1074
- ## TODO: This should also include survey and agent attributes
1075
- question = self.next_question(question, self.answers)
1076
-
1077
- # endregion
1078
-
1079
- def dag(self, textify: bool = False) -> DAG:
1080
- """Return the DAG of the survey, which reflects both skip-logic and memory.
1081
-
1082
- :param textify: Whether to return the DAG with question names instead of indices.
1083
-
1084
- >>> s = Survey.example()
1085
- >>> d = s.dag()
1086
- >>> d
1087
- {1: {0}, 2: {0}}
1088
-
1089
- """
1090
- from edsl.surveys.ConstructDAG import ConstructDAG
1091
-
1092
- return ConstructDAG(self).dag(textify)
1093
-
1094
- ###################
1095
- # DUNDER METHODS
1096
- ###################
1097
- def __len__(self) -> int:
1098
- """Return the number of questions in the survey.
1099
-
1100
- >>> s = Survey.example()
1101
- >>> len(s)
1102
- 3
1103
- """
1104
- return len(self._questions)
1105
-
1106
- def __getitem__(self, index) -> QuestionBase:
1107
- """Return the question object given the question index.
1108
-
1109
- :param index: The index of the question to get.
1110
-
1111
- >>> s = Survey.example()
1112
- >>> s[0]
1113
- Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1114
-
1115
- """
1116
- if isinstance(index, int):
1117
- return self._questions[index]
1118
- elif isinstance(index, str):
1119
- return getattr(self, index)
1120
-
1121
- # def _diff(self, other):
1122
- # """Used for debugging. Print out the differences between two surveys."""
1123
- # from rich import print
1124
-
1125
- # for key, value in self.to_dict().items():
1126
- # if value != other.to_dict()[key]:
1127
- # print(f"Key: {key}")
1128
- # print("\n")
1129
- # print(f"Self: {value}")
1130
- # print("\n")
1131
- # print(f"Other: {other.to_dict()[key]}")
1132
- # print("\n\n")
1133
-
1134
- def __repr__(self) -> str:
1135
- """Return a string representation of the survey."""
1136
-
1137
- # questions_string = ", ".join([repr(q) for q in self._questions])
1138
- questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1139
- # question_names_string = ", ".join([repr(name) for name in self.question_names])
1140
- 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})"
1141
-
1142
- def _summary(self) -> dict:
1143
- return {
1144
- "# questions": len(self),
1145
- "question_name list": self.question_names,
1146
- }
1147
-
1148
- def tree(self, node_list: Optional[List[str]] = None):
1149
- return self.to_scenario_list().tree(node_list=node_list)
1150
-
1151
- def table(self, *fields, tablefmt=None) -> Table:
1152
- return self.to_scenario_list().to_dataset().table(*fields, tablefmt=tablefmt)
1153
-
1154
- # endregion
1155
-
1156
- def codebook(self) -> dict[str, str]:
1157
- """Create a codebook for the survey, mapping question names to question text.
1158
-
1159
- >>> s = Survey.example()
1160
- >>> s.codebook()
1161
- {'q0': 'Do you like school?', 'q1': 'Why not?', 'q2': 'Why?'}
1162
- """
1163
- codebook = {}
1164
- for question in self._questions:
1165
- codebook[question.question_name] = question.question_text
1166
- return codebook
1167
-
1168
- @classmethod
1169
- def example(
1170
- cls,
1171
- params: bool = False,
1172
- randomize: bool = False,
1173
- include_instructions=False,
1174
- custom_instructions: Optional[str] = None,
1175
- ) -> Survey:
1176
- """Return an example survey.
1177
-
1178
- >>> s = Survey.example()
1179
- >>> [q.question_text for q in s.questions]
1180
- ['Do you like school?', 'Why not?', 'Why?']
1181
- """
1182
- from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
1183
-
1184
- addition = "" if not randomize else str(uuid4())
1185
- q0 = QuestionMultipleChoice(
1186
- question_text=f"Do you like school?{addition}",
1187
- question_options=["yes", "no"],
1188
- question_name="q0",
1189
- )
1190
- q1 = QuestionMultipleChoice(
1191
- question_text="Why not?",
1192
- question_options=["killer bees in cafeteria", "other"],
1193
- question_name="q1",
1194
- )
1195
- q2 = QuestionMultipleChoice(
1196
- question_text="Why?",
1197
- question_options=["**lack*** of killer bees in cafeteria", "other"],
1198
- question_name="q2",
1199
- )
1200
- if params:
1201
- q3 = QuestionMultipleChoice(
1202
- question_text="To the question '{{ q0.question_text}}', you said '{{ q0.answer }}'. Do you still feel this way?",
1203
- question_options=["yes", "no"],
1204
- question_name="q3",
1205
- )
1206
- s = cls(questions=[q0, q1, q2, q3])
1207
- return s
1208
-
1209
- if include_instructions:
1210
- from edsl import Instruction
1211
-
1212
- custom_instructions = (
1213
- custom_instructions if custom_instructions else "Please pay attention!"
1214
- )
1215
-
1216
- i = Instruction(text=custom_instructions, name="attention")
1217
- s = cls(questions=[i, q0, q1, q2])
1218
- return s
1219
-
1220
- s = cls(questions=[q0, q1, q2])
1221
- s = s.add_rule(q0, "q0 == 'yes'", q2)
1222
- return s
1223
-
1224
- def get_job(self, model=None, agent=None, **kwargs):
1225
- if model is None:
1226
- from edsl.language_models.model import Model
1227
-
1228
- model = Model()
1229
-
1230
- from edsl.scenarios.Scenario import Scenario
1231
-
1232
- s = Scenario(kwargs)
1233
-
1234
- if not agent:
1235
- from edsl.agents.Agent import Agent
1236
-
1237
- agent = Agent()
1238
-
1239
- return self.by(s).by(agent).by(model)
1240
-
1241
- ###################
1242
- # COOP METHODS
1243
- ###################
1244
- def humanize(
1245
- self,
1246
- project_name: str = "Project",
1247
- survey_description: Optional[str] = None,
1248
- survey_alias: Optional[str] = None,
1249
- survey_visibility: Optional["VisibilityType"] = "unlisted",
1250
- ) -> dict:
1251
- """
1252
- Send the survey to Coop.
1253
-
1254
- Then, create a project on Coop so you can share the survey with human respondents.
1255
- """
1256
- from edsl.coop import Coop
1257
-
1258
- c = Coop()
1259
- project_details = c.create_project(
1260
- self, project_name, survey_description, survey_alias, survey_visibility
1261
- )
1262
- return project_details
1263
-
1264
-
1265
- def main():
1266
- """Run the example survey."""
1267
-
1268
- def example_survey():
1269
- """Return an example survey."""
1270
- from edsl import QuestionMultipleChoice, QuestionList, QuestionNumerical, Survey
1271
-
1272
- q0 = QuestionMultipleChoice(
1273
- question_name="q0",
1274
- question_text="What is the capital of France?",
1275
- question_options=["London", "Paris", "Rome", "Boston", "I don't know"],
1276
- )
1277
- q1 = QuestionList(
1278
- question_name="q1",
1279
- question_text="Name some cities in France.",
1280
- max_list_items=5,
1281
- )
1282
- q2 = QuestionNumerical(
1283
- question_name="q2",
1284
- question_text="What is the population of {{ q0.answer }}?",
1285
- )
1286
- s = Survey(questions=[q0, q1, q2])
1287
- s = s.add_rule(q0, "q0 == 'Paris'", q2)
1288
- return s
1289
-
1290
- s = example_survey()
1291
- survey_dict = s.to_dict()
1292
- s2 = Survey.from_dict(survey_dict)
1293
- results = s2.run()
1294
- print(results)
1295
-
1296
-
1297
- if __name__ == "__main__":
1298
- import doctest
1299
-
1300
- # doctest.testmod(optionflags=doctest.ELLIPSIS | doctest.SKIP)
1301
- doctest.testmod(optionflags=doctest.ELLIPSIS)