edsl 0.1.54__py3-none-any.whl → 0.1.55__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.
- edsl/__init__.py +8 -1
- edsl/__init__original.py +134 -0
- edsl/__version__.py +1 -1
- edsl/agents/agent.py +29 -0
- edsl/agents/agent_list.py +36 -1
- edsl/base/base_class.py +281 -151
- edsl/buckets/__init__.py +8 -3
- edsl/buckets/bucket_collection.py +9 -3
- edsl/buckets/model_buckets.py +4 -2
- edsl/buckets/token_bucket.py +2 -2
- edsl/buckets/token_bucket_client.py +5 -3
- edsl/caching/cache.py +131 -62
- edsl/caching/cache_entry.py +70 -58
- edsl/caching/sql_dict.py +17 -0
- edsl/cli.py +99 -0
- edsl/config/config_class.py +16 -0
- edsl/conversation/__init__.py +31 -0
- edsl/coop/coop.py +276 -242
- edsl/coop/coop_jobs_objects.py +59 -0
- edsl/coop/coop_objects.py +29 -0
- edsl/coop/coop_regular_objects.py +26 -0
- edsl/coop/utils.py +24 -19
- edsl/dataset/dataset.py +338 -101
- edsl/db_list/sqlite_list.py +349 -0
- edsl/inference_services/__init__.py +40 -5
- edsl/inference_services/exceptions.py +11 -0
- edsl/inference_services/services/anthropic_service.py +5 -2
- edsl/inference_services/services/aws_bedrock.py +6 -2
- edsl/inference_services/services/azure_ai.py +6 -2
- edsl/inference_services/services/google_service.py +3 -2
- edsl/inference_services/services/mistral_ai_service.py +6 -2
- edsl/inference_services/services/open_ai_service.py +6 -2
- edsl/inference_services/services/perplexity_service.py +6 -2
- edsl/inference_services/services/test_service.py +94 -5
- edsl/interviews/answering_function.py +167 -59
- edsl/interviews/interview.py +124 -72
- edsl/interviews/interview_task_manager.py +10 -0
- edsl/invigilators/invigilators.py +9 -0
- edsl/jobs/async_interview_runner.py +146 -104
- edsl/jobs/data_structures.py +6 -4
- edsl/jobs/decorators.py +61 -0
- edsl/jobs/fetch_invigilator.py +61 -18
- edsl/jobs/html_table_job_logger.py +14 -2
- edsl/jobs/jobs.py +180 -104
- edsl/jobs/jobs_component_constructor.py +2 -2
- edsl/jobs/jobs_interview_constructor.py +2 -0
- edsl/jobs/jobs_remote_inference_logger.py +4 -0
- edsl/jobs/jobs_runner_status.py +30 -25
- edsl/jobs/progress_bar_manager.py +79 -0
- edsl/jobs/remote_inference.py +35 -1
- edsl/key_management/key_lookup_builder.py +6 -1
- edsl/language_models/language_model.py +86 -6
- edsl/language_models/model.py +10 -3
- edsl/language_models/price_manager.py +45 -75
- edsl/language_models/registry.py +5 -0
- edsl/notebooks/notebook.py +77 -10
- edsl/questions/VALIDATION_README.md +134 -0
- edsl/questions/__init__.py +24 -1
- edsl/questions/exceptions.py +21 -0
- edsl/questions/question_dict.py +201 -16
- edsl/questions/question_multiple_choice_with_other.py +624 -0
- edsl/questions/question_registry.py +2 -1
- edsl/questions/templates/multiple_choice_with_other/__init__.py +0 -0
- edsl/questions/templates/multiple_choice_with_other/answering_instructions.jinja +15 -0
- edsl/questions/templates/multiple_choice_with_other/question_presentation.jinja +17 -0
- edsl/questions/validation_analysis.py +185 -0
- edsl/questions/validation_cli.py +131 -0
- edsl/questions/validation_html_report.py +404 -0
- edsl/questions/validation_logger.py +136 -0
- edsl/results/result.py +63 -16
- edsl/results/results.py +702 -171
- edsl/scenarios/construct_download_link.py +16 -3
- edsl/scenarios/directory_scanner.py +226 -226
- edsl/scenarios/file_methods.py +5 -0
- edsl/scenarios/file_store.py +117 -6
- edsl/scenarios/handlers/__init__.py +5 -1
- edsl/scenarios/handlers/mp4_file_store.py +104 -0
- edsl/scenarios/handlers/webm_file_store.py +104 -0
- edsl/scenarios/scenario.py +120 -101
- edsl/scenarios/scenario_list.py +800 -727
- edsl/scenarios/scenario_list_gc_test.py +146 -0
- edsl/scenarios/scenario_list_memory_test.py +214 -0
- edsl/scenarios/scenario_list_source_refactor.md +35 -0
- edsl/scenarios/scenario_selector.py +5 -4
- edsl/scenarios/scenario_source.py +1990 -0
- edsl/scenarios/tests/test_scenario_list_sources.py +52 -0
- edsl/surveys/survey.py +22 -0
- edsl/tasks/__init__.py +4 -2
- edsl/tasks/task_history.py +198 -36
- edsl/tests/scenarios/test_ScenarioSource.py +51 -0
- edsl/tests/scenarios/test_scenario_list_sources.py +51 -0
- edsl/utilities/__init__.py +2 -1
- edsl/utilities/decorators.py +121 -0
- edsl/utilities/memory_debugger.py +1010 -0
- {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/METADATA +51 -76
- {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/RECORD +99 -75
- edsl/jobs/jobs_runner_asyncio.py +0 -281
- edsl/language_models/unused/fake_openai_service.py +0 -60
- {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/LICENSE +0 -0
- {edsl-0.1.54.dist-info → edsl-0.1.55.dist-info}/WHEEL +0 -0
- {edsl-0.1.54.dist-info → edsl-0.1.55.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",
|
File without changes
|
@@ -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.
|