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
edsl/questions/question_dict.py
CHANGED
@@ -1,8 +1,22 @@
|
|
1
|
+
"""
|
2
|
+
question_dict.py
|
3
|
+
|
4
|
+
Drop-in replacement for `QuestionDict`, with dynamic creation of a Pydantic model
|
5
|
+
to validate user responses automatically (just like QuestionNumerical).
|
6
|
+
|
7
|
+
|
8
|
+
Failure:
|
9
|
+
|
10
|
+
```python { "first_name": "Kris", "last_name": "Rosemann", "phone": "(262) 506-6064", "email": "InvestorRelations@generac.com", "title": "Senior Manager Corporate Development & Investor Relations", "external": False } ``` The first name and last name are extracted directly from the text. The phone number and email are provided in the text. The title is also given in the text. The email domain "generac.com" suggests that it is an internal email address, so "external" is set to False.
|
11
|
+
"""
|
12
|
+
|
1
13
|
from __future__ import annotations
|
2
14
|
from typing import Union, Optional, Dict, List, Any, Type
|
3
|
-
from pydantic import BaseModel, Field,
|
15
|
+
from pydantic import BaseModel, Field, create_model
|
4
16
|
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
|
5
17
|
from pathlib import Path
|
18
|
+
import re
|
19
|
+
import ast
|
6
20
|
|
7
21
|
from .question_base import QuestionBase
|
8
22
|
from .descriptors import (
|
@@ -16,9 +30,251 @@ from .exceptions import QuestionCreationValidationError
|
|
16
30
|
from .decorators import inject_exception
|
17
31
|
|
18
32
|
|
33
|
+
def _parse_type_string(type_str: str) -> Any:
|
34
|
+
"""
|
35
|
+
Very simplistic parser that can map:
|
36
|
+
- "int" -> int
|
37
|
+
- "float" -> float
|
38
|
+
- "str" -> str
|
39
|
+
- "list[str]" -> List[str]
|
40
|
+
- ...
|
41
|
+
Expand this as needed for more advanced usage.
|
42
|
+
"""
|
43
|
+
type_str = type_str.strip().lower()
|
44
|
+
if type_str == "int":
|
45
|
+
return int
|
46
|
+
elif type_str == "float":
|
47
|
+
return float
|
48
|
+
elif type_str == "str":
|
49
|
+
return str
|
50
|
+
elif type_str == "list":
|
51
|
+
return List[Any]
|
52
|
+
elif type_str.startswith("list["):
|
53
|
+
# e.g. "list[str]" or "list[int]" etc.
|
54
|
+
inner = type_str[len("list["):-1].strip()
|
55
|
+
return List[_parse_type_string(inner)]
|
56
|
+
# If none matched, return a very permissive type or raise an error
|
57
|
+
return Any
|
58
|
+
|
59
|
+
|
60
|
+
def create_dict_response(
|
61
|
+
answer_keys: List[str],
|
62
|
+
value_types: List[str],
|
63
|
+
permissive: bool = False,
|
64
|
+
) -> Type[BaseModel]:
|
65
|
+
"""
|
66
|
+
Dynamically builds a Pydantic model that has:
|
67
|
+
- an `answer` submodel containing your required keys
|
68
|
+
- an optional `comment` field
|
69
|
+
|
70
|
+
If `permissive=False`, extra keys in `answer` are forbidden.
|
71
|
+
If `permissive=True`, extra keys in `answer` are allowed.
|
72
|
+
"""
|
73
|
+
|
74
|
+
# 1) Build the 'answer' submodel fields
|
75
|
+
# Each key is required (using `...`), with the associated type from value_types.
|
76
|
+
field_definitions = {}
|
77
|
+
for key, t_str in zip(answer_keys, value_types):
|
78
|
+
python_type = _parse_type_string(t_str)
|
79
|
+
field_definitions[key] = (python_type, Field(...))
|
80
|
+
|
81
|
+
# Use Pydantic's create_model to construct an "AnswerSubModel" with these fields
|
82
|
+
AnswerSubModel = create_model(
|
83
|
+
"AnswerSubModel",
|
84
|
+
__base__=BaseModel,
|
85
|
+
**field_definitions
|
86
|
+
)
|
87
|
+
|
88
|
+
# 2) Define the top-level model with `answer` + optional `comment`
|
89
|
+
class DictResponse(BaseModel):
|
90
|
+
answer: AnswerSubModel
|
91
|
+
comment: Optional[str] = None
|
92
|
+
generated_tokens: Optional[Any] = Field(None)
|
93
|
+
|
94
|
+
class Config:
|
95
|
+
# If permissive=False, forbid extra keys in `answer`
|
96
|
+
# If permissive=True, allow them
|
97
|
+
extra = "allow" if permissive else "forbid"
|
98
|
+
|
99
|
+
return DictResponse
|
100
|
+
|
101
|
+
|
19
102
|
class DictResponseValidator(ResponseValidatorABC):
|
103
|
+
"""
|
104
|
+
Validator for dictionary responses with specific keys and value types.
|
105
|
+
|
106
|
+
This validator ensures that:
|
107
|
+
1. All required keys are present in the answer
|
108
|
+
2. Each value has the correct type as specified
|
109
|
+
3. Extra keys are forbidden unless permissive=True
|
110
|
+
|
111
|
+
Examples:
|
112
|
+
>>> from edsl.questions import QuestionDict
|
113
|
+
>>> q = QuestionDict(
|
114
|
+
... question_name="recipe",
|
115
|
+
... question_text="Describe a recipe",
|
116
|
+
... answer_keys=["name", "ingredients", "steps"],
|
117
|
+
... value_types=["str", "list[str]", "list[str]"]
|
118
|
+
... )
|
119
|
+
>>> validator = q.response_validator
|
120
|
+
>>> result = validator.validate({
|
121
|
+
... "answer": {
|
122
|
+
... "name": "Pancakes",
|
123
|
+
... "ingredients": ["flour", "milk", "eggs"],
|
124
|
+
... "steps": ["Mix", "Cook", "Serve"]
|
125
|
+
... }
|
126
|
+
... })
|
127
|
+
>>> sorted(result.keys())
|
128
|
+
['answer', 'comment', 'generated_tokens']
|
129
|
+
"""
|
20
130
|
required_params = ["answer_keys", "permissive"]
|
21
131
|
|
132
|
+
def fix(self, response, verbose=False):
|
133
|
+
"""
|
134
|
+
Attempt to fix an invalid dictionary response.
|
135
|
+
|
136
|
+
Examples:
|
137
|
+
>>> # Set up validator with proper response model
|
138
|
+
>>> from pydantic import BaseModel, create_model, Field
|
139
|
+
>>> from typing import Optional
|
140
|
+
>>> # Create a proper response model that matches our expected structure
|
141
|
+
>>> AnswerModel = create_model('AnswerModel', name=(str, ...), age=(int, ...))
|
142
|
+
>>> ResponseModel = create_model(
|
143
|
+
... 'ResponseModel',
|
144
|
+
... answer=(AnswerModel, ...),
|
145
|
+
... comment=(Optional[str], None),
|
146
|
+
... generated_tokens=(Optional[Any], None)
|
147
|
+
... )
|
148
|
+
>>> validator = DictResponseValidator(
|
149
|
+
... response_model=ResponseModel,
|
150
|
+
... answer_keys=["name", "age"],
|
151
|
+
... permissive=False
|
152
|
+
... )
|
153
|
+
>>> validator.value_types = ["str", "int"]
|
154
|
+
|
155
|
+
# Fix dictionary with comment on same line
|
156
|
+
>>> response = "{'name': 'john', 'age': 23} Here you go."
|
157
|
+
>>> result = validator.fix(response)
|
158
|
+
>>> dict(result['answer']) # Convert to dict for consistent output
|
159
|
+
{'name': 'john', 'age': 23}
|
160
|
+
>>> result['comment']
|
161
|
+
'Here you go.'
|
162
|
+
|
163
|
+
# Fix type conversion (string to int)
|
164
|
+
>>> response = {"answer": {"name": "john", "age": "23"}}
|
165
|
+
>>> result = validator.fix(response)
|
166
|
+
>>> dict(result['answer']) # Convert to dict for consistent output
|
167
|
+
{'name': 'john', 'age': 23}
|
168
|
+
|
169
|
+
# Fix list from comma-separated string
|
170
|
+
>>> AnswerModel2 = create_model('AnswerModel2', name=(str, ...), hobbies=(List[str], ...))
|
171
|
+
>>> ResponseModel2 = create_model(
|
172
|
+
... 'ResponseModel2',
|
173
|
+
... answer=(AnswerModel2, ...),
|
174
|
+
... comment=(Optional[str], None),
|
175
|
+
... generated_tokens=(Optional[Any], None)
|
176
|
+
... )
|
177
|
+
>>> validator = DictResponseValidator(
|
178
|
+
... response_model=ResponseModel2,
|
179
|
+
... answer_keys=["name", "hobbies"],
|
180
|
+
... permissive=False
|
181
|
+
... )
|
182
|
+
>>> validator.value_types = ["str", "list[str]"]
|
183
|
+
>>> response = {"answer": {"name": "john", "hobbies": "reading, gaming, coding"}}
|
184
|
+
>>> result = validator.fix(response)
|
185
|
+
>>> dict(result['answer']) # Convert to dict for consistent output
|
186
|
+
{'name': 'john', 'hobbies': ['reading', 'gaming', 'coding']}
|
187
|
+
|
188
|
+
# Handle invalid input gracefully
|
189
|
+
>>> response = "not a dictionary"
|
190
|
+
>>> validator.fix(response)
|
191
|
+
'not a dictionary'
|
192
|
+
"""
|
193
|
+
# First try to separate dictionary from trailing comment if they're on the same line
|
194
|
+
if isinstance(response, str):
|
195
|
+
# Try to find where the dictionary ends and comment begins
|
196
|
+
try:
|
197
|
+
dict_match = re.match(r'(\{.*?\})(.*)', response.strip())
|
198
|
+
if dict_match:
|
199
|
+
dict_str, comment = dict_match.groups()
|
200
|
+
try:
|
201
|
+
answer_dict = ast.literal_eval(dict_str)
|
202
|
+
response = {
|
203
|
+
"answer": answer_dict,
|
204
|
+
"comment": comment.strip() if comment.strip() else None
|
205
|
+
}
|
206
|
+
except (ValueError, SyntaxError):
|
207
|
+
pass
|
208
|
+
except Exception:
|
209
|
+
pass
|
210
|
+
|
211
|
+
# Continue with existing fix logic
|
212
|
+
if "answer" not in response or not isinstance(response["answer"], dict):
|
213
|
+
if verbose:
|
214
|
+
print("Cannot fix response: 'answer' field missing or not a dictionary")
|
215
|
+
return response
|
216
|
+
|
217
|
+
answer_dict = response["answer"]
|
218
|
+
fixed_answer = {}
|
219
|
+
|
220
|
+
# Try to convert values to expected types
|
221
|
+
for key, type_str in zip(self.answer_keys, getattr(self, "value_types", [])):
|
222
|
+
if key in answer_dict:
|
223
|
+
value = answer_dict[key]
|
224
|
+
# Try type conversion based on the expected type
|
225
|
+
if type_str == "int" and not isinstance(value, int):
|
226
|
+
try:
|
227
|
+
fixed_answer[key] = int(value)
|
228
|
+
if verbose:
|
229
|
+
print(f"Converted '{key}' from {type(value).__name__} to int")
|
230
|
+
continue
|
231
|
+
except (ValueError, TypeError):
|
232
|
+
pass
|
233
|
+
|
234
|
+
elif type_str == "float" and not isinstance(value, float):
|
235
|
+
try:
|
236
|
+
fixed_answer[key] = float(value)
|
237
|
+
if verbose:
|
238
|
+
print(f"Converted '{key}' from {type(value).__name__} to float")
|
239
|
+
continue
|
240
|
+
except (ValueError, TypeError):
|
241
|
+
pass
|
242
|
+
|
243
|
+
elif type_str.startswith("list[") and not isinstance(value, list):
|
244
|
+
# Try to convert string to list by splitting
|
245
|
+
if isinstance(value, str):
|
246
|
+
items = [item.strip() for item in value.split(",")]
|
247
|
+
fixed_answer[key] = items
|
248
|
+
if verbose:
|
249
|
+
print(f"Converted '{key}' from string to list: {items}")
|
250
|
+
continue
|
251
|
+
|
252
|
+
# If no conversion needed or possible, keep original
|
253
|
+
fixed_answer[key] = value
|
254
|
+
|
255
|
+
# Preserve any keys we didn't try to fix
|
256
|
+
for key, value in answer_dict.items():
|
257
|
+
if key not in fixed_answer:
|
258
|
+
fixed_answer[key] = value
|
259
|
+
|
260
|
+
# Return fixed response
|
261
|
+
fixed_response = {
|
262
|
+
"answer": fixed_answer,
|
263
|
+
"comment": response.get("comment"),
|
264
|
+
"generated_tokens": response.get("generated_tokens")
|
265
|
+
}
|
266
|
+
|
267
|
+
try:
|
268
|
+
# Validate the fixed answer
|
269
|
+
self.response_model.model_validate(fixed_response)
|
270
|
+
if verbose:
|
271
|
+
print("Successfully fixed response")
|
272
|
+
return fixed_response
|
273
|
+
except Exception as e:
|
274
|
+
if verbose:
|
275
|
+
print(f"Validation failed for fixed answer: {e}")
|
276
|
+
return response
|
277
|
+
|
22
278
|
valid_examples = [
|
23
279
|
(
|
24
280
|
{
|
@@ -42,17 +298,19 @@ class DictResponseValidator(ResponseValidatorABC):
|
|
42
298
|
),
|
43
299
|
(
|
44
300
|
{"answer": {"ingredients": "milk"}}, # Should be a list
|
45
|
-
{"answer_keys": ["ingredients"], "value_types": ["list"]},
|
301
|
+
{"answer_keys": ["ingredients"], "value_types": ["list[str]"]},
|
46
302
|
"Key 'ingredients' should be a list, got str",
|
47
303
|
)
|
48
304
|
]
|
49
305
|
|
50
306
|
|
51
307
|
class QuestionDict(QuestionBase):
|
52
|
-
"""
|
308
|
+
"""A QuestionDict allows you to create questions that expect dictionary responses
|
309
|
+
with specific keys and value types. It dynamically builds a pydantic model
|
310
|
+
so that Pydantic automatically raises ValidationError for missing/invalid fields.
|
311
|
+
|
312
|
+
Documentation: https://docs.expectedparrot.com/en/latest/questions.html#questiondict
|
53
313
|
|
54
|
-
Documenation: https://docs.expectedparrot.com/en/latest/questions.html#questiondict
|
55
|
-
|
56
314
|
Parameters
|
57
315
|
----------
|
58
316
|
question_name : str
|
@@ -73,16 +331,8 @@ class QuestionDict(QuestionBase):
|
|
73
331
|
Additional instructions for answering
|
74
332
|
permissive : bool
|
75
333
|
If True, allows additional keys not specified in answer_keys
|
76
|
-
|
77
|
-
Examples
|
78
|
-
--------
|
79
|
-
>>> q = QuestionDict(
|
80
|
-
... question_name="tweet",
|
81
|
-
... question_text="Draft a tweet.",
|
82
|
-
... answer_keys=["text", "characters"],
|
83
|
-
... value_descriptions=["The text of the tweet", "The number of characters in the tweet"]
|
84
|
-
... )
|
85
334
|
"""
|
335
|
+
|
86
336
|
question_type = "dict"
|
87
337
|
question_text: str = QuestionTextDescriptor()
|
88
338
|
answer_keys: List[str] = AnswerKeysDescriptor()
|
@@ -92,121 +342,6 @@ class QuestionDict(QuestionBase):
|
|
92
342
|
_response_model = None
|
93
343
|
response_validator_class = DictResponseValidator
|
94
344
|
|
95
|
-
def _get_default_answer(self) -> Dict[str, Any]:
|
96
|
-
"""Get default answer based on types."""
|
97
|
-
answer = {}
|
98
|
-
if not self.value_types:
|
99
|
-
return {
|
100
|
-
"title": "Sample Recipe",
|
101
|
-
"ingredients": ["ingredient1", "ingredient2"],
|
102
|
-
"num_ingredients": 2,
|
103
|
-
"instructions": "Sample instructions"
|
104
|
-
}
|
105
|
-
|
106
|
-
for key, type_str in zip(self.answer_keys, self.value_types):
|
107
|
-
if type_str.startswith(('list[', 'list')):
|
108
|
-
if '[' in type_str:
|
109
|
-
element_type = type_str[type_str.index('[') + 1:type_str.rindex(']')].lower()
|
110
|
-
if element_type == 'str':
|
111
|
-
answer[key] = ["sample_string"]
|
112
|
-
elif element_type == 'int':
|
113
|
-
answer[key] = [1]
|
114
|
-
elif element_type == 'float':
|
115
|
-
answer[key] = [1.0]
|
116
|
-
else:
|
117
|
-
answer[key] = []
|
118
|
-
else:
|
119
|
-
answer[key] = []
|
120
|
-
else:
|
121
|
-
if type_str == 'str':
|
122
|
-
answer[key] = "sample_string"
|
123
|
-
elif type_str == 'int':
|
124
|
-
answer[key] = 1
|
125
|
-
elif type_str == 'float':
|
126
|
-
answer[key] = 1.0
|
127
|
-
else:
|
128
|
-
answer[key] = None
|
129
|
-
|
130
|
-
return answer
|
131
|
-
|
132
|
-
def create_response_model(
|
133
|
-
self,
|
134
|
-
) -> Type[BaseModel]:
|
135
|
-
"""Create a response model for dict questions."""
|
136
|
-
default_answer = self._get_default_answer()
|
137
|
-
|
138
|
-
class DictResponse(BaseModel):
|
139
|
-
answer: Dict[str, Any] = Field(
|
140
|
-
default_factory=lambda: default_answer.copy()
|
141
|
-
)
|
142
|
-
comment: Optional[str] = None
|
143
|
-
|
144
|
-
@field_validator("answer")
|
145
|
-
def validate_answer(cls, v, values, **kwargs):
|
146
|
-
# Ensure all keys exist
|
147
|
-
missing_keys = set(self.answer_keys) - set(v.keys())
|
148
|
-
if missing_keys:
|
149
|
-
raise ValueError(f"Missing required keys: {missing_keys}")
|
150
|
-
|
151
|
-
# Validate value types if not permissive
|
152
|
-
if not self.permissive and self.value_types:
|
153
|
-
for key, type_str in zip(self.answer_keys, self.value_types):
|
154
|
-
if key not in v:
|
155
|
-
continue
|
156
|
-
|
157
|
-
value = v[key]
|
158
|
-
type_str = type_str.lower() # Normalize to lowercase
|
159
|
-
|
160
|
-
# Handle list types
|
161
|
-
if type_str.startswith(('list[', 'list')):
|
162
|
-
if not isinstance(value, list):
|
163
|
-
raise ValueError(f"Key '{key}' should be a list, got {type(value).__name__}")
|
164
|
-
|
165
|
-
# If it's a parameterized list, check element types
|
166
|
-
if '[' in type_str:
|
167
|
-
element_type = type_str[type_str.index('[') + 1:type_str.rindex(']')]
|
168
|
-
element_type = element_type.lower().strip()
|
169
|
-
|
170
|
-
for i, elem in enumerate(value):
|
171
|
-
expected_type = {
|
172
|
-
'str': str,
|
173
|
-
'int': int,
|
174
|
-
'float': float,
|
175
|
-
'list': list
|
176
|
-
}.get(element_type)
|
177
|
-
|
178
|
-
if expected_type and not isinstance(elem, expected_type):
|
179
|
-
raise ValueError(
|
180
|
-
f"List element at index {i} for key '{key}' "
|
181
|
-
f"has type {type(elem).__name__}, expected {element_type}"
|
182
|
-
)
|
183
|
-
else:
|
184
|
-
# Handle basic types
|
185
|
-
expected_type = {
|
186
|
-
'str': str,
|
187
|
-
'int': int,
|
188
|
-
'float': float,
|
189
|
-
'list': list,
|
190
|
-
}.get(type_str)
|
191
|
-
|
192
|
-
if expected_type and not isinstance(value, expected_type):
|
193
|
-
raise ValueError(
|
194
|
-
f"Key '{key}' has value of type {type(value).__name__}, expected {type_str}"
|
195
|
-
)
|
196
|
-
return v
|
197
|
-
|
198
|
-
model_config = {
|
199
|
-
"json_schema_extra": {
|
200
|
-
"examples": [{
|
201
|
-
"answer": default_answer,
|
202
|
-
"comment": None
|
203
|
-
}]
|
204
|
-
}
|
205
|
-
}
|
206
|
-
|
207
|
-
DictResponse.__name__ = "DictResponse"
|
208
|
-
return DictResponse
|
209
|
-
|
210
345
|
def __init__(
|
211
346
|
self,
|
212
347
|
question_name: str,
|
@@ -243,67 +378,54 @@ class QuestionDict(QuestionBase):
|
|
243
378
|
"Length of value_descriptions must match length of answer_keys."
|
244
379
|
)
|
245
380
|
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
return 'list'
|
257
|
-
|
258
|
-
# Handle string inputs
|
259
|
-
if isinstance(t, str):
|
260
|
-
t = t.lower()
|
261
|
-
# Handle list types
|
262
|
-
if t.startswith(('list[', 'list')):
|
263
|
-
if '[' in t:
|
264
|
-
# Normalize the inner type
|
265
|
-
inner_type = t[t.index('[') + 1:t.rindex(']')].strip().lower()
|
266
|
-
return f"list[{inner_type}]"
|
267
|
-
return "list"
|
268
|
-
return t
|
269
|
-
|
270
|
-
# Handle List the same as list
|
271
|
-
if t_str == "<class 'List'>":
|
272
|
-
return "list"
|
273
|
-
|
274
|
-
# If it's list type
|
275
|
-
if t is list:
|
276
|
-
return "list"
|
277
|
-
|
278
|
-
# If it's a basic type
|
279
|
-
if hasattr(t, "__name__"):
|
280
|
-
return t.__name__.lower()
|
281
|
-
|
282
|
-
# If it's a typing.List
|
283
|
-
if t_str.startswith(('list[', 'list')):
|
284
|
-
return t_str.replace('typing.', '').lower()
|
285
|
-
|
286
|
-
# Handle generic types
|
287
|
-
if hasattr(t, "__origin__"):
|
288
|
-
origin = t.__origin__.__name__.lower()
|
289
|
-
args = [
|
290
|
-
arg.__name__.lower() if hasattr(arg, "__name__") else str(arg).lower()
|
291
|
-
for arg in t.__args__
|
292
|
-
]
|
293
|
-
return f"{origin}[{', '.join(args)}]"
|
381
|
+
def create_response_model(self) -> Type[BaseModel]:
|
382
|
+
"""
|
383
|
+
Build and return the Pydantic model that should parse/validate user answers.
|
384
|
+
This is similar to `QuestionNumerical.create_response_model`, but for dicts.
|
385
|
+
"""
|
386
|
+
return create_dict_response(
|
387
|
+
answer_keys=self.answer_keys,
|
388
|
+
value_types=self.value_types or [],
|
389
|
+
permissive=self.permissive
|
390
|
+
)
|
294
391
|
|
295
|
-
|
296
|
-
|
297
|
-
|
392
|
+
def _get_default_answer(self) -> Dict[str, Any]:
|
393
|
+
"""Build a default example answer based on the declared types."""
|
394
|
+
if not self.value_types:
|
395
|
+
# If user didn't specify types, return some default structure
|
396
|
+
return {
|
397
|
+
"title": "Sample Recipe",
|
398
|
+
"ingredients": ["ingredient1", "ingredient2"],
|
399
|
+
"num_ingredients": 2,
|
400
|
+
"instructions": "Sample instructions"
|
401
|
+
}
|
298
402
|
|
299
|
-
|
300
|
-
for
|
301
|
-
|
302
|
-
|
303
|
-
|
304
|
-
|
305
|
-
|
306
|
-
|
403
|
+
answer = {}
|
404
|
+
for key, type_str in zip(self.answer_keys, self.value_types):
|
405
|
+
t_str = type_str.lower()
|
406
|
+
if t_str.startswith("list["):
|
407
|
+
# e.g. list[str], list[int], etc.
|
408
|
+
inner = t_str[len("list["):-1].strip()
|
409
|
+
if inner == "str":
|
410
|
+
answer[key] = ["sample_string"]
|
411
|
+
elif inner == "int":
|
412
|
+
answer[key] = [1]
|
413
|
+
elif inner == "float":
|
414
|
+
answer[key] = [1.0]
|
415
|
+
else:
|
416
|
+
answer[key] = []
|
417
|
+
elif t_str == "str":
|
418
|
+
answer[key] = "sample_string"
|
419
|
+
elif t_str == "int":
|
420
|
+
answer[key] = 1
|
421
|
+
elif t_str == "float":
|
422
|
+
answer[key] = 1.0
|
423
|
+
elif t_str == "list":
|
424
|
+
answer[key] = []
|
425
|
+
else:
|
426
|
+
# fallback
|
427
|
+
answer[key] = None
|
428
|
+
return answer
|
307
429
|
|
308
430
|
def _render_template(self, template_name: str) -> str:
|
309
431
|
"""Render a template using Jinja."""
|
@@ -322,6 +444,34 @@ class QuestionDict(QuestionBase):
|
|
322
444
|
except TemplateNotFound:
|
323
445
|
return f"Template {template_name} not found in {template_dir}."
|
324
446
|
|
447
|
+
@staticmethod
|
448
|
+
def _normalize_value_types(value_types: Optional[List[Union[str, type]]]) -> Optional[List[str]]:
|
449
|
+
"""
|
450
|
+
Convert all value_types to string representations (e.g. "int", "list[str]", etc.).
|
451
|
+
This logic is similar to your original approach but expanded to handle
|
452
|
+
python `type` objects as well as string hints.
|
453
|
+
"""
|
454
|
+
if not value_types:
|
455
|
+
return None
|
456
|
+
|
457
|
+
def normalize_type(t) -> str:
|
458
|
+
# Already a string?
|
459
|
+
if isinstance(t, str):
|
460
|
+
return t.lower().strip()
|
461
|
+
|
462
|
+
# It's a Python built-in type?
|
463
|
+
if hasattr(t, "__name__"):
|
464
|
+
if t.__name__ == "List":
|
465
|
+
return "list"
|
466
|
+
# For int, float, str, etc.
|
467
|
+
return t.__name__.lower()
|
468
|
+
|
469
|
+
# If it's a generic type like List[str], parse from its __origin__ / __args__
|
470
|
+
# or fallback:
|
471
|
+
return str(t).lower()
|
472
|
+
|
473
|
+
return [normalize_type(t) for t in value_types]
|
474
|
+
|
325
475
|
def to_dict(self, add_edsl_version: bool = True) -> dict:
|
326
476
|
"""Serialize to JSON-compatible dictionary."""
|
327
477
|
return {
|
@@ -366,12 +516,13 @@ class QuestionDict(QuestionBase):
|
|
366
516
|
)
|
367
517
|
|
368
518
|
def _simulate_answer(self) -> dict:
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
519
|
+
"""Simulate an answer for the question."""
|
520
|
+
return {
|
521
|
+
"answer": self._get_default_answer(),
|
522
|
+
"comment": None
|
523
|
+
}
|
524
|
+
|
374
525
|
|
375
526
|
if __name__ == "__main__":
|
376
527
|
q = QuestionDict.example()
|
377
|
-
print(q.to_dict())
|
528
|
+
print(q.to_dict())
|