edsl 0.1.40.dev2__py3-none-any.whl → 0.1.41__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.
@@ -0,0 +1,343 @@
1
+ from __future__ import annotations
2
+ from typing import Union, Optional, Dict, List, Any, Type
3
+ from pydantic import BaseModel, Field, field_validator
4
+ from jinja2 import Environment, FileSystemLoader, TemplateNotFound
5
+ from pathlib import Path
6
+
7
+ from edsl.questions.QuestionBase import QuestionBase
8
+ from edsl.questions.descriptors import (
9
+ AnswerKeysDescriptor,
10
+ ValueTypesDescriptor,
11
+ ValueDescriptionsDescriptor,
12
+ QuestionTextDescriptor,
13
+ )
14
+ from edsl.questions.response_validator_abc import ResponseValidatorABC
15
+ from edsl.exceptions.questions import QuestionCreationValidationError
16
+ from edsl.questions.decorators import inject_exception
17
+
18
+
19
+ class DictResponseValidator(ResponseValidatorABC):
20
+ required_params = ["answer_keys", "permissive"]
21
+
22
+ valid_examples = [
23
+ (
24
+ {
25
+ "answer": {
26
+ "name": "Hot Chocolate",
27
+ "num_ingredients": 5,
28
+ "ingredients": ["milk", "cocoa", "sugar"]
29
+ }
30
+ },
31
+ {
32
+ "answer_keys": ["name", "num_ingredients", "ingredients"],
33
+ "value_types": ["str", "int", "list[str]"]
34
+ },
35
+ )
36
+ ]
37
+ invalid_examples = [
38
+ (
39
+ {"answer": {"name": 123}}, # Name should be a string
40
+ {"answer_keys": ["name"], "value_types": ["str"]},
41
+ "Key 'name' has value of type int, expected str",
42
+ ),
43
+ (
44
+ {"answer": {"ingredients": "milk"}}, # Should be a list
45
+ {"answer_keys": ["ingredients"], "value_types": ["list"]},
46
+ "Key 'ingredients' should be a list, got str",
47
+ )
48
+ ]
49
+
50
+
51
+ class QuestionDict(QuestionBase):
52
+ question_type = "dict"
53
+ question_text: str = QuestionTextDescriptor()
54
+ answer_keys: List[str] = AnswerKeysDescriptor()
55
+ value_types: Optional[List[str]] = ValueTypesDescriptor()
56
+ value_descriptions: Optional[List[str]] = ValueDescriptionsDescriptor()
57
+
58
+ _response_model = None
59
+ response_validator_class = DictResponseValidator
60
+
61
+ def _get_default_answer(self) -> Dict[str, Any]:
62
+ """Get default answer based on types."""
63
+ answer = {}
64
+ if not self.value_types:
65
+ return {
66
+ "title": "Sample Recipe",
67
+ "ingredients": ["ingredient1", "ingredient2"],
68
+ "num_ingredients": 2,
69
+ "instructions": "Sample instructions"
70
+ }
71
+
72
+ for key, type_str in zip(self.answer_keys, self.value_types):
73
+ if type_str.startswith(('list[', 'list')):
74
+ if '[' in type_str:
75
+ element_type = type_str[type_str.index('[') + 1:type_str.rindex(']')].lower()
76
+ if element_type == 'str':
77
+ answer[key] = ["sample_string"]
78
+ elif element_type == 'int':
79
+ answer[key] = [1]
80
+ elif element_type == 'float':
81
+ answer[key] = [1.0]
82
+ else:
83
+ answer[key] = []
84
+ else:
85
+ answer[key] = []
86
+ else:
87
+ if type_str == 'str':
88
+ answer[key] = "sample_string"
89
+ elif type_str == 'int':
90
+ answer[key] = 1
91
+ elif type_str == 'float':
92
+ answer[key] = 1.0
93
+ else:
94
+ answer[key] = None
95
+
96
+ return answer
97
+
98
+ def create_response_model(
99
+ self,
100
+ ) -> Type[BaseModel]:
101
+ """Create a response model for dict questions."""
102
+ default_answer = self._get_default_answer()
103
+
104
+ class DictResponse(BaseModel):
105
+ answer: Dict[str, Any] = Field(
106
+ default_factory=lambda: default_answer.copy()
107
+ )
108
+ comment: Optional[str] = None
109
+
110
+ @field_validator("answer")
111
+ def validate_answer(cls, v, values, **kwargs):
112
+ # Ensure all keys exist
113
+ missing_keys = set(self.answer_keys) - set(v.keys())
114
+ if missing_keys:
115
+ raise ValueError(f"Missing required keys: {missing_keys}")
116
+
117
+ # Validate value types if not permissive
118
+ if not self.permissive and self.value_types:
119
+ for key, type_str in zip(self.answer_keys, self.value_types):
120
+ if key not in v:
121
+ continue
122
+
123
+ value = v[key]
124
+ type_str = type_str.lower() # Normalize to lowercase
125
+
126
+ # Handle list types
127
+ if type_str.startswith(('list[', 'list')):
128
+ if not isinstance(value, list):
129
+ raise ValueError(f"Key '{key}' should be a list, got {type(value).__name__}")
130
+
131
+ # If it's a parameterized list, check element types
132
+ if '[' in type_str:
133
+ element_type = type_str[type_str.index('[') + 1:type_str.rindex(']')]
134
+ element_type = element_type.lower().strip()
135
+
136
+ for i, elem in enumerate(value):
137
+ expected_type = {
138
+ 'str': str,
139
+ 'int': int,
140
+ 'float': float,
141
+ 'list': list
142
+ }.get(element_type)
143
+
144
+ if expected_type and not isinstance(elem, expected_type):
145
+ raise ValueError(
146
+ f"List element at index {i} for key '{key}' "
147
+ f"has type {type(elem).__name__}, expected {element_type}"
148
+ )
149
+ else:
150
+ # Handle basic types
151
+ expected_type = {
152
+ 'str': str,
153
+ 'int': int,
154
+ 'float': float,
155
+ 'list': list,
156
+ }.get(type_str)
157
+
158
+ if expected_type and not isinstance(value, expected_type):
159
+ raise ValueError(
160
+ f"Key '{key}' has value of type {type(value).__name__}, expected {type_str}"
161
+ )
162
+ return v
163
+
164
+ model_config = {
165
+ "json_schema_extra": {
166
+ "examples": [{
167
+ "answer": default_answer,
168
+ "comment": None
169
+ }]
170
+ }
171
+ }
172
+
173
+ DictResponse.__name__ = "DictResponse"
174
+ return DictResponse
175
+
176
+ def __init__(
177
+ self,
178
+ question_name: str,
179
+ question_text: str,
180
+ answer_keys: List[str],
181
+ value_types: Optional[List[Union[str, type]]] = None,
182
+ value_descriptions: Optional[List[str]] = None,
183
+ include_comment: bool = True,
184
+ question_presentation: Optional[str] = None,
185
+ answering_instructions: Optional[str] = None,
186
+ permissive: bool = False,
187
+ ):
188
+ self.question_name = question_name
189
+ self.question_text = question_text
190
+ self.answer_keys = answer_keys
191
+ self.value_types = self._normalize_value_types(value_types)
192
+ self.value_descriptions = value_descriptions
193
+ self.include_comment = include_comment
194
+ self.question_presentation = question_presentation or self._render_template(
195
+ "question_presentation.jinja"
196
+ )
197
+ self.answering_instructions = answering_instructions or self._render_template(
198
+ "answering_instructions.jinja"
199
+ )
200
+ self.permissive = permissive
201
+
202
+ # Validation
203
+ if self.value_types and len(self.value_types) != len(self.answer_keys):
204
+ raise QuestionCreationValidationError(
205
+ "Length of value_types must match length of answer_keys."
206
+ )
207
+ if self.value_descriptions and len(self.value_descriptions) != len(self.answer_keys):
208
+ raise QuestionCreationValidationError(
209
+ "Length of value_descriptions must match length of answer_keys."
210
+ )
211
+
212
+ @staticmethod
213
+ def _normalize_value_types(value_types: Optional[List[Union[str, type]]]) -> Optional[List[str]]:
214
+ """Convert all value_types to string representations, including type hints."""
215
+ if not value_types:
216
+ return None
217
+
218
+ def normalize_type(t) -> str:
219
+ # Handle string representations of List
220
+ t_str = str(t)
221
+ if t_str == 'List':
222
+ return 'list'
223
+
224
+ # Handle string inputs
225
+ if isinstance(t, str):
226
+ t = t.lower()
227
+ # Handle list types
228
+ if t.startswith(('list[', 'list')):
229
+ if '[' in t:
230
+ # Normalize the inner type
231
+ inner_type = t[t.index('[') + 1:t.rindex(']')].strip().lower()
232
+ return f"list[{inner_type}]"
233
+ return "list"
234
+ return t
235
+
236
+ # Handle List the same as list
237
+ if t_str == "<class 'List'>":
238
+ return "list"
239
+
240
+ # If it's list type
241
+ if t is list:
242
+ return "list"
243
+
244
+ # If it's a basic type
245
+ if hasattr(t, "__name__"):
246
+ return t.__name__.lower()
247
+
248
+ # If it's a typing.List
249
+ if t_str.startswith(('list[', 'list')):
250
+ return t_str.replace('typing.', '').lower()
251
+
252
+ # Handle generic types
253
+ if hasattr(t, "__origin__"):
254
+ origin = t.__origin__.__name__.lower()
255
+ args = [
256
+ arg.__name__.lower() if hasattr(arg, "__name__") else str(arg).lower()
257
+ for arg in t.__args__
258
+ ]
259
+ return f"{origin}[{', '.join(args)}]"
260
+
261
+ raise QuestionCreationValidationError(
262
+ f"Invalid type in value_types: {t}. Must be a type or string."
263
+ )
264
+
265
+ normalized = []
266
+ for t in value_types:
267
+ try:
268
+ normalized.append(normalize_type(t))
269
+ except Exception as e:
270
+ raise QuestionCreationValidationError(f"Error normalizing type {t}: {str(e)}")
271
+
272
+ return normalized
273
+
274
+ def _render_template(self, template_name: str) -> str:
275
+ """Render a template using Jinja."""
276
+ try:
277
+ template_dir = Path(__file__).parent / "templates" / "dict"
278
+ env = Environment(loader=FileSystemLoader(template_dir))
279
+ template = env.get_template(template_name)
280
+ return template.render(
281
+ question_name=self.question_name,
282
+ question_text=self.question_text,
283
+ answer_keys=self.answer_keys,
284
+ value_types=self.value_types,
285
+ value_descriptions=self.value_descriptions,
286
+ include_comment=self.include_comment,
287
+ )
288
+ except TemplateNotFound:
289
+ return f"Template {template_name} not found in {template_dir}."
290
+
291
+ def to_dict(self, add_edsl_version: bool = True) -> dict:
292
+ """Serialize to JSON-compatible dictionary."""
293
+ return {
294
+ "question_type": self.question_type,
295
+ "question_name": self.question_name,
296
+ "question_text": self.question_text,
297
+ "answer_keys": self.answer_keys,
298
+ "value_types": self.value_types or [],
299
+ "value_descriptions": self.value_descriptions or [],
300
+ "include_comment": self.include_comment,
301
+ "permissive": self.permissive,
302
+ }
303
+
304
+ @classmethod
305
+ def from_dict(cls, data: dict) -> 'QuestionDict':
306
+ """Recreate from a dictionary."""
307
+ return cls(
308
+ question_name=data["question_name"],
309
+ question_text=data["question_text"],
310
+ answer_keys=data["answer_keys"],
311
+ value_types=data.get("value_types"),
312
+ value_descriptions=data.get("value_descriptions"),
313
+ include_comment=data.get("include_comment", True),
314
+ permissive=data.get("permissive", False),
315
+ )
316
+
317
+ @classmethod
318
+ @inject_exception
319
+ def example(cls) -> 'QuestionDict':
320
+ """Return an example question."""
321
+ return cls(
322
+ question_name="example",
323
+ question_text="Please provide a simple recipe for hot chocolate.",
324
+ answer_keys=["title", "ingredients", "num_ingredients", "instructions"],
325
+ value_types=["str", "list[str]", "int", "str"],
326
+ value_descriptions=[
327
+ "The title of the recipe.",
328
+ "A list of ingredients.",
329
+ "The number of ingredients.",
330
+ "The instructions for making the recipe."
331
+ ],
332
+ )
333
+
334
+ def _simulate_answer(self) -> dict:
335
+ """Simulate an answer for the question."""
336
+ return {
337
+ "answer": self._get_default_answer(),
338
+ "comment": None
339
+ }
340
+
341
+ if __name__ == "__main__":
342
+ q = QuestionDict.example()
343
+ print(q.to_dict())
@@ -50,7 +50,7 @@ def extract_json(text, expected_keys, verbose=False):
50
50
 
