edsl 0.1.33.dev2__py3-none-any.whl → 0.1.34__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 (78) hide show
  1. edsl/Base.py +24 -14
  2. edsl/__init__.py +1 -0
  3. edsl/__version__.py +1 -1
  4. edsl/agents/Agent.py +6 -6
  5. edsl/agents/Invigilator.py +28 -6
  6. edsl/agents/InvigilatorBase.py +8 -27
  7. edsl/agents/{PromptConstructionMixin.py → PromptConstructor.py} +150 -182
  8. edsl/agents/prompt_helpers.py +129 -0
  9. edsl/config.py +26 -34
  10. edsl/coop/coop.py +14 -4
  11. edsl/data_transfer_models.py +26 -73
  12. edsl/enums.py +2 -0
  13. edsl/inference_services/AnthropicService.py +5 -2
  14. edsl/inference_services/AwsBedrock.py +5 -2
  15. edsl/inference_services/AzureAI.py +5 -2
  16. edsl/inference_services/GoogleService.py +108 -33
  17. edsl/inference_services/InferenceServiceABC.py +44 -13
  18. edsl/inference_services/MistralAIService.py +5 -2
  19. edsl/inference_services/OpenAIService.py +10 -6
  20. edsl/inference_services/TestService.py +34 -16
  21. edsl/inference_services/TogetherAIService.py +170 -0
  22. edsl/inference_services/registry.py +2 -0
  23. edsl/jobs/Jobs.py +109 -18
  24. edsl/jobs/buckets/BucketCollection.py +24 -15
  25. edsl/jobs/buckets/TokenBucket.py +64 -10
  26. edsl/jobs/interviews/Interview.py +130 -49
  27. edsl/jobs/interviews/{interview_exception_tracking.py → InterviewExceptionCollection.py} +16 -0
  28. edsl/jobs/interviews/InterviewExceptionEntry.py +2 -0
  29. edsl/jobs/runners/JobsRunnerAsyncio.py +119 -173
  30. edsl/jobs/runners/JobsRunnerStatus.py +332 -0
  31. edsl/jobs/tasks/QuestionTaskCreator.py +1 -13
  32. edsl/jobs/tasks/TaskHistory.py +17 -0
  33. edsl/language_models/LanguageModel.py +36 -38
  34. edsl/language_models/registry.py +13 -9
  35. edsl/language_models/utilities.py +5 -2
  36. edsl/questions/QuestionBase.py +74 -16
  37. edsl/questions/QuestionBaseGenMixin.py +28 -0
  38. edsl/questions/QuestionBudget.py +93 -41
  39. edsl/questions/QuestionCheckBox.py +1 -1
  40. edsl/questions/QuestionFreeText.py +6 -0
  41. edsl/questions/QuestionMultipleChoice.py +13 -24
  42. edsl/questions/QuestionNumerical.py +5 -4
  43. edsl/questions/Quick.py +41 -0
  44. edsl/questions/ResponseValidatorABC.py +11 -6
  45. edsl/questions/derived/QuestionLinearScale.py +4 -1
  46. edsl/questions/derived/QuestionTopK.py +4 -1
  47. edsl/questions/derived/QuestionYesNo.py +8 -2
  48. edsl/questions/descriptors.py +12 -11
  49. edsl/questions/templates/budget/__init__.py +0 -0
  50. edsl/questions/templates/budget/answering_instructions.jinja +7 -0
  51. edsl/questions/templates/budget/question_presentation.jinja +7 -0
  52. edsl/questions/templates/extract/__init__.py +0 -0
  53. edsl/questions/templates/numerical/answering_instructions.jinja +0 -1
  54. edsl/questions/templates/rank/__init__.py +0 -0
  55. edsl/questions/templates/yes_no/answering_instructions.jinja +2 -2
  56. edsl/results/DatasetExportMixin.py +5 -1
  57. edsl/results/Result.py +1 -1
  58. edsl/results/Results.py +4 -1
  59. edsl/scenarios/FileStore.py +178 -34
  60. edsl/scenarios/Scenario.py +76 -37
  61. edsl/scenarios/ScenarioList.py +19 -2
  62. edsl/scenarios/ScenarioListPdfMixin.py +150 -4
  63. edsl/study/Study.py +32 -0
  64. edsl/surveys/DAG.py +62 -0
  65. edsl/surveys/MemoryPlan.py +26 -0
  66. edsl/surveys/Rule.py +34 -1
  67. edsl/surveys/RuleCollection.py +55 -5
  68. edsl/surveys/Survey.py +189 -10
  69. edsl/surveys/base.py +4 -0
  70. edsl/templates/error_reporting/interview_details.html +6 -1
  71. edsl/utilities/utilities.py +9 -1
  72. {edsl-0.1.33.dev2.dist-info → edsl-0.1.34.dist-info}/METADATA +3 -1
  73. {edsl-0.1.33.dev2.dist-info → edsl-0.1.34.dist-info}/RECORD +75 -69
  74. edsl/jobs/interviews/retry_management.py +0 -39
  75. edsl/jobs/runners/JobsRunnerStatusMixin.py +0 -333
  76. edsl/scenarios/ScenarioImageMixin.py +0 -100
  77. {edsl-0.1.33.dev2.dist-info → edsl-0.1.34.dist-info}/LICENSE +0 -0
  78. {edsl-0.1.33.dev2.dist-info → edsl-0.1.34.dist-info}/WHEEL +0 -0
