edsl 0.1.27.dev2__py3-none-any.whl → 0.1.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. edsl/Base.py +99 -22
  2. edsl/BaseDiff.py +260 -0
  3. edsl/__init__.py +4 -0
  4. edsl/__version__.py +1 -1
  5. edsl/agents/Agent.py +26 -5
  6. edsl/agents/AgentList.py +62 -7
  7. edsl/agents/Invigilator.py +4 -9
  8. edsl/agents/InvigilatorBase.py +5 -5
  9. edsl/agents/descriptors.py +3 -1
  10. edsl/conjure/AgentConstructionMixin.py +152 -0
  11. edsl/conjure/Conjure.py +56 -0
  12. edsl/conjure/InputData.py +628 -0
  13. edsl/conjure/InputDataCSV.py +48 -0
  14. edsl/conjure/InputDataMixinQuestionStats.py +182 -0
  15. edsl/conjure/InputDataPyRead.py +91 -0
  16. edsl/conjure/InputDataSPSS.py +8 -0
  17. edsl/conjure/InputDataStata.py +8 -0
  18. edsl/conjure/QuestionOptionMixin.py +76 -0
  19. edsl/conjure/QuestionTypeMixin.py +23 -0
  20. edsl/conjure/RawQuestion.py +65 -0
  21. edsl/conjure/SurveyResponses.py +7 -0
  22. edsl/conjure/__init__.py +9 -4
  23. edsl/conjure/examples/placeholder.txt +0 -0
  24. edsl/conjure/naming_utilities.py +263 -0
  25. edsl/conjure/utilities.py +165 -28
  26. edsl/conversation/Conversation.py +238 -0
  27. edsl/conversation/car_buying.py +58 -0
  28. edsl/conversation/mug_negotiation.py +81 -0
  29. edsl/conversation/next_speaker_utilities.py +93 -0
  30. edsl/coop/coop.py +191 -12
  31. edsl/coop/utils.py +20 -2
  32. edsl/data/Cache.py +55 -17
  33. edsl/data/CacheHandler.py +10 -9
  34. edsl/inference_services/AnthropicService.py +1 -0
  35. edsl/inference_services/DeepInfraService.py +20 -13
  36. edsl/inference_services/GoogleService.py +7 -1
  37. edsl/inference_services/InferenceServicesCollection.py +33 -7
  38. edsl/inference_services/OpenAIService.py +17 -10
  39. edsl/inference_services/models_available_cache.py +69 -0
  40. edsl/inference_services/rate_limits_cache.py +25 -0
  41. edsl/inference_services/write_available.py +10 -0
  42. edsl/jobs/Jobs.py +240 -36
  43. edsl/jobs/buckets/BucketCollection.py +9 -3
  44. edsl/jobs/interviews/Interview.py +4 -1
  45. edsl/jobs/interviews/InterviewTaskBuildingMixin.py +24 -10
  46. edsl/jobs/interviews/retry_management.py +4 -4
  47. edsl/jobs/runners/JobsRunnerAsyncio.py +87 -45
  48. edsl/jobs/runners/JobsRunnerStatusData.py +3 -3
  49. edsl/jobs/tasks/QuestionTaskCreator.py +4 -2
  50. edsl/language_models/LanguageModel.py +37 -44
  51. edsl/language_models/ModelList.py +96 -0
  52. edsl/language_models/registry.py +14 -0
  53. edsl/language_models/repair.py +95 -24
  54. edsl/notebooks/Notebook.py +119 -31
  55. edsl/questions/QuestionBase.py +109 -12
  56. edsl/questions/descriptors.py +5 -2
  57. edsl/questions/question_registry.py +7 -0
  58. edsl/results/Result.py +20 -8
  59. edsl/results/Results.py +85 -11
  60. edsl/results/ResultsDBMixin.py +3 -6
  61. edsl/results/ResultsExportMixin.py +47 -16
  62. edsl/results/ResultsToolsMixin.py +5 -5
  63. edsl/scenarios/Scenario.py +59 -5
  64. edsl/scenarios/ScenarioList.py +97 -40
  65. edsl/study/ObjectEntry.py +97 -0
  66. edsl/study/ProofOfWork.py +110 -0
  67. edsl/study/SnapShot.py +77 -0
  68. edsl/study/Study.py +491 -0
  69. edsl/study/__init__.py +2 -0
  70. edsl/surveys/Survey.py +79 -31
  71. edsl/surveys/SurveyExportMixin.py +21 -3
  72. edsl/utilities/__init__.py +1 -0
  73. edsl/utilities/gcp_bucket/__init__.py +0 -0
  74. edsl/utilities/gcp_bucket/cloud_storage.py +96 -0
  75. edsl/utilities/gcp_bucket/simple_example.py +9 -0
  76. edsl/utilities/interface.py +24 -28
  77. edsl/utilities/repair_functions.py +28 -0
  78. edsl/utilities/utilities.py +57 -2
  79. {edsl-0.1.27.dev2.dist-info → edsl-0.1.28.dist-info}/METADATA +43 -17
  80. {edsl-0.1.27.dev2.dist-info → edsl-0.1.28.dist-info}/RECORD +83 -55
  81. edsl-0.1.28.dist-info/entry_points.txt +3 -0
  82. edsl/conjure/RawResponseColumn.py +0 -327
  83. edsl/conjure/SurveyBuilder.py +0 -308
  84. edsl/conjure/SurveyBuilderCSV.py +0 -78
  85. edsl/conjure/SurveyBuilderSPSS.py +0 -118
  86. edsl/data/RemoteDict.py +0 -103
  87. {edsl-0.1.27.dev2.dist-info → edsl-0.1.28.dist-info}/LICENSE +0 -0
  88. {edsl-0.1.27.dev2.dist-info → edsl-0.1.28.dist-info}/WHEEL +0 -0
