edsl 0.1.31.dev4__py3-none-any.whl → 0.1.32__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 (50) hide show
  1. edsl/__version__.py +1 -1
  2. edsl/agents/Invigilator.py +3 -4
  3. edsl/agents/PromptConstructionMixin.py +35 -15
  4. edsl/config.py +11 -1
  5. edsl/conjure/Conjure.py +6 -0
  6. edsl/data/CacheHandler.py +3 -4
  7. edsl/enums.py +4 -0
  8. edsl/exceptions/general.py +10 -8
  9. edsl/inference_services/AwsBedrock.py +110 -0
  10. edsl/inference_services/AzureAI.py +197 -0
  11. edsl/inference_services/DeepInfraService.py +4 -3
  12. edsl/inference_services/GroqService.py +3 -4
  13. edsl/inference_services/InferenceServicesCollection.py +13 -8
  14. edsl/inference_services/OllamaService.py +18 -0
  15. edsl/inference_services/OpenAIService.py +23 -18
  16. edsl/inference_services/models_available_cache.py +31 -0
  17. edsl/inference_services/registry.py +13 -1
  18. edsl/jobs/Jobs.py +100 -19
  19. edsl/jobs/buckets/TokenBucket.py +12 -4
  20. edsl/jobs/interviews/Interview.py +31 -9
  21. edsl/jobs/interviews/InterviewExceptionEntry.py +101 -0
  22. edsl/jobs/interviews/InterviewTaskBuildingMixin.py +49 -34
  23. edsl/jobs/interviews/interview_exception_tracking.py +68 -10
  24. edsl/jobs/runners/JobsRunnerAsyncio.py +36 -15
  25. edsl/jobs/runners/JobsRunnerStatusMixin.py +81 -51
  26. edsl/jobs/tasks/TaskCreators.py +1 -1
  27. edsl/jobs/tasks/TaskHistory.py +145 -1
  28. edsl/language_models/LanguageModel.py +58 -43
  29. edsl/language_models/registry.py +2 -2
  30. edsl/questions/QuestionBudget.py +0 -1
  31. edsl/questions/QuestionCheckBox.py +0 -1
  32. edsl/questions/QuestionExtract.py +0 -1
  33. edsl/questions/QuestionFreeText.py +2 -9
  34. edsl/questions/QuestionList.py +0 -1
  35. edsl/questions/QuestionMultipleChoice.py +1 -2
  36. edsl/questions/QuestionNumerical.py +0 -1
  37. edsl/questions/QuestionRank.py +0 -1
  38. edsl/results/DatasetExportMixin.py +33 -3
  39. edsl/scenarios/Scenario.py +14 -0
  40. edsl/scenarios/ScenarioList.py +216 -13
  41. edsl/scenarios/ScenarioListExportMixin.py +15 -4
  42. edsl/scenarios/ScenarioListPdfMixin.py +3 -0
  43. edsl/surveys/Rule.py +5 -2
  44. edsl/surveys/Survey.py +84 -1
  45. edsl/surveys/SurveyQualtricsImport.py +213 -0
  46. edsl/utilities/utilities.py +31 -0
  47. {edsl-0.1.31.dev4.dist-info → edsl-0.1.32.dist-info}/METADATA +4 -1
  48. {edsl-0.1.31.dev4.dist-info → edsl-0.1.32.dist-info}/RECORD +50 -45
  49. {edsl-0.1.31.dev4.dist-info → edsl-0.1.32.dist-info}/LICENSE +0 -0
  50. {edsl-0.1.31.dev4.dist-info → edsl-0.1.32.dist-info}/WHEEL +0 -0
@@ -11,6 +11,8 @@ class TaskHistory:
11
11
 
12
12
  [Interview.exceptions, Interview.exceptions, Interview.exceptions, ...]
13
13
 
14
+ >>> _ = TaskHistory.example()
15
+ ...
14
16
  """
15
17
 
16
18
  self.total_interviews = interviews
@@ -18,8 +20,26 @@ class TaskHistory:
18
20
 
19
21
  self._interviews = {index: i for index, i in enumerate(self.total_interviews)}
20
22
 
23
+ @classmethod
24
+ def example(cls):
25
+ from edsl.jobs.interviews.Interview import Interview
26
+
27
+ from edsl.jobs.Jobs import Jobs
28
+
29
+ j = Jobs.example(throw_exception_probability=1, test_model=True)
30
+
31
+ from edsl.config import CONFIG
32
+
33
+ results = j.run(print_exceptions=False, skip_retry=True, cache=False)
34
+
35
+ return cls(results.task_history.total_interviews)
36
+
21
37
  @property
22
38
  def exceptions(self):
39
+ """
40
+ >>> len(TaskHistory.example().exceptions)
41
+ 4
42
+ """
23
43
  return [i.exceptions for k, i in self._interviews.items() if i.exceptions != {}]
24
44
 
25
45
  @property
@@ -42,7 +62,12 @@ class TaskHistory:
42
62
 
43
63
  @property
44
64
  def has_exceptions(self) -> bool:
45
- """Return True if there are any exceptions."""
65
+ """Return True if there are any exceptions.
66
+
67
+ >>> TaskHistory.example().has_exceptions
68
+ True
69
+
70
+ """
46
71
  return len(self.exceptions) > 0
47
72
 
48
73
  def _repr_html_(self):
@@ -216,6 +241,47 @@ class TaskHistory:
216
241
  }
217
242
  """
218
243
 