51
51
  def dict_to_pydantic_model(input_dict: Dict[str, Any]) -> Any:
52
52
  field_definitions = {
53
- key: (str, Field(default=str(value))) for key, value in input_dict.items()
53
+ key: (type(value), Field(default=value)) for key, value in input_dict.items()
54
54
  }
55
55
 
56
56
  DynamicModel = create_model("DynamicModel", **field_definitions)
@@ -12,6 +12,7 @@ from edsl.questions.QuestionFreeText import QuestionFreeText
12
12
  from edsl.questions.QuestionFunctional import QuestionFunctional
13
13
  from edsl.questions.QuestionList import QuestionList
14
14
  from edsl.questions.QuestionMatrix import QuestionMatrix
15
+ from edsl.questions.QuestionDict import QuestionDict
15
16
  from edsl.questions.QuestionMultipleChoice import QuestionMultipleChoice
16
17
  from edsl.questions.QuestionNumerical import QuestionNumerical
17
18
  from edsl.questions.QuestionBudget import QuestionBudget
@@ -324,6 +324,35 @@ class AnswerValidatorMixin:
324
324
  f"Must be one of: {valid_options}"
325
325
  )
326
326
 
327
+ def _validate_answer_dict(self, answer: dict[str, Any]) -> None:
328
+ """Validate QuestionDict-specific answer.
329
+
330
+ Check that answer["answer"]:
331
+ - is a dictionary
332
+ - has all required answer_keys as keys
333
+ """
334
+ value = answer.get("answer")
335
+
336
+ # Check that answer is a dictionary
337
+ if not isinstance(value, dict):
338
+ raise QuestionAnswerValidationError(
339
+ f"Dict answer must be a dictionary mapping values to specified keys (got {value})"
340
+ )
341
+
342
+ # Check that all required answer keys are present
343
+ required_keys = set(self.answer_keys)
344
+ provided_keys = set(value.keys())
345
+
346
+ if missing_keys := (required_keys - provided_keys):
347
+ raise QuestionAnswerValidationError(
348
+ f"Missing required keys: {missing_keys}"
349
+ )
350
+
351
+ if extra_keys := (provided_keys - required_keys):
352
+ raise QuestionAnswerValidationError(
353
+ f"Unexpected keys: {extra_keys}"
354
+ )
355
+
327
356
 
328
357
  if __name__ == "__main__":
329
358
  pass
@@ -36,7 +36,7 @@ class QuestionLinearScale(QuestionMultipleChoice):
36
36
  question_name=question_name,
37
37
  question_text=question_text,
38
38
  question_options=question_options,
39
- use_code=False, # question linear scale will have it's own code
39
+ use_code=False, # question linear scale will have its own code
40
40
  include_comment=include_comment,
41
41
  )
42
42
  self.question_options = question_options
@@ -421,6 +421,50 @@ class QuestionTextDescriptor(BaseDescriptor):
421
421
  return None
422
422
 
423
423
 
424
+ class ValueTypesDescriptor(BaseDescriptor):
425
+ def validate(self, value, instance):
426
+ """Validate the value is a list of strings or None."""
427
+ if value is None: # Allow None as a valid value
428
+ return None
429
+ if not isinstance(value, list):
430
+ raise QuestionCreationValidationError(
431
+ f"`value_types` must be a list or None (got {value})."
432
+ )
433
+ # Convert all items in the list to strings
434
+ return [str(item) for item in value]
435
+
436
+
437
+ class ValueDescriptionsDescriptor(BaseDescriptor):
438
+ def validate(self, value, instance):
439
+ """Validate the value is a list of strings or None."""
440
+ if value is None: # Allow None as a valid value
441
+ return None
442
+ if not isinstance(value, list):
443
+ raise QuestionCreationValidationError(
444
+ f"`value_descriptions` must be a list or None (got {value})."
445
+ )
446
+ if not all(isinstance(x, str) for x in value):
447
+ raise QuestionCreationValidationError(
448
+ f"`value_descriptions` must be a list of strings (got {value})."
449
+ )
450
+ return value
451
+
452
+
453
+ class AnswerKeysDescriptor(BaseDescriptor):
454
+ """Validate that the `answer_keys` attribute is a list of strings or integers."""
455
+
456
+ def validate(self, value, instance):
457
+ """Validate the value is a list of strings or integers."""
458
+ if not isinstance(value, list):
459
+ raise QuestionCreationValidationError(
460
+ f"`answer_keys` must be a list (got {value})."
461
+ )
462
+ if not all(isinstance(x, (str, int)) for x in value):
463
+ raise QuestionCreationValidationError(
464
+ f"`answer_keys` must be a list of strings or integers (got {value})."
465
+ )
466
+
467
+
424
468
  if __name__ == "__main__":
425
469
  import doctest
426
470
 
@@ -96,7 +96,7 @@ class Question(metaclass=Meta):
96
96
 
97
97
  >>> from edsl import Question
98
98
  >>> Question.list_question_types()
99
- ['checkbox', 'extract', 'free_text', 'functional', 'likert_five', 'linear_scale', 'list', 'matrix', 'multiple_choice', 'numerical', 'rank', 'top_k', 'yes_no']
99
+ ['checkbox', 'dict', 'extract', 'free_text', 'functional', 'likert_five', 'linear_scale', 'list', 'matrix', 'multiple_choice', 'numerical', 'rank', 'top_k', 'yes_no']
100
100
  """
101
101
  return [
102
102
  q
File without changes
@@ -0,0 +1,21 @@
1
+ Please respond with a dictionary using the following keys: {{ answer_keys | join(', ') }}.
2
+
3
+ {% if value_descriptions %}
4
+ Here are descriptions of the values to provide:
5
+ {% for idx in range(answer_keys | length) %}
6
+ - "{{ answer_keys[idx] }}": "{{ value_descriptions[idx] }}"
7
+ {% endfor %}
8
+ {% endif %}
9
+
10
+ {% if value_types %}
11
+ The values should be formatted in the following types:
12
+ {% for idx in range(answer_keys | length) %}
13
+ - "{{ answer_keys[idx] }}": "{{ value_types[idx] }}"
14
+ {% endfor %}
15
+ {% endif %}
16
+
17
+ If you do not have a value for a given key, use "null".
18
+
19
+ {% if include_comment %}
20
+ After the answer, you can put a comment explaining your response on the next line.
21
+ {% endif %}
@@ -0,0 +1 @@
1
+ {{question_text}}
edsl/results/Result.py CHANGED
@@ -56,6 +56,7 @@ class Result(Base, UserDict):
56
56
  comments_dict: Optional[dict] = None,
57
57
  cache_used_dict: Optional[dict[QuestionName, bool]] = None,
58
58
  indices: Optional[dict] = None,
59
+ cache_keys: Optional[dict[QuestionName, str]] = None,
59
60
  ):
60
61
  """Initialize a Result object.
