edsl 0.1.50__py3-none-any.whl → 0.1.52__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 +45 -34
- 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 +9 -5
- edsl/conversation/car_buying.py +1 -3
- edsl/conversation/mug_negotiation.py +2 -6
- edsl/coop/__init__.py +11 -8
- edsl/coop/coop.py +15 -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/answering_function.py +20 -21
- edsl/interviews/exception_tracking.py +3 -2
- edsl/interviews/interview.py +1 -1
- edsl/interviews/interview_status_dictionary.py +1 -1
- edsl/interviews/interview_task_manager.py +7 -4
- edsl/interviews/request_token_estimator.py +3 -2
- edsl/interviews/statistics.py +2 -2
- edsl/invigilators/invigilators.py +34 -6
- 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/html_table_job_logger.py +494 -257
- 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/jobs_status_enums.py +1 -0
- edsl/jobs/remote_inference.py +47 -13
- edsl/jobs/results_exceptions_handler.py +2 -2
- edsl/language_models/language_model.py +151 -145
- 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 +500 -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 +48 -11
- edsl/templates/error_reporting/base.html +37 -4
- edsl/templates/error_reporting/exceptions_table.html +105 -33
- edsl/templates/error_reporting/interview_details.html +130 -126
- edsl/templates/error_reporting/overview.html +21 -25
- edsl/templates/error_reporting/report.css +215 -46
- edsl/templates/error_reporting/report.js +122 -20
- 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.52.dist-info}/METADATA +1 -1
- {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/RECORD +118 -116
- edsl/questions/derived/__init__.py +0 -0
- {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/LICENSE +0 -0
- {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/WHEEL +0 -0
- {edsl-0.1.50.dist-info → edsl-0.1.52.dist-info}/entry_points.txt +0 -0
@@ -1,68 +1,354 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
-
from typing import Optional, List
|
2
|
+
from typing import Optional, List, Any
|
3
|
+
import re
|
3
4
|
|
4
|
-
from pydantic import Field,
|
5
|
+
from pydantic import BaseModel, Field, model_validator, ValidationError
|
5
6
|
|
6
7
|
from .question_base import QuestionBase
|
7
8
|
from .descriptors import IntegerDescriptor, QuestionOptionsDescriptor
|
8
9
|
from .response_validator_abc import ResponseValidatorABC
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
10
|
+
from .exceptions import QuestionAnswerValidationError
|
11
|
+
|
12
|
+
|
13
|
+
class BudgetResponse(BaseModel):
|
14
|
+
"""
|
15
|
+
Pydantic model for validating budget allocation responses.
|
16
|
+
|
17
|
+
This model defines the structure and validation rules for responses to
|
18
|
+
budget questions, ensuring responses contain a list of numerical values
|
19
|
+
representing allocations.
|
20
|
+
|
21
|
+
Attributes:
|
22
|
+
answer: List of float values representing budget allocation
|
23
|
+
comment: Optional comment provided with the answer
|
24
|
+
generated_tokens: Optional raw LLM output for token tracking
|
25
|
+
|
26
|
+
Examples:
|
27
|
+
>>> # Valid response with just answer
|
28
|
+
>>> response = BudgetResponse(answer=[25, 25, 25, 25])
|
29
|
+
>>> response.answer
|
30
|
+
[25.0, 25.0, 25.0, 25.0]
|
31
|
+
|
32
|
+
>>> # Valid response with comment
|
33
|
+
>>> response = BudgetResponse(answer=[40, 30, 20, 10], comment="My allocation")
|
34
|
+
>>> response.answer
|
35
|
+
[40.0, 30.0, 20.0, 10.0]
|
36
|
+
>>> response.comment
|
37
|
+
'My allocation'
|
38
|
+
|
39
|
+
>>> # Invalid non-list answer
|
40
|
+
>>> try:
|
41
|
+
... BudgetResponse(answer="not a list")
|
42
|
+
... except Exception as e:
|
43
|
+
... print("Validation error occurred")
|
44
|
+
Validation error occurred
|
45
|
+
"""
|
46
|
+
answer: List[float]
|
47
|
+
comment: Optional[str] = None
|
48
|
+
generated_tokens: Optional[Any] = None
|
25
49
|
|
26
50
|
|
27
51
|
def create_budget_model(
|
28
52
|
budget_sum: float, permissive: bool, question_options: List[str]
|
29
53
|
):
|
30
|
-
|
54
|
+
"""
|
55
|
+
Create a constrained budget response model with appropriate validation.
|
56
|
+
|
57
|
+
This function creates a Pydantic model for budget allocation responses with
|
58
|
+
constraints on the number of values and total sum.
|
59
|
+
|
60
|
+
Args:
|
61
|
+
budget_sum: The total budget that must be allocated
|
62
|
+
permissive: If True, allow allocations less than budget_sum
|
63
|
+
question_options: List of options to allocate budget to
|
64
|
+
|
65
|
+
Returns:
|
66
|
+
A Pydantic model class tailored to the question's constraints
|
67
|
+
|
68
|
+
Examples:
|
69
|
+
>>> # Create model with constraints
|
70
|
+
>>> options = ["Pizza", "Ice Cream", "Burgers", "Salad"]
|
71
|
+
>>> ConstrainedModel = create_budget_model(100, False, options)
|
72
|
+
>>> response = ConstrainedModel(answer=[25, 25, 25, 25])
|
73
|
+
>>> response.answer
|
74
|
+
[25.0, 25.0, 25.0, 25.0]
|
75
|
+
|
76
|
+
>>> # Test count constraint
|
77
|
+
>>> try:
|
78
|
+
... ConstrainedModel(answer=[25, 25, 25])
|
79
|
+
... except Exception as e:
|
80
|
+
... "List should have at least 4 items" in str(e)
|
81
|
+
True
|
82
|
+
|
83
|
+
>>> # Test negative values constraint
|
84
|
+
>>> try:
|
85
|
+
... ConstrainedModel(answer=[50, 50, 25, -25])
|
86
|
+
... except Exception as e:
|
87
|
+
... "All values must be non-negative" in str(e)
|
88
|
+
True
|
89
|
+
|
90
|
+
>>> # Test sum constraint
|
91
|
+
>>> try:
|
92
|
+
... ConstrainedModel(answer=[30, 30, 30, 30])
|
93
|
+
... except Exception as e:
|
94
|
+
... "Sum of numbers must equal 100" in str(e)
|
95
|
+
True
|
96
|
+
|
97
|
+
>>> # Permissive mode allows lower sums
|
98
|
+
>>> PermissiveModel = create_budget_model(100, True, options)
|
99
|
+
>>> response = PermissiveModel(answer=[20, 20, 20, 20])
|
100
|
+
>>> response.answer
|
101
|
+
[20.0, 20.0, 20.0, 20.0]
|
102
|
+
|
103
|
+
>>> # But still prevents exceeding the budget
|
104
|
+
>>> try:
|
105
|
+
... PermissiveModel(answer=[30, 30, 30, 30])
|
106
|
+
... except Exception as e:
|
107
|
+
... "Sum of numbers cannot exceed 100" in str(e)
|
108
|
+
True
|
109
|
+
"""
|
110
|
+
class ConstrainedBudgetResponse(BudgetResponse):
|
111
|
+
"""Budget response model with added constraints on count and total."""
|
112
|
+
|
31
113
|
answer: List[float] = Field(
|
32
114
|
...,
|
33
115
|
description="List of non-negative numbers representing budget allocation",
|
34
|
-
|
35
|
-
|
116
|
+
min_length=len(question_options),
|
117
|
+
max_length=len(question_options),
|
36
118
|
)
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
if len(
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
119
|
+
|
120
|
+
@model_validator(mode='after')
|
121
|
+
def validate_budget_constraints(self):
|
122
|
+
"""Validate that the budget allocation meets all constraints."""
|
123
|
+
# Check length constraint
|
124
|
+
if len(self.answer) != len(question_options):
|
125
|
+
validation_error = ValidationError.from_exception_data(
|
126
|
+
title='ConstrainedBudgetResponse',
|
127
|
+
line_errors=[{
|
128
|
+
'type': 'value_error',
|
129
|
+
'loc': ('answer',),
|
130
|
+
'msg': f'Must provide {len(question_options)} values',
|
131
|
+
'input': self.answer,
|
132
|
+
'ctx': {'error': 'Invalid item count'}
|
133
|
+
}]
|
134
|
+
)
|
135
|
+
raise QuestionAnswerValidationError(
|
136
|
+
message=f"Must provide {len(question_options)} values",
|
137
|
+
data=self.model_dump(),
|
138
|
+
model=self.__class__,
|
139
|
+
pydantic_error=validation_error
|
140
|
+
)
|
141
|
+
|
142
|
+
# Check for negative values
|
143
|
+
if any(x < 0 for x in self.answer):
|
144
|
+
validation_error = ValidationError.from_exception_data(
|
145
|
+
title='ConstrainedBudgetResponse',
|
146
|
+
line_errors=[{
|
147
|
+
'type': 'value_error',
|
148
|
+
'loc': ('answer',),
|
149
|
+
'msg': 'All values must be non-negative',
|
150
|
+
'input': self.answer,
|
151
|
+
'ctx': {'error': 'Negative values'}
|
152
|
+
}]
|
153
|
+
)
|
154
|
+
raise QuestionAnswerValidationError(
|
155
|
+
message="All values must be non-negative",
|
156
|
+
data=self.model_dump(),
|
157
|
+
model=self.__class__,
|
158
|
+
pydantic_error=validation_error
|
159
|
+
)
|
160
|
+
|
161
|
+
# Check budget sum constraints
|
162
|
+
total = sum(self.answer)
|
49
163
|
if not permissive and total != budget_sum:
|
50
|
-
|
51
|
-
|
164
|
+
validation_error = ValidationError.from_exception_data(
|
165
|
+
title='ConstrainedBudgetResponse',
|
166
|
+
line_errors=[{
|
167
|
+
'type': 'value_error',
|
168
|
+
'loc': ('answer',),
|
169
|
+
'msg': f'Sum of numbers must equal {budget_sum}',
|
170
|
+
'input': self.answer,
|
171
|
+
'ctx': {'error': 'Invalid sum', 'total': total, 'expected': budget_sum}
|
172
|
+
}]
|
173
|
+
)
|
174
|
+
raise QuestionAnswerValidationError(
|
175
|
+
message=f"Sum of numbers must equal {budget_sum} (got {total})",
|
176
|
+
data=self.model_dump(),
|
177
|
+
model=self.__class__,
|
178
|
+
pydantic_error=validation_error
|
179
|
+
)
|
52
180
|
elif permissive and total > budget_sum:
|
53
|
-
|
54
|
-
|
55
|
-
|
181
|
+
validation_error = ValidationError.from_exception_data(
|
182
|
+
title='ConstrainedBudgetResponse',
|
183
|
+
line_errors=[{
|
184
|
+
'type': 'value_error',
|
185
|
+
'loc': ('answer',),
|
186
|
+
'msg': f'Sum of numbers cannot exceed {budget_sum}',
|
187
|
+
'input': self.answer,
|
188
|
+
'ctx': {'error': 'Sum too large', 'total': total, 'max': budget_sum}
|
189
|
+
}]
|
190
|
+
)
|
191
|
+
raise QuestionAnswerValidationError(
|
192
|
+
message=f"Sum of numbers cannot exceed {budget_sum} (got {total})",
|
193
|
+
data=self.model_dump(),
|
194
|
+
model=self.__class__,
|
195
|
+
pydantic_error=validation_error
|
196
|
+
)
|
197
|
+
|
198
|
+
return self
|
199
|
+
|
200
|
+
return ConstrainedBudgetResponse
|
56
201
|
|
57
|
-
class Config:
|
58
|
-
extra = "forbid"
|
59
202
|
|
60
|
-
|
203
|
+
class BudgetResponseValidator(ResponseValidatorABC):
|
204
|
+
"""
|
205
|
+
Validator for budget question responses.
|
206
|
+
|
207
|
+
This class implements the validation and fixing logic for budget allocation
|
208
|
+
responses, ensuring they meet the requirements for item count, non-negative values,
|
209
|
+
and budget total.
|
210
|
+
|
211
|
+
Attributes:
|
212
|
+
required_params: List of required parameters for validation
|
213
|
+
valid_examples: Examples of valid responses for testing
|
214
|
+
invalid_examples: Examples of invalid responses for testing
|
215
|
+
|
216
|
+
Examples:
|
217
|
+
>>> from edsl import QuestionBudget
|
218
|
+
>>> q = QuestionBudget.example()
|
219
|
+
>>> validator = q.response_validator
|
220
|
+
|
221
|
+
>>> # Fix string to list
|
222
|
+
>>> response = {"answer": "25, 25, 25, 25"}
|
223
|
+
>>> fixed = validator.fix(response)
|
224
|
+
>>> list(fixed.keys())
|
225
|
+
['answer']
|
226
|
+
|
227
|
+
>>> # Preserve comments when fixing
|
228
|
+
>>> response = {"answer": "25, 25, 25, 25", "comment": "My allocation"}
|
229
|
+
>>> fixed = validator.fix(response)
|
230
|
+
>>> "comment" in fixed
|
231
|
+
True
|
232
|
+
"""
|
233
|
+
required_params = ["budget_sum", "question_options", "permissive"]
|
234
|
+
|
235
|
+
valid_examples = [
|
236
|
+
({"answer": [25, 25, 25, 25]}, {"budget_sum": 100, "question_options": ["A", "B", "C", "D"], "permissive": False}),
|
237
|
+
({"answer": [20, 20, 20, 20]}, {"budget_sum": 100, "question_options": ["A", "B", "C", "D"], "permissive": True}),
|
238
|
+
]
|
239
|
+
|
240
|
+
invalid_examples = [
|
241
|
+
({"answer": [30, 30, 30, 30]}, {"budget_sum": 100, "question_options": ["A", "B", "C", "D"], "permissive": False}, "Sum must equal budget"),
|
242
|
+
({"answer": [25, 25, 25]}, {"budget_sum": 100, "question_options": ["A", "B", "C", "D"], "permissive": False}, "Must provide correct number of values"),
|
243
|
+
({"answer": [25, 25, -10, 60]}, {"budget_sum": 100, "question_options": ["A", "B", "C", "D"], "permissive": False}, "Values must be non-negative"),
|
244
|
+
]
|
245
|
+
|
246
|
+
def fix(self, response, verbose=False):
|
247
|
+
"""
|
248
|
+
Fix common issues in budget responses.
|
249
|
+
|
250
|
+
This method attempts to convert various response formats into a valid
|
251
|
+
budget allocation list, handling string inputs, comma-separated values,
|
252
|
+
and dictionary formats.
|
253
|
+
|
254
|
+
Args:
|
255
|
+
response: The response dictionary to fix
|
256
|
+
verbose: If True, print information about the fixing process
|
257
|
+
|
258
|
+
Returns:
|
259
|
+
A fixed version of the response dictionary
|
260
|
+
|
261
|
+
Notes:
|
262
|
+
- Handles string inputs by splitting on commas
|
263
|
+
- Converts dictionaries to lists
|
264
|
+
- Preserves any comment in the original response
|
265
|
+
"""
|
266
|
+
if verbose:
|
267
|
+
print(f"Fixing budget response: {response}")
|
268
|
+
|
269
|
+
# Start with a default answer
|
270
|
+
fixed_answer = []
|
271
|
+
|
272
|
+
# Extract the answer field or use generated_tokens as fallback
|
273
|
+
answer = response.get("answer")
|
274
|
+
if answer is None:
|
275
|
+
answer = response.get("generated_tokens", "")
|
276
|
+
|
277
|
+
# Strategy 1: Handle string inputs with comma separators
|
278
|
+
if isinstance(answer, str):
|
279
|
+
# Split by commas and convert to floats
|
280
|
+
try:
|
281
|
+
fixed_answer = [float(x.strip()) for x in answer.split(",") if x.strip()]
|
282
|
+
except ValueError:
|
283
|
+
# If conversion fails, try to extract numbers using regex
|
284
|
+
pattern = r"\b\d+(?:\.\d+)?\b"
|
285
|
+
matches = re.findall(pattern, answer.replace(",", " "))
|
286
|
+
if matches:
|
287
|
+
fixed_answer = [float(match) for match in matches]
|
288
|
+
|
289
|
+
# Strategy 2: Handle dictionary inputs (convert to list)
|
290
|
+
elif isinstance(answer, dict):
|
291
|
+
# If keys are numeric or string indices, convert to a list
|
292
|
+
try:
|
293
|
+
# Sort by key (if keys are integers or can be converted to integers)
|
294
|
+
sorted_keys = sorted(answer.keys(), key=lambda k: int(k) if isinstance(k, str) and k.isdigit() else k)
|
295
|
+
fixed_answer = [float(answer[k]) for k in sorted_keys]
|
296
|
+
except (ValueError, TypeError):
|
297
|
+
# If we can't sort, just take values in whatever order
|
298
|
+
fixed_answer = [float(v) for v in answer.values()]
|
299
|
+
|
300
|
+
# Strategy 3: If it's already a list but might contain non-numeric values
|
301
|
+
elif isinstance(answer, list):
|
302
|
+
try:
|
303
|
+
fixed_answer = [float(x) for x in answer]
|
304
|
+
except (ValueError, TypeError):
|
305
|
+
pass
|
306
|
+
|
307
|
+
if verbose:
|
308
|
+
print(f"Fixed answer: {fixed_answer}")
|
309
|
+
|
310
|
+
# Construct the response
|
311
|
+
fixed_response = {"answer": fixed_answer}
|
312
|
+
|
313
|
+
# Preserve comment if present
|
314
|
+
if "comment" in response:
|
315
|
+
fixed_response["comment"] = response["comment"]
|
316
|
+
|
317
|
+
return fixed_response
|
318
|
+
|
319
|
+
def _check_constraints(self, pydantic_edsl_answer: BaseModel):
|
320
|
+
"""Method preserved for compatibility, constraints handled in Pydantic model."""
|
321
|
+
pass
|
61
322
|
|
62
323
|
|
63
324
|
class QuestionBudget(QuestionBase):
|
64
|
-
"""
|
65
|
-
|
325
|
+
"""
|
326
|
+
A question that prompts the agent to allocate a budget among options.
|
327
|
+
|
328
|
+
QuestionBudget is designed for scenarios where a fixed amount needs to be
|
329
|
+
distributed across multiple categories or options. It's useful for allocation
|
330
|
+
questions, spending priorities, resource distribution, and similar scenarios.
|
331
|
+
|
332
|
+
Attributes:
|
333
|
+
question_type: Identifier for this question type, set to "budget"
|
334
|
+
budget_sum: The total amount to be allocated
|
335
|
+
question_options: List of options to allocate the budget among
|
336
|
+
_response_model: Initially None, set by create_response_model()
|
337
|
+
response_validator_class: Class used to validate and fix responses
|
338
|
+
|
339
|
+
Examples:
|
340
|
+
>>> # Create budget allocation question
|
341
|
+
>>> q = QuestionBudget(
|
342
|
+
... question_name="spending",
|
343
|
+
... question_text="How would you allocate $100?",
|
344
|
+
... question_options=["Food", "Housing", "Entertainment", "Savings"],
|
345
|
+
... budget_sum=100
|
346
|
+
... )
|
347
|
+
>>> q.budget_sum
|
348
|
+
100
|
349
|
+
>>> len(q.question_options)
|
350
|
+
4
|
351
|
+
"""
|
66
352
|
question_type = "budget"
|
67
353
|
budget_sum: int = IntegerDescriptor(none_allowed=False)
|
68
354
|
question_options: list[str] = QuestionOptionsDescriptor(q_budget=True)
|
@@ -80,12 +366,28 @@ class QuestionBudget(QuestionBase):
|
|
80
366
|
answering_instructions: Optional[str] = None,
|
81
367
|
permissive: bool = False,
|
82
368
|
):
|
83
|
-
"""
|
84
|
-
|
85
|
-
|
86
|
-
:
|
87
|
-
|
88
|
-
|
369
|
+
"""
|
370
|
+
Initialize a new budget allocation question.
|
371
|
+
|
372
|
+
Args:
|
373
|
+
question_name: Identifier for the question, used in results and templates
|
374
|
+
question_text: The actual text of the question to be asked
|
375
|
+
question_options: The options for allocation of the budget sum
|
376
|
+
budget_sum: The total amount of the budget to be allocated
|
377
|
+
include_comment: Whether to allow comments with the answer
|
378
|
+
question_presentation: Optional custom presentation template
|
379
|
+
answering_instructions: Optional additional instructions
|
380
|
+
permissive: If True, allow allocations less than budget_sum
|
381
|
+
|
382
|
+
Examples:
|
383
|
+
>>> q = QuestionBudget(
|
384
|
+
... question_name="investment",
|
385
|
+
... question_text="How would you invest $1000?",
|
386
|
+
... question_options=["Stocks", "Bonds", "Real Estate", "Cash"],
|
387
|
+
... budget_sum=1000
|
388
|
+
... )
|
389
|
+
>>> q.question_name
|
390
|
+
'investment'
|
89
391
|
"""
|
90
392
|
self.question_name = question_name
|
91
393
|
self.question_text = question_text
|
@@ -97,6 +399,21 @@ class QuestionBudget(QuestionBase):
|
|
97
399
|
self.include_comment = include_comment
|
98
400
|
|
99
401
|
def create_response_model(self):
|
402
|
+
"""
|
403
|
+
Create a response model with the appropriate constraints.
|
404
|
+
|
405
|
+
This method creates a Pydantic model customized with the budget constraints
|
406
|
+
and options specified for this question instance.
|
407
|
+
|
408
|
+
Returns:
|
409
|
+
A Pydantic model class tailored to this question's constraints
|
410
|
+
|
411
|
+
Examples:
|
412
|
+
>>> q = QuestionBudget.example()
|
413
|
+
>>> model = q.create_response_model()
|
414
|
+
>>> model(answer=[25, 25, 25, 25]).answer
|
415
|
+
[25.0, 25.0, 25.0, 25.0]
|
416
|
+
"""
|
100
417
|
return create_budget_model(
|
101
418
|
self.budget_sum, self.permissive, self.question_options
|
102
419
|
)
|
@@ -106,44 +423,86 @@ class QuestionBudget(QuestionBase):
|
|
106
423
|
) -> list[dict]:
|
107
424
|
"""
|
108
425
|
Translate the answer codes to the actual answers.
|
109
|
-
|
426
|
+
|
110
427
|
For example, for a budget question with options ["a", "b", "c"],
|
111
|
-
|
112
|
-
|
428
|
+
and answer values [50, 30, 20], this method will create a list of
|
429
|
+
dictionaries mapping each option to its allocated value.
|
430
|
+
|
431
|
+
Args:
|
432
|
+
answer_code: List of budget allocation values
|
433
|
+
combined_dict: Additional context (unused)
|
434
|
+
|
435
|
+
Returns:
|
436
|
+
List of dictionaries mapping options to their allocation values
|
437
|
+
|
438
|
+
Examples:
|
439
|
+
>>> q = QuestionBudget.example()
|
440
|
+
>>> q._translate_answer_code_to_answer([40, 30, 20, 10], {})
|
441
|
+
[{'Pizza': 40}, {'Ice Cream': 30}, {'Burgers': 20}, {'Salad': 10}]
|
113
442
|
"""
|
114
443
|
translated_codes = []
|
115
|
-
for
|
116
|
-
translated_codes.append({question_option:
|
444
|
+
for answer_value, question_option in zip(answer_code, self.question_options):
|
445
|
+
translated_codes.append({question_option: answer_value})
|
117
446
|
|
118
447
|
return translated_codes
|
119
448
|
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
449
|
+
def _simulate_answer(self, human_readable=True):
|
450
|
+
"""
|
451
|
+
Simulate a valid answer for debugging purposes.
|
452
|
+
|
453
|
+
This method generates a random budget allocation that satisfies the
|
454
|
+
constraints of the question, useful for testing and demonstrations.
|
455
|
+
|
456
|
+
Args:
|
457
|
+
human_readable: Whether to use option text (True) or indices (False)
|
458
|
+
|
459
|
+
Returns:
|
460
|
+
A dictionary containing a valid simulated answer
|
461
|
+
|
462
|
+
Examples:
|
463
|
+
>>> import random
|
464
|
+
>>> random.seed(42) # For reproducible test
|
465
|
+
>>> q = QuestionBudget.example()
|
466
|
+
>>> simulated = q._simulate_answer()
|
467
|
+
>>> len(simulated["answer"])
|
468
|
+
4
|
469
|
+
>>> abs(sum(simulated["answer"]) - q.budget_sum) < 0.01 # Allow for float imprecision
|
470
|
+
True
|
471
|
+
"""
|
472
|
+
import random
|
473
|
+
from edsl.utilities.utilities import random_string
|
474
|
+
|
475
|
+
# Generate a random allocation that sums to budget_sum
|
476
|
+
remaining_budget = self.budget_sum
|
477
|
+
values = []
|
478
|
+
|
479
|
+
for i in range(len(self.question_options)):
|
480
|
+
if i == len(self.question_options) - 1:
|
481
|
+
# Assign remaining budget to the last value
|
482
|
+
values.append(remaining_budget)
|
483
|
+
else:
|
484
|
+
# Generate a random value between 0 and remaining budget
|
485
|
+
value = random.randint(0, remaining_budget)
|
486
|
+
values.append(value)
|
487
|
+
remaining_budget -= value
|
488
|
+
|
489
|
+
return {
|
490
|
+
"answer": values,
|
491
|
+
"comment": random_string() if self.include_comment else None,
|
492
|
+
}
|
144
493
|
|
145
494
|
@property
|
146
495
|
def question_html_content(self) -> str:
|
496
|
+
"""
|
497
|
+
Generate HTML content for rendering the question in web interfaces.
|
498
|
+
|
499
|
+
This property generates HTML markup for the question when it needs to be
|
500
|
+
displayed in web interfaces or HTML contexts, including an interactive
|
501
|
+
budget allocation form with JavaScript for real-time budget tracking.
|
502
|
+
|
503
|
+
Returns:
|
504
|
+
str: HTML markup for rendering the question
|
505
|
+
"""
|
147
506
|
from jinja2 import Template
|
148
507
|
|
149
508
|
question_html_content = Template(
|
@@ -188,7 +547,29 @@ class QuestionBudget(QuestionBase):
|
|
188
547
|
################
|
189
548
|
@classmethod
|
190
549
|
def example(cls, include_comment: bool = True) -> QuestionBudget:
|
191
|
-
"""
|
550
|
+
"""
|
551
|
+
Create an example instance of a budget question.
|
552
|
+
|
553
|
+
This class method creates a predefined example of a budget question
|
554
|
+
for demonstration, testing, and documentation purposes.
|
555
|
+
|
556
|
+
Args:
|
557
|
+
include_comment: Whether to include a comment field with the answer
|
558
|
+
|
559
|
+
Returns:
|
560
|
+
QuestionBudget: An example budget question
|
561
|
+
|
562
|
+
Examples:
|
563
|
+
>>> q = QuestionBudget.example()
|
564
|
+
>>> q.question_name
|
565
|
+
'food_budget'
|
566
|
+
>>> q.question_text
|
567
|
+
'How would you allocate $100?'
|
568
|
+
>>> q.budget_sum
|
569
|
+
100
|
570
|
+
>>> q.question_options
|
571
|
+
['Pizza', 'Ice Cream', 'Burgers', 'Salad']
|
572
|
+
"""
|
192
573
|
return cls(
|
193
574
|
question_name="food_budget",
|
194
575
|
question_text="How would you allocate $100?",
|
@@ -199,32 +580,52 @@ class QuestionBudget(QuestionBase):
|
|
199
580
|
|
200
581
|
|
201
582
|
def main():
|
202
|
-
"""
|
203
|
-
|
204
|
-
|
583
|
+
"""
|
584
|
+
Demonstrate the functionality of the QuestionBudget class.
|
585
|
+
|
586
|
+
This function creates an example budget question and demonstrates its
|
587
|
+
key features including validation, serialization, and answer simulation.
|
588
|
+
It's primarily intended for testing and development purposes.
|
589
|
+
|
590
|
+
Note:
|
591
|
+
This function will be executed when the module is run directly,
|
592
|
+
but not when imported.
|
593
|
+
"""
|
594
|
+
# Create an example question
|
205
595
|
q = QuestionBudget.example()
|
206
|
-
|
207
|
-
q.
|
208
|
-
q.
|
209
|
-
|
210
|
-
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
|
217
|
-
q._simulate_answer(
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
596
|
+
|
597
|
+
print(f"Question text: {q.question_text}")
|
598
|
+
print(f"Question options: {q.question_options}")
|
599
|
+
print(f"Budget sum: {q.budget_sum}")
|
600
|
+
|
601
|
+
# Validate an answer
|
602
|
+
valid_answer = {"answer": [25, 25, 25, 25]}
|
603
|
+
validated = q._validate_answer(valid_answer)
|
604
|
+
print(f"Validated answer: {validated}")
|
605
|
+
|
606
|
+
# Simulate an answer
|
607
|
+
simulated = q._simulate_answer()
|
608
|
+
print(f"Simulated answer: {simulated}")
|
609
|
+
|
610
|
+
# Translate answer code
|
611
|
+
translated = q._translate_answer_code_to_answer([40, 30, 20, 10], {})
|
612
|
+
print(f"Translated answer: {translated}")
|
613
|
+
|
614
|
+
# Serialization demonstration
|
615
|
+
serialized = q.to_dict()
|
616
|
+
print(f"Serialized: {serialized}")
|
617
|
+
deserialized = QuestionBase.from_dict(serialized)
|
618
|
+
print(f"Deserialization successful: {deserialized.question_text == q.question_text}")
|
619
|
+
|
620
|
+
# Run doctests
|
621
|
+
import doctest
|
622
|
+
doctest.testmod(optionflags=doctest.ELLIPSIS)
|
623
|
+
print("Doctests completed")
|
222
624
|
|
223
625
|
|
224
626
|
if __name__ == "__main__":
|
225
|
-
# q = QuestionBudget.example()
|
226
|
-
# results = q.run()
|
227
|
-
|
228
627
|
import doctest
|
229
|
-
|
230
628
|
doctest.testmod(optionflags=doctest.ELLIPSIS)
|
629
|
+
|
630
|
+
# Uncomment to run demonstration
|
631
|
+
# main()
|