@@ -1,14 +1,19 @@
1
1
  import json
2
2
  import asyncio
3
+ import warnings
4
+ from rich import print
5
+ from rich.console import Console
6
+ from rich.syntax import Syntax
3
7
 
4
8
  from edsl.utilities.utilities import clean_json
5
9
 
10
+ from edsl.utilities.repair_functions import extract_json_from_string
6
11
 
7
- async def async_repair(bad_json, error_message=""):
8
- s = clean_json(bad_json)
9
- from edsl import Model
10
12
 
11
- m = Model()
13
+ async def async_repair(
14
+ bad_json, error_message="", user_prompt=None, system_prompt=None, cache=None
15
+ ):
16
+ s = clean_json(bad_json)
12
17
 
13
18
  try:
14
19
  # this is the OpenAI version, but that's fine
@@ -17,56 +22,122 @@ async def async_repair(bad_json, error_message=""):
17
22
  except json.JSONDecodeError:
18
23
  valid_dict = {}
19
24
  success = False
20
- # print("Replacing control characters didn't work. Trying with the model.")
25
+ # print("Replacing control characters didn't work. Trying extracting the sub-string.")
21
26
  else:
22
27
  return valid_dict, success
23
28
 
24
- prompt = f"""This is the output from a less capable language model.
25
- It was supposed to respond with just a JSON object with an answer to a question and some commentary,
26
- in a field called "comment" next to "answer".
27
- Please repair this bad JSON: {bad_json}."""
29
+ try:
30
+ valid_dict = extract_json_from_string(s)
31
+ success = True
32
+ except ValueError:
33
+ valid_dict = {}
34
+ success = False
35
+ else:
36
+ return valid_dict, success
28
37
 
29
- if error_message:
30
- prompt += f" Parsing error message: {error_message}"
38
+ from edsl import Model
31
39
 