@@ -19,6 +19,7 @@ class QuestionYesNo(QuestionMultipleChoice):
19
19
  question_options: list[str] = ["No", "Yes"],
20
20
  answering_instructions: Optional[str] = None,
21
21
  question_presentation: Optional[str] = None,
22
+ include_comment: Optional[bool] = True,
22
23
  ):
23
24
  """Instantiate a new QuestionYesNo.
24
25
 
@@ -33,6 +34,7 @@ class QuestionYesNo(QuestionMultipleChoice):
33
34
  use_code=False,
34
35
  answering_instructions=answering_instructions,
35
36
  question_presentation=question_presentation,
37
+ include_comment=include_comment,
36
38
  )
37
39
  self.question_options = question_options
38
40
 
@@ -41,9 +43,13 @@ class QuestionYesNo(QuestionMultipleChoice):
41
43
  ################
42
44
  @classmethod
43
45
  @inject_exception
44
- def example(cls) -> QuestionYesNo:
46
+ def example(cls, include_comment: bool = True) -> QuestionYesNo:
45
47
  """Return an example of a yes/no question."""
46
- return cls(question_name="is_it_equal", question_text="Is 5 + 5 equal to 11?")
48
+ return cls(
49
+ question_name="is_it_equal",
50
+ question_text="Is 5 + 5 equal to 11?",
51
+ include_comment=include_comment,
52
+ )
47
53
 
48
54
 
49
55
  def main():
@@ -303,7 +303,7 @@ class QuestionOptionsDescriptor(BaseDescriptor):
303
303
  return None
304
304
  else:
305
305
  raise QuestionCreationValidationError(
306
- f"Dynamic question options must have jina2 braces - instead received: {value}."
306
+ f"Dynamic question options must have jinja2 braces - instead received: {value}."
307
307
  )
308
308
  if not isinstance(value, list):
309
309
  raise QuestionCreationValidationError(
@@ -325,14 +325,15 @@ class QuestionOptionsDescriptor(BaseDescriptor):
325
325
  )
326
326
  if not self.linear_scale:
327
327
  if not self.q_budget:
328
- if not (
329
- value
330
- and all(type(x) == type(value[0]) for x in value)
331
- and isinstance(value[0], (str, list, int, float))
332
- ):
333
- raise QuestionCreationValidationError(
334
- f"Question options must be all same type (got {value}).)"
335
- )
328
+ pass
329
+ # if not (
330
+ # value
331
+ # and all(type(x) == type(value[0]) for x in value)
332
+ # and isinstance(value[0], (str, list, int, float))
333
+ # ):
334
+ # raise QuestionCreationValidationError(
335
+ # f"Question options must be all same type (got {value}).)"
336
+ # )
336
337
  else:
337
338
  if not all(isinstance(x, (str)) for x in value):
338
339
  raise QuestionCreationValidationError(
@@ -390,8 +391,8 @@ class QuestionTextDescriptor(BaseDescriptor):
390
391
 
391
392
  def validate(self, value, instance):
392
393
  """Validate the value is a string."""
393
- if len(value) > Settings.MAX_QUESTION_LENGTH:
394
- raise Exception("Question is too long!")
394
+ # if len(value) > Settings.MAX_QUESTION_LENGTH:
395
+ # raise Exception("Question is too long!")
395
396
  if len(value) < 1:
396
397
  raise Exception("Question is too short!")
397
398
  if not isinstance(value, str):
File without changes
@@ -0,0 +1,7 @@
1
+ Return only a comma-separated list the values in the same order as the options, with 0s included, on one line, in square braces.
2
+
3
+ Example: if there are 4 options, the response should be "[25,25,25,25]" to allocate 25 to each option.
4
+
5
+ {% if include_comment %}
6
+ After the answer, you can put a comment explaining your choice on the next line.
7
+ {% endif %}
@@ -0,0 +1,7 @@
1
+ {{question_text}}
2
+ The options are
3
+ {% for option in question_options %}
4
+ {{ loop.index0 }}: {{option}}
5
+ {% endfor %}
6
+ Allocate your budget of {{budget_sum}} among the options.
7
+
File without changes
@@ -1,7 +1,6 @@
1
1
  This question requires a numerical response in the form of an integer or decimal (e.g., -12, 0, 1, 2, 3.45, ...).
2
2
  Respond with just your number on a single line.
3
3
  If your response is equivalent to zero, report '0'
4
- If you cannot determine the answer, report 'None'
5
4
 
6
5
  {% if include_comment %}
7
6
  After the answer, put a comment explaining your choice on the next line.
File without changes
@@ -1,6 +1,6 @@
1
1
  {# Answering Instructions #}
2
- Please reponse with just your answer.
2
+ Please respond with just your answer.
3
3
 
4
4
  {% if include_comment %}
5
- After the answer, you can put a comment explaining your reponse.
5
+ After the answer, you can put a comment explaining your response.
6
6
  {% endif %}
@@ -472,7 +472,11 @@ class DatasetExportMixin:
472
472
  from edsl import ScenarioList, Scenario
473
473
 
474
474
  list_of_dicts = self.to_dicts(remove_prefix=remove_prefix)
475
- return ScenarioList([Scenario(d) for d in list_of_dicts])
475
+ scenarios = []
476
+ for d in list_of_dicts:
477
+ scenarios.append(Scenario(d))
478
+ return ScenarioList(scenarios)
479
+ # return ScenarioList([Scenario(d) for d in list_of_dicts])
476
480
 
477
481
  def to_agent_list(self, remove_prefix: bool = True):
478
482
  """Convert the results to a list of dictionaries, one per agent.
