edsl 0.1.33.dev1__py3-none-any.whl → 0.1.33.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. edsl/TemplateLoader.py +24 -0
  2. edsl/__init__.py +8 -4
  3. edsl/agents/Agent.py +46 -14
  4. edsl/agents/AgentList.py +43 -0
  5. edsl/agents/Invigilator.py +125 -212
  6. edsl/agents/InvigilatorBase.py +140 -32
  7. edsl/agents/PromptConstructionMixin.py +43 -66
  8. edsl/agents/__init__.py +1 -0
  9. edsl/auto/AutoStudy.py +117 -0
  10. edsl/auto/StageBase.py +230 -0
  11. edsl/auto/StageGenerateSurvey.py +178 -0
  12. edsl/auto/StageLabelQuestions.py +125 -0
  13. edsl/auto/StagePersona.py +61 -0
  14. edsl/auto/StagePersonaDimensionValueRanges.py +88 -0
  15. edsl/auto/StagePersonaDimensionValues.py +74 -0
  16. edsl/auto/StagePersonaDimensions.py +69 -0
  17. edsl/auto/StageQuestions.py +73 -0
  18. edsl/auto/SurveyCreatorPipeline.py +21 -0
  19. edsl/auto/utilities.py +224 -0
  20. edsl/config.py +38 -39
  21. edsl/coop/PriceFetcher.py +58 -0
  22. edsl/coop/coop.py +39 -5
  23. edsl/data/Cache.py +35 -1
  24. edsl/data_transfer_models.py +120 -38
  25. edsl/enums.py +2 -0
  26. edsl/exceptions/language_models.py +25 -1
  27. edsl/exceptions/questions.py +62 -5
  28. edsl/exceptions/results.py +4 -0
  29. edsl/inference_services/AnthropicService.py +13 -11
  30. edsl/inference_services/AwsBedrock.py +19 -17
  31. edsl/inference_services/AzureAI.py +37 -20
  32. edsl/inference_services/GoogleService.py +16 -12
  33. edsl/inference_services/GroqService.py +2 -0
  34. edsl/inference_services/InferenceServiceABC.py +24 -0
  35. edsl/inference_services/MistralAIService.py +120 -0
  36. edsl/inference_services/OpenAIService.py +41 -50
  37. edsl/inference_services/TestService.py +71 -0
  38. edsl/inference_services/models_available_cache.py +0 -6
  39. edsl/inference_services/registry.py +4 -0
  40. edsl/jobs/Answers.py +10 -12
  41. edsl/jobs/FailedQuestion.py +78 -0
  42. edsl/jobs/Jobs.py +18 -13
  43. edsl/jobs/buckets/TokenBucket.py +39 -14
  44. edsl/jobs/interviews/Interview.py +297 -77
  45. edsl/jobs/interviews/InterviewExceptionEntry.py +83 -19
  46. edsl/jobs/interviews/interview_exception_tracking.py +0 -70
  47. edsl/jobs/interviews/retry_management.py +3 -1
  48. edsl/jobs/runners/JobsRunnerAsyncio.py +116 -70
  49. edsl/jobs/runners/JobsRunnerStatusMixin.py +1 -1
  50. edsl/jobs/tasks/QuestionTaskCreator.py +30 -23
  51. edsl/jobs/tasks/TaskHistory.py +131 -213
  52. edsl/language_models/LanguageModel.py +239 -129
  53. edsl/language_models/ModelList.py +2 -2
  54. edsl/language_models/RegisterLanguageModelsMeta.py +14 -29
  55. edsl/language_models/fake_openai_call.py +15 -0
  56. edsl/language_models/fake_openai_service.py +61 -0
  57. edsl/language_models/registry.py +15 -2
  58. edsl/language_models/repair.py +0 -19
  59. edsl/language_models/utilities.py +61 -0
  60. edsl/prompts/Prompt.py +52 -2
  61. edsl/questions/AnswerValidatorMixin.py +23 -26
  62. edsl/questions/QuestionBase.py +273 -242
  63. edsl/questions/QuestionBaseGenMixin.py +133 -0
  64. edsl/questions/QuestionBasePromptsMixin.py +266 -0
  65. edsl/questions/QuestionBudget.py +6 -0
  66. edsl/questions/QuestionCheckBox.py +227 -35
  67. edsl/questions/QuestionExtract.py +98 -27
  68. edsl/questions/QuestionFreeText.py +46 -29
  69. edsl/questions/QuestionFunctional.py +7 -0
  70. edsl/questions/QuestionList.py +141 -22
  71. edsl/questions/QuestionMultipleChoice.py +173 -64
  72. edsl/questions/QuestionNumerical.py +87 -46
  73. edsl/questions/QuestionRank.py +182 -24
  74. edsl/questions/RegisterQuestionsMeta.py +31 -12
  75. edsl/questions/ResponseValidatorABC.py +169 -0
  76. edsl/questions/__init__.py +3 -4
  77. edsl/questions/decorators.py +21 -0
  78. edsl/questions/derived/QuestionLikertFive.py +10 -5
  79. edsl/questions/derived/QuestionLinearScale.py +11 -1
  80. edsl/questions/derived/QuestionTopK.py +6 -0
  81. edsl/questions/derived/QuestionYesNo.py +16 -1
  82. edsl/questions/descriptors.py +43 -7
  83. edsl/questions/prompt_templates/question_budget.jinja +13 -0
  84. edsl/questions/prompt_templates/question_checkbox.jinja +32 -0
  85. edsl/questions/prompt_templates/question_extract.jinja +11 -0
  86. edsl/questions/prompt_templates/question_free_text.jinja +3 -0
  87. edsl/questions/prompt_templates/question_linear_scale.jinja +11 -0
  88. edsl/questions/prompt_templates/question_list.jinja +17 -0
  89. edsl/questions/prompt_templates/question_multiple_choice.jinja +33 -0
  90. edsl/questions/prompt_templates/question_numerical.jinja +37 -0
  91. edsl/questions/question_registry.py +6 -2
  92. edsl/questions/templates/__init__.py +0 -0
  93. edsl/questions/templates/checkbox/__init__.py +0 -0
  94. edsl/questions/templates/checkbox/answering_instructions.jinja +10 -0
  95. edsl/questions/templates/checkbox/question_presentation.jinja +22 -0
  96. edsl/questions/templates/extract/answering_instructions.jinja +7 -0
  97. edsl/questions/templates/extract/question_presentation.jinja +1 -0
  98. edsl/questions/templates/free_text/__init__.py +0 -0
  99. edsl/questions/templates/free_text/answering_instructions.jinja +0 -0
  100. edsl/questions/templates/free_text/question_presentation.jinja +1 -0
  101. edsl/questions/templates/likert_five/__init__.py +0 -0
  102. edsl/questions/templates/likert_five/answering_instructions.jinja +10 -0
  103. edsl/questions/templates/likert_five/question_presentation.jinja +12 -0
  104. edsl/questions/templates/linear_scale/__init__.py +0 -0
  105. edsl/questions/templates/linear_scale/answering_instructions.jinja +5 -0
  106. edsl/questions/templates/linear_scale/question_presentation.jinja +5 -0
  107. edsl/questions/templates/list/__init__.py +0 -0
  108. edsl/questions/templates/list/answering_instructions.jinja +4 -0
  109. edsl/questions/templates/list/question_presentation.jinja +5 -0
  110. edsl/questions/templates/multiple_choice/__init__.py +0 -0
  111. edsl/questions/templates/multiple_choice/answering_instructions.jinja +9 -0
  112. edsl/questions/templates/multiple_choice/html.jinja +0 -0
  113. edsl/questions/templates/multiple_choice/question_presentation.jinja +12 -0
  114. edsl/questions/templates/numerical/__init__.py +0 -0
  115. edsl/questions/templates/numerical/answering_instructions.jinja +8 -0
  116. edsl/questions/templates/numerical/question_presentation.jinja +7 -0
  117. edsl/questions/templates/rank/answering_instructions.jinja +11 -0
  118. edsl/questions/templates/rank/question_presentation.jinja +15 -0
  119. edsl/questions/templates/top_k/__init__.py +0 -0
  120. edsl/questions/templates/top_k/answering_instructions.jinja +8 -0
  121. edsl/questions/templates/top_k/question_presentation.jinja +22 -0
  122. edsl/questions/templates/yes_no/__init__.py +0 -0
  123. edsl/questions/templates/yes_no/answering_instructions.jinja +6 -0
  124. edsl/questions/templates/yes_no/question_presentation.jinja +12 -0
  125. edsl/results/Dataset.py +20 -0
  126. edsl/results/DatasetExportMixin.py +41 -47
  127. edsl/results/DatasetTree.py +145 -0
  128. edsl/results/Result.py +32 -5
  129. edsl/results/Results.py +131 -45
  130. edsl/results/ResultsDBMixin.py +3 -3
  131. edsl/results/Selector.py +118 -0
  132. edsl/results/tree_explore.py +115 -0
  133. edsl/scenarios/Scenario.py +10 -4
  134. edsl/scenarios/ScenarioList.py +348 -39
  135. edsl/scenarios/ScenarioListExportMixin.py +9 -0
  136. edsl/study/SnapShot.py +8 -1
  137. edsl/surveys/RuleCollection.py +2 -2
  138. edsl/surveys/Survey.py +634 -315
  139. edsl/surveys/SurveyExportMixin.py +71 -9
  140. edsl/surveys/SurveyFlowVisualizationMixin.py +2 -1
  141. edsl/surveys/SurveyQualtricsImport.py +75 -4
  142. edsl/surveys/instructions/ChangeInstruction.py +47 -0
  143. edsl/surveys/instructions/Instruction.py +34 -0
  144. edsl/surveys/instructions/InstructionCollection.py +77 -0
  145. edsl/surveys/instructions/__init__.py +0 -0
  146. edsl/templates/error_reporting/base.html +24 -0
  147. edsl/templates/error_reporting/exceptions_by_model.html +35 -0
  148. edsl/templates/error_reporting/exceptions_by_question_name.html +17 -0
  149. edsl/templates/error_reporting/exceptions_by_type.html +17 -0
  150. edsl/templates/error_reporting/interview_details.html +111 -0
  151. edsl/templates/error_reporting/interviews.html +10 -0
  152. edsl/templates/error_reporting/overview.html +5 -0
  153. edsl/templates/error_reporting/performance_plot.html +2 -0
  154. edsl/templates/error_reporting/report.css +74 -0
  155. edsl/templates/error_reporting/report.html +118 -0
  156. edsl/templates/error_reporting/report.js +25 -0
  157. {edsl-0.1.33.dev1.dist-info → edsl-0.1.33.dev2.dist-info}/METADATA +4 -2
  158. edsl-0.1.33.dev2.dist-info/RECORD +289 -0
  159. edsl/jobs/interviews/InterviewTaskBuildingMixin.py +0 -286
  160. edsl/utilities/gcp_bucket/simple_example.py +0 -9
  161. edsl-0.1.33.dev1.dist-info/RECORD +0 -209
  162. {edsl-0.1.33.dev1.dist-info → edsl-0.1.33.dev2.dist-info}/LICENSE +0 -0
  163. {edsl-0.1.33.dev1.dist-info → edsl-0.1.33.dev2.dist-info}/WHEEL +0 -0