32
- try:
33
- results = await m.async_execute_model_call(
34
- prompt,
35
- system_prompt="You are a helpful agent. Only return the repaired JSON, nothing else.",
40
+ m = Model()
41
+
42
+ from edsl import QuestionExtract
43
+
44
+ with warnings.catch_warnings():
45
+ warnings.simplefilter("ignore", UserWarning)
46
+
47
+ q = QuestionExtract(
48
+ question_text="""
49
+ A language model was supposed to respond to a question.
50
+ The response should have been JSON object with an answer to a question and some commentary.
51
+
52
+ It should have retured a string like this:
53
+
54
+ '{'answer': 'The answer to the question.', 'comment': 'Some commentary.'}'
55
+
56
+ or:
57
+
58
+ '{'answer': 'The answer to the question.'}'
59
+
60
+ The answer field is very like an integer number. The comment field is always string.
61
+
62
+ You job is to return just the repaired JSON object that the model should have returned, properly formatted.
63
+
64
+ - It might have included some preliminary comments.
65
+ - It might have included some control characters.
66
+ - It might have included some extraneous text.
67
+
68
+ DO NOT include any extraneous text in your response. Just return the repaired JSON object.
69
+ Do not preface the JSON object with any text. Just return the JSON object.
70
+
71
+ Bad answer: """
72
+ + str(bad_json)
73
+ + "The model received a user prompt of: '"
74
+ + str(user_prompt)
75
+ + """'
76
+ The model received a system prompt of: ' """
77
+ + str(system_prompt)
78
+ + """
79
+ '
80
+ Please return the repaired JSON object, following the instructions the original model should have followed, though
81
+ using 'new_answer' a nd 'new_comment' as the keys.""",
82
+ answer_template={
83
+ "new_answer": "<number, string, list, etc.>",
84
+ "new_comment": "Model's comments",
85
+ },
86
+ question_name="model_repair",
36
87
  )
37
- except Exception as e:
38
- return {}, False
88
+
89
+ results = await q.run_async(cache=cache)
39
90
 
40
91
  try:
41
92
  # this is the OpenAI version, but that's fine
42
- valid_dict = json.loads(results["choices"][0]["message"]["content"])
93
+ valid_dict = json.loads(json.dumps(results))
43
94
  success = True
95
+ # this is to deal with the fact that the model returns the answer and comment as new_answer and new_comment
96
+ valid_dict["answer"] = valid_dict.pop("new_answer")
97
+ valid_dict["comment"] = valid_dict.pop("new_comment")
44
98
  except json.JSONDecodeError:
45
99
  valid_dict = {}
46
100
  success = False
101
+ console = Console()
102
+ error_message = (
103
+ f"All repairs. failed. LLM Model given [red]{str(bad_json)}[/red]"
104
+ )
105
+ console.print(" " + error_message)
106
+ model_returned = results["choices"][0]["message"]["content"]
107
+ console.print(f"LLM Model returned: [blue]{model_returned}[/blue]")
47
108
 
48
109
  return valid_dict, success
49
110
 
50
111
 
51
- def repair_wrapper(bad_json, error_message=""):
112
+ def repair_wrapper(
113
+ bad_json, error_message="", user_prompt=None, system_prompt=None, cache=None
114
+ ):
52
115
  try:
53
116
  loop = asyncio.get_event_loop()
54
117
  if loop.is_running():
55
118
  # Add repair as a task to the running loop
56
- task = loop.create_task(async_repair(bad_json, error_message))
119
+ task = loop.create_task(
120
+ async_repair(bad_json, error_message, user_prompt, system_prompt, cache)
121
+ )
57
122
  return task
58
123
  else:
59
124
  # Run a new event loop for repair
60
- return loop.run_until_complete(async_repair(bad_json, error_message))
125
+ return loop.run_until_complete(
126
+ async_repair(bad_json, error_message, user_prompt, system_prompt, cache)
127
+ )
61
128
  except RuntimeError:
62
129
  # Create a new event loop if one is not already available
63
130
  loop = asyncio.new_event_loop()
64
131
  asyncio.set_event_loop(loop)
65
- return loop.run_until_complete(async_repair(bad_json, error_message))
132
+ return loop.run_until_complete(
133
+ async_repair(bad_json, error_message, user_prompt, system_prompt, cache)
134
+ )
66
135
 
67
136
 
68
- def repair(bad_json, error_message=""):
69
- return repair_wrapper(bad_json, error_message)
137
+ def repair(
138
+ bad_json, error_message="", user_prompt=None, system_prompt=None, cache=None
139
+ ):
140
+ return repair_wrapper(bad_json, error_message, user_prompt, system_prompt, cache)
70
141
 
71
142
 
72
143
  # Example usage:
@@ -1,5 +1,8 @@
1
- """A Notebook is ...."""
1
+ """A Notebook is a utility class that allows you to easily share/pull ipynbs from Coop."""
2
2
 
3
+ import json
4
+ import nbformat
5
+ from nbconvert import HTMLExporter
3
6
  from typing import Dict, List, Optional
4
7
  from rich.table import Table
5
8
  from edsl.Base import Base
@@ -14,43 +17,58 @@ class Notebook(Base):
14
17
  A Notebook is a utility class that allows you to easily share/pull ipynbs from Coop.
15
18
  """
16
19
 
17
- def __init__(self, data: Optional[Dict] = None, path: Optional[str] = None):
20
+ default_name = "notebook"
21
+
22
+ def __init__(
23
+ self,
24
+ data: Optional[Dict] = None,
25
+ path: Optional[str] = None,
26
+ name: Optional[str] = None,
27
+ ):
18
28
  """
19
29
  Initialize a new Notebook.
20
- - if a path is provided, try to load the notebook from that path.
21
- - if no path is provided, assume this code is run in a notebook and try to load the current notebook.
30
+
31
+ :param data: A dictionary representing the notebook data.
32
+ This dictionary must conform to the official Jupyter Notebook format, as defined by nbformat.
33
+ :param path: A filepath from which to load the notebook.
34
+ If no path is provided, assume this code is run in a notebook and try to load the current notebook from file.
35
+ :param name: A name for the Notebook.
22
36
  """
37
+ # Load current notebook path as fallback (VS Code only)
38
+ path = path or globals().get("__vsc_ipynb_file__")
23
39
  if data is not None:
40
+ nbformat.validate(data)
24
41
  self.data = data
25
42
  elif path is not None:
26
- # TO BE IMPLEMENTED
27
- # store in this var the data from the notebook
28
- self.data = {"some": "data"}
43
+ with open(path, mode="r", encoding="utf-8") as f:
44
+ data = nbformat.read(f, as_version=4)
45
+ self.data = json.loads(json.dumps(data))
29
46
  else:
30
- # TO BE IMPLEMENTED
31
- # 1. Check you're in a notebook ...
32
- # 2. get its info and store it in self.data
33
- self.data = {"some": "data"}
47
+ # TODO: Support for IDEs other than VSCode
48
+ raise NotImplementedError(
49
+ "Cannot create a notebook from within itself in this development environment"
50
+ )
34
51
 
35
- # deprioritize - perhaps add sanity check function
52
+ # TODO: perhaps add sanity check function
36
53
  # 1. could check if the notebook is a valid notebook
37
54
  # 2. could check notebook uses EDSL
38
55
  # ....
39
56
 
57
+ self.name = name or self.default_name
58
+
40
59
  def __eq__(self, other):
41
60
  """
42
61
  Check if two Notebooks are equal.
62
+ This only checks the notebook data.
43
63
  """
44
64
  return self.data == other.data
45
65
 
46
66
  @add_edsl_version
47
67
  def to_dict(self) -> dict:
48
68
  """
49
- Convert to a dictionary.
50
- AF: here you will create a dict from which self.from_dict can recreate the object.
51
- AF: the decorator will add the edsl_version to the dict.
69
+ Convert a Notebook to a dictionary.
52
70
  """
53
- return {"data": self.data}
71
+ return {"name": self.name, "data": self.data}
54
72
 
55
73
  @classmethod
56
74
  @remove_edsl_version
@@ -58,12 +76,17 @@ class Notebook(Base):
58
76
  """
59
77
  Convert a dictionary representation of a Notebook to a Notebook object.
60
78
  """
61
- return cls(data=d["data"])
79
+ return cls(data=d["data"], name=d["name"])
80
+
81
+ def to_file(self, path: str):
82
+ """
83
+ Save the notebook at the specified filepath.
84
+ """
85
+ nbformat.write(nbformat.from_dict(self.data), fp=path)
62
86
 
63
87
  def print(self):
64
88
  """
65
89
  Print the notebook.
66
- AF: not sure how this should behave for a notebook
67
90
  """
68
91
  from rich import print_json
69
92
  import json
@@ -72,45 +95,110 @@ class Notebook(Base):
72
95
 
73
96
  def __repr__(self):
74
97
  """
75
- AF: not sure how this should behave for a notebook
98
+ Return representation of Notebook.
76
99
  """
77
- return f"Notebook({self.to_dict()})"
100
+ return f'Notebook(data={self.data}, name="""{self.name}""")'
78
101
 
79
102
  def _repr_html_(self):
80
103
  """
81
- AF: not sure how this should behave for a notebook
104
+ Return HTML representation of Notebook.
82
105
  """
83
- from edsl.utilities.utilities import data_to_html
106
+ notebook = nbformat.from_dict(self.data)
107
+ html_exporter = HTMLExporter(template_name="basic")
108
+ (body, _) = html_exporter.from_notebook_node(notebook)
109
+ return body
84
110
 
85
- return data_to_html(self.to_dict())
111
+ def _table(self) -> tuple[dict, list]:
112
+ """
113
+ Prepare generic table data.
114
+ """
115
+ table_data = []
116
+
117
+ notebook_preview = ""
118
+ for cell in self.data["cells"]:
119
+ if "source" in cell:
120
+ notebook_preview += f"{cell['source']}\n"
121
+ if len(notebook_preview) > 1000:
122
+ notebook_preview = f"{notebook_preview[:1000]} [...]"
123
+ break
124
+ notebook_preview = notebook_preview.rstrip()
125
+
126
+ table_data.append(
127
+ {
128
+ "Attribute": "name",
129
+ "Value": repr(self.name),
130
+ }
131
+ )
132
+ table_data.append(
133
+ {
134
+ "Attribute": "notebook_preview",
135
+ "Value": notebook_preview,
136
+ }
137
+ )
138
+
139
+ column_names = ["Attribute", "Value"]
140
+ return table_data, column_names
86
141
 
87
142
  def rich_print(self) -> "Table":
88
143
  """
89
- AF: not sure how we should implement this for a notebook
144
+ Display a Notebook as a rich table.
90
145
  """
91
- pass
146
+ table_data, column_names = self._table()
147
+ table = Table(title=f"{self.__class__.__name__} Attributes")
148
+ for column in column_names:
149
+ table.add_column(column, style="bold")
150
+
151
+ for row in table_data:
152
+ row_data = [row[column] for column in column_names]
153
+ table.add_row(*row_data)
154
+
155
+ return table
92
156
 
93
157
  @classmethod
94
158
  def example(cls) -> "Notebook":
95
159
  """
96
160
  Return an example Notebook.
97
- AF: add a simple custom example here
98
161
  """
99
- return cls(data={"some": "data"})
162
+ cells = [
163
+ {
164
+ "cell_type": "markdown",
165
+ "metadata": dict(),
166
+ "source": "# Test notebook",
167
+ },
168
+ {
169
+ "cell_type": "code",
170
+ "execution_count": 1,
171
+ "metadata": dict(),
172
+ "outputs": [
173
+ {
174
+ "name": "stdout",
175
+ "output_type": "stream",
176
+ "text": "Hello world!\n",
177
+ }
178
+ ],
179
+ "source": 'print("Hello world!")',
180
+ },
181
+ ]
182
+ data = {
183
+ "metadata": dict(),
184
+ "nbformat": 4,
185
+ "nbformat_minor": 4,
186
+ "cells": cells,
187
+ }
188
+ return cls(data=data)
100
189
 
101
190
  def code(self) -> List[str]:
102
191
  """
103
192
  Return the code that could be used to create this Notebook.
104
- AF: Again, not sure
105
193
  """
106
194
  lines = []
107
- lines.append("from edsl.notebooks import Notebook")
108
- lines.append(f"s = Notebook({self.data})")
195
+ lines.append("from edsl import Notebook")
196
+ lines.append(f'nb = Notebook(data={self.data}, name="""{self.name}""")')
109
197
  return lines
110
198
 
111
199
 
112
200
  if __name__ == "__main__":
113
- from edsl.notebooks import Notebook
201
+ from edsl import Notebook
114
202
 
115
203
  notebook = Notebook.example()
116
204
  assert notebook == notebook.from_dict(notebook.to_dict())
@@ -3,7 +3,8 @@
3
3
  from __future__ import annotations
4
4
  from abc import ABC, abstractmethod
5
5
  from rich.table import Table
6
- from typing import Any, Type, Optional
6
+ from typing import Any, Type, Optional, List, Callable
7
+ import copy
7
8
 
8
9
  from edsl.exceptions import (
9
10
  QuestionResponseValidationError,
@@ -15,6 +16,7 @@ from edsl.prompts.registry import get_classes as prompt_lookup
15
16
  from edsl.questions.AnswerValidatorMixin import AnswerValidatorMixin
16
17
  from edsl.questions.RegisterQuestionsMeta import RegisterQuestionsMeta
17
18
  from edsl.Base import PersistenceMixin, RichPrintingMixin
19
+ from edsl.BaseDiff import BaseDiff, BaseDiffCollection
18
20
 
19
21
  from edsl.questions.SimpleAskMixin import SimpleAskMixin
20
22
  from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
@@ -37,6 +39,12 @@ class QuestionBase(
37
39
  """Get an attribute of the question."""
38
40
  return getattr(self, key)
39
41
 
42
+ def __hash__(self) -> int:
43
+ """Return a hash of the question."""
44
+ from edsl.utilities.utilities import dict_hash
45
+
46
+ return dict_hash(self._to_dict())
47
+
40
48
  def _repr_html_(self):
41
49
  from edsl.utilities.utilities import data_to_html
42
50
 
@@ -49,6 +57,35 @@ class QuestionBase(
49
57
 
50
58
  return data_to_html(data)
51
59
 
60
+ def apply_function(self, func: Callable, exclude_components=None) -> QuestionBase:
61
+ """Apply a function to the question parts
62
+
63
+ >>> from edsl.questions import QuestionFreeText
64
+ >>> q = QuestionFreeText(question_name = "color", question_text = "What is your favorite color?")
65
+ >>> shouting = lambda x: x.upper()
66
+ >>> q.apply_function(shouting)
67
+ Question('free_text', question_name = \"""color\""", question_text = \"""WHAT IS YOUR FAVORITE COLOR?\""")
68
+
69
+ """
70
+ if exclude_components is None:
71
+ exclude_components = ["question_name", "question_type"]
72
+
73
+ d = copy.deepcopy(self._to_dict())
74
+ for key, value in d.items():
75
+ if key in exclude_components:
76
+ continue
77
+ if isinstance(value, dict):
78
+ for k, v in value.items():
79
+ value[k] = func(v)
80
+ d[key] = value
81
+ continue
82
+ if isinstance(value, list):
83
+ value = [func(v) for v in value]
84
+ d[key] = value
85
+ continue
86
+ d[key] = func(value)
87
+ return QuestionBase.from_dict(d)
88
+
52
89
  @property
53
90
  def data(self) -> dict:
54
91
  """Return a dictionary of question attributes **except** for question_type."""
@@ -101,6 +138,33 @@ class QuestionBase(
101
138
  self._model_instructions = {}
102
139
  return self._model_instructions
103
140
 
141
+ def _all_text(self) -> str:
142
+ """Return the question text."""
143
+ txt = ""
144
+ for key, value in self.data.items():
145
+ if isinstance(value, str):
146
+ txt += value
147
+ elif isinstance(value, list):
148
+ txt += "".join(str(value))
149
+ return txt
150
+
151
+ @property
152
+ def parameters(self) -> set[str]:
153
+ """Return the parameters of the question."""
154
+ from jinja2 import Environment, meta
155
+
156
+ env = Environment()
157
+ # Parse the template
158
+ txt = self._all_text()
159
+ # txt = self.question_text
160
+ # if hasattr(self, "question_options"):
161
+ # txt += " ".join(self.question_options)
162
+ parsed_content = env.parse(txt)
163
+ # Extract undeclared variables
164
+ variables = meta.find_undeclared_variables(parsed_content)
165
+ # Return as a list
166
+ return set(variables)
167
+
104
168
  @model_instructions.setter
105
169
  def model_instructions(self, data: dict):
106
170
  """Set the model-specific instructions for the question."""
@@ -167,13 +231,17 @@ class QuestionBase(
167
231
  ############################
168
232
  # Serialization methods
169
233
  ############################
170
- @add_edsl_version
171
- def to_dict(self) -> dict[str, Any]:
234
+ def _to_dict(self):
172
235
  """Convert the question to a dictionary that includes the question type (used in deserialization)."""
173
236
  candidate_data = self.data.copy()
174
237
  candidate_data["question_type"] = self.question_type
175
238
  return candidate_data
176
239
 
240
+ @add_edsl_version
241
+ def to_dict(self) -> dict[str, Any]:
242
+ """Convert the question to a dictionary that includes the question type (used in deserialization)."""
243
+ return self._to_dict()
244
+
177
245
  @classmethod
178
246
  @remove_edsl_version
179
247
  def from_dict(cls, data: dict) -> Type[QuestionBase]:
@@ -211,6 +279,10 @@ class QuestionBase(
211
279
 
212
280
  return question_class(**local_data)
213
281
 
282
+ def copy(self) -> Type[QuestionBase]:
283
+ """Return a deep copy of the question."""
284
+ return copy.deepcopy(self)
285
+
214
286
  ############################
215
287
  # Dunder methods
216
288
  ############################
@@ -220,6 +292,25 @@ class QuestionBase(
220
292
 
221
293
  print_json(json.dumps(self.to_dict()))
222
294
 
295
+ def __call__(self, just_answer=True, model=None, agent=None, **kwargs):
296
+ """Call the question."""
297
+ survey = self.to_survey()
298
+ results = survey(model=model, agent=agent, **kwargs)
299
+ if just_answer:
300
+ return results.select(f"answer.{self.question_name}").first()
301
+ else:
302
+ return results
303
+
304
+ async def run_async(self, just_answer=True, model=None, agent=None, **kwargs):
305
+ """Call the question."""
306
+ survey = self.to_survey()
307
+ ## asyncio.run(survey.async_call());
308
+ results = await survey.run_async(model=model, agent=agent, **kwargs)
309
+ if just_answer:
310
+ return results.select(f"answer.{self.question_name}").first()
311
+ else:
312
+ return results
313
+
223
314
  def __repr__(self) -> str:
224
315
  """Return a string representation of the question. Should be able to be used to reconstruct the question."""
225
316
  class_name = self.__class__.__name__
@@ -237,21 +328,27 @@ class QuestionBase(
237
328
  return False
238
329
  return self.to_dict() == other.to_dict()
239
330
 
331
+ def __sub__(self, other) -> BaseDiff:
332
+ """Return the difference between two objects."""
333
+
334
+ return BaseDiff(other, self)
335
+
240
336
  # TODO: Throws an error that should be addressed at QuestionFunctional
241
- def __add__(self, other_question):
337
+ def __add__(self, other_question_or_diff):
242
338
  """
243
339
  Compose two questions into a single question.
244
340
 
245
- >>> from edsl.scenarios.Scenario import Scenario
246
- >>> from edsl.questions.QuestionFreeText import QuestionFreeText
247
- >>> from edsl.questions.QuestionNumerical import QuestionNumerical
248
- >>> q1 = QuestionFreeText(question_text = "What is the capital of {{country}}", question_name = "capital")
249
- >>> q2 = QuestionNumerical(question_text = "What is the population of {{capital}}, in millions. Please round", question_name = "population")
250
- >>> q3 = q1 + q2
341
+ TODO: Probably getting deprecated.
342
+
251
343
  """
344
+ if isinstance(other_question_or_diff, BaseDiff) or isinstance(
345
+ other_question_or_diff, BaseDiffCollection
346
+ ):
347
+ return other_question_or_diff.apply(self)
348
+
252
349
  from edsl.questions import compose_questions
253
350
 
254
- return compose_questions(self, other_question)
351
+ return compose_questions(self, other_question_or_diff)
255
352
 
256
353
  @abstractmethod
257
354
  def _validate_answer(self, answer: dict[str, str]):
@@ -279,7 +376,7 @@ class QuestionBase(
279
376
  ############################
280
377
  # Forward methods
281
378
  ############################
282
- def add_question(self, other: Question) -> "Survey":
379
+ def add_question(self, other: QuestionBase) -> "Survey":
283
380
  """Add a question to this question by turning them into a survey with two questions."""
284
381
  from edsl.surveys.Survey import Survey
285
382
 
@@ -331,6 +331,9 @@ class QuestionTextDescriptor(BaseDescriptor):
331
331
  if not isinstance(value, str):
332
332
  raise Exception("Question must be a string!")
333
333
  if contains_single_braced_substring(value):
334
- print(
335
- f"WARNING: Question text contains a single-braced substring: {value}.\nIf you intended to parameterize the question with a Scenario this should be changed to a double-braced substring, e.g. {{variable}}.\nSee details on constructing Scenarios in the docs: https://docs.expectedparrot.com/en/latest/scenarios.html"
334
+ import warnings
335
+
336
+ warnings.warn(
337
+ f"WARNING: Question text contains a single-braced substring: If you intended to parameterize the question with a Scenario this should be changed to a double-braced substring, e.g. {{variable}}.\nSee details on constructing Scenarios in the docs: https://docs.expectedparrot.com/en/latest/scenarios.html",
338
+ UserWarning,
336
339
  )
@@ -52,6 +52,13 @@ class Question(metaclass=Meta):
52
52
  instance.__init__(*args, **kwargs)
53
53
  return instance
54
54
 
55
+ @classmethod
56
+ def example(cls, question_type: str):
57
+ """Return an example question of the given type."""
58
+ get_question_classes = RegisterQuestionsMeta.question_types_to_classes()
59
+ q = get_question_classes.get(question_type, None)
60
+ return q.example()
61
+
55
62
  @classmethod
56
63
  def pull(cls, id_or_url: str):
57
64
  """Pull the object from coop."""
edsl/results/Result.py CHANGED
@@ -58,6 +58,8 @@ class Result(Base, UserDict):
58
58
 
59
59
  The answer dictionary has the structure:
60
60
 
61
+ >>> import warnings
62
+ >>> warnings.simplefilter("ignore", UserWarning)
61
63
  >>> Result.example().answer
62
64
  {'how_feeling': 'OK', 'how_feeling_comment': 'This is a real survey response from a human.', 'how_feeling_yesterday': 'Great', 'how_feeling_yesterday_comment': 'This is a real survey response from a human.'}
63
65
 
@@ -237,14 +239,8 @@ class Result(Base, UserDict):
237
239
  ###############
238
240
  # Serialization
239
241
  ###############
240
- @add_edsl_version
241
- def to_dict(self) -> dict[str, Any]:
242
- """Return a dictionary representation of the Result object.
243
-
244
- >>> r = Result.example()
245
- >>> r.to_dict()['scenario']
246
- {'period': 'morning', 'edsl_version': '...', 'edsl_class_name': 'Scenario'}
247
- """
242
+ def _to_dict(self) -> dict[str, Any]:
243
+ """Return a dictionary representation of the Result object."""
248
244
  d = {}
249
245
  for key, value in self.items():
250
246
  if hasattr(value, "to_dict"):
@@ -262,6 +258,22 @@ class Result(Base, UserDict):
262
258
  d[key] = new_prompt_dict
263
259
  return d
264
260
 
261
+ @add_edsl_version
262
+ def to_dict(self) -> dict[str, Any]:
263
+ """Return a dictionary representation of the Result object.
264
+
265
+ >>> r = Result.example()
266
+ >>> r.to_dict()['scenario']
267
+ {'period': 'morning', 'edsl_version': '...', 'edsl_class_name': 'Scenario'}
268
+ """
269
+ return self._to_dict()
270
+
271
+ def __hash__(self):
272
+ """Return a hash of the Result object."""
273
+ from edsl.utilities.utilities import dict_hash
274
+
275
+ return dict_hash(self._to_dict())
276
+
265
277
  @classmethod
266
278
  @remove_edsl_version
267
279
  def from_dict(self, json_dict: dict) -> Result: