edsl 0.1.31.dev3__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 (52) hide show
  1. edsl/__version__.py +1 -1
  2. edsl/agents/Invigilator.py +7 -2
  3. edsl/agents/PromptConstructionMixin.py +35 -15
  4. edsl/config.py +15 -1
  5. edsl/conjure/Conjure.py +6 -0
  6. edsl/coop/coop.py +4 -0
  7. edsl/data/CacheHandler.py +3 -4
  8. edsl/enums.py +5 -0
  9. edsl/exceptions/general.py +10 -8
  10. edsl/inference_services/AwsBedrock.py +110 -0
  11. edsl/inference_services/AzureAI.py +197 -0
  12. edsl/inference_services/DeepInfraService.py +6 -91
  13. edsl/inference_services/GroqService.py +18 -0
  14. edsl/inference_services/InferenceServicesCollection.py +13 -8
  15. edsl/inference_services/OllamaService.py +18 -0
  16. edsl/inference_services/OpenAIService.py +68 -21
  17. edsl/inference_services/models_available_cache.py +31 -0
  18. edsl/inference_services/registry.py +14 -1
  19. edsl/jobs/Jobs.py +103 -21
  20. edsl/jobs/buckets/TokenBucket.py +12 -4
  21. edsl/jobs/interviews/Interview.py +31 -9
  22. edsl/jobs/interviews/InterviewExceptionEntry.py +101 -0
  23. edsl/jobs/interviews/InterviewTaskBuildingMixin.py +49 -33
  24. edsl/jobs/interviews/interview_exception_tracking.py +68 -10
  25. edsl/jobs/runners/JobsRunnerAsyncio.py +112 -81
  26. edsl/jobs/runners/JobsRunnerStatusData.py +0 -237
  27. edsl/jobs/runners/JobsRunnerStatusMixin.py +291 -35
  28. edsl/jobs/tasks/TaskCreators.py +8 -2
  29. edsl/jobs/tasks/TaskHistory.py +145 -1
  30. edsl/language_models/LanguageModel.py +62 -41
  31. edsl/language_models/registry.py +4 -0
  32. edsl/questions/QuestionBudget.py +0 -1
  33. edsl/questions/QuestionCheckBox.py +0 -1
  34. edsl/questions/QuestionExtract.py +0 -1
  35. edsl/questions/QuestionFreeText.py +2 -9
  36. edsl/questions/QuestionList.py +0 -1
  37. edsl/questions/QuestionMultipleChoice.py +1 -2
  38. edsl/questions/QuestionNumerical.py +0 -1
  39. edsl/questions/QuestionRank.py +0 -1
  40. edsl/results/DatasetExportMixin.py +33 -3
  41. edsl/scenarios/Scenario.py +14 -0
  42. edsl/scenarios/ScenarioList.py +216 -13
  43. edsl/scenarios/ScenarioListExportMixin.py +15 -4
  44. edsl/scenarios/ScenarioListPdfMixin.py +3 -0
  45. edsl/surveys/Rule.py +5 -2
  46. edsl/surveys/Survey.py +84 -1
  47. edsl/surveys/SurveyQualtricsImport.py +213 -0
  48. edsl/utilities/utilities.py +31 -0
  49. {edsl-0.1.31.dev3.dist-info → edsl-0.1.32.dist-info}/METADATA +5 -1
  50. {edsl-0.1.31.dev3.dist-info → edsl-0.1.32.dist-info}/RECORD +52 -46
  51. {edsl-0.1.31.dev3.dist-info → edsl-0.1.32.dist-info}/LICENSE +0 -0
  52. {edsl-0.1.31.dev3.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
@@ -122,22 +124,31 @@ class LanguageModel(
122
124
  # Skip the API key check. Sometimes this is useful for testing.
123
125
  self._api_token = None
124
126
 
127
+ def ask_question(self, question):
128
+ user_prompt = question.get_instructions().render(question.data).text
129
+ system_prompt = "You are a helpful agent pretending to be a human."
130
+ return self.execute_model_call(user_prompt, system_prompt)
131
+
125
132
  @property
126
133
  def api_token(self) -> str:
127
134
  if not hasattr(self, "_api_token"):
128
135
  key_name = service_to_api_keyname.get(self._inference_service_, "NOT FOUND")
129
- self._api_token = os.getenv(key_name)
130
- if (
131
- self._api_token is None
132
- and self._inference_service_ != "test"
133
- and not self.remote
134
- ):
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")
135
146
  raise MissingAPIKeyError(
136
147
  f"""The key for service: `{self._inference_service_}` is not set.
137
- Need a key with name {key_name} in your .env file.
138
- """
148
+ Need a key with name {key_name} in your .env file."""
139
149
  )
140
- return self._api_token
150
+
151
+ return self._api_token
141
152
 
142
153
  def __getitem__(self, key):
143
154
  return getattr(self, key)
@@ -202,8 +213,6 @@ class LanguageModel(
202
213
  """
203
214
  self._set_rate_limits(rpm=rpm, tpm=tpm)
204
215
 
205
-
206
-
207
216
  def _set_rate_limits(self, rpm=None, tpm=None) -> None:
208
217
  """Set the rate limits for the model.
209
218
 
@@ -244,14 +253,16 @@ class LanguageModel(
244
253
  >>> LanguageModel._overide_default_parameters(passed_parameter_dict={"temperature": 0.5}, default_parameter_dict={"temperature":0.9, "max_tokens": 1000})
245
254
  {'temperature': 0.5, 'max_tokens': 1000}
246
255
  """
247
- #parameters = dict({})
256
+ # parameters = dict({})
257
+
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
+ }
248
262
 
249
- return {parameter_name: passed_parameter_dict.get(parameter_name, default_value)
250
- for parameter_name, default_value in default_parameter_dict.items()}
251
-
252
- def __call__(self, user_prompt:str, system_prompt:str):
263
+ def __call__(self, user_prompt: str, system_prompt: str):
253
264
  return self.execute_model_call(user_prompt, system_prompt)
254
-
265
+
255
266
  @abstractmethod
256
267
  async def async_execute_model_call(user_prompt: str, system_prompt: str):
257
268
  """Execute the model call and returns a coroutine.
@@ -310,8 +321,10 @@ class LanguageModel(
310
321
  data["choices[0]"]["message"]["content"].
311
322
  """
312
323
  raise NotImplementedError
313
-
314
- 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:
315
328
  """Prepare the response for return."""
316
329
 
317
330
  model_response = {
@@ -321,21 +334,19 @@ class LanguageModel(
321
334
  "raw_model_response": model_call_outcome.response,
322
335
  }
323
336
 
324
- answer_portion = self.parse_response(model_call_outcome.response)
337
+ answer_portion = self.parse_response(model_call_outcome.response)
325
338
  try:
326
339
  answer_dict = json.loads(answer_portion)
327
340
  except json.JSONDecodeError as e:
328
341
  # TODO: Turn into logs to generate issues
329
342
  answer_dict, success = await repair(
330
- bad_json=answer_portion,
331
- error_message=str(e),
332
- cache=cache
343
+ bad_json=answer_portion, error_message=str(e), cache=cache
333
344
  )
334
345
  if not success:
335
346
  raise Exception(
336
347
  f"""Even the repair failed. The error was: {e}. The response was: {answer_portion}."""
337
348
  )
338
-
349
+
339
350
  return {**model_response, **answer_dict}
340
351
 
341
352
  async def async_get_raw_response(
@@ -347,16 +358,18 @@ class LanguageModel(
347
358
  encoded_image=None,
348
359
  ) -> IntendedModelCallOutcome:
349
360
  import warnings
350
- 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
+ )
351
365
  return await self._async_get_intended_model_call_outcome(
352
366
  user_prompt=user_prompt,
353
367
  system_prompt=system_prompt,
354
368
  cache=cache,
355
369
  iteration=iteration,
356
- encoded_image=encoded_image
370
+ encoded_image=encoded_image,
357
371
  )
358
372
 
359
-
360
373
  async def _async_get_intended_model_call_outcome(
361
374
  self,
362
375
  user_prompt: str,
@@ -398,8 +411,8 @@ class LanguageModel(
398
411
  "iteration": iteration,
399
412
  }
400
413
  cached_response, cache_key = cache.fetch(**cache_call_params)
401
-
402
- if (cache_used := cached_response is not None):
414
+
415
+ if cache_used := cached_response is not None:
403
416
  response = json.loads(cached_response)
404
417
  else:
405
418
  f = (
@@ -407,16 +420,24 @@ class LanguageModel(
407
420
  if hasattr(self, "remote") and self.remote
408
421
  else self.async_execute_model_call
409
422
  )
410
- params = {"user_prompt": user_prompt, "system_prompt": system_prompt,
411
- **({"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 {}),
412
427
  }
413
428
  response = await f(**params)
414
- new_cache_key = cache.store(**cache_call_params, response=response) # store the response in the cache
415
- assert new_cache_key == cache_key # should be the same
416
-
417
- 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
+ )
418
437
 
419
- _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
+ )
420
441
 
421
442
  get_raw_response = sync_wrapper(async_get_raw_response)
422
443
 
@@ -437,7 +458,7 @@ class LanguageModel(
437
458
  self,
438
459
  user_prompt: str,
439
460
  system_prompt: str,
440
- cache: 'Cache',
461
+ cache: "Cache",
441
462
  iteration: int = 1,
442
463
  encoded_image=None,
443
464
  ) -> dict:
@@ -455,8 +476,8 @@ class LanguageModel(
455
476
  "system_prompt": system_prompt,
456
477
  "iteration": iteration,
457
478
  "cache": cache,
458
- **({"encoded_image": encoded_image} if encoded_image else {})
459
- }
479
+ **({"encoded_image": encoded_image} if encoded_image else {}),
480
+ }
460
481
  model_call_outcome = await self._async_get_intended_model_call_outcome(**params)
461
482
  return await self._async_prepare_response(model_call_outcome, cache=cache)
462
483
 
@@ -36,6 +36,10 @@ class Model(metaclass=Meta):
36
36
  from edsl.inference_services.registry import default
37
37
 
38
38
  registry = registry or default
39
+
40
+ if isinstance(model_name, int):
41
+ model_name = cls.available(name_only=True)[model_name]
42
+
39
43
  factory = registry.create_model_factory(model_name)
40
44
  return factory(*args, **kwargs)
41
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):