edsl/results/Results.py CHANGED
@@ -17,6 +17,7 @@ from edsl.exceptions.results import (
17
17
  ResultsInvalidNameError,
18
18
  ResultsMutateError,
19
19
  ResultsFilterError,
20
+ ResultsDeserializationError,
20
21
  )
21
22
 
22
23
  from edsl.results.ResultsExportMixin import ResultsExportMixin
@@ -77,6 +78,7 @@ class Results(UserList, Mixins, Base):
77
78
  "question_options",
78
79
  "question_type",
79
80
  "comment",
81
+ "generated_tokens",
80
82
  ]
81
83
 
82
84
  def __init__(
@@ -108,6 +110,81 @@ class Results(UserList, Mixins, Base):
108
110
  if hasattr(self, "_add_output_functions"):
109
111
  self._add_output_functions()
110
112
 
113
+ def leaves(self):
114
+ leaves = []
115
+ for result in self:
116
+ leaves.extend(result.leaves())
117
+ return leaves
118
+
119
+ def tree(
120
+ self,
121
+ fold_attributes: Optional[List[str]] = None,
122
+ drop: Optional[List[str]] = None,
123
+ open_file=True,
124
+ ) -> dict:
125
+ """Return the results as a tree."""
126
+ from edsl.results.tree_explore import FoldableHTMLTableGenerator
127
+
128
+ if drop is None:
129
+ drop = []
130
+
131
+ valid_attributes = [
132
+ "model",
133
+ "scenario",
134
+ "agent",
135
+ "answer",
136
+ "question",
137
+ "iteration",
138
+ ]
139
+ if fold_attributes is None:
140
+ fold_attributes = []
141
+
142
+ for attribute in fold_attributes:
143
+ if attribute not in valid_attributes:
144
+ raise ValueError(
145
+ f"Invalid fold attribute: {attribute}; must be in {valid_attributes}"
146
+ )
147
+ data = self.leaves()
148
+ generator = FoldableHTMLTableGenerator(data)
149
+ tree = generator.tree(fold_attributes=fold_attributes, drop=drop)
150
+ html_content = generator.generate_html(tree, fold_attributes)
151
+ import tempfile
152
+ from edsl.utilities.utilities import is_notebook
153
+
154
+ from IPython.display import display, HTML
155
+
156
+ if is_notebook():
157
+ import html
158
+ from IPython.display import display, HTML
159
+
160
+ height = 1000
161
+ width = 1000
162
+ escaped_output = html.escape(html_content)
163
+ # escaped_output = rendered_html
164
+ iframe = f""""
165
+ <iframe srcdoc="{ escaped_output }" style="width: {width}px; height: {height}px;"></iframe>
166
+ """
167
+ display(HTML(iframe))
168
+ return None
169
+
170
+ with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as f:
171
+ f.write(html_content.encode())
172
+ print(f"HTML file has been generated: {f.name}")
173
+
174
+ if open_file:
175
+ import webbrowser
176
+ import time
177
+
178
+ time.sleep(1) # Wait for 1 second
179
+ # webbrowser.open(f.name)
180
+ import os
181
+
182
+ filename = f.name
183
+ webbrowser.open(f"file://{os.path.abspath(filename)}")
184
+
185
+ else:
186
+ return html_content
187
+
111
188
  def code(self):
112
189
  raise NotImplementedError
113
190
 
@@ -290,8 +367,7 @@ class Results(UserList, Mixins, Base):
290
367
  ),
