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.
Files changed (109) hide show
  1. edsl/__version__.py +1 -1
  2. edsl/base/base_exception.py +2 -2
  3. edsl/buckets/bucket_collection.py +1 -1
  4. edsl/buckets/exceptions.py +32 -0
  5. edsl/buckets/token_bucket_api.py +26 -10
  6. edsl/caching/cache.py +5 -2
  7. edsl/caching/remote_cache_sync.py +5 -5
  8. edsl/caching/sql_dict.py +12 -11
  9. edsl/config/__init__.py +1 -1
  10. edsl/config/config_class.py +4 -2
  11. edsl/conversation/Conversation.py +7 -4
  12. edsl/conversation/car_buying.py +1 -3
  13. edsl/conversation/mug_negotiation.py +2 -6
  14. edsl/coop/__init__.py +11 -8
  15. edsl/coop/coop.py +13 -13
  16. edsl/coop/coop_functions.py +1 -1
  17. edsl/coop/ep_key_handling.py +1 -1
  18. edsl/coop/price_fetcher.py +2 -2
  19. edsl/coop/utils.py +2 -2
  20. edsl/dataset/dataset.py +144 -63
  21. edsl/dataset/dataset_operations_mixin.py +14 -6
  22. edsl/dataset/dataset_tree.py +3 -3
  23. edsl/dataset/display/table_renderers.py +6 -3
  24. edsl/dataset/file_exports.py +4 -4
  25. edsl/dataset/r/ggplot.py +3 -3
  26. edsl/inference_services/available_model_fetcher.py +2 -2
  27. edsl/inference_services/data_structures.py +5 -5
  28. edsl/inference_services/inference_service_abc.py +1 -1
  29. edsl/inference_services/inference_services_collection.py +1 -1
  30. edsl/inference_services/service_availability.py +3 -3
  31. edsl/inference_services/services/azure_ai.py +3 -3
  32. edsl/inference_services/services/google_service.py +1 -1
  33. edsl/inference_services/services/test_service.py +1 -1
  34. edsl/instructions/change_instruction.py +5 -4
  35. edsl/instructions/instruction.py +1 -0
  36. edsl/instructions/instruction_collection.py +5 -4
  37. edsl/instructions/instruction_handler.py +10 -8
  38. edsl/interviews/exception_tracking.py +1 -1
  39. edsl/interviews/interview.py +1 -1
  40. edsl/interviews/interview_status_dictionary.py +1 -1
  41. edsl/interviews/interview_task_manager.py +2 -2
  42. edsl/interviews/request_token_estimator.py +3 -2
  43. edsl/interviews/statistics.py +2 -2
  44. edsl/invigilators/invigilators.py +2 -2
  45. edsl/jobs/__init__.py +39 -2
  46. edsl/jobs/async_interview_runner.py +1 -1
  47. edsl/jobs/check_survey_scenario_compatibility.py +5 -5
  48. edsl/jobs/data_structures.py +2 -2
  49. edsl/jobs/jobs.py +2 -2
  50. edsl/jobs/jobs_checks.py +5 -5
  51. edsl/jobs/jobs_component_constructor.py +2 -2
  52. edsl/jobs/jobs_pricing_estimation.py +1 -1
  53. edsl/jobs/jobs_runner_asyncio.py +2 -2
  54. edsl/jobs/remote_inference.py +1 -1
  55. edsl/jobs/results_exceptions_handler.py +2 -2
  56. edsl/language_models/language_model.py +5 -1
  57. edsl/notebooks/__init__.py +24 -1
  58. edsl/notebooks/exceptions.py +82 -0
  59. edsl/notebooks/notebook.py +7 -3
  60. edsl/notebooks/notebook_to_latex.py +1 -1
  61. edsl/prompts/__init__.py +23 -2
  62. edsl/prompts/prompt.py +1 -1
  63. edsl/questions/__init__.py +4 -4
  64. edsl/questions/answer_validator_mixin.py +0 -5
  65. edsl/questions/compose_questions.py +2 -2
  66. edsl/questions/descriptors.py +1 -1
  67. edsl/questions/question_base.py +32 -3
  68. edsl/questions/question_base_prompts_mixin.py +4 -4
  69. edsl/questions/question_budget.py +503 -102
  70. edsl/questions/question_check_box.py +658 -156
  71. edsl/questions/question_dict.py +176 -2
  72. edsl/questions/question_extract.py +401 -61
  73. edsl/questions/question_free_text.py +77 -9
  74. edsl/questions/question_functional.py +118 -9
  75. edsl/questions/{derived/question_likert_five.py → question_likert_five.py} +2 -2
  76. edsl/questions/{derived/question_linear_scale.py → question_linear_scale.py} +3 -4
  77. edsl/questions/question_list.py +246 -26
  78. edsl/questions/question_matrix.py +586 -73
  79. edsl/questions/question_multiple_choice.py +213 -47
  80. edsl/questions/question_numerical.py +360 -29
  81. edsl/questions/question_rank.py +401 -124
  82. edsl/questions/question_registry.py +3 -3
  83. edsl/questions/{derived/question_top_k.py → question_top_k.py} +3 -3
  84. edsl/questions/{derived/question_yes_no.py → question_yes_no.py} +3 -4
  85. edsl/questions/register_questions_meta.py +2 -1
  86. edsl/questions/response_validator_abc.py +6 -2
  87. edsl/questions/response_validator_factory.py +10 -12
  88. edsl/results/report.py +1 -1
  89. edsl/results/result.py +7 -4
  90. edsl/results/results.py +471 -271
  91. edsl/results/results_selector.py +2 -2
  92. edsl/scenarios/construct_download_link.py +3 -3
  93. edsl/scenarios/scenario.py +1 -2
  94. edsl/scenarios/scenario_list.py +41 -23
  95. edsl/surveys/survey_css.py +3 -3
  96. edsl/surveys/survey_simulator.py +2 -1
  97. edsl/tasks/__init__.py +22 -2
  98. edsl/tasks/exceptions.py +72 -0
  99. edsl/tasks/task_history.py +3 -3
  100. edsl/tokens/__init__.py +27 -1
  101. edsl/tokens/exceptions.py +37 -0
  102. edsl/tokens/interview_token_usage.py +3 -2
  103. edsl/tokens/token_usage.py +4 -3
  104. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/METADATA +1 -1
  105. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/RECORD +108 -106
  106. edsl/questions/derived/__init__.py +0 -0
  107. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/LICENSE +0 -0
  108. {edsl-0.1.50.dist-info → edsl-0.1.51.dist-info}/WHEEL +0 -0
  109. {edsl-0.1.50.dist-info → edsl-0.1.51.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, BaseModel, validator
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
- class BudgetResponseValidator(ResponseValidatorABC):
11
- valid_examples = []
12
-
13
- invalid_examples = []
14
-
15
- def fix(self, response, verbose=False):
16
- if verbose:
17
- print(f"Fixing list response: {response}")
18
- answer = str(response.get("answer") or response.get("generated_tokens", ""))
19
- if len(answer.split(",")) > 0:
20
- return (
21
- {"answer": answer.split(",")} | {"comment": response.get("comment")}
22
- if "comment" in response
23
- else {}
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
- class BudgetResponse(BaseModel):
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
- min_items=len(question_options),
35
- max_items=len(question_options),
116
+ min_length=len(question_options),
117
+ max_length=len(question_options),
36
118
  )
37
- comment: Optional[str] = None
38
- generated_tokens: Optional[str] = None
39
-
40
- @validator("answer")
41
- def validate_answer(cls, v):
42
- if len(v) != len(question_options):
43
- from .exceptions import QuestionAnswerValidationError
44
- raise QuestionAnswerValidationError(f"Must provide {len(question_options)} values")
45
- if any(x < 0 for x in v):
46
- from .exceptions import QuestionAnswerValidationError
47
- raise QuestionAnswerValidationError("All values must be non-negative")
48
- total = sum(v)
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
- from .exceptions import QuestionAnswerValidationError
51
- raise QuestionAnswerValidationError(f"Sum of numbers must equal {budget_sum}")
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
- from .exceptions import QuestionAnswerValidationError
54
- raise QuestionAnswerValidationError(f"Sum of numbers cannot exceed {budget_sum}")
55
- return v
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
- return BudgetResponse
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
- """This question prompts the agent to allocate a budget among options."""
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
- """Instantiate a new QuestionBudget.
84
-
85
- :param question_name: The name of the question.
86
- :param question_text: The text of the question.
87
- :param question_options: The options for allocation of the budget sum.
88
- :param budget_sum: The total amount of the budget to be allocated among the options.
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
- the answer codes are 0, 1, and 2. The LLM will respond with 0.
112
- This code will translate that to "a".
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 answer_code, question_option in zip(answer_code, self.question_options):
116
- translated_codes.append({question_option: answer_code})
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
- # def _simulate_answer(self, human_readable=True):
121
- # """Simulate a valid answer for debugging purposes (what the validator expects)."""
122
- # from edsl.utilities.utilities import random_string
123
-
124
- # if human_readable:
125
- # keys = self.question_options
126
- # else:
127
- # keys = range(len(self.question_options))
128
- # remaining_budget = self.budget_sum
129
- # values = []
130
- # for _ in range(len(self.question_options)):
131
- # if _ == len(self.question_options) - 1:
132
- # # Assign remaining budget to the last value
133
- # values.append(remaining_budget)
134
- # else:
135
- # # Generate a random value between 0 and remaining budget
136
- # value = random.randint(0, remaining_budget)
137
- # values.append(value)
138
- # remaining_budget -= value
139
- # answer = dict(zip(keys, values))
140
- # return {
141
- # "answer": answer,
142
- # "comment": random_string(),
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
- """Return an example of a budget question."""
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
- """Create an example of a budget question and demonstrate its functionality."""
203
- from edsl.questions.QuestionBudget import QuestionBudget
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
- q.question_text
207
- q.question_options
208
- q.question_name
209
- # validate an answer
210
- q._validate_answer(
211
- {"answer": {0: 100, 1: 0, 2: 0, 3: 0}, "comment": "I like custard"}
212
- )
213
- # translate answer code
214
- q._translate_answer_code_to_answer({0: 100, 1: 0, 2: 0, 3: 0})
215
- # simulate answer
216
- q._simulate_answer()
217
- q._simulate_answer(human_readable=False)
218
- q._validate_answer(q._simulate_answer(human_readable=False))
219
- # serialization (inherits from Question)
220
- q.to_dict()
221
- assert q.from_dict(q.to_dict()) == q
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()