edsl 0.1.50__py3-none-any.whl → 0.1.51__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/__version__.py +1 -1
- edsl/base/base_exception.py +2 -2
- edsl/buckets/bucket_collection.py +1 -1
- edsl/buckets/exceptions.py +32 -0
- edsl/buckets/token_bucket_api.py +26 -10
- edsl/caching/cache.py +5 -2
- edsl/caching/remote_cache_sync.py +5 -5
- edsl/caching/sql_dict.py +12 -11
- edsl/config/__init__.py +1 -1
- edsl/config/config_class.py +4 -2
- edsl/conversation/Conversation.py +7 -4
- edsl/conversation/car_buying.py +1 -3
- edsl/conversation/mug_negotiation.py +2 -6
- edsl/coop/__init__.py +11 -8
- edsl/coop/coop.py +13 -13
- edsl/coop/coop_functions.py +1 -1
- edsl/coop/ep_key_handling.py +1 -1
- edsl/coop/price_fetcher.py +2 -2
- edsl/coop/utils.py +2 -2
- edsl/dataset/dataset.py +144 -63
- edsl/dataset/dataset_operations_mixin.py +14 -6
- edsl/dataset/dataset_tree.py +3 -3
- edsl/dataset/display/table_renderers.py +6 -3
- edsl/dataset/file_exports.py +4 -4
- edsl/dataset/r/ggplot.py +3 -3
- edsl/inference_services/available_model_fetcher.py +2 -2
- edsl/inference_services/data_structures.py +5 -5
- edsl/inference_services/inference_service_abc.py +1 -1
- edsl/inference_services/inference_services_collection.py +1 -1
- edsl/inference_services/service_availability.py +3 -3
- edsl/inference_services/services/azure_ai.py +3 -3
- edsl/inference_services/services/google_service.py +1 -1
- edsl/inference_services/services/test_service.py +1 -1
- edsl/instructions/change_instruction.py +5 -4
- edsl/instructions/instruction.py +1 -0
- edsl/instructions/instruction_collection.py +5 -4
- edsl/instructions/instruction_handler.py +10 -8
- edsl/interviews/exception_tracking.py +1 -1
- edsl/interviews/interview.py +1 -1
- edsl/interviews/interview_status_dictionary.py +1 -1
- edsl/interviews/interview_task_manager.py +2 -2
- edsl/interviews/request_token_estimator.py +3 -2
- edsl/interviews/statistics.py +2 -2
- edsl/invigilators/invigilators.py +2 -2
- edsl/jobs/__init__.py +39 -2
- edsl/jobs/async_interview_runner.py +1 -1
- edsl/jobs/check_survey_scenario_compatibility.py +5 -5
- edsl/jobs/data_structures.py +2 -2
- edsl/jobs/jobs.py +2 -2
- edsl/jobs/jobs_checks.py +5 -5
- edsl/jobs/jobs_component_constructor.py +2 -2
- edsl/jobs/jobs_pricing_estimation.py +1 -1
- edsl/jobs/jobs_runner_asyncio.py +2 -2
- edsl/jobs/remote_inference.py +1 -1
- edsl/jobs/results_exceptions_handler.py +2 -2
- edsl/language_models/language_model.py +5 -1
- edsl/notebooks/__init__.py +24 -1
- edsl/notebooks/exceptions.py +82 -0
- edsl/notebooks/notebook.py +7 -3
- edsl/notebooks/notebook_to_latex.py +1 -1
- edsl/prompts/__init__.py +23 -2
- edsl/prompts/prompt.py +1 -1
- edsl/questions/__init__.py +4 -4
- edsl/questions/answer_validator_mixin.py +0 -5
- edsl/questions/compose_questions.py +2 -2
- edsl/questions/descriptors.py +1 -1
- edsl/questions/question_base.py +32 -3
- edsl/questions/question_base_prompts_mixin.py +4 -4
- edsl/questions/question_budget.py +503 -102
- edsl/questions/question_check_box.py +658 -156
- edsl/questions/question_dict.py +176 -2
- edsl/questions/question_extract.py +401 -61
- edsl/questions/question_free_text.py +77 -9
- edsl/questions/question_functional.py +118 -9
- edsl/questions/{derived/question_likert_five.py → question_likert_five.py} +2 -2
- edsl/questions/{derived/question_linear_scale.py → question_linear_scale.py} +3 -4
- edsl/questions/question_list.py +246 -26
- edsl/questions/question_matrix.py +586 -73
- edsl/questions/question_multiple_choice.py +213 -47
- edsl/questions/question_numerical.py +360 -29
- edsl/questions/question_rank.py +401 -124
- edsl/questions/question_registry.py +3 -3
- edsl/questions/{derived/question_top_k.py → question_top_k.py} +3 -3
- edsl/questions/{derived/question_yes_no.py → question_yes_no.py} +3 -4
- edsl/questions/register_questions_meta.py +2 -1
- edsl/questions/response_validator_abc.py +6 -2
- edsl/questions/response_validator_factory.py +10 -12
- edsl/results/report.py +1 -1
- edsl/results/result.py +7 -4
- edsl/results/results.py +471 -271
- edsl/results/results_selector.py +2 -2
- edsl/scenarios/construct_download_link.py +3 -3
- edsl/scenarios/scenario.py +1 -2
- edsl/scenarios/scenario_list.py +41 -23
- edsl/surveys/survey_css.py +3 -3
- edsl/surveys/survey_simulator.py +2 -1
- edsl/tasks/__init__.py +22 -2
- edsl/tasks/exceptions.py +72 -0
- edsl/tasks/task_history.py +3 -3
- edsl/tokens/__init__.py +27 -1
- edsl/tokens/exceptions.py +37 -0
- edsl/tokens/interview_token_usage.py +3 -2
- edsl/tokens/token_usage.py +4 -3
- {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/METADATA +1 -1
- {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/RECORD +108 -106
- edsl/questions/derived/__init__.py +0 -0
- {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/LICENSE +0 -0
- {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/WHEEL +0 -0
- {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/entry_points.txt +0 -0
@@ -1,8 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
from typing import Any, Optional, TYPE_CHECKING
|
3
3
|
|
4
|
+
import random
|
4
5
|
from jinja2 import Template
|
5
|
-
from pydantic import BaseModel, Field
|
6
|
+
from pydantic import BaseModel, Field, model_validator, ValidationError
|
6
7
|
from typing import List, Literal, Annotated
|
7
8
|
|
8
9
|
from .exceptions import QuestionAnswerValidationError
|
@@ -17,9 +18,45 @@ from .decorators import inject_exception
|
|
17
18
|
from .response_validator_abc import ResponseValidatorABC
|
18
19
|
|
19
20
|
if TYPE_CHECKING:
|
20
|
-
|
21
|
-
|
22
|
-
|
21
|
+
pass
|
22
|
+
|
23
|
+
|
24
|
+
class CheckboxResponse(BaseModel):
|
25
|
+
"""
|
26
|
+
Base Pydantic model for validating checkbox responses.
|
27
|
+
|
28
|
+
This model defines the structure and validation rules for responses to
|
29
|
+
checkbox questions, ensuring that selected options are properly formatted
|
30
|
+
as a list of choices.
|
31
|
+
|
32
|
+
Attributes:
|
33
|
+
answer: List of selected choices
|
34
|
+
comment: Optional comment provided with the answer
|
35
|
+
generated_tokens: Optional raw LLM output for token tracking
|
36
|
+
|
37
|
+
Examples:
|
38
|
+
>>> # Valid response with list of options
|
39
|
+
>>> response = CheckboxResponse(answer=[0, 1])
|
40
|
+
>>> response.answer
|
41
|
+
[0, 1]
|
42
|
+
|
43
|
+
>>> # Valid response with comment
|
44
|
+
>>> response = CheckboxResponse(answer=[1], comment="This is my choice")
|
45
|
+
>>> response.answer
|
46
|
+
[1]
|
47
|
+
>>> response.comment
|
48
|
+
'This is my choice'
|
49
|
+
|
50
|
+
>>> # Invalid non-list answer
|
51
|
+
>>> try:
|
52
|
+
... CheckboxResponse(answer=1)
|
53
|
+
... except Exception as e:
|
54
|
+
... print("Validation error occurred")
|
55
|
+
Validation error occurred
|
56
|
+
"""
|
57
|
+
answer: List[Any]
|
58
|
+
comment: Optional[str] = None
|
59
|
+
generated_tokens: Optional[Any] = None
|
23
60
|
|
24
61
|
|
25
62
|
def create_checkbox_response_model(
|
@@ -30,40 +67,218 @@ def create_checkbox_response_model(
|
|
30
67
|
):
|
31
68
|
"""
|
32
69
|
Dynamically create a CheckboxResponse model with a predefined list of choices.
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
70
|
+
|
71
|
+
This function creates a customized Pydantic model for checkbox questions that
|
72
|
+
validates both the format of the response and any constraints on selection count.
|
73
|
+
|
74
|
+
Args:
|
75
|
+
choices: A list of allowed values for the answer field
|
76
|
+
min_selections: Optional minimum number of selections required
|
77
|
+
max_selections: Optional maximum number of selections allowed
|
78
|
+
permissive: If True, constraints are not enforced
|
79
|
+
|
80
|
+
Returns:
|
81
|
+
A new Pydantic model class with appropriate validation
|
82
|
+
|
83
|
+
Examples:
|
84
|
+
>>> # Create model with constraints
|
85
|
+
>>> choices = [0, 1, 2, 3]
|
86
|
+
>>> ConstrainedModel = create_checkbox_response_model(
|
87
|
+
... choices=choices,
|
88
|
+
... min_selections=1,
|
89
|
+
... max_selections=2
|
90
|
+
... )
|
91
|
+
|
92
|
+
>>> # Valid response within constraints
|
93
|
+
>>> response = ConstrainedModel(answer=[0, 1])
|
94
|
+
>>> response.answer
|
95
|
+
[0, 1]
|
96
|
+
|
97
|
+
>>> # Too few selections fails validation
|
98
|
+
>>> try:
|
99
|
+
... ConstrainedModel(answer=[])
|
100
|
+
... except Exception as e:
|
101
|
+
... "at least 1" in str(e)
|
102
|
+
True
|
103
|
+
|
104
|
+
>>> # Too many selections fails validation
|
105
|
+
>>> try:
|
106
|
+
... ConstrainedModel(answer=[0, 1, 2])
|
107
|
+
... except Exception as e:
|
108
|
+
... "at most 2" in str(e)
|
109
|
+
True
|
110
|
+
|
111
|
+
>>> # Invalid choice fails validation
|
112
|
+
>>> try:
|
113
|
+
... ConstrainedModel(answer=[4])
|
114
|
+
... except Exception as e:
|
115
|
+
... any(x in str(e) for x in ["Invalid choice", "not a valid enumeration member", "validation error"])
|
116
|
+
True
|
117
|
+
|
118
|
+
>>> # Permissive model ignores constraints
|
119
|
+
>>> PermissiveModel = create_checkbox_response_model(
|
120
|
+
... choices=choices,
|
121
|
+
... min_selections=1,
|
122
|
+
... max_selections=2,
|
123
|
+
... permissive=True
|
124
|
+
... )
|
125
|
+
>>> response = PermissiveModel(answer=[0, 1, 2])
|
126
|
+
>>> len(response.answer)
|
127
|
+
3
|
37
128
|
"""
|
38
129
|
# Convert the choices list to a tuple for use with Literal
|
39
130
|
choice_tuple = tuple(choices)
|
40
131
|
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
132
|
+
if permissive:
|
133
|
+
# For permissive mode, we still validate the choice values but ignore count constraints
|
134
|
+
class PermissiveCheckboxResponse(CheckboxResponse):
|
135
|
+
"""Checkbox response model with choices validation but no count constraints."""
|
136
|
+
|
137
|
+
answer: Annotated[
|
138
|
+
List[Literal[choice_tuple]],
|
139
|
+
Field(description="List of selected choices"),
|
140
|
+
]
|
141
|
+
|
142
|
+
@model_validator(mode='after')
|
143
|
+
def validate_choices(self):
|
144
|
+
"""Validate that each selected choice is valid."""
|
145
|
+
for choice in self.answer:
|
146
|
+
if choice not in choices:
|
147
|
+
validation_error = ValidationError.from_exception_data(
|
148
|
+
title='CheckboxResponse',
|
149
|
+
line_errors=[{
|
150
|
+
'type': 'value_error',
|
151
|
+
'loc': ('answer',),
|
152
|
+
'msg': f'Invalid choice: {choice}. Must be one of: {choices}',
|
153
|
+
'input': choice,
|
154
|
+
'ctx': {'error': 'Invalid choice'}
|
155
|
+
}]
|
156
|
+
)
|
157
|
+
raise QuestionAnswerValidationError(
|
158
|
+
message=f"Invalid choice: {choice}. Must be one of: {choices}",
|
159
|
+
data=self.model_dump(),
|
160
|
+
model=self.__class__,
|
161
|
+
pydantic_error=validation_error
|
162
|
+
)
|
163
|
+
return self
|
164
|
+
|
165
|
+
return PermissiveCheckboxResponse
|
166
|
+
else:
|
167
|
+
# For non-permissive mode, enforce both choice values and count constraints
|
168
|
+
class ConstrainedCheckboxResponse(CheckboxResponse):
|
169
|
+
"""Checkbox response model with both choice and count constraints."""
|
170
|
+
|
171
|
+
answer: Annotated[
|
172
|
+
List[Literal[choice_tuple]],
|
173
|
+
Field(description="List of selected choices"),
|
174
|
+
]
|
175
|
+
|
176
|
+
@model_validator(mode='after')
|
177
|
+
def validate_selection_count(self):
|
178
|
+
"""Validate that the number of selections meets constraints."""
|
179
|
+
if min_selections is not None and len(self.answer) < min_selections:
|
180
|
+
validation_error = ValidationError.from_exception_data(
|
181
|
+
title='CheckboxResponse',
|
182
|
+
line_errors=[{
|
183
|
+
'type': 'value_error',
|
184
|
+
'loc': ('answer',),
|
185
|
+
'msg': f'Must select at least {min_selections} option(s)',
|
186
|
+
'input': self.answer,
|
187
|
+
'ctx': {'error': 'Too few selections'}
|
188
|
+
}]
|
189
|
+
)
|
190
|
+
raise QuestionAnswerValidationError(
|
191
|
+
message=f"Must select at least {min_selections} option(s), got {len(self.answer)}",
|
192
|
+
data=self.model_dump(),
|
193
|
+
model=self.__class__,
|
194
|
+
pydantic_error=validation_error
|
195
|
+
)
|
196
|
+
|
197
|
+
if max_selections is not None and len(self.answer) > max_selections:
|
198
|
+
validation_error = ValidationError.from_exception_data(
|
199
|
+
title='CheckboxResponse',
|
200
|
+
line_errors=[{
|
201
|
+
'type': 'value_error',
|
202
|
+
'loc': ('answer',),
|
203
|
+
'msg': f'Must select at most {max_selections} option(s)',
|
204
|
+
'input': self.answer,
|
205
|
+
'ctx': {'error': 'Too many selections'}
|
206
|
+
}]
|
207
|
+
)
|
208
|
+
raise QuestionAnswerValidationError(
|
209
|
+
message=f"Must select at most {max_selections} option(s), got {len(self.answer)}",
|
210
|
+
data=self.model_dump(),
|
211
|
+
model=self.__class__,
|
212
|
+
pydantic_error=validation_error
|
213
|
+
)
|
214
|
+
|
215
|
+
# Also validate that each choice is valid
|
216
|
+
for choice in self.answer:
|
217
|
+
if choice not in choices:
|
218
|
+
validation_error = ValidationError.from_exception_data(
|
219
|
+
title='CheckboxResponse',
|
220
|
+
line_errors=[{
|
221
|
+
'type': 'value_error',
|
222
|
+
'loc': ('answer',),
|
223
|
+
'msg': f'Invalid choice: {choice}. Must be one of: {choices}',
|
224
|
+
'input': choice,
|
225
|
+
'ctx': {'error': 'Invalid choice'}
|
226
|
+
}]
|
227
|
+
)
|
228
|
+
raise QuestionAnswerValidationError(
|
229
|
+
message=f"Invalid choice: {choice}. Must be one of: {choices}",
|
230
|
+
data=self.model_dump(),
|
231
|
+
model=self.__class__,
|
232
|
+
pydantic_error=validation_error
|
233
|
+
)
|
234
|
+
|
235
|
+
return self
|
236
|
+
|
237
|
+
return ConstrainedCheckboxResponse
|
64
238
|
|
65
239
|
|
66
240
|
class CheckBoxResponseValidator(ResponseValidatorABC):
|
241
|
+
"""
|
242
|
+
Validator for checkbox question responses.
|
243
|
+
|
244
|
+
This class implements the validation and fixing logic for checkbox responses.
|
245
|
+
It ensures that responses contain valid selections from the available options
|
246
|
+
and that the number of selections meets any constraints.
|
247
|
+
|
248
|
+
Attributes:
|
249
|
+
required_params: List of required parameters for validation.
|
250
|
+
valid_examples: Examples of valid responses for testing.
|
251
|
+
invalid_examples: Examples of invalid responses for testing.
|
252
|
+
|
253
|
+
Examples:
|
254
|
+
>>> from edsl import QuestionCheckBox
|
255
|
+
>>> q = QuestionCheckBox.example()
|
256
|
+
>>> validator = q.response_validator
|
257
|
+
|
258
|
+
>>> # Fix string to list
|
259
|
+
>>> response = {"answer": 1}
|
260
|
+
>>> fixed = validator.fix(response)
|
261
|
+
>>> isinstance(fixed["answer"], list)
|
262
|
+
True
|
263
|
+
|
264
|
+
>>> # Extract selections from text
|
265
|
+
>>> response = {"generated_tokens": "I choose options 0 and 2"}
|
266
|
+
>>> fixed = validator.fix(response)
|
267
|
+
>>> sorted(fixed["answer"])
|
268
|
+
[0, 2]
|
269
|
+
|
270
|
+
>>> # Fix comma-separated list
|
271
|
+
>>> response = {"generated_tokens": "0, 1, 3"}
|
272
|
+
>>> fixed = validator.fix(response)
|
273
|
+
>>> sorted(fixed["answer"])
|
274
|
+
[0, 1, 3]
|
275
|
+
|
276
|
+
>>> # Preserve comments when fixing
|
277
|
+
>>> response = {"answer": 1, "comment": "My explanation"}
|
278
|
+
>>> fixed = validator.fix(response)
|
279
|
+
>>> "comment" in fixed and fixed["comment"] == "My explanation"
|
280
|
+
True
|
281
|
+
"""
|
67
282
|
required_params = [
|
68
283
|
"question_options",
|
69
284
|
"min_selections",
|
@@ -80,12 +295,12 @@ class CheckBoxResponseValidator(ResponseValidatorABC):
|
|
80
295
|
(
|
81
296
|
{"answer": [-1]},
|
82
297
|
{"question_options": ["Good", "Great", "OK", "Bad"]},
|
83
|
-
"
|
298
|
+
"Invalid choice",
|
84
299
|
),
|
85
300
|
(
|
86
301
|
{"answer": 1},
|
87
302
|
{"question_options": ["Good", "Great", "OK", "Bad"]},
|
88
|
-
"
|
303
|
+
"value is not a valid list",
|
89
304
|
),
|
90
305
|
(
|
91
306
|
{"answer": [1, 2, 3, 4]},
|
@@ -94,84 +309,217 @@ class CheckBoxResponseValidator(ResponseValidatorABC):
|
|
94
309
|
"min_selections": 1,
|
95
310
|
"max_selections": 2,
|
96
311
|
},
|
97
|
-
"
|
312
|
+
"Must select at most 2",
|
98
313
|
),
|
99
314
|
]
|
100
315
|
|
101
316
|
def fix(self, response, verbose=False):
|
317
|
+
"""
|
318
|
+
Fix common issues in checkbox responses.
|
319
|
+
|
320
|
+
This method attempts to extract valid selections from responses with
|
321
|
+
format issues. It can handle:
|
322
|
+
1. Single values that should be lists
|
323
|
+
2. Comma-separated strings in answer field or generated_tokens
|
324
|
+
3. Finding option indices mentioned in text
|
325
|
+
|
326
|
+
Args:
|
327
|
+
response: The response dictionary to fix
|
328
|
+
verbose: If True, print information about the fixing process
|
329
|
+
|
330
|
+
Returns:
|
331
|
+
A fixed version of the response dictionary with a valid list of selections
|
332
|
+
|
333
|
+
Notes:
|
334
|
+
- First tries to convert to a list if the answer is not already a list
|
335
|
+
- Then tries to parse comma-separated values from answer or generated_tokens
|
336
|
+
- Finally tries to find option indices mentioned in the text
|
337
|
+
- Preserves any comment in the original response
|
338
|
+
"""
|
102
339
|
if verbose:
|
103
340
|
print("Invalid response of QuestionCheckBox was: ", response)
|
104
|
-
|
105
|
-
if
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
341
|
+
|
342
|
+
# Check if answer exists and is a comma-separated string (common LLM output format)
|
343
|
+
if "answer" in response and isinstance(response["answer"], str) and "," in response["answer"]:
|
344
|
+
if verbose:
|
345
|
+
print(f"Parsing comma-separated answer string: {response['answer']}")
|
346
|
+
|
347
|
+
# Split by commas and strip whitespace
|
348
|
+
proposed_list = response["answer"].split(",")
|
349
|
+
proposed_list = [item.strip() for item in proposed_list]
|
350
|
+
|
351
|
+
# Try to convert to integers if use_code is True
|
352
|
+
if self.use_code:
|
353
|
+
try:
|
354
|
+
proposed_list = [int(i) for i in proposed_list]
|
355
|
+
except ValueError:
|
356
|
+
# If we can't convert to integers, try to match values to indices
|
357
|
+
if verbose:
|
358
|
+
print("Could not convert comma-separated values to integers, trying to match options")
|
359
|
+
|
360
|
+
# Try to match option text values to their indices
|
361
|
+
index_map = {}
|
362
|
+
for i, option in enumerate(self.question_options):
|
363
|
+
index_map[option.lower().strip()] = i
|
364
|
+
|
365
|
+
converted_list = []
|
366
|
+
for item in proposed_list:
|
367
|
+
item_lower = item.lower().strip()
|
368
|
+
if item_lower in index_map:
|
369
|
+
converted_list.append(index_map[item_lower])
|
370
|
+
|
371
|
+
if converted_list:
|
372
|
+
proposed_list = converted_list
|
373
|
+
|
374
|
+
if verbose:
|
375
|
+
print("Proposed solution from comma separation is: ", proposed_list)
|
376
|
+
|
130
377
|
proposed_data = {
|
131
378
|
"answer": proposed_list,
|
132
|
-
"
|
379
|
+
"comment": response.get("comment"),
|
380
|
+
"generated_tokens": response.get("generated_tokens"),
|
133
381
|
}
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
print("Now seeing if responses show up in the answer")
|
146
|
-
matches = []
|
147
|
-
for index, option in enumerate(self.question_options):
|
148
|
-
if self.use_code:
|
149
|
-
if str(index) in response_text:
|
150
|
-
matches.append(index)
|
151
|
-
else:
|
152
|
-
if option in response_text:
|
153
|
-
matches.append(index)
|
154
|
-
proposed_data = {
|
155
|
-
"answer": matches,
|
156
|
-
"comment": response.get("comment", None),
|
157
|
-
"generated_tokens": response.get("generated_tokens", None),
|
158
|
-
}
|
159
|
-
try:
|
160
|
-
self.response_model(**proposed_data)
|
161
|
-
return proposed_data
|
162
|
-
except Exception as e:
|
382
|
+
|
383
|
+
# Try validating with the proposed solution
|
384
|
+
try:
|
385
|
+
validated = self._base_validate(proposed_data)
|
386
|
+
return validated.model_dump()
|
387
|
+
except Exception as e:
|
388
|
+
if verbose:
|
389
|
+
print(f"Comma-separated solution invalid: {e}")
|
390
|
+
|
391
|
+
# If answer exists but is not a list, convert it to a list
|
392
|
+
elif "answer" in response and not isinstance(response["answer"], list):
|
163
393
|
if verbose:
|
164
|
-
print(f"
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
170
|
-
|
394
|
+
print(f"Converting non-list answer {response['answer']} to a list")
|
395
|
+
answer_value = response["answer"]
|
396
|
+
response = {**response, "answer": [answer_value]}
|
397
|
+
|
398
|
+
# Try validating the fixed response
|
399
|
+
try:
|
400
|
+
validated = self._base_validate(response)
|
401
|
+
return validated.model_dump()
|
402
|
+
except Exception:
|
403
|
+
if verbose:
|
404
|
+
print("Converting to list didn't fix the issue")
|
405
|
+
|
406
|
+
# Try parsing from generated_tokens if present
|
407
|
+
response_text = response.get("generated_tokens")
|
408
|
+
if response_text and isinstance(response_text, str):
|
409
|
+
# Try comma-separated list first
|
410
|
+
if "," in response_text:
|
411
|
+
proposed_list = response_text.split(",")
|
412
|
+
proposed_list = [item.strip() for item in proposed_list]
|
413
|
+
|
414
|
+
if self.use_code:
|
415
|
+
try:
|
416
|
+
proposed_list = [int(i) for i in proposed_list]
|
417
|
+
except ValueError:
|
418
|
+
# If we can't convert to integers, try to match values to indices
|
419
|
+
if verbose:
|
420
|
+
print("Could not convert comma-separated values to integers, trying to match options")
|
421
|
+
|
422
|
+
# Try to match option text values to their indices
|
423
|
+
index_map = {}
|
424
|
+
for i, option in enumerate(self.question_options):
|
425
|
+
index_map[option.lower().strip()] = i
|
426
|
+
|
427
|
+
converted_list = []
|
428
|
+
for item in proposed_list:
|
429
|
+
item_lower = item.lower().strip()
|
430
|
+
if item_lower in index_map:
|
431
|
+
converted_list.append(index_map[item_lower])
|
432
|
+
|
433
|
+
if converted_list:
|
434
|
+
proposed_list = converted_list
|
435
|
+
|
436
|
+
if verbose:
|
437
|
+
print("Proposed solution from comma separation is: ", proposed_list)
|
438
|
+
|
439
|
+
proposed_data = {
|
440
|
+
"answer": proposed_list,
|
441
|
+
"comment": response.get("comment"),
|
442
|
+
"generated_tokens": response.get("generated_tokens"),
|
443
|
+
}
|
444
|
+
|
445
|
+
# Try validating with the proposed solution
|
446
|
+
try:
|
447
|
+
validated = self._base_validate(proposed_data)
|
448
|
+
return validated.model_dump()
|
449
|
+
except Exception as e:
|
450
|
+
if verbose:
|
451
|
+
print(f"Comma-separated solution invalid: {e}")
|
452
|
+
|
453
|
+
# Try finding option indices mentioned in the text
|
454
|
+
matches = []
|
455
|
+
for index, option in enumerate(self.question_options):
|
456
|
+
if self.use_code:
|
457
|
+
if str(index) in response_text:
|
458
|
+
matches.append(index)
|
459
|
+
else:
|
460
|
+
if option in response_text:
|
461
|
+
matches.append(option)
|
462
|
+
|
463
|
+
if matches:
|
464
|
+
if verbose:
|
465
|
+
print(f"Found options mentioned in text: {matches}")
|
466
|
+
|
467
|
+
proposed_data = {
|
468
|
+
"answer": matches,
|
469
|
+
"comment": response.get("comment"),
|
470
|
+
"generated_tokens": response.get("generated_tokens"),
|
471
|
+
}
|
472
|
+
|
473
|
+
# Try validating with the proposed solution
|
474
|
+
try:
|
475
|
+
validated = self._base_validate(proposed_data)
|
476
|
+
return validated.model_dump()
|
477
|
+
except Exception as e:
|
478
|
+
if verbose:
|
479
|
+
print(f"Text matching solution invalid: {e}")
|
480
|
+
|
481
|
+
# If nothing worked, return the original response
|
482
|
+
return response
|
171
483
|
|
172
484
|
|
173
485
|
class QuestionCheckBox(QuestionBase):
|
174
|
-
"""
|
486
|
+
"""
|
487
|
+
A question that prompts the agent to select multiple options from a list.
|
488
|
+
|
489
|
+
QuestionCheckBox allows agents to select one or more items from a predefined
|
490
|
+
list of options. It's useful for "select all that apply" scenarios, multi-select
|
491
|
+
preferences, or any question where multiple valid selections are possible.
|
492
|
+
|
493
|
+
Attributes:
|
494
|
+
question_type (str): Identifier for this question type, set to "checkbox".
|
495
|
+
purpose (str): Brief description of when to use this question type.
|
496
|
+
question_options: List of available options to select from.
|
497
|
+
min_selections: Optional minimum number of selections required.
|
498
|
+
max_selections: Optional maximum number of selections allowed.
|
499
|
+
_response_model: Initially None, set by create_response_model().
|
500
|
+
response_validator_class: Class used to validate and fix responses.
|
501
|
+
|
502
|
+
Examples:
|
503
|
+
>>> # Basic creation works
|
504
|
+
>>> q = QuestionCheckBox.example()
|
505
|
+
>>> q.question_type
|
506
|
+
'checkbox'
|
507
|
+
|
508
|
+
>>> # Create preferences question with selection constraints
|
509
|
+
>>> q = QuestionCheckBox(
|
510
|
+
... question_name="favorite_fruits",
|
511
|
+
... question_text="Which fruits do you like?",
|
512
|
+
... question_options=["Apple", "Banana", "Cherry", "Durian", "Elderberry"],
|
513
|
+
... min_selections=1,
|
514
|
+
... max_selections=3
|
515
|
+
... )
|
516
|
+
>>> q.question_options
|
517
|
+
['Apple', 'Banana', 'Cherry', 'Durian', 'Elderberry']
|
518
|
+
>>> q.min_selections
|
519
|
+
1
|
520
|
+
>>> q.max_selections
|
521
|
+
3
|
522
|
+
"""
|
175
523
|
|
176
524
|
question_type = "checkbox"
|
177
525
|
purpose = "When options are known and limited"
|
@@ -195,13 +543,41 @@ class QuestionCheckBox(QuestionBase):
|
|
195
543
|
answering_instructions: Optional[str] = None,
|
196
544
|
permissive: bool = False,
|
197
545
|
):
|
198
|
-
"""
|
199
|
-
|
200
|
-
|
201
|
-
:
|
202
|
-
|
203
|
-
|
204
|
-
|
546
|
+
"""
|
547
|
+
Initialize a new checkbox question.
|
548
|
+
|
549
|
+
Args:
|
550
|
+
question_name: Identifier for the question, used in results and templates.
|
551
|
+
Must be a valid Python variable name.
|
552
|
+
question_text: The actual text of the question to be asked.
|
553
|
+
question_options: List of options the agent can select from.
|
554
|
+
min_selections: Optional minimum number of options that must be selected.
|
555
|
+
max_selections: Optional maximum number of options that can be selected.
|
556
|
+
include_comment: Whether to allow comments with the answer.
|
557
|
+
use_code: If True, use indices (0,1,2...) instead of option text values.
|
558
|
+
question_presentation: Optional custom presentation template.
|
559
|
+
answering_instructions: Optional additional instructions.
|
560
|
+
permissive: If True, ignore selection count constraints during validation.
|
561
|
+
|
562
|
+
Examples:
|
563
|
+
>>> q = QuestionCheckBox(
|
564
|
+
... question_name="symptoms",
|
565
|
+
... question_text="Select all symptoms you are experiencing:",
|
566
|
+
... question_options=["Fever", "Cough", "Headache", "Fatigue"],
|
567
|
+
... min_selections=1
|
568
|
+
... )
|
569
|
+
>>> q.question_name
|
570
|
+
'symptoms'
|
571
|
+
|
572
|
+
>>> # Question with both min and max
|
573
|
+
>>> q = QuestionCheckBox(
|
574
|
+
... question_name="pizza_toppings",
|
575
|
+
... question_text="Choose 2-4 toppings for your pizza:",
|
576
|
+
... question_options=["Cheese", "Pepperoni", "Mushroom", "Onion",
|
577
|
+
... "Sausage", "Bacon", "Pineapple"],
|
578
|
+
... min_selections=2,
|
579
|
+
... max_selections=4
|
580
|
+
... )
|
205
581
|
"""
|
206
582
|
self.question_name = question_name
|
207
583
|
self.question_text = question_text
|
@@ -217,18 +593,35 @@ class QuestionCheckBox(QuestionBase):
|
|
217
593
|
self.answering_instructions = answering_instructions
|
218
594
|
|
219
595
|
def create_response_model(self):
|
596
|
+
"""
|
597
|
+
Create a response model with the appropriate constraints.
|
598
|
+
|
599
|
+
This method creates a Pydantic model customized with the options and
|
600
|
+
selection count constraints specified for this question instance.
|
601
|
+
|
602
|
+
Returns:
|
603
|
+
A Pydantic model class tailored to this question's constraints.
|
604
|
+
|
605
|
+
Examples:
|
606
|
+
>>> q = QuestionCheckBox.example()
|
607
|
+
>>> model = q.create_response_model()
|
608
|
+
>>> model(answer=[0, 2]) # Select first and third options
|
609
|
+
ConstrainedCheckboxResponse(answer=[0, 2], comment=None, generated_tokens=None)
|
610
|
+
"""
|
220
611
|
if not self._use_code:
|
612
|
+
# Use option text values as valid choices
|
221
613
|
return create_checkbox_response_model(
|
222
614
|
self.question_options,
|
223
615
|
min_selections=self.min_selections,
|
224
|
-
max_selections=self.max_selections,
|
616
|
+
max_selections=self.max_selections,
|
225
617
|
permissive=self.permissive,
|
226
618
|
)
|
227
619
|
else:
|
620
|
+
# Use option indices (0, 1, 2...) as valid choices
|
228
621
|
return create_checkbox_response_model(
|
229
622
|
list(range(len(self.question_options))),
|
230
623
|
min_selections=self.min_selections,
|
231
|
-
max_selections=self.max_selections,
|
624
|
+
max_selections=self.max_selections,
|
232
625
|
permissive=self.permissive,
|
233
626
|
)
|
234
627
|
|
@@ -236,10 +629,27 @@ class QuestionCheckBox(QuestionBase):
|
|
236
629
|
self, answer_codes, scenario: "Scenario" = None
|
237
630
|
):
|
238
631
|
"""
|
239
|
-
Translate the answer
|
240
|
-
|
241
|
-
For
|
242
|
-
|
632
|
+
Translate the answer codes to the actual answer text.
|
633
|
+
|
634
|
+
For checkbox questions with use_code=True, the agent responds with
|
635
|
+
option indices (e.g., [0, 1]) which need to be translated to their
|
636
|
+
corresponding option text values (e.g., ["Option A", "Option B"]).
|
637
|
+
|
638
|
+
Args:
|
639
|
+
answer_codes: List of selected option indices or values
|
640
|
+
scenario: Optional scenario with variables for template rendering
|
641
|
+
|
642
|
+
Returns:
|
643
|
+
List of selected option texts
|
644
|
+
|
645
|
+
Examples:
|
646
|
+
>>> q = QuestionCheckBox(
|
647
|
+
... question_name="example",
|
648
|
+
... question_text="Select options:",
|
649
|
+
... question_options=["A", "B", "C"]
|
650
|
+
... )
|
651
|
+
>>> q._translate_answer_code_to_answer([0, 2])
|
652
|
+
['A', 'C']
|
243
653
|
"""
|
244
654
|
scenario = scenario or Scenario()
|
245
655
|
translated_options = [
|
@@ -253,38 +663,73 @@ class QuestionCheckBox(QuestionBase):
|
|
253
663
|
translated_codes.append(answer_code)
|
254
664
|
return translated_codes
|
255
665
|
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
275
|
-
|
276
|
-
|
277
|
-
|
278
|
-
|
279
|
-
|
666
|
+
def _simulate_answer(self, human_readable=True):
|
667
|
+
"""
|
668
|
+
Simulate a valid answer for debugging purposes.
|
669
|
+
|
670
|
+
This method generates a random valid answer for the checkbox question,
|
671
|
+
useful for testing and demonstrations.
|
672
|
+
|
673
|
+
Args:
|
674
|
+
human_readable: If True, return option text values; if False, return indices
|
675
|
+
|
676
|
+
Returns:
|
677
|
+
A dictionary with a valid random answer
|
678
|
+
|
679
|
+
Examples:
|
680
|
+
>>> q = QuestionCheckBox.example()
|
681
|
+
>>> answer = q._simulate_answer(human_readable=False)
|
682
|
+
>>> len(answer["answer"]) >= q.min_selections
|
683
|
+
True
|
684
|
+
>>> len(answer["answer"]) <= q.max_selections
|
685
|
+
True
|
686
|
+
"""
|
687
|
+
from edsl.utilities.utilities import random_string
|
688
|
+
|
689
|
+
min_sel = self.min_selections or 1
|
690
|
+
max_sel = self.max_selections or len(self.question_options)
|
691
|
+
# Ensure we don't try to select more options than available
|
692
|
+
max_sel = min(max_sel, len(self.question_options))
|
693
|
+
min_sel = min(min_sel, max_sel)
|
694
|
+
|
695
|
+
num_selections = random.randint(min_sel, max_sel)
|
696
|
+
|
697
|
+
if human_readable:
|
698
|
+
# Select a random number of options from self.question_options
|
699
|
+
selected_options = random.sample(self.question_options, num_selections)
|
700
|
+
answer = {
|
701
|
+
"answer": selected_options,
|
702
|
+
"comment": random_string(),
|
703
|
+
}
|
704
|
+
else:
|
705
|
+
# Select a random number of indices from the range of self.question_options
|
706
|
+
selected_indices = random.sample(
|
707
|
+
range(len(self.question_options)), num_selections
|
708
|
+
)
|
709
|
+
answer = {
|
710
|
+
"answer": selected_indices,
|
711
|
+
"comment": random_string(),
|
712
|
+
}
|
713
|
+
return answer
|
280
714
|
|
281
715
|
@property
|
282
716
|
def question_html_content(self) -> str:
|
717
|
+
"""
|
718
|
+
Generate HTML content for rendering the question in web interfaces.
|
719
|
+
|
720
|
+
This property generates HTML markup for the question when it needs to be
|
721
|
+
displayed in web interfaces or HTML contexts. For a checkbox question,
|
722
|
+
this is a set of checkbox input elements, one for each option.
|
723
|
+
|
724
|
+
Returns:
|
725
|
+
str: HTML markup for rendering the question.
|
726
|
+
"""
|
283
727
|
instructions = ""
|
284
728
|
if self.min_selections is not None:
|
285
729
|
instructions += f"Select at least {self.min_selections} option(s). "
|
286
730
|
if self.max_selections is not None:
|
287
731
|
instructions += f"Select at most {self.max_selections} option(s)."
|
732
|
+
|
288
733
|
question_html_content = Template(
|
289
734
|
"""
|
290
735
|
<p>{{ instructions }}</p>
|
@@ -308,7 +753,30 @@ class QuestionCheckBox(QuestionBase):
|
|
308
753
|
@classmethod
|
309
754
|
@inject_exception
|
310
755
|
def example(cls, include_comment=False, use_code=True) -> QuestionCheckBox:
|
311
|
-
"""
|
756
|
+
"""
|
757
|
+
Create an example instance of a checkbox question.
|
758
|
+
|
759
|
+
This class method creates a predefined example of a checkbox question
|
760
|
+
for demonstration, testing, and documentation purposes.
|
761
|
+
|
762
|
+
Args:
|
763
|
+
include_comment: Whether to include a comment field with the answer.
|
764
|
+
use_code: Whether to use indices (True) or values (False) for answer codes.
|
765
|
+
|
766
|
+
Returns:
|
767
|
+
QuestionCheckBox: An example checkbox question.
|
768
|
+
|
769
|
+
Examples:
|
770
|
+
>>> q = QuestionCheckBox.example()
|
771
|
+
>>> q.question_name
|
772
|
+
'never_eat'
|
773
|
+
>>> len(q.question_options)
|
774
|
+
5
|
775
|
+
>>> q.min_selections
|
776
|
+
2
|
777
|
+
>>> q.max_selections
|
778
|
+
5
|
779
|
+
"""
|
312
780
|
return cls(
|
313
781
|
question_name="never_eat",
|
314
782
|
question_text="Which of the following foods would you eat if you had to?",
|
@@ -327,31 +795,65 @@ class QuestionCheckBox(QuestionBase):
|
|
327
795
|
|
328
796
|
|
329
797
|
def main():
|
330
|
-
"""
|
331
|
-
|
332
|
-
|
798
|
+
"""
|
799
|
+
Demonstrate the functionality of the QuestionCheckBox class.
|
800
|
+
|
801
|
+
This function creates an example checkbox question and demonstrates its
|
802
|
+
key features including validation, serialization, and answer simulation.
|
803
|
+
It's primarily intended for testing and development purposes.
|
804
|
+
|
805
|
+
Note:
|
806
|
+
This function will be executed when the module is run directly,
|
807
|
+
but not when imported.
|
808
|
+
"""
|
809
|
+
print("Creating a QuestionCheckBox example...")
|
333
810
|
q = QuestionCheckBox.example()
|
334
|
-
q.question_text
|
335
|
-
q.
|
336
|
-
q.
|
337
|
-
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
343
|
-
q.
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
811
|
+
print(f"Question text: {q.question_text}")
|
812
|
+
print(f"Question name: {q.question_name}")
|
813
|
+
print(f"Question options: {q.question_options}")
|
814
|
+
print(f"Min selections: {q.min_selections}")
|
815
|
+
print(f"Max selections: {q.max_selections}")
|
816
|
+
|
817
|
+
# Validate an answer
|
818
|
+
print("\nValidating an answer...")
|
819
|
+
valid_answer = {"answer": [1, 2], "comment": "I like these foods"}
|
820
|
+
validated = q._validate_answer(valid_answer)
|
821
|
+
print(f"Validated answer: {validated}")
|
822
|
+
|
823
|
+
# Translate answer codes
|
824
|
+
print("\nTranslating answer codes...")
|
825
|
+
translated = q._translate_answer_code_to_answer([1, 2])
|
826
|
+
print(f"Translated answer: {translated}")
|
827
|
+
|
828
|
+
# Simulate answers
|
829
|
+
print("\nSimulating answers...")
|
830
|
+
simulated_human = q._simulate_answer(human_readable=True)
|
831
|
+
print(f"Simulated human-readable answer: {simulated_human}")
|
832
|
+
|
833
|
+
simulated_codes = q._simulate_answer(human_readable=False)
|
834
|
+
print(f"Simulated code answer: {simulated_codes}")
|
835
|
+
|
836
|
+
# Validate simulated answer
|
837
|
+
validated_simulated = q._validate_answer(simulated_codes)
|
838
|
+
print(f"Validated simulated answer: {validated_simulated}")
|
839
|
+
|
840
|
+
# Serialization demonstration
|
841
|
+
print("\nTesting serialization...")
|
842
|
+
serialized = q.to_dict()
|
843
|
+
print(f"Serialized question (keys): {list(serialized.keys())}")
|
844
|
+
deserialized = QuestionBase.from_dict(serialized)
|
845
|
+
print(f"Deserialization successful: {deserialized.question_text == q.question_text}")
|
846
|
+
|
847
|
+
# Run doctests
|
848
|
+
print("\nRunning doctests...")
|
349
849
|
import doctest
|
350
|
-
|
351
850
|
doctest.testmod(optionflags=doctest.ELLIPSIS)
|
851
|
+
print("Doctests completed")
|
352
852
|
|
353
853
|
|
354
854
|
if __name__ == "__main__":
|
355
855
|
import doctest
|
356
|
-
|
357
856
|
doctest.testmod(optionflags=doctest.ELLIPSIS)
|
857
|
+
|
858
|
+
# Uncomment to run demonstration
|
859
|
+
# main()
|