291
368
  )
292
369
  except Exception as e:
293
- print(e)
294
- # breakpoint()
370
+ raise ResultsDeserializationError(f"Error in Results.from_dict: {e}")
295
371
  return results
296
372
 
297
373
  ######################
@@ -395,7 +471,7 @@ class Results(UserList, Mixins, Base):
395
471
 
396
472
  >>> r = Results.example()
397
473
  >>> r.models[0]
398
- Model(model_name = 'gpt-4-1106-preview', temperature = 0.5, max_tokens = 1000, top_p = 1, frequency_penalty = 0, presence_penalty = 0, logprobs = False, top_logprobs = 3)
474
+ Model(model_name = ...)
399
475
  """
400
476
  return [r.model for r in self.data]
401
477
 
@@ -477,39 +553,6 @@ class Results(UserList, Mixins, Base):
477
553
  )
478
554
  return sorted(list(all_keys))
479
555
 
480
- def _parse_column(self, column: str) -> tuple[str, str]:
481
- """
482
- Parses a column name into a tuple containing a data type and a key.
483
-
484
- >>> r = Results.example()
485
- >>> r._parse_column("answer.how_feeling")
486
- ('answer', 'how_feeling')
487
-
488
- The standard way a column is specified is with a dot-separated string, e.g. _parse_column("agent.status")
489
- But you can also specify a single key, e.g. "status", in which case it will look up the data type.
490
- """
491
- if "." in column:
492
- data_type, key = column.split(".")
493
- else:
494
- try:
495
- data_type, key = self._key_to_data_type[column], column
496
- except KeyError:
497
- import difflib
498
-
499
- close_matches = difflib.get_close_matches(
500
- column, self._key_to_data_type.keys()
501
- )
502
- if close_matches:
503
- suggestions = ", ".join(close_matches)
504
- raise ResultsColumnNotFoundError(
505
- f"Column '{column}' not found in data. Did you mean: {suggestions}?"
506
- )
507
- else:
508
- raise ResultsColumnNotFoundError(
509
- f"Column {column} not found in data"
510
- )
511
- return data_type, key
512
-
513
556
  def first(self) -> "Result":
514
557
  """Return the first observation in the results.