244
+ @property
245
+ def exceptions_by_type(self) -> dict:
246
+ """Return a dictionary of exceptions by type."""
247
+ exceptions_by_type = {}
248
+ for interview in self.total_interviews:
249
+ for question_name, exceptions in interview.exceptions.items():
250
+ for exception in exceptions:
251
+ exception_type = exception["exception"]
252
+ if exception_type in exceptions_by_type:
253
+ exceptions_by_type[exception_type] += 1
254
+ else:
255
+ exceptions_by_type[exception_type] = 1
256
+ return exceptions_by_type
257
+
258
+ @property
259
+ def exceptions_by_question_name(self) -> dict:
260
+ """Return a dictionary of exceptions tallied by question name."""
261
+ exceptions_by_question_name = {}
262
+ for interview in self.total_interviews:
263
+ for question_name, exceptions in interview.exceptions.items():
264
+ if question_name not in exceptions_by_question_name:
265
+ exceptions_by_question_name[question_name] = 0
266
+ exceptions_by_question_name[question_name] += len(exceptions)
267
+
268
+ for question in self.total_interviews[0].survey.questions:
269
+ if question.question_name not in exceptions_by_question_name:
270
+ exceptions_by_question_name[question.question_name] = 0
271
+ return exceptions_by_question_name
272
+
273
+ @property
274
+ def exceptions_by_model(self) -> dict:
275
+ """Return a dictionary of exceptions tallied by model and question name."""
276
+ exceptions_by_model = {}
277
+ for interview in self.total_interviews:
278
+ model = interview.model
279
+ if model not in exceptions_by_model:
280
+ exceptions_by_model[model.model] = 0
281
+ if interview.exceptions != {}:
282
+ exceptions_by_model[model.model] += len(interview.exceptions)
283
+ return exceptions_by_model
284
+
219
285
  def html(
220
286
  self,
221
287
  filename: Optional[str] = None,
@@ -236,6 +302,8 @@ class TaskHistory:
236
302
  if css is None:
237
303
  css = self.css()
238
304
 
305
+ models_used = set([i.model for index, i in self._interviews.items()])
306
+
239
307
  template = Template(
240
308
  """
241
309
  <!DOCTYPE html>
@@ -249,6 +317,69 @@ class TaskHistory:
249
317
  </style>
250
318
  </head>
251
319
  <body>
320
+ <h1>Overview</h1>
321
+ <p>There were {{ interviews|length }} total interviews. The number of interviews with exceptions was {{ num_exceptions }}.</p>
322
+ <p>The models used were: {{ models_used }}.</p>
323
+ <p>For documentation on dealing with exceptions on Expected Parrot,
324
+ see <a href="https://docs.expectedparrot.com/en/latest/exceptions.html">here</a>.</p>
325
+
326
+ <h2>Exceptions by Type</h2>
327
+ <table>
328
+ <thead>
329
+ <tr>
330
+ <th>Exception Type</th>
331
+ <th>Number</th>
332
+ </tr>
333
+ </thead>
334
+ <tbody>
335
+ {% for exception_type, exceptions in exceptions_by_type.items() %}
336
+ <tr>
337
+ <td>{{ exception_type }}</td>
338
+ <td>{{ exceptions }}</td>
339
+ </tr>
340
+ {% endfor %}
341
+ </tbody>
342
+ </table>
343
+
344
+
345
+ <h2>Exceptions by Model</h2>
346
+ <table>
347
+ <thead>
348
+ <tr>
349
+ <th>Model</th>
350
+ <th>Number</th>
351
+ </tr>
352
+ </thead>
353
+ <tbody>
354
+ {% for model, exceptions in exceptions_by_model.items() %}
355
+ <tr>
356
+ <td>{{ model }}</td>
357
+ <td>{{ exceptions }}</td>
358
+ </tr>
359
+ {% endfor %}
360
+ </tbody>
361
+ </table>
362
+
363
+
364
+ <h2>Exceptions by Question Name</h2>
365
+ <table>
366
+ <thead>
367
+ <tr>
368
+ <th>Question Name</th>
369
+ <th>Number of Exceptions</th>
370
+ </tr>
371
+ </thead>
372
+ <tbody>
373
+ {% for question_name, exception_count in exceptions_by_question_name.items() %}
374
+ <tr>
375
+ <td>{{ question_name }}</td>
376
+ <td>{{ exception_count }}</td>
377
+ </tr>
378
+ {% endfor %}
379
+ </tbody>
380
+ </table>
381
+
382
+
252
383
  {% for index, interview in interviews.items() %}
253
384
  {% if interview.exceptions != {} %}
254
385
  <div class="interview">Interview: {{ index }} </div>
@@ -296,11 +427,18 @@ class TaskHistory:
296
427
  """
297
428
  )
298
429
 
430
+ # breakpoint()
431
+
299
432
  # Render the template with data
300
433
  output = template.render(
301
434
  interviews=self._interviews,
302
435
  css=css,
436
+ num_exceptions=len(self.exceptions),
303
437
  performance_plot_html=performance_plot_html,
438
+ exceptions_by_type=self.exceptions_by_type,
439
+ exceptions_by_question_name=self.exceptions_by_question_name,
440
+ exceptions_by_model=self.exceptions_by_model,
441
+ models_used=models_used,
304
442
  )
305
443
 
306
444
  # Save the rendered output to a file
@@ -344,3 +482,9 @@ class TaskHistory:
344
482
 
345
483
  if return_link:
346
484
  return filename
485
+
486
+
487
+ if __name__ == "__main__":
488
+ import doctest
489
+
490
+ doctest.testmod(optionflags=doctest.ELLIPSIS)
@@ -11,6 +11,7 @@ import hashlib
11
11
  from typing import Coroutine, Any, Callable, Type, List, get_type_hints
12
12
  from abc import ABC, abstractmethod
13
13
 
14
+
14
15
  class IntendedModelCallOutcome:
15
16
  "This is a tuple-like class that holds the response, cache_used, and cache_key."
16
17
 
@@ -21,7 +22,7 @@ class IntendedModelCallOutcome:
21
22
 
22
23
  def __iter__(self):
23
24
  """Iterate over the class attributes.
24
-
25
+
25
26
  >>> a, b, c = IntendedModelCallOutcome({'answer': "yes"}, True, 'x1289')
26
27
  >>> a
27
28
  {'answer': 'yes'}
@@ -32,10 +33,11 @@ class IntendedModelCallOutcome:
32
33
 
33
34
  def __len__(self):
34
35
  return 3
35
-
36
+
36
37
  def __repr__(self):
37
38
  return f"IntendedModelCallOutcome(response = {self.response}, cache_used = {self.cache_used}, cache_key = '{self.cache_key}')"
38
39
 
40
+
39
41
  from edsl.config import CONFIG
40
42
 
41
43
  from edsl.utilities.decorators import sync_wrapper, jupyter_nb_handler
@@ -131,18 +133,22 @@ class LanguageModel(
131
133
  def api_token(self) -> str:
132
134
  if not hasattr(self, "_api_token"):
133
135
  key_name = service_to_api_keyname.get(self._inference_service_, "NOT FOUND")
134
- self._api_token = os.getenv(key_name)
135
- if (
136
- self._api_token is None
137
- and self._inference_service_ != "test"
138
- and not self.remote
139
- ):
136
+
137
+ if self._inference_service_ == "bedrock":
138
+ self._api_token = [os.getenv(key_name[0]), os.getenv(key_name[1])]
139
+ # Check if any of the tokens are None
140
+ missing_token = any(token is None for token in self._api_token)
141
+ else:
142
+ self._api_token = os.getenv(key_name)
143
+ missing_token = self._api_token is None
144
+ if missing_token and self._inference_service_ != "test" and not self.remote:
145
+ print("rainsing error")
140
146
  raise MissingAPIKeyError(
141
147
  f"""The key for service: `{self._inference_service_}` is not set.
142
- Need a key with name {key_name} in your .env file.
143
- """
148
+ Need a key with name {key_name} in your .env file."""
144
149
  )
