edsl 0.1.47__py3-none-any.whl → 0.1.49__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.
- edsl/__init__.py +44 -39
- edsl/__version__.py +1 -1
- edsl/agents/__init__.py +4 -2
- edsl/agents/{Agent.py → agent.py} +442 -152
- edsl/agents/{AgentList.py → agent_list.py} +220 -162
- edsl/agents/descriptors.py +46 -7
- edsl/{exceptions/agents.py → agents/exceptions.py} +3 -12
- edsl/base/__init__.py +75 -0
- edsl/base/base_class.py +1303 -0
- edsl/base/data_transfer_models.py +114 -0
- edsl/base/enums.py +215 -0
- edsl/base.py +8 -0
- edsl/buckets/__init__.py +25 -0
- edsl/buckets/bucket_collection.py +324 -0
- edsl/buckets/model_buckets.py +206 -0
- edsl/buckets/token_bucket.py +502 -0
- edsl/{jobs/buckets/TokenBucketAPI.py → buckets/token_bucket_api.py} +1 -1
- edsl/buckets/token_bucket_client.py +509 -0
- edsl/caching/__init__.py +20 -0
- edsl/caching/cache.py +814 -0
- edsl/caching/cache_entry.py +427 -0
- edsl/{data/CacheHandler.py → caching/cache_handler.py} +14 -15
- edsl/caching/exceptions.py +24 -0
- edsl/caching/orm.py +30 -0
- edsl/{data/RemoteCacheSync.py → caching/remote_cache_sync.py} +3 -3
- edsl/caching/sql_dict.py +441 -0
- edsl/config/__init__.py +8 -0
- edsl/config/config_class.py +177 -0
- edsl/config.py +4 -176
- edsl/conversation/Conversation.py +7 -7
- edsl/conversation/car_buying.py +4 -4
- edsl/conversation/chips.py +6 -6
- edsl/coop/__init__.py +25 -2
- edsl/coop/coop.py +311 -75
- edsl/coop/{ExpectedParrotKeyHandler.py → ep_key_handling.py} +86 -10
- edsl/coop/exceptions.py +62 -0
- edsl/coop/price_fetcher.py +126 -0
- edsl/coop/utils.py +89 -24
- edsl/data_transfer_models.py +5 -72
- edsl/dataset/__init__.py +10 -0
- edsl/{results/Dataset.py → dataset/dataset.py} +116 -36
- edsl/{results/DatasetExportMixin.py → dataset/dataset_operations_mixin.py} +606 -122
- edsl/{results/DatasetTree.py → dataset/dataset_tree.py} +156 -75
- edsl/{results/TableDisplay.py → dataset/display/table_display.py} +18 -7
- edsl/{results → dataset/display}/table_renderers.py +58 -2
- edsl/{results → dataset}/file_exports.py +4 -5
- edsl/{results → dataset}/smart_objects.py +2 -2
- edsl/enums.py +5 -205
- edsl/inference_services/__init__.py +5 -0
- edsl/inference_services/{AvailableModelCacheHandler.py → available_model_cache_handler.py} +2 -3
- edsl/inference_services/{AvailableModelFetcher.py → available_model_fetcher.py} +8 -14
- edsl/inference_services/data_structures.py +3 -2
- edsl/{exceptions/inference_services.py → inference_services/exceptions.py} +1 -1
- edsl/inference_services/{InferenceServiceABC.py → inference_service_abc.py} +1 -1
- edsl/inference_services/{InferenceServicesCollection.py → inference_services_collection.py} +8 -7
- edsl/inference_services/registry.py +4 -41
- edsl/inference_services/{ServiceAvailability.py → service_availability.py} +5 -25
- edsl/inference_services/services/__init__.py +31 -0
- edsl/inference_services/{AnthropicService.py → services/anthropic_service.py} +3 -3
- edsl/inference_services/{AwsBedrock.py → services/aws_bedrock.py} +2 -2
- edsl/inference_services/{AzureAI.py → services/azure_ai.py} +2 -2
- edsl/inference_services/{DeepInfraService.py → services/deep_infra_service.py} +1 -3
- edsl/inference_services/{DeepSeekService.py → services/deep_seek_service.py} +2 -4
- edsl/inference_services/{GoogleService.py → services/google_service.py} +5 -4
- edsl/inference_services/{GroqService.py → services/groq_service.py} +1 -1
- edsl/inference_services/{MistralAIService.py → services/mistral_ai_service.py} +3 -3
- edsl/inference_services/{OllamaService.py → services/ollama_service.py} +1 -7
- edsl/inference_services/{OpenAIService.py → services/open_ai_service.py} +5 -6
- edsl/inference_services/{PerplexityService.py → services/perplexity_service.py} +3 -7
- edsl/inference_services/{TestService.py → services/test_service.py} +7 -6
- edsl/inference_services/{TogetherAIService.py → services/together_ai_service.py} +2 -6
- edsl/inference_services/{XAIService.py → services/xai_service.py} +1 -1
- edsl/inference_services/write_available.py +1 -2
- edsl/instructions/__init__.py +6 -0
- edsl/{surveys/instructions/Instruction.py → instructions/instruction.py} +11 -6
- edsl/{surveys/instructions/InstructionCollection.py → instructions/instruction_collection.py} +10 -5
- edsl/{surveys/InstructionHandler.py → instructions/instruction_handler.py} +3 -3
- edsl/{jobs/interviews → interviews}/ReportErrors.py +2 -2
- edsl/interviews/__init__.py +4 -0
- edsl/{jobs/AnswerQuestionFunctionConstructor.py → interviews/answering_function.py} +45 -18
- edsl/{jobs/interviews/InterviewExceptionEntry.py → interviews/exception_tracking.py} +107 -22
- edsl/interviews/interview.py +638 -0
- edsl/{jobs/interviews/InterviewStatusDictionary.py → interviews/interview_status_dictionary.py} +21 -12
- edsl/{jobs/interviews/InterviewStatusLog.py → interviews/interview_status_log.py} +16 -7
- edsl/{jobs/InterviewTaskManager.py → interviews/interview_task_manager.py} +12 -7
- edsl/{jobs/RequestTokenEstimator.py → interviews/request_token_estimator.py} +8 -3
- edsl/{jobs/interviews/InterviewStatistic.py → interviews/statistics.py} +36 -10
- edsl/invigilators/__init__.py +38 -0
- edsl/invigilators/invigilator_base.py +477 -0
- edsl/{agents/Invigilator.py → invigilators/invigilators.py} +263 -10
- edsl/invigilators/prompt_constructor.py +476 -0
- edsl/{agents → invigilators}/prompt_helpers.py +2 -1
- edsl/{agents/QuestionInstructionPromptBuilder.py → invigilators/question_instructions_prompt_builder.py} +18 -13
- edsl/{agents → invigilators}/question_option_processor.py +96 -21
- edsl/{agents/QuestionTemplateReplacementsBuilder.py → invigilators/question_template_replacements_builder.py} +64 -12
- edsl/jobs/__init__.py +7 -1
- edsl/jobs/async_interview_runner.py +99 -35
- edsl/jobs/check_survey_scenario_compatibility.py +7 -5
- edsl/jobs/data_structures.py +153 -22
- edsl/{exceptions/jobs.py → jobs/exceptions.py} +2 -1
- edsl/jobs/{FetchInvigilator.py → fetch_invigilator.py} +4 -4
- edsl/jobs/{loggers/HTMLTableJobLogger.py → html_table_job_logger.py} +6 -2
- edsl/jobs/{Jobs.py → jobs.py} +313 -167
- edsl/jobs/{JobsChecks.py → jobs_checks.py} +15 -7
- edsl/jobs/{JobsComponentConstructor.py → jobs_component_constructor.py} +19 -17
- edsl/jobs/{InterviewsConstructor.py → jobs_interview_constructor.py} +10 -5
- edsl/jobs/jobs_pricing_estimation.py +347 -0
- edsl/jobs/{JobsRemoteInferenceLogger.py → jobs_remote_inference_logger.py} +4 -3
- edsl/jobs/jobs_runner_asyncio.py +282 -0
- edsl/jobs/{JobsRemoteInferenceHandler.py → remote_inference.py} +19 -22
- edsl/jobs/results_exceptions_handler.py +2 -2
- edsl/key_management/__init__.py +28 -0
- edsl/key_management/key_lookup.py +161 -0
- edsl/{language_models/key_management/KeyLookupBuilder.py → key_management/key_lookup_builder.py} +118 -47
- edsl/key_management/key_lookup_collection.py +82 -0
- edsl/key_management/models.py +218 -0
- edsl/language_models/__init__.py +7 -2
- edsl/language_models/{ComputeCost.py → compute_cost.py} +18 -3
- edsl/{exceptions/language_models.py → language_models/exceptions.py} +2 -1
- edsl/language_models/language_model.py +1080 -0
- edsl/language_models/model.py +10 -25
- edsl/language_models/{ModelList.py → model_list.py} +9 -14
- edsl/language_models/{RawResponseHandler.py → raw_response_handler.py} +1 -1
- edsl/language_models/{RegisterLanguageModelsMeta.py → registry.py} +1 -1
- edsl/language_models/repair.py +4 -4
- edsl/language_models/utilities.py +4 -4
- edsl/notebooks/__init__.py +3 -1
- edsl/notebooks/{Notebook.py → notebook.py} +7 -8
- edsl/prompts/__init__.py +1 -1
- edsl/{exceptions/prompts.py → prompts/exceptions.py} +3 -1
- edsl/prompts/{Prompt.py → prompt.py} +101 -95
- edsl/questions/HTMLQuestion.py +1 -1
- edsl/questions/__init__.py +154 -25
- edsl/questions/answer_validator_mixin.py +1 -1
- edsl/questions/compose_questions.py +4 -3
- edsl/questions/derived/question_likert_five.py +166 -0
- edsl/questions/derived/{QuestionLinearScale.py → question_linear_scale.py} +4 -4
- edsl/questions/derived/{QuestionTopK.py → question_top_k.py} +4 -4
- edsl/questions/derived/{QuestionYesNo.py → question_yes_no.py} +4 -5
- edsl/questions/descriptors.py +24 -30
- edsl/questions/loop_processor.py +65 -19
- edsl/questions/question_base.py +881 -0
- edsl/questions/question_base_gen_mixin.py +15 -16
- edsl/questions/{QuestionBasePromptsMixin.py → question_base_prompts_mixin.py} +2 -2
- edsl/questions/{QuestionBudget.py → question_budget.py} +3 -4
- edsl/questions/{QuestionCheckBox.py → question_check_box.py} +16 -16
- edsl/questions/{QuestionDict.py → question_dict.py} +39 -5
- edsl/questions/{QuestionExtract.py → question_extract.py} +9 -9
- edsl/questions/question_free_text.py +282 -0
- edsl/questions/{QuestionFunctional.py → question_functional.py} +6 -5
- edsl/questions/{QuestionList.py → question_list.py} +6 -7
- edsl/questions/{QuestionMatrix.py → question_matrix.py} +6 -5
- edsl/questions/{QuestionMultipleChoice.py → question_multiple_choice.py} +126 -21
- edsl/questions/{QuestionNumerical.py → question_numerical.py} +5 -5
- edsl/questions/{QuestionRank.py → question_rank.py} +6 -6
- edsl/questions/question_registry.py +4 -9
- edsl/questions/register_questions_meta.py +8 -4
- edsl/questions/response_validator_abc.py +17 -16
- edsl/results/__init__.py +4 -1
- edsl/{exceptions/results.py → results/exceptions.py} +1 -1
- edsl/results/report.py +197 -0
- edsl/results/{Result.py → result.py} +131 -45
- edsl/results/{Results.py → results.py} +365 -220
- edsl/results/results_selector.py +344 -25
- edsl/scenarios/__init__.py +30 -3
- edsl/scenarios/{ConstructDownloadLink.py → construct_download_link.py} +7 -0
- edsl/scenarios/directory_scanner.py +156 -13
- edsl/scenarios/document_chunker.py +186 -0
- edsl/scenarios/exceptions.py +101 -0
- edsl/scenarios/file_methods.py +2 -3
- edsl/scenarios/{FileStore.py → file_store.py} +275 -189
- edsl/scenarios/handlers/__init__.py +14 -14
- edsl/scenarios/handlers/{csv.py → csv_file_store.py} +1 -2
- edsl/scenarios/handlers/{docx.py → docx_file_store.py} +8 -7
- edsl/scenarios/handlers/{html.py → html_file_store.py} +1 -2
- edsl/scenarios/handlers/{jpeg.py → jpeg_file_store.py} +1 -1
- edsl/scenarios/handlers/{json.py → json_file_store.py} +1 -1
- edsl/scenarios/handlers/latex_file_store.py +5 -0
- edsl/scenarios/handlers/{md.py → md_file_store.py} +1 -1
- edsl/scenarios/handlers/{pdf.py → pdf_file_store.py} +2 -2
- edsl/scenarios/handlers/{png.py → png_file_store.py} +1 -1
- edsl/scenarios/handlers/{pptx.py → pptx_file_store.py} +8 -7
- edsl/scenarios/handlers/{py.py → py_file_store.py} +1 -3
- edsl/scenarios/handlers/{sql.py → sql_file_store.py} +2 -1
- edsl/scenarios/handlers/{sqlite.py → sqlite_file_store.py} +2 -3
- edsl/scenarios/handlers/{txt.py → txt_file_store.py} +1 -1
- edsl/scenarios/scenario.py +928 -0
- edsl/scenarios/scenario_join.py +18 -5
- edsl/scenarios/{ScenarioList.py → scenario_list.py} +294 -106
- edsl/scenarios/{ScenarioListPdfMixin.py → scenario_list_pdf_tools.py} +16 -15
- edsl/scenarios/scenario_selector.py +5 -1
- edsl/study/ObjectEntry.py +2 -2
- edsl/study/SnapShot.py +5 -5
- edsl/study/Study.py +18 -19
- edsl/study/__init__.py +6 -4
- edsl/surveys/__init__.py +7 -4
- edsl/surveys/dag/__init__.py +2 -0
- edsl/surveys/{ConstructDAG.py → dag/construct_dag.py} +3 -3
- edsl/surveys/{DAG.py → dag/dag.py} +13 -10
- edsl/surveys/descriptors.py +1 -1
- edsl/surveys/{EditSurvey.py → edit_survey.py} +9 -9
- edsl/{exceptions/surveys.py → surveys/exceptions.py} +1 -2
- edsl/surveys/memory/__init__.py +3 -0
- edsl/surveys/{MemoryPlan.py → memory/memory_plan.py} +10 -9
- edsl/surveys/rules/__init__.py +3 -0
- edsl/surveys/{Rule.py → rules/rule.py} +103 -43
- edsl/surveys/{RuleCollection.py → rules/rule_collection.py} +21 -30
- edsl/surveys/{RuleManager.py → rules/rule_manager.py} +19 -13
- edsl/surveys/survey.py +1743 -0
- edsl/surveys/{SurveyExportMixin.py → survey_export.py} +22 -27
- edsl/surveys/{SurveyFlowVisualization.py → survey_flow_visualization.py} +11 -2
- edsl/surveys/{Simulator.py → survey_simulator.py} +10 -3
- edsl/tasks/__init__.py +32 -0
- edsl/{jobs/tasks/QuestionTaskCreator.py → tasks/question_task_creator.py} +115 -57
- edsl/tasks/task_creators.py +135 -0
- edsl/{jobs/tasks/TaskHistory.py → tasks/task_history.py} +86 -47
- edsl/{jobs/tasks → tasks}/task_status_enum.py +91 -7
- edsl/tasks/task_status_log.py +85 -0
- edsl/tokens/__init__.py +2 -0
- edsl/tokens/interview_token_usage.py +53 -0
- edsl/utilities/PrettyList.py +1 -1
- edsl/utilities/SystemInfo.py +25 -22
- edsl/utilities/__init__.py +29 -21
- edsl/utilities/gcp_bucket/__init__.py +2 -0
- edsl/utilities/gcp_bucket/cloud_storage.py +99 -96
- edsl/utilities/interface.py +44 -536
- edsl/{results/MarkdownToPDF.py → utilities/markdown_to_pdf.py} +13 -5
- edsl/utilities/repair_functions.py +1 -1
- {edsl-0.1.47.dist-info → edsl-0.1.49.dist-info}/METADATA +1 -1
- edsl-0.1.49.dist-info/RECORD +347 -0
- edsl/Base.py +0 -493
- edsl/BaseDiff.py +0 -260
- edsl/agents/InvigilatorBase.py +0 -260
- edsl/agents/PromptConstructor.py +0 -318
- edsl/coop/PriceFetcher.py +0 -54
- edsl/data/Cache.py +0 -582
- edsl/data/CacheEntry.py +0 -238
- edsl/data/SQLiteDict.py +0 -292
- edsl/data/__init__.py +0 -5
- edsl/data/orm.py +0 -10
- edsl/exceptions/cache.py +0 -5
- edsl/exceptions/coop.py +0 -14
- edsl/exceptions/data.py +0 -14
- edsl/exceptions/scenarios.py +0 -29
- edsl/jobs/Answers.py +0 -43
- edsl/jobs/JobsPrompts.py +0 -354
- edsl/jobs/buckets/BucketCollection.py +0 -134
- edsl/jobs/buckets/ModelBuckets.py +0 -65
- edsl/jobs/buckets/TokenBucket.py +0 -283
- edsl/jobs/buckets/TokenBucketClient.py +0 -191
- edsl/jobs/interviews/Interview.py +0 -395
- edsl/jobs/interviews/InterviewExceptionCollection.py +0 -99
- edsl/jobs/interviews/InterviewStatisticsCollection.py +0 -25
- edsl/jobs/runners/JobsRunnerAsyncio.py +0 -163
- edsl/jobs/runners/JobsRunnerStatusData.py +0 -0
- edsl/jobs/tasks/TaskCreators.py +0 -64
- edsl/jobs/tasks/TaskStatusLog.py +0 -23
- edsl/jobs/tokens/InterviewTokenUsage.py +0 -27
- edsl/language_models/LanguageModel.py +0 -635
- edsl/language_models/ServiceDataSources.py +0 -0
- edsl/language_models/key_management/KeyLookup.py +0 -63
- edsl/language_models/key_management/KeyLookupCollection.py +0 -38
- edsl/language_models/key_management/models.py +0 -137
- edsl/questions/QuestionBase.py +0 -544
- edsl/questions/QuestionFreeText.py +0 -130
- edsl/questions/derived/QuestionLikertFive.py +0 -76
- edsl/results/ResultsExportMixin.py +0 -45
- edsl/results/TextEditor.py +0 -50
- edsl/results/results_fetch_mixin.py +0 -33
- edsl/results/results_tools_mixin.py +0 -98
- edsl/scenarios/DocumentChunker.py +0 -104
- edsl/scenarios/Scenario.py +0 -548
- edsl/scenarios/ScenarioHtmlMixin.py +0 -65
- edsl/scenarios/ScenarioListExportMixin.py +0 -45
- edsl/scenarios/handlers/latex.py +0 -5
- edsl/shared.py +0 -1
- edsl/surveys/Survey.py +0 -1301
- edsl/surveys/SurveyQualtricsImport.py +0 -284
- edsl/surveys/SurveyToApp.py +0 -141
- edsl/surveys/instructions/__init__.py +0 -0
- edsl/tools/__init__.py +0 -1
- edsl/tools/clusters.py +0 -192
- edsl/tools/embeddings.py +0 -27
- edsl/tools/embeddings_plotting.py +0 -118
- edsl/tools/plotting.py +0 -112
- edsl/tools/summarize.py +0 -18
- edsl/utilities/data/Registry.py +0 -6
- edsl/utilities/data/__init__.py +0 -1
- edsl/utilities/data/scooter_results.json +0 -1
- edsl-0.1.47.dist-info/RECORD +0 -354
- /edsl/coop/{CoopFunctionsMixin.py → coop_functions.py} +0 -0
- /edsl/{results → dataset/display}/CSSParameterizer.py +0 -0
- /edsl/{language_models/key_management → dataset/display}/__init__.py +0 -0
- /edsl/{results → dataset/display}/table_data_class.py +0 -0
- /edsl/{results → dataset/display}/table_display.css +0 -0
- /edsl/{results/ResultsGGMixin.py → dataset/r/ggplot.py} +0 -0
- /edsl/{results → dataset}/tree_explore.py +0 -0
- /edsl/{surveys/instructions/ChangeInstruction.py → instructions/change_instruction.py} +0 -0
- /edsl/{jobs/interviews → interviews}/interview_status_enum.py +0 -0
- /edsl/jobs/{runners/JobsRunnerStatus.py → jobs_runner_status.py} +0 -0
- /edsl/language_models/{PriceManager.py → price_manager.py} +0 -0
- /edsl/language_models/{fake_openai_call.py → unused/fake_openai_call.py} +0 -0
- /edsl/language_models/{fake_openai_service.py → unused/fake_openai_service.py} +0 -0
- /edsl/notebooks/{NotebookToLaTeX.py → notebook_to_latex.py} +0 -0
- /edsl/{exceptions/questions.py → questions/exceptions.py} +0 -0
- /edsl/questions/{SimpleAskMixin.py → simple_ask_mixin.py} +0 -0
- /edsl/surveys/{Memory.py → memory/memory.py} +0 -0
- /edsl/surveys/{MemoryManagement.py → memory/memory_management.py} +0 -0
- /edsl/surveys/{SurveyCSS.py → survey_css.py} +0 -0
- /edsl/{jobs/tokens/TokenUsage.py → tokens/token_usage.py} +0 -0
- /edsl/{results/MarkdownToDocx.py → utilities/markdown_to_docx.py} +0 -0
- /edsl/{TemplateLoader.py → utilities/template_loader.py} +0 -0
- {edsl-0.1.47.dist-info → edsl-0.1.49.dist-info}/LICENSE +0 -0
- {edsl-0.1.47.dist-info → edsl-0.1.49.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)
|