515
558
 
@@ -632,9 +675,11 @@ class Results(UserList, Mixins, Base):
632
675
  """
633
676
  if functions_dict is None:
634
677
  functions_dict = {}
635
- return EvalWithCompoundTypes(
678
+ evaluator = EvalWithCompoundTypes(
636
679
  names=result.combined_dict, functions=functions_dict
637
680
  )
681
+ evaluator.functions.update(int=int, float=float)
682
+ return evaluator
638
683
 
639
684
  def mutate(
640
685
  self, new_var_string: str, functions_dict: Optional[dict] = None
@@ -721,8 +766,8 @@ class Results(UserList, Mixins, Base):
721
766
 
722
767
  def sample(
723
768
  self,
724
- n: int = None,
725
- frac: float = None,
769
+ n: Optional[int] = None,
770
+ frac: Optional[float] = None,
726
771
  with_replacement: bool = True,
727
772
  seed: Optional[str] = "edsl",
728
773
  ) -> Results:
@@ -771,13 +816,17 @@ class Results(UserList, Mixins, Base):
771
816
  Dataset([{'answer.how_feeling': ['OK', 'Great', 'Terrible', 'OK']}])
772
817
 
773
818
  >>> results.select('how_feeling', 'model', 'how_feeling')
774
- Dataset([{'answer.how_feeling': ['OK', 'Great', 'Terrible', 'OK']}, {'model.model': ['gpt-4-1106-preview', 'gpt-4-1106-preview', 'gpt-4-1106-preview', 'gpt-4-1106-preview']}, {'answer.how_feeling': ['OK', 'Great', 'Terrible', 'OK']}])
819
+ Dataset([{'answer.how_feeling': ['OK', 'Great', 'Terrible', 'OK']}, {'model.model': ['...', '...', '...', '...']}, {'answer.how_feeling': ['OK', 'Great', 'Terrible', 'OK']}])
820
+
821
+ >>> from edsl import Results; r = Results.example(); r.select('answer.how_feeling_y')
822
+ Dataset([{'answer.how_feeling_yesterday': ['Great', 'Good', 'OK', 'Terrible']}])
775
823
  """