edsl/results/Result.py CHANGED
@@ -367,7 +367,7 @@ class Result(Base, UserDict):
367
367
  "raw_model_response", {"raw_model_response": "No raw model response"}
368
368
  ),
369
369
  question_to_attributes=json_dict.get("question_to_attributes", None),
370
- generated_tokens=json_dict.get("generated_tokens", None),
370
+ generated_tokens=json_dict.get("generated_tokens", {}),
371
371
  )
372
372
  return result
373
373
 
edsl/results/Results.py CHANGED
@@ -245,7 +245,9 @@ class Results(UserList, Mixins, Base):
245
245
  )
246
246
 
247
247
  def __repr__(self) -> str:
248
- return f"Results(data = {self.data}, survey = {repr(self.survey)}, created_columns = {self.created_columns})"
248
+ import reprlib
249
+
250
+ return f"Results(data = {reprlib.repr(self.data)}, survey = {repr(self.survey)}, created_columns = {self.created_columns})"
249
251
 
250
252
  def _repr_html_(self) -> str:
251
253
  from IPython.display import HTML
@@ -1089,6 +1091,7 @@ class Results(UserList, Mixins, Base):
1089
1091
  stop_on_exception=True,
1090
1092
  skip_retry=True,
1091
1093
  raise_validation_errors=True,
