edsl 0.1.49__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/__init__.py +124 -53
- edsl/__version__.py +1 -1
- edsl/agents/agent.py +21 -21
- edsl/agents/agent_list.py +2 -5
- edsl/agents/exceptions.py +119 -5
- edsl/base/__init__.py +10 -35
- edsl/base/base_class.py +71 -36
- edsl/base/base_exception.py +204 -0
- edsl/base/data_transfer_models.py +1 -1
- edsl/base/exceptions.py +94 -0
- edsl/buckets/__init__.py +15 -1
- edsl/buckets/bucket_collection.py +3 -4
- edsl/buckets/exceptions.py +107 -0
- edsl/buckets/model_buckets.py +1 -2
- edsl/buckets/token_bucket.py +11 -6
- edsl/buckets/token_bucket_api.py +27 -12
- edsl/buckets/token_bucket_client.py +9 -7
- edsl/caching/cache.py +12 -4
- edsl/caching/cache_entry.py +10 -9
- edsl/caching/exceptions.py +113 -7
- edsl/caching/remote_cache_sync.py +6 -7
- edsl/caching/sql_dict.py +20 -14
- edsl/cli.py +43 -0
- edsl/config/__init__.py +1 -1
- edsl/config/config_class.py +32 -6
- edsl/conversation/Conversation.py +8 -4
- edsl/conversation/car_buying.py +1 -3
- edsl/conversation/exceptions.py +58 -0
- edsl/conversation/mug_negotiation.py +2 -8
- edsl/coop/__init__.py +28 -6
- edsl/coop/coop.py +120 -29
- edsl/coop/coop_functions.py +1 -1
- edsl/coop/ep_key_handling.py +1 -1
- edsl/coop/exceptions.py +188 -9
- edsl/coop/price_fetcher.py +5 -8
- edsl/coop/utils.py +4 -6
- edsl/dataset/__init__.py +5 -4
- edsl/dataset/dataset.py +177 -86
- edsl/dataset/dataset_operations_mixin.py +98 -76
- edsl/dataset/dataset_tree.py +11 -7
- edsl/dataset/display/table_display.py +0 -2
- edsl/dataset/display/table_renderers.py +6 -4
- edsl/dataset/exceptions.py +125 -0
- edsl/dataset/file_exports.py +18 -11
- edsl/dataset/r/ggplot.py +13 -6
- edsl/display/__init__.py +27 -0
- edsl/display/core.py +147 -0
- edsl/display/plugin.py +189 -0
- edsl/display/utils.py +52 -0
- edsl/inference_services/__init__.py +9 -1
- edsl/inference_services/available_model_cache_handler.py +1 -1
- edsl/inference_services/available_model_fetcher.py +5 -6
- edsl/inference_services/data_structures.py +10 -7
- edsl/inference_services/exceptions.py +132 -1
- edsl/inference_services/inference_service_abc.py +2 -2
- edsl/inference_services/inference_services_collection.py +2 -6
- edsl/inference_services/registry.py +4 -3
- edsl/inference_services/service_availability.py +4 -3
- edsl/inference_services/services/anthropic_service.py +4 -1
- edsl/inference_services/services/aws_bedrock.py +13 -12
- edsl/inference_services/services/azure_ai.py +12 -10
- edsl/inference_services/services/deep_infra_service.py +1 -4
- edsl/inference_services/services/deep_seek_service.py +1 -5
- edsl/inference_services/services/google_service.py +7 -3
- edsl/inference_services/services/groq_service.py +1 -1
- edsl/inference_services/services/mistral_ai_service.py +4 -2
- edsl/inference_services/services/ollama_service.py +1 -1
- edsl/inference_services/services/open_ai_service.py +7 -5
- edsl/inference_services/services/perplexity_service.py +6 -2
- edsl/inference_services/services/test_service.py +8 -7
- edsl/inference_services/services/together_ai_service.py +2 -3
- edsl/inference_services/services/xai_service.py +1 -1
- edsl/instructions/__init__.py +1 -1
- edsl/instructions/change_instruction.py +7 -5
- edsl/instructions/exceptions.py +61 -0
- edsl/instructions/instruction.py +6 -2
- edsl/instructions/instruction_collection.py +6 -4
- edsl/instructions/instruction_handler.py +12 -15
- edsl/interviews/ReportErrors.py +0 -3
- edsl/interviews/__init__.py +9 -2
- edsl/interviews/answering_function.py +11 -13
- edsl/interviews/exception_tracking.py +15 -8
- edsl/interviews/exceptions.py +79 -0
- edsl/interviews/interview.py +33 -30
- edsl/interviews/interview_status_dictionary.py +4 -2
- edsl/interviews/interview_status_log.py +2 -1
- edsl/interviews/interview_task_manager.py +5 -5
- edsl/interviews/request_token_estimator.py +5 -2
- edsl/interviews/statistics.py +3 -4
- edsl/invigilators/__init__.py +7 -1
- edsl/invigilators/exceptions.py +79 -0
- edsl/invigilators/invigilator_base.py +0 -1
- edsl/invigilators/invigilators.py +9 -13
- edsl/invigilators/prompt_constructor.py +1 -5
- edsl/invigilators/prompt_helpers.py +8 -4
- edsl/invigilators/question_instructions_prompt_builder.py +1 -1
- edsl/invigilators/question_option_processor.py +9 -5
- edsl/invigilators/question_template_replacements_builder.py +3 -2
- edsl/jobs/__init__.py +42 -5
- edsl/jobs/async_interview_runner.py +25 -23
- edsl/jobs/check_survey_scenario_compatibility.py +11 -10
- edsl/jobs/data_structures.py +8 -5
- edsl/jobs/exceptions.py +177 -8
- edsl/jobs/fetch_invigilator.py +1 -1
- edsl/jobs/jobs.py +74 -69
- edsl/jobs/jobs_checks.py +6 -7
- edsl/jobs/jobs_component_constructor.py +4 -4
- edsl/jobs/jobs_pricing_estimation.py +4 -3
- edsl/jobs/jobs_remote_inference_logger.py +5 -4
- edsl/jobs/jobs_runner_asyncio.py +3 -4
- edsl/jobs/jobs_runner_status.py +8 -9
- edsl/jobs/remote_inference.py +27 -24
- edsl/jobs/results_exceptions_handler.py +10 -7
- edsl/key_management/__init__.py +3 -1
- edsl/key_management/exceptions.py +62 -0
- edsl/key_management/key_lookup.py +1 -1
- edsl/key_management/key_lookup_builder.py +37 -14
- edsl/key_management/key_lookup_collection.py +2 -0
- edsl/language_models/__init__.py +1 -1
- edsl/language_models/exceptions.py +302 -14
- edsl/language_models/language_model.py +9 -8
- edsl/language_models/model.py +4 -4
- edsl/language_models/model_list.py +1 -1
- edsl/language_models/price_manager.py +1 -1
- edsl/language_models/raw_response_handler.py +14 -9
- edsl/language_models/registry.py +17 -21
- edsl/language_models/repair.py +0 -6
- edsl/language_models/unused/fake_openai_service.py +0 -1
- edsl/load_plugins.py +69 -0
- edsl/logger.py +146 -0
- 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 -2
- edsl/plugins/__init__.py +63 -0
- edsl/plugins/built_in/export_example.py +50 -0
- edsl/plugins/built_in/pig_latin.py +67 -0
- edsl/plugins/cli.py +372 -0
- edsl/plugins/cli_typer.py +283 -0
- edsl/plugins/exceptions.py +31 -0
- edsl/plugins/hookspec.py +51 -0
- edsl/plugins/plugin_host.py +128 -0
- edsl/plugins/plugin_manager.py +633 -0
- edsl/plugins/plugins_registry.py +168 -0
- edsl/prompts/__init__.py +24 -1
- edsl/prompts/exceptions.py +107 -5
- edsl/prompts/prompt.py +15 -7
- edsl/questions/HTMLQuestion.py +5 -11
- edsl/questions/Quick.py +0 -1
- edsl/questions/__init__.py +6 -4
- edsl/questions/answer_validator_mixin.py +318 -323
- edsl/questions/compose_questions.py +3 -3
- edsl/questions/descriptors.py +11 -50
- edsl/questions/exceptions.py +278 -22
- edsl/questions/loop_processor.py +7 -5
- edsl/questions/prompt_templates/question_list.jinja +3 -0
- edsl/questions/question_base.py +46 -19
- edsl/questions/question_base_gen_mixin.py +2 -2
- edsl/questions/question_base_prompts_mixin.py +13 -7
- edsl/questions/question_budget.py +503 -98
- edsl/questions/question_check_box.py +660 -160
- edsl/questions/question_dict.py +345 -194
- edsl/questions/question_extract.py +401 -61
- edsl/questions/question_free_text.py +80 -14
- edsl/questions/question_functional.py +119 -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 +275 -28
- edsl/questions/question_matrix.py +643 -96
- edsl/questions/question_multiple_choice.py +219 -51
- edsl/questions/question_numerical.py +361 -32
- edsl/questions/question_rank.py +401 -124
- edsl/questions/question_registry.py +7 -5
- 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 -2
- edsl/questions/response_validator_abc.py +13 -15
- edsl/questions/response_validator_factory.py +10 -12
- edsl/questions/templates/dict/answering_instructions.jinja +1 -0
- edsl/questions/templates/rank/question_presentation.jinja +1 -1
- edsl/results/__init__.py +1 -1
- edsl/results/exceptions.py +141 -7
- edsl/results/report.py +1 -2
- edsl/results/result.py +11 -9
- edsl/results/results.py +480 -321
- edsl/results/results_selector.py +8 -4
- edsl/scenarios/PdfExtractor.py +2 -2
- edsl/scenarios/construct_download_link.py +69 -35
- edsl/scenarios/directory_scanner.py +33 -14
- edsl/scenarios/document_chunker.py +1 -1
- edsl/scenarios/exceptions.py +238 -14
- edsl/scenarios/file_methods.py +1 -1
- edsl/scenarios/file_store.py +7 -3
- edsl/scenarios/handlers/__init__.py +17 -0
- edsl/scenarios/handlers/docx_file_store.py +0 -5
- edsl/scenarios/handlers/pdf_file_store.py +0 -1
- edsl/scenarios/handlers/pptx_file_store.py +0 -5
- edsl/scenarios/handlers/py_file_store.py +0 -1
- edsl/scenarios/handlers/sql_file_store.py +1 -4
- edsl/scenarios/handlers/sqlite_file_store.py +0 -1
- edsl/scenarios/handlers/txt_file_store.py +1 -1
- edsl/scenarios/scenario.py +1 -3
- edsl/scenarios/scenario_list.py +179 -27
- edsl/scenarios/scenario_list_pdf_tools.py +1 -0
- edsl/scenarios/scenario_selector.py +0 -1
- edsl/surveys/__init__.py +3 -4
- edsl/surveys/dag/__init__.py +4 -2
- edsl/surveys/descriptors.py +1 -1
- edsl/surveys/edit_survey.py +1 -0
- edsl/surveys/exceptions.py +165 -9
- edsl/surveys/memory/__init__.py +5 -3
- edsl/surveys/memory/memory_management.py +1 -0
- edsl/surveys/memory/memory_plan.py +6 -15
- edsl/surveys/rules/__init__.py +5 -3
- edsl/surveys/rules/rule.py +1 -2
- edsl/surveys/rules/rule_collection.py +1 -1
- edsl/surveys/survey.py +12 -24
- edsl/surveys/survey_css.py +3 -3
- edsl/surveys/survey_export.py +6 -3
- edsl/surveys/survey_flow_visualization.py +10 -1
- edsl/surveys/survey_simulator.py +2 -1
- edsl/tasks/__init__.py +23 -1
- edsl/tasks/exceptions.py +72 -0
- edsl/tasks/question_task_creator.py +3 -3
- edsl/tasks/task_creators.py +1 -3
- edsl/tasks/task_history.py +8 -10
- edsl/tasks/task_status_log.py +1 -2
- edsl/tokens/__init__.py +29 -1
- edsl/tokens/exceptions.py +37 -0
- edsl/tokens/interview_token_usage.py +3 -2
- edsl/tokens/token_usage.py +4 -3
- edsl/utilities/__init__.py +21 -1
- edsl/utilities/decorators.py +1 -2
- edsl/utilities/markdown_to_docx.py +2 -2
- edsl/utilities/markdown_to_pdf.py +1 -1
- edsl/utilities/repair_functions.py +0 -1
- edsl/utilities/restricted_python.py +0 -1
- edsl/utilities/template_loader.py +2 -3
- edsl/utilities/utilities.py +8 -29
- {edsl-0.1.49.dist-info → edsl-0.1.51.dist-info}/METADATA +32 -2
- edsl-0.1.51.dist-info/RECORD +365 -0
- edsl-0.1.51.dist-info/entry_points.txt +3 -0
- edsl/dataset/smart_objects.py +0 -96
- edsl/exceptions/BaseException.py +0 -21
- edsl/exceptions/__init__.py +0 -54
- edsl/exceptions/configuration.py +0 -16
- edsl/exceptions/general.py +0 -34
- edsl/questions/derived/__init__.py +0 -0
- edsl/study/ObjectEntry.py +0 -173
- edsl/study/ProofOfWork.py +0 -113
- edsl/study/SnapShot.py +0 -80
- edsl/study/Study.py +0 -520
- edsl/study/__init__.py +0 -6
- edsl/utilities/interface.py +0 -135
- edsl-0.1.49.dist-info/RECORD +0 -347
- {edsl-0.1.49.dist-info → edsl-0.1.51.dist-info}/LICENSE +0 -0
- {edsl-0.1.49.dist-info → edsl-0.1.51.dist-info}/WHEEL +0 -0
@@ -1,11 +1,93 @@
|
|
1
1
|
from __future__ import annotations
|
2
|
-
from typing import Optional, Callable
|
2
|
+
from typing import Optional, Callable, Any
|
3
3
|
import inspect
|
4
4
|
|
5
|
+
from pydantic import BaseModel
|
6
|
+
|
5
7
|
from .question_base import QuestionBase
|
8
|
+
from .response_validator_abc import ResponseValidatorABC
|
9
|
+
from .exceptions import QuestionErrors, QuestionAnswerValidationError, QuestionNotImplementedError
|
6
10
|
|
7
11
|
from ..utilities.restricted_python import create_restricted_function
|
8
|
-
|
12
|
+
|
13
|
+
|
14
|
+
class FunctionalResponse(BaseModel):
|
15
|
+
"""
|
16
|
+
Pydantic model for functional question responses.
|
17
|
+
|
18
|
+
Since functional questions are evaluated directly by Python code rather than an LLM,
|
19
|
+
this model primarily serves as a structured way to represent the output.
|
20
|
+
|
21
|
+
Attributes:
|
22
|
+
answer: The result of the function evaluation
|
23
|
+
comment: Optional comment about the result
|
24
|
+
generated_tokens: Optional token usage data
|
25
|
+
|
26
|
+
Examples:
|
27
|
+
>>> # Valid response with a numeric answer
|
28
|
+
>>> response = FunctionalResponse(answer=42)
|
29
|
+
>>> response.answer
|
30
|
+
42
|
31
|
+
|
32
|
+
>>> # Valid response with a string answer and a comment
|
33
|
+
>>> response = FunctionalResponse(answer="Hello world", comment="Function executed successfully")
|
34
|
+
>>> response.answer
|
35
|
+
'Hello world'
|
36
|
+
>>> response.comment
|
37
|
+
'Function executed successfully'
|
38
|
+
"""
|
39
|
+
answer: Any
|
40
|
+
comment: Optional[str] = None
|
41
|
+
generated_tokens: Optional[Any] = None
|
42
|
+
|
43
|
+
|
44
|
+
class FunctionalResponseValidator(ResponseValidatorABC):
|
45
|
+
"""
|
46
|
+
Validator for functional question responses.
|
47
|
+
|
48
|
+
Since functional questions are evaluated directly and not by an LLM,
|
49
|
+
this validator is minimal and mainly serves for consistency with other question types.
|
50
|
+
"""
|
51
|
+
required_params = []
|
52
|
+
valid_examples = [
|
53
|
+
(
|
54
|
+
{"answer": 42},
|
55
|
+
{},
|
56
|
+
),
|
57
|
+
(
|
58
|
+
{"answer": "Hello world", "comment": "Function executed successfully"},
|
59
|
+
{},
|
60
|
+
),
|
61
|
+
]
|
62
|
+
invalid_examples = []
|
63
|
+
|
64
|
+
def fix(self, response, verbose=False):
|
65
|
+
"""
|
66
|
+
Attempt to fix an invalid response.
|
67
|
+
|
68
|
+
Since functional questions are evaluated directly, this method is mainly
|
69
|
+
for consistency with other question types.
|
70
|
+
|
71
|
+
Args:
|
72
|
+
response: The response to fix
|
73
|
+
verbose: Whether to print verbose output
|
74
|
+
|
75
|
+
Returns:
|
76
|
+
The fixed response or the original response if it cannot be fixed
|
77
|
+
"""
|
78
|
+
if verbose:
|
79
|
+
print(f"Fixing functional response: {response}")
|
80
|
+
|
81
|
+
# Handle case where response is a raw value without the proper structure
|
82
|
+
if not isinstance(response, dict):
|
83
|
+
try:
|
84
|
+
return {"answer": response}
|
85
|
+
except Exception as e:
|
86
|
+
if verbose:
|
87
|
+
print(f"Failed to fix response: {e}")
|
88
|
+
return {"answer": None, "comment": "Failed to execute function"}
|
89
|
+
|
90
|
+
return response
|
9
91
|
|
10
92
|
|
11
93
|
class QuestionFunctional(QuestionBase):
|
@@ -41,7 +123,7 @@ class QuestionFunctional(QuestionBase):
|
|
41
123
|
function_name = ""
|
42
124
|
|
43
125
|
_response_model = None
|
44
|
-
response_validator_class =
|
126
|
+
response_validator_class = FunctionalResponseValidator
|
45
127
|
|
46
128
|
def __init__(
|
47
129
|
self,
|
@@ -74,6 +156,12 @@ class QuestionFunctional(QuestionBase):
|
|
74
156
|
self.question_text = question_text
|
75
157
|
self.instructions = self.default_instructions
|
76
158
|
|
159
|
+
def create_response_model(self):
|
160
|
+
"""
|
161
|
+
Returns the Pydantic model for validating responses to this question.
|
162
|
+
"""
|
163
|
+
return FunctionalResponse
|
164
|
+
|
77
165
|
def activate(self):
|
78
166
|
self.activated = True
|
79
167
|
|
@@ -86,12 +174,14 @@ class QuestionFunctional(QuestionBase):
|
|
86
174
|
def answer_question_directly(self, scenario, agent_traits=None):
|
87
175
|
"""Return the answer to the question, ensuring the function is activated."""
|
88
176
|
if not self.activated:
|
89
|
-
raise
|
177
|
+
raise QuestionErrors("Function not activated. Please activate it first.")
|
90
178
|
try:
|
91
|
-
|
179
|
+
result = {"answer": self.func(scenario, agent_traits), "comment": None}
|
180
|
+
# Validate the result using the Pydantic model
|
181
|
+
return self.create_response_model()(**result).model_dump()
|
92
182
|
except Exception as e:
|
93
183
|
print("Function execution error:", e)
|
94
|
-
raise
|
184
|
+
raise QuestionErrors("Error during function execution.")
|
95
185
|
|
96
186
|
def _translate_answer_code_to_answer(self, answer, scenario):
|
97
187
|
"""Required by Question, but not used by QuestionFunctional."""
|
@@ -99,11 +189,31 @@ class QuestionFunctional(QuestionBase):
|
|
99
189
|
|
100
190
|
def _simulate_answer(self, human_readable=True) -> dict[str, str]:
|
101
191
|
"""Required by Question, but not used by QuestionFunctional."""
|
102
|
-
raise
|
192
|
+
raise QuestionNotImplementedError("_simulate_answer not implemented for QuestionFunctional")
|
103
193
|
|
104
194
|
def _validate_answer(self, answer: dict[str, str]):
|
105
|
-
"""
|
106
|
-
|
195
|
+
"""Validate the answer using the Pydantic model."""
|
196
|
+
try:
|
197
|
+
return self.create_response_model()(**answer).model_dump()
|
198
|
+
except Exception as e:
|
199
|
+
from pydantic import ValidationError
|
200
|
+
# Create a ValidationError with a helpful message
|
201
|
+
validation_error = ValidationError.from_exception_data(
|
202
|
+
title='FunctionalResponse',
|
203
|
+
line_errors=[{
|
204
|
+
'type': 'value_error',
|
205
|
+
'loc': ('answer',),
|
206
|
+
'msg': f'Function response validation failed: {str(e)}',
|
207
|
+
'input': answer,
|
208
|
+
'ctx': {'error': str(e)}
|
209
|
+
}]
|
210
|
+
)
|
211
|
+
raise QuestionAnswerValidationError(
|
212
|
+
message=f"Invalid function response: {str(e)}",
|
213
|
+
data=answer,
|
214
|
+
model=self.create_response_model(),
|
215
|
+
pydantic_error=validation_error
|
216
|
+
)
|
107
217
|
|
108
218
|
@property
|
109
219
|
def question_html_content(self) -> str:
|
@@ -1,7 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
from typing import Optional
|
3
|
-
from
|
4
|
-
from
|
3
|
+
from .question_multiple_choice import QuestionMultipleChoice
|
4
|
+
from .decorators import inject_exception
|
5
5
|
|
6
6
|
|
7
7
|
class QuestionLikertFive(QuestionMultipleChoice):
|
@@ -1,10 +1,9 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
from typing import Optional
|
3
3
|
|
4
|
-
from
|
5
|
-
from
|
6
|
-
|
7
|
-
from ..decorators import inject_exception
|
4
|
+
from .descriptors import QuestionOptionsDescriptor, OptionLabelDescriptor
|
5
|
+
from .question_multiple_choice import QuestionMultipleChoice
|
6
|
+
from .decorators import inject_exception
|
8
7
|
|
9
8
|
|
10
9
|
class QuestionLinearScale(QuestionMultipleChoice):
|
edsl/questions/question_list.py
CHANGED
@@ -1,16 +1,17 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
import json
|
3
|
-
from typing import Any, Optional, Union
|
3
|
+
from typing import Any, Optional, Union, ForwardRef
|
4
4
|
|
5
|
-
from pydantic import Field
|
5
|
+
from pydantic import Field, model_validator, ValidationError
|
6
6
|
from json_repair import repair_json
|
7
|
-
|
8
|
-
from .exceptions import QuestionAnswerValidationError
|
9
7
|
from .question_base import QuestionBase
|
10
8
|
from .descriptors import IntegerOrNoneDescriptor
|
11
9
|
from .decorators import inject_exception
|
12
10
|
from .response_validator_abc import ResponseValidatorABC
|
13
11
|
|
12
|
+
# Forward reference for function return type annotation
|
13
|
+
ListResponse = ForwardRef("ListResponse")
|
14
|
+
|
14
15
|
def convert_string(s: str) -> Union[float, int, str, dict]:
|
15
16
|
"""Convert a string to a more appropriate type if possible.
|
16
17
|
|
@@ -54,61 +55,301 @@ def convert_string(s: str) -> Union[float, int, str, dict]:
|
|
54
55
|
return s
|
55
56
|
|
56
57
|
|
57
|
-
def create_model(max_list_items: int, permissive: bool) -> "ListResponse":
|
58
|
+
def create_model(min_list_items: Optional[int], max_list_items: Optional[int], permissive: bool) -> "ListResponse":
|
58
59
|
from pydantic import BaseModel
|
59
60
|
|
60
|
-
if permissive or max_list_items is None:
|
61
|
-
|
61
|
+
if permissive or (max_list_items is None and min_list_items is None):
|
62
62
|
class ListResponse(BaseModel):
|
63
|
+
"""
|
64
|
+
Pydantic model for validating list responses with no constraints.
|
65
|
+
|
66
|
+
Examples:
|
67
|
+
>>> # Valid response with any number of items
|
68
|
+
>>> response = ListResponse(answer=["one", "two", "three"])
|
69
|
+
>>> response.answer
|
70
|
+
['one', 'two', 'three']
|
71
|
+
|
72
|
+
>>> # Empty list is valid in permissive mode
|
73
|
+
>>> response = ListResponse(answer=[])
|
74
|
+
>>> response.answer
|
75
|
+
[]
|
76
|
+
|
77
|
+
>>> # Missing answer field raises error
|
78
|
+
>>> try:
|
79
|
+
... ListResponse(you="will never be able to do this!")
|
80
|
+
... except Exception as e:
|
81
|
+
... "Field required" in str(e)
|
82
|
+
True
|
83
|
+
"""
|
63
84
|
answer: list[Any]
|
64
85
|
comment: Optional[str] = None
|
65
86
|
generated_tokens: Optional[str] = None
|
87
|
+
|
88
|
+
@classmethod
|
89
|
+
def model_validate(cls, obj, *args, **kwargs):
|
90
|
+
try:
|
91
|
+
return super().model_validate(obj, *args, **kwargs)
|
92
|
+
except ValidationError as e:
|
93
|
+
from .exceptions import QuestionAnswerValidationError
|
94
|
+
raise QuestionAnswerValidationError(
|
95
|
+
message=f"Invalid list response: {e}",
|
96
|
+
data=obj,
|
97
|
+
model=cls,
|
98
|
+
pydantic_error=e
|
99
|
+
)
|
66
100
|
|
67
101
|
else:
|
102
|
+
# Determine field constraints
|
103
|
+
field_kwargs = {"...": None}
|
104
|
+
|
105
|
+
if min_list_items is not None:
|
106
|
+
field_kwargs["min_items"] = min_list_items
|
107
|
+
|
108
|
+
if max_list_items is not None:
|
109
|
+
field_kwargs["max_items"] = max_list_items
|
68
110
|
|
69
111
|
class ListResponse(BaseModel):
|
70
112
|
"""
|
71
|
-
|
72
|
-
|
73
|
-
|
113
|
+
Pydantic model for validating list responses with size constraints.
|
114
|
+
|
115
|
+
Examples:
|
116
|
+
>>> # Create a model with min=2, max=4 items
|
117
|
+
>>> ConstrainedList = create_model(min_list_items=2, max_list_items=4, permissive=False)
|
118
|
+
|
119
|
+
>>> # Valid response within constraints
|
120
|
+
>>> response = ConstrainedList(answer=["Apple", "Cherry", "Banana"])
|
121
|
+
>>> len(response.answer)
|
122
|
+
3
|
123
|
+
|
124
|
+
>>> # Too few items raises error
|
125
|
+
>>> try:
|
126
|
+
... ConstrainedList(answer=["Apple"])
|
127
|
+
... except QuestionAnswerValidationError as e:
|
128
|
+
... "must have at least 2 items" in str(e)
|
129
|
+
True
|
130
|
+
|
131
|
+
>>> # Too many items raises error
|
132
|
+
>>> try:
|
133
|
+
... ConstrainedList(answer=["A", "B", "C", "D", "E"])
|
134
|
+
... except QuestionAnswerValidationError as e:
|
135
|
+
... "cannot have more than 4 items" in str(e)
|
136
|
+
True
|
137
|
+
|
138
|
+
>>> # Optional comment is allowed
|
139
|
+
>>> response = ConstrainedList(
|
140
|
+
... answer=["Apple", "Cherry"],
|
141
|
+
... comment="These are my favorites"
|
142
|
+
... )
|
143
|
+
>>> response.comment
|
144
|
+
'These are my favorites'
|
145
|
+
|
146
|
+
>>> # Generated tokens are optional
|
147
|
+
>>> response = ConstrainedList(
|
148
|
+
... answer=["Apple", "Cherry"],
|
149
|
+
... generated_tokens="Apple, Cherry"
|
150
|
+
... )
|
151
|
+
>>> response.generated_tokens
|
152
|
+
'Apple, Cherry'
|
74
153
|
"""
|
75
154
|
|
76
|
-
answer: list[Any] = Field(
|
155
|
+
answer: list[Any] = Field(**field_kwargs)
|
77
156
|
comment: Optional[str] = None
|
78
157
|
generated_tokens: Optional[str] = None
|
79
158
|
|
159
|
+
@model_validator(mode='after')
|
160
|
+
def validate_list_constraints(self):
|
161
|
+
"""
|
162
|
+
Validate that the list meets size constraints.
|
163
|
+
|
164
|
+
Returns:
|
165
|
+
The validated model instance.
|
166
|
+
|
167
|
+
Raises:
|
168
|
+
QuestionAnswerValidationError: If list size constraints are violated.
|
169
|
+
"""
|
170
|
+
if max_list_items is not None and len(self.answer) > max_list_items:
|
171
|
+
from .exceptions import QuestionAnswerValidationError
|
172
|
+
validation_error = ValidationError.from_exception_data(
|
173
|
+
title='ListResponse',
|
174
|
+
line_errors=[{
|
175
|
+
'type': 'value_error',
|
176
|
+
'loc': ('answer',),
|
177
|
+
'msg': f'List cannot have more than {max_list_items} items',
|
178
|
+
'input': self.answer,
|
179
|
+
'ctx': {'error': 'Too many items'}
|
180
|
+
}]
|
181
|
+
)
|
182
|
+
raise QuestionAnswerValidationError(
|
183
|
+
message=f"List cannot have more than {max_list_items} items",
|
184
|
+
data=self.model_dump(),
|
185
|
+
model=self.__class__,
|
186
|
+
pydantic_error=validation_error
|
187
|
+
)
|
188
|
+
|
189
|
+
if min_list_items is not None and len(self.answer) < min_list_items:
|
190
|
+
from .exceptions import QuestionAnswerValidationError
|
191
|
+
validation_error = ValidationError.from_exception_data(
|
192
|
+
title='ListResponse',
|
193
|
+
line_errors=[{
|
194
|
+
'type': 'value_error',
|
195
|
+
'loc': ('answer',),
|
196
|
+
'msg': f'List must have at least {min_list_items} items',
|
197
|
+
'input': self.answer,
|
198
|
+
'ctx': {'error': 'Too few items'}
|
199
|
+
}]
|
200
|
+
)
|
201
|
+
raise QuestionAnswerValidationError(
|
202
|
+
message=f"List must have at least {min_list_items} items",
|
203
|
+
data=self.model_dump(),
|
204
|
+
model=self.__class__,
|
205
|
+
pydantic_error=validation_error
|
206
|
+
)
|
207
|
+
return self
|
208
|
+
|
209
|
+
@classmethod
|
210
|
+
def model_validate(cls, obj, *args, **kwargs):
|
211
|
+
try:
|
212
|
+
return super().model_validate(obj, *args, **kwargs)
|
213
|
+
except ValidationError as e:
|
214
|
+
from .exceptions import QuestionAnswerValidationError
|
215
|
+
raise QuestionAnswerValidationError(
|
216
|
+
message=f"Invalid list response: {e}",
|
217
|
+
data=obj,
|
218
|
+
model=cls,
|
219
|
+
pydantic_error=e
|
220
|
+
)
|
221
|
+
|
80
222
|
return ListResponse
|
81
223
|
|
82
224
|
|
83
225
|
class ListResponseValidator(ResponseValidatorABC):
|
84
|
-
required_params = ["max_list_items", "permissive"]
|
226
|
+
required_params = ["min_list_items", "max_list_items", "permissive"]
|
85
227
|
valid_examples = [({"answer": ["hello", "world"]}, {"max_list_items": 5})]
|
86
|
-
|
87
228
|
invalid_examples = [
|
88
229
|
(
|
89
230
|
{"answer": ["hello", "world", "this", "is", "a", "test"]},
|
90
231
|
{"max_list_items": 5},
|
91
|
-
"
|
232
|
+
"List cannot have more than 5 items",
|
233
|
+
),
|
234
|
+
(
|
235
|
+
{"answer": ["hello"]},
|
236
|
+
{"min_list_items": 2},
|
237
|
+
"List must have at least 2 items",
|
92
238
|
),
|
93
239
|
]
|
240
|
+
|
241
|
+
def validate(
|
242
|
+
self,
|
243
|
+
raw_edsl_answer_dict: dict,
|
244
|
+
fix=False,
|
245
|
+
verbose=False,
|
246
|
+
replacement_dict: dict = None,
|
247
|
+
) -> dict:
|
248
|
+
"""Override validate to handle missing answer key properly."""
|
249
|
+
# Check for missing answer key
|
250
|
+
if "answer" not in raw_edsl_answer_dict:
|
251
|
+
from .exceptions import QuestionAnswerValidationError
|
252
|
+
from pydantic import ValidationError
|
253
|
+
|
254
|
+
# Create a synthetic validation error
|
255
|
+
validation_error = ValidationError.from_exception_data(
|
256
|
+
title='ListResponse',
|
257
|
+
line_errors=[{
|
258
|
+
'type': 'missing',
|
259
|
+
'loc': ('answer',),
|
260
|
+
'msg': 'Field required',
|
261
|
+
'input': raw_edsl_answer_dict,
|
262
|
+
}]
|
263
|
+
)
|
264
|
+
|
265
|
+
raise QuestionAnswerValidationError(
|
266
|
+
message="Missing required 'answer' field in response",
|
267
|
+
data=raw_edsl_answer_dict,
|
268
|
+
model=self.response_model,
|
269
|
+
pydantic_error=validation_error
|
270
|
+
)
|
271
|
+
|
272
|
+
# Check if answer is not a list
|
273
|
+
if "answer" in raw_edsl_answer_dict and not isinstance(raw_edsl_answer_dict["answer"], list):
|
274
|
+
from .exceptions import QuestionAnswerValidationError
|
275
|
+
from pydantic import ValidationError
|
276
|
+
|
277
|
+
# Create a synthetic validation error
|
278
|
+
validation_error = ValidationError.from_exception_data(
|
279
|
+
title='ListResponse',
|
280
|
+
line_errors=[{
|
281
|
+
'type': 'list_type',
|
282
|
+
'loc': ('answer',),
|
283
|
+
'msg': 'Input should be a valid list',
|
284
|
+
'input': raw_edsl_answer_dict["answer"],
|
285
|
+
}]
|
286
|
+
)
|
287
|
+
|
288
|
+
raise QuestionAnswerValidationError(
|
289
|
+
message=f"Answer must be a list (got {type(raw_edsl_answer_dict['answer']).__name__})",
|
290
|
+
data=raw_edsl_answer_dict,
|
291
|
+
model=self.response_model,
|
292
|
+
pydantic_error=validation_error
|
293
|
+
)
|
294
|
+
|
295
|
+
# Continue with parent validation
|
296
|
+
return super().validate(raw_edsl_answer_dict, fix, verbose, replacement_dict)
|
94
297
|
|
95
298
|
def _check_constraints(self, response) -> None:
|
96
|
-
|
97
|
-
|
98
|
-
and len(response.answer) > self.max_list_items
|
99
|
-
):
|
100
|
-
raise QuestionAnswerValidationError("Too many items.")
|
299
|
+
# This method can now be removed since validation is handled in the Pydantic model
|
300
|
+
pass
|
101
301
|
|
102
302
|
def fix(self, response, verbose=False):
|
303
|
+
"""
|
304
|
+
Fix common issues in list responses by splitting strings into lists.
|
305
|
+
|
306
|
+
Examples:
|
307
|
+
>>> from edsl import QuestionList
|
308
|
+
>>> q = QuestionList.example(min_list_items=2, max_list_items=4)
|
309
|
+
>>> validator = q.response_validator
|
310
|
+
|
311
|
+
>>> # Fix a string that should be a list
|
312
|
+
>>> bad_response = {"answer": "apple,banana,cherry"}
|
313
|
+
>>> try:
|
314
|
+
... validator.validate(bad_response)
|
315
|
+
... except Exception:
|
316
|
+
... fixed = validator.fix(bad_response)
|
317
|
+
... validated = validator.validate(fixed)
|
318
|
+
... validated # Show full response
|
319
|
+
{'answer': ['apple', 'banana', 'cherry'], 'comment': None, 'generated_tokens': None}
|
320
|
+
|
321
|
+
>>> # Fix using generated_tokens when answer is invalid
|
322
|
+
>>> bad_response = {
|
323
|
+
... "answer": None,
|
324
|
+
... "generated_tokens": "pizza, pasta, salad"
|
325
|
+
... }
|
326
|
+
>>> try:
|
327
|
+
... validator.validate(bad_response)
|
328
|
+
... except Exception:
|
329
|
+
... fixed = validator.fix(bad_response)
|
330
|
+
... validated = validator.validate(fixed)
|
331
|
+
... validated
|
332
|
+
{'answer': ['pizza', ' pasta', ' salad'], 'comment': None, 'generated_tokens': None}
|
333
|
+
|
334
|
+
>>> # Preserve comments during fixing
|
335
|
+
>>> bad_response = {
|
336
|
+
... "answer": "red,blue,green",
|
337
|
+
... "comment": "These are colors"
|
338
|
+
... }
|
339
|
+
>>> fixed = validator.fix(bad_response)
|
340
|
+
>>> fixed == {
|
341
|
+
... "answer": ["red", "blue", "green"],
|
342
|
+
... "comment": "These are colors"
|
343
|
+
... }
|
344
|
+
True
|
345
|
+
"""
|
103
346
|
if verbose:
|
104
347
|
print(f"Fixing list response: {response}")
|
105
348
|
answer = str(response.get("answer") or response.get("generated_tokens", ""))
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
else {}
|
111
|
-
)
|
349
|
+
result = {"answer": answer.split(",")}
|
350
|
+
if "comment" in response:
|
351
|
+
result["comment"] = response["comment"]
|
352
|
+
return result
|
112
353
|
|
113
354
|
def _post_process(self, edsl_answer_dict):
|
114
355
|
edsl_answer_dict["answer"] = [
|
@@ -122,6 +363,7 @@ class QuestionList(QuestionBase):
|
|
122
363
|
|
123
364
|
question_type = "list"
|
124
365
|
max_list_items: int = IntegerOrNoneDescriptor()
|
366
|
+
min_list_items: int = IntegerOrNoneDescriptor()
|
125
367
|
_response_model = None
|
126
368
|
response_validator_class = ListResponseValidator
|
127
369
|
|
@@ -131,6 +373,7 @@ class QuestionList(QuestionBase):
|
|
131
373
|
question_text: str,
|
132
374
|
include_comment: bool = True,
|
133
375
|
max_list_items: Optional[int] = None,
|
376
|
+
min_list_items: Optional[int] = None,
|
134
377
|
answering_instructions: Optional[str] = None,
|
135
378
|
question_presentation: Optional[str] = None,
|
136
379
|
permissive: bool = False,
|
@@ -140,12 +383,14 @@ class QuestionList(QuestionBase):
|
|
140
383
|
:param question_name: The name of the question.
|
141
384
|
:param question_text: The text of the question.
|
142
385
|
:param max_list_items: The maximum number of items that can be in the answer list.
|
386
|
+
:param min_list_items: The minimum number of items that must be in the answer list.
|
143
387
|
|
144
388
|
>>> QuestionList.example().self_check()
|
145
389
|
"""
|
146
390
|
self.question_name = question_name
|
147
391
|
self.question_text = question_text
|
148
392
|
self.max_list_items = max_list_items
|
393
|
+
self.min_list_items = min_list_items
|
149
394
|
self.permissive = permissive
|
150
395
|
|
151
396
|
self.include_comment = include_comment
|
@@ -153,7 +398,7 @@ class QuestionList(QuestionBase):
|
|
153
398
|
self.question_presentations = question_presentation
|
154
399
|
|
155
400
|
def create_response_model(self):
|
156
|
-
return create_model(self.max_list_items, self.permissive)
|
401
|
+
return create_model(self.min_list_items, self.max_list_items, self.permissive)
|
157
402
|
|
158
403
|
@property
|
159
404
|
def question_html_content(self) -> str:
|
@@ -183,7 +428,7 @@ class QuestionList(QuestionBase):
|
|
183
428
|
@classmethod
|
184
429
|
@inject_exception
|
185
430
|
def example(
|
186
|
-
cls, include_comment=True, max_list_items=None, permissive=False
|
431
|
+
cls, include_comment=True, max_list_items=None, min_list_items=None, permissive=False
|
187
432
|
) -> QuestionList:
|
188
433
|
"""Return an example of a list question."""
|
189
434
|
return cls(
|
@@ -191,6 +436,7 @@ class QuestionList(QuestionBase):
|
|
191
436
|
question_text="What are your favorite foods?",
|
192
437
|
include_comment=include_comment,
|
193
438
|
max_list_items=max_list_items,
|
439
|
+
min_list_items=min_list_items,
|
194
440
|
permissive=permissive,
|
195
441
|
)
|
196
442
|
|
@@ -199,10 +445,11 @@ def main():
|
|
199
445
|
"""Create an example of a list question and demonstrate its functionality."""
|
200
446
|
from edsl.questions import QuestionList
|
201
447
|
|
202
|
-
q = QuestionList.example(max_list_items=5)
|
448
|
+
q = QuestionList.example(max_list_items=5, min_list_items=2)
|
203
449
|
q.question_text
|
204
450
|
q.question_name
|
205
451
|
q.max_list_items
|
452
|
+
q.min_list_items
|
206
453
|
# validate an answer
|
207
454
|
q._validate_answer({"answer": ["pasta", "garlic", "oil", "parmesan"]})
|
208
455
|
# translate answer code
|