776
824
 
777
- if len(self) == 0:
778
- raise Exception("No data to select from---the Results object is empty.")
825
+ # if len(self) == 0:
826
+ # raise Exception("No data to select from---the Results object is empty.")
779
827
 
780
828
  if not columns or columns == ("*",) or columns == (None,):
829
+ # is the users passes nothing, then we'll return all the columns
781
830
  columns = ("*.*",)
782
831
 
783
832
  if isinstance(columns[0], list):
@@ -801,6 +850,16 @@ class Results(UserList, Mixins, Base):
801
850
  # iterate through the passed columns
802
851
  for column in columns:
803
852
  # a user could pass 'result.how_feeling' or just 'how_feeling'
853
+ matches = self._matching_columns(column)
854
+ if len(matches) > 1:
855
+ raise Exception(
856
+ f"Column '{column}' is ambiguous. Did you mean one of {matches}?"
857
+ )
858
+ if len(matches) == 0 and ".*" not in column:
859
+ raise Exception(f"Column '{column}' not found in data.")
860
+ if len(matches) == 1:
861
+ column = matches[0]
862
+
804
863
  parsed_data_type, parsed_key = self._parse_column(column)
805
864
  data_types = get_data_types_to_return(parsed_data_type)
806
865
  found_once = False # we need to track this to make sure we found the key at least once
@@ -843,6 +902,21 @@ class Results(UserList, Mixins, Base):
843
902
 
844
903
  return Dataset(sorted_new_data)
845
904
 
905
+ def select(self, *columns: Union[str, list[str]]) -> "Results":
906
+ from edsl.results.Selector import Selector
907
+
908
+ if len(self) == 0:
909
+ raise Exception("No data to select from---the Results object is empty.")
910
+
911
+ selector = Selector(
912
+ known_data_types=self.known_data_types,
913
+ data_type_to_keys=self._data_type_to_keys,
914
+ key_to_data_type=self._key_to_data_type,
915
+ fetch_list_func=self._fetch_list,
916
+ columns=self.columns,
917
+ )
918
+ return selector.select(*columns)
919
+
846
920
  def sort_by(self, *columns: str, reverse: bool = False) -> Results:
847
921
  import warnings
848
922
 