61
62
 
@@ -90,6 +91,7 @@ class Result(Base, UserDict):
90
91
  "generated_tokens": generated_tokens or {},
91
92
  "comments_dict": comments_dict or {},
92
93
  "cache_used_dict": cache_used_dict or {},
94
+ "cache_keys": cache_keys or {},
93
95
  }
94
96
  super().__init__(**data)
95
97
  self.indices = indices
@@ -163,6 +165,7 @@ class Result(Base, UserDict):
163
165
 
164
166
  def _construct_sub_dicts(self) -> dict[str, dict]:
165
167
  """Construct a dictionary of sub-dictionaries for the Result object."""
168
+
166
169
  sub_dicts_needing_new_keys = {
167
170
  "question_text": {},
168
171
  "question_options": {},
@@ -181,6 +184,8 @@ class Result(Base, UserDict):
181
184
  f"{k}_cache_used": v for k, v in self.data["cache_used_dict"].items()
182
185
  }
183
186
 
187
+ cache_keys = {f"{k}_cache_key": v for k, v in self.data["cache_keys"].items()}
188
+
184
189
  d = {
185
190
  **self._create_agent_sub_dict(self.data["agent"]),
186
191
  **self._create_model_sub_dict(self.data["model"]),
@@ -195,11 +200,13 @@ class Result(Base, UserDict):
195
200
  "question_options": sub_dicts_needing_new_keys["question_options"],
196
201
  "question_type": sub_dicts_needing_new_keys["question_type"],
197
202
  "cache_used": new_cache_dict,
203
+ "cache_keys": cache_keys,
198
204
  }