1094
+ disable_remote_inference=True,
1092
1095
  )
1093
1096
  return results
1094
1097
 
@@ -1,41 +1,101 @@
1
- from edsl import Scenario
2
1
  import base64
3
2
  import io
4
3
  import tempfile
5
- from typing import Optional
4
+ import mimetypes
5
+ import os
6
+ from typing import Dict, Any, IO, Optional
7
+ import requests
8
+ from urllib.parse import urlparse
9
+
10
+ import google.generativeai as genai
11
+
12
+ from edsl import Scenario
13
+ from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
14
+ from edsl.utilities.utilities import is_notebook
15
+
16
+
17
+ def view_pdf(pdf_path):
18
+ import os
19
+ import subprocess
20
+
21
+ if is_notebook():
22
+ from IPython.display import IFrame
23
+ from IPython.display import display, HTML
24
+
25
+ # Replace 'path/to/your/file.pdf' with the actual path to your PDF file
26
+ IFrame(pdf_path, width=700, height=600)
27
+ display(HTML(f'<a href="{pdf_path}" target="_blank">Open PDF</a>'))
28
+ return
29
+
30
+ if os.path.exists(pdf_path):
31
+ try:
32
+ if (os_name := os.name) == "posix":
33
+ # for cool kids
34
+ subprocess.run(["open", pdf_path], check=True) # macOS
35
+ elif os_name == "nt":
36
+ os.startfile(pdf_path) # Windows
37
+ else:
38
+ subprocess.run(["xdg-open", pdf_path], check=True) # Linux
39
+ except Exception as e:
40
+ print(f"Error opening PDF: {e}")
41
+ else:
42
+ print("PDF file was not created successfully.")
6
43
 
7
44
 
8
45
  class FileStore(Scenario):
9
46
  def __init__(
10
47
  self,
11
- filename: str,
48
+ path: Optional[str] = None,
49
+ mime_type: Optional[str] = None,
12
50
  binary: Optional[bool] = None,
13
51
  suffix: Optional[str] = None,
14
52
  base64_string: Optional[str] = None,
53
+ external_locations: Optional[Dict[str, str]] = None,
54
+ **kwargs,
15
55
  ):
16
- self.filename = filename
17
- self.suffix = suffix or "." + filename.split(".")[-1]
56
+ if path is None and "filename" in kwargs:
57
+ path = kwargs["filename"]
58
+ self.path = path
59
+ self.suffix = suffix or path.split(".")[-1]
18
60
  self.binary = binary or False