@@ -851,6 +925,11 @@ class Results(UserList, Mixins, Base):
851
925
  )
852
926
  return self.order_by(*columns, reverse=reverse)
853
927
 
928
+ def _parse_column(self, column: str) -> tuple[str, str]:
929
+ if "." in column:
930
+ return column.split(".")
931
+ return self._key_to_data_type[column], column
932
+
854
933
  def order_by(self, *columns: str, reverse: bool = False) -> Results:
855
934
  """Sort the results by one or more columns.
856
935
 
@@ -948,7 +1027,9 @@ class Results(UserList, Mixins, Base):
948
1027
  def has_single_equals(string):
949
1028
  if "!=" in string:
950
1029
  return False
951
- if "=" in string and not "==" in string:
1030
+ if "=" in string and not (
1031
+ "==" in string or "<=" in string or ">=" in string
1032
+ ):
952
1033
  return True
953
1034
 
954
1035
  if has_single_equals(expression):
@@ -989,7 +1070,7 @@ class Results(UserList, Mixins, Base):
989
1070
  return Results(survey=self.survey, data=new_data, created_columns=None)
990
1071
 
991
1072
  @classmethod
992
- def example(cls, debug: bool = False, randomize: bool = False) -> Results:
1073
+ def example(cls, randomize: bool = False) -> Results:
993
1074
  """Return an example `Results` object.
994
1075
 
995
1076
  Example usage:
@@ -1003,7 +1084,12 @@ class Results(UserList, Mixins, Base):
1003
1084
 
1004
1085
  c = Cache()
1005
1086
  job = Jobs.example(randomize=randomize)
1006
- results = job.run(cache=c, debug=debug)
1087
+ results = job.run(
1088
+ cache=c,
1089
+ stop_on_exception=True,
1090
+ skip_retry=True,
1091
+ raise_validation_errors=True,
1092
+ )
1007
1093
  return results
1008
1094
 
1009
1095
  def rich_print(self):
@@ -136,9 +136,9 @@ class ResultsDBMixin:
136
136
 
137
137
  >>> from edsl.results import Results
138
138
  >>> r = Results.example()
139
- >>> d = r.sql("select data_type, key, value from self where data_type = 'answer' limit 3", shape="long")
140
- >>> list(d['value'])
141
- ['OK', 'This is a real survey response from a human.', 'Great']
139
+ >>> d = r.sql("select data_type, key, value from self where data_type = 'answer' order by value limit 3", shape="long")
140
+ >>> sorted(list(d['value']))
141
+ ['Good', 'Great', 'Great']
142
142
 
143
143
  We can also return the data in wide format.
144
144
  Note the use of single quotes to escape the column names, as required by sql.