199
205
  if hasattr(self, "indices") and self.indices is not None:
200
206
  d["agent"].update({"agent_index": self.indices["agent"]})
201
207
  d["scenario"].update({"scenario_index": self.indices["scenario"]})
202
208
  d["model"].update({"model_index": self.indices["model"]})
209
+
203
210
  return d
204
211
 
205
212
  @property
@@ -406,6 +413,7 @@ class Result(Base, UserDict):
406
413
  generated_tokens=json_dict.get("generated_tokens", {}),
407
414
  comments_dict=json_dict.get("comments_dict", {}),
408
415
  cache_used_dict=json_dict.get("cache_used_dict", {}),
416
+ cache_keys=json_dict.get("cache_keys", {}),
409
417
  )
410
418
  return result
411
419
 
@@ -459,6 +467,12 @@ class Result(Base, UserDict):
459
467
  question_results[result.question_name] = result
460
468
  return question_results
461
469
 
470
+ def get_cache_keys(model_response_objects) -> dict[str, bool]:
471
+ cache_keys = {}
472
+ for result in model_response_objects:
473
+ cache_keys[result.question_name] = result.cache_key
474
+ return cache_keys
475
+
462
476
  def get_generated_tokens_dict(answer_key_names) -> dict[str, str]:
463
477
  generated_tokens_dict = {
464
478
  k + "_generated_tokens": question_results[k].generated_tokens
@@ -523,6 +537,7 @@ class Result(Base, UserDict):
523
537
  generated_tokens_dict = get_generated_tokens_dict(answer_key_names)
524
538
  comments_dict = get_comments_dict(answer_key_names)
525
539
  answer_dict = {k: extracted_answers[k] for k in answer_key_names}
540
+ cache_keys = get_cache_keys(model_response_objects)
526
541
 
527
542
  question_name_to_prompts = get_question_name_to_prompts(model_response_objects)
528
543
  prompt_dictionary = get_prompt_dictionary(
@@ -546,6 +561,7 @@ class Result(Base, UserDict):
546
561
  comments_dict=comments_dict,
547
562
  cache_used_dict=cache_used_dictionary,
548
563
  indices=interview.indices,
564
+ cache_keys=cache_keys,
549
565
  )
550
566
  result.interview_hash = interview.initial_hash
551
567
  return result
edsl/results/Results.py CHANGED
@@ -90,6 +90,7 @@ class Results(UserList, Mixins, Base):
90
90
  "comment",
91
91
  "generated_tokens",
92
92
  "cache_used",
93
+ "cache_keys",
93
94
  ]
