edsl 0.1.54__py3-none-any.whl → 0.1.56__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 (105) hide show
  1. edsl/__init__.py +8 -1
  2. edsl/__init__original.py +134 -0
  3. edsl/__version__.py +1 -1
  4. edsl/agents/agent.py +29 -0
  5. edsl/agents/agent_list.py +36 -1
  6. edsl/base/base_class.py +281 -151
  7. edsl/base/data_transfer_models.py +15 -4
  8. edsl/buckets/__init__.py +8 -3
  9. edsl/buckets/bucket_collection.py +9 -3
  10. edsl/buckets/model_buckets.py +4 -2
  11. edsl/buckets/token_bucket.py +2 -2
  12. edsl/buckets/token_bucket_client.py +5 -3
  13. edsl/caching/cache.py +131 -62
  14. edsl/caching/cache_entry.py +70 -58
  15. edsl/caching/sql_dict.py +17 -0
  16. edsl/cli.py +99 -0
  17. edsl/config/config_class.py +16 -0
  18. edsl/conversation/__init__.py +31 -0
  19. edsl/coop/coop.py +276 -242
  20. edsl/coop/coop_jobs_objects.py +59 -0
  21. edsl/coop/coop_objects.py +29 -0
  22. edsl/coop/coop_regular_objects.py +26 -0
  23. edsl/coop/utils.py +24 -19
  24. edsl/dataset/dataset.py +338 -101
  25. edsl/dataset/dataset_operations_mixin.py +216 -180
  26. edsl/db_list/sqlite_list.py +349 -0
  27. edsl/inference_services/__init__.py +40 -5
  28. edsl/inference_services/exceptions.py +11 -0
  29. edsl/inference_services/services/anthropic_service.py +5 -2
  30. edsl/inference_services/services/aws_bedrock.py +6 -2
  31. edsl/inference_services/services/azure_ai.py +6 -2
  32. edsl/inference_services/services/google_service.py +7 -3
  33. edsl/inference_services/services/mistral_ai_service.py +6 -2
  34. edsl/inference_services/services/open_ai_service.py +6 -2
  35. edsl/inference_services/services/perplexity_service.py +6 -2
  36. edsl/inference_services/services/test_service.py +94 -5
  37. edsl/interviews/answering_function.py +167 -59
  38. edsl/interviews/interview.py +124 -72
  39. edsl/interviews/interview_task_manager.py +10 -0
  40. edsl/interviews/request_token_estimator.py +8 -0
  41. edsl/invigilators/invigilators.py +35 -13
  42. edsl/jobs/async_interview_runner.py +146 -104
  43. edsl/jobs/data_structures.py +6 -4
  44. edsl/jobs/decorators.py +61 -0
  45. edsl/jobs/fetch_invigilator.py +61 -18
  46. edsl/jobs/html_table_job_logger.py +14 -2
  47. edsl/jobs/jobs.py +180 -104
  48. edsl/jobs/jobs_component_constructor.py +2 -2
  49. edsl/jobs/jobs_interview_constructor.py +2 -0
  50. edsl/jobs/jobs_pricing_estimation.py +154 -113
  51. edsl/jobs/jobs_remote_inference_logger.py +4 -0
  52. edsl/jobs/jobs_runner_status.py +30 -25
  53. edsl/jobs/progress_bar_manager.py +79 -0
  54. edsl/jobs/remote_inference.py +35 -1
  55. edsl/key_management/key_lookup_builder.py +6 -1
  56. edsl/language_models/language_model.py +110 -12
  57. edsl/language_models/model.py +10 -3
  58. edsl/language_models/price_manager.py +176 -71
  59. edsl/language_models/registry.py +5 -0
  60. edsl/notebooks/notebook.py +77 -10
  61. edsl/questions/VALIDATION_README.md +134 -0
  62. edsl/questions/__init__.py +24 -1
  63. edsl/questions/exceptions.py +21 -0
  64. edsl/questions/question_dict.py +201 -16
  65. edsl/questions/question_multiple_choice_with_other.py +624 -0
  66. edsl/questions/question_registry.py +2 -1
  67. edsl/questions/templates/multiple_choice_with_other/__init__.py +0 -0
  68. edsl/questions/templates/multiple_choice_with_other/answering_instructions.jinja +15 -0
  69. edsl/questions/templates/multiple_choice_with_other/question_presentation.jinja +17 -0
  70. edsl/questions/validation_analysis.py +185 -0
  71. edsl/questions/validation_cli.py +131 -0
  72. edsl/questions/validation_html_report.py +404 -0
  73. edsl/questions/validation_logger.py +136 -0
  74. edsl/results/result.py +115 -46
  75. edsl/results/results.py +702 -171
  76. edsl/scenarios/construct_download_link.py +16 -3
  77. edsl/scenarios/directory_scanner.py +226 -226
  78. edsl/scenarios/file_methods.py +5 -0
  79. edsl/scenarios/file_store.py +150 -9
  80. edsl/scenarios/handlers/__init__.py +5 -1
  81. edsl/scenarios/handlers/mp4_file_store.py +104 -0
  82. edsl/scenarios/handlers/webm_file_store.py +104 -0
  83. edsl/scenarios/scenario.py +120 -101
  84. edsl/scenarios/scenario_list.py +800 -727
  85. edsl/scenarios/scenario_list_gc_test.py +146 -0
  86. edsl/scenarios/scenario_list_memory_test.py +214 -0
  87. edsl/scenarios/scenario_list_source_refactor.md +35 -0
  88. edsl/scenarios/scenario_selector.py +5 -4
  89. edsl/scenarios/scenario_source.py +1990 -0
  90. edsl/scenarios/tests/test_scenario_list_sources.py +52 -0
  91. edsl/surveys/survey.py +22 -0
  92. edsl/tasks/__init__.py +4 -2
  93. edsl/tasks/task_history.py +198 -36
  94. edsl/tests/scenarios/test_ScenarioSource.py +51 -0
  95. edsl/tests/scenarios/test_scenario_list_sources.py +51 -0
  96. edsl/utilities/__init__.py +2 -1
  97. edsl/utilities/decorators.py +121 -0
  98. edsl/utilities/memory_debugger.py +1010 -0
  99. {edsl-0.1.54.dist-info → edsl-0.1.56.dist-info}/METADATA +51 -76
  100. {edsl-0.1.54.dist-info → edsl-0.1.56.dist-info}/RECORD +103 -79
  101. edsl/jobs/jobs_runner_asyncio.py +0 -281
  102. edsl/language_models/unused/fake_openai_service.py +0 -60
  103. {edsl-0.1.54.dist-info → edsl-0.1.56.dist-info}/LICENSE +0 -0
  104. {edsl-0.1.54.dist-info → edsl-0.1.56.dist-info}/WHEEL +0 -0
  105. {edsl-0.1.54.dist-info → edsl-0.1.56.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,624 @@
1
+ from __future__ import annotations
2
+ from typing import Union, Literal, Optional, List, Any
3
+
4
+ from jinja2 import Template
5
+ from pydantic import BaseModel, Field
6
+
7
+ from .question_base import QuestionBase
8
+ from .descriptors import QuestionOptionsDescriptor
9
+ from .decorators import inject_exception
10
+ from .response_validator_abc import ResponseValidatorABC
11
+ from .question_multiple_choice import (
12
+ BaseMultipleChoiceResponse,
13
+ MultipleChoiceResponseValidator,
14
+ )
15
+ from pydantic import BaseModel, Field
16
+
17
+
18
+ # Create a custom response model for MultipleChoiceWithOther
19
+ class BaseMultipleChoiceWithOtherResponse(BaseModel):
20
+ """
21
+ Base model for multiple choice with other responses.
22
+
23
+ Attributes:
24
+ answer: The selected choice
25
+ other_text: Text for the "Other" option when selected
26
+ comment: Optional comment field
27
+ generated_tokens: Optional raw tokens generated by the model
28
+ """
29
+
30
+ answer: Any = Field(..., description="Selected choice")
31
+ other_text: Optional[str] = Field(
32
+ None, description="Specification when 'Other' is selected"
33
+ )
34
+ comment: Optional[str] = Field(None, description="Optional comment field")
35
+ generated_tokens: Optional[Any] = Field(None, description="Generated tokens")
36
+
37
+
38
+ def create_response_model_with_other(choices: List[str], permissive: bool = False):
39
+ """
40
+ Create a ChoiceWithOtherResponse model class with a predefined list of choices.
41
+
42
+ :param choices: A list of allowed values for the answer field.
43
+ :param permissive: If True, any value will be accepted as an answer.
44
+ :return: A new Pydantic model class.
45
+ """
46
+ choice_tuple = tuple(choices)
47
+
48
+ if not permissive:
49
+
50
+ class ChoiceWithOtherResponse(BaseMultipleChoiceWithOtherResponse):
51
+ """
52
+ A model for multiple choice with other responses with strict validation.
53
+ """
54
+
55
+ answer: Literal[choice_tuple] = Field(description="Selected choice")
56
+
57
+ model_config = {
58
+ "json_schema_extra": {"properties": {"answer": {"enum": choices}}}
59
+ }
60
+
61
+ else:
62
+
63
+ class ChoiceWithOtherResponse(BaseMultipleChoiceWithOtherResponse):
64
+ """
65
+ A model for multiple choice with other responses with permissive validation.
66
+ """
67
+
68
+ answer: Any = Field(
69
+ description=f"Selected choice (can be any value). Suggested choices are: {choices}"
70
+ )
71
+
72
+ model_config = {"title": "PermissiveChoiceWithOtherResponse"}
73
+
74
+ return ChoiceWithOtherResponse
75
+
76
+
77
+ class MultipleChoiceWithOtherResponseValidator(MultipleChoiceResponseValidator):
78
+ """
79
+ Validator for multiple choice with "other" responses.
80
+
81
+ This validator extends the MultipleChoiceResponseValidator to handle
82
+ the case where a user selects "Other" and provides a custom response.
83
+
84
+ Examples:
85
+ >>> from edsl.questions import QuestionMultipleChoiceWithOther
86
+ >>> q = QuestionMultipleChoiceWithOther(
87
+ ... question_name="feeling",
88
+ ... question_text="How are you feeling?",
89
+ ... question_options=["Good", "Bad", "Neutral"],
90
+ ... other_option_text="Other"
91
+ ... )
92
+ >>> validator = q.response_validator
93
+ >>> result = validator.validate({"answer": "Good"})
94
+ >>> sorted(result.keys())
95
+ ['answer', 'comment', 'generated_tokens', 'other_text']
96
+ >>> result["answer"]
97
+ 'Good'
98
+ >>> result = validator.validate({"answer": "Other", "other_text": "Excited"})
99
+ >>> sorted(result.keys())
100
+ ['answer', 'comment', 'generated_tokens', 'other_text']
101
+ >>> result["answer"]
102
+ 'Other'
103
+ >>> result["other_text"]
104
+ 'Excited'
105
+ >>> # Direct "Other: X" format
106
+ >>> result = validator.validate({"answer": "Other: Paris"})
107
+ >>> result["answer"]
108
+ 'Other'
109
+ >>> result["other_text"]
110
+ 'Paris'
111
+ """
112
+
113
+ required_params = ["question_options", "use_code", "other_option_text"]
114
+
115
+ def __init__(self, **kwargs):
116
+ """
117
+ Initialize the validator.
118
+
119
+ Ensures that "Other" is added to the question_options.
120
+ """
121
+ super().__init__(**kwargs)
122
+
123
+ # Make sure "Other" is always in the list of valid options
124
+ if "Other" not in self.question_options:
125
+ # Create a new list but don't modify the original reference
126
+ self.question_options = list(self.question_options) + ["Other"]
127
+
128
+ def validate(self, response_dict, verbose=False):
129
+ """
130
+ Validate the response according to the schema.
131
+
132
+ This overrides the parent validate method to handle the "Other" option specially.
133
+ If the answer is in the format "Other: X", it splits it into "Other" and the other_text.
134
+
135
+ Parameters:
136
+ response_dict: The response to validate
137
+ verbose: Whether to print debug information
138
+
139
+ Returns:
140
+ A validated response dict with "Other" as the answer and other_text field set
141
+ """
142
+ # Keep original for reference
143
+ orig_response_dict = response_dict.copy()
144
+
145
+ # Create a copy to avoid modifying the original that may be needed elsewhere
146
+ response_dict = response_dict.copy()
147
+ answer = str(response_dict.get("answer", ""))
148
+ other_text = None
149
+
150
+ # Check for "Other: X" format directly in the answer field
151
+ if ":" in answer:
152
+ parts = answer.split(":", 1)
153
+ if len(parts) == 2 and parts[0].strip().lower() == "other":
154
+ # Extract the "Other" part and the custom text
155
+ other_text = parts[1].strip()
156
+ if verbose:
157
+ print(f"Detected 'Other: X' format: {answer}")
158
+ print(f"Extracted other_text: {other_text}")
159
+
160
+ # Change answer to just "Other" for validation
161
+ response_dict["answer"] = "Other"
162
+
163
+ # Add the other_text to the response
164
+ if "other_text" not in response_dict or not response_dict["other_text"]:
165
+ response_dict["other_text"] = other_text
166
+
167
+ # Make sure "Other" is in the list of valid options for validation
168
+ question_options = list(self.question_options)
169
+ if "Other" not in question_options:
170
+ question_options.append("Other")
171
+
172
+ # Validate with the parent validator
173
+ validated_response = super().validate(response_dict, verbose)
174
+
175
+ # If we extracted other_text but it wasn't set in the validated response, set it now
176
+ if other_text and (
177
+ "other_text" not in validated_response
178
+ or not validated_response["other_text"]
179
+ ):
180
+ validated_response["other_text"] = other_text
181
+
182
+ return validated_response
183
+
184
+ def fix(self, response, verbose=False):
185
+ """
186
+ Attempt to fix an invalid multiple choice with other response.
187
+
188
+ Extends the MultipleChoiceResponseValidator fix method to also
189
+ handle "Other" responses with custom text.
190
+
191
+ Parameters:
192
+ response: The invalid response to fix
193
+ verbose: Whether to print debug information
194
+
195
+ Returns:
196
+ A fixed response dict if possible, otherwise the original response
197
+ """
198
+ # Check if this is an "Other" response with additional text
199
+ response_text = str(response.get("answer", ""))
200
+
201
+ # Handle "Other: X" format - extract "Other" and other_text separately
202
+ if ":" in response_text:
203
+ parts = response_text.split(":", 1)
204
+ if len(parts) == 2 and parts[0].strip().lower() == "other":
205
+ if verbose:
206
+ print(f"Identified as 'Other: X' format: {response_text}")
207
+
208
+ # Extract the custom text after "Other:"
209
+ other_text = parts[1].strip()
210
+
211
+ # Create response with "Other" and the other_text separately
212
+ proposed_data = {
213
+ "answer": "Other",
214
+ "other_text": other_text,
215
+ "comment": response.get("comment"),
216
+ "generated_tokens": response.get("generated_tokens"),
217
+ }
218
+
219
+ try:
220
+ # Validate the fixed answer
221
+ self.response_model.model_validate(proposed_data)
222
+ if verbose:
223
+ print(f"Fixed answer: Other with other_text={other_text}")
224
+ return proposed_data
225
+ except Exception as e:
226
+ if verbose:
227
+ print(f"Validation failed for 'Other: X' format: {e}")
228
+
229
+ # If the response is not in question_options but contains "Other",
230
+ # it might be an "Other" response with custom text in a different format
231
+ elif (
232
+ response_text not in self.question_options
233
+ and "other" in response_text.lower()
234
+ ):
235
+
236
+ # Try to extract the custom text after "Other"
237
+ # This is a fallback for other formats
238
+ after_other = response_text.lower().split("other", 1)
239
+ if len(after_other) > 1:
240
+ other_text = after_other[1].strip()
241
+ if other_text.startswith(":"):
242
+ other_text = other_text[1:].strip()
243
+
244
+ if verbose:
245
+ print(f"Extracted text after 'Other': {other_text}")
246
+
247
+ # Create response with "Other" and the other_text separately
248
+ proposed_data = {
249
+ "answer": "Other",
250
+ "other_text": other_text,
251
+ "comment": response.get("comment"),
252
+ "generated_tokens": response.get("generated_tokens"),
253
+ }
254
+
255
+ try:
256
+ # Validate the fixed answer
257
+ self.response_model.model_validate(proposed_data)
258
+ if verbose:
259
+ print(f"Fixed answer: Other with other_text={other_text}")
260
+ return proposed_data
261
+ except Exception as e:
262
+ if verbose:
263
+ print(f"Validation failed for extracted 'Other' text: {e}")
264
+
265
+ # If not an "Other" response or validation failed, try the parent class fix method
266
+ return super().fix(response, verbose)
267
+
268
+ valid_examples = [
269
+ (
270
+ {"answer": "Good"},
271
+ {
272
+ "question_options": ["Good", "Great", "OK", "Bad"],
273
+ "other_option_text": "Other",
274
+ },
275
+ ),
276
+ (
277
+ {"answer": "Other", "other_text": "Fantastic"},
278
+ {
279
+ "question_options": ["Good", "Great", "OK", "Bad"],
280
+ "other_option_text": "Other",
281
+ },
282
+ ),
283
+ ]
284
+
285
+ invalid_examples = [
286
+ (
287
+ {"answer": "Terrible"},
288
+ {
289
+ "question_options": ["Good", "Great", "OK", "Bad"],
290
+ "other_option_text": "Other",
291
+ },
292
+ "Value error, Permitted values are 'Good', 'Great', 'OK', 'Bad', 'Other'",
293
+ ),
294
+ (
295
+ {"answer": "Other", "other_text": ""},
296
+ {
297
+ "question_options": ["Good", "Great", "OK", "Bad"],
298
+ "other_option_text": "Other",
299
+ },
300
+ "When selecting 'Other', you must provide text in the other_text field",
301
+ ),
302
+ ]
303
+
304
+
305
+ class QuestionMultipleChoiceWithOther(QuestionBase):
306
+ """
307
+ A question that prompts the agent to select one option from a list of choices or specify "Other".
308
+
309
+ QuestionMultipleChoiceWithOther extends QuestionMultipleChoice to include an "Other" option
310
+ that allows the agent to provide a custom response when none of the predefined options
311
+ are suitable. This is especially useful for surveys and open-ended questions where
312
+ you want to capture responses that don't fit into predefined categories.
313
+
314
+ Key Features:
315
+ - All features of QuestionMultipleChoice
316
+ - Additional "Other" option with free-text field
317
+ - Customizable text for the "Other" option
318
+ - Validation ensures that when "Other" is selected, a text explanation is provided
319
+ - Supports "Other: X" format where X is the custom response
320
+
321
+ Technical Details:
322
+ - Uses extended Pydantic models for validation
323
+ - Preserves all functionality of QuestionMultipleChoice
324
+ - Adds 'other_text' field for custom responses
325
+ - Post-processes responses to handle "Other: X" format
326
+
327
+ Examples:
328
+ Basic usage:
329
+
330
+ ```python
331
+ q = QuestionMultipleChoiceWithOther(
332
+ question_name="preference",
333
+ question_text="Which color do you prefer?",
334
+ question_options=["Red", "Green", "Blue", "Yellow"]
335
+ )
336
+ ```
337
+
338
+ Custom "Other" option text:
339
+
340
+ ```python
341
+ q = QuestionMultipleChoiceWithOther(
342
+ question_name="preference",
343
+ question_text="Which color do you prefer?",
344
+ question_options=["Red", "Green", "Blue", "Yellow"],
345
+ other_option_text="Something else (please specify)"
346
+ )
347
+ ```
348
+
349
+ Handling "Other: X" format:
350
+
351
+ ```python
352
+ # If the model responds with "Other: Paris"
353
+ q = QuestionMultipleChoiceWithOther(
354
+ question_name="capital",
355
+ question_text="What is the capital of France?",
356
+ question_options=["London", "Berlin", "Madrid"]
357
+ )
358
+ result = q.by(model).run()
359
+ # result will have:
360
+ # answer.capital = "Other"
361
+ # other_text.capital_other_text = "Paris"
362
+ ```
363
+ """
364
+
365
+ question_type = "multiple_choice_with_other"
366
+ purpose = "When options are known but you want to allow for custom responses"
367
+ question_options: Union[list[str], list[list], list[float], list[int]] = (
368
+ QuestionOptionsDescriptor()
369
+ )
370
+ _response_model = None
371
+ response_validator_class = MultipleChoiceWithOtherResponseValidator
372
+
373
+ def post_process_result(self, result):
374
+ """
375
+ Post-process the result to handle "Other: X" format.
376
+
377
+ This method is called after the result is generated by the model
378
+ and before it's returned to the user. It checks if the answer
379
+ has the "Other: X" format and stores the full answer.
380
+
381
+ Parameters:
382
+ result: The result object to process
383
+
384
+ Returns:
385
+ The processed result object
386
+ """
387
+ # Process each result in the results list
388
+ for r in result:
389
+ if "answer" in r:
390
+ question_name = self.question_name
391
+ answer = r["answer"].get(question_name)
392
+
393
+ # Check if the answer has the "Other: X" format or custom "Something else: X" format
394
+ if isinstance(answer, str) and ":" in answer:
395
+ # Split into two parts
396
+ parts = answer.split(":", 1)
397
+ if len(parts) == 2:
398
+ # Check if the first part is either "Other" or matches other_option_text
399
+ prefix = parts[0].strip()
400
+ if prefix == "Other" or prefix == self.other_option_text:
401
+ # Extract the custom text
402
+ other_text = parts[1].strip()
403
+
404
+ # Set the answer to just "Other" (not the full "Other: X" format)
405
+ r["answer"][question_name] = "Other"
406
+
407
+ # Store the other_text in the result object as well as an attribute
408
+ other_text_key = f"{question_name}_other_text"
409
+ if "other_text" not in r:
410
+ r["other_text"] = {}
411
+ r["other_text"][other_text_key] = other_text
412
+
413
+ # Store the other_text as an attribute of the question instance
414
+ self._other_text = other_text
415
+
416
+ return result
417
+
418
+ @property
419
+ def other_text(self):
420
+ """
421
+ Get the text entered for the 'Other' option.
422
+
423
+ Returns:
424
+ The text entered for the 'Other' option, or None if not applicable
425
+ """
426
+ return getattr(self, "_other_text", None)
427
+
428
+ def __init__(
429
+ self,
430
+ question_name: str,
431
+ question_text: str,
432
+ question_options: Union[list[str], list[list], list[float], list[int]],
433
+ include_comment: bool = True,
434
+ use_code: bool = False,
435
+ answering_instructions: Optional[str] = None,
436
+ question_presentation: Optional[str] = None,
437
+ permissive: bool = False,
438
+ other_option_text: str = "Other",
439
+ other_instructions: Optional[str] = None,
440
+ ):
441
+ """
442
+ Initialize a new multiple choice with "Other" question.
443
+
444
+ Parameters
445
+ ----------
446
+ question_name : str
447
+ The name of the question, used as an identifier. Must be a valid Python variable name.
448
+
449
+ question_text : str
450
+ The actual text of the question to be asked.
451
+
452
+ question_options : Union[list[str], list[list], list[float], list[int]]
453
+ The list of options the agent can select from. The "Other" option will be
454
+ automatically added to this list.
455
+
456
+ include_comment : bool, default=True
457
+ Whether to include a comment field in the response.
458
+
459
+ use_code : bool, default=False
460
+ If True, the answer will be the index of the selected option (0-based) instead of
461
+ the option text itself.
462
+
463
+ answering_instructions : Optional[str], default=None
464
+ Custom instructions for how the model should answer the question.
465
+
466
+ question_presentation : Optional[str], default=None
467
+ Custom template for how the question is presented to the model.
468
+
469
+ permissive : bool, default=False
470
+ If True, the validator will accept answers that are not in the provided options list.
471
+
472
+ other_option_text : str, default="Other"
473
+ The text to use for the "Other" option. This will be added to the list of options.
474
+
475
+ other_instructions : Optional[str], default=None
476
+ Custom instructions for how to provide the "Other" response. If None,
477
+ default instructions will be used.
478
+
479
+ Examples
480
+ --------
481
+ >>> q = QuestionMultipleChoiceWithOther(
482
+ ... question_name="color_preference",
483
+ ... question_text="What is your favorite color?",
484
+ ... question_options=["Red", "Blue", "Green", "Yellow"],
485
+ ... other_option_text="Something else (please specify)"
486
+ ... )
487
+ """
488
+ # Initialize base attributes from QuestionBase
489
+ self.question_name = question_name
490
+ self.question_text = question_text
491
+ self.question_options = question_options
492
+
493
+ # Add other specific attributes
494
+ self._include_comment = include_comment
495
+ self.use_code = use_code
496
+ self.answering_instructions = answering_instructions
497
+ self.question_presentation = question_presentation
498
+ self.permissive = permissive
499
+ self.other_option_text = other_option_text
500
+ self.other_instructions = other_instructions
501
+
502
+ def create_response_model(self, replacement_dict: dict = None):
503
+ """Create a response model that allows for the 'Other' option."""
504
+ if replacement_dict is None:
505
+ replacement_dict = {}
506
+
507
+ # Create options list with "Other" option added
508
+ # Always use "Other" (not self.other_option_text) to ensure consistency
509
+ options = list(self.question_options)
510
+ if "Other" not in options:
511
+ options.append("Other")
512
+
513
+ if self.use_code:
514
+ return create_response_model_with_other(
515
+ list(range(len(options))), self.permissive
516
+ )
517
+ else:
518
+ return create_response_model_with_other(options, self.permissive)
519
+
520
+ @property
521
+ def question_html_content(self) -> str:
522
+ """Return the HTML version of the question with the Other option."""
523
+ if hasattr(self, "option_labels"):
524
+ option_labels = self.option_labels
525
+ else:
526
+ option_labels = {}
527
+
528
+ # Create a list of all options including the "Other" option
529
+ all_options = list(self.question_options) + [self.other_option_text]
530
+
531
+ question_html_content = Template(
532
+ """
533
+ {% for option in question_options %}
534
+ <div>
535
+ <input type="radio" id="{{ option }}" name="{{ question_name }}" value="{{ option }}">
536
+ <label for="{{ option }}">
537
+ {{ option }}
538
+ {% if option in option_labels %}
539
+ : {{ option_labels[option] }}
540
+ {% endif %}
541
+ </label>
542
+ </div>
543
+ {% endfor %}
544
+
545
+ <div>
546
+ <input type="radio" id="{{ other_option }}" name="{{ question_name }}" value="{{ other_option }}">
547
+ <label for="{{ other_option }}">{{ other_option }}</label>
548
+ <input type="text" id="{{ question_name }}_other_text" name="{{ question_name }}_other_text"
549
+ placeholder="Please specify" style="display:none;">
550
+ </div>
551
+
552
+ <script>
553
+ document.getElementById('{{ other_option }}').addEventListener('change', function() {
554
+ document.getElementById('{{ question_name }}_other_text').style.display = 'inline-block';
555
+ });
556
+
557
+ // Hide the text input when any other option is selected
558
+ {% for option in question_options %}
559
+ document.getElementById('{{ option }}').addEventListener('change', function() {
560
+ document.getElementById('{{ question_name }}_other_text').style.display = 'none';
561
+ });
562
+ {% endfor %}
563
+ </script>
564
+ """
565
+ ).render(
566
+ question_name=self.question_name,
567
+ question_options=self.question_options,
568
+ option_labels=option_labels,
569
+ other_option=self.other_option_text,
570
+ )
571
+ return question_html_content
572
+
573
+ def by(self, *models, **kwargs):
574
+ """
575
+ Chain this question with one or more models.
576
+
577
+ This method overrides the parent class's by method to add post-processing
578
+ for the 'Other: X' format in results.
579
+
580
+ Parameters:
581
+ *models: One or more models to chain with this question
582
+ **kwargs: Additional kwargs to pass to the parent class
583
+
584
+ Returns:
585
+ The chained object with post-processing added
586
+ """
587
+ # Call the parent class's by method first
588
+ chained = super().by(*models, **kwargs)
589
+
590
+ # Add a hook to post-process the results
591
+ original_run = chained.run
592
+
593
+ def run_with_post_processing(*args, **kwargs):
594
+ # Call the original run method
595
+ results = original_run(*args, **kwargs)
596
+
597
+ # Post-process the results to handle 'Other: X' format
598
+ return self.post_process_result(results)
599
+
600
+ # Replace the run method with our wrapped version
601
+ chained.run = run_with_post_processing
602
+
603
+ return chained
604
+
605
+ @classmethod
606
+ @inject_exception
607
+ def example(
608
+ cls, include_comment=False, use_code=False
609
+ ) -> QuestionMultipleChoiceWithOther:
610
+ """Return an example instance."""
611
+ return cls(
612
+ question_text="How are you?",
613
+ question_options=["Good", "Great", "OK", "Bad"],
614
+ question_name="how_feeling_with_other",
615
+ include_comment=include_comment,
616
+ use_code=use_code,
617
+ other_option_text="Other (please specify)",
618
+ )
619
+
620
+
621
+ if __name__ == "__main__":
622
+ import doctest
623
+
624
+ doctest.testmod(optionflags=doctest.ELLIPSIS)
@@ -95,7 +95,7 @@ class Question(metaclass=Meta):
95
95
 
96
96
  >>> from edsl import Question
97
97
  >>> Question.list_question_types()
98
- ['checkbox', 'dict', 'extract', 'free_text', 'functional', 'likert_five', 'linear_scale', 'list', 'matrix', 'multiple_choice', 'numerical', 'rank', 'top_k', 'yes_no']
98
+ ['checkbox', 'dict', 'extract', 'free_text', 'functional', 'likert_five', 'linear_scale', 'list', 'matrix', 'multiple_choice', 'multiple_choice_with_other', 'numerical', 'rank', 'top_k', 'yes_no']
99
99
  """
100
100
  return [
101
101
  q
@@ -154,6 +154,7 @@ def get_question_class(question_type):
154
154
 
155
155
  question_purpose = {
156
156
  "multiple_choice": "When options are known and limited",
157
+ "multiple_choice_with_other": "When options are known but you want to allow for custom responses",
157
158
  "free_text": "When options are unknown or unlimited",
158
159
  "checkbox": "When multiple options can be selected",
159
160
  "numerical": "When the answer is a single numerical value e.g., a float",
@@ -0,0 +1,15 @@
1
+ {# Answering Instructions #}
2
+ {% if use_code %}
3
+ Respond with the code corresponding to one of the options.
4
+ {% else %}
5
+ Respond with a string corresponding to one of the options.
6
+ {% endif %}
7
+
8
+ If none of the options match, respond with:
9
+ Other: [your specific response]
10
+
11
+ For example: "Other: Paris" if your answer is Paris.
12
+
13
+ {% if include_comment %}
14
+ After the answer, you can put a comment explaining why you chose that option on the next line.
15
+ {% endif %}
@@ -0,0 +1,17 @@
1
+ {# Question Presentation #}
2
+ {{question_text}}
3
+ {% if use_code %}
4
+ {%- for option in question_options %}
5
+ {{ loop.index0 }}: {{option}}
6
+ {% endfor %}
7
+ {% else %}
8
+ {% for option in question_options %}
9
+ {{option}}
10
+ {% endfor %}
11
+ {% endif %}
12
+ {% if other_instructions %}
13
+ {{ other_instructions }}
14
+ {% else %}
15
+ If none of the provided options apply, select "Other" and provide your specific response.
16
+ {% endif %}
17
+ Only 1 option may be selected.