@@ -0,0 +1,118 @@
1
+ from typing import Union, List, Dict, Any
2
+ from collections import defaultdict
3
+ from edsl.results.Dataset import Dataset
4
+
5
+
6
+ class Selector:
7
+ def __init__(
8
+ self,
9
+ known_data_types: List[str],
10
+ data_type_to_keys: Dict[str, List[str]],
11
+ key_to_data_type: Dict[str, str],
12
+ fetch_list_func,
13
+ columns: List[str],
14
+ ):
15
+ self.known_data_types = known_data_types
16
+ self._data_type_to_keys = data_type_to_keys
17
+ self._key_to_data_type = key_to_data_type
18
+ self._fetch_list = fetch_list_func
19
+ self.columns = columns
20
+
21
+ def select(self, *columns: Union[str, List[str]]) -> "Dataset":
22
+ columns = self._normalize_columns(columns)
23
+ to_fetch = self._get_columns_to_fetch(columns)
24
+ new_data = self._fetch_data(to_fetch)
25
+ return Dataset(new_data)
26
+
27
+ def _normalize_columns(self, columns: Union[str, List[str]]) -> tuple:
28
+ if not columns or columns == ("*",) or columns == (None,):
29
+ return ("*.*",)
30
+ if isinstance(columns[0], list):
31
+ return tuple(columns[0])
32
+ return columns
33
+
34
+ def _get_columns_to_fetch(self, columns: tuple) -> Dict[str, List[str]]:
35
+ to_fetch = defaultdict(list)
36
+ self.items_in_order = []
37
+
38
+ for column in columns:
39
+ matches = self._find_matching_columns(column)
40
+ self._validate_matches(column, matches)
41
+
42
+ if len(matches) == 1:
43
+ column = matches[0]
44
+
45
+ data_type, key = self._parse_column(column)
46
+ self._process_column(data_type, key, to_fetch)
47
+
48
+ return to_fetch
49
+
50
+ def _find_matching_columns(self, partial_name: str) -> list[str]:
51
+ if "." in partial_name:
52
+ search_in_list = self.columns
53
+ else:
54
+ search_in_list = [s.split(".")[1] for s in self.columns]
55
+
56
+ matches = [s for s in search_in_list if s.startswith(partial_name)]
57
+ return [partial_name] if partial_name in matches else matches
58
+
59
+ def _validate_matches(self, column: str, matches: List[str]):
60
+ if len(matches) > 1:
61
+ raise ValueError(
62
+ f"Column '{column}' is ambiguous. Did you mean one of {matches}?"
63
+ )
64
+ if len(matches) == 0 and ".*" not in column:
65
+ raise ValueError(f"Column '{column}' not found in data.")
66
+
67
+ def _parse_column(self, column: str) -> tuple[str, str]:
68
+ if "." in column:
69
+ return column.split(".")
70
+ try:
71
+ return self._key_to_data_type[column], column
72
+ except KeyError:
73
+ self._raise_key_error(column)
74
+
75
+ def _raise_key_error(self, column: str):
76
+ import difflib
77
+
78
+ close_matches = difflib.get_close_matches(column, self._key_to_data_type.keys())
79
+ if close_matches:
80
+ suggestions = ", ".join(close_matches)
81
+ raise KeyError(
82
+ f"Column '{column}' not found in data. Did you mean: {suggestions}?"
83
+ )
84
+ else:
85
+ raise KeyError(f"Column {column} not found in data")
86
+
87
+ def _process_column(self, data_type: str, key: str, to_fetch: Dict[str, List[str]]):
88
+ data_types = self._get_data_types_to_return(data_type)
89
+ found_once = False
90
+
91
+ for dt in data_types:
92
+ relevant_keys = self._data_type_to_keys[dt]
93
+ for k in relevant_keys:
94
+ if k == key or key == "*":
95
+ found_once = True
96
+ to_fetch[dt].append(k)
97
+ self.items_in_order.append(f"{dt}.{k}")
98
+
99
+ if not found_once:
100
+ raise ValueError(f"Key {key} not found in data.")
101
+
102
+ def _get_data_types_to_return(self, parsed_data_type: str) -> List[str]:
103
+ if parsed_data_type == "*":
104
+ return self.known_data_types
105
+ if parsed_data_type not in self.known_data_types:
106
+ raise ValueError(
107
+ f"Data type {parsed_data_type} not found in data. Did you mean one of {self.known_data_types}"
108
+ )
109
+ return [parsed_data_type]
110
+
111
+ def _fetch_data(self, to_fetch: Dict[str, List[str]]) -> List[Dict[str, Any]]:
112
+ new_data = []
113
+ for data_type, keys in to_fetch.items():
114
+ for key in keys:
115
+ entries = self._fetch_list(data_type, key)
116
+ new_data.append({f"{data_type}.{key}": entries})
117
+
118
+ return [d for key in self.items_in_order for d in new_data if key in d]
@@ -0,0 +1,115 @@
1
+ from collections import defaultdict
2
+ from typing import List, Dict, Any
3
+ import json
4
+
5
+
6
+ class FoldableHTMLTableGenerator:
7
+ def __init__(self, data: List[Dict[str, Any]]):
8
+ self.data = data
9
+
10
+ def tree(self, fold_attributes: List[str], drop: List[str] = None) -> Dict:
11
+ def nested_dict():
12
+ return defaultdict(nested_dict)
13
+
14
+ result = nested_dict()
15
+ drop = drop or [] # Use an empty list if drop is None
16
+
17
+ for item in self.data:
18
+ current = result
19
+ for attr in fold_attributes:
20
+ current = current[item[attr]]
21
+
22
+ row = {
23
+ k: v
24
+ for k, v in item.items()
25
+ if k not in fold_attributes and k not in drop
26
+ }
27
+ if "_rows" not in current:
28
+ current["_rows"] = []
29
+ current["_rows"].append(row)
30
+
31
+ return result
32
+
33
+ def generate_html(self, tree, fold_attributes: List[str]) -> str:
34
+ html_content = """
35
+ <!DOCTYPE html>
36
+ <html lang="en">
37
+ <head>
38
+ <meta charset="UTF-8">
39
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
40
+ <title>Foldable Nested Table</title>
41
+ <style>
42
+ .folding-section { margin-left: 20px; }
43
+ .fold-button { cursor: pointer; margin: 5px 0; }
44
+ table { border-collapse: collapse; margin-top: 10px; }
45
+ th, td { border: 1px solid black; padding: 5px; }
46
+ .attribute-label { font-weight: bold; }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div id="root"></div>
51
+ <script>
52
+ function toggleFold(id) {
53
+ const element = document.getElementById(id);
54
+ element.style.display = element.style.display === 'none' ? 'block' : 'none';
55
+ }
56
+
57
+ function createFoldableSection(data, path = [], attributes = %s) {
58
+ const container = document.createElement('div');
59
+ container.className = 'folding-section';
60
+
61
+ for (const [key, value] of Object.entries(data)) {
62
+ if (key === '_rows') {
63
+ const table = document.createElement('table');
64
+ const headerRow = table.insertRow();
65
+ const headers = Object.keys(value[0]);
66
+ headers.forEach(header => {
67
+ const th = document.createElement('th');
68
+ th.textContent = header;
69
+ headerRow.appendChild(th);
70
+ });
71
+ value.forEach(row => {
72
+ const tableRow = table.insertRow();
73
+ headers.forEach(header => {
74
+ const cell = tableRow.insertCell();
75
+ cell.textContent = row[header];
76
+ });
77
+ });
78
+ container.appendChild(table);
79
+ } else {
80
+ const button = document.createElement('button');
81
+ const attributeType = attributes[path.length];
82
+ button.innerHTML = `<span class="attribute-label">${attributeType}:</span> ${key}`;
83
+ button.className = 'fold-button';
84
+ const sectionId = `section-${path.join('-')}-${key}`;
85
+ button.onclick = () => toggleFold(sectionId);
86
+ container.appendChild(button);
87
+
88
+ const section = document.createElement('div');
89
+ section.id = sectionId;
90
+ section.style.display = 'none';
91
+ section.appendChild(createFoldableSection(value, [...path, key], attributes));
92
+ container.appendChild(section);
93
+ }
94
+ }
95
+
96
+ return container;
97
+ }
98
+
99
+ const treeData = %s;
100
+ document.getElementById('root').appendChild(createFoldableSection(treeData));
101
+ </script>
102
+ </body>
103
+ </html>
104
+ """
105
+
106
+ return html_content % (json.dumps(fold_attributes), json.dumps(tree))
107
+
108
+ def save_html(self, fold_attributes: List[str], filename: str = "output.html"):
109
+ tree = self.tree(fold_attributes)
110
+ html_content = self.generate_html(tree, fold_attributes)
111
+
112
+ with open(filename, "w", encoding="utf-8") as f:
113
+ f.write(html_content)
114
+
115
+ print(f"HTML file has been generated: {filename}")
@@ -219,10 +219,9 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
219
219
 
220
220
  @classmethod
221
221
  def from_pdf(cls, pdf_path):
222
- import fitz # PyMuPDF
223
- from edsl import Scenario
224
-
225
222
  # Ensure the file exists
223
+ import fitz
224
+
226
225
  if not os.path.exists(pdf_path):
227
226
  raise FileNotFoundError(f"The file {pdf_path} does not exist.")
228
227
 
@@ -236,7 +235,14 @@ class Scenario(Base, UserDict, ScenarioImageMixin, ScenarioHtmlMixin):
236
235
  text = ""
237
236
  for page_num in range(len(document)):
238
237
  page = document.load_page(page_num)
239
- text = text + page.get_text()
238
+ blocks = page.get_text("blocks") # Extract text blocks
239
+
240
+ # Sort blocks by their vertical position (y0) to maintain reading order
241
+ blocks.sort(key=lambda b: (b[1], b[0])) # Sort by y0 first, then x0
242
+
243
+ # Combine the text blocks in order
244
+ for block in blocks:
245
+ text += block[4] + "\n"
240
246
 
241
247
  # Create a dictionary for the combined text
242
248
  page_info = {"filename": filename, "text": text}