19
- self.base64_string = base64_string or self.encode_file_to_base64_string(
20
- filename
61
+ self.mime_type = (
62
+ mime_type or mimetypes.guess_type(path)[0] or "application/octet-stream"
21
63
  )
64
+ self.base64_string = base64_string or self.encode_file_to_base64_string(path)
65
+ self.external_locations = external_locations or {}
22
66
  super().__init__(
23
67
  {
24
- "filename": self.filename,
68
+ "path": self.path,
25
69
  "base64_string": self.base64_string,
26
70
  "binary": self.binary,
27
71
  "suffix": self.suffix,
72
+ "mime_type": self.mime_type,
73
+ "external_locations": self.external_locations,
28
74
  }
29
75
  )
30
76
 
77
+ def __str__(self):
78
+ return "FileStore: self.path"
79
+
80
+ @property
81
+ def size(self) -> int:
82
+ return os.path.getsize(self.path)
83
+
84
+ def upload_google(self, refresh: bool = False) -> None:
85
+ genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
86
+ google_info = genai.upload_file(self.path, mime_type=self.mime_type)
87
+ self.external_locations["google"] = google_info.to_dict()
88
+
31
89
  @classmethod
90
+ @remove_edsl_version
32
91
  def from_dict(cls, d):
33
- return cls(d["filename"], d["binary"], d["suffix"], d["base64_string"])
92
+ # return cls(d["filename"], d["binary"], d["suffix"], d["base64_string"])
93
+ return cls(**d)
34
94
 
35
95
  def __repr__(self):
36
- return f"FileStore(filename='{self.filename}', binary='{self.binary}', 'suffix'={self.suffix})"
96
+ return f"FileStore({self.path})"
37
97
 
38
- def encode_file_to_base64_string(self, file_path):
98
+ def encode_file_to_base64_string(self, file_path: str):
39
99
  try:
40
100
  # Attempt to open the file in text mode
41
101
  with open(file_path, "r") as text_file:
@@ -56,14 +116,14 @@ class FileStore(Scenario):
56
116
 
57
117
  return base64_string
58
118
 
59
- def open(self):
119
+ def open(self) -> "IO":
60
120
  if self.binary:
61
121
  return self.base64_to_file(self["base64_string"], is_binary=True)
62
122
  else:
63
123
  return self.base64_to_text_file(self["base64_string"])
64
124
 
65
125
  @staticmethod
66
- def base64_to_text_file(base64_string):
126
+ def base64_to_text_file(base64_string) -> "IO":
67
127
  # Decode the base64 string to bytes
68
128
  text_data_bytes = base64.b64decode(base64_string)
69
129
 
@@ -101,7 +161,9 @@ class FileStore(Scenario):
101
161
 
102
162
  # Create a named temporary file
103
163
  mode = "wb" if self.binary else "w"
104
- temp_file = tempfile.NamedTemporaryFile(delete=False, suffix=suffix, mode=mode)
164
+ temp_file = tempfile.NamedTemporaryFile(
165
+ delete=False, suffix="." + suffix, mode=mode
166
+ )
105
167
 
106
168
  if self.binary:
107
169
  temp_file.write(file_like_object.read())
@@ -112,23 +174,95 @@ class FileStore(Scenario):
112
174
 
113
175
  return temp_file.name
114
176
 
115
- def push(self, description=None):
177
+ def view(self, max_size: int = 300) -> None:
178
+ if self.suffix == "pdf":
179
+ view_pdf(self.path)
180
+
181
+ if self.suffix == "png" or self.suffix == "jpg" or self.suffix == "jpeg":
182
+ if is_notebook():
183
+ from IPython.display import Image
184
+ from PIL import Image as PILImage
185
+
186
+ if max_size:
187
+ # Open the image using Pillow
188
+ with PILImage.open(self.path) as img:
189
+ # Get original width and height
190
+ original_width, original_height = img.size
191
+
192
+ # Calculate the scaling factor
193
+ scale = min(
194
+ max_size / original_width, max_size / original_height
195
+ )
196
+
197
+ # Calculate new dimensions
198
+ new_width = int(original_width * scale)
199
+ new_height = int(original_height * scale)
200
+
201
+ return Image(self.path, width=new_width, height=new_height)
202
+ else:
203
+ return Image(self.path)
204
+
205
+ def push(
206
+ self, description: Optional[str] = None, visibility: str = "unlisted"
207
+ ) -> dict:
208
+ """
209
+ Push the object to Coop.
210
+ :param description: The description of the object to push.
211
+ :param visibility: The visibility of the object to push.
212
+ """
116
213
  scenario_version = Scenario.from_dict(self.to_dict())
117
214
  if description is None:
118
- description = "File: " + self["filename"]
119
- info = scenario_version.push(description=description)
215
+ description = "File: " + self.path
216
+ info = scenario_version.push(description=description, visibility=visibility)
120
217
  return info
121
218
 
122
219
  @classmethod
123
- def pull(cls, uuid):
124
- scenario_version = Scenario.pull(uuid)
220
+ def pull(cls, uuid: str, expected_parrot_url: Optional[str] = None) -> "FileStore":
221
+ """
222
+ :param uuid: The UUID of the object to pull.
223
+ :param expected_parrot_url: The URL of the Parrot server to use.
224
+ :return: The object pulled from the Parrot server.
225
+ """
226
+ scenario_version = Scenario.pull(uuid, expected_parrot_url=expected_parrot_url)
125
227
  return cls.from_dict(scenario_version.to_dict())
126
228
 
229
+ @classmethod
230
+ def from_url(
231
+ cls,
232
+ url: str,
233
+ download_path: Optional[str] = None,
234
+ mime_type: Optional[str] = None,
235
+ ) -> "FileStore":
236
+ """
237
+ :param url: The URL of the file to download.
238
+ :param download_path: The path to save the downloaded file.
239
+ :param mime_type: The MIME type of the file. If None, it will be guessed from the file extension.
240
+ """
241
+
242
+ response = requests.get(url, stream=True)
243
+ response.raise_for_status() # Raises an HTTPError for bad responses
244
+
245
+ # Get the filename from the URL if download_path is not provided
246
+ if download_path is None:
247
+ filename = os.path.basename(urlparse(url).path)
248
+ if not filename:
249
+ filename = "downloaded_file"
250
+ # download_path = filename
251
+ download_path = os.path.join(os.getcwd(), filename)
252
+
253
+ # Ensure the directory exists
254
+ os.makedirs(os.path.dirname(download_path), exist_ok=True)
255
+
256
+ # Write the file
257
+ with open(download_path, "wb") as file:
258
+ for chunk in response.iter_content(chunk_size=8192):
259
+ file.write(chunk)
260
+
261
+ # Create and return a new File instance
262
+ return cls(download_path, mime_type=mime_type)
127
263
 
128
- class CSVFileStore(FileStore):
129
- def __init__(self, filename):
130
- super().__init__(filename, suffix=".csv")
131
264
 
265
+ class CSVFileStore(FileStore):
132
266
  @classmethod
133
267
  def example(cls):
134
268
  from edsl.results.Results import Results
@@ -147,9 +281,6 @@ class CSVFileStore(FileStore):
147
281
 
148
282
 
149
283
  class PDFFileStore(FileStore):
150
- def __init__(self, filename):
151
- super().__init__(filename, suffix=".pdf")
152
-
153
284
  def view(self):
154
285
  pdf_path = self.to_tempfile()
155
286
  print(f"PDF path: {pdf_path}") # Print the path to ensure it exists
@@ -225,9 +356,6 @@ class PDFFileStore(FileStore):
225
356
 
226
357
 
227
358
  class PNGFileStore(FileStore):
228
- def __init__(self, filename):
229
- super().__init__(filename, suffix=".png")
230
-
231
359
  @classmethod
232
360
  def example(cls):
233
361
  import textwrap
@@ -251,9 +379,6 @@ class PNGFileStore(FileStore):
251
379
 
252
380
 
253
381
  class SQLiteFileStore(FileStore):
254
- def __init__(self, filename):
255
- super().__init__(filename, suffix=".sqlite")
256
-
257
382
  @classmethod
258
383
  def example(cls):
259
384
  import sqlite3
@@ -265,6 +390,8 @@ class SQLiteFileStore(FileStore):
265
390
  c.execute("""CREATE TABLE stocks (date text)""")
266
391
  conn.commit()
267
392
 
393
+ return cls(f.name)
394
+
268
395
  def view(self):
269
396
  import subprocess
270
397
  import os
@@ -273,6 +400,22 @@ class SQLiteFileStore(FileStore):
273
400
  os.system(f"sqlite3 {sqlite_path}")
274
401
 
275
402
 
403
+ class HTMLFileStore(FileStore):
404
+ @classmethod
405
+ def example(cls):
406
+ import tempfile
407
+
408
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as f:
409
+ f.write("<html><body><h1>Test</h1></body></html>".encode())
410
+ return cls(f.name)
411
+
412
+ def view(self):
413
+ import webbrowser
414
+
415
+ html_path = self.to_tempfile()
416
+ webbrowser.open("file://" + html_path)
417
+
418
+
276
419
  if __name__ == "__main__":
277
420
  # file_path = "../conjure/examples/Ex11-2.sav"
278
421
  # fs = FileStore(file_path)
@@ -289,9 +432,10 @@ if __name__ == "__main__":
289
432
  # fs = PDFFileStore("paper.pdf")
290
433
  # fs.view()
291
434
  # from edsl import Conjure
292
-
293
- fs = PNGFileStore("robot.png")
294
- fs.view()
435
+ pass
436
+ # fs = PNGFileStore("logo.png")
437
+ # fs.view()
438
+ # fs.upload_google()
295
439
 
296
440
  # c = Conjure(datafile_name=fs.to_tempfile())
297
441
  # f = PDFFileStore("paper.pdf")
@@ -2,19 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
  import copy
5
- import base64
6
5
  import hashlib
7
6
  import os
8
7
  from collections import UserDict
9
8
  from typing import Union, List, Optional, Generator
10
9
  from uuid import uuid4
10
+
11
11
  from edsl.Base import Base
12
- from edsl.scenarios.ScenarioImageMixin import ScenarioImageMixin
13
12
  from edsl.scenarios.ScenarioHtmlMixin import ScenarioHtmlMixin
14
13
  from edsl.utilities.decorators import add_edsl_version, remove_edsl_version
15
14
 
16
15
 
17
- class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
16
+ class Scenario(Base, UserDict, ScenarioHtmlMixin):
18
17
  """A Scenario is a dictionary of keys/values.
19
18
 
20
19
  They can be used parameterize edsl questions."""
@@ -42,16 +41,46 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
42
41
 
43
42
  return ScenarioList([copy.deepcopy(self) for _ in range(n)])
44
43
 
44
+ # @property
45
+ # def has_image(self) -> bool:
46
+ # """Return whether the scenario has an image."""
47
+ # if not hasattr(self, "_has_image"):
48
+ # self._has_image = False
49
+ # return self._has_image
50
+
45
51
  @property
46
- def has_image(self) -> bool:
47
- """Return whether the scenario has an image."""
48
- if not hasattr(self, "_has_image"):
49
- self._has_image = False
50
- return self._has_image
52
+ def has_jinja_braces(self) -> bool:
53
+ """Return whether the scenario has jinja braces. This matters for rendering.
54
+
55
+ >>> s = Scenario({"food": "I love {{wood chips}}"})
56
+ >>> s.has_jinja_braces
57
+ True
58
+ """
59
+ for _, value in self.items():
60
+ if isinstance(value, str):
61
+ if "{{" in value and "}}" in value:
62
+ return True
63
+ return False
51
64
 
52
- @has_image.setter
53
- def has_image(self, value):
54
- self._has_image = value
65
+ def convert_jinja_braces(
66
+ self, replacement_left="<<", replacement_right=">>"
67
+ ) -> Scenario:
68
+ """Convert Jinja braces to some other character.
69
+
70
+ >>> s = Scenario({"food": "I love {{wood chips}}"})
71
+ >>> s.convert_jinja_braces()
72
+ Scenario({'food': 'I love <<wood chips>>'})
73
+
74
+ """
75
+ new_scenario = Scenario()
76
+ for key, value in self.items():
77
+ if isinstance(value, str):
78
+ new_scenario[key] = value.replace("{{", replacement_left).replace(
79
+ "}}", replacement_right
80
+ )
81
+ else:
82
+ new_scenario[key] = value
83
+ return new_scenario
55
84
 
56
85
  def __add__(self, other_scenario: "Scenario") -> "Scenario":
57
86
  """Combine two scenarios by taking the union of their keys
@@ -75,8 +104,6 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
75
104
  data1 = copy.deepcopy(self.data)
76
105
  data2 = copy.deepcopy(other_scenario.data)
77
106
  s = Scenario(data1 | data2)
78
- if self.has_image or other_scenario.has_image:
79
- s._has_image = True
80
107
  return s
81
108
 
82
109
  def rename(self, replacement_dict: dict) -> "Scenario":
@@ -142,6 +169,7 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
142
169
  print_json(json.dumps(self.to_dict()))
143
170
 
144
171
  def __repr__(self):
172
+ # return "Scenario(" + reprlib.repr(self.data) + ")"
145
173
  return "Scenario(" + repr(self.data) + ")"
146
174
 
147
175
  def _repr_html_(self):
@@ -196,26 +224,34 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
196
224
  return cls({"url": url, field_name: text})
197
225
 
198
226
  @classmethod
199
- def from_image(cls, image_path: str) -> str:
200
- """Creates a scenario with a base64 encoding of an image.
227
+ def from_file(cls, file_path: str, field_name: str) -> "Scenario":
228
+ """Creates a scenario from a file."""
229
+ from edsl.scenarios.FileStore import FileStore
201
230
 
202
- Example:
231
+ fs = FileStore(file_path)
232
+ return cls({field_name: fs})
203
233
 
204
- >>> s = Scenario.from_image(Scenario.example_image())
205
- >>> s
206
- Scenario({'file_path': '...', 'encoded_image': '...'})
234
+ @classmethod
235
+ def from_image(
236
+ cls, image_path: str, image_name: Optional[str] = None
237
+ ) -> "Scenario":
207
238
  """
208
- with open(image_path, "rb") as image_file:
209
- s = cls(
210
- {
211
- "file_path": image_path,
212
- "encoded_image": base64.b64encode(image_file.read()).decode(
213
- "utf-8"
214
- ),
215
- }
216
- )
217
- s.has_image = True
218
- return s
239
+ Creates a scenario with a base64 encoding of an image.
240
+
241
+ Args:
242
+ image_path (str): Path to the image file.
243
+
244
+ Returns:
245
+ Scenario: A new Scenario instance with image information.
246
+
247
+ """
248
+ if not os.path.exists(image_path):
249
+ raise FileNotFoundError(f"Image file not found: {image_path}")
250
+
251
+ if image_name is None:
252
+ image_name = os.path.basename(image_path).split(".")[0]
253
+
254
+ return cls.from_file(image_path, image_name)
219
255
 
220
256
  @classmethod
221
257
  def from_pdf(cls, pdf_path):
@@ -429,18 +465,21 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
429
465
  return table
430
466
 
431
467
  @classmethod
432
- def example(cls, randomize: bool = False) -> Scenario:
468
+ def example(cls, randomize: bool = False, has_image=False) -> Scenario:
433
469
  """
434
470
  Returns an example Scenario instance.
435
471
 
436
472
  :param randomize: If True, adds a random string to the value of the example key.
437
473
  """
438
- addition = "" if not randomize else str(uuid4())
439
- return cls(
440
- {
441
- "persona": f"A reseacher studying whether LLMs can be used to generate surveys.{addition}",
442
- }
443
- )
474
+ if not has_image:
475
+ addition = "" if not randomize else str(uuid4())
476
+ return cls(
477
+ {
478
+ "persona": f"A reseacher studying whether LLMs can be used to generate surveys.{addition}",
479
+ }
480
+ )
481
+ else:
482
+ return cls.from_image(cls.example_image())
444
483
 
445
484
  def code(self) -> List[str]:
446
485
  """Return the code for the scenario."""