94
95
 
95
96
  def __init__(
@@ -109,6 +110,7 @@ class Results(UserList, Mixins, Base):
109
110
  :param created_columns: A list of strings that are created columns.
110
111
  :param job_uuid: A string representing the job UUID.
111
112
  :param total_results: An integer representing the total number of results.
113
+ :cache: A Cache object.
112
114
  """
113
115
  super().__init__(data)
114
116
  from edsl.data.Cache import Cache
@@ -138,6 +140,16 @@ class Results(UserList, Mixins, Base):
138
140
  }
139
141
  return d
140
142
 
143
+ def _cache_keys(self):
144
+ cache_keys = []
145
+ for result in self:
146
+ cache_keys.extend(list(result["cache_keys"].values()))
147
+ return cache_keys
148
+
149
+ def relevant_cache(self, cache: Cache) -> Cache:
150
+ cache_keys = self._cache_keys()
151
+ return cache.subset(cache_keys)
152
+
141
153
  def insert(self, item):
142
154
  item_order = getattr(item, "order", None)
143
155
  if item_order is not None:
@@ -170,12 +182,12 @@ class Results(UserList, Mixins, Base):
170
182
  """
171
183
  total_cost = 0
172
184
  for result in self:
173
- for key in result.raw_model_response:
185
+ for key in result["raw_model_response"]:
174
186
  if key.endswith("_cost"):
175
- result_cost = result.raw_model_response[key]
187
+ result_cost = result["raw_model_response"][key]
176
188
 
177
189
  question_name = key.removesuffix("_cost")
178
- cache_used = result.cache_used_dict[question_name]
190
+ cache_used = result["cache_used_dict"][question_name]
179
191
 
180
192
  if isinstance(result_cost, (int, float)):
181
193
  if include_cached_responses_in_cost:
@@ -349,7 +361,7 @@ class Results(UserList, Mixins, Base):
349
361
  self,
350
362
  sort: bool = False,
351
363
  add_edsl_version: bool = False,
352
- include_cache: bool = False,
364
+ include_cache: bool = True,
353
365
  include_task_history: bool = False,
354
366
  include_cache_info: bool = True,
355
367
  ) -> dict[str, Any]:
@@ -327,6 +327,38 @@ class FileStore(Scenario):
327
327
 
328
328
  return ConstructDownloadLink(self).create_link(custom_filename, style)
329
329
 
330
+ def to_pandas(self):
331
+ """
332
+ Convert the file content to a pandas DataFrame if supported by the file handler.
333
+
334
+ Returns:
335
+ pandas.DataFrame: The data from the file as a DataFrame
336
+
337
+ Raises:
338
+ AttributeError: If the file type's handler doesn't support pandas conversion
339
+ """
340
+ handler = FileMethods.get_handler(self.suffix)
341
+ if handler and hasattr(handler, "to_pandas"):
342
+ return handler(self.path).to_pandas()
343
+ raise AttributeError(
344
+ f"Converting {self.suffix} files to pandas DataFrame is not supported"
345
+ )
346
+
347
+ def __getattr__(self, name):
348
+ """
349
+ Delegate pandas DataFrame methods to the underlying DataFrame if this is a CSV file
350
+ """
351
+ if self.suffix == "csv":
352
+ # Get the pandas DataFrame
353
+ df = self.to_pandas()
354
+ # Check if the requested attribute exists in the DataFrame
355
+ if hasattr(df, name):
356
+ return getattr(df, name)
357
+ # If not a CSV or attribute doesn't exist in DataFrame, raise AttributeError
358
+ raise AttributeError(
359
+ f"'{self.__class__.__name__}' object has no attribute '{name}'"
360
+ )
361
+
330
362
 
331
363
  class CSVFileStore(FileStore):
332
364
  @classmethod