145
- return self._api_token
150
+
151
+ return self._api_token
146
152
 
147
153
  def __getitem__(self, key):
148
154
  return getattr(self, key)
@@ -159,8 +165,7 @@ class LanguageModel(
159
165
  if verbose:
160
166
  print(f"Current key is {masked}")
161
167
  return self.execute_model_call(
162
- user_prompt="Hello, model!",
163
- system_prompt="You are a helpful agent."
168
+ user_prompt="Hello, model!", system_prompt="You are a helpful agent."
164
169
  )
165
170
 
166
171
  def has_valid_api_key(self) -> bool:
@@ -208,8 +213,6 @@ class LanguageModel(
208
213
  """
209
214
  self._set_rate_limits(rpm=rpm, tpm=tpm)
210
215
 
211
-
212
-
213
216
  def _set_rate_limits(self, rpm=None, tpm=None) -> None:
214
217
  """Set the rate limits for the model.
215
218
 
@@ -250,14 +253,16 @@ class LanguageModel(
250
253
  >>> LanguageModel._overide_default_parameters(passed_parameter_dict={"temperature": 0.5}, default_parameter_dict={"temperature":0.9, "max_tokens": 1000})
251
254
  {'temperature': 0.5, 'max_tokens': 1000}
252
255
  """
253
- #parameters = dict({})
256
+ # parameters = dict({})
254
257
 
255
- return {parameter_name: passed_parameter_dict.get(parameter_name, default_value)
256
- for parameter_name, default_value in default_parameter_dict.items()}
257
-
258
- def __call__(self, user_prompt:str, system_prompt:str):
258
+ return {
259
+ parameter_name: passed_parameter_dict.get(parameter_name, default_value)
260
+ for parameter_name, default_value in default_parameter_dict.items()
261
+ }
262
+
263
+ def __call__(self, user_prompt: str, system_prompt: str):
259
264
  return self.execute_model_call(user_prompt, system_prompt)
260
-
265
+
261
266
  @abstractmethod
262
267
  async def async_execute_model_call(user_prompt: str, system_prompt: str):
263
268
  """Execute the model call and returns a coroutine.
@@ -316,8 +321,10 @@ class LanguageModel(
316
321
  data["choices[0]"]["message"]["content"].
317
322
  """
318
323
  raise NotImplementedError
319
-
320
- async def _async_prepare_response(self, model_call_outcome: IntendedModelCallOutcome, cache: "Cache") -> dict:
324
+
325
+ async def _async_prepare_response(
326
+ self, model_call_outcome: IntendedModelCallOutcome, cache: "Cache"
327
+ ) -> dict:
321
328
  """Prepare the response for return."""
322
329
 
323
330
  model_response = {
@@ -327,21 +334,19 @@ class LanguageModel(
327
334
  "raw_model_response": model_call_outcome.response,
328
335
  }
329
336
 
330
- answer_portion = self.parse_response(model_call_outcome.response)
337
+ answer_portion = self.parse_response(model_call_outcome.response)
331
338
  try:
332
339
  answer_dict = json.loads(answer_portion)
333
340
  except json.JSONDecodeError as e:
334
341
  # TODO: Turn into logs to generate issues
335
342
  answer_dict, success = await repair(
336
- bad_json=answer_portion,
337
- error_message=str(e),
338
- cache=cache
343
+ bad_json=answer_portion, error_message=str(e), cache=cache
339
344
  )
340
345
  if not success:
341
346
  raise Exception(
342
347
  f"""Even the repair failed. The error was: {e}. The response was: {answer_portion}."""
343
348
  )
344
-
349
+
345
350
  return {**model_response, **answer_dict}
346
351
 
347
352
  async def async_get_raw_response(
@@ -353,16 +358,18 @@ class LanguageModel(
353
358
  encoded_image=None,
354
359
  ) -> IntendedModelCallOutcome:
355
360
  import warnings
356
- warnings.warn("This method is deprecated. Use async_get_intended_model_call_outcome.")
361
+
362
+ warnings.warn(
363
+ "This method is deprecated. Use async_get_intended_model_call_outcome."
364
+ )
357
365
  return await self._async_get_intended_model_call_outcome(
358
366
  user_prompt=user_prompt,
359
367
  system_prompt=system_prompt,
360
368
  cache=cache,
361
369
  iteration=iteration,
362
- encoded_image=encoded_image
370
+ encoded_image=encoded_image,
363
371
  )
364
372
 
365
-
366
373
  async def _async_get_intended_model_call_outcome(
367
374
  self,
368
375
  user_prompt: str,
@@ -404,8 +411,8 @@ class LanguageModel(
404
411
  "iteration": iteration,
405
412
  }
406
413
  cached_response, cache_key = cache.fetch(**cache_call_params)
407
-
408
- if (cache_used := cached_response is not None):
414
+
415
+ if cache_used := cached_response is not None:
409
416
  response = json.loads(cached_response)
410
417
  else:
411
418
  f = (
@@ -413,16 +420,24 @@ class LanguageModel(
413
420
  if hasattr(self, "remote") and self.remote
414
421
  else self.async_execute_model_call
415
422
  )
416
- params = {"user_prompt": user_prompt, "system_prompt": system_prompt,
417
- **({"encoded_image": encoded_image} if encoded_image else {})
423
+ params = {
424
+ "user_prompt": user_prompt,
425
+ "system_prompt": system_prompt,
426
+ **({"encoded_image": encoded_image} if encoded_image else {}),
418
427
  }
419
428
  response = await f(**params)
420
- new_cache_key = cache.store(**cache_call_params, response=response) # store the response in the cache
421
- assert new_cache_key == cache_key # should be the same
422
-
423
- return IntendedModelCallOutcome(response = response, cache_used = cache_used, cache_key = cache_key)
429
+ new_cache_key = cache.store(
430
+ **cache_call_params, response=response
431
+ ) # store the response in the cache
432
+ assert new_cache_key == cache_key # should be the same
433
+
434
+ return IntendedModelCallOutcome(
435
+ response=response, cache_used=cache_used, cache_key=cache_key
436
+ )
424
437
 
425
- _get_intended_model_call_outcome = sync_wrapper(_async_get_intended_model_call_outcome)
438
+ _get_intended_model_call_outcome = sync_wrapper(
439
+ _async_get_intended_model_call_outcome
440
+ )
426
441
 
427
442
  get_raw_response = sync_wrapper(async_get_raw_response)
428
443
 
@@ -443,7 +458,7 @@ class LanguageModel(
443
458
  self,
444
459
  user_prompt: str,
445
460
  system_prompt: str,
446
- cache: 'Cache',
461
+ cache: "Cache",
447
462
  iteration: int = 1,
448
463
  encoded_image=None,
449
464
  ) -> dict:
@@ -461,8 +476,8 @@ class LanguageModel(
461
476
  "system_prompt": system_prompt,
462
477
  "iteration": iteration,
463
478
  "cache": cache,
464
- **({"encoded_image": encoded_image} if encoded_image else {})
465
- }
479
+ **({"encoded_image": encoded_image} if encoded_image else {}),
480
+ }
466
481
  model_call_outcome = await self._async_get_intended_model_call_outcome(**params)
467
482
  return await self._async_prepare_response(model_call_outcome, cache=cache)
468
483
 
@@ -36,10 +36,10 @@ class Model(metaclass=Meta):
36
36
  from edsl.inference_services.registry import default
37
37
 
38
38
  registry = registry or default
39
-
39
+
40
40
  if isinstance(model_name, int):
41
41
  model_name = cls.available(name_only=True)[model_name]
42
-
42
+
43
43
  factory = registry.create_model_factory(model_name)
44
44
  return factory(*args, **kwargs)
45
45
 
@@ -25,7 +25,6 @@ class QuestionBudget(QuestionBase):
25
25
  :param question_text: The text of the question.
26
26
  :param question_options: The options for allocation of the budget sum.
27
27
  :param budget_sum: The total amount of the budget to be allocated among the options.
28
- :param instructions: Instructions for the question. If not provided, the default instructions are used. To view them, run `QuestionBudget.default_instructions`.
29
28
  """
30
29
  self.question_name = question_name
31
30
  self.question_text = question_text
@@ -33,7 +33,6 @@ class QuestionCheckBox(QuestionBase):
33
33
  :param question_name: The name of the question.
34
34
  :param question_text: The text of the question.
35
35
  :param question_options: The options the respondent should select from.
36
- :param instructions: Instructions for the question. If not provided, the default instructions are used. To view them, run `QuestionCheckBox.default_instructions`.
37
36
  :param min_selections: The minimum number of options that must be selected.
38
37
  :param max_selections: The maximum number of options that must be selected.
39
38
  """
@@ -22,7 +22,6 @@ class QuestionExtract(QuestionBase):
22
22
  :param question_text: The text of the question.
23
23
  :param question_options: The options the respondent should select from.
24
24
  :param answer_template: The template for the answer.
25
- :param instructions: Instructions for the question. If not provided, the default instructions are used. To view them, run `QuestionExtract.default_instructions`.
26
25
  """
27
26
  self.question_name = question_name
28
27
  self.question_text = question_text
@@ -17,21 +17,14 @@ class QuestionFreeText(QuestionBase):
17
17
  """
18
18
  )
19
19
 
20
- def __init__(
21
- self,
22
- question_name: str,
23
- question_text: str,
24
- instructions: Optional[str] = None,
25
- ):
20
+ def __init__(self, question_name: str, question_text: str):
26
21
  """Instantiate a new QuestionFreeText.
27
22
 
28
23
  :param question_name: The name of the question.
29
24
  :param question_text: The text of the question.
30
- :param instructions: Instructions for the question. If not provided, the default instructions are used. To view them, run `QuestionFreeText.default_instructions`.
31
25
  """
32
26
  self.question_name = question_name
33
27
  self.question_text = question_text
34
- self.instructions = instructions
35
28
 
36
29
  ################
37
30
  # Answer methods
@@ -79,7 +72,7 @@ def main():
79
72
  q = QuestionFreeText.example()
80
73
  q.question_text
81
74
  q.question_name
82
- q.instructions
75
+ # q.instructions
83
76
  # validate an answer
84
77
  q._validate_answer({"answer": "I like custard"})
85
78
  # translate answer code
@@ -22,7 +22,6 @@ class QuestionList(QuestionBase):
22
22
 
23
23
  :param question_name: The name of the question.
24
24
  :param question_text: The text of the question.
25
- :param instructions: Instructions for the question. If not provided, the default instructions are used. To view them, run `QuestionList.default_instructions`.
26
25
  :param max_list_items: The maximum number of items that can be in the answer list.
27
26
  """
28
27
  self.question_name = question_name
@@ -33,7 +33,6 @@ class QuestionMultipleChoice(QuestionBase):
33
33
  :param question_name: The name of the question.
34
34
  :param question_text: The text of the question.
35
35
  :param question_options: The options the agent should select from.
36
- :param instructions: Instructions for the question. If not provided, the default instructions are used. To view them, run `QuestionMultipleChoice.default_instructions`.
37
36
  """
38
37
  self.question_name = question_name
39
38
  self.question_text = question_text
@@ -96,7 +95,7 @@ class QuestionMultipleChoice(QuestionBase):
96
95
  question_option_key = list(meta.find_undeclared_variables(parsed_content))[
97
96
  0
98
97
  ]
99
- #breakpoint()
98
+ # breakpoint()
100
99
  translated_options = scenario.get(question_option_key)
101
100
  else:
102
101
  translated_options = [
@@ -26,7 +26,6 @@ class QuestionNumerical(QuestionBase):
26
26
 
27
27
  :param question_name: The name of the question.
28
28
  :param question_text: The text of the question.
29
- :param instructions: Instructions for the question. If not provided, the default instructions are used. To view them, run `QuestionNumerical.default_instructions`.
30
29
  :param min_value: The minimum value of the answer.
31
30
  :param max_value: The maximum value of the answer.
32
31
  """
@@ -30,7 +30,6 @@ class QuestionRank(QuestionBase):
30
30
  :param question_name: The name of the question.
31
31
  :param question_text: The text of the question.
32
32
  :param question_options: The options the respondent should select from.
33
- :param instructions: Instructions for the question. If not provided, the default instructions are used. To view them, run `QuestionRank.default_instructions`.
34
33
  :param min_selections: The minimum number of options that must be selected.
35
34
  :param max_selections: The maximum number of options that must be selected.
36
35
  """
@@ -27,6 +27,10 @@ class DatasetExportMixin:
27
27
  >>> d.relevant_columns(remove_prefix=True)
28
28
  ['b']
29
29
 
30
+ >>> d = Dataset([{'a':[1,2,3,4]}, {'b':[5,6,7,8]}])
31
+ >>> d.relevant_columns()
32
+ ['a', 'b']
33
+
30
34
  >>> from edsl.results import Results; Results.example().select('how_feeling', 'how_feeling_yesterday').relevant_columns()
31
35
  ['answer.how_feeling', 'answer.how_feeling_yesterday']
32
36
 
@@ -592,8 +596,29 @@ class DatasetExportMixin:
592
596
  if return_link:
593
597
  return filename
594
598
 
599
+ def to_docx(self, filename: Optional[str] = None, separator: str = "\n"):
600
+ """Export the results to a Word document.
601
+
602
+ :param filename: The filename to save the Word document to.
603
+
604
+
605
+ """
606
+ from docx import Document
607
+
608
+ doc = Document()
609
+ for entry in self:
610
+ key, values = list(entry.items())[0]
611
+ doc.add_paragraph(key)
612
+ line = separator.join(values)
613
+ doc.add_paragraph(line)
614
+
615
+ if filename is not None:
616
+ doc.save(filename)
617
+ else:
618
+ return doc
619
+
595
620
  def tally(
596
- self, *fields: Optional[str], top_n: Optional[int] = None, output="dict"
621
+ self, *fields: Optional[str], top_n: Optional[int] = None, output="Dataset"
597
622
  ) -> Union[dict, "Dataset"]:
598
623
  """Tally the values of a field or perform a cross-tab of multiple fields.
599
624
 
@@ -601,9 +626,11 @@ class DatasetExportMixin:
601
626
 
602
627
  >>> from edsl.results import Results
603
628
  >>> r = Results.example()
604
- >>> r.select('how_feeling').tally('answer.how_feeling')
629
+ >>> r.select('how_feeling').tally('answer.how_feeling', output = "dict")
605
630
  {'OK': 2, 'Great': 1, 'Terrible': 1}
606
- >>> r.select('how_feeling', 'period').tally('how_feeling', 'period')
631
+ >>> r.select('how_feeling').tally('answer.how_feeling', output = "Dataset")
632
+ Dataset([{'value': ['OK', 'Great', 'Terrible']}, {'count': [2, 1, 1]}])
633
+ >>> r.select('how_feeling', 'period').tally('how_feeling', 'period', output = "dict")
607
634
  {('OK', 'morning'): 1, ('Great', 'afternoon'): 1, ('Terrible', 'morning'): 1, ('OK', 'afternoon'): 1}
608
635
  """
609
636
  from collections import Counter
@@ -615,6 +642,8 @@ class DatasetExportMixin:
615
642
  column.split(".")[-1] for column in self.relevant_columns()
616
643
  ]
617
644
 
645
+ # breakpoint()
646
+
618
647
  if not all(
619
648
  f in self.relevant_columns() or f in relevant_columns_without_prefix
620
649
  for f in fields
@@ -641,6 +670,7 @@ class DatasetExportMixin:
641
670
  from edsl.results.Dataset import Dataset
642
671
 
643
672
  if output == "dict":
673
+ # why did I do this?
644
674
  warnings.warn(
645
675
  textwrap.dedent(
646
676
  """\
@@ -182,6 +182,19 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
182
182
  new_scenario[key] = self[key]
183
183
  return new_scenario
184
184
 
185
+ @classmethod
186
+ def from_url(cls, url: str, field_name: Optional[str] = "text") -> "Scenario":
187
+ """Creates a scenario from a URL.
188
+
189
+ :param url: The URL to create the scenario from.
190
+ :param field_name: The field name to use for the text.
191
+
192
+ """
193
+ import requests
194
+
195
+ text = requests.get(url).text
196
+ return cls({"url": url, field_name: text})
197
+
185
198
  @classmethod
186
199
  def from_image(cls, image_path: str) -> str:
187
200
  """Creates a scenario with a base64 encoding of an image.
@@ -207,6 +220,7 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
207
220
  @classmethod
208
221
  def from_pdf(cls, pdf_path):
209
222
  import fitz # PyMuPDF
223
+ from edsl import Scenario
210
224
 
211
225
  # Ensure the file exists
212
226
  if not os.path.exists(pdf_path):