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 ADDED
@@ -0,0 +1,1743 @@
1
+ """A Survey is a collection of questions that can be administered to an Agent or a Human.
2
+
3
+ This module defines the Survey class, which is the central data structure for creating
4
+ and managing surveys. A Survey consists of questions, instructions, and rules that
5
+ determine the flow of questions based on previous answers.
6
+
7
+ Surveys can include skip logic, memory management, and question groups, making them
8
+ flexible for a variety of use cases from simple linear questionnaires to complex
9
+ branching surveys with conditional logic.
10
+ """
11
+
12
+ from __future__ import annotations
13
+ import re
14
+ import random
15
+ from collections import UserDict
16
+ from uuid import uuid4
17
+
18
+ from typing import (
19
+ Any,
20
+ Generator,
21
+ Optional,
22
+ Union,
23
+ List,
24
+ Callable,
25
+ TYPE_CHECKING,
26
+ Dict,
27
+ Tuple,
28
+ Set,
29
+ Type,
30
+ )
31
+ from typing_extensions import Literal, TypeAlias
32
+ from ..base import Base
33
+ from ..agents import Agent
34
+ from ..scenarios import Scenario
35
+ from ..utilities import remove_edsl_version
36
+
37
+ if TYPE_CHECKING:
38
+ from ..questions import QuestionBase
39
+ from ..agents import Agent
40
+ from .dag import DAG
41
+ from ..language_models import LanguageModel
42
+ from ..caching import Cache
43
+ from ..jobs import Jobs
44
+ from ..results import Results
45
+ from ..scenarios import ScenarioList
46
+ from ..buckets.bucket_collection import BucketCollection
47
+ from ..key_management.key_lookup import KeyLookup
48
+ from .memory import Memory
49
+
50
+ # Define types for documentation purpose only
51
+ VisibilityType = Literal["unlisted", "public", "private"]
52
+ Table = Any # Type for table display
53
+ # Type alias for docx document
54
+ Document = Any
55
+
56
+ QuestionType = Union[QuestionBase, "Instruction", "ChangeInstruction"]
57
+ QuestionGroupType = Dict[str, Tuple[int, int]]
58
+
59
+
60
+ from ..instructions import InstructionCollection
61
+ from ..instructions import Instruction
62
+ from ..instructions import ChangeInstruction
63
+
64
+ from .base import EndOfSurvey, EndOfSurveyParent
65
+ from .descriptors import QuestionsDescriptor
66
+ from .memory import MemoryPlan
67
+ from .survey_flow_visualization import SurveyFlowVisualization
68
+ from ..instructions import InstructionHandler
69
+ from .edit_survey import EditSurvey
70
+ from .survey_simulator import Simulator
71
+ from .memory import MemoryManagement
72
+ from .rules import RuleManager, RuleCollection
73
+ from .survey_export import SurveyExport
74
+ from .exceptions import SurveyCreationError, SurveyHasNoRulesError, SurveyError
75
+
76
+ class PseudoIndices(UserDict):
77
+ """A dictionary of pseudo-indices for the survey.
78
+
79
+ This class manages indices for both questions and instructions in a survey. It assigns
80
+ floating-point indices to instructions so they can be interspersed between integer-indexed
81
+ questions while maintaining order. This is crucial for properly serializing and deserializing
82
+ surveys with both questions and instructions.
83
+
84
+ Attributes:
85
+ data (dict): The underlying dictionary mapping item names to their pseudo-indices.
86
+ """
87
+ @property
88
+ def max_pseudo_index(self) -> float:
89
+ """Return the maximum pseudo index in the survey.
90
+
91
+ Returns:
92
+ float: The highest pseudo-index value currently assigned, or -1 if empty.
93
+
94
+ Examples:
95
+ >>> Survey.example()._pseudo_indices.max_pseudo_index
96
+ 2
97
+ """
98
+ if len(self) == 0:
99
+ return -1
100
+ return max(self.values())
101
+
102
+ @property
103
+ def last_item_was_instruction(self) -> bool:
104
+ """Determine if the last item added to the survey was an instruction.
105
+
106
+ This is used to determine the pseudo-index of the next item added to the survey.
107
+ Instructions are assigned floating-point indices (e.g., 1.5) while questions
108
+ have integer indices.
109
+
110
+ Returns:
111
+ bool: True if the last added item was an instruction, False otherwise.
112
+
113
+ Examples:
114
+ >>> s = Survey.example()
115
+ >>> s._pseudo_indices.last_item_was_instruction
116
+ False
117
+ >>> from edsl.instructions import Instruction
118
+ >>> s = s.add_instruction(Instruction(text="Pay attention to the following questions.", name="intro"))
119
+ >>> s._pseudo_indices.last_item_was_instruction
120
+ True
121
+ """
122
+ return isinstance(self.max_pseudo_index, float)
123
+
124
+
125
+ class Survey(Base):
126
+ """A collection of questions with logic for navigating between them.
127
+
128
+ Survey is the main class for creating, modifying, and running surveys. It supports:
129
+
130
+ - Skip logic: conditional navigation between questions based on previous answers
131
+ - Memory: controlling which previous answers are visible to agents
132
+ - Question grouping: organizing questions into logical sections
133
+ - Randomization: randomly ordering certain questions to reduce bias
134
+ - Instructions: adding non-question elements to guide respondents
135
+
136
+ A Survey instance can be used to:
137
+ 1. Define a set of questions and their order
138
+ 2. Add rules for navigating between questions
139
+ 3. Run the survey with agents or humans
140
+ 4. Export the survey in various formats
141
+
142
+ The survey maintains the order of questions, any skip logic rules, and handles
143
+ serialization for storage or transmission.
144
+ """
145
+
146
+ __documentation__ = """https://docs.expectedparrot.com/en/latest/surveys.html"""
147
+
148
+ questions = QuestionsDescriptor()
149
+ """A descriptor that manages the list of questions in the survey.
150
+
151
+ This descriptor handles the setting and getting of questions, ensuring
152
+ proper validation and maintaining internal data structures. It manages
153
+ both direct question objects and their names.
154
+
155
+ The underlying questions are stored in the protected `_questions` attribute,
156
+ while this property provides the public interface for accessing them.
157
+
158
+ Notes:
159
+ - The presumed order of the survey is the order in which questions are added
160
+ - Questions must have unique names within a survey
161
+ - Each question can have rules associated with it that determine the next question
162
+ """
163
+
164
+ def __init__(
165
+ self,
166
+ questions: Optional[List["QuestionType"]] = None,
167
+ memory_plan: Optional["MemoryPlan"] = None,
168
+ rule_collection: Optional["RuleCollection"] = None,
169
+ question_groups: Optional["QuestionGroupType"] = None,
170
+ name: Optional[str] = None,
171
+ questions_to_randomize: Optional[List[str]] = None,
172
+ ):
173
+ """Initialize a new Survey instance.
174
+
175
+ This constructor sets up a new survey with the provided questions and optional
176
+ configuration for memory, rules, grouping, and randomization.
177
+
178
+ Args:
179
+ questions: A list of question objects to include in the survey.
180
+ Can include QuestionBase objects, Instructions, and ChangeInstructions.
181
+ memory_plan: Defines which previous questions and answers are available
182
+ when answering each question. If None, a default plan is created.
183
+ rule_collection: Contains rules for determining which question comes next
184
+ based on previous answers. If None, default sequential rules are created.
185
+ question_groups: A dictionary mapping group names to (start_idx, end_idx)
186
+ tuples that define groups of questions.
187
+ name: DEPRECATED. The name of the survey.
188
+ questions_to_randomize: A list of question names to randomize when the
189
+ survey is drawn. This affects the order of options in these questions.
190
+
191
+ Examples:
192
+ Create a basic survey with three questions:
193
+
194
+ >>> from edsl import QuestionFreeText
195
+ >>> q1 = QuestionFreeText(question_text="What is your name?", question_name="name")
196
+ >>> q2 = QuestionFreeText(question_text="What is your favorite color?", question_name="color")
197
+ >>> q3 = QuestionFreeText(question_text="Is a hot dog a sandwich?", question_name="food")
198
+ >>> s = Survey([q1, q2, q3])
199
+
200
+ Create a survey with question groups:
201
+
202
+ >>> s = Survey([q1, q2, q3], question_groups={"demographics": (0, 1), "food_questions": (2, 2)})
203
+ """
204
+
205
+ self.raw_passed_questions = questions
206
+
207
+ true_questions = self._process_raw_questions(self.raw_passed_questions)
208
+
209
+ self.rule_collection = RuleCollection(
210
+ num_questions=len(true_questions) if true_questions else None
211
+ )
212
+ # the RuleCollection needs to be present while we add the questions; we might override this later
213
+ # if a rule_collection is provided. This allows us to serialize the survey with the rule_collection.
214
+
215
+ # this is where the Questions constructor is called.
216
+ self.questions = true_questions
217
+ # self.instruction_names_to_instructions = instruction_names_to_instructions
218
+
219
+ self.memory_plan = memory_plan or MemoryPlan(self)
220
+ if question_groups is not None:
221
+ self.question_groups = question_groups
222
+ else:
223
+ self.question_groups = {}
224
+
225
+ # if a rule collection is provided, use it instead of the constructed one
226
+ if rule_collection is not None:
227
+ self.rule_collection = rule_collection
228
+
229
+ if name is not None:
230
+ import warnings
231
+
232
+ warnings.warn("name parameter to a survey is deprecated.")
233
+
234
+ if questions_to_randomize is not None:
235
+ self.questions_to_randomize = questions_to_randomize
236
+ else:
237
+ self.questions_to_randomize = []
238
+
239
+ self._seed: Optional[int] = None
240
+
241
+ # Cache the InstructionCollection
242
+ self._cached_instruction_collection: Optional[InstructionCollection] = None
243
+
244
+ self._exporter = SurveyExport(self)
245
+
246
+ def question_names_valid(self) -> bool:
247
+ """Check if the question names are valid."""
248
+ return all(q.is_valid_question_name() for q in self.questions)
249
+
250
+ def draw(self) -> "Survey":
251
+ """Return a new survey with a randomly selected permutation of the options."""
252
+ if self._seed is None: # only set once
253
+ self._seed = hash(self)
254
+ random.seed(self._seed) # type: ignore
255
+
256
+ if len(self.questions_to_randomize) == 0:
257
+ return self
258
+
259
+ new_questions = []
260
+ for question in self.questions:
261
+ if question.question_name in self.questions_to_randomize:
262
+ new_questions.append(question.draw())
263
+ else:
264
+ new_questions.append(question.duplicate())
265
+
266
+ d = self.to_dict()
267
+ d["questions"] = [q.to_dict() for q in new_questions]
268
+ return Survey.from_dict(d)
269
+
270
+ def _process_raw_questions(self, questions: Optional[List["QuestionType"]]) -> list:
271
+ """Process the raw questions passed to the survey."""
272
+ handler = InstructionHandler(self)
273
+ result = handler.separate_questions_and_instructions(questions or [])
274
+
275
+ # Handle result safely for mypy
276
+ if hasattr(result, 'true_questions') and hasattr(result, 'instruction_names_to_instructions') and hasattr(result, 'pseudo_indices'):
277
+ # It's the SeparatedComponents dataclass
278
+ self._instruction_names_to_instructions = result.instruction_names_to_instructions # type: ignore
279
+ self._pseudo_indices = PseudoIndices(result.pseudo_indices) # type: ignore
280
+ return result.true_questions # type: ignore
281
+ else:
282
+ # For older versions that return a tuple
283
+ # This is a hacky way to get mypy to allow tuple unpacking of an Any type
284
+ result_list = list(result) # type: ignore
285
+ if len(result_list) == 3:
286
+ true_q = result_list[0]
287
+ inst_dict = result_list[1]
288
+ pseudo_idx = result_list[2]
289
+ self._instruction_names_to_instructions = inst_dict
290
+ self._pseudo_indices = PseudoIndices(pseudo_idx)
291
+ return true_q
292
+ else:
293
+ raise TypeError(f"Unexpected result type from separate_questions_and_instructions: {type(result)}")
294
+
295
+ @property
296
+ def _relevant_instructions_dict(self) -> InstructionCollection:
297
+ """Return a dictionary with keys as question names and values as instructions that are relevant to the question."""
298
+ if self._cached_instruction_collection is None:
299
+ self._cached_instruction_collection = InstructionCollection(
300
+ self._instruction_names_to_instructions, self.questions
301
+ )
302
+ return self._cached_instruction_collection
303
+
304
+ def _relevant_instructions(self, question: QuestionBase) -> dict:
305
+ """Return instructions that are relevant to the question."""
306
+ return self._relevant_instructions_dict[question]
307
+
308
+ def show_flow(self, filename: Optional[str] = None) -> None:
309
+ """Show the flow of the survey."""
310
+ SurveyFlowVisualization(self).show_flow(filename=filename)
311
+
312
+ def add_instruction(
313
+ self, instruction: Union["Instruction", "ChangeInstruction"]
314
+ ) -> Survey:
315
+ """
316
+ Add an instruction to the survey.
317
+
318
+ :param instruction: The instruction to add to the survey.
319
+
320
+ >>> from edsl import Instruction
321
+ >>> i = Instruction(text="Pay attention to the following questions.", name="intro")
322
+ >>> s = Survey().add_instruction(i)
323
+ >>> s._instruction_names_to_instructions
324
+ {'intro': Instruction(name="intro", text="Pay attention to the following questions.")}
325
+ >>> s._pseudo_indices
326
+ {'intro': -0.5}
327
+ """
328
+ return EditSurvey(self).add_instruction(instruction)
329
+
330
+ @classmethod
331
+ def random_survey(cls):
332
+ return Simulator.random_survey()
333
+
334
+ def simulate(self) -> dict:
335
+ """Simulate the survey and return the answers."""
336
+ return Simulator(self).simulate()
337
+
338
+ def _get_question_index(
339
+ self, q: Union["QuestionBase", str, EndOfSurveyParent]
340
+ ) -> Union[int, EndOfSurveyParent]:
341
+ """Return the index of the question or EndOfSurvey object.
342
+
343
+ :param q: The question or question name to get the index of.
344
+
345
+ It can handle it if the user passes in the question name, the question object, or the EndOfSurvey object.
346
+
347
+ >>> s = Survey.example()
348
+ >>> s._get_question_index("q0")
349
+ 0
350
+
351
+ This doesnt' work with questions that don't exist:
352
+
353
+ >>> s._get_question_index("poop")
354
+ Traceback (most recent call last):
355
+ ...
356
+ edsl.surveys.exceptions.SurveyError: Question name poop not found in survey. The current question names are {'q0': 0, 'q1': 1, 'q2': 2}.
357
+ ...
358
+ """
359
+ if q is EndOfSurvey:
360
+ return EndOfSurvey
361
+ else:
362
+ if isinstance(q, str):
363
+ question_name = q
364
+ elif isinstance(q, EndOfSurveyParent):
365
+ return EndOfSurvey
366
+ else:
367
+ question_name = q.question_name
368
+ if question_name not in self.question_name_to_index:
369
+ raise SurveyError(
370
+ f"""Question name {question_name} not found in survey. The current question names are {self.question_name_to_index}."""
371
+ )
372
+ return self.question_name_to_index[question_name]
373
+
374
+ def _get_question_by_name(self, question_name: str) -> QuestionBase:
375
+ """Return the question object given the question name.
376
+
377
+ :param question_name: The name of the question to get.
378
+
379
+ >>> s = Survey.example()
380
+ >>> s._get_question_by_name("q0")
381
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
382
+ """
383
+ if question_name not in self.question_name_to_index:
384
+ raise SurveyError(f"Question name {question_name} not found in survey.")
385
+ return self.questions[self.question_name_to_index[question_name]]
386
+
387
+ def question_names_to_questions(self) -> dict:
388
+ """Return a dictionary mapping question names to question attributes."""
389
+ return {q.question_name: q for q in self.questions}
390
+
391
+ @property
392
+ def question_names(self) -> list[str]:
393
+ """Return a list of question names in the survey.
394
+
395
+ Example:
396
+
397
+ >>> s = Survey.example()
398
+ >>> s.question_names
399
+ ['q0', 'q1', 'q2']
400
+ """
401
+ return [q.question_name for q in self.questions]
402
+
403
+ @property
404
+ def question_name_to_index(self) -> dict[str, int]:
405
+ """Return a dictionary mapping question names to question indices.
406
+
407
+ Example:
408
+
409
+ >>> s = Survey.example()
410
+ >>> s.question_name_to_index
411
+ {'q0': 0, 'q1': 1, 'q2': 2}
412
+ """
413
+ return {q.question_name: i for i, q in enumerate(self.questions)}
414
+
415
+ def to_dict(self, add_edsl_version=True) -> dict[str, Any]:
416
+ """Serialize the Survey object to a dictionary for storage or transmission.
417
+
418
+ This method converts the entire survey structure, including questions, rules,
419
+ memory plan, and question groups, into a dictionary that can be serialized to JSON.
420
+ This is essential for saving surveys, sharing them, or transferring them between
421
+ systems.
422
+
423
+ The serialized dictionary contains the complete state of the survey, allowing it
424
+ to be fully reconstructed using the from_dict() method.
425
+
426
+ Args:
427
+ add_edsl_version: If True (default), includes the EDSL version and class name
428
+ in the dictionary, which can be useful for backward compatibility when
429
+ deserializing.
430
+
431
+ Returns:
432
+ dict[str, Any]: A dictionary representation of the survey with the following keys:
433
+ - 'questions': List of serialized questions and instructions
434
+ - 'memory_plan': Serialized memory plan
435
+ - 'rule_collection': Serialized rule collection
436
+ - 'question_groups': Dictionary of question groups
437
+ - 'questions_to_randomize': List of questions to randomize (if any)
438
+ - 'edsl_version': EDSL version (if add_edsl_version=True)
439
+ - 'edsl_class_name': Class name (if add_edsl_version=True)
440
+
441
+ Examples:
442
+ >>> s = Survey.example()
443
+ >>> s.to_dict(add_edsl_version=False).keys()
444
+ dict_keys(['questions', 'memory_plan', 'rule_collection', 'question_groups'])
445
+
446
+ With version information:
447
+
448
+ >>> d = s.to_dict(add_edsl_version=True)
449
+ >>> 'edsl_version' in d and 'edsl_class_name' in d
450
+ True
451
+ """
452
+ from edsl import __version__
453
+
454
+ # Create the base dictionary with all survey components
455
+ d = {
456
+ "questions": [
457
+ q.to_dict(add_edsl_version=add_edsl_version)
458
+ for q in self._recombined_questions_and_instructions()
459
+ ],
460
+ "memory_plan": self.memory_plan.to_dict(add_edsl_version=add_edsl_version),
461
+ "rule_collection": self.rule_collection.to_dict(
462
+ add_edsl_version=add_edsl_version
463
+ ),
464
+ "question_groups": self.question_groups,
465
+ }
466
+
467
+ # Include randomization information if present
468
+ if self.questions_to_randomize != []:
469
+ d["questions_to_randomize"] = self.questions_to_randomize
470
+
471
+ # Add version information if requested
472
+ if add_edsl_version:
473
+ d["edsl_version"] = __version__
474
+ d["edsl_class_name"] = "Survey"
475
+
476
+ return d
477
+
478
+ @classmethod
479
+ @remove_edsl_version
480
+ def from_dict(cls, data: dict) -> Survey:
481
+ """Reconstruct a Survey object from its dictionary representation.
482
+
483
+ This class method is the counterpart to to_dict() and allows you to recreate
484
+ a Survey object from a serialized dictionary. This is useful for loading saved
485
+ surveys, receiving surveys from other systems, or cloning surveys.
486
+
487
+ The method handles deserialization of all survey components, including questions,
488
+ instructions, memory plan, rules, and question groups.
489
+
490
+ Args:
491
+ data: A dictionary containing the serialized survey data, typically
492
+ created by the to_dict() method.
493
+
494
+ Returns:
495
+ Survey: A fully reconstructed Survey object with all the original
496
+ questions, rules, and configuration.
497
+
498
+ Examples:
499
+ Create a survey, serialize it, and deserialize it back:
500
+
501
+ >>> d = Survey.example().to_dict()
502
+ >>> s = Survey.from_dict(d)
503
+ >>> s == Survey.example()
504
+ True
505
+
506
+ Works with instructions as well:
507
+
508
+ >>> s = Survey.example(include_instructions=True)
509
+ >>> d = s.to_dict()
510
+ >>> news = Survey.from_dict(d)
511
+ >>> news == s
512
+ True
513
+ """
514
+ # Helper function to determine the correct class for each serialized component
515
+ def get_class(pass_dict):
516
+ from ..questions import QuestionBase
517
+
518
+ if (class_name := pass_dict.get("edsl_class_name")) == "QuestionBase":
519
+ return QuestionBase
520
+ elif pass_dict.get("edsl_class_name") == "QuestionDict":
521
+ from ..questions import QuestionDict
522
+ return QuestionDict
523
+ elif class_name == "Instruction":
524
+ from ..instructions import Instruction
525
+ return Instruction
526
+ elif class_name == "ChangeInstruction":
527
+ from ..instructions import ChangeInstruction
528
+ return ChangeInstruction
529
+ else:
530
+ return QuestionBase
531
+
532
+ # Deserialize each question and instruction
533
+ questions = [
534
+ get_class(q_dict).from_dict(q_dict) for q_dict in data["questions"]
535
+ ]
536
+
537
+ # Deserialize the memory plan
538
+ memory_plan = MemoryPlan.from_dict(data["memory_plan"])
539
+
540
+ # Get the list of questions to randomize if present
541
+ if "questions_to_randomize" in data:
542
+ questions_to_randomize = data["questions_to_randomize"]
543
+ else:
544
+ questions_to_randomize = None
545
+
546
+ # Create and return the reconstructed survey
547
+ survey = cls(
548
+ questions=questions,
549
+ memory_plan=memory_plan,
550
+ rule_collection=RuleCollection.from_dict(data["rule_collection"]),
551
+ question_groups=data["question_groups"],
552
+ questions_to_randomize=questions_to_randomize,
553
+ )
554
+ return survey
555
+
556
+ @property
557
+ def scenario_attributes(self) -> list[str]:
558
+ """Return a list of attributes that admissible Scenarios should have.
559
+
560
+ Here we have a survey with a question that uses a jinja2 style {{ }} template:
561
+
562
+ >>> from edsl import QuestionFreeText
563
+ >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your name?", question_name="name"))
564
+ >>> s.scenario_attributes
565
+ ['greeting']
566
+
567
+ >>> s = Survey().add_question(QuestionFreeText(question_text="{{ greeting }}. What is your {{ attribute }}?", question_name="name"))
568
+ >>> s.scenario_attributes
569
+ ['greeting', 'attribute']
570
+
571
+
572
+ """
573
+ temp = []
574
+ for question in self.questions:
575
+ question_text = question.question_text
576
+ # extract the contents of all {{ }} in the question text using regex
577
+ matches = re.findall(r"\{\{(.+?)\}\}", question_text)
578
+ # remove whitespace
579
+ matches = [match.strip() for match in matches]
580
+ # add them to the temp list
581
+ temp.extend(matches)
582
+ return temp
583
+
584
+ @property
585
+ def parameters(self):
586
+ """Return a set of parameters in the survey.
587
+
588
+ >>> s = Survey.example()
589
+ >>> s.parameters
590
+ set()
591
+ """
592
+ return set.union(*[q.parameters for q in self.questions])
593
+
594
+ @property
595
+ def parameters_by_question(self):
596
+ """Return a dictionary of parameters by question in the survey.
597
+ >>> from edsl import QuestionFreeText
598
+ >>> q = QuestionFreeText(question_name = "example", question_text = "What is the capital of {{ country}}?")
599
+ >>> s = Survey([q])
600
+ >>> s.parameters_by_question
601
+ {'example': {'country'}}
602
+ """
603
+ return {q.question_name: q.parameters for q in self.questions}
604
+
605
+ def __add__(self, other: Survey) -> Survey:
606
+ """Combine two surveys.
607
+
608
+ :param other: The other survey to combine with this one.
609
+ >>> s1 = Survey.example()
610
+ >>> from edsl import QuestionFreeText
611
+ >>> s2 = Survey([QuestionFreeText(question_text="What is your name?", question_name="yo")])
612
+ >>> s3 = s1 + s2
613
+ Traceback (most recent call last):
614
+ ...
615
+ edsl.surveys.exceptions.SurveyCreationError: ...
616
+ ...
617
+ >>> s3 = s1.clear_non_default_rules() + s2
618
+ >>> len(s3.questions)
619
+ 4
620
+
621
+ """
622
+ if (
623
+ len(self.rule_collection.non_default_rules) > 0
624
+ or len(other.rule_collection.non_default_rules) > 0
625
+ ):
626
+ raise SurveyCreationError(
627
+ "Cannot combine two surveys with non-default rules. Please use the 'clear_non_default_rules' method to remove non-default rules from the survey.",
628
+ )
629
+
630
+ return Survey(questions=self.questions + other.questions)
631
+
632
+ def move_question(self, identifier: Union[str, int], new_index: int) -> Survey:
633
+ """
634
+ >>> from edsl import QuestionMultipleChoice, Survey
635
+ >>> s = Survey.example()
636
+ >>> s.question_names
637
+ ['q0', 'q1', 'q2']
638
+ >>> s.move_question("q0", 2).question_names
639
+ ['q1', 'q2', 'q0']
640
+ """
641
+ return EditSurvey(self).move_question(identifier, new_index)
642
+
643
+ def delete_question(self, identifier: Union[str, int]) -> Survey:
644
+ """
645
+ Delete a question from the survey.
646
+
647
+ :param identifier: The name or index of the question to delete.
648
+ :return: The updated Survey object.
649
+
650
+ >>> from edsl import QuestionMultipleChoice, Survey
651
+ >>> q1 = QuestionMultipleChoice(question_text="Q1", question_options=["A", "B"], question_name="q1")
652
+ >>> q2 = QuestionMultipleChoice(question_text="Q2", question_options=["C", "D"], question_name="q2")
653
+ >>> s = Survey().add_question(q1).add_question(q2)
654
+ >>> _ = s.delete_question("q1")
655
+ >>> len(s.questions)
656
+ 1
657
+ >>> _ = s.delete_question(0)
658
+ >>> len(s.questions)
659
+ 0
660
+ """
661
+ return EditSurvey(self).delete_question(identifier)
662
+
663
+ def add_question(
664
+ self, question: QuestionBase, index: Optional[int] = None
665
+ ) -> Survey:
666
+ """
667
+ Add a question to survey.
668
+
669
+ :param question: The question to add to the survey.
670
+ :param question_name: The name of the question. If not provided, the question name is used.
671
+
672
+ The question is appended at the end of the self.questions list
673
+ A default rule is created that the next index is the next question.
674
+
675
+ >>> from edsl import QuestionMultipleChoice
676
+ >>> q = QuestionMultipleChoice(question_text = "Do you like school?", question_options=["yes", "no"], question_name="q0")
677
+ >>> s = Survey().add_question(q)
678
+
679
+ >>> s = Survey().add_question(q).add_question(q)
680
+ Traceback (most recent call last):
681
+ ...
682
+ edsl.surveys.exceptions.SurveyCreationError: Question name 'q0' already exists in survey. Existing names are ['q0'].
683
+ ...
684
+ """
685
+ return EditSurvey(self).add_question(question, index)
686
+
687
+ def _recombined_questions_and_instructions(
688
+ self,
689
+ ) -> List[Union["QuestionBase", "Instruction"]]:
690
+ """Return a list of questions and instructions sorted by pseudo index."""
691
+ questions_and_instructions = list(self.questions) + list(
692
+ self._instruction_names_to_instructions.values()
693
+ )
694
+ return sorted(
695
+ questions_and_instructions, key=lambda x: self._pseudo_indices[x.name]
696
+ )
697
+
698
+ def set_full_memory_mode(self) -> Survey:
699
+ """Configure the survey so agents remember all previous questions and answers.
700
+
701
+ In full memory mode, when an agent answers any question, it will have access to
702
+ all previously asked questions and the agent's answers to them. This is useful
703
+ for surveys where later questions build on or reference earlier responses.
704
+
705
+ Returns:
706
+ Survey: The modified survey instance (allows for method chaining).
707
+
708
+ Examples:
709
+ >>> s = Survey.example().set_full_memory_mode()
710
+ """
711
+ MemoryManagement(self)._set_memory_plan(lambda i: self.question_names[:i])
712
+ return self
713
+
714
+ def set_lagged_memory(self, lags: int) -> Survey:
715
+ """Configure the survey so agents remember a limited window of previous questions.
716
+
717
+ In lagged memory mode, when an agent answers a question, it will only have access
718
+ to the most recent 'lags' number of questions and answers. This is useful for
719
+ limiting context when only recent questions are relevant.
720
+
721
+ Args:
722
+ lags: The number of previous questions to remember. For example, if lags=2,
723
+ only the two most recent questions and answers will be remembered.
724
+
725
+ Returns:
726
+ Survey: The modified survey instance (allows for method chaining).
727
+
728
+ Examples:
729
+ Remember only the two most recent questions:
730
+
731
+ >>> s = Survey.example().set_lagged_memory(2)
732
+ """
733
+ MemoryManagement(self)._set_memory_plan(
734
+ lambda i: self.question_names[max(0, i - lags) : i]
735
+ )
736
+ return self
737
+
738
+ def _set_memory_plan(self, prior_questions_func: Callable) -> None:
739
+ """Set a custom memory plan based on a provided function.
740
+
741
+ This is an internal method used to define custom memory plans. The function
742
+ provided determines which previous questions should be remembered for each
743
+ question index.
744
+
745
+ Args:
746
+ prior_questions_func: A function that takes the index of the current question
747
+ and returns a list of question names to remember.
748
+
749
+ Examples:
750
+ >>> s = Survey.example()
751
+ >>> s._set_memory_plan(lambda i: s.question_names[:i])
752
+ """
753
+ MemoryManagement(self)._set_memory_plan(prior_questions_func)
754
+
755
+ def add_targeted_memory(
756
+ self,
757
+ focal_question: Union[QuestionBase, str],
758
+ prior_question: Union[QuestionBase, str],
759
+ ) -> Survey:
760
+ """Configure the survey so a specific question has access to a prior question's answer.
761
+
762
+ This method allows you to define memory relationships between specific questions.
763
+ When an agent answers the focal_question, it will have access to the prior_question
764
+ and its answer, regardless of other memory settings.
765
+
766
+ Args:
767
+ focal_question: The question for which to add memory, specified either as a
768
+ QuestionBase object or its question_name string.
769
+ prior_question: The prior question to remember, specified either as a
770
+ QuestionBase object or its question_name string.
771
+
772
+ Returns:
773
+ Survey: The modified survey instance (allows for method chaining).
774
+
775
+ Examples:
776
+ When answering q2, remember the answer to q0:
777
+
778
+ >>> s = Survey.example().add_targeted_memory("q2", "q0")
779
+ >>> s.memory_plan
780
+ {'q2': Memory(prior_questions=['q0'])}
781
+ """
782
+ return MemoryManagement(self).add_targeted_memory(
783
+ focal_question, prior_question
784
+ )
785
+
786
+ def add_memory_collection(
787
+ self,
788
+ focal_question: Union[QuestionBase, str],
789
+ prior_questions: List[Union[QuestionBase, str]],
790
+ ) -> Survey:
791
+ """Configure the survey so a specific question has access to multiple prior questions.
792
+
793
+ This method allows you to define memory relationships between specific questions.
794
+ When an agent answers the focal_question, it will have access to all the questions
795
+ and answers specified in prior_questions.
796
+
797
+ Args:
798
+ focal_question: The question for which to add memory, specified either as a
799
+ QuestionBase object or its question_name string.
800
+ prior_questions: A list of prior questions to remember, each specified either
801
+ as a QuestionBase object or its question_name string.
802
+
803
+ Returns:
804
+ Survey: The modified survey instance (allows for method chaining).
805
+
806
+ Examples:
807
+ When answering q2, remember the answers to both q0 and q1:
808
+
809
+ >>> s = Survey.example().add_memory_collection("q2", ["q0", "q1"])
810
+ >>> s.memory_plan
811
+ {'q2': Memory(prior_questions=['q0', 'q1'])}
812
+ """
813
+ return MemoryManagement(self).add_memory_collection(
814
+ focal_question, prior_questions
815
+ )
816
+
817
+ def add_question_group(
818
+ self,
819
+ start_question: Union[QuestionBase, str],
820
+ end_question: Union[QuestionBase, str],
821
+ group_name: str,
822
+ ) -> Survey:
823
+ """Create a logical group of questions within the survey.
824
+
825
+ Question groups allow you to organize questions into meaningful sections,
826
+ which can be useful for:
827
+ - Analysis (analyzing responses by section)
828
+ - Navigation (jumping between sections)
829
+ - Presentation (displaying sections with headers)
830
+
831
+ Groups are defined by a contiguous range of questions from start_question
832
+ to end_question, inclusive. Groups cannot overlap with other groups.
833
+
834
+ Args:
835
+ start_question: The first question in the group, specified either as a
836
+ QuestionBase object or its question_name string.
837
+ end_question: The last question in the group, specified either as a
838
+ QuestionBase object or its question_name string.
839
+ group_name: A name for the group. Must be a valid Python identifier
840
+ and must not conflict with existing group or question names.
841
+
842
+ Returns:
843
+ Survey: The modified survey instance (allows for method chaining).
844
+
845
+ Raises:
846
+ SurveyCreationError: If the group name is invalid, already exists,
847
+ conflicts with a question name, if start comes after end,
848
+ or if the group overlaps with an existing group.
849
+
850
+ Examples:
851
+ Create a group of questions for demographics:
852
+
853
+ >>> s = Survey.example().add_question_group("q0", "q1", "group1")
854
+ >>> s.question_groups
855
+ {'group1': (0, 1)}
856
+
857
+ Group names must be valid Python identifiers:
858
+
859
+ >>> from edsl.surveys.exceptions import SurveyCreationError
860
+ >>> # Example showing invalid group name error
861
+ >>> try:
862
+ ... Survey.example().add_question_group("q0", "q2", "1group1")
863
+ ... except SurveyCreationError:
864
+ ... print("Error: Invalid group name (as expected)")
865
+ Error: Invalid group name (as expected)
866
+
867
+ Group names can't conflict with question names:
868
+
869
+ >>> # Example showing name conflict error
870
+ >>> try:
871
+ ... Survey.example().add_question_group("q0", "q1", "q0")
872
+ ... except SurveyCreationError:
873
+ ... print("Error: Group name conflicts with question name (as expected)")
874
+ Error: Group name conflicts with question name (as expected)
875
+
876
+ Start question must come before end question:
877
+
878
+ >>> # Example showing index order error
879
+ >>> try:
880
+ ... Survey.example().add_question_group("q1", "q0", "group1")
881
+ ... except SurveyCreationError:
882
+ ... print("Error: Start index greater than end index (as expected)")
883
+ Error: Start index greater than end index (as expected)
884
+ """
885
+
886
+ if not group_name.isidentifier():
887
+ raise SurveyCreationError(
888
+ f"Group name {group_name} is not a valid identifier."
889
+ )
890
+
891
+ if group_name in self.question_groups:
892
+ raise SurveyCreationError(
893
+ f"Group name {group_name} already exists in the survey."
894
+ )
895
+
896
+ if group_name in self.question_name_to_index:
897
+ raise SurveyCreationError(
898
+ f"Group name {group_name} already exists as a question name in the survey."
899
+ )
900
+
901
+ start_index = self._get_question_index(start_question)
902
+ end_index = self._get_question_index(end_question)
903
+
904
+ # Check if either index is the EndOfSurvey object
905
+ if start_index is EndOfSurvey or end_index is EndOfSurvey:
906
+ raise SurveyCreationError(
907
+ "Cannot use EndOfSurvey as a boundary for question groups."
908
+ )
909
+
910
+ # Now we know both are integers
911
+ assert isinstance(start_index, int) and isinstance(end_index, int)
912
+
913
+ if start_index > end_index:
914
+ raise SurveyCreationError(
915
+ f"Start index {start_index} is greater than end index {end_index}."
916
+ )
917
+
918
+ # Check for overlaps with existing groups
919
+ for existing_group_name, (exist_start, exist_end) in self.question_groups.items():
920
+ # Ensure the existing indices are integers (they should be, but for type checking)
921
+ assert isinstance(exist_start, int) and isinstance(exist_end, int)
922
+
923
+ # Check containment and overlap cases
924
+ if start_index < exist_start and end_index > exist_end:
925
+ raise SurveyCreationError(
926
+ f"Group {existing_group_name} is contained within the new group."
927
+ )
928
+ if start_index > exist_start and end_index < exist_end:
929
+ raise SurveyCreationError(
930
+ f"New group would be contained within existing group {existing_group_name}."
931
+ )
932
+ if start_index < exist_start and end_index > exist_start:
933
+ raise SurveyCreationError(
934
+ f"New group overlaps with the start of existing group {existing_group_name}."
935
+ )
936
+ if start_index < exist_end and end_index > exist_end:
937
+ raise SurveyCreationError(
938
+ f"New group overlaps with the end of existing group {existing_group_name}."
939
+ )
940
+
941
+ self.question_groups[group_name] = (start_index, end_index)
942
+ return self
943
+
944
+ def show_rules(self) -> None:
945
+ """Print out the rules in the survey.
946
+
947
+ >>> s = Survey.example()
948
+ >>> s.show_rules()
949
+ Dataset([{'current_q': [0, 0, 1, 2]}, {'expression': ['True', "{{ q0.answer }}== 'yes'", 'True', 'True']}, {'next_q': [1, 2, 2, 3]}, {'priority': [-1, 0, -1, -1]}, {'before_rule': [False, False, False, False]}])
950
+ """
951
+ return self.rule_collection.show_rules()
952
+
953
+ def add_stop_rule(
954
+ self, question: Union[QuestionBase, str], expression: str
955
+ ) -> Survey:
956
+ """Add a rule that stops the survey.
957
+ The rule is evaluated *after* the question is answered. If the rule is true, the survey ends.
958
+
959
+ :param question: The question to add the stop rule to.
960
+ :param expression: The expression to evaluate.
961
+
962
+ If this rule is true, the survey ends.
963
+
964
+ Here, answering "yes" to q0 ends the survey:
965
+
966
+ >>> s = Survey.example().add_stop_rule("q0", "{{ q0.answer }} == 'yes'")
967
+ >>> s.next_question("q0", {"q0.answer": "yes"})
968
+ EndOfSurvey
969
+
970
+ By comparison, answering "no" to q0 does not end the survey:
971
+
972
+ >>> s.next_question("q0", {"q0.answer": "no"}).question_name
973
+ 'q1'
974
+
975
+ >>> s.add_stop_rule("q0", "{{ q1.answer }} <> 'yes'")
976
+ Traceback (most recent call last):
977
+ ...
978
+ edsl.surveys.exceptions.SurveyCreationError: The expression contains '<>', which is not allowed. You probably mean '!='.
979
+ ...
980
+ """
981
+ return RuleManager(self).add_stop_rule(question, expression)
982
+
983
+ def clear_non_default_rules(self) -> Survey:
984
+ """Remove all non-default rules from the survey.
985
+
986
+ >>> Survey.example().show_rules()
987
+ Dataset([{'current_q': [0, 0, 1, 2]}, {'expression': ['True', "{{ q0.answer }}== 'yes'", 'True', 'True']}, {'next_q': [1, 2, 2, 3]}, {'priority': [-1, 0, -1, -1]}, {'before_rule': [False, False, False, False]}])
988
+ >>> Survey.example().clear_non_default_rules().show_rules()
989
+ Dataset([{'current_q': [0, 1, 2]}, {'expression': ['True', 'True', 'True']}, {'next_q': [1, 2, 3]}, {'priority': [-1, -1, -1]}, {'before_rule': [False, False, False]}])
990
+ """
991
+ s = Survey()
992
+ for question in self.questions:
993
+ s.add_question(question)
994
+ return s
995
+
996
+ def add_skip_rule(
997
+ self, question: Union["QuestionBase", str], expression: str
998
+ ) -> Survey:
999
+ """Add a rule to skip a question based on a conditional expression.
1000
+
1001
+ Skip rules are evaluated *before* the question is presented. If the expression
1002
+ evaluates to True, the question is skipped and the flow proceeds to the next
1003
+ question in sequence. This is different from jump rules which are evaluated
1004
+ *after* a question is answered.
1005
+
1006
+ Args:
1007
+ question: The question to add the skip rule to, either as a QuestionBase object
1008
+ or its question_name string.
1009
+ expression: A string expression that will be evaluated to determine if the
1010
+ question should be skipped. Can reference previous questions' answers
1011
+ using the template syntax, e.g., "{{ q0.answer }} == 'yes'".
1012
+
1013
+ Returns:
1014
+ Survey: The modified survey instance (allows for method chaining).
1015
+
1016
+ Examples:
1017
+ Skip q0 unconditionally (always skip):
1018
+
1019
+ >>> from edsl import QuestionFreeText
1020
+ >>> q0 = QuestionFreeText.example()
1021
+ >>> q0.question_name = "q0"
1022
+ >>> q1 = QuestionFreeText.example()
1023
+ >>> q1.question_name = "q1"
1024
+ >>> s = Survey([q0, q1]).add_skip_rule("q0", "True")
1025
+ >>> s.next_question("q0", {}).question_name
1026
+ 'q1'
1027
+
1028
+ Skip a question conditionally:
1029
+
1030
+ >>> q2 = QuestionFreeText.example()
1031
+ >>> q2.question_name = "q2"
1032
+ >>> s = Survey([q0, q1, q2])
1033
+ >>> s = s.add_skip_rule("q1", "{{ q0.answer }} == 'skip next'")
1034
+ """
1035
+ question_index = self._get_question_index(question)
1036
+
1037
+ # Only proceed if question_index is an integer (not EndOfSurvey)
1038
+ if isinstance(question_index, int):
1039
+ next_index = question_index + 1
1040
+ return RuleManager(self).add_rule(
1041
+ question, expression, next_index, before_rule=True
1042
+ )
1043
+ else:
1044
+ raise SurveyCreationError(
1045
+ "Cannot add skip rule to EndOfSurvey"
1046
+ )
1047
+
1048
+ def add_rule(
1049
+ self,
1050
+ question: Union["QuestionBase", str],
1051
+ expression: str,
1052
+ next_question: Union["QuestionBase", str, int, EndOfSurveyParent],
1053
+ before_rule: bool = False,
1054
+ ) -> Survey:
1055
+ """Add a conditional rule for navigating between questions in the survey.
1056
+
1057
+ Rules determine the flow of questions based on conditional expressions. When a rule's
1058
+ expression evaluates to True, the survey will navigate to the specified next question,
1059
+ potentially skipping questions or jumping to an earlier question.
1060
+
1061
+ By default, rules are evaluated *after* a question is answered. When before_rule=True,
1062
+ the rule is evaluated before the question is presented (which is useful for skip logic).
1063
+
1064
+ Args:
1065
+ question: The question this rule applies to, either as a QuestionBase object
1066
+ or its question_name string.
1067
+ expression: A string expression that will be evaluated to determine if the
1068
+ rule should trigger. Can reference previous questions' answers using
1069
+ the template syntax, e.g., "{{ q0.answer }} == 'yes'".
1070
+ next_question: The destination question to jump to if the expression is True.
1071
+ Can be specified as a QuestionBase object, a question_name string, an index,
1072
+ or the EndOfSurvey class to end the survey.
1073
+ before_rule: If True, the rule is evaluated before the question is presented.
1074
+ If False (default), the rule is evaluated after the question is answered.
1075
+
1076
+ Returns:
1077
+ Survey: The modified survey instance (allows for method chaining).
1078
+
1079
+ Examples:
1080
+ Add a rule that navigates to q2 if the answer to q0 is 'yes':
1081
+
1082
+ >>> s = Survey.example().add_rule("q0", "{{ q0.answer }} == 'yes'", "q2")
1083
+ >>> s.next_question("q0", {"q0.answer": "yes"}).question_name
1084
+ 'q2'
1085
+
1086
+ Add a rule to end the survey conditionally:
1087
+
1088
+ >>> from edsl.surveys.base import EndOfSurvey
1089
+ >>> s = Survey.example().add_rule("q0", "{{ q0.answer }} == 'end'", EndOfSurvey)
1090
+ """
1091
+ return RuleManager(self).add_rule(
1092
+ question, expression, next_question, before_rule=before_rule
1093
+ )
1094
+
1095
+ def by(self, *args: Union["Agent", "Scenario", "LanguageModel"]) -> "Jobs":
1096
+ """Add components to the survey and return a runnable Jobs object.
1097
+
1098
+ This method is the primary way to prepare a survey for execution. It adds the
1099
+ necessary components (agents, scenarios, language models) to create a Jobs object
1100
+ that can be run to generate responses to the survey.
1101
+
1102
+ The method can be chained to add multiple components in sequence.
1103
+
1104
+ Args:
1105
+ *args: One or more components to add to the survey. Can include:
1106
+ - Agent: The persona that will answer the survey questions
1107
+ - Scenario: The context for the survey, with variables to substitute
1108
+ - LanguageModel: The model that will generate the agent's responses
1109
+
1110
+ Returns:
1111
+ Jobs: A Jobs object that can be run to execute the survey.
1112
+
1113
+ Examples:
1114
+ Create a runnable Jobs object with an agent and scenario:
1115
+
1116
+ >>> s = Survey.example()
1117
+ >>> from edsl.agents import Agent
1118
+ >>> from edsl import Scenario
1119
+ >>> s.by(Agent.example()).by(Scenario.example())
1120
+ Jobs(...)
1121
+
1122
+ Chain all components in a single call:
1123
+
1124
+ >>> from edsl.language_models import LanguageModel
1125
+ >>> s.by(Agent.example(), Scenario.example(), LanguageModel.example())
1126
+ Jobs(...)
1127
+ """
1128
+ from edsl.jobs import Jobs
1129
+
1130
+ return Jobs(survey=self).by(*args)
1131
+
1132
+ def to_jobs(self) -> "Jobs":
1133
+ """Convert the survey to a Jobs object without adding components.
1134
+
1135
+ This method creates a Jobs object from the survey without adding any agents,
1136
+ scenarios, or language models. You'll need to add these components later
1137
+ using the `by()` method before running the job.
1138
+
1139
+ Returns:
1140
+ Jobs: A Jobs object based on this survey.
1141
+
1142
+ Examples:
1143
+ >>> s = Survey.example()
1144
+ >>> jobs = s.to_jobs()
1145
+ >>> jobs
1146
+ Jobs(...)
1147
+ """
1148
+ from edsl.jobs import Jobs
1149
+
1150
+ return Jobs(survey=self)
1151
+
1152
+ def show_prompts(self):
1153
+ """Display the prompts that will be used when running the survey.
1154
+
1155
+ This method converts the survey to a Jobs object and shows the prompts that
1156
+ would be sent to a language model. This is useful for debugging and understanding
1157
+ how the survey will be presented.
1158
+
1159
+ Returns:
1160
+ The detailed prompts for the survey.
1161
+ """
1162
+ return self.to_jobs().show_prompts()
1163
+
1164
+ def __call__(
1165
+ self,
1166
+ model=None,
1167
+ agent=None,
1168
+ cache=None,
1169
+ verbose=False,
1170
+ disable_remote_cache: bool = False,
1171
+ disable_remote_inference: bool = False,
1172
+ **kwargs,
1173
+ ) -> "Results":
1174
+ """Execute the survey with the given parameters and return results.
1175
+
1176
+ This is a convenient shorthand for creating a Jobs object and running it immediately.
1177
+ Any keyword arguments are passed as scenario parameters.
1178
+
1179
+ Args:
1180
+ model: The language model to use. If None, a default model is used.
1181
+ agent: The agent to use. If None, a default agent is used.
1182
+ cache: The cache to use for storing results. If None, no caching is used.
1183
+ verbose: If True, show detailed progress information.
1184
+ disable_remote_cache: If True, don't use remote cache even if available.
1185
+ disable_remote_inference: If True, don't use remote inference even if available.
1186
+ **kwargs: Key-value pairs to use as scenario parameters.
1187
+
1188
+ Returns:
1189
+ Results: The results of running the survey.
1190
+
1191
+ Examples:
1192
+ Run a survey with a functional question that uses scenario parameters:
1193
+
1194
+ >>> from edsl.questions import QuestionFunctional
1195
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1196
+ >>> q = QuestionFunctional(question_name="q0", func=f)
1197
+ >>> s = Survey([q])
1198
+ >>> s(period="morning", cache=False, disable_remote_cache=True, disable_remote_inference=True).select("answer.q0").first()
1199
+ 'yes'
1200
+ >>> s(period="evening", cache=False, disable_remote_cache=True, disable_remote_inference=True).select("answer.q0").first()
1201
+ 'no'
1202
+ """
1203
+ return self.get_job(model, agent, **kwargs).run(
1204
+ cache=cache,
1205
+ verbose=verbose,
1206
+ disable_remote_cache=disable_remote_cache,
1207
+ disable_remote_inference=disable_remote_inference,
1208
+ )
1209
+
1210
+ async def run_async(
1211
+ self,
1212
+ model: Optional["LanguageModel"] = None,
1213
+ agent: Optional["Agent"] = None,
1214
+ cache: Optional["Cache"] = None,
1215
+ **kwargs,
1216
+ ) -> "Results":
1217
+ """Execute the survey asynchronously and return results.
1218
+
1219
+ This method provides an asynchronous way to run surveys, which is useful for
1220
+ concurrent execution or integration with other async code. It creates a Jobs
1221
+ object and runs it asynchronously.
1222
+
1223
+ Args:
1224
+ model: The language model to use. If None, a default model is used.
1225
+ agent: The agent to use. If None, a default agent is used.
1226
+ cache: The cache to use for storing results. If provided, reuses cached results.
1227
+ **kwargs: Key-value pairs to use as scenario parameters. May include:
1228
+ - disable_remote_inference: If True, don't use remote inference even if available.
1229
+ - disable_remote_cache: If True, don't use remote cache even if available.
1230
+
1231
+ Returns:
1232
+ Results: The results of running the survey.
1233
+
1234
+ Examples:
1235
+ Run a survey asynchronously with morning parameter:
1236
+
1237
+ >>> import asyncio
1238
+ >>> from edsl.questions import QuestionFunctional
1239
+ >>> def f(scenario, agent_traits): return "yes" if scenario["period"] == "morning" else "no"
1240
+ >>> q = QuestionFunctional(question_name="q0", func=f)
1241
+ >>> from edsl import Model
1242
+ >>> s = Survey([q])
1243
+ >>> async def test_run_async():
1244
+ ... result = await s.run_async(period="morning", disable_remote_inference = True)
1245
+ ... print(result.select("answer.q0").first())
1246
+ >>> asyncio.run(test_run_async())
1247
+ yes
1248
+
1249
+ Run with evening parameter:
1250
+
1251
+ >>> async def test_run_async2():
1252
+ ... result = await s.run_async(period="evening", disable_remote_inference = True)
1253
+ ... print(result.select("answer.q0").first())
1254
+ >>> asyncio.run(test_run_async2())
1255
+ no
1256
+ """
1257
+ # Create a cache if none provided
1258
+ if cache is None:
1259
+ from edsl.caching import Cache
1260
+ c = Cache()
1261
+ else:
1262
+ c = cache
1263
+
1264
+ # Get scenario parameters, excluding any that will be passed to run_async
1265
+ scenario_kwargs = {k: v for k, v in kwargs.items()
1266
+ if k not in ['disable_remote_inference', 'disable_remote_cache']}
1267
+
1268
+ # Get the job options to pass to run_async
1269
+ job_kwargs = {k: v for k, v in kwargs.items()
1270
+ if k in ['disable_remote_inference', 'disable_remote_cache']}
1271
+
1272
+ jobs: "Jobs" = self.get_job(model=model, agent=agent, **scenario_kwargs).using(c)
1273
+ return await jobs.run_async(**job_kwargs)
1274
+
1275
+ def run(self, *args, **kwargs) -> "Results":
1276
+ """Convert the survey to a Job and execute it with the provided parameters.
1277
+
1278
+ This method creates a Jobs object from the survey and runs it immediately with
1279
+ the provided arguments. It's a convenient way to run a survey without explicitly
1280
+ creating a Jobs object first.
1281
+
1282
+ Args:
1283
+ *args: Positional arguments passed to the Jobs.run() method.
1284
+ **kwargs: Keyword arguments passed to the Jobs.run() method, which can include:
1285
+ - cache: The cache to use for storing results
1286
+ - verbose: Whether to show detailed progress
1287
+ - disable_remote_cache: Whether to disable remote caching
1288
+ - disable_remote_inference: Whether to disable remote inference
1289
+
1290
+ Returns:
1291
+ Results: The results of running the survey.
1292
+
1293
+ Examples:
1294
+ Run a survey with a test language model:
1295
+
1296
+ >>> from edsl import QuestionFreeText
1297
+ >>> s = Survey([QuestionFreeText.example()])
1298
+ >>> from edsl.language_models import LanguageModel
1299
+ >>> m = LanguageModel.example(test_model=True, canned_response="Great!")
1300
+ >>> results = s.by(m).run(cache=False, disable_remote_cache=True, disable_remote_inference=True)
1301
+ >>> results.select('answer.*')
1302
+ Dataset([{'answer.how_are_you': ['Great!']}])
1303
+ """
1304
+ from ..jobs import Jobs
1305
+
1306
+ return Jobs(survey=self).run(*args, **kwargs)
1307
+
1308
+ def using(self, obj: Union["Cache", "KeyLookup", "BucketCollection"]) -> "Jobs":
1309
+ """Turn the survey into a Job and appends the arguments to the Job."""
1310
+ from ..jobs.Jobs import Jobs
1311
+
1312
+ return Jobs(survey=self).using(obj)
1313
+
1314
+ def duplicate(self):
1315
+ """Duplicate the survey.
1316
+
1317
+ >>> s = Survey.example()
1318
+ >>> s2 = s.duplicate()
1319
+ >>> s == s2
1320
+ True
1321
+ >>> s is s2
1322
+ False
1323
+
1324
+ """
1325
+ return Survey.from_dict(self.to_dict())
1326
+
1327
+ def next_question(
1328
+ self,
1329
+ current_question: Optional[Union[str, "QuestionBase"]] = None,
1330
+ answers: Optional[Dict[str, Any]] = None,
1331
+ ) -> Union["QuestionBase", EndOfSurveyParent]:
1332
+ """
1333
+ Return the next question in a survey.
1334
+
1335
+ :param current_question: The current question in the survey.
1336
+ :param answers: The answers for the survey so far
1337
+
1338
+ - If called with no arguments, it returns the first question in the survey.
1339
+ - 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.
1340
+ - If the next question is the last question in the survey, an EndOfSurvey object is returned.
1341
+
1342
+ >>> s = Survey.example()
1343
+ >>> s.next_question("q0", {"q0.answer": "yes"}).question_name
1344
+ 'q2'
1345
+ >>> s.next_question("q0", {"q0.answer": "no"}).question_name
1346
+ 'q1'
1347
+
1348
+ """
1349
+ if current_question is None:
1350
+ return self.questions[0]
1351
+
1352
+ if isinstance(current_question, str):
1353
+ current_question = self._get_question_by_name(current_question)
1354
+
1355
+ question_index = self.question_name_to_index[current_question.question_name]
1356
+ # Ensure we have a non-None answers dict
1357
+ answer_dict = answers if answers is not None else {}
1358
+ next_question_object = self.rule_collection.next_question(
1359
+ question_index, answer_dict
1360
+ )
1361
+
1362
+ if next_question_object.num_rules_found == 0:
1363
+ raise SurveyHasNoRulesError("No rules found for this question")
1364
+
1365
+ if next_question_object.next_q == EndOfSurvey:
1366
+ return EndOfSurvey
1367
+ else:
1368
+ if next_question_object.next_q >= len(self.questions):
1369
+ return EndOfSurvey
1370
+ else:
1371
+ return self.questions[next_question_object.next_q]
1372
+
1373
+ def gen_path_through_survey(self) -> Generator[QuestionBase, dict, None]:
1374
+ """Generate a coroutine that navigates through the survey based on answers.
1375
+
1376
+ This method creates a Python generator that implements the survey flow logic.
1377
+ It yields questions and receives answers, handling the branching logic based
1378
+ on the rules defined in the survey. This generator is the core mechanism used
1379
+ by the Interview process to administer surveys.
1380
+
1381
+ The generator follows these steps:
1382
+ 1. Yields the first question (or skips it if skip rules apply)
1383
+ 2. Receives an answer dictionary from the caller via .send()
1384
+ 3. Updates the accumulated answers
1385
+ 4. Determines the next question based on the survey rules
1386
+ 5. Yields the next question
1387
+ 6. Repeats steps 2-5 until the end of survey is reached
1388
+
1389
+ Returns:
1390
+ Generator[QuestionBase, dict, None]: A generator that yields questions and
1391
+ receives answer dictionaries. The generator terminates when it reaches
1392
+ the end of the survey.
1393
+
1394
+ Examples:
1395
+ For the example survey with conditional branching:
1396
+
1397
+ >>> s = Survey.example()
1398
+ >>> s.show_rules()
1399
+ Dataset([{'current_q': [0, 0, 1, 2]}, {'expression': ['True', "{{ q0.answer }}== 'yes'", 'True', 'True']}, {'next_q': [1, 2, 2, 3]}, {'priority': [-1, 0, -1, -1]}, {'before_rule': [False, False, False, False]}])
1400
+
1401
+ Path when answering "yes" to first question:
1402
+
1403
+ >>> i = s.gen_path_through_survey()
1404
+ >>> next(i) # Get first question
1405
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1406
+ >>> i.send({"q0.answer": "yes"}) # Answer "yes" and get next question
1407
+ Question('multiple_choice', question_name = \"""q2\""", question_text = \"""Why?\""", question_options = ['**lack*** of killer bees in cafeteria', 'other'])
1408
+
1409
+ Path when answering "no" to first question:
1410
+
1411
+ >>> i2 = s.gen_path_through_survey()
1412
+ >>> next(i2) # Get first question
1413
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1414
+ >>> i2.send({"q0.answer": "no"}) # Answer "no" and get next question
1415
+ Question('multiple_choice', question_name = \"""q1\""", question_text = \"""Why not?\""", question_options = ['killer bees in cafeteria', 'other'])
1416
+ """
1417
+ # Initialize empty answers dictionary
1418
+ self.answers: Dict[str, Any] = {}
1419
+
1420
+ # Start with the first question
1421
+ question = self.questions[0]
1422
+
1423
+ # Check if the first question should be skipped based on skip rules
1424
+ if self.rule_collection.skip_question_before_running(0, self.answers):
1425
+ question = self.next_question(question, self.answers)
1426
+
1427
+ # Continue through the survey until we reach the end
1428
+ while not question == EndOfSurvey:
1429
+ # Yield the current question and wait for an answer
1430
+ answer = yield question
1431
+
1432
+ # Update the accumulated answers with the new answer
1433
+ self.answers.update(answer)
1434
+
1435
+ # Determine the next question based on the rules and answers
1436
+ # TODO: This should also include survey and agent attributes
1437
+ question = self.next_question(question, self.answers)
1438
+
1439
+
1440
+ def dag(self, textify: bool = False) -> "DAG":
1441
+ """Return a Directed Acyclic Graph (DAG) representation of the survey flow.
1442
+
1443
+ This method constructs a DAG that represents the possible paths through the survey,
1444
+ taking into account both skip logic and memory relationships. The DAG is useful
1445
+ for visualizing and analyzing the structure of the survey.
1446
+
1447
+ Args:
1448
+ textify: If True, the DAG will use question names as nodes instead of indices.
1449
+ This makes the DAG more human-readable but less compact.
1450
+
1451
+ Returns:
1452
+ DAG: A dictionary where keys are question indices (or names if textify=True)
1453
+ and values are sets of prerequisite questions. For example, {2: {0, 1}}
1454
+ means question 2 depends on questions 0 and 1.
1455
+
1456
+ Examples:
1457
+ >>> s = Survey.example()
1458
+ >>> d = s.dag()
1459
+ >>> d
1460
+ {1: {0}, 2: {0}}
1461
+
1462
+ With textify=True:
1463
+
1464
+ >>> dag = s.dag(textify=True)
1465
+ >>> sorted([(k, sorted(list(v))) for k, v in dag.items()])
1466
+ [('q1', ['q0']), ('q2', ['q0'])]
1467
+ """
1468
+ from .dag import ConstructDAG
1469
+
1470
+ return ConstructDAG(self).dag(textify)
1471
+
1472
+ ###################
1473
+ # DUNDER METHODS
1474
+ ###################
1475
+ def __len__(self) -> int:
1476
+ """Return the number of questions in the survey.
1477
+
1478
+ >>> s = Survey.example()
1479
+ >>> len(s)
1480
+ 3
1481
+ """
1482
+ return len(self.questions)
1483
+
1484
+ def __getitem__(self, index: Union[int, str]) -> "QuestionBase":
1485
+ """Return the question object given the question index.
1486
+
1487
+ :param index: The index of the question to get.
1488
+
1489
+ >>> s = Survey.example()
1490
+ >>> s[0]
1491
+ Question('multiple_choice', question_name = \"""q0\""", question_text = \"""Do you like school?\""", question_options = ['yes', 'no'])
1492
+
1493
+ """
1494
+ if isinstance(index, int):
1495
+ return self.questions[index]
1496
+ elif isinstance(index, str):
1497
+ return getattr(self, index)
1498
+
1499
+ def __repr__(self) -> str:
1500
+ """Return a string representation of the survey."""
1501
+
1502
+ # questions_string = ", ".join([repr(q) for q in self._questions])
1503
+ questions_string = ", ".join([repr(q) for q in self.raw_passed_questions or []])
1504
+ # question_names_string = ", ".join([repr(name) for name in self.question_names])
1505
+ 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})"
1506
+
1507
+ def _summary(self) -> dict:
1508
+ return {
1509
+ "# questions": len(self),
1510
+ "question_name list": self.question_names,
1511
+ }
1512
+
1513
+ def tree(self, node_list: Optional[List[str]] = None):
1514
+ return self.to_scenario_list().tree(node_list=node_list)
1515
+
1516
+ def table(self, *fields, tablefmt=None) -> Table:
1517
+ return self.to_scenario_list().to_dataset().table(*fields, tablefmt=tablefmt)
1518
+
1519
+ def codebook(self) -> Dict[str, str]:
1520
+ """Create a codebook for the survey, mapping question names to question text.
1521
+
1522
+ >>> s = Survey.example()
1523
+ >>> s.codebook()
1524
+ {'q0': 'Do you like school?', 'q1': 'Why not?', 'q2': 'Why?'}
1525
+ """
1526
+ codebook = {}
1527
+ for question in self.questions:
1528
+ codebook[question.question_name] = question.question_text
1529
+ return codebook
1530
+
1531
+ @classmethod
1532
+ def example(
1533
+ cls,
1534
+ params: bool = False,
1535
+ randomize: bool = False,
1536
+ include_instructions: bool = False,
1537
+ custom_instructions: Optional[str] = None,
1538
+ ) -> Survey:
1539
+ """Create an example survey for testing and demonstration purposes.
1540
+
1541
+ This method creates a simple branching survey about school preferences.
1542
+ The default survey contains three questions with conditional logic:
1543
+ - If the user answers "yes" to liking school, they are asked why they like it
1544
+ - If the user answers "no", they are asked why they don't like it
1545
+
1546
+ Args:
1547
+ params: If True, adds a fourth question that demonstrates parameter substitution
1548
+ by referencing the question text and answer from the first question.
1549
+ randomize: If True, adds a random UUID to the first question text to ensure
1550
+ uniqueness across multiple instances.
1551
+ include_instructions: If True, adds an instruction to the beginning of the survey.
1552
+ custom_instructions: Custom instruction text to use if include_instructions is True.
1553
+ Defaults to "Please pay attention!" if not provided.
1554
+
1555
+ Returns:
1556
+ Survey: A configured example survey instance.
1557
+
1558
+ Examples:
1559
+ Create a basic example survey:
1560
+
1561
+ >>> s = Survey.example()
1562
+ >>> [q.question_text for q in s.questions]
1563
+ ['Do you like school?', 'Why not?', 'Why?']
1564
+
1565
+ Create an example with parameter substitution:
1566
+
1567
+ >>> s = Survey.example(params=True)
1568
+ >>> s.questions[3].question_text
1569
+ "To the question '{{ q0.question_text}}', you said '{{ q0.answer }}'. Do you still feel this way?"
1570
+ """
1571
+ from ..questions import QuestionMultipleChoice
1572
+
1573
+ # Add random UUID to question text if randomization is requested
1574
+ addition = "" if not randomize else str(uuid4())
1575
+
1576
+ # Create the basic questions
1577
+ q0 = QuestionMultipleChoice(
1578
+ question_text=f"Do you like school?{addition}",
1579
+ question_options=["yes", "no"],
1580
+ question_name="q0",
1581
+ )
1582
+ q1 = QuestionMultipleChoice(
1583
+ question_text="Why not?",
1584
+ question_options=["killer bees in cafeteria", "other"],
1585
+ question_name="q1",
1586
+ )
1587
+ q2 = QuestionMultipleChoice(
1588
+ question_text="Why?",
1589
+ question_options=["**lack*** of killer bees in cafeteria", "other"],
1590
+ question_name="q2",
1591
+ )
1592
+
1593
+ # Add parameter demonstration question if requested
1594
+ if params:
1595
+ q3 = QuestionMultipleChoice(
1596
+ question_text="To the question '{{ q0.question_text}}', you said '{{ q0.answer }}'. Do you still feel this way?",
1597
+ question_options=["yes", "no"],
1598
+ question_name="q3",
1599
+ )
1600
+ s = cls(questions=[q0, q1, q2, q3])
1601
+ return s
1602
+
1603
+ # Add instruction if requested
1604
+ if include_instructions:
1605
+ from edsl import Instruction
1606
+
1607
+ custom_instructions = (
1608
+ custom_instructions if custom_instructions else "Please pay attention!"
1609
+ )
1610
+
1611
+ i = Instruction(text=custom_instructions, name="attention")
1612
+ s = cls(questions=[i, q0, q1, q2])
1613
+ return s
1614
+
1615
+ # Create the basic survey with branching logic
1616
+ s = cls(questions=[q0, q1, q2])
1617
+ s = s.add_rule(q0, "{{ q0.answer }}== 'yes'", q2)
1618
+ return s
1619
+
1620
+ def get_job(self, model=None, agent=None, **kwargs):
1621
+ if model is None:
1622
+ from edsl.language_models.model import Model
1623
+
1624
+ model = Model()
1625
+
1626
+ from edsl.scenarios import Scenario
1627
+
1628
+ s = Scenario(kwargs)
1629
+
1630
+ if not agent:
1631
+ from edsl.agents import Agent
1632
+
1633
+ agent = Agent()
1634
+
1635
+ return self.by(s).by(agent).by(model)
1636
+
1637
+ ###################
1638
+ # COOP METHODS
1639
+ ###################
1640
+ def humanize(
1641
+ self,
1642
+ project_name: str = "Project",
1643
+ survey_description: Optional[str] = None,
1644
+ survey_alias: Optional[str] = None,
1645
+ survey_visibility: Optional["VisibilityType"] = "unlisted",
1646
+ ) -> dict:
1647
+ """
1648
+ Send the survey to Coop.
1649
+
1650
+ Then, create a project on Coop so you can share the survey with human respondents.
1651
+ """
1652
+ from edsl.coop import Coop
1653
+
1654
+ c = Coop()
1655
+ project_details = c.create_project(
1656
+ self, project_name, survey_description, survey_alias, survey_visibility
1657
+ )
1658
+ return project_details
1659
+
1660
+ # Add export method delegations
1661
+ def css(self):
1662
+ """Return the default CSS style for the survey."""
1663
+ return self._exporter.css()
1664
+
1665
+ def get_description(self) -> str:
1666
+ """Return the description of the survey."""
1667
+ return self._exporter.get_description()
1668
+
1669
+ def docx(
1670
+ self,
1671
+ return_document_object: bool = False,
1672
+ filename: str = "",
1673
+ open_file: bool = False,
1674
+ ) -> Union["Document", None]:
1675
+ """Generate a docx document for the survey."""
1676
+ return self._exporter.docx(return_document_object, filename, open_file)
1677
+
1678
+ def show(self):
1679
+ """Display the survey in a rich format."""
1680
+ return self._exporter.show()
1681
+
1682
+ def to_scenario_list(
1683
+ self, questions_only: bool = True, rename=False
1684
+ ) -> "ScenarioList":
1685
+ """Convert the survey to a scenario list."""
1686
+ return self._exporter.to_scenario_list(questions_only, rename)
1687
+
1688
+ def code(self, filename: str = "", survey_var_name: str = "survey") -> list[str]:
1689
+ """Create the Python code representation of a survey."""
1690
+ return self._exporter.code(filename, survey_var_name)
1691
+
1692
+ def html(
1693
+ self,
1694
+ scenario: Optional[dict] = None,
1695
+ filename: str = "",
1696
+ return_link=False,
1697
+ css: Optional[str] = None,
1698
+ cta: str = "Open HTML file",
1699
+ include_question_name=False,
1700
+ ):
1701
+ """Generate HTML representation of the survey."""
1702
+ return self._exporter.html(
1703
+ scenario, filename, return_link, css, cta, include_question_name
1704
+ )
1705
+
1706
+
1707
+ def main():
1708
+ """Run the example survey."""
1709
+
1710
+ def example_survey():
1711
+ """Return an example survey."""
1712
+ from edsl import QuestionMultipleChoice, QuestionList, QuestionNumerical, Survey
1713
+
1714
+ q0 = QuestionMultipleChoice(
1715
+ question_name="q0",
1716
+ question_text="What is the capital of France?",
1717
+ question_options=["London", "Paris", "Rome", "Boston", "I don't know"],
1718
+ )
1719
+ q1 = QuestionList(
1720
+ question_name="q1",
1721
+ question_text="Name some cities in France.",
1722
+ max_list_items=5,
1723
+ )
1724
+ q2 = QuestionNumerical(
1725
+ question_name="q2",
1726
+ question_text="What is the population of {{ q0.answer }}?",
1727
+ )
1728
+ s = Survey(questions=[q0, q1, q2])
1729
+ s = s.add_rule(q0, "q0 == 'Paris'", q2)
1730
+ return s
1731
+
1732
+ s = example_survey()
1733
+ survey_dict = s.to_dict()
1734
+ s2 = Survey.from_dict(survey_dict)
1735
+ results = s2.run()
1736
+ print(results)
1737
+
1738
+
1739
+ if __name__ == "__main__":
1740
+ import doctest
1741
+
1742
+ # doctest.testmod(optionflags=doctest.ELLIPSIS | doctest.SKIP)
1743
+ doctest.testmod(optionflags=doctest.ELLIPSIS)